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
This commit is contained in:
hypercross 2026-07-18 21:06:57 +08:00
parent a19861c080
commit 0a28890f2c
5 changed files with 124 additions and 48 deletions

View File

@ -38,9 +38,9 @@ public struct PlaceMarkCommand : ICommand
if (target == null) if (target == null)
return; // Cell already occupied or invalid position. 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<T>.
world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer }); world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer });
world.MarkModified<Mark>(target.Value);
// Advance turn. // Advance turn.
state.MoveCount++; state.MoveCount++;

View File

@ -5,58 +5,47 @@ namespace TicTacToe;
public static class Program public static class Program
{ {
private static readonly string SavePath = Path.Combine(
AppContext.BaseDirectory, "save.bin");
public static void Main() public static void Main()
{ {
var world = new World(); 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<string>(); var reactivityLog = new List<string>();
SetupReactivityLogging(world, reactivityLog);
// 1. Log every entity-level change (creates, destroys). // ── Try to load a saved game ──────────────────────────────────
world.ObserveEntityChanges().Subscribe(change =>
bool loaded = false;
if (File.Exists(SavePath))
{ {
if (change.ComponentType != null) return; Console.Write("Saved game found. Load it? (y/n): ");
reactivityLog.Add($"[react] {change}"); 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). // ── Load fresh board from CSV (if not loaded) ─────────────────
world.ObserveComponentChanges<Mark>().Subscribe(change =>
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.SetSingleton(new GameState
world.ObserveComponentChanges<GameState>().Subscribe(change => {
{ CurrentPlayer = Player.X,
reactivityLog.Add($"[react] {change}"); Status = GameStatus.Playing,
}); MoveCount = 0
});
// 4. Log changes matching a query: cells that have a Mark. world.PostChanges();
var markedQuery = world.Query().With<Cell>().With<Mark>().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();
// ── Wire up systems ─────────────────────────────────────────── // ── Wire up systems ───────────────────────────────────────────
@ -66,7 +55,6 @@ public static class Program
// ── Game loop ───────────────────────────────────────────────── // ── Game loop ─────────────────────────────────────────────────
// Initial render.
group.RunLogical(); group.RunLogical();
PrintReactivityLog(reactivityLog); PrintReactivityLog(reactivityLog);
@ -80,12 +68,20 @@ public static class Program
if (string.IsNullOrWhiteSpace(input)) if (string.IsNullOrWhiteSpace(input))
continue; continue;
// "save" command.
if (input.Trim().ToLower() == "save")
{
SaveGame(world);
Console.WriteLine(" Game saved.");
continue;
}
var parts = input.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); var parts = input.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2 || if (parts.Length != 2 ||
!int.TryParse(parts[0], out var row) || !int.TryParse(parts[0], out var row) ||
!int.TryParse(parts[1], out var col)) !int.TryParse(parts[1], out var col))
{ {
Console.WriteLine(" Invalid input. Try \"1 2\"."); Console.WriteLine(" Invalid input. Try \"1 2\" or \"save\".");
continue; continue;
} }
@ -98,6 +94,9 @@ public static class Program
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col }); world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
group.RunLogical(); group.RunLogical();
PrintReactivityLog(reactivityLog); PrintReactivityLog(reactivityLog);
// Auto-save after every move.
SaveGame(world);
} }
// ── Final state ─────────────────────────────────────────────── // ── Final state ───────────────────────────────────────────────
@ -105,6 +104,41 @@ public static class Program
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("=== Game over ==="); Console.WriteLine("=== Game over ===");
PrintReactivityLog(reactivityLog); 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<string> log)
{
world.ObserveEntityChanges().Subscribe(change =>
{
if (change.ComponentType != null) return;
log.Add($"[react] {change}");
});
world.ObserveComponentChanges<Mark>().Subscribe(change =>
{
log.Add($"[react] {change}");
});
world.ObserveComponentChanges<GameState>().Subscribe(change =>
{
log.Add($"[react] {change}");
});
var markedQuery = world.Query().With<Cell>().With<Mark>().Build();
world.ObserveQuery(markedQuery).Subscribe(change =>
{
log.Add($"[react:query] {change}");
});
} }
private static void PrintReactivityLog(List<string> log) private static void PrintReactivityLog(List<string> log)
@ -116,4 +150,4 @@ public static class Program
Console.WriteLine(); Console.WriteLine();
log.Clear(); log.Clear();
} }
} }

View File

@ -73,6 +73,20 @@ internal class EntityAllocator
_versions[singleton.Id] = (byte)singleton.Version; _versions[singleton.Id] = (byte)singleton.Version;
} }
/// <summary>
/// Reserves a specific entity ID and version for deserialization.
/// Advances <see cref="_nextId"/> past the reserved ID so future
/// allocations don't collide.
/// </summary>
public void Reserve(Entity entity)
{
uint id = entity.Id;
EnsureCapacity(id);
_versions[id] = (byte)entity.Version;
if (id >= _nextId)
_nextId = id + 1;
}
/// <summary> /// <summary>
/// Returns true if the entity's version matches the current version for its ID. /// Returns true if the entity's version matches the current version for its ID.
/// </summary> /// </summary>

View File

@ -11,6 +11,18 @@ internal interface ISparseSet
void Remove(Entity entity); void Remove(Entity entity);
bool Contains(Entity entity); bool Contains(Entity entity);
int Count { get; } int Count { get; }
/// <summary>
/// Returns the dense entity array (first <see cref="Count"/> elements are valid).
/// Used by serialization to enumerate entities without knowing the component type.
/// </summary>
Entity[] GetDenseEntities();
/// <summary>
/// Returns the component at the given dense index as an object.
/// Used by serialization to extract component values.
/// </summary>
object GetComponentAt(int denseIndex);
} }
/// <summary> /// <summary>
@ -171,6 +183,10 @@ internal class SparseSet<T> : ISparseSet where T : struct
_count = 0; _count = 0;
} }
Entity[] ISparseSet.GetDenseEntities() => _denseEntities;
object ISparseSet.GetComponentAt(int denseIndex) => _dense[denseIndex]!;
private void EnsureSparseCapacity(uint entityId) private void EnsureSparseCapacity(uint entityId)
{ {
if (entityId >= (uint)_sparse.Length) if (entityId >= (uint)_sparse.Length)

View File

@ -54,6 +54,18 @@ public class World : IDisposable
return entity; return entity;
} }
/// <summary>
/// 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).
/// </summary>
internal Entity CreateEntity(Entity handle)
{
_allocator.Reserve(handle);
return handle;
}
/// <summary> /// <summary>
/// Destroys an entity, removing all its components and recycling its ID. /// Destroys an entity, removing all its components and recycling its ID.
/// ///