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:
@@ -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) ----
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a54f970b22b42c4d95036021a8a2b2f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializable client settings model (Graphics + Audio) persisted to JSON by <see cref="SettingsService"/>.
|
||||
/// Flat fields only (an enum stored as an int) so <c>JsonUtility</c> round-trips it without Newtonsoft.
|
||||
/// <see cref="Version"/> gates forward-compatible migration. Purely client-local — never replicated (the
|
||||
/// server has no opinion on a player's resolution or volume).
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>Sensible defaults derived from the current display + active quality level.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Clamp every field into a safe range (defensive against hand-edited / corrupt JSON).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b7e08f285d06e4429849d6ef4d7b730
|
||||
@@ -0,0 +1,33 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-only audio volume BUSES, read by the procedural-audio presentation systems
|
||||
/// (<see cref="AmbientAudioSystem"/> = Music bus; <see cref="CombatFeedbackSystem"/> +
|
||||
/// <see cref="WorldFeedbackSystem"/> = Sfx bus). MASTER is applied separately as
|
||||
/// <c>AudioListener.volume</c> (a global listener gain) by <see cref="SettingsService"/>, so the per-call
|
||||
/// multipliers here are the per-bus trims ONLY — never multiply by master again or it double-attenuates.
|
||||
/// <para>
|
||||
/// A plain static (not an <c>IComponentData</c>) so the Burst-free managed presentation systems read it with
|
||||
/// zero ECS plumbing. NOT named <c>AudioSettings</c> — that collides with <c>UnityEngine.AudioSettings</c>.
|
||||
/// Reset on play-enter (<see cref="RuntimeInitializeLoadType.SubsystemRegistration"/>) so a fast-enter-playmode
|
||||
/// domain reload never carries a stale value into a fresh session.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class GameVolume
|
||||
{
|
||||
/// <summary>Music/ambience bus trim (0..1). Applied by AmbientAudioSystem.</summary>
|
||||
public static float Music = 1f;
|
||||
|
||||
/// <summary>SFX bus trim (0..1). Applied by Combat/World feedback systems.</summary>
|
||||
public static float Sfx = 1f;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetStatics()
|
||||
{
|
||||
Music = 1f;
|
||||
Sfx = 1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a41fe49c050b2ae4b869edd52ddb716e
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-local settings persistence + application. Loads <c>settings.json</c> from
|
||||
/// <c>Application.persistentDataPath</c> at boot (<see cref="RuntimeInitializeOnLoadMethod"/>) and applies
|
||||
/// Graphics (Screen / QualitySettings / targetFrameRate) + Audio (<c>AudioListener.volume = Master</c>;
|
||||
/// <see cref="GameVolume"/> Music/Sfx bus trims). The single source of truth for the UITK
|
||||
/// <c>SettingsScreen</c> shared by the main menu + the in-game pause overlay. Saves are atomic
|
||||
/// (temp file + <c>File.Replace</c>). <c>JsonUtility</c> keeps it asmdef-ref-free. Never replicated.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Read settings from disk (or defaults if absent/corrupt). Returns the loaded value.</summary>
|
||||
public static GameSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(FilePath))
|
||||
{
|
||||
var json = File.ReadAllText(FilePath);
|
||||
var s = JsonUtility.FromJson<GameSettings>(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;
|
||||
}
|
||||
|
||||
/// <summary>Clamp + version-stamp + atomically write to disk; updates <see cref="Current"/>.</summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Apply settings to the live engine. Master rides AudioListener.volume; buses are per-call trims.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Convenience for the settings UI: persist then apply.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3daf657b3863e6e4fa02aa046a1b074e
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff809b44eac2a224cae4178439cf3215
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
@@ -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<->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 "< value >" 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
|
||||
@@ -0,0 +1,63 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c4747ef10cbb424bbfa8ebd4f807fb7
|
||||
@@ -29,7 +29,9 @@ namespace ProjectM.Server
|
||||
|
||||
cfgRef.ValueRW.Requested = false;
|
||||
|
||||
var endpoint = NetworkEndpoint.AnyIpv4.WithPort(cfgRef.ValueRO.Port);
|
||||
var endpoint = cfgRef.ValueRO.Address.ToString() == "127.0.0.1"
|
||||
? NetworkEndpoint.LoopbackIpv4.WithPort(cfgRef.ValueRO.Port) // single-player: bind loopback only (no firewall prompt)
|
||||
: NetworkEndpoint.AnyIpv4.WithPort(cfgRef.ValueRO.Port); // host: accept LAN peers
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
var req = ecb.CreateEntity();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6aa6ed6723d75a64796d926f11f63236
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Host-only autosave writer. A managed <see cref="SystemBase"/> (file IO => NO Burst) that reacts to the
|
||||
/// <see cref="SaveRequest"/> flag the Bursted <c>CyclePhaseSystem</c> raises on the Siege->Calm checkpoint:
|
||||
/// reads the authoritative <see cref="GoalProgress"/> + shared resource ledger off the CycleDirector ghost,
|
||||
/// writes the JSON save (<see cref="SaveService"/>), then clears the flag. ServerSimulation-only, so a pure
|
||||
/// (Join) client never writes. Deliberately carries NO <c>[UpdateAfter(CyclePhaseSystem)]</c> (that would risk
|
||||
/// a sort-cycle); a one-tick-late autosave is irrelevant.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial class SaveWriteSystem : SystemBase
|
||||
{
|
||||
protected override void OnCreate()
|
||||
{
|
||||
RequireForUpdate<SaveRequest>();
|
||||
RequireForUpdate<NetworkTime>();
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
var dir = SystemAPI.GetSingletonEntity<SaveRequest>();
|
||||
var req = SystemAPI.GetComponent<SaveRequest>(dir);
|
||||
if (req.Pending == 0)
|
||||
return;
|
||||
|
||||
req.Pending = 0;
|
||||
SystemAPI.SetComponent(dir, req);
|
||||
|
||||
var goal = SystemAPI.HasComponent<GoalProgress>(dir)
|
||||
? SystemAPI.GetComponent<GoalProgress>(dir)
|
||||
: default;
|
||||
|
||||
// The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer).
|
||||
var buffer = SystemAPI.GetBuffer<StorageEntry>(dir);
|
||||
var rows = new LedgerRow[buffer.Length];
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count };
|
||||
|
||||
// M7: also persist player-built structures + their production tick-state / inventory (single shared scan).
|
||||
uint nowTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
|
||||
SaveStructureScan.Collect(EntityManager, nowTick, out var structures, out var structureIo);
|
||||
|
||||
SaveService.Save(new SaveData
|
||||
{
|
||||
GoalCharge = goal.Charge,
|
||||
GoalTarget = goal.Target,
|
||||
Ledger = rows,
|
||||
Structures = structures,
|
||||
StructureIo = structureIo,
|
||||
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a08738b815484f4499b9ab614698c3e6
|
||||
@@ -58,6 +58,24 @@ namespace ProjectM.Server
|
||||
});
|
||||
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
|
||||
ecb.AddComponent(director, new ThreatState());
|
||||
|
||||
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
||||
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
|
||||
if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity))
|
||||
{
|
||||
var pending = SystemAPI.GetComponent<PendingSave>(pendingEntity);
|
||||
if (pending.HasData != 0)
|
||||
{
|
||||
ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = pending.GoalTarget });
|
||||
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
|
||||
var destLedger = ecb.SetBuffer<StorageEntry>(director);
|
||||
SaveApply.WriteLedger(srcLedger, destLedger);
|
||||
}
|
||||
ecb.DestroyEntity(pendingEntity);
|
||||
}
|
||||
|
||||
// Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint.
|
||||
ecb.AddComponent(director, new SaveRequest { Pending = 0 });
|
||||
}
|
||||
|
||||
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
|
||||
|
||||
@@ -99,6 +99,9 @@ namespace ProjectM.Server
|
||||
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
||||
goal.Charge += 1;
|
||||
SystemAPI.SetComponent(cycleEntity, goal);
|
||||
// Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag).
|
||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ProjectM.Simulation
|
||||
/// <para>
|
||||
/// M4 (LAN co-op): auto-connect is DISABLED (<see cref="ClientServerBootstrap.AutoConnectPort"/> = 0).
|
||||
/// Listening/connecting is driven explicitly via the <see cref="ConnectionConfig"/> singleton and the
|
||||
/// per-world ConnectionControlSystems — from <c>ConnectionUI</c> (Host / Join + IP) in player builds,
|
||||
/// per-world ConnectionControlSystems — from the UITK frontend menu (<c>MainMenuController</c> → <c>WorldLauncher</c>, Host / Join + IP) in player builds,
|
||||
/// or from the editor-only <c>EditorAutoHostSystem</c>, which auto-hosts on loopback and connects the
|
||||
/// in-proc client plus any Multiplayer-PlayMode-Tools thin clients. Direct IP/LAN only for now; Unity
|
||||
/// Relay is deferred to a later pass.
|
||||
@@ -21,10 +21,34 @@ namespace ProjectM.Simulation
|
||||
{
|
||||
public override bool Initialize(string defaultWorldName)
|
||||
{
|
||||
// No auto-connect: ConnectionConfig + the ConnectionControlSystems own listen/connect (M4).
|
||||
// No auto-connect: the menu (or, in the editor, the auto-host system) owns listen/connect.
|
||||
AutoConnectPort = 0;
|
||||
CreateDefaultClientServerWorlds();
|
||||
return true;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// Editor: keep today's instant-into-game + MPPM loop by DEFAULT. Only the MAIN editor
|
||||
// (ClientAndServer) with the "Boot Into Menu (Editor)" toggle ON takes the frontend path, so MPPM
|
||||
// virtual players (Client) never boot to the menu. Open MainMenu.unity + Play to test the menu.
|
||||
bool bootMenu = UnityEditor.EditorPrefs.GetBool("ProjectM.BootIntoMenu", false)
|
||||
&& RequestedPlayType == PlayType.ClientAndServer;
|
||||
if (!bootMenu)
|
||||
{
|
||||
CreateDefaultClientServerWorlds();
|
||||
return true;
|
||||
}
|
||||
return false; // Frontend: Entities makes a single default "menu" world; MainMenuController drives sessions.
|
||||
#else
|
||||
// Player build: a dedicated/headless server auto-hosts; everyone else boots the front-end menu.
|
||||
if (RequestedPlayType == PlayType.Server)
|
||||
{
|
||||
var server = CreateServerWorld("ServerWorld");
|
||||
World.DefaultGameObjectInjectionWorld = server;
|
||||
var em = server.EntityManager;
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new ConnectionConfig { Mode = ConnectionMode.Host, Address = "0.0.0.0", Port = 7979, Requested = true });
|
||||
return true;
|
||||
}
|
||||
return false; // Frontend menu (MainMenu.unity is build index 0).
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 82c8fb2f6f68a864fbbdf60b8c634439
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,19 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure save-apply helpers shared by the server spawn system (born-correct load) and EditMode tests.
|
||||
/// Burst-safe: unmanaged, non-generic, no enums (avoids the cross-assembly-generic Burst ICE class).
|
||||
/// </summary>
|
||||
public static class SaveApply
|
||||
{
|
||||
/// <summary>Replace a StorageEntry ledger buffer's contents with a staged PendingSaveLedgerRow buffer.</summary>
|
||||
public static void WriteLedger(DynamicBuffer<PendingSaveLedgerRow> src, DynamicBuffer<StorageEntry> dest)
|
||||
{
|
||||
dest.Clear();
|
||||
for (int i = 0; i < src.Length; i++)
|
||||
dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b187f01051a0d9e45b30b8f1c4c1e4c1
|
||||
@@ -0,0 +1,62 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-world, UNMANAGED bridge holding a save slice the menu staged for a "Continue" session, applied
|
||||
/// AT SPAWN by the server CycleDirectorSpawnSystem so the director ghost is BORN correct — it never
|
||||
/// serializes a default <see cref="GoalProgress"/> / empty ledger to clients (no replication flicker). The
|
||||
/// menu creates exactly one of these (with the <see cref="PendingSaveLedgerRow"/> buffer) in the freshly
|
||||
/// created ServerWorld BEFORE the gameplay subscene streams in; the spawn system consumes + destroys it.
|
||||
/// Unmanaged so the Bursted spawn system reads it without a managed bridge.
|
||||
/// </summary>
|
||||
public struct PendingSave : IComponentData
|
||||
{
|
||||
public int GoalCharge;
|
||||
public int GoalTarget;
|
||||
|
||||
/// <summary>0 = nothing staged (New Game); non-zero = apply the staged slice at director spawn.</summary>
|
||||
public byte HasData;
|
||||
}
|
||||
|
||||
/// <summary>One staged ledger row for a Continue session; copied into the director's StorageEntry buffer at spawn.</summary>
|
||||
public struct PendingSaveLedgerRow : IBufferElementData
|
||||
{
|
||||
public ushort ItemId;
|
||||
public int Count;
|
||||
}
|
||||
/// <summary>One staged player-built structure row for a Continue session (M7); BaseRestoreSystem replays it
|
||||
/// charge-free into the freshly-streamed base. Mirrors <see cref="StructureSave"/> but as an unmanaged ECS
|
||||
/// buffer element (staged in the ServerWorld before the subscene streams).</summary>
|
||||
public struct PendingStructure : IBufferElementData
|
||||
{
|
||||
public byte Type;
|
||||
public int CellX;
|
||||
public int CellZ;
|
||||
public byte Direction;
|
||||
public uint RemainingTicks;
|
||||
public byte ConveyorResId;
|
||||
public int ConveyorCount;
|
||||
}
|
||||
|
||||
/// <summary>One staged machine I/O row (M7), joined to the <see cref="PendingStructure"/> buffer by index.
|
||||
/// Slot 0 = MachineInput, 1 = MachineOutput.</summary>
|
||||
public struct PendingStructureIo : IBufferElementData
|
||||
{
|
||||
public int StructureIndex;
|
||||
public byte Slot;
|
||||
public byte ResourceId;
|
||||
public int Count;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Host-only autosave request flag on the CycleDirector entity (added at spawn). The Bursted CyclePhaseSystem
|
||||
/// sets <see cref="Pending"/>=1 on the Siege->Calm checkpoint; the managed SaveWriteSystem reads it, writes
|
||||
/// the JSON save, and clears it. A plain byte => Burst-safe (no managed/string/file touch in the sim loop).
|
||||
/// </summary>
|
||||
public struct SaveRequest : IComponentData
|
||||
{
|
||||
public byte Pending;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ed817060ccb7b445b5f1ad094e10443
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>One serialized ledger row (item id + count). An array FIELD of <see cref="SaveData"/>.</summary>
|
||||
[Serializable]
|
||||
public struct LedgerRow
|
||||
{
|
||||
public int ItemId;
|
||||
public int Count;
|
||||
}
|
||||
/// <summary>
|
||||
/// One serialized player-built structure (M7). Flat scalars (JsonUtility has no int2). The production
|
||||
/// cooldown is stored as REMAINING ticks (epoch-independent) so it survives the server-tick origin reset on a
|
||||
/// fresh session; the in-flight conveyor item (if any) rides here, while variable-length machine I/O buffers
|
||||
/// live in the flat <see cref="SaveData.StructureIo"/> table keyed by index.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public struct StructureSave
|
||||
{
|
||||
public byte Type;
|
||||
public int CellX;
|
||||
public int CellZ;
|
||||
public byte Direction; // conveyor facing (0 for non-conveyors)
|
||||
public uint RemainingTicks; // production/cooldown ticks left at save time
|
||||
public byte ConveyorResId; // in-flight conveyor item resource (0 = none)
|
||||
public int ConveyorCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One serialized machine I/O buffer row, joined to <see cref="SaveData.Structures"/> by
|
||||
/// <see cref="StructureIndex"/>. A flat top-level array (JsonUtility can't nest arrays-of-arrays); Slot 0 =
|
||||
/// MachineInput, Slot 1 = MachineOutput.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public struct StructureIoRow
|
||||
{
|
||||
public int StructureIndex;
|
||||
public byte Slot;
|
||||
public byte ResourceId;
|
||||
public int Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Versioned, host-authoritative save slice (the FOUNDATION): the long-arc goal charge/target + the shared
|
||||
/// resource ledger. JsonUtility-friendly — a class with flat fields and an array FIELD (never a root array).
|
||||
/// The schema is intentionally ADDITIVE: future fields (placed structures, threat, storage) append without
|
||||
/// breaking old saves, gated by <see cref="Version"/> migration.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class SaveData
|
||||
{
|
||||
public const int CurrentVersion = 2;
|
||||
|
||||
public int Version = CurrentVersion;
|
||||
public int GoalCharge;
|
||||
public int GoalTarget;
|
||||
public LedgerRow[] Ledger = Array.Empty<LedgerRow>();
|
||||
public StructureSave[] Structures = Array.Empty<StructureSave>();
|
||||
public StructureIoRow[] StructureIo = Array.Empty<StructureIoRow>();
|
||||
public long SavedAtMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2f5cc5646cf9ee4b94d7cebaefb8f30
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Host-local persistence for the game save slice (<see cref="SaveData"/>) — single slot, versioned JSON at
|
||||
/// <c>Application.persistentDataPath/save_0.json</c>, atomic writes (temp + <c>File.Replace</c>). Read by the
|
||||
/// menu (to offer "Continue" + stage a <see cref="PendingSave"/>) and the server SaveWriteSystem (autosave).
|
||||
/// JsonUtility keeps it dependency-free. Returns null on a missing / corrupt / version-mismatched file —
|
||||
/// never throws to callers (a bad save degrades to "New Game", it never crashes boot).
|
||||
/// </summary>
|
||||
public static class SaveService
|
||||
{
|
||||
static string FilePath => Path.Combine(Application.persistentDataPath, "save_0.json");
|
||||
|
||||
public static bool HasSave() => File.Exists(FilePath);
|
||||
|
||||
public static SaveData Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(FilePath)) return null;
|
||||
var data = JsonUtility.FromJson<SaveData>(File.ReadAllText(FilePath));
|
||||
if (data == null || data.Version != SaveData.CurrentVersion) return null;
|
||||
data.Ledger ??= Array.Empty<LedgerRow>();
|
||||
return data;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[SaveService] Load failed ({e.Message}); treating as no save.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Save(SaveData data)
|
||||
{
|
||||
if (data == null) return;
|
||||
data.Version = SaveData.CurrentVersion;
|
||||
try
|
||||
{
|
||||
var json = JsonUtility.ToJson(data, true);
|
||||
var tmp = FilePath + ".tmp";
|
||||
File.WriteAllText(tmp, json);
|
||||
if (File.Exists(FilePath)) File.Replace(tmp, FilePath, null);
|
||||
else File.Move(tmp, FilePath);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[SaveService] Save failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Delete()
|
||||
{
|
||||
try { if (File.Exists(FilePath)) File.Delete(FilePath); }
|
||||
catch (Exception e) { Debug.LogWarning($"[SaveService] Delete failed: {e.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4aca4b85393065f45a5c5ff87f35e428
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans a server world for PLAYER-built structures (<see cref="PlacedStructure"/> + <see cref="RuntimePlacedTag"/>)
|
||||
/// into the flat SaveData v2 arrays — the SINGLE shared scan used by BOTH the autosave (SaveWriteSystem) and the
|
||||
/// quit-to-menu save (WorldLauncher), so the two paths can never drift (only RuntimePlacedTag structures are saved;
|
||||
/// anything baked into the subscene is the subscene's source of truth, not the save's). Cooldowns are stored as
|
||||
/// REMAINING ticks (epoch-independent). Managed (List/array) — runs only on a save, never in the hot loop.
|
||||
/// </summary>
|
||||
public static class SaveStructureScan
|
||||
{
|
||||
public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures, out StructureIoRow[] io)
|
||||
{
|
||||
var structs = new List<StructureSave>();
|
||||
var ioRows = new List<StructureIoRow>();
|
||||
|
||||
using var q = em.CreateEntityQuery(
|
||||
ComponentType.ReadOnly<PlacedStructure>(),
|
||||
ComponentType.ReadOnly<RuntimePlacedTag>());
|
||||
using var entities = q.ToEntityArray(Allocator.Temp);
|
||||
|
||||
for (int k = 0; k < entities.Length; k++)
|
||||
{
|
||||
var e = entities[k];
|
||||
var ps = em.GetComponentData<PlacedStructure>(e);
|
||||
int idx = structs.Count;
|
||||
|
||||
var row = new StructureSave
|
||||
{
|
||||
Type = ps.Type,
|
||||
CellX = ps.Cell.x,
|
||||
CellZ = ps.Cell.y,
|
||||
RemainingTicks = ProductionMath.RemainingTicks(ps.NextTick, nowTick),
|
||||
};
|
||||
|
||||
if (em.HasComponent<Conveyor>(e))
|
||||
row.Direction = em.GetComponentData<Conveyor>(e).Direction;
|
||||
|
||||
if (em.HasComponent<ConveyorItem>(e) && em.IsComponentEnabled<ConveyorItem>(e))
|
||||
{
|
||||
var item = em.GetComponentData<ConveyorItem>(e);
|
||||
row.ConveyorResId = item.ResourceId;
|
||||
row.ConveyorCount = item.Count;
|
||||
}
|
||||
|
||||
structs.Add(row);
|
||||
|
||||
if (em.HasBuffer<MachineInput>(e))
|
||||
{
|
||||
var buf = em.GetBuffer<MachineInput>(e, true);
|
||||
for (int i = 0; i < buf.Length; i++)
|
||||
ioRows.Add(new StructureIoRow { StructureIndex = idx, Slot = 0, ResourceId = buf[i].ResourceId, Count = buf[i].Count });
|
||||
}
|
||||
if (em.HasBuffer<MachineOutput>(e))
|
||||
{
|
||||
var buf = em.GetBuffer<MachineOutput>(e, true);
|
||||
for (int i = 0; i < buf.Length; i++)
|
||||
ioRows.Add(new StructureIoRow { StructureIndex = idx, Slot = 1, ResourceId = buf[i].ResourceId, Count = buf[i].Count });
|
||||
}
|
||||
}
|
||||
|
||||
structures = structs.ToArray();
|
||||
io = ioRows.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47b137b2d90c6154d8c195f8c491f0d8
|
||||
Reference in New Issue
Block a user