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
@@ -29,7 +29,9 @@ namespace ProjectM.Server
cfgRef.ValueRW.Requested = false;
var endpoint = NetworkEndpoint.AnyIpv4.WithPort(cfgRef.ValueRO.Port);
var endpoint = cfgRef.ValueRO.Address.ToString() == "127.0.0.1"
? NetworkEndpoint.LoopbackIpv4.WithPort(cfgRef.ValueRO.Port) // single-player: bind loopback only (no firewall prompt)
: NetworkEndpoint.AnyIpv4.WithPort(cfgRef.ValueRO.Port); // host: accept LAN peers
var ecb = new EntityCommandBuffer(Allocator.Temp);
var req = ecb.CreateEntity();
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6aa6ed6723d75a64796d926f11f63236
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,60 @@
using System;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Host-only autosave writer. A managed <see cref="SystemBase"/> (file IO =&gt; NO Burst) that reacts to the
/// <see cref="SaveRequest"/> flag the Bursted <c>CyclePhaseSystem</c> raises on the Siege-&gt;Calm checkpoint:
/// reads the authoritative <see cref="GoalProgress"/> + shared resource ledger off the CycleDirector ghost,
/// writes the JSON save (<see cref="SaveService"/>), then clears the flag. ServerSimulation-only, so a pure
/// (Join) client never writes. Deliberately carries NO <c>[UpdateAfter(CyclePhaseSystem)]</c> (that would risk
/// a sort-cycle); a one-tick-late autosave is irrelevant.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial class SaveWriteSystem : SystemBase
{
protected override void OnCreate()
{
RequireForUpdate<SaveRequest>();
RequireForUpdate<NetworkTime>();
}
protected override void OnUpdate()
{
var dir = SystemAPI.GetSingletonEntity<SaveRequest>();
var req = SystemAPI.GetComponent<SaveRequest>(dir);
if (req.Pending == 0)
return;
req.Pending = 0;
SystemAPI.SetComponent(dir, req);
var goal = SystemAPI.HasComponent<GoalProgress>(dir)
? SystemAPI.GetComponent<GoalProgress>(dir)
: default;
// The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer).
var buffer = SystemAPI.GetBuffer<StorageEntry>(dir);
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 + their production tick-state / inventory (single shared scan).
uint nowTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
SaveStructureScan.Collect(EntityManager, nowTick, out var structures, out var structureIo);
SaveService.Save(new SaveData
{
GoalCharge = goal.Charge,
GoalTarget = goal.Target,
Ledger = rows,
Structures = structures,
StructureIo = structureIo,
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a08738b815484f4499b9ab614698c3e6
@@ -58,6 +58,24 @@ namespace ProjectM.Server
});
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
ecb.AddComponent(director, new ThreatState());
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity))
{
var pending = SystemAPI.GetComponent<PendingSave>(pendingEntity);
if (pending.HasData != 0)
{
ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = pending.GoalTarget });
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
var destLedger = ecb.SetBuffer<StorageEntry>(director);
SaveApply.WriteLedger(srcLedger, destLedger);
}
ecb.DestroyEntity(pendingEntity);
}
// Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint.
ecb.AddComponent(director, new SaveRequest { Pending = 0 });
}
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
@@ -99,6 +99,9 @@ namespace ProjectM.Server
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge += 1;
SystemAPI.SetComponent(cycleEntity, goal);
// Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag).
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
}
}
}