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,113 @@
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Loads card definitions from a CSV and creates card entities in the world.
|
||||
/// </summary>
|
||||
public static class CardDataLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates all card definition entities in the world from CSV data.
|
||||
/// Returns a list of (entity, CardDef) pairs.
|
||||
/// </summary>
|
||||
public static List<(Entity Entity, CardDef Def)> LoadDefinitions(World world, string csv)
|
||||
{
|
||||
var results = new List<(Entity, CardDef)>();
|
||||
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (lines.Length < 2)
|
||||
return results;
|
||||
|
||||
// Skip header line.
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
|
||||
var def = ParseLine(line);
|
||||
if (def.Name == null) continue;
|
||||
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, def);
|
||||
results.Add((entity, def));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static CardDef ParseLine(string line)
|
||||
{
|
||||
// Simple CSV parsing: name,ranks,effect
|
||||
// Ranks are semicolon-separated.
|
||||
var parts = SplitCsv(line);
|
||||
if (parts.Length < 3)
|
||||
return default;
|
||||
|
||||
var name = parts[0].Trim();
|
||||
var rankStrs = parts[1].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var ranks = new int[rankStrs.Length];
|
||||
for (int i = 0; i < rankStrs.Length; i++)
|
||||
ranks[i] = int.Parse(rankStrs[i]);
|
||||
|
||||
var effect = ParseEffect(parts[2].Trim());
|
||||
|
||||
return new CardDef
|
||||
{
|
||||
Name = name,
|
||||
Ranks = ranks,
|
||||
Effect = effect,
|
||||
Kind = CardKind.Public
|
||||
};
|
||||
}
|
||||
|
||||
private static string[] SplitCsv(string line)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var current = new System.Text.StringBuilder();
|
||||
bool inQuotes = false;
|
||||
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
char c = line[i];
|
||||
if (c == '"')
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
else if (c == ',' && !inQuotes)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
}
|
||||
result.Add(current.ToString());
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static CardEffect ParseEffect(string effect)
|
||||
{
|
||||
return effect switch
|
||||
{
|
||||
string s when s.Contains("战士") => CardEffect.Warrior,
|
||||
string s when s.Contains("弓手") => CardEffect.Archer,
|
||||
string s when s.Contains("佣兵") => CardEffect.Mercenary,
|
||||
string s when s.Contains("商人") => CardEffect.Merchant,
|
||||
string s when s.Contains("舞姬") => CardEffect.Dancer,
|
||||
string s when s.Contains("圣骑士") => CardEffect.Paladin,
|
||||
string s when s.Contains("飞马") => CardEffect.Pegasus,
|
||||
string s when s.Contains("诅咒师") => CardEffect.CurseMaster,
|
||||
string s when s.Contains("魔导士") => CardEffect.Mage,
|
||||
string s when s.Contains("公主") => CardEffect.Princess,
|
||||
string s when s.Contains("决斗家") => CardEffect.Duelist,
|
||||
string s when s.Contains("修女") => CardEffect.Nun,
|
||||
string s when s.Contains("斥候") => CardEffect.Scout,
|
||||
string s when s.Contains("女巫") => CardEffect.Witch,
|
||||
string s when s.Contains("盗贼") => CardEffect.Thief,
|
||||
_ => CardEffect.None
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// A card effect that can be registered with the effect dispatcher.
|
||||
/// </summary>
|
||||
public interface ICardEffect
|
||||
{
|
||||
CardEffect Effect { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Called when the card is played face-up or flipped face-up.
|
||||
/// Return true if fully resolved. Return false if a pending component
|
||||
/// was set and the effect needs a player command to continue.
|
||||
/// </summary>
|
||||
bool Resolve(World world, Entity card, Entity player, Entity owner);
|
||||
|
||||
/// <summary>
|
||||
/// Called when the player provides a choice to continue a pending effect.
|
||||
/// The effect handler is responsible for reading the correct pending
|
||||
/// component type from the singleton and removing it when done.
|
||||
/// </summary>
|
||||
bool ResolveChoice(World world, Entity target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry of card effects, ordered by registration.
|
||||
/// </summary>
|
||||
public static class CardEffectRegistry
|
||||
{
|
||||
private static readonly List<ICardEffect> _effects = new();
|
||||
private static bool _frozen;
|
||||
|
||||
public static void Add<TEffect>() where TEffect : ICardEffect, new()
|
||||
{
|
||||
if (_frozen)
|
||||
throw new InvalidOperationException("Cannot add effects after the registry is frozen.");
|
||||
_effects.Add(new TEffect());
|
||||
}
|
||||
|
||||
public static void Freeze() => _frozen = true;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a card effect. Returns true if fully resolved.
|
||||
/// </summary>
|
||||
public static bool Dispatch(World world, CardEffect effect, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
foreach (var e in _effects)
|
||||
{
|
||||
if (e.Effect == effect)
|
||||
return e.Resolve(world, card, player, owner);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up the handler for the currently pending effect and resolve it.
|
||||
/// </summary>
|
||||
public static bool DispatchChoice(World world, Entity target)
|
||||
{
|
||||
// Find which pending component is active and dispatch to the matching handler.
|
||||
if (world.HasSingleton<PendingMercenary>())
|
||||
return DispatchByEffect(world, CardEffect.Mercenary, target);
|
||||
if (world.HasSingleton<PendingDancer>())
|
||||
return DispatchByEffect(world, CardEffect.Dancer, target);
|
||||
if (world.HasSingleton<PendingPaladin>())
|
||||
return DispatchByEffect(world, CardEffect.Paladin, target);
|
||||
if (world.HasSingleton<PendingNun>())
|
||||
return DispatchByEffect(world, CardEffect.Nun, target);
|
||||
if (world.HasSingleton<PendingScout>())
|
||||
return DispatchByEffect(world, CardEffect.Scout, target);
|
||||
if (world.HasSingleton<PendingPegasus>())
|
||||
return DispatchByEffect(world, CardEffect.Pegasus, target);
|
||||
if (world.HasSingleton<PendingCurseMaster>())
|
||||
return DispatchByEffect(world, CardEffect.CurseMaster, target);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool DispatchByEffect(World world, CardEffect effect, Entity target)
|
||||
{
|
||||
foreach (var e in _effects)
|
||||
{
|
||||
if (e.Effect == effect)
|
||||
return e.ResolveChoice(world, target);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ClearForTests()
|
||||
{
|
||||
_effects.Clear();
|
||||
_frozen = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Simple effects that resolve immediately.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
public class WarriorEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Warrior;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
if (HasOtherOnField(world, owner, card, CardEffect.Warrior))
|
||||
CardEffectHelpers.AddHorn(world, card);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
|
||||
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
|
||||
{
|
||||
return GameUtil.GetFieldCards(world, player)
|
||||
.Any(c => c != self && world.HasComponent<CardDef>(c)
|
||||
&& world.ReadComponent<CardDef>(c).Effect == effect);
|
||||
}
|
||||
}
|
||||
|
||||
public class ArcherEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Archer;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
if (HasOtherOnField(world, owner, card, CardEffect.Archer))
|
||||
{
|
||||
var banner = CardEffectHelpers.GetBanner(world, owner);
|
||||
if (banner != Entity.Null) CardEffectHelpers.AddHorn(world, banner);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
|
||||
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
|
||||
{
|
||||
return GameUtil.GetFieldCards(world, player)
|
||||
.Any(c => c != self && world.HasComponent<CardDef>(c)
|
||||
&& world.ReadComponent<CardDef>(c).Effect == effect);
|
||||
}
|
||||
}
|
||||
|
||||
public class MercenaryEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Mercenary;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
if (!HasOtherOnField(world, owner, card, CardEffect.Mercenary))
|
||||
return true;
|
||||
|
||||
world.SetSingleton(new PendingMercenary { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingMercenary>();
|
||||
if (target != Entity.Null)
|
||||
CardEffectHelpers.AddSkull(world, target);
|
||||
world.RemoveSingleton<PendingMercenary>();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
|
||||
{
|
||||
return GameUtil.GetFieldCards(world, player)
|
||||
.Any(c => c != self && world.HasComponent<CardDef>(c)
|
||||
&& world.ReadComponent<CardDef>(c).Effect == effect);
|
||||
}
|
||||
}
|
||||
|
||||
public class MerchantEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Merchant;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.Commands.Enqueue(new DrawCardCommand { Player = player, Kind = null });
|
||||
world.Commands.Enqueue(new ExtraActionCommand());
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
public class DancerEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Dancer;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
var powered = GameUtil.GetFieldCards(world, owner)
|
||||
.Where(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0)
|
||||
.ToList();
|
||||
|
||||
if (powered.Count == 0) return true;
|
||||
if (powered.Count <= 2)
|
||||
{
|
||||
foreach (var c in powered)
|
||||
CardEffectHelpers.AddHorn(world, c);
|
||||
return true;
|
||||
}
|
||||
|
||||
world.SetSingleton(new PendingDancer { CardEntity = card, Player = player, Step = 0 });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingDancer>();
|
||||
|
||||
if (target != Entity.Null)
|
||||
CardEffectHelpers.AddHorn(world, target);
|
||||
|
||||
if (pending.Step == 0)
|
||||
{
|
||||
world.SetSingleton(new PendingDancer { CardEntity = pending.CardEntity, Player = pending.Player, Step = 1 });
|
||||
return false;
|
||||
}
|
||||
|
||||
world.RemoveSingleton<PendingDancer>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class PaladinEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Paladin;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.SetSingleton(new PendingPaladin { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
if (target != Entity.Null)
|
||||
CardEffectHelpers.AddHorn(world, target);
|
||||
world.RemoveSingleton<PendingPaladin>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class PrincessEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Princess;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.Commands.Enqueue(new ReplaceCastleCommand());
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
public class DuelistEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Duelist;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.Commands.Enqueue(new EndPlayPhaseCommand());
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
public class NunEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Nun;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
var fieldCards = GameUtil.GetFieldCards(world, owner);
|
||||
if (fieldCards.Count == 0)
|
||||
{
|
||||
world.Commands.Enqueue(new DrawCardCommand { Player = player, Kind = null });
|
||||
return true;
|
||||
}
|
||||
|
||||
world.SetSingleton(new PendingNun { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingNun>();
|
||||
if (target != Entity.Null)
|
||||
{
|
||||
var discardDeck = CardEffectHelpers.GetDiscardDeck(world, pending.Player);
|
||||
world.RemoveComponent<OnField>(target);
|
||||
if (world.HasComponent<Card>(target))
|
||||
world.RemoveComponent<Card>(target);
|
||||
world.AddComponent(target, new InDeck { Source = target, Target = discardDeck });
|
||||
}
|
||||
world.Commands.Enqueue(new DrawCardCommand { Player = pending.Player, Kind = null });
|
||||
world.RemoveSingleton<PendingNun>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class ScoutEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Scout;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.SetSingleton(new PendingScout { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingScout>();
|
||||
if (target != Entity.Null && world.HasComponent<Player>(target))
|
||||
{
|
||||
foreach (var c in GameUtil.GetFieldCards(world, target))
|
||||
{
|
||||
if (world.TryGetComponent<Card>(c, out var cd) && cd.FaceDown)
|
||||
{
|
||||
ref var cardRef = ref world.GetComponent<Card>(c);
|
||||
cardRef.FaceDown = false;
|
||||
world.MarkModified<Card>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
world.RemoveSingleton<PendingScout>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class WitchEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Witch;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
var banner = CardEffectHelpers.GetBanner(world, owner);
|
||||
if (banner != Entity.Null)
|
||||
CardEffectHelpers.AddSkull(world, banner);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
public class ThiefEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Thief;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Face-down flip effects
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
public class PegasusEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Pegasus;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.SetSingleton(new PendingPegasus { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingPegasus>();
|
||||
if (target != Entity.Null && target != pending.CardEntity)
|
||||
{
|
||||
world.RemoveComponent<OnField>(target);
|
||||
if (world.HasComponent<Card>(target))
|
||||
world.RemoveComponent<Card>(target);
|
||||
world.AddComponent(target, new HeldBy { Source = target, Target = pending.Player });
|
||||
}
|
||||
world.RemoveSingleton<PendingPegasus>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class CurseMasterEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.CurseMaster;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.SetSingleton(new PendingCurseMaster { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingCurseMaster>();
|
||||
if (target != Entity.Null && world.HasComponent<Horn>(target))
|
||||
{
|
||||
var placedTargets = world.GetSources<PlacedOn>(target).ToList();
|
||||
world.DestroyEntity(target);
|
||||
foreach (var pt in placedTargets)
|
||||
{
|
||||
var skull = world.CreateEntity();
|
||||
world.AddComponent(skull, new Skull());
|
||||
world.AddComponent(skull, new PlacedOn { Source = skull, Target = pt });
|
||||
}
|
||||
}
|
||||
world.RemoveSingleton<PendingCurseMaster>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class MageEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Mage;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
Entity best = Entity.Null;
|
||||
int bestRank = int.MinValue;
|
||||
foreach (var p in GameUtil.FindAllEntities<Player>(world))
|
||||
{
|
||||
foreach (var c in GameUtil.GetFieldCards(world, p))
|
||||
{
|
||||
if (world.TryGetComponent<Card>(c, out var cd) && !cd.FaceDown && cd.Rank > bestRank)
|
||||
{
|
||||
bestRank = cd.Rank;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best != Entity.Null)
|
||||
CardEffectHelpers.AddSkull(world, best);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Shared helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
public static class CardEffectHelpers
|
||||
{
|
||||
public static void AddHorn(World world, Entity target)
|
||||
{
|
||||
var horn = world.CreateEntity();
|
||||
world.AddComponent(horn, new Horn());
|
||||
world.AddComponent(horn, new PlacedOn { Source = horn, Target = target });
|
||||
}
|
||||
|
||||
public static void AddSkull(World world, Entity target)
|
||||
{
|
||||
var skull = world.CreateEntity();
|
||||
world.AddComponent(skull, new Skull());
|
||||
world.AddComponent(skull, new PlacedOn { Source = skull, Target = target });
|
||||
}
|
||||
|
||||
public static Entity GetBanner(World world, Entity player)
|
||||
{
|
||||
using var iter = world.Select<Banner>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity == World.SingletonEntity) continue;
|
||||
var onField = world.GetSources<OnField>(player);
|
||||
if (onField.Contains(iter.CurrentEntity))
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
|
||||
public static Entity GetDiscardDeck(World world, Entity player)
|
||||
{
|
||||
using var iter = world.Select<FactionDeck>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>Game.CardWars</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for a player's banner entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct Banner { }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for a faction deck entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct FactionDeck { }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for the public deck entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct PublicDeck { }
|
||||
@@ -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 { }
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace Game.CardWars;
|
||||
|
||||
public enum CardKind : byte
|
||||
{
|
||||
Public = 0,
|
||||
Faction = 1
|
||||
}
|
||||
|
||||
public enum CardEffect : byte
|
||||
{
|
||||
None = 0,
|
||||
|
||||
// On-play effects
|
||||
Warrior,
|
||||
Archer,
|
||||
Mercenary,
|
||||
Merchant,
|
||||
Dancer,
|
||||
Paladin,
|
||||
Princess,
|
||||
Duelist,
|
||||
Nun,
|
||||
Scout,
|
||||
Witch,
|
||||
Thief,
|
||||
|
||||
// On-flip effects (played face-down)
|
||||
Pegasus,
|
||||
CurseMaster,
|
||||
Mage
|
||||
}
|
||||
|
||||
public enum CastleColor : byte
|
||||
{
|
||||
Black = 0,
|
||||
White = 1,
|
||||
Blue = 2,
|
||||
Brown = 3,
|
||||
Yellow = 4,
|
||||
Indigo = 5,
|
||||
Gold = 6
|
||||
}
|
||||
|
||||
public enum GamePhase : byte
|
||||
{
|
||||
Setup = 0,
|
||||
PlayPhase = 1,
|
||||
FlipPhase = 2,
|
||||
Scoring = 3,
|
||||
Cleanup = 4,
|
||||
GameOver = 5
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating a fully configured game world and system group.
|
||||
/// </summary>
|
||||
public static class GameFactory
|
||||
{
|
||||
public static string DefaultCardData =>
|
||||
"name,ranks,effect\n" +
|
||||
"战士,3;4;4;5,战士\n" +
|
||||
"弓手,3;4;4;5,弓手\n" +
|
||||
"佣兵,4;5;5;6,佣兵\n" +
|
||||
"商人,0;1,商人\n" +
|
||||
"舞姬,0;1,舞姬\n" +
|
||||
"圣骑士,2;3,圣骑士\n" +
|
||||
"飞马,2;3,飞马\n" +
|
||||
"诅咒师,1;2,诅咒师\n" +
|
||||
"魔导士,0;1,魔导士\n" +
|
||||
"公主,4,公主\n" +
|
||||
"决斗家,4,决斗家\n" +
|
||||
"修女,3,修女\n" +
|
||||
"斥候,2,斥候\n" +
|
||||
"女巫,-1,女巫\n" +
|
||||
"盗贼,-1,盗贼\n";
|
||||
|
||||
public static (World World, SystemGroup Group) Create(int playerCount, uint seed = 42)
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
world.Commands.Enqueue(new NewGameCommand
|
||||
{
|
||||
PlayerCount = playerCount,
|
||||
Seed = seed,
|
||||
CardDataCsv = DefaultCardData
|
||||
});
|
||||
|
||||
group.Add(new GameSetupSystem(playerCount, DefaultCardData));
|
||||
group.Add(new PlayPhaseSystem());
|
||||
group.Add(new FlipPhaseSystem());
|
||||
group.Add(new ScoringSystem());
|
||||
group.Add(new CleanupSystem());
|
||||
|
||||
// Run setup.
|
||||
group.RunLogical();
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Shared utility methods for querying and manipulating the game world.
|
||||
/// </summary>
|
||||
public static class GameUtil
|
||||
{
|
||||
public static Entity FindEntity<T>(World world) where T : struct
|
||||
{
|
||||
using var iter = world.Select<T>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
|
||||
public static List<Entity> FindAllEntities<T>(World world) where T : struct
|
||||
{
|
||||
var list = new List<Entity>();
|
||||
using var iter = world.Select<T>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
list.Add(iter.CurrentEntity);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Draw the top card from a deck and add it to the player's hand.</summary>
|
||||
public static bool DrawCard(World world, Entity deck, Entity player)
|
||||
{
|
||||
var cards = world.GetSources<InDeck>(deck);
|
||||
if (cards.Count == 0) return false;
|
||||
|
||||
var cardEntity = cards.First();
|
||||
world.RemoveComponent<InDeck>(cardEntity);
|
||||
world.AddComponent(cardEntity, new HeldBy { Source = cardEntity, Target = player });
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Shuffle cards in a deck using Fisher-Yates with the given seed.</summary>
|
||||
public static void ShuffleDeck(World world, Entity deck, ref uint seed)
|
||||
{
|
||||
var cards = world.GetSources<InDeck>(deck).ToArray();
|
||||
for (int i = cards.Length - 1; i > 0; i--)
|
||||
{
|
||||
int j = Mulberry32.NextInt(ref seed, 0, i);
|
||||
(cards[i], cards[j]) = (cards[j], cards[i]);
|
||||
}
|
||||
for (int i = cards.Length - 1; i >= 0; i--)
|
||||
world.RemoveComponent<InDeck>(cards[i]);
|
||||
for (int i = 0; i < cards.Length; i++)
|
||||
world.AddComponent(cards[i], new InDeck { Source = cards[i], Target = deck });
|
||||
}
|
||||
|
||||
/// <summary>Get all field cards for a player.</summary>
|
||||
public static List<Entity> GetFieldCards(World world, Entity player)
|
||||
{
|
||||
return world.GetSources<OnField>(player).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Get all cards in a player's hand.</summary>
|
||||
public static List<Entity> GetHandCards(World world, Entity player)
|
||||
{
|
||||
return world.GetSources<HeldBy>(player).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Calculate a player's total battle power.</summary>
|
||||
public static int CalculatePower(World world, Entity player)
|
||||
{
|
||||
int total = 0;
|
||||
|
||||
// Leader power.
|
||||
using var leaderIter = world.Select<Leader>();
|
||||
while (leaderIter.MoveNext())
|
||||
{
|
||||
if (leaderIter.CurrentEntity == World.SingletonEntity) continue;
|
||||
var onFieldSources = world.GetSources<OnField>(player);
|
||||
if (onFieldSources.Contains(leaderIter.CurrentEntity))
|
||||
{
|
||||
if (world.TryGetComponent<Card>(leaderIter.CurrentEntity, out var leaderCard))
|
||||
total += leaderCard.Rank;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the player's banner.
|
||||
var banner = CardEffectHelpers.GetBanner(world, player);
|
||||
bool bannerHasHorn = banner != Entity.Null && world.HasComponent<Horn>(banner);
|
||||
bool bannerHasSkull = banner != Entity.Null && world.HasComponent<Skull>(banner);
|
||||
|
||||
// Field cards.
|
||||
var fieldCards = GetFieldCards(world, player);
|
||||
int poweredCount = 0;
|
||||
foreach (var card in fieldCards)
|
||||
{
|
||||
if (!world.TryGetComponent<Card>(card, out var cardData)) continue;
|
||||
if (cardData.FaceDown) continue;
|
||||
if (cardData.Rank <= 0) continue;
|
||||
|
||||
poweredCount++;
|
||||
int rank = cardData.Rank;
|
||||
if (world.HasComponent<Horn>(card))
|
||||
rank *= 2;
|
||||
if (world.HasComponent<Skull>(card))
|
||||
rank = 0;
|
||||
|
||||
total += rank;
|
||||
}
|
||||
|
||||
if (bannerHasHorn) total += poweredCount;
|
||||
if (bannerHasSkull) total -= poweredCount;
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>Move all field cards (except leaders and banners) to the discard pile.</summary>
|
||||
public static void DiscardField(World world, Entity player, Entity discardDeck)
|
||||
{
|
||||
var fieldCards = GetFieldCards(world, player);
|
||||
|
||||
var toDiscard = new List<Entity>();
|
||||
foreach (var card in fieldCards)
|
||||
{
|
||||
// Keep leaders and banners on the field.
|
||||
if (world.HasComponent<Leader>(card)) continue;
|
||||
if (world.HasComponent<Banner>(card)) continue;
|
||||
toDiscard.Add(card);
|
||||
}
|
||||
|
||||
foreach (var card in toDiscard)
|
||||
{
|
||||
world.RemoveComponent<OnField>(card);
|
||||
world.RemoveComponent<Card>(card);
|
||||
world.AddComponent(card, new InDeck { Source = card, Target = discardDeck });
|
||||
}
|
||||
|
||||
// Destroy horns and skulls placed on discarded cards.
|
||||
var tokenTargets = new HashSet<Entity>();
|
||||
foreach (var card in toDiscard)
|
||||
{
|
||||
foreach (var token in world.GetSources<PlacedOn>(card))
|
||||
tokenTargets.Add(token);
|
||||
}
|
||||
foreach (var token in tokenTargets)
|
||||
world.DestroyEntity(token);
|
||||
}
|
||||
|
||||
/// <summary>Recycle discard pile into draw pile.</summary>
|
||||
public static void RecycleDiscard(World world, Entity drawDeck, Entity discardDeck)
|
||||
{
|
||||
var cards = world.GetSources<InDeck>(discardDeck).ToList();
|
||||
foreach (var card in cards)
|
||||
{
|
||||
world.RemoveComponent<InDeck>(card);
|
||||
world.AddComponent(card, new InDeck { Source = card, Target = drawDeck });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
|
||||
/// </summary>
|
||||
public static class Mulberry32
|
||||
{
|
||||
public static float NextFloat(ref uint state)
|
||||
{
|
||||
state += 0x6D2B79F5u;
|
||||
uint z = state;
|
||||
z = (z ^ (z >> 15)) * (z | 1u);
|
||||
z ^= z + (z ^ (z >> 7)) * (z | 61u);
|
||||
return (z ^ (z >> 14)) / (float)uint.MaxValue;
|
||||
}
|
||||
|
||||
public static int NextInt(ref uint state, int min, int max)
|
||||
{
|
||||
return (int)(NextFloat(ref state) * (max - min + 1)) + min;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Global game state on the singleton entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct GameState
|
||||
{
|
||||
[Key(0)] public GamePhase Phase;
|
||||
[Key(1)] public int PlayerCount;
|
||||
[Key(2)] public int CurrentPlayerIndex;
|
||||
[Key(3)] public int StartingPlayerIndex;
|
||||
[Key(4)] public int RoundNumber;
|
||||
[Key(5)] public uint Seed;
|
||||
[Key(6)] public bool PlayPhaseEnded;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Per-player score tracking.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct PlayerScores
|
||||
{
|
||||
[Key(0)] public int[] Scores;
|
||||
[Key(1)] public CastleColor[] WonCastles;
|
||||
[Key(2)] public int Count;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# CardWars — Design Document
|
||||
|
||||
<!-- Fill in the rules, mechanics, and design notes here. -->
|
||||
Reference in New Issue
Block a user