diff --git a/crates/benilla-app/src/player/approach.rs b/crates/benilla-app/src/player/approach.rs index c9cea014..566cfafa 100644 --- a/crates/benilla-app/src/player/approach.rs +++ b/crates/benilla-app/src/player/approach.rs @@ -1,12 +1,13 @@ -//! Click to Move's approach (`AutoInteract`, "Toggles auto-move to interact target"): with the -//! option on, a right-click on an NPC, a body to loot or skin, or a GameObject beyond the verb's -//! reach walks the player there, and the verb runs on arrival. It is [`super::follow`]'s auto-move: -//! one cell holds either (mode `0xc4d888`, guid `0xc4d980`), so arming one ends the other -//! (`0x611130` calls the canceller `0x60fb60` first), the same input cancels both, and nothing goes -//! on the wire but the movement. The terrain and sky walks (modes 4, 8) and the attack approach -//! (mode 10) are not built. +//! Click to Move's walks (`AutoInteract`, "Toggles auto-move to interact target"). With the option +//! on, a right-click on an NPC, a body to loot or skin, or a GameObject beyond the verb's reach +//! walks the player there and the verb runs on arrival; a right-click on the ground walks to the +//! point (mode 4), one on the sky walks that way until stopped (mode 8), and an attack on an enemy +//! out of melee walks to where it stood (mode 10). It is [`super::follow`]'s auto-move: one cell +//! holds either (mode `0xc4d888`, guid `0xc4d980`), so arming one ends the other (`0x611130` calls +//! the canceller `0x60fb60` first), the same input cancels both, and nothing goes on the wire but +//! the movement; an attack on an enemy already in melee only turns to face it (mode 2). -use std::f32::consts::PI; +use std::f32::consts::{FRAC_PI_2, PI}; use bevy::ecs::system::SystemParam; use bevy::prelude::*; @@ -51,10 +52,17 @@ pub(crate) enum Refused { pub(crate) const TALK_STOP: f32 = 5.555_555_3 * 0.5; /// Use and Skin stop at this fraction of the verb's range (`0x804588`). pub(crate) const RANGE_STOP_FRACTION: f32 = 0.9; -/// No approach starts or lasts at 80 yd or more: modes 5-10's row in `0x860a58` (`0x60e5d1`). +/// The ground walk stops half a yard short (`0x60e740`). +const GROUND_STOP: f32 = 0.5; +/// No approach starts or lasts at 80 yd or more: the rows of modes 5-7, 9 and 10 in `0x860a58` +/// (`0x60e5d1`); the ground and sky rows have none. const LEASH_SQ: f32 = 6400.0; /// Within this many yards on the ground the goal's height is ignored (`0x80c4c8`). const FLAT_SNAP: f32 = 0.5; +/// The sky walk's swim pitch: straight up or down past this sine (`0x80c4d4`), level within the +/// next (`0x80c4d8`). +const SKY_PITCH_STEEP: f32 = 0.866_02; +const SKY_PITCH_LEVEL: f32 = 0.173_64; /// `automoveturnspeednarrow` and `automoveturnspeedwide`, deg/s (`0x6039e9`, `0x6039ca`). const TURN_NARROW: f32 = 800.0; const TURN_WIDE: f32 = 1200.0; @@ -71,9 +79,53 @@ pub(crate) struct Approach { pub(crate) arrived: Option<(ApproachVerb, u64)>, } +/// What a walk heads for, by its mode in `0xc4d888`. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Toward { + /// Modes 5-7 and 9: the object's live position. + Object { verb: ApproachVerb, guid: u64 }, + /// Mode 4. + Ground(Anchor), + /// Mode 10. + Melee(Anchor), + /// Mode 8: a facing (`0xc4d9a8`), with no stop. + Sky(f32), + /// Mode 2: a facing, turned to and done. + Face(f32), +} + +/// A point fixed at the arm (`0xc4d890`), held in the frame of the transport the player stands on +/// (`0x7c4a00`) and carried back to the world each frame (`0x7c4930`). +#[derive(Clone, Copy, PartialEq, Debug)] +struct Anchor { + at: Vec3, + frame: Option, +} + +impl Toward { + fn is_talk(self) -> bool { + matches!( + self, + Toward::Object { + verb: ApproachVerb::Talk, + .. + } + ) + } + + /// Talk's and the melee walk's arrive and snap radii never scale with speed (`0x610ae8`, + /// `0x610ff1`). + fn unscaled(self) -> bool { + self.is_talk() || matches!(self, Toward::Melee(_)) + } + + fn leashed(self) -> bool { + matches!(self, Toward::Object { .. } | Toward::Melee(_)) + } +} + struct Goal { - verb: ApproachVerb, - guid: u64, + toward: Toward, /// `0xc4d910`. stop: f32, /// Latched by the first turn (`0x61042c`). @@ -83,6 +135,8 @@ struct Goal { /// Last frame's position (`0xc4da84`). last_pos: Vec3, held: bool, + /// The swim pitch the sky arm sets (`0x6101ee`), applied on the walk's first frame. + pitch: Option, } impl Approach { @@ -99,6 +153,13 @@ impl Approach { pub(crate) fn stop_distance(&self) -> Option { self.goal.as_ref().map(|g| g.stop) } + + #[cfg(test)] + pub(crate) fn facing(&self) -> bool { + self.goal + .as_ref() + .is_some_and(|g| matches!(g.toward, Toward::Face(_))) + } } /// `CanAutoInteract` (`0x60f900`): alive, driving our own body, and the option on. @@ -109,7 +170,8 @@ pub(crate) fn can_auto_interact(enabled: bool, me: Option<&ObjectStore>, player: && !player.control_lost } -/// The start: the gate (`0x60fed0`, `0x610300`) and the arm `0x611130`. +/// The start: the gates (`0x60fed0`, `0x610300`, `CanAutoInteract` for the rest) and the arm +/// `0x611130`. #[derive(SystemParam)] pub(crate) struct AutoMove<'w, 's> { pub(crate) approach: ResMut<'w, Approach>, @@ -117,6 +179,7 @@ pub(crate) struct AutoMove<'w, 's> { player: Res<'w, Player>, me: Query<'w, 's, (&'static Guid, &'static ObjectStore), With>, stand: MessageWriter<'w, super::StandStateRequest>, + transports: Query<'w, 's, &'static Transform>, } impl AutoMove<'_, '_> { @@ -128,6 +191,7 @@ impl AutoMove<'_, '_> { ) } + /// The interaction walks' gate (`0x60fed0`): the verb's own kind of target, never ourselves. pub(crate) fn start( &mut self, verb: ApproachVerb, @@ -154,21 +218,94 @@ impl AutoMove<'_, '_> { { return Err(Refused::Silent); } - let standing = store.0.unit_stand_state() == 0; + self.arm(Toward::Object { verb, guid }, at, stop) + } + + /// `0x6102b0`, the right-click on the ground (`0x5e0378`). + pub(crate) fn walk_to_ground(&mut self, at: Vec3) -> bool { + if !self.can_auto_interact() { + return false; + } + let anchor = self.anchor(at); + self.arm(Toward::Ground(anchor), at, GROUND_STOP).is_ok() + } + + /// `0x610100`, the right-click on the sky (`0x492dca`), along the press's ray. + pub(crate) fn walk_toward_sky(&mut self, ray: Vec3) -> bool { + let dir = ray.normalize_or_zero(); + if dir == Vec3::ZERO || !self.can_auto_interact() { + return false; + } + let facing = follow::bearing_to(Vec3::new(dir.x, 0.0, dir.z)); + let at = self.player.pos; + if self.arm(Toward::Sky(facing), at, 0.0).is_err() { + return false; + } + if let Some(goal) = self.approach.goal.as_mut() { + goal.pitch = self.player.swimming.then(|| sky_pitch(dir.y)); + } + true + } + + /// `0x60fcc0`'s walk, for an attack on an enemy out of melee: to where it stands, stopping + /// `stop` short. + /// Deviation: the reference re-projects the point's height onto the first walkable facet a + /// zero-size box gather finds at the enemy (`0x60fe25`-`0x60fe51`); here it keeps the enemy's + /// height. The snap radius is the stop, so the two differ only while swimming or on a steep facet. + /// Deviation: the reference stores the world point unconverted and reads it back as deck-local + /// (`0x60fe6c`, `0x610e95`), so on a transport it walks astray; here it rides the deck. + pub(crate) fn walk_into_melee(&mut self, at: Vec3, stop: f32) -> Result<(), Refused> { + if !self.can_auto_interact() { + return Err(Refused::Silent); + } + let anchor = self.anchor(at); + self.arm(Toward::Melee(anchor), at, stop) + } + + /// `0x6100a0`, for an attack on an enemy in melee: turn to face `at`. + pub(crate) fn face(&mut self, at: Vec3) -> bool { + if !self.can_auto_interact() { + return false; + } + let delta = at - self.player.pos; + let facing = follow::bearing_to(Vec3::new(delta.x, 0.0, delta.z)); + self.arm(Toward::Face(facing), at, 0.0).is_ok() + } + + /// Deviation: the reference converts back through the transport the player stands on each + /// frame (`0x610e6b`), so stepping off mid-walk sends the point astray; here the deck is fixed + /// at the arm. + fn anchor(&self, at: Vec3) -> Anchor { + let frame = self.player.ride.as_ref().map(|r| r.entity); + match frame.and_then(|e| self.transports.get(e).ok().map(|tf| (e, tf))) { + Some((e, tf)) => Anchor { + at: tf.compute_affine().inverse().transform_point3(at), + frame: Some(e), + }, + None => Anchor { at, frame: None }, + } + } + + /// `0x611130`: the canceller, then the leash on the world point `at`, then the goal. + fn arm(&mut self, toward: Toward, at: Vec3, stop: f32) -> Result<(), Refused> { + let standing = self + .me + .single() + .is_ok_and(|(_, s)| s.0.unit_stand_state() == 0); self.follow.stop(); self.approach.stop(); - if self.player.pos.distance_squared(at) >= LEASH_SQ { + if toward.leashed() && self.player.pos.distance_squared(at) >= LEASH_SQ { return Err(Refused::TooFar); } - info!("approach: {verb:?} {guid:#x}, stopping {stop:.2} yd short"); + info!("approach: {toward:?}, stopping {stop:.2} yd short"); self.approach.goal = Some(Goal { - verb, - guid, + toward, stop, turn_rate: None, aligned: false, last_pos: self.player.pos, held: false, + pitch: None, }); if !standing { self.stand.write(super::StandStateRequest { state: 0 }); @@ -177,17 +314,33 @@ impl AutoMove<'_, '_> { } } +/// The swim pitch the sky walk takes from its ray (`0x6101b9`-`0x61022c`). +fn sky_pitch(y: f32) -> f32 { + if y.abs() > SKY_PITCH_STEEP { + FRAC_PI_2.copysign(y) + } else if y.abs() > SKY_PITCH_LEVEL { + y.asin() + } else { + 0.0 + } +} + fn speed_scale(speed: f32) -> f32 { (speed / follow::SPEED_NORM).max(1.0) } /// The goal distance, squared (`0x610e40`'s tail): on the ground alone once inside the snap -/// radius, unless swimming. Talk's radius never scales with speed. -fn goal_distance_sq(verb: ApproachVerb, delta: Vec3, speed: f32, swimming: bool) -> f32 { - let snap = if verb == ApproachVerb::Talk { - FLAT_SNAP +/// radius, unless swimming. The melee walk's snap radius is its stop (`0x610eaf`). +fn goal_distance_sq(toward: Toward, stop: f32, delta: Vec3, speed: f32, swimming: bool) -> f32 { + let base = if matches!(toward, Toward::Melee(_)) { + stop } else { - FLAT_SNAP * speed_scale(speed) + FLAT_SNAP + }; + let snap = if toward.unscaled() { + base + } else { + base * speed_scale(speed) }; let flat = delta.x * delta.x + delta.z * delta.z; if !swimming && flat <= snap * snap { @@ -198,9 +351,9 @@ fn goal_distance_sq(verb: ApproachVerb, delta: Vec3, speed: f32, swimming: bool) } /// The arrive distance (`0x610add`-`0x610b07`): the stop distance scaled by the mover's current -/// speed over 7 (`0x7c4c90`, 0 at rest), never below 1×; Talk's is never scaled. -fn arrive_distance(verb: ApproachVerb, stop: f32, speed: f32) -> f32 { - if verb == ApproachVerb::Talk { +/// speed over 7 (`0x7c4c90`, 0 at rest), never below 1×. +fn arrive_distance(toward: Toward, stop: f32, speed: f32) -> f32 { + if toward.unscaled() { stop } else { stop * speed_scale(speed) @@ -217,7 +370,7 @@ fn turn_rate(remaining: f32) -> f32 { deg.to_radians() } -/// Steer toward the goal and hold forward; on arrival face it and owe its verb. After +/// Steer toward the goal and hold forward; on arrival at an object face it and owe its verb. After /// [`follow::steer_follow`], which clears [`Player::auto_forward`]. pub(super) fn steer_approach( time: Res