feat: add Blackjack example

This commit is contained in:
hypercross 2026-07-18 23:26:56 +08:00
parent b551a67915
commit 713d578179
24 changed files with 874 additions and 0 deletions

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Blackjack</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OECS\OECS.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,58 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Player hits: draws one card from the deck to the player's hand.
/// </summary>
[MessagePackObject]
public struct HitCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
DrawCard<PlayerHand>(world);
}
/// <summary>
/// Draws the top card from the deck and adds it to the given hand.
/// </summary>
internal static void DrawCard<THand>(World world)
where THand : struct
{
var singletonEntity = World.SingletonEntity;
var deckEntity = FindEntity<Deck>(world, singletonEntity);
var handEntity = FindEntity<THand>(world, singletonEntity);
if (deckEntity == Entity.Null || handEntity == Entity.Null)
return;
// Find a card still in the deck.
var cardsInDeck = world.GetSources<InDeck>(deckEntity);
if (cardsInDeck.Count == 0)
return;
var cardEntity = cardsInDeck.First();
// Remove from deck, add to hand.
world.RemoveComponent<InDeck>(cardEntity);
world.AddComponent(cardEntity, new Holds { Source = cardEntity, Target = handEntity });
}
internal static Entity FindEntity<T>(World world, Entity singletonEntity)
where T : struct
{
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != singletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
}

View File

@ -0,0 +1,53 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Starts a new round after the previous one ended.
/// </summary>
[MessagePackObject]
public struct NewRoundCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.RoundOver)
return;
// Clear hands from previous round.
var singletonEntity = World.SingletonEntity;
var handEntities = new List<Entity>();
using (var iter = world.Select<PlayerHand>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != singletonEntity)
handEntities.Add(iter.CurrentEntity);
}
}
using (var iter = world.Select<DealerHand>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != singletonEntity)
handEntities.Add(iter.CurrentEntity);
}
}
foreach (var hand in handEntities)
{
var cards = world.GetSources<Holds>(hand);
var deckEntity = HitCommand.FindEntity<Deck>(world, singletonEntity);
foreach (var card in cards)
{
world.RemoveComponent<Holds>(card);
world.AddComponent(card, new InDeck { Source = card, Target = deckEntity });
}
}
state.Phase = GamePhase.Betting;
state.Result = RoundResult.None;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,29 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Places a bet and advances the game to the dealing phase.
/// </summary>
[MessagePackObject]
public struct PlaceBetCommand : ICommand
{
[Key(0)] public int Amount;
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.Betting)
return;
if (Amount < 1 || Amount > state.Chips)
return;
state.CurrentBet = Amount;
state.Chips -= Amount;
state.Phase = GamePhase.Dealing;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,22 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Player stands: advance to the dealer's turn.
/// </summary>
[MessagePackObject]
public struct StandCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
state.Phase = GamePhase.DealerTurn;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,14 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// A playing card with a suit and rank.
/// One entity per card.
/// </summary>
[MessagePackObject]
public struct Card
{
[Key(0)] public Suit Suit;
[Key(1)] public Rank Rank;
}

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Tag component marking the dealer's hand entity.
/// </summary>
[MessagePackObject]
public struct DealerHand { }

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Tag component marking the deck entity.
/// </summary>
[MessagePackObject]
public struct Deck { }

View File

@ -0,0 +1,15 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Relationship from a hand entity to a card entity,
/// representing that the hand holds this card.
/// </summary>
[MessagePackObject]
public struct Holds : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}

View File

@ -0,0 +1,15 @@
using MessagePack;
using OECS;
namespace Blackjack;
/// <summary>
/// Relationship from a card entity to the deck entity,
/// representing that the card is in the deck.
/// </summary>
[MessagePackObject]
public struct InDeck : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Tag component marking the player's hand entity.
/// </summary>
[MessagePackObject]
public struct PlayerHand { }

View File

@ -0,0 +1,18 @@
namespace Blackjack;
public enum Rank : byte
{
Ace = 1,
Two = 2,
Three = 3,
Four = 4,
Five = 5,
Six = 6,
Seven = 7,
Eight = 8,
Nine = 9,
Ten = 10,
Jack = 11,
Queen = 12,
King = 13
}

View File

@ -0,0 +1,9 @@
namespace Blackjack;
public enum Suit : byte
{
Clubs = 0,
Diamonds = 1,
Hearts = 2,
Spades = 3
}

View File

@ -0,0 +1,28 @@
namespace Blackjack;
/// <summary>
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
/// Seed is stored in the ECS GameState singleton.
/// </summary>
public static class Mulberry32
{
/// <summary>
/// Advances the state and returns a random float in [0, 1).
/// </summary>
public static float NextFloat(ref uint state)
{
state += 0x6D2B79F5u;
uint z = state;
z = (z ^ (z >> 15)) * (z | 1u);
z ^= z + (z ^ (z >> 7)) * (z | 61u);
return (z ^ (z >> 14)) / (float)uint.MaxValue;
}
/// <summary>
/// Advances the state and returns a random integer in [min, max].
/// </summary>
public static int NextInt(ref uint state, int min, int max)
{
return (int)(NextFloat(ref state) * (max - min + 1)) + min;
}
}

View File

@ -0,0 +1,114 @@
using OECS;
namespace Blackjack;
public static class Program
{
public static void Main()
{
var world = new World();
// ── Initialize game state ─────────────────────────────────────
// Use a seed based on current time, or a fixed seed for reproducibility.
uint seed = (uint)Environment.TickCount;
// Uncomment the next line for a deterministic game:
// seed = 12345u;
world.SetSingleton(new GameState
{
Phase = GamePhase.Betting,
Result = RoundResult.None,
Chips = 100,
CurrentBet = 0,
RoundNumber = 1,
Seed = seed
});
world.PostChanges();
// ── Wire up systems ───────────────────────────────────────────
var group = new SystemGroup(world);
group.Add(new DeckSetupSystem());
group.Add(new DealSystem());
group.Add(new PlayerBustCheckSystem());
group.Add(new DealerSystem());
group.Add(new RenderSystem());
// ── Game loop ─────────────────────────────────────────────────
group.RunLogical();
while (true)
{
var state = world.ReadSingleton<GameState>();
// Check for game over (out of chips).
if (state.Chips <= 0 && state.Phase == GamePhase.Betting)
{
Console.WriteLine();
Console.WriteLine(" You're out of chips! Game over.");
break;
}
switch (state.Phase)
{
case GamePhase.Betting:
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
continue;
if (input.Trim().ToLower() == "quit")
return;
if (int.TryParse(input.Trim(), out var bet))
{
world.Commands.Enqueue(new PlaceBetCommand { Amount = bet });
}
else
{
Console.WriteLine(" Invalid bet. Enter a number.");
continue;
}
break;
case GamePhase.PlayerTurn:
var choice = Console.ReadLine();
if (string.IsNullOrWhiteSpace(choice))
continue;
if (choice.Trim().ToLower() == "quit")
return;
switch (choice.Trim().ToLower())
{
case "h":
case "hit":
world.Commands.Enqueue(new HitCommand());
break;
case "s":
case "stand":
world.Commands.Enqueue(new StandCommand());
break;
default:
Console.WriteLine(" Invalid choice. Enter H or S.");
continue;
}
break;
case GamePhase.RoundOver:
Console.ReadLine();
ref var roundState = ref world.GetSingleton<GameState>();
roundState.RoundNumber++;
world.MarkModified<GameState>(World.SingletonEntity);
world.PostChanges();
world.Commands.Enqueue(new NewRoundCommand());
break;
default:
// Dealing, DealerTurn — systems handle these automatically.
break;
}
group.RunLogical();
}
}
}

View File

@ -0,0 +1,10 @@
namespace Blackjack;
public enum GamePhase : byte
{
Betting = 0,
Dealing = 1,
PlayerTurn = 2,
DealerTurn = 3,
RoundOver = 4
}

View File

@ -0,0 +1,17 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Global game state stored on the singleton entity.
/// </summary>
[MessagePackObject]
public struct GameState
{
[Key(0)] public GamePhase Phase;
[Key(1)] public RoundResult Result;
[Key(2)] public int Chips;
[Key(3)] public int CurrentBet;
[Key(4)] public int RoundNumber;
[Key(5)] public uint Seed;
}

View File

@ -0,0 +1,12 @@
namespace Blackjack;
public enum RoundResult : byte
{
None = 0,
PlayerBust = 1,
DealerBust = 2,
PlayerWin = 3,
DealerWin = 4,
Push = 5,
Blackjack = 6
}

View File

@ -0,0 +1,31 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Deals initial two cards to player and dealer when entering the dealing phase.
/// Advances to player turn after dealing.
/// </summary>
public class DealSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
// Deal two cards to player, then two to dealer.
HitCommand.DrawCard<PlayerHand>(world);
HitCommand.DrawCard<PlayerHand>(world);
HitCommand.DrawCard<DealerHand>(world);
HitCommand.DrawCard<DealerHand>(world);
mutableState.Phase = GamePhase.PlayerTurn;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
internal static class PlayerHandTag { public static readonly PlayerHand Instance = new(); }
internal static class DealerHandTag { public static readonly DealerHand Instance = new(); }

View File

@ -0,0 +1,54 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Dealer draws cards until reaching 17 or higher,
/// then resolves the round result.
/// </summary>
public class DealerSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.DealerTurn)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
// Dealer must hit on 16 and below, stand on 17+.
int dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
while (dealerTotal < 17)
{
HitCommand.DrawCard<DealerHand>(world);
dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
}
int playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
mutableState.Phase = GamePhase.RoundOver;
if (dealerTotal > 21)
{
mutableState.Result = RoundResult.DealerBust;
mutableState.Chips += mutableState.CurrentBet * 2;
}
else if (playerTotal > dealerTotal)
{
mutableState.Result = RoundResult.PlayerWin;
mutableState.Chips += mutableState.CurrentBet * 2;
}
else if (dealerTotal > playerTotal)
{
mutableState.Result = RoundResult.DealerWin;
}
else
{
// Push: return the bet.
mutableState.Result = RoundResult.Push;
mutableState.Chips += mutableState.CurrentBet;
}
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,87 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Creates the deck (52 cards + deck/hand entities) and shuffles
/// using mulberry32 with the seed from the GameState singleton.
/// Runs once at the start of each round (during Dealing phase).
/// </summary>
public class DeckSetupSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
return;
// Only create deck entities if they don't exist yet.
var singletonEntity = World.SingletonEntity;
bool hasDeck = false;
using (var iter = world.Select<Deck>())
{
hasDeck = iter.MoveNext() && iter.CurrentEntity != singletonEntity;
}
if (!hasDeck)
{
// Create deck entity.
var deckEntity = world.CreateEntity();
world.AddComponent(deckEntity, new Deck());
// Create all 52 cards.
foreach (Suit suit in Enum.GetValues<Suit>())
{
foreach (Rank rank in Enum.GetValues<Rank>())
{
var cardEntity = world.CreateEntity();
world.AddComponent(cardEntity, new Card { Suit = suit, Rank = rank });
world.AddComponent(cardEntity, new InDeck { Source = cardEntity, Target = deckEntity });
}
}
// Create hand entities.
var playerHand = world.CreateEntity();
world.AddComponent(playerHand, new PlayerHand());
var dealerHand = world.CreateEntity();
world.AddComponent(dealerHand, new DealerHand());
}
// Shuffle the deck using mulberry32 with the current seed.
ref var mutableState = ref world.GetSingleton<GameState>();
var deckEntity2 = HitCommand.FindEntity<Deck>(world, singletonEntity);
var cards = world.GetSources<InDeck>(deckEntity2).ToArray();
Shuffle(world, deckEntity2, cards, ref mutableState.Seed);
}
private static void Shuffle(World world, Entity deckEntity, Entity[] cardEntities, ref uint seed)
{
// Fisher-Yates shuffle using mulberry32.
for (int i = cardEntities.Length - 1; i > 0; i--)
{
int j = Mulberry32.NextInt(ref seed, 0, i);
// Swap the InDeck relationship targets (cards are always in the deck,
// so there's nothing to swap except the cards themselves — but we
// shuffle the card order conceptually by removing and re-adding
// InDeck components in shuffled order). Actually, since InDeck
// is just a tag, we just re-shuffle the order in the source collection.
// The simplest approach: no need to swap component data; we just
// need to ensure the cards are iterated in shuffled order.
// We'll swap the card entities in the array.
(cardEntities[i], cardEntities[j]) = (cardEntities[j], cardEntities[i]);
}
// Now remove all InDeck and re-add in shuffled order so GetSources
// returns them in shuffled order.
for (int i = cardEntities.Length - 1; i >= 0; i--)
{
world.RemoveComponent<InDeck>(cardEntities[i]);
}
for (int i = 0; i < cardEntities.Length; i++)
{
world.AddComponent(cardEntities[i], new InDeck { Source = cardEntities[i], Target = deckEntity });
}
}
}

View File

@ -0,0 +1,72 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Helper to calculate the blackjack value of a hand.
/// Aces count as 11 unless that would bust, then they count as 1.
/// </summary>
public static class HandUtil
{
public static int CalculateHand(World world, DealerHand hand)
{
return CalculateHandImpl(world, hand);
}
public static int CalculateHand(World world, PlayerHand hand)
{
return CalculateHandImpl(world, hand);
}
private static int CalculateHandImpl<T>(World world, T handTag)
where T : struct
{
// Find the hand entity.
Entity handEntity = Entity.Null;
using (var iter = world.Select<T>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
{
handEntity = iter.CurrentEntity;
break;
}
}
}
if (handEntity == Entity.Null)
return 0;
var cards = world.GetSources<Holds>(handEntity);
int total = 0;
int aceCount = 0;
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
int value = card.Rank switch
{
Rank.Ace => 11,
Rank.Jack => 10,
Rank.Queen => 10,
Rank.King => 10,
_ => (int)card.Rank
};
if (card.Rank == Rank.Ace)
aceCount++;
total += value;
}
// Downgrade aces from 11 to 1 as needed.
while (total > 21 && aceCount > 0)
{
total -= 10;
aceCount--;
}
return total;
}
}

View File

@ -0,0 +1,26 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Evaluates the player's hand total after each hit.
/// Busts the player if they exceed 21.
/// </summary>
public class PlayerBustCheckSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
var total = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
if (total <= 21)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
mutableState.Phase = GamePhase.RoundOver;
mutableState.Result = RoundResult.PlayerBust;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,148 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Renders the current game state to the console.
/// </summary>
public class RenderSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
Console.WriteLine();
Console.WriteLine(" Blackjack");
Console.WriteLine(" ═════════");
Console.WriteLine();
Console.WriteLine($" Chips: {state.Chips} | Round: {state.RoundNumber}");
Console.WriteLine();
// Show dealer's hand.
var dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
Console.Write(" Dealer: ");
if (state.Phase == GamePhase.PlayerTurn)
{
// Hide first card (hole card) during player's turn.
Console.Write("?? ");
PrintHand(world, DealerHandTag.Instance, skipFirst: true);
}
else
{
PrintHand(world, DealerHandTag.Instance);
Console.Write($" ({dealerTotal})");
}
Console.WriteLine();
// Show player's hand.
var playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
Console.Write(" Player: ");
PrintHand(world, PlayerHandTag.Instance);
Console.Write($" ({playerTotal})");
Console.WriteLine();
Console.WriteLine();
// Status line.
switch (state.Phase)
{
case GamePhase.Betting:
Console.Write($" Enter bet (1{state.Chips}): ");
break;
case GamePhase.PlayerTurn:
Console.Write(" [H]it or [S]tand: ");
break;
case GamePhase.RoundOver:
Console.WriteLine($" {ResultText(state.Result)}");
Console.WriteLine();
Console.Write(" Press Enter for next round...");
break;
default:
break;
}
}
private static void PrintHand(World world, DealerHand hand, bool skipFirst = false)
{
PrintHandImpl(world, hand, skipFirst);
}
private static void PrintHand(World world, PlayerHand hand)
{
PrintHandImpl(world, hand, skipFirst: false);
}
private static void PrintHandImpl<T>(World world, T handTag, bool skipFirst)
where T : struct
{
Entity handEntity = Entity.Null;
using (var iter = world.Select<T>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
{
handEntity = iter.CurrentEntity;
break;
}
}
}
if (handEntity == Entity.Null)
return;
var cards = world.GetSources<Holds>(handEntity);
var ordered = cards.OrderByDescending(c =>
{
var card = world.ReadComponent<Card>(c);
return (int)card.Rank;
}).ToList();
bool first = true;
foreach (var cardEntity in ordered)
{
if (skipFirst && first)
{
first = false;
continue;
}
first = false;
var card = world.ReadComponent<Card>(cardEntity);
Console.Write(CardToString(card));
Console.Write(" ");
}
}
private static string CardToString(Card card)
{
var suit = card.Suit switch
{
Suit.Hearts => "♥",
Suit.Diamonds => "♦",
Suit.Clubs => "♣",
Suit.Spades => "♠",
_ => "?"
};
var rank = card.Rank switch
{
Rank.Ace => "A",
Rank.Jack => "J",
Rank.Queen => "Q",
Rank.King => "K",
_ => ((int)card.Rank).ToString()
};
return $"{rank}{suit}";
}
private static string ResultText(RoundResult result) => result switch
{
RoundResult.PlayerBust => "You bust! Dealer wins.",
RoundResult.DealerBust => "Dealer busts! You win!",
RoundResult.PlayerWin => "You win!",
RoundResult.DealerWin => "Dealer wins.",
RoundResult.Push => "Push — it's a tie!",
RoundResult.Blackjack => "Blackjack!",
_ => ""
};
}