diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 2a61e6336..8650af003 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -62,6 +62,7 @@ mod jukebox; mod lan; mod lan_actions; mod lan_crafting; +mod lan_mobs; mod lan_screen; mod lan_trading; mod leads; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 98d85e811..3c9cced21 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -514,6 +514,13 @@ impl Game<'_> { let mut dead = Vec::new(); let mut died = std::mem::take(&mut self.died); let ticking = self.entity_ticking_area(); + // With guests on, each mob sees its nearest player (game/lan_mobs.rs). + let guests = self.lan_guest_views(); + let mut bindings = match self.lan.as_mut() { + Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players), + _ => Default::default(), + }; + let mut guest_events = Vec::new(); let level_random; { // The level random, lent to the mobs (brain behaviours draw from @@ -522,8 +529,24 @@ impl Game<'_> { let mut pushes = Vec::new(); let mut keep = Vec::with_capacity(mobs.len()); for i in 0..mobs.len() { - let w = self.mob_world_base(&snapshot); + let mut w = self.mob_world_base(&snapshot); let m = &mut mobs[i]; + let seen_guest = if guests.is_empty() { + None + } else { + let bound = bindings.get(&m.id).copied(); + let conn = super::lan_mobs::player_for(m, w.player.as_ref(), &guests, bound); + match conn { + Some(conn) => { + bindings.insert(m.id, conn); + w.player = guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()); + } + None => { + bindings.remove(&m.id); + } + } + conn + }; if !w .world .loaded(m.position.x.floor() as i32, m.position.z.floor() as i32) @@ -547,6 +570,16 @@ impl Game<'_> { let from = events.len(); let kept = m.tick(&w, &mut scratch, &mut events); horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes); + if let Some(conn) = seen_guest { + let mut k = from; + while k < events.len() { + if super::lan_mobs::acts_on_player(&events[k]) { + guest_events.push((conn, events.remove(k))); + } else { + k += 1; + } + } + } // Killed during its own tick (drowning, burning, falling...). if !m.alive() && !m.removed && died.insert(m.id) { dead.push(m.clone()); @@ -569,6 +602,12 @@ impl Game<'_> { level_random = scratch; } self.world.random = level_random; + if let Some(host) = self.lan.as_mut() + && !guests.is_empty() + { + bindings.retain(|id, _| mobs.iter().any(|m| m.id == *id)); + host.mob_players = bindings; + } self.mobs.append(&mut mobs); self.died = died; self.sync_riders(); @@ -581,6 +620,7 @@ impl Game<'_> { self.mob_died(&m); } self.handle_mob_events(events)?; + self.lan_guest_mob_events(guest_events); self.tick_projectiles()?; self.tick_fangs(); self.tick_allays(); @@ -1536,12 +1576,24 @@ impl Game<'_> { // Arrows also hit minecarts (VehicleEntity is pickable). let mut arrow_targets = targets.clone(); arrow_targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds()))); + // Mobs' projectiles also hit the guests (game/lan_mobs.rs). + let guests = self.lan_projectile_targets(); + let mut arrow_targets_guests = arrow_targets.clone(); + arrow_targets_guests.extend(guests.iter().copied()); + let mut targets_guests = targets.clone(); + targets_guests.extend(guests); let pw = ProjWorld { w: &w, targets }; + let pw_guests = ProjWorld { + w: &w, + targets: targets_guests, + }; projectiles.retain_mut(|p| { - let (keep, hit) = if p.arrow.is_some() { - horizon_client::arrows::tick(p, &scene, &arrow_targets) - } else { - p.tick(&pw) + let mob_shot = !matches!(p.owner, Some(Who::Player)); + let (keep, hit) = match (p.arrow.is_some(), mob_shot) { + (true, false) => horizon_client::arrows::tick(p, &scene, &arrow_targets), + (true, true) => horizon_client::arrows::tick(p, &scene, &arrow_targets_guests), + (false, false) => p.tick(&pw), + (false, true) => p.tick(&pw_guests), }; if let Some(a) = p.arrow.as_mut() { arrow_events.extend(a.events.drain(..).map(|e| (p.position, e))); @@ -1722,7 +1774,7 @@ impl Game<'_> { self.creatures.projectiles.push(arrow); } } - fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> { + pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> { self.combat.projectile_owner = match p.owner { Some(Who::Mob(id)) => Some(id), _ => None, @@ -1731,6 +1783,14 @@ impl Game<'_> { Hit::Entity { at, .. } | Hit::Block { at, .. } => at, Hit::None => return Ok(()), }; + if let Hit::Entity { + who: Who::Mob(id), + at, + } = hit + && self.lan_projectile_hit(p, id, at) + { + return Ok(()); + } if matches!( p.kind, ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 217de8e8c..b9eccb1e0 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -53,6 +53,8 @@ pub(super) struct LanHost { pub broken: Vec<[f64; 3]>, /// Hurt and death state of the mobs guests see. mob_state: HashMap, + /// The guest each mob sees as its player, when not the host (game/lan_mobs.rs). + pub mob_players: HashMap, } /// A stable UUID for an entity that has none. @@ -454,6 +456,7 @@ impl Game<'_> { play: HashMap::new(), broken: Vec::new(), mob_state: HashMap::new(), + mob_players: HashMap::new(), }); self.audio.capture = Some(Vec::new()); Ok(()) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index e7acb4698..48eb83e28 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -589,6 +589,10 @@ impl Game<'_> { self.as_guest(host, conn, |g| { g.attack(); }); + // The mob now answers to this guest (game/lan_mobs.rs). + if let Ok(id) = u32::try_from(id) { + host.mob_players.insert(id, conn); + } host.hub.guests[gi].yaw = old_yaw; host.hub.guests[gi].pitch = old_pitch; } diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs new file mode 100644 index 000000000..e8b6c2fd7 --- /dev/null +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -0,0 +1,263 @@ +//! Mobs and the guests of an open world. The mob code sees one player +//! (`MobWorld.player`, `Who::Player`); with guests on, each mob sees the +//! player vanilla's `Level.getNearestPlayer` would give it, and keeps the +//! guest it targets until it lets go. What it then does to `Who::Player` +//! (hits, pushes, fire, its projectiles) runs with that guest swapped in. +use super::{Game, lan::LanHost}; +use horizon_client::{ + mobs::{Mob, MobEvent, PlayerView, Who}, + projectiles::{Hit, Projectile}, +}; +use horizon_core::geometry::{Aabb, Vec3}; +use horizon_protocol::packets::play::{self, Clientbound as C}; + +/// The guests mobs can see this tick, by connection. +pub(super) type GuestViews = Vec<(u32, PlayerView)>; + +/// The player a mob sees this tick: `None` for the host, else a guest. +/// A mob that targets a guest, or that a guest hurt in the last 100 ticks +/// (`LivingEntity.getLastHurtByMob`), keeps them while they are still a +/// valid target; otherwise it sees the nearest player +/// (`TargetingConditions`: alive, neither creative nor spectator), or the +/// nearest one that is not a spectator if none qualifies. +pub(super) fn player_for( + m: &Mob, + host: Option<&PlayerView>, + guests: &GuestViews, + bound: Option, +) -> Option { + let targeting = m.ai.target == Some(Who::Player) + || m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100; + choose(m.position, targeting, host, guests, bound) +} + +fn choose( + at: Vec3, + targeting: bool, + host: Option<&PlayerView>, + guests: &GuestViews, + bound: Option, +) -> Option { + let targetable = |v: &PlayerView| v.alive && !v.creative && !v.spectator; + if targeting + && let Some(conn) = bound + && guests.iter().any(|(c, v)| *c == conn && targetable(v)) + { + return Some(conn); + } + let d = |v: &PlayerView| at.distance_squared(v.position); + let nearest = |ok: &dyn Fn(&PlayerView) -> bool| { + let mut best = host.filter(|v| ok(v)).map(|v| (None, d(v))); + for (conn, v) in guests.iter().filter(|(_, v)| ok(v)) { + if best.is_none_or(|(_, b)| d(v) < b) { + best = Some((Some(*conn), d(v))); + } + } + best + }; + nearest(&targetable) + .or_else(|| nearest(&|v: &PlayerView| v.alive && !v.spectator)) + .and_then(|(conn, _)| conn) +} + +/// The events of a mob's tick that act on the player it sees. +pub(super) fn acts_on_player(e: &MobEvent) -> bool { + matches!( + e, + MobEvent::AttackPlayer(..) + | MobEvent::DamagePlayer { .. } + | MobEvent::Push { + target: Who::Player, + .. + } + | MobEvent::Ignite { + target: Who::Player, + .. + } + | MobEvent::Launch { + target: Who::Player, + .. + } + ) +} + +impl Game<'_> { + /// How mobs see each guest in play (`player_view` with the guest swapped + /// in; what the host alone tracks, such as its last attacker, stays out). + pub(super) fn lan_guest_views(&mut self) -> GuestViews { + let Some(mut host) = self.lan.take() else { + return Vec::new(); + }; + let conns: Vec<(u32, u128, [f64; 3])> = host + .hub + .guests + .iter() + .filter(|g| g.placed && g.loaded && !g.dead) + .map(|g| { + let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]); + (g.conn, g.uuid, moved) + }) + .collect(); + let mut views = Vec::new(); + for (conn, uuid, moved) in conns { + if let Some(Some(mut v)) = self.as_guest(&mut host, conn, |g| g.player_view()) { + v.hurt_by = None; + v.last_hurt = None; + v.hero = false; + v.velocity = Vec3::new(moved[0], moved[1], moved[2]); + v.uuid = horizon_client::lan::playerdata::uuid_ints(uuid); + v.allies = Default::default(); + views.push((conn, v)); + } + } + self.lan = Some(host); + views + } + + /// Mob events aimed at a guest, run as that guest. Knockback reaches + /// their client (`hurtMarked`: ClientboundSetEntityMotionPacket). + pub(super) fn lan_guest_mob_events(&mut self, events: Vec<(u32, MobEvent)>) { + if events.is_empty() { + return; + } + let Some(mut host) = self.lan.take() else { + return; + }; + for (conn, e) in events { + let pushed = self.as_guest(&mut host, conn, |g| { + let before = g.player.as_ref().map(|p| p.velocity); + let _ = g.handle_mob_events(vec![e]); + g.player + .as_ref() + .map(|p| p.velocity) + .filter(|v| Some(*v) != before) + }); + if let Some(Some(v)) = pushed { + send_motion(&host, conn, v); + } + } + self.lan = Some(host); + } + + /// The guests a projectile can hit, under their entity ids. + pub(super) fn lan_projectile_targets(&self) -> Vec<(Who, Aabb)> { + let Some(host) = self.lan.as_ref() else { + return Vec::new(); + }; + host.hub + .guests + .iter() + .filter(|g| g.placed && g.loaded && !g.dead && !g.spectator()) + .map(|g| { + let [x, y, z] = g.position; + let height = if g.sneaking { 1.5 } else { 1.8 }; + let b = Aabb::new( + Vec3::new(x - 0.3, y, z - 0.3), + Vec3::new(x + 0.3, y + height, z + 0.3), + ); + (Who::Mob(g.entity_id as u32), b) + }) + .collect() + } + + /// A projectile that hit a guest: its hit runs as theirs. False when + /// `id` is not a guest. + pub(super) fn lan_projectile_hit(&mut self, p: &Projectile, id: u32, at: Vec3) -> bool { + let Some(mut host) = self.lan.take() else { + return false; + }; + let conn = host + .hub + .guests + .iter() + .find(|g| g.entity_id as u32 == id) + .map(|g| g.conn); + if let Some(conn) = conn { + let pushed = self.as_guest(&mut host, conn, |g| { + let before = g.player.as_ref().map(|p| p.velocity); + let hit = Hit::Entity { + who: Who::Player, + at, + }; + let _ = g.projectile_hit(p, hit); + g.player + .as_ref() + .map(|p| p.velocity) + .filter(|v| Some(*v) != before) + }); + if let Some(Some(v)) = pushed { + send_motion(&host, conn, v); + } + } + self.lan = Some(host); + conn.is_some() + } +} + +fn send_motion(host: &LanHost, conn: u32, v: Vec3) { + if let Some(g) = host.hub.guest(conn) { + g.send(&C::SetEntityMotion(play::SetEntityMotion { + id: g.entity_id, + velocity: [v.x, v.y, v.z], + })); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn view(x: f64, creative: bool) -> PlayerView { + PlayerView { + position: Vec3::new(x, 64.0, 0.0), + bounds: Aabb::new(Vec3::new(x, 64.0, 0.0), Vec3::new(x, 65.8, 0.0)), + eye: [x, 65.62, 0.0], + alive: true, + creative, + sneaking: false, + look: [0.0; 3], + main_hand: None, + off_hand: None, + head: None, + armor_cover: 0.0, + spectator: false, + gold_armor: false, + hurt_by: None, + last_hurt: None, + velocity: Vec3::ZERO, + yaw: 0.0, + hero: false, + uuid: [0; 4], + allies: Default::default(), + } + } + + const MOB: Vec3 = Vec3::new(0.0, 64.0, 0.0); + + #[test] + fn the_nearest_survival_player_is_seen() { + let host = view(10.0, false); + let guests = vec![(7, view(3.0, false)), (8, view(-5.0, false))]; + assert_eq!(choose(MOB, false, Some(&host), &guests, None), Some(7)); + // A creative guest is passed over for the host further away. + let guests = vec![(7, view(3.0, true))]; + assert_eq!(choose(MOB, false, Some(&host), &guests, None), None); + // Nobody targetable: the nearest one is still seen. + let host = view(10.0, true); + assert_eq!(choose(MOB, false, Some(&host), &guests, None), Some(7)); + assert_eq!(choose(MOB, false, None, &Vec::new(), None), None); + } + + #[test] + fn a_targeted_guest_stays_the_target() { + let host = view(1.0, false); + let guests = vec![(7, view(12.0, false))]; + assert_eq!(choose(MOB, true, Some(&host), &guests, Some(7)), Some(7)); + // Gone creative: the mob looks again. + let guests = vec![(7, view(12.0, true))]; + assert_eq!(choose(MOB, true, Some(&host), &guests, Some(7)), None); + // Without a target the nearest player wins. + let guests = vec![(7, view(12.0, false))]; + assert_eq!(choose(MOB, false, Some(&host), &guests, Some(7)), None); + } +} diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index b3b5fabf8..adb4ea7b6 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1046,6 +1046,12 @@ impl Game<'_> { } self.remove_entity(p.item); } + // ClientPacketListener.handleSetEntityMotion: knockback on this player. + C::SetEntityMotion(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => { + if let Some(pl) = self.player.as_mut() { + pl.velocity = Vec3::new(p.velocity[0], p.velocity[1], p.velocity[2]); + } + } C::HurtAnimation(p) => { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { m.hurt_time = 10; diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index 6dc5f15ba..4fbc18c30 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -18,7 +18,8 @@ pub fn path(world: &Path, uuid: u128) -> PathBuf { .join(format!("{}.dat", uuid_string(uuid))) } -fn uuid_ints(uuid: u128) -> [i32; 4] { +/// `UUIDUtil.uuidToIntArray`. +pub fn uuid_ints(uuid: u128) -> [i32; 4] { [ (uuid >> 96) as i32, (uuid >> 64) as i32, diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index a78948dd0..ab46abef4 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -12,7 +12,8 @@ address. The script then checks, from both sides, what a guest's game used to ge - chest: a chest the guest opens opens on the guest's screen, its lid opens on both sides and closes on both when the guest closes it; - crafting: the guest crafts planks in the inventory's 2x2 grid and opens a crafting table; -- damage: a guest in survival takes fall damage. +- damage: a guest in survival takes fall damage; +- mobs: with the host in creative, a zombie and a skeleton go for the guest and hurt them. Each check is reported on its own (a failing one doesn't stop the others) and the screenshots of both sides go to --out (host-*.png, guest-*.png). @@ -39,7 +40,7 @@ from agent_control_e2e import Cli, Failed, game_env # noqa: E402 ROOT = Path(__file__).resolve().parents[1] GUEST_NAME = 'Guest' -CHECKS = ['tab', 'self', 'armour', 'nametags', 'chest', 'crafting', 'damage'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'chest', 'crafting', 'damage', 'mobs'] class Game: @@ -263,8 +264,45 @@ def check_damage(host, guest, out): return {'before': before, 'after': after, 'water': water} +def first_hurt(guest, before, ticks): + """The guest's health once it drops below `before`, waiting at most `ticks`.""" + health = remote(guest)['health'] + for _ in range(ticks // 10): + if health < before: + break + guest.wait(10) + health = remote(guest)['health'] + return health + + +def check_mobs(host, guest, out): + host('command', command='gamemode creative') + host('command', command='difficulty normal') + host('command', command='time set midnight') + guest('command', command='gamemode survival') + guest.wait(10) + hurt = {} + try: + for mob, distance in (('zombie', 3), ('skeleton', 7)): + x, y, z = pos_of(guest) + before = remote(guest)['health'] + host('command', command=f'summon minecraft:{mob} {x:.2f} {y:.2f} {z + distance:.2f}') + low = first_hurt(guest, before, 300) + guest.shot(out, f'mobs-{mob}') + host.shot(out, f'mobs-{mob}') + host('command', command=f'kill @e[type=minecraft:{mob}]') + hurt[mob] = (before, low) + finally: + host('command', command='gamemode survival') + host('command', command='time set day') + for mob, (before, low) in hurt.items(): + assert low < before, f'the {mob} never hurt the guest: health {before} -> {low} (all: {hurt})' + return hurt + + CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'nametags': check_nametags, - 'chest': check_chest, 'crafting': check_crafting, 'damage': check_damage} + 'chest': check_chest, 'crafting': check_crafting, 'damage': check_damage, + 'mobs': check_mobs} def run(ctl, out, resources, checks): @@ -272,8 +310,12 @@ def run(ctl, out, resources, checks): results = {} try: host('launch', wait=False, offscreen=sys.platform == 'darwin') - host.raw('open', 'Lan', 5) + # Guests' commands need the world's Allow Commands as well as the LAN's (#372): a creative + # world has them; everyone plays in survival. + host.raw('open', 'Lan', 5, 'creative') host('wait_ready', seconds=400) + host('command', command='defaultgamemode survival') + host('command', command='gamemode survival') host('command', command='time set day') # An open stone floor around the host, so the players see each other and nothing is in the way. host('command', command='fill ~-8 ~-1 ~-8 ~8 ~-1 ~8 minecraft:stone')