附录 A 环境与复现速查

卷一的复现手册:两个 python3(/opt/homebrew/bin/python3 3.14.6 与系统 3.9.6)的区别与陷阱、Node 26 原生跑 TypeScript 的 strip-only 约束、Playwright 复用写法(NODE_PATH 对 ESM import 无效)、pebble 目录结构与全部实跑命令,以及一份可一键运行的环境自检脚本。数字对不上时先看这一页。

这一页解决一个问题:书里的代码怎么在我自己的机器上跑起来,跑出来的数字为什么可能和书上不完全一样。

一、本机实测环境

这套书的环境前提是实测过的,不是「一般来说」:

项实测结果说明
Python(S 层)3.14.6,/opt/homebrew/bin/python3S 层仿真用这个
numpy2.5.1数组与确定性随机数
pandas3.0.3CSV 与数据表
matplotlib未安装所以图表走纯 Python SVG
scipy未安装拟合用 numpy 手写
Python(系统)3.9.6,/usr/bin/python3无 numpy/pandas,只跑最朴素的脚本
Node26.5.0P 层内核,原生跑 TypeScript
tsc / tsx / vitest均未安装所以不用它们
Playwright1.59.1,Chromium 已缓存W 层无头验收

环境说明:在已经准备这些依赖的机器上无需再次安装。 其他机器必须先准备符合基线的运行时与依赖;本机的绝对路径不是跨平台要求。 Python 有 numpy 和 pandas 就够了,Node 直接跑 TypeScript。没有 package.json,没有 npm install,没有 tsconfig.json。

网页原型共用构建文件(本次补齐)

正文多个 web/build_*.mjs 引用了 web/pack.mjs,旧稿没有交付该文件。把下面完整文件写入工作区,之后再执行各章构建命令。它使用 Node 官方类型擦除 API,不需要额外包;但只处理章节显式列出的简单本地模块,不能当作通用 bundler,也不做类型检查。依赖文件必须先列在使用者前;别名导入、动态导入与模块作用域冲突需要显式处理。

// file: web/pack.mjs
// tier: W
// verify: node web/pack.mjs
// 最小静态拼接器;只支持正文显式列出的、无别名的本地模块依赖。
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { stripTypeScriptTypes } from "node:module";

export function bundle(root, files) {
  const pieces = [];
  for (const name of files) {
    const path = resolve(root, name);
    if (!path.startsWith(resolve(root) + "/")) throw new Error("path outside project");
    let js = stripTypeScriptTypes(readFileSync(path, "utf8"), { mode: "strip" });
    // Module imports are replaced by the preceding source in the explicit list.
    js = js.replace(/^import\s+[\s\S]*?\sfrom\s+["'][^"']+["'];?\s*$/gm, "")
      .replace(/^export\s*\{[^}]*\};?\s*$/gm, "")
      .replace(/\bexport\s+(?=(?:function|class|const|let|var)\b)/g, "");
    if (/^\s*(?:import|export)\b/m.test(js)) throw new Error("unsupported module syntax: " + name);
    pieces.push(js);
  }
  return pieces.join("\n");
}
export function inlineConfig(value) {
  return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029");
}
function escape(value) {
  return String(value).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
export function page({title, body, script, css=""}) {
  // body/script/css are authored source, not untrusted user content.
  return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)}</title><style>
  *{box-sizing:border-box}body{margin:0;padding:24px;background:#0b1220;color:#e2e8f0;font:16px system-ui;max-width:960px;margin-inline:auto}h1{font-size:26px}.sub{color:#94a3b8;margin-bottom:18px}.card{padding:16px;margin-block:12px;background:#172033;border-radius:12px}.row{display:flex;gap:12px;flex-wrap:wrap}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}.big{font-size:28px;font-weight:700}button{padding:10px 16px;border:0;border-radius:8px;background:#38bdf8;color:#0b1220;cursor:pointer}canvas{max-width:100%;display:block}input{max-width:100%}${css}</style><body>${body}<script>(() => {${script}\n})();</script></body></html>`;
}
export function writePage(path, html) {
  mkdirSync(dirname(path), { recursive:true });
  writeFileSync(path, html, "utf8");
  return Buffer.byteLength(html, "utf8");
}

单独运行该文件仅检查可加载性;原型内容须运行对应构建脚本和浏览器验证。inlineConfig 会转义 <,避免配置字符串提前结束 script 元素;页面外壳把脚本包在函数作用域内,避免导出的 top 等名字与 Window 的内置只读属性冲突;需要给验收器访问的 API 必须显式挂到 globalThis。页面外壳只接收可信作者代码。真实平台宿主和审核结果仍属于 X 层。

二、两个 python3:区别与陷阱

这是本套最容易踩的坑。本机同时存在两个 python3,它们不是同一个东西:

路径版本带 numpy/pandas?用途
/opt/homebrew/bin/python33.14.6是S 层仿真一律用它
/usr/bin/python33.9.6否只跑最朴素的脚本

陷阱在于:如果你在正文里写 python3 sim/xxx.py,而 shell 的 PATH 恰好先命中系统那个 3.9.6,脚本会在 import pandas 处报 ModuleNotFoundError,看起来像「代码错了」,其实是解释器错了。所以本套所有命令一律写全路径 /opt/homebrew/bin/python3。

顺带一提,/opt/homebrew/bin/python3 本身是一个符号链接,os.path.realpath() 会解析到 .../Cellar/python@3.14/3.14.6/.../python3.14。写全路径时用 /opt/homebrew/bin/python3 这个稳定入口即可,不要写 Cellar 里的真实路径——升级补丁版本时它就会变。

下面这段 S 层脚本会替你把两个运行时都确认一遍:

# file: sim/ch00_env_check.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch00_env_check.py
"""环境自检:确认《小游戏开发实战》三卷需要的运行时都在,并打印 pebble 的能力阶梯。

本机有两个 python3,不要混:
  /opt/homebrew/bin/python3 = 3.14.6(带 numpy / pandas / PIL),S 层仿真一律用它;
  /usr/bin/python3          = 3.9.6(系统自带,无 numpy/pandas),只能跑最朴素的脚本。
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys

EXPECT_PY = "/opt/homebrew/bin/python3"


def main() -> int:
    ok = True

    # /opt/homebrew/bin/python3 是符号链接,比较时解析到真实二进制再判断
    real = os.path.realpath(sys.executable)
    print(f"python     {sys.version.split()[0]}  ({real})")
    if real != os.path.realpath(EXPECT_PY):
        print(f"           注意:正文脚本一律用 {EXPECT_PY},当前解释器不同")

    try:
        import numpy
        import pandas

        print(f"numpy      {numpy.__version__}")
        print(f"pandas     {pandas.__version__}")
    except ImportError as e:  # noqa: BLE001
        ok = False
        print(f"FAIL 缺依赖:{e}(S 层需要 numpy 与 pandas)")

    node = shutil.which("node")
    if not node:
        ok = False
        print("FAIL node 未安装(P 层需要 Node 26 或更高)")
    else:
        ver = subprocess.run([node, "-v"], capture_output=True, text=True).stdout.strip()
        major = int(ver.lstrip("v").split(".")[0])
        note = "" if major >= 26 else "  (低于 26,原生 TypeScript 不可用)"
        print(f"node       {ver}{note}")
        ok = ok and major >= 26

    print("pebble 能力阶梯:S 仿真 / P 内核 / W 网页 / D 静态 / X 不可复现 / B 基准")
    print("OK 环境就绪" if ok else "FAIL 环境不满足")
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑输出:

python     3.14.6  (/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/bin/python3.14)
numpy      2.5.1
pandas     3.0.3
node       v26.5.0
pebble 能力阶梯:S 仿真 / P 内核 / W 网页 / D 静态 / X 不可复现 / B 基准
OK 环境就绪

三、Node 26 原生跑 TypeScript

Node 26 内置 TypeScript 类型擦除(strip-only),.ts 文件可以直接 node x.ts,断言用内置的 node --test。但擦除器只认「可以整体删掉、不改变运行语义」的语法,所以下面这些一律禁用:

  • enum(要改用 const 对象 + 联合类型);
  • namespace(要改用普通模块);
  • 构造函数参数属性(constructor(private x: number));
  • 装饰器。

这些语法需要在运行时保留元数据,strip-only 擦不掉,会直接报错。本套的 core/ 代码因此全部只用可擦除语法。

第二个坑:node --test 传目录不发现 .ts 文件。 node --test core/test/ 会报找不到模块;必须用 glob:

# snippet: P 层断言的正确写法(必须用 glob)
node --test 'core/test/*.test.ts'

本机实跑 node --test 'core/test/*.test.ts' 的结果是 5 个测试全部通过(pass 5 / fail 0),断言内容包括「5 级产出 = 20」「留存第 30 天 = 8」等——它们同时是 S 层与 W 层的对照基准。

四、Playwright 复用写法

W 层用 Playwright 无头打开单文件 HTML、采样 globalThis.__pebble、检查 canvas 真画了东西、截图存档。本机 Playwright 装在 /opt/homebrew/lib/node_modules,不在项目里。复用它的正确写法是 NODE_PATH + createRequire:

// snippet: web/verify.mjs 的 Playwright 复用写法
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright"); // 必须走 require

关键陷阱:NODE_PATH 对 ESM import 无效。 如果你写 import { chromium } from "playwright",即使设了 NODE_PATH=/opt/homebrew/lib/node_modules 也会失败——ESM 的模块解析不读 NODE_PATH。必须用 createRequire(import.meta.url) 之后 require("playwright")。运行命令:

# snippet: W 层验收命令
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify.mjs web/dist/index.html

本机实跑输出(canvas 采样到 15926 个非背景像素,说明确实画上了东西):

{
  "page": ".../pebble/web/dist/index.html",
  "probeLevel": 5,
  "outputAtProbe": 20,
  "domText": "20",
  "canvasPaintedPx": 15926,
  "screenshot": ".../pebble/out/shots/prototype.png"
}
SMOKE3/4/5: PASS

五、pebble/ 目录结构与复现步骤

pebble/
  config/      # 唯一真源(全整数,禁浮点)
    economy.json      ch00_budget.json      ch00_schema.json  …
  sim/         # Python:读 config 跑仿真,产出 JSON + CSV + SVG
    ch00_env_check.py      ch00_constraint_budget.py      ch00_validate_config.py  …
  core/        # TypeScript:读 config 跑逻辑内核
    src/   economy.ts  index.ts  ch00_two_hosts.ts  …
    test/  economy.test.ts  …
  web/         # 单文件 HTML 原型与无头验收
    pack.mjs   build.mjs   verify.mjs   dist/index.html
  out/         # 仿真与原型产物:JSON / CSV / SVG / 截图

复现任意一节的步骤都一样:把该节的 file: 块还原到 pebble/<file>(本套工作区已就绪),然后跑它 verify: 里的那条命令。 具体地:

# snippet: 一键复现三步
cd ~/.mgbook/pebble
/opt/homebrew/bin/python3 sim/ch00_env_check.py            # 先自检环境
/opt/homebrew/bin/python3 sim/ch00_constraint_budget.py --config config/ --out out/
node --test 'core/test/*.test.ts'

六、数字对不上怎么办

按这个顺序排查:

  1. 配置被改过吗? 这正是「一份 config 三种消费」该有的行为。改 ch00_budget.json 里的 atlasAssetKB,首包能放的贴图数就会变,书上写的是某个具体值时的结果。
  2. 解释器对吗? 用了系统 3.9.6 会在 import pandas 处失败,见第二节。
  3. node 版本对吗? 低于 26 就没有原生 TypeScript,.ts 直接跑不起来。
  4. 是不是「同机相对量级」? 正文标了「同机相对量级,绝对数值不可移植」的地方,绝对值本来就依赖机器,只有相对关系可移植。
  5. 还不对,那就是书错了。 请告诉我具体的节号和数字。

两条纪律复现时也要守:不编造平台后台数字(真实平台只作「公开可观察机制」引用并标注观察时点);所有数字来自本机实跑,不写「提升 N 倍」式的绝对话。

配置表的字段字典与完整 schema 见附录 B 。回到目录页 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制