PvP & HUD: 1.8 knockback under legacy combat rules

With the legacy rules on, a player's knockback follows 1.8.9's
EntityLivingBase.knockBack: the motion halves and the 0.4 push lifts in the
air too (26.3 lifts only from the ground), and knockback resistance is a
chance to take none instead of a cut. The sprint and Knockback push is added
on top as attackTargetEntityWithCurrentItem's addVelocity (0.1 up) instead
of going through knockback, for players and mobs alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-06 00:01:54 +01:00
1 parent f06b417ac4
commit 3400a0316b
6 files changed
+101 -6

No files matched your search

+13 -1
View File
@@ -12315,7 +12315,19 @@ impl Game<'_> {
if landed {
if knockback > 0.0 {
let (xd, zd) = self.extra_knockback_direction();
self.mobs[index].knockback_effect(f64::from(knockback), xd, zd, &source, amount);
if self.legacy_rules() {
let m = &mut self.mobs[index];
let v = [m.velocity.x, m.velocity.y, m.velocity.z];
let [x, y, z] = horizon_client::legacy_combat::extra_knockback(
v,
f64::from(knockback),
(xd, zd),
);
m.velocity = Vec3::new(x, y, z);
m.needs_sync = true;
} else {
self.mobs[index].knockback_effect(f64::from(knockback), xd, zd, &source, amount);
}
self.stop_sprint_after_extra_knockback();
}
let mob = &self.mobs[index];
+20 -4
View File
@@ -3856,8 +3856,9 @@ impl Game<'_> {
0 => credit,
attacker => attacker,
});
let legacy = self.legacy_rules();
if let Some(p) = self.player.as_mut() {
knock_back(p, &mut self.world.random, power, (xd, zd), source);
knock_back(p, &mut self.world.random, power, (xd, zd), source, legacy);
}
}
/// A status effect from a mob, arrow or splash potion (`addEffect`).
@@ -7824,13 +7825,15 @@ impl Game<'_> {
}
}
}
/// `LivingEntity.knockback` on `p`: [`Game::knock_back_player`].
/// `LivingEntity.knockback` on `p`: [`Game::knock_back_player`]; 1.8.9's
/// `EntityLivingBase.knockBack` under `legacy` rules.
fn knock_back(
p: &mut horizon_client::motion::PlayerMotion,
random: &mut horizon_core::random::LegacyRandom,
power: f64,
(xd, zd): (f64, f64),
source: HurtSource<'_>,
legacy: bool,
) {
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
// The native hook runs first, on the raw strength (`LivingKnockBackEvent`), then the
@@ -7848,7 +7851,14 @@ fn knock_back(
x: mut xd,
z: mut zd,
} = knock;
let power = power * (1.0 - resistance);
if legacy && horizon_client::legacy_combat::resists(resistance, random.next_double()) {
return;
}
let power = if legacy {
power
} else {
power * (1.0 - resistance)
};
if power <= 0.0 {
return;
}
@@ -7856,6 +7866,12 @@ fn knock_back(
xd = (random.next_double() - random.next_double()) * 0.01;
zd = (random.next_double() - random.next_double()) * 0.01;
}
if legacy {
let v = p.velocity;
let [x, y, z] = horizon_client::legacy_combat::knockback([v.x, v.y, v.z], power, (xd, zd));
p.velocity = Vec3::new(x, y, z);
return;
}
let l = (xd * xd + zd * zd).sqrt();
let v = p.velocity;
p.velocity = Vec3::new(
@@ -8357,7 +8373,7 @@ mod knockback_tests {
let set = p.set_modifier(attribute, "horizon:test", resistance, "add_value", true);
set.unwrap();
}
knock_back(&mut p, &mut random, 0.4, (0.0, 3.0), hit);
knock_back(&mut p, &mut random, 0.4, (0.0, 3.0), hit, false);
(p.velocity.x, p.velocity.y, p.velocity.z)
};
// No mod in the way: vanilla's push, away from z = 3.
+5 -1
View File
@@ -325,7 +325,11 @@ impl Game<'_> {
g.combat.player_attacker = None;
let (strength, xd, zd) = s.knockback;
if hurt && strength > 0.0 {
g.knock_back_player(f64::from(strength), xd, zd, PVP_SOURCE);
if g.legacy_rules() {
g.legacy_extra_knockback(f64::from(strength), (xd, zd));
} else {
g.knock_back_player(f64::from(strength), xd, zd, PVP_SOURCE);
}
}
// Entity.igniteForSeconds on the victim: the fire timer only
// rises, and a fire-immune victim (no player is by type) is spared.
@@ -57,6 +57,17 @@ impl Game<'_> {
})
}
/// The sprint and Knockback push on this game's player (a guest swapped in), added as 1.8.9 did.
pub(super) fn legacy_extra_knockback(&mut self, strength: f64, dir: (f64, f64)) {
if let Some(p) = self.player.as_mut() {
let v = p.velocity;
let [x, y, z] = legacy_combat::extra_knockback([v.x, v.y, v.z], strength, dir);
p.velocity.x = x;
p.velocity.y = y;
p.velocity.z = z;
}
}
fn legacy_attacker(&self) -> legacy_combat::Attacker {
let p = self.player.as_ref();
legacy_combat::Attacker {
@@ -17,6 +17,11 @@
//! operation 2 products, clamped to the attribute's minimum 0.
//! - `EnchantmentDamage.calcDamageByCreature`: Sharpness 1.25 per level, Smite 2.5 per level
//! against `UNDEAD`, Bane of Arthropods 2.5 per level against `ARTHROPOD`.
//! - `EntityLivingBase.knockBack`: resisted only by chance (`rand.nextDouble() < knockbackResistance`); otherwise
//! the motion halves, the push of 0.4 goes away from the attacker, and up by 0.4 (at most 0.4 in all), in the air
//! too (26.3 lifts only from the ground and scales the push by the resistance).
//! - The sprint and Knockback push in `attackTargetEntityWithCurrentItem`: `addVelocity(-sin(yaw) * i * 0.5, 0.1,
//! cos(yaw) * i * 0.5)`, added to the motion as it is (26.3 halves it first, through `knockback`).
use std::sync::Arc;
/// `ToolMaterial.getDamageVsEntity` by the item id's material. Netherite and copper didn't
@@ -136,6 +141,30 @@ pub fn damage(attribute: f32, crit: Option<f32>, enchant: f32) -> f32 {
base + enchant
}
/// `EntityLivingBase.knockBack` on `velocity`: `power` away from the direction (`xd`, `zd`), which points at the
/// attacker, and up, in the air as on the ground. The caller has already rolled the resistance.
pub fn knockback(velocity: [f64; 3], power: f64, (xd, zd): (f64, f64)) -> [f64; 3] {
let len = (xd * xd + zd * zd).sqrt();
[
velocity[0] / 2.0 - xd / len * power,
(velocity[1] / 2.0 + power).min(0.4),
velocity[2] / 2.0 - zd / len * power,
]
}
/// Whether a knockback resistance of `resistance` stops the push, from a roll of `roll` in [0, 1).
pub fn resists(resistance: f64, roll: f64) -> bool {
roll < resistance
}
/// The extra push of a sprinting or Knockback hit, `strength` along (`xd`, `zd`) (the attacker's look, as
/// `extra_knockback_direction` gives it: 26.3's `knockback` pushes away from it), and 0.1 up.
pub fn extra_knockback(velocity: [f64; 3], strength: f64, (xd, zd): (f64, f64)) -> [f64; 3] {
[
velocity[0] - xd * strength,
velocity[1] + 0.1,
velocity[2] - zd * strength,
]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -226,4 +255,24 @@ mod tests {
assert_eq!(damage(0.0, Some(CRIT), 1.25), 1.25);
assert_eq!(damage(8.0, None, 0.0), 8.0);
}
#[test]
fn a_hit_lifts_a_player_in_the_air_too() {
// Hit from z = +1 while falling at 0.5 a tick: 1.8.9 halves the fall and adds 0.4 (0.15 up);
// 26.3 would leave the fall as it was.
let v = knockback([0.0, -0.5, 0.0], 0.4, (0.0, 1.0));
assert_eq!(v, [0.0, 0.15000000000000002, -0.4]);
// Already rising fast: capped at 0.4.
assert_eq!(knockback([0.2, 0.6, 0.0], 0.4, (3.0, 4.0))[1], 0.4);
assert!((knockback([0.2, 0.6, 0.0], 0.4, (3.0, 4.0))[0] - (0.1 - 0.24)).abs() < 1e-12);
// Resistance is a chance, not a cut: 0.6 stops 60% of the rolls.
assert!(resists(0.6, 0.59) && !resists(0.6, 0.6) && !resists(0.0, 0.0));
}
#[test]
fn a_sprint_hit_adds_its_push_without_halving() {
// A sprint hit (i = 1) looking along +x: (sin, -cos) of yaw -90 is (-1, 0); the target flies to +x.
let v = extra_knockback([0.1, 0.0, 0.0], 0.5, (-1.0, 0.0));
assert_eq!(v, [0.6, 0.1, 0.0]);
}
}
+3
View File
@@ -179,6 +179,9 @@ own rules. Melee hits follow 1.8.9's `EntityPlayer.attackTargetEntityWithCurrent
- Strength multiplies by 1 + 1.3 per level, Weakness subtracts 0.5 per level;
- Sharpness adds 1.25 per level, Smite and Bane of Arthropods 2.5 per level against their targets;
- a critical hit (×1.5) needs a fall, not a charge, and can be a sprinting hit.
- knockback on players as `EntityLivingBase.knockBack`: it lifts a player in the air too (combos), and knockback
resistance is a chance to take none instead of a cut; the sprint and Knockback push is added on top (0.1 up)
instead of halving the motion first.
Code: `crates/horizon-client/src/legacy_combat.rs` (the formulas, with their 1.8.9 sources),
`game/legacy_rules.rs`; `lan_e2e.py --check legacy-pvp` measures it between two games.