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
@@ -76,21 +76,24 @@ namespace ProjectM.Client
Body(c, "Build menu — Tab / Y");
Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)");
Body(c, "Deposit at base — G");
Body(c, "Ready up (launch a run) — T");
Body(c, "Boons / route map / shop — mouse click");
Body(c, "Inventory — I");
Body(c, "Pause — Esc");
break;
case 1: // The Loop
Head(c, "THE LOOP — expeditions are how you win");
Body(c, "1. BASE (Calm) — mine Ore, build Turrets + a Fabricator to defend your Engine Core.");
Body(c, "2. EXPEDITION — walk to the Gate and fight the wave. CLEARING it is the progress beat.");
Body(c, "3. RETURN — come home to bank the clear: +1 on the Engine meter.");
Body(c, "4. SIEGE — returning provokes a retaliation attack. Defend the Core!");
Body(c, "5. WIN — fill the Engine meter, then hold the final siege.");
Body(c, "The Engine meter (top of screen) is the goal — base sieges are a consequence, not the win.");
Head(c, "THE LOOP — expedition RUNS are how you win");
Body(c, "1. BASE build Turrets + a Fabricator around your Engine Core; spend Aether on PERMANENT class upgrades.");
Body(c, "2. READY UP [T] — when everyone is ready, the party launches into a multi-room run together.");
Body(c, "3. RUN — clear each room, pick 1 of 3 BOONS (this run only), choose your path on the map, mine the crystals.");
Body(c, "4. BOSS — fell it and the party returns with the haul: +1 on the Engine meter. A wipe banks nothing.");
Body(c, "5. SIEGE — a completed run provokes retaliation at base. Defend the Core!");
Body(c, "6. WIN — fill the Engine meter, then hold the final siege.");
break;
case 2: // Build & Economy
Head(c, "RESOURCES & BUILDING");
Body(c, "Ore — main currency. Mine it by attacking the glowing nodes at base.");
Body(c, "Ore — main currency. Attack the glowing crystals INSIDE runs and haul them home (they're scarce — spend well).");
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.");
@@ -101,7 +104,7 @@ namespace ProjectM.Client
Head(c, "THREATS");
Body(c, "Husks assault the base during a Siege — keep them off the Engine Core.");
Body(c, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends.");
Body(c, "Expeditions throw waves at you — clear them all to bank the win.");
Body(c, "Runs escalate room by room — elites guard the deep paths, a boss guards the end.");
Body(c, "Enemy variety scales the deeper you push.");
break;
default: // Win / Lose
@@ -80,6 +80,17 @@ namespace ProjectM.Client
_mainPanel = MenuUi.FullScreenRoot(true);
var card = MenuUi.Card("PROJECT M");
card.Add(MenuUi.Caption("Frontier colony — co-op"));
// Why the last session ended (connection watchdog / bad join address) — shown once, then cleared.
if (!string.IsNullOrEmpty(SessionEnd.Reason))
{
var reason = MenuUi.Caption(SessionEnd.Reason);
reason.style.color = new Color(1f, 0.55f, 0.35f);
reason.style.whiteSpace = WhiteSpace.Normal;
reason.style.maxWidth = 340;
SessionEnd.Reason = null;
card.Add(reason);
}
// Slice 2: class picker -> sets WorldLauncher.SelectedClass for the next session (Warrior melee / Ranger ranged).
_classLabel = new Label(ClassName(WorldLauncher.SelectedClass));
_classLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
@@ -31,10 +31,16 @@ namespace ProjectM.Client
/// <summary>Slice 2: the class chosen in the menu (a CharacterId byte), seeded into the client world at session start.</summary>
public static byte SelectedClass = (byte)CharacterId.Warrior;
/// <summary>The mode of the most recently started session — the HUD's outcome banner branches on it
/// (single: PLAY AGAIN restarts; co-op: the honest exit is a clean teardown for everyone).</summary>
public static SessionMode LastMode { get; private set; } = SessionMode.Single;
public static void StartSession(SessionMode mode, string joinIp, bool loadSave)
{
if (Busy) return;
Busy = true;
LastMode = mode;
SessionRunner.Run(StartRoutine(mode, joinIp, loadSave));
}
@@ -47,6 +53,12 @@ namespace ProjectM.Client
static IEnumerator StartRoutine(SessionMode mode, string joinIp, bool loadSave)
{
// A LIVE session restarting in place (the banner's PLAY AGAIN) checkpoints first — the teardown
// path saves, so this path must too or the terminal state (outcome, last meta buys) is dropped.
var liveServer = ClientServerBootstrap.ServerWorld;
if (liveServer is { IsCreated: true })
TrySaveFromServer(liveServer);
// Dispose the idle menu world so a netcode world can own DefaultGameObjectInjectionWorld (the
// subscene then streams into the netcode worlds, exactly as in the always-on bootstrap flow).
World.DisposeAllWorlds();
@@ -117,6 +129,10 @@ namespace ProjectM.Client
static void StagePendingSave(World server)
{
var data = SaveService.Load();
// A terminal (won/lost) save Continues as a FRESH campaign — base+meta kept, outcome/meter/core reset
// (else the dead outcome banner re-latches from the first snapshot).
SaveService.RollTerminalCampaignForward(data);
if (data == null) return;
var em = server.EntityManager;
var e = em.CreateEntity();