From a19861c080b9bda870172e0c47e505029f97882c Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 18 Jul 2026 20:43:13 +0800 Subject: [PATCH] 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 --- .../TicTacToe/Commands/PlaceMarkCommand.cs | 50 ++++++++ examples/TicTacToe/Components/Cell.cs | 13 ++ examples/TicTacToe/Components/Mark.cs | 13 ++ examples/TicTacToe/Components/Player.cs | 8 ++ examples/TicTacToe/CsvLoader.cs | 45 +++++++ examples/TicTacToe/Data/board.csv | 10 ++ examples/TicTacToe/Program.cs | 119 ++++++++++++++++++ examples/TicTacToe/Singletons/GameState.cs | 14 +++ examples/TicTacToe/Singletons/GameStatus.cs | 9 ++ examples/TicTacToe/Systems/RenderSystem.cs | 67 ++++++++++ examples/TicTacToe/Systems/WinCheckSystem.cs | 89 +++++++++++++ examples/TicTacToe/TicTacToe.csproj | 21 ++++ examples/TicTacToe/test_input.txt | 5 + src/OECS/SystemGroup.cs | 4 + 14 files changed, 467 insertions(+) create mode 100644 examples/TicTacToe/Commands/PlaceMarkCommand.cs create mode 100644 examples/TicTacToe/Components/Cell.cs create mode 100644 examples/TicTacToe/Components/Mark.cs create mode 100644 examples/TicTacToe/Components/Player.cs create mode 100644 examples/TicTacToe/CsvLoader.cs create mode 100644 examples/TicTacToe/Data/board.csv create mode 100644 examples/TicTacToe/Program.cs create mode 100644 examples/TicTacToe/Singletons/GameState.cs create mode 100644 examples/TicTacToe/Singletons/GameStatus.cs create mode 100644 examples/TicTacToe/Systems/RenderSystem.cs create mode 100644 examples/TicTacToe/Systems/WinCheckSystem.cs create mode 100644 examples/TicTacToe/TicTacToe.csproj create mode 100644 examples/TicTacToe/test_input.txt diff --git a/examples/TicTacToe/Commands/PlaceMarkCommand.cs b/examples/TicTacToe/Commands/PlaceMarkCommand.cs new file mode 100644 index 0000000..520ccb7 --- /dev/null +++ b/examples/TicTacToe/Commands/PlaceMarkCommand.cs @@ -0,0 +1,50 @@ +using MessagePack; +using OECS; + +namespace TicTacToe; + +/// +/// Places the current player's mark on the cell at (Row, Col). +/// Validates that the cell is empty and the game is still playing. +/// +[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(); + + 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().Without().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(target.Value); + + // Advance turn. + state.MoveCount++; + state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X; + world.MarkModified(World.SingletonEntity); + } +} diff --git a/examples/TicTacToe/Components/Cell.cs b/examples/TicTacToe/Components/Cell.cs new file mode 100644 index 0000000..18e30b6 --- /dev/null +++ b/examples/TicTacToe/Components/Cell.cs @@ -0,0 +1,13 @@ +using MessagePack; + +namespace TicTacToe; + +/// +/// Identifies a board position. One entity per cell. +/// +[MessagePackObject] +public struct Cell +{ + [Key(0)] public int Row; + [Key(1)] public int Col; +} diff --git a/examples/TicTacToe/Components/Mark.cs b/examples/TicTacToe/Components/Mark.cs new file mode 100644 index 0000000..567cd6c --- /dev/null +++ b/examples/TicTacToe/Components/Mark.cs @@ -0,0 +1,13 @@ +using MessagePack; + +namespace TicTacToe; + +/// +/// A mark placed on a cell by a player. +/// Only present on cells that have been claimed. +/// +[MessagePackObject] +public struct Mark +{ + [Key(0)] public Player Player; +} diff --git a/examples/TicTacToe/Components/Player.cs b/examples/TicTacToe/Components/Player.cs new file mode 100644 index 0000000..103885d --- /dev/null +++ b/examples/TicTacToe/Components/Player.cs @@ -0,0 +1,8 @@ +namespace TicTacToe; + +public enum Player : byte +{ + None = 0, + X = 1, + O = 2 +} diff --git a/examples/TicTacToe/CsvLoader.cs b/examples/TicTacToe/CsvLoader.cs new file mode 100644 index 0000000..43bd7c0 --- /dev/null +++ b/examples/TicTacToe/CsvLoader.cs @@ -0,0 +1,45 @@ +using OECS; + +namespace TicTacToe; + +/// +/// 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. +/// +public static class CsvLoader +{ + public static List LoadCells(World world, string filePath) + { + var entities = new List(); + 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; + } +} diff --git a/examples/TicTacToe/Data/board.csv b/examples/TicTacToe/Data/board.csv new file mode 100644 index 0000000..41024db --- /dev/null +++ b/examples/TicTacToe/Data/board.csv @@ -0,0 +1,10 @@ +Row,Col +0,0 +0,1 +0,2 +1,0 +1,1 +1,2 +2,0 +2,1 +2,2 diff --git a/examples/TicTacToe/Program.cs b/examples/TicTacToe/Program.cs new file mode 100644 index 0000000..77f34ea --- /dev/null +++ b/examples/TicTacToe/Program.cs @@ -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(); + + // 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().Subscribe(change => + { + reactivityLog.Add($"[react] {change}"); + }); + + // 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(); + + // ── 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(); + 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 log) + { + if (log.Count == 0) return; + Console.WriteLine("── reactivity log ──"); + foreach (var entry in log) + Console.WriteLine(entry); + Console.WriteLine(); + log.Clear(); + } +} diff --git a/examples/TicTacToe/Singletons/GameState.cs b/examples/TicTacToe/Singletons/GameState.cs new file mode 100644 index 0000000..0888da1 --- /dev/null +++ b/examples/TicTacToe/Singletons/GameState.cs @@ -0,0 +1,14 @@ +using MessagePack; + +namespace TicTacToe; + +/// +/// Global game state stored on the singleton entity. +/// +[MessagePackObject] +public struct GameState +{ + [Key(0)] public Player CurrentPlayer; + [Key(1)] public GameStatus Status; + [Key(2)] public int MoveCount; +} diff --git a/examples/TicTacToe/Singletons/GameStatus.cs b/examples/TicTacToe/Singletons/GameStatus.cs new file mode 100644 index 0000000..c1d3946 --- /dev/null +++ b/examples/TicTacToe/Singletons/GameStatus.cs @@ -0,0 +1,9 @@ +namespace TicTacToe; + +public enum GameStatus : byte +{ + Playing = 0, + XWon = 1, + OWon = 2, + Draw = 3 +} diff --git a/examples/TicTacToe/Systems/RenderSystem.cs b/examples/TicTacToe/Systems/RenderSystem.cs new file mode 100644 index 0000000..df1f30e --- /dev/null +++ b/examples/TicTacToe/Systems/RenderSystem.cs @@ -0,0 +1,67 @@ +using OECS; + +namespace TicTacToe; + +/// +/// Prints the board to the console. +/// +public class RenderSystem : ISystem +{ + public QueryDescriptor Query { get; } + + public RenderSystem(World world) + { + Query = world.Query().With().Build(); + } + + public void Run(World world) + { + ref var state = ref world.GetSingleton(); + + // 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().With().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\"): "); + } +} diff --git a/examples/TicTacToe/Systems/WinCheckSystem.cs b/examples/TicTacToe/Systems/WinCheckSystem.cs new file mode 100644 index 0000000..0d1ce53 --- /dev/null +++ b/examples/TicTacToe/Systems/WinCheckSystem.cs @@ -0,0 +1,89 @@ +using OECS; + +namespace TicTacToe; + +/// +/// After each move, checks whether the game has been won or drawn. +/// Updates the GameState singleton accordingly. +/// +public class WinCheckSystem : ISystem +{ + public QueryDescriptor Query { get; } + + public WinCheckSystem(World world) + { + Query = world.Query().With().With().Build(); + } + + public void Run(World world) + { + ref var state = ref world.GetSingleton(); + + 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(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(World.SingletonEntity); + } +} diff --git a/examples/TicTacToe/TicTacToe.csproj b/examples/TicTacToe/TicTacToe.csproj new file mode 100644 index 0000000..8852db0 --- /dev/null +++ b/examples/TicTacToe/TicTacToe.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + TicTacToe + + + + + + + + + PreserveNewest + + + + diff --git a/examples/TicTacToe/test_input.txt b/examples/TicTacToe/test_input.txt new file mode 100644 index 0000000..75b2559 --- /dev/null +++ b/examples/TicTacToe/test_input.txt @@ -0,0 +1,5 @@ +0 0 +1 1 +0 1 +1 0 +0 2 diff --git a/src/OECS/SystemGroup.cs b/src/OECS/SystemGroup.cs index 4cd638d..bc4dbc1 100644 --- a/src/OECS/SystemGroup.cs +++ b/src/OECS/SystemGroup.cs @@ -58,6 +58,10 @@ public class SystemGroup 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) { if (system is ITickedSystem ticked)