- ground-targeted spells work end to end: press the button, get the targeting cursor, click the ground, and the cast ships on the wire the way the real client sends it - a reticle sized to the spell's real radius rides the cursor, and on the click the storm model, falling shards and area sound all land at the point - character screens: a newly created character comes back selected on the list, and the authored layout scales to small windows instead of clipping the bottom controls off - blob shadows ride their receiver's clip transform and the raster bias that kept them faint is measured and fixed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
544 lines
25 KiB
Rust
544 lines
25 KiB
Rust
//! World interaction foundation — *what is the player pointing at?*
|
|
//!
|
|
//! This is the shared base for every "point at the world" feature: the debug **object inspector**
|
|
//! (today), and tomorrow hover tooltips, the contextual cursor (gear over objects, sword over
|
|
//! attackable units), mouseover-targeting, and right-click-to-interact. Built once so we don't build
|
|
//! picking + identity twice.
|
|
//!
|
|
//! Three pieces:
|
|
//! - [`WorldObject`] — an **identity** component on every pickable world entity (a doodad/WMO model, a
|
|
//! creature, a GameObject): its kind, a human label (model path or unit name), an id, and an optional
|
|
//! detail line. Attached at the spawn sites.
|
|
//! - [`MouseoverTarget`] — the resource [`update_mouseover`] fills each frame with the nearest
|
|
//! `WorldObject` under the cursor, found by ray-casting against the **actual mesh geometry** (so it
|
|
//! works on colliderless props — most doodads, including the campfire — which a physics raycast
|
|
//! misses).
|
|
//! - the **inspector surface** ([`inspect_ui`]) — a standalone, key-toggleable overlay (**Ctrl+Cmd+I**): a
|
|
//! weak "armed" pill plus a small identity card that follows the cursor over any picked object. It's
|
|
//! its own surface, *not* a section of the backtick debug panel, so identifying a thing costs one
|
|
//! chord and no panel.
|
|
//! - the **cast journal** ([`journal`]) — the *temporal* half of the same instrument: a spell is an
|
|
//! event, gone before a cursor could reach it, so every cast edge is recorded as it flows past and
|
|
//! the same inspector overlay lists the recent ones, click-to-copy.
|
|
//!
|
|
//! Picking only runs while [`InspectMode`] is on (today's sole consumer, driven by the inspector
|
|
//! chord); when real player-facing consumers arrive it simply runs whenever any of them is active.
|
|
|
|
use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility};
|
|
use bevy::platform::collections::HashSet;
|
|
use bevy::prelude::*;
|
|
use bevy::window::PrimaryWindow;
|
|
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
|
|
|
|
use crate::debug_panel::{overlay_text, ModelKind, OVERLAY_FILL, OVERLAY_TEXT_DIM};
|
|
use crate::net::ObjectStore;
|
|
use crate::player::WorldCamera;
|
|
use crate::ui_script::PointerOverUi;
|
|
|
|
mod journal;
|
|
|
|
/// The identity of a pickable world thing, read by the inspector now and by tooltips/cursor/targeting
|
|
/// later. Attached to a thing's renderable mesh entities at spawn.
|
|
#[derive(Component, Clone)]
|
|
pub struct WorldObject {
|
|
pub kind: ModelKind,
|
|
/// Primary label — a model path (doodads/WMOs/GameObjects) or a unit name. Shown to the player/dev.
|
|
pub label: String,
|
|
/// Identifier — placement uniqueId, server guid, or display id (`0` if none).
|
|
pub id: u32,
|
|
/// Optional second line of kind-specific detail (e.g. `"emitters: 2"`); shown when non-empty.
|
|
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.
|
|
#[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
|
|
/// cursor (later: interact/gossip on a friendly).
|
|
#[derive(Message, Clone, Copy)]
|
|
pub struct WorldRightClick;
|
|
|
|
/// The right button's **DOWN edge** in the world — emitted at the press, before the click-vs-drag
|
|
/// test even starts, whenever the press belongs to the world (in the viewport off the UI, or any
|
|
/// press while a look session already owns the hidden cursor). The reference's
|
|
/// `CGWorldFrame::OnMouseDown 0x483c40` analogue: ground-targeting's right-click cancel hangs off
|
|
/// this edge (`0x492c20`, wow-re `world-click-targeting.md` Q3), which fires whether the press
|
|
/// becomes a click OR a turn-drag, and consumes nothing (the ref handler returns 0, so the
|
|
/// BUTTON2 turn and the release's context click still run).
|
|
#[derive(Message, Clone, Copy)]
|
|
pub struct WorldRightPress;
|
|
|
|
/// Whether mouseover picking runs. Today it's armed/disarmed by the **Ctrl+Cmd+I** inspector toggle
|
|
/// ([`toggle_inspect`]).
|
|
#[derive(Resource, Default)]
|
|
pub struct InspectMode {
|
|
pub enabled: bool,
|
|
}
|
|
|
|
/// The nearest [`WorldObject`] under the cursor this frame, or `None`. Consumers read `entity` and look
|
|
/// up its [`WorldObject`]; `point`/`distance` are the world-space hit.
|
|
#[derive(Resource, Default)]
|
|
pub struct MouseoverTarget {
|
|
pub entity: Option<Entity>,
|
|
pub point: Vec3,
|
|
pub distance: f32,
|
|
}
|
|
|
|
/// Ray-cast from the cursor into the world and record the nearest [`WorldObject`] hit. Restricted to
|
|
/// entities carrying `WorldObject` (so terrain, particle billboards, and other un-identified meshes are
|
|
/// transparent to the pick), and skipped entirely unless inspection is active.
|
|
fn update_mouseover(
|
|
inspect: Res<InspectMode>,
|
|
pointer_over_ui: Res<PointerOverUi>,
|
|
mut target: ResMut<MouseoverTarget>,
|
|
window: Query<&Window, With<PrimaryWindow>>,
|
|
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
|
|
objects: Query<Entity, With<WorldObject>>,
|
|
mut ray_cast: MeshRayCast,
|
|
) {
|
|
if !inspect.enabled {
|
|
if target.entity.is_some() {
|
|
*target = MouseoverTarget::default();
|
|
}
|
|
return;
|
|
}
|
|
target.entity = None;
|
|
// The pointer is over the dev UI (e.g. the now-overlaid debug panel), not the world — don't pick
|
|
// behind it. This replaces the old "is the cursor in the inset world viewport?" test, which no
|
|
// longer means anything now the panel overlays a full-screen view.
|
|
if pointer_over_ui.0 {
|
|
return;
|
|
}
|
|
let Ok((camera, cam_tf)) = camera.single() else {
|
|
return;
|
|
};
|
|
let Ok(window) = window.single() else {
|
|
return;
|
|
};
|
|
let Some(cursor) = window.cursor_position() else {
|
|
return; // cursor left the window, or we're in mouselook (hidden)
|
|
};
|
|
let identified: HashSet<Entity> = objects.iter().collect();
|
|
if let Some((entity, point, distance)) =
|
|
pick_at_cursor(cursor, camera, cam_tf, &identified, &mut ray_cast)
|
|
{
|
|
target.entity = Some(entity);
|
|
target.point = point;
|
|
target.distance = distance;
|
|
}
|
|
}
|
|
|
|
/// Cast a ray from the logical `cursor` position into the world and return the nearest hit among
|
|
/// `pickable`: `(entity, world point, distance)`. The `pickable` set restricts the cast (terrain,
|
|
/// particle billboards, and other un-identified meshes stay transparent), so callers choose *what* is
|
|
/// pickable while sharing *how* — the inspector's per-frame mouseover passes every [`WorldObject`], the
|
|
/// target picker passes only unit meshes (so a doodad in front doesn't block a click on a mob).
|
|
pub fn pick_at_cursor(
|
|
cursor: Vec2,
|
|
camera: &Camera,
|
|
cam_tf: &GlobalTransform,
|
|
pickable: &HashSet<Entity>,
|
|
ray_cast: &mut MeshRayCast,
|
|
) -> Option<(Entity, Vec3, f32)> {
|
|
let ray = camera.viewport_to_world(cam_tf, cursor).ok()?;
|
|
let filter = |e: Entity| pickable.contains(&e);
|
|
let settings = MeshRayCastSettings::default()
|
|
.with_visibility(RayCastVisibility::VisibleInView)
|
|
.with_filter(&filter);
|
|
ray_cast
|
|
.cast_ray(ray, &settings)
|
|
.first()
|
|
.map(|(e, hit)| (*e, hit.point, hit.distance))
|
|
}
|
|
|
|
/// **Ctrl+Cmd+I** (for *inspect*) arms/disarms the inspector — the dev-instrument chord, off the
|
|
/// bare-letter plane the game's own bindings own (decision 0585). Unmistakable as a chord, so unlike
|
|
/// the old bare `i` it needs no chat-bar/EditBox gate.
|
|
fn toggle_inspect(keys: Res<ButtonInput<KeyCode>>, mut inspect: ResMut<InspectMode>) {
|
|
if crate::debug_panel::dev_chord(&keys, KeyCode::KeyI) {
|
|
inspect.enabled = !inspect.enabled;
|
|
}
|
|
}
|
|
|
|
/// How long the inspector card shows its "copied to clipboard" confirmation after a left-click.
|
|
const COPY_FLASH_SECS: f32 = 1.2;
|
|
|
|
/// A per-kind accent so the card's header is glanceable (which *sort* of thing am I over?) before you
|
|
/// even read the label.
|
|
fn kind_color(kind: ModelKind) -> egui::Color32 {
|
|
match kind {
|
|
ModelKind::Doodad => egui::Color32::from_rgb(140, 220, 140), // green — props/trees
|
|
ModelKind::Wmo => egui::Color32::from_rgb(150, 185, 240), // blue — buildings
|
|
ModelKind::Creature => egui::Color32::from_rgb(240, 205, 130), // gold — NPCs
|
|
ModelKind::GameObject => egui::Color32::from_rgb(220, 165, 220), // violet — GameObjects
|
|
}
|
|
}
|
|
|
|
/// The inspector's GameObject collision readout (decision 0763): does a hull exist, is it disabled
|
|
/// right now, and what stored state does the passability gate see. Named so the bundled `stores`
|
|
/// param stays readable.
|
|
type GoCollisionReadout = (
|
|
Has<avian3d::prelude::Collider>,
|
|
Has<avian3d::prelude::ColliderDisabled>,
|
|
Option<&'static crate::go_anim::GoAnim>,
|
|
);
|
|
|
|
/// The inspector's entity LIGHT readout (decision 0776): the lane this object's parts render
|
|
/// under, and which attach found the room — "two identical GameObjects a few yards apart, one lit
|
|
/// like the room and one like the street" is the report that made this a card line rather than a
|
|
/// rebuild with `WOW_INTERIOR_LOG`.
|
|
type EntityLightReadout = (
|
|
&'static crate::interior::InteriorAnchor,
|
|
Has<crate::interior::ContainmentAttach>,
|
|
);
|
|
|
|
/// The inspector overlay, drawn only while armed: a weak top-centre "armed" pill (so it's obvious the
|
|
/// mode is on and how to leave it) and, whenever the cursor is over an identified object, a compact
|
|
/// identity card pinned to the cursor. No chrome, no panel — its own lightweight surface.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn inspect_ui(
|
|
mut contexts: EguiContexts,
|
|
inspect: Res<InspectMode>,
|
|
mouseover: Res<MouseoverTarget>,
|
|
objects: Query<&WorldObject>,
|
|
// The pickable mesh is a child of the net entity; its descriptor store (`ObjectStore`) lives on the
|
|
// parent, so the readout hops child → parent.
|
|
parents: Query<&ChildOf>,
|
|
// Bundled into one param (the same arity ceiling as `click_input` below): the descriptor
|
|
// store + the coarse kind the line gates go by.
|
|
stores: (
|
|
Query<&ObjectStore>,
|
|
Query<&crate::net::NetEntity>,
|
|
// The GameObject collision readout (decision 0763): whether a hull exists, whether it is
|
|
// currently disabled, and the client's stored state the gate reads.
|
|
Query<GoCollisionReadout>,
|
|
Query<EntityLightReadout>,
|
|
),
|
|
guids: Query<&crate::net::Guid>,
|
|
castings: Query<&crate::creature_anim::Casting>,
|
|
drivers: Query<&crate::creature_anim::AnimDriver>,
|
|
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
|
spells: Option<Res<crate::ui_action::Spells>>,
|
|
mut names: ResMut<crate::names::NameCache>,
|
|
net_commands: Res<crate::net::NetCommands>,
|
|
// Bundled into one param (Bevy's system-function arity ceiling): the copy-click button, and
|
|
// the flag it must yield to — a left press this frame the UI already consumed as a
|
|
// cursor-payload world drop (0216 §3) must not ALSO land as an inspector copy-click, the same
|
|
// yield every other world left-press consumer gives it (see `PointerOverUi` above for the
|
|
// hover-time twin).
|
|
click_input: (
|
|
Res<ButtonInput<MouseButton>>,
|
|
Res<crate::ui_script::PlayerUiClickConsumed>,
|
|
),
|
|
time: Res<Time>,
|
|
mut copied_at: Local<Option<f32>>,
|
|
) -> Result {
|
|
let (buttons, click_consumed) = (&click_input.0, &click_input.1);
|
|
if !inspect.enabled {
|
|
return Ok(());
|
|
}
|
|
let ctx = contexts.ctx_mut()?;
|
|
|
|
// Armed indicator — small + dim, so it states "inspect is on" without competing with the world.
|
|
egui::Area::new(egui::Id::new("inspect_armed"))
|
|
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 8.0))
|
|
.show(ctx, |ui| {
|
|
egui::Frame::NONE
|
|
.inner_margin(egui::Margin::symmetric(8, 4))
|
|
.corner_radius(5.0)
|
|
.fill(OVERLAY_FILL)
|
|
.show(ui, |ui| {
|
|
overlay_text(ui);
|
|
// Spelled out, not ⌃⌘ — egui's default font stack has no glyph for U+2303 and
|
|
// would draw tofu.
|
|
ui.label(egui::RichText::new("inspect · ctrl+cmd+I to exit").small());
|
|
});
|
|
});
|
|
|
|
// The identity card: only when hovering a picked object, pinned just off the cursor tip.
|
|
let Some(obj) = mouseover.entity.and_then(|e| objects.get(e).ok()) else {
|
|
return Ok(());
|
|
};
|
|
let Some(cursor) = ctx.pointer_latest_pos() else {
|
|
return Ok(());
|
|
};
|
|
|
|
// A unit's decoded server vitals from its descriptor store (`ObjectStore`), if the picked mesh's
|
|
// parent has them — proof the descriptor pipeline (UpdateFields → ObjectValues → ECS) reached it.
|
|
let net_entity = mouseover
|
|
.entity
|
|
.and_then(|e| parents.get(e).ok())
|
|
.map(|c| c.parent());
|
|
let (stores, kinds, collision, lit) = (&stores.0, &stores.1, &stores.2, &stores.3);
|
|
let store = net_entity.and_then(|p| stores.get(p).ok());
|
|
// The unit's server name through the query cache — asks on first hover, fills on a later frame
|
|
// (the same ask-once path the unit frames use).
|
|
let name_line = net_entity
|
|
.and_then(|p| guids.get(p).ok())
|
|
.and_then(|g| names.resolve(g.0, &net_commands))
|
|
.map(str::to_string);
|
|
// Line gates go by the entity's KIND, not field presence: a create-seeded store answers every
|
|
// field (absent = 0, the descriptor truth), so "is the health field there" stopped meaning
|
|
// "is this a unit".
|
|
let kind = net_entity.and_then(|p| kinds.get(p).ok()).map(|n| n.kind);
|
|
let is_unit = matches!(
|
|
kind,
|
|
Some(benilla_protocol::EntityKind::Unit | benilla_protocol::EntityKind::Player)
|
|
);
|
|
let is_player = kind == Some(benilla_protocol::EntityKind::Player);
|
|
// The GameObject collision + state readout (decision 0763) — the line that closes the loop on
|
|
// "this door is drawn open but I can't walk through it". It answers, for the object under the
|
|
// cursor, the three facts the passability gate turns on: the client's stored `GAMEOBJECT_STATE`
|
|
// (`go_anim::go_state`, i.e. what we believe open/closed is), whether a collision hull exists at
|
|
// all, and whether it is currently disabled. A door reading `state 0 open · SOLID` is the bug
|
|
// live on screen; `state 0 open · passable` says the gate ran and something else is blocking.
|
|
let go_line = store
|
|
.filter(|_| kind == Some(benilla_protocol::EntityKind::GameObject))
|
|
.map(|s| {
|
|
let (has_hull, disabled, anim) = net_entity
|
|
.and_then(|p| collision.get(p).ok())
|
|
.unwrap_or((false, false, None));
|
|
let state = crate::go_anim::go_state(anim, s);
|
|
let word = match state {
|
|
0 => "open(ACTIVE)",
|
|
1 => "closed(READY)",
|
|
2 => "alt(DESTROYED)",
|
|
_ => "?",
|
|
};
|
|
let solidity = match (has_hull, disabled) {
|
|
(false, _) => "no hull",
|
|
(true, true) => "passable",
|
|
(true, false) => "SOLID",
|
|
};
|
|
let flags = s.0.gameobject_flags();
|
|
let mut named = Vec::new();
|
|
for (bit, name) in [
|
|
(0x1, "IN_USE"),
|
|
(0x2, "LOCKED"),
|
|
(0x4, "INTERACT_COND"),
|
|
(0x10, "NO_INTERACT"),
|
|
] {
|
|
if flags & bit != 0 {
|
|
named.push(name);
|
|
}
|
|
}
|
|
let flag_text = if named.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" [{}]", named.join("|"))
|
|
};
|
|
format!(
|
|
"go type {} · state {state} {word} · {solidity} · flags {flags:#x}{flag_text}",
|
|
s.0.gameobject_type_id()
|
|
)
|
|
});
|
|
let vitals_line = store.filter(|_| is_unit).map(|s| {
|
|
format!(
|
|
"hp {}/{} · level {}",
|
|
s.0.unit_health().unwrap_or(0),
|
|
s.0.unit_max_health().unwrap_or(0),
|
|
s.0.unit_level().unwrap_or(0)
|
|
)
|
|
});
|
|
// Raw bytes (not name-mapped): creature race/class don't share the player-race enum, so a label
|
|
// would mislead. The character model will name-map these for players specifically.
|
|
let appearance_line = store.filter(|_| is_unit).map(|s| {
|
|
format!(
|
|
"race {} · class {} · sex {}",
|
|
s.0.unit_race().unwrap_or(0),
|
|
s.0.unit_class().unwrap_or(0),
|
|
s.0.unit_gender().unwrap_or(0)
|
|
)
|
|
});
|
|
// Player-only customization: the compositor's input, shown raw so we can
|
|
// confirm the PLAYER_BYTES decode against an in-game character.
|
|
let customization_line = store.filter(|_| is_player).map(|s| {
|
|
format!(
|
|
"skin {} · face {} · hair {}/{} · facial {}",
|
|
s.0.player_skin().unwrap_or(0),
|
|
s.0.player_face().unwrap_or(0),
|
|
s.0.player_hair_style().unwrap_or(0),
|
|
s.0.player_hair_color().unwrap_or(0),
|
|
s.0.player_facial_hair().unwrap_or(0)
|
|
)
|
|
});
|
|
|
|
// A unit mid-cast (`SMSG_SPELL_START` .. GO — the `Casting` wire seam): which spell, by id and
|
|
// display name. The director's "what is it casting?" answered on hover; the finished cast's
|
|
// trail lives in the journal.
|
|
let casting_line = net_entity.and_then(|p| castings.get(p).ok()).map(|c| {
|
|
match spells.as_ref().and_then(|s| s.catalog.get(c.spell_id)) {
|
|
Some(d) => format!("casting {} \"{}\"", c.spell_id, d.name),
|
|
None => format!("casting {}", c.spell_id),
|
|
}
|
|
});
|
|
// The animation slots this frame (requested `AnimationData` ids — the selector's choice,
|
|
// before missing-clip substitution): the full-body base + any masked upper-body overlay.
|
|
let anim_line = net_entity.and_then(|p| drivers.get(p).ok()).map(|d| {
|
|
let fmt = |id: u16| match anim_data.as_ref().and_then(|a| a.0.name(id)) {
|
|
Some(name) => format!("{name}({id})"),
|
|
None => format!("{id}"),
|
|
};
|
|
let (base, overlay) = d.playing();
|
|
let base = base.map(&fmt).unwrap_or_else(|| "—".into());
|
|
match overlay {
|
|
Some(o) => format!("anim {base} + overlay {}", fmt(o)),
|
|
None => format!("anim {base}"),
|
|
}
|
|
});
|
|
// The light lane + the attach that found it (decision 0776). Absent until the classifier has
|
|
// resolved the anchor once — a freshly streamed object shows no line rather than a wrong one.
|
|
let light_line = net_entity
|
|
.and_then(|p| lit.get(p).ok())
|
|
.map(|(anchor, containment)| {
|
|
format!(
|
|
"light {} · {} attach",
|
|
anchor.law_label(),
|
|
if containment {
|
|
"containment"
|
|
} else {
|
|
"down-ray"
|
|
}
|
|
)
|
|
});
|
|
|
|
// The lines shown in the card — also exactly what a left-click copies to the clipboard.
|
|
let mut lines = vec![format!("{:?}", obj.kind), obj.label.clone()];
|
|
if let Some(name) = &name_line {
|
|
lines.push(format!("\"{name}\""));
|
|
}
|
|
if obj.id != 0 {
|
|
lines.push(format!("id {}", obj.id));
|
|
}
|
|
if !obj.detail.is_empty() {
|
|
lines.push(obj.detail.clone());
|
|
}
|
|
if let Some(line) = &go_line {
|
|
lines.push(line.clone());
|
|
}
|
|
if let Some(line) = &vitals_line {
|
|
lines.push(line.clone());
|
|
}
|
|
if let Some(line) = &appearance_line {
|
|
lines.push(line.clone());
|
|
}
|
|
if let Some(line) = &customization_line {
|
|
lines.push(line.clone());
|
|
}
|
|
if let Some(line) = &casting_line {
|
|
lines.push(line.clone());
|
|
}
|
|
if let Some(line) = &anim_line {
|
|
lines.push(line.clone());
|
|
}
|
|
if let Some(line) = &light_line {
|
|
lines.push(line.clone());
|
|
}
|
|
lines.push(format!("{:.1} yd away", mouseover.distance));
|
|
|
|
// The inspector owns left-click while armed (player::control suppresses left-orbit during inspect),
|
|
// so a press over the hovered object copies the whole card to the clipboard.
|
|
if buttons.just_pressed(MouseButton::Left) && !click_consumed.0 {
|
|
ctx.copy_text(lines.join("\n"));
|
|
*copied_at = Some(time.elapsed_secs());
|
|
}
|
|
let just_copied = copied_at.is_some_and(|t| time.elapsed_secs() - t < COPY_FLASH_SECS);
|
|
|
|
egui::Area::new(egui::Id::new("inspect_card"))
|
|
.fixed_pos(cursor + egui::vec2(18.0, 18.0))
|
|
.show(ctx, |ui| {
|
|
egui::Frame::NONE
|
|
.inner_margin(egui::Margin::symmetric(8, 6))
|
|
.corner_radius(5.0)
|
|
.fill(OVERLAY_FILL)
|
|
.show(ui, |ui| {
|
|
overlay_text(ui);
|
|
ui.colored_label(kind_color(obj.kind), format!("{:?}", obj.kind));
|
|
ui.label(egui::RichText::new(&obj.label).monospace());
|
|
if obj.id != 0 {
|
|
ui.label(
|
|
egui::RichText::new(format!("id {}", obj.id)).color(OVERLAY_TEXT_DIM),
|
|
);
|
|
}
|
|
if !obj.detail.is_empty() {
|
|
ui.label(egui::RichText::new(&obj.detail).color(OVERLAY_TEXT_DIM));
|
|
}
|
|
if let Some(line) = &go_line {
|
|
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
|
|
}
|
|
if let Some(line) = &vitals_line {
|
|
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
|
|
}
|
|
if let Some(line) = &appearance_line {
|
|
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
|
|
}
|
|
if let Some(line) = &customization_line {
|
|
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
|
|
}
|
|
if let Some(line) = &casting_line {
|
|
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
|
|
}
|
|
if let Some(line) = &anim_line {
|
|
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
|
|
}
|
|
if let Some(line) = &light_line {
|
|
ui.label(egui::RichText::new(line).color(OVERLAY_TEXT_DIM));
|
|
}
|
|
ui.label(
|
|
egui::RichText::new(format!("{:.1} yd away", mouseover.distance))
|
|
.color(OVERLAY_TEXT_DIM),
|
|
);
|
|
// Copy affordance, swapped for a brief confirmation after a left-click.
|
|
if just_copied {
|
|
ui.label(
|
|
egui::RichText::new("copied to clipboard")
|
|
.small()
|
|
.color(egui::Color32::from_rgb(140, 220, 140)),
|
|
);
|
|
} else {
|
|
ui.label(
|
|
egui::RichText::new("left-click to copy")
|
|
.small()
|
|
.color(OVERLAY_TEXT_DIM),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Registers the mouseover foundation (the [`MouseoverTarget`] + [`InspectMode`] resources and the
|
|
/// per-frame pick), the standalone Ctrl+Cmd+I inspector surface, and the cast journal (recording
|
|
/// always, drawing under the same toggle).
|
|
pub struct InteractPlugin;
|
|
|
|
impl Plugin for InteractPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<MouseoverTarget>()
|
|
.init_resource::<InspectMode>()
|
|
.init_resource::<journal::CastJournal>()
|
|
.add_message::<WorldClick>()
|
|
.add_message::<WorldRightClick>()
|
|
.add_message::<WorldRightPress>()
|
|
// After the UI keyboard feed because `update_mouseover` reads `PointerOverUi`, whose
|
|
// player-UI half `UiInput` writes — the pick must see this frame's hover, not last
|
|
// frame's. (`toggle_inspect` itself no longer needs the ordering: its Ctrl+Cmd+I chord
|
|
// can't be typed text, so it reads no keyboard-capture flag — decision 0585.)
|
|
.add_systems(
|
|
Update,
|
|
(toggle_inspect, update_mouseover)
|
|
.chain()
|
|
.after(crate::ui_script::UiInput),
|
|
)
|
|
// Always recording (messages persist two frames — no ordering constraint needed).
|
|
.add_systems(Update, journal::record_casts)
|
|
.add_systems(EguiPrimaryContextPass, (inspect_ui, journal::journal_ui));
|
|
}
|
|
}
|