refactor: reorganize project structure and move examples

- Move examples (Blackjack, TicTacToe) to the root directory
- Convert example projects from Console applications to Libraries
- Add Directory.Build.props for shared build settings
- Update solution file and project references to reflect new paths
This commit is contained in:
2026-07-20 16:30:02 +08:00
parent 5594515a53
commit 7946f4bafd
80 changed files with 61 additions and 549 deletions
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>Blackjack</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
+58
View File
@@ -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;
}
}
+53
View File
@@ -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);
}
}
+29
View File
@@ -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);
}
}
+22
View File
@@ -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);
}
}
+14
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Tag component marking the dealer's hand entity.
/// </summary>
[MessagePackObject]
public struct DealerHand { }
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Tag component marking the deck entity.
/// </summary>
[MessagePackObject]
public struct Deck { }
+15
View File
@@ -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; }
}
+15
View File
@@ -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; }
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Blackjack;
/// <summary>
/// Tag component marking the player's hand entity.
/// </summary>
[MessagePackObject]
public struct PlayerHand { }
+18
View File
@@ -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
}
+9
View File
@@ -0,0 +1,9 @@
namespace Blackjack;
public enum Suit : byte
{
Clubs = 0,
Diamonds = 1,
Hearts = 2,
Spades = 3
}
+28
View File
@@ -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;
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace Blackjack;
public enum GamePhase : byte
{
Betting = 0,
Dealing = 1,
PlayerTurn = 2,
DealerTurn = 3,
RoundOver = 4
}
+17
View File
@@ -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;
}
+12
View File
@@ -0,0 +1,12 @@
namespace Blackjack;
public enum RoundResult : byte
{
None = 0,
PlayerBust = 1,
DealerBust = 2,
PlayerWin = 3,
DealerWin = 4,
Push = 5,
Blackjack = 6
}
+31
View File
@@ -0,0 +1,31 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Deals initial two cards to player and dealer when entering the dealing phase.
/// Advances to player turn after dealing.
/// </summary>
public class DealSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
// Deal two cards to player, then two to dealer.
HitCommand.DrawCard<PlayerHand>(world);
HitCommand.DrawCard<PlayerHand>(world);
HitCommand.DrawCard<DealerHand>(world);
HitCommand.DrawCard<DealerHand>(world);
mutableState.Phase = GamePhase.PlayerTurn;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
internal static class PlayerHandTag { public static readonly PlayerHand Instance = new(); }
internal static class DealerHandTag { public static readonly DealerHand Instance = new(); }
+54
View File
@@ -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);
}
}
+87
View File
@@ -0,0 +1,87 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Creates the deck (52 cards + deck/hand entities) and shuffles
/// using mulberry32 with the seed from the GameState singleton.
/// Runs once at the start of each round (during Dealing phase).
/// </summary>
public class DeckSetupSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Dealing)
return;
// Only create deck entities if they don't exist yet.
var singletonEntity = World.SingletonEntity;
bool hasDeck = false;
using (var iter = world.Select<Deck>())
{
hasDeck = iter.MoveNext() && iter.CurrentEntity != singletonEntity;
}
if (!hasDeck)
{
// Create deck entity.
var deckEntity = world.CreateEntity();
world.AddComponent(deckEntity, new Deck());
// Create all 52 cards.
foreach (Suit suit in Enum.GetValues<Suit>())
{
foreach (Rank rank in Enum.GetValues<Rank>())
{
var cardEntity = world.CreateEntity();
world.AddComponent(cardEntity, new Card { Suit = suit, Rank = rank });
world.AddComponent(cardEntity, new InDeck { Source = cardEntity, Target = deckEntity });
}
}
// Create hand entities.
var playerHand = world.CreateEntity();
world.AddComponent(playerHand, new PlayerHand());
var dealerHand = world.CreateEntity();
world.AddComponent(dealerHand, new DealerHand());
}
// Shuffle the deck using mulberry32 with the current seed.
ref var mutableState = ref world.GetSingleton<GameState>();
var deckEntity2 = HitCommand.FindEntity<Deck>(world, singletonEntity);
var cards = world.GetSources<InDeck>(deckEntity2).ToArray();
Shuffle(world, deckEntity2, cards, ref mutableState.Seed);
}
private static void Shuffle(World world, Entity deckEntity, Entity[] cardEntities, ref uint seed)
{
// Fisher-Yates shuffle using mulberry32.
for (int i = cardEntities.Length - 1; i > 0; i--)
{
int j = Mulberry32.NextInt(ref seed, 0, i);
// Swap the InDeck relationship targets (cards are always in the deck,
// so there's nothing to swap except the cards themselves — but we
// shuffle the card order conceptually by removing and re-adding
// InDeck components in shuffled order). Actually, since InDeck
// is just a tag, we just re-shuffle the order in the source collection.
// The simplest approach: no need to swap component data; we just
// need to ensure the cards are iterated in shuffled order.
// We'll swap the card entities in the array.
(cardEntities[i], cardEntities[j]) = (cardEntities[j], cardEntities[i]);
}
// Now remove all InDeck and re-add in shuffled order so GetSources
// returns them in shuffled order.
for (int i = cardEntities.Length - 1; i >= 0; i--)
{
world.RemoveComponent<InDeck>(cardEntities[i]);
}
for (int i = 0; i < cardEntities.Length; i++)
{
world.AddComponent(cardEntities[i], new InDeck { Source = cardEntities[i], Target = deckEntity });
}
}
}
+72
View File
@@ -0,0 +1,72 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Helper to calculate the blackjack value of a hand.
/// Aces count as 11 unless that would bust, then they count as 1.
/// </summary>
public static class HandUtil
{
public static int CalculateHand(World world, DealerHand hand)
{
return CalculateHandImpl(world, hand);
}
public static int CalculateHand(World world, PlayerHand hand)
{
return CalculateHandImpl(world, hand);
}
private static int CalculateHandImpl<T>(World world, T handTag)
where T : struct
{
// Find the hand entity.
Entity handEntity = Entity.Null;
using (var iter = world.Select<T>())
{
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
{
handEntity = iter.CurrentEntity;
break;
}
}
}
if (handEntity == Entity.Null)
return 0;
var cards = world.GetSources<Holds>(handEntity);
int total = 0;
int aceCount = 0;
foreach (var cardEntity in cards)
{
var card = world.ReadComponent<Card>(cardEntity);
int value = card.Rank switch
{
Rank.Ace => 11,
Rank.Jack => 10,
Rank.Queen => 10,
Rank.King => 10,
_ => (int)card.Rank
};
if (card.Rank == Rank.Ace)
aceCount++;
total += value;
}
// Downgrade aces from 11 to 1 as needed.
while (total > 21 && aceCount > 0)
{
total -= 10;
aceCount--;
}
return total;
}
}
@@ -0,0 +1,26 @@
using OECS;
namespace Blackjack;
/// <summary>
/// Evaluates the player's hand total after each hit.
/// Busts the player if they exceed 21.
/// </summary>
public class PlayerBustCheckSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayerTurn)
return;
var total = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
if (total <= 21)
return;
ref var mutableState = ref world.GetSingleton<GameState>();
mutableState.Phase = GamePhase.RoundOver;
mutableState.Result = RoundResult.PlayerBust;
world.MarkModified<GameState>(World.SingletonEntity);
}
}