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
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a player's banner entity.
/// </summary>
[MessagePackObject]
public struct Banner { }
+17
View File
@@ -0,0 +1,17 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Instance data for a card that has been played to the field.
/// Cards in hand/deck only have CardDef; this is added when played.
/// </summary>
[MessagePackObject]
public record struct Card
{
/// <summary>The rank chosen for this play (one of CardDef.Ranks).</summary>
[Key(0)] public int Rank;
/// <summary>Whether the card is face-down on the field.</summary>
[Key(1)] public bool FaceDown;
}
+17
View File
@@ -0,0 +1,17 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Static card definition — name, possible ranks, effect, and kind.
/// Cards in the deck/hand only have CardDef. When played to the field,
/// they also get a Card component with the chosen rank.
/// </summary>
[MessagePackObject]
public record struct CardDef
{
[Key(0)] public string Name;
[Key(1)] public int[] Ranks;
[Key(2)] public CardEffect Effect;
[Key(3)] public CardKind Kind;
}
+12
View File
@@ -0,0 +1,12 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// A castle that can be won each round.
/// </summary>
[MessagePackObject]
public record struct Castle
{
[Key(0)] public CastleColor Color;
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a faction deck entity.
/// </summary>
[MessagePackObject]
public struct FactionDeck { }
+15
View File
@@ -0,0 +1,15 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to the player who holds it in hand.
/// Added to the card: Source=card, Target=player.
/// </summary>
[MessagePackObject]
public record struct HeldBy : 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 Game.CardWars;
/// <summary>
/// Tag component for a horn token. Can be placed on cards, banners, or leaders.
/// </summary>
[MessagePackObject]
public struct Horn { }
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to a deck entity.
/// </summary>
[MessagePackObject]
public record struct InDeck : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component marking a card entity as the leader card.
/// The leader is always on the field and contributes its rank to power.
/// </summary>
[MessagePackObject]
public struct Leader { }
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to the player entity whose field it's on.
/// </summary>
[MessagePackObject]
public record struct OnField : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}
+86
View File
@@ -0,0 +1,86 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
// ────────────────────────────────────────────────────────────
// Per-effect pending choice components.
// Each is placed on the singleton entity when a card effect
// requires player input. Their presence blocks game advancement.
// ────────────────────────────────────────────────────────────
/// <summary>Mercenary: choose a combatant to give a skull.</summary>
[MessagePackObject]
public record struct PendingMercenary
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Dancer: choose up to 2 combatants for horn. Step tracks progress.</summary>
[MessagePackObject]
public record struct PendingDancer
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
[Key(2)] public int Step; // 0 = first choice, 1 = second choice
}
/// <summary>Paladin: choose a combatant or banner for horn.</summary>
[MessagePackObject]
public record struct PendingPaladin
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Nun: choose a field card to discard.</summary>
[MessagePackObject]
public record struct PendingNun
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Scout: choose an opponent player to scout.</summary>
[MessagePackObject]
public record struct PendingScout
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Pegasus: choose a field card to return to hand (or null to pass).</summary>
[MessagePackObject]
public record struct PendingPegasus
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>CurseMaster: choose a horn token to turn into skull.</summary>
[MessagePackObject]
public record struct PendingCurseMaster
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
// ────────────────────────────────────────────────────────────
// Helper to detect any pending component on the singleton.
// ────────────────────────────────────────────────────────────
public static class PendingChoice
{
/// <summary>Returns true if any pending choice component exists on the singleton.</summary>
public static bool Any(World world)
{
return world.HasSingleton<PendingMercenary>()
|| world.HasSingleton<PendingDancer>()
|| world.HasSingleton<PendingPaladin>()
|| world.HasSingleton<PendingNun>()
|| world.HasSingleton<PendingScout>()
|| world.HasSingleton<PendingPegasus>()
|| world.HasSingleton<PendingCurseMaster>();
}
}
@@ -0,0 +1,14 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Placed on the singleton when a 决斗家 (Duelist) is played.
/// The play phase ends when the turn comes back to this player.
/// Removed automatically when the phase transitions.
/// </summary>
[MessagePackObject]
public struct PendingDuelist
{
[Key(0)] public int PlayerIndex;
}
+14
View File
@@ -0,0 +1,14 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a horn/skull token entity to the target entity it's placed on.
/// </summary>
[MessagePackObject]
public record struct PlacedOn : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag + index for a player entity.
/// </summary>
[MessagePackObject]
public record struct Player
{
[Key(0)] public int Index;
}
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for the public deck entity.
/// </summary>
[MessagePackObject]
public struct PublicDeck { }
+9
View File
@@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a skull token. Can be placed on cards, banners, or leaders.
/// </summary>
[MessagePackObject]
public struct Skull { }