feat: add Blackjack example
This commit is contained in:
parent
b551a67915
commit
713d578179
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component marking the dealer's hand entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct DealerHand { }
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component marking the deck entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct Deck { }
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Blackjack;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component marking the player's hand entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct PlayerHand { }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace Blackjack;
|
||||
|
||||
public enum Suit : byte
|
||||
{
|
||||
Clubs = 0,
|
||||
Diamonds = 1,
|
||||
Hearts = 2,
|
||||
Spades = 3
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
namespace Blackjack;
|
||||
|
||||
public enum GamePhase : byte
|
||||
{
|
||||
Betting = 0,
|
||||
Dealing = 1,
|
||||
PlayerTurn = 2,
|
||||
DealerTurn = 3,
|
||||
RoundOver = 4
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace Blackjack;
|
||||
|
||||
public enum RoundResult : byte
|
||||
{
|
||||
None = 0,
|
||||
PlayerBust = 1,
|
||||
DealerBust = 2,
|
||||
PlayerWin = 3,
|
||||
DealerWin = 4,
|
||||
Push = 5,
|
||||
Blackjack = 6
|
||||
}
|
||||
|
|
@ -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!",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue