第16章 - 踩坑记录与常见问题

这一章不按”知识点”组织,而按故障组织:每一条都写清症状、原因、修法,以及”怎么在下次提前发现”。全部来自这个仓库真实踩过的坑,代码位置可以直接跳过去看。

如果只想解决眼前的问题,跳到 16.10 的高频问答。

16.1 TypeScript 钉在 5.9.3

症状:把 typescript 升到 7.x 后,pnpm typecheck(vue-tsc --noEmit)直接崩,报找不到 ./lib/tsc。

原因:vue-tsc 依赖 TypeScript 包内部的文件布局,TS 7 移除了 ./lib/tsc,而 vue-tsc(本项目用 ^3.3.13)还没有跟上。

修法:package.json 里写 "typescript": "~5.9.3"——注意是 ~ 而不是 ^,只允许补丁版本浮动。这不是”保守”,而是用一个已知能工作的组合锁住工具链。

顺带说清”strict 带来的连锁”:全仓 strict 意味着 TaskId 是字面量联合、SaveData 的每个字段都不可选、Record<TaskId, …> 必须穷尽。新增一条任务时那些”缺键”报错不是噪音,而是这套结构在替你检查登记完整性——把它们当成 checklist 的自动执行版本,比记文档可靠。

下次怎么提前发现:这类问题不会被单测抓到,只能在升级依赖时跑一次 pnpm typecheck。所以本项目把 TS 升级当成”需要专门跑一遍全量门禁”的动作,而不是随手 pnpm up。

16.2 Phaser 裁剪:改一行就要重建三样东西

背景:完整版 Phaser 打出来约 347 KB gzip,而十个日常玩法只用到 Scene / GameObjects / Input / Tweens / Time / Cameras / Scale。裁剪版是 packages/phaser-kit/vendor/phaser-trim.esm.js(实测 856.3 KB 原始 / 220.6 KB gzip,B 档约 -36% gzip),由 scripts/build-phaser-trim.mjs 从 Phaser 源码生成。

产物旁边还有两个必须一起提交的文件:phaser-trim.esm.js.LICENSE.txt(webpack / terser 抽取的许可注释)与同目录的 LICENSE.phaser.md。Phaser 是 MIT,MIT 要求保留版权声明——删掉许可文件不是”清理产物”,而是许可问题。packages/phaser-kit/vendor/.version 则记录 Phaser 版本与 entry 哈希,是”跳过重建”的依据,也别手改。

三个约束:

  1. 产物不能手改。文件头写着”本文件由 scripts/build-phaser-trim.mjs 从 phaser 源码生成,请勿手改”,且整份文件有 /* eslint-disable */——手改会在下次重建时被覆盖,lint 也不会帮你发现;
  2. 重建需要 webpack 5,仓库默认不装(pnpm add -Dw webpack@5),只有升级 Phaser 或改 vendor-src/entry.cjs、gameobjects-trim.cjs 时才需要;
  3. 重建后必须跑三件事:pnpm build、pnpm size、全量 pnpm e2e。脚本自己有指纹(RECIPE + Phaser 版本 + entry 哈希),不变则跳过重建——但”跳过重建”不等于”产物没问题”。

裁剪的代价要提前知道:被裁掉的 physics / tilemaps / sound / animations 不是”用不到”,而是”现在用不到”。哪天做一个需要物理引擎的玩法,正确做法是回 vendor-src 调整裁剪清单并重建,而不是临时 import 完整 Phaser——后者会让 vendor-phaser 从 220.6 KB gzip 涨回 347 KB,直接顶穿 260 KB 的单 chunk 预算。

症状级坑:chunkFileNames 里 if (chunk.name.includes('phaser')) return 'assets/vendor-phaser-[hash].js';——如果改了 codeSplitting 的组名,Phaser 不会消失,但会落到 assets/[name]-[hash].js。于是 preview.spec.ts 里”应该有 vendor-phaser chunk”与”首屏不许引用 Phaser”两条断言同时失效/误报。改名前先看 check-size 的输出里最大 chunk 叫什么。

16.3 IndexedDB:打不开、被清、写失败

这三件事经常被混成一句”存档丢了”,但它们的处理完全不同。

打不开。无痕模式、存储被策略禁用、旧标签页占着版本升级——indexedDB.open 可能永不回调。代码里因此有一个 3 秒超时:

// packages/platform/src/web.ts(节选)
const timer = setTimeout(() => resolve(null), 3000);
const request = indexedDB.open(DB_NAME, 1);

超时就退回内存档(memory),游戏照常可玩,只是关掉页面就没了。绝不能让启动流程挂在这里——这是 CHAGS 踩过、这里直接继承的教训。

被清。移动端存储压力下浏览器可能清掉 IndexedDB。能申请持久存储就申请一次:

if (typeof navigator !== 'undefined' && navigator.storage?.persist) {
  void navigator.storage.persist();
}

注意 persist() 只是”请求”,浏览器可以不批准;它降低被清的概率,不提供保证。

写失败。这一条最重要:写盘失败必须让调用方知道。save() 在 tx.onerror / tx.onabort 时 reject,store 把它翻译成 unsaved,PlayView 的奖励 toast 就会多出一行”这次可能没存上”。

// apps/site/src/stores/progress.ts(节选)
async function run<T>(action: () => Promise<T>): Promise<T | null> {
  try {
    const result = await action();
    unsaved.value = false;
    return result;
  } catch (cause) {
    unsaved.value = true;
    console.warn('[bamboo] 存档写入失败:本次进度可能不会保留', cause);
    return null;
  } finally {
    syncSave();
  }
}

存档还有个容易忽略的边界:只保留最近 30 天(MAX_DAYS = 30,pruneDays() 按 YYYY-MM-DD 的字典序裁)。日期键的字典序就是时间序,所以裁剪不需要解析日期。30 天足够家长页看”本周”与日记看”最近七天”,又不至于让 IndexedDB 里的对象无限增长——本地优先的产品,存储增长必须有上限。

克制的地方在于:失败不打断这一局。孩子刚刷完牙,不该因为配额满了就看不到鼓励;家长需要知道的是”这次可能没存上”,而不是一句技术报错。

读档失败同样不是致命错误:progress.init() 出错时只置 initError 并 console.warn,本次会话从空档开始,界面照样能用。空档可用 > 白屏报错——这是儿童产品的一条硬规矩:孩子不应该因为一次存储异常就什么都玩不了。

16.4 SpeechSynthesis:没语音包、语言不匹配、静音竞态

症状 A:安卓上完全没有旁白。 原因不是代码写错,而是 Android WebView 没有实现 Web Speech API。修法是优先走原生 TTS:

// packages/platform/src/web.ts(节选)
const native = nativeTextToSpeech();
if (native) {
  const done = native.speak({ text, lang: speechLang(locale), rate: …, pitch: …, volume: 0.8 })
    .then((): SpeechOutcome => 'ended')
    .catch((): SpeechOutcome => 'unavailable');
  return { done };
}

原生桥缺失时才回落 speechSynthesis,两者都没有就返回 unavailable——静默降级,绝不把界面钉在”正在听”。

症状 B:英文内容用中文嗓音念。 speechLang(locale) 之后还要按语言挑嗓音:

const prefix = locale === 'en' ? 'en' : 'zh';
const match = voices.find((voice) => voice.lang.toLowerCase().startsWith(prefix));
if (match) utterance.voice = match;

设备上装了多套语音时,不指定 voice 会按系统默认走,混合语言环境里就会出现”英文句子用中文声调读”。

症状 C:静音后还在念。 这是竞态:setMuted(true) 必须立即 stopSpeech(),而不是等当前这句念完。实现里 setMuted 第一件事就是停语音,unlock() 时还会 speechSynthesis.resume()——iOS 上语音合成同样需要一次用户手势内的调用。

症状 D:连续调用时语音排队。 speak() 里先 speechSynthesis.cancel() 再 speak(utterance):孩子快速点两下,第二句必须打断第一句,而不是排成一条长队。这也意味着上一句的 done 可能以 unavailable 收尾——所以不要用语音时长驱动界面节奏。模板里统一用 say(text, hintMs):念一句,同时在屏幕上显示同一句提示(默认 2600ms,调用方可传 1800 / 2000),语音与文字是两条独立通道,听不见的孩子也能读。

症状 E:语速/音量不合适。 孩子的设备常在耳边,所以统一 rate 0.92(中文)/ 0.95(英文)、pitch 1.08、volume 0.8。这不是玄学:0.9 倍速明显更容易跟上,0.8 音量不会在安静房间里炸响。

还有一个跨平台差异:iOS 的 getVoices() 首次可能返回空数组,需要等 voiceschanged。本项目的做法是「取不到就不指定 voice」,让系统按 lang 自己选——比死等列表更稳,也避免为了一个可选优化把首句旁白拖慢半秒。

16.5 懒加载的三种死法

首屏体积只有 56.8 KB(gzip)这件事非常脆弱,三种写法都能一夜之间毁掉它。

死法一:外壳 import @bamboo/content/full。 这个入口把十条详情、英文补丁、zod 全部拉进来。所以 eslint 直接禁:

// eslint.config.js(节选)
{
  files: ['apps/site/src/**/*.{ts,vue}'],
  rules: {
    'no-restricted-imports': ['error', {
      paths: [{
        name: '@bamboo/content/full',
        message: '外壳不许直接引入完整内容:用 @bamboo/content 的 loadTask()(动态 import)。',
      }],
    }],
  },
},

为什么要”直接禁”而不是”写进文档”:一句 import 就能让懒加载作废,而体积门禁要到 build 之后才发现——在写的当下拦住,比在一小时后拦住便宜得多。

死法二:从主入口导出 schema。 packages/content/src/index.ts 刻意只导出常量与摘要,类型用 export type:

// 需要 zod 的地方走 '@bamboo/content/full'(Node CLI 与测试)
export * from './ids';
export type { TaskDetail, TaskDetailExtra, TaskSummary } from './schema';

z.object(...) 是真实副作用——只要主入口引用它,zod 就会跟着进首屏(CHAGS 实测 24.7 KB gzip,占首屏 29%)。常量因此单独拆成 ids.ts,progress 的纯逻辑只从那里取 TASK_IDS / STAT_KEYS。

死法三:把 PlayView 改成静态 import。 App.vue 里 PlayView 与 ParentView 都是 defineAsyncComponent,这是 Phaser 不进首屏的前提。改成 import PlayView from './views/PlayView.vue' 后首屏会立刻多出引擎与全部玩法依赖。

还有一个反直觉的细节:不要为了”预热”而在空闲时 import 玩法。import('@bamboo/game-daily/learn') 一执行就会下载并实例化 Phaser 依赖,等于把懒加载改成”延迟加载”。真想优化首进玩法的等待,应该优化 vendor-phaser 的缓存头,而不是提前下载。

怎么提前发现:pnpm e2e:dist 的 preview.spec.ts 会逐条断言”进 learn 之前不许下载 game-learn-*"”首屏不许出现 vendor-phaser“。dev 环境下这些断言永远绿(dev server 一个文件一个请求),所以只有真实产物能证明它。

16.6 双语:漏翻与”英文 chunk 误入首屏”

漏翻有三种,而且只有第三种会被类型系统抓到:

  1. 摘要补丁少一个字段 → content:check 用 findCjk() 递归查(包括 levelScript);
  2. 英文标题照抄中文 → content:check 有独立判重:”英文标题与中文相同(疑似漏翻)”;
  3. 界面文案表少一个键 → Record<MessageKey, string> 在 typecheck 就报。

最容易漏的其实是第 1 种里的关卡脚本:只翻了 intro / done / retry,忘了 rounds[0].question。这就是 findCjk 要递归检查 levelScript 的原因。

英文 chunk 误入首屏是另一种失败:英文界面表(2.4 KB gzip)加英文摘要补丁(0.8 KB)约 3.2 KB,静态进主包会把首屏从 56.8 KB 顶到 60 KB 预算线上——之后任何一点新增都会超。所以英文走独立的动态 import:

内容 chunk 实测
界面文案表 en-*.js 6.1 KB 原始 / 2.4 KB gzip
英文摘要补丁(10 条) summaries-*.js 1.6 KB 原始 / 0.8 KB gzip
英文详情补丁(每条一份) <id>.game-*.js 0.2–0.7 KB

?lang= 与 localStorage 的优先级也要固定:URL 参数 > 上次选择 > 默认中文。URL 优先是为了”分享一个英文链接给英语家庭”这件事成立;写回 localStorage 只在 initial.fromUrl 为真时发生,避免”打开一次带参数的链接就永久改了我的语言”。记录位置在 main.ts 与 logic/locale.ts,键是 bamboo.locale。

切换顺序也有坑:如果先切语言、再加载文案,会出现”界面英文、内容中文”的半截状态。main.ts 的做法是先就位再挂载:

void Promise.all([ensureSummaries(initial.locale), ensureMessages(initial.locale)]).finally(() => {
  if (initial.fromUrl) persistLocale(initial.locale);
  applyLocaleDocument(initial.locale);
  createApp(App).use(createPinia()).mount('#app');
});

注意 finally 而不是 then:英文 chunk 加载失败时仍然要挂载(回落中文),宁可语言不完整,也不白屏。

16.7 打包链上的五个坑

症状 原因 修法
Unsupported class file major version 69 系统 JDK 25,Gradle 8.14 只支持到 24 build-apk.mjs 自动找 17–24 的 JDK;CI 用 JDK 21
./gradlew: Permission denied 仓库在 Windows 上提交,git 没保留可执行位 CI 里 chmod +x gradlew
Gradle 报缺少 platform 没装 platforms;android-36 / build-tools;36.0.0 本地用 sdkmanager 装,CI 已显式安装
手机上提示”签名不一致,无法安装” 上次是 debug 签名,这次换了密钥 先卸载旧版(会清存档),或从一开始就用 pnpm keystore 固定密钥
exe / 快捷方式还是旧图标 Windows 图标缓存 / 安卓启动器缓存 换目录、重启资源管理器;安卓卸载重装

其中”签名不一致”是唯一会造成用户数据损失的坑,所以发布清单里单列一条。pnpm keystore 生成的 keystore.properties 与 *.keystore 都不入库,必须自己备份。

另外一条容易误判的:tauri.conf.json 里 bundle.targets 写了 nsis,但 pnpm dist:win 用的是 tauri build --no-bundle——配置里有安装包目标,不代表产物是安装包。看到只有一个 exe 不要以为构建失败。

还有一条与体积有关的:Gradle 的 release 构建 minifyEnabled false,不混淆也不裁剪。Web 资源本身已经是打包产物,Java 侧只有 Capacitor 的桥接代码,混淆收益极小,换来的是崩溃栈可读;真正的体积守卫是 Release 里那条 10 MB 门禁(ARTIFACT_MAX_BYTES),超了直接 fail。

16.8 部署与移植

子路径白屏。根因永远只有一个:资源用了绝对路径。本项目 base: './',所以同一份 dist 放在 /、/bamboobuddy/、file:// 都能跑。部署时最容易犯的错是多套一层目录(bamboobuddy/dist/assets/...),此时 index.html 能开、资源全 404。

iOS 没有声音。Safari 要求音频由用户手势解锁,且只解一次。所以 App.vue 在第一次 pointerdown 时调用 platform.audio.unlock() 并 { once: true }:

window.addEventListener('pointerdown', unlockAudio, { once: true });

unlock() 里同时 ctx.resume() 与 speechSynthesis.resume()——音效与语音是两套 API,只解一个会出现”有音效没旁白”。

旧安卓 WebView。三个现实差异:没有 Web Speech API(走原生 TTS)、WebGL 能力弱(本项目只有 2D,压力不大)、存储更容易被清(persist() + 内存兜底)。真机抽查时优先找一台三五年前的机器。

部署完不要只看”页面能开”:把 DevTools 的 Network 按 Size 排序,确认首屏只有 index-*.js 与 index-*.css 两个 200,vendor-phaser 要等你点进第一个玩法才出现。这一步三十秒,能替你排除掉”看起来正常、其实全量加载”的假象。

子路径部署还有一个隐性依赖:index.html 必须与 assets/ 同级。若把资源放到 CDN、HTML 留在 Pages,base: './' 的相对路径会指向 CDN 根——那就得把 base 改成 CDN 绝对地址,并重新验证 file:// 与桌面壳。本项目不分发 CDN,所以不需要这层复杂度。

最后一条与 hash 路由有关的经验:不要为了「URL 更好看」改成 history 路由。那会把「无 rewrite 的静态托管」变成「必须配 404 回退」,博客子路径、桌面壳、安卓本地服务三处都要各配一遍,而改完的收益只是少一个 #。

Electron / file:// 的 CORS。index.html 用的是 <script type="module" crossorigin>,在 file:// 下会被 Chromium 拒绝,必须注册自定义协议。Tauri 默认走自定义协议,所以没这个问题;如果哪天要换 Electron,这是第一个要处理的地方。

16.9 四个和框架无关的坑

这四个之所以单列,是因为它们不看文档几乎必踩,而且报错信息都不指向真正的原因。

一、zod 4 的 z.record(enum, …) 是穷尽记录。 任务的 stats 只想给两个维度,用 z.record 会报另外三个键 missing。改用 z.partialRecord:

stats: z.partialRecord(z.enum(STAT_KEYS), z.number().int().min(1).max(30)),

二、Phaser 的 fillEllipse 只接受 4 个参数。 想顺手传颜色/描边会静默失效或报参数错误——颜色必须先 fillStyle / lineStyle,或走 cartoon.ellipse 助手。写 props 时一次性暴露了这个问题。

三、e2e 不能 import 场景模块。 Playwright 在 Node 里加载 templates/<id>.ts 会连带执行 Phaser 顶层依赖。所以真实指针坐标必须放在不 import Phaser 的 packages/templates/src/layout.ts——这也是 SCRUB_LAYOUT / TIDY_LAYOUT / ORDER_LAYOUT / COMPLETE_LAYOUT 集中在一个纯常量文件里的原因。

四、弯引号在 CJK 字体里是全身宽。 英文副标题 A Panda’s Day 最初渲染成 A Panda’ s Day(看起来多了一个空格)。修法不是改文案,而是把拉丁字体排在字体栈最前(Segoe UI / Helvetica Neue / Arial),中文字形自动回落到 Noto Sans SC / 雅黑——中英排版同时变好。

16.10 高频问答

Q1:pnpm dev 报端口被占用怎么办? 端口 5190 / 5191 是 strictPort: true 写死的,因为 e2e 与 preview 配置都按这两个地址找。先关掉占用进程(Windows:netstat -ano | findstr 5190),不要改端口——改了要同步改 e2e/shared.ts 与 playwright.preview.config.ts。

Q2:为什么线上加 ?autoplay=1 没用? createGameContext.ts 的 debugAllowed() 只在 import.meta.env.DEV 或 navigator.webdriver 为真时放行。生产环境里这个开关被刻意关掉,否则分享链接能自动通关刷星星。

Q3:清档后竹笋为什么是 3? emptyPet() 的初始值是 shoots: 3(新手礼物)。parent.spec.ts 正是用这个数字断言”清档真的回到初始状态”,而不是”本来就是这个数”。

Q4:为什么 pnpm e2e 总有 2 条 skipped? 那是 preview.spec.ts 的两条用例,它只在 baseURL 含 :5191 时执行(pnpm e2e:dist)。拿 dev server 的请求去断言 /assets/ 产物没有意义,所以显式 skip。

Q5:pnpm size 报”找不到 dist/index.html”? 它不是构建脚本,不会替你 build。先 pnpm build。同理,pnpm verify 里 size 被排在 build 之后单独串行,就是为了不读到上一轮的旧产物。

Q6:切英文后界面英文、任务标题还是中文? 说明英文摘要 chunk 没加载成功。检查 Network 里 summaries-*.js / en-*.js 是否 200;离线或资源被清时会回落到中文(ensureSummaries 的 catch),这是设计行为,不是 bug。

Q7:孩子玩了几局,关掉再开进度没了? 先看有没有出现”这次可能没存上”。三种可能:无痕模式(open 超时后退内存)、存储被清、配额满导致 save() reject。window.__bamboo.save 里能看到本次会话的存档快照,刷新后对比即可判断是”没写入”还是”没读出”。

Q8:安卓上一点声音都没有,音效也没有? 音效是 WebAudio,语音是原生 TTS,两者独立。若都没声音,先看是不是静音开关(localStorage 'bamboo.muted' === '1');只有语音没声音,多半是设备没装语音包,此时 speak() 返回 unavailable 并静默降级——游戏照常可玩是对的;不过家长页目前并没有”本机缺少语音包”的专门提示,这是已知限制(写进了 README 第 8 节)。

Q9:下载的 exe 双击没反应 / 被 SmartScreen 拦住? 未签名 exe 首次运行会提示”Windows 已保护你的电脑”,点”更多信息 → 仍要运行”。如果完全没反应,确认下载完整(3.27 MB 左右)、系统有 WebView2 运行时。

Q10:家长页怎么进? 长按左上角标题 3 秒。短按只会回小屋——这是给孩子设计的:误触进不去。清档同样要长按 3 秒,且清完仍停在家长页。

Q11:为什么任务卡是灰的、点不进去? unlockLevel 大于当前成长阶段。TasksView.vue 的 locked() 是 unlockLevel > progress.stage.id,卡片上会显示锁与”第 N 阶段解锁”。玩别的任务攒成长值即可——这也是为什么新增任务时 unlockLevel 建议先写 1(第 13 章)。

Q12:pnpm install 报 Node / pnpm 版本不符? 仓库根 package.json 写了 "packageManager": "pnpm@11.22.0",.github/workflows 用 Node 24。版本对不上时 Corepack 会拒绝,或依赖解析出奇怪的结果——按第 3 章的版本装,不要用”最新版”碰运气。

Q13:改了内容但页面没变? 三种常见原因:改的是 details/*.game.json 却停在任务页(详情只在进玩法时 import);浏览器缓存了旧的 index.html(硬刷新);改的是 docs/ 或 e2e/ 里抄的一份数据(内容唯一事实来源永远是 packages/content/src/)。

Q14:想部署到自己的域名子路径,要改代码吗? 不用。base: './' 已经让资源全部相对,把整个 dist 放进子目录即可;唯一要做的是给 index.html 设短缓存、给 /assets/* 设长缓存。反过来,如果为了”看起来更规范”把 base 改成 /bamboobuddy/,本地 file:// 与桌面壳里的 file 协议就会同时坏掉——别改。

16.11 结语

把这十六章连起来看,BambooBuddy 的技术选择其实只有一个主题:让”正确”变成便宜的选择。

  • 逻辑放纯 TS,于是单测能在 Node 里跑,不用浏览器;
  • 内容放 JSON + zod,于是加一条任务只需要写数据,不用改代码;
  • 引擎单独成 chunk,于是首屏只有 56.8 KB,慢网也能先看到竹竹;
  • 每个玩法可无头直达,于是 e2e 不靠”点得准”,AI 协作也有观测窗口;
  • 门禁写成脚本,于是”做完了”有一份可执行的答案,而不是一句主观判断。

这套做法有明确的代价:多包结构略重、双栈要两套心智、程序化美术观感朴素、无后端意味着没有云同步。技术方案里把每一条都写了”何时翻案”——一个架构文档最诚实的部分,不是它选了什么,而是它承认什么情况下该换掉。

源码在 https://github.com/znlgis/BambooBuddy,在线试玩在 https://znlgis.github.io/bamboobuddy/。如果你要动手改,建议从第 3 章的四条命令开始,再挑一条你最熟悉的生活习惯,按第 13 章的清单加进去——那是理解这套结构最快的方式。


← 上一章 目录