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:
@@ -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->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
|
||||
Reference in New Issue
Block a user