补上浏览器试玩客户端,只渲染状态并提交合法行动。

占位美术先顶着,方便点选验证而不把规则写进界面。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-09 02:06:23 +08:00
co-authored by Cursor
parent ae13f1d06a
commit 41f3499b5c
11 changed files with 1269 additions and 0 deletions
+34
View File
@@ -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
+214
View File
@@ -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<string> Detail);
public sealed record PlayerView(
int Index,
int Life,
int Energy,
int Deck,
int Graveyard,
IReadOnlyList<EntityView> 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<AiCandidateView> TopCandidates);
public sealed record MatchView(
ulong Seed,
int Turn,
int Active,
bool Over,
int? Winner,
string? Error,
PlayerView You,
PlayerView Foe,
IReadOnlyList<EntityView> YourBoard,
IReadOnlyList<EntityView> FoeBoard,
IReadOnlyList<ActionView> Legal,
IReadOnlyList<string> Log,
IReadOnlyList<AiStepView> 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<PlayCardAction>().Select(a => a.CardId.Value).ToHashSet();
var attackers = legal.OfType<AttackAction>().Select(a => a.AttackerId.Value).ToHashSet();
var activators = legal.OfType<ActivateAction>().Select(a => a.SourceId.Value).ToHashSet();
var trace = (aiTrace?.Steps ?? Array.Empty<AiStep>())
.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<int> playable,
HashSet<int> attackers,
HashSet<int> 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<int> playable,
HashSet<int> attackers,
HashSet<int> 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
};
}
+39
View File
@@ -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()
};
}
+147
View File
@@ -0,0 +1,147 @@
using System.Collections.Immutable;
using CardGame.Core;
namespace CardGame.Client;
public static class CardRulesText
{
public static IReadOnlyList<string> For(Entity entity, GameState state)
{
var def = entity.Definition;
var lines = new List<string>
{
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<Effect> 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;
}
}
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>CardGame.Client</RootNamespace>
<AssemblyName>Game.Client</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Game.Core\Game.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\content\**\*" CopyToOutputDirectory="PreserveNewest" Link="content\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
</Project>
+72
View File
@@ -0,0 +1,72 @@
using CardGame.Core;
namespace CardGame.Client;
public sealed class MatchHost
{
private readonly object _gate = new();
private readonly IReadOnlyDictionary<string, CardDefinition> _catalog;
private GameState _state;
private AiTurnTrace? _lastAi;
public MatchHost(IReadOnlyDictionary<string, CardDefinition> 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);
}
}
}
}
+48
View File
@@ -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);
@@ -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"
}
}
}
}
+295
View File
@@ -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; }
}
+303
View File
@@ -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} <span class="score">${c.score}</span>`;
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 = `<div class="face">${c.emoji}</div>
<div class="name">${c.name}</div>
<div class="stats">${c.cost}费 ${body}${cd}</div>
<div class="meta">${c.holder}${board && c.canAttack ? " ·可攻" : ""}${board && c.canActivate ? " ·可激活" : ""}</div>`;
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) => `<li>${escapeHtml(line)}</li>`)
.join("");
tip.innerHTML = `<div class="tooltip-title"><span class="emoji">${c.emoji}</span><span>${escapeHtml(c.name)}</span><span class="tooltip-holder">${escapeHtml(c.holder)}</span></div><ul>${lines}</ul>`;
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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);
});
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>对战测试 · Card Roguelike</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<div class="shell">
<header class="bar">
<div>
<strong>对战测试</strong>
<span class="muted" id="meta">载入中…</span>
</div>
<div class="bar-actions">
<label class="seed">种子
<input id="seed" type="text" inputmode="numeric" placeholder="随机" />
</label>
<button type="button" id="new">新对局</button>
</div>
</header>
<p class="banner" id="banner" hidden></p>
<div class="select-strip" id="select-strip" hidden>
<span id="select-text"></span>
<button type="button" id="cancel-select">取消选择</button>
</div>
<main class="table">
<section class="row" aria-label="敌方手牌">
<h2>敌方手牌(测试可见)</h2>
<div class="lane hand" id="foe-hand"></div>
</section>
<section class="hero foe" id="foe-hero" data-seat="1" tabindex="0">
<div class="portrait" aria-hidden="true">
<svg viewBox="0 0 48 48"><circle cx="24" cy="18" r="10" fill="#4a3f35"/><ellipse cx="24" cy="40" rx="16" ry="10" fill="#3a322c"/></svg>
</div>
<div>
<div class="who">敌方主战者</div>
<div class="nums" id="foe-nums"></div>
</div>
<span class="hero-tag" id="foe-tag" hidden>可选目标</span>
</section>
<section class="row" aria-label="敌方场面">
<h2>敌方场面</h2>
<div class="lane" id="foe-board"></div>
</section>
<section class="row" aria-label="己方场面">
<h2>己方场面</h2>
<div class="lane" id="you-board"></div>
</section>
<section class="hero you" id="you-hero" data-seat="0">
<div class="portrait" aria-hidden="true">
<svg viewBox="0 0 48 48"><circle cx="24" cy="18" r="10" fill="#6b5344"/><ellipse cx="24" cy="40" rx="16" ry="10" fill="#8a6a52"/></svg>
</div>
<div>
<div class="who">你</div>
<div class="nums" id="you-nums"></div>
</div>
<button type="button" class="end" id="end">结束回合</button>
</section>
<section class="row hand-row" aria-label="手牌">
<h2>手牌</h2>
<div class="lane hand" id="you-hand"></div>
</section>
</main>
<aside class="rail">
<h2>当前选择</h2>
<p class="hint" id="hint">点手牌或场面开始。</p>
<h2>合法行动</h2>
<div class="actions" id="actions"></div>
<h2>AI 本回合(Beam)</h2>
<p class="hint">敌方用当前回合浅层搜索;每步显示分数最高的几条候选,粗体为实际选择。</p>
<div id="ai-trace" class="ai-trace"></div>
<h2>最近事件</h2>
<ol class="log" id="log"></ol>
</aside>
</div>
<div class="tooltip" id="tooltip" hidden></div>
<script src="/app.js"></script>
</body>
</html>