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
@@ -38,13 +38,14 @@ namespace ProjectM.Client
UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily)
GameObject _ghost; // translucent ground preview cube
Material _ghostMat;
MeshFilter _ghostMf; // ghost mesh swaps to the selected structure's preview mesh (HudTheme)
MeshRenderer _ghostMr;
Mesh _cubeMesh; // procedural fallback (the original preview cube)
byte _ghostType = 255; // last-applied preview type (255 = unset)
byte _lastSelected; // skip placing on the frame a palette click changes the selection
// DR-042 C6a: the ability-upgrade send is RUNTIME (the HUD Aether button calls UpgradeAbility); only the
// execute_code PLACE statics stay editor-gated. Mirrors EquipSendSystem's unconditional queue + drain.
static int s_PendingUpgrades = 0;
/// <summary>Runtime hook (HUD Aether button) + execute_code: queue an ability-damage upgrade.</summary>
public static void UpgradeAbility() => s_PendingUpgrades++;
// (Step 11: UpgradeAbility/s_PendingUpgrades RETIRED with the AbilityUpgradeRequest wire — in-run boons +
// the base meta-shop replaced the Aether damage upgrade.)des++;
#if UNITY_EDITOR
struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; }
@@ -113,17 +114,8 @@ namespace ProjectM.Client
foreach (var (key, type) in s_BuildHotkeys)
if (keyboard[key].wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell))
SendBuild(connection, type, cell.x, cell.y, type == StructureType.Conveyor ? s_ConveyorDir : (byte)0);
if (keyboard.uKey.wasPressedThisFrame)
SendUpgrade(connection);
}
// DR-042 C6a: the ability-upgrade drain runs at RUNTIME (the HUD Aether button enqueues via UpgradeAbility);
// only the execute_code PLACE drain stays editor-gated.
while (s_PendingUpgrades > 0)
{
s_PendingUpgrades--;
SendUpgrade(connection);
}
#if UNITY_EDITOR
while (s_PendingBuild.Count > 0)
{
@@ -185,7 +177,7 @@ namespace ProjectM.Client
byte reason = BuildPreviewMath.Evaluate(anchor, targetCell, occupied, LedgerOre(), cost);
bool valid = reason == BuildPreviewMath.Valid;
ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid);
ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid, sel);;
// Place on a left-click (valid, not the selecting click).
if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame)
@@ -208,16 +200,44 @@ namespace ProjectM.Client
return int.MaxValue;
}
// ---- Ground ghost preview (procedural translucent cube, like AimReticleSystem's reticle) ----
void ShowGhost(float3 center, float cellSize, bool valid)
// ---- Ground ghost preview: the selected structure's REAL mesh (HudTheme, build-safe serialized refs)
// tinted translucent green/red; falls back to the original procedural cube when no mesh is authored. ----
void ShowGhost(float3 center, float cellSize, bool valid, byte type)
{
EnsureGhost();
_ghost.transform.position = (Vector3)center + Vector3.up * 0.5f;
_ghost.transform.localScale = new Vector3(cellSize * 0.9f, 1f, cellSize * 0.9f);
ApplyGhostMesh(type);
if (_ghostMf.sharedMesh == _cubeMesh)
{
_ghost.transform.position = (Vector3)center + Vector3.up * 0.5f;
_ghost.transform.localScale = new Vector3(cellSize * 0.9f, 1f, cellSize * 0.9f);
}
else
{
// preview meshes are authored real-size with a ground pivot (SM_Turret_01 / SM_Wall_01 / SM_Fabricator_01)
_ghost.transform.position = (Vector3)center;
_ghost.transform.localScale = Vector3.one;
}
_ghostMat.color = valid ? new Color(0.3f, 1f, 0.45f, 0.35f) : new Color(1f, 0.32f, 0.26f, 0.35f);
if (!_ghost.activeSelf) _ghost.SetActive(true);
}
// Swap the ghost's mesh when the palette selection changes (255 = unset sentinel; no selection is 0 -> cube).
void ApplyGhostMesh(byte type)
{
if (type == _ghostType) return;
_ghostType = type;
var theme = HudTheme.Get();
Mesh mesh = theme != null ? theme.StructureGhostMesh(type) : null;
if (mesh == null) mesh = _cubeMesh;
_ghostMf.sharedMesh = mesh;
if (_ghostMr.sharedMaterials.Length != mesh.subMeshCount)
{
var mats = new Material[mesh.subMeshCount]; // one translucent mat per submesh so the whole preview tints
for (int i = 0; i < mats.Length; i++) mats[i] = _ghostMat;
_ghostMr.sharedMaterials = mats;
}
}
void HideGhost()
{
if (_ghost != null && _ghost.activeSelf) _ghost.SetActive(false);
@@ -233,10 +253,13 @@ namespace ProjectM.Client
_ghost.name = "~BuildGhost";
var col = _ghost.GetComponent<Collider>();
if (col != null) Object.Destroy(col);
var mr = _ghost.GetComponent<MeshRenderer>();
mr.sharedMaterial = _ghostMat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
_ghostMf = _ghost.GetComponent<MeshFilter>();
_cubeMesh = _ghostMf.sharedMesh;
_ghostType = 255;
_ghostMr = _ghost.GetComponent<MeshRenderer>();
_ghostMr.sharedMaterial = _ghostMat;
_ghostMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_ghostMr.receiveShadows = false;
_ghost.SetActive(false);
}
@@ -270,12 +293,6 @@ namespace ProjectM.Client
EntityManager.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ, Direction = direction });
EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection });
}
void SendUpgrade(Entity connection)
{
var e = EntityManager.CreateEntity();
EntityManager.AddComponentData(e, new AbilityUpgradeRequest());
EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection });
}
// (Step 11: the Aether ability-upgrade sender was RETIRED with AbilityUpgradeRequest — boons replaced it.)
}
}
@@ -0,0 +1,49 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side boon-pick sender: a static enqueue (the Step-14 3-card modal / execute_code) drained into
/// <see cref="BoonPickRequest"/> RPCs. Carries only the option INDEX — the server resolves it against the
/// sender's own authoritative <c>BoonOffer</c> and validates lifecycle/pending, so a stale or forged pick is
/// simply dropped. Statics reset on play-enter (the stale-bridge hazard).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class BoonSendSystem : SystemBase
{
static int s_Pending;
static byte s_PendingIndex;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
s_Pending = 0;
s_PendingIndex = 0;
}
/// <summary>Queue a boon pick (0/1/2). The HUD card click + execute_code drive this.</summary>
public static void PickBoon(byte optionIndex)
{
s_PendingIndex = optionIndex;
s_Pending++;
}
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
while (s_Pending > 0)
{
s_Pending--;
var req = EntityManager.CreateEntity(typeof(BoonPickRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new BoonPickRequest { Index = s_PendingIndex });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d7e90bc7230614845817b81f0f7b70f0
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 60aece22e94371c468102dbf35c3fe47
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,41 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side meta-purchase sender: a static enqueue (the Step-14 base meta-shop panel / execute_code) drained
/// into <see cref="MetaSpendRequest"/> RPCs. Carries only the upgrade ID — the tier is server-computed and the
/// server re-validates everything (Staging gate, class mask, MaxTier, prereq, Aether affordability), so a stale
/// or forged request is simply dropped. A real QUEUE (unlike the single-slot boon pick) — two rapid clicks on
/// two different shop rows must both arrive. Statics reset on play-enter (the stale-bridge hazard).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class MetaSpendSendSystem : SystemBase
{
static readonly Queue<byte> s_Queue = new();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() => s_Queue.Clear();
/// <summary>Queue a permanent-upgrade purchase by catalog id. The shop row click + execute_code drive this.</summary>
public static void RequestPurchase(byte upgradeId) => s_Queue.Enqueue(upgradeId);
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
while (s_Queue.Count > 0)
{
var req = EntityManager.CreateEntity(typeof(MetaSpendRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new MetaSpendRequest { UpgradeId = s_Queue.Dequeue() });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9f3197b4ede6dea41a6c4c93ffa51b42
@@ -102,9 +102,9 @@ namespace ProjectM.Client
case Mine: return attack + " — Attack the glowing Ore to mine it";
case Build: return build + " — open Build, then place a Turret by your Core";
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
case Gate: return "Reach the Expedition Gate — clearing it charges the Engine";
case Clear: return "Clear the zone — defeat every enemy";
case Return: return "Return through the gatebank your clear (+1 Engine charge)";
case Gate: return "Press T to READY UP — when everyone is ready, the run launches";
case Clear: return "Clear each room — pick a boon, choose your path, reach the boss";
case Return: return "Fell the bossyou return home and the Engine charges (+1)";;
case Defend: return "Defend the Core! — hold the line through the siege";
case Done: return "You've got it. Clear expeditions to fill the Engine and win.";
default: return "";
@@ -215,13 +215,9 @@ namespace ProjectM.Client
}
return found;
}
// base gate (go) lives in the base region; expedition gate (return) lives past the region split.
bool wantBase = kind == OnboardingStepMath.PointerBaseGate;
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<ExpeditionGate>())
{
var p = lt.ValueRO.Position;
if ((p.x < ExpeditionRegionXMin) == wantBase) { target = p; return true; }
}
// Step 11: the walk-in ExpeditionGate was RETIRED (the ready-check launches runs), so gate pointers
// have no world target — hide the arrow; the step text still teaches. Step 14's HUD pass re-points
// onboarding at the READY panel instead.
return false;
}
@@ -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));
@@ -94,7 +94,7 @@ namespace ProjectM.Client
Body(c, "Turret (40 Ore) — auto-fires at enemies. Needs Charge as ammo.");
Body(c, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing.");
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
Body(c, "Aether — spend it on UPGRADE DMG to boost your damage.");
Body(c, "Aether — rare; fuels PERMANENT class upgrades at the base between runs.");
Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it.");
break;
case 3: // Threats
@@ -55,6 +55,11 @@ namespace ProjectM.Client
public Sprite FabricatorIcon;
public Sprite ConveyorIcon;
[Header("Build-ghost preview meshes (authored real-size, ground pivot — BuildSendSystem)")]
public Mesh TurretGhostMesh;
public Mesh WallGhostMesh;
public Mesh FabricatorGhostMesh;
[Header("Build-mode control glyphs")]
public Sprite KbmPlace; // LMB
public Sprite KbmCancel; // RMB
@@ -91,6 +96,18 @@ namespace ProjectM.Client
}
}
/// <summary>Placement-ghost preview mesh for a <see cref="StructureType"/> byte (null → the cube fallback).</summary>
public Mesh StructureGhostMesh(byte type)
{
switch (type)
{
case StructureType.Turret: return TurretGhostMesh;
case StructureType.Wall: return WallGhostMesh;
case StructureType.Fabricator: return FabricatorGhostMesh;
default: return null;
}
}
// ---- cached SDF font definitions (one FontAsset per font, built once, reset per play session) ----
static FontAsset _displayFa, _bodyFa, _bodyLightFa;
static bool _displayTried, _bodyTried, _bodyLightTried;
@@ -120,7 +120,16 @@ namespace ProjectM.Client
if (data == null) return;
var em = server.EntityManager;
var e = em.CreateEntity();
em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, RunOutcome = (byte)data.RunOutcome, HasData = 1 });
// v5->v6 migration (operator-approved): an old save's Charge counted boss-cleared runs (DR-042), so a
// missing RunsCompleted floors to it — the HUD never shows "Charge 3/4" beside "Runs completed: 0".
int runsCompleted = data.RunsCompleted > data.GoalCharge ? data.RunsCompleted : data.GoalCharge;
em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, RunOutcome = (byte)data.RunOutcome, RunsCompleted = runsCompleted, MaxDepthReached = data.MaxDepthReached, HasData = 1 });
// v6: stage the meta tiers UNCONDITIONALLY (empty OK — the Bursted spawn system GetBuffers it in the
// HasData block; a conditional buffer would throw on any v<=5 Continue). Rows verbatim, no clamping.
var mbuf = em.AddBuffer<PendingMetaRow>(e);
if (data.MetaUpgrades != null)
foreach (var mrow in data.MetaUpgrades)
mbuf.Add(new PendingMetaRow { ClassId = mrow.ClassId, UpgradeId = mrow.UpgradeId, Tier = mrow.Tier });
var buf = em.AddBuffer<PendingSaveLedgerRow>(e);
if (data.Ledger != null)
foreach (var row in data.Ledger)
@@ -167,6 +176,9 @@ namespace ProjectM.Client
var st = tq.GetSingleton<NetworkTime>().ServerTick;
if (st.IsValid) nowTick = st.TickIndexForValidTick;
}
// v6: the permanent-meta slice via the ONE shared collector — omitting it HERE (the most common
// exit path) would silently WIPE all meta progression on quit (the meta review's top blocker).
MetaSaveScan.Collect(em, dir, out var metaRows, out var runsCompleted, out var maxDepth);
SaveStructureScan.Collect(em, nowTick, out var structures, out var structureIo);
SaveService.Save(new SaveData
@@ -174,6 +186,9 @@ namespace ProjectM.Client
GoalCharge = goal.Charge,
GoalTarget = goal.Target,
CoreCurrent = core.Current,
RunsCompleted = runsCompleted,
MaxDepthReached = maxDepth,
MetaUpgrades = metaRows,
RunOutcome = outcome.Value,
Ledger = rows,
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 23831c9502abad541a3b2a3dc65502c2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,59 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side ready-toggle sender: a static enqueue (HUD button at Step 14 / the T dev key / execute_code)
/// drained into <see cref="ReadyToggleRequest"/> RPC entities — the BuildSendSystem queue+drain idiom. The local
/// bool tracks only the toggle DIRECTION; the server-replicated <see cref="PlayerReady"/> is the truth the HUD
/// renders. Statics reset on play-enter (statics survive fast-enter-playmode reloads — the stale-bridge hazard).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class ReadySendSystem : SystemBase
{
static int s_Pending; // queued explicit sets
static byte s_PendingValue;
static bool s_LocalReady; // last requested state (toggle direction only, not authority)
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
s_Pending = 0;
s_PendingValue = 0;
s_LocalReady = false;
}
/// <summary>Queue an explicit ready set (HUD button / execute_code).</summary>
public static void SetReady(bool ready)
{
s_PendingValue = (byte)(ready ? 1 : 0);
s_Pending++;
s_LocalReady = ready;
}
/// <summary>Queue a toggle of the last requested state (the T dev key; HUD replaces this at Step 14).</summary>
public static void ToggleReady() => SetReady(!s_LocalReady);
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
var keyboard = UnityEngine.InputSystem.Keyboard.current;
if (keyboard != null && keyboard.tKey.wasPressedThisFrame && !PauseMenuController.Open)
ToggleReady();
while (s_Pending > 0)
{
s_Pending--;
var req = EntityManager.CreateEntity(typeof(ReadyToggleRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new ReadyToggleRequest { Ready = s_PendingValue });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7047cb8f6861ba8498f33698c9948dc8
@@ -0,0 +1,60 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side route-pick sender: a static enqueue (the Step-14 map panel's option buttons / execute_code)
/// drained into <see cref="RouteSelectRequest"/> RPCs. The request is stamped from the CLIENT's replicated
/// <see cref="RunInfo"/>: <c>ForRunEpoch = (int)RunSeed</c> (the re-meaned run-identity token — the server-only
/// RunEpoch is not client-knowable) and <c>ForLayer = CurrentRoom</c> VERBATIM (during a gate that is still the
/// just-cleared layer — never +1). The server re-validates everything; a stale/possessed pick is simply dropped.
/// Statics reset on play-enter (the stale-bridge hazard).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class RouteSendSystem : SystemBase
{
static int s_Pending;
static byte s_PendingIndex;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
s_Pending = 0;
s_PendingIndex = 0;
}
/// <summary>Queue a route pick (0..RouteOptionCount-1). HUD map panel + execute_code drive this.</summary>
public static void PickRoute(byte optionIndex)
{
s_PendingIndex = optionIndex;
s_Pending++;
}
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
RequireForUpdate<RunInfo>();
}
protected override void OnUpdate()
{
if (s_Pending == 0)
return;
var runInfo = SystemAPI.GetSingleton<RunInfo>();
while (s_Pending > 0)
{
s_Pending--;
var req = EntityManager.CreateEntity(typeof(RouteSelectRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new RouteSelectRequest
{
OptionIndex = s_PendingIndex,
ForRunEpoch = (int)runInfo.RunSeed, // the re-meaned replicated run token
ForLayer = runInfo.CurrentRoom, // the gate's un-incremented cleared layer
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c22921cccedc3564b86d12491d88488e