第07章 - 玩法模板库详解

7.1 模板库要解决的问题

没有模板库时,「30 个典故」就是 30 份从零开始的游戏代码。模板库的思路是给玩法骨架建模,把每个典故的成本压缩为:

一个游戏 = 若干模板(骨架)+ 一份内容 JSON(血肉)+ 专属美术与叙事(气质)+ 约 20% 定制代码。

目标比例是 60% 复用 / 40% 定制。有复用就有同质化的风险,对策是两条硬性要求:

  1. 每个典故必须有自己的「动词」与签名画面(第 1 章的表);
  2. 模板只提供交互原语与循环,画面完全外置 —— 同一个 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()

三条贯穿所有模板的行为规则:

  1. 逐帧重画:需要动起来的模板每帧调用 drawStage(g, metrics),游戏包在这一个回调里画整个世界(水位、角色、车马),而不是维护一堆 Sprite 引用;
  2. autoplay 走同一代码路径:无头验证分支不模拟点击,而是直接调用与真实操作相同的内部方法(如 addStoneToBoat、settle、carve);
  3. 零失败:放错、划歪、点错都不判错、不扣分,弹回原位继续试;仅少数「刻意体验」的动作会计入 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;
}

两条刻意的设计:

  1. 零失败:拿起光球时,它该去的槽位自己发光(提示圈),放错不惩罚、弹回托盘继续试;
  2. 用听不用看:每归位一件,用语音念出 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 为例):

  1. 定 schema:在 packages/templates/src/schemas.ts 写 MatchLevelSchema,把「教学核心」写成 refine(参考 sort 的「先尝后分」约束);
  2. 注册 schema:加入文件末尾的 templateSchemas 注册表 —— 这一步之后,content:check 自动开始校验所有使用它的内容(含双语合并后的二次校验);
  3. 登记场景键:在 layout.ts 的 SCENE_KEYS 里加 match: 'match';如需新坐标常量,一并加进 layout(e2e 会复用);
  4. 写纯逻辑:match.logic.ts(判定、进度、并集去重计数),不 import Phaser、不用 Math.random / Date.now(lint 会拦);
  5. 写场景工厂:match.ts,实现工厂 + drawXxx 回调外置 + autoplay 分支 + 卡住提示 + 逐帧/事件驱动;
  6. 写单测:match.logic.test.ts 覆盖判定与边界(参考 place.logic.test.ts 的 12 条用例);
  7. 导出:在 packages/templates/src/index.ts 导出(注意与现有同名导出显式改名,文件里已有多个先例);
  8. 游戏使用:写作战章(第 12 章)的流程接一个典故进去;
  9. 端到端:在 e2e/smoke.spec.ts 给使用它的典故加真实操作 + autoplay 用例。

7.15 小结

  • 模板 = 交互原语 + 循环 + 判定(logic)+ 外置画面(draw 回调),十个场景工厂覆盖了十二部游戏;
  • 各模板的教学核心被写进了 schema:可通关性(substitution 的背包校验)、先尝后分(sort)、只进不退(trace)、永远等得到(wait)、整除(tap);
  • 兼容真实设备行为的坑都有注释与单测兜底:墙体钟计时、命中线段扫描、输入锁、去重计数;
  • 专属场景允许「一次性机制」,但数据必须登记,保证质量工具全覆盖。

下一章看这些模板脚下的地基:Phaser 渲染工具箱(phaser-kit)。