feat: add Blackjack example
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user