304ee8c2a7
Portal-gated rooms: new RunLifecycle.RoomExplore loot window; room+nodes persist past the last kill; PortalInteractRequest -> server-only PortalCommand advances (client derives the portal pos). RunDirectorSystem moves teardown off the InRoom edge, relocates the boss-branch/route-gate into the RoomExplore exit, and advances an empty expedition immediately (incl. a boss clear). Class-at-base via a shared ClassSwapUtil (meta-band strip + per-class MetaTierState replay, so the base RPC and the dev SetClass can't drift); ClassSelectRequest Staging-gated. Per-run PREP buffs (PrepPurchaseRequest, PrepCatalog): once-per-run == the row's prep StatModifier band is present, TotalOf-before-Withdraw atomic, stripped on Returning beside the boon band. Health.Max promoted to [GhostField] (the one ghost-hash re-bake) so the boss + floating enemy HP bars read it directly. Feel: melee cone connect-thunk (C3), hit-stop throttle so a horde wipe can't stutter (C4), Spitter cornered-hold. 456/456 EditMode; two adversarial reviews (pre-code 13-confirmed folded; post-impl clean bar the RoomExplore boss-clear dead-time, fixed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2085 lines
104 KiB
C#
2085 lines
104 KiB
C#
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 Unity.Mathematics; // DR-046: portal proximity math (float3/.xz/math.distance)
|
|
|
|
|
|
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 now a [GhostField] (replicated x8 for the boss), so
|
|
// the fraction reads true directly.
|
|
bool bossAlive = false;
|
|
float bossHp = 0f, bossMax = 0f;
|
|
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
|
|
{
|
|
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 > bossMax)
|
|
{
|
|
bossMax = bhq.ValueRO.Max;
|
|
bossHp = bhq.ValueRO.Current;
|
|
bossAlive = bhq.ValueRO.Current > 0f;
|
|
}
|
|
}
|
|
}
|
|
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);
|
|
UpdateClassPanel(metaShow, localClass); // DR-046: base class pick (Staging)
|
|
UpdatePrepPanel(metaShow, ore, bio, aether); // DR-046: base prep loadout (Staging)
|
|
UpdatePortalPrompt(haveRun ? runInfo : default, haveRun); // DR-046: room-exit portal prompt (RoomExplore)
|
|
|
|
// 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;
|
|
}
|
|
|
|
// DR-046: base class-select + prep-loadout panels (Staging) + the room-exit portal prompt (RoomExplore).
|
|
VisualElement _classPanel, _prepPanel, _prepRowsHost;
|
|
Label _classTitle, _prepTitle, _portalPrompt;
|
|
Button _classWarBtn, _classRangerBtn;
|
|
bool _classPanelBuilt, _prepPanelBuilt, _portalBuilt;
|
|
int _classShownFor, _prepShownFor;
|
|
|
|
void UpdateClassPanel(bool show, byte classId)
|
|
{
|
|
if (!show) { if (_classPanel != null) _classPanel.style.display = DisplayStyle.None; _classShownFor = 0; return; }
|
|
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
|
if (!_classPanelBuilt) { BuildClassPanel(root); _classPanelBuilt = true; }
|
|
int sig = classId + 1;
|
|
if (_classShownFor != sig)
|
|
{
|
|
bool ranger = classId == ClassTraits.RangerClass;
|
|
_classWarBtn.text = ranger ? "WARRIOR" : "WARRIOR ✓";
|
|
_classRangerBtn.text = ranger ? "RANGER ✓" : "RANGER";
|
|
_classWarBtn.SetEnabled(ranger);
|
|
_classRangerBtn.SetEnabled(!ranger);
|
|
_classShownFor = sig;
|
|
}
|
|
_classPanel.style.display = DisplayStyle.Flex;
|
|
}
|
|
|
|
void BuildClassPanel(VisualElement root)
|
|
{
|
|
_classPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
|
_classPanel.style.position = Position.Absolute;
|
|
_classPanel.style.left = 12; _classPanel.style.top = Length.Percent(22);
|
|
_classPanel.style.display = DisplayStyle.None;
|
|
var box = new VisualElement();
|
|
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
|
|
MenuUi.Round(box, 10);
|
|
box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10;
|
|
_classTitle = new Label("CLASS");
|
|
_classTitle.style.color = MenuUi.Accent; _classTitle.style.fontSize = 14;
|
|
_classTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _classTitle.style.marginBottom = 8;
|
|
box.Add(_classTitle);
|
|
_classWarBtn = MenuUi.Button("WARRIOR", () => ClassSelectSendSystem.RequestClass(ClassTraits.WarriorClass));
|
|
_classWarBtn.style.marginBottom = 4; box.Add(_classWarBtn);
|
|
_classRangerBtn = MenuUi.Button("RANGER", () => ClassSelectSendSystem.RequestClass(ClassTraits.RangerClass));
|
|
box.Add(_classRangerBtn);
|
|
_classPanel.Add(box); root.Add(_classPanel);
|
|
}
|
|
|
|
void UpdatePrepPanel(bool show, int ore, int bio, int aether)
|
|
{
|
|
if (!show) { if (_prepPanel != null) _prepPanel.style.display = DisplayStyle.None; _prepShownFor = 0; return; }
|
|
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
|
if (!_prepPanelBuilt) { BuildPrepPanel(root); _prepPanelBuilt = true; }
|
|
uint boughtMask = 0;
|
|
foreach (var mods in SystemAPI.Query<DynamicBuffer<StatModifier>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
|
{
|
|
for (int m = 0; m < mods.Length; m++)
|
|
{
|
|
uint sid = mods[m].SourceId;
|
|
if (sid >= Tuning.PrepSourceIdBase && sid < Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan)
|
|
boughtMask |= (uint)(1 << (int)(sid - Tuning.PrepSourceIdBase));
|
|
}
|
|
break;
|
|
}
|
|
int sig = ore * 7 ^ bio * 13 ^ aether * 31 ^ (int)boughtMask * 101;
|
|
if (sig == 0) sig = 1;
|
|
if (_prepShownFor != sig)
|
|
{
|
|
_prepRowsHost.Clear();
|
|
for (int i = 0; i < PrepCatalog.Count; i++)
|
|
{
|
|
var r = PrepCatalog.Rows[i];
|
|
int have = r.CostResId == ResourceId.Aether ? aether : r.CostResId == ResourceId.Biomass ? bio : ore;
|
|
bool bought = (boughtMask & (uint)(1 << r.Id)) != 0;
|
|
string resName = r.CostResId == ResourceId.Aether ? "Aether" : r.CostResId == ResourceId.Biomass ? "Biomass" : "Ore";
|
|
string label = PrepLabel(r.Id) + (bought ? " BOUGHT" : " - " + r.Cost + " " + resName);
|
|
byte buyId = r.Id;
|
|
var row = MenuUi.Button(label, () => PrepPurchaseSendSystem.RequestPrep(buyId));
|
|
row.style.width = 240; row.style.marginBottom = 4;
|
|
row.style.whiteSpace = WhiteSpace.Normal; row.style.unityTextAlign = TextAnchor.MiddleLeft;
|
|
row.SetEnabled(!bought && have >= r.Cost);
|
|
_prepRowsHost.Add(row);
|
|
}
|
|
_prepShownFor = sig;
|
|
}
|
|
_prepPanel.style.display = DisplayStyle.Flex;
|
|
}
|
|
|
|
static string PrepLabel(byte id) => id == 0 ? "+30 Max HP" : id == 1 ? "+12% Move Speed"
|
|
: id == 2 ? "+20% Melee Damage" : "+20% Ranged Damage";
|
|
|
|
void BuildPrepPanel(VisualElement root)
|
|
{
|
|
_prepPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
|
_prepPanel.style.position = Position.Absolute;
|
|
_prepPanel.style.left = 12; _prepPanel.style.top = Length.Percent(45);
|
|
_prepPanel.style.display = DisplayStyle.None;
|
|
var box = new VisualElement();
|
|
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
|
|
MenuUi.Round(box, 10);
|
|
box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10;
|
|
_prepTitle = new Label("PREP LOADOUT (lasts the run)");
|
|
_prepTitle.style.color = MenuUi.Accent; _prepTitle.style.fontSize = 14;
|
|
_prepTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _prepTitle.style.marginBottom = 8;
|
|
box.Add(_prepTitle);
|
|
_prepRowsHost = new VisualElement(); box.Add(_prepRowsHost);
|
|
_prepPanel.Add(box); root.Add(_prepPanel);
|
|
}
|
|
|
|
void UpdatePortalPrompt(RunInfo runInfo, bool haveRun)
|
|
{
|
|
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
|
if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; }
|
|
bool show = false;
|
|
if (haveRun && runInfo.Lifecycle == RunLifecycle.RoomExplore
|
|
&& SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
|
{
|
|
float3 center = BaseGridMath.PlotCenter(anchor);
|
|
float3 portalPos = RegionMath.ExpeditionRoomOrigin(center, (byte)(runInfo.CurrentRoom & 1));
|
|
portalPos.z += Tuning.PortalOffsetZ;
|
|
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
|
{
|
|
if (math.distance(lt.ValueRO.Position.xz, portalPos.xz) <= Tuning.PortalInteractRange)
|
|
{
|
|
show = true;
|
|
var kb = UnityEngine.InputSystem.Keyboard.current;
|
|
if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
_portalPrompt.style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
|
|
}
|
|
|
|
void BuildPortalPrompt(VisualElement root)
|
|
{
|
|
_portalPrompt = HudUi.Display("PRESS E TO LEAVE — the haul comes home", 20, new Color(0.55f, 0.95f, 1f), TextAnchor.MiddleCenter);
|
|
_portalPrompt.style.position = Position.Absolute;
|
|
_portalPrompt.style.left = 0; _portalPrompt.style.right = 0; _portalPrompt.style.bottom = 240;
|
|
_portalPrompt.pickingMode = PickingMode.Ignore;
|
|
_portalPrompt.style.display = DisplayStyle.None;
|
|
root.Add(_portalPrompt);
|
|
}
|
|
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|