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:
parent
a19861c080
commit
0a28890f2c
|
|
@ -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++;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue