第03章 - 环境准备与快速开始

3.1 前置条件

跑起来只需要两样东西:Node.js 24 与 pnpm 11.22。其余依赖(Rust、JDK、Android SDK)只在打包原生壳时才需要,日常开发完全不用装。

依赖 版本 是否必需 说明
Node.js 24 必需 Vite 8 与 Playwright 1.64 都要求 20+;仓库以 24 为基线
pnpm 11.22.0 必需 版本写在根 package.json 的 packageManager 字段里
Rust 工具链 — 仅 pnpm dist:win Tauri 2 需要;不装也能跑 pnpm pack:check
JDK + Android SDK — 仅 pnpm dist:apk Capacitor 8 的安卓工程已入库,需要本机能跑 Gradle
WebView2 — 运行时 Windows 10/11 自带;桌面包运行时依赖它

pnpm 的安装方式有两种,效果一样:

# 方式一:用 Node 自带的 Corepack,它会读取 packageManager 字段
corepack enable
corepack prepare pnpm@11.22.0 --activate

# 方式二:全局安装指定版本
npm install -g pnpm@11.22.0

装完先确认版本,版本不对会在下一步以很难懂的方式失败:

node -v      # v24.x
pnpm -v      # 11.22.0

「可选的都不装」是这个项目刻意的开发体验设计。原生壳的依赖链很长(Rust 工具链、MSVC、JDK、Android SDK、签名工具),如果把它们设成跑起来的必要条件,第一次接触仓库的人会在配置环境上耗掉半天,而这半天与「这个游戏到底怎么设计的」毫无关系。所以 packages/templates 与 apps/site 的开发路径被设计成零原生依赖:只要有 Node 与 pnpm,就能改玩法、改界面、跑全部单测;端到端也只需要额外装一次浏览器。

打包配置本身也有一个不需要原生工具的体检入口:pnpm pack:check。它读 tauri.conf.json 与 capacitor.config.ts,检查 frontendDist / webDir 是否指向 apps/site/dist、appId 与 identifier 是否一致、图标文件是否齐备、版本号格式是否合法。这一步被接进了 pnpm verify,所以「打包配置写错了」不需要等到发版那天才发现。

如果你在 Windows 上开发,还有两个小提醒:一是终端建议用 PowerShell 7 或 Windows Terminal,老版 cmd 对 pnpm 的彩色输出与路径处理都不太友好;二是仓库路径不要放在 OneDrive 同步目录里,node_modules 的符号链接在同步盘上经常被改写,表现为「昨天还好好的,今天 import 不到了」。

还有一项不属于「前置依赖」但迟早要装的东西:Playwright 的浏览器。pnpm install 只装 @playwright/test 这个 npm 包,真正跑用例前需要 pnpm exec playwright install chromium 下载浏览器二进制(约一百多 MB)。它放在第一次要跑端到端时装最合适——只做界面与玩法开发的话,不装也不影响 pnpm dev、pnpm test 与 pnpm build。

如果本机同时有多个 Node 版本,推荐用 nvm / fnm 这类版本管理器把 24 固定下来,而不是在每次开新终端时手动切换。Vite 8 对 Node 的最低要求是 20+,Node 22 大概率也能跑,但仓库的验证基线是 24——遇到「别人能跑我不能跑」时,第一件事就是把版本对齐到 24。

3.2 安装与四条命令

pnpm install     # 安装全部工作区依赖
pnpm dev         # 开发服务器 http://127.0.0.1:5190
pnpm build       # 生产构建,产物 apps/site/dist
pnpm preview     # 预览构建产物 http://127.0.0.1:5191

四条命令对应的实际行为值得逐条说清楚,因为它们的失败模式完全不同:

命令 实际执行 产物 / 地址 常见失败
pnpm install 解析 workspaces,链接本地包,执行白名单内的安装脚本 node_modules/.pnpm/ 网络、代理、lockfile 不一致
pnpm dev pnpm --filter @bamboo/site dev → Vite dev server http://127.0.0.1:5190 端口被占用(strictPort 直接退出)
pnpm build pnpm --filter @bamboo/site build → vite build apps/site/dist/ 类型早已在 typecheck 拦住;这里多是资源或 chunk 命名问题
pnpm preview pnpm --filter @bamboo/site preview http://127.0.0.1:5191 忘记先 build,预览的是上一次的产物

第一次跑起来,建议按这个顺序花五分钟走一遍,比直接读代码更快建立直觉:

  1. 打开小屋,等两秒——竹竹会有一个呼吸/眨眼的循环,五条状态条从存档读出来;
  2. 点底部「任务」,看四张推荐卡:它们由当天日期与当前时段决定,同一天刷新不会变;
  3. 点任意一张卡进入玩法,先不要动,听一句语音引导(没有语音包时屏幕上仍有同样的提示);
  4. 故意点错一次,观察反馈:只有一句「再试一次」与一个轻柔音效,进度不倒退;
  5. 玩完一局,看完成卡片的三颗星与「收下 / 再玩一次」两个按钮;
  6. 回到任务页,卡片上出现完成标记;再回小屋,竹笋数字变多了。

这六步就是第 1 章那张核心循环表的可操作版本。如果其中任何一步不对,先别改代码——很可能是存档里有上一轮的旧数据。清档的办法有两个:长按小屋标题进家长页做「清档」(需要再长按 3 秒确认),或者在 DevTools 的 Application 面板里删掉 bamboobuddy 这个 IndexedDB 数据库。

pnpm dev 启动的是 Vite 开发服务器,带 HMR。改 Vue 组件会热替换;改 Phaser 场景建议整页刷新——热替换有时会叠出第二个 canvas,这是第 16 章记录过的坑。

构建产物值得打开看一眼,因为后面很多章节都会引用它的结构:apps/site/dist/index.html 只引用首屏的 index-*.js 与 index-*.css;assets/ 下则是 game-<id>-*.js(十个玩法入口)、content-<id>-*.js(十份关卡详情)、en-*.js(英文文案)与 vendor-phaser-*.js(唯一的引擎 chunk)。如果你在 index.html 里看到 vendor-phaser,说明有代码把 Phaser 静态引入了首屏——这是第 2 章那条 ESLint 规则要拦的情况,通常也意味着体积门禁会红。

打开 http://127.0.0.1:5190 后应该看到小屋:米白底色、一只程序化绘制的竹竹、五条状态条,底部三个大入口(小屋 / 任务 / 装扮),右上角语言与声音按钮。如果看到空白页,先看浏览器控制台——多半是 pnpm install 没跑完。

pnpm build 的产物是这次教程部署的同一份东西:apps/site/dist 下 41 个文件、约 1.19 MB。其中 index.html 引用的 JS 与 CSS 是首屏,其余按玩法与内容切成独立 chunk。把整个 dist 目录原样拷到任意子路径(比如博客的 /bamboobuddy/)就能直接用,因为 vite.config.ts 里写了 base: './'。

3.3 手机尺寸调试

这是一个竖屏游戏,设计分辨率 750×1334。桌面浏览器直接打开会看到很宽的留白,必须切到手机尺寸才有真实体验。

推荐两个尺寸,覆盖主流 iPhone 与安卓机的两端:

尺寸 对应机型 用途
375 × 812 iPhone X / 12 mini 一档 检查最小屏下的按钮高度、文字换行
430 × 932 iPhone 15 Pro Max 一档 检查大屏下的留白与场景缩放

具体做法:打开 DevTools(F12)→ 点设备工具栏(Ctrl+Shift+M)→ 选「Responsive」→ 手动输入宽高;或者选一个自带机型。务必同时确认触摸模拟是开启的(设备工具栏左上角的手形图标或「Touch」开关),否则 pointerdown 会变成鼠标事件,长按、拖拽的手感会失真。

为什么设计分辨率定成 750×1334 而不是「按屏幕实际像素」:这是一个竖屏游戏,750×1334 正好是 375×667 的两倍,几乎所有手机都能整除缩放;坐标写在这个空间里,场景代码不需要关心设备。代价是超长屏(如 20:9)上下会有留白,超宽屏左右会有留白——留白用深色,看起来像是「舞台」,而不是 bug。

如果你在桌面浏览器里看到画面只占中间一小块,那不是缩放失效,而是 FIT 在宽屏上的正常行为:它保证完整的 750×1334 都可见。想让它铺满,把窗口拉成竖长条即可。

Phaser 的缩放策略是 Phaser.Scale.FIT + CENTER_BOTH:画面等比缩放到容器内,四周留黑边(实际是 THEME.ink 的深色)。这条策略的意义是——设计坐标永远是 750×1334,场景里写死的位置不需要适配任何机型;适配的责任全部交给 Phaser 的 ScaleManager。

端到端测试用的是 Playwright 的 devices['Pixel 5'],它同时设置了视口、DPR、触摸与移动端 UA。所以「在 DevTools 里看着对」和「e2e 能过」是两件独立的事:前者靠肉眼,后者靠设备配置。

3.4 无头直达:三个 URL 开关

开发与自动化最常用的能力是「不点任何东西,直接进到某一局的某个状态」。BambooBuddy 用三个 query 参数实现:

参数 作用 典型用途
autoplay=1 场景自己模拟输入,把这一局走完 端到端冒烟、批量验证十个玩法都能通关
instant=1 跳过过场、语音等待与完成卡片的停留 截图巡检、视觉回归
lang=en 直接指定语言(en / en-US / zh / zh-CN 都认) 分享英文链接、双语用例

组合方式有讲究:参数可以挂在 search 上,也可以挂在 hash 的 query 上。

http://127.0.0.1:5190/#/play/brush?autoplay=1          # 推荐:链接干净,刷新 hash 原样保留
http://127.0.0.1:5190/?autoplay=1#/play/brush          # 也支持:参数在 search 里
http://127.0.0.1:5190/?lang=en#/play/feed              # 英文界面 + 英文关卡
http://127.0.0.1:5190/#/play/sleep?autoplay=1&instant=1

路由解析会先按 ? 截断再匹配任务 id,所以 brush?autoplay=1 不会被当成一个叫 brush?autoplay=1 的任务:

// apps/site/src/router.ts(节选)
const clean = hash.split('?')[0] ?? hash;
const parts = clean.replace(/^#\/?/, '').split('/').filter(Boolean);
if (parts[0] === 'play') {
  const taskId = asTaskId(parts[1]);
  return taskId ? { name: 'player', taskId } : { name: 'home' };
}

这三个开关不是「测试专用后门」,而是场景内部的一等公民:autoplay 走的是与真实操作完全相同的处理函数(例如刷洗模板调用 attemptScrub 与 attemptFinish),只是由场景自己按墙钟节奏喂输入。这一点很关键——如果另写一条「自动通关」的分支,那么 e2e 通过只能证明那条分支没坏,证明不了孩子真的能玩通。

instant 则只影响等待:跳过开场旁白、跳过完成卡片在自动退出前的停留。它不改变判定,也不改变奖励。截图巡检用它,是因为等待语音念完会让整批截图慢上几十秒。

调试参数则同时读 search 与 hash query,且 search 优先(家长可能把带参数的链接收藏起来):

// apps/site/src/shell/createGameContext.ts(节选)
function debugAllowed(): boolean {
  if (import.meta.env.DEV) return true;
  return typeof navigator !== 'undefined' && navigator.webdriver === true;
}

export function readDebugOptions(): DebugOptions | undefined {
  if (!debugAllowed()) return undefined;
  const params = new URLSearchParams(window.location.search);
  const hash = window.location.hash;
  const queryAt = hash.indexOf('?');
  if (queryAt >= 0) {
    for (const [key, value] of new URLSearchParams(hash.slice(queryAt + 1))) {
      if (!params.has(key)) params.set(key, value);
    }
  }
  const autoplay = params.get('autoplay') === '1';
  const instant = params.get('instant') === '1';
  if (!autoplay && !instant) return undefined;
  const options: DebugOptions = {};
  if (autoplay) options.autoplay = true;
  if (instant) options.instant = true;
  return options;
}

debugAllowed() 这一层很关键,值得单独说明:生产环境里 ?autoplay=1 是无效的,除非浏览器由自动化工具驱动(navigator.webdriver === true)。否则任何人拿到一个带参数的分享链接,就能自动通关把星星写进存档——对儿童产品来说,这是「进度可以随便刷」的信任问题,不是安全问题。

这三个开关也是端到端用例的地基:每条玩法用例都只是 gotoPlay(page, id) 加上一句「等 task:complete:<id> 事件」。没有它们,测试就只能靠「找一个坐标点一下、再等两秒」这种注定不稳定的写法。反过来说,如果你自己手动验证时发现 autoplay 能过、真实操作过不了,那问题一定在输入链路上(热区、拖拽轨迹、连点节流),而不是判定逻辑——这个区分能省掉大量排查时间。

语言开关的优先级是 ?lang= → localStorage 里的上次选择 → 中文。它同时认 search 与 hash query,并且在切换时先 await 英文 chunk 加载完再换状态,避免出现「界面英文、内容中文」的半截状态。

3.5 pnpm verify 的七个阶段

pnpm verify 是全仓唯一的「完成」定义。它由 scripts/verify.mjs 编排:六个互不依赖的阶段并行跑,第七个(体积)必须等构建结束。

// scripts/verify.mjs(节选)
const STEPS = [
  { name: 'typecheck', command: 'pnpm typecheck' },
  { name: 'lint', command: 'pnpm lint' },
  { name: 'test', command: 'pnpm test' },
  { name: 'content:check', command: 'pnpm content:check' },
  { name: 'pack:check', command: 'pnpm pack:check' },
  { name: 'build', command: 'pnpm build' },
];
// 六个并行;全部成功后单独串行跑 size(它读 dist)

为什么体积必须串行:体积断言的对象是构建产物。如果和 build 并行,读到的可能是上一轮的 dist——那是最坏的一种「假绿」:旧产物不超标,新产物已经超了。同理,并行输出保留各自前缀([typecheck]、[lint]…),挂在哪一步一眼能看出来。

七个阶段各自在防什么:

阶段 命令 拦下的问题
typecheck vue-tsc --noEmit 类型不匹配、模板里写错字段、契约改动漏改调用方
lint eslint . --cache 外壳误 import 完整内容、纯逻辑层用了 Math.random / Date.now
test vitest run 91 个单测:奖励口径、状态衰减、推荐确定性、模板判定、存档迁移
content:check tsx packages/templates/src/cli/check-content.ts zod schema、模板脚本语义、时段覆盖、双语漏翻、伤害性文案
pack:check node scripts/check-packaging.mjs Tauri / Capacitor 配置体检(不装 Rust / JDK 也能跑)
build vite build 构建期错误、chunk 命名规则失效
size node scripts/check-size.mjs 首屏、单 chunk、dist 总量、非代码资源共七项预算

门禁的耗时结构也值得知道:六个并行阶段里最慢的通常是 build,其次是 test;size 在最后串行,只读已经生成的 dist,本身很快。所以「改了一行文案」和「改了一个模板判定」的验证成本差不多,都是等一次全量构建——这也是为什么作者建议先单独跑 pnpm test 定位逻辑问题,最后再跑一次完整的 pnpm verify。

失败时不要急着重跑全部。输出里每一行都带 [阶段名] 前缀,照着重跑那一条即可:pnpm test、pnpm content:check、pnpm size 都是独立的。只有 size 依赖 build 的产物,重跑它之前要先构建。

content:check 是七个阶段里最「产品」的一步,它检查的不只是 JSON 能不能解析:

  • 十条内容都过得了 zod,且 id 与 TASK_IDS 一一对应;
  • 每条的 levelScript 过得了对应模板的 schema;
  • 模板之间的语义:吃饭的均衡目标必须有对应食物、整理的每个玩具必须有箱子、情绪每轮选项必须包含答案且不重复、穿衣槽位不重复、睡前步骤 id 不重复;
  • 早 / 中 / 晚每个时段至少 2 个任务;
  • 八个模板都真的被内容用到;
  • 英文内容零残留中文,且中英标题不相同(相同视为漏翻);
  • 中英文案里都不出现「失败 / 惩罚 / 笨蛋 / 输了 / 淘汰」与「fail / stupid / loser / punish」。

3.6 pnpm e2e 与 pnpm e2e:dist

两个命令跑的是同一批 Playwright 用例,但环境完全不同:

命令 配置 服务器 跑什么 基线
pnpm e2e playwright.config.ts pnpm dev,5190 四套:smoke / flows / parent / language 20 passed / 2 skipped
pnpm e2e:dist playwright.preview.config.ts 先 pnpm build,再 pnpm preview,5191 只有 preview.spec.ts 2 passed

开发配置用 Pixel 5 设备、90 秒超时、CI 上重试 2 次、失败保留 trace 与截图;服务端是 pnpm dev 且本地会复用已开着的服务器(CI 上必须全新)。生产配置只匹配 preview.spec.ts,因为那两条用例断言的是「产物结构」——首屏没有重 chunk、十个玩法各自懒加载、Phaser 只在第一个玩法时才加载。

只想跑一条用例时,Playwright 的原生命令比 pnpm e2e 快得多:pnpm exec playwright test e2e/smoke.spec.ts -g "brush"。注意 -g 匹配的是用例标题,而十条玩法用例的标题里都带任务 id,所以按 id 过滤很稳。跑完先看 playwright-report/(失败时还有 test-results/ 里的 trace),再看 window.__bamboo.events——用例失败时会把最近十条事件与 lastError 一起打进错误信息。

两个跳过项就来自这里:preview.spec.ts 在开发配置下按设计跳过,所以 20 passed / 2 skipped;跑到 e2e:dist 时它们才会真正执行。

四套用例的分工是这样的:smoke.spec.ts 除了一条首页冒烟与一条「刷新后进度还在」,主体是从 TASK_IDS 生成的十条玩法用例——每条都用 autoplay 打完一局,然后断言存档里的完成次数与星星、任务卡上的完成标记;flows.spec.ts 用真实指针走刷牙 / 整理 / 睡觉三条链路,验证「不用 autoplay 也能玩通」;parent.spec.ts 覆盖家长锁、最近七天、清档与刷新后进度;language.spec.ts 验证中英切换、?lang=en 直达与英文玩法。

四条用例共享同一份 e2e/shared.ts:事件等待、存档快照、设计坐标换算、长按工具都在里面。这不是为了少写代码,而是为了让「证据策略」只有一处——超时、trace、截图策略改一次就全局生效。

用例怎么做到「不靠运气点得准」,值得先看一眼。入口统一走 gotoPlay:

// e2e/shared.ts(节选)
export const gotoPlay = (page: Page, id: TaskId, query = 'autoplay=1'): Promise<unknown> =>
  page.goto('/#/play/' + id + (query ? '?' + query : ''));

真实指针用例则通过 toScreen() 把设计坐标换算成屏幕坐标:读 canvas 的 boundingBox,按 min(宽/750, 高/1334) 求缩放,再算 letterbox 偏移。设计坐标本身来自 @bamboo/templates/layout 里的纯常量(这个文件不 import Phaser,所以 Node 侧可以安全引用)。这套换算让「点刷牙的第三个牙点」变成一行可读的断言,而不是一堆魔法数字。

3.7 常见启动问题

现象 原因 处理
pnpm dev 报端口被占用并退出 strictPort: true,5190 被别的进程占了 关掉占用进程;不要改端口,e2e 与文档都按 5190
pnpm preview 打开是旧页面 没先 pnpm build,预览的是上一次的 dist 先 pnpm build 再 pnpm preview
ERR_PNPM_UNSUPPORTED_ENGINE 或奇怪的类型错误 Node 版本不是 24 切回 24 后删除 node_modules 重装
import 得到 undefined,但文件明明存在 依赖没写进 package.json,靠提升碰巧能解析 补 workspace:* 依赖,重新 pnpm install
页面空白、控制台报 Cannot find module pnpm install 未完成或 lockfile 与 package.json 不一致 pnpm install --frozen-lockfile 复现问题
?autoplay=1 没反应 在 pnpm build + pnpm preview 下且不是自动化浏览器 这是设计行为;要用调试开关请跑 pnpm dev
长按标题进不了家长页 桌面浏览器把长按识别成拖拽 / 右键 用触屏模拟或真机;用例里用 longPress() 按住 3.2 秒
无痕模式下进度不保留 IndexedDB 在无痕窗口里可能被禁用或清理 换普通窗口;界面会提示「这次可能没存上」
英文切换后仍有中文 英文 chunk 加载失败(离线 / 资源被清) 设计上会回落中文;检查网络面板里的 en-*.js
Windows 上 pnpm e2e 找不到浏览器 Playwright 浏览器未安装 pnpm exec playwright install chromium

报问题的时候,附上三样东西基本就能定位:node -v 与 pnpm -v 的输出、失败那一条命令的完整日志(带 [阶段名] 前缀)、以及浏览器控制台里的第一条红色错误。这个项目的错误处理有一条原则——给孩子一句人话,给开发与自动化完整细节——所以技术细节一定能在控制台或 window.__bamboo.lastError 里找到,不会凭空消失。

最后一个经验值得单独强调:遇到「本地能跑、CI 不能跑」,先怀疑版本与 lockfile,再怀疑代码。这个仓库的门禁是并行的,任何一步失败都会标出名字;照着名字去重跑那一条,比从头再跑一遍 pnpm verify 快得多。

到这里,项目已经能跑、能玩、能验收。下一章进入架构的核心:三份契约如何定义「一局游戏」的边界,以及一局游戏从挂载到销毁的完整生命周期。


← 上一章 目录 下一章 →