第01章 - BambooBuddy项目概述与设计理念

1.1 什么是 BambooBuddy

BambooBuddy(《熊猫宝宝的一天》,英文名 Bamboo Buddy,副标题 A Panda’s Day)是一个面向 4–8 岁儿童的轻量日常生活养成游戏。孩子照顾一只叫「竹竹 / Bamboo」的卡通大熊猫,在刷牙、洗手、吃饭、穿衣、洗澡、睡觉、整理、运动、情绪、学习这十件小事里,一件一件地帮它做好。竹竹因此开心、长大,孩子因此愿意在家里模仿同一件事。

它不是课程软件,也不是数值养成手游。产品的核心指标只有一句:孩子今天愿意把某个好习惯真的做一遍。这句话直接决定了后面所有的技术取舍——没有失败、没有倒计时、没有体力、没有广告、没有账号,进度只存在本机。

名字这件事在代码里也有痕迹:中文界面里它叫「竹竹」,英文界面里叫「Bamboo」,但存档里的 pet.name 只有一份;界面标题是 app.title / app.subtitle 两个文案键,中英各一份值。角色名不做「可改名」功能——对 4–8 岁的孩子来说,一个稳定的名字比自定义更亲切,也省掉了一整类输入法与敏感词问题。

源码在 https://github.com/znlgis/BambooBuddy,采用 MIT 许可协议;线上试玩在 https://znlgis.github.io/bamboobuddy/,手机浏览器打开即玩,构建产物用相对路径,可以直接放在任意子目录下。

核心循环的六个步骤

设计与实现之间最容易脱节的地方是「循环」。BambooBuddy 把循环写死成六步,每一步都有一个明确的代码落点,任何一个落点缺位都会让循环断掉:

  1. 打开小屋,看到竹竹与五维状态(饱腹 / 精力 / 清洁 / 心情 / 健康);
  2. 按现实时间段推荐 4 件今日小事(早上 / 中午 / 晚上);
  3. 进入 30–90 秒的小游戏;
  4. 完成后得到竹笋、星星、装扮碎片,偶尔得到一张贴纸;
  5. 用竹笋与碎片解锁衣服和小窝物件,也可以把竹笋喂给竹竹;
  6. 竹竹积累成长值,经历四个成长阶段,第四个阶段是「竹竹的小屋」与旅行日记。
步骤 玩家看到什么 代码落点
1 小屋、竹竹、五条状态条 apps/site/src/views/HomeView.vue、apps/site/src/components/RoomScene.vue、apps/site/src/components/PandaBuddy.vue、packages/progress/src/stats.ts
2 「今天想做的四件小事」 packages/progress/src/daily.ts(日期 + 时段做种子)
3 一个玩法全屏 apps/site/src/registry.ts → games/daily/src/games/<id>.ts → packages/templates/<id>.ts
4 竹笋 +n、碎片 +n、贴纸 packages/progress/src/rewards.ts、apps/site/src/views/PlayView.vue 的 finishTask()
5 装扮页与小窝 packages/progress/src/catalog.ts、apps/site/src/views/DressView.vue、packages/progress/src/manager.ts 的 feed()
6 阶段名与下一阶段进度 packages/progress/src/growth.ts(0 / 60 / 160 / 300 成长值)

六步之外还有一条不给孩子的线:家长页。它从标题的长按进入,展示最近七天的记录、本周练习过的习惯、声音开关与清档入口。这条线故意做得「不好点」——孩子不该在玩到一半时误入一个满是数字的表格,而家长需要能确认「今天到底做没做」。

这里有一个刻意的产品判断:奖励不是终点,习惯才是。所以第 4 步的奖励永远为正——重复玩同一天的任务只把奖励减半,最低仍有 1 根竹笋与 3 点成长值(rewards.ts 的 completionReward());第 6 步的成长线也不设失败,只设时间。

1.2 与 CHAGS 的关系:沿用什么,差异什么

BambooBuddy 的技术选型明确参考了同一位作者的另一个项目 CHAGS(中国历史典故儿童游戏合集)。项目 README 第 7 节把两者的异同写成了清单,这里把它展开成可对照的表格:

维度 CHAGS BambooBuddy 为什么改
内容单位 一个典故 一件日常小事 养成游戏的主题是「每天重复」,不是「读完一个故事」
玩法骨架 12 部典故,各有一个专属动词 8 个模板撑起 10 个任务 日常习惯的交互原语更少,scrub 一个模板就覆盖刷牙 / 洗手 / 洗澡
叙事 分幕绘本,听完故事再动手 语音与操作同时开始 4–8 岁的日常模仿需要即时反馈,等不起一段旁白
记忆机制 五层记忆 + 间隔重复复习 不做到期复习 核心指标是行为,不是知识;复习队列会让孩子觉得「又要考试」
进度表达 星星 + 典故卡 + 图鉴 五维状态 + 成长四阶段 + 装扮 + 贴纸 + 亲密度 养成游戏要让「照顾」有连续感
外壳页面 大厅 / 图鉴 / 游戏页 小屋 / 任务 / 装扮 / 日记 / 家长页 / 游戏页 家长页是儿童产品必须有的安全边界
双语 界面、内容、语音三层 同样三层,且英文是懒加载 chunk 中文用户不该为英文付首屏体积
打包 Tauri 2 + Capacitor 8 同一套 一份 apps/site/dist 喂两个壳,游戏代码零改动

沿用的部分(也是这个仓库最值钱的部分):Phaser 4 + Vue 3.5 双栈、pnpm workspace 多包、内容 JSON + zod、GameModule / GameContext 契约、逻辑与渲染分离、autoplay / instant 调试开关、体积门禁、无后端、本地优先、儿童安全规范。

主动不做的部分,README 列得很干脆,值得在这里重复一遍,因为它保护的是「循环能不能跑通」:到期复习与间隔重复、账号 / 云存档 / 排行榜 / 多人 / 内购 / 广告 / CMS / PWA、抽卡与限定皮肤。装扮全部由游戏内奖励解锁——没有付费点,就没有「不买就落后」的焦虑。

一句话总结两者的关系:CHAGS 证明「内容与代码分离 + 模板库」能撑起十几个小游戏;BambooBuddy 检验同一套结构在「日常重复」题材上是否依然成立。

1.3 十个任务与八个模板

十个任务的全部身份信息都来自 packages/content/src/tasks/<id>.json。下面这张表的时段、时长、收益、奖励都是 JSON 里的真实取值,不是策划文案里的估计:

任务 id 模板 时段 单局 状态收益 奖励
刷牙 brush scrub 早 / 晚 45 秒 清洁 +16、心情 +5 竹笋 2 / 成长 12
洗手 wash scrub 早 / 中 / 晚 40 秒 清洁 +12、健康 +4 竹笋 2 / 成长 10
吃饭 feed feed 早 / 中 / 晚 70 秒 饱腹 +22、健康 +6、心情 +4 竹笋 3 / 成长 14
穿衣 dress dress 早 55 秒 心情 +9、健康 +3 竹笋 2 / 成长 12
洗澡 bath scrub 晚 70 秒 清洁 +24、心情 +6 竹笋 3 / 成长 14
睡觉 sleep order 晚 50 秒 精力 +26、健康 +6 竹笋 3 / 成长 14
整理 tidy tidy 中 / 晚 60 秒 心情 +10、清洁 +5 竹笋 2 / 成长 12
运动 sport rhythm 早 / 中 55 秒 健康 +12、心情 +7、精力 +5 竹笋 3 / 成长 13
情绪 emotion pick 早 / 中 / 晚 45 秒 心情 +15、健康 +3 竹笋 2 / 成长 12
学习 learn learn 早 / 中 50 秒 心情 +9、健康 +4 竹笋 2 / 成长 13

「单局 30–90 秒」不是口号:packages/content/src/schema.ts 里 durationHintSec 被约束在 20–120,十条内容都落在 40–70 秒;时段覆盖由 content:check 强制——早、中、晚每个时段至少要有 2 个任务可选,否则报错。

八个模板是交互原语的集合,模板只提供「怎么玩」,关卡 JSON 决定「这一局画什么、目标是什么」:

模板 交互原语 判定要点(*.logic.ts) 用于
scrub 按住工具在目标区来回刷,刷干净后冲水 / 擦干 最近点位 + 半径命中、每点蹭够 rubs 次、finishTaps 次收尾 刷牙、洗手、洗澡
feed 把食物拖到竹竹嘴边,一口一口吃完 每口递减、总口数、needKinds 的类别均衡 吃饭
dress 把衣物拖到身上正确的部位 槽位匹配、全部穿上 穿衣
order 按顺序完成睡前步骤 当前步骤 id,点错不清进度 睡觉
tidy 按类别把玩具拖进对应的箱子 类别匹配、全部归位 整理
rhythm 竹竿开合,窗口内点按 墙钟三角波、zone、220ms 连点门 运动
pick 认出情绪,再做安慰动作 目标情绪、轮次推进 情绪
learn 颜色 / 形状 / 数字点选 答案下标、可无限重试 学习

同一个模板被三条内容复用时,差异全部由关卡数据承担,而不是复制三份代码。以 scrub 为例,场景只根据关卡里的 tool 与 room 选一套画法:

// packages/templates/src/scrub.ts(节选)
function pickVariant(level: ScrubLevel): Variant {
  if (level.tool === 'hands') return 'wash';
  return 'bath';
}

刷牙是「牙刷 + 牙齿区」,洗手是「双手 + 手部区」,洗澡是「海绵 + 身体区」——三份 JSON 的 spots 坐标、rubs 次数、finishTaps 都不同,但判定函数只有一份,单测也只测一份。这就是「8 个模板撑 10 个任务」的真实含义:复用发生在交互原语层,差异化发生在数据层。

模板 id 的唯一事实来源是 packages/content/src/ids.ts 的 TEMPLATE_IDS,packages/templates/src/schemas.ts 用 satisfies Record<TemplateId, ZodType> 约束——新增一个模板却忘了登记 schema,pnpm typecheck 当场报错;内容里写错模板名,则 pnpm content:check 在构建前拦下。

1.4 儿童向设计原则如何落进代码

写进 code review 的规范如果只停在文档里,三个月后就会消失。BambooBuddy 的做法是让每条原则都有一个能指认的代码位置:

原则 代码证据
无失败惩罚、无 0 星 rewards.ts 的 starsFor():失误 ≤1 给 3 颗、≤3 给 2 颗、其余 1 颗,没有 0 颗
无倒计时、无扣命 契约里没有任何计时器字段;task:retry 只是观测事件,不改状态
可点区域 ≥88×88 逻辑像素 packages/templates/src/layout.ts 的 UI.minTap = 96;BaseScene.addButton 默认高度 112
不靠颜色单独传达信息 packages/templates/src/tidy.ts 的注释与实现:玩具颜色与箱子故意错开,逼孩子看形状
静音立即停语音 apps/site/src/stores/settings.ts 的 setMuted(true) 会调用 platform.audio.stopSpeech()
家长锁 apps/site/src/App.vue 长按标题 3000ms 才进家长页,孩子点不到
20 分钟休息提醒 apps/site/src/stores/progress.ts 的 REST_SECONDS = 20 * 60,一天只提醒一次
尊重减少动效 GameContext.reducedMotion 来自 matchMedia('(prefers-reduced-motion: reduce)'),场景据此关掉闪光与补间
文案里不出现失败词汇 packages/templates/src/cli/check-content.ts 对中英文各有一组禁词:失败 / 惩罚 / 笨蛋 / 输了 / 淘汰,以及 fail / stupid / loser / punish
语音不阻塞操作 场景先 speak(intro) 再立即可交互;同一句话同时上屏(BaseScene.say()),听不见的孩子也能读

星星口径值得单独看一眼,因为它是「不惩罚」最容易被破坏的地方:

// packages/progress/src/rewards.ts
/**
 * 星星是"这一局玩得怎么样"的正向反馈,不是评分羞辱:
 * 几乎不失误 3 颗,偶尔重来 2 颗,重来很多次 1 颗 —— **没有 0 颗**。
 */
export function starsFor(mistakes: number): Stars {
  if (mistakes <= 1) return 3;
  if (mistakes <= 3) return 2;
  return 1;
}

家长锁的实现同样只有十几行,但三个细节缺一不可:按下开始计时、抬起 / 离开 / 取消都要清掉计时器、长按触发后浏览器补的那一次 click 不能把小屋又盖回去:

// apps/site/src/App.vue(节选)
function startParentHold(): void {
  if (holdTimer !== null) return;
  holdFired = false;
  holdTimer = setTimeout(() => {
    holdTimer = null;
    holdFired = true;
    navigate('/parent');
  }, 3000);
}

/** 长按结束后浏览器还会补一次 click:那一次不能再把小屋盖过去 */
function onTitleClick(): void {
  if (holdFired) {
    holdFired = false;
    return;
  }
  navigate('/');
}

还有一个常被忽略的点:孩子看得见的失败提示,往往是从存档写入失败开始的。所以 apps/site/src/stores/progress.ts 把写盘失败显式建模成 unsaved 状态,小屋顶部会出现一句「这次可能没存上」,而不是静默丢进度。

1.5 技术选型速览

决策项 选型 一句话理由
游戏引擎 Phaser 4.2.1(裁剪版 vendor) 纯代码、可 diff、可单测;只在玩法 chunk 里出现
应用外壳 Vue 3.5.44 SFC + Pinia 4 + Tailwind 4 小屋 / 任务 / 装扮 / 日记是 UI 密集型,改一页只动一个文件
语言 TypeScript ~5.9.3(strict) 与 vue-tsc 3.3 兼容(TS 7 会崩,见第 2 章)
构建 Vite 8.3.4 + pnpm 11.22 workspace 每个玩法自动独立 chunk,Phaser 单独成 chunk
内容 JSON + zod 4,构建期校验 换一条任务 = 换内容;中英各一份补丁
存储 IndexedDB(本地优先)+ 可替换接口 无后端、无账号,儿童隐私最安全
音频 WebAudio 合成 + 浏览器 TTS 零音频文件先把「听」通道跑通
测试 Vitest 5 + vue-tsc + Playwright 1.64 + 内容校验 + 体积门禁 一条 pnpm verify 定义「完成」
打包 Tauri 2(EXE)+ Capacitor 8(APK) 同一份 apps/site/dist 喂两个壳
后端 完全不做 纯静态站点;家长报告只读本机存档

引擎这一行值得多解释一句:vite.config.ts 里有一条 resolve.alias,把裸 phaser 导入替换成 packages/phaser-kit/vendor/phaser-trim.esm.js。这不是「用了另一个引擎」,而是把完整包里对十个日常玩法毫无用处的 physics / tilemaps / sound / animations 裁掉;TypeScript 的类型仍然解析到 npm 的 phaser 包,所以 typecheck 与自动补全完全不受影响。裁剪的细节与风险在第 8 章。

选型表里最不像「决策」的一项其实是零美术资源:所有角色、房间、食物、衣服、玩具都是 Phaser Graphics 与 SVG 几何绘制,音效由 WebAudio 振荡器合成,旁白交给浏览器 TTS。代价是观感朴素,收益是首屏的非代码资源为 0 KB、没有版权风险、每一笔改动都能 diff。将来换成真实美术时,游戏代码不需要动——角色绘制集中在 packages/phaser-kit/src/panda.ts 与外壳的 PandaBuddy.vue,音频只换 platform 的 AudioBus 实现。

三条架构铁律与 CHAGS 完全一致,后面的章节会反复回到它们:

  1. 逻辑与渲染分离:规则写在 *.logic.ts 与 packages/progress,不 import Phaser,可以脱离浏览器单测;ESLint 还禁止这两处使用 Math.random 与 Date.now。
  2. 游戏不得自己碰环境:存档、音频、生命周期一律通过注入的 PlatformServices;游戏代码里出现 localStorage 就是 bug。
  3. 每个玩法必须能被无头直达:#/play/<id>?autoplay=1 自动通关、?instant=1 跳过过场,否则端到端测试与 AI 协作都无从谈起。

1.6 仓库结构总览

BambooBuddy/
├─ apps/
│  ├─ site/                 # Vue 3.5 外壳:小屋 / 任务 / 装扮 / 日记 / 家长页 / 玩法宿主
│  ├─ desktop/              # Tauri 2 桌面壳(便携 exe)
│  └─ mobile/               # Capacitor 8 安卓壳(apk,android 工程已入库)
├─ packages/
│  ├─ game-kit/             # 契约:GameModule / GameContext / 事件 / SaveData(无 Phaser)
│  ├─ content/              # 10 条摘要 JSON + 10 份详情 JSON + loadTask() 动态加载
│  ├─ i18n/                 # 界面文案表(键是联合类型,写错编译期报)
│  ├─ progress/             # 纯 TS:推荐、状态、奖励、成长、连续天数、亲密度、存档迁移
│  ├─ platform/             # Web 实现:IndexedDB / 合成音效 / TTS / 生命周期 / telemetry
│  ├─ phaser-kit/           # 主题、绘制、竹竹、拖拽、BaseScene、Phaser 裁剪版
│  └─ templates/            # 8 个模板:*.logic.ts(判定)+ *.ts(场景)+ 关卡 schema
├─ games/daily/             # 10 个玩法入口(一个任务 = 一个动态 import 的子路径)
├─ e2e/                     # Playwright:冒烟、真实操作、家长页、双语、生产产物
├─ scripts/                 # verify / 体积门禁 / 图标 / APK / keystore / 打包体检
└─ docs/                    # 技术方案、打包与分发

内容被刻意拆成两半:packages/content/src/tasks/<id>.json 是摘要(标题、emoji、颜色、时段、模板、收益、奖励、习惯、日记一行、提示),小屋 / 任务 / 装扮 / 日记四个页面只读它;packages/content/src/details/<id>.game.json 是详情(intro / done / retry 语音文案 + 关卡脚本),只有进入那个玩法时才由 loadTask() 动态 import。这条拆分是首屏能压在 56.8 KB 的直接原因,也是 ESLint 要禁止外壳 import @bamboo/content/full 的原因。

依赖方向是这个结构的关键:game-kit 与 content 不 import Phaser,所以外壳可以安全地直接引用它们;Phaser 只出现在 games/*、packages/templates 与 packages/phaser-kit 里;外壳甚至连 @bamboo/templates 都不在依赖列表里(第 2 章会给出完整的包依赖图)。这条边界带来的直接结果是:首屏永远不会下载那 876 KB 的引擎。

1.7 当前验收与产物现状

以下数字都是 2026-10-19 在本机实测的基线,也是后续章节反复引用的对照值:

项目 实测值
单元测试 91 个(Vitest,Node 环境,纯逻辑 + 内容 + 外壳小工具)
端到端 20 passed / 2 skipped / 0 failed(Playwright,Pixel 5 模拟)
生产产物懒加载 pnpm e2e:dist 2 passed(首屏不下载任何重 chunk)
pnpm verify 七阶段全绿:typecheck / lint / test / content:check / pack:check / build / size
dist 体积 41 个文件 / 1,245,469 B(约 1.19 MB)
首屏 JS + CSS gzip 56.8 KB(预算 60 KB)
Phaser 裁剪版 876,874 B(gzip 约 227 KB),只在第一次进玩法时下载
Windows 便携 exe 3.27 MB(Tauri 2,系统 WebView2)
安卓 apk 4.69 MB(Capacitor 8,debug 签名)
非代码资源 0(全部几何绘制 + WebAudio 合成)

验收这条线有一条不成文的规矩:数字要能被复现。所以第 14 章会把这里的每一项都对应到具体的脚本与断言——91 个单测来自哪些 *.test.ts、20 条端到端分别覆盖什么、体积的 56.8 KB 是怎么算出来的(首屏 = index.html 实际引用 + 内联脚本,不是「所有 js 加起来」)。没有对应的断言,数字就只是上一次跑出来的巧合。

两个跳过项不是失败:preview.spec.ts 是生产产物用例,只在 pnpm e2e:dist(端口 5191)下运行,在开发配置(端口 5190)里按设计跳过。

打包与发布同样有实测记录:GitHub Actions 的 Release 工作流在推 v* tag 时构建两个平台并挂到 Release,也可以手动 Run workflow 只出产物。产物都是「一份站点 + 一个壳」——Tauri 走系统 WebView2,Capacitor 走内置本地服务 https://localhost,所以 ES module、IndexedDB、WebGL 全部按浏览器标准工作,游戏代码不知道自己在哪个壳里跑。

体积这一项值得单独强调,因为它决定了很多写法。首屏 56.8 KB 意味着:不能为了省事把 zod 拖进外壳(@bamboo/content 主入口刻意不导出 schema 的运行时对象),不能让英文文案静态进主包,也不能让任何一句 import '@bamboo/content/full' 混进 apps/site/src——ESLint 直接禁止最后这条。

1.8 本教程的章节安排

全书 16 章,按「先跑起来、再拆结构、最后做交付」的顺序组织:

  • 第 1–3 章(认识与跑起来):项目概述与设计理念、技术栈与工程结构、环境准备与快速开始;
  • 第 4–8 章(架构主干):核心契约与游戏生命周期、Vue 应用外壳、内容数据模型与任务 JSON、玩法模板库、Phaser 渲染工具箱;
  • 第 9–12 章(能力层):平台适配层与音频、进度系统与日常任务、亲密互动 / 装扮 / 日记 / 家长页、中英双语;
  • 第 13–16 章(实战与交付):开发一个新的日常任务、测试体系与质量门禁、构建打包与分发、踩坑记录与常见问题。

章节之间尽量低耦合:想改玩法可以直接读第 7 章,想加内容直接读第 6 章与第 13 章。但第 4 章的契约是所有后续内容的地基,建议不要跳过。

1.9 阅读本章之后应该记住的三件事

  1. 产品定位是技术约束的来源:无失败、无倒计时、零外部资源、本地存档,不是「顺手做的好事」,而是「一个任务一个小游戏」这个形态能成立的前提;
  2. 模板库复用交互原语,不复用关卡:8 个模板撑 10 个任务,靠的是 *.logic.ts 与关卡 JSON 的分工;
  3. 验收是一串可复现的数字:91 个单测、20 条端到端、56.8 KB 首屏、1.19 MB 产物,每一项背后都有一个脚本,而不是一次人工检查。

下一章把视角拉近一层:这套东西到底由哪些包组成、版本为什么锁在这些数字上、Vite 配置里那几条 chunkFileNames 规则各自解决什么问题。


目录 下一章 →