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