Frontend menu + settings + saves foundation

Netcode frontend pattern: UITK main menu / pause / settings (MenuUi + controllers), on-demand world lifecycle (WorldLauncher/SessionRunner), GameBootstrap menu branch; Graphics/Audio settings (SettingsService/GameVolume); single-slot save foundation (SaveData/SaveService, born-correct load at director spawn, autosave on Siege->Calm + quit); RuntimePanelSettings + theme; BuildTool menu; 10 EditMode tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:05:36 -07:00
parent f3f65bccbf
commit f31ffe910b
56 changed files with 1744 additions and 8 deletions
@@ -10,7 +10,7 @@ namespace ProjectM.Simulation
/// <para>
/// M4 (LAN co-op): auto-connect is DISABLED (<see cref="ClientServerBootstrap.AutoConnectPort"/> = 0).
/// Listening/connecting is driven explicitly via the <see cref="ConnectionConfig"/> singleton and the
/// per-world ConnectionControlSystems — from <c>ConnectionUI</c> (Host / Join + IP) in player builds,
/// per-world ConnectionControlSystems — from the UITK frontend menu (<c>MainMenuController</c> → <c>WorldLauncher</c>, Host / Join + IP) in player builds,
/// or from the editor-only <c>EditorAutoHostSystem</c>, which auto-hosts on loopback and connects the
/// in-proc client plus any Multiplayer-PlayMode-Tools thin clients. Direct IP/LAN only for now; Unity
/// Relay is deferred to a later pass.
@@ -21,10 +21,34 @@ namespace ProjectM.Simulation
{
public override bool Initialize(string defaultWorldName)
{
// No auto-connect: ConnectionConfig + the ConnectionControlSystems own listen/connect (M4).
// No auto-connect: the menu (or, in the editor, the auto-host system) owns listen/connect.
AutoConnectPort = 0;
CreateDefaultClientServerWorlds();
return true;
#if UNITY_EDITOR
// Editor: keep today's instant-into-game + MPPM loop by DEFAULT. Only the MAIN editor
// (ClientAndServer) with the "Boot Into Menu (Editor)" toggle ON takes the frontend path, so MPPM
// virtual players (Client) never boot to the menu. Open MainMenu.unity + Play to test the menu.
bool bootMenu = UnityEditor.EditorPrefs.GetBool("ProjectM.BootIntoMenu", false)
&& RequestedPlayType == PlayType.ClientAndServer;
if (!bootMenu)
{
CreateDefaultClientServerWorlds();
return true;
}
return false; // Frontend: Entities makes a single default "menu" world; MainMenuController drives sessions.
#else
// Player build: a dedicated/headless server auto-hosts; everyone else boots the front-end menu.
if (RequestedPlayType == PlayType.Server)
{
var server = CreateServerWorld("ServerWorld");
World.DefaultGameObjectInjectionWorld = server;
var em = server.EntityManager;
var e = em.CreateEntity();
em.AddComponentData(e, new ConnectionConfig { Mode = ConnectionMode.Host, Address = "0.0.0.0", Port = 7979, Requested = true });
return true;
}
return false; // Frontend menu (MainMenu.unity is build index 0).
#endif
}
}
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 82c8fb2f6f68a864fbbdf60b8c634439
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,19 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure save-apply helpers shared by the server spawn system (born-correct load) and EditMode tests.
/// Burst-safe: unmanaged, non-generic, no enums (avoids the cross-assembly-generic Burst ICE class).
/// </summary>
public static class SaveApply
{
/// <summary>Replace a StorageEntry ledger buffer's contents with a staged PendingSaveLedgerRow buffer.</summary>
public static void WriteLedger(DynamicBuffer<PendingSaveLedgerRow> src, DynamicBuffer<StorageEntry> dest)
{
dest.Clear();
for (int i = 0; i < src.Length; i++)
dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count });
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b187f01051a0d9e45b30b8f1c4c1e4c1
@@ -0,0 +1,62 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Server-world, UNMANAGED bridge holding a save slice the menu staged for a "Continue" session, applied
/// AT SPAWN by the server CycleDirectorSpawnSystem so the director ghost is BORN correct — it never
/// serializes a default <see cref="GoalProgress"/> / empty ledger to clients (no replication flicker). The
/// menu creates exactly one of these (with the <see cref="PendingSaveLedgerRow"/> buffer) in the freshly
/// created ServerWorld BEFORE the gameplay subscene streams in; the spawn system consumes + destroys it.
/// Unmanaged so the Bursted spawn system reads it without a managed bridge.
/// </summary>
public struct PendingSave : IComponentData
{
public int GoalCharge;
public int GoalTarget;
/// <summary>0 = nothing staged (New Game); non-zero = apply the staged slice at director spawn.</summary>
public byte HasData;
}
/// <summary>One staged ledger row for a Continue session; copied into the director's StorageEntry buffer at spawn.</summary>
public struct PendingSaveLedgerRow : IBufferElementData
{
public ushort ItemId;
public int Count;
}
/// <summary>One staged player-built structure row for a Continue session (M7); BaseRestoreSystem replays it
/// charge-free into the freshly-streamed base. Mirrors <see cref="StructureSave"/> but as an unmanaged ECS
/// buffer element (staged in the ServerWorld before the subscene streams).</summary>
public struct PendingStructure : IBufferElementData
{
public byte Type;
public int CellX;
public int CellZ;
public byte Direction;
public uint RemainingTicks;
public byte ConveyorResId;
public int ConveyorCount;
}
/// <summary>One staged machine I/O row (M7), joined to the <see cref="PendingStructure"/> buffer by index.
/// Slot 0 = MachineInput, 1 = MachineOutput.</summary>
public struct PendingStructureIo : IBufferElementData
{
public int StructureIndex;
public byte Slot;
public byte ResourceId;
public int Count;
}
/// <summary>
/// Host-only autosave request flag on the CycleDirector entity (added at spawn). The Bursted CyclePhaseSystem
/// sets <see cref="Pending"/>=1 on the Siege-&gt;Calm checkpoint; the managed SaveWriteSystem reads it, writes
/// the JSON save, and clears it. A plain byte => Burst-safe (no managed/string/file touch in the sim loop).
/// </summary>
public struct SaveRequest : IComponentData
{
public byte Pending;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ed817060ccb7b445b5f1ad094e10443
@@ -0,0 +1,63 @@
using System;
namespace ProjectM.Simulation
{
/// <summary>One serialized ledger row (item id + count). An array FIELD of <see cref="SaveData"/>.</summary>
[Serializable]
public struct LedgerRow
{
public int ItemId;
public int Count;
}
/// <summary>
/// One serialized player-built structure (M7). Flat scalars (JsonUtility has no int2). The production
/// cooldown is stored as REMAINING ticks (epoch-independent) so it survives the server-tick origin reset on a
/// fresh session; the in-flight conveyor item (if any) rides here, while variable-length machine I/O buffers
/// live in the flat <see cref="SaveData.StructureIo"/> table keyed by index.
/// </summary>
[Serializable]
public struct StructureSave
{
public byte Type;
public int CellX;
public int CellZ;
public byte Direction; // conveyor facing (0 for non-conveyors)
public uint RemainingTicks; // production/cooldown ticks left at save time
public byte ConveyorResId; // in-flight conveyor item resource (0 = none)
public int ConveyorCount;
}
/// <summary>
/// One serialized machine I/O buffer row, joined to <see cref="SaveData.Structures"/> by
/// <see cref="StructureIndex"/>. A flat top-level array (JsonUtility can't nest arrays-of-arrays); Slot 0 =
/// MachineInput, Slot 1 = MachineOutput.
/// </summary>
[Serializable]
public struct StructureIoRow
{
public int StructureIndex;
public byte Slot;
public byte ResourceId;
public int Count;
}
/// <summary>
/// Versioned, host-authoritative save slice (the FOUNDATION): the long-arc goal charge/target + the shared
/// resource ledger. JsonUtility-friendly — a class with flat fields and an array FIELD (never a root array).
/// The schema is intentionally ADDITIVE: future fields (placed structures, threat, storage) append without
/// breaking old saves, gated by <see cref="Version"/> migration.
/// </summary>
[Serializable]
public class SaveData
{
public const int CurrentVersion = 2;
public int Version = CurrentVersion;
public int GoalCharge;
public int GoalTarget;
public LedgerRow[] Ledger = Array.Empty<LedgerRow>();
public StructureSave[] Structures = Array.Empty<StructureSave>();
public StructureIoRow[] StructureIo = Array.Empty<StructureIoRow>();
public long SavedAtMs;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c2f5cc5646cf9ee4b94d7cebaefb8f30
@@ -0,0 +1,61 @@
using System;
using System.IO;
using UnityEngine;
namespace ProjectM.Simulation
{
/// <summary>
/// Host-local persistence for the game save slice (<see cref="SaveData"/>) — single slot, versioned JSON at
/// <c>Application.persistentDataPath/save_0.json</c>, atomic writes (temp + <c>File.Replace</c>). Read by the
/// menu (to offer "Continue" + stage a <see cref="PendingSave"/>) and the server SaveWriteSystem (autosave).
/// JsonUtility keeps it dependency-free. Returns null on a missing / corrupt / version-mismatched file —
/// never throws to callers (a bad save degrades to "New Game", it never crashes boot).
/// </summary>
public static class SaveService
{
static string FilePath => Path.Combine(Application.persistentDataPath, "save_0.json");
public static bool HasSave() => File.Exists(FilePath);
public static SaveData Load()
{
try
{
if (!File.Exists(FilePath)) return null;
var data = JsonUtility.FromJson<SaveData>(File.ReadAllText(FilePath));
if (data == null || data.Version != SaveData.CurrentVersion) return null;
data.Ledger ??= Array.Empty<LedgerRow>();
return data;
}
catch (Exception e)
{
Debug.LogWarning($"[SaveService] Load failed ({e.Message}); treating as no save.");
return null;
}
}
public static void Save(SaveData data)
{
if (data == null) return;
data.Version = SaveData.CurrentVersion;
try
{
var json = JsonUtility.ToJson(data, true);
var tmp = FilePath + ".tmp";
File.WriteAllText(tmp, json);
if (File.Exists(FilePath)) File.Replace(tmp, FilePath, null);
else File.Move(tmp, FilePath);
}
catch (Exception e)
{
Debug.LogWarning($"[SaveService] Save failed: {e.Message}");
}
}
public static void Delete()
{
try { if (File.Exists(FilePath)) File.Delete(FilePath); }
catch (Exception e) { Debug.LogWarning($"[SaveService] Delete failed: {e.Message}"); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4aca4b85393065f45a5c5ff87f35e428
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Scans a server world for PLAYER-built structures (<see cref="PlacedStructure"/> + <see cref="RuntimePlacedTag"/>)
/// into the flat SaveData v2 arrays — the SINGLE shared scan used by BOTH the autosave (SaveWriteSystem) and the
/// quit-to-menu save (WorldLauncher), so the two paths can never drift (only RuntimePlacedTag structures are saved;
/// anything baked into the subscene is the subscene's source of truth, not the save's). Cooldowns are stored as
/// REMAINING ticks (epoch-independent). Managed (List/array) — runs only on a save, never in the hot loop.
/// </summary>
public static class SaveStructureScan
{
public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures, out StructureIoRow[] io)
{
var structs = new List<StructureSave>();
var ioRows = new List<StructureIoRow>();
using var q = em.CreateEntityQuery(
ComponentType.ReadOnly<PlacedStructure>(),
ComponentType.ReadOnly<RuntimePlacedTag>());
using var entities = q.ToEntityArray(Allocator.Temp);
for (int k = 0; k < entities.Length; k++)
{
var e = entities[k];
var ps = em.GetComponentData<PlacedStructure>(e);
int idx = structs.Count;
var row = new StructureSave
{
Type = ps.Type,
CellX = ps.Cell.x,
CellZ = ps.Cell.y,
RemainingTicks = ProductionMath.RemainingTicks(ps.NextTick, nowTick),
};
if (em.HasComponent<Conveyor>(e))
row.Direction = em.GetComponentData<Conveyor>(e).Direction;
if (em.HasComponent<ConveyorItem>(e) && em.IsComponentEnabled<ConveyorItem>(e))
{
var item = em.GetComponentData<ConveyorItem>(e);
row.ConveyorResId = item.ResourceId;
row.ConveyorCount = item.Count;
}
structs.Add(row);
if (em.HasBuffer<MachineInput>(e))
{
var buf = em.GetBuffer<MachineInput>(e, true);
for (int i = 0; i < buf.Length; i++)
ioRows.Add(new StructureIoRow { StructureIndex = idx, Slot = 0, ResourceId = buf[i].ResourceId, Count = buf[i].Count });
}
if (em.HasBuffer<MachineOutput>(e))
{
var buf = em.GetBuffer<MachineOutput>(e, true);
for (int i = 0; i < buf.Length; i++)
ioRows.Add(new StructureIoRow { StructureIndex = idx, Slot = 1, ResourceId = buf[i].ResourceId, Count = buf[i].Count });
}
}
structures = structs.ToArray();
io = ioRows.ToArray();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47b137b2d90c6154d8c195f8c491f0d8