diff --git a/CardGame.sln b/CardGame.sln new file mode 100644 index 0000000..3cf5ef3 --- /dev/null +++ b/CardGame.sln @@ -0,0 +1,34 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Core", "src\Game.Core\Game.Core.csproj", "{A1B2C3D4-0001-4000-8000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Core.Tests", "tests\Game.Core.Tests\Game.Core.Tests.csproj", "{A1B2C3D4-0001-4000-8000-000000000002}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HeadlessBattle", "tools\HeadlessBattle\HeadlessBattle.csproj", "{A1B2C3D4-0001-4000-8000-000000000003}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Client", "src\Game.Client\Game.Client.csproj", "{A1B2C3D4-0001-4000-8000-000000000004}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-0001-4000-8000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-0001-4000-8000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-0001-4000-8000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-0001-4000-8000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-0001-4000-8000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-0001-4000-8000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-0001-4000-8000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-0001-4000-8000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-0001-4000-8000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-0001-4000-8000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-0001-4000-8000-000000000003}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-0001-4000-8000-000000000003}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-0001-4000-8000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-0001-4000-8000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-0001-4000-8000-000000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-0001-4000-8000-000000000004}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/Game.Client/BattleView.cs b/src/Game.Client/BattleView.cs new file mode 100644 index 0000000..62e0207 --- /dev/null +++ b/src/Game.Client/BattleView.cs @@ -0,0 +1,214 @@ +using CardGame.Core; + +namespace CardGame.Client; + +public sealed record EntityView( + int Id, + string CardId, + string Name, + string Type, + string Emoji, + string Holder, + int Cost, + int Attack, + int Health, + int Countdown, + int AttacksLeft, + int ActivatesLeft, + bool IsSystem, + bool Playable, + bool CanAttack, + bool CanActivate, + IReadOnlyList Detail); + +public sealed record PlayerView( + int Index, + int Life, + int Energy, + int Deck, + int Graveyard, + IReadOnlyList Hand); + +public sealed record ActionView( + int Index, + string Kind, + string Label, + int? SourceId, + int? TargetEntityId, + int? TargetPlayer, + int? Target2EntityId); + +public sealed record AiCandidateView(string Label, double Score, bool Chosen); + +public sealed record AiStepView( + string ChosenLabel, + double ChosenScore, + IReadOnlyList TopCandidates); + +public sealed record MatchView( + ulong Seed, + int Turn, + int Active, + bool Over, + int? Winner, + string? Error, + PlayerView You, + PlayerView Foe, + IReadOnlyList YourBoard, + IReadOnlyList FoeBoard, + IReadOnlyList Legal, + IReadOnlyList Log, + IReadOnlyList AiTrace); + +public static class BattleView +{ + public static MatchView From(GameState state, string? error = null, AiTurnTrace? aiTrace = null) + { + var legal = RulesEngine.GetLegalActions(state); + var you = 0; + var foe = 1; + var playable = legal.OfType().Select(a => a.CardId.Value).ToHashSet(); + var attackers = legal.OfType().Select(a => a.AttackerId.Value).ToHashSet(); + var activators = legal.OfType().Select(a => a.SourceId.Value).ToHashSet(); + var trace = (aiTrace?.Steps ?? Array.Empty()) + .Select(s => new AiStepView( + s.ChosenLabel, + s.ChosenScore, + s.TopCandidates.Select(c => new AiCandidateView(c.Label, c.Score, c.Chosen)).ToList())) + .ToList(); + + return new MatchView( + state.Seed, + state.TurnNumber, + state.ActivePlayerIndex, + state.IsOver, + state.WinnerIndex, + error, + MapPlayer(state, you, playable, attackers, activators), + MapPlayer(state, foe, playable, attackers, activators), + state.Board.Where(e => e.ControllerIndex == you).Select(e => MapEntity(state, e, playable, attackers, activators)).ToList(), + state.Board.Where(e => e.ControllerIndex == foe).Select(e => MapEntity(state, e, playable, attackers, activators)).ToList(), + legal.Select((a, i) => MapAction(state, a, i)).ToList(), + state.EventLog.TakeLast(14).Select(DescribeEvent).ToList(), + trace); + } + + private static PlayerView MapPlayer( + GameState state, + int index, + HashSet playable, + HashSet attackers, + HashSet activators) + { + var p = state.Players[index]; + return new PlayerView( + index, + p.Life, + p.Energy, + p.Deck.Count, + p.Graveyard.Count, + p.Hand.Select(e => MapEntity(state, e, playable, attackers, activators)).ToList()); + } + + private static EntityView MapEntity( + GameState state, + Entity e, + HashSet playable, + HashSet attackers, + HashSet activators) + { + var atk = e.Definition.Type == CardType.Creature ? state.EffectiveAttack(e) : e.Attack; + return new EntityView( + e.Id.Value, + e.Definition.Id, + e.Definition.Name, + e.Definition.Type.ToString(), + CardArt.Emoji(e.Definition.Id), + CardArt.HolderMark(e.HolderId), + e.Cost, + atk, + e.Health, + e.Countdown, + e.AttacksRemaining, + e.ActivatesRemaining, + e.IsSystem, + playable.Contains(e.Id.Value), + attackers.Contains(e.Id.Value), + activators.Contains(e.Id.Value), + CardRulesText.For(e, state)); + } + + private static ActionView MapAction(GameState state, GameAction action, int index) => + action switch + { + PlayCardAction play => new ActionView( + index, "play", Describe(state, play), play.CardId.Value, + play.Target is EntityTarget et ? et.Id.Value : null, + play.Target is PlayerTarget pt ? pt.PlayerIndex : null, + play.Target2 is EntityTarget et2 ? et2.Id.Value : null), + AttackAction attack => new ActionView( + index, "attack", Describe(state, attack), attack.AttackerId.Value, + attack.Target is EntityTarget et ? et.Id.Value : null, + attack.Target is PlayerTarget pt ? pt.PlayerIndex : null, + null), + ActivateAction act => new ActionView( + index, "activate", Describe(state, act), act.SourceId.Value, + act.Target is EntityTarget et ? et.Id.Value : null, + act.Target is PlayerTarget pt ? pt.PlayerIndex : null, + act.Target2 is EntityTarget et2 ? et2.Id.Value : null), + EndTurnAction => new ActionView(index, "end", "结束回合", null, null, null, null), + _ => new ActionView(index, "other", action.ToString() ?? "?", null, null, null, null) + }; + + public static string Describe(GameState state, GameAction action) => + action switch + { + PlayCardAction play => + "打出 " + Name(state, play.CardId) + + TargetText(state, play.Target) + + TargetText(state, play.Target2), + AttackAction attack => + "攻击 " + Name(state, attack.AttackerId) + TargetText(state, attack.Target), + ActivateAction act => + "激活 " + Name(state, act.SourceId) + TargetText(state, act.Target), + EndTurnAction => "结束回合", + _ => action.ToString() ?? "?" + }; + + private static string TargetText(GameState state, TargetRef? target) => + target switch + { + null => "", + PlayerTarget p => p.PlayerIndex == 0 ? " → 己方主战者" : " → 敌方主战者", + EntityTarget e => " → " + Name(state, e.Id), + _ => "" + }; + + private static string Name(GameState state, EntityId id) + { + var e = state.Find(id); + return e is null ? id.ToString() : $"{CardArt.Emoji(e.Definition.Id)}{e.Definition.Name}"; + } + + private static string DescribeEvent(GameEvent e) => + e switch + { + TurnStartedEvent t => $"回合{t.TurnNumber} P{t.PlayerIndex}开始", + TurnEndedEvent t => $"P{t.PlayerIndex}结束回合", + CardDrawnEvent d => $"P{d.PlayerIndex}抽到 {d.CardIdName}", + CardPlayedEvent p => $"P{p.PlayerIndex}打出 {p.CardIdName}", + EnteredBoardEvent en => $"{en.CardIdName}入场", + DamageEvent d => d.VictimId is null + ? $"对P{d.PlayerIndex}造成{d.Applied}伤害" + : $"对实体造成{d.Applied}伤害", + HealEvent h => $"治疗{h.Applied}", + DestroyedEvent d => $"{d.CardIdName}被破坏", + AttackDeclaredEvent => "宣言攻击", + ActivatedEvent => "激活", + SupplyGeneratedEvent s => $"供能{s.Amount}", + CountdownChangedEvent c => $"倒计时→{c.Value}", + FatigueEvent f => $"疲劳{f.Amount}", + EnergySetEvent n => $"P{n.PlayerIndex}费用={n.Energy}", + _ => e.GetType().Name + }; +} diff --git a/src/Game.Client/CardArt.cs b/src/Game.Client/CardArt.cs new file mode 100644 index 0000000..47c05d5 --- /dev/null +++ b/src/Game.Client/CardArt.cs @@ -0,0 +1,39 @@ +using CardGame.Core; + +namespace CardGame.Client; + +public static class CardArt +{ + public static string Emoji(string cardId) => cardId switch + { + "card.system.base_field" => "⛺", + "card.guard.militia" => "🪖", + "card.guard.patrol" => "🚶", + "card.guard.captain" => "🛡️", + "card.guard.farm" => "🌾", + "card.guard.ration" => "📜", + "card.guard.orders" => "📣", + "card.guard.crossbow" => "🏹", + "card.guard.conscript" => "🪧", + "card.cult.civilian" => "🙇", + "card.cult.believer" => "🔥", + "card.cult.mine" => "⛏️", + "card.cult.camp" => "🏚️", + "card.cult.vilefire" => "☄️", + "card.example.spark" => "⚡", + "card.example.wisp" => "✨", + "card.example.salve" => "💧", + "card.example.ward" => "🧱", + "card.example.lantern" => "🏮", + "card.example.fuse" => "💣", + _ => "🃏" + }; + + public static string HolderMark(string holderId) => holderId switch + { + Factions.Guard => "官兵", + Factions.Cult => "魔教", + Factions.System => "系统", + _ => holderId.Split('.').Last() + }; +} diff --git a/src/Game.Client/CardRulesText.cs b/src/Game.Client/CardRulesText.cs new file mode 100644 index 0000000..7b4c609 --- /dev/null +++ b/src/Game.Client/CardRulesText.cs @@ -0,0 +1,147 @@ +using System.Collections.Immutable; +using CardGame.Core; + +namespace CardGame.Client; + +public static class CardRulesText +{ + public static IReadOnlyList For(Entity entity, GameState state) + { + var def = entity.Definition; + var lines = new List + { + TypeLine(entity, state) + }; + + if (entity.Cost != def.Cost) + { + lines.Add($"当前费用 {entity.Cost}(印刷 {def.Cost})"); + } + + if (def.Id == "card.guard.captain") + { + lines.Add("光环:己方当前费用为 1 的生物攻击 +1。"); + } + + foreach (var effect in def.Effects) + { + lines.Add("打出:" + EffectLine(effect, state)); + } + + foreach (var trigger in def.TriggerList) + { + lines.Add($"{WhenZh(trigger.When)}:{JoinEffects(trigger.Effects, state)}"); + } + + if (def.HasActivate) + { + var need = def.ActivateNeedsFriendly ? "(目标:友方生物)" + : def.ActivateNeedsEnemy ? "(目标:敌方生物)" + : ""; + lines.Add($"激活{need}:{JoinEffects(def.ActivateList, state)}"); + } + + if (entity.Zone == Zone.Board && def.Type == CardType.Creature) + { + lines.Add($"本回合:攻击次数 {entity.AttacksRemaining},激活次数 {entity.ActivatesRemaining}"); + } + + if (lines.Count == 1) + { + lines.Add("无额外效果。"); + } + + return lines; + } + + private static string TypeLine(Entity entity, GameState state) + { + var def = entity.Definition; + return def.Type switch + { + CardType.Creature => + $"{entity.Cost} 费生物 · {state.EffectiveAttack(entity)}/{entity.Health}" + + (entity.Attack != def.Attack || entity.Health != def.Health + ? $"(印刷 {def.Attack}/{def.Health})" + : ""), + CardType.FieldCard => + $"{entity.Cost} 费场域" + + (entity.Countdown > 0 || def.Countdown > 0 ? $" · 倒计时 {entity.Countdown}" : " · 无倒计时"), + CardType.Spell => $"{entity.Cost} 费法术", + _ => def.Type.ToString() + }; + } + + private static string JoinEffects(ImmutableArray effects, GameState state) => + string.Join(";", effects.Select(e => EffectLine(e, state))); + + private static string EffectLine(Effect effect, GameState state) + { + if (!string.IsNullOrWhiteSpace(effect.CustomHandler)) + { + return effect.CustomHandler switch + { + "vile_fire" => "对一个己方生物、敌方主战者、一个敌方生物各造成 3 点伤害。", + _ => $"特殊效果 {effect.CustomHandler}" + }; + } + + return effect.Op switch + { + EffectOp.Damage when effect.Amount < 0 => + $"对{TargetZh(effect.Target)}造成等同于本生物当前攻击力的伤害", + EffectOp.Damage => $"对{TargetZh(effect.Target)}造成 {effect.Amount} 点伤害", + EffectOp.Heal => $"为{TargetZh(effect.Target)}恢复 {effect.Amount} 点生命", + EffectOp.Draw => $"抽 {Math.Max(1, effect.Amount)} 张牌", + EffectOp.Destroy => $"破坏{TargetZh(effect.Target)}", + EffectOp.GenerateEnergy => $"供能 {effect.Amount}", + EffectOp.ModifyCountdown => effect.Amount < 0 + ? $"此牌倒计时 {effect.Amount}" + : $"此牌倒计时 +{effect.Amount}", + EffectOp.Summon => $"召唤 {CardName(state, effect.CardId)}", + EffectOp.BuffAllFriendly => + $"己方所有生物获得 +{effect.Amount}/+{(effect.Amount2 != 0 ? effect.Amount2 : effect.Amount)}", + EffectOp.CreateInHand => + $"将一张 {CardName(state, effect.CardId)} 置入手牌" + + (effect.Amount >= 0 ? $",费用改为 {effect.Amount}" : ""), + EffectOp.ShuffleIntoDeck => $"将一张 {CardName(state, effect.CardId)} 洗入牌库", + EffectOp.TransferStatsThenDie => + "令另一个己方生物获得 +X/+Y(X=本生物当前攻击,Y=本生物当前生命),然后此生物死亡", + EffectOp.SetCost => $"费用改为 {effect.Amount}", + _ => effect.Op.ToString() + }; + } + + private static string TargetZh(EffectTarget target) => + target switch + { + EffectTarget.SelfPlayer => "自己", + EffectTarget.OpponentPlayer => "敌方主战者", + EffectTarget.ChosenEnemy => "一个敌方目标", + EffectTarget.ChosenFriendly => "一个友方生物", + EffectTarget.Self => "自己", + EffectTarget.AllFriendlyCreatures => "己方所有生物", + _ => "目标" + }; + + private static string WhenZh(TriggerWhen when) => + when switch + { + TriggerWhen.TurnStart => "自己的回合开始时", + TriggerWhen.OnDestroyed => "被破坏时", + TriggerWhen.OnEnterBoard => "每当一个己方生物入场时", + TriggerWhen.OpponentTurnEnd => "对方回合结束时", + TriggerWhen.OnSupply => "每当此牌供能时", + _ => when.ToString() + }; + + private static string CardName(GameState state, string? cardId) + { + if (string.IsNullOrWhiteSpace(cardId)) + { + return "未知牌"; + } + + return state.Catalog.TryGetValue(cardId, out var def) ? def.Name : cardId; + } +} diff --git a/src/Game.Client/Game.Client.csproj b/src/Game.Client/Game.Client.csproj new file mode 100644 index 0000000..60df7ce --- /dev/null +++ b/src/Game.Client/Game.Client.csproj @@ -0,0 +1,12 @@ + + + CardGame.Client + Game.Client + + + + + + + + diff --git a/src/Game.Client/MatchHost.cs b/src/Game.Client/MatchHost.cs new file mode 100644 index 0000000..2e41f6e --- /dev/null +++ b/src/Game.Client/MatchHost.cs @@ -0,0 +1,72 @@ +using CardGame.Core; + +namespace CardGame.Client; + +public sealed class MatchHost +{ + private readonly object _gate = new(); + private readonly IReadOnlyDictionary _catalog; + private GameState _state; + private AiTurnTrace? _lastAi; + + public MatchHost(IReadOnlyDictionary catalog) + { + _catalog = catalog; + _state = MatchSetup.CreateStartingBattle(20261006UL, catalog); + } + + public MatchView New(ulong? seed) + { + lock (_gate) + { + var use = seed is > 0 ? seed.Value : (ulong)DateTime.UtcNow.Ticks; + _state = MatchSetup.CreateStartingBattle(use, _catalog); + (_state, _lastAi) = TurnBeamSearch.PlayUntil(_state, untilPlayerIndex: 0); + return BattleView.From(_state, aiTrace: _lastAi); + } + } + + public MatchView View() + { + lock (_gate) + { + return BattleView.From(_state, aiTrace: _lastAi); + } + } + + public MatchView Act(int index) + { + lock (_gate) + { + if (_state.IsOver) + { + return BattleView.From(_state, "对局已结束。开新局再打。", _lastAi); + } + + var legal = RulesEngine.GetLegalActions(_state); + if (index < 0 || index >= legal.Length) + { + return BattleView.From(_state, "行动编号无效。", _lastAi); + } + + try + { + _state = RulesEngine.Execute(_state, legal[index]); + if (!_state.IsOver && _state.ActivePlayerIndex != 0) + { + (_state, _lastAi) = TurnBeamSearch.PlayUntil(_state, untilPlayerIndex: 0); + } + else + { + _lastAi = null; + } + + return BattleView.From(_state, aiTrace: _lastAi); + } + catch (Exception ex) + { + return BattleView.From(_state, ex.Message, _lastAi); + } + } + } +} diff --git a/src/Game.Client/Program.cs b/src/Game.Client/Program.cs new file mode 100644 index 0000000..9aee7a4 --- /dev/null +++ b/src/Game.Client/Program.cs @@ -0,0 +1,48 @@ +using CardGame.Client; +using CardGame.Core; + +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls("http://127.0.0.1:5088"); +builder.Services.ConfigureHttpJsonOptions(o => +{ + o.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; +}); + +var app = builder.Build(); +app.UseDefaultFiles(); +app.UseStaticFiles(); + +var catalog = CardJson.LoadCatalog(FindCardsDir()); +var host = new MatchHost(catalog); + +app.MapGet("/api/state", () => Results.Json(host.View())); +app.MapPost("/api/new", (NewMatchRequest? req) => Results.Json(host.New(req?.Seed))); +app.MapPost("/api/act", (ActRequest req) => Results.Json(host.Act(req.Index))); + +app.Run(); + +static string FindCardsDir() +{ + var dir = AppContext.BaseDirectory; + for (var i = 0; i < 8; i++) + { + var candidate = Path.Combine(dir, "content", "cards"); + if (Directory.Exists(candidate)) + { + return candidate; + } + + var parent = Directory.GetParent(dir); + if (parent is null) + { + break; + } + + dir = parent.FullName; + } + + throw new DirectoryNotFoundException("content/cards not found."); +} + +internal sealed record NewMatchRequest(ulong? Seed); +internal sealed record ActRequest(int Index); diff --git a/src/Game.Client/Properties/launchSettings.json b/src/Game.Client/Properties/launchSettings.json new file mode 100644 index 0000000..5ccd4fa --- /dev/null +++ b/src/Game.Client/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "Game.Client": { + "commandName": "Project", + "applicationUrl": "http://127.0.0.1:5088", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Game.Client/wwwroot/app.css b/src/Game.Client/wwwroot/app.css new file mode 100644 index 0000000..8591188 --- /dev/null +++ b/src/Game.Client/wwwroot/app.css @@ -0,0 +1,295 @@ +:root { + --ink: #1c1814; + --paper: #efe6d6; + --felt: #2c3a32; + --felt-2: #24312a; + --line: #c9b89a; + --muted: #6d6356; + --accent: #8b3a2a; + --ok: #2f5d3a; + --focus: #c4a35a; +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + font: 13px/1.4 "Segoe UI", "PingFang SC", sans-serif; + color: var(--ink); + background: var(--felt-2); +} + +.shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + grid-template-rows: auto auto 1fr; + min-height: 100%; +} + +.bar { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--paper); + border-bottom: 1px solid var(--line); +} + +.bar-actions { display: flex; gap: 8px; align-items: center; } +.muted { color: var(--muted); margin-left: 8px; font-size: 12px; } +.seed { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; } +input, button { + font: inherit; + border: 1px solid #b6a78c; + background: #fffdf8; + color: var(--ink); + border-radius: 4px; + padding: 4px 8px; +} +button { cursor: pointer; } +button:hover { background: #f3ead8; } +button:focus-visible, input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } +button:disabled { opacity: 0.45; cursor: not-allowed; } + +.banner { + grid-column: 1 / -1; + margin: 0; + padding: 6px 14px; + background: #f3d2c6; + color: #6a2418; +} + +.select-strip { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin: 0; + padding: 8px 14px; + background: #2a2218; + color: #f4e7c8; + border-bottom: 2px solid var(--accent); + font-size: 13px; +} +.select-strip button { + background: #4a3424; + color: #f4e7c8; + border-color: #7a5a3a; +} + +.table { + padding: 12px 16px 20px; + background: + radial-gradient(ellipse at top, #355043 0%, transparent 55%), + var(--felt); +} + +.rail { + background: var(--paper); + border-left: 1px solid var(--line); + padding: 10px 12px; + overflow: auto; +} + +h2 { + margin: 0 0 6px; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #d8cbb3; + font-weight: 600; +} +.rail h2 { color: var(--muted); margin-top: 12px; } +.rail h2:first-child { margin-top: 0; } + +.row { margin-bottom: 10px; } +.lane { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 96px; + padding: 8px; + border: 1px dashed rgba(232, 220, 196, 0.35); + border-radius: 6px; +} + +.hero { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + margin-bottom: 8px; + color: var(--paper); + border-radius: 6px; + border: 2px solid transparent; +} +.hero.you { justify-content: flex-start; } +.hero.foe { cursor: default; } +.hero.foe.targetable { + border-color: var(--focus); + background: rgba(196, 163, 90, 0.18); + cursor: pointer; +} +.hero.foe.target-picked { + border-color: var(--accent); + background: rgba(139, 58, 42, 0.28); +} +.hero-tag { + margin-left: auto; + font-size: 11px; + font-weight: 700; + color: #2a2218; + background: var(--focus); + padding: 2px 8px; + border-radius: 3px; +} +.portrait svg { width: 40px; height: 40px; display: block; } +.who { font-size: 11px; opacity: 0.8; } +.nums { font-variant-numeric: tabular-nums; font-size: 16px; font-weight: 650; } + +.end { + margin-left: auto; + background: var(--accent); + color: #fffdf8; + border-color: #6e2c20; + padding: 8px 14px; +} +.end:hover { background: #a34432; color: #fffdf8; } + +.card { + width: 92px; + min-height: 118px; + padding: 6px; + border: 1px solid #5b4a38; + border-radius: 6px; + background: + linear-gradient(#fffaf0, #e8dcc4); + box-shadow: none; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + text-align: center; + cursor: default; +} +.card button-reset { all: unset; } +button.card { cursor: pointer; } +button.card:hover { border-color: var(--ok); } +.card.playable, .card.can-attack, .card.can-activate { outline: 2px solid var(--ok); } +.card.selected { + outline: 3px solid var(--accent); + transform: translateY(-4px); + background: linear-gradient(#fff6e0, #f0d8a8); + position: relative; +} +.card.selected::after { + content: "选中"; + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); + background: var(--accent); + color: #fffdf8; + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: 3px; +} +.card.drop { + outline: 3px dashed var(--focus); + background: linear-gradient(#fffceb, #e8d48a); + position: relative; +} +.card.drop::before { + content: "目标"; + position: absolute; + top: -8px; + right: 4px; + background: var(--focus); + color: #2a2218; + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: 3px; +} +.card.system { background: linear-gradient(#e8efe6, #cfd8c8); } +.face { font-size: 26px; line-height: 1.1; } +.name { font-size: 11px; font-weight: 650; } +.meta { font-size: 10px; color: var(--muted); } +.stats { font-size: 12px; font-variant-numeric: tabular-nums; } + +.ai-trace { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; } +.ai-step { + border: 1px solid var(--line); + border-radius: 4px; + padding: 6px 8px; + background: #fffdf8; +} +.ai-step strong { display: block; margin-bottom: 4px; } +.ai-cands { margin: 0; padding-left: 16px; font-size: 12px; color: #3f3830; } +.ai-cands li.chosen { font-weight: 700; color: var(--accent); } +.ai-cands .score { color: var(--muted); font-variant-numeric: tabular-nums; } + +.empty { + color: rgba(232, 220, 196, 0.55); + font-size: 12px; + padding: 20px 8px; +} + +.actions { display: flex; flex-direction: column; gap: 4px; } +.actions button { + text-align: left; + padding: 6px 8px; +} +.actions button[data-kind="end"] { background: #f6e4dc; } +.hint { color: var(--muted); font-size: 12px; margin: 0 0 8px; } +.log { + margin: 0; + padding-left: 18px; + font-size: 12px; + color: #3f3830; +} +.log li { margin: 2px 0; } + +.tooltip { + position: fixed; + z-index: 40; + max-width: 280px; + min-width: 180px; + padding: 10px 12px; + background: #1f1a14; + color: #f4e7c8; + border: 1px solid #c4a35a; + border-radius: 6px; + pointer-events: none; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.tooltip-title { + display: flex; + align-items: baseline; + gap: 6px; + margin-bottom: 6px; + font-weight: 700; +} +.tooltip-title .emoji { font-size: 18px; } +.tooltip-holder { + margin-left: auto; + font-size: 11px; + color: #c4a35a; + font-weight: 600; +} +.tooltip ul { + margin: 0; + padding-left: 16px; + font-size: 12px; + line-height: 1.45; +} +.tooltip li { margin: 2px 0; } + +@media (max-width: 860px) { + .shell { grid-template-columns: 1fr; } + .rail { border-left: 0; border-top: 1px solid var(--line); } + .end { margin-left: 0; } +} diff --git a/src/Game.Client/wwwroot/app.js b/src/Game.Client/wwwroot/app.js new file mode 100644 index 0000000..9041c22 --- /dev/null +++ b/src/Game.Client/wwwroot/app.js @@ -0,0 +1,303 @@ +const $ = (id) => document.getElementById(id); + +let view = null; +/** @type {{ id: number, name: string, kind: string } | null} */ +let selected = null; + +async function api(path, body) { + const res = await fetch(path, { + method: body ? "POST" : "GET", + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined + }); + if (!res.ok) { + throw new Error("请求失败 " + res.status); + } + return res.json(); +} + +function findCard(id) { + if (!view) return null; + return ( + view.you.hand.find((c) => c.id === id) || + view.yourBoard.find((c) => c.id === id) || + view.foeBoard.find((c) => c.id === id) || + view.foe.hand.find((c) => c.id === id) || + null + ); +} + +function sourceActions(id) { + return (view?.legal || []).filter((a) => a.sourceId === id); +} + +function clearSelection() { + selected = null; + paintSelectionChrome(); +} + +function paintSelectionChrome() { + const strip = $("select-strip"); + const foe = $("foe-hero"); + const tag = $("foe-tag"); + if (!selected || !view) { + strip.hidden = true; + foe.classList.remove("targetable", "target-picked"); + tag.hidden = true; + document.querySelectorAll(".card").forEach((el) => { + el.classList.remove("selected", "drop"); + }); + $("hint").textContent = view?.over + ? "对局结束。开一局新的。" + : "点能出的手牌,或点能攻/激活的场面;需要目标时会标出「选中」「目标」。"; + return; + } + + const card = findCard(selected.id); + const acts = sourceActions(selected.id); + const needsTarget = acts.some( + (a) => a.targetEntityId != null || a.targetPlayer != null || a.target2EntityId != null + ); + const heroOk = acts.some((a) => a.targetPlayer === 1); + + strip.hidden = false; + $("select-text").textContent = needsTarget + ? `已选中:${card?.emoji || ""} ${card?.name || "#" + selected.id}(${selected.kind})→ 请再点黄色「目标」牌或敌方主战者` + : `已选中:${card?.emoji || ""} ${card?.name || "#" + selected.id}`; + $("hint").textContent = needsTarget + ? "黄框=可选目标。点目标完成行动;点「取消选择」或再点选中牌可取消。" + : "该牌无需目标,应已自动执行。"; + + foe.classList.toggle("targetable", heroOk); + tag.hidden = !heroOk; + + document.querySelectorAll(".card").forEach((el) => { + const id = Number(el.dataset.id); + el.classList.toggle("selected", id === selected.id); + const ok = acts.some((a) => a.targetEntityId === id || a.target2EntityId === id); + el.classList.toggle("drop", ok); + }); +} + +function render(v) { + view = v; + selected = null; + $("meta").textContent = `种子 ${v.seed} · 回合 ${v.turn} · ${v.over ? "已结束" : (v.active === 0 ? "你的回合" : "敌方回合")}`; + $("banner").hidden = !v.error && !v.over; + $("banner").textContent = v.error || (v.over ? `胜者 P${v.winner}` : ""); + $("foe-nums").textContent = `${v.foe.life} 命 · ${v.foe.energy} 费 · 库${v.foe.deck} 墓${v.foe.graveyard}`; + $("you-nums").textContent = `${v.you.life} 命 · ${v.you.energy} 费 · 库${v.you.deck} 墓${v.you.graveyard}`; + paintLane("foe-hand", v.foe.hand, false); + paintLane("foe-board", v.foeBoard, true); + paintLane("you-board", v.yourBoard, true); + paintLane("you-hand", v.you.hand, false); + paintActions(v.legal); + paintAi(v.aiTrace || []); + const log = $("log"); + log.replaceChildren(); + for (const line of v.log.slice().reverse()) { + const li = document.createElement("li"); + li.textContent = line; + log.append(li); + } + $("end").disabled = v.over || !v.legal.some((a) => a.kind === "end"); + paintSelectionChrome(); +} + +function paintAi(steps) { + const root = $("ai-trace"); + root.replaceChildren(); + if (!steps.length) { + const p = document.createElement("p"); + p.className = "hint"; + p.textContent = "本回合还没有敌方行动记录。"; + root.append(p); + return; + } + steps.forEach((step, i) => { + const box = document.createElement("div"); + box.className = "ai-step"; + const title = document.createElement("strong"); + title.textContent = `步骤 ${i + 1}:${step.chosenLabel} (${step.chosenScore})`; + const ul = document.createElement("ol"); + ul.className = "ai-cands"; + for (const c of step.topCandidates) { + const li = document.createElement("li"); + if (c.chosen) li.className = "chosen"; + li.innerHTML = `${c.label} ${c.score}`; + ul.append(li); + } + box.append(title, ul); + root.append(box); + }); +} + +function paintLane(id, cards, board) { + const root = $(id); + root.replaceChildren(); + if (!cards.length) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "空"; + root.append(empty); + return; + } + for (const c of cards) { + root.append(cardEl(c, board)); + } +} + +function cardEl(c, board) { + const el = document.createElement("button"); + el.type = "button"; + el.className = "card"; + if (c.isSystem) el.classList.add("system"); + if (c.playable) el.classList.add("playable"); + if (c.canAttack) el.classList.add("can-attack"); + if (c.canActivate) el.classList.add("can-activate"); + el.dataset.id = String(c.id); + const cd = c.type === "FieldCard" ? ` CD${c.countdown}` : ""; + const body = c.type === "Creature" ? `${c.attack}/${c.health}` : c.type === "FieldCard" ? "场域" : "法术"; + el.innerHTML = `
${c.emoji}
+
${c.name}
+
${c.cost}费 ${body}${cd}
+
${c.holder}${board && c.canAttack ? " ·可攻" : ""}${board && c.canActivate ? " ·可激活" : ""}
`; + el.addEventListener("click", () => onCard(c)); + el.addEventListener("pointerenter", (ev) => showTooltip(c, ev)); + el.addEventListener("pointermove", (ev) => moveTooltip(ev)); + el.addEventListener("pointerleave", hideTooltip); + return el; +} + +function showTooltip(c, ev) { + const tip = $("tooltip"); + const lines = (c.detail && c.detail.length ? c.detail : ["无额外效果。"]) + .map((line) => `
  • ${escapeHtml(line)}
  • `) + .join(""); + tip.innerHTML = `
    ${c.emoji}${escapeHtml(c.name)}${escapeHtml(c.holder)}
      ${lines}
    `; + tip.hidden = false; + moveTooltip(ev); +} + +function moveTooltip(ev) { + const tip = $("tooltip"); + if (tip.hidden) return; + const pad = 14; + const tw = tip.offsetWidth || 220; + const th = tip.offsetHeight || 120; + let x = ev.clientX + pad; + let y = ev.clientY + pad; + if (x + tw > window.innerWidth - 8) x = ev.clientX - tw - pad; + if (y + th > window.innerHeight - 8) y = ev.clientY - th - pad; + tip.style.left = `${Math.max(8, x)}px`; + tip.style.top = `${Math.max(8, y)}px`; +} + +function hideTooltip() { + $("tooltip").hidden = true; +} + +function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function paintActions(legal) { + const root = $("actions"); + root.replaceChildren(); + if (!legal.length) { + const p = document.createElement("p"); + p.className = "hint"; + p.textContent = "没有合法行动。"; + root.append(p); + return; + } + for (const a of legal) { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = a.label; + b.dataset.kind = a.kind; + b.addEventListener("click", () => { + clearSelection(); + act(a.index); + }); + root.append(b); + } +} + +function onCard(c) { + if (!view || view.over) return; + const legal = view.legal; + + if (selected && selected.id === c.id) { + clearSelection(); + return; + } + + if (selected) { + const hit = legal.find((a) => + a.sourceId === selected.id && (a.targetEntityId === c.id || a.target2EntityId === c.id)); + if (hit) { + clearSelection(); + act(hit.index); + return; + } + } + + const sourced = sourceActions(c.id); + if (!sourced.length) { + return; + } + + if (sourced.length === 1 && sourced[0].targetEntityId == null && sourced[0].targetPlayer == null && sourced[0].target2EntityId == null) { + clearSelection(); + act(sourced[0].index); + return; + } + + const kind = sourced[0].kind === "play" ? "打出" : sourced[0].kind === "attack" ? "攻击" : "激活"; + selected = { id: c.id, name: c.name, kind }; + paintSelectionChrome(); +} + +document.getElementById("foe-hero").addEventListener("click", () => { + if (!view || selected == null) return; + const hit = view.legal.find((a) => a.sourceId === selected.id && a.targetPlayer === 1); + if (hit) { + clearSelection(); + act(hit.index); + } +}); + +$("cancel-select").addEventListener("click", clearSelection); + +async function act(index) { + try { + render(await api("/api/act", { index })); + } catch (e) { + $("banner").hidden = false; + $("banner").textContent = String(e.message || e); + } +} + +$("new").addEventListener("click", async () => { + const raw = $("seed").value.trim(); + const seed = raw ? Number(raw) : null; + render(await api("/api/new", { seed })); +}); + +$("end").addEventListener("click", () => { + const end = view?.legal.find((a) => a.kind === "end"); + if (end) { + clearSelection(); + act(end.index); + } +}); + +api("/api/state").then(render).catch((e) => { + $("banner").hidden = false; + $("banner").textContent = String(e.message || e); +}); diff --git a/src/Game.Client/wwwroot/index.html b/src/Game.Client/wwwroot/index.html new file mode 100644 index 0000000..b8e02da --- /dev/null +++ b/src/Game.Client/wwwroot/index.html @@ -0,0 +1,92 @@ + + + + + + 对战测试 · Card Roguelike + + + +
    +
    +
    + 对战测试 + 载入中… +
    +
    + + +
    +
    + + + + +
    +
    +

    敌方手牌(测试可见)

    +
    +
    + +
    + +
    +
    敌方主战者
    +
    +
    + +
    + +
    +

    敌方场面

    +
    +
    + +
    +

    己方场面

    +
    +
    + +
    + +
    +
    你
    +
    +
    + +
    + +
    +

    手牌

    +
    +
    +
    + + +
    + + + +