feat: click to move walks to an out-of-reach target, then interacts (#752)
- Click to Move: with AutoInteract on, a right-click on an NPC, a body to loot or skin, or a GameObject out of reach walks there and runs the verb on arrival - Click to Move: the arrival scales by the mover's current speed, and a Use still out of range walks again - Click to Move: a Loot walk stops at the square root of the melee reach - Put the meeting-stone test's doc comment back on its test - A GameObject click out of range raises ERR_USE_TOO_FAR unless the walk starts - Wrap the finish's doc lines at the column limit Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Sam
parent
cfd5fd2c8a
commit
2ce6a3e881
@@ -438,7 +438,7 @@
|
||||
Controls = {
|
||||
"RowStickyTarget", "RowAssistAttack", "RowClearAFK",
|
||||
"RowBlockTrades", "RowAutoSelfCast",
|
||||
"RowAutoLoot", "RowInvertMouse", "RowMouseSpeed",
|
||||
"RowAutoLoot", "RowInvertMouse", "RowMouseSpeed", "RowClickToMove",
|
||||
"RowCameraFollowStyle", "RowFollowTerrain", "RowHeadBob",
|
||||
"RowAutoFollowSpeed", "RowWaterCollision", "RowSmartPivot",
|
||||
"RowMouseLookSpeed", "RowMaxCameraDistance",
|
||||
@@ -1614,12 +1614,21 @@
|
||||
BenillaOptionsSliderRow_Setup(this, 0.5, 1.5, 0.05)
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- 1.12's box sits under the mouse slider. -->
|
||||
<Frame name="$parentRowClickToMove" inherits="BenillaOptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>BenillaOptionsRow_OnLoad(this, "autointeract", "Click-to-Move",
|
||||
"OPTION_TOOLTIP_CLICK_TO_MOVE")</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<!-- Smart 1, Always 2, Never 0 (UIOptionsFrame.lua:525,
|
||||
536, 547). -->
|
||||
<Frame name="$parentRowCameraFollowStyle" inherits="BenillaOptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowMouseSpeed" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowClickToMove" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowClickToMove" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
BenillaOptionsRow_OnLoad(this, "cameraSmoothStyle",
|
||||
|
||||
@@ -203,6 +203,9 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
// `deselectOnClick` and `mouseInvertPitch` are 1.12's own (`UIOptionsFrame.lua:8,4`);
|
||||
// `autoLootDefault` is the later-era name, 1.12 having only the shift gesture.
|
||||
same("deselectOnClick", "1"),
|
||||
// `AutoInteract` (`0x603390`, record `[0xc4d9a4]`), Click-to-Move (`UIOptionsFrame.lua:7`):
|
||||
// "1" on koKR alone (`0x603368`). The knob is [`crate::player::Approach`].
|
||||
same("AutoInteract", "0"),
|
||||
// `BlockTrades` (`0x842fbc`), `UIOptionsFrame.lua:11`: the refusal leg `0x4bf7bc` fires only
|
||||
// when it is set. The knob is [`crate::ui_trade::BlockTrades`].
|
||||
same("BlockTrades", "0"),
|
||||
|
||||
@@ -675,12 +675,18 @@ pub(crate) mod schedule_tests {
|
||||
/// release, `transport::compose_riders` and `ground_deck_riders`): the readers take the
|
||||
/// movement flags of the caster (`Player::move_flags`) and its target
|
||||
/// (`RemoteMotion::flags`), and those writers touch `login_pitch`, the settle fields and a
|
||||
/// rider's pose, never the flags.
|
||||
/// rider's pose, never the flags;
|
||||
/// - Click to Move's steer and arrival (`player::approach::steer_approach`,
|
||||
/// `target::click::act_on_arrival`) against what `steer_follow` and `act_on_right_click`
|
||||
/// already pair with: the steer is chained after follow's and writes the same `Player`
|
||||
/// fields, the arrival runs the click's dispatchers, and their `Transform` reads are the
|
||||
/// disjoint-lane kind. The dispatchers also carry the walk's start (`Player`,
|
||||
/// `FollowState`, `Approach`, the stand request), which only a click or an arrival writes.
|
||||
///
|
||||
/// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the
|
||||
/// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource
|
||||
/// that commutes by construction belongs in [`Classes`].
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 4_860;
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 4_930;
|
||||
const UPDATE_ACTIONABLE_SLACK: usize = 40;
|
||||
|
||||
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
|
||||
|
||||
@@ -23,6 +23,7 @@ use benilla_assets::AssetSet;
|
||||
use benilla_world::interact::{WorldClick, WorldRightClick};
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
mod approach;
|
||||
mod arc;
|
||||
mod net;
|
||||
// Writes the frame onto the body we drive: pose, `MovementState`, the counter-twist gap.
|
||||
@@ -77,6 +78,11 @@ use camera_zoom::{apply_zoom_scroll, CAM_DIST_DEFAULT};
|
||||
// `/follow`: chat sends the request, `crate::target` resolves the subject into the state, and
|
||||
// `follow` owns the motion.
|
||||
pub(crate) use follow::{FollowRequest, FollowState};
|
||||
// Click to Move: `crate::target`'s dispatchers start it and run the verb it owes on arrival.
|
||||
pub(crate) use approach::{
|
||||
can_auto_interact, Approach, ApproachVerb, AutoMove, Refused, Subject, RANGE_STOP_FRACTION,
|
||||
TALK_STOP,
|
||||
};
|
||||
use state::{
|
||||
MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, FALL_FAR_DROP, FALL_FAR_TIME, FOOT_CONE_HEIGHT,
|
||||
GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO,
|
||||
@@ -205,6 +211,7 @@ impl Plugin for PlayerPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
net::register(app);
|
||||
follow::plugin(app);
|
||||
approach::plugin(app);
|
||||
camera_saved::plugin(app);
|
||||
camera_view::plugin(app);
|
||||
// The stream focus is published after `control`'s teleport snap and before the stream
|
||||
@@ -339,11 +346,12 @@ impl Plugin for PlayerPlugin {
|
||||
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>))
|
||||
.in_set(crate::char_select::InWorldGated),
|
||||
)
|
||||
// `/follow` steers before `control`, so the player's own turn input lands after it and
|
||||
// wins over the follow's steering.
|
||||
// `/follow` and Click to Move steer before `control`, so the player's own turn input
|
||||
// lands after them and wins over their steering.
|
||||
.add_systems(
|
||||
Update,
|
||||
follow::steer_follow
|
||||
(follow::steer_follow, approach::steer_approach)
|
||||
.chain()
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.in_set(crate::char_select::InWorldGated),
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
//! 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.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::EntityKind;
|
||||
|
||||
use crate::net::{Embodied, Guid, GuidIndex, NetEntity, ObjectStore, SelfPlayer, UnitSpeeds};
|
||||
|
||||
use super::follow::{self, FollowInput, FollowState};
|
||||
use super::state::Player;
|
||||
|
||||
/// The verb an approach owes, by its mode in `0xc4d888`; the pending dispatcher `0x60fa20` runs it
|
||||
/// at the stop.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum ApproachVerb {
|
||||
/// Mode 5: the NPC-service dispatch `0x5f0130`.
|
||||
Talk,
|
||||
/// Mode 6: the unit loot `0x5df2a0`.
|
||||
Loot,
|
||||
/// Mode 7: the GameObject use `0x5f86b0`.
|
||||
Use,
|
||||
/// Mode 9: the skin cast `0x5f05e0`.
|
||||
Skin,
|
||||
}
|
||||
|
||||
/// What the start gate (`0x60fed0`) checks the target for.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum Subject {
|
||||
Unit { dead: bool },
|
||||
Corpse,
|
||||
GameObject,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum Refused {
|
||||
Silent,
|
||||
/// `ERR_AUTOFOLLOW_TOO_FAR` (`0x126`, `0x61110c`).
|
||||
TooFar,
|
||||
}
|
||||
|
||||
/// Talk stops at half the 5.5556 yd service reach (`0x60e680`).
|
||||
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`).
|
||||
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;
|
||||
/// `automoveturnspeednarrow` and `automoveturnspeedwide`, deg/s (`0x6039e9`, `0x6039ca`).
|
||||
const TURN_NARROW: f32 = 800.0;
|
||||
const TURN_WIDE: f32 = 1200.0;
|
||||
/// Less ground than this in a frame while lined up and holding forward is stuck (`0x80c4dc`).
|
||||
const STUCK: f32 = 1.0 / 360.0;
|
||||
|
||||
/// The approach in flight, and the verb an arrival owes.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Approach {
|
||||
/// `AutoInteract`, `CanAutoInteract`'s last term (`0x60f925`).
|
||||
pub(crate) enabled: bool,
|
||||
goal: Option<Goal>,
|
||||
/// The pending cells `0x60f940` fills (`0xc4da28`, `0xc4da78`, `0xc4d950`, `0xc4da20`).
|
||||
pub(crate) arrived: Option<(ApproachVerb, u64)>,
|
||||
}
|
||||
|
||||
struct Goal {
|
||||
verb: ApproachVerb,
|
||||
guid: u64,
|
||||
/// `0xc4d910`.
|
||||
stop: f32,
|
||||
/// Latched by the first turn (`0x61042c`).
|
||||
turn_rate: Option<f32>,
|
||||
/// Bit 1 of `0xc4da48`: the facing has lined up once.
|
||||
aligned: bool,
|
||||
/// Last frame's position (`0xc4da84`).
|
||||
last_pos: Vec3,
|
||||
held: bool,
|
||||
}
|
||||
|
||||
impl Approach {
|
||||
/// The reference's `0x6103a0` for this mode family: an approach is in flight.
|
||||
pub(crate) fn active(&self) -> bool {
|
||||
self.goal.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn stop(&mut self) {
|
||||
self.goal = None;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn stop_distance(&self) -> Option<f32> {
|
||||
self.goal.as_ref().map(|g| g.stop)
|
||||
}
|
||||
}
|
||||
|
||||
/// `CanAutoInteract` (`0x60f900`): alive, driving our own body, and the option on.
|
||||
pub(crate) fn can_auto_interact(enabled: bool, me: Option<&ObjectStore>, player: &Player) -> bool {
|
||||
enabled
|
||||
&& me.is_some_and(|s| s.0.unit_health().unwrap_or(0) > 0)
|
||||
&& player.foreign_mover.is_none()
|
||||
&& !player.control_lost
|
||||
}
|
||||
|
||||
/// The start: the gate (`0x60fed0`, `0x610300`) and the arm `0x611130`.
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct AutoMove<'w, 's> {
|
||||
pub(crate) approach: ResMut<'w, Approach>,
|
||||
follow: ResMut<'w, FollowState>,
|
||||
player: Res<'w, Player>,
|
||||
me: Query<'w, 's, (&'static Guid, &'static ObjectStore), With<SelfPlayer>>,
|
||||
stand: MessageWriter<'w, super::StandStateRequest>,
|
||||
}
|
||||
|
||||
impl AutoMove<'_, '_> {
|
||||
pub(crate) fn can_auto_interact(&self) -> bool {
|
||||
can_auto_interact(
|
||||
self.approach.enabled,
|
||||
self.me.single().ok().map(|(_, s)| s),
|
||||
&self.player,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn start(
|
||||
&mut self,
|
||||
verb: ApproachVerb,
|
||||
guid: u64,
|
||||
subject: Subject,
|
||||
at: Vec3,
|
||||
stop: f32,
|
||||
) -> Result<(), Refused> {
|
||||
let Ok((me, store)) = self.me.single() else {
|
||||
return Err(Refused::Silent);
|
||||
};
|
||||
let admitted = match (verb, subject) {
|
||||
(ApproachVerb::Talk, Subject::Unit { .. }) => true,
|
||||
(ApproachVerb::Loot | ApproachVerb::Skin, Subject::Corpse) => true,
|
||||
(ApproachVerb::Loot | ApproachVerb::Skin, Subject::Unit { dead }) => dead,
|
||||
(ApproachVerb::Use, Subject::GameObject) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !admitted
|
||||
|| guid == me.0
|
||||
|| self.player.foreign_mover.is_some()
|
||||
|| self.player.control_lost
|
||||
|| store.0.unit_health().unwrap_or(0) == 0
|
||||
{
|
||||
return Err(Refused::Silent);
|
||||
}
|
||||
let standing = store.0.unit_stand_state() == 0;
|
||||
self.follow.stop();
|
||||
self.approach.stop();
|
||||
if self.player.pos.distance_squared(at) >= LEASH_SQ {
|
||||
return Err(Refused::TooFar);
|
||||
}
|
||||
info!("approach: {verb:?} {guid:#x}, stopping {stop:.2} yd short");
|
||||
self.approach.goal = Some(Goal {
|
||||
verb,
|
||||
guid,
|
||||
stop,
|
||||
turn_rate: None,
|
||||
aligned: false,
|
||||
last_pos: self.player.pos,
|
||||
held: false,
|
||||
});
|
||||
if !standing {
|
||||
self.stand.write(super::StandStateRequest { state: 0 });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
FLAT_SNAP * speed_scale(speed)
|
||||
};
|
||||
let flat = delta.x * delta.x + delta.z * delta.z;
|
||||
if !swimming && flat <= snap * snap {
|
||||
flat
|
||||
} else {
|
||||
delta.length_squared()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
stop
|
||||
} else {
|
||||
stop * speed_scale(speed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rate the first turn latches, rad/s: wide for a turn of more than 120° (`0x61043f`).
|
||||
fn turn_rate(remaining: f32) -> f32 {
|
||||
let deg = if remaining.abs() > 2.0 * PI / 3.0 {
|
||||
TURN_WIDE
|
||||
} else {
|
||||
TURN_NARROW
|
||||
};
|
||||
deg.to_radians()
|
||||
}
|
||||
|
||||
/// Steer toward the goal and hold forward; on arrival face it and owe its verb. After
|
||||
/// [`follow::steer_follow`], which clears [`Player::auto_forward`].
|
||||
pub(super) fn steer_approach(
|
||||
time: Res<Time>,
|
||||
mut approach: ResMut<Approach>,
|
||||
mut player: ResMut<Player>,
|
||||
mover: Query<&UnitSpeeds, With<Embodied>>,
|
||||
index: Res<GuidIndex>,
|
||||
targets: Query<(&Transform, Option<&ObjectStore>, Option<&NetEntity>)>,
|
||||
input: FollowInput,
|
||||
) {
|
||||
let Some(goal) = approach.goal.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// `GetCurrentSpeed` on last frame's word, as the reference reads the mover's own.
|
||||
let speed = mover
|
||||
.single()
|
||||
.map_or(0.0, |s| crate::net::current_speed(&s.0, player.move_flags));
|
||||
if input.cancels(&player) {
|
||||
info!("approach: cancelled by the player's own movement input");
|
||||
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 target = tf.translation;
|
||||
let delta = target - player.pos;
|
||||
let d2 = goal_distance_sq(goal.verb, delta, speed, player.swimming);
|
||||
if 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;
|
||||
}
|
||||
let flat = Vec3::new(delta.x, 0.0, delta.z);
|
||||
let bearing = follow::bearing_to(flat);
|
||||
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 {
|
||||
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;
|
||||
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 {
|
||||
info!("approach: stuck");
|
||||
approach.stop();
|
||||
return;
|
||||
}
|
||||
if follow::vertically_degenerate(delta) {
|
||||
info!("approach: the target is straight overhead");
|
||||
approach.stop();
|
||||
return;
|
||||
}
|
||||
goal.last_pos = player.pos;
|
||||
goal.held = hold;
|
||||
player.auto_forward = hold;
|
||||
}
|
||||
|
||||
fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut approach: ResMut<Approach>) {
|
||||
if ev.is("AutoInteract") {
|
||||
approach.enabled = ev.flag();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.init_resource::<Approach>().add_observer(on_cvar);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::state::MoveSpeed;
|
||||
use super::*;
|
||||
use benilla_protocol::messages::ObjectFields;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
#[test]
|
||||
fn talk_stops_inside_the_service_reach_and_never_scales() {
|
||||
assert!((TALK_STOP - 2.777_777_7).abs() < 1e-5);
|
||||
assert_eq!(
|
||||
arrive_distance(ApproachVerb::Talk, TALK_STOP, 14.0),
|
||||
TALK_STOP
|
||||
);
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_goal_flattens_only_inside_the_snap_radius() {
|
||||
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);
|
||||
let far = Vec3::new(3.0, 4.0, 0.0);
|
||||
assert_eq!(goal_distance_sq(ApproachVerb::Use, far, 7.0, false), 25.0);
|
||||
}
|
||||
|
||||
const ME: u64 = 0x1;
|
||||
const VENDOR: u64 = 0xF00D;
|
||||
const DT: f32 = 1.0 / 60.0;
|
||||
|
||||
/// Our body at the origin facing -Z, alive (and seated when `stand_state` is set), a vendor at
|
||||
/// `at`, and both steers chained as the plugin runs them.
|
||||
fn world(at: Vec3, stand_state: u32) -> App {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<Time>()
|
||||
.init_resource::<Approach>()
|
||||
.init_resource::<FollowState>()
|
||||
.init_resource::<Player>()
|
||||
.init_resource::<GuidIndex>()
|
||||
.init_resource::<ButtonInput<MouseButton>>()
|
||||
.init_resource::<crate::bindings::BindingsState>()
|
||||
.init_resource::<super::super::camera::CameraControl>()
|
||||
.init_resource::<super::super::view_subject::ViewSubject>()
|
||||
.insert_resource(MoveSpeed {
|
||||
value: 7.0,
|
||||
env_override: false,
|
||||
})
|
||||
.add_message::<super::super::StandStateRequest>()
|
||||
.add_systems(Update, (follow::steer_follow, steer_approach).chain());
|
||||
app.world_mut().resource_mut::<Approach>().enabled = true;
|
||||
app.world_mut().spawn((
|
||||
SelfPlayer,
|
||||
Guid(ME),
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
(22, 100),
|
||||
(28, 100),
|
||||
(138, stand_state),
|
||||
])),
|
||||
));
|
||||
let vendor = app
|
||||
.world_mut()
|
||||
.spawn((Guid(VENDOR), Transform::from_translation(at)))
|
||||
.id();
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(VENDOR, vendor);
|
||||
app
|
||||
}
|
||||
|
||||
fn start(app: &mut App, verb: ApproachVerb, subject: Subject, at: Vec3) -> Result<(), Refused> {
|
||||
app.world_mut()
|
||||
.run_system_once(move |mut auto: AutoMove| {
|
||||
auto.start(verb, VENDOR, subject, at, TALK_STOP)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// One frame: the steers, then the controller's walk along the facing while forward is held.
|
||||
fn frame(app: &mut App, walks: bool) {
|
||||
app.world_mut()
|
||||
.resource_mut::<Time>()
|
||||
.advance_by(std::time::Duration::from_secs_f32(DT));
|
||||
app.update();
|
||||
let mut player = app.world_mut().resource_mut::<Player>();
|
||||
if walks && player.auto_forward {
|
||||
let forward = Quat::from_rotation_y(player.face_yaw) * Vec3::NEG_Z;
|
||||
player.pos += forward * 7.0 * DT;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vendor_out_of_reach_is_walked_to_and_owed_the_talk() {
|
||||
let at = Vec3::new(14.0, 0.0, 0.0);
|
||||
let mut app = world(at, 0);
|
||||
assert_eq!(
|
||||
start(
|
||||
&mut app,
|
||||
ApproachVerb::Talk,
|
||||
Subject::Unit { dead: false },
|
||||
at
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
frame(&mut app, true);
|
||||
assert!(
|
||||
app.world().resource::<Player>().auto_forward,
|
||||
"forward from the first frame"
|
||||
);
|
||||
let mut frames = 1;
|
||||
while app.world().resource::<Approach>().arrived.is_none() {
|
||||
assert!(frames < 300, "never arrived");
|
||||
frame(&mut app, true);
|
||||
frames += 1;
|
||||
}
|
||||
let player = app.world().resource::<Player>();
|
||||
let left = player.pos.distance(at);
|
||||
assert!(
|
||||
left <= TALK_STOP && left > TALK_STOP - 7.0 * DT - 1e-3,
|
||||
"stopped {left} yd off, not at the 2.78 yd stop"
|
||||
);
|
||||
assert!(!player.auto_forward, "forward let go at the stop");
|
||||
assert!(
|
||||
(follow::wrap_pi(player.face_yaw - follow::bearing_to(at - player.pos))).abs() < 1e-4,
|
||||
"facing the vendor"
|
||||
);
|
||||
let approach = app.world().resource::<Approach>();
|
||||
assert_eq!(approach.arrived, Some((ApproachVerb::Talk, VENDOR)));
|
||||
assert!(!approach.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arm_ends_a_follow_and_refuses_past_80_yards() {
|
||||
let near = Vec3::new(14.0, 0.0, 0.0);
|
||||
let mut app = world(near, 0);
|
||||
app.world_mut()
|
||||
.resource_mut::<FollowState>()
|
||||
.start(0xBEEF, "Friend".into());
|
||||
assert_eq!(
|
||||
start(
|
||||
&mut app,
|
||||
ApproachVerb::Talk,
|
||||
Subject::Unit { dead: false },
|
||||
near
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
assert!(
|
||||
app.world().resource::<FollowState>().guid.is_none(),
|
||||
"one auto-move cell"
|
||||
);
|
||||
|
||||
let far = Vec3::new(80.0, 0.0, 0.0);
|
||||
assert_eq!(
|
||||
start(
|
||||
&mut app,
|
||||
ApproachVerb::Talk,
|
||||
Subject::Unit { dead: false },
|
||||
far
|
||||
),
|
||||
Err(Refused::TooFar)
|
||||
);
|
||||
assert!(
|
||||
!app.world().resource::<Approach>().active(),
|
||||
"the arm cancelled the last walk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gate_admits_each_verb_its_own_kind_of_target() {
|
||||
let at = Vec3::new(14.0, 0.0, 0.0);
|
||||
let mut app = world(at, 0);
|
||||
for (verb, subject, ok) in [
|
||||
(ApproachVerb::Talk, Subject::Unit { dead: false }, true),
|
||||
(ApproachVerb::Talk, Subject::GameObject, false),
|
||||
(ApproachVerb::Loot, Subject::Unit { dead: true }, true),
|
||||
(ApproachVerb::Loot, Subject::Unit { dead: false }, false),
|
||||
(ApproachVerb::Loot, Subject::Corpse, true),
|
||||
(ApproachVerb::Skin, Subject::Unit { dead: false }, false),
|
||||
(ApproachVerb::Use, Subject::GameObject, true),
|
||||
(ApproachVerb::Use, Subject::Unit { dead: false }, false),
|
||||
] {
|
||||
let got = start(&mut app, verb, subject, at);
|
||||
assert_eq!(got.is_ok(), ok, "{verb:?} at {subject:?}: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seated_player_stands_to_walk() {
|
||||
let at = Vec3::new(14.0, 0.0, 0.0);
|
||||
let mut app = world(at, 1);
|
||||
assert_eq!(
|
||||
start(
|
||||
&mut app,
|
||||
ApproachVerb::Talk,
|
||||
Subject::Unit { dead: false },
|
||||
at
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
let asks: Vec<u8> = app
|
||||
.world_mut()
|
||||
.resource_mut::<Messages<super::super::StandStateRequest>>()
|
||||
.drain()
|
||||
.map(|m| m.state)
|
||||
.collect();
|
||||
assert_eq!(asks, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_players_own_movement_cancels_the_walk() {
|
||||
let at = Vec3::new(14.0, 0.0, 0.0);
|
||||
let mut app = world(at, 0);
|
||||
assert_eq!(
|
||||
start(
|
||||
&mut app,
|
||||
ApproachVerb::Talk,
|
||||
Subject::Unit { dead: false },
|
||||
at
|
||||
),
|
||||
Ok(())
|
||||
);
|
||||
frame(&mut app, true);
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<MouseButton>>()
|
||||
.press(MouseButton::Forward);
|
||||
frame(&mut app, true);
|
||||
let approach = app.world().resource::<Approach>();
|
||||
assert!(!approach.active() && approach.arrived.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_that_cannot_move_gives_up_once_lined_up() {
|
||||
let at = Vec3::new(14.0, 0.0, 0.0);
|
||||
let mut app = world(at, 0);
|
||||
assert_eq!(
|
||||
start(&mut app, ApproachVerb::Use, Subject::GameObject, at),
|
||||
Ok(())
|
||||
);
|
||||
frame(&mut app, false);
|
||||
frame(&mut app, false);
|
||||
assert!(
|
||||
app.world().resource::<Approach>().active(),
|
||||
"still turning, where standing still is not stuck"
|
||||
);
|
||||
for _ in 0..60 {
|
||||
frame(&mut app, false);
|
||||
}
|
||||
let approach = app.world().resource::<Approach>();
|
||||
assert!(!approach.active(), "stuck against a wall");
|
||||
assert!(approach.arrived.is_none(), "nothing owed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_turn_picks_the_wide_rate_past_120_degrees() {
|
||||
assert_eq!(turn_rate(0.5), 800f32.to_radians());
|
||||
assert_eq!(turn_rate(-2.0 * PI / 3.0), 800f32.to_radians());
|
||||
assert_eq!(turn_rate(2.2), 1200f32.to_radians());
|
||||
assert_eq!(turn_rate(-PI), 1200f32.to_radians());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::approach::Approach;
|
||||
use super::follow::FollowState;
|
||||
use super::state::Player;
|
||||
use crate::creature_anim::MovementState;
|
||||
@@ -26,6 +27,7 @@ pub(super) fn maintain_embodiment(
|
||||
mut commands: Commands,
|
||||
mut player: ResMut<Player>,
|
||||
mut follow: ResMut<FollowState>,
|
||||
mut approach: ResMut<Approach>,
|
||||
guids: (Res<SelfGuid>, Res<GuidIndex>),
|
||||
self_body: Query<Entity, With<SelfPlayer>>,
|
||||
attached: Query<Entity, With<Embodied>>,
|
||||
@@ -49,6 +51,7 @@ pub(super) fn maintain_embodiment(
|
||||
// `SetActiveMover` `0x6006e0` cancels click-to-move and follow on the outgoing mover,
|
||||
// through `0x6103a0` → `0x60fb60(0, 1)`.
|
||||
follow.stop();
|
||||
approach.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +94,7 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<Player>()
|
||||
.init_resource::<FollowState>()
|
||||
.init_resource::<Approach>()
|
||||
.init_resource::<SelfGuid>()
|
||||
.init_resource::<GuidIndex>()
|
||||
.add_systems(Update, maintain_embodiment);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! A player's movement start cancels it on the key-down edge: the start emitters call `0x60e990`,
|
||||
//! which cancels at `0x60e9b5` unless bit 0 of `ds:0xc4da48` is set, and only follow's own emitters
|
||||
//! (`0x60e790`, `0x60e7f0`, `0x60e8a0`, `0x60e940`) set it; ours is a flag,
|
||||
//! [`Player::follow_forward`], so it never reaches the cancel. Jump survives (`0x60e990(0, 0)` at
|
||||
//! [`Player::auto_forward`], so it never reaches the cancel. Jump survives (`0x60e990(0, 0)` at
|
||||
//! `0x60dea8`), as do key releases, sitting, the walk toggle, damage, combat, mounting, casting and
|
||||
//! the followee leaving range (`0x6107e5` skips follow's mode at `0x610749`, `0x610752`).
|
||||
//!
|
||||
@@ -29,10 +29,10 @@ use super::state::{MoveSpeed, Player};
|
||||
const TURN_RATE: f32 = PI;
|
||||
|
||||
/// Inside this many radians of the bearing, follow stops turning (`0x6108bb` → `0x60e920`).
|
||||
const TURN_DEADZONE: f32 = 0.001;
|
||||
pub(super) const TURN_DEADZONE: f32 = 0.001;
|
||||
|
||||
/// The speed the thresholds scale by (`0x80c4d0`), the base run speed.
|
||||
const SPEED_NORM: f32 = 7.0;
|
||||
pub(super) const SPEED_NORM: f32 = 7.0;
|
||||
|
||||
/// The base stop distance (`0x80c4c0`), a constant, not a CVar.
|
||||
const STOP_DISTANCE: f32 = 3.0;
|
||||
@@ -116,7 +116,7 @@ fn follow_cancelled(
|
||||
}
|
||||
|
||||
/// Wrap an angle into `[-π, π)`.
|
||||
fn wrap_pi(a: f32) -> f32 {
|
||||
pub(super) fn wrap_pi(a: f32) -> f32 {
|
||||
let t = std::f32::consts::TAU;
|
||||
let x = (a + PI).rem_euclid(t);
|
||||
x - PI
|
||||
@@ -124,23 +124,23 @@ fn wrap_pi(a: f32) -> f32 {
|
||||
|
||||
/// The `face_yaw` pointing at a horizontal Bevy-space delta, matching `control`'s forward
|
||||
/// `from_rotation_y(y) * NEG_Z = (-sin y, 0, -cos y)`: `y = atan2(-dx, -dz)`.
|
||||
fn bearing_to(delta: Vec3) -> f32 {
|
||||
pub(super) fn bearing_to(delta: Vec3) -> f32 {
|
||||
(-delta.x).atan2(-delta.z)
|
||||
}
|
||||
|
||||
/// The followee is within 1° of straight up or down, where the bearing means nothing (`0x610d32`).
|
||||
fn vertically_degenerate(delta: Vec3) -> bool {
|
||||
pub(super) fn vertically_degenerate(delta: Vec3) -> bool {
|
||||
let dist = delta.length();
|
||||
dist > f32::EPSILON && delta.y.abs() / dist > VERTICAL_ALIGN_COS
|
||||
}
|
||||
|
||||
/// Turn toward `bearing` by at most `rate × dt`, not inside the deadzone (`0x6103d0`).
|
||||
fn steer(face: f32, bearing: f32, dt: f32) -> f32 {
|
||||
pub(super) fn steer(face: f32, bearing: f32, rate: f32, dt: f32) -> f32 {
|
||||
let remaining = wrap_pi(bearing - face);
|
||||
if remaining.abs() <= TURN_DEADZONE {
|
||||
return face;
|
||||
}
|
||||
let budget = TURN_RATE * dt;
|
||||
let budget = rate * dt;
|
||||
face + remaining.clamp(-budget, budget)
|
||||
}
|
||||
|
||||
@@ -176,6 +176,26 @@ impl FollowInput<'_, '_> {
|
||||
.any(|&c| self.binds.just_pressed(c))
|
||||
}
|
||||
|
||||
/// The cancel set, the start emitters' `0x60e990` and the input tick's teardown, which end any
|
||||
/// auto-move.
|
||||
pub(super) fn cancels(&self, player: &Player) -> bool {
|
||||
// The world's buttons, not the device's: the both-button run is two bindings held, and a
|
||||
// press a UI frame captured dispatches neither.
|
||||
let both_engaged = self.rig.world_mouse.both() && self.rig.world_mouse.rose();
|
||||
follow_cancelled(
|
||||
self.move_start(),
|
||||
// The on edge: `control` toggles `autorun` after this, so the flag is the pre-toggle
|
||||
// value.
|
||||
self.buttons.just_pressed(MouseButton::Forward) && !player.autorun,
|
||||
both_engaged,
|
||||
self.rig.look == Some(LookButton::Right),
|
||||
// The teardown leg, or the body handed to a server spline; the ride term is not one of
|
||||
// the reference's conjuncts (`0x5144e0`), and its reference side is untraced.
|
||||
self.input_torn_down(player.modes.rooted, player.foreign_mover.is_none())
|
||||
|| player.server_riding(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The input tick's teardown leg ([`super::state::MoverInput::torn_down`]), which the reference
|
||||
/// takes (`0x5146d6 call 0x60fb60`) only with both predicates down (`0x5146c3`, `0x5146ce`):
|
||||
/// death, far sight, or root and stun together end a follow, a root alone does not.
|
||||
@@ -193,7 +213,7 @@ impl FollowInput<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel, re-resolve the followee, steer, and set [`Player::follow_forward`] for `control`. The
|
||||
/// Cancel, re-resolve the followee, steer, and set [`Player::auto_forward`] for `control`. The
|
||||
/// cancel comes first, as the reference's start emitters run ahead of the axis math (`0x5150a7` vs
|
||||
/// the emitter tail `0x5151a0`).
|
||||
pub(super) fn steer_follow(
|
||||
@@ -205,25 +225,11 @@ pub(super) fn steer_follow(
|
||||
transforms: Query<&Transform>,
|
||||
input: FollowInput,
|
||||
) {
|
||||
player.follow_forward = false;
|
||||
player.auto_forward = false;
|
||||
if follow.guid.is_none() {
|
||||
return;
|
||||
}
|
||||
// ── The cancel set ──
|
||||
// The world's buttons, not the device's: the both-button run is two bindings held, and a
|
||||
// press a UI frame captured dispatches neither.
|
||||
let both_engaged = input.rig.world_mouse.both() && input.rig.world_mouse.rose();
|
||||
if follow_cancelled(
|
||||
input.move_start(),
|
||||
// The on edge: `control` toggles `autorun` after this, so the flag is the pre-toggle value.
|
||||
input.buttons.just_pressed(MouseButton::Forward) && !player.autorun,
|
||||
both_engaged,
|
||||
input.rig.look == Some(LookButton::Right),
|
||||
// The teardown leg, or the body handed to a server spline; the ride term is not one of the
|
||||
// reference's conjuncts (`0x5144e0`), and its reference side is untraced.
|
||||
input.input_torn_down(player.modes.rooted, player.foreign_mover.is_none())
|
||||
|| player.server_riding(),
|
||||
) {
|
||||
if input.cancels(&player) {
|
||||
info!("follow: cancelled by the player's own movement input");
|
||||
follow.stop();
|
||||
return;
|
||||
@@ -253,7 +259,7 @@ pub(super) fn steer_follow(
|
||||
return;
|
||||
}
|
||||
let bearing = bearing_to(flat);
|
||||
player.face_yaw = steer(player.face_yaw, bearing, time.delta_secs());
|
||||
player.face_yaw = steer(player.face_yaw, bearing, TURN_RATE, time.delta_secs());
|
||||
let moving = should_move(follow.moving, distance, speed.value);
|
||||
if moving != follow.moving {
|
||||
info!(
|
||||
@@ -283,7 +289,7 @@ pub(super) fn steer_follow(
|
||||
}
|
||||
}
|
||||
follow.moving = moving;
|
||||
player.follow_forward = moving;
|
||||
player.auto_forward = moving;
|
||||
}
|
||||
|
||||
/// Whether `WOW_FOLLOW_TRACE` is set, read once.
|
||||
@@ -350,18 +356,18 @@ mod tests {
|
||||
#[test]
|
||||
fn the_turn_is_rate_limited_and_has_a_deadzone() {
|
||||
// 180°/s for a 0.1 s tick is 18° of the 90°.
|
||||
let after = steer(0.0, PI / 2.0, 0.1);
|
||||
let after = steer(0.0, PI / 2.0, TURN_RATE, 0.1);
|
||||
assert!(
|
||||
(after - TURN_RATE * 0.1).abs() < 1e-6,
|
||||
"clamped to the budget"
|
||||
);
|
||||
assert!((steer(0.0, 0.05, 1.0) - 0.05).abs() < 1e-6);
|
||||
assert_eq!(steer(1.0, 1.0 + TURN_DEADZONE / 2.0, 1.0), 1.0);
|
||||
assert!((steer(0.0, 0.05, TURN_RATE, 1.0) - 0.05).abs() < 1e-6);
|
||||
assert_eq!(steer(1.0, 1.0 + TURN_DEADZONE / 2.0, TURN_RATE, 1.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steering_takes_the_short_way_round() {
|
||||
let after = steer(PI - 0.05, -PI + 0.05, 1.0);
|
||||
let after = steer(PI - 0.05, -PI + 0.05, TURN_RATE, 1.0);
|
||||
assert!(
|
||||
wrap_pi(after - (PI - 0.05)) > 0.0,
|
||||
"should wrap forward across ±π"
|
||||
|
||||
@@ -41,9 +41,9 @@ pub(super) fn look_input(
|
||||
rig.world_mouse.held(LookButton::Left) || steer_held,
|
||||
bit::LEFT_MOUSE,
|
||||
);
|
||||
// `/follow` sets the forward bit through W's own setter, so it arms the camera like W.
|
||||
// The auto-move sets the forward bit through W's own setter, so it arms the camera like W.
|
||||
set(
|
||||
binds.pressed(crate::bindings::Input::MoveForward) || player.follow_forward,
|
||||
binds.pressed(crate::bindings::Input::MoveForward) || player.auto_forward,
|
||||
bit::FORWARD,
|
||||
);
|
||||
set(
|
||||
@@ -153,10 +153,11 @@ pub(super) fn move_axes(
|
||||
// ── The forward/back axis ── The order of S and autorun decides: S held, then autorun
|
||||
// toggled, keeps both (the toggle pushes `0x1000`, so `test cl,0x30` misses) and the
|
||||
// reference sends MSG_MOVE_STOP with S held; autorun, then S, clears the bit at key-down and
|
||||
// walks you backward. `/follow` is a held forward, W's own bit `0x100000` through W's setter,
|
||||
// so it nets against S like W and never trips the cancel set, which fires on key-down edges.
|
||||
// walks you backward. The auto-move is a held forward, W's own bit `0x100000` through W's
|
||||
// setter, so it nets against S like W and never trips the cancel set, which fires on key-down
|
||||
// edges.
|
||||
let fwd_axis = state::forward_axis(
|
||||
binds.pressed(crate::bindings::Input::MoveForward) || player.follow_forward,
|
||||
binds.pressed(crate::bindings::Input::MoveForward) || player.auto_forward,
|
||||
binds.pressed(crate::bindings::Input::MoveBackward),
|
||||
both_buttons,
|
||||
autorun,
|
||||
|
||||
@@ -296,10 +296,11 @@ pub(crate) struct Player {
|
||||
/// current bit (`0x60e080`). Observers read our gait off it, and it is in the server-authored
|
||||
/// merge mask, so a server move can flip it ([`super::wire_in`]); it owes no ack.
|
||||
pub(super) walking: bool,
|
||||
/// `/follow` holding forward this frame: the reference's follow pushes W's own bit
|
||||
/// (`0x60e790`), so this is a [`forward_axis`] term. Rewritten each frame by
|
||||
/// [`super::follow::steer_follow`] just before the controller reads it.
|
||||
pub(super) follow_forward: bool,
|
||||
/// The auto-move (`/follow` or Click to Move) holding forward this frame: the reference pushes
|
||||
/// W's own bit (`0x60e790`), so this is a [`forward_axis`] term. Rewritten each frame by
|
||||
/// [`super::follow::steer_follow`] and [`super::approach::steer_approach`] just before the
|
||||
/// controller reads it.
|
||||
pub(super) auto_forward: bool,
|
||||
/// Free-fly (`F`): the camera moves on its own and the avatar/server position is frozen.
|
||||
pub(crate) detached: bool,
|
||||
/// `SMSG_CLIENT_CONTROL_UPDATE` with `allowMove = 0` named the unit we drive: us, while someone
|
||||
|
||||
@@ -406,6 +406,7 @@ pub(super) fn follow_requests(
|
||||
cast: Res<crate::spell::PendingCast>,
|
||||
mut errors: ResMut<crate::ui_action::UiErrorKeys>,
|
||||
mut follow: ResMut<crate::player::FollowState>,
|
||||
mut approach: ResMut<crate::player::Approach>,
|
||||
) {
|
||||
for request in requests.read() {
|
||||
let resolved = match request {
|
||||
@@ -448,6 +449,8 @@ pub(super) fn follow_requests(
|
||||
match resolved {
|
||||
Some((guid, name)) => {
|
||||
info!("follow: now following \"{name}\" guid {guid:#x}");
|
||||
// The arm `0x611130` cancels what runs first: one auto-move cell.
|
||||
approach.stop();
|
||||
follow.start(guid, name);
|
||||
}
|
||||
None => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -150,12 +150,16 @@ const ATTACK_RANGE_SQ: f32 = 109.2025;
|
||||
/// 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;
|
||||
const MELEE_FLOOR: f32 = 5.0;
|
||||
pub(super) const MELEE_FLOOR: f32 = 5.0;
|
||||
|
||||
pub(super) fn melee_reach(target: &ObjectStore, me: &ObjectStore) -> f32 {
|
||||
(target.0.unit_combat_reach() + me.0.unit_combat_reach() + MELEE_OFFSET).max(MELEE_FLOOR)
|
||||
}
|
||||
|
||||
/// A corpse's interact reach, squared: a flat 5 yd, since a corpse has no combat reach and the
|
||||
/// melee formula falls to its floor. `CanLootNow 0x5ec110` and the `CMSG_LOOT` sender `0x5df130`
|
||||
/// both compare against 25.0.
|
||||
const CORPSE_INTERACT_RANGE_SQ: f32 = 25.0;
|
||||
pub(super) const CORPSE_INTERACT_RANGE_SQ: f32 = 25.0;
|
||||
|
||||
/// `GAMEOBJECT_TYPE_GENERIC`: decoration whose highlightable slot is constant false (`0x5f47f0`),
|
||||
/// so it never shows an interact cursor.
|
||||
@@ -203,7 +207,7 @@ pub(crate) const GO_TYPE_FISHINGNODE: i32 = 17;
|
||||
/// `GAMEOBJECT_TYPE_CHAIR` (7): its own range predicate `0x5f5670` accepts within 3.0 yd
|
||||
/// (`[0xc4d808]`, written only by `0x5f98b0` as 3.0 squared).
|
||||
pub(crate) const GO_TYPE_CHAIR: i32 = 7;
|
||||
fn go_interact_range_sq(type_id: i32) -> f32 {
|
||||
pub(super) fn go_interact_range_sq(type_id: i32) -> f32 {
|
||||
match type_id {
|
||||
GO_TYPE_FISHINGNODE => 100.0 * 100.0,
|
||||
// Measured to the object; the reference (seats from `0x5f5760`) and vmangos
|
||||
@@ -493,6 +497,8 @@ struct CorpseFacts {
|
||||
dist_sq: f32,
|
||||
/// The already-looting override: `[player+0x1d28/2c] == corpse GUID`.
|
||||
looting_this: bool,
|
||||
/// `CanAutoInteract`, which `CanLootNow` asks first and then skips its range (`0x5ec11b`).
|
||||
can_auto_interact: bool,
|
||||
auto_loot: bool,
|
||||
/// Key 0, shift.
|
||||
shift_held: bool,
|
||||
@@ -502,7 +508,7 @@ fn corpse_cursor(f: CorpseFacts) -> Option<(CursorKind, bool)> {
|
||||
// Leg 1, lootable: able within `CanLootNow 0x5ec110`'s reach or while this corpse's loot
|
||||
// window is open; Pickup or LootAll by key 0 (`0x41f8f0` at `0x4827f8`), grayed at `0x482818`.
|
||||
if f.lootable {
|
||||
let able = f.dist_sq <= CORPSE_INTERACT_RANGE_SQ || f.looting_this;
|
||||
let able = f.can_auto_interact || f.dist_sq <= CORPSE_INTERACT_RANGE_SQ || f.looting_this;
|
||||
return Some((loot_cursor(f.auto_loot, f.shift_held), !able));
|
||||
}
|
||||
// Leg 2, the PvP insignia (`0x482875..0x4828a2`), inert in 1.12.1 as no player holds a
|
||||
@@ -523,7 +529,7 @@ pub(super) fn classify_cursor(
|
||||
reputations: Res<Reputations>,
|
||||
(mut cursor, mut fork): (ResMut<WorldCursor>, ResMut<AttackFork>),
|
||||
// `control_lost` is the attack leg's `[0xb4b3e4]` clear.
|
||||
player: Res<crate::player::Player>,
|
||||
(player, approach): (Res<crate::player::Player>, Res<crate::player::Approach>),
|
||||
units: Query<(
|
||||
&Transform,
|
||||
Option<&ObjectStore>,
|
||||
@@ -542,6 +548,11 @@ pub(super) fn classify_cursor(
|
||||
// `[0xb72038]`, the queued area; absent in a headless build, which reads as not queued.
|
||||
stone: Option<Res<crate::ui_dialog_verbs::MeetingStone>>,
|
||||
) {
|
||||
let can_auto_interact = crate::player::can_auto_interact(
|
||||
approach.enabled,
|
||||
self_q.single().ok().map(|(_, s)| s),
|
||||
&player,
|
||||
);
|
||||
// A GameObject that is not highlightable clears the cursor, as the reference's handler does.
|
||||
let resolve_go = || {
|
||||
let (go_tf, store, anim) = units.get(hovered_object.target?).ok()?;
|
||||
@@ -606,8 +617,7 @@ pub(super) fn classify_cursor(
|
||||
let store = store?;
|
||||
let (self_tf, self_store) = self_q.single().ok()?;
|
||||
let dist_sq = unit_tf.translation.distance_squared(self_tf.translation);
|
||||
let reach = (store.0.unit_combat_reach() + self_store.0.unit_combat_reach() + MELEE_OFFSET)
|
||||
.max(MELEE_FLOOR);
|
||||
let reach = melee_reach(store, self_store);
|
||||
let in_melee = dist_sq <= reach * reach;
|
||||
|
||||
let dead = store.0.unit_is_dead();
|
||||
@@ -616,7 +626,10 @@ pub(super) fn classify_cursor(
|
||||
// Grayed by `CanLootNow 0x5ec110`'s melee reach; the reference's mid-loot state
|
||||
// block and open-loot-window override are not applied to units.
|
||||
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
return Some((loot_cursor(loot_cfg.auto_loot, shift), !in_melee));
|
||||
return Some((
|
||||
loot_cursor(loot_cfg.auto_loot, shift),
|
||||
!(can_auto_interact || in_melee),
|
||||
));
|
||||
}
|
||||
// Skin also needs the learn latch `[0xb700e4 + 4×isPlayerTarget]` (`0x482589`): a
|
||||
// player who has learned no Skinning spell sees Point.
|
||||
@@ -666,6 +679,7 @@ pub(super) fn classify_cursor(
|
||||
skin_latch: learned.skin_player_corpse.is_some(),
|
||||
dist_sq: corpse_tf.translation.distance_squared(self_tf.translation),
|
||||
looting_this: loot_latch.0.is_some() && loot_latch.0 == hovered.corpse_guid,
|
||||
can_auto_interact,
|
||||
auto_loot: loot_cfg.auto_loot,
|
||||
shift_held: shift,
|
||||
})
|
||||
@@ -693,6 +707,25 @@ mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::dialog_status;
|
||||
|
||||
/// `CanLootNow 0x5ec110` skips its range under `CanAutoInteract` (`0x5ec11b`), so the click
|
||||
/// that walks there finds a lit pouch.
|
||||
#[test]
|
||||
fn click_to_move_lights_a_far_corpses_pouch() {
|
||||
let far = CorpseFacts {
|
||||
lootable: true,
|
||||
dist_sq: 400.0,
|
||||
..CorpseFacts::default()
|
||||
};
|
||||
assert_eq!(corpse_cursor(far), Some((CursorKind::Pickup, true)));
|
||||
assert_eq!(
|
||||
corpse_cursor(CorpseFacts {
|
||||
can_auto_interact: true,
|
||||
..far
|
||||
}),
|
||||
Some((CursorKind::Pickup, false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corpse_classifier_has_two_legs_and_no_own_corpse_leg() {
|
||||
let near = 25.0; // exactly the gate, which is inclusive
|
||||
|
||||
@@ -326,7 +326,8 @@ impl Plugin for TargetPlugin {
|
||||
// order is free.
|
||||
crate::spell::targeting::commit_ground_cast_on_click,
|
||||
crate::spell::targeting::commit_object_cast_on_click,
|
||||
click::act_on_right_click,
|
||||
// The verb a walk owes runs at its stop, ahead of this frame's click.
|
||||
(click::act_on_arrival, click::act_on_right_click).chain(),
|
||||
// The loot close's guid-scoped deselect.
|
||||
click::clear_target_requests,
|
||||
// The script calls that touch the selection, the cast or the targeting cursor
|
||||
|
||||
@@ -90,9 +90,10 @@ pub(crate) struct IdleGates {
|
||||
/// `UNIT_FIELD_MOUNTDISPLAYID > 0`, the sit's.
|
||||
pub(crate) mounted: bool,
|
||||
/// The sit's fourth gate (`0x482f84 call 0x6103a0`): for a solo player, the movement-action
|
||||
/// mode `[0xc4d888]` at its in-world `0xc`. `/follow` sets 3 (`FollowUnit 0x489e00`) and
|
||||
/// autorun never moves it, so a follower whose target has stopped is not seated.
|
||||
pub(crate) following: bool,
|
||||
/// mode `[0xc4d888]` at its in-world `0xc`. `/follow` sets 3 (`FollowUnit 0x489e00`), Click to
|
||||
/// Move 5 to 9, and autorun never moves it, so a follower whose target has stopped is not
|
||||
/// seated.
|
||||
pub(crate) auto_moving: bool,
|
||||
/// `UNIT_FIELD_FLAGS` bit 20 (`0x100000`), the AFK's, not the sit's.
|
||||
pub(crate) on_taxi: bool,
|
||||
/// The optimistic mirror `[0xb6e5cc]`: the AFK's second gate, and its anti-repeat.
|
||||
@@ -114,7 +115,7 @@ pub(crate) fn idle_action(idle: Duration, gates: IdleGates) -> IdleAction {
|
||||
};
|
||||
}
|
||||
IdleAction {
|
||||
sit: gates.stand_state == 0 && !gates.in_combat && !gates.mounted && !gates.following,
|
||||
sit: gates.stand_state == 0 && !gates.in_combat && !gates.mounted && !gates.auto_moving,
|
||||
afk: !gates.on_taxi && !gates.afk,
|
||||
camp: false,
|
||||
}
|
||||
@@ -131,6 +132,7 @@ pub(crate) fn idle_handler(
|
||||
commands: Res<crate::net::NetCommands>,
|
||||
mut stand: MessageWriter<crate::player::StandStateRequest>,
|
||||
follow: Res<crate::player::FollowState>,
|
||||
approach: Res<crate::player::Approach>,
|
||||
logout: Res<crate::ui_logout::LogoutState>,
|
||||
) {
|
||||
let idle = time.elapsed().saturating_sub(last.0);
|
||||
@@ -148,7 +150,7 @@ pub(crate) fn idle_handler(
|
||||
mounted: store.0.unit_mount_display_id() != 0,
|
||||
on_taxi: flags & crate::player::UNIT_FLAG_TAXI_FLIGHT != 0,
|
||||
afk: mirror.is_afk(),
|
||||
following: follow.guid.is_some(),
|
||||
auto_moving: follow.guid.is_some() || approach.active(),
|
||||
}
|
||||
});
|
||||
let action = match gates {
|
||||
@@ -325,7 +327,7 @@ mod tests {
|
||||
`[0xc4d888]` non-`0xc` implies translating, which `stand_state_refused` refuses \
|
||||
downstream. The AFK mark is untouched: it is not one of the sit's gates",
|
||||
IdleGates {
|
||||
following: true,
|
||||
auto_moving: true,
|
||||
..open()
|
||||
},
|
||||
IdleAction {
|
||||
@@ -501,6 +503,7 @@ mod tests {
|
||||
.init_resource::<ChatLog>()
|
||||
.init_resource::<LastInput>()
|
||||
.init_resource::<crate::player::FollowState>()
|
||||
.init_resource::<crate::player::Approach>()
|
||||
.init_resource::<crate::ui_logout::LogoutState>()
|
||||
.add_message::<crate::player::StandStateRequest>()
|
||||
.insert_resource(NetCommands(tx));
|
||||
|
||||
@@ -1796,6 +1796,39 @@ fn the_controls_checkboxes_write_flags_with_the_interface_panel_kit() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Click-to-Move (`UIOptionsFrame.lua:7`) moves `AutoInteract`, which boots off as 1.12's Western
|
||||
/// client registers it (`0x603374`).
|
||||
#[test]
|
||||
fn the_controls_page_offers_click_to_move_on_autointeract() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
let _ = s.take_cvar_changes();
|
||||
let row = "BenillaOptionsFrameContainerBodyControlsRowClickToMove";
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {row}Label:GetText()"))
|
||||
.unwrap(),
|
||||
"Click-to-Move"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>(&format!("return {row}Check:GetChecked()"))
|
||||
.unwrap(),
|
||||
"off by default"
|
||||
);
|
||||
|
||||
s.run(&format!("{row}Check:Click()")).unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("AutoInteract".to_string(), "1".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCVar(\"AutoInteract\")")
|
||||
.unwrap(),
|
||||
"1"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_resets_the_controls_page_to_registered_defaults() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -2281,9 +2314,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
// 82 rows less the three untipped below; four of the 79 carry a `BENILLA_` key, and a dropdown
|
||||
// 83 rows less the three untipped below; four of the 80 carry a `BENILLA_` key, and a dropdown
|
||||
// row is checked on the key it wears at rest.
|
||||
assert_eq!(checked, 79, "every tipped row carries a live key");
|
||||
assert_eq!(checked, 80, "every tipped row carries a live key");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
@@ -2379,8 +2412,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
s.errors()
|
||||
);
|
||||
}
|
||||
// Every tipped row: the same 79 the key census counts.
|
||||
assert_eq!(raised, 79, "every row but Auto Loot raises a description");
|
||||
// Every tipped row: the same 80 the key census counts.
|
||||
assert_eq!(raised, 80, "every row but Auto Loot raises a description");
|
||||
}
|
||||
|
||||
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
|
||||
@@ -4036,17 +4069,6 @@ fn without_a_seated_measurer_the_same_fit_reads_zero() {
|
||||
// nothing and reads back nil, so its box would offer a setting benilla does not have. A CVar is
|
||||
// registered only once something reads it.
|
||||
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
|
||||
(
|
||||
"autointeract",
|
||||
"click-to-move — the one row here that is a whole movement mode rather than a knob. \
|
||||
`CanAutoInteract 0x60f900` gates the world-click pick mask's bit 0 and inverts \
|
||||
`0x5ec110`'s interaction-distance refusal, so an out-of-range corpse/GO/NPC/cast click \
|
||||
queues an approach (`0x60fed0`, move kinds 6/7/9/0xa) instead of refusing. The commit \
|
||||
itself puts NOTHING on the wire — it stamps a local goal at `0x611130` — and the packets \
|
||||
are its consequences (a stand, then the original verb on arrival). benilla has only the \
|
||||
keyboard controller and /follow (mode 3). Registered default is \"0\" on every locale but \
|
||||
koKR (`0x603374` selects on the locale index), so stock West ships it OFF",
|
||||
),
|
||||
(
|
||||
"UnitNamePlayerPVPTitle",
|
||||
"the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \
|
||||
|
||||
Reference in New Issue
Block a user