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)
|
||||
{
|
||||
// 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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue