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

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

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
+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>