click targeting survives a moving mouse, and low edges stop popping
- a left click that starts on a unit targets on release even while the drag orbits the camera — the real client's 200/800 ms click law, with the pick latched at the press - a deliberate orbit held past the window does not retarget on release; click and orbit are independent mechanisms now, not rivals - a low edge rides the foot cone instead of popping the body to the top - the step-up certifies its advance by body radius, not by one frame's travel Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -507,6 +507,84 @@ fn aabb_to_local(aabb: ColliderAabb, pos: Vec3, inv_rot: Quat) -> ParryAabb {
|
||||
ParryAabb::new(mins, maxs)
|
||||
}
|
||||
|
||||
/// One collision triangle near a point, as the **movement law** sees it — the geometry readout
|
||||
/// behind the step-up probe ([`crate::player::step_probe`]).
|
||||
pub(crate) struct FaceProbe {
|
||||
/// The collider it belongs to (a WMO walk bake, a terrain tile, a doodad hull…).
|
||||
pub(crate) entity: Entity,
|
||||
/// World-space normal at the **authored winding** — the vector [`cast_move`]'s gate tests.
|
||||
pub(crate) normal: Vec3,
|
||||
/// World-space vertices.
|
||||
pub(crate) verts: [Vec3; 3],
|
||||
}
|
||||
|
||||
impl FaceProbe {
|
||||
/// Whether this face may block a sweep heading `dir` — [`cast_move`]'s gate, exactly.
|
||||
pub(crate) fn blocks(&self, dir: Vec3) -> bool {
|
||||
self.normal.dot(dir) <= FACING_EPS
|
||||
}
|
||||
|
||||
/// World centroid — what "how far ahead is this face" is measured from.
|
||||
pub(crate) fn centroid(&self) -> Vec3 {
|
||||
(self.verts[0] + self.verts[1] + self.verts[2]) / 3.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Every trimesh face inside the box `at ± half` that `filter` can reach, nearest centroid first.
|
||||
///
|
||||
/// **Diagnostic only, and deliberately UNGATED**: it reports the faces the one-sided law *rejects*
|
||||
/// alongside the ones it keeps, which is the entire point of having it. "The kerb top is authored
|
||||
/// downward-facing, so our settle probe passes straight through it" and "the advance was too short
|
||||
/// to reach the tread" produce the same silent non-step from the outside, and opposite readings
|
||||
/// here. Convex (non-trimesh) colliders have no per-face winding to report and are skipped — the
|
||||
/// law leaves them whole-shape too.
|
||||
pub(crate) fn faces_near(
|
||||
ms: &MoveAndSlide<'_, '_>,
|
||||
at: Vec3,
|
||||
half: Vec3,
|
||||
filter: &SpatialQueryFilter,
|
||||
limit: usize,
|
||||
) -> Vec<FaceProbe> {
|
||||
let box_aabb = ColliderAabb {
|
||||
min: at - half,
|
||||
max: at + half,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entity in ms.spatial_query.aabb_intersections_with_aabb(box_aabb) {
|
||||
let Ok((collider, pos, rot, layers)) = ms.colliders.get(entity) else {
|
||||
continue;
|
||||
};
|
||||
if !filter.test(entity, layers.copied().unwrap_or_default()) {
|
||||
continue;
|
||||
}
|
||||
let Some(trimesh) = collider.shape_scaled().as_trimesh() else {
|
||||
continue;
|
||||
};
|
||||
let inv_rot = rot.0.inverse();
|
||||
for tri_id in trimesh
|
||||
.bvh()
|
||||
.intersect_aabb(&aabb_to_local(box_aabb, pos.0, inv_rot))
|
||||
{
|
||||
let tri = trimesh.triangle(tri_id);
|
||||
let Some(n) = tri.normal() else {
|
||||
continue;
|
||||
};
|
||||
out.push(FaceProbe {
|
||||
entity,
|
||||
normal: rot.0 * n,
|
||||
verts: [tri.a, tri.b, tri.c].map(|v| pos.0 + rot.0 * v),
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| {
|
||||
a.centroid()
|
||||
.distance_squared(at)
|
||||
.total_cmp(&b.centroid().distance_squared(at))
|
||||
});
|
||||
out.truncate(limit);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -668,6 +668,40 @@ fn debug_panel_ui(
|
||||
}
|
||||
}
|
||||
});
|
||||
// The step-up probe's last blocked-frame report (`crate::player::step_probe`).
|
||||
// On the panel *because a capture has to be steerable*: the director walks into
|
||||
// the kerb that will not climb and watches the `t=` stamp tick and the ladder
|
||||
// fill in, instead of finding out afterwards that the probe never fired at the
|
||||
// spot they meant (method §6 — prove the run before reading the result).
|
||||
egui::CollapsingHeader::new("Step-up")
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
let (report, at) = crate::player::step_probe::latest();
|
||||
if report.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"no blocked walk frame yet — walk square into the thing \
|
||||
that will not step up",
|
||||
)
|
||||
.color(OVERLAY_TEXT_DIM),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(format!("last fired t={at:.1}s"))
|
||||
.small()
|
||||
.color(OVERLAY_TEXT_DIM),
|
||||
);
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt("stepup_report")
|
||||
.show(ui, |ui| {
|
||||
for line in report {
|
||||
ui.label(
|
||||
egui::RichText::new(line).small().monospace(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Future sections add their own CollapsingHeader here.
|
||||
});
|
||||
|
||||
|
||||
@@ -57,16 +57,25 @@ pub struct WorldObject {
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// A clean left-*click* in the world — a press + release with no drag. Emitted by
|
||||
/// [`crate::player::control`], which is the single arbiter of click-vs-drag because it owns the
|
||||
/// left-drag camera orbit: a left *drag* engages the orbit and emits nothing, a left *click* emits this.
|
||||
/// World-interaction consumers (target selection) read it instead of re-deciding the gesture; *where*
|
||||
/// the cursor is is already tracked continuously (the hover pick), so this carries no position.
|
||||
/// A left *select* gesture in the world. Emitted by [`crate::player::control`] on the button's
|
||||
/// **release**, when the press satisfied the reference's click predicate — under 200 ms whatever the
|
||||
/// mouse did, or under 800 ms having turned the camera less than 2.25° of yaw / 2.0° of pitch
|
||||
/// ([`crate::player::camera::PressGesture`], decision 1122).
|
||||
///
|
||||
/// **A drag emits this too.** Orbit and select are independent in the reference: the press engages
|
||||
/// the camera look immediately and arms this test alongside it, so a fast flick-and-click orbits the
|
||||
/// camera *and* selects. This doc comment used to assert the opposite as design — *"a left drag
|
||||
/// engages the orbit and emits nothing"* — and a reporter quoted it back at us with an A/B against
|
||||
/// the real client proving it wrong (ledger B226).
|
||||
///
|
||||
/// It carries no position because the *pick* is latched separately, at the press
|
||||
/// ([`crate::target::PressPick`]) — never the live hover, which is blank by the time a dragged
|
||||
/// click lands.
|
||||
#[derive(Message, Clone, Copy)]
|
||||
pub struct WorldClick;
|
||||
|
||||
/// A clean right-*click* in the world — same arbiter and drag test as [`WorldClick`], for the right
|
||||
/// button (whose *drag* is the character turn). Vanilla's context action: attack a hostile under the
|
||||
/// The right button's context gesture — same arbiter and same predicate as [`WorldClick`], for the
|
||||
/// button whose drag is the character turn. Vanilla's context action: attack a hostile under the
|
||||
/// cursor (later: interact/gossip on a friendly).
|
||||
#[derive(Message, Clone, Copy)]
|
||||
pub struct WorldRightClick;
|
||||
|
||||
@@ -51,6 +51,8 @@ mod probe_look;
|
||||
mod server_ride;
|
||||
mod setup;
|
||||
mod state;
|
||||
/// The step-up diagnostic probe — the blocked-frame report behind the `stup` trace tag.
|
||||
pub(crate) mod step_probe;
|
||||
mod swim;
|
||||
mod wire_in;
|
||||
|
||||
@@ -70,9 +72,10 @@ pub(crate) use follow::{FollowRequest, FollowState};
|
||||
// what lets this module and the concern modules beside it keep naming them `super::X` unchanged.
|
||||
use state::{
|
||||
MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, CAPSULE_RADIUS, FALL_FAR_DROP, FALL_FAR_TIME,
|
||||
GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO,
|
||||
SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_HEIGHT,
|
||||
TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
|
||||
FOOT_CONE_HEIGHT, GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP,
|
||||
RUN_BACK_RATIO, SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK,
|
||||
STEP_UP_ADVANCE, STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL,
|
||||
WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
|
||||
};
|
||||
// `SETTLE_TIMEOUT` is `pub(crate)`: the settle release lives in the terrain streamer (decision
|
||||
// 0737 — residency releases the hold, not ground contact), which owns the deadline push while the
|
||||
@@ -340,17 +343,20 @@ fn control(
|
||||
(With<SelfPlayer>, Without<FlyCam>),
|
||||
>,
|
||||
window: Single<(&mut Window, &mut CursorOptions), With<PrimaryWindow>>,
|
||||
// Clean clicks (press+release, no drag) go out here — left for the target picker, right for the
|
||||
// context action (attack) — while *drags* engage the camera looks below instead; the third is
|
||||
// the right button's raw DOWN edge (targeting's cancel, decision 0792). The locals hold
|
||||
// each button's accumulated drag distance while a press is being classified (`None` = no press
|
||||
// pending).
|
||||
// Clicks go out here — left for the target picker, right for the context action (attack) — and
|
||||
// the third is the right button's raw DOWN edge (targeting's cancel, decision 0792). A press
|
||||
// engages its camera look *and* arms a click test; the release settles that test on the
|
||||
// reference's time/travel predicate, so one gesture can orbit and select both (decision 1122).
|
||||
// The locals hold each button's pending [`camera::PressGesture`] (`None` = no press pending).
|
||||
mut world_clicks: (
|
||||
MessageWriter<WorldClick>,
|
||||
MessageWriter<WorldRightClick>,
|
||||
MessageWriter<WorldRightPress>,
|
||||
),
|
||||
mut click_test: (Local<Option<f32>>, Local<Option<f32>>),
|
||||
mut click_test: (
|
||||
Local<Option<camera::PressGesture>>,
|
||||
Local<Option<camera::PressGesture>>,
|
||||
),
|
||||
// World context for the mover, bundled into one param (16-param limit): the loaded water
|
||||
// surfaces (swim mode + the buoyant float, see [`swim`]), the armed transports (the
|
||||
// platform-frame carry/attach — decision 0438 phase 2; `Without`s only disjoint the borrows),
|
||||
@@ -473,6 +479,7 @@ fn control(
|
||||
left_click,
|
||||
right_click,
|
||||
invert_pitch,
|
||||
time.elapsed_secs(),
|
||||
);
|
||||
// A stun freezes the BODY, not the view. The look session has already moved `cam.yaw` (and, on
|
||||
// a right-drag, coupled `face_yaw = cam.yaw`); putting the aim back leaves the camera orbiting
|
||||
|
||||
@@ -20,10 +20,71 @@ use crate::model_fade::{
|
||||
use crate::net::SelfPlayer;
|
||||
use crate::terrain::WowModelMaterial;
|
||||
|
||||
/// Accumulated cursor motion (logical px) past which a held mouse button becomes a **drag** (camera
|
||||
/// orbit / character turn) rather than a **click** (left: target select; right: the context attack).
|
||||
/// Small — a click has near-zero jitter; any real drag crosses it in a frame or two.
|
||||
const CLICK_DRAG_THRESHOLD: f32 = 4.0;
|
||||
/// The reference's up-edge click predicate, in **camera degrees and milliseconds** — the whole
|
||||
/// orbit-vs-select law (decision 1122; wow-re `world-click-drag-arbitration.md`, §5 fan-out
|
||||
/// 2026-08-08). Verbatim, from `0x514ae0`, which returns 1 = suppress / 0 = dispatch:
|
||||
///
|
||||
/// ```text
|
||||
/// isClick = elapsed < 200ms
|
||||
/// || (elapsed < 800ms && yaw_travel < 2.25° && pitch_travel < 2.0°)
|
||||
/// ```
|
||||
///
|
||||
/// **A press under 200 ms selects however far the mouse swept.** That arm is the entire bug report
|
||||
/// (ledger B226): flick the cursor across a mob and click on arrival with the hand still moving, and
|
||||
/// the reference targets it — the camera has been orbiting since the first motion sample and selects
|
||||
/// anyway. The two mechanisms are independent and share only the button state.
|
||||
///
|
||||
/// **The orbit has no threshold at all** — it engages on the *down* edge (`0x51491f`, guarded only by
|
||||
/// the 0→1 held transition) and the first motion sample already turns the camera. benilla deferred it
|
||||
/// behind 4 px of travel and, worse, *destroyed* the pending click on crossing; there is no such
|
||||
/// thing in the reference. The travel numbers below gate the **click**, never the orbit.
|
||||
///
|
||||
/// The literals in the binary are 8.0 event-units per axis against `Σ|0.8·Δx|` / `Σ|0.6·Δy|` of raw
|
||||
/// device counts. Copying those would bind us to the reference's device scaling, which is itself
|
||||
/// only INFERRED ("1 unit = 1 mouse count" is not established). So we copy the **angle** they mean,
|
||||
/// which is exact and device-independent: 8.0 event-units is `Σ|Δx| = 10.0` raw ⇒
|
||||
/// `cameraYawMoveSpeed·10/800` = **2.25°** of yaw at the shipped default 180; and `Σ|Δy| = 13.333`
|
||||
/// raw ⇒ `cameraPitchMoveSpeed·13.333/600` = **2.0°** of pitch at the default 90.
|
||||
///
|
||||
/// Per axis, independently, and **accumulated absolute travel** — not net displacement from the
|
||||
/// press point (`0x514400`: `fabs; fadd; fstp`, no subtraction and no stored origin). A shake that
|
||||
/// returns to where it started still spends the budget.
|
||||
const CLICK_HOLD_CEILING: f32 = 0.800;
|
||||
const CLICK_FREE_WINDOW: f32 = 0.200;
|
||||
const CLICK_YAW_TRAVEL: f32 = 2.25 * std::f32::consts::PI / 180.0;
|
||||
const CLICK_PITCH_TRAVEL: f32 = 2.0 * std::f32::consts::PI / 180.0;
|
||||
|
||||
/// A primary button's press, while it is still undecided — the reference's world-input state
|
||||
/// (`[0xbe1148]`): press instant `+0x14`, and the two independent motion accumulators zeroed at
|
||||
/// `0x514910`/`0x514913`. Our accumulators are in **radians of camera rotation** rather than device
|
||||
/// counts, for the reason in [`CLICK_HOLD_CEILING`]'s note.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct PressGesture {
|
||||
/// Seconds on the app clock when the button went down.
|
||||
at: f32,
|
||||
/// Accumulated |Δyaw| and |Δpitch| this press has asked the camera for, radians.
|
||||
yaw_travel: f32,
|
||||
pitch_travel: f32,
|
||||
}
|
||||
|
||||
impl PressGesture {
|
||||
fn new(now: f32) -> Self {
|
||||
Self {
|
||||
at: now,
|
||||
yaw_travel: 0.0,
|
||||
pitch_travel: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x514ae0`, minimised: the free window first, then the bounded-travel window.
|
||||
fn is_click(&self, now: f32) -> bool {
|
||||
let elapsed = now - self.at;
|
||||
elapsed < CLICK_FREE_WINDOW
|
||||
|| (elapsed < CLICK_HOLD_CEILING
|
||||
&& self.yaw_travel < CLICK_YAW_TRAVEL
|
||||
&& self.pitch_travel < CLICK_PITCH_TRAVEL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Third-person orbit-distance limits (yards). **VERIFIED from `WoW.exe` 5875** (`FUN_005112d0` +
|
||||
/// the camera CVars, wow-re `follow-camera`): max orbit = `cameraDistanceMax × cameraDistanceMaxFactor`,
|
||||
@@ -206,10 +267,16 @@ pub(crate) struct CameraPivot {
|
||||
}
|
||||
|
||||
/// Mouse-look session state machine — start/stop/hand-off between the two look buttons, cursor
|
||||
/// grab/stash/restore, and the left/right click-vs-drag tests that emit [`WorldClick`]/
|
||||
/// [`WorldRightClick`]. Also applies this frame's accumulated motion as look rotation while a button is
|
||||
/// held (right-drag syncs the character facing too). Called once per frame from [`super::control`];
|
||||
/// `both_buttons` is vanilla's both-button run (steers like a right-drag without its own click test).
|
||||
/// grab/stash/restore, and the two click tests that emit [`WorldClick`]/[`WorldRightClick`]. Also
|
||||
/// applies this frame's accumulated motion as look rotation while a button is held (right-drag syncs
|
||||
/// the character facing too). Called once per frame from [`super::control`]; `both_buttons` is
|
||||
/// vanilla's both-button run (steers like a right-drag without its own click test).
|
||||
///
|
||||
/// **Orbit and select are independent** (decision 1122): every primary press engages its look
|
||||
/// session immediately and *also* arms a click test, and the release decides the click on
|
||||
/// [`PressGesture::is_click`] alone. There is no "promotion" and nothing cancels the click for
|
||||
/// having moved — the pending click used to be destroyed the moment the cursor crossed a 4 px
|
||||
/// threshold, which is why a drag could never select (ledger B226).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn run_look_session(
|
||||
buttons: &ButtonInput<MouseButton>,
|
||||
@@ -224,15 +291,17 @@ pub(super) fn run_look_session(
|
||||
pointer_over_ui: bool,
|
||||
inspect_enabled: bool,
|
||||
// A left press this frame the UI already consumed as a cursor-payload world drop (0216 §3) —
|
||||
// the left click-vs-drag test below must yield to it exactly as it yields to a UI hover, so
|
||||
// dropping a held item never also starts a camera orbit.
|
||||
// the left click test must yield to it exactly as it yields to a UI hover, so dropping a held
|
||||
// item never also selects.
|
||||
click_consumed: bool,
|
||||
world_click: &mut MessageWriter<WorldClick>,
|
||||
world_right_click: &mut MessageWriter<WorldRightClick>,
|
||||
world_right_press: &mut MessageWriter<WorldRightPress>,
|
||||
left_click: &mut Option<f32>,
|
||||
right_click: &mut Option<f32>,
|
||||
left_click: &mut Option<PressGesture>,
|
||||
right_click: &mut Option<PressGesture>,
|
||||
invert_pitch: bool,
|
||||
// Seconds on the app clock — the press predicate's two time gates are measured against it.
|
||||
now: f32,
|
||||
) {
|
||||
// The right button's DOWN edge, before any click-vs-drag classification — the reference's
|
||||
// WorldFrame OnMouseDown fires at the press whether it becomes a click or a turn. It belongs
|
||||
@@ -244,37 +313,48 @@ pub(super) fn run_look_session(
|
||||
{
|
||||
world_right_press.write(WorldRightPress);
|
||||
}
|
||||
// A left+right gesture is a both-button run / camera turn — never a target select. Cancel any
|
||||
// pending left click-vs-drag test the instant the right button joins in, so releasing out of a
|
||||
// both-button move never fires a spurious selection click.
|
||||
if buttons.pressed(MouseButton::Right) {
|
||||
// A chord — both primaries down — is a both-button run, never a select. The reference kills the
|
||||
// pending click on the *second* press and refuses to arm a new one while another primary is held
|
||||
// (`0x514ac1`, `0x51481a`), so neither release of a chord can dispatch. Cancel both tests.
|
||||
if buttons.pressed(MouseButton::Left) && buttons.pressed(MouseButton::Right) {
|
||||
*left_click = None;
|
||||
*right_click = None;
|
||||
}
|
||||
// The right button's own click-vs-drag test (vanilla's context click — attack): unlike left, the
|
||||
// right *look* engages instantly on press (turn must feel immediate), so the test just rides the
|
||||
// session, accumulating motion; the release decides click vs turn below. A left join (both-button
|
||||
// run) is never a click.
|
||||
if let Some(moved) = right_click.as_mut() {
|
||||
*moved += mouse_motion.delta.length();
|
||||
if buttons.pressed(MouseButton::Left) {
|
||||
*right_click = None;
|
||||
}
|
||||
// Both tests just ride their session, accumulating the camera rotation the press has asked for;
|
||||
// the release decides. The travel is charged from the *input* delta, before the pitch clamp —
|
||||
// the reference accumulates raw device motion, so a drag pinned at the pitch limit still spends
|
||||
// its budget.
|
||||
let dyaw = (mouse_motion.delta.x * LOOK_SENSITIVITY).abs();
|
||||
let dpitch = (mouse_motion.delta.y * LOOK_SENSITIVITY).abs();
|
||||
for test in [&mut *left_click, &mut *right_click].into_iter().flatten() {
|
||||
test.yaw_travel += dyaw;
|
||||
test.pitch_travel += dpitch;
|
||||
}
|
||||
|
||||
// Mouse-look start/stop + cursor grab. Right-drag turns the character (press-triggered); left-drag
|
||||
// orbits the camera (deferred — a left *click* selects a target instead, so the orbit only engages
|
||||
// once the cursor drags past a threshold). Either hides + locks the cursor in place (so it can't
|
||||
// drift out of the window while we turn) and restores it where it was on release. A press that
|
||||
// begins over the debug panel is egui's, not ours.
|
||||
// Mouse-look start/stop + cursor grab. **Both** buttons engage their look session on the DOWN
|
||||
// edge — the reference has no deferral and no engage threshold (`0x51491f`), and the first
|
||||
// motion sample already turns the camera. The click test is a separate rider decided at the
|
||||
// release, which is exactly what lets one gesture orbit *and* select. Either button hides +
|
||||
// locks the cursor in place (so it can't drift out of the window while we turn) and restores it
|
||||
// where it was on release. A press that begins over the debug panel is egui's, not ours.
|
||||
if let Some(active) = rig.look {
|
||||
if !buttons.pressed(active.button()) {
|
||||
// A right press+release that never turned is vanilla's context *click* (attack the unit
|
||||
// under the cursor) — emit it as the session ends. A handoff release (left still held)
|
||||
// never fires: the left join already cancelled the test above.
|
||||
if active == LookButton::Right {
|
||||
if let Some(moved) = right_click.take() {
|
||||
if moved < CLICK_DRAG_THRESHOLD {
|
||||
world_right_click.write(WorldRightClick);
|
||||
// The session's button went up: settle its click test against the reference predicate.
|
||||
// A handoff release (the other button still held) never fires — the chord already
|
||||
// cancelled both tests above.
|
||||
let test = match active {
|
||||
LookButton::Left => left_click.take(),
|
||||
LookButton::Right => right_click.take(),
|
||||
};
|
||||
if let Some(test) = test {
|
||||
if test.is_click(now) {
|
||||
match active {
|
||||
LookButton::Left => {
|
||||
world_click.write(WorldClick);
|
||||
}
|
||||
LookButton::Right => {
|
||||
world_right_click.write(WorldRightClick);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,41 +381,28 @@ pub(super) fn run_look_session(
|
||||
// A press over the egui dev UI (the overlaid debug panel, the perf pill) or outside the world
|
||||
// viewport is not ours — this keeps a slider-drag from grabbing the cursor into mouse-look.
|
||||
let world_press = cursor_in_viewport(window, camera) && !pointer_over_ui;
|
||||
// Right-drag turn. Arms its context-click test too; not armed when left is already down (a
|
||||
// chord is never a click).
|
||||
if buttons.just_pressed(MouseButton::Right) && world_press {
|
||||
// Right-drag turn: engage immediately on press, as before — while also arming the
|
||||
// click-vs-drag test (a clean release becomes the context attack click). Not armed when
|
||||
// left is already down (a both-button run is never a click).
|
||||
rig.look = Some(LookButton::Right);
|
||||
rig.cursor_stash = window.cursor_position();
|
||||
cursor_opts.grab_mode = CursorGrabMode::Locked;
|
||||
cursor_opts.visible = false;
|
||||
*right_click = (!buttons.pressed(MouseButton::Left)).then_some(0.0);
|
||||
} else if !inspect_enabled {
|
||||
// Left is deferred into a click-vs-drag test so a clean left *click* can select a target
|
||||
// (vanilla left-click). A left press begins the test; it engages the camera orbit only once
|
||||
// the accumulated cursor motion crosses `CLICK_DRAG_THRESHOLD`, and a press+release with no
|
||||
// drag emits a `WorldClick` for the target picker. While the inspector is armed, left belongs
|
||||
// to it (its own copy-on-click handler), so the test is skipped and left never orbits.
|
||||
if buttons.just_pressed(MouseButton::Left) && world_press && !click_consumed {
|
||||
*left_click = Some(0.0);
|
||||
}
|
||||
if let Some(moved) = left_click.as_mut() {
|
||||
*moved += mouse_motion.delta.length();
|
||||
if !buttons.pressed(MouseButton::Left) {
|
||||
// Released with no drag → a click (the hover pick already knows what's under it).
|
||||
if *moved < CLICK_DRAG_THRESHOLD {
|
||||
world_click.write(WorldClick);
|
||||
}
|
||||
*left_click = None;
|
||||
} else if *moved >= CLICK_DRAG_THRESHOLD {
|
||||
// Dragged past the threshold → promote to the left-orbit look session.
|
||||
rig.look = Some(LookButton::Left);
|
||||
rig.cursor_stash = window.cursor_position();
|
||||
cursor_opts.grab_mode = CursorGrabMode::Locked;
|
||||
cursor_opts.visible = false;
|
||||
*left_click = None;
|
||||
}
|
||||
}
|
||||
*right_click = (!buttons.pressed(MouseButton::Left)).then(|| PressGesture::new(now));
|
||||
} else if buttons.just_pressed(MouseButton::Left) && world_press && !inspect_enabled {
|
||||
// Left-drag orbit — engaged on the press, exactly like right, because the reference
|
||||
// engages on the press (`0x51491f`). The select is not deferred behind it; it rides
|
||||
// along and settles at the release. While the inspector is armed left belongs to it
|
||||
// (its own copy-on-click handler), so neither the orbit nor the test starts.
|
||||
rig.look = Some(LookButton::Left);
|
||||
rig.cursor_stash = window.cursor_position();
|
||||
cursor_opts.grab_mode = CursorGrabMode::Locked;
|
||||
cursor_opts.visible = false;
|
||||
// A press the UI already consumed as a cursor-payload world drop (0216 §3) still orbits
|
||||
// — the reference's orbit is unconditional on the down edge — but must not also select.
|
||||
*right_click = None;
|
||||
*left_click = (!click_consumed && !buttons.pressed(MouseButton::Right))
|
||||
.then(|| PressGesture::new(now));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,6 +874,66 @@ mod tests {
|
||||
use crate::billboard::BillboardCard;
|
||||
use crate::mesh_tag::alpha_bits;
|
||||
|
||||
/// A press that has travelled `yaw`/`pitch` **degrees** of camera rotation.
|
||||
fn press(yaw_deg: f32, pitch_deg: f32) -> PressGesture {
|
||||
PressGesture {
|
||||
at: 0.0,
|
||||
yaw_travel: yaw_deg.to_radians(),
|
||||
pitch_travel: pitch_deg.to_radians(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The report this whole change exists for (ledger B226, decision 1122): **a fast click
|
||||
/// selects however far the mouse swept.** Under 200 ms the reference asks nothing about
|
||||
/// motion at all (`0x514ae0`'s first arm, `0x514b24`) — which is the gesture people actually
|
||||
/// make, flicking the cursor at a mob and clicking on arrival with the hand still moving.
|
||||
/// benilla used to destroy the pending click after 4 px of travel, so this case never fired.
|
||||
#[test]
|
||||
fn a_fast_click_selects_however_far_the_mouse_swept() {
|
||||
// A whole screen's worth of sweep — orders of magnitude past the travel gate.
|
||||
let swept = press(90.0, 45.0);
|
||||
assert!(
|
||||
swept.is_click(0.199),
|
||||
"under 200 ms, travel is not consulted"
|
||||
);
|
||||
// And the camera is expected to have orbited through all of it: the two are independent,
|
||||
// which is the half that makes the reference's gesture possible at all.
|
||||
}
|
||||
|
||||
/// The second arm: between 200 and 800 ms the travel gate applies, per axis and independently
|
||||
/// (`0x514ae0`'s `both accums < 8.0` arm). The thresholds are the reference's 2.25° of yaw and
|
||||
/// 2.0° of pitch — see [`CLICK_HOLD_CEILING`] for why we hold the angle, not the raw literal.
|
||||
#[test]
|
||||
fn between_the_windows_a_steady_hand_still_clicks_but_a_drag_does_not() {
|
||||
assert!(press(2.0, 1.5).is_click(0.5), "inside both travel gates");
|
||||
assert!(
|
||||
!press(2.3, 1.5).is_click(0.5),
|
||||
"yaw alone spends the budget"
|
||||
);
|
||||
assert!(!press(2.0, 2.1).is_click(0.5), "pitch alone spends it too");
|
||||
// Exactly at a threshold is a drag: the reference's compare is `< 8.0`, not `<=`.
|
||||
assert!(!press(2.25, 0.0).is_click(0.5), "the yaw gate is exclusive");
|
||||
assert!(
|
||||
!press(0.0, 2.0).is_click(0.5),
|
||||
"the pitch gate is exclusive"
|
||||
);
|
||||
}
|
||||
|
||||
/// The 800 ms ceiling (`0x514aeb lea eax,[edx-0x320]`) is absolute — a long hold is never a
|
||||
/// click, however still the hand was. This is the arm that keeps a deliberate camera orbit from
|
||||
/// re-targeting whatever it started on, and it is the reason the fix could not simply be
|
||||
/// "always select on release".
|
||||
#[test]
|
||||
fn a_long_hold_is_never_a_click_however_still() {
|
||||
let motionless = press(0.0, 0.0);
|
||||
assert!(motionless.is_click(0.799), "just inside the ceiling");
|
||||
assert!(!motionless.is_click(0.8), "the ceiling is exclusive");
|
||||
assert!(
|
||||
!motionless.is_click(5.0),
|
||||
"a long motionless hold is a drag"
|
||||
);
|
||||
}
|
||||
|
||||
/// The self-avatar's zoom-to-first-person fade reaches its BILLBOARD cards — the night-elf eye
|
||||
/// glow (ledger B71: two additive quads left burning in mid-air after the body was hidden).
|
||||
/// A card is a world ROOT following an anchor inside the model (decision 0153), so the fade's
|
||||
|
||||
@@ -16,8 +16,12 @@
|
||||
//! - a walkable slope never slows or deflects the walk: the real client's walk is
|
||||
//! two-dimensional (speed·dt of *horizontal* distance), so an opposing walkable plane rides
|
||||
//! instead of clipping ([`walkable_ride_velocity`]) — full 2D speed on every ≤50° surface;
|
||||
//! - a steep face in the way runs the atomic step-up ([`step_up`]): rise–advance–settle onto
|
||||
//! a walkable floor, committed whole within the frame or not at all (decision 0209);
|
||||
//! - a steep face in the way is first *certified* by the atomic step-up ([`step_up`]):
|
||||
//! rise–advance–settle onto a walkable floor, or nothing (decision 0209). What a certified
|
||||
//! obstacle then costs is the reference's two-regime law (decision 1123): a rise inside the
|
||||
//! foot cone ([`FOOT_CONE_HEIGHT`]) is **ridden** up the cone's 61.6° skirt over the frames the
|
||||
//! gait needs ([`foot_cone_ride`]) — a kerb takes three at a run; only a rise above the cone is
|
||||
//! the instant pop, committed whole within the frame. Uncertified, nothing rises at all;
|
||||
//! - a steep face never *lifts* the mover: when the slide's clip would convert a push into
|
||||
//! upward motion, the face clips as a vertical wall instead ([`steep_wall_plane`]) — you
|
||||
//! rub along trunks and steep banks, never up them;
|
||||
@@ -34,10 +38,10 @@ use bevy::prelude::*;
|
||||
use crate::collision::player_query_filter;
|
||||
|
||||
use super::{
|
||||
move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY, GRAVITY,
|
||||
GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT, JUMP_SPEED, LAND_PROBE, SKIN_WIDTH,
|
||||
STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL,
|
||||
WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
|
||||
move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY,
|
||||
FOOT_CONE_HEIGHT, GRAVITY, GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT,
|
||||
JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE,
|
||||
STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
|
||||
};
|
||||
|
||||
/// What the step decided — read by the move-flags / wire logic that follows it in `control`.
|
||||
@@ -141,7 +145,13 @@ pub(super) fn step(
|
||||
// can begin under a root by any path. (wow-re `moveflag-family.md` §1/§5.3,
|
||||
// `step-vs-fall-election.md`.)
|
||||
let anchored = !held && player.modes.rooted;
|
||||
let on_floor = !held && on_walkable && player.vel_y <= 0.0;
|
||||
// A body part-way up a foot cone is standing (decision 1123). The probe above looks straight
|
||||
// down and finds only the steep riser it is riding, so on its own it would call a mid-ride frame
|
||||
// airborne — gravity would then undo the climb and the body would dwell on the face, the exact
|
||||
// failure 0209's atomic commit was built to make impossible. The ride is re-earned from the
|
||||
// certification every frame it continues, so this can hold nothing up that has not just proved
|
||||
// it can be climbed.
|
||||
let on_floor = !held && (on_walkable || player.cone_riding) && player.vel_y <= 0.0;
|
||||
|
||||
// The wedged rest (decision 0211) stands until real ground takes over or the support
|
||||
// vanishes — we walked off the funnel wall into open air, which resumes a normal fresh fall.
|
||||
@@ -165,6 +175,7 @@ pub(super) fn step(
|
||||
if want_jump {
|
||||
player.vel_y = JUMP_SPEED;
|
||||
player.wedged = false;
|
||||
player.cone_riding = false;
|
||||
jumped = true;
|
||||
}
|
||||
} else {
|
||||
@@ -212,9 +223,23 @@ pub(super) fn step(
|
||||
time.delta(),
|
||||
hover_offset,
|
||||
);
|
||||
// The step-up probe (this is the LOCAL mover; a remote's dead-reckon is not a report
|
||||
// anyone is looking at): a walk frame that went nowhere writes the `stup` deep report —
|
||||
// the surface profile ahead, the advance ladder, the candidate faces.
|
||||
super::step_probe::watch(
|
||||
ms,
|
||||
capsule,
|
||||
&filter,
|
||||
center,
|
||||
g.center,
|
||||
player.horiz_vel,
|
||||
dt,
|
||||
time.elapsed_secs(),
|
||||
);
|
||||
center = g.center;
|
||||
climb = g.climb;
|
||||
snap_probe = g.snap;
|
||||
player.cone_riding = g.cone_riding;
|
||||
if let Some(e) = g.ground {
|
||||
ground_entity = Some(e);
|
||||
}
|
||||
@@ -229,6 +254,8 @@ pub(super) fn step(
|
||||
// The airborne slide is the OTHER shared resolve ([`airborne_step`]) — the same code a
|
||||
// remote mover's arc runs, so a jump meets our walls whoever is jumping (decision 0627).
|
||||
center = airborne_step(ms, capsule, &filter, center, velocity, time.delta());
|
||||
// Nothing here can be riding a cone: this arm is the arc, the hold and the anchor.
|
||||
player.cone_riding = false;
|
||||
}
|
||||
// Wedge-rest detection (decisions 0211/0212): airborne, already falling fast, yet the
|
||||
// descent achieved is a sliver of what gravity intended — [`WEDGE_STILL_FRAMES`] in a row
|
||||
@@ -332,8 +359,13 @@ pub(crate) struct GroundedStep {
|
||||
/// `None` means "keep whatever the caller already believed" — a step-up commit and a missed
|
||||
/// snap both leave the support unchanged.
|
||||
pub(crate) ground: Option<Entity>,
|
||||
/// The atomic step-up's committed height gain (yd), when the maneuver ran.
|
||||
/// The height gain (yd) this frame's climb achieved — the atomic step-up's committed rise, or
|
||||
/// the distance a foot-cone ride carried the body up its skirt.
|
||||
pub(crate) climb: Option<f32>,
|
||||
/// The body is part-way up a **foot-cone ride** and is *supported by the edge it is riding*,
|
||||
/// not standing on a floor — so the caller must keep treating it as grounded until a walkable
|
||||
/// floor takes over (decision 1123). Always `false` for a frame that did not ride.
|
||||
pub(crate) cone_riding: bool,
|
||||
/// The election snap's `(probe reach, what it found)` — trace fodder, `None` when the step-up
|
||||
/// took the frame instead. The inner pair is `(hit distance, hit normal.y)`.
|
||||
pub(crate) snap: Option<(f32, Option<(f32, f32)>)>,
|
||||
@@ -377,21 +409,81 @@ pub(crate) fn grounded_step(
|
||||
// every stuck/bounce report of the step-up era was that dwelling). Grazing a face nets
|
||||
// back onto the same floor (reads as sliding); a square push onto a low step lands on its
|
||||
// top; anything taller than [`STEP_UP_HEIGHT`] never commits.
|
||||
let stepped = if speed > 1.0e-6 {
|
||||
step_up(&cast, center, horiz_vel / speed, speed * dt.as_secs_f32())
|
||||
let attempt = if speed > 1.0e-6 {
|
||||
let travel = speed * dt.as_secs_f32();
|
||||
// The look-ahead is this frame's travel — "is there a steep face in my way *now*" is a
|
||||
// question about this frame. The **advance** is not: how far forward the maneuver must
|
||||
// reach to see the tread it would stand on is a property of the body, so it is at least
|
||||
// [`STEP_UP_ADVANCE`] whatever the frame rate or the gait (decision 1121). Travel still
|
||||
// wins when it is longer, so a very low frame rate never steps you less far than you asked
|
||||
// to walk.
|
||||
step_up(
|
||||
&cast,
|
||||
center,
|
||||
horiz_vel / speed,
|
||||
travel,
|
||||
travel.max(STEP_UP_ADVANCE),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
StepAttempt {
|
||||
contact: None,
|
||||
verdict: StepVerdict::NoFace,
|
||||
}
|
||||
};
|
||||
if let Some(landed) = stepped {
|
||||
// The committed maneuver IS this frame's motion — already settled on a walkable floor,
|
||||
// so the slide and the snap below are skipped.
|
||||
return GroundedStep {
|
||||
center: landed,
|
||||
ground: None,
|
||||
climb: Some(landed.y - center.y),
|
||||
snap: None,
|
||||
};
|
||||
// The certify trace (`WOW_MOVE_TRACE`, tag `step`): one line per attempt with every probe
|
||||
// number and the world-space contact, so a feel report pins to the exact placement and probe —
|
||||
// the instrument that broke the fence/tree cases, which reasoning alone could not. (The
|
||||
// *blocked-frame* deep report — the advance ladder and the surface profile — is the `stup` tag
|
||||
// in [`super::step_probe`], which the local mover fires when a walk frame goes nowhere.)
|
||||
if let Some((point, n)) = attempt.contact {
|
||||
if crate::dbg_trace::enabled_for("step") {
|
||||
let feet_y = center.y - CAPSULE_HEIGHT * 0.5;
|
||||
crate::dbg_trace::line(
|
||||
"step",
|
||||
&format!(
|
||||
"hit ({:8.2},{:7.2},{:8.2}) h={:+.2} n=({:+.2},{:+.2},{:+.2}) {}",
|
||||
point.x,
|
||||
point.y,
|
||||
point.z,
|
||||
point.y - feet_y,
|
||||
n.x,
|
||||
n.y,
|
||||
n.z,
|
||||
attempt.verdict
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// **Which regime** (decision 1123, wow-re `climb-vs-slide.md` §2/§4/§6). The certification above
|
||||
// settles *whether* the obstacle can be cleared; its committed rise settles *how*. The real
|
||||
// client's solid is a cone below [`FOOT_CONE_HEIGHT`] and a vertical box above it, so a low edge
|
||||
// never meets a wall to be lifted over — it meets the slanted skirt, and the ordinary slide
|
||||
// carries the body up it. Only an edge that clears the cone meets the box square, and that
|
||||
// square meeting is the instant step-up. One law, two outcomes, selected by the rise:
|
||||
// - `dy ≤ FOOT_CONE_HEIGHT` ⇒ **ride** the skirt at atan 1.8494 ≈ 61.6°, over as many frames
|
||||
// as the gait needs (a kerb takes two at a run) — smooth, and never a teleport;
|
||||
// - `dy > FOOT_CONE_HEIGHT` ⇒ the atomic pop of 0209, unchanged;
|
||||
// - no certification at all ⇒ neither: the plain slide, and the body never rises. (This is
|
||||
// the gate that keeps the ride honest — a tall wall's contact point sits *inside* the cone
|
||||
// band too, so height alone would ride it. Only the certification distinguishes them.)
|
||||
let ride_to = match attempt.verdict {
|
||||
StepVerdict::Commit { landed, dy, .. } if dy <= FOOT_CONE_HEIGHT => Some(landed.y),
|
||||
_ => None,
|
||||
};
|
||||
if ride_to.is_none() {
|
||||
if let Some(landed) = attempt.verdict.landed() {
|
||||
// The committed maneuver IS this frame's motion — already settled on a walkable floor,
|
||||
// so the slide and the snap below are skipped.
|
||||
return GroundedStep {
|
||||
center: landed,
|
||||
ground: None,
|
||||
climb: Some(landed.y - center.y),
|
||||
snap: None,
|
||||
cone_riding: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut rode = false;
|
||||
let out = crate::collision::one_sided::move_and_slide(
|
||||
ms,
|
||||
capsule,
|
||||
@@ -405,6 +497,18 @@ pub(crate) fn grounded_step(
|
||||
*hit.velocity = ride;
|
||||
return MoveAndSlideHitResponse::Accept;
|
||||
}
|
||||
// The foot cone's skirt, on a certified low edge only. The ceiling is the
|
||||
// certification's own landing: once the body is that high the obstacle is cleared, so
|
||||
// the skirt has nothing left to ride and any further contact is an ordinary wall. Any
|
||||
// overshoot inside a sub-step is taken back out by the election snap below, which only
|
||||
// ever descends onto a walkable floor.
|
||||
if ride_to.is_some_and(|ceiling| hit.position.y < ceiling) {
|
||||
if let Some(up) = foot_cone_ride(**hit.normal, *hit.velocity) {
|
||||
*hit.velocity = up;
|
||||
rode = true;
|
||||
return MoveAndSlideHitResponse::Accept;
|
||||
}
|
||||
}
|
||||
if let Some(wall) = steep_wall_plane(**hit.normal, *hit.velocity) {
|
||||
if let Ok(wall) = Dir::new(wall) {
|
||||
*hit.normal = wall;
|
||||
@@ -450,8 +554,14 @@ pub(crate) fn grounded_step(
|
||||
}
|
||||
GroundedStep {
|
||||
center: slid,
|
||||
// A ride is a climb too — the trace reads the same whichever regime moved the body.
|
||||
climb: rode.then_some(slid.y - center.y),
|
||||
// Mid-ride the body is held up by the edge under its skirt, and the frame-start ground
|
||||
// probe can only see the steep riser there. Say so, so the caller keeps it standing
|
||||
// instead of letting gravity undo the climb. A snap onto a walkable floor ends the ride:
|
||||
// the tread is under the feet and ordinary grounding takes over from here.
|
||||
cone_riding: rode && ground.is_none(),
|
||||
ground,
|
||||
climb: None,
|
||||
snap,
|
||||
}
|
||||
}
|
||||
@@ -519,6 +629,40 @@ fn walkable_ride_velocity(n: Vec3, v: Vec3) -> Option<Vec3> {
|
||||
Some(Vec3::new(v.x, -(v.x * n.x + v.z * n.z) / n.y, v.z))
|
||||
}
|
||||
|
||||
/// **The foot cone's ride** — the smooth half of the reference's climb law (decision 1123).
|
||||
///
|
||||
/// The real client's movement solid is a **cone below the waist**: the k-DOP build at `0x631440`
|
||||
/// emits four bevels running from a point at the foot out to the full radius at
|
||||
/// `foot + radius·1.8493990`, and only above that height is it a vertical box (wow-re
|
||||
/// `climb-vs-slide.md` §2 — the `n.z < 0` sign on those planes is the tell that the cone narrows
|
||||
/// *downward*, so it is a foot cone and not a top-rim chamfer). A low edge therefore never presents
|
||||
/// the mover a wall to be lifted over: it presents the slanted skirt, and the resolver's own slide
|
||||
/// runs the body up it.
|
||||
///
|
||||
/// The gain is the note's `T` at §4 — `1.8494 · cosθ · len`, where `θ` is how squarely the approach
|
||||
/// meets the face — and that is exactly this projection: the closing horizontal speed (the dot
|
||||
/// product supplies `cosθ`) times [`STEP_SLOPE_RATIO`], the cone's own surface slope. Horizontal
|
||||
/// speed is untouched, as it is on every walkable ride ([`walkable_ride_velocity`]); the grounded
|
||||
/// mover owns no vertical of its own, so this *sets* the vertical rather than adding to it.
|
||||
///
|
||||
/// Only steep, non-overhanging, opposing faces ride — a walkable face already had its ride, and an
|
||||
/// overhang is a ceiling. **The caller owns the real gate:** this says only "here is what the skirt
|
||||
/// would do", never "the body may climb". Whether the obstacle can be cleared at all is the
|
||||
/// certification's call in [`grounded_step`], because a tall wall's contact point sits inside the
|
||||
/// cone band too and height alone cannot tell the two apart.
|
||||
fn foot_cone_ride(n: Vec3, v: Vec3) -> Option<Vec3> {
|
||||
if !(0.0..GROUND_COS).contains(&n.y) {
|
||||
return None;
|
||||
}
|
||||
// Steepness bounds the horizontal part below by sin 50°, so the normalize is safe.
|
||||
let h = Vec3::new(n.x, 0.0, n.z).normalize();
|
||||
let into = -(v.x * h.x + v.z * h.z);
|
||||
if into <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(Vec3::new(v.x, into * STEP_SLOPE_RATIO, v.z))
|
||||
}
|
||||
|
||||
/// The steep-face wall rule: a steep (non-walkable, non-overhanging) face must never *lift*
|
||||
/// the mover. Collide-and-slide clips velocity onto each contact plane, and on a tilted plane
|
||||
/// that clip manufactures upward motion out of a horizontal push (`v'.y − v.y = −(v·n)·n.y`,
|
||||
@@ -546,15 +690,99 @@ fn steep_wall_plane(n: Vec3, v: Vec3) -> Option<Vec3> {
|
||||
Some(Vec3::new(n.x, 0.0, n.z).normalize())
|
||||
}
|
||||
|
||||
/// What one atomic step-up attempt decided — the structured form of the `step` trace line.
|
||||
///
|
||||
/// Structured rather than logged-and-forgotten because the diagnostic probe
|
||||
/// ([`super::step_probe`]) re-runs the *same* maneuver at a ladder of forward advances and reads
|
||||
/// the reason back off every rung. "Why did this step fail, and what would have made it succeed"
|
||||
/// is then one table in the trace, not a text parse of six different format strings.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum StepVerdict {
|
||||
/// Nothing steep and opposing within the look-ahead — not a step-up frame at all.
|
||||
NoFace,
|
||||
/// The rise found no headroom above the capsule.
|
||||
NoHeadroom,
|
||||
/// The settle probe found no floor at all under the advanced point.
|
||||
NoFloor { up: f32, fwd: f32 },
|
||||
/// The settle found a floor, but one too steep to stand on (`ny` under [`GROUND_COS`]).
|
||||
SteepFloor {
|
||||
up: f32,
|
||||
fwd: f32,
|
||||
dist: f32,
|
||||
ny: f32,
|
||||
},
|
||||
/// The maneuver gained no height — a graze, a too-tall wall, or a pinch's gap floor. The plain
|
||||
/// slide owns the frame.
|
||||
NetZero {
|
||||
up: f32,
|
||||
fwd: f32,
|
||||
dist: f32,
|
||||
ny: f32,
|
||||
dy: f32,
|
||||
},
|
||||
/// Committed: this landing **is** the frame's motion.
|
||||
Commit {
|
||||
landed: Vec3,
|
||||
up: f32,
|
||||
fwd: f32,
|
||||
dy: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl StepVerdict {
|
||||
/// The committed capsule centre, if this attempt took the frame.
|
||||
pub(crate) fn landed(self) -> Option<Vec3> {
|
||||
match self {
|
||||
StepVerdict::Commit { landed, .. } => Some(landed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StepVerdict {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match *self {
|
||||
StepVerdict::NoFace => write!(f, "no opposing face"),
|
||||
StepVerdict::NoHeadroom => write!(f, "up=0.00 NO-HEADROOM -> slide"),
|
||||
StepVerdict::NoFloor { up, fwd } => {
|
||||
write!(f, "up={up:.2} fwd={fwd:.2} down=miss NO-FLOOR -> slide")
|
||||
}
|
||||
StepVerdict::SteepFloor { up, fwd, dist, ny } => write!(
|
||||
f,
|
||||
"up={up:.2} fwd={fwd:.2} down=(d={dist:.2} ny={ny:+.2}) STEEP-FLOOR -> slide"
|
||||
),
|
||||
StepVerdict::NetZero {
|
||||
up,
|
||||
fwd,
|
||||
dist,
|
||||
ny,
|
||||
dy,
|
||||
} => write!(
|
||||
f,
|
||||
"up={up:.2} fwd={fwd:.2} down=(d={dist:.2} ny={ny:+.2}) dy={dy:+.3} NET-ZERO -> slide"
|
||||
),
|
||||
StepVerdict::Commit { up, fwd, dy, .. } => {
|
||||
write!(f, "up={up:.2} fwd={fwd:.2} dy={dy:+.3} -> COMMIT")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One step-up attempt: the opposing face that triggered it (world contact point + its authored
|
||||
/// normal), and what the maneuver decided about it.
|
||||
pub(crate) struct StepAttempt {
|
||||
/// `None` when no steep opposing face was within the look-ahead — there was nothing to step.
|
||||
pub(crate) contact: Option<(Vec3, Vec3)>,
|
||||
pub(crate) verdict: StepVerdict,
|
||||
}
|
||||
|
||||
/// The atomic step-up (decision 0209) — the standard kinematic-controller maneuver, *not* the
|
||||
/// reference resolver's (that direction is closed, 0207): a steep opposing face within this
|
||||
/// frame's travel triggers **rise → advance → settle**, committed whole inside this one frame,
|
||||
/// or nothing.
|
||||
/// reference resolver's (that direction is closed, 0207): a steep opposing face within `look`
|
||||
/// triggers **rise → advance → settle**, committed whole inside this one frame, or nothing.
|
||||
///
|
||||
/// - **Rise** by the free headroom, at most [`STEP_UP_HEIGHT`] — the deliberately low ceiling
|
||||
/// that scopes this to stairs/doorsteps/low rocks and keeps fences and walls slide-only.
|
||||
/// - **Advance** this frame's own travel along the *input* direction at the raised height —
|
||||
/// never a probe-length lunge.
|
||||
/// - **Advance** by `advance` along the *input* direction at the raised height.
|
||||
/// - **Settle** back down by the walk election's own reach; commit **only onto a walkable
|
||||
/// floor that is actually higher**.
|
||||
///
|
||||
@@ -564,91 +792,334 @@ fn steep_wall_plane(n: Vec3, v: Vec3) -> Option<Vec3> {
|
||||
/// the origin floor ⇒ slide; a pinch between two tree trunks offers only steep landings ⇒
|
||||
/// **no commit, ever** — the wedge/bounce class of 0191–0195 is impossible by construction,
|
||||
/// because there is no intermediate mid-climb state to be caught in.
|
||||
fn step_up(
|
||||
///
|
||||
/// **`look` and `advance` are separate parameters only so the maneuver is measurable.** The live
|
||||
/// mover passes this frame's own travel for both (0209's design — never a probe-length lunge);
|
||||
/// the diagnostic probe ([`super::step_probe`]) sweeps `advance` to find the offset at which the
|
||||
/// settle probe would have cleared the obstacle's lip, which is the number a "it won't step up
|
||||
/// this curb" report is actually about.
|
||||
pub(crate) fn step_up(
|
||||
cast: &impl Fn(Vec3, Vec3) -> Option<MoveHitData>,
|
||||
center: Vec3,
|
||||
dir_h: Vec3,
|
||||
travel: f32,
|
||||
) -> Option<Vec3> {
|
||||
// A steep, non-overhanging face opposing the motion, within this frame's travel (+skin).
|
||||
look: f32,
|
||||
advance: f32,
|
||||
) -> StepAttempt {
|
||||
let none = |verdict| StepAttempt {
|
||||
contact: None,
|
||||
verdict,
|
||||
};
|
||||
// A steep, non-overhanging face opposing the motion, within `look` (+skin).
|
||||
// No incidence gate — the verified ref has none; grazing nets zero through the settle.
|
||||
let ahead = cast(center, dir_h * travel)?;
|
||||
let Some(ahead) = cast(center, dir_h * look) else {
|
||||
return none(StepVerdict::NoFace);
|
||||
};
|
||||
let n = ahead.normal1;
|
||||
if n.y >= GROUND_COS || n.y < 0.0 || n.dot(dir_h) >= 0.0 {
|
||||
return None;
|
||||
return none(StepVerdict::NoFace);
|
||||
}
|
||||
// The certify trace (`WOW_MOVE_TRACE`): one `step` line per attempt with every probe number
|
||||
// and the world-space contact, so a feel report pins to the exact placement and probe —
|
||||
// the instrument that broke the fence/tree cases, which reasoning alone could not.
|
||||
let feet_y = center.y - CAPSULE_HEIGHT * 0.5;
|
||||
let hit = ahead.point1;
|
||||
let log = |verdict: &str| {
|
||||
if crate::dbg_trace::enabled() {
|
||||
crate::dbg_trace::line(
|
||||
"step",
|
||||
&format!(
|
||||
"hit ({:8.2},{:7.2},{:8.2}) h={:+.2} n=({:+.2},{:+.2},{:+.2}) {}",
|
||||
hit.x,
|
||||
hit.y,
|
||||
hit.z,
|
||||
hit.y - feet_y,
|
||||
n.x,
|
||||
n.y,
|
||||
n.z,
|
||||
verdict
|
||||
),
|
||||
);
|
||||
}
|
||||
let at = |verdict| StepAttempt {
|
||||
contact: Some((ahead.point1, n)),
|
||||
verdict,
|
||||
};
|
||||
|
||||
// Rise: the free headroom, at most H.
|
||||
let up_t = cast(center, Vec3::Y * STEP_UP_HEIGHT).map_or(STEP_UP_HEIGHT, |h| h.distance);
|
||||
if up_t < 1e-3 {
|
||||
log("up=0.00 NO-HEADROOM -> slide");
|
||||
return None;
|
||||
let up = cast(center, Vec3::Y * STEP_UP_HEIGHT).map_or(STEP_UP_HEIGHT, |h| h.distance);
|
||||
if up < 1e-3 {
|
||||
return at(StepVerdict::NoHeadroom);
|
||||
}
|
||||
// Advance: this frame's travel along the input dir, swept at the raised height.
|
||||
let raised = center + Vec3::Y * up_t;
|
||||
let fwd_t = cast(raised, dir_h * travel).map_or(travel, |h| h.distance);
|
||||
let over = raised + dir_h * fwd_t;
|
||||
// Advance: along the input dir, swept at the raised height.
|
||||
let raised = center + Vec3::Y * up;
|
||||
let fwd = cast(raised, dir_h * advance).map_or(advance, |h| h.distance);
|
||||
let over = raised + dir_h * fwd;
|
||||
// Settle: the walk election's reach below the advanced point — the rise undone, plus the
|
||||
// travel-scaled step-down allowance (decisions 0182/0190) — onto a WALKABLE floor only.
|
||||
let reach = up_t + travel * STEP_SLOPE_RATIO + STEP_SNAP_SLACK;
|
||||
let reach = up + advance * STEP_SLOPE_RATIO + STEP_SNAP_SLACK;
|
||||
let Some(down) = cast(over, Vec3::NEG_Y * reach) else {
|
||||
log(&format!(
|
||||
"up={up_t:.2} fwd={fwd_t:.2} down=miss NO-FLOOR -> slide"
|
||||
));
|
||||
return None;
|
||||
return at(StepVerdict::NoFloor { up, fwd });
|
||||
};
|
||||
if down.normal1.y < GROUND_COS {
|
||||
log(&format!(
|
||||
"up={up_t:.2} fwd={fwd_t:.2} down=(d={:.2} ny={:+.2}) STEEP-FLOOR -> slide",
|
||||
down.distance, down.normal1.y
|
||||
));
|
||||
return None;
|
||||
let (dist, ny) = (down.distance, down.normal1.y);
|
||||
if ny < GROUND_COS {
|
||||
return at(StepVerdict::SteepFloor { up, fwd, dist, ny });
|
||||
}
|
||||
let landed = over + Vec3::NEG_Y * down.distance;
|
||||
let landed = over + Vec3::NEG_Y * dist;
|
||||
let dy = landed.y - center.y;
|
||||
// Commit only a landing that actually gained a floor. A net-zero maneuver (grazing a face,
|
||||
// pushing a too-tall wall, the tree pinch's gap grass) belongs to the plain slide — its
|
||||
// deflection is what "sliding along the fence" is; committing here would dead-stop it.
|
||||
if dy <= 0.05 {
|
||||
log(&format!(
|
||||
"up={up_t:.2} fwd={fwd_t:.2} down=(d={:.2} ny={:+.2}) dy={dy:+.3} NET-ZERO -> slide",
|
||||
down.distance, down.normal1.y
|
||||
));
|
||||
return None;
|
||||
return at(StepVerdict::NetZero {
|
||||
up,
|
||||
fwd,
|
||||
dist,
|
||||
ny,
|
||||
dy,
|
||||
});
|
||||
}
|
||||
log(&format!(
|
||||
"up={up_t:.2} fwd={fwd_t:.2} down=(d={:.2} ny={:+.2}) dy={dy:+.3} -> COMMIT",
|
||||
down.distance, down.normal1.y
|
||||
));
|
||||
Some(landed)
|
||||
at(StepVerdict::Commit {
|
||||
landed,
|
||||
up,
|
||||
fwd,
|
||||
dy,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::player::CAPSULE_RADIUS;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// A headless physics world holding **the kerb the director's capture measured** — Stormwind
|
||||
/// Trade District, decision 1121: a 0.28 yd sidewalk whose riser is a ~61° bevel, not a
|
||||
/// vertical face. Profiled from the `stup` down-ray scan at the real spot (street out to
|
||||
/// +0.20, the bevel's face normal `ny=+0.49`, flat tread `ny=+0.99` from +0.50 on), so the
|
||||
/// fixture is the geometry, not an idea of it.
|
||||
///
|
||||
/// The profile is a `(x, y)` polyline extruded across `z`, wound so every face's **authored**
|
||||
/// normal points up and back at the approaching body — the one-sided law (0970) is live in
|
||||
/// these casts, so a mis-wound fixture would silently be a hole to fall through.
|
||||
fn world_with_kerb() -> App {
|
||||
const PROFILE: [(f32, f32); 4] = [(-2.0, 0.0), (0.29, 0.0), (0.446, 0.28), (3.0, 0.28)];
|
||||
const W: f32 = 3.0;
|
||||
let mut app = App::new();
|
||||
// avian's collider backend reads `Assets<Mesh>` and `SceneSpawner` even in a meshless
|
||||
// world, so the headless asset/scene plugins ride along.
|
||||
app.add_plugins((
|
||||
MinimalPlugins,
|
||||
bevy::transform::TransformPlugin,
|
||||
bevy::asset::AssetPlugin::default(),
|
||||
bevy::scene::ScenePlugin,
|
||||
PhysicsPlugins::new(bevy::app::PostUpdate),
|
||||
));
|
||||
app.init_asset::<Mesh>();
|
||||
let (mut verts, mut tris) = (Vec::new(), Vec::new());
|
||||
for w in PROFILE.windows(2) {
|
||||
let (&(x0, y0), &(x1, y1)) = (&w[0], &w[1]);
|
||||
let b = verts.len() as u32;
|
||||
verts.extend([
|
||||
Vec3::new(x0, y0, -W),
|
||||
Vec3::new(x1, y1, -W),
|
||||
Vec3::new(x1, y1, W),
|
||||
Vec3::new(x0, y0, W),
|
||||
]);
|
||||
// (a, c, b) / (a, d, c): normal = (-dy, dx, 0) — up for the flats, up-and-back for
|
||||
// the riser. The reverse winding is a backface and blocks nothing at all.
|
||||
tris.extend([[b, b + 2, b + 1], [b, b + 3, b + 2]]);
|
||||
}
|
||||
app.world_mut().spawn((
|
||||
RigidBody::Static,
|
||||
Collider::trimesh(verts, tris),
|
||||
Transform::default(),
|
||||
));
|
||||
app.update(); // one frame builds Position/Rotation and the spatial-query trees
|
||||
app
|
||||
}
|
||||
|
||||
fn player_capsule() -> Collider {
|
||||
Collider::capsule(CAPSULE_RADIUS, CAPSULE_HEIGHT - 2.0 * CAPSULE_RADIUS)
|
||||
}
|
||||
|
||||
/// Walk the capsule into the kerb exactly as the mover does, then run one step-up attempt at
|
||||
/// `advance`. Returns the verdict from where the slide actually left the body — not from a
|
||||
/// hand-placed pose, which is how a fixture ends up testing a spot the mover never reaches.
|
||||
fn step_at(advance: f32) -> StepVerdict {
|
||||
world_with_kerb()
|
||||
.world_mut()
|
||||
.run_system_once(move |ms: MoveAndSlide| {
|
||||
let capsule = player_capsule();
|
||||
let filter = SpatialQueryFilter::default();
|
||||
let cast = |from: Vec3, disp: Vec3| {
|
||||
crate::collision::one_sided::cast_move(
|
||||
&ms, &capsule, from, disp, SKIN_WIDTH, &filter,
|
||||
)
|
||||
};
|
||||
// Approach from 1 yd back along +X at street level and stop where the kerb stops us.
|
||||
let start = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5, 0.0);
|
||||
let run = cast(start, Vec3::X).map_or(1.0, |h| h.distance);
|
||||
let center = start + Vec3::X * run;
|
||||
step_up(&cast, center, Vec3::X, TRAVEL_60FPS, advance).verdict
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// One frame's travel at a run (7.0 yd/s) and 60 fps — 0209's advance, and the number the
|
||||
/// capture caught failing.
|
||||
const TRAVEL_60FPS: f32 = 7.0 / 60.0;
|
||||
|
||||
/// Walk the capsule into the kerb and run `frames` consecutive **whole** grounded steps from
|
||||
/// wherever the last one left it — the mover's own loop, so what these assert is the behaviour
|
||||
/// on screen and not a single probe in isolation.
|
||||
fn walk_kerb(start_y: f32, frames: usize) -> Vec<(f32, Option<f32>, bool)> {
|
||||
world_with_kerb()
|
||||
.world_mut()
|
||||
.run_system_once(move |ms: MoveAndSlide| {
|
||||
let capsule = player_capsule();
|
||||
let filter = SpatialQueryFilter::default();
|
||||
let cast = |from: Vec3, disp: Vec3| {
|
||||
crate::collision::one_sided::cast_move(
|
||||
&ms, &capsule, from, disp, SKIN_WIDTH, &filter,
|
||||
)
|
||||
};
|
||||
let start = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5 + start_y, 0.0);
|
||||
let run = cast(start, Vec3::X).map_or(1.0, |h| h.distance);
|
||||
let mut center = start + Vec3::X * run;
|
||||
let dt = std::time::Duration::from_secs_f32(TRAVEL_60FPS / 7.0);
|
||||
(0..frames)
|
||||
.map(|_| {
|
||||
let g =
|
||||
grounded_step(&ms, &capsule, &filter, center, Vec3::X * 7.0, dt, 0.0);
|
||||
center = g.center;
|
||||
// Feet height relative to the street, the climb, and the ride latch.
|
||||
(center.y - CAPSULE_HEIGHT * 0.5, g.climb, g.cone_riding)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kerb_is_ridden_up_its_skirt_never_popped() {
|
||||
// Decision 1123, the whole point: a 0.28 yd kerb is inside the foot cone
|
||||
// ([`FOOT_CONE_HEIGHT`] ≈ 0.62), so the certified obstacle is *ridden* — a smooth diagonal
|
||||
// over the frames the gait needs — instead of the body being teleported onto the tread in
|
||||
// one frame. Both halves are asserted, because either alone is satisfiable by a bug: it
|
||||
// must arrive on the tread, AND no single frame may deliver the whole rise.
|
||||
let frames = walk_kerb(0.0, 4);
|
||||
let arrived = frames
|
||||
.iter()
|
||||
.position(|&(y, _, _)| (y - 0.28).abs() < 0.03)
|
||||
.expect("the ride should put the feet on the 0.28 yd tread");
|
||||
assert!(
|
||||
arrived > 0,
|
||||
"arriving on the very first frame is the pop, not a ride: {frames:?}"
|
||||
);
|
||||
assert!(
|
||||
frames[0].2,
|
||||
"the first frame is mid-ride and must report itself grounded: {frames:?}"
|
||||
);
|
||||
assert!(
|
||||
frames[0].0 > 0.0 && frames[0].0 < 0.28,
|
||||
"the first frame should be part-way up the skirt, got {:+.3}",
|
||||
frames[0].0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wall_is_never_ridden() {
|
||||
// The certification is the gate, not the height (a tall wall's contact point sits inside
|
||||
// the cone band too). Read the same geometry from a full kerb below — a 2.3 yd wall — and
|
||||
// nothing may rise: no ride, no climb, no lift, just the slide. This is the check that
|
||||
// stops the ride becoming a ladder up every cliff in the world.
|
||||
for (y, climb, riding) in walk_kerb(-2.0, 4) {
|
||||
assert!(!riding, "a wall must never start a cone ride");
|
||||
assert!(climb.is_none(), "a wall must never register a climb");
|
||||
assert!(
|
||||
y < -2.0 + 0.05,
|
||||
"the body must not rise against a wall, feet at {y:+.3}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cone_ride_gains_the_reference_slope() {
|
||||
// wow-re `climb-vs-slide.md` §4: `T = 1.8494 · cosθ · len`. Head-on, the gain is the cone's
|
||||
// own surface slope times the speed…
|
||||
let head_on = foot_cone_ride(Vec3::new(-1.0, 0.0, 0.0), Vec3::X * 7.0).unwrap();
|
||||
assert!(
|
||||
(head_on.y - 7.0 * STEP_SLOPE_RATIO).abs() < 1e-4,
|
||||
"{head_on:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
head_on.x, 7.0,
|
||||
"horizontal speed is never touched by a ride"
|
||||
);
|
||||
// …and meeting the same face at 60° gains exactly cos60° of it, which is the `cosθ` term
|
||||
// falling out of the closing-speed projection rather than being applied by hand.
|
||||
let oblique = foot_cone_ride(
|
||||
Vec3::new(-1.0, 0.0, 0.0),
|
||||
Vec3::new(7.0 * 0.5, 0.0, 7.0 * 0.866_025_4),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(oblique.y - 7.0 * 0.5 * STEP_SLOPE_RATIO).abs() < 1e-3,
|
||||
"{oblique:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cone_ride_declines_what_is_not_its_business() {
|
||||
let v = Vec3::X * 7.0;
|
||||
// A walkable face already had its ride ([`walkable_ride_velocity`])…
|
||||
assert!(foot_cone_ride(Vec3::new(-0.5, 0.866, 0.0).normalize(), v).is_none());
|
||||
// …an overhang is a ceiling, not a skirt…
|
||||
assert!(foot_cone_ride(Vec3::new(-0.5, -0.866, 0.0).normalize(), v).is_none());
|
||||
// …and a face we are moving away from is not in the way at all.
|
||||
assert!(foot_cone_ride(Vec3::new(1.0, 0.0, 0.0), v).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kerb_is_out_of_reach_of_one_frames_travel() {
|
||||
// The defect the capture pinned (decision 1121): the settle probe is still over the bevel
|
||||
// at 0.117 yd, so it lands on a 61° face and the walkable gate — correctly — refuses it.
|
||||
// The step-up is not wrong about the face; it never looked far enough to see the tread.
|
||||
let v = step_at(TRAVEL_60FPS);
|
||||
assert!(
|
||||
matches!(v, StepVerdict::SteepFloor { .. }),
|
||||
"one frame's travel should still be over the bevel, got {v}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_scaled_advance_climbs_the_kerb() {
|
||||
// …and a body radius ahead, the same probe is over the tread and commits onto it. `dy` is
|
||||
// the kerb's real height, so this pins that we land ON the sidewalk, not part-way up its
|
||||
// bevel.
|
||||
let v = step_at(STEP_UP_ADVANCE);
|
||||
let StepVerdict::Commit { dy, .. } = v else {
|
||||
panic!("a body-radius advance should reach the tread, got {v}");
|
||||
};
|
||||
assert!(
|
||||
(dy - 0.28).abs() < 0.03,
|
||||
"should land on the 0.28 yd tread, gained {dy:+.3}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_advance_never_climbs_past_the_rise_ceiling() {
|
||||
// The reach grew; what may be climbed did not (decision 1121). A wall taller than
|
||||
// [`STEP_UP_HEIGHT`] clips the elevated sweep, so the settle falls back to the origin
|
||||
// floor and the plain slide keeps the frame — the fence/trunk behaviour 0209 was built
|
||||
// for, asserted at the advance that made the kerb work.
|
||||
let v = world_with_kerb()
|
||||
.world_mut()
|
||||
.run_system_once(move |ms: MoveAndSlide| {
|
||||
let capsule = player_capsule();
|
||||
let filter = SpatialQueryFilter::default();
|
||||
let cast = |from: Vec3, disp: Vec3| {
|
||||
crate::collision::one_sided::cast_move(
|
||||
&ms, &capsule, from, disp, SKIN_WIDTH, &filter,
|
||||
)
|
||||
};
|
||||
// Stand the body a full kerb below the tread — the same geometry read as a 2.3 yd
|
||||
// wall by dropping the approach to y = −2.0, where the tread is far overhead.
|
||||
let start = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5 - 2.0, 0.0);
|
||||
let run = cast(start, Vec3::X).map_or(1.0, |h| h.distance);
|
||||
step_up(
|
||||
&cast,
|
||||
start + Vec3::X * run,
|
||||
Vec3::X,
|
||||
TRAVEL_60FPS,
|
||||
STEP_UP_ADVANCE,
|
||||
)
|
||||
.verdict
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
v.landed().is_none(),
|
||||
"a face above the rise ceiling must never commit, got {v}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Outward normal of a face rising toward +x, tilted `deg` from horizontal.
|
||||
fn face(deg: f32) -> Vec3 {
|
||||
|
||||
@@ -118,6 +118,47 @@ pub(super) const STEP_SNAP_SLACK: f32 = 0.027_777_8;
|
||||
/// deliberately NOT the reference's ~2 yd body-height budget, so fences (collision tops
|
||||
/// 1.8–2.3 yd) always slide. One number to nudge if a real spot feels too restrictive.
|
||||
pub(super) const STEP_UP_HEIGHT: f32 = 0.7;
|
||||
/// The step-up **certify advance** (yd): how far ahead the maneuver looks for the tread it would
|
||||
/// stand on. A property of the BODY, never of the frame (decision 1121).
|
||||
///
|
||||
/// 0209 advanced by this frame's own travel, which made every kerb in the game a frame-rate
|
||||
/// lottery: at 60 fps a run is 0.117 yd of travel, at 144 fps it is 0.049, and walking is 0.041 —
|
||||
/// while the capsule's own radius is [`CAPSULE_RADIUS`]. A settle probe that far forward is still
|
||||
/// over the riser, so it lands on the riser's face and the walkable gate rejects it. Measured at
|
||||
/// Stormwind's Trade District kerbs (decision 1121): the live 0.117 rung fails, 0.20 and beyond
|
||||
/// commit onto the tread, at every one of the seven captured contacts. Sidewalks 0.28 yd tall
|
||||
/// refused to be stepped onto because the probe never reached them.
|
||||
///
|
||||
/// A body radius is the principled value: it is exactly the distance at which the capsule's
|
||||
/// footprint has cleared the lip it is standing against, and it is the same number whatever the
|
||||
/// frame rate or the gait. The frame's travel still wins when it is *longer* (a very low frame
|
||||
/// rate must not step you less far than you asked to walk), so this is a floor, not a fixed reach.
|
||||
///
|
||||
/// It does not widen what may be climbed: the rise ceiling is still [`STEP_UP_HEIGHT`], the settle
|
||||
/// must still find a *walkable* floor higher than the feet, and the elevated forward sweep is still
|
||||
/// clipped by anything in the way — so a fence, a trunk and a two-trunk pinch all still block at
|
||||
/// the same body height they did before, with a clipped advance and a net-zero settle.
|
||||
pub(super) const STEP_UP_ADVANCE: f32 = CAPSULE_RADIUS;
|
||||
/// The **foot cone's height** (yd): how far above the feet the reference's movement solid is still
|
||||
/// narrower than its full radius — and therefore the band within which a blocking edge is *ridden
|
||||
/// up* rather than stepped onto (decision 1123).
|
||||
///
|
||||
/// The real client's movement solid is not a capsule. Its lower half is a **cone**: the k-DOP build
|
||||
/// at `0x631440` emits 9 planes — 4 vertical box sides at `center.xy ± radius`, and 4 bevels running
|
||||
/// from a point at the foot out to `(pos.xy ± radius, pos.z + r')` with `r' = radius · 1.8493990`
|
||||
/// (`[0x80c740]` — our [`STEP_SLOPE_RATIO`]), above which it is a plain vertical box. There is no
|
||||
/// −Z plane: the solid is open at the bottom (wow-re `climb-vs-slide.md` §2).
|
||||
///
|
||||
/// So a *low* edge never meets a wall — it meets the slanted skirt, and the resolver's ordinary
|
||||
/// slide carries the body up it at the cone's own surface slope, atan 1.8494 ≈ 61.6°, gaining
|
||||
/// `1.8494 · cosθ · len` per step (the note's `T` at §4). Only an edge **above** this height meets
|
||||
/// the vertical box square, and that is the case the instant step-up ([`STEP_UP_HEIGHT`]) exists
|
||||
/// for. One solid, two behaviours, selected by the edge's height above the feet.
|
||||
///
|
||||
/// Not a new tunable: it is our own two constants multiplied, and it reproduces the reference's
|
||||
/// ≈0.62 yd exactly, because [`CAPSULE_RADIUS`] is already the reference's own mover radius of 1/3
|
||||
/// (`climb-vs-slide.md` §2 names it).
|
||||
pub(super) const FOOT_CONE_HEIGHT: f32 = CAPSULE_RADIUS * STEP_SLOPE_RATIO;
|
||||
/// The landing probe (yd): while airborne, walk mode resumes only this close to the floor, so
|
||||
/// the arc ends where the slide actually contacts (skin scale) instead of [`GROUND_PROBE`]
|
||||
/// early — which cut the last ~0.2 yd of every fall into a same-frame snap, the visible pop at
|
||||
@@ -467,6 +508,13 @@ pub(crate) struct Player {
|
||||
pub(super) wedged: bool,
|
||||
/// Consecutive stalled airborne frames (see [`WEDGE_STALL_RATIO`]).
|
||||
pub(super) wedge_still: u8,
|
||||
/// Part-way up a **foot-cone ride** — the body is resting on the slanted skirt of a low edge it
|
||||
/// has already certified it can clear, climbing it over the frames the gait needs (decision
|
||||
/// 1123). Counts as standing: the frame-start ground probe sees only the steep riser under the
|
||||
/// feet, so without this the mid-ride frame reads as airborne and gravity undoes the climb —
|
||||
/// the mid-face dwelling 0209 was built to make impossible. Re-earned from the certification
|
||||
/// every frame (no certification, no ride), and cleared the moment a walkable floor takes over.
|
||||
pub(super) cone_riding: bool,
|
||||
/// The take-off vertical speed (yd/s, WoW +Z up) snapshotted when the airborne phase began — the
|
||||
/// client's `StartFalling` argument (`+0xa0`, constant per arc) and the `zspeed` we send in the
|
||||
/// jump tail: `JUMP_SPEED` for a jump, **exactly 0** for a step-off (the walk election calls
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! **The step-up probe** — the instrument that answers "why won't it climb *this* kerb?".
|
||||
//!
|
||||
//! The symptom is always the same from outside the window: you walk into something small, the
|
||||
//! avatar keeps running on the spot, and nothing in the ordinary trace says why — the atomic
|
||||
//! step-up ([`super::mover::step_up`]) declined, and one of six different reasons is the truth.
|
||||
//! Reasoning cannot pick between them (0209's whole history is sessions that tried), and neither
|
||||
//! can a screenshot, so this measures instead:
|
||||
//!
|
||||
//! 1. **The blocked frame is detected, not hunted.** A grounded walk frame whose achieved
|
||||
//! horizontal displacement is a fraction of what the input asked for *is* the report. No
|
||||
//! keybind to remember, no guessing which of the director's frames were the bad ones.
|
||||
//! 2. **The surface profile ahead is measured** with one-sided down rays at a ladder of forward
|
||||
//! offsets: how tall the thing actually is, whether it has a walkable top at all, and — the
|
||||
//! reason the rays are one-sided like the mover's own probes — whether that top is even
|
||||
//! *visible* to a downward sweep under the 0970 facing law.
|
||||
//! 3. **The maneuver is re-run at a ladder of advances.** The live step-up advances by this
|
||||
//! frame's own travel (0209), which is 12 cm at 60 fps and 3 cm at 240 — while the capsule
|
||||
//! radius is [`super::CAPSULE_RADIUS`]. If a rung further out commits where the live one did
|
||||
//! not, the advance is the defect; if *every* rung says `NO-FLOOR`, the geometry is; if every
|
||||
//! rung says `STEEP-FLOOR`, the walkable gate is. One line, one verdict.
|
||||
//! 4. **The candidate faces are dumped** ([`crate::collision::one_sided::faces_near`]), with the
|
||||
//! facing gate's own answer per face, when the trace asks for them.
|
||||
//!
|
||||
//! Output goes two places: the `stup` tag of `WOW_MOVE_TRACE` (the record we read afterwards) and
|
||||
//! [`latest`], a small ring the debug panel renders live — so the director can *see* the probe
|
||||
//! fire at the spot they meant, which is what makes a capture worth reading (method §6).
|
||||
//!
|
||||
//! Cost: nothing at all on a frame that moves. On a blocked one it is rate-limited to
|
||||
//! [`REPORT_HZ`] reports a second, each a handful of shape casts and rays — so leaning on a wall
|
||||
//! forever cannot turn into a frame-rate story of its own.
|
||||
|
||||
use avian3d::character_controller::move_and_slide::MoveAndSlide;
|
||||
use avian3d::prelude::*;
|
||||
use bevy::prelude::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::mover::{step_up, StepVerdict};
|
||||
use super::{CAPSULE_HEIGHT, CAPSULE_RADIUS, SKIN_WIDTH, STEP_UP_HEIGHT};
|
||||
|
||||
/// A walk frame counts as blocked when it achieved less than this share of the horizontal distance
|
||||
/// the input asked for. A square push into a wall achieves ~0; a legitimate slide along one at 45°
|
||||
/// still achieves ~70%, and a walkable-slope ride achieves 100% by construction (decision 0220) —
|
||||
/// so the band below this is "went nowhere", not "was deflected".
|
||||
const BLOCKED_SHARE: f32 = 0.35;
|
||||
|
||||
/// Reports per second while blocked. Enough to catch a bump-and-retry, cheap enough to lean on.
|
||||
const REPORT_HZ: f32 = 5.0;
|
||||
|
||||
/// Forward offsets the surface profile is sampled at (yd). The last two are past anything the
|
||||
/// maneuver could reach — they say what the geometry *is*, which is how a "there is no top surface
|
||||
/// here" reads differently from "the top is 0.9 yd away".
|
||||
const PROFILE: [f32; 8] = [0.0, 0.1, 0.2, 0.35, 0.5, 0.7, 1.0, 1.4];
|
||||
|
||||
/// Advances the maneuver is re-run at (yd). The live rung — this frame's own travel — is prepended
|
||||
/// at report time, so the table always opens with what actually happened.
|
||||
const LADDER: [f32; 6] = [0.1, 0.2, CAPSULE_RADIUS, 0.5, 0.8, 1.2];
|
||||
|
||||
/// How many candidate faces the geometry dump prints, nearest first.
|
||||
const FACE_DUMP: usize = 24;
|
||||
|
||||
/// The probe's own state: the rate limiter, and the last report for the panel to render.
|
||||
struct Probe {
|
||||
/// App-elapsed seconds of the last report; `f32::MIN` before the first.
|
||||
last: f32,
|
||||
/// The most recent report, one string per line — what [`latest`] hands the debug panel.
|
||||
report: Vec<String>,
|
||||
/// App-elapsed seconds the report was taken, so the panel can grey out a stale one.
|
||||
at: f32,
|
||||
}
|
||||
|
||||
static PROBE: Mutex<Probe> = Mutex::new(Probe {
|
||||
last: f32::MIN,
|
||||
report: Vec::new(),
|
||||
at: f32::MIN,
|
||||
});
|
||||
|
||||
/// The last blocked report (lines) and the app-elapsed time it was taken — the debug panel's read.
|
||||
pub(crate) fn latest() -> (Vec<String>, f32) {
|
||||
PROBE
|
||||
.lock()
|
||||
.map(|p| (p.report.clone(), p.at))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Watch one **local** grounded walk frame, and report it if the body went nowhere.
|
||||
///
|
||||
/// `from`/`to` are the capsule centre either side of [`super::mover::grounded_step`] — the walk
|
||||
/// resolve alone, before the hover climb and the water-walk clamp, both of which move the body for
|
||||
/// reasons that have nothing to do with a kerb.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn watch(
|
||||
ms: &MoveAndSlide<'_, '_>,
|
||||
capsule: &Collider,
|
||||
filter: &SpatialQueryFilter,
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
horiz_vel: Vec3,
|
||||
dt: f32,
|
||||
now: f32,
|
||||
) {
|
||||
let speed = horiz_vel.length();
|
||||
let wanted = speed * dt;
|
||||
// Below a millimetre of intent there is no "blocked" to speak of — standing still, or a frame
|
||||
// so short that every ratio is noise.
|
||||
if wanted < 1.0e-3 {
|
||||
return;
|
||||
}
|
||||
let d = to - from;
|
||||
let got = d.x.hypot(d.z);
|
||||
if got >= wanted * BLOCKED_SHARE {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let Ok(mut probe) = PROBE.lock() else { return };
|
||||
if now - probe.last < 1.0 / REPORT_HZ {
|
||||
return;
|
||||
}
|
||||
probe.last = now;
|
||||
}
|
||||
|
||||
let dir_h = horiz_vel / speed;
|
||||
let feet_y = from.y - CAPSULE_HEIGHT * 0.5;
|
||||
let cast = |c: Vec3, disp: Vec3| {
|
||||
crate::collision::one_sided::cast_move(ms, capsule, c, disp, SKIN_WIDTH, filter)
|
||||
};
|
||||
|
||||
// The spot is stamped in **WoW** coordinates as well as Bevy's: every other probe, every `.go
|
||||
// xyz`, and the director's own `/gps` speak that frame, so a blocked report is walkable-back-to
|
||||
// without anyone converting by hand. (Bevy's are what the rest of this report's geometry is in,
|
||||
// so both are on the line.)
|
||||
let feet_wow = benilla_assets::coords::bevy_to_wow(Vec3::new(from.x, feet_y, from.z));
|
||||
let mut lines = Vec::with_capacity(5);
|
||||
lines.push(format!(
|
||||
"BLOCKED wow ({:9.2},{:9.2},{:7.2}) bevy ({:9.2},{:7.2},{:9.2}) dir({:+.2},{:+.2}) \
|
||||
sp {speed:.2} dt {dt:.4} want {wanted:.3} got {got:.3} ({:.0}%) dy {:+.3}",
|
||||
feet_wow[0],
|
||||
feet_wow[1],
|
||||
feet_wow[2],
|
||||
from.x,
|
||||
from.y,
|
||||
from.z,
|
||||
dir_h.x,
|
||||
dir_h.z,
|
||||
100.0 * got / wanted,
|
||||
d.y,
|
||||
));
|
||||
|
||||
// What we are pressed against — a look-ahead a full radius deep, so the face is found even
|
||||
// when this frame's own travel is a centimetre and the live maneuver never saw it.
|
||||
let wall = cast(from, dir_h * CAPSULE_RADIUS);
|
||||
lines.push(match &wall {
|
||||
None => format!(" wall none within {CAPSULE_RADIUS:.2} yd ahead"),
|
||||
Some(h) => format!(
|
||||
" wall d={:.3} n=({:+.2},{:+.2},{:+.2}) contact ({:8.2},{:7.2},{:8.2}) h={:+.2} {:?}",
|
||||
h.distance,
|
||||
h.normal1.x,
|
||||
h.normal1.y,
|
||||
h.normal1.z,
|
||||
h.point1.x,
|
||||
h.point1.y,
|
||||
h.point1.z,
|
||||
h.point1.y - feet_y,
|
||||
h.entity,
|
||||
),
|
||||
});
|
||||
|
||||
// The surface profile ahead: one-sided down rays from above the capsule, reported as height
|
||||
// above the feet. `miss` on a rung with solid geometry under it is the facing law rejecting a
|
||||
// top face — the reading no shape cast can give, because a sweep that finds nothing and a
|
||||
// sweep that finds a backface are the same `None`.
|
||||
let eye = from + Vec3::Y * STEP_UP_HEIGHT;
|
||||
let ray_len = STEP_UP_HEIGHT + CAPSULE_HEIGHT * 0.5 + 1.0;
|
||||
let profile: Vec<String> = PROFILE
|
||||
.iter()
|
||||
.map(|&o| {
|
||||
match crate::collision::one_sided::cast_ray(
|
||||
ms,
|
||||
eye + dir_h * o,
|
||||
Dir3::NEG_Y,
|
||||
ray_len,
|
||||
filter,
|
||||
) {
|
||||
None => format!("{o:+.2}:miss"),
|
||||
Some(h) => format!(
|
||||
"{o:+.2}:{:+.2}/{:+.2}",
|
||||
eye.y - h.distance - feet_y,
|
||||
h.normal.y
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
lines.push(format!(" ahead {}", profile.join(" ")));
|
||||
|
||||
// The ladder: the same maneuver, at advances the live one never tries. The live rung first.
|
||||
let rungs: Vec<String> = std::iter::once(wanted)
|
||||
.chain(LADDER)
|
||||
.map(|adv| {
|
||||
let v = step_up(&cast, from, dir_h, adv.max(wanted), adv).verdict;
|
||||
// `fwd` — how far the elevated sweep ACTUALLY got — is on every rung, because without
|
||||
// it a failing far rung is two different stories: "we advanced that far and the floor
|
||||
// there is steep" and "something at head height stopped us short of it".
|
||||
let tag = match v {
|
||||
StepVerdict::NoFace => "no-face".to_string(),
|
||||
StepVerdict::NoHeadroom => "NO-HEADROOM".to_string(),
|
||||
StepVerdict::NoFloor { fwd, .. } => format!("NO-FLOOR fwd{fwd:.2}"),
|
||||
StepVerdict::SteepFloor { fwd, ny, .. } => format!("STEEP fwd{fwd:.2} ny{ny:+.2}"),
|
||||
StepVerdict::NetZero { fwd, dy, .. } => format!("net-zero fwd{fwd:.2} dy{dy:+.3}"),
|
||||
StepVerdict::Commit { fwd, dy, .. } => format!("COMMIT fwd{fwd:.2} dy{dy:+.3}"),
|
||||
};
|
||||
format!("{adv:.2}:{tag}")
|
||||
})
|
||||
.collect();
|
||||
lines.push(format!(" ladder {}", rungs.join(" | ")));
|
||||
|
||||
// The faces themselves, when the trace asks. Kept off the panel and off an untraced run: it is
|
||||
// two dozen lines, and it is the answer only when the three readings above disagree.
|
||||
let mut faces = Vec::new();
|
||||
if crate::dbg_trace::enabled_for("stup") {
|
||||
let at = from + dir_h * CAPSULE_RADIUS;
|
||||
let half = Vec3::new(1.0, CAPSULE_HEIGHT * 0.5 + STEP_UP_HEIGHT, 1.0);
|
||||
for f in crate::collision::one_sided::faces_near(ms, at, half, filter, FACE_DUMP) {
|
||||
let c = f.centroid();
|
||||
faces.push(format!(
|
||||
" face n=({:+.2},{:+.2},{:+.2}) mid ({:8.2},{:7.2},{:8.2}) h={:+.2} \
|
||||
down={} fwd={} {:?}",
|
||||
f.normal.x,
|
||||
f.normal.y,
|
||||
f.normal.z,
|
||||
c.x,
|
||||
c.y,
|
||||
c.z,
|
||||
c.y - feet_y,
|
||||
if f.blocks(Vec3::NEG_Y) {
|
||||
"block"
|
||||
} else {
|
||||
"PASS "
|
||||
},
|
||||
if f.blocks(dir_h) { "block" } else { "PASS " },
|
||||
f.entity,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for l in lines.iter().chain(&faces) {
|
||||
crate::dbg_trace::line("stup", l);
|
||||
}
|
||||
if let Ok(mut probe) = PROBE.lock() {
|
||||
probe.report = lines;
|
||||
probe.at = now;
|
||||
}
|
||||
}
|
||||
@@ -43,27 +43,32 @@ pub(super) fn world_right_click_payload(
|
||||
script.clear_cursor_payload();
|
||||
}
|
||||
|
||||
/// On a clean left-click (a [`WorldClick`], never a drag), select whatever unit is [`Hovered`] and
|
||||
/// inform the server; a click on empty ground / a non-unit clears the target — except a click on
|
||||
/// NOTHING (sky — no occlusion-ray hit) while a payload is held: the reference's nothing-leg
|
||||
/// deselect is no-payload-gated (`0x492d30`'s local flag test), while the terrain leg deselects
|
||||
/// regardless of a surviving spell/action payload (`0x5e03bb` — decisions 0571 + 0574). Skipped
|
||||
/// while the inspector is armed (left-click is its copy affordance).
|
||||
/// On a [`WorldClick`], select the unit the **press** was over ([`PressPick`]) and inform the
|
||||
/// server; a click on empty ground / a non-unit clears the target — except a click on NOTHING (sky
|
||||
/// — no occlusion-ray hit) while a payload is held: the reference's nothing-leg deselect is
|
||||
/// no-payload-gated (`0x492d30`'s local flag test), while the terrain leg deselects regardless of a
|
||||
/// surviving spell/action payload (`0x5e03bb` — decisions 0571 + 0574). Skipped while the inspector
|
||||
/// is armed (left-click is its copy affordance).
|
||||
///
|
||||
/// **The press pick, not the live hover** (decision 1122). A click may now arrive at the end of a
|
||||
/// gesture that orbited the camera, and the live hover is empty by then — `update_hover` clears it
|
||||
/// for the whole look session, as the reference suppresses its own hover during freelook. Reading
|
||||
/// the live hover here would take the `_ =>` arm below and *clear* the player's target on every
|
||||
/// drag. The reference picks once, on the down edge, and this consumes that same latch.
|
||||
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
|
||||
pub(super) fn select_on_click(
|
||||
mut clicks: MessageReader<WorldClick>,
|
||||
inspect: Res<InspectMode>,
|
||||
hovered: Res<Hovered>,
|
||||
cursor: Res<WorldCursor>,
|
||||
press: Res<PressPick>,
|
||||
mut selection: ResMut<Selection>,
|
||||
mut seam: crate::creature_anim::AttackSeam,
|
||||
self_q: Query<(&Guid, Has<Engaged>), With<SelfPlayer>>,
|
||||
payload_held: Res<crate::ui_script::CursorPayloadHeld>,
|
||||
occlusion: Res<PickOcclusion>,
|
||||
mut greeting: MessageWriter<crate::sound::NpcGreetingRequest>,
|
||||
ground: Res<crate::ui_action::SpellTargeting>,
|
||||
click_cfg: Res<ClickConfig>,
|
||||
) {
|
||||
let (hovered, occlusion) = (press.hovered, press.occlusion);
|
||||
// Drain the frame's clicks; act only if there was one and the inspector isn't holding left-click.
|
||||
let clicked = clicks.read().last().is_some();
|
||||
if !clicked || inspect.enabled {
|
||||
@@ -100,7 +105,7 @@ pub(super) fn select_on_click(
|
||||
guid,
|
||||
engaged,
|
||||
self_guid,
|
||||
cursor.kind == cursor_mode::CursorKind::Attack,
|
||||
press.attack,
|
||||
);
|
||||
}
|
||||
// Clicked nothing targetable → deselect (only sends the clear if we actually had a
|
||||
|
||||
@@ -94,7 +94,7 @@ pub(crate) struct Selection {
|
||||
/// The unit under the cursor this frame (the net entity + its guid), or `None`. Recomputed every frame
|
||||
/// by [`hover::update_hover`] (plate rects first, then the posed-mesh pick); a click selects it, and
|
||||
/// the mouseover highlight — the model emissive + the plate glow — reads it.
|
||||
#[derive(Resource, Default)]
|
||||
#[derive(Resource, Default, Clone, Copy)]
|
||||
pub(crate) struct Hovered {
|
||||
pub(crate) target: Option<Entity>,
|
||||
pub(crate) guid: Option<u64>,
|
||||
@@ -109,7 +109,7 @@ pub(crate) struct Hovered {
|
||||
/// *usable but never selected*. Filled by [`hover::update_hovered_object`] (a GO-only mesh-ray pick);
|
||||
/// read by the cursor classifier (the Interact gear) and the right-click USE action. Both compose it
|
||||
/// with the unit hover by [`go_is_nearest`] — a GO acts only when it is the nearer object.
|
||||
#[derive(Resource, Default)]
|
||||
#[derive(Resource, Default, Clone, Copy)]
|
||||
pub(crate) struct HoveredObject {
|
||||
pub(crate) target: Option<Entity>,
|
||||
pub(crate) guid: Option<u64>,
|
||||
@@ -124,7 +124,7 @@ pub(crate) struct HoveredObject {
|
||||
/// and discards the object hit iff the world hit is *strictly* nearer — a unit or GameObject
|
||||
/// behind a wall is not hoverable. Written by [`hover::update_pick_occlusion`] at the head of the
|
||||
/// target chain; both picks post-compare their final hit against it.
|
||||
#[derive(Resource)]
|
||||
#[derive(Resource, Clone, Copy)]
|
||||
pub(crate) struct PickOcclusion {
|
||||
pub(crate) distance: f32,
|
||||
/// The hit itself — the world-space point `distance` names along the cursor ray, or `None`
|
||||
@@ -142,6 +142,70 @@ impl Default for PickOcclusion {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The pick latched on the button DOWN edge** — what a world click acts on, frozen at the press.
|
||||
///
|
||||
/// The reference picks **exactly once per press** and never again for that gesture: `0x481f00` has
|
||||
/// one caller image-wide, on the down edge (wow-re `world-click-drag-arbitration.md` §2), and it
|
||||
/// writes the whole result into the WorldFrame — pick state `+0x350`, guid `+0x358`, hit point
|
||||
/// `+0x360`, distance `+0x36c`. Nothing re-picks on move or on release, so the release acts on what
|
||||
/// the **press** was over. That single fact is what lets one gesture orbit the camera *and* select:
|
||||
/// the mouse may travel anywhere in between, and the latched pick is untouched.
|
||||
///
|
||||
/// benilla had no latch — every click consumer read the *live* hover, which
|
||||
/// [`hover::update_hover`] clears outright the moment a look session engages. So the release found
|
||||
/// an empty hover and [`click::select_on_click`] fell into its deselect arm: a drag didn't merely
|
||||
/// fail to select, it would have **cleared** the target (decision 1122, ledger B226).
|
||||
///
|
||||
/// Note this is a *different* thing from the hover highlight, which the reference **does** suppress
|
||||
/// during freelook (`0x483e80` sets the WorldFrame cursor-suppress bit `[wf+0x38c]&2`, tested by the
|
||||
/// hover classifier `0x4828d0`). Tooltip, brighten and cursor go quiet while you orbit; the pending
|
||||
/// click's subject does not. Conflating those two was the root cause.
|
||||
///
|
||||
/// Written by [`latch_press_pick`] at the head of the target chain, which runs **before**
|
||||
/// [`hover::update_hover`] can clear anything, so the values it copies are the pick as of the press.
|
||||
#[derive(Resource, Default, Clone, Copy)]
|
||||
pub(crate) struct PressPick {
|
||||
pub(crate) hovered: Hovered,
|
||||
pub(crate) object: HoveredObject,
|
||||
pub(crate) occlusion: PickOcclusion,
|
||||
/// The cursor's Attack classification at the press — the reference's new-target validation
|
||||
/// (`0x5ecb70`), which likewise reads the pick the gesture started on, not a live one.
|
||||
pub(crate) attack: bool,
|
||||
}
|
||||
|
||||
/// Latch the frame's pick as a press begins — the [`PressPick`] writer.
|
||||
///
|
||||
/// Ordered at the **head** of the target chain, ahead of every pick refresh, for a reason that is
|
||||
/// easy to lose: `player::control` (input) has already run this frame, so on the press frame the
|
||||
/// look session is *already* engaged and [`hover::update_hover`] — two systems below — is about to
|
||||
/// clear the hover. The values still standing here are last frame's, computed at the same cursor
|
||||
/// position the press landed on, because the cursor cannot have moved between the two. That is the
|
||||
/// press-time pick, and it is the last moment it exists.
|
||||
pub(crate) fn latch_press_pick(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
hovered: Res<Hovered>,
|
||||
object: Res<HoveredObject>,
|
||||
occlusion: Res<PickOcclusion>,
|
||||
cursor: Res<cursor_mode::WorldCursor>,
|
||||
mut press: ResMut<PressPick>,
|
||||
) {
|
||||
// Either primary button's down edge arms a pick, exactly as the reference's `0x514810` does for
|
||||
// clickMode 1 (CameraOrSelectOrMove) and 2 (TurnOrAction). A chord — the second button of a
|
||||
// both-button run — must NOT re-latch: `0x51481a` refuses to arm while another primary is held,
|
||||
// and the arbiter cancels the pending click outright anyway.
|
||||
let left = buttons.just_pressed(MouseButton::Left) && !buttons.pressed(MouseButton::Right);
|
||||
let right = buttons.just_pressed(MouseButton::Right) && !buttons.pressed(MouseButton::Left);
|
||||
if !(left || right) {
|
||||
return;
|
||||
}
|
||||
*press = PressPick {
|
||||
hovered: *hovered,
|
||||
object: *object,
|
||||
occlusion: *occlusion,
|
||||
attack: cursor.kind == cursor_mode::CursorKind::Attack,
|
||||
};
|
||||
}
|
||||
|
||||
/// Which of the two hovered CGObjects — the unit [`Hovered`] or the [`HoveredObject`] — is the
|
||||
/// nearer thing under the cursor, and so the one a click acts on. The reference makes one pick over
|
||||
/// all CGObjects and switches on type; benilla runs the two picks separately, then chooses the
|
||||
@@ -200,6 +264,7 @@ impl Plugin for TargetPlugin {
|
||||
.init_resource::<Hovered>()
|
||||
.init_resource::<HoveredObject>()
|
||||
.init_resource::<PickOcclusion>()
|
||||
.init_resource::<PressPick>()
|
||||
.init_resource::<WorldCursor>()
|
||||
.init_resource::<CombatFlash>()
|
||||
.init_resource::<scan::TabHistory>()
|
||||
@@ -223,7 +288,10 @@ impl Plugin for TargetPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
hover::update_pick_occlusion,
|
||||
// Grouped as one element (the outer chain is at Bevy's 20-tuple limit), and
|
||||
// ordered: the latch FIRST, ahead of every pick refresh, to freeze what the
|
||||
// press was over while it still exists (decision 1122 — see [`latch_press_pick`]).
|
||||
(latch_press_pick, hover::update_pick_occlusion).chain(),
|
||||
hover::update_hover,
|
||||
hover::update_hovered_object,
|
||||
cursor_mode::classify_cursor,
|
||||
@@ -319,4 +387,84 @@ mod tests {
|
||||
assert!(!go_is_nearest(&hov(true, 4.0), &obj(true, 10.0)));
|
||||
assert!(!go_is_nearest(&hov(true, 5.0), &obj(true, 5.0)));
|
||||
}
|
||||
|
||||
/// The latch survives the hover being cleared — which is the whole reason it exists.
|
||||
///
|
||||
/// A drag engages the look session, and `hover::update_hover` then blanks [`Hovered`] for its
|
||||
/// whole duration (as the reference suppresses its own hover during freelook). Before decision
|
||||
/// 1122 every click consumer read that live hover, so a click ending a drag saw *nothing*
|
||||
/// hovered and [`click::select_on_click`] took its deselect arm: the gesture would have cleared
|
||||
/// the player's target rather than selecting. The press latch is what a click reads instead.
|
||||
#[test]
|
||||
fn the_press_latch_outlives_the_hover_a_drag_clears() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<Hovered>();
|
||||
world.init_resource::<HoveredObject>();
|
||||
world.init_resource::<PickOcclusion>();
|
||||
world.init_resource::<cursor_mode::WorldCursor>();
|
||||
world.init_resource::<PressPick>();
|
||||
world.init_resource::<ButtonInput<MouseButton>>();
|
||||
let id = world.register_system(latch_press_pick);
|
||||
|
||||
// A boar under the cursor, and the cursor classified as Attack over it.
|
||||
const BOAR: u64 = 0xB0A2;
|
||||
world.resource_mut::<Hovered>().guid = Some(BOAR);
|
||||
world.resource_mut::<cursor_mode::WorldCursor>().kind = cursor_mode::CursorKind::Attack;
|
||||
|
||||
// Press left. The latch takes the pick.
|
||||
world
|
||||
.resource_mut::<ButtonInput<MouseButton>>()
|
||||
.press(MouseButton::Left);
|
||||
world.run_system(id).unwrap();
|
||||
assert_eq!(world.resource::<PressPick>().hovered.guid, Some(BOAR));
|
||||
assert!(world.resource::<PressPick>().attack, "Attack rode along");
|
||||
|
||||
// The drag begins: the look session blanks the live hover, every frame, for as long as it
|
||||
// lasts. The latch must not follow it down.
|
||||
*world.resource_mut::<Hovered>() = Hovered::default();
|
||||
world.resource_mut::<ButtonInput<MouseButton>>().clear();
|
||||
world.run_system(id).unwrap();
|
||||
assert_eq!(
|
||||
world.resource::<PressPick>().hovered.guid,
|
||||
Some(BOAR),
|
||||
"the release must still know what the press was over"
|
||||
);
|
||||
}
|
||||
|
||||
/// A chord — the second primary joining a held one — must not re-latch. The reference refuses
|
||||
/// to arm or pick while another primary is down (`0x51481a`), and kills the pending click
|
||||
/// outright (`0x514ac1`), so neither release of a both-button run can select. Re-latching here
|
||||
/// would hand a spurious pick to a gesture that is only ever a camera turn.
|
||||
#[test]
|
||||
fn a_chord_does_not_relatch_the_pick() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<Hovered>();
|
||||
world.init_resource::<HoveredObject>();
|
||||
world.init_resource::<PickOcclusion>();
|
||||
world.init_resource::<cursor_mode::WorldCursor>();
|
||||
world.init_resource::<PressPick>();
|
||||
world.init_resource::<ButtonInput<MouseButton>>();
|
||||
let id = world.register_system(latch_press_pick);
|
||||
|
||||
const FIRST: u64 = 0x1111;
|
||||
const SECOND: u64 = 0x2222;
|
||||
world.resource_mut::<Hovered>().guid = Some(FIRST);
|
||||
world
|
||||
.resource_mut::<ButtonInput<MouseButton>>()
|
||||
.press(MouseButton::Left);
|
||||
world.run_system(id).unwrap();
|
||||
assert_eq!(world.resource::<PressPick>().hovered.guid, Some(FIRST));
|
||||
|
||||
// Right joins while left is still held, over something else entirely.
|
||||
world.resource_mut::<Hovered>().guid = Some(SECOND);
|
||||
let mut buttons = world.resource_mut::<ButtonInput<MouseButton>>();
|
||||
buttons.clear(); // drop last frame's just_pressed, keep `pressed`
|
||||
buttons.press(MouseButton::Right);
|
||||
world.run_system(id).unwrap();
|
||||
assert_eq!(
|
||||
world.resource::<PressPick>().hovered.guid,
|
||||
Some(FIRST),
|
||||
"the chord's second press must not steal the latch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub(crate) fn drop_item_on_unit(
|
||||
index: Res<crate::net::GuidIndex>,
|
||||
pet_bar: Res<crate::ui_pet::PetBar>,
|
||||
learned: Res<super::LearnedAbilities>,
|
||||
hovered: Res<crate::target::Hovered>,
|
||||
press: Res<crate::target::PressPick>,
|
||||
mut clicks: MessageReader<crate::interact::WorldClick>,
|
||||
mut ladder: super::CastLadder,
|
||||
) {
|
||||
@@ -81,9 +81,11 @@ pub(crate) fn drop_item_on_unit(
|
||||
clicks.clear();
|
||||
return;
|
||||
};
|
||||
let hovered = press.hovered;
|
||||
let mut tokens = script.take_drop_item_on_unit();
|
||||
// The world entry: a left click whose pick IS the pet. `Hovered` is the same nearest-object
|
||||
// pick the click itself selects from, so cursor and click agree by construction.
|
||||
// The world entry: a left click whose pick IS the pet. The click's own press latch is the same
|
||||
// nearest-object pick it selects from, so cursor and click agree by construction (1122: the
|
||||
// *live* hover no longer does, since a click can end a gesture that orbited).
|
||||
let clicked_pet = clicks.read().count() > 0
|
||||
&& pet_bar.spells.pet_guid != 0
|
||||
&& hovered.guid == Some(pet_bar.spells.pet_guid);
|
||||
|
||||
@@ -22,7 +22,9 @@ use bevy::prelude::*;
|
||||
use benilla_assets::coords::bevy_to_wow;
|
||||
|
||||
use crate::interact::WorldClick;
|
||||
use crate::target::{go_is_nearest, Hovered, HoveredObject, PickOcclusion};
|
||||
use crate::target::go_is_nearest;
|
||||
#[cfg(test)]
|
||||
use crate::target::{Hovered, HoveredObject};
|
||||
use crate::ui_action::cast_send::TargetedBind;
|
||||
|
||||
use super::TargetingWants;
|
||||
@@ -41,9 +43,12 @@ use super::TargetingWants;
|
||||
/// so the commit that clears it must come later in the same frame.
|
||||
pub(crate) fn commit_ground_cast_on_click(
|
||||
mut clicks: MessageReader<WorldClick>,
|
||||
occlusion: Res<PickOcclusion>,
|
||||
// The point the PRESS ray hit, not this frame's — the reference's `+0x360`, written by the one
|
||||
// down-edge pick and read unchanged at the release (decision 1122).
|
||||
press: Res<crate::target::PressPick>,
|
||||
mut ladder: crate::ui_action::CastLadder,
|
||||
) {
|
||||
let occlusion = press.occlusion;
|
||||
if !ladder.ground.active() {
|
||||
// Keep the reader current so a click buffered while idle can never replay as a commit
|
||||
// the frame the mode turns on.
|
||||
@@ -106,10 +111,12 @@ pub(crate) fn commit_ground_cast_on_click(
|
||||
/// Runs AFTER `select_on_click`, like the terrain commit and for the same reason.
|
||||
pub(crate) fn commit_object_cast_on_click(
|
||||
mut clicks: MessageReader<WorldClick>,
|
||||
hovered: Res<Hovered>,
|
||||
hovered_object: Res<HoveredObject>,
|
||||
// The press's pick, as in the terrain leg — the object a gesture binds is the one it started
|
||||
// on, whatever the mouse did after (decision 1122).
|
||||
press: Res<crate::target::PressPick>,
|
||||
mut ladder: crate::ui_action::CastLadder,
|
||||
) {
|
||||
let (hovered, hovered_object) = (press.hovered, press.object);
|
||||
if !ladder.ground.active() {
|
||||
// Reader hygiene, as in the terrain leg: a click buffered while idle must not replay as a
|
||||
// commit the frame the mode turns on.
|
||||
@@ -168,9 +175,8 @@ mod tests {
|
||||
world.init_resource::<super::super::SpellTargeting>();
|
||||
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
|
||||
world.init_resource::<Messages<WorldClick>>();
|
||||
world.init_resource::<Hovered>();
|
||||
world.init_resource::<HoveredObject>();
|
||||
world.init_resource::<PickOcclusion>();
|
||||
// The commit legs read the PRESS latch now (decision 1122), not the live hover.
|
||||
world.init_resource::<crate::target::PressPick>();
|
||||
// REGISTERED, not `run_system_once`: this seam's reader hygiene is a property of state
|
||||
// that survives between frames, and a fresh system per call would start every read at
|
||||
// cursor 0 and hide exactly the bug the drain exists to prevent.
|
||||
@@ -178,13 +184,14 @@ mod tests {
|
||||
(world, rx, id)
|
||||
}
|
||||
|
||||
/// Put a GameObject under the cursor at `distance`, nearer than any unit.
|
||||
/// Put a GameObject under the cursor at `distance`, nearer than any unit — in the press latch,
|
||||
/// which is where a click's subject lives.
|
||||
fn hover_go(world: &mut World, distance: f32) {
|
||||
world.insert_resource(HoveredObject {
|
||||
world.resource_mut::<crate::target::PressPick>().object = HoveredObject {
|
||||
target: Some(Entity::from_raw_u32(1).unwrap()),
|
||||
guid: Some(CHEST),
|
||||
distance,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
fn click(world: &mut World, id: SystemId) {
|
||||
@@ -273,11 +280,11 @@ mod tests {
|
||||
.resource_mut::<super::super::SpellTargeting>()
|
||||
.enter(OPENING, commit, LOCK_WORD);
|
||||
hover_go(&mut world, 9.0);
|
||||
world.insert_resource(Hovered {
|
||||
world.resource_mut::<crate::target::PressPick>().hovered = Hovered {
|
||||
target: Some(Entity::from_raw_u32(2).unwrap()),
|
||||
guid: Some(0x1234),
|
||||
distance: 4.0,
|
||||
});
|
||||
};
|
||||
click(&mut world, id);
|
||||
assert!(rx.try_recv().is_err(), "a unit in front of the chest wins");
|
||||
assert!(world.resource::<super::super::SpellTargeting>().active());
|
||||
|
||||
Reference in New Issue
Block a user