This commit is contained in:
2026-07-04 16:57:40 -07:00
parent 16e396841e
commit c6b7890212
47 changed files with 1713 additions and 209 deletions
@@ -72,9 +72,9 @@ namespace ProjectM.Client
// 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, _routeBtnRow;
VisualElement _routePanel;
Label _routeTitle;
byte _boonShownFor; // last (Option0^Option1^Option2 ^ room) signature the modal was built for
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).
@@ -82,6 +82,28 @@ namespace ProjectM.Client
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();
@@ -97,6 +119,8 @@ namespace ProjectM.Client
// 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)
@@ -194,25 +218,21 @@ namespace ProjectM.Client
switch (runInfo.Lifecycle)
{
case RunLifecycle.Staging:
{
// Client counts the party's replicated ready flags (send-to-all) for the N/M readout.
int total = 0, readyCount = 0;
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>())
{
total++;
if (pr.ValueRO.Value != 0) readyCount++;
}
_locationText.text = "READY UP [T] - " + readyCount + "/" + Mathf.Max(total, 1)
+ " ready - launch a run to advance the Engine";
// 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:
{
uint nowTick = SystemAPI.TryGetSingleton<NetworkTime>(out var ntime) && ntime.ServerTick.IsValid
? ntime.ServerTick.TickIndexForValidTick : 0u;
int secs = runInfo.LaunchTick != 0 && nowTick != 0
? Mathf.Max(0, (int)((runInfo.LaunchTick - nowTick) / 60u) + 1) : 0;
// 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;
@@ -272,9 +292,72 @@ namespace ProjectM.Client
BlobAssetReference<BoonCatalogBlob> boonPool = default;
if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat))
boonPool = bcat.Value;
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1, boonPool);
// 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
&& (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 — client heuristic: a Boss room spawns exactly ONE enemy (the boss), so any live
// EnemyTag Health in a Boss-type room is it. Replicated state only; no netcode surface.
bool bossAlive = false;
float bossHp = 0f, bossMax = 0f;
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
{
foreach (var bhq in SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag>())
{
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))
{
@@ -388,6 +471,14 @@ namespace ProjectM.Client
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);
@@ -397,6 +488,32 @@ namespace ProjectM.Client
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;
}
@@ -487,6 +604,18 @@ namespace ProjectM.Client
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
@@ -1022,7 +1151,12 @@ namespace ProjectM.Client
{
_downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
}
_downed.Add(HudUi.Display("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
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);
}
@@ -1042,17 +1176,32 @@ namespace ProjectM.Client
_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.
// PLAY AGAIN restarts a fresh Single run via the proven menu lifecycle; QUIT TO MENU reuses the teardown.
// Both self-guard on WorldLauncher.Busy. The row picks (Position) even though the banner root Ignores.
// 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;
var again = MenuUi.Button("PLAY AGAIN", () => WorldLauncher.StartSession(SessionMode.Single, null, false));
again.style.marginRight = 12;
btnRow.Add(again);
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
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;
@@ -1230,7 +1379,6 @@ namespace ProjectM.Client
// ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ====
byte _routeShownFor; // last (room ^ options) signature the route buttons were built for
static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]"
: roomType == RoomTypeId.Elite ? "[ELITE]"
@@ -1253,8 +1401,9 @@ namespace ProjectM.Client
}
// Rebuild the three cards only when the offer actually changes (a new room's deal).
byte sig = (byte)(offer.Option0 ^ (offer.Option1 * 3) ^ (offer.Option2 * 7));
if (sig == 0) sig = 1;
// 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();
@@ -1265,10 +1414,18 @@ namespace ProjectM.Client
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 = 96;
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;
@@ -1312,6 +1469,25 @@ namespace ProjectM.Client
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).
@@ -1319,7 +1495,7 @@ namespace ProjectM.Client
if (!show)
{
if (_routePanel != null) _routePanel.style.display = DisplayStyle.None;
_routeShownFor = 0;
_routeMapSig = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
@@ -1330,23 +1506,15 @@ namespace ProjectM.Client
_routePanelBuilt = true;
}
byte sig = (byte)((runInfo.CurrentRoom + 1) ^ (runInfo.RouteOpt0Type * 3)
^ (runInfo.RouteOpt1Type * 5) ^ (runInfo.RouteOpt2Type * 7) ^ runInfo.RouteOptionCount);
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 (_routeShownFor != sig)
if (_routeMapSig != sig)
{
_routeBtnRow.Clear();
for (byte k = 0; k < runInfo.RouteOptionCount && k < 3; k++)
{
byte type = k == 2 ? runInfo.RouteOpt2Type : k == 1 ? runInfo.RouteOpt1Type : runInfo.RouteOpt0Type;
byte pick = k; // closure copy
var b = MenuUi.Button("→ " + RoomTypeLabel(type), () => RouteSendSystem.PickRoute(pick));
b.style.marginLeft = 6;
b.style.marginRight = 6;
_routeBtnRow.Add(b);
}
RebuildRouteMap(runInfo);
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
_routeShownFor = sig;
_routeMapSig = sig;
}
_routePanel.style.display = DisplayStyle.Flex;
}
@@ -1356,33 +1524,299 @@ namespace ProjectM.Client
_routePanel = new VisualElement { pickingMode = PickingMode.Ignore };
_routePanel.style.position = Position.Absolute;
_routePanel.style.left = 0; _routePanel.style.right = 0;
_routePanel.style.bottom = 120;
_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();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.94f);
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 = 16; box.style.paddingRight = 16;
box.style.paddingTop = 10; box.style.paddingBottom = 12;
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 = 15;
_routeTitle.style.fontSize = 16;
_routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
_routeTitle.style.marginBottom = 8;
_routeTitle.style.marginBottom = 10;
box.Add(_routeTitle);
_routeBtnRow = new VisualElement();
_routeBtnRow.style.flexDirection = FlexDirection.Row;
box.Add(_routeBtnRow);
_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)
{
@@ -1417,18 +1851,35 @@ namespace ProjectM.Client
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() + " [" + owned + "/" + defs.Defs[d].MaxTier + "]"
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;