feat: add Blackjack example

This commit is contained in:
2026-07-18 23:26:56 +08:00
parent b551a67915
commit 713d578179
24 changed files with 874 additions and 0 deletions
+31
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(); }
@@ -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);
}
}
@@ -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 });
}
}
}
+72
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;
}
}
@@ -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);
}
}
+148
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!",
_ => ""
};
}