Files
Project-M/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs
T
kronic 9f61f7c6fe LANTERN kit into the shipping scene + frame rename (audit H2/M4)
H2 was the audit's sharpest finding: in Game.unity every player spawned
with four ability sockets pointing at SparkIds the baked AbilityDatabase
did not contain, so all four resolved to Damage=0 Range=0 Cooldown=0.
Melee and dash were the only working combat verbs in the built game.
Cause: the 5 LANTERN Sparks were added to GymSub.unity and never to
Gameplay.unity.

- Gameplay.unity's AbilityDatabaseAuthoring now carries all 9 defs (the
  4 legacy ids keep their numbers; Sparks are 5-9) with their effect
  prefabs. Live-verified in Play: sockets now read
  Vortex 8dmg/6range/420cd, Blink 20range/1cd, Hook & Pull
  15dmg/25range/120cd, Light Zone 6dmg/5range/480cd.
- Removed 6 orphaned authoring GameObjects the purge left behind in the
  subscene (StorageSpawner, StructureCatalog, ItemDatabase,
  SpitterProjectileConfig, BoonCatalog, MetaCatalog) and the RoomDressing
  object in Game.unity — the latter is what scattered 47 Synty desert
  props into the seabed murk.
- Deleted 4 now-unreferenced prefabs: EnemySpit, Pylon, Storage, Wall.
- Frame rename (M4): FrameKind.Warrior/Ranger -> Bathynaut/Harpooner,
  93 identifier sites. Byte values pinned (2/3), so no ghost-hash or save
  impact. The menu said "Warrior"/"Ranger" to players three weeks after
  the frames were renamed in design.
- Menu now reads LANTERN / "Light is territory — co-op descent" instead
  of "PROJECT M" / "Frontier colony — co-op" (the Awakening-Engine
  tagline, two directions stale).
- Removed the "Replay Tutorial" button and the Settings ONBOARDING
  section: both drove coach-marks DR-051 deleted. The settings fields
  still round-trip so existing settings files load unchanged.

All four project scenes + all prefabs verified free of missing scripts.
Server measured at 59.6 ticks/s against a 60 Hz target. 295/295 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:11:58 -07:00

197 lines
8.9 KiB
C#

using System;
using System.Collections;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace ProjectM.Client
{
public enum SessionMode { Single, Host, Join }
/// <summary>
/// The SINGLE funnel for netcode world lifecycle driven by the menu, so a connection/handshake fix lives in
/// one place. <see cref="StartSession"/> creates the right worlds (Single/Host = server+client, Join =
/// client-only) via the public <c>ClientServerBootstrap.Create*World</c> helpers (which register the
/// ServerWorld/ClientWorld statics), seeds the existing <see cref="ConnectionConfig"/> request component,
/// optionally stages a save (Continue), then loads Game.unity worlds-first (the subscene-streaming
/// invariant). <see cref="TeardownToMenu"/> autosaves (host), disposes all worlds, and returns to MainMenu.
/// Every dispose/scene step runs at a frame boundary on <see cref="SessionRunner"/> — never in an ECS system.
/// </summary>
public static class WorldLauncher
{
public const ushort Port = 7979;
const string GameScene = "Game";
const string MenuScene = "MainMenu";
const string Loopback = "127.0.0.1";
public static bool Busy { get; private set; }
/// <summary>Slice 2: the frame chosen in the menu (a FrameKind byte), seeded into the client world at session start.</summary>
public static byte SelectedClass = (byte)FrameKind.Bathynaut;
/// <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));
}
public static void TeardownToMenu()
{
if (Busy) return;
Busy = true;
SessionRunner.Run(TeardownRoutine());
}
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();
yield return null;
World server = null;
World client = ClientServerBootstrap.CreateClientWorld("ClientWorld");
SeedClass(client, SelectedClass); // Slice 2: stage the chosen class for GoInGameClientSystem -> spawn
if (mode == SessionMode.Join)
{
Seed(client, ConnectionMode.Join, string.IsNullOrWhiteSpace(joinIp) ? Loopback : joinIp.Trim(), Port);
}
else
{
server = ClientServerBootstrap.CreateServerWorld("ServerWorld");
string bind = mode == SessionMode.Single ? Loopback : "0.0.0.0"; // Single binds loopback (no firewall)
Seed(server, ConnectionMode.Host, bind, Port);
Seed(client, ConnectionMode.Join, Loopback, Port);
if (loadSave) StagePendingSave(server);
}
World.DefaultGameObjectInjectionWorld = server ?? client;
// Worlds exist -> loading Game.unity streams its SubScene into them.
SceneManager.LoadScene(GameScene, LoadSceneMode.Single);
Busy = false;
}
static IEnumerator TeardownRoutine()
{
var server = ClientServerBootstrap.ServerWorld;
if (server is { IsCreated: true })
TrySaveFromServer(server);
yield return null;
World.DisposeAllWorlds();
yield return null;
SceneManager.LoadScene(MenuScene, LoadSceneMode.Single);
Busy = false;
}
static void Seed(World world, byte mode, string address, ushort port)
{
if (world is not { IsCreated: true }) return;
var em = world.EntityManager;
using var q = em.CreateEntityQuery(ComponentType.ReadWrite<ConnectionConfig>());
Entity e = q.IsEmptyIgnoreFilter ? em.CreateEntity(typeof(ConnectionConfig)) : q.GetSingletonEntity();
em.SetComponentData(e, new ConnectionConfig
{
Mode = mode,
Address = address,
Port = port,
Requested = true,
});
}
static void SeedClass(World world, byte classId)
{
if (world is not { IsCreated: true }) return;
var em = world.EntityManager;
using var q = em.CreateEntityQuery(ComponentType.ReadWrite<ClassSelection>());
Entity e = q.IsEmptyIgnoreFilter ? em.CreateEntity(typeof(ClassSelection)) : q.GetSingletonEntity();
em.SetComponentData(e, new ClassSelection { ClassId = classId });
}
static void StagePendingSave(World server)
{
var data = SaveService.Load();
if (data == null) return;
var em = server.EntityManager;
var e = em.CreateEntity();
em.AddComponentData(e, new PendingSave { RunsCompleted = data.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)
buf.Add(new PendingSaveLedgerRow { ItemId = (ushort)row.ItemId, Count = row.Count });
// M7: stage player-built structures on a SEPARATE carrier (BaseRestoreSystem owns its lifecycle; the
// PendingSave entity above is consumed + destroyed by CycleDirectorSpawnSystem at director spawn).
if (data.Structures != null && data.Structures.Length > 0)
{
var se = em.CreateEntity();
var sbuf = em.AddBuffer<PendingStructure>(se);
foreach (var s in data.Structures)
sbuf.Add(SaveApply.ToPending(s)); // EB-1: pure mapping (unit-tested, incl. the wounded HP)
}
}
static void TrySaveFromServer(World server)
{
try
{
var em = server.EntityManager;
em.CompleteAllTrackedJobs();
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<ResourceLedger>());
if (q.IsEmptyIgnoreFilter) return;
var dir = q.GetSingletonEntity();
var buffer = em.GetBuffer<StorageEntry>(dir, true);
var rows = new LedgerRow[buffer.Length];
for (int i = 0; i < buffer.Length; i++)
rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count };
// M7: also persist player-built structures (same shared scan as the autosave path).
uint nowTick = 0;
using (var tq = em.CreateEntityQuery(ComponentType.ReadOnly<NetworkTime>()))
if (!tq.IsEmptyIgnoreFilter)
{
var st = tq.GetSingleton<NetworkTime>().ServerTick;
if (st.IsValid) nowTick = st.TickIndexForValidTick;
}
// 2026-08-07 audit purge: the quit-to-menu save used to collect the permanent-meta slice and the
// placed structures too. Both layers are deleted; the ledger is the whole save now.
SaveService.Save(new SaveData
{
Ledger = rows,
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
}
catch (Exception e)
{
Debug.LogWarning($"[WorldLauncher] Quit-to-menu save skipped: {e.Message}");
}
}
}
}