第05章 - Vue应用外壳详解
5.1 外壳的边界
apps/site 是整个仓库里唯一的产品界面代码,也是唯一「知道 Phaser 存在、但只在三处提起它」的包:registry.ts 里那张动态 import 表、views/PlayView.vue 这个宿主组件、shell/createGameContext.ts 里的上下文组装(第三处第 4 章已拆过)。除此之外的二十来个文件——六个视图、三个 store、六个 shell 工具、六个 logic 纯函数、两个 SVG 组件——完全不认识游戏引擎。
目录速查:
文件(相对 apps/site/src/) |
内容 | 行数 |
|---|---|---|
main.ts |
启动链:调试桥 → 语言 chunk → 挂载 | 25 |
App.vue |
顶栏 / 主滚动区 / 底栏,家长页长按入口 | 210 |
router.ts |
hash 路由,6 条 | 54 |
registry.ts |
任务 id → 动态 import | 22 |
views/ |
Home 267 / Tasks 146 / Play 286 / Dress 186 / Diary 157 / Parent 172 | — |
stores/ |
progress 215 / settings 47 / locale 37 | — |
shell/ |
platform、progress、debug、createGameContext、useI18n、backButton | 7–89 |
logic/ |
locale、labels、taskGroups、diary、dressGroups、pandaLook | 22–76 |
components/ |
PandaBuddy(SVG 熊猫)、RoomScene(SVG 小屋) | 312 / 146 |
三条边界不靠自觉,靠工具守住:
- 内容边界:
eslint.config.js对apps/site/src/**/*.{ts,vue}打开了no-restricted-imports,直接禁掉@bamboo/content/full,报错文案就是「外壳不许直接引入完整内容:用 @bamboo/content 的 loadTask()(动态 import)」。有人为了省事把十个关卡脚本同步 import 进来,lint 当场失败,首屏体积不会悄悄涨上去(第 6 章)。 - 存档边界:界面不拼
SaveData,只调useProgress()暴露的方法;真正的读写、迁移、衰减在packages/progress的ProgressManager里(第 10 章)。 - 平台边界:存储、音频、生命周期、遥测统一走
shell/platform.ts导出的platform单例,界面与玩法都不许绕过它去碰localStorage或fetch(第 9 章)。
这条边界带来的直接好处是:外壳可以原封不动地塞进 Tauri 的 WebView2 和 Capacitor 的安卓 WebView(第 15 章),因为除了 shell/backButton.ts 里那一次 nativeApp() 探测,它没有任何宿主假设。
5.2 启动链:25 行里的四个决定
main.ts 很短,但每一个 await 都有代价与理由:
// apps/site/src/main.ts
initDebugBridge();
const initial = initialLocale();
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');
});
四个决定:
- 调试桥先于挂载。
initDebugBridge()在createApp之前跑,因为 e2e 可能一打开页面就读window.__bamboo.save,不等用户点任何东西;它只有事件名和存档快照,不含个人信息,生产环境同样开着。 - 英文内容补丁先就位再挂载。
ensureSummaries(摘要补丁)与ensureMessages(界面文案)并行等待,然后才mount。否则英文用户会先看到一屏中文标题再「跳」成英文;中文用户因为ensureSummaries对zh-CN直接Promise.resolve(),一个字节都不多下载。 finally而不是then。任何一份 chunk 加载失败(离线、资源被清)也要能把应用挂起来——补丁缺失时ensureSummaries内部会置空缓存并回落中文,界面不会白屏。fromUrl才写 localStorage。?lang=en是分享链接语义:只有 URL 明确指定时才把选择记下来,免得自动化测试跑完把测试机的默认语言改掉。
App.vue 的 <script setup> 只做四件事:
const PlayView = defineAsyncComponent(() => import('./views/PlayView.vue'));
const ParentView = defineAsyncComponent(() => import('./views/ParentView.vue'));
const isFullScreen = computed(
() => route.value.name === 'player' || route.value.name === 'parent',
);
// 路由切换把主滚动区拉回顶部:从滚到一半的小屋点「任务」,
// 新页面若保持旧 scrollTop,会直接停在半张卡片上
watch(
() => route.value.name + ':' + (route.value.taskId ?? ''),
() => {
void nextTick(() => {
mainRef.value?.scrollTo({ top: 0 });
});
},
);
- 玩法页与家长页懒加载:
PlayView是 Phaser 的入口,必须等孩子真的点开某个玩法才下载;ParentView懒加载则纯粹是不给首屏加代码——绝大多数会话永远进不去家长页。 isFullScreen同时干掉顶栏与底栏:游戏是全屏 Canvas,家长页是一份长文档,两者都不该有「小屋 / 任务 / 装扮」三个大按钮在旁边晃。PlayView的 key 是taskId + ':' + locale:切语言即重挂游戏、换任务即重建 Phaser 实例,避免出现半中半英的场景或两个 canvas 叠加。
onMounted 里挂的东西也值得逐条列:
| 动作 | 为什么放在挂载时 |
|---|---|
settings.init() |
读回上次的静音选择并同步给 platform.audio |
installBackButtonHandler(route) |
安卓实体返回键:先回小屋,再按才退出(浏览器里是空操作) |
progress.init() |
一次性读 IndexedDB 存档,小屋的星星与阶段依赖它 |
pointerdown 上的一次性音频解锁 |
iOS / 微信要求音频由用户手势解锁,只解一次,用 { once: true } |
platform.lifecycle.onResume() → progress.refresh() |
应用可能在平板上挂了一整夜,回前台补一次跨天衰减 |
onBeforeUnmount 把定时器、事件监听、生命周期订阅逐一撤掉。这一层没有 onUnmounted 兜底——单页应用里 App.vue 不会卸载,写在这里是为了 HMR 时不叠监听。
5.3 hash 路由表:54 行,没有 vue-router
router.ts 只有两个导出:useRoute() 返回一个 Ref<Route>,navigate(path) 改 window.location.hash。路由表是 if 串联:
// apps/site/src/router.ts
export function parseRoute(hash: string): Route {
// 调试参数也允许挂在 hash 里('#/play/brush?autoplay=1'):路由只看 '?' 之前的部分
const clean = hash.split('?')[0] ?? hash;
const parts = clean.replace(/^#\/?/, '').split('/').filter(Boolean);
const head = parts[0];
if (head === 'play') {
const taskId = asTaskId(parts[1]);
// 未知玩法 id 直接回小屋:不给孩子一个空白页
return taskId ? { name: 'player', taskId } : { name: 'home' };
}
if (head === 'tasks') return { name: 'tasks' };
if (head === 'dress') return { name: 'dress' };
if (head === 'diary') return { name: 'diary' };
// 家长页:长按标题 3 秒进入(孩子点不到),与孩子的主线完全分开
if (head === 'parent') return { name: 'parent' };
return { name: 'home' };
}
六条路由:
| hash | Route.name |
视图 | 说明 |
|---|---|---|---|
#/ |
home |
HomeView |
小屋,默认入口 |
#/tasks |
tasks |
TasksView |
推荐 + 全部玩法 |
#/play/<id> |
player |
PlayView |
带 taskId,id 必须在 TASK_IDS 里 |
#/dress |
dress |
DressView |
衣服 / 小窝两个 tab |
#/diary |
diary |
DiaryView |
竹竹的一天 + 贴纸册 + 最近七天 |
#/parent |
parent |
ParentView |
家长页,长按 3 秒进入 |
三个细节决定了这套路由能不能撑住真实使用:
?query先切掉。分享链接#/play/brush?autoplay=1比?autoplay=1#/play/brush干净,两种都要能跑;不切的话brush?autoplay=1会被当成一个任务 id,asTaskId校验不过,直接掉回小屋——这种「分享链接打不开」的 bug 只在家长之间传链接时才暴露。asTaskId用TASK_IDS做白名单:(TASK_IDS as readonly string[]).includes(decoded)之后才as TaskId。这行断言是整个外壳唯一允许的 id 收窄,因为registry用Record<TaskId, GameLoader>挡住漏登记,路由这边只需挡住脏输入。- 为什么是 hash 而不是 history:静态托管(GitHub Pages、对象存储)不需要任何 rewrite 规则;Tauri 与 Capacitor 的本地协议下 hash 一定可用;自动化测试可以直接
page.goto('/#/play/brush')直达。翻案条件是「路由超过 5 条或需要嵌套」——现在 6 条,其中 5 条是平级页面,仍然不值得引入路由库。
5.4 三个 Pinia store
progress:存档的唯一门面
stores/progress.ts 是外壳里最长的一个文件(215 行),它的三条纪律写在文件头的注释里,也真的落实在代码里:
const save = ref<SaveData>(progressManager.snapshot());
const ready = ref(false);
/** 写盘失败(配额满 / 无痕模式):界面据此提示「这次可能没存上」 */
const unsaved = ref(false);
const initError = ref<string | null>(null);
let inflight: Promise<void> | null = null;
function init(): Promise<void> {
inflight ??= (async () => {
try {
save.value = await progressManager.init();
} catch (cause) {
initError.value = cause instanceof Error ? cause.message : String(cause);
} finally {
ready.value = true;
syncSave();
}
})();
return inflight;
}
- 读档 single-flight:
inflight ??=让并发的init()共用同一个 Promise。深链直达玩法时,PlayView会先await progress.init()再挂游戏;否则「秒通关」的写入可能早于读档完成,被旧快照盖掉——这是 CHAGS 那一代留下的真实故障。 - 所有写操作走
run():成功清unsaved、失败置位并只console.warn,finally里syncSave()把新快照同时交给界面与window.__bamboo.save。写盘失败不打断当前这一局的反馈——孩子刚喂完竹竹,不该看到红色错误框。 - 快照每次都是新对象:
progressManager.snapshot()返回新对象,save.value = snapshot触发重渲染,调试桥也换成新对象,e2e 读到的永远是当前值。
派生数据全部是 computed,视图层不自己算:
| 计算属性 / 方法 | 来源 | 用途 |
|---|---|---|
pet |
save.value.pet |
五维状态、竹笋、碎片、装扮、贴纸 |
stage / nextStage / xpToNext |
stageForXp / nextStageForXp / xpToNextStage |
小屋的阶段徽章与「还差多少」 |
todayRecord / weekSummary |
todayRecord() / weekSummary()(注入 platform.now()) |
日记页、家长页 |
todaySeconds / restDue |
REST_SECONDS = 20 * 60 + restDismissed |
20 分钟休息提醒,一天只出现一次 |
careAvailable |
careAvailable(pet.careDays, action, dayKey(now)) |
摸头 / 抱抱 / 击掌每天各一次 |
starsFor / streakFor / hasSticker / completedToday / completedBefore |
save.tasks 与 pet.stickers |
任务卡上的星、连续天数、贴纸、今日已完成 |
owns / equippedId / roomId |
findItem + ownsItem / pet.equipped / pet.room |
装扮页的按钮三态 |
completeBefore 这个名字值得单独说一句:玩法里用它决定要不要说「又做到啦」。completeTask 之前先取 getTaskSummary(taskId),摘要缺失时返回 null 而不是抛错——内容被删掉一半的中间态不该让孩子看到崩溃页。
settings:声音开关
/** 键与取值都固定:e2e 会直接读 localStorage 断言('1' 静音 / '0' 有声音) */
const MUTED_KEY = 'bamboo.muted';
function readMuted(): boolean {
try {
return window.localStorage.getItem(MUTED_KEY) === '1';
} catch {
return false; // 隐私模式里 localStorage 可能直接抛错:读不到就按「有声音」处理
}
}
setMuted() 做三件事:改状态、platform.audio.setMuted(next)、静音时立刻 stopSpeech()(正在念的旁白不能继续)。写 localStorage 用 try/catch 包住,写不进去也照样本次会话生效。键值约定成 '1' / '0' 是给测试用的:e2e/parent.spec.ts 直接断言 localStorage,不必去猜 UI 状态。
locale:切语言是一个 await
async function set(next: Locale): Promise<void> {
if (next === current.value) return;
await Promise.all([ensureSummaries(next), ensureMessages(next)]);
current.value = next;
persistLocale(next);
applyLocaleDocument(next);
}
先等两份 chunk 到位,再换 current——否则会出现「界面英文、任务标题中文」的半截状态。persistLocale 写 bamboo.locale,applyLocaleDocument 同步 document.documentElement.lang 与 document.title(屏幕阅读器靠前者判断朗读语音)。语言优先级在 logic/locale.ts:
- URL 的
?lang=(window.location.search与 hash 里的 query 都认); - 上次选择(localStorage 的
bamboo.locale); - 中文。
5.5 小屋:一间用 SVG 画出来的房间
首屏只有小屋。它要在不加载任何位图的前提下回答三个问题:竹竹现在怎么样、我能为它做点什么、今天玩什么。
RoomScene:五个槽位决定一间房
components/RoomScene.vue 是一个 viewBox="0 0 400 280"、preserveAspectRatio="xMidYMax slice" 的 SVG,接一个 room?: Partial<Record<RoomSlot, string>>。五个槽位(wallpaper / bed / shelf / toyCorner / rug)先被折成五个布尔量:
const forestWall = computed(() => props.room.wallpaper === 'room-wall-forest');
const flowerRug = computed(() => props.room.rug === 'room-rug-flower');
const hasBed = computed(() => props.room.bed === 'room-bed-cloud');
const hasShelf = computed(() => props.room.shelf === 'room-shelf');
const hasToy = computed(() => props.room.toyCorner === 'room-toy');
五个槽位与装扮页的「小窝」tab 是同一套数据:目录里 7 件小窝物件分布在墙纸 / 床 / 书架 / 玩具角 / 地毯这五个槽位上,存档里 pet.room 只记「每个槽位现在用的是哪一件」。所以小屋背景不需要任何额外状态——换一张墙纸就是改 pet.room.wallpaper,返回小屋立刻生效。槽位里没有物件时对应的 <g> 直接不出现,房间不会出现「空框」这种中间态。
然后每个布尔量决定一组 <g v-if> 是否出现:竹林墙纸是四根竹子加叶子,云朵床是三个 ellipse 加一条毯子,玩具角是一颗球、两块积木、一只小熊(五个圆拼出来),书架是六个 rect,盆栽是 path + 两片叶子。换一张墙纸不应该多下载一张图——这是小窝系统能便宜地做到 7 件物件的原因,也让离线包与截图比对标定都稳定。
PandaBuddy:同一只竹竹
components/PandaBuddy.vue 是 viewBox="0 0 260 300" 的 SVG,接三个 prop:mood、stageScale(成长阶段 0.82–1.08 的体型)、equipped(五个装扮槽位)。它的表情不是「哪项最低就报错」,而是只挑最需要被照顾的那一项给一个温柔表情:
// apps/site/src/logic/pandaLook.ts
export function moodFromStats(stats: Stats): PandaMood {
if (stats.energy < 40) return 'sleepy';
if (stats.fullness < 40) return 'hungry';
if (stats.clean < 40) return 'dirty';
if (stats.mood < 40) return 'sad';
return 'happy';
}
优先级是精力 → 饱腹 → 清洁 → 心情:困了先睡,饿了先吃,脏了先洗,最后才是情绪。孩子一次只需要读懂一种情绪,四项同时低的叠加脸是没有意义的。faceFor(mood) 再把表情折成四个开关(eyes / mouth / tear / dirt)+ sleepy,模板里就是四组 v-if。
装扮颜色与帽子形态也由 id 前缀决定,不查目录:
export const PANDA_COLORS = {
white: '#fbf7ef',
black: '#3b3a37',
ink: '#4a3b2f',
blush: '#f2b8ae',
gold: '#e3b85a',
bamboo: '#7aa05c',
eyeShine: '#ffffff',
} as const;
clothingColor(id) 按前缀挑低饱和色,hatKind(id) 返回 'bamboo' | 'straw' | 'crown' | null,三种帽子的画法完全不同(竹叶帽是两片斜椭圆,草帽是宽椭圆加帽箍,皇冠是五点 polygon)。这份配色与 packages/phaser-kit 的 THEME 必须一致——小屋里的竹竹和玩法里的竹竹是同一只,色差会被孩子一眼看出来。
三段 CSS 动画负责「活着」:呼吸 4.8 秒(纵向 1.028 倍)、眨眼 5.4 秒里眨两下(93.5%–96% 时 scaleY(0.08))、打瞌睡的小 z 3.2 秒上下浮动 4px。@media (prefers-reduced-motion: reduce) 下三段全部 animation: none,转场也归零。
HomeView 的一屏
| 区块 | 数据 | 关键点 |
|---|---|---|
| 小屋卡 | RoomScene + PandaBuddy + 阶段 / 竹笋 / 碎片 / 下一阶段 |
高度 h-64 sm:h-72,熊猫绝对定位在下沿 |
| 存档异常提示 | unsaved \|\| initError !== null |
role="status",两种文案分开 |
| 休息提醒 | restDue |
REST_SECONDS = 1200,点过即 dismissRest() |
| 五维状态 | STAT_KEYS + STAT_COLORS |
低于 40 只加一朵 🌱,不报警不倒数 |
| 亲昵 | CARE_ACTIONS 三个按钮 + 喂竹笋 |
不可用时置灰并写「今天已经做过啦」 |
| 小愿望 | statHints(pet.stats) 取第一条 |
点一下直接进对应玩法 |
| 今日推荐 | recommendedTasks(dayKey, slot, tasks, 2) |
只放两个入口,避免挑花眼 |
五维状态条的颜色也不是随便挑的:
export const STAT_COLORS: Record<StatKey, string> = {
fullness: '#e0b978', // 麦色
energy: '#b9b2d6', // 薰衣草
clean: '#9ec9d8', // 水蓝
mood: '#f0b7ad', // 腮红
health: '#a8c68a', // 竹绿
};
没有一项是刺眼的警戒红。状态条本身带 role="progressbar"、aria-valuenow、aria-valuemin/max,读屏用户能听到数值;aria-hidden 只标在装饰性 emoji 上。
5.6 TasksView:推荐四个,全部十个
任务页是外壳里唯一「列表可能变长」的页面,所以它对数据源的要求也最高:taskSummaries(locale) 在切语言时会被 ensureSummaries 换成一份新数组,页面自然重渲染,不需要 watch、不需要手动刷新。
任务页分两段。上面 recommendedTasks(dayKey(now), currentSlot(now), tasks, 4) 给四张卡——推荐是确定性的(日期 + 时段做种子,第 10 章),同一天进来多少次都是同一批,孩子不会因为刷新一次就找不到刚才想做的那件事。下面按早 / 中 / 晚分组列出全部十个:
// apps/site/src/logic/taskGroups.ts
export function groupTasksBySlot(tasks: readonly TaskSummary[]): TaskSlotGroup[] {
const groups: TaskSlotGroup[] = TIME_SLOTS.map((slot) => ({ slot, tasks: [] }));
for (const task of tasks) {
const slot = task.timeSlots[0] ?? 'morning'; // 只进第一个时段
const group = groups.find((candidate) => candidate.slot === slot);
if (group) group.tasks.push(task);
}
return groups.filter((group) => group.tasks.length > 0);
}
「只进第一个时段」是刻意的:按「包含的每个时段都列一次」,洗手会在三组里各出现一遍,孩子会以为是三个不同的玩法;卡片总数也必须仍然是十张。空组直接不渲染。
每张任务卡的所有状态都来自 store:
/** 星星只显示 0–3,空星用 ☆ —— 三颗星是成绩,一颗也是「做到啦」 */
function starText(taskId: TaskId): string {
const stars = progress.starsFor(taskId);
return '★'.repeat(stars) + '☆'.repeat(3 - stars);
}
卡片上的徽章依次是:星星(text-gold、tracking-widest)、连续天数 🔥(streakFor > 0 才出现)、贴纸 🏅(hasSticker)、今日已完成 ✅(completedToday)。未解锁时按钮换成灰底并 disabled,旁边写「再长大一点就能玩啦 · 需要阶段 N」。当前十条内容的 unlockLevel 都是 1,这条路径暂时不会触发,但保留着——成长系统一加解锁就会用到。
data-testid 的分布有一条硬规矩:task-card-<id> / task-stars-<id> / task-start-<id> / task-done-<id> / task-locked-<id> 只出现在「全部玩法」里,推荐区用 tasks-today-<id>。同一个 task id 在页面上只能有一个同名 testid,否则 e2e 的 getByTestId 会变成「取第一个」,断言跟着变含糊。
5.7 DressView / DiaryView / ParentView
装扮页
两个 tab(clothes / room),目录按部位分组:衣服是帽子 / 围兜 / 衣服 / 书包 / 鞋,小窝是墙纸 / 床 / 书架 / 玩具角 / 地毯。分组函数只做一件事——把 CLOTHING_SLOTS 或 ROOM_SLOTS 映射成 itemsForSlot(slot),空组过滤掉:
// apps/site/src/logic/dressGroups.ts
return CLOTHING_SLOTS.map((slot) => ({
slot,
title: tr(CLOTHING_SLOT_KEYS[slot]),
items: itemsForSlot(slot),
})).filter((group) => group.items.length > 0);
页面顶部是「试穿预览」:RoomScene 打底 + 穿好装的 PandaBuddy,换什么立刻看得见;下面一行是余额(🎋 竹笋 / 🧩 碎片)与阶段名,孩子自己就能判断「还差多少」。按钮三态由 actionLabel() 决定:没拥有 → 「解锁」;拥有且正穿着 → 「脱下 / 已穿着」;拥有未穿 → 「换上」。解锁失败要说人话,unlockMessage(reason, item) 分三种:level → 「竹竹还没长大」、noShoots → 「竹笋不够」、noFragments → 「碎片不够」。结果用一个 2.6 秒的 toast 显示,role="status",onBeforeUnmount 清定时器。
日记页
今天做过的事被串成「竹竹的一天」:todayRecord.completed 逐个 getTaskSummary(id, locale),拿摘要里的 diary 一行一件,配一个 emoji。右上角语音按钮把今天所有日记行 join(' ') 后交给 platform.audio.speak(text, locale),用返回的 handle.done 把按钮文字切回「听一听」:
function playDiary(): void {
if (settings.muted) return; // 静音时不做事,而不是灰掉按钮
const lines = todayLines.value.map((task) => task.diary);
if (lines.length === 0) return;
const handle = platform.audio.speak(lines.join(' '), locale.current);
speaking.value = true;
void handle.done.then(() => { speaking.value = false; });
}
为什么不把按钮 disabled:孩子会一直点一个看不懂原因的禁用按钮。静音时保持可按、只是不发声,是这个产品反复用到的处理方式。
贴纸册永远十格(stickerRows(pet, taskSummaries(locale))),得到的亮起来,没得到的是灰度剪影 + 名字写「还没得到」——孩子能看见「还有几张」。每格带 data-owned="true|false",e2e 直接按属性断言,不必看图。最近七天来自 weekSummary,dayKind(date, today) 把第一列折成「今天 / 昨天 / MM/DD」。
家长页
#/parent 只回答家长最关心的四件事:
- 安全边界三条固定文案(不联网 / 无广告内购 / 存档只在本机);
- 这周完成了几件小事:
weekCompletedCount(save, now),以及去重后的习惯列表——遍历weekSummary的completed,取摘要里的habit,用Set去重并保持首次出现顺序; - 声音开关(直接调
settings.toggle())与清除本机存档(长按 3 秒); - 版本号:
import sitePkg from '../../package.json',version缺省'0.0.0'。Vite 会把 JSON 内联进 chunk,不额外请求。
5.8 shell 与 logic 里的小工具
文件(相对 apps/site/src/) |
一句话 |
|---|---|
shell/platform.ts |
export const platform = createWebPlatform();——存档 / 音频 / 生命周期 / 遥测的唯一出口(7 行) |
shell/progress.ts |
new ProgressManager(platform.storage, () => platform.now()),并在启动即把空档挂到 window.__bamboo.save |
shell/useI18n.ts |
组件取词入口:每次调用都读当前语言,模板渲染因此订阅语言状态 |
shell/debug.ts |
{ events, save, lastError? } 三字段观测窗口,MAX_EVENTS = 600 |
shell/backButton.ts |
安卓返回键桥,接口可注入,因此能单测 |
logic/locale.ts |
语言优先级、persistLocale、applyLocaleDocument |
logic/labels.ts |
id → 文案键映射表,Record<联合类型, MessageKey> 把漏一条变成编译错误 |
logic/pandaLook.ts |
状态 → 表情、装扮 id → 颜色与帽子形态 |
logic/taskGroups.ts / logic/diary.ts / logic/dressGroups.ts |
三个纯函数,全部可单测 |
shell/progress.ts 全文值得抄一遍,因为它只有十几行却承担了「启动即观测」:
export const progressManager = new ProgressManager(platform.storage, () => platform.now());
// 启动即把空档挂到 window.__bamboo.save:e2e 不需要等界面点出任何东西
initDebugBridge(platform.now());
setDebugSave(progressManager.snapshot());
调试桥的形状是四个文件之间约定死的:
export interface BambooDebugBridge {
/** 事件序列:'session:start' / 'scene:enter:<scene>' / 'task:complete:<id>' … */
events: string[];
/** 最新存档快照(每次存档变化都换成一个**新对象**) */
save: SaveData;
/** 最近一次载入失败的技术详情 */
lastError?: string;
}
注释里写着「不要往上面加字段(加之前先叫上 QA 一起改)」——因为 e2e/shared.ts 的每个断言都建立在这三个字段上。事件超过 600 条时 shift() 掉最旧的,长会话不会吃内存。
shell/backButton.ts 是「为了能单测而抽接口」的样板:
export interface BackBridge {
addListener(event: 'backButton', callback: () => void): void;
exitApp(): Promise<void>;
}
export function installBackButtonHandler(
route: Ref<Route>,
bridge: BackBridge | null = nativeApp(),
goHome: NavigateTo = navigate,
): void {
if (bridge === null) return; // 浏览器里没有原生桥:直接返回,不报错
bridge.addListener('backButton', () => {
if (route.value.name !== 'home') { goHome('/'); return; }
void bridge.exitApp();
});
}
两个可选参数(bridge 与 goHome)让测试不必在 Node 里伪造 window,backButton.test.ts 直接注入假实现断言「在玩法页按返回 → 回小屋」「在小屋按返回 → exitApp」。以前这条路径 0 覆盖,直到真机上误按返回键把游戏关掉才发现。
logic/labels.ts 值得单独说一句:它把「动态拼 'hint.' + id」换成六张 Record 表(STAT_LABEL_KEYS、CARE_LABELS、TASK_HINT_KEYS、SLOT_LABEL_KEYS、CLOTHING_SLOT_KEYS、ROOM_SLOT_KEYS)。动态拼在运行时能跑,但键名写错没有 typecheck 兜底;换成 Record<TaskId, MessageKey> 之后,漏一条、写错一个键都是编译错误。
5.9 家长锁:两处长按,两种理由
家长页入口是长按标题 3 秒。实现只有三个函数加一个标记:
let holdTimer: ReturnType<typeof setTimeout> | null = null;
let holdFired = false;
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('/');
}
四个 pointer 事件(pointerdown 开始、pointerup / pointerleave / pointercancel 取消)覆盖了手指滑出按钮与系统打断两种中止。holdFired 挡住长按之后紧跟的那次 click——不挡的话,家长刚好不容易进了家长页,又被同一次手势送回小屋。
家长页的「清除本机存档」是第二处长按,做法不同:按住时有可见进度,每 80ms 更新一次百分比,到 100% 才真的清:
const HOLD_MS = 3000;
ticker = setInterval(() => {
const pct = Math.min(100, ((platform.now() - startedAt) / HOLD_MS) * 100);
holdProgress.value = pct;
if (pct >= 100) { clearInterval(ticker); void finishClear(); }
}, 80);
按钮上有一条必须照做的注释:
宽度必须固定(
min-w-64):文案会在「清除本机存档(长按 3 秒)」和「继续按住…」之间变化,如果按钮跟着缩,按住时指针会落到按钮外触发pointerleave,长按永远到不了 3 秒。
这是真机上才会出现的 bug:桌面测试分辨率下两句文案都放得下,手机上短的那句让按钮缩了一圈,长按就在最后一秒失败。
可访问性上还有几条贯穿全站的约定:
| 约定 | 落点 |
|---|---|
可点高度 min-h-12(48px)以上,底部三入口 min-h-14,游戏内按钮 min-h-14 min-w-14 |
全站按钮 |
每个只有图标或 emoji 的按钮都带 aria-label |
nav-lang / nav-sound / play-leave / play-sound |
装饰性 emoji 一律 aria-hidden="true" |
全站 |
动态反馈用 role="status" |
存档提示、toast、日记贴纸说明 |
prefers-reduced-motion 下停掉 DOM 动画,游戏内相机淡入由 ctx.reducedMotion 单独处理 |
style.css + BaseScene.fadeIn |
到这一章为止,外壳的所有零件都摆出来了:怎么启动、怎么路由、状态存在哪、六个页面各管什么。它读的每一份数据都来自 @bamboo/content——下一章把那份内容拆开,看一份 481 字节的任务 JSON 里到底有什么。