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
@@ -0,0 +1,104 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// Drives the front-end main menu (UI Toolkit). Lives on a GameObject (with a <c>UIDocument</c>) in
/// MainMenu.unity — build index 0. On <see cref="Awake"/> it ENSURES a default "menu" world exists (the
/// bootstrap creates one on first boot, but on return-from-game <c>World.DisposeAllWorlds</c> left none and
/// <c>Initialize</c> does not re-run), ensures the EventSystem, and assigns the shared PanelSettings; on
/// enable it builds the menu. Single/Host/Join hand off to <see cref="WorldLauncher"/>.
/// </summary>
[RequireComponent(typeof(UIDocument))]
public class MainMenuController : MonoBehaviour
{
UIDocument _doc;
VisualElement _mainPanel;
VisualElement _settingsPanel;
TextField _ipField;
void Awake()
{
EnsureMenuWorld();
MenuUi.EnsureEventSystem();
_doc = GetComponent<UIDocument>();
if (_doc.panelSettings == null)
_doc.panelSettings = MenuUi.LoadPanelSettings();
// The menu owns the cursor.
UnityEngine.Cursor.lockState = CursorLockMode.None;
UnityEngine.Cursor.visible = true;
}
void OnEnable()
{
if (_doc == null) _doc = GetComponent<UIDocument>();
var root = _doc.rootVisualElement;
if (root == null) return;
root.Clear();
BuildMain(root);
}
static void EnsureMenuWorld()
{
var w = World.DefaultGameObjectInjectionWorld;
if (w == null || !w.IsCreated)
DefaultWorldInitialization.Initialize("MenuWorld", false);
}
void BuildMain(VisualElement root)
{
_mainPanel = MenuUi.FullScreenRoot(true);
var card = MenuUi.Card("PROJECT M");
card.Add(MenuUi.Caption("Frontier colony — co-op"));
card.Add(MenuUi.Button("Single Player", () => Launch(SessionMode.Single, false)));
if (SaveService.HasSave())
card.Add(MenuUi.Button("Continue", () => Launch(SessionMode.Single, true)));
card.Add(MenuUi.Button("Host Co-op (LAN)", () => Launch(SessionMode.Host, SaveService.HasSave())));
_ipField = new TextField("Join IP") { value = "127.0.0.1" };
_ipField.style.marginTop = 8;
card.Add(_ipField);
card.Add(MenuUi.Button("Join", () => Launch(SessionMode.Join, false)));
card.Add(MenuUi.Button("Settings", ShowSettings));
card.Add(MenuUi.Button("Quit", Quit));
_mainPanel.Add(card);
root.Add(_mainPanel);
}
void Launch(SessionMode mode, bool loadSave)
{
string ip = _ipField != null ? _ipField.value : "127.0.0.1";
WorldLauncher.StartSession(mode, ip, loadSave);
}
void ShowSettings()
{
_mainPanel.style.display = DisplayStyle.None;
_settingsPanel = SettingsScreen.Build(HideSettings);
_doc.rootVisualElement.Add(_settingsPanel);
}
void HideSettings()
{
if (_settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
_mainPanel.style.display = DisplayStyle.Flex;
}
static void Quit()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bb953c16db2c778418e6c1c049c003aa
+118
View File
@@ -0,0 +1,118 @@
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem.UI;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// Shared UI Toolkit helpers for the code-built menu / settings / pause screens — a small palette + styled
/// element factories (so every screen reads in one visual language) plus runtime plumbing: loading the
/// shared <c>PanelSettings</c> (from Resources) and ensuring an <c>EventSystem</c> with an
/// <c>InputSystemUIInputModule</c> exists. Runtime UITK receives pointer/click events THROUGH the
/// EventSystem, and this project uses the Input System (1.19.0), so the module must be the Input-System one,
/// not the legacy StandaloneInputModule — without it the buttons are silently dead.
/// </summary>
public static class MenuUi
{
public static readonly Color Bg = new(0.04f, 0.05f, 0.07f, 0.96f);
public static readonly Color PanelBg = new(0.09f, 0.11f, 0.15f, 0.98f);
public static readonly Color Accent = new(0.30f, 0.85f, 1f, 1f); // Aether cyan
public static readonly Color TextCol = new(0.86f, 0.92f, 0.97f, 1f);
public static readonly Color SubCol = new(0.60f, 0.68f, 0.76f, 1f);
public static PanelSettings LoadPanelSettings()
=> Resources.Load<PanelSettings>("RuntimePanelSettings");
public static void EnsureEventSystem()
{
if (UnityEngine.Object.FindFirstObjectByType<EventSystem>() != null) return;
var go = new GameObject("EventSystem");
go.AddComponent<EventSystem>();
go.AddComponent<InputSystemUIInputModule>();
}
public static VisualElement FullScreenRoot(bool dim)
{
var root = new VisualElement();
root.style.position = Position.Absolute;
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
root.style.alignItems = Align.Center;
root.style.justifyContent = Justify.Center;
if (dim) root.style.backgroundColor = Bg;
return root;
}
public static VisualElement Card(string titleText)
{
var card = new VisualElement();
card.style.backgroundColor = PanelBg;
card.style.paddingLeft = 28; card.style.paddingRight = 28;
card.style.paddingTop = 22; card.style.paddingBottom = 22;
card.style.minWidth = 380;
card.style.alignItems = Align.Stretch;
Round(card, 10);
Border(card, new Color(Accent.r, Accent.g, Accent.b, 0.25f), 1);
if (!string.IsNullOrEmpty(titleText)) card.Add(Title(titleText));
return card;
}
public static Label Title(string text)
{
var l = new Label(text);
l.style.unityFontStyleAndWeight = FontStyle.Bold;
l.style.fontSize = 26;
l.style.color = Accent;
l.style.unityTextAlign = TextAnchor.MiddleCenter;
l.style.marginBottom = 16;
return l;
}
public static Label Caption(string text)
{
var l = new Label(text);
l.style.fontSize = 13;
l.style.color = SubCol;
l.style.unityTextAlign = TextAnchor.MiddleCenter;
l.style.marginTop = 8; l.style.marginBottom = 6;
return l;
}
public static Button Button(string text, Action onClick)
{
var b = new Button(onClick) { text = text };
b.style.height = 40;
b.style.fontSize = 16;
b.style.marginTop = 5; b.style.marginBottom = 5;
b.style.color = TextCol;
b.style.backgroundColor = new Color(0.16f, 0.20f, 0.27f, 1f);
b.style.unityFontStyleAndWeight = FontStyle.Bold;
Round(b, 6);
Border(b, new Color(0f, 0f, 0f, 0f), 0);
return b;
}
public static Button SmallButton(string text, Action onClick)
{
var b = Button(text, onClick);
b.style.width = 36; b.style.height = 30;
b.style.marginLeft = 4; b.style.marginRight = 4;
return b;
}
public static void Round(VisualElement e, float r)
{
e.style.borderTopLeftRadius = r; e.style.borderTopRightRadius = r;
e.style.borderBottomLeftRadius = r; e.style.borderBottomRightRadius = r;
}
public static void Border(VisualElement e, Color c, float w)
{
e.style.borderLeftWidth = w; e.style.borderRightWidth = w;
e.style.borderTopWidth = w; e.style.borderBottomWidth = w;
e.style.borderLeftColor = c; e.style.borderRightColor = c;
e.style.borderTopColor = c; e.style.borderBottomColor = c;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f2a83d2b10895944492bb7441e8308e1
@@ -0,0 +1,87 @@
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// In-game pause overlay (UI Toolkit), spawned by <see cref="PauseMenuSystem"/> in the client world. Esc
/// toggles it; Resume / Settings / Quit-to-Menu / Quit-to-Desktop. Quit-to-Menu hands off to
/// <see cref="WorldLauncher.TeardownToMenu"/> (autosave + dispose worlds + load MainMenu). Builds its own
/// UIDocument in code (shared PanelSettings from Resources) above the HUD; the scene swap on Quit-to-Menu
/// destroys it.
/// </summary>
public class PauseMenuController : MonoBehaviour
{
UIDocument _doc;
VisualElement _root;
VisualElement _pausePanel;
VisualElement _settingsPanel;
bool _open;
/// <summary>True while the pause overlay is shown (BuildSendSystem suspends build-clicks while paused).</summary>
public static bool Open;
void Awake()
{
MenuUi.EnsureEventSystem();
_doc = gameObject.AddComponent<UIDocument>();
_doc.panelSettings = MenuUi.LoadPanelSettings();
_doc.sortingOrder = 100; // above the in-game HUD
}
void Start()
{
_root = _doc.rootVisualElement;
Build();
SetOpen(false);
}
void Update()
{
var kb = UnityEngine.InputSystem.Keyboard.current;
if (kb != null && kb.escapeKey.wasPressedThisFrame)
SetOpen(!_open);
}
void Build()
{
_pausePanel = MenuUi.FullScreenRoot(false);
_pausePanel.style.backgroundColor = new Color(0.02f, 0.03f, 0.05f, 0.72f);
var card = MenuUi.Card("PAUSED");
card.Add(MenuUi.Button("Resume", () => SetOpen(false)));
card.Add(MenuUi.Button("Settings", ShowSettings));
card.Add(MenuUi.Button("Quit to Menu", WorldLauncher.TeardownToMenu));
card.Add(MenuUi.Button("Quit to Desktop", Quit));
_pausePanel.Add(card);
_root.Add(_pausePanel);
}
void SetOpen(bool open)
{
_open = open;
Open = open;
if (_pausePanel != null) _pausePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
if (!open && _settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
if (open) { UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; }
}
void ShowSettings()
{
_pausePanel.style.display = DisplayStyle.None;
_settingsPanel = SettingsScreen.Build(() =>
{
if (_settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
_pausePanel.style.display = DisplayStyle.Flex;
});
_root.Add(_settingsPanel);
}
static void Quit()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 76b9e91f569010b4aaf703ce756a4b1c
@@ -0,0 +1,26 @@
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only bootstrap that ensures the in-game pause overlay (Esc) exists once a session is running.
/// Mirrors how HudSystem creates its UI at runtime — spawns one <see cref="PauseMenuController"/> GameObject
/// in the active (Game) scene; the scene swap on Quit-to-Menu destroys it, and a fresh session's new client
/// world spawns a new one. Client world only, so the menu's plain default world never spawns a pause overlay.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class PauseMenuSystem : SystemBase
{
bool _spawned;
protected override void OnUpdate()
{
if (_spawned) return;
_spawned = true;
new GameObject("~PauseMenu").AddComponent<PauseMenuController>();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0bbe1a1c153da4c4499841a023262e9e
@@ -0,0 +1,32 @@
using System.Collections;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Persistent (<c>DontDestroyOnLoad</c>) MonoBehaviour that hosts the world-lifecycle coroutines for
/// <see cref="WorldLauncher"/>. World create/dispose + scene loads must run at a FRAME BOUNDARY (never
/// inside an ECS system update — that crashes), and must survive the MainMenu&lt;-&gt;Game scene swap, so a
/// single long-lived runner owns them. Created lazily on first use.
/// </summary>
public class SessionRunner : MonoBehaviour
{
static SessionRunner _instance;
static SessionRunner Instance
{
get
{
if (_instance == null)
{
var go = new GameObject("~SessionRunner");
DontDestroyOnLoad(go);
_instance = go.AddComponent<SessionRunner>();
}
return _instance;
}
}
public static void Run(IEnumerator routine) => Instance.StartCoroutine(routine);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e2af19327c034684da1997a5478f7e31
@@ -0,0 +1,128 @@
using System;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// Code-built UI Toolkit settings panel (Graphics + Audio) shared by the main menu and the in-game pause
/// overlay. Binds to <see cref="SettingsService"/>: edits a working copy, previews audio live, and persists
/// + applies on Apply/Back. Enum settings use compact "&lt; value &gt;" cycle rows (robust, no dropdown-popup
/// theming); volumes use sliders with a live percentage readout.
/// </summary>
public static class SettingsScreen
{
public static VisualElement Build(Action onBack)
{
var working = SettingsService.Current;
var root = MenuUi.FullScreenRoot(true);
var card = MenuUi.Card("SETTINGS");
card.style.minWidth = 480;
root.Add(card);
// ---------------- Graphics ----------------
card.Add(MenuUi.Caption("— GRAPHICS —"));
var resolutions = Screen.resolutions;
int resIndex = FindResIndex(resolutions, working.ResWidth, working.ResHeight);
card.Add(CycleRow("Resolution",
() => resolutions.Length > 0 ? ResLabel(resolutions[Mathf.Clamp(resIndex, 0, resolutions.Length - 1)]) : $"{working.ResWidth}x{working.ResHeight}",
dir =>
{
if (resolutions.Length == 0) return;
resIndex = Wrap(resIndex + dir, resolutions.Length);
var r = resolutions[resIndex];
working.ResWidth = r.width;
working.ResHeight = r.height;
working.RefreshHz = Mathf.RoundToInt((float)r.refreshRateRatio.value);
}));
string[] modes = { "Exclusive", "Borderless", "Maximized", "Windowed" };
card.Add(CycleRow("Display Mode",
() => modes[Mathf.Clamp(working.FullScreenMode, 0, 3)],
dir => working.FullScreenMode = Wrap(working.FullScreenMode + dir, 4)));
var qnames = QualitySettings.names;
card.Add(CycleRow("Quality",
() => qnames.Length > 0 ? qnames[Mathf.Clamp(working.QualityLevel, 0, qnames.Length - 1)] : "—",
dir => { if (qnames.Length > 0) working.QualityLevel = Wrap(working.QualityLevel + dir, qnames.Length); }));
string[] vsync = { "Off", "On", "Half" };
card.Add(CycleRow("V-Sync",
() => vsync[Mathf.Clamp(working.VSync, 0, 2)],
dir => working.VSync = Wrap(working.VSync + dir, 3)));
// ---------------- Audio ----------------
card.Add(MenuUi.Caption("— AUDIO —"));
card.Add(VolumeRow("Master", working.Master, v => { working.Master = v; AudioListener.volume = v; }));
card.Add(VolumeRow("Music", working.Music, v => { working.Music = v; GameVolume.Music = v; }));
card.Add(VolumeRow("SFX", working.Sfx, v => { working.Sfx = v; GameVolume.Sfx = v; }));
// ---------------- Buttons ----------------
var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working));
apply.style.backgroundColor = new Color(0.12f, 0.30f, 0.22f, 1f);
card.Add(apply);
card.Add(MenuUi.Button("Back", () =>
{
SettingsService.SaveAndApply(working);
onBack?.Invoke();
}));
return root;
}
static VisualElement CycleRow(string name, Func<string> getValue, Action<int> onCycle)
{
var row = Row(name);
var val = new Label(getValue());
val.style.flexGrow = 1;
val.style.unityTextAlign = TextAnchor.MiddleCenter;
val.style.color = MenuUi.TextCol;
val.style.fontSize = 15;
var dec = MenuUi.SmallButton("<", () => { onCycle(-1); val.text = getValue(); });
var inc = MenuUi.SmallButton(">", () => { onCycle(+1); val.text = getValue(); });
row.Add(dec); row.Add(val); row.Add(inc);
return row;
}
static VisualElement VolumeRow(string name, float initial, Action<float> onChange)
{
var row = Row(name);
var slider = new Slider(0f, 1f) { value = initial };
slider.style.flexGrow = 1;
var pct = new Label(Pct(initial));
pct.style.minWidth = 52;
pct.style.unityTextAlign = TextAnchor.MiddleRight;
pct.style.color = MenuUi.TextCol;
slider.RegisterValueChangedCallback(evt => { onChange(evt.newValue); pct.text = Pct(evt.newValue); });
row.Add(slider); row.Add(pct);
return row;
}
static VisualElement Row(string name)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.style.marginTop = 4; row.style.marginBottom = 4;
var label = new Label(name);
label.style.minWidth = 120;
label.style.color = MenuUi.SubCol;
label.style.fontSize = 14;
row.Add(label);
return row;
}
static string ResLabel(Resolution r) => $"{r.width} x {r.height} @ {Mathf.RoundToInt((float)r.refreshRateRatio.value)}";
static string Pct(float v) => Mathf.RoundToInt(v * 100f) + "%";
static int Wrap(int i, int n) => n <= 0 ? 0 : ((i % n) + n) % n;
static int FindResIndex(Resolution[] all, int w, int h)
{
for (int i = 0; i < all.Length; i++)
if (all[i].width == w && all[i].height == h) return i;
return Mathf.Max(0, all.Length - 1); // default to the highest available
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8cd9f577a41c1974796661abc14266d6
@@ -0,0 +1,176 @@
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; }
public static void StartSession(SessionMode mode, string joinIp, bool loadSave)
{
if (Busy) return;
Busy = true;
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)
{
// 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");
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, ConnectionMode 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 StagePendingSave(World server)
{
var data = SaveService.Load();
if (data == null) return;
var em = server.EntityManager;
var e = em.CreateEntity();
em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, HasData = 1 });
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(new PendingStructure
{
Type = s.Type, CellX = s.CellX, CellZ = s.CellZ, Direction = s.Direction,
RemainingTicks = s.RemainingTicks, ConveyorResId = s.ConveyorResId, ConveyorCount = s.ConveyorCount,
});
var iobuf = em.AddBuffer<PendingStructureIo>(se);
if (data.StructureIo != null)
foreach (var io in data.StructureIo)
iobuf.Add(new PendingStructureIo { StructureIndex = io.StructureIndex, Slot = io.Slot, ResourceId = io.ResourceId, Count = io.Count });
}
}
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 goal = em.HasComponent<GoalProgress>(dir) ? em.GetComponentData<GoalProgress>(dir) : default;
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;
}
SaveStructureScan.Collect(em, 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(),
});
}
catch (Exception e)
{
Debug.LogWarning($"[WorldLauncher] Quit-to-menu save skipped: {e.Message}");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 96a2c416d0d434a419db03355c08ffea