第02章 - 技术栈与工程结构
2.1 版本基线
所有版本号都写在各级 package.json 里,下面这张表是仓库当前的实测值。它同时也是排障的起点:很多「在我机器上能跑」的问题,最后都归结为某一个包的次版本号不同。
| 依赖 | 实际版本 | 说明 |
|---|---|---|
| Node.js | 24 | 根 package.json 未声明 engines,但 Vite 8 与 Playwright 1.64 都要求 20+;README 以 24 为基线 |
| pnpm | 11.22.0 | packageManager 字段锁死,Corepack 会按它选版本 |
| phaser | 4.2.1 | 通过 alias 换成裁剪版 vendor 文件,npm 包只提供类型 |
| vue | 3.5.44 | SFC + Composition API |
| pinia | 4.0.3 | 三个 store:progress / settings / locale |
| vite | 8.3.4 | 构建器,使用 rolldown 形态的 rolldownOptions |
| @vitejs/plugin-vue | 6.0.10 | SFC 编译 |
| tailwindcss | 4.3.3 | 配合 @tailwindcss/vite,无 PostCSS 配置 |
| typescript | ~5.9.3 | 刻意不用 TS 7,原因见下 |
| vue-tsc | 3.3.13 | pnpm typecheck 的执行者 |
| eslint | 10.12.0 | flat config |
| typescript-eslint | 8.71.2 | 与 eslint 10 配套 |
| eslint-plugin-vue | 10.11.1 | .vue 规则 |
| zod | 4.6.7 | 只出现在 @bamboo/content/full、模板 schema 与单测里 |
| vitest | 5.0.4 | Node 环境,environment: 'node' |
| @playwright/test | 1.64.x | Pixel 5 设备模拟 |
| tsx | 4.23.15 | pnpm content:check 直接跑 TS 源码 |
| @tauri-apps/cli | 2.12.2 | 桌面壳 |
| @capacitor/* | 8.5.3 | 安卓壳;TTS 插件 8.0.2 |
版本表只记录了「现在是什么」。真正决定可复现性的是 pnpm-lock.yaml:它把每一个传递依赖的解析结果都钉死,包括 Phaser 依赖的若干内部包。日常开发里最常见的两种破坏方式是「手动删掉 lockfile 重装」和「用 npm 装了某个工具」——前者会让所有间接依赖漂到最新,后者的 node_modules 结构与 pnpm 的符号链接布局不兼容,往往表现为某个包「明明装了却 import 不到」。
因此仓库里只有一种安装方式:
pnpm install --frozen-lockfile # CI 与发布用:lockfile 与 package.json 不一致就直接失败
pnpm install # 本地加依赖时用:允许更新 lockfile
版本升级也建议按「一次一个包」的节奏做:升级后立刻跑 pnpm typecheck && pnpm test && pnpm build,不要攒着一起升。第 16 章会给出几个已知的版本雷区(TS 7、Phaser 裁剪、Tailwind 4 的 oxide 原生模块)。
为什么 TypeScript 锁在 5.9
TypeScript 7 移除了 ./lib/tsc 这个入口,而 vue-tsc 3.x 正是通过它来加载编译器。升级到 TS 7 之后,pnpm typecheck 会在启动阶段直接崩掉,报的是「找不到模块」而不是任何类型错误——这种错误最难排查,因为它看起来像依赖没装好。
CHAGS 在 2.3 节记录了同一次实测,BambooBuddy 直接沿用了结论:typescript 用 ~5.9.3(只允许补丁升级),vue-tsc 用 3.3.x。等 vue-tsc 正式支持 TS 7 之后再整体抬版本,届时只需要改两个 package.json 与一次全量 typecheck。
2.2 pnpm workspace 布局
工作区的定义只有九行:
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
- 'games/*'
allowBuilds:
esbuild: true
'@tailwindcss/oxide': true
sharp: true
三个 glob 对应三类东西:apps/* 是「能启动的壳」(site / desktop / mobile),packages/* 是「被引用的库」,games/* 是「玩法包」。allowBuilds 是 pnpm 11 对安装期脚本的白名单——esbuild、Tailwind 的 oxide 原生模块、sharp(图标生成用)需要执行 postinstall,其余一律不执行。这条白名单本身就是一道供应链防护。
三个 glob 之外还有一条隐含规则:目录名即包名。apps/site 的包名是 @bamboo/site,packages/game-kit 是 @bamboo/game-kit,games/daily 是 @bamboo/game-daily。包名与路径不要求完全一致(games/daily 的包名多了 game- 前缀,因为 @bamboo/daily 太泛),但命名空间统一用 @bamboo/,这样在任何一个 import 语句里都能一眼看出这是工作区内部包,而不是 npm 上的第三方依赖。
根 package.json 不产出任何代码,只做两件事:声明公共 devDependencies,以及把命令转发给具体的包:
{
"dev": "pnpm --filter @bamboo/site dev",
"build": "pnpm --filter @bamboo/site build",
"preview": "pnpm --filter @bamboo/site preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"content:check": "tsx packages/templates/src/cli/check-content.ts",
"e2e:dist": "pnpm build && playwright test --config playwright.preview.config.ts",
"verify": "node scripts/verify.mjs"
}
注意 typecheck 与 content:check 这两条:它们不在某一个子包里,而是全仓一条命令。这是有意的——如果一个包可以「只在自己的目录里 typecheck 通过」,跨包的契约错误就会漏到运行时。
这些脚本可以分为四组,理解分组比记住每一条更有用:开发(dev / build / preview)、门禁(typecheck / lint / test / content:check / pack:check / size / verify)、端到端(e2e / e2e:dist)、打包(dist:win / dist:apk / icons / keystore)。其中 verify 是门禁组的编排者,第 3 章会逐阶段拆开;dist:* 是发布组的入口,第 15 章会讲清两个原生壳各自的产物。
另外,build 只构建 @bamboo/site。桌面壳与安卓壳都不自己打包前端,它们读的是同一个 apps/site/dist——所以「一次构建、三个平台」在这里不是口号,是 package.json 里写着的依赖关系。
还有一个容易被忽略的设计:所有包都不构建。每个 packages/*/package.json 的 exports 直接指向 .ts 源码:
{
"name": "@bamboo/content",
"exports": {
".": "./src/index.ts",
"./full": "./src/full.ts",
"./ids": "./src/ids.ts"
}
}
Vite 直接吃 TS 源码,vitest 也是;没有 tsc --build、没有 dist、没有「改了源码忘了重新构建库」这类问题。代价是发布到 npm 不可行(这个仓库也没打算发布),收益是开发期的反馈循环极短。
还有一个物理层面的细节值得知道:pnpm 把所有包的真实文件放在仓库根的 node_modules/.pnpm/ 下,各包的 node_modules 里只有符号链接。这带来两个好处——磁盘占用小、同一个版本的 Phaser 只存在一份;也带来一个排障常识:看到 node_modules/.pnpm 里有一堆哈希目录是正常的,不要手动清理它,清理后重装往往比修复更快。
新增一个包时,标准动作只有三步:在 packages/<name>/package.json 里写 name 与 exports;在使用方的 dependencies 里加 "@bamboo/<name>": "workspace:*";在根 pnpm install 让 pnpm 建链接。workspace:* 是 pnpm 的工作区协议,任何「忘记加依赖但能 import 到」的错觉都来自提升(hoisting)——在本仓库里它通常意味着依赖声明漏了,而不是运气好。
2.3 每个包的职责与依赖方向
| 包 | 一句话职责 | 直接依赖 |
|---|---|---|
@bamboo/game-kit |
契约层:GameModule / GameContext / 事件表 / 存档类型 |
content |
@bamboo/content |
10 条摘要 + 10 份详情 + loadTask() + zod schema |
zod |
@bamboo/i18n |
界面文案表(中文常驻、英文独立 chunk)+ 取词器 | content |
@bamboo/progress |
纯 TS 数值:推荐、状态、奖励、成长、亲密度、存档迁移 | content、game-kit |
@bamboo/platform |
Web 实现:IndexedDB、合成音效、TTS、生命周期、telemetry | game-kit、progress |
@bamboo/phaser-kit |
引擎工具:主题、绘制、竹竹、拖拽、BaseScene、裁剪版 Phaser |
game-kit、i18n、phaser |
@bamboo/templates |
8 个模板:*.logic.ts 判定 + *.ts 场景 + 关卡 schema |
content、game-kit、i18n、phaser-kit、phaser、zod |
@bamboo/game-daily |
10 个玩法入口,一个任务一个子路径导出 | content、game-kit、i18n、phaser-kit、templates、phaser |
@bamboo/site |
Vue 外壳:六个页面 + 玩法宿主 | content、game-daily、game-kit、i18n、platform、progress、pinia、vue |
九个包听起来比「一个 src 目录」复杂得多,但这里的分包不是按技术分层切的,而是按变化原因切的:内容会因为新增任务而变,模板会因为交互改版而变,数值会因为平衡性而变,外壳会因为页面改版而变。四类变化互不相干,各归其位之后,一次改动的影响面就是可控的——改 packages/progress 不可能顺手改坏一个 Phaser 场景,因为它根本 import 不到。
另一个现实理由是并行开发与长期维护:改动越局部,冲突越少。packages/templates/src/scrub.ts 与 apps/site/src/views/HomeView.vue 分属两个包,它们的修改不会互相影响;反过来,如果全部塞进一个 src,一次「调整刷牙手感」的提交就可能顺手碰到小屋的渲染代码。
依赖方向可以画成一张单向图:
apps/site
├─ @bamboo/content ────────┐
├─ @bamboo/i18n ───────────┤
├─ @bamboo/progress ───────┤
├─ @bamboo/platform ───────┤
├─ @bamboo/game-kit ───────┤
└─ @bamboo/game-daily │
└─ @bamboo/templates │
├─ @bamboo/phaser-kit ── phaser
├─ @bamboo/game-kit
├─ @bamboo/i18n
└─ @bamboo/content
四条边界值得单独说明,因为它们每一条都对应一次真实的体积或耦合事故:
game-kit只依赖content,且只用类型与ids常量。 契约层必须足够轻,才能被所有包引用而不引入副作用。progress与platform不 import Phaser。 所以存档、奖励、衰减这些规则可以跑在 Node 环境的 Vitest 里,不需要浏览器与 canvas。apps/site的依赖列表里没有@bamboo/templates。 外壳根本不认识模板,它只认识games/daily暴露的十个动态 import 子路径——这条约束保证了「外壳永远不加载 Phaser」不是靠自觉,而是靠依赖图。- 没有环。
content不知道progress存在,progress不知道platform存在,templates不知道自己被哪个 game 包使用。
这四条边界不是写在文档里的君子协定,而是可以被验证的。验证方法很朴素:打开任意一个 package.json,看 dependencies 里有没有那个包;没有,就不该 import。举个反例:如果某天有人为了复用拖拽逻辑,在 @bamboo/progress 里写了 import { DragController } from '@bamboo/phaser-kit',那么 packages/progress/src/logic.test.ts 会立刻在 Node 环境里崩掉——因为 phaser-kit 顶层就 import 了 Phaser,而 Phaser 需要 window。单测挂掉是好事:它比「打包后 Phaser 出现在首屏」早了几十秒被发现。
同理,如果 @bamboo/game-kit 改成 import @bamboo/templates,整个依赖图会出现环:templates 已经依赖 game-kit。pnpm 不阻止环,但 typecheck 会因为循环类型推断而给出难懂的错误,构建也可能出现未初始化的模块。保持单向是唯一省心的做法。
2.4 apps/site/vite.config.ts
整个文件 56 行,却是全仓最值得逐行读的一份配置:
// apps/site/vite.config.ts(省略 import 与类型参数)
const phaserTrim = fileURLToPath(
new URL('../../packages/phaser-kit/vendor/phaser-trim.esm.js', import.meta.url),
);
export default defineConfig({
base: './',
plugins: [vue(), tailwindcss()],
resolve: {
alias: [{ find: /^phaser$/, replacement: phaserTrim }],
},
server: { host: '127.0.0.1', port: 5190, strictPort: true },
preview: { host: '127.0.0.1', port: 5191, strictPort: true },
build: {
target: 'es2022',
chunkSizeWarningLimit: 1000,
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{ name: 'vendor-phaser', test: /node_modules[\\/]phaser[\\/]|phaser-kit[\\/]vendor[\\/]phaser-trim/ },
],
},
chunkFileNames(chunk) {
const id = chunk.facadeModuleId ?? '';
const game = /[\\/]games[\\/][^\\/]+[\\/]src[\\/]games[\\/]([^\\/]+)\.ts$/.exec(id);
if (game) return 'assets/game-' + game[1] + '-[hash].js';
const detail = /[\\/]content[\\/]src[\\/]details[\\/]([^\\/]+)\.game\.json$/.exec(id);
if (detail) return 'assets/content-' + detail[1] + '-[hash].js';
if (chunk.name.includes('phaser')) return 'assets/vendor-phaser-[hash].js';
return 'assets/[name]-[hash].js';
},
},
},
},
});
这里用的是 Vite 8 的 rolldownOptions,而不是过去常见的 rollupOptions.manualChunks。两种写法的目的相同——把共享依赖切出来——但 codeSplitting.groups 是以「模块匹配规则」为中心表达的:一条 test 正则命中哪些模块,就归到哪个 name 的 chunk 里。对 Phaser 这种「十个玩法都要用、体积又最大」的依赖,这种表达方式比在函数里逐个判断 id.includes(...) 更不容易写错。
为什么一定要把 Phaser 单独切出来?因为 Vite 的默认行为是「动态 import 的模块各自成 chunk,共享依赖按需提取」。十个玩法入口都 import @bamboo/templates/<id>,而模板又 import phaser-kit,如果引擎没有被显式命名成一个共享组,构建器完全可能把它内联进第一个被加载的玩法 chunk。结果是:孩子先玩刷牙,下载一份 876 KB;再玩洗手,又下载一份新的。名字固定成 vendor-phaser 之后,浏览器缓存能一直命中。
逐项解释:
| 配置 | 作用 | 不这么写会怎样 |
|---|---|---|
base: './' |
所有资源用相对路径 | 部署到 /bamboobuddy/ 这类子路径时白屏;本次博客的线上试玩就是靠它直接放子目录 |
resolve.alias 的 /^phaser$/ |
只替换裸导入 phaser |
用字符串匹配会误伤 phaser-kit 之类的路径;正则锚定 ^$ 才不会 |
strictPort: true |
端口被占用时直接报错 | Vite 默认会悄悄换 5191、5192,端到端测试与文档里的地址全部失效 |
target: 'es2022' |
允许 top-level await、类字段等 | 目标过低会把现代语法降级成一大坨 helper,反而变大 |
chunkSizeWarningLimit: 1000 |
告警线贴着裁剪后的 Phaser | 默认 500 KB 会让每次构建都刷一条「Phaser 太大」的噪音告警,真问题被淹没 |
codeSplitting.groups |
把 Phaser 单独切一个共享 chunk | 不切的话每个玩法 chunk 都会内联一份引擎,换一个玩法就要重下 876 KB |
chunkFileNames 的四条分支解决的是可读性问题。默认产物全叫 index-<hash>.js,一旦体积门禁报「最大的懒加载 chunk 超预算」,你只能靠猜。现在文件名自己会说话:
| 分支 | 产物示例 | 含义 |
|---|---|---|
| 玩法入口 | assets/game-brush-Bu460FZL.js |
刷牙这一局的入口,含 summary 与场景编排 |
| 内容详情 | assets/content-brush-LCxE43am.js |
刷牙的关卡脚本 + 语音文案(动态 import) |
| Phaser | assets/vendor-phaser-zPpKQHLK.js |
裁剪版引擎,十个玩法共享 |
| 兜底 | assets/PlayView-HT4hIFE2.js |
其余按原名 + hash |
哈希后缀也不是装饰。每个文件名里的 [hash] 由内容决定:改一行业务代码,对应 chunk 的名字就变,浏览器会重新下载;没改的 chunk 名字不变,继续命中缓存。端到端里那条「首屏不下载重 chunk」的断言之所以稳定,靠的就是「首屏引用的文件名是确定的、且不以 vendor-phaser- 开头」。如果所有产物都叫 index-[hash].js,这条断言就只能靠读文件大小去猜。
这些文件名不是示意,就是 apps/site/dist/assets/ 里的真实产物。第 15 章讲打包时会再用到它们。
2.5 TypeScript 与 ESLint 约束
tsconfig:一条 strict,加三条「防退化」
根 tsconfig.json 只有一份,覆盖 packages/*/src、games/*/src、apps/site/src、e2e 与所有 *.config.ts。关键项:
{
"compilerOptions": {
"target": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitOverride": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"paths": {
"@bamboo/content/full": ["./packages/content/src/full.ts"],
"@bamboo/templates/layout": ["./packages/templates/src/layout.ts"]
}
}
}
noUnusedLocals 与 noUnusedParameters 看起来只是洁癖,实际上在重构时非常好用:删掉一个模板分支后忘记删的变量会立刻变成红色波浪线。verbatimModuleSyntax 强制 import type 与值导入分开写,好处是编译产物里不会留下只为类型存在的 import——这在「game-kit 的契约不能把 content 的运行时拖进游戏 chunk」这件事上是实质保障。
paths 里那两条是给 Playwright 用的:e2e/shared.ts 直接 import @bamboo/templates/layout 的 DESIGN 做坐标换算,而这个文件不 import Phaser(第 7 章会解释原因)。
为什么必须用 vue-tsc 而不是 tsc:.vue 文件在 tsc 眼里是不认识的后缀,apps/site/src/views/*.vue 里的 defineProps 类型、模板里的属性访问、stores 的返回值推断全部不会被检查。vue-tsc --noEmit -p tsconfig.json 会先编译 SFC 再交给 TypeScript,所以「模板里写错一个 store 字段」也能在命令行阶段报出来。
这条命令还有一个隐性收益:它把 e2e/**/*.ts 也纳入了 include。Playwright 的用例里有大量 SaveData 字段访问(save.tasks[id].completions 之类),如果存档结构改了而用例没改,typecheck 会和单测一起红,而不是等到跑完两分钟端到端才失败。
ESLint 与 typecheck 的分工也值得说明:ESLint 管「不该出现的写法」(禁 import、禁隐式时钟、Vue 模板风格),typecheck 管「类型对不对」。两者都不负责「行为对不对」——那是 91 个单测与 20 条端到端的事。
ESLint:两条硬规则
eslint.config.js 是 flat config,主体是 js.configs.recommended + tseslint.configs.recommended + pluginVue.configs['flat/recommended']。真正体现项目意志的是两个覆盖块:
// ① 外壳只吃摘要
{
files: ['apps/site/src/**/*.{ts,vue}'],
rules: {
'no-restricted-imports': ['error', {
paths: [{
name: '@bamboo/content/full',
message: '外壳不许直接引入完整内容:用 @bamboo/content 的 loadTask()(动态 import)。',
}],
}],
},
},
// ② 纯逻辑层必须可复现
{
files: ['packages/progress/src/**/*.ts', '**/*.logic.ts'],
rules: {
'no-restricted-properties': ['error',
{ object: 'Math', property: 'random', message: '纯逻辑层禁止 Math.random,请注入种子。' },
{ object: 'Date', property: 'now', message: '纯逻辑层禁止 Date.now,请注入 now。' },
],
},
},
第一条拦的是体积:一句 import { taskDetails } from '@bamboo/content/full' 就会把 zod 与十份详情 JSON 全部拖进首屏,而体积门禁要到 pnpm build 之后才发现——那时已经写完几十行代码了。所以在写的当下就报错。
第二条拦的是可测试性:如果 daily.ts 里出现 Date.now(),「今天的推荐」就永远无法在单测里复现;如果 rhythm.logic.ts 里出现 Math.random(),「这一局能不能过」就成了概率问题。两个禁止项把时间与随机性都推到参数上,调用方(外壳或场景)负责注入。
写规则时还有一个取舍:这些禁令只覆盖 apps/site/src 与纯逻辑层,而不是全仓。e2e/、scripts/ 与内容校验 CLI 可以自由使用 Date.now() 与 Math.random()——它们的职责就是「在真实时间里跑一遍」,限制它们没有意义。规则的作用域越精确,越不容易被后来的开发者用 eslint-disable 绕开。
还有一处容易被忽略的 ignores:packages/phaser-kit/vendor/**、apps/desktop/src-tauri/target/**、apps/mobile/android/**。这些不是我们的代码,lint 它们只会制造噪音。
2.6 内容拆两半的工程含义
@bamboo/content 有三个入口,不是随意拆的:
| 入口 | 内容 | 谁用 | 为什么要独立 |
|---|---|---|---|
@bamboo/content |
TASK_IDS 等常量 + 摘要表 + loadTask() |
外壳、progress、templates | 首屏只需要摘要,不能把 zod 拖进来 |
@bamboo/content/ids |
纯常量:TASK_IDS / TEMPLATE_IDS / STAT_KEYS / LOCALES |
progress 的运行时校验、e2e/shared.ts |
连摘要 JSON 都不需要,只要 id 联合类型 |
@bamboo/content/full |
zod schema + 全部详情 | 内容校验 CLI、单测 | Node 侧才用得上的重东西 |
ids.ts 的文件头注释把原因写得很直白:
/**
* 不含 zod、不含任何 JSON 的常量层。
*
* 为什么单独一个入口:progress 包的纯逻辑要用 TASK_IDS / STAT_KEYS 做运行时校验,
* 如果从 '@bamboo/content' 主入口取,就会把 zod 与十条内容的摘要一起拖进单测与首屏。
*/
拆分的实际收益是可测量的:assets/content-brush-*.js 这类详情 chunk 每条只有几 KB,而首屏的 assets/index-*.js 加 CSS 合计维持在 56.8 KB gzip。反过来,如果摘要也塞进详情、或者详情静态进主包,首屏会立刻涨到 100 KB 以上,直接顶穿预算。
这条拆分在工程上有三个可验证的后果。第一,content:check 能跑在 Node 里而不需要浏览器:它 import 的是 @bamboo/content/full,zod 与十份 JSON 都在这一侧,慢一点没关系。第二,外壳的 pnpm build 产物里,详情 JSON 各自成一个 content-<id>-*.js,与玩法入口一一对应,体积报告里能直接看到「哪一关的脚本最大」。第三,ensureSummaries('en') 只在切换语言时触发一次,英文摘要与英文详情是两份独立 chunk,中文用户一份都不下载。
要判断一次改动有没有破坏这条边界,最直接的办法是构建后看 apps/site/dist/index.html 引用了哪些文件。如果里面出现了 content- 或 en- 开头的脚本,说明某处把懒加载写成了静态 import——通常就是那句被 ESLint 禁止的 @bamboo/content/full。
顺带解释一下 ids 入口里为什么连 SLOT_LABELS 这种小常量都放:任务卡要显示「早上 / 中午 / 晚上」,家长页也要显示同样的三个词。如果每个页面各写一份,改文案时必然漏掉一处。放在 ids.ts 的好处是它与 TimeSlot 类型同源,Record<TimeSlot, string> 少一个键就编译不过;代价是它属于「内容」而非「代码」,英文版因此另有一份 SLOT_LABELS_EN,由 i18n 的 time.* 键接管界面显示。
「摘要 / 详情」的边界也不是随口定的,它对应一句产品判断:小屋、任务、装扮、日记四个页面渲染卡片时,只需要标题、emoji、颜色、时段、收益、习惯、日记一行;关卡脚本与语音文案只有真的进入玩法才需要。 这条判断写进了 schema.ts 的两个类型:TaskSummary 与 TaskDetailSchema = TaskSummarySchema.extend({ intro, done, retry, levelScript })。
2.7 目录速查表
改代码之前先定位文件,比通读源码高效得多:
| 路径 | 里面是什么 |
|---|---|
pnpm-workspace.yaml |
三个 glob + 安装期脚本白名单 |
package.json |
根脚本(dev / build / verify / content:check / dist:*) |
tsconfig.json |
全仓唯一一份 TS 配置 |
eslint.config.js |
flat config + 两条项目专属禁令 |
vitest.config.ts |
Node 环境,只跑 *.test.ts |
playwright.config.ts |
开发服务器 5190 + Pixel 5 |
playwright.preview.config.ts |
预览服务器 5191,只跑 preview.spec.ts |
apps/site/vite.config.ts |
别名、端口、chunk 命名 |
packages/game-kit/src/contract.ts |
所有契约与存档类型(第 4 章逐条讲) |
packages/content/src/ids.ts |
任务 / 模板 / 状态 / 语言 / 时段的唯一事实来源 |
packages/templates/src/layout.ts |
750×1334 设计坐标、可点下限、e2e 用的按钮坐标 |
packages/templates/src/schemas.ts |
satisfies Record<TemplateId, ZodType> 的模板注册表 |
apps/site/src/registry.ts |
任务 id → 玩法包的动态 import 表 |
scripts/verify.mjs |
七阶段门禁的编排 |
scripts/check-size.mjs |
七项体积预算与计算口径 |
最后提醒一个读仓库的顺序:先看 pnpm-workspace.yaml 与根 package.json 知道有哪些包和命令,再看 packages/content/src/ids.ts 知道十个任务、八个模板、五个状态、两种语言这些「名词」的枚举值,然后看 packages/game-kit/src/contract.ts 知道它们之间的契约。这三份文件加起来不到 300 行,却定义了后面十四章里出现的所有专有名词。
到这里,工程骨架已经清楚了:谁依赖谁、构建时切哪几个 chunk、哪些写法被 ESLint 禁止。下一章把它跑起来——从装 Node 与 pnpm 开始,到 pnpm dev 打开手机尺寸的页面,再到无头直达与门禁命令。