补上浏览器试玩客户端,只渲染状态并提交合法行动。
占位美术先顶着,方便点选验证而不把规则写进界面。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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("&", "&")
|
||||||
|
.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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user