Files
Project-M/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
T
kronic 43d7cca2f8 Combat feel: correct boss bar, player hit-flash, dash-to-move, finisher hit-stop
Boss bar reconstructs the true max client-side (Health.Max unreplicated) via
Kind==Charger + expedition-pos filter; boss slam telegraph = a full ground ring
at BossSlamRadius; IsAttacking OR's IsLunging. Player body flash on hurt (distinct
red, jitter-safe threshold). Dash toward movement input when moving else facing.
Warrior cone: muzzle-cue suppressed (cone arc is its cue). Minimal finisher-only
hit-stop (PrototypeCameraRig.Hold, behind FeelConfig flag; never Time.timeScale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:21:32 -07:00

1938 lines
96 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms; // A6: boss-bar query reads LocalTransform (source-gen needs the using in this file)
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// Client-only screen HUD on UI Toolkit, skinned with the curated Synty sci-fi-soldier kit
/// (<see cref="HudTheme"/>) over <see cref="MenuUi"/>'s Aether palette so it reads in one visual language with
/// the menu / pause / settings. A managed presentation <see cref="SystemBase"/> (<see cref="PresentationSystemGroup"/>)
/// that OBSERVES the local player ghost + the global cycle / ledger / goal each frame and pushes values into a
/// runtime UIDocument (shared PanelSettings, sortingOrder 50 so it sits behind the pause overlay's 100). The
/// root is <c>pickingMode = Ignore</c> so the HUD never eats world clicks — only the build-palette slots pick.
/// Layout (good-HUD spatial convention): persistent self-state hugs the corners (health bottom-left, resources
/// top-left, threat top-right, build deck bottom-center); transient mission state (phase / countdown / wave /
/// goal) lives center-top; a low-health vignette + hurt-flash + a scheme-aware build-mode control-hint bar give
/// just-in-time feedback. EVERY skinned element is null-safe: with no <see cref="HudTheme"/> it falls back to
/// the flat-colour HUD. Presentation only (client world, no simulation, no rollback double-fire).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
[UpdateAfter(typeof(OnboardingSystem))] // read OnboardingState.Active same-frame (single prompt voice)
public partial class HudSystem : SystemBase
{
// ---- palette (Aether language; Synty white skins are tinted into these) ----
static readonly Color AetherCyan = new(0.30f, 0.85f, 1f);
static readonly Color OreAmber = new(1f, 0.72f, 0.35f);
static readonly Color BioGreen = new(0.55f, 0.85f, 0.45f);
static readonly Color ChargeViolet = new(0.80f, 0.45f, 1f);
static readonly Color CoreRed = new(1f, 0.40f, 0.32f); // END-1 Engine Core integrity bar
static readonly Color PanelDark = new(0.08f, 0.11f, 0.15f, 0.90f);
static readonly Color PanelWarm = new(0.16f, 0.09f, 0.09f, 0.88f);
static readonly Color PipDim = new(0.25f, 0.30f, 0.36f, 0.9f);
static readonly Color BlightRed = new(0.85f, 0.10f, 0.08f);
static readonly Color ThreatWarm = new(1f, 0.62f, 0.4f);
static readonly Color SlotIdleBg = new(0.09f, 0.11f, 0.15f, 0.92f);
static readonly Color SlotSelBg = new(0.16f, 0.26f, 0.32f, 0.95f);
static readonly Color SlotIdleBorder = new(1f, 1f, 1f, 0.08f);
const int MaxPips = 12;
const float ExpeditionRegionXMin = 500f; // camera x past this = the +1000 expedition region (DR-013)
GameObject _hudGo;
UIDocument _doc;
bool _built;
bool _themed; // HudTheme + PanelBox present (drives sprite-tint vs flat-colour retint)
// vitals
VisualElement _healthFill, _cooldownFill, _shieldRow, _cdRow;
Label _healthText;
// threat
VisualElement _threatPanel, _threatIcon;
Label _threatNum;
// macro: banner + location + goal
VisualElement _banner, _goalContainer, _goalPipsRow, _goalBar, _goalFill;
Label _phaseText, _cycleText, _locationText, _goalText;
// END-1: Engine Core integrity (losable base-heart) + overrun flash edge-detector
VisualElement _coreContainer, _coreBar, _coreFill;
Label _coreText;
uint _lastOverrunTick;
float _overrunFlashLeft;
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
VisualElement _runBanner;
Label _runBannerText, _runBannerSub;
// Step 14 (expedition redesign): the choice-of-3 boon modal + the route-choice panel. Both are
// observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks
// enqueue through the client send-systems' statics. Built lazily on first show.
VisualElement _boonModal, _boonCardRow;
VisualElement _routePanel;
Label _routeTitle;
int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for
bool _boonModalBuilt, _routePanelBuilt;
// Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether;
// row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything).
VisualElement _metaPanel, _metaRowsHost;
Label _metaShopTitle;
bool _metaShopBuilt;
int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for
// Demo polish: the clickable READY panel (Staging/Launching) + the drawn branching route map
// (RouteSelect) — the map is regenerated client-side from RunInfo.RunSeed for DISPLAY only; the
// clickable next-layer nodes bind to the authoritative RouteOpt* bytes (never the regen).
VisualElement _readyPanel, _readyPipRow;
Button _readyBtn;
Label _readyTitle;
bool _readyPanelBuilt;
int _readyShownFor; // (ready, total, localReady, secs, launching) rebuild signature
VisualElement _routeMapHost; // node circles + Painter2D edges
int _routeMapSig; // (seed, room, col, options) signature the map was drawn for
readonly List<int> _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed
uint _routeVisitedSeed;
// Demo polish round 2: boss presence bar, run-depth dots, outcome flash.
VisualElement _bossPanel, _bossFill;
Label _bossText;
bool _bossBarBuilt;
VisualElement _depthPanel;
int _depthShownFor;
bool _depthBuilt;
VisualElement _outcomeFlash; // one-shot gold/red full-screen flash when the outcome banner first lands
float _outcomeFlashLeft;
byte _outcomeFlashedFor;
readonly List<VisualElement> _pips = new();
// resources
Label _aetherNum, _oreNum, _bioNum, _chargeNum;
// build palette + hints
VisualElement _paletteRow, _hintBar, _facingArrow, _buildDiscoveryChip;
bool _paletteBuilt, _hintBuilt, _hintConveyor;
byte _hintScheme = 255;
readonly Dictionary<byte, PaletteItem> _palette = new();
// overlays
VisualElement _vignette, _downed;
Label _downedText; // "RESPAWNING IN N" countdown (client-local: baked DelayTicks + death-edge latch)
float _downedSince = -1f;
float _prevHp, _flash;
bool _haveHp;
// personal inventory panel (read-only; toggled with I)
VisualElement _invPanel, _invList, _equipList;
bool _invOpen;
EntityQuery _huskQuery;
struct PaletteItem { public VisualElement Root; public Label Cost; public int CostAmount; public byte CostRes; public VisualElement Glow; public VisualElement Icon; }
protected override void OnCreate()
{
_huskQuery = GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
}
protected override void OnStartRunning()
{
if (_hudGo != null) return;
MenuUi.EnsureEventSystem();
_hudGo = new GameObject("~HUD");
_doc = _hudGo.AddComponent<UIDocument>();
_doc.panelSettings = MenuUi.LoadPanelSettings();
_doc.sortingOrder = 50; // behind the pause overlay (100)
}
protected override void OnDestroy()
{
if (_hudGo != null) Object.Destroy(_hudGo);
}
protected override void OnUpdate()
{
if (_doc == null) return;
if (!_built)
{
var r = _doc.rootVisualElement;
if (r == null) return; // panel not initialised yet (next frame)
BuildTree(r);
_built = true;
}
// Job-safety insurance (matches the sibling presentation systems + CLAUDE.md): finish any jobs writing
// the components we read on the main thread before reading them. No job writes these today, but this
// stays correct the day a Health/stats writer is parallelised.
EntityManager.CompleteDependencyBeforeRO<Health>();
EntityManager.CompleteDependencyBeforeRO<EffectiveCharacterStats>();
EntityManager.CompleteDependencyBeforeRO<EffectiveAbilityStats>();
EntityManager.CompleteDependencyBeforeRO<AbilityCooldown>();
EntityManager.CompleteDependencyBeforeRO<RespawnInvuln>();
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
int huskCount = _huskQuery.CalculateEntityCount();
// ---- Macro: phase + cycle + countdown (center-top banner) ----
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
bool goalFull = SystemAPI.TryGetSingleton<GoalProgress>(out var goalNow) && goalNow.Target > 0 && goalNow.Charge >= goalNow.Target;
bool finalSiege = siege && goalFull; // END-2: the climactic final siege (goal cap reached)
if (haveCycle)
{
var endTick = new NetworkTick(cyc.PhaseEndTick);
bool arming = haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick);
bool finalArming = !siege && goalFull && arming; // the cap-reached arming window before the final wave
int secs = arming ? (endTick.TicksSince(nt.ServerTick) / 60 + 1) : 0;
string detail;
if (siege)
detail = (finalSiege ? "FINAL SIEGE" : "WAVE " + cyc.WaveNumber) + " - " + huskCount + " HUSKS";
else if (arming)
detail = (finalArming ? "FINAL SIEGE INCOMING" : "INCURSION") + " - " + secs + "s";
else
detail = "";
// END-2: the climax reads distinct (intense red), not a normal incursion/wave.
var col = finalSiege || finalArming ? new Color(1f, 0.28f, 0.22f) : PhaseColor(cyc.Phase);
_phaseText.text = (finalSiege ? "HOLD THE ENGINE" : PhaseLabel(cyc.Phase)) + (detail.Length > 0 ? " - " + detail : "");
_phaseText.style.color = col;
_cycleText.text = "CYCLE " + cyc.CycleNumber;
_banner.style.borderBottomColor = col;
RetintPanel(_banner, siege ? PanelWarm : PanelDark);
}
else
{
_phaseText.text = "";
_cycleText.text = "";
}
// ---- Location line (banner sub-line) — Step 14: driven by the replicated RunInfo lifecycle FSM ----
// (the old camera-X + walk-in-gate copy died with the gate; siege/final overrides below still win).
var cam = Camera.main; // camera-X region signal still feeds downstream panels (atmosphere/threat)
bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin;
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj);
if (haveRun && !siege && !finalSiege)
{
switch (runInfo.Lifecycle)
{
case RunLifecycle.Staging:
// The READY panel (bottom-center) owns the action + N/M count; the top line frames intent.
_locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch";
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
break;
case RunLifecycle.Launching:
{
// Wrap-safe countdown (post-impl review): the client's PREDICTED tick passes LaunchTick
// near zero while Lifecycle is still Launching — signed TicksSince, never raw uint math.
int secs = 0;
if (runInfo.LaunchTick != 0 && SystemAPI.TryGetSingleton<NetworkTime>(out var ntime)
&& ntime.ServerTick.IsValid)
{
int ticksLeft = new NetworkTick(runInfo.LaunchTick).TicksSince(ntime.ServerTick);
if (ticksLeft > 0) secs = ticksLeft / 60 + 1;
}
_locationText.text = "LAUNCHING IN " + secs + " - un-ready [T] to abort";
_locationText.style.color = new Color(1f, 0.9f, 0.4f);
break;
}
case RunLifecycle.InRoom:
{
string room = "ROOM " + (runInfo.CurrentRoom + 1) + "/" + runInfo.RoomCount
+ " " + RoomTypeLabel(runInfo.CurrentRoomType);
_locationText.text = obj.State == ExpeditionObjectiveState.Active
? room + " - " + obj.Remaining + " enemies remaining"
: room + " - clear it to advance";
_locationText.style.color = new Color(1f, 0.8f, 0.4f);
break;
}
case RunLifecycle.RoomReward:
_locationText.text = "ROOM CLEARED - choose your boon";
_locationText.style.color = new Color(0.5f, 1f, 0.6f);
break;
case RunLifecycle.RouteSelect:
_locationText.text = "CHOOSE YOUR PATH";
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
break;
case RunLifecycle.Returning:
_locationText.text = "RETURNING HOME...";
_locationText.style.color = new Color(0.7f, 0.9f, 1f);
break;
}
}
else if (!haveRun)
{
_locationText.text = finalSiege
? "FINAL SIEGE - hold the Engine, this is the last stand"
: siege ? "DEFEND THE BASE - hold the line"
: "MINE THE CRYSTALS - any attack harvests Ore, then BUILD";
_locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f)
: siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
}
else
{
_locationText.text = finalSiege
? "FINAL SIEGE - hold the Engine, this is the last stand"
: "DEFEND THE BASE - hold the line";
_locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f) : new Color(1f, 0.55f, 0.4f);
}
// ---- Step 14: the choice-of-3 boon modal + the route-choice panel (observe replicated state; the
// card/button clicks enqueue through the client send-systems' statics) ----
BoonOffer localOffer = default;
bool hasOffer = false;
foreach (var off in SystemAPI.Query<RefRO<BoonOffer>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localOffer = off.ValueRO;
hasOffer = true;
break;
}
BlobAssetReference<BoonCatalogBlob> boonPool = default;
if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat))
boonPool = bcat.Value;
// Lifecycle gate (post-impl review): even a stale replicated Pending never shows the modal outside
// the reward window.
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1
&& haveRun && runInfo.Lifecycle == RunLifecycle.RoomReward, boonPool);
UpdateRoutePanel(haveRun ? runInfo : default);
// Client-local path trace for the route map (nodeIds visited this run; display-only).
if (haveRun && runInfo.RunSeed != _routeVisitedSeed)
{
_routeVisited.Clear();
_routeVisitedSeed = runInfo.RunSeed;
}
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom)
{
int visitedNode = RunMap.NodeId(runInfo.CurrentRoom, runInfo.CurrentCol);
if (_routeVisited.Count == 0 || _routeVisited[^1] != visitedNode) _routeVisited.Add(visitedNode);
}
// The clickable READY panel (Staging/Launching, hidden once the outcome latched — the banner owns
// the screen then). Counts are the replicated send-to-all PlayerReady flags.
int rTotal = 0, rReady = 0;
bool localReady = false;
int launchSecs = 0;
bool terminal = SystemAPI.TryGetSingleton<RunOutcome>(out var readyOc)
&& readyOc.Value != RunOutcomeId.InProgress;
bool readyShow = haveRun && !terminal && !goalFull /* D6: goal full -> final defense armed, launching is refused server-side */
&& (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching);
if (readyShow)
{
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>())
{
rTotal++;
if (pr.ValueRO.Value != 0) rReady++;
}
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
localReady = pr.ValueRO.Value != 0;
if (runInfo.Lifecycle == RunLifecycle.Launching && runInfo.LaunchTick != 0
&& SystemAPI.TryGetSingleton<NetworkTime>(out var lnt) && lnt.ServerTick.IsValid)
{
int tl = new NetworkTick(runInfo.LaunchTick).TicksSince(lnt.ServerTick);
if (tl > 0) launchSecs = tl / 60 + 1;
}
}
UpdateReadyPanel(readyShow, runInfo, rTotal, rReady, localReady, launchSecs);
// Boss presence bar. The boss is a scaled Charger (EnemyTelegraph.Kind==KindCharger, baked/client-safe)
// in the EXPEDITION region — filtering on both excludes phase-two summoned swarmers AND a base-region
// siege enemy a dead teammate can see. Health.Max is NOT replicated, so reconstruct the true max from the
// baked Charger Max × the shared BossHealthMultiplier (A6 client fix; zero ghost-hash change).
bool bossAlive = false;
float bossHp = 0f, bossMax = 0f;
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
{
float bossBakedMax = 0f;
foreach (var (bhq, tele, blt) in
SystemAPI.Query<RefRO<Health>, RefRO<EnemyTelegraph>, RefRO<LocalTransform>>().WithAll<EnemyTag>())
{
if (tele.ValueRO.Kind != ZoneEnemyMath.KindCharger) continue; // the boss is a Charger; skip summoned swarmers
if (blt.ValueRO.Position.x <= ExpeditionRegionXMin) continue; // expedition only (not a base siege enemy)
if (bhq.ValueRO.Max > bossBakedMax)
{
bossBakedMax = bhq.ValueRO.Max;
bossHp = bhq.ValueRO.Current;
bossAlive = bhq.ValueRO.Current > 0f;
}
}
bossMax = bossBakedMax * Tuning.BossHealthMultiplier;
}
UpdateBossBar(bossAlive, bossHp, bossMax);
// Run-depth dots — keeps the roguelite spine visible while fighting (the map only shows at gates).
UpdateRunDepth(haveRun ? runInfo : default, haveRun);
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
{
_goalContainer.style.display = DisplayStyle.Flex;
float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f;
_goalText.text = "GOAL " + goal.Charge + " / " + goal.Target;
if (goal.Target >= 1 && goal.Target <= MaxPips)
{
_goalPipsRow.style.display = DisplayStyle.Flex;
_goalBar.style.display = DisplayStyle.None;
int active = Mathf.Min(goal.Charge, goal.Target); // Charge is the integer pip count; never over-fill
for (int i = 0; i < _pips.Count; i++)
{
bool show = i < goal.Target;
_pips[i].style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
if (show) SetPip(_pips[i], i < active);
}
}
else
{
_goalPipsRow.style.display = DisplayStyle.None;
_goalBar.style.display = DisplayStyle.Flex;
HudUi.SetFill(_goalFill, gfrac);
}
}
else
{
_goalContainer.style.display = DisplayStyle.None;
}
// ---- Resources (feed palette affordability) ----
int aether = 0, ore = 0, bio = 0, charge = 0;
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
{
var buf = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
for (int i = 0; i < buf.Length; i++)
{
var en = buf[i];
if (en.ItemId == ResourceId.Aether) aether = en.Count;
else if (en.ItemId == ResourceId.Ore) ore = en.Count;
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
else if (en.ItemId == ResourceId.Charge) charge = en.Count;
}
}
_aetherNum.text = aether.ToString();
_oreNum.text = ore.ToString();
_bioNum.text = bio.ToString();
_chargeNum.text = charge.ToString();
// ---- Step 14 (meta shop): Staging-only permanent-upgrade shop for the LOCAL class. Class derives from
// the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only); tiers from the
// replicated MetaTierState record on the director ghost; Aether from the ledger read above. ----
byte localClass = ClassTraits.WarriorClass;
bool haveLocalPlayer = false;
foreach (var ar in SystemAPI.Query<RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id);
haveLocalPlayer = true;
break;
}
bool metaShow = false;
BlobAssetReference<MetaUpgradeCatalogBlob> metaPool = default;
DynamicBuffer<MetaTierState> metaRecord = default;
if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true))
{
metaPool = metaCat.Value;
metaShow = true;
}
UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord);
// DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const).
// (Step 11: upgrade-button affordability tint retired with the button.)
// EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one
// broken turret): a dry base during a siege tells the player to build a Fabricator.
if (siege && charge == 0 && !onExpedition)
{
_locationText.text = "TURRETS OUT OF AMMO - build a Fabricator (Ore -> ammo)";
_locationText.style.color = new Color(1f, 0.4f, 0.9f);
}
// ---- Engine Core integrity (END-1): a red base-heart bar; an overrun stamps a transient pulse we flash ----
if (SystemAPI.TryGetSingleton<CoreIntegrity>(out var core) && core.Max > 0)
{
_coreContainer.style.display = DisplayStyle.Flex;
float cfrac = Mathf.Clamp01(core.Current / (float)core.Max);
HudUi.SetFill(_coreFill, cfrac);
_coreText.text = "CORE " + core.Current + " / " + core.Max;
_coreText.style.color = Color.Lerp(BlightRed, CoreRed, cfrac); // shifts to danger as it drops
if (core.OverrunTick != 0 && core.OverrunTick != _lastOverrunTick)
{
_lastOverrunTick = core.OverrunTick; // edge-detect the replicated breach pulse
_overrunFlashLeft = 3.5f;
}
}
else
{
_coreContainer.style.display = DisplayStyle.None;
}
// Overrun flash overrides the location line (runs AFTER the EB-2 cue so it wins; at a breach Phase is Calm).
if (_overrunFlashLeft > 0f)
{
_overrunFlashLeft -= dt;
_locationText.text = "BASE OVERRUN - resources lost; the Core will recover";
_locationText.style.color = new Color(1f, 0.3f, 0.25f);
}
// First-run onboarding owns the prompt voice: while a coach-mark step is showing, blank the HUD's own
// location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay).
if (OnboardingState.SuppressLocationLine) _locationText.text = ""; // D4: blank only for the early base-framing steps; room/siege/charge cues survive
// D6: goal full but the final siege hasn't spawned yet (the arming gap) -> the READY panel is hidden; tell the
// player what's coming instead of a stale base line (goalFull is replicated; RunPhase is server-only).
if (haveRun && goalFull && !terminal && !siege && !finalSiege)
{
_locationText.text = "GOAL REACHED - FINAL DEFENSE INCOMING: hold the Engine!";
_locationText.style.color = new Color(1f, 0.35f, 0.28f);
}
// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ----
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
{
bool win = runOutcome.Value == RunOutcomeId.Victory;
if (_outcomeFlashedFor != runOutcome.Value)
{
// One-shot landing flourish: full-screen color flash + camera kick — the beat gets a payoff.
_outcomeFlashedFor = runOutcome.Value;
_outcomeFlashLeft = win ? 0.9f : 0.7f;
PrototypeCameraRig.PunchFov(win ? 5f : 3f, win ? 420f : 260f);
PrototypeCameraRig.AddShake(win ? 0.25f : 0.5f);
}
_runBanner.style.display = DisplayStyle.Flex;
_runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN";
_runBannerText.style.color = win ? new Color(0.45f, 0.95f, 1f) : new Color(1f, 0.35f, 0.3f);
_runBannerSub.text = win ? "VICTORY - the final siege is broken" : "THE FINAL STAND FELL";
_runBannerSub.style.color = win ? new Color(0.7f, 0.95f, 1f) : new Color(1f, 0.6f, 0.5f);
}
else
{
_runBanner.style.display = DisplayStyle.None;
_outcomeFlashedFor = 0;
}
// Outcome flash decay (lazy element; the banner dim sits above it, the world below).
if (_outcomeFlashLeft > 0f)
{
if (_outcomeFlash == null && _doc != null && _doc.rootVisualElement != null)
{
_outcomeFlash = new VisualElement { pickingMode = PickingMode.Ignore };
_outcomeFlash.style.position = Position.Absolute;
_outcomeFlash.style.left = 0; _outcomeFlash.style.right = 0;
_outcomeFlash.style.top = 0; _outcomeFlash.style.bottom = 0;
_doc.rootVisualElement.Add(_outcomeFlash);
}
_outcomeFlashLeft -= dt;
if (_outcomeFlash != null)
{
bool winFlash = _outcomeFlashedFor == RunOutcomeId.Victory;
var fc = winFlash ? new Color(1f, 0.85f, 0.35f) : new Color(1f, 0.20f, 0.15f);
_outcomeFlash.style.backgroundColor = new Color(fc.r, fc.g, fc.b, Mathf.Clamp01(_outcomeFlashLeft) * 0.35f);
_outcomeFlash.style.display = DisplayStyle.Flex;
}
}
else if (_outcomeFlash != null)
{
_outcomeFlash.style.display = DisplayStyle.None;
}
// ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ----
bool showThreat = siege || huskCount > 0;
_threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None;
if (showThreat)
{
float intensity = Mathf.Clamp01(huskCount / 30f);
Color tc = siege ? Color.Lerp(ThreatWarm, BlightRed, intensity) : ThreatWarm;
_threatNum.text = huskCount.ToString();
_threatNum.style.color = tc;
_threatIcon.style.unityBackgroundImageTintColor = tc;
RetintPanel(_threatPanel, siege ? PanelWarm : PanelDark);
}
// ---- Build palette + control hints (bottom-center) ----
UpdatePalette(aether, ore, bio, onExpedition);
bool paletteOpen = BuildPaletteState.PaletteOpen && !onExpedition && _paletteBuilt;
bool buildActive = paletteOpen && BuildPaletteState.Active;
if (buildActive)
{
byte scheme = AimPresentation.Scheme;
bool conv = BuildPaletteState.Selected == StructureType.Conveyor;
if (!_hintBuilt || _hintScheme != scheme || _hintConveyor != conv) RebuildHints(scheme, conv);
if (conv && _facingArrow != null)
_facingArrow.style.rotate = new StyleRotate(new Rotate(new Angle(FacingDegrees(BuildPaletteState.Direction))));
_hintBar.style.display = DisplayStyle.Flex;
}
else
{
_hintBar.style.display = DisplayStyle.None;
}
// Build-mode discovery chip: a subtle "Tab/Y — BUILD" hint when the palette is hidden at base (Slice 1).
_buildDiscoveryChip.style.display = (!onExpedition && !BuildPaletteState.PaletteOpen)
? DisplayStyle.Flex : DisplayStyle.None;
// ---- Per-player vitals ----
bool found = false;
float hp = 0f, maxHp = 1f, cdFrac = 1f;
bool dead = false, shielded = false;
foreach (var (health, effChar, effAbility, cd, invuln, entity) in
SystemAPI.Query<RefRO<Health>, RefRO<EffectiveCharacterStats>, RefRO<EffectiveAbilityStats>,
RefRO<AbilityCooldown>, RefRO<RespawnInvuln>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess())
{
found = true;
hp = health.ValueRO.Current;
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
dead = SystemAPI.IsComponentEnabled<Dead>(entity);
uint nextFire = cd.ValueRO.NextFireTick;
int cdTicks = effAbility.ValueRO.CooldownTicks;
var nextTick = new NetworkTick(nextFire);
cdFrac = (haveTick && nextFire != 0 && cdTicks > 0 && nextTick.IsValid && nextTick.IsNewerThan(nt.ServerTick))
? Mathf.Clamp01(1f - nextTick.TicksSince(nt.ServerTick) / (float)cdTicks)
: 1f;
uint invulnUntil = invuln.ValueRO.UntilTick;
var invulnTick = new NetworkTick(invulnUntil);
shielded = haveTick && invulnUntil != 0 && invulnTick.IsValid && invulnTick.IsNewerThan(nt.ServerTick);
break;
}
_doc.rootVisualElement.style.display = (found || haveCycle) ? DisplayStyle.Flex : DisplayStyle.None;
// ---- Low-health vignette + hurt flash (full-screen) ----
_flash = HudVisualMath.DecayFlash(_flash, dt);
if (found)
{
float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f;
if (_haveHp && hp < _prevHp - 1f) _flash = HudVisualMath.HurtFlashKick;
_prevHp = hp; _haveHp = true;
float vigOp = dead ? 0f : HudVisualMath.CombinedOpacity(frac, _flash);
_vignette.style.opacity = vigOp;
_vignette.style.display = vigOp > 0.001f ? DisplayStyle.Flex : DisplayStyle.None;
HudUi.SetFill(_healthFill, frac);
_healthFill.style.backgroundColor = shielded
? new Color(0.45f, 0.85f, 1f)
: Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac);
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp);
_shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
HudUi.SetFill(_cooldownFill, cdFrac);
// A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads.
if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f;
if (dead)
{
// Client-local countdown: latch the death edge; the baked (non-replicated) DelayTicks is the
// honest duration — RespawnTick itself is server-only.
if (_downedSince < 0f) _downedSince = (float)SystemAPI.Time.ElapsedTime;
int delayTicks = 180;
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince);
_downedText.text = left > 0.05f ? "RESPAWNING IN " + Mathf.CeilToInt(left) : "RESPAWNING...";
}
else _downedSince = -1f;
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
}
else
{
_haveHp = false; _flash = 0f;
_vignette.style.display = DisplayStyle.None;
_downed.style.display = DisplayStyle.None;
}
// ---- Personal inventory (read-only; toggle with I, deposit-all with G via InventoryDepositSendSystem) ----
var invKb = UnityEngine.InputSystem.Keyboard.current;
if (invKb != null && invKb.iKey.wasPressedThisFrame) _invOpen = !_invOpen;
if (_invOpen && found)
{
EntityManager.CompleteDependencyBeforeRO<InventorySlot>();
EntityManager.CompleteDependencyBeforeRO<EquipmentSlot>();
bool haveItemDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
_invPanel.style.display = DisplayStyle.Flex;
_invList.Clear();
int shown = 0;
foreach (var bag in SystemAPI.Query<DynamicBuffer<InventorySlot>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
for (int i = 0; i < bag.Length; i++)
{
var slot = bag[i];
if (slot.ItemId == 0 || slot.Count <= 0) continue;
AddInvRow(slot.ItemId, ItemName(haveItemDb, itemDb, slot.ItemId), ItemTint(slot.ItemId), slot.Count,
IsEquippable(haveItemDb, itemDb, slot.ItemId));
shown++;
}
break;
}
if (shown == 0)
_invList.Add(HudUi.Text("(empty)", 13, MenuUi.SubCol, TextAnchor.MiddleLeft));
_equipList.Clear();
foreach (var slots in SystemAPI.Query<DynamicBuffer<EquipmentSlot>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
for (byte s = 0; s < EquipSlotId.Count && s < slots.Length; s++)
{
ushort id = slots[s].ItemId;
string label = SlotName(s) + ": " + (id == 0 ? "-" : ItemName(haveItemDb, itemDb, id));
AddEquipRow(s, label, id != 0);
}
break;
}
}
else
{
_invPanel.style.display = DisplayStyle.None;
}
}
// ---- per-frame helpers ----
void RetintPanel(VisualElement p, Color c)
{
if (_themed) p.style.unityBackgroundImageTintColor = c;
else p.style.backgroundColor = c;
}
void SetPip(VisualElement pip, bool active)
{
var theme = HudTheme.Get();
var spr = active ? theme?.PipActive : theme?.PipInactive;
if (spr != null)
{
pip.style.backgroundImage = new StyleBackground(Background.FromSprite(spr));
pip.style.unityBackgroundImageTintColor = active ? AetherCyan : PipDim;
pip.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Contain));
}
else
{
pip.style.backgroundColor = active ? AetherCyan : PipDim;
MenuUi.Round(pip, 3);
}
}
// DR-042 C6d: Harvester/Conveyor/Pylon are dead (unwired automation) -> hidden from the build palette
// (catalog + prefabs stay baked, code-intact per DR-020). Only Turret/Wall/Fabricator are buildable in the UI.
static bool IsPaletteType(byte type) =>
type != StructureType.Pylon && type != StructureType.Harvester && type != StructureType.Conveyor;
void UpdatePalette(int aether, int ore, int bio, bool onExpedition)
{
if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var catE))
{
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(catE);
for (int i = 0; i < cat.Length; i++)
if (IsPaletteType(cat[i].Type))
AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId);
_paletteBuilt = true;
}
if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; }
bool showPalette = !onExpedition && BuildPaletteState.PaletteOpen;
_paletteRow.style.display = showPalette ? DisplayStyle.Flex : DisplayStyle.None;
foreach (var kv in _palette)
{
var item = kv.Value;
int have = item.CostRes == ResourceId.Aether ? aether : item.CostRes == ResourceId.Biomass ? bio : ore;
bool affordable = have >= item.CostAmount;
bool selected = BuildPaletteState.Selected == kv.Key;
item.Root.style.opacity = affordable ? 1f : 0.5f;
item.Cost.style.color = affordable ? new Color(0.7f, 0.95f, 0.8f) : new Color(1f, 0.5f, 0.4f);
if (item.Icon != null)
item.Icon.style.unityBackgroundImageTintColor = affordable ? AetherCyan : new Color(0.5f, 0.55f, 0.6f);
MenuUi.Border(item.Root, selected ? MenuUi.Accent : SlotIdleBorder, selected ? 2 : 1);
item.Root.style.backgroundColor = selected ? SlotSelBg : SlotIdleBg;
if (item.Glow != null) item.Glow.style.opacity = selected ? 0.6f : 0f;
}
}
void AddPaletteItem(byte type, int cost, byte costRes)
{
if (type == 0 || _palette.ContainsKey(type)) return;
var theme = HudTheme.Get();
var root = new VisualElement();
root.style.width = 86;
root.style.marginLeft = 4; root.style.marginRight = 4;
root.style.paddingTop = 8; root.style.paddingBottom = 6;
root.style.alignItems = Align.Center;
root.style.backgroundColor = SlotIdleBg;
root.pickingMode = PickingMode.Position;
MenuUi.Round(root, 6);
MenuUi.Border(root, SlotIdleBorder, 1);
// selection glow: a soft Synty glow filling the slot behind everything, opacity toggled on select.
var glow = new VisualElement();
glow.style.position = Position.Absolute;
glow.style.left = 3; glow.style.right = 3; glow.style.top = 4; glow.style.bottom = 4;
glow.pickingMode = PickingMode.Ignore;
glow.style.opacity = 0f;
if (theme != null && theme.Glow != null)
{
glow.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Glow));
glow.style.unityBackgroundImageTintColor = AetherCyan;
glow.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover));
}
root.Add(glow);
var iconEl = HudUi.Icon(theme != null ? theme.StructureIcon(type) : null, 44, AetherCyan);
root.Add(iconEl);
var nameLabel = HudUi.Text(StructureName(type), 12, MenuUi.TextCol, TextAnchor.MiddleCenter);
nameLabel.style.marginTop = 2;
root.Add(nameLabel);
var costRow = new VisualElement();
costRow.style.flexDirection = FlexDirection.Row;
costRow.style.alignItems = Align.Center;
costRow.style.marginTop = 2;
costRow.pickingMode = PickingMode.Ignore;
var costIcon = HudUi.Icon(ResourceSprite(theme, costRes), 14, ResourceTint(costRes));
costIcon.style.marginRight = 3;
costRow.Add(costIcon);
var costLabel = HudUi.Display(cost.ToString(), 13, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter);
costRow.Add(costLabel);
root.Add(costRow);
byte t = type;
root.RegisterCallback<ClickEvent>(_ =>
BuildPaletteState.Select(BuildPaletteState.Selected == t ? (byte)0 : t));
_paletteRow.Add(root);
_palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost, CostRes = costRes, Glow = glow, Icon = iconEl };
}
void RebuildHints(byte scheme, bool conveyor)
{
_hintBar.Clear();
_facingArrow = null;
var theme = HudTheme.Get();
bool pad = scheme == InputSchemeId.Gamepad;
AddHint(pad ? theme?.PadPlace : theme?.KbmPlace, pad ? "A" : "LMB", "PLACE");
AddHint(pad ? theme?.PadCancel : theme?.KbmCancel, pad ? "B" : "RMB", "CANCEL");
if (conveyor)
{
// Rotate hint + a LIVE facing arrow (resolves the DR-021 conveyor-facing indicator). Only conveyors
// rotate, so this chip is gated to them — the other buildables don't show a meaningless ROTATE.
var chip = MakeChip();
chip.Add(HudUi.Glyph(pad ? theme?.PadRotate : null, pad ? "LB" : "R", 26));
var lbl = HudUi.Text("FACING", 12, MenuUi.SubCol, TextAnchor.MiddleLeft);
lbl.style.marginLeft = 5; lbl.style.marginRight = 6;
chip.Add(lbl);
_facingArrow = HudUi.Icon(theme != null ? theme.ConveyorIcon : null, 24, AetherCyan);
chip.Add(_facingArrow);
_hintBar.Add(chip);
}
AddHint(pad ? theme?.PadExit : null, pad ? "MENU" : "ESC", "EXIT");
_hintScheme = scheme;
_hintConveyor = conveyor;
_hintBuilt = true;
}
VisualElement MakeChip()
{
var chip = new VisualElement();
chip.style.flexDirection = FlexDirection.Row;
chip.style.alignItems = Align.Center;
chip.style.marginLeft = 8; chip.style.marginRight = 8;
chip.pickingMode = PickingMode.Ignore;
return chip;
}
void AddHint(Sprite glyph, string fallback, string action)
{
var chip = MakeChip();
chip.Add(HudUi.Glyph(glyph, fallback, 26));
var lbl = HudUi.Text(action, 12, MenuUi.SubCol, TextAnchor.MiddleLeft);
lbl.style.marginLeft = 5;
chip.Add(lbl);
_hintBar.Add(chip);
}
// ---- UITK construction ----
void BuildTree(VisualElement root)
{
var theme = HudTheme.Get();
_themed = theme != null && theme.PanelBox != null;
root.style.position = Position.Absolute;
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
root.pickingMode = PickingMode.Ignore; // never eat game-world clicks
BuildVignette(root);
BuildVitals(root);
BuildThreat(root);
BuildMacro(root);
BuildResources(root);
BuildPaletteRow(root);
BuildHintBar(root);
BuildDiscoveryChip(root);
BuildDowned(root);
BuildInventory(root);
BuildRunBanner(root);
}
void BuildVignette(VisualElement root)
{
_vignette = new VisualElement();
_vignette.style.position = Position.Absolute;
_vignette.style.left = 0; _vignette.style.right = 0; _vignette.style.top = 0; _vignette.style.bottom = 0;
_vignette.pickingMode = PickingMode.Ignore;
var theme = HudTheme.Get();
if (theme != null && theme.Vignette != null)
{
_vignette.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Vignette));
_vignette.style.unityBackgroundImageTintColor = BlightRed;
_vignette.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover));
}
else
{
_vignette.style.backgroundColor = new Color(BlightRed.r, BlightRed.g, BlightRed.b, 1f);
}
_vignette.style.display = DisplayStyle.None;
root.Add(_vignette);
}
void BuildVitals(VisualElement root)
{
var panel = HudUi.Panel(PanelDark);
panel.style.position = Position.Absolute;
panel.style.left = 40; panel.style.bottom = 40;
panel.style.paddingLeft = 14; panel.style.paddingRight = 14;
panel.style.paddingTop = 12; panel.style.paddingBottom = 12;
panel.style.alignItems = Align.FlexStart;
var theme = HudTheme.Get();
// shield chip (shown only while the respawn shield is active)
_shieldRow = new VisualElement();
_shieldRow.style.flexDirection = FlexDirection.Row;
_shieldRow.style.alignItems = Align.Center;
_shieldRow.style.marginBottom = 6;
_shieldRow.pickingMode = PickingMode.Ignore;
var shieldIcon = HudUi.Icon(theme != null ? theme.ShieldIcon : null, 22, AetherCyan);
shieldIcon.style.marginRight = 6;
_shieldRow.Add(shieldIcon);
_shieldRow.Add(HudUi.Text("SHIELDED", 13, new Color(0.45f, 0.85f, 1f), TextAnchor.MiddleLeft));
_shieldRow.style.display = DisplayStyle.None;
panel.Add(_shieldRow);
// cooldown row: weapon icon + thin bar
_cdRow = new VisualElement();
_cdRow.style.flexDirection = FlexDirection.Row;
_cdRow.style.alignItems = Align.Center;
_cdRow.style.marginBottom = 6;
_cdRow.pickingMode = PickingMode.Ignore;
var cdIcon = HudUi.Icon(theme != null ? theme.CooldownIcon : null, 22, AetherCyan);
cdIcon.style.marginRight = 8;
_cdRow.Add(cdIcon);
var cdBar = HudUi.Bar(420, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill);
_cdRow.Add(cdBar);
panel.Add(_cdRow);
// health row: health icon + big bar with numeric overlay
var hpRow = new VisualElement();
hpRow.style.flexDirection = FlexDirection.Row;
hpRow.style.alignItems = Align.Center;
hpRow.pickingMode = PickingMode.Ignore;
var hpIcon = HudUi.Icon(theme != null ? theme.HealthIcon : null, 34, new Color(0.95f, 0.4f, 0.4f));
hpIcon.style.marginRight = 8;
hpRow.Add(hpIcon);
var hpBar = HudUi.Bar(420, 40, new Color(0.25f, 0.9f, 0.5f), out _healthFill);
_healthText = HudUi.Display("100 / 100", 24, Color.white, TextAnchor.MiddleCenter);
_healthText.style.position = Position.Absolute;
_healthText.style.left = 0; _healthText.style.right = 0; _healthText.style.top = 0; _healthText.style.bottom = 0;
hpBar.Add(_healthText);
hpRow.Add(hpBar);
panel.Add(hpRow);
root.Add(panel);
}
void BuildThreat(VisualElement root)
{
_threatPanel = HudUi.Panel(PanelDark);
_threatPanel.style.position = Position.Absolute;
_threatPanel.style.right = 40; _threatPanel.style.top = 28;
_threatPanel.style.paddingLeft = 16; _threatPanel.style.paddingRight = 16;
_threatPanel.style.paddingTop = 8; _threatPanel.style.paddingBottom = 8;
_threatPanel.style.alignItems = Align.FlexEnd;
var theme = HudTheme.Get();
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.pickingMode = PickingMode.Ignore;
_threatIcon = HudUi.Icon(theme != null ? theme.ThreatIcon : null, 36, ThreatWarm);
_threatIcon.style.marginRight = 8;
row.Add(_threatIcon);
_threatNum = HudUi.Display("0", 34, ThreatWarm, TextAnchor.MiddleRight);
row.Add(_threatNum);
_threatPanel.Add(row);
var caption = HudUi.Text("HUSKS", 13, MenuUi.SubCol, TextAnchor.MiddleRight);
caption.style.letterSpacing = 4;
_threatPanel.Add(caption);
_threatPanel.style.display = DisplayStyle.None;
root.Add(_threatPanel);
}
void BuildMacro(VisualElement root)
{
var macro = HudUi.Group(Align.Center);
macro.style.position = Position.Absolute;
macro.style.top = 16; macro.style.left = 0; macro.style.right = 0;
var theme = HudTheme.Get();
// banner: objective icon + phase line + cycle, phase-coloured underline
_banner = HudUi.Panel(PanelDark);
_banner.style.flexDirection = FlexDirection.Row;
_banner.style.alignItems = Align.Center;
_banner.style.paddingLeft = 22; _banner.style.paddingRight = 22;
_banner.style.paddingTop = 8; _banner.style.paddingBottom = 8;
_banner.style.borderBottomWidth = 2;
_banner.style.borderBottomColor = AetherCyan;
var bIcon = HudUi.Icon(theme != null ? theme.GoalIcon : null, 26, AetherCyan);
bIcon.style.marginRight = 10;
_banner.Add(bIcon);
_phaseText = HudUi.Display("", 30, AetherCyan, TextAnchor.MiddleCenter);
_banner.Add(_phaseText);
_cycleText = HudUi.Text("", 14, MenuUi.SubCol, TextAnchor.MiddleCenter);
_cycleText.style.marginLeft = 14;
_banner.Add(_cycleText);
macro.Add(_banner);
_locationText = HudUi.Text("", 15, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter);
_locationText.style.marginTop = 5;
macro.Add(_locationText);
// goal: hex-pip meter (or fallback bar) + numeral
_goalContainer = HudUi.Group(Align.Center);
_goalContainer.style.marginTop = 8;
var goalLine = new VisualElement();
goalLine.style.flexDirection = FlexDirection.Row;
goalLine.style.alignItems = Align.Center;
goalLine.pickingMode = PickingMode.Ignore;
_goalPipsRow = new VisualElement();
_goalPipsRow.style.flexDirection = FlexDirection.Row;
_goalPipsRow.style.alignItems = Align.Center;
_goalPipsRow.pickingMode = PickingMode.Ignore;
for (int i = 0; i < MaxPips; i++)
{
var pip = new VisualElement();
pip.style.width = 22; pip.style.height = 22;
pip.style.marginLeft = 2; pip.style.marginRight = 2;
pip.style.flexShrink = 0;
pip.pickingMode = PickingMode.Ignore;
pip.style.display = DisplayStyle.None;
_pips.Add(pip);
_goalPipsRow.Add(pip);
}
goalLine.Add(_goalPipsRow);
_goalText = HudUi.Display("GOAL 0 / 10", 16, AetherCyan, TextAnchor.MiddleCenter);
_goalText.style.marginLeft = 10;
goalLine.Add(_goalText);
_goalContainer.Add(goalLine);
// fallback continuous bar (large targets)
_goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill);
_goalBar.style.marginTop = 4;
_goalBar.style.display = DisplayStyle.None;
_goalContainer.Add(_goalBar);
macro.Add(_goalContainer);
// END-1: Engine Core integrity bar (red) — the losable base-heart meter.
_coreContainer = HudUi.Group(Align.Center);
_coreContainer.style.marginTop = 6;
var coreLine = new VisualElement();
coreLine.style.flexDirection = FlexDirection.Row;
coreLine.style.alignItems = Align.Center;
coreLine.pickingMode = PickingMode.Ignore;
_coreBar = HudUi.Bar(360, 14, CoreRed, out _coreFill);
coreLine.Add(_coreBar);
_coreText = HudUi.Text("CORE 100 / 100", 13, CoreRed, TextAnchor.MiddleLeft);
_coreText.style.marginLeft = 10;
coreLine.Add(_coreText);
_coreContainer.Add(coreLine);
_coreContainer.style.display = DisplayStyle.None;
macro.Add(_coreContainer);
root.Add(macro);
}
void BuildResources(VisualElement root)
{
var strip = HudUi.Panel(PanelDark);
strip.style.position = Position.Absolute;
strip.style.left = 40; strip.style.top = 28;
strip.style.flexDirection = FlexDirection.Row;
strip.style.alignItems = Align.Center;
strip.style.paddingLeft = 14; strip.style.paddingRight = 14;
strip.style.paddingTop = 8; strip.style.paddingBottom = 8;
var theme = HudTheme.Get();
strip.Add(ResourceChip(theme != null ? theme.AetherIcon : null, AetherCyan, "0", out _aetherNum, 26, 20));
strip.Add(ResourceChip(theme != null ? theme.OreIcon : null, OreAmber, "0", out _oreNum, 30, 22));
strip.Add(ResourceChip(theme != null ? theme.BioIcon : null, BioGreen, "0", out _bioNum, 26, 20));
strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon)
// DR-042 C6a: the only Aether sink (ability-damage upgrade) gets a visible, clickable button (was U-key
// only). The Button element handles its own picking even though the HUD root Ignores clicks.
// (Step 11: the Aether UPGRADE-DMG button was RETIRED with AbilityUpgradeRequest — the choice-of-3
// boon modal + the base meta-shop (Step 14) replace it.)
root.Add(strip);
}
VisualElement ResourceChip(Sprite icon, Color tint, string initial, out Label num, float iconSize, int fontSize)
{
var chip = new VisualElement();
chip.style.flexDirection = FlexDirection.Row;
chip.style.alignItems = Align.Center;
chip.style.marginLeft = 9; chip.style.marginRight = 9;
chip.pickingMode = PickingMode.Ignore;
var ic = HudUi.Icon(icon, iconSize, tint);
ic.style.marginRight = 6;
chip.Add(ic);
num = HudUi.Display(initial, fontSize, tint, TextAnchor.MiddleLeft);
chip.Add(num);
return chip;
}
void BuildPaletteRow(VisualElement root)
{
_paletteRow = new VisualElement();
_paletteRow.style.position = Position.Absolute;
_paletteRow.style.bottom = 24; _paletteRow.style.left = 0; _paletteRow.style.right = 0;
_paletteRow.style.flexDirection = FlexDirection.Row;
_paletteRow.style.justifyContent = Justify.Center;
_paletteRow.pickingMode = PickingMode.Ignore; // the row passes clicks through; its slots pick
root.Add(_paletteRow);
}
void BuildHintBar(VisualElement root)
{
_hintBar = new VisualElement();
_hintBar.style.position = Position.Absolute;
_hintBar.style.bottom = 138; _hintBar.style.left = 0; _hintBar.style.right = 0;
_hintBar.style.flexDirection = FlexDirection.Row;
_hintBar.style.justifyContent = Justify.Center;
_hintBar.pickingMode = PickingMode.Ignore;
_hintBar.style.display = DisplayStyle.None;
root.Add(_hintBar);
}
void BuildDiscoveryChip(VisualElement root)
{
// Slice 1 HUD declutter: a subtle bottom-center chip teaching the build-mode toggle, shown only while
// the palette is CLOSED at base. The glyph uses the text fallback ("Tab"/"Y") — no HudTheme sprite needed.
bool pad = AimPresentation.Scheme == InputSchemeId.Gamepad;
_buildDiscoveryChip = new VisualElement();
_buildDiscoveryChip.style.position = Position.Absolute;
_buildDiscoveryChip.style.bottom = 28; _buildDiscoveryChip.style.left = 0; _buildDiscoveryChip.style.right = 0;
_buildDiscoveryChip.style.flexDirection = FlexDirection.Row;
_buildDiscoveryChip.style.justifyContent = Justify.Center;
_buildDiscoveryChip.style.alignItems = Align.Center;
_buildDiscoveryChip.pickingMode = PickingMode.Ignore;
_buildDiscoveryChip.style.opacity = 0.6f;
_buildDiscoveryChip.Add(HudUi.Glyph(null, pad ? "Y" : "Tab", 26));
var lbl = HudUi.Text("BUILD", 12, MenuUi.SubCol, TextAnchor.MiddleLeft);
lbl.style.marginLeft = 5;
_buildDiscoveryChip.Add(lbl);
_buildDiscoveryChip.style.display = DisplayStyle.None;
root.Add(_buildDiscoveryChip);
}
void BuildDowned(VisualElement root)
{
_downed = new VisualElement();
_downed.style.position = Position.Absolute;
_downed.style.left = 0; _downed.style.right = 0; _downed.style.top = 0; _downed.style.bottom = 0;
_downed.style.alignItems = Align.Center;
_downed.style.justifyContent = Justify.Center;
_downed.pickingMode = PickingMode.Ignore;
var theme = HudTheme.Get();
if (theme != null && theme.Vignette != null)
{
_downed.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Vignette));
_downed.style.unityBackgroundImageTintColor = new Color(0.45f, 0f, 0f, 0.6f);
_downed.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover));
}
else
{
_downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
}
var downedCol = HudUi.Group(Align.Center);
downedCol.Add(HudUi.Display("DOWNED", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
_downedText = HudUi.Text("RESPAWNING...", 24, new Color(1f, 0.75f, 0.7f), TextAnchor.MiddleCenter);
_downedText.style.marginTop = 6;
downedCol.Add(_downedText);
_downed.Add(downedCol);
_downed.style.display = DisplayStyle.None;
root.Add(_downed);
}
void BuildRunBanner(VisualElement root)
{
_runBanner = new VisualElement();
_runBanner.style.position = Position.Absolute;
_runBanner.style.left = 0; _runBanner.style.right = 0; _runBanner.style.top = 0; _runBanner.style.bottom = 0;
_runBanner.style.alignItems = Align.Center;
_runBanner.style.justifyContent = Justify.Center;
_runBanner.pickingMode = PickingMode.Ignore;
_runBanner.style.backgroundColor = new Color(0.02f, 0.03f, 0.05f, 0.55f);
var col = HudUi.Group(Align.Center);
_runBannerText = HudUi.Display("", 72, Color.white, TextAnchor.MiddleCenter);
col.Add(_runBannerText);
_runBannerSub = HudUi.Text("", 22, MenuUi.SubCol, TextAnchor.MiddleCenter);
_runBannerSub.style.marginTop = 8;
col.Add(_runBannerSub);
// END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc.
// SINGLE: PLAY AGAIN Continues as a fresh campaign (base+meta kept — the terminal save rolls
// forward on stage). CO-OP (operator-locked): the honest exit is a clean teardown for everyone —
// the host ends the session (each joiner's ConnectionWatchdog returns them to the menu with a
// reason), a joiner just leaves. All self-guard on WorldLauncher.Busy. The row picks (Position)
// even though the banner root Ignores.
var btnRow = new VisualElement();
btnRow.style.flexDirection = FlexDirection.Row;
btnRow.style.justifyContent = Justify.Center;
btnRow.style.marginTop = 28;
btnRow.pickingMode = PickingMode.Position;
switch (WorldLauncher.LastMode)
{
case SessionMode.Host:
btnRow.Add(MenuUi.Button("END SESSION — ALL TO MENU", WorldLauncher.TeardownToMenu));
break;
case SessionMode.Join:
btnRow.Add(MenuUi.Button("LEAVE TO MENU", WorldLauncher.TeardownToMenu));
break;
default:
var again = MenuUi.Button("PLAY AGAIN",
() => WorldLauncher.StartSession(SessionMode.Single, null, SaveService.HasSave()));
again.style.marginRight = 12;
btnRow.Add(again);
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
break;
}
col.Add(btnRow);
_runBanner.Add(col);
_runBanner.style.display = DisplayStyle.None;
root.Add(_runBanner);
}
void BuildInventory(VisualElement root)
{
_invPanel = HudUi.Panel(PanelDark);
_invPanel.style.position = Position.Absolute;
_invPanel.style.right = 40; _invPanel.style.bottom = 40;
_invPanel.style.minWidth = 224;
_invPanel.style.paddingLeft = 14; _invPanel.style.paddingRight = 14;
_invPanel.style.paddingTop = 10; _invPanel.style.paddingBottom = 10;
_invPanel.style.alignItems = Align.FlexStart;
_invPanel.pickingMode = PickingMode.Ignore;
var header = HudUi.Display("INVENTORY", 16, AetherCyan, TextAnchor.MiddleLeft);
header.style.marginBottom = 6;
_invPanel.Add(header);
_invList = new VisualElement();
_invList.pickingMode = PickingMode.Ignore;
_invPanel.Add(_invList);
var equipHeader = HudUi.Display("EQUIPMENT", 14, AetherCyan, TextAnchor.MiddleLeft);
equipHeader.style.marginTop = 8; equipHeader.style.marginBottom = 4;
_invPanel.Add(equipHeader);
_equipList = new VisualElement();
_equipList.pickingMode = PickingMode.Ignore;
_invPanel.Add(_equipList);
var hint = HudUi.Text("I close - click item=equip / slot=unequip - G deposit", 11, MenuUi.SubCol, TextAnchor.MiddleLeft);
hint.style.marginTop = 8;
_invPanel.Add(hint);
_invPanel.style.display = DisplayStyle.None;
root.Add(_invPanel);
}
void AddInvRow(ushort itemId, string name, Color tint, int count, bool equippable)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.justifyContent = Justify.SpaceBetween;
row.style.minWidth = 196;
row.style.marginTop = 2;
row.Add(HudUi.Text(name + (equippable ? " (equip)" : ""), 13, tint, TextAnchor.MiddleLeft));
row.Add(HudUi.Display("x" + count, 13, Color.white, TextAnchor.MiddleRight));
if (equippable)
{
row.pickingMode = PickingMode.Position;
ushort id = itemId;
row.RegisterCallback<ClickEvent>(_ => EquipSendSystem.Equip(id));
}
else row.pickingMode = PickingMode.Ignore;
_invList.Add(row);
}
static string ItemName(bool haveDb, ItemDatabase db, ushort id)
{
if (haveDb && db.Value.IsCreated)
{
ref var blob = ref db.Value.Value;
if (blob.TryGetItem(id, out var def)) return def.Name.ToString();
}
if (id == ResourceId.Aether) return "Aether";
if (id == ResourceId.Ore) return "Ore";
if (id == ResourceId.Biomass) return "Biomass";
return "Item " + id;
}
static Color ItemTint(ushort id)
{
if (id == ResourceId.Aether) return AetherCyan;
if (id == ResourceId.Ore) return OreAmber;
if (id == ResourceId.Biomass) return BioGreen;
return new Color(0.85f, 0.85f, 0.9f);
}
static bool IsEquippable(bool haveDb, ItemDatabase db, ushort id)
{
if (!haveDb || !db.Value.IsCreated) return false;
ref var b = ref db.Value.Value;
return b.TryGetItem(id, out var def) && def.EquipSlot < EquipSlotId.Count;
}
static string SlotName(byte slot)
{
switch (slot)
{
case EquipSlotId.Weapon: return "Weapon";
case EquipSlotId.Armor: return "Armor";
case EquipSlotId.Trinket: return "Trinket";
case EquipSlotId.Tool: return "Tool";
default: return "Slot " + slot;
}
}
void AddEquipRow(byte slot, string label, bool occupied)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.justifyContent = Justify.SpaceBetween;
row.style.minWidth = 196;
row.style.marginTop = 2;
row.Add(HudUi.Text(label, 13, occupied ? AetherCyan : MenuUi.SubCol, TextAnchor.MiddleLeft));
if (occupied)
{
row.pickingMode = PickingMode.Position;
byte s = slot;
row.RegisterCallback<ClickEvent>(_ => EquipSendSystem.Unequip(s));
row.Add(HudUi.Text("unequip", 11, new Color(1f, 0.6f, 0.5f), TextAnchor.MiddleRight));
}
else row.pickingMode = PickingMode.Ignore;
_equipList.Add(row);
}
static Color ResourceTint(byte resId)
=> resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber;
static Sprite ResourceSprite(HudTheme t, byte resId)
{
if (t == null) return null;
return resId == ResourceId.Aether ? t.AetherIcon : resId == ResourceId.Biomass ? t.BioIcon : t.OreIcon;
}
// Conveyor facing (BuildPaletteState.Direction 0=+X,1=-X,2=+Z,3=-Z) → arrow rotation; the arrow art points up (+Z).
static float FacingDegrees(byte dir)
{
switch (dir)
{
case 0: return 90f; // +X
case 1: return 270f; // -X
case 3: return 180f; // -Z
default: return 0f; // +Z
}
}
static Color PhaseColor(byte phase)
{
switch (phase)
{
case CyclePhase.Calm: return new Color(0.45f, 0.9f, 0.7f);
case CyclePhase.Siege: return new Color(1f, 0.45f, 0.3f);
default: return Color.white;
}
}
static string PhaseLabel(byte phase)
{
switch (phase)
{
case CyclePhase.Calm: return "AT BASE";
case CyclePhase.Siege: return "UNDER SIEGE";
default: return "";
}
}
static string StructureName(byte type)
{
switch (type)
{
case StructureType.Turret: return "Turret";
case StructureType.Wall: return "Wall";
case StructureType.Pylon: return "Pylon";
case StructureType.Harvester: return "Harvester";
case StructureType.Fabricator: return "Fabricator";
case StructureType.Conveyor: return "Conveyor";
default: return "?";
}
}
// ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ====
static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]"
: roomType == RoomTypeId.Elite ? "[ELITE]"
: roomType == RoomTypeId.Reward ? "[REWARD]" : "[COMBAT]";
void UpdateBoonModal(BoonOffer offer, bool show, BlobAssetReference<BoonCatalogBlob> pool)
{
if (!show || !pool.IsCreated)
{
if (_boonModal != null) _boonModal.style.display = DisplayStyle.None;
_boonShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_boonModalBuilt)
{
BuildBoonModal(root);
_boonModalBuilt = true;
}
// Rebuild the three cards only when the offer actually changes (a new room's deal).
// Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive
// rooms and leave stale card labels. +1 keeps 0 as the hidden/reset sentinel.
int sig = 1 + (offer.Option0 | (offer.Option1 << 8) | (offer.Option2 << 16));
if (_boonShownFor != sig)
{
_boonCardRow.Clear();
ref var defs = ref pool.Value;
for (byte k = 0; k < 3; k++)
{
byte id = k == 2 ? offer.Option2 : k == 1 ? offer.Option1 : offer.Option0;
int idx = BoonMath.FindDef(ref defs, id);
string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id);
string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : "";
byte weight = idx >= 0 ? defs.Defs[idx].Weight : (byte)100;
byte pick = k; // capture a COPY into the closure, never the loop variable
var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick));
card.style.width = 200;
card.style.height = StyleKeyword.Auto; // long descs grow the card
card.style.minHeight = 96;
// Rarity from the draw weight (100 common / 60 uncommon / 30 rare / 10 epic).
var rare = weight <= 10 ? new Color(1f, 0.82f, 0.30f)
: weight <= 30 ? new Color(0.65f, 0.50f, 1f)
: weight <= 60 ? new Color(0.45f, 0.95f, 0.55f)
: new Color(1f, 1f, 1f, 0.30f);
MenuUi.Border(card, rare, weight <= 30 ? 2.5f : 1.5f);
card.style.marginLeft = 8;
card.style.marginRight = 8;
card.style.whiteSpace = WhiteSpace.Normal;
_boonCardRow.Add(card);
}
_boonShownFor = sig;
}
_boonModal.style.display = DisplayStyle.Flex;
}
void BuildBoonModal(VisualElement root)
{
_boonModal = new VisualElement { pickingMode = PickingMode.Ignore };
_boonModal.style.position = Position.Absolute;
_boonModal.style.left = 0; _boonModal.style.right = 0;
_boonModal.style.top = 0; _boonModal.style.bottom = 0;
_boonModal.style.alignItems = Align.Center;
_boonModal.style.justifyContent = Justify.Center;
_boonModal.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.96f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 18; box.style.paddingRight = 18;
box.style.paddingTop = 14; box.style.paddingBottom = 16;
box.style.alignItems = Align.Center;
var title = new Label("ROOM CLEARED — CHOOSE A BOON");
title.style.color = new Color(0.6f, 1f, 0.7f);
title.style.fontSize = 18;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
title.style.marginBottom = 12;
box.Add(title);
_boonCardRow = new VisualElement();
_boonCardRow.style.flexDirection = FlexDirection.Row;
box.Add(_boonCardRow);
_boonModal.Add(box);
root.Add(_boonModal);
}
// ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind
// to the authoritative RouteOpt* bytes) ----
const float MapStrideX = 58f, MapStrideY = 46f, MapNodeSize = 34f, MapPad = 14f;
static Vector2 MapNodePos(int layer, int col, byte layerWidth)
{
float x = MapPad + layer * MapStrideX;
float y = MapPad + MapStrideY + (col - (layerWidth - 1) * 0.5f) * MapStrideY;
return new Vector2(x, y);
}
static string RoomGlyph(byte t) => t == RoomTypeId.Boss ? "B"
: t == RoomTypeId.Elite ? "E" : t == RoomTypeId.Reward ? "R" : "C";
static Color RoomColor(byte t) => t == RoomTypeId.Boss ? new Color(0.92f, 0.28f, 0.22f)
: t == RoomTypeId.Elite ? new Color(0.80f, 0.45f, 1f)
: t == RoomTypeId.Reward ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 0.72f, 0.35f);
void UpdateRoutePanel(RunInfo runInfo)
{
// Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion).
bool show = runInfo.Lifecycle == RunLifecycle.RouteSelect && runInfo.RouteOptionCount > 0;
if (!show)
{
if (_routePanel != null) _routePanel.style.display = DisplayStyle.None;
_routeMapSig = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_routePanelBuilt)
{
BuildRoutePanel(root);
_routePanelBuilt = true;
}
int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31
^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16)
^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20);
if (sig == 0) sig = 1;
if (_routeMapSig != sig)
{
RebuildRouteMap(runInfo);
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
_routeMapSig = sig;
}
_routePanel.style.display = DisplayStyle.Flex;
}
void BuildRoutePanel(VisualElement root)
{
_routePanel = new VisualElement { pickingMode = PickingMode.Ignore };
_routePanel.style.position = Position.Absolute;
_routePanel.style.left = 0; _routePanel.style.right = 0;
_routePanel.style.top = 0; _routePanel.style.bottom = 0;
_routePanel.style.alignItems = Align.Center;
_routePanel.style.justifyContent = Justify.Center;
_routePanel.style.display = DisplayStyle.None;
var box = new VisualElement { pickingMode = PickingMode.Position }; // swallow world clicks under the map
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 18; box.style.paddingRight = 18;
box.style.paddingTop = 12; box.style.paddingBottom = 12;
box.style.alignItems = Align.Center;
_routeTitle = new Label("CHOOSE YOUR PATH");
_routeTitle.style.color = new Color(0.55f, 0.85f, 1f);
_routeTitle.style.fontSize = 16;
_routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
_routeTitle.style.marginBottom = 10;
box.Add(_routeTitle);
_routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore };
_routeMapHost.style.position = Position.Relative;
box.Add(_routeMapHost);
var cap = HudUi.Text("your path is lit — click a highlighted room to commit the party", 13,
MenuUi.SubCol, TextAnchor.MiddleCenter);
cap.style.marginTop = 10;
box.Add(cap);
_routePanel.Add(box);
root.Add(_routePanel);
}
void RebuildRouteMap(RunInfo runInfo)
{
_routeMapHost.Clear();
var map = RunMapMath.Generate(runInfo.RunSeed);
_routeMapHost.style.width = MapPad * 2f + (map.LayerCount - 1) * MapStrideX + MapNodeSize;
_routeMapHost.style.height = MapPad * 2f + 2f * MapStrideY + MapNodeSize;
// Edges under the nodes (Painter2D); walked segments glow, the rest are faint.
var edges = new VisualElement { pickingMode = PickingMode.Ignore };
edges.style.position = Position.Absolute;
edges.style.left = 0; edges.style.top = 0; edges.style.right = 0; edges.style.bottom = 0;
var mapCopy = map;
var visited = new List<int>(_routeVisited);
edges.generateVisualContent += ctx =>
{
var p = ctx.painter2D;
p.lineWidth = 2f;
var c = new Vector2(MapNodeSize * 0.5f, MapNodeSize * 0.5f);
for (int layer = 0; layer < mapCopy.LayerCount - 1; layer++)
for (int col = 0; col < mapCopy.LayerWidths[layer]; col++)
{
var node = mapCopy.Node(layer, col);
if (node.NextMask == 0) continue;
var a = MapNodePos(layer, col, mapCopy.LayerWidths[layer]);
for (int j = 0; j < mapCopy.LayerWidths[layer + 1]; j++)
{
if ((node.NextMask & (1 << j)) == 0) continue;
var b = MapNodePos(layer + 1, j, mapCopy.LayerWidths[layer + 1]);
bool walked = visited.Contains(RunMap.NodeId(layer, col))
&& visited.Contains(RunMap.NodeId(layer + 1, j));
p.strokeColor = walked ? new Color(0.55f, 0.85f, 1f, 0.9f) : new Color(1f, 1f, 1f, 0.16f);
p.BeginPath();
p.MoveTo(a + c);
p.LineTo(b + c);
p.Stroke();
}
}
};
_routeMapHost.Add(edges);
int nextLayer = runInfo.CurrentRoom + 1;
for (int layer = 0; layer < map.LayerCount; layer++)
for (int col = 0; col < map.LayerWidths[layer]; col++)
{
var node = map.Node(layer, col);
bool isCurrent = layer == runInfo.CurrentRoom && col == runInfo.CurrentCol;
bool wasVisited = _routeVisited.Contains(RunMap.NodeId(layer, col));
byte opt = 255;
if (layer == nextLayer)
{
if (runInfo.RouteOptionCount > 0 && col == runInfo.RouteOpt0Col) opt = 0;
else if (runInfo.RouteOptionCount > 1 && col == runInfo.RouteOpt1Col) opt = 1;
else if (runInfo.RouteOptionCount > 2 && col == runInfo.RouteOpt2Col) opt = 2;
}
_routeMapHost.Add(MakeMapNode(node.RoomType,
MapNodePos(layer, col, map.LayerWidths[layer]), isCurrent, wasVisited, opt, layer <= runInfo.CurrentRoom));
}
}
VisualElement MakeMapNode(byte roomType, Vector2 pos, bool isCurrent, bool visited, byte optionIndex, bool past)
{
bool clickable = optionIndex != 255;
var n = new VisualElement { pickingMode = clickable ? PickingMode.Position : PickingMode.Ignore };
n.style.position = Position.Absolute;
n.style.left = pos.x; n.style.top = pos.y;
n.style.width = MapNodeSize; n.style.height = MapNodeSize;
MenuUi.Round(n, MapNodeSize * 0.5f);
var c = RoomColor(roomType);
float bgA = clickable ? 0.95f : visited || isCurrent ? 0.85f : past ? 0.20f : 0.40f;
var restBg = new Color(c.r * 0.35f, c.g * 0.35f, c.b * 0.35f, bgA);
n.style.backgroundColor = restBg;
MenuUi.Border(n, isCurrent ? new Color(0.55f, 0.85f, 1f) : clickable ? c : new Color(1f, 1f, 1f, 0.18f),
isCurrent || clickable ? 2.5f : 1.2f);
var lbl = new Label(RoomGlyph(roomType)) { pickingMode = PickingMode.Ignore };
lbl.style.unityTextAlign = TextAnchor.MiddleCenter;
lbl.style.flexGrow = 1;
lbl.style.color = clickable || visited || isCurrent ? c : new Color(1f, 1f, 1f, 0.35f);
lbl.style.fontSize = 15;
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
n.Add(lbl);
if (clickable)
{
byte pick = optionIndex; // closure copy, never the loop variable
n.RegisterCallback<ClickEvent>(_ => RouteSendSystem.PickRoute(pick));
n.RegisterCallback<MouseEnterEvent>(_ =>
n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f));
n.RegisterCallback<MouseLeaveEvent>(_ => n.style.backgroundColor = restBg);
}
return n;
}
// ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ----
void UpdateReadyPanel(bool show, RunInfo runInfo, int total, int ready, bool localReady, int launchSecs)
{
if (!show)
{
if (_readyPanel != null) _readyPanel.style.display = DisplayStyle.None;
_readyShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_readyPanelBuilt)
{
BuildReadyPanel(root);
_readyPanelBuilt = true;
}
bool launching = runInfo.Lifecycle == RunLifecycle.Launching;
int sig = 1 + ready + (total << 4) + (localReady ? 1 << 8 : 0) + (launchSecs << 9) + (launching ? 1 << 16 : 0);
if (_readyShownFor != sig)
{
_readyTitle.text = launching
? "LAUNCHING IN " + launchSecs
: "EXPEDITION — " + ready + "/" + Mathf.Max(total, 1) + " READY";
_readyTitle.style.color = launching ? new Color(1f, 0.9f, 0.4f) : new Color(0.55f, 0.85f, 1f);
_readyBtn.text = launching ? "ABORT [T]" : localReady ? "UNREADY [T]" : "READY UP [T]";
_readyPipRow.Clear();
for (int i = 0; i < total; i++)
{
var pip = new VisualElement();
pip.style.width = 14; pip.style.height = 14;
pip.style.marginLeft = 3; pip.style.marginRight = 3;
MenuUi.Round(pip, 7f);
pip.style.backgroundColor = i < ready
? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 1f, 1f, 0.15f);
_readyPipRow.Add(pip);
}
_readyShownFor = sig;
}
_readyPanel.style.display = DisplayStyle.Flex;
}
void BuildReadyPanel(VisualElement root)
{
_readyPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_readyPanel.style.position = Position.Absolute;
_readyPanel.style.left = 0; _readyPanel.style.right = 0;
_readyPanel.style.bottom = 170; // clear of the build palette row + hint bar
_readyPanel.style.alignItems = Align.Center;
_readyPanel.style.display = DisplayStyle.None;
var box = new VisualElement { pickingMode = PickingMode.Position };
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 18; box.style.paddingRight = 18;
box.style.paddingTop = 10; box.style.paddingBottom = 12;
box.style.alignItems = Align.Center;
_readyTitle = new Label("EXPEDITION");
_readyTitle.style.fontSize = 16;
_readyTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
box.Add(_readyTitle);
_readyPipRow = new VisualElement();
_readyPipRow.style.flexDirection = FlexDirection.Row;
_readyPipRow.style.justifyContent = Justify.Center;
_readyPipRow.style.marginTop = 6; _readyPipRow.style.marginBottom = 8;
box.Add(_readyPipRow);
_readyBtn = MenuUi.Button("READY UP [T]", ReadySendSystem.ToggleReady);
box.Add(_readyBtn);
_readyPanel.Add(box);
root.Add(_readyPanel);
}
// ---- boss presence bar (Boss rooms only; red, top-center, under the macro banner) ----
void UpdateBossBar(bool alive, float hp, float max)
{
if (!alive || max <= 0f)
{
if (_bossPanel != null) _bossPanel.style.display = DisplayStyle.None;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_bossBarBuilt)
{
BuildBossBar(root);
_bossBarBuilt = true;
}
HudUi.SetFill(_bossFill, Mathf.Clamp01(hp / max));
_bossText.text = "ALPHA HUSK " + Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(max);
_bossPanel.style.display = DisplayStyle.Flex;
}
void BuildBossBar(VisualElement root)
{
_bossPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_bossPanel.style.position = Position.Absolute;
_bossPanel.style.left = 0; _bossPanel.style.right = 0;
_bossPanel.style.top = 168;
_bossPanel.style.alignItems = Align.Center;
_bossPanel.style.display = DisplayStyle.None;
var col = HudUi.Group(Align.Center);
_bossText = HudUi.Display("ALPHA HUSK", 22, new Color(1f, 0.35f, 0.3f), TextAnchor.MiddleCenter);
col.Add(_bossText);
var bar = HudUi.Bar(420, 12, new Color(0.92f, 0.22f, 0.18f), out _bossFill);
bar.style.marginTop = 4;
col.Add(bar);
_bossPanel.Add(col);
root.Add(_bossPanel);
}
// ---- run-depth dots (visible through the whole run; the current room pulses bigger) ----
void UpdateRunDepth(RunInfo runInfo, bool haveRun)
{
bool show = haveRun && runInfo.RoomCount > 0
&& (runInfo.Lifecycle == RunLifecycle.InRoom
|| runInfo.Lifecycle == RunLifecycle.RoomReward
|| runInfo.Lifecycle == RunLifecycle.RouteSelect);
if (!show)
{
if (_depthPanel != null) _depthPanel.style.display = DisplayStyle.None;
_depthShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_depthBuilt)
{
_depthPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_depthPanel.style.position = Position.Absolute;
_depthPanel.style.left = 0; _depthPanel.style.right = 0;
_depthPanel.style.top = 208; // below the macro cluster (goal/core) AND the boss bar (168)
_depthPanel.style.flexDirection = FlexDirection.Row;
_depthPanel.style.justifyContent = Justify.Center;
root.Add(_depthPanel);
_depthBuilt = true;
}
int sig = 1 + runInfo.CurrentRoom * 37 + runInfo.RoomCount * 3;
if (_depthShownFor != sig)
{
_depthPanel.Clear();
for (int i = 0; i < runInfo.RoomCount; i++)
{
bool current = i == runInfo.CurrentRoom;
bool done = i < runInfo.CurrentRoom;
var dot = new VisualElement { pickingMode = PickingMode.Ignore };
float size = current ? 12f : 8f;
dot.style.width = size; dot.style.height = size;
dot.style.marginLeft = 3; dot.style.marginRight = 3;
dot.style.alignSelf = Align.Center;
MenuUi.Round(dot, size * 0.5f);
dot.style.backgroundColor = current ? new Color(0.55f, 0.85f, 1f)
: done ? new Color(0.55f, 0.85f, 1f, 0.55f)
: new Color(1f, 1f, 1f, 0.16f);
_depthPanel.Add(dot);
}
_depthShownFor = sig;
}
_depthPanel.style.display = DisplayStyle.Flex;
}
void UpdateMetaShop(bool show, byte classId, int aether,
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
{
if (!show)
{
if (_metaPanel != null) _metaPanel.style.display = DisplayStyle.None;
_metaShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_metaShopBuilt)
{
BuildMetaShop(root);
_metaShopBuilt = true;
}
// Rebuild the rows only when class / owned tiers / affordability actually change (Staging-only, <=8 rows).
int sig = classId * 131 ^ aether * 31;
for (int i = 0; i < record.Length; i++)
sig ^= (record[i].ClassId * 7 + record[i].UpgradeId * 13 + record[i].Tier) * (i + 3);
if (sig == 0) sig = 1;
if (_metaShownFor != sig)
{
_metaRowsHost.Clear();
_metaShopTitle.text = (classId == ClassTraits.RangerClass ? "RANGER" : "WARRIOR")
+ " PERMANENT UPGRADES - AETHER " + aether;
ref var defs = ref pool.Value;
byte classBit = BoonMath.MaskFor(classId);
for (int d = 0; d < defs.Defs.Length; d++)
{
if ((defs.Defs[d].ClassMask & classBit) == 0) continue;
byte id = defs.Defs[d].Id;
byte owned = MetaMath.TierOf(record, classId, id);
if (owned > defs.Defs[d].MaxTier) owned = defs.Defs[d].MaxTier; // D-F5 display clamp (seed AND spend AND shop)
bool maxed = owned >= defs.Defs[d].MaxTier;
int cost = MetaMath.CostForTier(in defs.Defs[d], owned);
string label = defs.Defs[d].Name.ToString()
+ (maxed ? " MAXED" : " - " + cost + " Aether")
+ "\n" + defs.Defs[d].Desc.ToString();
byte buyId = id; // closure copy, never the loop variable
var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId));
row.style.width = 290;
row.style.height = StyleKeyword.Auto; // two-line labels must grow the row (overlap fix)
row.style.paddingTop = 6; row.style.paddingBottom = 6;
row.style.marginBottom = 4;
row.style.whiteSpace = WhiteSpace.Normal;
row.style.unityTextAlign = TextAnchor.MiddleLeft;
row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway
// Owned-tier pips (replaces the "[2/5]" text — reads at a glance).
var pipRow = new VisualElement { pickingMode = PickingMode.Ignore };
pipRow.style.flexDirection = FlexDirection.Row;
pipRow.style.marginTop = 3;
for (int p = 0; p < defs.Defs[d].MaxTier; p++)
{
var tp = new VisualElement { pickingMode = PickingMode.Ignore };
tp.style.width = 9; tp.style.height = 9;
tp.style.marginRight = 3;
MenuUi.Round(tp, 4.5f);
tp.style.backgroundColor = p < owned ? AetherCyan : new Color(1f, 1f, 1f, 0.14f);
pipRow.Add(tp);
}
row.Add(pipRow);
_metaRowsHost.Add(row);
}
_metaShownFor = sig;
}
_metaPanel.style.display = DisplayStyle.Flex;
}
void BuildMetaShop(VisualElement root)
{
_metaPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_metaPanel.style.position = Position.Absolute;
_metaPanel.style.right = 12;
_metaPanel.style.top = Length.Percent(22);
_metaPanel.style.alignItems = Align.FlexEnd;
_metaPanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 12; box.style.paddingRight = 12;
box.style.paddingTop = 10; box.style.paddingBottom = 10;
_metaShopTitle = new Label("PERMANENT UPGRADES");
_metaShopTitle.style.color = AetherCyan;
_metaShopTitle.style.fontSize = 14;
_metaShopTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
_metaShopTitle.style.marginBottom = 8;
box.Add(_metaShopTitle);
_metaRowsHost = new VisualElement();
box.Add(_metaRowsHost);
_metaPanel.Add(box);
root.Add(_metaPanel);
}
}
}