Polishes
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user