第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

四条边界值得单独说明,因为它们每一条都对应一次真实的体积或耦合事故:

  1. game-kit 只依赖 content,且只用类型与 ids 常量。 契约层必须足够轻,才能被所有包引用而不引入副作用。
  2. progress 与 platform 不 import Phaser。 所以存档、奖励、衰减这些规则可以跑在 Node 环境的 Vitest 里,不需要浏览器与 canvas。
  3. apps/site 的依赖列表里没有 @bamboo/templates。 外壳根本不认识模板,它只认识 games/daily 暴露的十个动态 import 子路径——这条约束保证了「外壳永远不加载 Phaser」不是靠自觉,而是靠依赖图。
  4. 没有环。 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 打开手机尺寸的页面,再到无头直达与门禁命令。


← 上一章 目录 下一章 →