feat: add TicTacToe example project
Implement a complete Tic-Tac-Toe game using OECS to demonstrate ECS patterns, including: - Command pattern for placing marks - System groups for logical updates and rendering - CSV loading for initial board state - Reactivity logging for observing entity and component changes
This commit is contained in:
parent
dddddbdbd6
commit
a19861c080
|
|
@ -0,0 +1,50 @@
|
||||||
|
using MessagePack;
|
||||||
|
using OECS;
|
||||||
|
|
||||||
|
namespace TicTacToe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Places the current player's mark on the cell at (Row, Col).
|
||||||
|
/// Validates that the cell is empty and the game is still playing.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct PlaceMarkCommand : ICommand
|
||||||
|
{
|
||||||
|
[Key(0)] public int Row;
|
||||||
|
[Key(1)] public int Col;
|
||||||
|
|
||||||
|
public void Execute(World world)
|
||||||
|
{
|
||||||
|
ref var state = ref world.GetSingleton<GameState>();
|
||||||
|
|
||||||
|
if (state.Status != GameStatus.Playing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Copy to locals so the lambda in ForEach can capture them
|
||||||
|
// (this is a struct, so `this` cannot be captured directly).
|
||||||
|
int row = Row;
|
||||||
|
int col = Col;
|
||||||
|
|
||||||
|
// Find the cell entity at (Row, Col) that has no Mark.
|
||||||
|
var query = world.Query().With<Cell>().Without<Mark>().Build();
|
||||||
|
Entity? target = null;
|
||||||
|
|
||||||
|
world.ForEach(query, (Entity entity, ref Cell cell) =>
|
||||||
|
{
|
||||||
|
if (cell.Row == row && cell.Col == col)
|
||||||
|
target = entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (target == null)
|
||||||
|
return; // Cell already occupied or invalid position.
|
||||||
|
|
||||||
|
// Place the mark.
|
||||||
|
world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer });
|
||||||
|
world.MarkModified<Mark>(target.Value);
|
||||||
|
|
||||||
|
// Advance turn.
|
||||||
|
state.MoveCount++;
|
||||||
|
state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X;
|
||||||
|
world.MarkModified<GameState>(World.SingletonEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace TicTacToe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies a board position. One entity per cell.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct Cell
|
||||||
|
{
|
||||||
|
[Key(0)] public int Row;
|
||||||
|
[Key(1)] public int Col;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace TicTacToe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A mark placed on a cell by a player.
|
||||||
|
/// Only present on cells that have been claimed.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct Mark
|
||||||
|
{
|
||||||
|
[Key(0)] public Player Player;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace TicTacToe;
|
||||||
|
|
||||||
|
public enum Player : byte
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
X = 1,
|
||||||
|
O = 2
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
using OECS;
|
||||||
|
|
||||||
|
namespace TicTacToe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal CSV loader that creates entities from a CSV file.
|
||||||
|
///
|
||||||
|
/// The first row is a header. Each subsequent row creates one entity.
|
||||||
|
/// Column names are mapped to component fields by convention:
|
||||||
|
/// a column named "Row" sets Cell.Row, "Col" sets Cell.Col, etc.
|
||||||
|
///
|
||||||
|
/// Currently hardcoded for the Cell component. Extend as needed.
|
||||||
|
/// </summary>
|
||||||
|
public static class CsvLoader
|
||||||
|
{
|
||||||
|
public static List<Entity> LoadCells(World world, string filePath)
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
var lines = File.ReadAllLines(filePath);
|
||||||
|
|
||||||
|
if (lines.Length < 2)
|
||||||
|
return entities;
|
||||||
|
|
||||||
|
// Parse header to get column indices.
|
||||||
|
var headers = lines[0].Split(',');
|
||||||
|
int rowIdx = Array.IndexOf(headers, "Row");
|
||||||
|
int colIdx = Array.IndexOf(headers, "Col");
|
||||||
|
|
||||||
|
for (int i = 1; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
var values = lines[i].Split(',');
|
||||||
|
if (values.Length < 2) continue;
|
||||||
|
|
||||||
|
var entity = world.CreateEntity();
|
||||||
|
world.AddComponent(entity, new Cell
|
||||||
|
{
|
||||||
|
Row = int.Parse(values[rowIdx]),
|
||||||
|
Col = int.Parse(values[colIdx])
|
||||||
|
});
|
||||||
|
entities.Add(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
Row,Col
|
||||||
|
0,0
|
||||||
|
0,1
|
||||||
|
0,2
|
||||||
|
1,0
|
||||||
|
1,1
|
||||||
|
1,2
|
||||||
|
2,0
|
||||||
|
2,1
|
||||||
|
2,2
|
||||||
|
|
|
@ -0,0 +1,119 @@
|
||||||
|
using OECS;
|
||||||
|
using R3;
|
||||||
|
|
||||||
|
namespace TicTacToe;
|
||||||
|
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
// 1. Log every entity-level change (creates, destroys).
|
||||||
|
world.ObserveEntityChanges().Subscribe(change =>
|
||||||
|
{
|
||||||
|
if (change.ComponentType != null) return;
|
||||||
|
reactivityLog.Add($"[react] {change}");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Log every Mark component change (placed on a cell).
|
||||||
|
world.ObserveComponentChanges<Mark>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
reactivityLog.Add($"[react] {change}");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Log every GameState singleton change.
|
||||||
|
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||||
|
{
|
||||||
|
reactivityLog.Add($"[react] {change}");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Log changes matching a query: cells that have a Mark.
|
||||||
|
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 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
var group = new SystemGroup(world);
|
||||||
|
group.Add(new WinCheckSystem(world));
|
||||||
|
group.Add(new RenderSystem(world));
|
||||||
|
|
||||||
|
// ── Game loop ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Initial render.
|
||||||
|
group.RunLogical();
|
||||||
|
PrintReactivityLog(reactivityLog);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
ref var state = ref world.GetSingleton<GameState>();
|
||||||
|
if (state.Status != GameStatus.Playing)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var input = Console.ReadLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
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\".");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row < 0 || row > 2 || col < 0 || col > 2)
|
||||||
|
{
|
||||||
|
Console.WriteLine(" Row and col must be 0–2.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
|
||||||
|
group.RunLogical();
|
||||||
|
PrintReactivityLog(reactivityLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Final state ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("=== Game over ===");
|
||||||
|
PrintReactivityLog(reactivityLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrintReactivityLog(List<string> log)
|
||||||
|
{
|
||||||
|
if (log.Count == 0) return;
|
||||||
|
Console.WriteLine("── reactivity log ──");
|
||||||
|
foreach (var entry in log)
|
||||||
|
Console.WriteLine(entry);
|
||||||
|
Console.WriteLine();
|
||||||
|
log.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace TicTacToe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Global game state stored on the singleton entity.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public struct GameState
|
||||||
|
{
|
||||||
|
[Key(0)] public Player CurrentPlayer;
|
||||||
|
[Key(1)] public GameStatus Status;
|
||||||
|
[Key(2)] public int MoveCount;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace TicTacToe;
|
||||||
|
|
||||||
|
public enum GameStatus : byte
|
||||||
|
{
|
||||||
|
Playing = 0,
|
||||||
|
XWon = 1,
|
||||||
|
OWon = 2,
|
||||||
|
Draw = 3
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
using OECS;
|
||||||
|
|
||||||
|
namespace TicTacToe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prints the board to the console.
|
||||||
|
/// </summary>
|
||||||
|
public class RenderSystem : ISystem
|
||||||
|
{
|
||||||
|
public QueryDescriptor Query { get; }
|
||||||
|
|
||||||
|
public RenderSystem(World world)
|
||||||
|
{
|
||||||
|
Query = world.Query().With<Cell>().Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Run(World world)
|
||||||
|
{
|
||||||
|
ref var state = ref world.GetSingleton<GameState>();
|
||||||
|
|
||||||
|
// Build a 3×3 grid.
|
||||||
|
var grid = new char[3, 3];
|
||||||
|
for (int r = 0; r < 3; r++)
|
||||||
|
for (int c = 0; c < 3; c++)
|
||||||
|
grid[r, c] = '.';
|
||||||
|
|
||||||
|
// Fill in placed marks.
|
||||||
|
var markQuery = world.Query().With<Cell>().With<Mark>().Build();
|
||||||
|
world.ForEach(markQuery, (Entity entity, ref Cell cell, ref Mark mark) =>
|
||||||
|
{
|
||||||
|
grid[cell.Row, cell.Col] = mark.Player == Player.X ? 'X' : 'O';
|
||||||
|
});
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(" Tic-Tac-Toe");
|
||||||
|
Console.WriteLine(" ═══════════");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(" 0 1 2");
|
||||||
|
Console.WriteLine(" ┌───┬───┬───┐");
|
||||||
|
for (int r = 0; r < 3; r++)
|
||||||
|
{
|
||||||
|
Console.Write($"{r} │");
|
||||||
|
for (int c = 0; c < 3; c++)
|
||||||
|
{
|
||||||
|
Console.Write($" {grid[r, c]} ");
|
||||||
|
if (c < 2) Console.Write("│");
|
||||||
|
}
|
||||||
|
Console.WriteLine("│");
|
||||||
|
if (r < 2) Console.WriteLine(" ├───┼───┼───┤");
|
||||||
|
}
|
||||||
|
Console.WriteLine(" └───┴───┴───┘");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
// Status line.
|
||||||
|
var statusText = state.Status switch
|
||||||
|
{
|
||||||
|
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn (move {state.MoveCount + 1})",
|
||||||
|
GameStatus.XWon => "X wins!",
|
||||||
|
GameStatus.OWon => "O wins!",
|
||||||
|
GameStatus.Draw => "It's a draw!",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
Console.WriteLine($" {statusText}");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.Write(" Enter row col (e.g. \"1 2\"): ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
using OECS;
|
||||||
|
|
||||||
|
namespace TicTacToe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// After each move, checks whether the game has been won or drawn.
|
||||||
|
/// Updates the GameState singleton accordingly.
|
||||||
|
/// </summary>
|
||||||
|
public class WinCheckSystem : ISystem
|
||||||
|
{
|
||||||
|
public QueryDescriptor Query { get; }
|
||||||
|
|
||||||
|
public WinCheckSystem(World world)
|
||||||
|
{
|
||||||
|
Query = world.Query().With<Cell>().With<Mark>().Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Run(World world)
|
||||||
|
{
|
||||||
|
ref var state = ref world.GetSingleton<GameState>();
|
||||||
|
|
||||||
|
if (state.Status != GameStatus.Playing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Build a 3×3 grid of marks.
|
||||||
|
var grid = new Player[3, 3];
|
||||||
|
|
||||||
|
world.ForEach(Query, (Entity entity, ref Cell cell, ref Mark mark) =>
|
||||||
|
{
|
||||||
|
grid[cell.Row, cell.Col] = mark.Player;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check rows.
|
||||||
|
for (int r = 0; r < 3; r++)
|
||||||
|
{
|
||||||
|
if (TryGetWinner(grid[r, 0], grid[r, 1], grid[r, 2], out var winner))
|
||||||
|
{
|
||||||
|
SetWinner(world, ref state, winner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check columns.
|
||||||
|
for (int c = 0; c < 3; c++)
|
||||||
|
{
|
||||||
|
if (TryGetWinner(grid[0, c], grid[1, c], grid[2, c], out var winner))
|
||||||
|
{
|
||||||
|
SetWinner(world, ref state, winner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check diagonals.
|
||||||
|
if (TryGetWinner(grid[0, 0], grid[1, 1], grid[2, 2], out var diag1))
|
||||||
|
{
|
||||||
|
SetWinner(world, ref state, diag1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (TryGetWinner(grid[0, 2], grid[1, 1], grid[2, 0], out var diag2))
|
||||||
|
{
|
||||||
|
SetWinner(world, ref state, diag2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check draw.
|
||||||
|
if (state.MoveCount >= 9)
|
||||||
|
{
|
||||||
|
state.Status = GameStatus.Draw;
|
||||||
|
world.MarkModified<GameState>(World.SingletonEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetWinner(Player a, Player b, Player c, out Player winner)
|
||||||
|
{
|
||||||
|
if (a != Player.None && a == b && b == c)
|
||||||
|
{
|
||||||
|
winner = a;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
winner = Player.None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetWinner(World world, ref GameState state, Player winner)
|
||||||
|
{
|
||||||
|
state.Status = winner == Player.X ? GameStatus.XWon : GameStatus.OWon;
|
||||||
|
world.MarkModified<GameState>(World.SingletonEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>TicTacToe</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Data\board.csv">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
0 0
|
||||||
|
1 1
|
||||||
|
0 1
|
||||||
|
1 0
|
||||||
|
0 2
|
||||||
|
|
@ -58,6 +58,10 @@ public class SystemGroup
|
||||||
|
|
||||||
private void RunAll(Tick tick)
|
private void RunAll(Tick tick)
|
||||||
{
|
{
|
||||||
|
// Drain commands enqueued before the tick so the first system
|
||||||
|
// sees their effects.
|
||||||
|
_world.ExecuteCommands();
|
||||||
|
|
||||||
foreach (var system in _systems)
|
foreach (var system in _systems)
|
||||||
{
|
{
|
||||||
if (system is ITickedSystem ticked)
|
if (system is ITickedSystem ticked)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue