- a NOT_SELECTABLE unit takes no target: Stratholme's Baron Rivendare, pre-event, is spawned by vmangos with `UNIT_FLAG_NOT_SELECTABLE | UNIT_FLAG_SPAWNING`, and where the reference can neither damage nor target him benilla refused the damage (`CanAttack` already read bit 25) and took him as a target anyway — most visibly through the ATTACKERSTATEUPDATE self-defence acquire, which hands you your attacker the moment he opens on you with no click and no hover involved, and every other writer (left-click, right-click, TAB, `TargetUnit`/`/target`/`/assist`, the attack auto-acquire) had the same hole; the reference's gate is the third early-out of `SetSelection` `0x493540` (`0x4935ee`/`0x4935f3`): the resolved object's `+0x58` vcall to `0x60be60` `IsSelectable` = `!(UNIT_FIELD_FLAGS & 0x02000000) || UNIT_FIELD_CREATEDBY == your guid`, sitting BEFORE the dedup so a refusal is a complete no-op — no `CMSG_ATTACKSTOP`, no `CMSG_SET_SELECTION`, the target already in hand survives — and skipped entirely for an unresolved object, which is what keeps an out-of-range party member selectable off the roster; `target::relations::is_selectable` transcribes it whole, the CREATEDBY clause (your own totem stays clickable, somebody else's does not) and the xor-eax base stub every non-unit vtable carries at `+0x58` included, and `scan::commit` runs it first behind a mandatory `target_store` parameter so none of its eight call sites can forget a gate it does not compute; the hover is the SAME predicate at a second site, byte-verified by the wow-re round this dispatched: the hover grader `0x4828d0` calls slot 21 directly on the pick's winner at `0x482982`, and a false answer jumps to `0x482090(0,0)` + `ResetCursor` `0x523d30`, skipping the publisher `0x492890` that holds the mouseover globals' only two writers image-wide — so no tooltip, no cursor, no emissive brighten and no `mouseover` token, all from one rule; it is a GRADER, not a filter, and the difference is observable — a census over the pick itself (`0x480a50`, `0x480d90`, `0x713cb0`, `0x481190`, `0x480df0`) finds zero reads of the unit flags against four inside `CanAttack`, so the flagged unit wins the trace and is then thrown away, which `Hovered::refused` carries with the pick's distance kept so a trigger stalker in front of a chest takes the pick and the chest behind it does not inherit the mouseover; graded at `update_hover`'s publish rather than in a system of its own, because `drive_mouseover_tooltip` is ordered only after the net stage and a second system would have bought a one-frame name plate for a unit that must never have one; the V-plate suppression turns out to be load-bearing, since a plate hover publishes the mouseover directly (`0x7cb869 call 0x492890`) with no grading at all; and a left-click on a refused unit no longer clears what you had — the grader had dropped it into `select_on_click`'s arm beside "clicked the sky", where the reference's deselect lives in the terrain and NOTHING legs only: the down-edge pick `0x481f00` still found the object, so the release takes the object leg `0x4925d0` whose tail hits `SetSelection`'s early-out and changes nothing, the reasoning the corpse guard beside it already carried and which gets its first test here (2060) - three things a landed hit showed that the reference does not: a lootable corpse took blood, flinch, impact sounds and a floating number — the victim dispatcher `0x624530`, which owns every victim-side consequence of a swing, opens with `test byte [[victim+0x110]+0x224],1` (`UNIT_DYNAMIC_FLAGS` bit `0x1`) and jumps past all four — but the obvious fix, gating the victim, is wrong: benilla fans that dispatcher out into one message emitted on three occasions and the reference gates the DISPATCHER, and an image-wide census of the number emitter `0x6243e0` (exactly three call sites, zero tail-jumps, zero address-takes, earned against a positive control) puts `0x624566` inside the gate but the `SMSG_ATTACKSTOP` flush `0x624ea7` and the supersede flush `0x625893` outside it, so a lootable victim still floats a number for a superseded record; gated: the impact-tag dispatch (record still consumed first, as `0x6247d0` clears the GUIDs before calling) and the unresolved-attacker fallback `0x625a6d`; ungated: both flushes and the whiff slow-down, `0x624ca0` being its own function past the dispatcher's end; the flush's own predicate `0x625e40` reads an unnamed `[rec+0xac]` and stays unbuilt rather than guessed (2066); a crit played a full CombatCritical clip on a fighting unit — vmangos sends `SMSG_EMOTE(EMOTE_ONESHOT_WOUNDCRITICAL)` to the victim of every damaging crit (`Unit.cpp:1601`), `Emotes.dbc` resolves it to AnimID 10, and on the director's 90 s brawl trace that was 7 one-shots plus 9 fast-path entries on the warrior cutting its own swing (`fastpath cur=87 req=10`), 19 across all units; the reference's shared emote player `0x5fcd20` refuses at `0x5fcd96` when `0x60ecd0` is true — `UNIT_FIELD_FLAGS` bit `0x800` OR an auto-attack target `[+0xc48]` — and 1469 had built the flag half alone, dormant on vmangos (its `0x800` is `PET_IN_COMBAT`; a fighting player or creature carries `IN_COMBAT 0x80000`, a bit the client never tests), so `emote_anim::play_eligible` takes `engaged` and both producers, `SMSG_EMOTE` and the chat gesture, pass `Has<Engaged>` through one `PerformerQuery`: a crit on a fighting unit shows only as the wound overlay, and the wound trace lines carry `unit=` (2067); and an ending effect's particles popped — the teardown `0x6203e0` that both the completion callback and the same-slot replace reach hides the node and queues it rather than freeing it, and whether that is visible turned on a half no wow-re note covered, dispatched rather than assumed: the draw path's own admission test is the live-particle count (`0x7b4b46 mov eax,[esi+0x64]`) and the model enqueues onto two scene lists on two fields — MESH on the visible flag (`0x719207`), PARTICLE on the aggregate `CM2Model+0x3d8` (`0x71922c`), set while any particle lives and the same word `0x61f680` frees on — so for `Strike_Impact_Chest` a node destroyed at 0.10 s still shows ~30 particles at 0.20 s, gone by 0.334 s, and benilla cut up to 0.30 s of a 0.334 s effect; the three points where an effect ENDS — expiry, the same-slot replace, the reap's immediate destroy — now hand their emitters to the drain by the instance root they already carry as anchor (`drain_instance_emitters`, `ParticleEmitter::drain_on_owner_loss`) and the root still despawns, the reference dropping the mesh the same frame; flipped at the ending rather than set at spawn because the other way an owner vanishes is the model dtor (gear change, display swap, stream-out), where the reference frees synchronously and draining that case is what stranded ghost clouds in the air (0826/0833), and the collateral path still frees (2070) - a creature whose template carries `DO_NOT_PLAY_WOUND_ANIM` takes no flinch and no parry, and the wound grunt is rolled: the creature template's `type_flags` carries the bit at `0x8`, benilla consumed the two bits either side — `0x10 NO_FACTION_TOOLTIP` in the unit tooltip, `0x20 MORE_AUDIBLE` in the parked-event gate — with neither reader naming its bit, and `wound.rs` carried the gap in a doc comment claiming "no template cache" that had been false since the cache landed; the reference refuses the flinch inside `0x60ea70` itself (`0x60ea9f`, so every trigger is covered) through the getter `0x6125f0`, whose null-record leg fails OPEN, so `names::type_flags` names all eight bits (a call site could say `& 0x10` and look right — that is what let it be wrong) and the wound trigger refuses a flagged victim as its first gate, keyed off the descriptor's `OBJECT_FIELD_ENTRY` the way `0x60b160` keys the cache; animation only, since no creature-type mechanism suppresses the blood spurt — a flagged creature bleeds (2068); the vocal half was dispatched rather than built, because wow-re's published "makes no pain sound" called the flag's second consumer `0x60ec00` a pain-sound dispatcher while three of its own notes said otherwise, and the cross-check came back with all seven questions VERIFIED: `0x60ec00` is an ANIMATION dispatcher — `GetWeapon(0,0)`, the mainhand subclass through the 18-entry remap `0x60ec98` and jump table `0x60ec84` to Parry1H/2H/2HL/Unarmed, handed to `0x5fe2f0` = `PlayAnimation`, whose 40 call sites push literals in `[16, 202]` and which reaches no sound band — so the flag gates two animations and no sound, and the parry now rides it (victimState 3 only; Dodge 30 and Block 24 reach `PlayAnimation` directly from `0x624a90`/`0x624a74`, and our subclass map agrees with the raw remap on all 18 rows); nothing suppresses the injury vocal — of the fourteen functions in the wound-vocal chain the only one dereferencing `[unit+0xb30]` is `0x623b70`, testing bit 5 for a bus — but the ordinary grunt IS rolled: `0x623490` runs the class chance roll at `0x8626d4[2] = 60` for a creature and `0x86424c[2] = 30` for a player, by the VICTIM's type, while classes 3 (critical) and 9 (crushing) carry 100 and always sound, so `INJURY_CHANCE_CREATURE`/`INJURY_CHANCE_PLAYER` thin the grunt under sustained melee where benilla had played all three every time — 40 grunts per 100 too many on a creature, 70 on a player — and a crushing blow's class 9 punches through a server-pushed object sound's suppression, which stops at class 8 (`0x6234bb`/`0x6234bf`/`0x6234c4`); a pet gets a template record at last, since `NameCache::resolve` routes a `HIGHGUID_PET` guid to the pet-NAME query and a tamed unit had none — 18 of the 471 flagged templates on the world DB carry a `pet_family` — so the create arm also asks by the descriptor's entry, the reference's own key (`[[unit+8]+0xc]`); 2068's claim that templates are queried lazily is corrected, the create arm having warmed the cache for every streamed unit all along, at the reference's own timing (`0x60afb0` → `0x60b157`, on the create path) rather than as the convenience its comment called it; and the skeletons of the report still flinch, and should — neither vmangos nor cmangos flags them (2 of 62 templates in each), `Skeleton.m2` authors CombatWound and CombatCritical, so what changes for them is the grunt's rate, not its existence (2073) - the realm list is a dialog, not a screen: the first live run of 2056's realm selection reported no hover highlight, Cancel and the X throwing you out of the session instead of back to character select, the character screen gone from behind it, and then a login that hung on "Connecting" forever with nothing in the log — three of the four are one mistake, since `GlueScreenInfo` names all seven glue screens and `RealmList` is not among them: `RealmList.xml` is a `frameStrata="DIALOG"` frame with a full-screen 75%-black quad that `Show()`s over whatever screen is up and `RealmList_OnCancel` only `Hide()`s it, while 2056 had built it as a `ClientState`, so Cancel had to pick somewhere to go, the screen behind was torn down, and — the one that mattered — a roster arriving while that state was up moved nothing because `to_select_on_roster` only ever leaves `Login`; Okay looked inert while the IO thread walked on to the character park, the next Cancel answered a park two ahead of the thread, and every later login queued behind a `pick_rx.recv()` that was never coming, silently, reproduced with a scripted walk before anything changed; the realm list is a `shown` flag now, served in place by the character park over `select!` — refreshing on the realmd socket the cycle still holds, since `RequestRealmList` (`0x46b8d0`) begins `COP_GET_REALMS` on a socket closed only at world entry (`0x46b70c`) and only `ConnectToRealm` (`0x46b210`) drops the world connection — and leaving only when a realm is picked, `HeldLogon` and the resumed cycle gone with it; Cancel sends one `Abandon` and the park that hears it decides, which is the reference's own shape: `RealmListDialogCancelled` (`0x46ed20` → `0x46b810`) compares the current screen name against "login" and returns immediately when it differs, tail-jumping `CLoginMgr::Cancel` (`0x5b3320`) only from the login screen; the hover was never a realm-list oversight — `Hilight` was "the owning screen's to drive", four screens drove it, two folding selection into the same expression and one scoped so the `GlueCloseButton` X on every panel could never light, and the realm list made five by having none — so one owner now, `glue_hilights`, lit by hover or `LockHighlight` and walking UP to the nearest enclosing button because a modal dialog's root is itself a button, with `art_swaps`, `glue_button_visuals`, `glue_hilights` and `sync_outlines` registered once in `GluePlugin` instead of hand-copied per screen; the IO thread names each park it stops at, a logout comes back through the same park a first login does, so the policy ladder puts the realm the player is currently on ahead of the persisted `realmName`, which benilla writes only at world entry, the reproduction is now a smoke leg (Change Realm → Okay → Change Realm → Cancel → Okay against the live server) that fails on the old code by construction, and two divergences are stated: the X abandons like Cancel because our thread parks on the question rather than polling for it, and Change Realm shows the list already in hand at once rather than waiting a round trip behind a status dialog (2072) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
419 lines
16 KiB
Rust
419 lines
16 KiB
Rust
//! The create screen's live refresh (decision 0423's polish passes) — everything that follows the
|
|
//! selection or the pointer after [`super::screen`] spawns the tree: icon rects, per-race dial
|
|
//! labels, the GlueStrings info paragraphs, faction tints, the ref's hover/selected visuals
|
|
//! (`LockHighlight`, `HighlightFont`, up/down art states), and the info panels' wheel scroll.
|
|
|
|
use bevy::input::mouse::{AccumulatedMouseMotion, MouseScrollUnit, MouseWheel};
|
|
use bevy::prelude::*;
|
|
|
|
use crate::char_select::{class_name, race_name};
|
|
use crate::entities::CharCreate;
|
|
use crate::glue_strings::GlueStrings;
|
|
|
|
use super::parts::{
|
|
CharCreateUi, DialRow, DynIcon, DynText, DynTint, InfoKind, InfoScroll, ScrollArrow,
|
|
ScrollHides, ScrollThumb,
|
|
};
|
|
use super::{class_file, race_classes, CreateAction, CreateSelection, ALLIANCE};
|
|
use crate::glue::art::{
|
|
class_tc, race_tc, tc_rect, GlueArt, ALLIANCE_FILL, BACKDROP_ALLIANCE, BACKDROP_HORDE, BTN_BG,
|
|
BTN_HOVER, FALLBACK_ALPHA, HORDE_FILL,
|
|
};
|
|
use crate::glue::widgets::{FallbackFace, GlueDisabled, Hilight, HoverLabel, LockHighlight};
|
|
|
|
/// Refill everything that follows the selection — icon rects, dial labels, info texts, faction
|
|
/// tints, class-slot mapping — on selection change or a fresh spawn.
|
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
|
pub(super) fn refresh_dynamic(
|
|
sel: Res<CreateSelection>,
|
|
catalog: Option<Res<CharCreate>>,
|
|
art: Res<GlueArt>,
|
|
strings: Option<Res<GlueStrings>>,
|
|
root: Query<Ref<CharCreateUi>>,
|
|
mut icons: Query<(&DynIcon, &mut ImageNode)>,
|
|
mut texts: Query<(&DynText, &mut Text)>,
|
|
mut tints: Query<
|
|
(
|
|
&DynTint,
|
|
Option<&mut BackgroundColor>,
|
|
Option<&mut ImageNode>,
|
|
),
|
|
Without<DynIcon>,
|
|
>,
|
|
mut dial_rows: Query<(&DialRow, &mut Node), Without<Button>>,
|
|
mut slots: Query<(&CreateAction, &mut Node), With<Button>>,
|
|
) {
|
|
let force = root.iter().any(|r| r.is_added());
|
|
if !force && !sel.is_changed() {
|
|
return;
|
|
}
|
|
let cat = catalog.as_deref();
|
|
let empty = GlueStrings::default();
|
|
let strings = strings.as_deref().unwrap_or(&empty);
|
|
let classes = race_classes(cat, sel.race);
|
|
let alliance = ALLIANCE.contains(&sel.race);
|
|
let file = cat
|
|
.and_then(|c| c.0.race_file(sel.race))
|
|
.unwrap_or("Human")
|
|
.to_uppercase();
|
|
let hair_tok = cat
|
|
.and_then(|c| c.0.hair_customization(sel.race))
|
|
.unwrap_or("NORMAL")
|
|
.to_string();
|
|
let facial_tok = cat
|
|
.and_then(|c| c.0.facial_hair_customization(sel.race, sel.sex))
|
|
.unwrap_or("NORMAL")
|
|
.to_string();
|
|
|
|
for (icon, mut node) in &mut icons {
|
|
let tc = match icon {
|
|
DynIcon::Race(r) => art
|
|
.races
|
|
.as_ref()
|
|
.and_then(|(h, size)| Some((h.clone(), tc_rect(*size, race_tc(*r, sel.sex)?)))),
|
|
DynIcon::ClassSlot(i) => {
|
|
let class = classes.get(*i as usize).copied();
|
|
art.classes
|
|
.as_ref()
|
|
.and_then(|(h, size)| Some((h.clone(), tc_rect(*size, class_tc(class?)?))))
|
|
}
|
|
DynIcon::Info(InfoKind::Faction) => art.factions.as_ref().map(|(h, size)| {
|
|
let half = if alliance {
|
|
[0.0, 0.5, 0.0, 1.0]
|
|
} else {
|
|
[0.5, 1.0, 0.0, 1.0]
|
|
};
|
|
(h.clone(), tc_rect(*size, half))
|
|
}),
|
|
DynIcon::Info(InfoKind::Race) => art.races.as_ref().and_then(|(h, size)| {
|
|
Some((h.clone(), tc_rect(*size, race_tc(sel.race, sel.sex)?)))
|
|
}),
|
|
DynIcon::Info(InfoKind::Class) => art
|
|
.classes
|
|
.as_ref()
|
|
.and_then(|(h, size)| Some((h.clone(), tc_rect(*size, class_tc(sel.class)?)))),
|
|
};
|
|
if let Some((image, rect)) = tc {
|
|
node.image = image;
|
|
node.rect = Some(rect);
|
|
node.color = Color::WHITE; // spawned transparent until real art lands
|
|
}
|
|
}
|
|
|
|
for (text, mut t) in &mut texts {
|
|
let new = match text {
|
|
DynText::DialLabel(d) => match d {
|
|
0 => strings
|
|
.text("CHAR_CUSTOMIZATION1_DESC", "Skin Color")
|
|
.to_string(),
|
|
1 => strings.text("CHAR_CUSTOMIZATION2_DESC", "Face").to_string(),
|
|
2 => strings
|
|
.text(&format!("HAIR_{hair_tok}_STYLE"), "Hair Style")
|
|
.to_string(),
|
|
3 => strings
|
|
.text(&format!("HAIR_{hair_tok}_COLOR"), "Hair Color")
|
|
.to_string(),
|
|
_ => strings
|
|
.text(&format!("FACIAL_HAIR_{facial_tok}"), "Facial Hair")
|
|
.to_string(),
|
|
},
|
|
// The name box is five flex items (segments + carets), painted from its
|
|
// `EditBoxState` by `refresh_name_box` — never a single string here (decision 0704).
|
|
DynText::Name => continue,
|
|
DynText::InfoTitle(InfoKind::Faction) => strings
|
|
.text(
|
|
if alliance { "ALLIANCE" } else { "HORDE" },
|
|
if alliance { "Alliance" } else { "Horde" },
|
|
)
|
|
.to_string(),
|
|
DynText::InfoTitle(InfoKind::Race) => race_name(sel.race).to_string(),
|
|
DynText::InfoTitle(InfoKind::Class) => class_name(sel.class).to_string(),
|
|
// The info bodies keep the shipped strings VERBATIM: every `FACTION_INFO_*`/
|
|
// `RACE_INFO_*`/`CLASS_*` opens with eight literal spaces — that indent IS the ref's
|
|
// first-line clearance past the header icon (the FontString is full-width at x=0;
|
|
// GlueStrings.lua authors the inset into the text). Trimming it ran line 1 under the
|
|
// icon (director's report, 2026-07-20).
|
|
DynText::InfoBody(InfoKind::Faction) => strings
|
|
.text(
|
|
if alliance {
|
|
"FACTION_INFO_ALLIANCE"
|
|
} else {
|
|
"FACTION_INFO_HORDE"
|
|
},
|
|
"",
|
|
)
|
|
.to_string(),
|
|
DynText::InfoBody(InfoKind::Race) => {
|
|
strings.text(&format!("RACE_INFO_{file}"), "").to_string()
|
|
}
|
|
DynText::InfoAbilities => {
|
|
// The ref's `CharacterCreateEnumerateRaces` join: every `ABILITY_INFO_<FILE><n>`
|
|
// line, newline-separated, into the gold ability text.
|
|
let mut lines = Vec::new();
|
|
let mut n = 1;
|
|
while let Some(a) = strings.get(&format!("ABILITY_INFO_{file}{n}")) {
|
|
lines.push(a);
|
|
n += 1;
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
DynText::InfoBody(InfoKind::Class) => strings
|
|
.text(&format!("CLASS_{}", class_file(sel.class)), "")
|
|
.to_string(),
|
|
DynText::ClassSlotLabel(i) => classes
|
|
.get(*i as usize)
|
|
.map(|&c| class_name(c).to_string())
|
|
.unwrap_or_default(),
|
|
};
|
|
if t.0 != new {
|
|
t.0 = new;
|
|
}
|
|
}
|
|
|
|
// The faction tints: the page lean, and the panels' backdrop-bg color (the ref's
|
|
// `SetBackdropColor` — the border stays untinted, its color table entry commented out).
|
|
for (tint, bg, node) in &mut tints {
|
|
let fill = if alliance { ALLIANCE_FILL } else { HORDE_FILL };
|
|
match tint {
|
|
DynTint::Backdrop => {
|
|
if let Some(mut bg) = bg {
|
|
bg.0 = if alliance {
|
|
BACKDROP_ALLIANCE
|
|
} else {
|
|
BACKDROP_HORDE
|
|
};
|
|
}
|
|
}
|
|
DynTint::BoxFill => {
|
|
if let Some(mut node) = node {
|
|
node.color = fill; // the texture's own alpha rides along
|
|
} else if let Some(mut bg) = bg {
|
|
bg.0 = fill.with_alpha(FALLBACK_ALPHA);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The facial dial hides when the race's token is NONE (the ref rule; no 5875 row is, but the
|
|
// mechanism is the authored one).
|
|
for (row, mut node) in &mut dial_rows {
|
|
if row.0 == 4 {
|
|
node.display = if facial_tok == "NONE" {
|
|
Display::None
|
|
} else {
|
|
Display::Flex
|
|
};
|
|
}
|
|
}
|
|
|
|
// Unused class slots collapse (the ref enumerates the valid classes into the first buttons and
|
|
// hides the rest — same compaction).
|
|
for (action, mut node) in &mut slots {
|
|
if let CreateAction::ClassSlot(i) = action {
|
|
node.display = if (*i as usize) < classes.len() {
|
|
Display::Flex
|
|
} else {
|
|
Display::None
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Per-frame interaction visuals the CREATE screen owns: highlight overlays (hover — and held
|
|
/// while selected, the ref's `LockHighlight`), hover labels, and the Create button's disabled
|
|
/// latch while a create is in flight. The screen-agnostic passes — up/down art swaps, the glue
|
|
/// buttons' art + caption color, outline mirroring — are [`crate::glue`]'s, registered beside
|
|
/// this in the plugin chain.
|
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
|
pub(super) fn refresh_hover(
|
|
sel: Res<CreateSelection>,
|
|
catalog: Option<Res<CharCreate>>,
|
|
art: Res<GlueArt>,
|
|
// The glue-panel buttons (Accept/Back/Randomize) are the shared visuals pass's whole —
|
|
// art states, caption, fallback shade, hover highlight — hence `Without<GlueBtn>` here.
|
|
mut buttons: Query<
|
|
(
|
|
&CreateAction,
|
|
&Interaction,
|
|
&Children,
|
|
&mut BackgroundColor,
|
|
Has<FallbackFace>,
|
|
&mut LockHighlight,
|
|
),
|
|
(With<Button>, Without<crate::glue::widgets::GlueBtn>),
|
|
>,
|
|
mut labels: Query<&mut Visibility, (With<HoverLabel>, Without<Hilight>)>,
|
|
mut disables: Query<(&CreateAction, &mut GlueDisabled)>,
|
|
) {
|
|
let cat = catalog.as_deref();
|
|
let classes = race_classes(cat, sel.race);
|
|
let selected = |action: &CreateAction| match *action {
|
|
CreateAction::Race(r) => sel.race == r,
|
|
CreateAction::Gender(g) => sel.sex == g,
|
|
CreateAction::ClassSlot(i) => classes.get(i as usize) == Some(&sel.class),
|
|
_ => false,
|
|
};
|
|
|
|
for (action, interaction, children, mut bg, fallback, mut locked) in &mut buttons {
|
|
let is_sel = selected(action);
|
|
let hovered = *interaction != Interaction::None;
|
|
let lit = is_sel || hovered;
|
|
// The sheen itself is `crate::glue::glue_hilights`' — this says only which row is chosen.
|
|
if locked.0 != is_sel {
|
|
locked.0 = is_sel;
|
|
}
|
|
// No-art fallback only: buttons spawned with a plain face get a hover shade. (Every node
|
|
// *has* a `BackgroundColor` — only a `FallbackFace`'s belongs to us.)
|
|
if fallback {
|
|
bg.0 = if lit { BTN_HOVER } else { BTN_BG };
|
|
}
|
|
for child in children {
|
|
if let Ok(mut vis) = labels.get_mut(*child) {
|
|
// Without icon art the label IS the button face — always visible.
|
|
let show = lit || art.races.is_none();
|
|
*vis = if show {
|
|
Visibility::Inherited
|
|
} else {
|
|
Visibility::Hidden
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// The Create button disarms while a create is in flight (the shared visuals pass renders it).
|
|
for (action, mut disabled) in &mut disables {
|
|
if matches!(action, CreateAction::Create) {
|
|
let want = sel.creating;
|
|
if disabled.0 != want {
|
|
disabled.0 = want;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wheel-scroll a hovered info panel (the ref's GlueScrollFrame `OnMouseWheel`).
|
|
pub(super) fn scroll_info(
|
|
mut wheel: MessageReader<MouseWheel>,
|
|
mut boxes: Query<(&Interaction, &ComputedNode, &mut ScrollPosition), With<InfoScroll>>,
|
|
) {
|
|
for ev in wheel.read() {
|
|
let dy = match ev.unit {
|
|
MouseScrollUnit::Line => ev.y * 24.0,
|
|
MouseScrollUnit::Pixel => ev.y,
|
|
};
|
|
for (interaction, node, mut pos) in &mut boxes {
|
|
if *interaction != Interaction::None {
|
|
pos.0.y = (pos.0.y - dy).clamp(0.0, max_scroll(node));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A scroll frame's maximum offset, logical px (`ComputedNode` sizes are physical).
|
|
fn max_scroll(node: &ComputedNode) -> f32 {
|
|
((node.content_size.y - node.size.y) * node.inverse_scale_factor).max(0.0)
|
|
}
|
|
|
|
/// The scrollbar's inputs (`GlueScrollBarTemplate`): an arrow click steps half a track (the ref's
|
|
/// `SetValue(GetValue() ± GetHeight()/2)`), the knob drags.
|
|
pub(super) fn scroll_drive(
|
|
motion: Res<AccumulatedMouseMotion>,
|
|
arrows: Query<(Entity, &ScrollArrow)>,
|
|
clicks: Res<crate::glue::GlueClicks>,
|
|
thumbs: Query<(&ScrollThumb, &Interaction)>,
|
|
mut scrolls: Query<(&ComputedNode, &mut ScrollPosition), With<InfoScroll>>,
|
|
) {
|
|
// The arrow steps on the RELEASE — it is a `<Button>` with an `<OnClick>`, not a slider (1533).
|
|
// The knob below is the slider half, and a drag is a held press by definition.
|
|
for (entity, arrow) in &arrows {
|
|
if !clicks.hit(entity) {
|
|
continue;
|
|
}
|
|
let Ok((node, mut pos)) = scrolls.get_mut(arrow.scroll) else {
|
|
continue;
|
|
};
|
|
let step = if arrow.up { -arrow.step } else { arrow.step };
|
|
pos.0.y = (pos.0.y + step).clamp(0.0, max_scroll(node));
|
|
}
|
|
for (thumb, interaction) in &thumbs {
|
|
if *interaction != Interaction::Pressed || motion.delta.y == 0.0 || thumb.travel <= 0.0 {
|
|
continue;
|
|
}
|
|
let Ok((node, mut pos)) = scrolls.get_mut(thumb.scroll) else {
|
|
continue;
|
|
};
|
|
let max = max_scroll(node);
|
|
pos.0.y = (pos.0.y + motion.delta.y * max / thumb.travel).clamp(0.0, max);
|
|
}
|
|
}
|
|
|
|
/// The scrollbar's look: the knob rides the scroll fraction, the arrows swap up/down/disabled art
|
|
/// (disabled at their end stop, the additive highlight on hover), and every [`ScrollHides`] piece
|
|
/// vanishes while the panel has nothing to scroll (the ref's `scrollBarHideable` + the Top/Bottom
|
|
/// track art's range-changed hide).
|
|
#[allow(clippy::type_complexity)]
|
|
pub(super) fn scroll_visuals(
|
|
art: Res<GlueArt>,
|
|
scrolls: Query<(&ComputedNode, &ScrollPosition), With<InfoScroll>>,
|
|
mut arrows: Query<(
|
|
&ScrollArrow,
|
|
&Interaction,
|
|
&mut ImageNode,
|
|
&mut GlueDisabled,
|
|
)>,
|
|
mut thumbs: Query<(&ScrollThumb, &mut Node)>,
|
|
mut hides: Query<(&ScrollHides, &mut Visibility), Without<Hilight>>,
|
|
) {
|
|
let Some(sc) = &art.scroll else {
|
|
return;
|
|
};
|
|
for (arrow, interaction, mut img, mut off) in &mut arrows {
|
|
let Ok((node, pos)) = scrolls.get(arrow.scroll) else {
|
|
continue;
|
|
};
|
|
let disabled = if arrow.up {
|
|
pos.0.y <= 0.0
|
|
} else {
|
|
pos.0.y >= max_scroll(node)
|
|
};
|
|
let set = if arrow.up { &sc.up_btn } else { &sc.down_btn };
|
|
let face = if disabled {
|
|
&set.dis
|
|
} else if *interaction == Interaction::Pressed {
|
|
&set.down
|
|
} else {
|
|
&set.up
|
|
};
|
|
if img.image != *face {
|
|
img.image = face.clone();
|
|
}
|
|
// A scroll arrow at the end of its travel is genuinely disabled — said once, in the
|
|
// component the shared sheen pass already reads, rather than re-derived beside it.
|
|
if off.0 != disabled {
|
|
off.0 = disabled;
|
|
}
|
|
}
|
|
for (thumb, mut node) in &mut thumbs {
|
|
let Ok((cn, pos)) = scrolls.get(thumb.scroll) else {
|
|
continue;
|
|
};
|
|
let max = max_scroll(cn);
|
|
let frac = if max > 0.0 {
|
|
(pos.0.y / max).clamp(0.0, 1.0)
|
|
} else {
|
|
0.0
|
|
};
|
|
node.top = Val::Px(frac * thumb.travel);
|
|
}
|
|
for (hide, mut vis) in &mut hides {
|
|
let Ok((cn, _)) = scrolls.get(hide.scroll) else {
|
|
continue;
|
|
};
|
|
*vis = if max_scroll(cn) > 0.0 {
|
|
Visibility::Inherited
|
|
} else {
|
|
Visibility::Hidden
|
|
};
|
|
}
|
|
}
|