Run Re-Do

This commit is contained in:
2026-07-02 20:41:43 -07:00
parent 86575dd5bc
commit 16e396841e
188 changed files with 8291 additions and 2429 deletions
@@ -68,8 +68,20 @@ namespace ProjectM.Client
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
VisualElement _runBanner;
Label _runBannerText, _runBannerSub;
// DR-042 C6a: the Aether ability-upgrade button (was U-key only) + its live affordability tint.
Button _upgradeBtn;
// 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, _routeBtnRow;
Label _routeTitle;
byte _boonShownFor; // last (Option0^Option1^Option2 ^ room) 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
readonly List<VisualElement> _pips = new();
@@ -171,41 +183,97 @@ namespace ProjectM.Client
_cycleText.text = "";
}
// ---- Location + gate hint (banner sub-line) ----
var cam = Camera.main;
// ---- 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;
_locationText.text = onExpedition
? "ON EXPEDITION - carve the frontier, then return"
: 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 = onExpedition ? new Color(1f, 0.8f, 0.4f)
: finalSiege ? new Color(1f, 0.3f, 0.25f)
: siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
// DR-042 C7 (gate prompt) + C7b (objective readout): the expedition is the win-driver, so signpost it.
// Reads the REPLICATED ExpeditionObjective summary (cross-region safe). Lower priority than the siege /
// cold-turret / overrun overrides below, which still win.
if (SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj))
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj);
if (haveRun && !siege && !finalSiege)
{
if (onExpedition)
switch (runInfo.Lifecycle)
{
if (obj.State == ExpeditionObjectiveState.Cleared)
{ _locationText.text = "ZONE CLEARED - return to base to claim"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); }
else if (obj.State == ExpeditionObjectiveState.Active)
{ _locationText.text = "CLEAR THE ZONE - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); }
}
else if (!siege)
{
if (obj.State == ExpeditionObjectiveState.Cleared)
{ _locationText.text = "EXPEDITION CLEARED - return to claim your reward"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); }
else if (obj.State == ExpeditionObjectiveState.Active)
{ _locationText.text = "EXPEDITION IN PROGRESS - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); }
else
{ _locationText.text = "GO TO THE EXPEDITION GATE - clear a sortie to advance the Engine"; _locationText.style.color = new Color(0.55f, 0.85f, 1f); }
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";
_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;
_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;
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1, boonPool);
UpdateRoutePanel(haveRun ? runInfo : default);
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
@@ -255,9 +323,31 @@ namespace ProjectM.Client
_oreNum.text = ore.ToString();
_bioNum.text = bio.ToString();
_chargeNum.text = charge.ToString();
// ---- Step 14 (meta shop): Staging-only permanent-upgrade shop for the LOCAL class. Class derives from
// the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only); tiers from the
// replicated MetaTierState record on the director ghost; Aether from the ledger read above. ----
byte localClass = ClassTraits.WarriorClass;
bool haveLocalPlayer = false;
foreach (var ar in SystemAPI.Query<RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id);
haveLocalPlayer = true;
break;
}
bool metaShow = false;
BlobAssetReference<MetaUpgradeCatalogBlob> metaPool = default;
DynamicBuffer<MetaTierState> metaRecord = default;
if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true))
{
metaPool = metaCat.Value;
metaShow = true;
}
UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord);
// DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const).
if (_upgradeBtn != null)
_upgradeBtn.style.opacity = aether >= Tuning.AbilityUpgradeCostAmount ? 1f : 0.5f;
// (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)
@@ -848,9 +938,8 @@ namespace ProjectM.Client
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.
_upgradeBtn = MenuUi.Button("UPGRADE DMG (" + Tuning.AbilityUpgradeCostAmount + " AETHER)", BuildSendSystem.UpgradeAbility);
_upgradeBtn.style.marginLeft = 18;
strip.Add(_upgradeBtn);
// (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);
}
@@ -1138,5 +1227,243 @@ namespace ProjectM.Client
default: return "?";
}
}
// ==== 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]"
: 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).
byte sig = (byte)(offer.Option0 ^ (offer.Option1 * 3) ^ (offer.Option2 * 7));
if (sig == 0) sig = 1;
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 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.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);
}
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;
_routeShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_routePanelBuilt)
{
BuildRoutePanel(root);
_routePanelBuilt = true;
}
byte sig = (byte)((runInfo.CurrentRoom + 1) ^ (runInfo.RouteOpt0Type * 3)
^ (runInfo.RouteOpt1Type * 5) ^ (runInfo.RouteOpt2Type * 7) ^ runInfo.RouteOptionCount);
if (sig == 0) sig = 1;
if (_routeShownFor != 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);
}
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
_routeShownFor = 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.bottom = 120;
_routePanel.style.alignItems = Align.Center;
_routePanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.94f);
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.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.unityFontStyleAndWeight = FontStyle.Bold;
_routeTitle.style.marginBottom = 8;
box.Add(_routeTitle);
_routeBtnRow = new VisualElement();
_routeBtnRow.style.flexDirection = FlexDirection.Row;
box.Add(_routeBtnRow);
_routePanel.Add(box);
root.Add(_routePanel);
}
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);
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 + "]"
+ (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.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
_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);
}
}
}
@@ -37,6 +37,30 @@ namespace ProjectM.Client
Color expFog = cfg != null ? cfg.ExpeditionFogColor : new Color(0.851f, 0.549f, 0.227f, 1f);
float expDen = cfg != null ? cfg.ExpeditionFogDensity : 0.010f;
Color expAmb = cfg != null ? cfg.ExpeditionAmbientSky : new Color(0.910f, 0.769f, 0.604f, 1f);
// Step 14 (expedition redesign): the EXPEDITION-side palette keys on the replicated per-room biome
// (RunInfo.CurrentBiome) so every room re-themes — the camera-X blend still owns the base↔run fade.
// Palettes are code consts (cosmetic; per-biome config knobs can layer on later without churn).
if (SystemAPI.TryGetSingleton<ProjectM.Simulation.RunInfo>(out var runInfo)
&& runInfo.Lifecycle != ProjectM.Simulation.RunLifecycle.Staging)
{
switch (runInfo.CurrentBiome)
{
case ProjectM.Simulation.RoomBiomeId.Meadow:
expFog = new Color(0.62f, 0.82f, 0.62f, 1f); expDen = 0.008f;
expAmb = new Color(0.68f, 0.88f, 0.70f, 1f);
break;
case ProjectM.Simulation.RoomBiomeId.Cavern:
expFog = new Color(0.42f, 0.48f, 0.60f, 1f); expDen = 0.014f;
expAmb = new Color(0.50f, 0.56f, 0.72f, 1f);
break;
case ProjectM.Simulation.RoomBiomeId.Blight:
expFog = new Color(0.55f, 0.42f, 0.62f, 1f); expDen = 0.016f;
expAmb = new Color(0.62f, 0.50f, 0.70f, 1f);
break;
// Arid keeps the config/default orange above.
}
}
float x = _cam.transform.position.x;
float t = Mathf.Clamp01((x - (boundary - half)) / (2f * half));