From 0a28890f2c7f25abab5c2e0c314f51cdae792d93 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 18 Jul 2026 21:06:57 +0800 Subject: [PATCH] feat: implement game saving and loading in TicTacToe - Add serialization support to `World`, `EntityAllocator`, and `SparseSet` - Implement `SaveGame` and `LoadGame` functionality in the TicTacToe example - Add ability to resume a game from a saved state - Implement auto-save after every move in the game loop --- .../TicTacToe/Commands/PlaceMarkCommand.cs | 4 +- examples/TicTacToe/Program.cs | 126 +++++++++++------- src/OECS/EntityAllocator.cs | 14 ++ src/OECS/SparseSet.cs | 16 +++ src/OECS/World.cs | 12 ++ 5 files changed, 124 insertions(+), 48 deletions(-) diff --git a/examples/TicTacToe/Commands/PlaceMarkCommand.cs b/examples/TicTacToe/Commands/PlaceMarkCommand.cs index 520ccb7..27d39e6 100644 --- a/examples/TicTacToe/Commands/PlaceMarkCommand.cs +++ b/examples/TicTacToe/Commands/PlaceMarkCommand.cs @@ -38,9 +38,9 @@ public struct PlaceMarkCommand : ICommand if (target == null) return; // Cell already occupied or invalid position. - // Place the mark. + // Place the mark. ComponentAdded is sufficient — MarkModified + // is only needed when mutating an existing component via GetComponent. world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer }); - world.MarkModified(target.Value); // Advance turn. state.MoveCount++; diff --git a/examples/TicTacToe/Program.cs b/examples/TicTacToe/Program.cs index 77f34ea..9c8d882 100644 --- a/examples/TicTacToe/Program.cs +++ b/examples/TicTacToe/Program.cs @@ -5,58 +5,47 @@ namespace TicTacToe; public static class Program { + private static readonly string SavePath = Path.Combine( + AppContext.BaseDirectory, "save.bin"); + public static void Main() { var world = new World(); - - // ── Setup reactivity logging ────────────────────────────────── - // - // Each subscription appends to a shared log buffer. The log is - // printed after the board render so Console.Clear() doesn't wipe it. - var reactivityLog = new List(); + SetupReactivityLogging(world, reactivityLog); - // 1. Log every entity-level change (creates, destroys). - world.ObserveEntityChanges().Subscribe(change => + // ── Try to load a saved game ────────────────────────────────── + + bool loaded = false; + if (File.Exists(SavePath)) { - if (change.ComponentType != null) return; - reactivityLog.Add($"[react] {change}"); - }); + Console.Write("Saved game found. Load it? (y/n): "); + if (Console.ReadLine()?.Trim().ToLower() == "y") + { + using var stream = File.OpenRead(SavePath); + WorldSerializer.Load(world, stream); + loaded = true; + Console.WriteLine("Game loaded!"); + Console.WriteLine(); + } + } - // 2. Log every Mark component change (placed on a cell). - world.ObserveComponentChanges().Subscribe(change => + // ── Load fresh board from CSV (if not loaded) ───────────────── + + if (!loaded) { - reactivityLog.Add($"[react] {change}"); - }); + var csvPath = Path.Combine(AppContext.BaseDirectory, "Data", "board.csv"); + CsvLoader.LoadCells(world, csvPath); + world.PostChanges(); - // 3. Log every GameState singleton change. - world.ObserveComponentChanges().Subscribe(change => - { - reactivityLog.Add($"[react] {change}"); - }); - - // 4. Log changes matching a query: cells that have a Mark. - var markedQuery = world.Query().With().With().Build(); - world.ObserveQuery(markedQuery).Subscribe(change => - { - reactivityLog.Add($"[react:query] {change}"); - }); - - // ── Load initial state from CSV ─────────────────────────────── - - var csvPath = Path.Combine(AppContext.BaseDirectory, "Data", "board.csv"); - CsvLoader.LoadCells(world, csvPath); - world.PostChanges(); // flush entity-created notifications - - // ── Initialize singleton ────────────────────────────────────── - - world.SetSingleton(new GameState - { - CurrentPlayer = Player.X, - Status = GameStatus.Playing, - MoveCount = 0 - }); - world.PostChanges(); + world.SetSingleton(new GameState + { + CurrentPlayer = Player.X, + Status = GameStatus.Playing, + MoveCount = 0 + }); + world.PostChanges(); + } // ── Wire up systems ─────────────────────────────────────────── @@ -66,7 +55,6 @@ public static class Program // ── Game loop ───────────────────────────────────────────────── - // Initial render. group.RunLogical(); PrintReactivityLog(reactivityLog); @@ -80,12 +68,20 @@ public static class Program if (string.IsNullOrWhiteSpace(input)) continue; + // "save" command. + if (input.Trim().ToLower() == "save") + { + SaveGame(world); + Console.WriteLine(" Game saved."); + continue; + } + var parts = input.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 2 || !int.TryParse(parts[0], out var row) || !int.TryParse(parts[1], out var col)) { - Console.WriteLine(" Invalid input. Try \"1 2\"."); + Console.WriteLine(" Invalid input. Try \"1 2\" or \"save\"."); continue; } @@ -98,6 +94,9 @@ public static class Program world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col }); group.RunLogical(); PrintReactivityLog(reactivityLog); + + // Auto-save after every move. + SaveGame(world); } // ── Final state ─────────────────────────────────────────────── @@ -105,6 +104,41 @@ public static class Program Console.WriteLine(); Console.WriteLine("=== Game over ==="); PrintReactivityLog(reactivityLog); + + // Clean up save file on game over. + if (File.Exists(SavePath)) + File.Delete(SavePath); + } + + private static void SaveGame(World world) + { + using var stream = File.Create(SavePath); + WorldSerializer.Save(world, stream); + } + + private static void SetupReactivityLogging(World world, List log) + { + world.ObserveEntityChanges().Subscribe(change => + { + if (change.ComponentType != null) return; + log.Add($"[react] {change}"); + }); + + world.ObserveComponentChanges().Subscribe(change => + { + log.Add($"[react] {change}"); + }); + + world.ObserveComponentChanges().Subscribe(change => + { + log.Add($"[react] {change}"); + }); + + var markedQuery = world.Query().With().With().Build(); + world.ObserveQuery(markedQuery).Subscribe(change => + { + log.Add($"[react:query] {change}"); + }); } private static void PrintReactivityLog(List log) @@ -116,4 +150,4 @@ public static class Program Console.WriteLine(); log.Clear(); } -} +} \ No newline at end of file diff --git a/src/OECS/EntityAllocator.cs b/src/OECS/EntityAllocator.cs index 87be247..8e73191 100644 --- a/src/OECS/EntityAllocator.cs +++ b/src/OECS/EntityAllocator.cs @@ -73,6 +73,20 @@ internal class EntityAllocator _versions[singleton.Id] = (byte)singleton.Version; } + /// + /// Reserves a specific entity ID and version for deserialization. + /// Advances past the reserved ID so future + /// allocations don't collide. + /// + public void Reserve(Entity entity) + { + uint id = entity.Id; + EnsureCapacity(id); + _versions[id] = (byte)entity.Version; + if (id >= _nextId) + _nextId = id + 1; + } + /// /// Returns true if the entity's version matches the current version for its ID. /// diff --git a/src/OECS/SparseSet.cs b/src/OECS/SparseSet.cs index 5a514e6..c1c91e3 100644 --- a/src/OECS/SparseSet.cs +++ b/src/OECS/SparseSet.cs @@ -11,6 +11,18 @@ internal interface ISparseSet void Remove(Entity entity); bool Contains(Entity entity); int Count { get; } + + /// + /// Returns the dense entity array (first elements are valid). + /// Used by serialization to enumerate entities without knowing the component type. + /// + Entity[] GetDenseEntities(); + + /// + /// Returns the component at the given dense index as an object. + /// Used by serialization to extract component values. + /// + object GetComponentAt(int denseIndex); } /// @@ -171,6 +183,10 @@ internal class SparseSet : ISparseSet where T : struct _count = 0; } + Entity[] ISparseSet.GetDenseEntities() => _denseEntities; + + object ISparseSet.GetComponentAt(int denseIndex) => _dense[denseIndex]!; + private void EnsureSparseCapacity(uint entityId) { if (entityId >= (uint)_sparse.Length) diff --git a/src/OECS/World.cs b/src/OECS/World.cs index 37411dc..ab0432c 100644 --- a/src/OECS/World.cs +++ b/src/OECS/World.cs @@ -54,6 +54,18 @@ public class World : IDisposable return entity; } + /// + /// Creates an entity with a specific handle. Used during deserialization + /// to restore entities with their original IDs and versions. + /// Does NOT mark an EntityAdded change (caller is responsible for + /// posting changes after the full load). + /// + internal Entity CreateEntity(Entity handle) + { + _allocator.Reserve(handle); + return handle; + } + /// /// Destroys an entity, removing all its components and recycling its ID. ///