feat: implement CardWars game engine

Implement the core CardWars game logic using an ECS-based architecture.
This includes:
- Card effect registry and various card effect implementations.
- Game phase management (Setup, Play, Flip, Scoring, Cleanup).
- Command system for player actions (PlayCard, FlipCard, etc.).
- Component-based game state and entity relationships.
- Automated game setup and scoring systems.
- Unit tests for game setup, card effects, and play logs.
This commit is contained in:
2026-07-20 22:52:28 +08:00
parent 3b08138580
commit 2de735498c
40 changed files with 2440 additions and 25 deletions
+173
View File
@@ -0,0 +1,173 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
[MessagePackObject]
public struct SkipPlayCommand : ICommand
{
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) return;
ref var mutable = ref world.GetSingleton<GameState>();
mutable.PlayPhaseEnded = true;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
[MessagePackObject]
public struct FlipCardCommand : ICommand
{
[Key(0)] public Entity CardEntity;
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
if (!world.IsAlive(CardEntity)) return;
if (!world.HasComponent<Card>(CardEntity)) return;
ref var card = ref world.GetComponent<Card>(CardEntity);
if (!card.FaceDown) return;
card.FaceDown = false;
world.MarkModified<Card>(CardEntity);
var def = world.ReadComponent<CardDef>(CardEntity);
Entity player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
// Resolve flip effects via registry.
CardEffectRegistry.Dispatch(world, def.Effect, CardEntity, player, player);
}
}
[MessagePackObject]
public struct SkipFlipCommand : ICommand
{
public void Execute(World world)
{
// Skip is only valid if no face-down cards remain.
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
Entity player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var fieldCards = GameUtil.GetFieldCards(world, player);
bool hasFaceDown = fieldCards.Any(c =>
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
if (hasFaceDown) return;
// Advance is handled by FlipPhaseSystem.
}
}
[MessagePackObject]
public struct DrawCardCommand : ICommand
{
[Key(0)] public Entity Player;
[Key(1)] public CardKind? Kind;
public void Execute(World world)
{
if (!world.IsAlive(Player)) return;
var kind = Kind ?? CardKind.Public;
var deck = GetDeck(world, kind);
if (deck == Entity.Null) return;
var cards = world.GetSources<InDeck>(deck);
if (cards.Count == 0)
{
// Recycle: for now, faction discards recycle to the same deck.
GameUtil.RecycleDiscard(world, deck, deck);
cards = world.GetSources<InDeck>(deck);
if (cards.Count == 0) return;
}
GameUtil.DrawCard(world, deck, Player);
}
private static Entity GetDeck(World world, CardKind kind)
{
if (kind == CardKind.Public)
{
using var iter = world.Select<PublicDeck>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
}
else
{
using var iter = world.Select<FactionDeck>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
}
return Entity.Null;
}
}
[MessagePackObject]
public struct ExtraActionCommand : ICommand
{
public void Execute(World world) { }
}
[MessagePackObject]
public struct EndPlayPhaseCommand : ICommand
{
public void Execute(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) return;
world.SetSingleton(new PendingDuelist { PlayerIndex = state.CurrentPlayerIndex });
}
}
[MessagePackObject]
public struct ReplaceCastleCommand : ICommand
{
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
var castles = GameUtil.FindAllEntities<Castle>(world);
foreach (var c in castles)
world.DestroyEntity(c);
var colors = new[] { CastleColor.Black, CastleColor.White, CastleColor.Blue,
CastleColor.Brown, CastleColor.Yellow, CastleColor.Indigo, CastleColor.Gold };
var seed = state.Seed;
for (int i = 0; i < 3; i++)
{
int idx = Mulberry32.NextInt(ref seed, 0, colors.Length - 1);
var castle = world.CreateEntity();
world.AddComponent(castle, new Castle { Color = colors[idx] });
}
}
}
/// <summary>
/// Player makes a choice to resolve a pending card effect.
/// The target entity depends on the effect:
/// - Mercenary, Dancer, Paladin, Nun: a field card entity
/// - Scout: a player entity
/// - Pegasus: a field card to return (or null)
/// - CurseMaster: a horn token entity
/// </summary>
[MessagePackObject]
public struct ResolveChoiceCommand : ICommand
{
[Key(0)] public Entity Target;
public void Execute(World world)
{
if (!PendingChoice.Any(world)) return;
CardEffectRegistry.DispatchChoice(world, Target);
}
}
+28
View File
@@ -0,0 +1,28 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Initializes a fresh game with the given player count and seed.
/// </summary>
[MessagePackObject]
public struct NewGameCommand : ICommand
{
[Key(0)] public int PlayerCount;
[Key(1)] public uint Seed;
[Key(2)] public string CardDataCsv;
public void Execute(World world)
{
world.SetSingleton(new GameState
{
Phase = GamePhase.Setup,
PlayerCount = PlayerCount,
CurrentPlayerIndex = 0,
StartingPlayerIndex = 0,
RoundNumber = 0,
Seed = Seed
});
}
}
+62
View File
@@ -0,0 +1,62 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Play a card from hand to the field, choosing a rank.
/// Resolves on-play effects via the CardEffectRegistry.
/// </summary>
[MessagePackObject]
public struct PlayCardCommand : ICommand
{
[Key(0)] public Entity CardEntity;
[Key(1)] public int Rank;
[Key(2)] public bool FaceDown;
[Key(3)] public Entity? TargetPlayer;
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase is not GamePhase.PlayPhase) return;
if (!world.IsAlive(CardEntity)) return;
Entity player = FindPlayerByIndex(world, state.CurrentPlayerIndex);
if (player == Entity.Null) return;
// Verify the card is in the player's hand.
if (!world.HasComponent<HeldBy>(CardEntity)) return;
var heldBy = world.ReadComponent<HeldBy>(CardEntity);
if (heldBy.Target != player) return;
var def = world.ReadComponent<CardDef>(CardEntity);
// Determine target: Witch/Thief are played to another player's field.
Entity fieldOwner = def.Effect is CardEffect.Witch or CardEffect.Thief
? (TargetPlayer ?? player)
: player;
// Move from hand to field.
world.RemoveComponent<HeldBy>(CardEntity);
world.AddComponent(CardEntity, new OnField { Source = CardEntity, Target = fieldOwner });
world.AddComponent(CardEntity, new Card { Rank = Rank, FaceDown = FaceDown });
// Resolve on-play effects via registry.
if (!FaceDown)
CardEffectRegistry.Dispatch(world, def.Effect, CardEntity, player, fieldOwner);
world.MarkModified<GameState>(World.SingletonEntity);
}
public static Entity FindPlayerByIndex(World world, int index)
{
using var iter = world.Select<Player>();
while (iter.MoveNext())
{
if (iter.CurrentEntity == World.SingletonEntity) continue;
if (iter.Current1.Index == index)
return iter.CurrentEntity;
}
return Entity.Null;
}
}