第17章 - 隐藏小游戏与竹林小路
前十六章讲的是「一件小事 → 一个模板 → 一局判定 → 一份奖励」这条主线。这一章讲主线之外的岔路:孩子完成当天四件小事后,小屋墙上出现一扇小竹门,门后是三款按 4–8 岁重做的街机小游戏(消灭星星、弹一弹、俄罗斯方块),每天每款一次;另有一条更隐蔽的路——在小屋里连续点竹竹 7 次(5 秒内),当天小门直接打开、三款游戏无限畅玩。后者在代码里叫「竹林小路」,存档里只占一个布尔值。
这一章回答三个问题:为什么它是彩蛋而不是第十一个日常任务,两套解锁规则怎样落到纯函数与存档上,三款实时玩法怎样在「逻辑与渲染分离」下写出 75 条能跑在 Node 里的单测。
17.1 为什么是彩蛋,而不是第十一个任务
最省事的做法是「再加一条内容 JSON、复用某个模板、发 task:complete」。这条路会在三处崩掉:
| 冲突点 | 日常玩法 | 三款街机 | 硬套的后果 |
|---|---|---|---|
| 数据来源 | 内容 JSON 驱动的关卡脚本 | 种子随机 + 实时状态机(棋盘、球、方块队列) | 关卡 schema 要长出「棋盘」这种没人看得懂的字段 |
| 判定 | 有限步的手势判定,一局 30–90 秒 | 帧循环、反弹物理、消行、AI | 模板层被迫引入物理与帧同步 |
| 结算 | 星星 + 竹笋 + 成长值 + task:complete |
只有一个分数 | 任务经济被彩蛋污染:连续天数、贴纸、家长页统计全部失真 |
所以这一轮是新契约 + 新包 + 新宿主,只复用视觉与平台出口:契约在 packages/game-kit/src/arcade.ts(46 行),实现在 games/arcade(三款各自 *.logic.ts + *.scene.ts + 17 行入口),宿主是 ArcadePlayView.vue,路由是 #/arcade 与 #/arcade/<id>。8 个玩法模板、10 条内容 JSON、任务存档、vite 配置、体积预算一行都没动。
「不碰任务存档」可以 grep 验证:games/arcade 里没有 progress、没有 task:complete、没有 TaskId。彩蛋只发 arcade:* 四种事件,写档由外壳在 arcade:complete 之后调用 ProgressManager.playArcade()——与「模板不允许自己发奖励」是同一条纪律的镜像。
三款游戏的选择遵循同一条产品判断:保留微信小游戏的核心乐趣,去掉所有惩罚。
| 游戏 | 保留的核心乐趣 | 低龄化改造 | 不失败的处理 |
|---|---|---|---|
| 消灭星星 | 一按爆一片、下落补星、连消音调升高 | 8×10、4 色 + 花纹双通道、无倒计时 | 没有可消组时剩下的星星飞回天上并计入收集 |
| 弹一弹 | 瞄准 → 弹射 → 到处反弹 → 方块一串碎 | 任意位置拖拽瞄准、虚线 + 反弹点预测、一击碎 | 每 6 发下沉一格;贴底时「开出小花」飞走,本轮算完成 |
| 俄罗斯方块 | 拼、消行、越堆越高 | 8×14、四个大按钮 + 2 个预览、不做 hold | 堆到顶变「彩虹」,清掉最下 4 行继续玩 |
17.2 孩子看到的时间线
早上:小屋 / 任务页照旧,界面上没有任何彩蛋痕迹
│
完成第 4 件【不同的】今日小事
│
├─ 奖励照旧(竹笋 / 星星 / 碎片 / 贴纸)
└─ 结算浮层多出一行:「咦,竹林后面有一扇小门…」
│ [推开门] [等会儿]
▼
小屋墙上多了一扇亮着的小竹门(当天有效)
│ 推门
▼
竹林游戏厅:三张卡片(今天还能玩 / 今天玩过啦·明天再来 / 最好成绩)
│
▼
全屏小游戏:一局 60–120 秒 → 撒花 → 回游戏厅,这张卡变成「今天玩过啦」
│
第二天 00:00:小门消失,重新攒四件小事
四个落点各自只做一件事:completeTask() 的返回值 arcadeUnlocked 只在「这一刻从不满足变成满足」时为真;PlayView.vue 据此显示浮层(不自动跳转);HomeView.vue 用 v-if="progress.arcadeUnlocked" 决定小门在不在 DOM 里;ArcadeView.vue 在挂载时把未解锁的访问送回小屋。
「浮层只在跨过的那一刻出现」值得单独说:completeTask() 先取 arcadeWasUnlocked,结算完再取 isArcadeUnlocked(nextDay):
// packages/progress/src/manager.ts(节选)
const arcadeWasUnlocked = isArcadeUnlocked(day);
// …结算奖励、状态、连续天数…
return {
task: progress,
isFirstToday: firstToday,
stars,
shoots: reward.shoots,
xp: reward.xp,
fragments,
sticker,
arcadeUnlocked: !arcadeWasUnlocked && isArcadeUnlocked(nextDay),
};
不做「已有 4 件就补弹一次」:老存档里今天已经做过 4 件的孩子,第二天打开会莫名其妙被弹一个「发现一扇小门」。门应该出现,浮层不该补——补弹是在奖励一个没有发生过的瞬间。
17.3 解锁判定:按数量,不按推荐集
解锁只有一行判定,落在 packages/progress/src/arcade.ts:
export function arcadeUnlocked(day: DayRecord | undefined): boolean {
if (day === undefined) return false;
return day.completed.length >= DAILY_TASK_TARGET || arcadeUnlimited(day);
}
DAILY_TASK_TARGET 住在 daily.ts,与「今日推荐几件」共用同一个常量(export const DAILY_TASK_TARGET = 4;)。为什么不按「今日推荐的那四件」判定:推荐集是日期 + 时段做种子的(第 10 章),中午与晚上会换一批,按集合判定会出现「上午解锁、中午又锁上」,而孩子明明做了四件事。按数量判定天然稳定,day.completed 去重、只增不减。
判定由日记录派生,不额外存「已解锁」布尔值——少一个字段就少一类不一致。「今天还能不能玩某一款」是另一个函数,它把彩蛋也考虑进去:
/** 这一款今天还能不能玩(未解锁 → 都不能;已玩过 → 不能;隐藏彩蛋 → 随便玩) */
export function arcadeAvailable(save: SaveData, gameId: ArcadeGameId, now: number): boolean {
const day = save.days[dayKey(now)];
if (!arcadeUnlocked(day) || day === undefined) return false;
return arcadeUnlimited(day) || !day.arcade.includes(gameId);
}
arcadeStatus() 再把它们汇总成界面用的状态对象(unlocked / completed / target / played / remaining / unlimited),界面与测试读同一份实现。边界口径:重复玩同一件只算 1 件;第 4 件 23:59 完成则当天解锁、00:00 后重攒;后台跨夜回前台按新的一天算;老存档已有 4 件时门会出现但不补浮层;清档后门消失;只做 3 件时没有任何提示(完全隐藏,没有进度条);未解锁的 #/arcade 深链送回小屋,不给孩子一个「锁着的页面」。
17.4 竹林小路:连点竹竹 7 次
隐藏触发是一个纯前端判定,落在 HomeView.vue 的 onPandaTap():
/** 点几下 / 多长时间内点完:孩子能连出来,大人瞎点一般不会凑巧触发 */
const SECRET_TAPS = 7;
const SECRET_WINDOW_MS = 5000;
async function onPandaTap(): Promise<void> {
pandaWiggle.value = true;
if (wiggleTimer !== null) clearTimeout(wiggleTimer);
wiggleTimer = setTimeout(() => {
pandaWiggle.value = false;
wiggleTimer = null;
}, 260);
if (progress.arcadeUnlimited) return;
const now = platform.now();
pandaTaps = [...pandaTaps.filter((at) => now - at < SECRET_WINDOW_MS), now];
if (pandaTaps.length < SECRET_TAPS) return;
pandaTaps = [];
const outcome = await progress.unlockArcadeSecret();
if (outcome === null || !outcome.ok) return;
secretOpen.value = true;
platform.audio.play('unlock');
if (!settings.muted) {
void platform.audio.speak(t('arcade.secret.voice'), locale.current).done;
}
}
三个选择各有理由:7 次——5 次太容易被连点误触(点亲昵按钮时手指会连击),10 次以上连大人都不愿意试;触发前每点一下竹竹轻轻弹一下(pandaWiggle,260ms),所以它不像隐藏指令,更像一只被戳烦了的熊猫。5 秒滑动窗口——每次只保留最近的点击,孩子边玩边点也能自然积攒,数组随组件卸载丢掉。放在竹竹身上——它是小屋的视觉中心,触发点与「完成四件小事」完全分开;「戳熊猫」本来就是小屋里唯一不做任何事的手势,加一条隐藏路径不会与既有交互冲突。
写档的是 ProgressManager.unlockArcadeSecret():
async unlockArcadeSecret(now = this.clock()): Promise<ArcadeSecretOutcome> {
const today = dayKey(now);
const data = ensureDay(this.data, now);
const day = data.days[today]!;
if (day.arcadeSecret) return { ok: true, already: true };
const next: SaveData = {
...data,
updatedAt: now,
days: { ...data.days, [today]: { ...day, arcadeSecret: true } },
};
await this.commit(next);
return { ok: true, already: false };
}
只写一个布尔值,不写奖励、不写任务统计。「当天重复触发只当摸摸头」由 already: true 守住:界面因此不弹浮层,但仍然知道「状态是对的」。这个字段也解释了为什么 schema 要再升一版:arcade(跨天成绩)是 v2 加的,arcadeSecret(当天彩蛋)是 v3 加的;两次都只加字段,迁移函数是恒等函数,缺的字段由 sanitize 逐条兜底——老玩家的竹笋、星星、成长值一根都不会少。
17.5 第二套契约:没有任务、没有星星
packages/game-kit/src/arcade.ts 只有类型。它和 contract.ts 的差别不是「少几个字段」,而是中心的那个名词换了:
| 维度 | GameModule / GameContext |
ArcadeModule / ArcadeContext |
|---|---|---|
| 中心对象 | 一条任务(task: TaskDetail) |
一个彩蛋(manifest 自带元信息) |
| 身份 | id: TaskId |
id: ArcadeGameId(popstar / bounce / tetris) |
| 完成信号 | task:complete(stars / mistakes / correct / total) |
arcade:complete(只有 score / durationMs) |
| 关卡数据 | ctx.task.levelScript |
没有内容 JSON,种子 + 参数即可开局 |
| 退出 | requestExit('quit' \| 'replay') |
requestExit('quit'):没有「再玩一次」的路由语义 |
| 奖励 | 外壳据 task:complete 结算竹笋 / 成长值 / 贴纸 |
外壳只记最高分与局数 |
// packages/game-kit/src/arcade.ts(节选)
export interface ArcadeEventMap {
'arcade:start': { gameId: ArcadeGameId; at: number };
'arcade:round': { gameId: ArcadeGameId; round: number; totalRounds: number };
/** 一局真正结束:外壳据此写存档(最高分 + 今天玩过) */
'arcade:complete': { gameId: ArcadeGameId; score: number; durationMs: number };
'arcade:exit': { reason: 'complete' | 'quit' };
}
export interface ArcadeContext {
container: HTMLElement;
manifest: ArcadeManifest;
/** 复用同一个平台出口:小游戏只用到 audio / now */
services: PlatformServices;
locale: Locale;
reducedMotion: boolean;
/** ?autoplay=1 自动通关 / ?instant=1 跳过过场(与日常玩法同一套开关) */
debug?: DebugOptions;
emit<K extends keyof ArcadeEventMap>(type: K, payload: ArcadeEventMap[K]): void;
requestExit(reason?: 'quit'): void;
}
为什么仍传整个 PlatformServices 而不是 { audio, now }:注入方式必须与日常玩法一致——同一个单例、同一套静音状态、同一个时钟。彩蛋若单独建一个迷你平台对象,静音就会变成两处状态,第 9 章「三处同一状态」的规矩当场破掉。契约上写全,注释里写清只用到两个。
requestExit(reason?: 'quit') 没有 replay 是刻意的:日常玩法的「再玩一次」是路由级语义(原地卸载再挂载),而彩蛋每天每款一次,再来一局要等明天。少一个可选值,就少一条没人测的分支。
17.6 defineArcadeGame、三款入口与注册表
工厂函数与 defineGame 同构(38 行):
// games/arcade/src/defineArcadeGame.ts
export function defineArcadeGame(definition: ArcadeGameDefinition): ArcadeModule {
let game: Phaser.Game | null = null;
return {
manifest: definition.manifest,
async mount(ctx: ArcadeContext): Promise<void> {
game = createPhaserGame(ctx, { scenes: definition.scenes(ctx) });
},
async unmount(): Promise<void> {
game?.destroy(true);
game = null;
},
pause(): void {
game?.loop.sleep();
},
resume(): void {
game?.loop.wake();
},
};
}
每个游戏入口只有 17 行,只声明「我是谁、我要哪个场景」:
// games/arcade/src/games/popstar.ts
export const manifest: ArcadeManifest = {
id: 'popstar',
title: '消灭星星',
version: '0.1.0',
designSize: { width: ARCADE_DESIGN.width, height: ARCADE_DESIGN.height },
ageRange: [4, 8],
};
export default defineArcadeGame({
manifest,
scenes: (ctx) => [createPopstarScene(ctx)],
});
包的 exports 把三款锁成三个子路径(./popstar、./bounce、./tetris),Vite 因此切成三个独立 chunk。外壳侧是两张表:arcade/registry.ts 只放「id → 动态 import」,arcade/catalog.ts 只放展示元数据(emoji、卡片底色、nameKey / descKey)——registry 里没有一句文案,catalog 里没有一句中文,e2e 也从 catalog 取 testid。
vite 配置一行都没改:chunkFileNames 认的是 games/*/src/games/*.ts,所以小游戏入口自动得到 game-popstar-*.js 等文件名,Phaser 仍走共享的 vendor-phaser。这不是运气,而是当初把命名规则写成「路径 → 前缀」而不是「任务 id 列表」的回报。
17.7 ArcadeScene:三款游戏的公共底座
arcadeScene.ts(258 行)承担六件事:设计尺寸(750×1334,与日常玩法一致)、addBigButton()(可点区域 ≥96 逻辑像素、厚底 + 按压反馈、命中区走 setContainerHitArea)、HUD 药丸(第 n 轮、再收集 n 颗)、单槽表扬条(新条顶掉旧条,不遮目标)、轮次广播(arcade:round)、以及全仓唯一发 arcade:complete 的收尾。最后一项值得完整看:
/** 一局的唯一收尾:发 arcade:complete(外壳写存档)、撒花、显示成绩与唯一出口。 */
protected finish(score: number): void {
if (this.finished) return;
this.finished = true;
const safeScore = Math.max(0, Math.floor(score));
this.ctx.emit('arcade:complete', {
gameId: this.ctx.manifest.id,
score: safeScore,
durationMs: this.now() - this.startedAt,
});
this.sfx('complete');
this.say('arcade.doneVoice');
this.time.delayedCall(this.instant ? 0 : 420, () => this.showCelebration(safeScore));
}
/** 离开这一局(完成卡片上的按钮 / autoplay 自动走) */
protected leave(): void {
if (this.exited) return;
this.exited = true;
this.ctx.emit('arcade:exit', { reason: 'complete' });
this.ctx.requestExit();
}
两个 if 守卫挡的是同一类 bug:重复结算与重复退出。三款游戏都在多处调 finish()(达成目标、autoplay 结束、轮次推进),没有守卫就会出现「一局写两次存档、局数 +2」。单槽表扬条则来自一个具体现象:轮次切换时会连续发两条提示(「第 2 / 3 轮」与「这一轮完成啦」),叠在一起正好盖住 HUD 的目标药丸。
autoplay 的节奏也在这里统一:走与真实操作完全相同的处理函数,只是由场景自己喂输入——消灭星星每 120ms 用 largestGroup 选最大组再 tryPop;弹一弹按墙钟差 ×10 倍速、每 24ms 一片推进,每片先瞄准再 fireBall;俄罗斯方块每 80ms 用 planPlacement 选落点,再走 handleRotate / handleMove / handleHardDrop。三款都在撒花后延迟 900ms 自动 leave(),所以 e2e 只等 arcade:complete:<id> 与游戏厅重新出现。
17.8 消灭星星:连通组、无解兜底与确定性补星
全部规则在 popstar.logic.ts(349 行),场景只画棋盘、把点按换算成格子。
| 常量 | 值 | 含义 |
|---|---|---|
POPSTAR_COLS × POPSTAR_ROWS |
8 × 10 | 棋盘;格子 80 逻辑像素 |
POPSTAR_FAMILY_COUNT |
4 | 四种星星家族,颜色之外各有花纹(圆点 / 圆环 / 爱心 / 小花) |
POPSTAR_MIN_GROUP |
2 | 至少 2 颗连在一起才能消;单颗点了什么都不发生(不是失败) |
POPSTAR_ROUNDS / POPSTAR_GOALS |
3 轮 / 24、32、40 颗 | 一局最多收集 96 颗 |
POPSTAR_AUTO_STEP_MS / POPSTAR_HINT_AFTER_MS |
120 / 8000 | autoplay 节拍;8 秒不动轻闪最大组(不强制、不扣分) |
棋盘是行优先的一维数组(下标 = row * COLS + col,空洞为 null),一维让连通性逻辑更容易测,也让回放只需要一个数字数组:
/** 当前最大的组(不到 2 颗就不算「可消」,返回空数组);同大小时取靠前的那组 */
export function largestGroup(board: PopstarBoard): readonly number[] {
const visited = new Set<number>();
let best: readonly number[] = [];
for (let index = 0; index < board.length; index += 1) {
if (visited.has(index) || board[index] === null) continue;
const group = findGroup(board, index);
for (const member of group) visited.add(member);
if (group.length > best.length) best = group;
}
return best.length >= POPSTAR_MIN_GROUP ? best : [];
}
neighborIndices() 里两条不起眼但关键的处理:不能跨行绕回(col = 0 的左边不是上一行最后一格),不包含斜角。写错的话「连通组」用例当场红——这正是把棋盘放进纯逻辑的价值。
消除只有一个入口 tryPop(state, group),真实点击与 autoplay 都走它;它先 canonicalGroup() 重新校验(同家族、4 邻接、≥2 颗、不重复、必须是完整的一组),校验失败不扣任何东西,只交给无解兜底:
/** 空棋盘没有任何组时:剩下的星星飞回天上计入收集,然后补一副新棋盘(绝不失败) */
function settleNoMove(state: PopstarState): { state: PopstarState; autoCollected: number } {
if (state.status !== 'playing' || largestGroup(state.board).length >= POPSTAR_MIN_GROUP) {
return { state, autoCollected: 0 };
}
const remaining = filledCount(state.board);
if (remaining === 0) {
const board = dealBoard(state.seed, `round:${state.round}:empty:${state.pops}`);
return { state: { ...state, board }, autoCollected: 0 };
}
const collected = state.collected + remaining;
// …(节选:其余字段与状态推进略)
}
这就是「没有可消组时不失败」的全部实现:剩下的星星换算成收集数、棋盘按种子重发一副,孩子只看到星星飞走、新的落下来。它还顺手救了另一个边界:dealBoard() 里有一段兜底,把死棋的左上角两颗改成同家族,保证每个种子都能玩。补星同样确定性——collapseAndRefill() 用 seededRandom(hashString(...)),同一批种子永远得到同一副棋盘,所以「第三次消除后补下来的四颗是什么」可以写成断言。
计分口径就是「一局收集的星星总数」(最多 96):不是消除组数、不是连击倍率,幼儿园孩子也理解「收集了多少颗」。组越大音调越高(popTone(size))复用 AudioBus.tone(),不新增音效通道。
17.9 弹一弹:定步长、预测线与非惩罚性压力
反弹要可测,答案是把物理钉在固定步长上:bounce.logic.ts(792 行)全部按 8ms 一步推进,真实帧的余数留在 accumMs,慢手机与 CI 结果一致。
| 常量 | 值 | 含义 |
|---|---|---|
BOUNCE_STEP_MS |
8 | 物理固定步长;一帧可走多步,余数保留 |
shotSpeed / maxBounces / maxBalls |
1500 px/s / 8 次 / 3 颗 | 一颗球反弹 8 次回发射器;同时最多 3 颗 |
reloadMs / BOUNCE_AIM_LIMIT_DEG |
900ms / 75° | 装弹节奏;瞄准离竖直向上最多 75°,球永远往上打 |
BOUNCE_DROP_EVERY_SHOTS |
6 | 每 6 发方块整体下沉一格(保留原作的越拖越紧) |
BOUNCE_STARS_PER_BLOCK / BOUNCE_COMBO_BONUS |
3 / 5 | 每块 3 颗星;同一发击碎第 2、3… 块时每多一块 +5 分 |
一局 3 轮,方块 6 / 9 / 12,每轮混 2 个弹弹方块(bouncyBoost = 1.18,命中后球更快一点,是变化不是压力)。
/** 发射一颗球;第 6 / 12 / 18… 发会触发整体下沉(可能直接开花结束本轮) */
export function fireBall(state: BounceState, aim: BounceAim): BounceFireResult {
const events = emptyEvents();
if (!canFire(state)) return { state, fired: false, events };
const origin = launchPointFor(aim);
// (节选:BounceBall 的 id / x / y 等字段略)
const ball: BounceBall = { vx: aim.x * speed, vy: aim.y * speed, bounces: 0, hits: 0 };
if (next.shotsThisRound % BOUNCE_DROP_EVERY_SHOTS === 0) next = sinkBlocks(next, events);
return { state: next, fired: true, events };
}
/** 按固定步长推进一帧。dtMs 可以不是 8 的倍数,余数留在 accumMs 里。 */
export function advanceBounce(state: BounceState, dtMs: number): BounceAdvanceResult {
// …(节选:轮次暂停与装弹计时略)
while (accumMs >= BOUNCE_STEP_MS && steps < MAX_STEPS_PER_ADVANCE) {
next = stepPhysics(next, events);
accumMs -= BOUNCE_STEP_MS;
steps += 1;
}
}
三个细节:MAX_STEPS_PER_ADVANCE = 128 防「卡顿死亡螺旋」(切后台回来积累 5 秒时,不设上限会在单帧里跑 600 多步);函数返回 events(碎块 / 开花 / 换轮 / 结束)而不是直接播动画,逻辑层因此不认识 Phaser 也不认识音频;方块中心越过 flowerLine = 950 时整轮方块「开出小花」飞走并计入收集,本轮直接完成。
瞄准预测 predictAim() 也是纯函数:从发射点出发最多找两个反弹点,撞到方块就停。场景把 points 画虚线、bouncePoints 画圆点——预测与现实走同一份公式,所以「虚线指哪儿、球就打哪儿」不需要额外保证。autoplay 同样不是作弊,而是每 24ms 模拟一小片(×10 倍速),每片先 autoplayTarget() 瞄准再 fireBall(),与孩子拖拽发射共用 aimToward。
17.10 俄罗斯方块:7-bag、彩虹救援与 80ms 的 AI
tetris.logic.ts 512 行,是三者中规则最多的一份。
| 常量 | 值 | 含义 |
|---|---|---|
TETRIS_COLS × TETRIS_ROWS |
8 × 14 | 棋盘;格子 60 逻辑像素(原作 10×20) |
PIECE_KINDS |
I / O / T / S / Z / J / L | 7 种方块,7-bag 种子随机,右侧 2 个预览、无 hold |
TETRIS_GOAL_LINES / TETRIS_RESCUE_ROWS |
3 行 / 4 行 | 累计消 3 行完成;堆到顶时最下 4 行变彩虹 |
TETRIS_START_FALL_MS / TETRIS_MIN_FALL_MS |
1100 / 550 | 起始 1.1 秒/格,最快 0.55 秒/格 |
TETRIS_SCORE_PER_LINE / TETRIS_SCORE_PER_PIECE |
10 / 1 | 分数 = 行数 × 10 + 落子块数 |
7-bag 只有五行,却决定「同一个种子永远发同一串方块」:
/** 一袋 7 种各一次;同一 seed + 同一袋号永远得到同一个顺序 */
export function createTetrisBag(seed: number, index: number): PieceKind[] {
const random = seededRandom(hashString(seed + ':' + index));
return shuffled(PIECE_KINDS, random);
}
堆到顶的处理最特别,写在 lockPiece() 里:
// 堆到顶不失败:最下面 4 行变彩虹,等价于消掉 1 行,然后继续
while (collides(nextBoard, nextActive.kind, nextActive.rotation, nextActive.x, nextActive.y)) {
nextBoard = rescueBottomRows(nextBoard);
lines += 1;
rescues += 1;
rescuedNow += 1;
if (lines >= TETRIS_GOAL_LINES) {
finished = true;
break;
}
}
用 while 而不是 if:极端情况下新方块出生就被占两次,救援一次不够。这条路径同时是「一局必定能结束」的保证——每次救援都 +1 行,再糟糕的堆法也会在 3 行内收尾。重力加速是纯函数 fallIntervalForLines():1100 - lines * 180,下限 550;三行完成时正好 560ms/格,这不是巧合,是「3 行内不会眨眼就没」的推导结果。
autoplay 的 AI 只有两个函数:planPlacement() 枚举 4 种旋转 × 每一列,用 landingY() 找落点,再用 evaluatePlacement() 打分:
const score =
cleared * 1000 -
aggregateHeight * 1.6 -
holes * 27 -
bumpiness * 1.2 -
maxHeight * 3;
四项惩罚对应四种坏形状:总高度(堆太高)、空洞(填不满)、起伏(坑坑洼洼)、最高列(局部尖塔)。权重是手调的,但评分函数是纯的、可测的——单测里「AI 三轮内稳过」直接调 planPlacement() 跑完整局,而不是在浏览器里看它玩。场景侧两个坑也写进了注释:预览方块要画在比预览框更高的层级(否则被底色盖住);棋盘手势用无参 .setInteractive() 的默认命中区,所以它一直是好的——反过来印证第 8 章的结论。
17.11 存档:v3、每天每款一次与「中途退出不算」
彩蛋对存档的改动只有三处,全部是加字段:SAVE_SCHEMA_VERSION = 3;SaveData.arcade: Record<ArcadeGameId, ArcadeProgress>(跨天保留的 best / plays / lastPlayedDay);DayRecord.arcade: ArcadeGameId[](今天玩过哪些)与 DayRecord.arcadeSecret: boolean(今天触发过竹林小路)。
跨天与当天分开存是关键:SaveData.arcade 里的最好成绩不能被午夜清零;DayRecord.arcade 是消耗品,跨天自动重置。若偷懒把「今天玩过」也写进跨天记录(加一个 lastPlayedDay 比较),午夜重置就变成「每次读取时比较日期」——一处漏判,孩子第二天就玩不了。
写档的纯函数是 withArcadePlay():
/** 记录一局:最高分只增不减、总次数 +1、今天玩过。纯函数,落盘由 Manager 负责。 */
export function withArcadePlay(save: SaveData, gameId: ArcadeGameId, score: number, now: number): SaveData {
const today = dayKey(now);
const data = ensureDay(save, now);
const day = data.days[today]!;
const safeScore = Math.max(0, Math.floor(score));
const previous: ArcadeProgress = data.arcade[gameId] ?? { best: 0, plays: 0, lastPlayedDay: null };
return {
...data,
updatedAt: now,
arcade: {
...data.arcade,
[gameId]: { best: Math.max(previous.best, safeScore), plays: previous.plays + 1, lastPlayedDay: today },
},
days: {
...data.days,
[today]: { ...day, arcade: day.arcade.includes(gameId) ? day.arcade : [...day.arcade, gameId] },
},
};
}
ProgressManager.playArcade() 是唯一入口,四道判定顺序固定:非法 id(unknown)→ 未解锁(notUnlocked)→ 已玩过且没彩蛋(already)→ 写档。第三道写成 !isArcadeUnlimited(day) && day.arcade.includes(gameId)——彩蛋模式下「已玩过」不再是拒绝理由,这就是「无限畅玩」在存档层的全部实现。
「中途退出不消耗机会」由宿主保证:ArcadePlayView 的 handleEvent() 只认 arcade:complete(且有 recorded 守卫),点「回去」走 leaveNow()——只记 arcade:exit:quit 并导航,一个字节都不写档。孩子误触退出,今天的这一款还在。
| 界面状态 | 判定 | 数据来源 |
|---|---|---|
| 今天还能玩 | 已解锁且(彩蛋或今天没玩过) | arcadeAvailable() |
| 今天玩过啦·明天再来 | 已解锁、今天玩过、没有彩蛋 | day.arcade.includes(id) |
| ∞ 无限畅玩 | 今天触发了竹林小路 | day.arcadeSecret |
两个小落点各一行:家长页「今天和竹竹玩了 {n} 局小游戏」、日记「今天还和竹竹玩了:消灭星星、弹一弹」。游玩时长照旧走 addSeconds(30)(宿主每 30 秒一次),所以 20 分钟休息提醒对彩蛋同样有效——彩蛋不是法外之地。
17.12 外壳接线:两条路由、两个宿主、一层守卫
路由只加一个分支:
// apps/site/src/router.ts(节选)
// 隐藏小游戏:未知 id 回游戏厅(不给孩子一个空白页)
if (head === 'arcade') {
const arcadeId = asArcadeGameId(parts[1]);
return arcadeId ? { name: 'arcadePlay', arcadeId } : { name: 'arcade' };
}
asArcadeGameId() 与 asTaskId() 是同一套白名单收窄(ARCADE_GAME_IDS.includes 之后才断言),所以 #/arcade/tetris2 掉回游戏厅。App.vue 里 ArcadeView 与 ArcadePlayView 都是 defineAsyncComponent,isFullScreen 收进 arcadePlay——彩蛋是全屏画布,顶栏与底栏会直接压在游戏上;游戏厅则不是全屏页,孩子要能按底栏回小屋。
两个宿主各有一道守卫:ArcadeView 挂载后 init() + refresh(),未解锁 navigate('/');ArcadePlayView 先 await progress.init(),未解锁回小屋、今天玩过回游戏厅(孩子是从那里点进来的,回去还能看到「今天玩过啦」与另外两款)。四条纪律与 PlayView 一致:shallowRef 持有模块、先读档再挂载、unmount() 真正销毁 Phaser、前后台 pause()/resume()。多出来的两条是静音按钮(直接调 settings.toggle(),与全站同一状态)和退出确认(「再玩玩 / 回去」)。
返回键也补了一条:backButton.ts 里 arcadePlay → /arcade,其它页面照旧先回小屋。文案层新增 60 个 arcade.* 键(中英各一份),语音只加三句(每款 intro、彩蛋触发、完成),音效完全复用现有 12 个 SfxKey——没有新增一个音频文件。
17.13 测试:75 条单测 + 8 条 e2e
彩蛋新增 75 条单测,占全仓 167 条的 45%:
| 文件 | 用例数 | describe | 守什么 |
|---|---|---|---|
popstar.logic.test.ts |
15 | 6 | 连通组、下落压缩、补星确定性、无解自动收集、三轮目标、提示选择 |
bounce.logic.test.ts |
21 | 6 | 定步长碰撞、反弹次数、瞄准预测、下沉节奏、贴底开花、三轮完成、autoplay |
tetris.logic.test.ts |
20 | 8 | 7-bag 确定性、旋转踢墙、落地锁定、消行、影子、重力加速、彩虹救援、AI 稳过 |
packages/progress/src/arcade.test.ts |
19 | 4 | 3 件不解锁 / 4 件解锁、重复不计数、跨天重置、每天一次、中途退出不消耗、v1→v3 迁移 |
它们全部能在 Node 里跑,没有 jsdom、没有 canvas 桩——*.logic.ts 与 progress/arcade.ts 都不 import Phaser。这是「逻辑与渲染分离」在实时玩法上唯一一次真正的压力测试:帧循环、反弹、消行、AI 都被关在纯函数里。
e2e 新增 e2e/arcade.spec.ts 8 条(5 条静态 + 3 条按游戏循环):隐藏(没有门、深链回小屋)、解锁(第 4 件 → 浮层 → 三张卡)、每天每款一次(再点与深链都被拦)、竹林小路(连点 7 次 → 无限 → 同一款连玩两局 plays === 2)、三款 autoplay 写档、英文游戏厅。存档不是「先玩四关」凑的,而是直接往 IndexedDB 播种一份最小存档(seedSave),且刻意写成 schemaVersion: 2 —— e2e 的种子数据本身也成了一条迁移用例。
观测仍靠调试桥,事件名是扁平的:arcade:start:popstar / arcade:round:popstar:1 / arcade:complete:popstar / arcade:exit:complete。createArcadeContext.ts 的 detailOf() 负责把 payload 折成一行,与 createGameContext 同构,所以断言只需 waitEvent(page, 'arcade:complete:popstar'),不用猜 DOM。第 8 章那次命中区修复也在这里留了一条回归用例——ArcadeScene.addBigButton 与 BaseScene.addButton 是同一套实现,坑也会被两个人共用。
17.14 体积与边界
| 项 | 实测(2026-10-20 构建) | 说明 |
|---|---|---|
game-popstar / game-bounce / game-tetris |
11,755 / 14,593 / 11,612 B(gzip 4.3 / 5.5 / 4.2 KB) | 只在点开那一款时下载 |
共享底座 rng-*.js |
4,447 B | ArcadeScene + 确定性随机,三款共用 |
| 首屏 JS + CSS | gzip 58.9 KB(预算 60 KB) | 彩蛋入口让首屏多约 0.8 KB,靠日记页改路由懒加载换回来 |
| dist 原始大小 | 1,313,452 B(约 1.28 MB,53 个文件) | 预算 2400 KB |
三款 chunk 合计 37,960 B(gzip 14 KB),只在点进某一款时下载——「一个不玩彩蛋的孩子永远不为彩蛋付流量」与「不玩任何任务的孩子永远不为引擎付流量」是同一条纪律。
产品边界同样明确:每天最多 3 局、每局 60–120 秒,合计不超过 5 分钟,20 分钟休息提醒照旧;没有排行榜、推送、连续签到、内购,最好成绩只在本机;不发竹笋 / 成长值 / 贴纸,不写 task:complete,连续天数、贴纸册、家长页统计都不受影响;一局结束只有「回游戏厅」一个出口,避免「点一下又开一局」的无限循环。
17.15 小结
隐藏小游戏是一次「往成熟结构里加东西」的演练:
- 新的东西用新契约:
arcade.ts只有 46 行,却把「彩蛋」与「任务」彻底分开——没有 TaskId、没有星星、不发task:complete; - 共用的东西走既有出口:视觉、音频、平台服务、按钮命中区、调试桥、体积门禁全部复用,构建配置一行没改;
- 规则放纯函数:连通组、定步长反弹、7-bag、彩虹救援、AI 评分、解锁判定、写档合并全部可单测,75 条用例在 Node 里毫秒级跑完;
- 不失败是设计出来的:无解自动收集、贴底开花、堆顶彩虹——三款游戏都没有「输」这个状态;
- 隐藏也是产品需求:未解锁时 DOM 里没有入口、深链被送回、没有进度条、没有「锁着的页面」;彩蛋是孩子的动作(连点 7 次),不是大人的快捷键,当天有效、跨天消失、不发奖励。
这一章是正篇的最后一章。第 14 章拆过的门禁、第 15 章讲过的两条打包链、第 16 章记下的坑,同样罩着这三款彩蛋——它们不是「顺便做的小玩具」,而是同一套契约、同一套测试、同一套体积预算下的第三类玩法包。要加第四款,路径是现成的:写一份 *.logic.ts、一份 *.scene.ts、一个 17 行的入口,在 ARCADE_GAME_IDS 与 ARCADE_CATALOG 各登记一行,其余不用动。