diff --git a/examples/Blackjack/Blackjack.csproj b/examples/Blackjack/Blackjack.csproj new file mode 100644 index 0000000..a91f1df --- /dev/null +++ b/examples/Blackjack/Blackjack.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + Blackjack + + + + + + + \ No newline at end of file diff --git a/examples/Blackjack/Commands/HitCommand.cs b/examples/Blackjack/Commands/HitCommand.cs new file mode 100644 index 0000000..3228876 --- /dev/null +++ b/examples/Blackjack/Commands/HitCommand.cs @@ -0,0 +1,58 @@ +using MessagePack; +using OECS; + +namespace Blackjack; + +/// +/// Player hits: draws one card from the deck to the player's hand. +/// +[MessagePackObject] +public struct HitCommand : ICommand +{ + public void Execute(World world) + { + ref var state = ref world.GetSingleton(); + + if (state.Phase != GamePhase.PlayerTurn) + return; + + DrawCard(world); + } + + /// + /// Draws the top card from the deck and adds it to the given hand. + /// + internal static void DrawCard(World world) + where THand : struct + { + var singletonEntity = World.SingletonEntity; + var deckEntity = FindEntity(world, singletonEntity); + var handEntity = FindEntity(world, singletonEntity); + + if (deckEntity == Entity.Null || handEntity == Entity.Null) + return; + + // Find a card still in the deck. + var cardsInDeck = world.GetSources(deckEntity); + if (cardsInDeck.Count == 0) + return; + + var cardEntity = cardsInDeck.First(); + + // Remove from deck, add to hand. + world.RemoveComponent(cardEntity); + world.AddComponent(cardEntity, new Holds { Source = cardEntity, Target = handEntity }); + } + + internal static Entity FindEntity(World world, Entity singletonEntity) + where T : struct + { + using var iter = world.Select(); + while (iter.MoveNext()) + { + if (iter.CurrentEntity != singletonEntity) + return iter.CurrentEntity; + } + return Entity.Null; + } +} \ No newline at end of file diff --git a/examples/Blackjack/Commands/NewRoundCommand.cs b/examples/Blackjack/Commands/NewRoundCommand.cs new file mode 100644 index 0000000..afe61b9 --- /dev/null +++ b/examples/Blackjack/Commands/NewRoundCommand.cs @@ -0,0 +1,53 @@ +using MessagePack; +using OECS; + +namespace Blackjack; + +/// +/// Starts a new round after the previous one ended. +/// +[MessagePackObject] +public struct NewRoundCommand : ICommand +{ + public void Execute(World world) + { + ref var state = ref world.GetSingleton(); + + if (state.Phase != GamePhase.RoundOver) + return; + + // Clear hands from previous round. + var singletonEntity = World.SingletonEntity; + var handEntities = new List(); + using (var iter = world.Select()) + { + while (iter.MoveNext()) + { + if (iter.CurrentEntity != singletonEntity) + handEntities.Add(iter.CurrentEntity); + } + } + using (var iter = world.Select()) + { + while (iter.MoveNext()) + { + if (iter.CurrentEntity != singletonEntity) + handEntities.Add(iter.CurrentEntity); + } + } + foreach (var hand in handEntities) + { + var cards = world.GetSources(hand); + var deckEntity = HitCommand.FindEntity(world, singletonEntity); + foreach (var card in cards) + { + world.RemoveComponent(card); + world.AddComponent(card, new InDeck { Source = card, Target = deckEntity }); + } + } + + state.Phase = GamePhase.Betting; + state.Result = RoundResult.None; + world.MarkModified(World.SingletonEntity); + } +} \ No newline at end of file diff --git a/examples/Blackjack/Commands/PlaceBetCommand.cs b/examples/Blackjack/Commands/PlaceBetCommand.cs new file mode 100644 index 0000000..527afad --- /dev/null +++ b/examples/Blackjack/Commands/PlaceBetCommand.cs @@ -0,0 +1,29 @@ +using MessagePack; +using OECS; + +namespace Blackjack; + +/// +/// Places a bet and advances the game to the dealing phase. +/// +[MessagePackObject] +public struct PlaceBetCommand : ICommand +{ + [Key(0)] public int Amount; + + public void Execute(World world) + { + ref var state = ref world.GetSingleton(); + + 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(World.SingletonEntity); + } +} \ No newline at end of file diff --git a/examples/Blackjack/Commands/StandCommand.cs b/examples/Blackjack/Commands/StandCommand.cs new file mode 100644 index 0000000..97d1ed8 --- /dev/null +++ b/examples/Blackjack/Commands/StandCommand.cs @@ -0,0 +1,22 @@ +using MessagePack; +using OECS; + +namespace Blackjack; + +/// +/// Player stands: advance to the dealer's turn. +/// +[MessagePackObject] +public struct StandCommand : ICommand +{ + public void Execute(World world) + { + ref var state = ref world.GetSingleton(); + + if (state.Phase != GamePhase.PlayerTurn) + return; + + state.Phase = GamePhase.DealerTurn; + world.MarkModified(World.SingletonEntity); + } +} \ No newline at end of file diff --git a/examples/Blackjack/Components/Card.cs b/examples/Blackjack/Components/Card.cs new file mode 100644 index 0000000..9c87084 --- /dev/null +++ b/examples/Blackjack/Components/Card.cs @@ -0,0 +1,14 @@ +using MessagePack; + +namespace Blackjack; + +/// +/// A playing card with a suit and rank. +/// One entity per card. +/// +[MessagePackObject] +public struct Card +{ + [Key(0)] public Suit Suit; + [Key(1)] public Rank Rank; +} \ No newline at end of file diff --git a/examples/Blackjack/Components/DealerHand.cs b/examples/Blackjack/Components/DealerHand.cs new file mode 100644 index 0000000..5dd8fd0 --- /dev/null +++ b/examples/Blackjack/Components/DealerHand.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace Blackjack; + +/// +/// Tag component marking the dealer's hand entity. +/// +[MessagePackObject] +public struct DealerHand { } \ No newline at end of file diff --git a/examples/Blackjack/Components/Deck.cs b/examples/Blackjack/Components/Deck.cs new file mode 100644 index 0000000..9754ee7 --- /dev/null +++ b/examples/Blackjack/Components/Deck.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace Blackjack; + +/// +/// Tag component marking the deck entity. +/// +[MessagePackObject] +public struct Deck { } \ No newline at end of file diff --git a/examples/Blackjack/Components/Holds.cs b/examples/Blackjack/Components/Holds.cs new file mode 100644 index 0000000..d704e03 --- /dev/null +++ b/examples/Blackjack/Components/Holds.cs @@ -0,0 +1,15 @@ +using MessagePack; +using OECS; + +namespace Blackjack; + +/// +/// Relationship from a hand entity to a card entity, +/// representing that the hand holds this card. +/// +[MessagePackObject] +public struct Holds : IRelationship +{ + [Key(0)] public Entity Source { get; set; } + [Key(1)] public Entity Target { get; set; } +} \ No newline at end of file diff --git a/examples/Blackjack/Components/InDeck.cs b/examples/Blackjack/Components/InDeck.cs new file mode 100644 index 0000000..13b54d0 --- /dev/null +++ b/examples/Blackjack/Components/InDeck.cs @@ -0,0 +1,15 @@ +using MessagePack; +using OECS; + +namespace Blackjack; + +/// +/// Relationship from a card entity to the deck entity, +/// representing that the card is in the deck. +/// +[MessagePackObject] +public struct InDeck : IRelationship +{ + [Key(0)] public Entity Source { get; set; } + [Key(1)] public Entity Target { get; set; } +} \ No newline at end of file diff --git a/examples/Blackjack/Components/PlayerHand.cs b/examples/Blackjack/Components/PlayerHand.cs new file mode 100644 index 0000000..cd35db0 --- /dev/null +++ b/examples/Blackjack/Components/PlayerHand.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace Blackjack; + +/// +/// Tag component marking the player's hand entity. +/// +[MessagePackObject] +public struct PlayerHand { } \ No newline at end of file diff --git a/examples/Blackjack/Components/Rank.cs b/examples/Blackjack/Components/Rank.cs new file mode 100644 index 0000000..3458aa0 --- /dev/null +++ b/examples/Blackjack/Components/Rank.cs @@ -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 +} \ No newline at end of file diff --git a/examples/Blackjack/Components/Suit.cs b/examples/Blackjack/Components/Suit.cs new file mode 100644 index 0000000..ab16b9b --- /dev/null +++ b/examples/Blackjack/Components/Suit.cs @@ -0,0 +1,9 @@ +namespace Blackjack; + +public enum Suit : byte +{ + Clubs = 0, + Diamonds = 1, + Hearts = 2, + Spades = 3 +} \ No newline at end of file diff --git a/examples/Blackjack/Mulberry32.cs b/examples/Blackjack/Mulberry32.cs new file mode 100644 index 0000000..406bb48 --- /dev/null +++ b/examples/Blackjack/Mulberry32.cs @@ -0,0 +1,28 @@ +namespace Blackjack; + +/// +/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator. +/// Seed is stored in the ECS GameState singleton. +/// +public static class Mulberry32 +{ + /// + /// Advances the state and returns a random float in [0, 1). + /// + 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; + } + + /// + /// Advances the state and returns a random integer in [min, max]. + /// + public static int NextInt(ref uint state, int min, int max) + { + return (int)(NextFloat(ref state) * (max - min + 1)) + min; + } +} \ No newline at end of file diff --git a/examples/Blackjack/Program.cs b/examples/Blackjack/Program.cs new file mode 100644 index 0000000..0b97dcc --- /dev/null +++ b/examples/Blackjack/Program.cs @@ -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(); + + // 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(); + roundState.RoundNumber++; + world.MarkModified(World.SingletonEntity); + world.PostChanges(); + world.Commands.Enqueue(new NewRoundCommand()); + break; + + default: + // Dealing, DealerTurn — systems handle these automatically. + break; + } + + group.RunLogical(); + } + } +} \ No newline at end of file diff --git a/examples/Blackjack/Singletons/GamePhase.cs b/examples/Blackjack/Singletons/GamePhase.cs new file mode 100644 index 0000000..4267941 --- /dev/null +++ b/examples/Blackjack/Singletons/GamePhase.cs @@ -0,0 +1,10 @@ +namespace Blackjack; + +public enum GamePhase : byte +{ + Betting = 0, + Dealing = 1, + PlayerTurn = 2, + DealerTurn = 3, + RoundOver = 4 +} \ No newline at end of file diff --git a/examples/Blackjack/Singletons/GameState.cs b/examples/Blackjack/Singletons/GameState.cs new file mode 100644 index 0000000..19ebe5f --- /dev/null +++ b/examples/Blackjack/Singletons/GameState.cs @@ -0,0 +1,17 @@ +using MessagePack; + +namespace Blackjack; + +/// +/// Global game state stored on the singleton entity. +/// +[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; +} \ No newline at end of file diff --git a/examples/Blackjack/Singletons/RoundResult.cs b/examples/Blackjack/Singletons/RoundResult.cs new file mode 100644 index 0000000..ef350f4 --- /dev/null +++ b/examples/Blackjack/Singletons/RoundResult.cs @@ -0,0 +1,12 @@ +namespace Blackjack; + +public enum RoundResult : byte +{ + None = 0, + PlayerBust = 1, + DealerBust = 2, + PlayerWin = 3, + DealerWin = 4, + Push = 5, + Blackjack = 6 +} \ No newline at end of file diff --git a/examples/Blackjack/Systems/DealSystem.cs b/examples/Blackjack/Systems/DealSystem.cs new file mode 100644 index 0000000..81c0715 --- /dev/null +++ b/examples/Blackjack/Systems/DealSystem.cs @@ -0,0 +1,31 @@ +using OECS; + +namespace Blackjack; + +/// +/// Deals initial two cards to player and dealer when entering the dealing phase. +/// Advances to player turn after dealing. +/// +public class DealSystem : ISystem +{ + public void Run(World world) + { + var state = world.ReadSingleton(); + if (state.Phase != GamePhase.Dealing) + return; + + ref var mutableState = ref world.GetSingleton(); + + // Deal two cards to player, then two to dealer. + HitCommand.DrawCard(world); + HitCommand.DrawCard(world); + HitCommand.DrawCard(world); + HitCommand.DrawCard(world); + + mutableState.Phase = GamePhase.PlayerTurn; + world.MarkModified(World.SingletonEntity); + } +} + +internal static class PlayerHandTag { public static readonly PlayerHand Instance = new(); } +internal static class DealerHandTag { public static readonly DealerHand Instance = new(); } \ No newline at end of file diff --git a/examples/Blackjack/Systems/DealerSystem.cs b/examples/Blackjack/Systems/DealerSystem.cs new file mode 100644 index 0000000..d3caec4 --- /dev/null +++ b/examples/Blackjack/Systems/DealerSystem.cs @@ -0,0 +1,54 @@ +using OECS; + +namespace Blackjack; + +/// +/// Dealer draws cards until reaching 17 or higher, +/// then resolves the round result. +/// +public class DealerSystem : ISystem +{ + public void Run(World world) + { + var state = world.ReadSingleton(); + if (state.Phase != GamePhase.DealerTurn) + return; + + ref var mutableState = ref world.GetSingleton(); + + // Dealer must hit on 16 and below, stand on 17+. + int dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance); + while (dealerTotal < 17) + { + HitCommand.DrawCard(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(World.SingletonEntity); + } +} \ No newline at end of file diff --git a/examples/Blackjack/Systems/DeckSetupSystem.cs b/examples/Blackjack/Systems/DeckSetupSystem.cs new file mode 100644 index 0000000..87c54d7 --- /dev/null +++ b/examples/Blackjack/Systems/DeckSetupSystem.cs @@ -0,0 +1,87 @@ +using OECS; + +namespace Blackjack; + +/// +/// 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). +/// +public class DeckSetupSystem : ISystem +{ + public void Run(World world) + { + var state = world.ReadSingleton(); + 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()) + { + 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()) + { + foreach (Rank rank in Enum.GetValues()) + { + 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(); + var deckEntity2 = HitCommand.FindEntity(world, singletonEntity); + var cards = world.GetSources(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(cardEntities[i]); + } + for (int i = 0; i < cardEntities.Length; i++) + { + world.AddComponent(cardEntities[i], new InDeck { Source = cardEntities[i], Target = deckEntity }); + } + } +} \ No newline at end of file diff --git a/examples/Blackjack/Systems/HandUtil.cs b/examples/Blackjack/Systems/HandUtil.cs new file mode 100644 index 0000000..abf5da3 --- /dev/null +++ b/examples/Blackjack/Systems/HandUtil.cs @@ -0,0 +1,72 @@ +using OECS; + +namespace Blackjack; + +/// +/// Helper to calculate the blackjack value of a hand. +/// Aces count as 11 unless that would bust, then they count as 1. +/// +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(World world, T handTag) + where T : struct + { + // Find the hand entity. + Entity handEntity = Entity.Null; + using (var iter = world.Select()) + { + while (iter.MoveNext()) + { + if (iter.CurrentEntity != World.SingletonEntity) + { + handEntity = iter.CurrentEntity; + break; + } + } + } + + if (handEntity == Entity.Null) + return 0; + + var cards = world.GetSources(handEntity); + int total = 0; + int aceCount = 0; + + foreach (var cardEntity in cards) + { + var card = world.ReadComponent(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; + } +} \ No newline at end of file diff --git a/examples/Blackjack/Systems/PlayerBustCheckSystem.cs b/examples/Blackjack/Systems/PlayerBustCheckSystem.cs new file mode 100644 index 0000000..a05ca13 --- /dev/null +++ b/examples/Blackjack/Systems/PlayerBustCheckSystem.cs @@ -0,0 +1,26 @@ +using OECS; + +namespace Blackjack; + +/// +/// Evaluates the player's hand total after each hit. +/// Busts the player if they exceed 21. +/// +public class PlayerBustCheckSystem : ISystem +{ + public void Run(World world) + { + var state = world.ReadSingleton(); + if (state.Phase != GamePhase.PlayerTurn) + return; + + var total = HandUtil.CalculateHand(world, PlayerHandTag.Instance); + if (total <= 21) + return; + + ref var mutableState = ref world.GetSingleton(); + mutableState.Phase = GamePhase.RoundOver; + mutableState.Result = RoundResult.PlayerBust; + world.MarkModified(World.SingletonEntity); + } +} \ No newline at end of file diff --git a/examples/Blackjack/Systems/RenderSystem.cs b/examples/Blackjack/Systems/RenderSystem.cs new file mode 100644 index 0000000..f325e41 --- /dev/null +++ b/examples/Blackjack/Systems/RenderSystem.cs @@ -0,0 +1,148 @@ +using OECS; + +namespace Blackjack; + +/// +/// Renders the current game state to the console. +/// +public class RenderSystem : ISystem +{ + public void Run(World world) + { + var state = world.ReadSingleton(); + + 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(World world, T handTag, bool skipFirst) + where T : struct + { + Entity handEntity = Entity.Null; + using (var iter = world.Select()) + { + while (iter.MoveNext()) + { + if (iter.CurrentEntity != World.SingletonEntity) + { + handEntity = iter.CurrentEntity; + break; + } + } + } + + if (handEntity == Entity.Null) + return; + + var cards = world.GetSources(handEntity); + var ordered = cards.OrderByDescending(c => + { + var card = world.ReadComponent(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(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!", + _ => "" + }; +} \ No newline at end of file