Phaser 过场系统
关心的是演出怎么排——镜头、时机、表现力。本节关心的是另一件事:界面上的每个可交互元素,能不能被一段脚本稳定地找到。9.1 把规格与内核内联进了一个 HTML;这一节给这页原型挂一套 data-* 契约,让无头浏览器能像读结构一样读它。
9.2 选择界面与状态显示
一页可玩原型只需要三块界面:当前节点台词、选择按钮、状态显示区。前两块让玩家推进,第三块让玩家(以及断言脚本)看到状态——4 个好感度、已置位 flag、持有物品。
界面本身不难写,难的是可断言:如果按钮是「第 3 个 div 里的第 2 个孩子」,那么加一个装饰性元素就会让断言失效。做法是给每个可交互元素挂一个稳定的 data-* 属性——它是界面与测试之间的契约,与视觉样式、DOM 层级解耦。
契约表:每个元素一个可寻址的属性
| 契约 | 挂在 | 含义 | 实测例 |
|---|---|---|---|
data-node-id | 场景卡 #scene | 当前对话节点 id | d_pier_1 |
data-choice-id | 每个选择按钮 | 该按钮对应的选项 id | c_pier_1a |
data-aff-<角色> | 每个好感度胶囊 | 该角色的当前好感度 | data-aff-yan="20" |
data-ending | 终局节点上的结局行 | 走到的结局名 | 灯守 |
data-choice-id只挂在「当前可用」的选项上:render()里先filter((c) => condOk(c.when, state))再建按钮,所以按钮数恒等于当前可用选项数——被条件挡住的选项根本不进 DOM,不会以disabled形态混在里面。data-aff-<角色>用属性名承载角色 id:data-aff-yan/data-aff-hui/data-aff-shen/data-aff-mu四个属性各带一个整数值,脚本不用解析文本就能取值。data-node-id挂在场景卡上而非某个文本节点:节点推进时它整体替换,断言只需读一个属性。- 已置位 flag 与持有物品以空格连接的可读文本呈现(初始为「(无)」),它们是给人看的,不承担机器断言。
状态显示区:4 个好感度、flag、物品
状态显示区分三块,初值直接来自规格:
| 区域 | 内容 | 初始值 |
|---|---|---|
| 好感度 | 4 个 data-aff-<角色> 胶囊 | yan 20 / hui 50 / shen 50 / mu 50 |
| 已置位 flag | 置位 flag 名,空格连接 | (无) |
| 持有物品 | 持有线索名,空格连接 | (无) |
好感度初值不是凭空写的——它是 characters[].affinity.init,与 initState() 一致。渲染时先 Object.keys(state.aff).sort() 保证顺序稳定,再逐个建胶囊:
const aff = document.getElementById("aff");
aff.textContent = "";
for (const k of Object.keys(state.aff).sort()) {
const s = document.createElement("span");
s.className = "pill";
s.setAttribute("data-aff-" + k, String(state.aff[k]));
s.textContent = k + " " + state.aff[k];
aff.appendChild(s);
}
document.getElementById("flags").textContent =
Object.keys(state.flags).sort().filter((k) => state.flags[k]).join(" ") || "(无)";
document.getElementById("items").textContent =
Object.keys(state.items).sort().filter((k) => state.items[k]).join(" ") || "(无)";
sort() 不是可有可无的:data-aff-<角色> 的顺序若随对象键序漂移,截图与 DOM 快照就不稳定。flag 与物品只显示置位 / 持有的项(filter((k) => state.flags[k])),没置位的不占位——这跟「按钮只显示可用选项」是同一个思路:显示的是当前有效状态,不是全部字段。
点击如何驱动状态机
每个按钮的 click 回调只有一句:state = applyChoice(SPEC, state, c.id); render();。注意它是纯函数式的——applyChoice 返回新状态,不改旧状态;render() 是幂等的——每次都把三块界面从 state 重建一遍,不增量改 DOM。这两点让界面永远等于状态的函数。
| 纪律 | 做法 | 好处 |
|---|---|---|
| 纯函数推进 | applyChoice 返回新状态 | 同一状态同一结果,可回放 |
| 幂等渲染 | render() 全量重建 | 界面不会与状态漂移 |
| 单一定义源 | 三块界面都从 state 读 | 断言只需读一处 |
因为 render() 全量重建,「按钮数 = 可用选项数」这条断言在任何节点上都成立,而不是只在起点成立。
W 层:无头浏览器断言
web/verify_ne09.mjs 用 Playwright 无头打开 web/dist/ne09.html,先断言三件事——DOM 已绘制、按钮数等于当前可用选项数、点击后节点推进——再在 9.3 里走完同一条确定性路径。它按契约只读 data-* 属性,不碰任何类名或 DOM 层级。
// file: web/verify_ne09.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne09.mjs
// 卷二 9.2 / 9.3:Playwright 无头打开单文件原型,断言 DOM 已绘制、按钮数 = 当前可用
// 选项数、点击推进节点;再走「总是选第一个可用选项」的确定性路径(25 事件),把终局
// canonical 与 FNV-1a 摘要与 Python 侧(out/ne_01_3_py.json 的 first 策略)逐位比对。
// NODE_PATH 对 ESM import 无效 → 必须 createRequire。
import { createRequire } from "node:module";
import { readFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const SHOTS = join(ROOT, "out/shots");
mkdirSync(SHOTS, { recursive: true });
const py = JSON.parse(readFileSync(join(ROOT, "out/ne_01_3_py.json"), "utf8"));
const ref = py.find((r) => r.strategy === "first");
const url = pathToFileURL(join(HERE, "dist/ne09.html")).href;
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 820, height: 900 } });
const page = await ctx.newPage();
// 冷启动预热页:首个 page 读像素恒为空白,先画一次 8x8 再丢弃
const warm = await ctx.newPage();
await warm.setContent('<canvas id="w" width="8" height="8"></canvas>');
await warm.evaluate(() => {
const g = document.getElementById("w").getContext("2d");
g.fillStyle = "#38bdf8";
g.fillRect(0, 0, 8, 8);
});
await warm.close();
let fail = 0;
function check(name, got, want) {
const ok = got === want;
if (!ok) fail += 1;
console.log(`${ok ? "OK " : "FAIL"} ${name}: ${got}${ok ? "" : " != " + want}`);
}
await page.goto(url);
const start = await page.evaluate(() => ({
node: window.__ne09.node, choices: window.__ne09.choices, available: window.__ne09.available,
}));
check("起始节点", start.node, "d_pier_1");
check("按钮数 = 当前可用选项数", start.choices, start.available);
const firstId = await page.getAttribute("[data-choice-id]", "data-choice-id");
await page.click(`[data-choice-id="${firstId}"]`);
check("点击后节点推进", await page.evaluate(() => window.__ne09.node) !== start.node, true);
const shot = await page.screenshot({ path: join(SHOTS, "ne09_playable.png"), fullPage: true });
console.log(`OK 截图 out/shots/ne09_playable.png ${shot.length} bytes`);
if (shot.length <= 0) { fail += 1; console.log("FAIL 截图为空"); }
await page.goto(url);
await page.evaluate(() => window.__ne09.reset());
let steps = 0;
for (let i = 0; i < 200; i += 1) {
if ((await page.evaluate(() => window.__ne09.choices)) === 0) break;
const cid = await page.getAttribute("[data-choice-id]", "data-choice-id");
await page.click(`[data-choice-id="${cid}"]`);
steps += 1;
}
const got = await page.evaluate(() => ({
node: window.__ne09.node, ending: window.__ne09.ending,
canonical: window.__ne09.canonical, digest: window.__ne09.digest,
}));
check("策略路径事件数", steps, ref.choices.length);
check("终局节点", got.node, ref.node);
check("结局", got.ending, ref.ending);
check("canonical(浏览器 vs Python)", got.canonical, ref.canonical);
check("FNV-1a 摘要(浏览器 vs Python)", got.digest, ref.digest);
check("摘要锚点 4162796975", got.digest, 4162796975);
await browser.close();
console.log(`== verify_ne09: 失败 ${fail}`);
process.exit(fail ? 1 : 0);
本机实跑 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne09.mjs,界面断言部分(9.2):
OK 起始节点: d_pier_1
OK 按钮数 = 当前可用选项数: 2
OK 点击后节点推进: true
OK 截图 out/shots/ne09_playable.png 50002 bytes
判读:断言的是「可寻址」,不是「好看」
「起始节点: d_pier_1」 读的是 #scene 的 data-node-id——原型打开时就在规格声明的起点上。
「按钮数 = 当前可用选项数: 2」 是本节的中心断言。d_pier_1 上有两个选项(c_pier_1a 问他灯塔为什么没亮、c_pier_1b 塞给他一枚铜板),都无条件,所以 DOM 里恰好两个 data-choice-id 按钮;脚本里 window.__ne09.choices 数 DOM 按钮,window.__ne09.available 用内核的 condOk 数可用选项,两者必须相等。这条断言在有条件选项的节点上才真正吃劲——被条件挡住的选项不进 DOM,因此等式仍然成立。
「点击后节点推进: true」 说明点击真的驱动了状态机:按钮的 click 回调调用 applyChoice 再 render(),data-node-id 随之改变。
「截图 50002 bytes」 是「已绘制」的证据。已绘制断言只写字节数大于 0,绝不写「等于 0」的反向断言——空白页与渲染失败是两回事,冷启动首个 page 读像素恒为空白这件事也提醒我们:像素类断言要么先预热,要么只看「有内容」。
为什么断言的是「可用」而不是「全部」:d_pier_1 的两个选项都无条件,所以这个节点上「可用 = 全部」,断言看不出区别;但在 34 个带条件的选项里,两者会分开。把判据写成 choices === available(而不是硬编码数字 2),是为了让同一条断言在有条件节点上也成立。
契约为什么用 data-* 而不是类名
类名是样式的钩子,会随设计调整而变;data-* 是数据的钩子,语义稳定。用类名做选择器,改一次样式就可能让断言失效;用 data-*,样式随便改,契约不动。这不是本节发明的做法——它是把「界面」与「界面背后的状态」分开的通用工程习惯,在无头测试里尤其值钱。
一个常见误解:禁用 vs 不渲染
很多对话系统把不可用选项渲染成灰色 disabled 按钮。本页刻意不这样做:不可用选项根本不进 DOM。两种做法对玩家都成立,但对断言不同:
| 做法 | DOM 里的按钮数 | 断言要写 |
|---|---|---|
渲染 + disabled | 全部选项数 | 数「非 disabled」的按钮 |
| 不渲染(本页) | 仅可用选项数 | 直接数按钮 |
不渲染让「按钮数 = 可用选项数」成为一条恒等式,断言里不用再过滤一次状态。代价是玩家看不到「这里本来有个被挡住的选项」——那是 UX 取舍,不是本节要回答的问题。同样要划清:本节证明的是界面可寻址、可断言,不是「界面好看」;视觉设计与动效属于美术与演出,需真实业务环境,标 X。
状态区为什么要显示 flag 与物品
台词与按钮足以让玩家推进,但状态显示区是给断言与调试用的。走完一条路径后,「已置位 flag」与「持有物品」直接告诉你这条路径捡到了什么、触发了什么——不用翻规格或内核。本节的策略路径终局会置位 8 个 flag、持有 4 件物品(下一节给出终局规范串),这些在界面上就是两行文本。
它们是只读的:没有点击、没有输入,因此不进断言契约表——只有 data-* 那四个承担机器断言。把它们放进来,是因为「可玩原型」同时也是「调试原型」:一个能被读、能被点、能被脚本注入的界面,比一个只有按钮的界面更接近真实对话系统。
「点击后节点推进: true」 只证明「变了」,不证明「变对了」。变对了没有,由 9.3 的同源比对回答:浏览器推进出的终局状态必须与 Python 侧逐位相同。界面断言负责「能动」,同源断言负责「动得对」——两者合起来才是完整的可玩性证据。
本节不涉及任何网络、存储或并发——它是一页纯前端的单机原型。存档、读档与快照属于第 11 章;服务端同步与持久化属于 game/server/ 各篇,不在本节范围。
复现命令
# snippet: 构建并验证可玩原型
cd ~/.inkwellbook/inkwell && node web/build_ne09.mjs \
&& NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne09.mjs
build_ne09.mjs 必须先跑(产出 web/dist/ne09.html),verify_ne09.mjs 后跑(打开这个文件做断言);顺序反了会打开上一版的产物。NODE_PATH 只对 require 生效,脚本里因此用 createRequire(import.meta.url) 取全局 Playwright。
自检表
| 检查项 | 合格标准 |
|---|---|
| 稳定寻址 | 每个可交互元素有 data-* 契约,断言不依赖类名或层级 |
| 按钮数一致 | [data-choice-id] 计数 = 当前可用选项数 |
| 点击推进 | 点击首个按钮后 data-node-id 改变 |
| 状态可见 | 4 个好感度各有一个 data-aff-<角色> 属性 |
| 已绘制 | 截图字节数大于 0,不写「等于 0」的反向断言 |
| 预热页 | 冷启动前先开一个 8×8 预热页并丢弃 |
界面能寻址、能点击推进之后,下一节回答最后一个问题:这页原型与命令行里的内核,是不是同一个东西——把浏览器里走出的终局状态,与 Python 侧的规范串和摘要逐位比对。
阅读导航:上一节:9.1 规格内联进单文件 HTML · 下一节:9.3 原型与内核的同源 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。