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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user