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