From f31ffe910b5989148f06ae6fc047697e70476233 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Sat, 6 Jun 2026 15:05:36 -0700 Subject: [PATCH] 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 --- Assets/UI Toolkit.meta | 8 + Assets/UI Toolkit/UnityThemes.meta | 8 + .../UnityThemes/UnityDefaultRuntimeTheme.tss | 1 + .../UnityDefaultRuntimeTheme.tss.meta | 12 ++ Assets/_Project/Resources.meta | 8 + .../Resources/RuntimePanelSettings.asset | 52 ++++++ .../Resources/RuntimePanelSettings.asset.meta | 8 + Assets/_Project/Resources/RuntimeTheme.tss | 1 + .../_Project/Resources/RuntimeTheme.tss.meta | 12 ++ .../Client/Presentation/AmbientAudioSystem.cs | 6 +- Assets/_Project/Scripts/Client/Settings.meta | 8 + .../Scripts/Client/Settings/GameSettings.cs | 71 +++++++ .../Client/Settings/GameSettings.cs.meta | 2 + .../Scripts/Client/Settings/GameVolume.cs | 33 ++++ .../Client/Settings/GameVolume.cs.meta | 2 + .../Client/Settings/SettingsService.cs | 116 ++++++++++++ .../Client/Settings/SettingsService.cs.meta | 2 + Assets/_Project/Scripts/Client/UI.meta | 8 + .../Scripts/Client/UI/MainMenuController.cs | 104 +++++++++++ .../Client/UI/MainMenuController.cs.meta | 2 + Assets/_Project/Scripts/Client/UI/MenuUi.cs | 118 ++++++++++++ .../_Project/Scripts/Client/UI/MenuUi.cs.meta | 2 + .../Scripts/Client/UI/PauseMenuController.cs | 87 +++++++++ .../Client/UI/PauseMenuController.cs.meta | 2 + .../Scripts/Client/UI/PauseMenuSystem.cs | 26 +++ .../Scripts/Client/UI/PauseMenuSystem.cs.meta | 2 + .../Scripts/Client/UI/SessionRunner.cs | 32 ++++ .../Scripts/Client/UI/SessionRunner.cs.meta | 2 + .../Scripts/Client/UI/SettingsScreen.cs | 128 +++++++++++++ .../Scripts/Client/UI/SettingsScreen.cs.meta | 2 + .../Scripts/Client/UI/WorldLauncher.cs | 176 ++++++++++++++++++ .../Scripts/Client/UI/WorldLauncher.cs.meta | 2 + Assets/_Project/Scripts/Editor/BuildTool.cs | 63 +++++++ .../_Project/Scripts/Editor/BuildTool.cs.meta | 2 + .../ServerConnectionControlSystem.cs | 4 +- .../_Project/Scripts/Server/Persistence.meta | 8 + .../Server/Persistence/SaveWriteSystem.cs | 60 ++++++ .../Persistence/SaveWriteSystem.cs.meta | 2 + .../Server/World/CycleDirectorSpawnSystem.cs | 18 ++ .../Scripts/Server/World/CyclePhaseSystem.cs | 3 + .../Scripts/Simulation/GameBootstrap.cs | 32 +++- .../Scripts/Simulation/Persistence.meta | 8 + .../Simulation/Persistence/SaveApply.cs | 19 ++ .../Simulation/Persistence/SaveApply.cs.meta | 2 + .../Simulation/Persistence/SaveComponents.cs | 62 ++++++ .../Persistence/SaveComponents.cs.meta | 2 + .../Simulation/Persistence/SaveData.cs | 63 +++++++ .../Simulation/Persistence/SaveData.cs.meta | 2 + .../Simulation/Persistence/SaveService.cs | 61 ++++++ .../Persistence/SaveService.cs.meta | 2 + .../Persistence/SaveStructureScan.cs | 70 +++++++ .../Persistence/SaveStructureScan.cs.meta | 2 + .../Tests/EditMode/GameSettingsTests.cs | 113 +++++++++++ .../Tests/EditMode/GameSettingsTests.cs.meta | 2 + .../Tests/EditMode/SavePersistenceTests.cs | 107 +++++++++++ .../EditMode/SavePersistenceTests.cs.meta | 2 + 56 files changed, 1744 insertions(+), 8 deletions(-) create mode 100644 Assets/UI Toolkit.meta create mode 100644 Assets/UI Toolkit/UnityThemes.meta create mode 100644 Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss create mode 100644 Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss.meta create mode 100644 Assets/_Project/Resources.meta create mode 100644 Assets/_Project/Resources/RuntimePanelSettings.asset create mode 100644 Assets/_Project/Resources/RuntimePanelSettings.asset.meta create mode 100644 Assets/_Project/Resources/RuntimeTheme.tss create mode 100644 Assets/_Project/Resources/RuntimeTheme.tss.meta create mode 100644 Assets/_Project/Scripts/Client/Settings.meta create mode 100644 Assets/_Project/Scripts/Client/Settings/GameSettings.cs create mode 100644 Assets/_Project/Scripts/Client/Settings/GameSettings.cs.meta create mode 100644 Assets/_Project/Scripts/Client/Settings/GameVolume.cs create mode 100644 Assets/_Project/Scripts/Client/Settings/GameVolume.cs.meta create mode 100644 Assets/_Project/Scripts/Client/Settings/SettingsService.cs create mode 100644 Assets/_Project/Scripts/Client/Settings/SettingsService.cs.meta create mode 100644 Assets/_Project/Scripts/Client/UI.meta create mode 100644 Assets/_Project/Scripts/Client/UI/MainMenuController.cs create mode 100644 Assets/_Project/Scripts/Client/UI/MainMenuController.cs.meta create mode 100644 Assets/_Project/Scripts/Client/UI/MenuUi.cs create mode 100644 Assets/_Project/Scripts/Client/UI/MenuUi.cs.meta create mode 100644 Assets/_Project/Scripts/Client/UI/PauseMenuController.cs create mode 100644 Assets/_Project/Scripts/Client/UI/PauseMenuController.cs.meta create mode 100644 Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs create mode 100644 Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Client/UI/SessionRunner.cs create mode 100644 Assets/_Project/Scripts/Client/UI/SessionRunner.cs.meta create mode 100644 Assets/_Project/Scripts/Client/UI/SettingsScreen.cs create mode 100644 Assets/_Project/Scripts/Client/UI/SettingsScreen.cs.meta create mode 100644 Assets/_Project/Scripts/Client/UI/WorldLauncher.cs create mode 100644 Assets/_Project/Scripts/Client/UI/WorldLauncher.cs.meta create mode 100644 Assets/_Project/Scripts/Editor/BuildTool.cs create mode 100644 Assets/_Project/Scripts/Editor/BuildTool.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Persistence.meta create mode 100644 Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Persistence.meta create mode 100644 Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs create mode 100644 Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs create mode 100644 Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs create mode 100644 Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs create mode 100644 Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs create mode 100644 Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/GameSettingsTests.cs create mode 100644 Assets/_Project/Tests/EditMode/GameSettingsTests.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/SavePersistenceTests.cs create mode 100644 Assets/_Project/Tests/EditMode/SavePersistenceTests.cs.meta diff --git a/Assets/UI Toolkit.meta b/Assets/UI Toolkit.meta new file mode 100644 index 000000000..2b0335e6f --- /dev/null +++ b/Assets/UI Toolkit.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c91eb6d541888a04ba1aa4353578cb44 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/UI Toolkit/UnityThemes.meta b/Assets/UI Toolkit/UnityThemes.meta new file mode 100644 index 000000000..bec24d270 --- /dev/null +++ b/Assets/UI Toolkit/UnityThemes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: da3af149a1afd6f4e8b1f021201ad948 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss b/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss new file mode 100644 index 000000000..1056e07ed --- /dev/null +++ b/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss @@ -0,0 +1 @@ +@import url("unity-theme://default"); \ No newline at end of file diff --git a/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss.meta b/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss.meta new file mode 100644 index 000000000..753a876a2 --- /dev/null +++ b/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 8d8cd9ceeb854cb4292122b7adc0e718 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12388, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 + unsupportedSelectorAction: 0 diff --git a/Assets/_Project/Resources.meta b/Assets/_Project/Resources.meta new file mode 100644 index 000000000..081cc2b2e --- /dev/null +++ b/Assets/_Project/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a8c31026d2a6f0b4aa2c47046bcd0384 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Resources/RuntimePanelSettings.asset b/Assets/_Project/Resources/RuntimePanelSettings.asset new file mode 100644 index 000000000..03ba579ce --- /dev/null +++ b/Assets/_Project/Resources/RuntimePanelSettings.asset @@ -0,0 +1,52 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 19101, guid: 0000000000000000e000000000000000, type: 0} + m_Name: RuntimePanelSettings + m_EditorClassIdentifier: UnityEngine.dll::UnityEngine.UIElements.PanelSettings + themeUss: {fileID: -4733365628477956816, guid: 7234a887e0f405148ae5bd095ea5f3c4, type: 3} + m_DisableNoThemeWarning: 0 + m_TargetTexture: {fileID: 0} + m_RenderMode: 0 + m_ColliderUpdateMode: 0 + m_ColliderIsTrigger: 1 + m_ScaleMode: 1 + m_ReferenceSpritePixelsPerUnit: 100 + m_PixelsPerUnit: 100 + m_Scale: 1 + m_ReferenceDpi: 96 + m_FallbackDpi: 96 + m_ReferenceResolution: {x: 1200, y: 800} + m_ScreenMatchMode: 0 + m_Match: 0 + m_SortingOrder: 0 + m_TargetDisplay: 0 + m_BindingLogLevel: 0 + m_ClearDepthStencil: 1 + m_ClearColor: 0 + m_ColorClearValue: {r: 0, g: 0, b: 0, a: 0} + m_VertexBudget: 0 + m_TextureSlotCount: 8 + m_DynamicAtlasSettings: + m_MinAtlasSize: 64 + m_MaxAtlasSize: 4096 + m_MaxSubTextureSize: 64 + m_ActiveFilters: 31 + m_AtlasBlitShader: {fileID: 9101, guid: 0000000000000000f000000000000000, type: 0} + m_DefaultShader: {fileID: 9100, guid: 0000000000000000f000000000000000, type: 0} + m_RuntimeGaussianBlurShader: {fileID: 20300, guid: 0000000000000000f000000000000000, type: 0} + m_RuntimeColorEffectShader: {fileID: 20301, guid: 0000000000000000f000000000000000, type: 0} + m_SDFShader: {fileID: 19011, guid: 0000000000000000f000000000000000, type: 0} + m_BitmapShader: {fileID: 9001, guid: 0000000000000000f000000000000000, type: 0} + m_SpriteShader: {fileID: 19012, guid: 0000000000000000f000000000000000, type: 0} + m_ICUDataAsset: {fileID: 0} + forceGammaRendering: 0 + textSettings: {fileID: 0} diff --git a/Assets/_Project/Resources/RuntimePanelSettings.asset.meta b/Assets/_Project/Resources/RuntimePanelSettings.asset.meta new file mode 100644 index 000000000..54c07baef --- /dev/null +++ b/Assets/_Project/Resources/RuntimePanelSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 638f245095dd4ea4a83f2349de780e00 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Resources/RuntimeTheme.tss b/Assets/_Project/Resources/RuntimeTheme.tss new file mode 100644 index 000000000..51924cb33 --- /dev/null +++ b/Assets/_Project/Resources/RuntimeTheme.tss @@ -0,0 +1 @@ +@import url("unity-theme://default"); diff --git a/Assets/_Project/Resources/RuntimeTheme.tss.meta b/Assets/_Project/Resources/RuntimeTheme.tss.meta new file mode 100644 index 000000000..22843f086 --- /dev/null +++ b/Assets/_Project/Resources/RuntimeTheme.tss.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 7234a887e0f405148ae5bd095ea5f3c4 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12388, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 + unsupportedSelectorAction: 0 diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs index ab05b0ec0..42ed2ba12 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs @@ -47,7 +47,7 @@ namespace ProjectM.Client _ambient.loop = true; _ambient.playOnAwake = false; _ambient.spatialBlend = 0f; // 2D bed - _ambient.volume = AmbientBaseVolume; + _ambient.volume = AmbientBaseVolume * GameVolume.Music; _ambient.Play(); } @@ -75,14 +75,14 @@ namespace ProjectM.Client // Ease the drone intensity toward the phase target (tenser during Defend). float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume; - _ambient.volume = Mathf.MoveTowards(_ambient.volume, target, SystemAPI.Time.DeltaTime * 0.25f); + _ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f); } void PlaySting(byte phase) { AudioClip clip = phase == CyclePhase.Siege ? _stingDefend : _stingBuild; if (clip != null && _ambient != null) - _ambient.PlayOneShot(clip, 0.6f); + _ambient.PlayOneShot(clip, 0.6f * GameVolume.Music); } // ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ---- diff --git a/Assets/_Project/Scripts/Client/Settings.meta b/Assets/_Project/Scripts/Client/Settings.meta new file mode 100644 index 000000000..13d12ac87 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Settings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6a54f970b22b42c4d95036021a8a2b2f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Client/Settings/GameSettings.cs b/Assets/_Project/Scripts/Client/Settings/GameSettings.cs new file mode 100644 index 000000000..a03f44d25 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Settings/GameSettings.cs @@ -0,0 +1,71 @@ +using System; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Serializable client settings model (Graphics + Audio) persisted to JSON by . + /// Flat fields only (an enum stored as an int) so JsonUtility round-trips it without Newtonsoft. + /// gates forward-compatible migration. Purely client-local — never replicated (the + /// server has no opinion on a player's resolution or volume). + /// + [Serializable] + public struct GameSettings + { + public const int CurrentVersion = 1; + + public int Version; + + // ---- Graphics ---- + public int ResWidth; + public int ResHeight; + public int RefreshHz; // 0 = platform default + public int FullScreenMode; // (int)UnityEngine.FullScreenMode (0 Exclusive,1 FullScreenWindow,2 Maximized,3 Windowed) + public int QualityLevel; + public int VSync; // QualitySettings.vSyncCount: 0/1/2 + public int TargetFps; // Application.targetFrameRate: -1 = uncapped + + // ---- Audio (0..1) ---- + public float Master; + public float Music; + public float Sfx; + + /// Sensible defaults derived from the current display + active quality level. + public static GameSettings Defaults() + { + var r = Screen.currentResolution; + return new GameSettings + { + Version = CurrentVersion, + ResWidth = r.width > 0 ? r.width : 1920, + ResHeight = r.height > 0 ? r.height : 1080, + RefreshHz = 0, + FullScreenMode = (int)UnityEngine.FullScreenMode.FullScreenWindow, + QualityLevel = QualitySettings.GetQualityLevel(), + VSync = 1, + TargetFps = -1, + Master = 1f, + Music = 1f, + Sfx = 1f, + }; + } + + /// Clamp every field into a safe range (defensive against hand-edited / corrupt JSON). + public GameSettings Clamped() + { + var s = this; + s.ResWidth = Mathf.Clamp(s.ResWidth <= 0 ? 1920 : s.ResWidth, 640, 7680); + s.ResHeight = Mathf.Clamp(s.ResHeight <= 0 ? 1080 : s.ResHeight, 480, 4320); + s.RefreshHz = Mathf.Max(0, s.RefreshHz); + s.FullScreenMode = Mathf.Clamp(s.FullScreenMode, 0, 3); + int qn = (QualitySettings.names != null && QualitySettings.names.Length > 0) ? QualitySettings.names.Length : 1; + s.QualityLevel = Mathf.Clamp(s.QualityLevel, 0, qn - 1); + s.VSync = Mathf.Clamp(s.VSync, 0, 2); + s.TargetFps = s.TargetFps <= 0 ? -1 : Mathf.Clamp(s.TargetFps, 20, 1000); + s.Master = Mathf.Clamp01(s.Master); + s.Music = Mathf.Clamp01(s.Music); + s.Sfx = Mathf.Clamp01(s.Sfx); + return s; + } + } +} diff --git a/Assets/_Project/Scripts/Client/Settings/GameSettings.cs.meta b/Assets/_Project/Scripts/Client/Settings/GameSettings.cs.meta new file mode 100644 index 000000000..352b437cb --- /dev/null +++ b/Assets/_Project/Scripts/Client/Settings/GameSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1b7e08f285d06e4429849d6ef4d7b730 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Settings/GameVolume.cs b/Assets/_Project/Scripts/Client/Settings/GameVolume.cs new file mode 100644 index 000000000..403fd97db --- /dev/null +++ b/Assets/_Project/Scripts/Client/Settings/GameVolume.cs @@ -0,0 +1,33 @@ +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-only audio volume BUSES, read by the procedural-audio presentation systems + /// ( = Music bus; + + /// = Sfx bus). MASTER is applied separately as + /// AudioListener.volume (a global listener gain) by , so the per-call + /// multipliers here are the per-bus trims ONLY — never multiply by master again or it double-attenuates. + /// + /// A plain static (not an IComponentData) so the Burst-free managed presentation systems read it with + /// zero ECS plumbing. NOT named AudioSettings — that collides with UnityEngine.AudioSettings. + /// Reset on play-enter () so a fast-enter-playmode + /// domain reload never carries a stale value into a fresh session. + /// + /// + public static class GameVolume + { + /// Music/ambience bus trim (0..1). Applied by AmbientAudioSystem. + public static float Music = 1f; + + /// SFX bus trim (0..1). Applied by Combat/World feedback systems. + public static float Sfx = 1f; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() + { + Music = 1f; + Sfx = 1f; + } + } +} diff --git a/Assets/_Project/Scripts/Client/Settings/GameVolume.cs.meta b/Assets/_Project/Scripts/Client/Settings/GameVolume.cs.meta new file mode 100644 index 000000000..06ac679c8 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Settings/GameVolume.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a41fe49c050b2ae4b869edd52ddb716e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Settings/SettingsService.cs b/Assets/_Project/Scripts/Client/Settings/SettingsService.cs new file mode 100644 index 000000000..c4145b095 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Settings/SettingsService.cs @@ -0,0 +1,116 @@ +using System; +using System.IO; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-local settings persistence + application. Loads settings.json from + /// Application.persistentDataPath at boot () and applies + /// Graphics (Screen / QualitySettings / targetFrameRate) + Audio (AudioListener.volume = Master; + /// Music/Sfx bus trims). The single source of truth for the UITK + /// SettingsScreen shared by the main menu + the in-game pause overlay. Saves are atomic + /// (temp file + File.Replace). JsonUtility keeps it asmdef-ref-free. Never replicated. + /// + public static class SettingsService + { + public static GameSettings Current { get; private set; } = GameSettings.Defaults(); + + static string FilePath => Path.Combine(Application.persistentDataPath, "settings.json"); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + static void Boot() + { + Load(); + Apply(Current); + } + + /// Read settings from disk (or defaults if absent/corrupt). Returns the loaded value. + public static GameSettings Load() + { + try + { + if (File.Exists(FilePath)) + { + var json = File.ReadAllText(FilePath); + var s = JsonUtility.FromJson(json); + if (s.Version != GameSettings.CurrentVersion) + s = Migrate(s); + Current = s.Clamped(); + } + else + { + Current = GameSettings.Defaults(); + } + } + catch (Exception e) + { + Debug.LogWarning($"[SettingsService] Load failed ({e.Message}); using defaults."); + Current = GameSettings.Defaults(); + } + return Current; + } + + /// Clamp + version-stamp + atomically write to disk; updates . + public static void Save(GameSettings settings) + { + settings = settings.Clamped(); + settings.Version = GameSettings.CurrentVersion; + Current = settings; + try + { + var json = JsonUtility.ToJson(settings, 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($"[SettingsService] Save failed: {e.Message}"); + } + } + + /// Apply settings to the live engine. Master rides AudioListener.volume; buses are per-call trims. + public static void Apply(GameSettings s) + { + s = s.Clamped(); + + // Graphics + QualitySettings.SetQualityLevel(s.QualityLevel, true); + QualitySettings.vSyncCount = s.VSync; + var mode = (FullScreenMode)s.FullScreenMode; + if (s.RefreshHz > 0) + Screen.SetResolution(s.ResWidth, s.ResHeight, mode, new RefreshRate { numerator = (uint)s.RefreshHz, denominator = 1 }); + else + Screen.SetResolution(s.ResWidth, s.ResHeight, mode); + Application.targetFrameRate = s.TargetFps; // -1 = uncapped (ignored while vSync > 0) + + // Audio + AudioListener.volume = s.Master; + GameVolume.Music = s.Music; + GameVolume.Sfx = s.Sfx; + } + + /// Convenience for the settings UI: persist then apply. + public static void SaveAndApply(GameSettings s) + { + Save(s); + Apply(Current); + } + + // Forward-compatible migration: fill from current defaults, preserve any recognizable old values. + // Additive-only as the schema grows (never throws on an unknown version). + static GameSettings Migrate(GameSettings old) + { + var def = GameSettings.Defaults(); + if (old.ResWidth > 0) def.ResWidth = old.ResWidth; + if (old.ResHeight > 0) def.ResHeight = old.ResHeight; + if (old.Master > 0f) def.Master = old.Master; + if (old.Music > 0f) def.Music = old.Music; + if (old.Sfx > 0f) def.Sfx = old.Sfx; + def.Version = GameSettings.CurrentVersion; + return def; + } + } +} diff --git a/Assets/_Project/Scripts/Client/Settings/SettingsService.cs.meta b/Assets/_Project/Scripts/Client/Settings/SettingsService.cs.meta new file mode 100644 index 000000000..790c8eab6 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Settings/SettingsService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3daf657b3863e6e4fa02aa046a1b074e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/UI.meta b/Assets/_Project/Scripts/Client/UI.meta new file mode 100644 index 000000000..e1ab7e5d4 --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ff809b44eac2a224cae4178439cf3215 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Client/UI/MainMenuController.cs b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs new file mode 100644 index 000000000..2c433a80f --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs @@ -0,0 +1,104 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectM.Client +{ + /// + /// Drives the front-end main menu (UI Toolkit). Lives on a GameObject (with a UIDocument) in + /// MainMenu.unity — build index 0. On it ENSURES a default "menu" world exists (the + /// bootstrap creates one on first boot, but on return-from-game World.DisposeAllWorlds left none and + /// Initialize does not re-run), ensures the EventSystem, and assigns the shared PanelSettings; on + /// enable it builds the menu. Single/Host/Join hand off to . + /// + [RequireComponent(typeof(UIDocument))] + public class MainMenuController : MonoBehaviour + { + UIDocument _doc; + VisualElement _mainPanel; + VisualElement _settingsPanel; + TextField _ipField; + + void Awake() + { + EnsureMenuWorld(); + MenuUi.EnsureEventSystem(); + _doc = GetComponent(); + 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(); + 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 + } + } +} diff --git a/Assets/_Project/Scripts/Client/UI/MainMenuController.cs.meta b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs.meta new file mode 100644 index 000000000..889176cd8 --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bb953c16db2c778418e6c1c049c003aa \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/UI/MenuUi.cs b/Assets/_Project/Scripts/Client/UI/MenuUi.cs new file mode 100644 index 000000000..ac319fbdd --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/MenuUi.cs @@ -0,0 +1,118 @@ +using System; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.InputSystem.UI; +using UnityEngine.UIElements; + +namespace ProjectM.Client +{ + /// + /// 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 PanelSettings (from Resources) and ensuring an EventSystem with an + /// InputSystemUIInputModule 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. + /// + 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("RuntimePanelSettings"); + + public static void EnsureEventSystem() + { + if (UnityEngine.Object.FindFirstObjectByType() != null) return; + var go = new GameObject("EventSystem"); + go.AddComponent(); + go.AddComponent(); + } + + 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; + } + } +} diff --git a/Assets/_Project/Scripts/Client/UI/MenuUi.cs.meta b/Assets/_Project/Scripts/Client/UI/MenuUi.cs.meta new file mode 100644 index 000000000..ef3b1acea --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/MenuUi.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2a83d2b10895944492bb7441e8308e1 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs b/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs new file mode 100644 index 000000000..fedff8df9 --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs @@ -0,0 +1,87 @@ +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectM.Client +{ + /// + /// In-game pause overlay (UI Toolkit), spawned by in the client world. Esc + /// toggles it; Resume / Settings / Quit-to-Menu / Quit-to-Desktop. Quit-to-Menu hands off to + /// (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. + /// + public class PauseMenuController : MonoBehaviour + { + UIDocument _doc; + VisualElement _root; + VisualElement _pausePanel; + VisualElement _settingsPanel; + bool _open; + /// True while the pause overlay is shown (BuildSendSystem suspends build-clicks while paused). + public static bool Open; + + void Awake() + { + MenuUi.EnsureEventSystem(); + _doc = gameObject.AddComponent(); + _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 + } + } +} diff --git a/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs.meta b/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs.meta new file mode 100644 index 000000000..38f86dc32 --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/PauseMenuController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 76b9e91f569010b4aaf703ce756a4b1c \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs b/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs new file mode 100644 index 000000000..84dde46ab --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs @@ -0,0 +1,26 @@ +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// 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 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. + /// + [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(); + } + } +} diff --git a/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs.meta b/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs.meta new file mode 100644 index 000000000..01ec4eb38 --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/PauseMenuSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0bbe1a1c153da4c4499841a023262e9e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/UI/SessionRunner.cs b/Assets/_Project/Scripts/Client/UI/SessionRunner.cs new file mode 100644 index 000000000..bdbda49de --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/SessionRunner.cs @@ -0,0 +1,32 @@ +using System.Collections; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Persistent (DontDestroyOnLoad) MonoBehaviour that hosts the world-lifecycle coroutines for + /// . World create/dispose + scene loads must run at a FRAME BOUNDARY (never + /// inside an ECS system update — that crashes), and must survive the MainMenu<->Game scene swap, so a + /// single long-lived runner owns them. Created lazily on first use. + /// + public class SessionRunner : MonoBehaviour + { + static SessionRunner _instance; + + static SessionRunner Instance + { + get + { + if (_instance == null) + { + var go = new GameObject("~SessionRunner"); + DontDestroyOnLoad(go); + _instance = go.AddComponent(); + } + return _instance; + } + } + + public static void Run(IEnumerator routine) => Instance.StartCoroutine(routine); + } +} diff --git a/Assets/_Project/Scripts/Client/UI/SessionRunner.cs.meta b/Assets/_Project/Scripts/Client/UI/SessionRunner.cs.meta new file mode 100644 index 000000000..704f1fe45 --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/SessionRunner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e2af19327c034684da1997a5478f7e31 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs b/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs new file mode 100644 index 000000000..3dd448daa --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs @@ -0,0 +1,128 @@ +using System; +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectM.Client +{ + /// + /// Code-built UI Toolkit settings panel (Graphics + Audio) shared by the main menu and the in-game pause + /// overlay. Binds to : edits a working copy, previews audio live, and persists + /// + applies on Apply/Back. Enum settings use compact "< value >" cycle rows (robust, no dropdown-popup + /// theming); volumes use sliders with a live percentage readout. + /// + 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 getValue, Action 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 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 + } + } +} diff --git a/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs.meta b/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs.meta new file mode 100644 index 000000000..2a89d15fc --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/SettingsScreen.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8cd9f577a41c1974796661abc14266d6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs new file mode 100644 index 000000000..36c554a8c --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs @@ -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 } + + /// + /// The SINGLE funnel for netcode world lifecycle driven by the menu, so a connection/handshake fix lives in + /// one place. creates the right worlds (Single/Host = server+client, Join = + /// client-only) via the public ClientServerBootstrap.Create*World helpers (which register the + /// ServerWorld/ClientWorld statics), seeds the existing request component, + /// optionally stages a save (Continue), then loads Game.unity worlds-first (the subscene-streaming + /// invariant). autosaves (host), disposes all worlds, and returns to MainMenu. + /// Every dispose/scene step runs at a frame boundary on — never in an ECS system. + /// + 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()); + 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(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(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(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()); + if (q.IsEmptyIgnoreFilter) return; + var dir = q.GetSingletonEntity(); + var goal = em.HasComponent(dir) ? em.GetComponentData(dir) : default; + var buffer = em.GetBuffer(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())) + if (!tq.IsEmptyIgnoreFilter) + { + var st = tq.GetSingleton().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}"); + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs.meta b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs.meta new file mode 100644 index 000000000..c6894814f --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 96a2c416d0d434a419db03355c08ffea \ No newline at end of file diff --git a/Assets/_Project/Scripts/Editor/BuildTool.cs b/Assets/_Project/Scripts/Editor/BuildTool.cs new file mode 100644 index 000000000..7cf8c833a --- /dev/null +++ b/Assets/_Project/Scripts/Editor/BuildTool.cs @@ -0,0 +1,63 @@ +#if UNITY_EDITOR +using UnityEditor; +using UnityEditor.Build.Reporting; +using UnityEngine; + +namespace ProjectM.EditorTools +{ + /// + /// One-click Windows player build + the "Boot Into Menu (Editor)" toggle that flips GameBootstrap into the + /// frontend path for in-editor menu testing (shared EditorPrefs key "ProjectM.BootIntoMenu"). Lives in the + /// special Editor folder (compiles into Assembly-CSharp-Editor) so it needs no ProjectM asmdef references. + /// + public static class BuildTool + { + const string BootPrefKey = "ProjectM.BootIntoMenu"; + const string BootMenuItem = "ProjectM/Boot Into Menu (Editor)"; + + [MenuItem("ProjectM/Build/Windows Player")] + public static void BuildWindows() + { + string[] scenes = + { + "Assets/Scenes/MainMenu.unity", + "Assets/Scenes/Game.unity", + }; + const string dir = "Builds/Windows"; + System.IO.Directory.CreateDirectory(dir); + + var options = new BuildPlayerOptions + { + scenes = scenes, + locationPathName = System.IO.Path.Combine(dir, "ProjectM.exe"), + target = BuildTarget.StandaloneWindows64, + options = BuildOptions.None, + }; + + BuildReport report = BuildPipeline.BuildPlayer(options); + BuildSummary s = report.summary; + if (s.result == BuildResult.Succeeded) + Debug.Log($"[BuildTool] Build succeeded: ~{s.totalSize / (1024 * 1024)} MB -> {options.locationPathName}"); + else + Debug.LogError($"[BuildTool] Build {s.result}: {s.totalErrors} error(s)."); + } + + [MenuItem(BootMenuItem)] + public static void ToggleBootMenu() + { + bool v = !EditorPrefs.GetBool(BootPrefKey, false); + EditorPrefs.SetBool(BootPrefKey, v); + Debug.Log($"[BuildTool] Boot Into Menu (Editor) = {v}. " + + (v ? "Open MainMenu.unity and Play to test the menu." + : "Normal instant-play / MPPM loop restored.")); + } + + [MenuItem(BootMenuItem, true)] + public static bool ToggleBootMenuValidate() + { + Menu.SetChecked(BootMenuItem, EditorPrefs.GetBool(BootPrefKey, false)); + return true; + } + } +} +#endif diff --git a/Assets/_Project/Scripts/Editor/BuildTool.cs.meta b/Assets/_Project/Scripts/Editor/BuildTool.cs.meta new file mode 100644 index 000000000..08be3e225 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/BuildTool.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c4747ef10cbb424bbfa8ebd4f807fb7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Connection/ServerConnectionControlSystem.cs b/Assets/_Project/Scripts/Server/Connection/ServerConnectionControlSystem.cs index e012c79a6..62847b2f8 100644 --- a/Assets/_Project/Scripts/Server/Connection/ServerConnectionControlSystem.cs +++ b/Assets/_Project/Scripts/Server/Connection/ServerConnectionControlSystem.cs @@ -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(); diff --git a/Assets/_Project/Scripts/Server/Persistence.meta b/Assets/_Project/Scripts/Server/Persistence.meta new file mode 100644 index 000000000..5e16f0c22 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Persistence.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6aa6ed6723d75a64796d926f11f63236 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs new file mode 100644 index 000000000..db39ddc07 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs @@ -0,0 +1,60 @@ +using System; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Host-only autosave writer. A managed (file IO => NO Burst) that reacts to the + /// flag the Bursted CyclePhaseSystem raises on the Siege->Calm checkpoint: + /// reads the authoritative + shared resource ledger off the CycleDirector ghost, + /// writes the JSON save (), then clears the flag. ServerSimulation-only, so a pure + /// (Join) client never writes. Deliberately carries NO [UpdateAfter(CyclePhaseSystem)] (that would risk + /// a sort-cycle); a one-tick-late autosave is irrelevant. + /// + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + public partial class SaveWriteSystem : SystemBase + { + protected override void OnCreate() + { + RequireForUpdate(); + RequireForUpdate(); + } + + protected override void OnUpdate() + { + var dir = SystemAPI.GetSingletonEntity(); + var req = SystemAPI.GetComponent(dir); + if (req.Pending == 0) + return; + + req.Pending = 0; + SystemAPI.SetComponent(dir, req); + + var goal = SystemAPI.HasComponent(dir) + ? SystemAPI.GetComponent(dir) + : default; + + // The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer). + var buffer = SystemAPI.GetBuffer(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().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(), + }); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs.meta b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs.meta new file mode 100644 index 000000000..2145c103f --- /dev/null +++ b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a08738b815484f4499b9ab614698c3e6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs index 12a159718..464f557f4 100644 --- a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs @@ -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(out var pendingEntity)) + { + var pending = SystemAPI.GetComponent(pendingEntity); + if (pending.HasData != 0) + { + ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = pending.GoalTarget }); + var srcLedger = SystemAPI.GetBuffer(pendingEntity); + var destLedger = ecb.SetBuffer(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. diff --git a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs index bb4f535b5..947bdbfe3 100644 --- a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs @@ -99,6 +99,9 @@ namespace ProjectM.Server var goal = SystemAPI.GetComponent(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(cycleEntity)) + SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 }); } } } diff --git a/Assets/_Project/Scripts/Simulation/GameBootstrap.cs b/Assets/_Project/Scripts/Simulation/GameBootstrap.cs index 9783b8b26..aced85289 100644 --- a/Assets/_Project/Scripts/Simulation/GameBootstrap.cs +++ b/Assets/_Project/Scripts/Simulation/GameBootstrap.cs @@ -10,7 +10,7 @@ namespace ProjectM.Simulation /// /// M4 (LAN co-op): auto-connect is DISABLED ( = 0). /// Listening/connecting is driven explicitly via the singleton and the - /// per-world ConnectionControlSystems — from ConnectionUI (Host / Join + IP) in player builds, + /// per-world ConnectionControlSystems — from the UITK frontend menu (MainMenuControllerWorldLauncher, Host / Join + IP) in player builds, /// or from the editor-only EditorAutoHostSystem, 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 } } } diff --git a/Assets/_Project/Scripts/Simulation/Persistence.meta b/Assets/_Project/Scripts/Simulation/Persistence.meta new file mode 100644 index 000000000..bd9b24049 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 82c8fb2f6f68a864fbbdf60b8c634439 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs new file mode 100644 index 000000000..077e6f9e7 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs @@ -0,0 +1,19 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// 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). + /// + public static class SaveApply + { + /// Replace a StorageEntry ledger buffer's contents with a staged PendingSaveLedgerRow buffer. + public static void WriteLedger(DynamicBuffer src, DynamicBuffer dest) + { + dest.Clear(); + for (int i = 0; i < src.Length; i++) + dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count }); + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs.meta new file mode 100644 index 000000000..4410bd9f5 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b187f01051a0d9e45b30b8f1c4c1e4c1 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs new file mode 100644 index 000000000..2f3f494dd --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs @@ -0,0 +1,62 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// 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 / empty ledger to clients (no replication flicker). The + /// menu creates exactly one of these (with the 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. + /// + public struct PendingSave : IComponentData + { + public int GoalCharge; + public int GoalTarget; + + /// 0 = nothing staged (New Game); non-zero = apply the staged slice at director spawn. + public byte HasData; + } + + /// One staged ledger row for a Continue session; copied into the director's StorageEntry buffer at spawn. + public struct PendingSaveLedgerRow : IBufferElementData + { + public ushort ItemId; + public int Count; + } + /// One staged player-built structure row for a Continue session (M7); BaseRestoreSystem replays it + /// charge-free into the freshly-streamed base. Mirrors but as an unmanaged ECS + /// buffer element (staged in the ServerWorld before the subscene streams). + 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; + } + + /// One staged machine I/O row (M7), joined to the buffer by index. + /// Slot 0 = MachineInput, 1 = MachineOutput. + public struct PendingStructureIo : IBufferElementData + { + public int StructureIndex; + public byte Slot; + public byte ResourceId; + public int Count; + } + + + /// + /// Host-only autosave request flag on the CycleDirector entity (added at spawn). The Bursted CyclePhaseSystem + /// sets =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). + /// + public struct SaveRequest : IComponentData + { + public byte Pending; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs.meta new file mode 100644 index 000000000..3dd1e8fe4 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ed817060ccb7b445b5f1ad094e10443 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs new file mode 100644 index 000000000..fa816849a --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs @@ -0,0 +1,63 @@ +using System; + +namespace ProjectM.Simulation +{ + /// One serialized ledger row (item id + count). An array FIELD of . + [Serializable] + public struct LedgerRow + { + public int ItemId; + public int Count; + } + /// + /// 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 table keyed by index. + /// + [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; + } + + /// + /// One serialized machine I/O buffer row, joined to by + /// . A flat top-level array (JsonUtility can't nest arrays-of-arrays); Slot 0 = + /// MachineInput, Slot 1 = MachineOutput. + /// + [Serializable] + public struct StructureIoRow + { + public int StructureIndex; + public byte Slot; + public byte ResourceId; + public int Count; + } + + /// + /// 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 migration. + /// + [Serializable] + public class SaveData + { + public const int CurrentVersion = 2; + + public int Version = CurrentVersion; + public int GoalCharge; + public int GoalTarget; + public LedgerRow[] Ledger = Array.Empty(); + public StructureSave[] Structures = Array.Empty(); + public StructureIoRow[] StructureIo = Array.Empty(); + public long SavedAtMs; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs.meta new file mode 100644 index 000000000..b61c4e576 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c2f5cc5646cf9ee4b94d7cebaefb8f30 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs new file mode 100644 index 000000000..56e11e916 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using UnityEngine; + +namespace ProjectM.Simulation +{ + /// + /// Host-local persistence for the game save slice () — single slot, versioned JSON at + /// Application.persistentDataPath/save_0.json, atomic writes (temp + File.Replace). Read by the + /// menu (to offer "Continue" + stage a ) 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). + /// + 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(File.ReadAllText(FilePath)); + if (data == null || data.Version != SaveData.CurrentVersion) return null; + data.Ledger ??= Array.Empty(); + 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}"); } + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs.meta new file mode 100644 index 000000000..6159116a1 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4aca4b85393065f45a5c5ff87f35e428 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs new file mode 100644 index 000000000..601228201 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using Unity.Collections; +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Scans a server world for PLAYER-built structures ( + ) + /// 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. + /// + public static class SaveStructureScan + { + public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures, out StructureIoRow[] io) + { + var structs = new List(); + var ioRows = new List(); + + using var q = em.CreateEntityQuery( + ComponentType.ReadOnly(), + ComponentType.ReadOnly()); + using var entities = q.ToEntityArray(Allocator.Temp); + + for (int k = 0; k < entities.Length; k++) + { + var e = entities[k]; + var ps = em.GetComponentData(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(e)) + row.Direction = em.GetComponentData(e).Direction; + + if (em.HasComponent(e) && em.IsComponentEnabled(e)) + { + var item = em.GetComponentData(e); + row.ConveyorResId = item.ResourceId; + row.ConveyorCount = item.Count; + } + + structs.Add(row); + + if (em.HasBuffer(e)) + { + var buf = em.GetBuffer(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(e)) + { + var buf = em.GetBuffer(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(); + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs.meta b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs.meta new file mode 100644 index 000000000..e4d58c1b2 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 47b137b2d90c6154d8c195f8c491f0d8 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/GameSettingsTests.cs b/Assets/_Project/Tests/EditMode/GameSettingsTests.cs new file mode 100644 index 000000000..e18a1c827 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/GameSettingsTests.cs @@ -0,0 +1,113 @@ +using NUnit.Framework; +using ProjectM.Client; +using UnityEngine; + +namespace ProjectM.Tests +{ + /// + /// Pure-logic tests for the client settings model (no ECS world): JSON round-trip via JsonUtility, + /// defensive clamping of hand-edited/corrupt values, and the GameVolume bus statics. Engine APIs + /// (Screen/QualitySettings) resolve in EditMode, so Defaults()/Clamped() are exercisable here. + /// + public class GameSettingsTests + { + static GameSettings Sample() => new GameSettings + { + Version = GameSettings.CurrentVersion, + ResWidth = 2560, + ResHeight = 1440, + RefreshHz = 144, + FullScreenMode = (int)FullScreenMode.ExclusiveFullScreen, + QualityLevel = 0, + VSync = 2, + TargetFps = 120, + Master = 0.8f, + Music = 0.5f, + Sfx = 0.3f, + }; + + [Test] + public void Json_RoundTrip_PreservesAllFields() + { + var src = Sample(); + var json = JsonUtility.ToJson(src); + var dst = JsonUtility.FromJson(json); + + Assert.AreEqual(src.Version, dst.Version); + Assert.AreEqual(src.ResWidth, dst.ResWidth); + Assert.AreEqual(src.ResHeight, dst.ResHeight); + Assert.AreEqual(src.RefreshHz, dst.RefreshHz); + Assert.AreEqual(src.FullScreenMode, dst.FullScreenMode); + Assert.AreEqual(src.QualityLevel, dst.QualityLevel); + Assert.AreEqual(src.VSync, dst.VSync); + Assert.AreEqual(src.TargetFps, dst.TargetFps); + Assert.AreEqual(src.Master, dst.Master, 1e-5f); + Assert.AreEqual(src.Music, dst.Music, 1e-5f); + Assert.AreEqual(src.Sfx, dst.Sfx, 1e-5f); + } + + [Test] + public void Clamped_BoundsOutOfRangeValues() + { + var bad = new GameSettings + { + ResWidth = 99999, ResHeight = -5, RefreshHz = -10, + FullScreenMode = 99, QualityLevel = 9999, VSync = 7, + TargetFps = 5, Master = 2f, Music = -1f, Sfx = 3f, + }; + var c = bad.Clamped(); + + Assert.LessOrEqual(c.ResWidth, 7680); + Assert.GreaterOrEqual(c.ResHeight, 480); + Assert.GreaterOrEqual(c.RefreshHz, 0); + Assert.That(c.FullScreenMode, Is.InRange(0, 3)); + Assert.That(c.VSync, Is.InRange(0, 2)); + Assert.AreEqual(1f, c.Master, 1e-5f); // 2f -> 1 + Assert.AreEqual(0f, c.Music, 1e-5f); // -1 -> 0 + Assert.AreEqual(1f, c.Sfx, 1e-5f); // 3f -> 1 + Assert.That(c.QualityLevel, Is.GreaterThanOrEqualTo(0)); // clamped to a valid level index + } + + [Test] + public void Clamped_NonPositiveTargetFps_BecomesUncapped() + { + Assert.AreEqual(-1, new GameSettings { TargetFps = 0 }.Clamped().TargetFps); + Assert.AreEqual(-1, new GameSettings { TargetFps = -50 }.Clamped().TargetFps); + Assert.AreEqual(120, new GameSettings { TargetFps = 120, Master = 1f }.Clamped().TargetFps); + } + + [Test] + public void Defaults_AreValidAndFullVolume() + { + var d = GameSettings.Defaults(); + Assert.AreEqual(GameSettings.CurrentVersion, d.Version); + Assert.Greater(d.ResWidth, 0); + Assert.Greater(d.ResHeight, 0); + Assert.AreEqual(1f, d.Master, 1e-5f); + Assert.AreEqual(1f, d.Music, 1e-5f); + Assert.AreEqual(1f, d.Sfx, 1e-5f); + // Defaults survive a clamp unchanged (they are in-range by construction). + var c = d.Clamped(); + Assert.AreEqual(d.Master, c.Master, 1e-5f); + Assert.AreEqual(d.VSync, c.VSync); + } + + [Test] + public void GameVolume_BusesAreSettable_DefaultFull() + { + float prevMusic = GameVolume.Music, prevSfx = GameVolume.Sfx; + try + { + GameVolume.Music = 0.25f; + GameVolume.Sfx = 0.6f; + Assert.AreEqual(0.25f, GameVolume.Music, 1e-5f); + Assert.AreEqual(0.6f, GameVolume.Sfx, 1e-5f); + } + finally + { + GameVolume.Music = prevMusic; + GameVolume.Sfx = prevSfx; + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/GameSettingsTests.cs.meta b/Assets/_Project/Tests/EditMode/GameSettingsTests.cs.meta new file mode 100644 index 000000000..36686034e --- /dev/null +++ b/Assets/_Project/Tests/EditMode/GameSettingsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5a097bb2e0683f544944c8a0cc9676ff \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs new file mode 100644 index 000000000..5ce88e404 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs @@ -0,0 +1,107 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Tests +{ + /// + /// Pure tests for the save FOUNDATION: the JSON schema round-trips (JsonUtility), version handling is safe, + /// and the born-correct ledger apply () the server spawn system uses to + /// overwrite a director's StorageEntry buffer from a staged PendingSave. + /// + public class SavePersistenceTests + { + [Test] + public void SaveData_Json_RoundTrip_PreservesFields() + { + var data = new SaveData + { + GoalCharge = 42, + GoalTarget = 10, + Ledger = new[] + { + new LedgerRow { ItemId = 1, Count = 5 }, + new LedgerRow { ItemId = 2, Count = 9 }, + }, + SavedAtMs = 1234567890123L, + }; + + var json = JsonUtility.ToJson(data); + var back = JsonUtility.FromJson(json); + + Assert.AreEqual(SaveData.CurrentVersion, back.Version); + Assert.AreEqual(42, back.GoalCharge); + Assert.AreEqual(10, back.GoalTarget); + Assert.AreEqual(2, back.Ledger.Length); + Assert.AreEqual(1, back.Ledger[0].ItemId); + Assert.AreEqual(5, back.Ledger[0].Count); + Assert.AreEqual(2, back.Ledger[1].ItemId); + Assert.AreEqual(9, back.Ledger[1].Count); + Assert.AreEqual(1234567890123L, back.SavedAtMs); + } + + [Test] + public void SaveData_EmptyJson_DoesNotThrow_And_EmptyLedgerRoundTrips() + { + Assert.DoesNotThrow(() => JsonUtility.FromJson("{}")); + + var empty = new SaveData { GoalCharge = 0, GoalTarget = 10 }; + var back = JsonUtility.FromJson(JsonUtility.ToJson(empty)); + Assert.IsNotNull(back.Ledger); + Assert.AreEqual(0, back.Ledger.Length); + } + + [Test] + public void SaveData_OldVersion_IsDetectable() + { + // A stale-version blob round-trips with its Version intact, so SaveService.Load rejects it (-> New Game). + var old = new SaveData { Version = 0, GoalCharge = 7 }; + var back = JsonUtility.FromJson(JsonUtility.ToJson(old)); + Assert.AreEqual(0, back.Version); + Assert.AreNotEqual(SaveData.CurrentVersion, back.Version); + } + + [Test] + public void WriteLedger_Overwrites_Destination_From_Staged_Rows() + { + using var world = new World("SaveApplyTest"); + var em = world.EntityManager; + var e = em.CreateEntity(); + em.AddBuffer(e); + em.AddBuffer(e); + + var src = em.GetBuffer(e); + src.Add(new PendingSaveLedgerRow { ItemId = 3, Count = 7 }); + src.Add(new PendingSaveLedgerRow { ItemId = 5, Count = 12 }); + + var dest = em.GetBuffer(e); + dest.Add(new StorageEntry { ItemId = 99, Count = 1 }); // pre-existing junk that must be cleared + + SaveApply.WriteLedger(em.GetBuffer(e), em.GetBuffer(e)); + + var result = em.GetBuffer(e); + Assert.AreEqual(2, result.Length); + Assert.AreEqual(3, result[0].ItemId); + Assert.AreEqual(7, result[0].Count); + Assert.AreEqual(5, result[1].ItemId); + Assert.AreEqual(12, result[1].Count); + } + + [Test] + public void WriteLedger_EmptySource_ClearsDestination() + { + using var world = new World("SaveApplyEmptyTest"); + var em = world.EntityManager; + var e = em.CreateEntity(); + em.AddBuffer(e); + em.AddBuffer(e); + var dest = em.GetBuffer(e); + dest.Add(new StorageEntry { ItemId = 1, Count = 1 }); + + SaveApply.WriteLedger(em.GetBuffer(e), em.GetBuffer(e)); + + Assert.AreEqual(0, em.GetBuffer(e).Length); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs.meta b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs.meta new file mode 100644 index 000000000..8e5cd822a --- /dev/null +++ b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f637165d4b6be6940b81f5832204e89d \ No newline at end of file