diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 3c13d6d0..ccc06b66 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -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)
+
+
+
+
+
+
+ BenillaOptionsRow_OnLoad(this, "autointeract", "Click-to-Move",
+ "OPTION_TOOLTIP_CLICK_TO_MOVE")
+
-
-
+
+
BenillaOptionsRow_OnLoad(this, "cameraSmoothStyle",
diff --git a/crates/benilla-app/src/cvars/table.rs b/crates/benilla-app/src/cvars/table.rs
index 59a22702..891fccfa 100644
--- a/crates/benilla-app/src/cvars/table.rs
+++ b/crates/benilla-app/src/cvars/table.rs
@@ -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"),
diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs
index 7486a11f..aa83695c 100644
--- a/crates/benilla-app/src/game_plugins.rs
+++ b/crates/benilla-app/src/game_plugins.rs
@@ -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) {
diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs
index 53186fb9..8fe96be4 100644
--- a/crates/benilla-app/src/player.rs
+++ b/crates/benilla-app/src/player.rs
@@ -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::))
.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),
diff --git a/crates/benilla-app/src/player/approach.rs b/crates/benilla-app/src/player/approach.rs
new file mode 100644
index 00000000..c9cea014
--- /dev/null
+++ b/crates/benilla-app/src/player/approach.rs
@@ -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,
+ /// 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,
+ /// 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 {
+ 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>,
+ 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