Phaser 过场系统
做的是过场编排与演出——怎么切镜头、怎么把一段剧情演出来,它依赖引擎的渲染管线与资源加载。本节与它划界明确:这里没有引擎、没有 CDN、没有 <script src>,只有一份 config/story_spec.json 与一个内核被内联进同一个 HTML 文件,用浏览器直接打开就能玩。
9.1 规格内联进单文件 HTML
前八章都在命令行里推进状态:Python 穷举、TypeScript 断言、摘要逐位比对。这一章换一个宿主——浏览器。做法不是把内核编译成资源包再配一个服务器,而是走反方向:把规格与内核一起内联进一个 HTML,让这个文件自包含到可以离线双击。
单文件原型要满足三个「零」,缺一个就不是「双击即玩」:
| 零 | 含义 | 反面 |
|---|---|---|
| 零外部依赖 | 页面里没有任何 <script src> / <link href> / CDN | 断网即白屏 |
| 零构建步骤 | 打开文件就是终态,不需要打包器 / 服务器 | 要 npm run dev 才能看 |
| 零网络请求 | 全部数据随文件走,运行期不发请求 | 首屏等接口 |
三个零合起来只有一个判据:产物里的外部引用计数为 0。本节把它做成脚本里的硬断言,而不是靠肉眼检查。
W 层:把规格与内核塞进一个文件
web/build_ne09.mjs 复用 web/pack.mjs 的 bundle / page / writePage / inlineConfig:bundle() 把 core/src/inkwell/engine.ts 擦除类型后拼成一段 JS(各章的 build 脚本不自写擦除逻辑),inlineConfig() 把整份规格序列化成 const SPEC = {…},两者一起塞进唯一的 <script type="module">。
// file: web/build_ne09.mjs
// tier: W
// verify: node web/build_ne09.mjs
// 卷二 9.1:把 config/story_spec.json 与 core/src/inkwell/engine.ts 内联进一个单文件
// HTML。复用 web/pack.mjs 的 bundle / page / writePage / inlineConfig,不自写擦除逻辑。
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8"));
const core = bundle(ROOT, ["core/src/inkwell/engine.ts"]);
const pageScript = `
const SPEC = ${inlineConfig(spec)};
let state = initState(SPEC);
function render() {
const n = nodeById(SPEC, state.node);
document.getElementById("scene").setAttribute("data-node-id", state.node);
document.getElementById("who").textContent = n.speaker;
document.getElementById("text").textContent = n.line;
const box = document.getElementById("choices");
box.textContent = "";
const opts = (n.choices || []).filter((c) => condOk(c.when, state));
for (const c of opts) {
const b = document.createElement("button");
b.className = "choice";
b.setAttribute("data-choice-id", c.id);
b.textContent = c.text;
b.addEventListener("click", () => { state = applyChoice(SPEC, state, c.id); render(); });
box.appendChild(b);
}
if (opts.length === 0) {
const d = document.createElement("div");
d.className = "line";
d.setAttribute("data-ending", n.ending || "");
d.textContent = "结局:" + (n.ending || "无");
box.appendChild(d);
}
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(" ") || "(无)";
}
window.__ne09 = {
get node() { return state.node; },
get ending() { return nodeById(SPEC, state.node).ending || ""; },
get digest() { return digest(state); },
get canonical() { return canonical(state); },
get choices() { return document.querySelectorAll("[data-choice-id]").length; },
get available() {
const n = nodeById(SPEC, state.node);
return (n.choices || []).filter((c) => condOk(c.when, state)).length;
},
reset() { state = initState(SPEC); render(); },
};
render();
`;
const body = `
<h1>雾港夜航 · 可玩原型</h1>
<div class="sub">config/story_spec.json + core/src/inkwell/engine.ts 内联 · 零外部依赖</div>
<div class="card" id="scene" data-node-id="">
<div class="who" id="who"></div>
<div id="text"></div>
</div>
<div class="card"><div class="k">选择</div><div id="choices"></div></div>
<div class="card"><div class="k">好感度</div><div id="aff" class="row"></div></div>
<div class="card"><div class="k">已置位 flag</div><div id="flags"></div>
<div class="k">持有物品</div><div id="items"></div></div>
`;
const html = page({ title: "雾港夜航 · 可玩原型", body, script: core + "\n\n" + pageScript });
const bytes = writePage(join(HERE, "dist/ne09.html"), html);
// 零外部依赖自检:无 <script src>、无任何 http(s) 外链。
const scriptSrc = (html.match(/<script[^>]*\ssrc=/gi) || []).length;
const extUrl = (html.match(/https?:\/\//gi) || []).length;
console.log("wrote web/dist/ne09.html", bytes, "bytes");
console.log("script src", scriptSrc, "· 外部 http(s) 链接", extUrl);
if (scriptSrc !== 0 || extUrl !== 0) {
console.log("FAIL 原型存在外部依赖");
process.exit(1);
}
本机实跑 node web/build_ne09.mjs(stderr 上有一行 Node 的 stripTypeScriptTypes 实验特性提示,不影响 stdout):
wrote web/dist/ne09.html 41641 bytes
script src 0 · 外部 http(s) 链接 0
产物:一个自包含的 HTML
内联后的 web/dist/ne09.html 就是全部交付。它的骨架与普通网页无异,区别只在没有任何外链:<head> 里只有 <meta> 与内联的 <style>,脚本区只有唯一的 <script type="module">。下面是从真实产物里摘出的头尾:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>雾港夜航 · 可玩原型</title>
<style>
/* ……web/pack.mjs 的 BASE_CSS 与本节 css 内联在此…… */
</style>
</head>
<body>
<div class="wrap">
<!-- ……页面骨架:场景卡 / 选择区 / 好感度 / flag / 物品…… -->
</div>
<script type="module">
// file: core/src/inkwell/engine.ts
// tier: P
/* ……擦除类型后的内核:initState / condOk / applyChoice / canonical / digest…… */
const SPEC = { /* ……整份 config/story_spec.json,37KB 量级…… */ };
/* ……build_ne09.mjs 的 pageScript:render() 与 window.__ne09…… */
</script>
</body>
</html>
注意脚本区的第一行是 // file: core/src/inkwell/engine.ts——bundle() 只删跨文件 import 与顶层 export,注释原样保留,所以内联进浏览器的内核仍带着它在工作区里的文件头。产物总共 41641 字节,其中绝大部分就是那份 37KB 的规格本身。
零外部依赖自检
自检落在 build 脚本最后四行:scriptSrc 统计 <script … src= 的出现次数,extUrl 统计 https?:// 的出现次数,两者都必须为 0,否则 exit 1。这比「人眼看一遍」可靠,因为它是产物级的判据——检查的是真正写出的那个 HTML,不是模板。
| 自检项 | 判据 | 实测 |
|---|---|---|
| 外部脚本 | <script … src= 计数 | 0 |
| 外部链接 | https?:// 计数 | 0 |
| 产物字节数 | writePage 返回值 | 41641 |
| 脚本入口 | <script type="module"> 计数 | 1 |
inlineConfig() 把规格序列化时会把 < 转义成 \u003c,防止台词里出现 </script> 之类的片段把内联脚本提前闭合。这是内联方案唯一真正需要小心的地方:
/** 把 config 对象安全地内联进页面。 */
export function inlineConfig(value) {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
一条台词里若含 <,不转义就会在 HTML 里被当成标签开头;转义后它仍是合法 JSON,浏览器解析出的字符串与规格逐字节一致。内联的安全边界只有一个:所有进入 <script> 的字符串都必须经过 inlineConfig(),不手工拼接。
内联的代价与边界
单文件不是没有代价:把 37KB 规格内联进 HTML,意味着每次改规格都要重建一次产物,且产物体积随规格线性增长。判断「该不该内联」有一条简单的线:
| 该内联 | 不该内联 |
|---|---|
| 规格与内核(几十 KB,随文件走) | 大图 / 字体 / 音频(几 MB,该走资源引用) |
| 样式(BASE_CSS,几 KB) | 需要跨页复用的第三方库 |
| 运行期不变化的常量 | 需要按环境切换的配置 |
本页原型没有图、没有字体、没有音频,所以内联是零负担的;一旦要加立绘或配音,就该退回「资源引用 + 本地服务器」,并接受它不再「双击即玩」——那时它也不再是本节讨论的「可断言原型」,而是引擎里的正式实现。
判读:为什么值得走「单文件」这条路
第一,可归档。 一个 HTML 文件就是一份可玩原型,不依赖任何运行时;把它丢进归档,明年再打开仍然能玩——因为规格、内核、样式全在里面。这对「可断言的原型」很关键:无头浏览器要打开的就是这个文件,没有中间层。
第二,可离线。 三个零的直接收益是断网可用。原型阶段最怕的是「本地能跑、评审机器上白屏」;单文件把这种失败模式整类消掉。
第三,与内核同源。 内联的不是一份「为浏览器重写的迷你版」,而是同一个 core/src/inkwell/engine.ts——下一节 9.3 会证明:浏览器里推进出的状态摘要,与 Python 侧、TypeScript 侧逐位相同。
与卷一单页预览的关系
卷一 12.3 做过一个文本表单页预览:同样内联进单文件 HTML,但内联的是一张静态表,没有可玩性。本节把同一套「内联」手法推进了一步:内联的除了数据还有内核,所以页面不仅能显示,还能推进状态。
| 维度 | 卷一 12.3 单页预览 | 本节可玩原型 |
|---|---|---|
| 内联内容 | 文本表(静态) | 规格 + 内核(可执行) |
| 可否交互 | 否 | 是(点击推进) |
| 断言对象 | 行数 / 键指纹 | 节点 / 可用选项数 / 终局摘要 |
| 共享纪律 | 产物级零外部依赖自检 | 同左 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 零外部依赖 | 产物内 <script src> 与 http(s):// 计数均为 0(脚本内断言) |
| 零构建步骤 | 打开 web/dist/ne09.html 即为终态,无需服务器 |
| 单一脚本入口 | 恰好一个 <script type="module">,无其它脚本标签 |
| 复用打包器 | 调用 web/pack.mjs 的 bundle/page/writePage/inlineConfig,不自写擦除逻辑 |
| 内核同源 | 内联的是 core/src/inkwell/engine.ts,非重写副本 |
下一节把这页原型的界面讲清楚:选择按钮、当前台词、以及状态显示区怎么挂稳定的 data-* 选择器契约,供无头浏览器注入与断言。
阅读导航:上一节:8.3 好感度的不变量 · 下一节:9.2 选择界面与状态显示 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。