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