9.1 规格内联进单文件 HTML

把 config/story_spec.json 与 core/src/inkwell/engine.ts 一起内联进一个单文件 HTML,产出 web/dist/ne09.html。本节实测产物 41641 字节、script src 0、外部 http(s) 链接 0,给出零外部依赖的自检口径与双击即玩的最小交付形态。

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 选择界面与状态显示 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长