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:
co-authored by
Claude Opus 5.5
Mathih13
Sam
parent
9821765451
commit
b396bbf69a
@@ -1,12 +1,13 @@
|
|||||||
//! Click to Move's approach (`AutoInteract`, "Toggles auto-move to interact target"): with the
|
//! Click to Move's walks (`AutoInteract`, "Toggles auto-move to interact target"). With the option
|
||||||
//! option on, a right-click on an NPC, a body to loot or skin, or a GameObject beyond the verb's
|
//! on, a right-click on an NPC, a body to loot or skin, or a GameObject beyond the verb's reach
|
||||||
//! reach walks the player there, and the verb runs on arrival. It is [`super::follow`]'s auto-move:
|
//! walks the player there and the verb runs on arrival; a right-click on the ground walks to the
|
||||||
//! one cell holds either (mode `0xc4d888`, guid `0xc4d980`), so arming one ends the other
|
//! point (mode 4), one on the sky walks that way until stopped (mode 8), and an attack on an enemy
|
||||||
//! (`0x611130` calls the canceller `0x60fb60` first), the same input cancels both, and nothing goes
|
//! out of melee walks to where it stood (mode 10). It is [`super::follow`]'s auto-move: one cell
|
||||||
//! on the wire but the movement. The terrain and sky walks (modes 4, 8) and the attack approach
|
//! holds either (mode `0xc4d888`, guid `0xc4d980`), so arming one ends the other (`0x611130` calls
|
||||||
//! (mode 10) are not built.
|
//! 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::ecs::system::SystemParam;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
@@ -51,10 +52,17 @@ pub(crate) enum Refused {
|
|||||||
pub(crate) const TALK_STOP: f32 = 5.555_555_3 * 0.5;
|
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`).
|
/// Use and Skin stop at this fraction of the verb's range (`0x804588`).
|
||||||
pub(crate) const RANGE_STOP_FRACTION: f32 = 0.9;
|
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;
|
const LEASH_SQ: f32 = 6400.0;
|
||||||
/// Within this many yards on the ground the goal's height is ignored (`0x80c4c8`).
|
/// Within this many yards on the ground the goal's height is ignored (`0x80c4c8`).
|
||||||
const FLAT_SNAP: f32 = 0.5;
|
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`).
|
/// `automoveturnspeednarrow` and `automoveturnspeedwide`, deg/s (`0x6039e9`, `0x6039ca`).
|
||||||
const TURN_NARROW: f32 = 800.0;
|
const TURN_NARROW: f32 = 800.0;
|
||||||
const TURN_WIDE: f32 = 1200.0;
|
const TURN_WIDE: f32 = 1200.0;
|
||||||
@@ -71,9 +79,53 @@ pub(crate) struct Approach {
|
|||||||
pub(crate) arrived: Option<(ApproachVerb, u64)>,
|
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 {
|
struct Goal {
|
||||||
verb: ApproachVerb,
|
toward: Toward,
|
||||||
guid: u64,
|
|
||||||
/// `0xc4d910`.
|
/// `0xc4d910`.
|
||||||
stop: f32,
|
stop: f32,
|
||||||
/// Latched by the first turn (`0x61042c`).
|
/// Latched by the first turn (`0x61042c`).
|
||||||
@@ -83,6 +135,8 @@ struct Goal {
|
|||||||
/// Last frame's position (`0xc4da84`).
|
/// Last frame's position (`0xc4da84`).
|
||||||
last_pos: Vec3,
|
last_pos: Vec3,
|
||||||
held: bool,
|
held: bool,
|
||||||
|
/// The swim pitch the sky arm sets (`0x6101ee`), applied on the walk's first frame.
|
||||||
|
pitch: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Approach {
|
impl Approach {
|
||||||
@@ -99,6 +153,13 @@ impl Approach {
|
|||||||
pub(crate) fn stop_distance(&self) -> Option<f32> {
|
pub(crate) fn stop_distance(&self) -> Option<f32> {
|
||||||
self.goal.as_ref().map(|g| g.stop)
|
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.
|
/// `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
|
&& !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)]
|
#[derive(SystemParam)]
|
||||||
pub(crate) struct AutoMove<'w, 's> {
|
pub(crate) struct AutoMove<'w, 's> {
|
||||||
pub(crate) approach: ResMut<'w, Approach>,
|
pub(crate) approach: ResMut<'w, Approach>,
|
||||||
@@ -117,6 +179,7 @@ pub(crate) struct AutoMove<'w, 's> {
|
|||||||
player: Res<'w, Player>,
|
player: Res<'w, Player>,
|
||||||
me: Query<'w, 's, (&'static Guid, &'static ObjectStore), With<SelfPlayer>>,
|
me: Query<'w, 's, (&'static Guid, &'static ObjectStore), With<SelfPlayer>>,
|
||||||
stand: MessageWriter<'w, super::StandStateRequest>,
|
stand: MessageWriter<'w, super::StandStateRequest>,
|
||||||
|
transports: Query<'w, 's, &'static Transform>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoMove<'_, '_> {
|
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(
|
pub(crate) fn start(
|
||||||
&mut self,
|
&mut self,
|
||||||
verb: ApproachVerb,
|
verb: ApproachVerb,
|
||||||
@@ -154,21 +218,94 @@ impl AutoMove<'_, '_> {
|
|||||||
{
|
{
|
||||||
return Err(Refused::Silent);
|
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.follow.stop();
|
||||||
self.approach.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);
|
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 {
|
self.approach.goal = Some(Goal {
|
||||||
verb,
|
toward,
|
||||||
guid,
|
|
||||||
stop,
|
stop,
|
||||||
turn_rate: None,
|
turn_rate: None,
|
||||||
aligned: false,
|
aligned: false,
|
||||||
last_pos: self.player.pos,
|
last_pos: self.player.pos,
|
||||||
held: false,
|
held: false,
|
||||||
|
pitch: None,
|
||||||
});
|
});
|
||||||
if !standing {
|
if !standing {
|
||||||
self.stand.write(super::StandStateRequest { state: 0 });
|
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 {
|
fn speed_scale(speed: f32) -> f32 {
|
||||||
(speed / follow::SPEED_NORM).max(1.0)
|
(speed / follow::SPEED_NORM).max(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The goal distance, squared (`0x610e40`'s tail): on the ground alone once inside the snap
|
/// 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.
|
/// radius, unless swimming. The melee walk's snap radius is its stop (`0x610eaf`).
|
||||||
fn goal_distance_sq(verb: ApproachVerb, delta: Vec3, speed: f32, swimming: bool) -> f32 {
|
fn goal_distance_sq(toward: Toward, stop: f32, delta: Vec3, speed: f32, swimming: bool) -> f32 {
|
||||||
let snap = if verb == ApproachVerb::Talk {
|
let base = if matches!(toward, Toward::Melee(_)) {
|
||||||
FLAT_SNAP
|
stop
|
||||||
} else {
|
} 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;
|
let flat = delta.x * delta.x + delta.z * delta.z;
|
||||||
if !swimming && flat <= snap * snap {
|
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
|
/// 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.
|
/// speed over 7 (`0x7c4c90`, 0 at rest), never below 1×.
|
||||||
fn arrive_distance(verb: ApproachVerb, stop: f32, speed: f32) -> f32 {
|
fn arrive_distance(toward: Toward, stop: f32, speed: f32) -> f32 {
|
||||||
if verb == ApproachVerb::Talk {
|
if toward.unscaled() {
|
||||||
stop
|
stop
|
||||||
} else {
|
} else {
|
||||||
stop * speed_scale(speed)
|
stop * speed_scale(speed)
|
||||||
@@ -217,7 +370,7 @@ fn turn_rate(remaining: f32) -> f32 {
|
|||||||
deg.to_radians()
|
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`].
|
/// [`follow::steer_follow`], which clears [`Player::auto_forward`].
|
||||||
pub(super) fn steer_approach(
|
pub(super) fn steer_approach(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
@@ -240,60 +393,101 @@ pub(super) fn steer_approach(
|
|||||||
approach.stop();
|
approach.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some((tf, store, kind)) = index.0.get(&goal.guid).and_then(|e| targets.get(*e).ok()) else {
|
let toward = goal.toward;
|
||||||
info!("approach: the target is gone");
|
if let Some(pitch) = goal.pitch.take() {
|
||||||
approach.stop();
|
player.mover_pitch = pitch;
|
||||||
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 target = tf.translation;
|
let target = match toward {
|
||||||
let delta = target - player.pos;
|
Toward::Object { verb, guid } => {
|
||||||
let d2 = goal_distance_sq(goal.verb, delta, speed, player.swimming);
|
let Some((tf, store, kind)) = index.0.get(&guid).and_then(|e| targets.get(*e).ok())
|
||||||
if d2 >= LEASH_SQ {
|
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");
|
info!("approach: beyond 80 yd");
|
||||||
approach.stop();
|
approach.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// `0x61073c`: once lined up, a frame that ends farther off than the last aborts, but not Talk.
|
// `0x61073c`: once lined up, a frame that ends farther off than the last aborts, but not Talk.
|
||||||
if goal.aligned
|
if let Some(target) = target {
|
||||||
&& goal.verb != ApproachVerb::Talk
|
if goal.aligned
|
||||||
&& goal.last_pos.distance_squared(target) < player.pos.distance_squared(target)
|
&& !toward.is_talk()
|
||||||
{
|
&& goal.last_pos.distance_squared(target) < player.pos.distance_squared(target)
|
||||||
info!("approach: no longer closing");
|
{
|
||||||
approach.stop();
|
info!("approach: no longer closing");
|
||||||
return;
|
approach.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let flat = Vec3::new(delta.x, 0.0, delta.z);
|
let bearing = match (toward, delta) {
|
||||||
let bearing = follow::bearing_to(flat);
|
(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 remaining = follow::wrap_pi(bearing - player.face_yaw);
|
||||||
let turning = remaining.abs() > follow::TURN_DEADZONE;
|
let turning = remaining.abs() > follow::TURN_DEADZONE;
|
||||||
if turning {
|
if turning {
|
||||||
let rate = *goal.turn_rate.get_or_insert_with(|| turn_rate(remaining));
|
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());
|
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 {
|
} else {
|
||||||
goal.aligned = true;
|
goal.aligned = true;
|
||||||
}
|
}
|
||||||
let arrive = arrive_distance(goal.verb, goal.stop, speed);
|
if let Toward::Face(_) = toward {
|
||||||
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;
|
|
||||||
return;
|
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.
|
// Rooted, the reference lets go of forward (`0x610b4a`), which also spares the stuck test.
|
||||||
let hold = !player.modes.rooted;
|
let hold = !player.modes.rooted;
|
||||||
if !turning && goal.held && player.pos.distance_squared(goal.last_pos) < STUCK * STUCK {
|
if !turning && goal.held && player.pos.distance_squared(goal.last_pos) < STUCK * STUCK {
|
||||||
@@ -301,7 +495,7 @@ pub(super) fn steer_approach(
|
|||||||
approach.stop();
|
approach.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if follow::vertically_degenerate(delta) {
|
if delta.is_some_and(follow::vertically_degenerate) {
|
||||||
info!("approach: the target is straight overhead");
|
info!("approach: the target is straight overhead");
|
||||||
approach.stop();
|
approach.stop();
|
||||||
return;
|
return;
|
||||||
@@ -323,31 +517,74 @@ pub(super) fn plugin(app: &mut App) {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::super::state::MoveSpeed;
|
use super::super::state::{MoveSpeed, PlayerRide};
|
||||||
use super::*;
|
use super::*;
|
||||||
use benilla_protocol::messages::ObjectFields;
|
use benilla_protocol::messages::ObjectFields;
|
||||||
use bevy::ecs::system::RunSystemOnce;
|
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]
|
#[test]
|
||||||
fn talk_stops_inside_the_service_reach_and_never_scales() {
|
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!((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!(
|
assert_eq!(
|
||||||
arrive_distance(ApproachVerb::Talk, TALK_STOP, 14.0),
|
arrive_distance(Toward::Ground(POINT), GROUND_STOP, 7.0),
|
||||||
TALK_STOP
|
0.5
|
||||||
);
|
);
|
||||||
assert_eq!(arrive_distance(ApproachVerb::Loot, 5.0, 7.0), 5.0);
|
assert_eq!(
|
||||||
assert_eq!(arrive_distance(ApproachVerb::Loot, 5.0, 14.0), 10.0);
|
arrive_distance(Toward::Ground(POINT), GROUND_STOP, 14.0),
|
||||||
assert_eq!(arrive_distance(ApproachVerb::Use, 5.0, 3.5), 5.0);
|
1.0
|
||||||
assert_eq!(arrive_distance(ApproachVerb::Use, 5.0, 0.0), 5.0, "at rest");
|
);
|
||||||
|
assert_eq!(arrive_distance(Toward::Melee(POINT), 1.9, 14.0), 1.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_goal_flattens_only_inside_the_snap_radius() {
|
fn the_goal_flattens_only_inside_the_snap_radius() {
|
||||||
|
let usage = object(ApproachVerb::Use);
|
||||||
let above = Vec3::new(0.3, 4.0, 0.3);
|
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(usage, 0.0, 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, true) - 16.18).abs() < 1e-4);
|
||||||
let far = Vec3::new(3.0, 4.0, 0.0);
|
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;
|
const ME: u64 = 0x1;
|
||||||
@@ -584,4 +821,199 @@ mod tests {
|
|||||||
assert_eq!(turn_rate(2.2), 1200f32.to_radians());
|
assert_eq!(turn_rate(2.2), 1200f32.to_radians());
|
||||||
assert_eq!(turn_rate(-PI), 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 {
|
world.insert_resource(PickOcclusion {
|
||||||
distance: 10.0,
|
distance: 10.0,
|
||||||
point,
|
point,
|
||||||
|
ray: None,
|
||||||
});
|
});
|
||||||
if let Some(distance) = go {
|
if let Some(distance) = go {
|
||||||
let chest = world.spawn(Transform::default()).id();
|
let chest = world.spawn(Transform::default()).id();
|
||||||
@@ -593,6 +594,7 @@ mod tests {
|
|||||||
world.insert_resource(PickOcclusion {
|
world.insert_resource(PickOcclusion {
|
||||||
distance: 10.0,
|
distance: 10.0,
|
||||||
point: Some(Vec3::new(d, 0.0, 0.0)),
|
point: Some(Vec3::new(d, 0.0, 0.0)),
|
||||||
|
ray: None,
|
||||||
});
|
});
|
||||||
let mut mods = SpellModifiers::default();
|
let mut mods = SpellModifiers::default();
|
||||||
mods.set_class_family(3);
|
mods.set_class_family(3);
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ mod tests {
|
|||||||
crate::target::PickOcclusion {
|
crate::target::PickOcclusion {
|
||||||
distance: 5.0,
|
distance: 5.0,
|
||||||
point: Some(Vec3::new(1.0, 2.0, 3.0)),
|
point: Some(Vec3::new(1.0, 2.0, 3.0)),
|
||||||
|
ray: None,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
let run = |world: &mut World, id: SystemId| {
|
let run = |world: &mut World, id: SystemId| {
|
||||||
|
|||||||
@@ -176,6 +176,27 @@ pub(crate) struct Feedback<'w> {
|
|||||||
stone_uses: MessageWriter<'w, crate::ui_dialog_verbs::MeetingStoneUse>,
|
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`,
|
/// 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
|
/// `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
|
/// [`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 {
|
let Ok(at) = self.places.get(target).map(|t| t.translation) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
match self.auto.start(verb, guid, subject, at, stop) {
|
let started = self.auto.start(verb, guid, subject, at, stop);
|
||||||
Ok(()) => true,
|
self.feedback.walked(started)
|
||||||
Err(crate::player::Refused::TooFar) => {
|
}
|
||||||
self.feedback
|
|
||||||
.errors
|
/// `0x60fcc0`'s two arms for an attack: inside the melee reach `max(rA + rB + 1.3333, 5)` a
|
||||||
.0
|
/// turn to face the enemy (`0x6100a0`), beyond it a walk to where it stands, stopping at the
|
||||||
.push(crate::ui_action::UiError::key("ERR_AUTOFOLLOW_TOO_FAR"));
|
/// square root (`0x6111ab`) of the reach less its offset (`0x61131d`), measured between the
|
||||||
false
|
/// player and itself, as the arm names the player's own guid (`0x60fe5f`).
|
||||||
}
|
fn melee_approach(&self, target: Entity) -> Option<MeleeApproach> {
|
||||||
Err(crate::player::Refused::Silent) => false,
|
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
|
/// 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>,
|
press: Res<PressPick>,
|
||||||
mut selection: ResMut<Selection>,
|
mut selection: ResMut<Selection>,
|
||||||
mut dispatch: Dispatch,
|
mut dispatch: Dispatch,
|
||||||
|
payload_held: Res<crate::ui_script::CursorPayloadHeld>,
|
||||||
) {
|
) {
|
||||||
if clicks.read().last().is_none() {
|
if clicks.read().last().is_none() {
|
||||||
return;
|
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));
|
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) {
|
match unit_branch(attack, dead_fork, leg) {
|
||||||
UnitBranch::Attack => {
|
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`,
|
// 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,
|
// 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.
|
// 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::Approach>();
|
||||||
world.init_resource::<crate::player::FollowState>();
|
world.init_resource::<crate::player::FollowState>();
|
||||||
world.init_resource::<crate::player::Player>();
|
world.init_resource::<crate::player::Player>();
|
||||||
|
world.init_resource::<crate::ui_script::CursorPayloadHeld>();
|
||||||
world.init_resource::<Messages<Interact>>();
|
world.init_resource::<Messages<Interact>>();
|
||||||
world.init_resource::<crate::net::Reputations>();
|
world.init_resource::<crate::net::Reputations>();
|
||||||
world.init_resource::<crate::ui_loot::LootConfig>();
|
world.init_resource::<crate::ui_loot::LootConfig>();
|
||||||
@@ -2750,4 +2811,195 @@ mod tests {
|
|||||||
assert_eq!(keys, vec!["ERR_USE_TOO_FAR"]);
|
assert_eq!(keys, vec!["ERR_USE_TOO_FAR"]);
|
||||||
assert!(!world.resource::<crate::player::Approach>().active());
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// floor, so large creatures reach farther. Gates skin (`0x6e3480`) and loot (`CanLootNow
|
||||||
/// 0x5ec110`), center to center, boundary-inclusive.
|
/// 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) const MELEE_FLOOR: f32 = 5.0;
|
||||||
|
|
||||||
pub(super) fn melee_reach(target: &ObjectStore, me: &ObjectStore) -> f32 {
|
pub(super) fn melee_reach(target: &ObjectStore, me: &ObjectStore) -> f32 {
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ pub(super) fn update_pick_occlusion(
|
|||||||
occluders: Query<(), With<PickOccluder>>,
|
occluders: Query<(), With<PickOccluder>>,
|
||||||
mut occlusion: ResMut<PickOcclusion>,
|
mut occlusion: ResMut<PickOcclusion>,
|
||||||
) {
|
) {
|
||||||
occlusion.distance = f32::INFINITY;
|
*occlusion = PickOcclusion::default();
|
||||||
occlusion.point = None;
|
|
||||||
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
|
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -46,6 +45,7 @@ pub(super) fn update_pick_occlusion(
|
|||||||
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
|
let Ok(ray) = camera.viewport_to_world(cam_tf, cursor) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
occlusion.ray = Some(ray.direction);
|
||||||
if let Some(hit) = spatial.cast_ray_predicate(
|
if let Some(hit) = spatial.cast_ray_predicate(
|
||||||
ray.origin,
|
ray.origin,
|
||||||
ray.direction,
|
ray.direction,
|
||||||
@@ -911,6 +911,7 @@ mod tests {
|
|||||||
world.insert_resource(PickOcclusion {
|
world.insert_resource(PickOcclusion {
|
||||||
distance: f32::INFINITY,
|
distance: f32::INFINITY,
|
||||||
point: None,
|
point: None,
|
||||||
|
ray: None,
|
||||||
});
|
});
|
||||||
world.init_resource::<crate::vplates::PlateHover>();
|
world.init_resource::<crate::vplates::PlateHover>();
|
||||||
world.init_resource::<Hovered>();
|
world.init_resource::<Hovered>();
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ pub(crate) struct PickOcclusion {
|
|||||||
pub(crate) distance: f32,
|
pub(crate) distance: f32,
|
||||||
/// The world hit point, which the ground-targeting cursor rides, as in the reference.
|
/// The world hit point, which the ground-targeting cursor rides, as in the reference.
|
||||||
pub(crate) point: Option<Vec3>,
|
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 {
|
impl Default for PickOcclusion {
|
||||||
@@ -154,6 +156,7 @@ impl Default for PickOcclusion {
|
|||||||
Self {
|
Self {
|
||||||
distance: f32::INFINITY,
|
distance: f32::INFINITY,
|
||||||
point: None,
|
point: None,
|
||||||
|
ray: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ mod tests {
|
|||||||
world.insert_resource(PickOcclusion {
|
world.insert_resource(PickOcclusion {
|
||||||
distance: 10.0,
|
distance: 10.0,
|
||||||
point: Some(Vec3::ZERO),
|
point: Some(Vec3::ZERO),
|
||||||
|
ray: None,
|
||||||
});
|
});
|
||||||
world
|
world
|
||||||
.resource_mut::<SpellTargeting>()
|
.resource_mut::<SpellTargeting>()
|
||||||
|
|||||||
Reference in New Issue
Block a user