第07章 - 玩法模板库详解
7.1 模板库要解决的问题
没有模板库时,「30 个典故」就是 30 份从零开始的游戏代码。模板库的思路是给玩法骨架建模,把每个典故的成本压缩为:
一个游戏 = 若干模板(骨架)+ 一份内容 JSON(血肉)+ 专属美术与叙事(气质)+ 约 20% 定制代码。
目标比例是 60% 复用 / 40% 定制。有复用就有同质化的风险,对策是两条硬性要求:
- 每个典故必须有自己的「动词」与签名画面(第 1 章的表);
- 模板只提供交互原语与循环,画面完全外置 —— 同一个 tap 循环下,盘古撑天、精卫投石、夸父奔跑、愚公挖山四段画面毫无关系。
packages/templates 的组成:
packages/templates/src/
├─ story.ts / substitution.ts / tap.ts / place.ts / aim.ts
├─ sort.ts / trace.ts / wait.ts / quiz.ts / reward.ts # 十个场景工厂
├─ *.logic.ts # 对应的纯函数逻辑
├─ schemas.ts # 各模板关卡脚本的 zod schema + 注册表
├─ layout.ts # 750×1334 设计坐标常量(e2e 直接复用)
├─ session.ts # 一局状态(mistakes/correct/total)+ 星星规则
├─ defineGame.ts # 三段式装配(第 4 章)
└─ cli/check-content.ts # 内容与译文校验入口
7.2 模板的通用形状
所有模板都是同一套模式:工厂函数返回场景类,配置对象里除了数据,全是「由游戏包提供的绘制回调」。
export function createXxxScene(ctx: GameContext, cfg: XxxConfig): Phaser.Types.Scenes.SceneType {
return class XxxScene extends BaseScene {
create() { /* 组装画面、监听输入、启动逐帧循环 */ }
};
}
通用配置字段:
| 字段 | 作用 |
|---|---|
level |
已经过 zod 解析的关卡脚本(来自内容 JSON) |
session |
一局共享的状态(星星由 mistakes 推导,各场景不各算一套) |
nextSceneKey |
完成后跳转的场景(SCENE_KEYS 里的常量) |
titleKey |
顶栏标题的文案键(界面文案,不是典故文字) |
drawXxx 回调 |
画面绘制全部外置:drawStage / drawPiece / drawOrb / drawWorld / drawFinale … |
reveal / revealY |
完成时的一句揭晓语及其位置(来自 keyMoment.reveal) |
onXxx 回调 |
游戏接管特定瞬间(里程碑、诱饵点击、归位语音),处理完调 done() / resume() |
三条贯穿所有模板的行为规则:
- 逐帧重画:需要动起来的模板每帧调用
drawStage(g, metrics),游戏包在这一个回调里画整个世界(水位、角色、车马),而不是维护一堆 Sprite 引用; - autoplay 走同一代码路径:无头验证分支不模拟点击,而是直接调用与真实操作相同的内部方法(如
addStoneToBoat、settle、carve); - 零失败:放错、划歪、点错都不判错、不扣分,弹回原位继续试;仅少数「刻意体验」的动作会计入
session.mistakes(如把大象直接拖上秤)。
7.3 模板清单
| 模板 | 交互原语 | 已用于 | 可复用方向 |
|---|---|---|---|
story |
分幕绘本 + 旁白(所有游戏共用开场) | 全部 12 部 | 通用 |
substitution |
拖拽 + 水痕:等量代换 | 曹冲称象 | 原理与转化类 |
tap |
连点 / 长按推进一个进度(画面完全自绘) | 盘古撑天、精卫、夸父、愚公、大禹筑堤 | 坚持、积累类 |
place |
把东西拖到它该去的地方 | 盘古化身、女娲补天、杞人忧天 | 归属、修补类 |
aim |
拉弓瞄准射击 | 后羿射日 | 精准、分寸类 |
sort |
分类归位(可带「先尝后分」) | 神农尝百草 | 辨别类 |
trace |
手指划路,按顺序过锚点 | 大禹治水 | 疏导、路径类 |
wait |
以「不动作」为玩法 | 太公钓鱼 | 等待、坚持类 |
quiz |
选择/判断(复习用,首次通关不出现) | 复习中心(尚未接入) | 记忆巩固、迁移测验 |
reward |
典故卡 + 完成 | 全部 12 部 | 通用 |
beacon(数据登记) |
烽火传递 + 信任递减 | 烽火戏诸侯(专属场景) | 一次性机制 |
TemplateId 联合类型里还预留了 match / order / drag / board / timing / react / branch / build / grow 等未实现模板的 id,用作 30–100 个典故路线的占位(grow 曾被用作盘古 manifest 的主模板标签,实际玩法走的是 tap)。
7.4 story:分幕绘本
所有游戏共用同一个开场:一屏一个画面、一句话,点哪都行。没有「下一幕」按钮,也没有文字选择题。
export interface StoryConfig {
beats: StoryBeat[]; // 来自 ctx.content.story
nextSceneKey: string; // 最后进入哪个玩法场景
drawBeat?: (g, index, rect) => void; // 每个典故自己的分幕插图
}
实现上的三个细节(story.ts):
tapToContinue()是全屏的透明 Zone + 一只小手;350ms 的输入锁防止连点跳幕(连点第二下会落在下一幕刚建好的点击区上);- 正文排版:标题在
y=205,正文从y=950开始,宽度 596(44px 汉字一行 13 个字,避免出现「12/12/12/2」式的孤行);小手跟随正文实测高度,放不下就移到右下角 —— 英文更长的句子不会压住最后一行; if (!this.autoplay) this.speak(beat.narration):自动通关时不放语音,避免无头测试里语音队列堆积。
7.5 substitution:等量代换(曹冲称象)
substitution 用一个内部状态机走完四步:
load(想直接称:拖大象上秤 → 秤被压垮)
→ mark(大象上船 → 水痕出现 → 点船身刻一刀)
→ substitute(大象下船 → 从岸边石堆拖石头上船,直到水痕回到水面)
→ weigh(石头一块块搬上秤,读数累加)
→ done(等式:大象 = 3000)
核心判定是三条纯函数(substitution.logic.ts):
export type WeighVerdict = 'light' | 'match' | 'heavy';
/** 重物上船时刻下的水痕位置(0=刚碰到水,1=完全没入) */
export function markRatio(level: SubstitutionLevel): number {
return clamp01(level.heavy.weight / level.capacity);
}
/** 当前载重对应的吃水比例 */
export function depthRatio(total: number, level: SubstitutionLevel): number {
return clamp01(total / level.capacity);
}
export function judge(total: number, level: SubstitutionLevel): WeighVerdict {
const diff = total - level.heavy.weight;
if (Math.abs(diff) <= level.tolerance) return 'match';
return diff < 0 ? 'light' : 'heavy';
}
关卡数据里没有「答案数字」,只有物理量:
{
"heavy": { "weight": 3000 }, // 大象
"capacity": 3600, // 船的载重上限
"tolerance": 100, // 容差
"stones": [
{ "id": "big", "value": 500, "radius": 36 },
{ "id": "mid", "value": 300, "radius": 26 },
{ "id": "small", "value": 100, "radius": 17 }
],
"hints": {
"tooLight": "再加几块石头",
"tooHeavy": "拿掉一块试试",
"stuck": "让水痕回到水面"
}
}
判定与画面分离的好处在这里体现得很直接:水位线、水痕、船上石头的布局都从 depthRatio 推导,而 judge 只在数值层面判定 —— 两个函数都各有单测(substitution.logic.test.ts)。
schema 侧还有一条「内容写错不让孩子发现」的规则:canReach() 用有界完全背包在构建期验证「这些石头能不能凑出容差内的重量」,凑不出直接拒绝这份内容(SubstitutionLevelSchema 的 refine)。
其他实现要点:
- 石堆是无限取用点:拖走的那块「变成」船上的石头,石堆立刻复位 —— 孩子不需要数数,只需要重复动作;
- 卡住 9 秒才有提示(
checkIdle),提示语是hints.tooLight / tooHeavy / stuck,24 字以内; - 还轻/太沉用箭头而不是文字:向下加料、向上拿掉;
- 刻痕的坐标由
markRatio求出,视觉上水痕随载重上下移动,孩子能直接「看到」代换成立的那一刻。
7.6 tap:连点 / 长按推进
tap 是复用面最大的模板,五部游戏(盘古、精卫、夸父、愚公、大禹筑堤段)共用,但四段画面毫不相干。秘密在配置接口:
export interface TapConfig {
level: TapLevel;
session: SessionState;
nextSceneKey: string;
titleKey: MessageKey;
/**
* 画面完全由游戏决定。
* 模板只提供"连点/长按推进"这套循环。
*/
drawStage: (g: Phaser.GameObjects.Graphics, stage: TapStageMetrics) => void;
drawFinale?: (g, stage) => void;
/** 里程碑:游戏接管,处理完调用 api.resume() 继续 */
onMilestone?: (id: string, api: TapMilestoneApi) => void;
/** 每帧回调:精卫一直在飞、夸父的太阳一直在跑 */
onFrame?: (stage: TapStageMetrics, delta: number) => void;
onAdvance?: (stage: TapStageMetrics) => void;
finaleHoldMs?: number;
}
TapStageMetrics 每帧携带 progress(原始)与 eased(缓动)两个进度:
/** 画面用的缓动进度:resistance 越大,后半程显得越吃力 */
export function easedProgress(value: number, level: TapLevel): number {
return Math.pow(progressFor(value, level), 1 + level.resistance);
}
两个模式:
mode: 'tap':每次点击 +stepValue;schema 强制targetValue % stepValue === 0(否则永远点不满);mode: 'hold':按住推进,每秒 +stepValue(夸父的奔跑)。这里有一条来自真机测试的教训:
// 长按:松手就停,会有"追不上"的紧张感。
// 记下起点,之后**按墙钟时间**算进度,不再累加帧间隔。
// Phaser 在帧率低时会钳制 delta,慢机器上"按住 6 秒"累积不到 6 秒的进度 ——
// 而旧安卓手机正是目标用户。
this.holdStartedAt = this.ctx.services.now();
this.holdStartValue = this.value;
里程碑是 tap 模板最有表现力的一环:愚公的三条里程碑(智叟出现、换代 ×2)会在推进到指定进度时暂停循环,把控制权交给游戏包,处理完(如等玩家点一下)再 api.resume():
onMilestone: (id, api) => {
if (id === 'zhisou') {
zhisou = true;
ctx.services.audio.play('wrong');
waitForTap(api, () => { zhisou = false; api.resume(); });
return;
}
handover = true;
waitForTap(api, () => {
handover = false;
generation += 1;
ctx.services.audio.play('unlock');
api.resume();
});
},
每条里程碑触发时会发 beat:enter:milestone:<id> 事件 —— 这不是可选项:没有事件,把暂停逻辑删掉测试也会全绿,所以事件是让里程碑可观测、可测试的协议。
其他细节:
- 计数气泡固定在底部(
TAP.counterY),画面变化时它不动;长按模式显示前必须Math.round(),否则会出现814.967999999994这类小数; - 音调随进度上升(
audio.tone(300 + eased * 520, 90))—— 进度是「听得见」的; - 小手在玩家「会了」之后收起(连点模式第一次点即收起;长按模式按住 420ms 才算会),卡住 6 秒把小手找回来并显示提示;这条逻辑对两种模式都生效(曾经只写在连点分支里,导致夸父永远不提示,已修复)。
7.7 place:把东西放回该去的地方
export interface PlaceConfig {
level: PlaceLevel;
session: SessionState;
nextSceneKey: string;
drawPiece: (g, kind, x, y, filled) => void; // 槽位与归位后的样子
drawOrb: (g, kind) => void; // 光球的画法
reveal: string;
drawFinale?: (g, placedIds) => void;
}
两条刻意的设计:
- 零失败:拿起光球时,它该去的槽位自己发光(提示圈),放错不惩罚、弹回托盘继续试;
- 用听不用看:每归位一件,用语音念出
orb.speak(「左眼变成了太阳」)。
判定逻辑全在 place.logic.ts:findSlotAt(在槽位半径 + dropForgive(70px)内找最近槽位)、isCorrect(orb.slotId === slot.id)、isPlaceComplete(按去重后的槽位数计数 —— 曾经 ['sun','sun'] 会被算成两个洞)。归位后的完整演出由 drawFinale 接手,例如女娲补完天后的五色霞光。
7.8 aim:拉弓瞄准射击
aim 的判定精髓在于箭很快,必须按「这一帧扫过的线段」判定,否则会直接穿过目标:
/** 点到线段的最短距离 */
export function distanceToSegment(p, a, b): number { /* ... */ }
/**
* 这一帧里被命中的目标。
* 命中半径内可能有多个,取**沿弹道最先碰到的那一个** —— 这才是箭的语义;
* 只按垂直距离取最近会在几发并排时选错。
*/
export function firstHit(from, to, targets, radius): string | null {
let best: string | null = null;
let bestT = Number.POSITIVE_INFINITY;
for (const target of targets) {
if (distanceToSegment(target, from, to) > radius) continue;
const t = projectionT(target, from, to);
if (t < bestT) { best = target.id; bestT = t; }
}
return best;
}
配置由游戏包提供弓箭手位置、瞄准线、靶子的绘制与命中回调;AimApi 允许游戏接管后续:aliveIds() / revive(id) / lock(ms) / redraw() / complete()。后羿的「射九留一」全在这套 API 上实现:
- 射到只剩 1 个 → 大地回春,
graceTimer延时 2.4 秒再收工,给好奇的孩子再射一箭的机会; - 把最后一个也射了 → 天黑,屏幕上出现「要留一个太阳」,太阳自己回来 —— 零惩罚,自己撞一次就懂了。
if (alive.length === 0) {
// 他真的把最后一个也射了 —— 取消刚才那次"准备收工"
graceTimer?.remove(false);
graceTimer = null;
api.lock(3000);
ctx.services.audio.play('wrong');
/* 黑幕 + 提示 → 1.8 秒后 api.revive(id) 把太阳放回来 */
}
7.9 sort:分类归位(先尝后分)
sort 的基础是「把东西分进对的那一堆」,但神农尝百草需要更强的机制:先尝后分。有 probe(嘴边)的关卡里,没尝过的东西放到木牍上会被送回嘴边先尝一口 —— 不判错、不扣分,但「想分类,先自己试一口」这条教学核心由数据结构强制:
.refine((l) => l.probe === undefined || l.items.every((i) => i.tastedKind !== undefined), {
message: '有"先尝后分"却没有给每件东西写 tastedKind —— 尝完什么都看不出来',
path: ['items'],
})
流程图:
拿起东西(未品尝)→ 嘴边发光 → 拖到嘴边 → onTaste 演出(游戏画表情/落叶)
→ 记录 tasted → 重新画成"尝过的样子"(靠图案不靠文字)
拿起东西(已品尝)→ 该去的那块木牍发光 → 拖入 → 缩小留在木牍上 → 计数
全部归位 → drawFinale(药书上的字一片片亮起来)→ reveal
onTaste(item, api) 让游戏接管品尝表现(神农会皱眉、冒星星),完成后调 api.done() 继续;sort.logic.ts 提供 needsTaste / nextUntasted / probeHit / placedIn 等纯函数,卡住提示与无头自动都建立在它们之上。
7.10 trace:手指划出一条路
trace 只判顺序,不判手感:
/**
* 往前推进一格 —— **只前进,不后退**:
* 划歪了、划回去了都不会把已经过的锚点退掉,孩子不会因为手抖丢进度。
*
* prev 用来挡"瞬移"(手指抬起后落在别处,不该算一条河道),
* 但它只是防作弊,不是精度要求。
*/
export function advanceTrace(level, cursor, point, prev?): TraceCursor {
const segment = currentSegment(level, cursor);
if (!segment) return cursor;
if (prev && Math.hypot(point.x - prev.x, point.y - prev.y) > level.maxGap) return cursor;
const target = currentTarget(level, cursor);
if (!target) return cursor;
if (Math.hypot(point.x - target.x, point.y - target.y) > target.radius) return cursor;
if (cursor.anchor < segment.anchors.length) {
return { segment: cursor.segment, anchor: cursor.anchor + 1 };
}
return { segment: cursor.segment + 1, anchor: 0 };
}
大禹治水用三段河道(ditch 田间 → mountain 山间 → sea 入海),每段有 3 个锚点(半径 70,给手指留足宽松度)和一个终点。每划完一段触发 onSegmentDone:「三过家门」的演出(窗里亮着灯,他都没有进去),做完调 api.done() 继续 —— 并且有 1.5s/2.4s 双重兜底,补间被打断也绝不让玩家卡在门口。drawStage 每帧按 metrics.path(已划出的轨迹)画水流、露出来的土地与绿苗。
7.11 wait:以「不动作」为玩法
太公钓鱼的主线只有一件事:等。三条刻意的设计(接口注释原文):
- 按墙钟算(不累加帧间隔):慢手机、CI 上「等了 45 秒」就是 45 秒。这是夸父那次的教训;
- 诱惑只是分心:点换钩/换饵/挪地方,终点后移(默认每次 +5 秒),但
penaltyCap(默认 3 次)封顶 —— 推满之后只回应、不再延后,不存在等不到头; - 最后一件事是动作:等到了要自己把直钩收起来(
finale),否则这一场白等。
/**
* 点了一次诱惑:把终点往后推一点。
* 推满 penaltyCap 次之后只回应、不再延后 —— "分心就得多等一会儿",但不能等不到头。
*/
export function applyTemptation(level, state, penaltyMs): WaitState {
if (state.hits >= level.penaltyCap) return state;
return { penaltyMs: state.penaltyMs + Math.max(0, penaltyMs), hits: state.hits + 1 };
}
无头验证时,effectiveDuration(level, autoplay) 把 45 秒压成 7 秒以内(WAIT.autoplayFactor = 7,下限 1.5 秒)—— e2e 不能真等 45 秒。还有一个 fallbackMs: 16000 的兜底:等到之后一直不点「收钩」也不会卡死。
7.12 quiz 与 reward
quiz:已实现、未接线的复习模板
quiz 实现了完整的问答场景:recall / transfer 两类题、答对答错的即时反馈与解释、完成后把 correct/total 写回 session。但它目前没有生产调用方 —— 「每局结束都考三题」与「少写字、多动手」的产品原则冲突,更合适的入口是「到期复习」(从图鉴点进到期的典故做一次小测验)。项目把这件事明确记录为「没做完」而非 bug,数据(每典故 3 道题、21 道题双语)都保留着,接的时候要一并修掉模板里硬编码的中文与偏低的对比度(第 16 章)。
reward:典故卡
每局的最后一幕。版面必须先量后排:中文「曹冲称象」五个字,英文 “Cao Chong Weighs the Elephant” 三十个字母,固定坐标排版必然重叠(早期英文版就是这么糊掉的)。实现是:所有文字块按卡片内宽换行 → 量出总高度 → 卡片高度由内容决定 → 万一仍超出可用高度,整体等比缩小,宁可小一点也不重叠。
const stars = starsFor(cfg.session); // 1~3 星,没有 0 星(对小朋友永远给星星)
// ... 量排七块文字:标题 / 拼音 / 星星 / 寓意 / 关键词 / 出处 / 原文
this.ctx.emit('card:unlocked', { allusionId: cfg.allusionId, cardId: cfg.allusionId });
this.ctx.emit('level:complete', {
levelId: 'main', stars,
mistakes: cfg.session.mistakes, correct: cfg.session.correct, total: cfg.session.total,
durationMs: now - cfg.session.startedAt,
});
星星规则(session.ts):错 ≤ 1 次三星、≤ 3 次两星、再多一星 —— 门槛刻意宽松,这是给小朋友的,宁可多复习一次也不要打击信心。「收下」按钮的坐标在 REWARD.done 里固定(375, 1190),自动化用例直接按坐标点。
7.13 游戏专属场景:40% 定制代码的落点
模板库无法覆盖的一次性机制,做成游戏包自己的场景。当前有两个例子:
- 盘古的「开蛋」(
games/pangu/src/scenes/chaos.ts):连点 4 次蛋壳,裂缝一道道出现,最后炸开 —— 亮粒子上升、暗块下沉,用画面完成「阳清为天,阴浊为地」,不写一个字。它还承担了一个叙述责任:全程没有死亡表现,盘古是「躺下 → 化成光 → 光一件件回到世界」; - 烽火的「点烽火」(
games/fenghuo/src/scenes/beacon.ts):点一次火 → 火光一座座传向天边 → 信任少一格。它是完全一次性机制,但关卡数据仍然登记在模板层的 schema 注册表里(BeaconLevelSchema):
/**
* beacon 关卡(烽火戏诸侯专用):
* 场景本身留在这个游戏包里(像盘古的开蛋场景一样),但**数据在这里登记**,
* 这样双语内容校验、翻译体检、schema 体检都会覆盖到它。
*/
export const BeaconLevelSchema = z.object({ /* towers / decoyRounds / lines / hint */ });
这条「场景可专属、数据必登记」的规则保证了:专属场景的内容同样被 content:check、翻译完整性、schema 体检覆盖,不会有「体检盲区」。
7.14 新增一个模板的检查清单
当已有模板都装不下一个典故的核心玩法时,才新增模板。步骤(以新增 match 为例):
- 定 schema:在
packages/templates/src/schemas.ts写MatchLevelSchema,把「教学核心」写成 refine(参考 sort 的「先尝后分」约束); - 注册 schema:加入文件末尾的
templateSchemas注册表 —— 这一步之后,content:check自动开始校验所有使用它的内容(含双语合并后的二次校验); - 登记场景键:在
layout.ts的SCENE_KEYS里加match: 'match';如需新坐标常量,一并加进 layout(e2e 会复用); - 写纯逻辑:
match.logic.ts(判定、进度、并集去重计数),不 import Phaser、不用Math.random/Date.now(lint 会拦); - 写场景工厂:
match.ts,实现工厂 +drawXxx回调外置 + autoplay 分支 + 卡住提示 + 逐帧/事件驱动; - 写单测:
match.logic.test.ts覆盖判定与边界(参考place.logic.test.ts的 12 条用例); - 导出:在
packages/templates/src/index.ts导出(注意与现有同名导出显式改名,文件里已有多个先例); - 游戏使用:写作战章(第 12 章)的流程接一个典故进去;
- 端到端:在
e2e/smoke.spec.ts给使用它的典故加真实操作 + autoplay 用例。
7.15 小结
- 模板 = 交互原语 + 循环 + 判定(logic)+ 外置画面(draw 回调),十个场景工厂覆盖了十二部游戏;
- 各模板的教学核心被写进了 schema:可通关性(substitution 的背包校验)、先尝后分(sort)、只进不退(trace)、永远等得到(wait)、整除(tap);
- 兼容真实设备行为的坑都有注释与单测兜底:墙体钟计时、命中线段扫描、输入锁、去重计数;
- 专属场景允许「一次性机制」,但数据必须登记,保证质量工具全覆盖。
下一章看这些模板脚下的地基:Phaser 渲染工具箱(phaser-kit)。