- "Unable to connect" now says which: an address that does not resolve reads the reference's "Invalid Login Server", a resolved address nothing answers on reads "Login Server Down", and each names the `host:port` actually dialed — resolution and connection are two operations now (a DNS failure surfaces as `Uncategorized` on macOS, so no error kind could tell them apart), and every address a name carries is tried, `localhost` → `::1` then `127.0.0.1` included - a typed wrong password shows the login dialog instead of exiting the client, and the two auth-result enums — realmd's and the world server's — are split so a code is read against the family that sent it, with the world server's own refusal text shown for its codes - the world cursor's fork is `CanInteract` and `CanAttack`, not a reaction-rank threshold: a Cenarion Circle NPC with no gossip showed the sword at neutral (and TAB acquired it, and right-click attacked it) because one predicate implemented twice read the standing where the binary reads the at-war bit in the player→unit direction; one byte-verified `CanAttack` is shared by the cursor, TAB, pet attack orders, `UnitCanAttack` and the action-button usable gate, the service ladder falls out to a plain pointer instead of the attack leg, and `/reaction` prints the at-war bit and the branch verdict Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
480 lines
27 KiB
Rust
480 lines
27 KiB
Rust
//! Targeting — click a unit to select it, and mark it with a ground selection ring.
|
||
//!
|
||
//! The first real player-facing consumer of the [`benilla_world::interact`] picking foundation (whose header
|
||
//! long anticipated "mouseover-targeting"). This module owns the *selection state and input*; the
|
||
//! ring itself — the projected decal, its colour selector, and the reaction decode — lives in
|
||
//! [`ring`]. The pieces here:
|
||
//! - **Selection state** ([`Selection`]) — the entity + guid of our current target, tracked *locally*
|
||
//! and set the instant we click (the faithful client behaviour: the ring appears immediately, no
|
||
//! server round-trip). Sending [`ClientCommand::SetSelection`] just informs the server, which records
|
||
//! it in our `UNIT_FIELD_TARGET` and relays it to observers.
|
||
//! - **Hover** ([`hover::update_hover`]) — each frame, the unit under the cursor, found the way the real
|
||
//! client finds it (wow-re pick-volume RE `bd630be`, §5-verified: `CGWorldFrame` pick `0x481190` →
|
||
//! `0x7089c0`): a **broad phase** ray-vs-sphere on the *current animation sequence's* bounds
|
||
//! (world-placed + scaled, no pad), then a **narrow phase** ray-vs-triangle against the unit's
|
||
//! **posed render mesh** — the drawn vertices skinned through the live joint pose. Clickable =
|
||
//! the visible creature's silhouette at its pose. (The M2 bounding-triangle block is *not* the
|
||
//! unit pick volume — that's the static-doodad path; an earlier gloss saying otherwise was
|
||
//! refuted by asset data.) Hull-less/model-less units (cube fallbacks) keep the old render-mesh
|
||
//! AABB test. Stored in [`Hovered`] — the state a mouseover highlight will read.
|
||
//! - **Click to select** ([`select_on_click`]) — a [`WorldClick`] (the clean left-click the player
|
||
//! controller emits; a left *drag* orbits the camera instead) selects whatever is [`Hovered`].
|
||
//! Clicking empty ground / a non-unit, or pressing **Esc**, clears the target; the target's death
|
||
//! clears it too (in [`ring::update_ring`], where death is already read).
|
||
//!
|
||
//! The mouseover/target "light-up" has two consumers: the per-model **emissive lift**
|
||
//! ([`highlight`], the byte-verified additive term) and the V-plate's reaction-tinted **glow**
|
||
//! (`crate::vplates`, drawn later off [`Hovered`] + [`Selection`]). The plate rect is itself part
|
||
//! of the pick: hovering a plate makes its unit the mouseover *before* any world ray-test
|
||
//! ([`hover::update_hover`] consults `crate::vplates::PlateRects` first — the reference's
|
||
//! UI-blocks-world frame walk), so plates brighten the model and take clicks like the body does.
|
||
//! The real pick's **world-occlusion clamp** is modelled ([`PickOcclusion`]): the reference's scene
|
||
//! trace runs `CWorld::Intersect` first and the object pick only wins nearer than the world hit —
|
||
//! a unit or GameObject behind a wall is not hoverable. Not yet modelled: the header-sphere
|
||
//! fallback for a sequence with unauthored bounds (we pass the broad phase through instead —
|
||
//! strictly more permissive, never less).
|
||
|
||
use bevy::prelude::*;
|
||
|
||
use benilla_ui::script::UiScript;
|
||
|
||
use crate::creature_anim::Engaged;
|
||
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
|
||
use crate::ui_script::InspectMode;
|
||
use benilla_assets::AssetSet;
|
||
use benilla_world::interact::{WorldClick, WorldRightClick};
|
||
use benilla_world::schedule::WorldStage;
|
||
|
||
mod by_name;
|
||
// `pub(crate)` for the chest live probe alone (decision 1471): it resolves a GameObject's
|
||
// right-click action through the very same [`click::resolve_go_action`] the mouse does, rather
|
||
// than re-deciding lockless-vs-opener itself — an instrument that guesses the packet is testing
|
||
// its own guess (`probe_book`'s pattern: drive the click's own route, never a parallel one).
|
||
pub(crate) mod click;
|
||
// `pub(crate)` for the hover inspector's interact-gate line (`interact/inspect.rs`): the overlay
|
||
// states the very predicate the cursor runs ([`cursor_mode::go_highlightable`]) rather than a
|
||
// re-derivation of it, so the readout can never drift from the behaviour it is reporting on.
|
||
pub(crate) mod cursor_mode;
|
||
mod flash;
|
||
mod highlight;
|
||
pub(crate) mod hover;
|
||
pub(crate) mod lock;
|
||
mod relations;
|
||
mod reticle;
|
||
// `pub(crate)` for the same reason as `cursor_mode`: the faction catalog is one of
|
||
// [`cursor_mode::go_highlightable`]'s three inputs, so the inspector needs it to run the real gate.
|
||
pub(crate) mod ring;
|
||
mod scan;
|
||
|
||
pub(crate) use cursor_mode::{CursorKind, WorldCursor, GO_TYPE_GENERIC, SERVICE_RANGE_SQ};
|
||
/// `GO_FLAG_LOCKED` — the wire bit the lock chain and the GO tooltip's "Locked" line both read.
|
||
pub(crate) use lock::GO_FLAG_LOCKED;
|
||
// This frame's combat-flash verdict — read by the ring's material pick (in `ring`) and by the
|
||
// nameplate colour gate (`crate::nameplates`), the flash's only two consumers (byte-verified).
|
||
pub(crate) use flash::CombatFlash;
|
||
// The action layer's "attack pressed with no target" request — the nearest-enemy auto-acquire
|
||
// (`scan`) answers it with the same core TAB uses. `attack_order_target` + `EnemyScan` are that
|
||
// same core called *synchronously*, by the pet bar's ATTACK arm: the pet's order has to leave in
|
||
// the frame it was pressed carrying the acquired guid, so it cannot go round through a request.
|
||
pub(crate) use relations::{can_assist, can_attack, can_interact};
|
||
pub(crate) use scan::{attack_order_target, AttackNearestRequest, EnemyScan};
|
||
// The chat layer's by-name selection asks (`/target`, `/assist` — decision 0886), answered by the
|
||
// shared resolver the reference parameterises per caller.
|
||
pub(crate) use by_name::{AssistRequest, TargetByNameRequest};
|
||
// The byte-verified reaction decode + its faction catalog, reused by the unit-frame feed to tint the
|
||
// target's name plate (`TargetFrame_CheckFaction`) the same way the selection ring colours itself.
|
||
// `duel_rung` is the diagnostic face of the same walk, for `/reaction` (decision 0637).
|
||
pub(crate) use ring::{duel_rung, ring_reaction, ring_variant, Factions, RingVariant};
|
||
|
||
/// Our current target: the selected entity and its server guid, or `None`. Set the instant we click a
|
||
/// unit (client-authoritative for the ring — the real client doesn't wait for the server), cleared on
|
||
/// deselect or when the target streams out. The guid is what we send in `CMSG_SET_SELECTION` and, later,
|
||
/// what unit frames / other-unit rings key off.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct Selection {
|
||
pub(crate) target: Option<Entity>,
|
||
pub(crate) guid: Option<u64>,
|
||
}
|
||
|
||
/// 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, Clone, Copy)]
|
||
pub(crate) struct Hovered {
|
||
pub(crate) target: Option<Entity>,
|
||
pub(crate) guid: Option<u64>,
|
||
/// World-space ray distance to the picked mesh (a plate-rect hover is `0.0` — UI wins any tie).
|
||
/// Meaningless when `target` is `None`. Lets the cursor/interact pick the **nearer** of a hovered
|
||
/// unit vs. a hovered GameObject ([`go_is_nearest`]), the reference's single nearest-CGObject pick.
|
||
pub(crate) distance: f32,
|
||
}
|
||
|
||
/// The **GameObject** under the cursor this frame — the interaction arc's separate hover authority
|
||
/// (decision 0236), kept out of the byte-verified unit [`Hovered`] path because a GameObject is
|
||
/// *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, Clone, Copy)]
|
||
pub(crate) struct HoveredObject {
|
||
pub(crate) target: Option<Entity>,
|
||
pub(crate) guid: Option<u64>,
|
||
/// World-space ray distance to the GO mesh, compared against [`Hovered::distance`].
|
||
pub(crate) distance: f32,
|
||
}
|
||
|
||
/// This frame's **world-occlusion distance** for the mouse pick: the distance along the cursor ray
|
||
/// to the nearest [`benilla_world::collision::PickOccluder`] hit (terrain, the WMO walk-bake ≈ `0x84`
|
||
/// reject-mask faces, static doodad hulls), or `f32::INFINITY` with no hit. The reference's scene
|
||
/// trace (`0x480df0`, wow-re selection-circle PART 3, §5-cross-checked) traces objects unbounded
|
||
/// 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, 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`
|
||
/// with no hit (sky, mouselook, no cursor). The ground-targeting machine's hover/commit point
|
||
/// (decision 0792): the same world trace the reference's targeting position rides.
|
||
pub(crate) point: Option<Vec3>,
|
||
}
|
||
|
||
impl Default for PickOcclusion {
|
||
fn default() -> Self {
|
||
Self {
|
||
distance: f32::INFINITY,
|
||
point: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **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
|
||
/// nearer here. A GameObject wins only when it is strictly closer than a hovered unit (so a mailbox
|
||
/// in front of a distant NPC takes the click; an NPC in front of a chest keeps it).
|
||
pub(crate) fn go_is_nearest(unit: &Hovered, go: &HoveredObject) -> bool {
|
||
match (unit.target, go.target) {
|
||
(Some(_), Some(_)) => go.distance < unit.distance,
|
||
(None, Some(_)) => true,
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// A unit's model-local selection radius — the **Stand-animation footprint** `sqrt(0.5 · sqrt(dx² + dy²))`
|
||
/// (dx/dy = the Stand sequence box's horizontal extents), the exact model-local input the real client's
|
||
/// living-unit ring uses, scaled by `OBJECT_FIELD_SCALE_X` (wow-re selection-ring RE, `0x608e00`/`0x60aee0`,
|
||
/// byte-verified + emulated to the reference pixels). Stamped at attach ([`crate::entities`]); the ring's
|
||
/// world radius is this × the unit's transform scale (which is SCALE_X). Absent on model-less (cube) units,
|
||
/// which fall back to the ring's fallback radius.
|
||
#[derive(Component, Clone, Copy)]
|
||
pub(crate) struct SelectionRadius(pub(crate) f32);
|
||
|
||
/// The Update set the whole targeting chain runs in. Consumers of the frame's verdicts —
|
||
/// [`Selection`], [`Hovered`], [`CombatFlash`] (the nameplate driver) — order after it; without
|
||
/// the constraint their order against the chain is ambiguous and they can read last frame's
|
||
/// verdict (one-frame flicker on select/flash edges).
|
||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||
pub(crate) struct TargetUpdate;
|
||
|
||
/// The click-behavior player knob (decision 0961): `deselectOnClick` is 1.12's own CVar behind
|
||
/// the "Sticky Targeting" Interface Options checkbox (inverted there AND in era — checked means
|
||
/// the CVar at 0). Default true = the reference default: an empty-world click clears the
|
||
/// target, which is exactly the behavior [`click::select_on_click`] shipped with (0571/0574's
|
||
/// terrain/nothing legs); the knob only makes its gate player-settable through the CVar store
|
||
/// (0954).
|
||
#[derive(Resource)]
|
||
pub(crate) struct ClickConfig {
|
||
pub(crate) deselect_on_click: bool,
|
||
}
|
||
|
||
impl Default for ClickConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
deselect_on_click: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Targeting: click-to-select + the ground selection ring.
|
||
pub(crate) struct TargetPlugin;
|
||
|
||
impl Plugin for TargetPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.init_resource::<Selection>()
|
||
.init_resource::<ClickConfig>()
|
||
.init_resource::<Hovered>()
|
||
.init_resource::<HoveredObject>()
|
||
.init_resource::<PickOcclusion>()
|
||
.init_resource::<PressPick>()
|
||
.init_resource::<WorldCursor>()
|
||
.init_resource::<CombatFlash>()
|
||
.init_resource::<scan::TabHistory>()
|
||
.add_message::<AttackNearestRequest>()
|
||
.add_message::<TargetByNameRequest>()
|
||
.add_message::<AssistRequest>()
|
||
.add_systems(
|
||
Startup,
|
||
(
|
||
ring::setup_ring,
|
||
reticle::setup_reticle,
|
||
(ring::load_factions, scan::load_creature_types).after(AssetSet::Open),
|
||
),
|
||
)
|
||
// All after input (so this frame's `WorldClick` from `player::control` is available), in
|
||
// order: refresh the hovered unit, resolve its context cursor, route the right-click
|
||
// cursor-payload leg (decisions 0571 + 0574), let a click select it, handle Esc,
|
||
// drain the UI's `TargetUnit` requests (the PlayerFrame self-target), then the
|
||
// selection writers that don't come from the mouse (the auto-acquires + TAB), then
|
||
// the flash verdict and the ring off the frame's final selection.
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
// 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,
|
||
// The right button's down-edge cancel first (the ref's OnMouseDown hook,
|
||
// 0792): the frame the press lands, the cursor drive below already reads
|
||
// the mode cleared and the classifier's verdict stands.
|
||
crate::ui_action::targeting::cancel_targeting_on_right_press,
|
||
// The ground-targeting pre-empt (decision 0792): overwrites the classifier's
|
||
// verdict while the targeting cursor is up — the ref's dispatcher runs this
|
||
// branch before the object classifier; last-writer-wins reads the same.
|
||
crate::ui_action::targeting::drive_targeting_cursor,
|
||
// The AoE reticle reads that verdict (`WorldCursor.unable` IS the frame's
|
||
// range state — the ref's one CheckGroundPointInRange caller feeds both).
|
||
reticle::update_reticle,
|
||
click::world_right_click_payload,
|
||
click::select_on_click,
|
||
// AFTER the gated select (which holds while the mode is active): the commit
|
||
// may clear the mode, and the selection gate must have read it first. The two
|
||
// world legs are siblings, not a fallback chain — the pending spell's word
|
||
// decides which of them a click can even feed (decision 0939), so their order
|
||
// relative to each other never matters.
|
||
crate::ui_action::targeting::commit_ground_cast_on_click,
|
||
crate::ui_action::targeting::commit_object_cast_on_click,
|
||
click::act_on_right_click,
|
||
click::clear_target_requests,
|
||
// The two unit-token drains, grouped as one element (the outer chain is at
|
||
// Bevy's 20-tuple limit): `TargetUnit`'s selection commit, and
|
||
// `DropItemOnUnit`'s pet-leg feed (decision 1055). Independent of each other —
|
||
// one writes `Selection`, the other sends a cast — so they need no order.
|
||
(
|
||
click::target_unit_requests,
|
||
crate::ui_action::drop_item::drop_item_on_unit,
|
||
),
|
||
// The chat layer's by-name asks (decision 0886), beside the UI's token asks:
|
||
// both are non-mouse selection writers committing through `scan::commit`.
|
||
// The chat layer's by-name asks, grouped as one chained element (the outer
|
||
// chain is at Bevy's 20-tuple limit): `/target` and `/assist` commit through
|
||
// `scan::commit` like every other non-mouse selection writer, and `/follow`
|
||
// resolves its subject for `crate::player` to move (decision 0890) without
|
||
// touching `Selection` at all.
|
||
(
|
||
by_name::target_by_name_requests,
|
||
// The Lua binding's own by-name asks (11 corpus addons call
|
||
// `TargetByName`), beside the chat layer's — same resolver, same commit,
|
||
// one extra argument the slash command cannot supply.
|
||
by_name::script_target_by_name_requests,
|
||
by_name::assist_requests,
|
||
by_name::follow_requests,
|
||
)
|
||
.chain(),
|
||
scan::auto_acquire_attacker,
|
||
scan::tab_target,
|
||
scan::acquire_and_attack,
|
||
flash::drive_flash,
|
||
ring::update_ring,
|
||
)
|
||
.chain()
|
||
.in_set(TargetUpdate)
|
||
.after(WorldStage::Input),
|
||
)
|
||
// The model brighten writes MeshTag bit 31 — PostUpdate, after every Update payload
|
||
// writer (fade/interior/…), so their whole-tag overwrites can't strand the bit.
|
||
.add_systems(PostUpdate, highlight::apply_highlight)
|
||
// The ring's stream push: after the frame's stream clear (the projection cache was
|
||
// rebuilt by `update_ring` in Update; this is a tinted copy).
|
||
.add_systems(
|
||
PostUpdate,
|
||
(ring::push_ring, reticle::push_reticle)
|
||
.after(benilla_world::particles::buffer::begin_effect_frame),
|
||
);
|
||
}
|
||
}
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn gameobject_wins_the_click_only_when_the_nearer_object() {
|
||
let hov = |t: bool, d: f32| Hovered {
|
||
target: t.then_some(Entity::PLACEHOLDER),
|
||
guid: None,
|
||
distance: d,
|
||
};
|
||
let obj = |t: bool, d: f32| HoveredObject {
|
||
target: t.then_some(Entity::PLACEHOLDER),
|
||
guid: None,
|
||
distance: d,
|
||
};
|
||
// No GameObject hovered → the unit path always keeps the click.
|
||
assert!(!go_is_nearest(&hov(true, 5.0), &obj(false, 0.0)));
|
||
assert!(!go_is_nearest(&hov(false, 0.0), &obj(false, 0.0)));
|
||
// A GameObject with no competing unit → it takes the click.
|
||
assert!(go_is_nearest(&hov(false, 0.0), &obj(true, 42.0)));
|
||
// Both hovered → only a *strictly nearer* GameObject wins (a mailbox in front of a distant
|
||
// NPC); a farther or tied GameObject yields to the unit (a plate hover sits at distance 0.0).
|
||
assert!(go_is_nearest(&hov(true, 10.0), &obj(true, 4.0)));
|
||
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"
|
||
);
|
||
}
|
||
}
|