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
+66
View File
@@ -0,0 +1,66 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Discards field cards, clears tokens, draws new hands, checks game over.
/// </summary>
public class CleanupSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Cleanup) return;
ref var mutable = ref world.GetSingleton<GameState>();
var players = GameUtil.FindAllEntities<Player>(world);
var publicDeck = GameUtil.FindEntity<PublicDeck>(world);
foreach (var player in players)
{
GameUtil.DiscardField(world, player, publicDeck);
// Clear tokens on banners.
var banner = CardEffectHelpers.GetBanner(world, player);
if (banner != Entity.Null)
{
var tokens = world.GetSources<PlacedOn>(banner).ToList();
foreach (var token in tokens)
world.DestroyEntity(token);
}
}
// Draw 2 public cards per player.
foreach (var player in players)
{
for (int i = 0; i < 2; i++)
GameUtil.DrawCard(world, publicDeck, player);
}
// Check game over.
int maxScore = 0;
foreach (var player in players)
{
var castles = world.GetSources<HeldBy>(player)
.Where(c => world.HasComponent<Castle>(c))
.ToList();
maxScore = Math.Max(maxScore, castles.Count);
}
if (maxScore >= 4)
{
mutable.Phase = GamePhase.GameOver;
}
else
{
mutable.Phase = GamePhase.PlayPhase;
mutable.RoundNumber++;
mutable.CurrentPlayerIndex = (mutable.StartingPlayerIndex + 1) % mutable.PlayerCount;
mutable.StartingPlayerIndex = mutable.CurrentPlayerIndex;
mutable.PlayPhaseEnded = false;
}
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+20
View File
@@ -0,0 +1,20 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Manages the flip phase: players take turns flipping cards.
/// Blocks if a PendingChoice exists.
/// </summary>
public class FlipPhaseSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
// Block if a pending choice exists.
if (PendingChoice.Any(world))
return;
}
}
+104
View File
@@ -0,0 +1,104 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// One-time setup: creates players, leaders, banners, decks, castles, and deals starting hands.
/// </summary>
public class GameSetupSystem : ISystem
{
private readonly int _playerCount;
private readonly string _cardDataCsv;
private bool _hasRun;
public GameSetupSystem(int playerCount, string cardDataCsv)
{
_playerCount = playerCount;
_cardDataCsv = cardDataCsv;
}
public void Run(World world)
{
if (_hasRun) return;
_hasRun = true;
var state = world.ReadSingleton<GameState>();
ref var mutable = ref world.GetSingleton<GameState>();
mutable.PlayerCount = _playerCount;
mutable.Phase = GamePhase.PlayPhase;
mutable.CurrentPlayerIndex = 0;
mutable.StartingPlayerIndex = 0;
mutable.RoundNumber = 1;
world.MarkModified<GameState>(World.SingletonEntity);
// Register card effects.
CardEffectRegistry.Add<WarriorEffect>();
CardEffectRegistry.Add<ArcherEffect>();
CardEffectRegistry.Add<MercenaryEffect>();
CardEffectRegistry.Add<MerchantEffect>();
CardEffectRegistry.Add<DancerEffect>();
CardEffectRegistry.Add<PaladinEffect>();
CardEffectRegistry.Add<PrincessEffect>();
CardEffectRegistry.Add<DuelistEffect>();
CardEffectRegistry.Add<NunEffect>();
CardEffectRegistry.Add<ScoutEffect>();
CardEffectRegistry.Add<WitchEffect>();
CardEffectRegistry.Add<ThiefEffect>();
CardEffectRegistry.Add<PegasusEffect>();
CardEffectRegistry.Add<CurseMasterEffect>();
CardEffectRegistry.Add<MageEffect>();
CardEffectRegistry.Freeze();
// Create public deck.
var publicDeck = world.CreateEntity();
world.AddComponent(publicDeck, new PublicDeck());
// Load card definitions and create card instances for the public deck.
var defs = CardDataLoader.LoadDefinitions(world, _cardDataCsv);
foreach (var (defEntity, _) in defs)
{
world.AddComponent(defEntity, new InDeck { Source = defEntity, Target = publicDeck });
}
// Shuffle public deck.
var seed = state.Seed;
GameUtil.ShuffleDeck(world, publicDeck, ref seed);
// Create the first castle.
var castle = world.CreateEntity();
world.AddComponent(castle, new Castle { Color = CastleColor.Black });
// Create players.
for (int i = 0; i < _playerCount; i++)
{
var player = world.CreateEntity();
world.AddComponent(player, new Player { Index = i });
// Create leader card (always on field, rank 0 placeholder).
var leader = world.CreateEntity();
world.AddComponent(leader, new Leader());
world.AddComponent(leader, new CardDef { Name = $"领袖{i}", Ranks = new[] { 0 }, Effect = CardEffect.None, Kind = CardKind.Faction });
world.AddComponent(leader, new Card { Rank = 0, FaceDown = false });
world.AddComponent(leader, new OnField { Source = leader, Target = player });
// Create banner.
var banner = world.CreateEntity();
world.AddComponent(banner, new Banner());
world.AddComponent(banner, new OnField { Source = banner, Target = player });
// Create faction deck.
var factionDeck = world.CreateEntity();
world.AddComponent(factionDeck, new FactionDeck());
}
// Deal starting hands: 3 public cards per player.
foreach (var player in GameUtil.FindAllEntities<Player>(world))
{
for (int i = 0; i < 3; i++)
GameUtil.DrawCard(world, publicDeck, player);
}
mutable.Seed = seed;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+58
View File
@@ -0,0 +1,58 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Manages the play phase: players take turns. If a PendingChoice exists,
/// blocks advancement until the player resolves it.
/// </summary>
public class PlayPhaseSystem : ISystem
{
public void Run(World world)
{
ref var state = ref world.GetSingleton<GameState>();
if (state.Phase != GamePhase.PlayPhase) return;
// Block if a pending choice exists.
if (PendingChoice.Any(world))
return;
// If the current player has skipped, move to the next player.
if (state.PlayPhaseEnded)
{
state.PlayPhaseEnded = false;
AdvanceTurn(world, ref state);
return;
}
}
private static void AdvanceTurn(World world, ref GameState state)
{
int next = (state.CurrentPlayerIndex + 1) % state.PlayerCount;
// Duelist: if the next player is the one who played duelist, end the phase.
if (world.HasSingleton<PendingDuelist>())
{
var duelist = world.ReadSingleton<PendingDuelist>();
if (next == duelist.PlayerIndex)
{
world.RemoveSingleton<PendingDuelist>();
state.Phase = GamePhase.FlipPhase;
state.CurrentPlayerIndex = state.StartingPlayerIndex;
world.MarkModified<GameState>(World.SingletonEntity);
return;
}
}
state.CurrentPlayerIndex = next;
// If we've come back to the starting player, everyone has skipped.
if (state.CurrentPlayerIndex == state.StartingPlayerIndex)
{
state.Phase = GamePhase.FlipPhase;
world.RemoveSingleton<PendingDuelist>(); // Clean up in case duelist was never triggered.
}
world.MarkModified<GameState>(World.SingletonEntity);
}
}
+59
View File
@@ -0,0 +1,59 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Calculates each player's battle power, awards the castle to the winner,
/// and checks for game end.
/// </summary>
public class ScoringSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Scoring) return;
ref var mutable = ref world.GetSingleton<GameState>();
var players = GameUtil.FindAllEntities<Player>(world);
if (players.Count == 0) return;
int bestPower = int.MinValue;
Entity winner = Entity.Null;
int winnerIndex = -1;
for (int i = 0; i < players.Count; i++)
{
int idx = (state.StartingPlayerIndex + i) % players.Count;
var player = players[idx];
int power = GameUtil.CalculatePower(world, player);
if (power > bestPower)
{
bestPower = power;
winner = player;
winnerIndex = idx;
}
}
if (winner != Entity.Null)
{
var castles = GameUtil.FindAllEntities<Castle>(world);
if (castles.Count > 0)
{
var castle = castles[0];
world.AddComponent(castle, new HeldBy { Source = castle, Target = winner });
world.RemoveComponent<Castle>(castle);
}
var colors = new[] { CastleColor.Black, CastleColor.White, CastleColor.Blue,
CastleColor.Brown, CastleColor.Yellow, CastleColor.Indigo, CastleColor.Gold };
int colorIdx = Mulberry32.NextInt(ref mutable.Seed, 0, colors.Length - 1);
var newCastle = world.CreateEntity();
world.AddComponent(newCastle, new Castle { Color = colors[colorIdx] });
}
mutable.Phase = GamePhase.Cleanup;
world.MarkModified<GameState>(World.SingletonEntity);
}
}