Click to Move walks to the ground, along the sky and into melee (#783)

With Click to Move on, benilla walked only to things you interact with. In 1.12.1 a right-click on the ground walks to the point (`0x5e0378` → `0x6102b0`, 0.5 yd short, speed-scaled, no leash, held in a deck's frame). A right-click on the sky with an empty cursor walks along the ray (`0x492dca` → `0x610100`), pitching while swimming. An attack on an enemy out of melee walks to where it stood (`0x60c22b` → `0x60fcc0`, mode 10): the stop is about 1.9 yd because the arm names the player's own guid, the radii are unscaled, and the 80 yd leash says ERR_AUTOFOLLOW_TOO_FAR. Inside melee the player turns to face it.

Deviations, each written where it lives: the melee point keeps the enemy's height, does not snap to the reference's fixed arrival heading, and rides the deck it was armed on.

Tests: the router, the steering, the gates, the leash and the deck frames.

Fixes #758

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mathih13 <15229325+Mathih13@users.noreply.github.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
Mathih13
2026-10-02 21:03:36 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Mathih13 Sam
parent 9821765451
commit b396bbf69a
8 changed files with 779 additions and 87 deletions
+506 -74
View File
@@ -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<Entity>,
}
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<f32>,
}
impl Approach {
@@ -99,6 +153,13 @@ impl Approach {
pub(crate) fn stop_distance(&self) -> Option<f32> {
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<SelfPlayer>>,
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<Time>,
@@ -240,60 +393,101 @@ pub(super) fn steer_approach(
approach.stop();
return;
}
let Some((tf, store, kind)) = index.0.get(&goal.guid).and_then(|e| targets.get(*e).ok()) else {
info!("approach: the target is gone");
approach.stop();
return;
};
// `0x610f13`-`0x610f40`: a unit on a taxi ends it, a dead one too unless the verb wants a body.
if kind.is_some_and(|k| matches!(k.kind, EntityKind::Unit | EntityKind::Player)) {
let store = store.map(|s| &s.0);
let taxi = store.is_some_and(|s| s.unit_flags() & super::UNIT_FLAG_TAXI_FLIGHT != 0);
let dead = store.is_none_or(|s| s.unit_health().unwrap_or(0) == 0);
if taxi || (dead && !matches!(goal.verb, ApproachVerb::Loot | ApproachVerb::Skin)) {
info!("approach: the target left reach (taxi {taxi}, dead {dead})");
approach.stop();
return;
}
let toward = goal.toward;
if let Some(pitch) = goal.pitch.take() {
player.mover_pitch = pitch;
}
let target = tf.translation;
let delta = target - player.pos;
let d2 = goal_distance_sq(goal.verb, delta, speed, player.swimming);
if d2 >= LEASH_SQ {
let target = match toward {
Toward::Object { verb, guid } => {
let Some((tf, store, kind)) = index.0.get(&guid).and_then(|e| targets.get(*e).ok())
else {
info!("approach: the target is gone");
approach.stop();
return;
};
// `0x610f13`-`0x610f40`: a unit on a taxi ends it, a dead one too unless the verb
// wants a body.
if kind.is_some_and(|k| matches!(k.kind, EntityKind::Unit | EntityKind::Player)) {
let store = store.map(|s| &s.0);
let taxi =
store.is_some_and(|s| s.unit_flags() & super::UNIT_FLAG_TAXI_FLIGHT != 0);
let dead = store.is_none_or(|s| s.unit_health().unwrap_or(0) == 0);
if taxi || (dead && !matches!(verb, ApproachVerb::Loot | ApproachVerb::Skin)) {
info!("approach: the target left reach (taxi {taxi}, dead {dead})");
approach.stop();
return;
}
}
Some(tf.translation)
}
Toward::Ground(anchor) | Toward::Melee(anchor) => match anchor.frame {
None => Some(anchor.at),
Some(frame) => {
let Ok((tf, ..)) = targets.get(frame) else {
info!("approach: the transport is gone");
approach.stop();
return;
};
Some(tf.transform_point(anchor.at))
}
},
Toward::Sky(_) | Toward::Face(_) => None,
};
let delta = target.map(|t| t - player.pos);
let d2 = delta.map(|d| goal_distance_sq(toward, goal.stop, d, speed, player.swimming));
if toward.leashed() && d2.is_some_and(|d2| d2 >= LEASH_SQ) {
info!("approach: beyond 80 yd");
approach.stop();
return;
}
// `0x61073c`: once lined up, a frame that ends farther off than the last aborts, but not Talk.
if goal.aligned
&& goal.verb != ApproachVerb::Talk
&& goal.last_pos.distance_squared(target) < player.pos.distance_squared(target)
{
info!("approach: no longer closing");
approach.stop();
return;
if let Some(target) = target {
if goal.aligned
&& !toward.is_talk()
&& goal.last_pos.distance_squared(target) < player.pos.distance_squared(target)
{
info!("approach: no longer closing");
approach.stop();
return;
}
}
let flat = Vec3::new(delta.x, 0.0, delta.z);
let bearing = follow::bearing_to(flat);
let bearing = match (toward, delta) {
(Toward::Sky(facing) | Toward::Face(facing), _) => facing,
(_, Some(d)) => follow::bearing_to(Vec3::new(d.x, 0.0, d.z)),
(_, None) => player.face_yaw,
};
let remaining = follow::wrap_pi(bearing - player.face_yaw);
let turning = remaining.abs() > follow::TURN_DEADZONE;
if turning {
let rate = *goal.turn_rate.get_or_insert_with(|| turn_rate(remaining));
player.face_yaw = follow::steer(player.face_yaw, bearing, rate, time.delta_secs());
} else if let Toward::Face(_) = toward {
// `0x6108c3`: the facing walk ends once lined up, never holding forward.
approach.goal = None;
return;
} else {
goal.aligned = true;
}
let arrive = arrive_distance(goal.verb, goal.stop, speed);
if d2 <= arrive * arrive {
info!("approach: arrived at {:.2} yd", d2.sqrt());
// The arrival's canceller faces the target (`0x60fbe8`).
if flat.length_squared() > f32::EPSILON {
player.face_yaw = bearing;
}
approach.arrived = Some((goal.verb, goal.guid));
approach.goal = None;
if let Toward::Face(_) = toward {
return;
}
if let (Some(d2), Some(delta)) = (d2, delta) {
let arrive = arrive_distance(toward, goal.stop, speed);
if d2 <= arrive * arrive {
info!("approach: arrived at {:.2} yd", d2.sqrt());
// The canceller faces the armed guid (`0x60fbe8`): the object, and none for the ground.
// Deviation: the melee arm names the player's own guid (`0x60fe5f`), so the reference
// turns to face itself, a fixed heading; here the melee walk does not turn.
if let Toward::Object { verb, guid } = toward {
if delta.x * delta.x + delta.z * delta.z > f32::EPSILON {
player.face_yaw = bearing;
}
approach.arrived = Some((verb, guid));
}
approach.goal = None;
return;
}
}
// Rooted, the reference lets go of forward (`0x610b4a`), which also spares the stuck test.
let hold = !player.modes.rooted;
if !turning && goal.held && player.pos.distance_squared(goal.last_pos) < STUCK * STUCK {
@@ -301,7 +495,7 @@ pub(super) fn steer_approach(
approach.stop();
return;
}
if follow::vertically_degenerate(delta) {
if delta.is_some_and(follow::vertically_degenerate) {
info!("approach: the target is straight overhead");
approach.stop();
return;
@@ -323,31 +517,74 @@ pub(super) fn plugin(app: &mut App) {
#[cfg(test)]
mod tests {
use super::super::state::MoveSpeed;
use super::super::state::{MoveSpeed, PlayerRide};
use super::*;
use benilla_protocol::messages::ObjectFields;
use bevy::ecs::system::RunSystemOnce;
const fn object(verb: ApproachVerb) -> Toward {
Toward::Object { verb, guid: 0 }
}
const POINT: Anchor = Anchor {
at: Vec3::ZERO,
frame: None,
};
#[test]
fn talk_stops_inside_the_service_reach_and_never_scales() {
let talk = object(ApproachVerb::Talk);
assert!((TALK_STOP - 2.777_777_7).abs() < 1e-5);
assert_eq!(arrive_distance(talk, TALK_STOP, 14.0), TALK_STOP);
let loot = object(ApproachVerb::Loot);
assert_eq!(arrive_distance(loot, 5.0, 7.0), 5.0);
assert_eq!(arrive_distance(loot, 5.0, 14.0), 10.0);
let usage = object(ApproachVerb::Use);
assert_eq!(arrive_distance(usage, 5.0, 3.5), 5.0);
assert_eq!(arrive_distance(usage, 5.0, 0.0), 5.0, "at rest");
}
/// `0x610ae8`: the melee walk's stop is Talk's kind, never scaled; the ground's is.
#[test]
fn the_ground_stop_scales_with_speed_and_the_melee_stop_does_not() {
assert_eq!(
arrive_distance(ApproachVerb::Talk, TALK_STOP, 14.0),
TALK_STOP
arrive_distance(Toward::Ground(POINT), GROUND_STOP, 7.0),
0.5
);
assert_eq!(arrive_distance(ApproachVerb::Loot, 5.0, 7.0), 5.0);
assert_eq!(arrive_distance(ApproachVerb::Loot, 5.0, 14.0), 10.0);
assert_eq!(arrive_distance(ApproachVerb::Use, 5.0, 3.5), 5.0);
assert_eq!(arrive_distance(ApproachVerb::Use, 5.0, 0.0), 5.0, "at rest");
assert_eq!(
arrive_distance(Toward::Ground(POINT), GROUND_STOP, 14.0),
1.0
);
assert_eq!(arrive_distance(Toward::Melee(POINT), 1.9, 14.0), 1.9);
}
#[test]
fn the_goal_flattens_only_inside_the_snap_radius() {
let usage = object(ApproachVerb::Use);
let above = Vec3::new(0.3, 4.0, 0.3);
assert!((goal_distance_sq(ApproachVerb::Use, above, 7.0, false) - 0.18).abs() < 1e-5);
assert!((goal_distance_sq(ApproachVerb::Use, above, 7.0, true) - 16.18).abs() < 1e-4);
assert!((goal_distance_sq(usage, 0.0, above, 7.0, false) - 0.18).abs() < 1e-5);
assert!((goal_distance_sq(usage, 0.0, above, 7.0, true) - 16.18).abs() < 1e-4);
let far = Vec3::new(3.0, 4.0, 0.0);
assert_eq!(goal_distance_sq(ApproachVerb::Use, far, 7.0, false), 25.0);
assert_eq!(goal_distance_sq(usage, 0.0, far, 7.0, false), 25.0);
}
/// `0x610eaf`: the melee walk snaps to the ground within its stop, not half a yard, unscaled.
#[test]
fn the_melee_goal_flattens_within_its_stop() {
let melee = Toward::Melee(POINT);
let above = Vec3::new(1.5, 4.0, 0.0);
assert!((goal_distance_sq(melee, 1.9, above, 14.0, false) - 2.25).abs() < 1e-5);
let beyond = Vec3::new(2.0, 4.0, 0.0);
assert!((goal_distance_sq(melee, 1.9, beyond, 14.0, false) - 20.0).abs() < 1e-4);
}
/// `0x6101b9`-`0x61022c`.
#[test]
fn the_sky_swim_pitch_clamps_steep_and_levels_shallow() {
assert_eq!(sky_pitch(0.9), FRAC_PI_2);
assert_eq!(sky_pitch(-0.9), -FRAC_PI_2);
assert!((sky_pitch(0.5) - 0.5_f32.asin()).abs() < 1e-6);
assert_eq!(sky_pitch(0.1), 0.0);
}
const ME: u64 = 0x1;
@@ -584,4 +821,199 @@ mod tests {
assert_eq!(turn_rate(2.2), 1200f32.to_radians());
assert_eq!(turn_rate(-PI), 1200f32.to_radians());
}
fn run_until_idle(app: &mut App, frames: usize) -> usize {
for n in 0..frames {
if !app.world().resource::<Approach>().active() {
return n;
}
frame(app, true);
}
frames
}
/// `0x6102b0`: the ground walk stops half a yard short at walking pace and owes nothing.
#[test]
fn a_ground_walk_stops_half_a_yard_short_and_owes_nothing() {
let point = Vec3::new(0.0, 0.0, -10.0);
let mut app = world(Vec3::new(40.0, 0.0, 0.0), 0);
assert!(app
.world_mut()
.run_system_once(move |mut auto: AutoMove| auto.walk_to_ground(point))
.unwrap());
assert!(run_until_idle(&mut app, 300) < 300, "never arrived");
let left = app.world().resource::<Player>().pos.distance(point);
assert!(left <= 0.5 && left > 0.5 - 7.0 * DT - 1e-3, "{left}");
assert!(app.world().resource::<Approach>().arrived.is_none());
}
/// The ground row has no leash (`0x860a8c` = 0): a point past 80 yd still walks.
#[test]
fn a_ground_walk_has_no_leash() {
let mut app = world(Vec3::new(14.0, 0.0, 0.0), 0);
let far = Vec3::new(0.0, 0.0, -120.0);
assert!(app
.world_mut()
.run_system_once(move |mut auto: AutoMove| auto.walk_to_ground(far))
.unwrap());
for _ in 0..30 {
frame(&mut app, true);
}
assert!(app.world().resource::<Approach>().active());
}
/// `0x60f900`: with the option off, neither world walk arms.
#[test]
fn the_world_walks_need_click_to_move() {
let mut app = world(Vec3::new(14.0, 0.0, 0.0), 0);
app.world_mut().resource_mut::<Approach>().enabled = false;
let armed = app
.world_mut()
.run_system_once(|mut auto: AutoMove| {
auto.walk_to_ground(Vec3::new(0.0, 0.0, -10.0))
|| auto.walk_toward_sky(Vec3::new(1.0, 0.5, 0.0))
})
.unwrap();
assert!(!armed);
}
/// `0x610100`: the sky walk turns to the ray's heading and holds forward with no stop (row 8's
/// `+9` flag, `0x610a93`).
#[test]
fn a_sky_walk_turns_to_the_ray_and_keeps_going() {
let mut app = world(Vec3::new(14.0, 0.0, 0.0), 0);
assert!(app
.world_mut()
.run_system_once(|mut auto: AutoMove| auto.walk_toward_sky(Vec3::new(1.0, 0.4, 0.0)))
.unwrap());
for _ in 0..600 {
frame(&mut app, true);
}
let player = app.world().resource::<Player>();
let want = follow::bearing_to(Vec3::X);
assert!(follow::wrap_pi(player.face_yaw - want).abs() < 1e-3);
assert!(player.auto_forward);
assert!(player.pos.x > 60.0, "walked {}", player.pos.x);
assert!(app.world().resource::<Approach>().active());
}
/// `0x7c4a00` and `0x7c4930`: a ground point on a deck rides the deck.
#[test]
fn a_ground_point_on_a_transport_rides_it() {
let mut app = world(Vec3::new(40.0, 0.0, 0.0), 0);
let boat = app.world_mut().spawn(Transform::default()).id();
app.world_mut().resource_mut::<Player>().ride = Some(PlayerRide {
entity: boat,
guid: 0xB0A7,
local_pos: Vec3::ZERO,
boat_yaw: 0.0,
});
let point = Vec3::new(0.0, 0.0, -10.0);
assert!(app
.world_mut()
.run_system_once(move |mut auto: AutoMove| auto.walk_to_ground(point))
.unwrap());
app.world_mut()
.entity_mut(boat)
.insert(Transform::from_xyz(-6.0, 0.0, 0.0));
assert!(run_until_idle(&mut app, 300) < 300, "never arrived");
let left = app
.world()
.resource::<Player>()
.pos
.distance(Vec3::new(-6.0, 0.0, -10.0));
assert!(left <= 0.5, "{left}");
}
/// `0x60fcc0` → mode 10: the walk stops at its stop, unscaled, owes nothing and does not turn
/// to face on arrival.
#[test]
fn a_melee_walk_stops_at_its_stop_and_owes_nothing() {
let at = Vec3::new(0.0, 0.0, -10.0);
let stop = (5.0_f32 - 1.333_333_3).sqrt();
let mut app = world(Vec3::new(40.0, 0.0, 0.0), 0);
assert_eq!(
app.world_mut()
.run_system_once(move |mut auto: AutoMove| auto.walk_into_melee(at, stop))
.unwrap(),
Ok(())
);
assert!(run_until_idle(&mut app, 300) < 300, "never arrived");
let left = app.world().resource::<Player>().pos.distance(at);
assert!(left <= stop && left > stop - 7.0 * DT - 1e-3, "{left}");
assert!(app.world().resource::<Approach>().arrived.is_none());
}
/// `0x6100a0` → mode 2: ends a follow, turns to the enemy and is done, never walking.
#[test]
fn a_face_turns_to_the_enemy_ends_a_follow_and_never_walks() {
let mut app = world(Vec3::new(14.0, 0.0, 0.0), 0);
app.world_mut()
.resource_mut::<FollowState>()
.start(0xBEEF, "Friend".into());
let enemy = Vec3::new(3.0, 0.0, 0.0);
assert!(app
.world_mut()
.run_system_once(move |mut auto: AutoMove| auto.face(enemy))
.unwrap());
assert!(app.world().resource::<FollowState>().guid.is_none());
let mut walked = false;
let frames = (0..120)
.take_while(|_| {
frame(&mut app, true);
walked |= app.world().resource::<Player>().auto_forward;
app.world().resource::<Approach>().active()
})
.count();
assert!(frames < 119, "never lined up");
assert!(!walked);
let player = app.world().resource::<Player>();
assert_eq!(player.pos, Vec3::ZERO);
assert!(follow::wrap_pi(player.face_yaw - follow::bearing_to(enemy)).abs() < 1e-3);
}
/// `0x7c4930` on a turning deck: the point turns with it.
#[test]
fn a_ground_point_on_a_turning_transport_turns_with_it() {
let mut app = world(Vec3::new(40.0, 0.0, 0.0), 0);
let boat = app.world_mut().spawn(Transform::default()).id();
app.world_mut().resource_mut::<Player>().ride = Some(PlayerRide {
entity: boat,
guid: 0xB0A7,
local_pos: Vec3::ZERO,
boat_yaw: 0.0,
});
let point = Vec3::new(0.0, 0.0, -10.0);
assert!(app
.world_mut()
.run_system_once(move |mut auto: AutoMove| auto.walk_to_ground(point))
.unwrap());
app.world_mut()
.entity_mut(boat)
.insert(Transform::from_rotation(Quat::from_rotation_y(FRAC_PI_2)));
assert!(run_until_idle(&mut app, 300) < 300, "never arrived");
let left = app
.world()
.resource::<Player>()
.pos
.distance(Vec3::new(-10.0, 0.0, 0.0));
assert!(left <= 0.5, "{left}");
}
/// `0x6101b9`: swimming, the sky walk pitches along its ray.
#[test]
fn a_swimming_sky_walk_pitches_along_the_ray() {
let mut app = world(Vec3::new(14.0, 0.0, 0.0), 0);
app.world_mut().resource_mut::<Player>().swimming = true;
assert!(app
.world_mut()
.run_system_once(|mut auto: AutoMove| auto.walk_toward_sky(Vec3::new(1.0, 1.0, 0.0)))
.unwrap());
frame(&mut app, false);
let pitch = app.world().resource::<Player>().mover_pitch;
assert!(
(pitch - std::f32::consts::FRAC_PI_4).abs() < 1e-5,
"{pitch}"
);
}
}
@@ -516,6 +516,7 @@ mod tests {
world.insert_resource(PickOcclusion {
distance: 10.0,
point,
ray: None,
});
if let Some(distance) = go {
let chest = world.spawn(Transform::default()).id();
@@ -593,6 +594,7 @@ mod tests {
world.insert_resource(PickOcclusion {
distance: 10.0,
point: Some(Vec3::new(d, 0.0, 0.0)),
ray: None,
});
let mut mods = SpellModifiers::default();
mods.set_class_family(3);
@@ -189,6 +189,7 @@ mod tests {
crate::target::PickOcclusion {
distance: 5.0,
point: Some(Vec3::new(1.0, 2.0, 3.0)),
ray: None,
};
};
let run = |world: &mut World, id: SystemId| {
+262 -10
View File
@@ -176,6 +176,27 @@ pub(crate) struct Feedback<'w> {
stone_uses: MessageWriter<'w, crate::ui_dialog_verbs::MeetingStoneUse>,
}
impl Feedback<'_> {
/// Whether a walk started; the leash's refusal says `ERR_AUTOFOLLOW_TOO_FAR` (`0x61110c`).
fn walked(&mut self, started: Result<(), crate::player::Refused>) -> bool {
match started {
Ok(()) => true,
Err(crate::player::Refused::TooFar) => {
self.errors
.0
.push(crate::ui_action::UiError::key("ERR_AUTOFOLLOW_TOO_FAR"));
false
}
Err(crate::player::Refused::Silent) => false,
}
}
}
enum MeleeApproach {
Face(Vec3),
Walk { at: Vec3, stop: f32 },
}
/// The dispatchers, which take the object (`0x5f0130`, `0x5df2a0`, `0x5df130`, `0x5f86b0`,
/// `0x5f05e0`): a right-click reaches them through [`act_on_right_click`] and a crate's
/// [`Interact`] through [`act_on_interact`], both with Click to Move's walk, an approach's arrival
@@ -256,17 +277,26 @@ impl Dispatch<'_, '_> {
let Ok(at) = self.places.get(target).map(|t| t.translation) else {
return false;
};
match self.auto.start(verb, guid, subject, at, stop) {
Ok(()) => true,
Err(crate::player::Refused::TooFar) => {
self.feedback
.errors
.0
.push(crate::ui_action::UiError::key("ERR_AUTOFOLLOW_TOO_FAR"));
false
}
Err(crate::player::Refused::Silent) => false,
let started = self.auto.start(verb, guid, subject, at, stop);
self.feedback.walked(started)
}
/// `0x60fcc0`'s two arms for an attack: inside the melee reach `max(rA + rB + 1.3333, 5)` a
/// turn to face the enemy (`0x6100a0`), beyond it a walk to where it stands, stopping at the
/// square root (`0x6111ab`) of the reach less its offset (`0x61131d`), measured between the
/// player and itself, as the arm names the player's own guid (`0x60fe5f`).
fn melee_approach(&self, target: Entity) -> Option<MeleeApproach> {
let d2 = self.dist_sq(target)?;
let at = self.places.get(target).ok()?.translation;
let reach = self.melee_reach(target);
if d2 < reach * reach {
return Some(MeleeApproach::Face(at));
}
let own = self.self_store().map_or(cursor_mode::MELEE_FLOOR, |me| {
cursor_mode::melee_reach(me, me)
});
let stop = (own - cursor_mode::MELEE_OFFSET).sqrt();
Some(MeleeApproach::Walk { at, stop })
}
/// A GameObject out of its use range (`0x5f3346`, error `0xe1`): the approach (`0x610300`) when
@@ -629,10 +659,25 @@ pub(super) fn act_on_right_click(
press: Res<PressPick>,
mut selection: ResMut<Selection>,
mut dispatch: Dispatch,
payload_held: Res<crate::ui_script::CursorPayloadHeld>,
) {
if clicks.read().last().is_none() {
return;
}
// The router's other legs (`0x481f60`): the world hit walks there (`0x5e0378`), and nothing
// walks along the ray when the cursor is empty (`0x492d50`).
if press.hovered.any().is_none() && !press.hovered.refused && press.object.target.is_none() {
match (press.occlusion.point, press.occlusion.ray) {
(Some(at), _) => {
dispatch.auto.walk_to_ground(at);
}
(None, Some(ray)) if !payload_held.0 => {
dispatch.auto.walk_toward_sky(*ray);
}
_ => {}
}
return;
}
interact(&mut dispatch, &press, Select::First(&mut selection));
}
@@ -820,6 +865,21 @@ fn interact(dispatch: &mut Dispatch, press: &PressPick, mut select: Select) {
};
match unit_branch(attack, dead_fork, leg) {
UnitBranch::Attack => {
// `0x60c22b`, past the dead and mounted gates (`0x60c1a1`, `0x60c1bc`) and before
// StartAttack, so a refused swing still walks.
let refused = dispatch
.self_store()
.is_some_and(|s| s.0.is_dead_or_ghost() || s.0.unit_mount_display_id() > 0);
match dispatch.melee_approach(entity).filter(|_| !refused) {
Some(MeleeApproach::Face(at)) => {
dispatch.auto.face(at);
}
Some(MeleeApproach::Walk { at, stop }) => {
let started = dispatch.auto.walk_into_melee(at, stop);
dispatch.feedback.walked(started);
}
None => {}
}
// Silent after the select: the click's `0x60c247 call 0x5ecb70` has no `DisplayError`,
// and the red `ERR_ATTACK_*` lines are `0x612df0`'s (the Attack action, pet attack,
// TryCast). The predicate is `0x612df0`'s; `0x5ecb70`'s own set is not transcribed.
@@ -1995,6 +2055,7 @@ mod tests {
world.init_resource::<crate::player::Approach>();
world.init_resource::<crate::player::FollowState>();
world.init_resource::<crate::player::Player>();
world.init_resource::<crate::ui_script::CursorPayloadHeld>();
world.init_resource::<Messages<Interact>>();
world.init_resource::<crate::net::Reputations>();
world.init_resource::<crate::ui_loot::LootConfig>();
@@ -2750,4 +2811,195 @@ mod tests {
assert_eq!(keys, vec!["ERR_USE_TOO_FAR"]);
assert!(!world.resource::<crate::player::Approach>().active());
}
fn right_click_world_at(world: &mut World, occlusion: PickOcclusion) {
*world.resource_mut::<PressPick>() = PressPick {
occlusion,
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
world.run_system_once(act_on_right_click).unwrap();
}
/// `0x5e0378`: a right-click on the ground walks there through `CanAutoInteract`, sending
/// nothing and keeping the selection; a held payload is dropped (`0x492ca9`) and the walk
/// still starts.
#[test]
fn a_ground_right_click_walks_there_only_with_click_to_move_on() {
for (walk, held) in [(true, false), (true, true), (false, false)] {
let (mut world, vendor, rx) = walking_world(Vec3::new(14.0, 0.0, 0.0), walk);
world
.resource_mut::<crate::ui_script::CursorPayloadHeld>()
.0 = held;
// `0x5e0320` returns without deselecting: the vendor stays the target.
let vendor_guid = world.get::<Guid>(vendor).unwrap().0;
let mut selection = world.resource_mut::<Selection>();
selection.target = Some(vendor);
selection.guid = Some(vendor_guid);
right_click_world_at(
&mut world,
PickOcclusion {
distance: 10.0,
point: Some(Vec3::new(0.0, 0.0, -10.0)),
..PickOcclusion::default()
},
);
let approach = world.resource::<crate::player::Approach>();
assert_eq!(approach.active(), walk, "AutoInteract {walk}, held {held}");
if walk {
assert!((approach.stop_distance().unwrap() - 0.5).abs() < 1e-6);
}
assert!(rx.try_iter().next().is_none());
assert_eq!(world.resource::<Selection>().guid, Some(vendor_guid));
}
}
fn attack_wolf_at(at: Vec3, reach: f32) -> (World, crossbeam_channel::Receiver<ClientCommand>) {
const F_COMBAT_REACH: u16 = 130;
let (mut world, _vendor, rx) = walking_world(Vec3::new(14.0, 0.0, 0.0), true);
let wolf = world
.spawn((
Guid(WOLF),
store(&[
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_COMBAT_REACH, reach.to_bits()),
]),
Transform::from_translation(at),
))
.id();
*world.resource_mut::<PressPick>() = PressPick {
hovered: Hovered {
target: Some(wolf),
guid: Some(WOLF),
distance: at.length(),
..Hovered::default()
},
cursor: WorldCursor {
kind: cursor_mode::CursorKind::Attack,
unable: false,
},
attack_fork: cursor_mode::AttackFork(true),
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
world.run_system_once(act_on_right_click).unwrap();
(world, rx)
}
/// `0x60c22b` → `0x60fcc0`: out of melee the walk arms before the swing, stopping at
/// `sqrt(max(2·own reach + 1.3333, 5) − 1.3333)`, the enemy's own reach not counted
/// (`0x60fe5f`, `0x6112b6`, `0x61131d`, `0x6111ab`).
#[test]
fn an_enemy_out_of_melee_is_walked_into_before_the_swing() {
let (world, rx) = attack_wolf_at(Vec3::new(0.0, 0.0, -12.0), 4.0);
let stop = world
.resource::<crate::player::Approach>()
.stop_distance()
.expect("the melee walk starts");
assert!(
(stop - (5.0_f32 - 1.333_333_3).sqrt()).abs() < 1e-3,
"{stop}"
);
assert!(rx
.try_iter()
.any(|c| matches!(c, ClientCommand::AttackSwing { guid: WOLF })));
}
/// Inside `max(rA + rB + 1.3333, 5)` the attack turns to face the enemy where it stands
/// (`0x60fd80`) and swings.
#[test]
fn an_enemy_in_melee_is_faced_without_a_walk() {
let (world, rx) = attack_wolf_at(Vec3::new(0.0, 0.0, -6.0), 4.0);
assert!(world.resource::<crate::player::Approach>().facing());
assert!(rx
.try_iter()
.any(|c| matches!(c, ClientCommand::AttackSwing { guid: WOLF })));
}
/// The mounted gate (`0x60c1bc`) skips the walk and the swing alike.
#[test]
fn a_mounted_attack_does_not_walk() {
const F_MOUNT: u16 = benilla_protocol::field::FIELD_UNIT_MOUNTDISPLAYID;
let (mut world, _vendor, rx) = walking_world(Vec3::new(14.0, 0.0, 0.0), true);
let me = world
.query_filtered::<Entity, With<SelfPlayer>>()
.single(&world)
.unwrap();
world.entity_mut(me).insert(store(&[
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_MOUNT, 14_337),
]));
let wolf = world
.spawn((
Guid(WOLF),
store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)]),
Transform::from_xyz(0.0, 0.0, -12.0),
))
.id();
*world.resource_mut::<PressPick>() = PressPick {
hovered: Hovered {
target: Some(wolf),
guid: Some(WOLF),
distance: 12.0,
..Hovered::default()
},
attack_fork: cursor_mode::AttackFork(true),
..PressPick::default()
};
world
.resource_mut::<Messages<WorldRightClick>>()
.write(WorldRightClick);
world.run_system_once(act_on_right_click).unwrap();
assert!(!world.resource::<crate::player::Approach>().active());
assert!(
!rx.try_iter()
.any(|c| matches!(c, ClientCommand::AttackSwing { .. })),
"the mounted gate skips StartAttack too"
);
}
/// The melee row's 80 yd leash (`0x60e652`) refuses with `ERR_AUTOFOLLOW_TOO_FAR` (`0x61110c`);
/// the swing still goes out (`0x60c247`).
#[test]
fn an_enemy_80_yards_off_is_too_far_to_walk_into() {
let (world, rx) = attack_wolf_at(Vec3::new(0.0, 0.0, -85.0), 1.5);
assert!(!world.resource::<crate::player::Approach>().active());
assert_eq!(
world.resource::<crate::ui_action::UiErrorKeys>().0,
vec![crate::ui_action::UiError::key("ERR_AUTOFOLLOW_TOO_FAR")]
);
assert!(rx
.try_iter()
.any(|c| matches!(c, ClientCommand::AttackSwing { guid: WOLF })));
}
/// `0x492d30`: a right-click on nothing walks along the ray, but not with a payload held
/// (`0x492d50`), which that click only drops.
#[test]
fn a_sky_right_click_walks_along_the_ray_with_an_empty_cursor() {
for held in [false, true] {
let (mut world, _vendor, _rx) = walking_world(Vec3::new(14.0, 0.0, 0.0), true);
world
.resource_mut::<crate::ui_script::CursorPayloadHeld>()
.0 = held;
right_click_world_at(
&mut world,
PickOcclusion {
ray: Dir3::new(Vec3::new(0.0, 0.3, -1.0)).ok(),
..PickOcclusion::default()
},
);
assert_eq!(
world.resource::<crate::player::Approach>().active(),
!held,
"payload held {held}"
);
}
}
}
+1 -1
View File
@@ -149,7 +149,7 @@ const ATTACK_RANGE_SQ: f32 = 109.2025;
/// Melee interact reach `max(reachA + reachB + 1.333, 5.0)` (`0x80b058`, `0x80a1e8`): 5.0 is a
/// floor, so large creatures reach farther. Gates skin (`0x6e3480`) and loot (`CanLootNow
/// 0x5ec110`), center to center, boundary-inclusive.
const MELEE_OFFSET: f32 = 1.333_33;
pub(super) const MELEE_OFFSET: f32 = 1.333_33;
pub(super) const MELEE_FLOOR: f32 = 5.0;
pub(super) fn melee_reach(target: &ObjectStore, me: &ObjectStore) -> f32 {
+3 -2
View File
@@ -35,8 +35,7 @@ pub(super) fn update_pick_occlusion(
occluders: Query<(), With<PickOccluder>>,
mut occlusion: ResMut<PickOcclusion>,
) {
occlusion.distance = f32::INFINITY;
occlusion.point = None;
*occlusion = PickOcclusion::default();
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
return;
};
@@ -46,6 +45,7 @@ pub(super) fn update_pick_occlusion(
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
return;
};
occlusion.ray = Some(ray.direction);
if let Some(hit) = spatial.cast_ray_predicate(
ray.origin,
ray.direction,
@@ -911,6 +911,7 @@ mod tests {
world.insert_resource(PickOcclusion {
distance: f32::INFINITY,
point: None,
ray: None,
});
world.init_resource::<crate::vplates::PlateHover>();
world.init_resource::<Hovered>();
+3
View File
@@ -147,6 +147,8 @@ pub(crate) struct PickOcclusion {
pub(crate) distance: f32,
/// The world hit point, which the ground-targeting cursor rides, as in the reference.
pub(crate) point: Option<Vec3>,
/// The cursor ray's direction, which a right-click on the sky walks along (`0x492d6c`).
pub(crate) ray: Option<Dir3>,
}
impl Default for PickOcclusion {
@@ -154,6 +156,7 @@ impl Default for PickOcclusion {
Self {
distance: f32::INFINITY,
point: None,
ray: None,
}
}
}
+1
View File
@@ -202,6 +202,7 @@ mod tests {
world.insert_resource(PickOcclusion {
distance: 10.0,
point: Some(Vec3::ZERO),
ray: None,
});
world
.resource_mut::<SpellTargeting>()