第08章 - Phaser渲染工具箱

8.1 为什么是 Phaser,又为什么要裁剪

十个玩法都跑在 Phaser 4.2.1 上。选它的理由不复杂:场景生命周期、补间、输入系统、缩放策略这四件事如果自己写,第一个玩法就要多出两三百行,而且每一件都比想象中难做对(尤其是「手指离开元素之后还继续跟随」和「画布随窗口变化重算坐标」)。packages/phaser-kit 把这些能力包成一套面向本项目的工具箱,游戏包只依赖它,不直接写 Phaser 的底层调用。

但完整版 Phaser 打出来是一个大得离谱的 chunk:vendor-phaser 命令行输出 1341.9 KiB 原始 / 347.3 KiB gzip。而本项目实际用到的只有:

  • Scene / Scenes / Game / Scale / Cameras
  • GameObjects 里的 Container / Graphics / Text / Zone / Rectangle
  • Input / Tweens / Time / Math / Geom / Structs / Utils / Display / Events

physics、tilemaps、sound、animations、particles、loader(image)、shader、mesh、renderTexture 这些系统一个都没用到——声音由自研的 WebAudio 层负责(第 9 章),所有美术都是 Graphics 画出来的,没有一个位图要加载。于是 scripts/build-phaser-trim.mjs 把 Phaser 按这份清单重新打包,结果约 859.7 KiB 原始 / 221.4 KiB gzip,gzip 减少 36%;本次构建产物里那份 apps/site/dist/assets/vendor-phaser-zPpKQHLK.js 是 876,874 字节,.version 里记录的 gzip 是 227,189 字节。

这笔账值得算,是因为十个玩法共用同一份 Phaser:首屏虽然不加载它(点开一个玩法才下载),但孩子每换一个玩法、每次清缓存回来都要重新下。少 36% 对移动网络与安卓 WebView 是实打实的差别。

本次构建的 41 个文件把这条边界显示得很清楚:首屏只有 index-<hash>.js 149,841 字节 + index-<hash>.css 20,592 字节(gzip 合计 56.8 KB,与 check-size.mjs 的口径一致);common-<hash>.js 111,340 字节是进玩法前才拉起的共享 chunk;十个玩法的入口 chunk 在 342–8,189 字节之间(刷牙 / 洗手 / 洗澡只有 342–344 字节,场景代码在共享的 scrub-<hash>.js 里);vendor-phaser-<hash>.js 876,874 字节(gzip 227,189)只在 PlayView 被拉起的那一刻才进入下载队列。也就是说,一个不玩任何任务的孩子,永远不会为游戏引擎付流量。

8.2 裁剪的原理、清单与风险

裁剪不是删代码,是「换一个入口重新打包」。packages/phaser-kit/vendor-src/entry.cjs 按 Phaser 的内部模块路径逐个 require,packages/phaser-kit/vendor-src/gameobjects-trim.cjs 把 GameObjects 收窄到实际用到的五类,然后由 webpack 5 打成一个 ESM bundle:

// scripts/build-phaser-trim.mjs(webpack 配置节选)
plugins: [
  new webpack.DefinePlugin({
    // 与 Phaser 自带 config/webpack.dist.config.js 同一套开关语义(typeof X 必须被替换成布尔值)
    'typeof CANVAS_RENDERER': JSON.stringify(true),
    'typeof WEBGL_RENDERER': JSON.stringify(true),
    'typeof WEBGL_DEBUG': JSON.stringify(false),
    'typeof FEATURE_SOUND': JSON.stringify(false),
    'typeof EXPERIMENTAL': JSON.stringify(false),
  }),
],
optimization: { minimize: true, splitChunks: false, runtimeChunk: false },

五个开关里 FEATURE_SOUND = false 是最划算的一个——它让 entry.cjs 里这一段被静态消除:

// packages/phaser-kit/vendor-src/entry.cjs
// FEATURE_SOUND=false 由构建脚本的 DefinePlugin 注入:这一支会被静态消除,声音系统不参与打包。
if (typeof FEATURE_SOUND) {
  Phaser.Sound = require('phaser-src/sound');
}

注意注释里的警告:直接用 esbuild/vite 打包这个文件会得到错误结果。未定义时 typeof FEATURE_SOUND 是 'undefined',字符串为真,声音系统会悄悄回来,体积白涨。所以裁剪版必须由这个脚本生成,不能顺手改 apps/site/vite.config.ts 去打包 vendor-src。

脚本的其余部分都在处理「这件事很容易做错,所以要能自证」:

机制 作用
RECIPE = 'b-tier-v1' 配方版本;改 DefinePlugin 开关或 webpack 关键选项时 +1,强制重建
entryHash sha256(RECIPE + phaser 版本 + entry.cjs + gameobjects-trim.cjs) 的前 16 位
.version 记录 phaser 版本 / 配方 / 指纹 / 产物大小与 gzip,命中即跳过重建
体积防呆 gzip 不在 180–260 KiB 之间直接抛错,并提示「完整 Phaser 约 347 KiB gzip」
产物横幅 标明「由脚本生成,请勿手改」与 MIT 许可位置
许可抽取 把 webpack/terser 抽出的 LICENSE.txt 一起复制;缺失时警告「发布前确认一次」

风险都在明面上,packages/phaser-kit/vendor/README.md 写成一节:

⚠️ 与 entry.cjs 一样:这是 Phaser 内部路径,不是公开 API,升级 phaser 需复核 + 重跑 pnpm phaser:trim。 合并前跑全量 pnpm e2e——裁剪掉的对象如果在某个游戏里被用到,只会在运行时炸,TS 类型查不出来。

「TS 类型查不出来」这句是关键:import Phaser from 'phaser' 的类型永远指向完整包,所以裁剪清单里少一个对象,类型检查仍然是绿的,只有真的跑到那一行才会 undefined is not a function。这就是为什么重建后的验收流程被写死成三步:pnpm build && pnpm size,加上全量 pnpm e2e。回退方案也留好了:删掉 apps/site/vite.config.ts 里的 alias,或临时用 PHASER_FULL=1 走完整包。

alias 本身只有一行:

const phaserTrim = fileURLToPath(
  new URL('../../packages/phaser-kit/vendor/phaser-trim.esm.js', import.meta.url),
);

resolve: { alias: [{ find: /^phaser$/, replacement: phaserTrim }] },

用 /^phaser$/ 精确匹配,是因为仓库里还有 phaser-kit/vendor-src 这类路径不该被替换;类型仍然解析到 node_modules/phaser,所以 vue-tsc 完全不受影响。

8.3 主题与调色板

packages/phaser-kit/src/theme.ts 是一个纯常量文件(69 行),它把「颜色」这件事从每个模板里抽出来。数值版给 Graphics 用,字符串版给 Text 与样式用:

export const THEME = {
  outline: 0x4a3b2f,      // 描边用深棕而不是纯黑
  ink: 0x4a3b2f,
  inkSoft: 0x7b6a5a,
  paper: 0xfaf3e7,
  card: 0xfffdf8,
  bamboo: 0x7aa05c,
  bambooDeep: 0x55793f,
  bambooLight: 0xa8c68a,
  cream: 0xf7ead6,
  wood: 0xd3a97c,
  woodDeep: 0xa97e50,
  blush: 0xf2b8ae,
  pink: 0xe8a79e,
  sky: 0xdfeff0,
  water: 0x9fd0d6,
  waterDeep: 0x6fb2ba,
  sun: 0xf5cf7a,
  gold: 0xf2c14e,
  goldDeep: 0xb9822a,
  warmRed: 0xe07a6a,
  warmRedDeep: 0xb9523f,
  mint: 0xcfe6d4,
  lavender: 0xd9d2ea,
  white: 0xffffff,
  pandaWhite: 0xfbf7ef,
  pandaBlack: 0x3b3a37,
  grey: 0xb8b2a8,
  shadow: 0x4a3b2f,
} as const;

两条约定值得记住:描边用 0x4a3b2f 而不是 0x000000(注释:「圆润卡通的关键是柔和的边,不是漫画的墨线」),每一组强调色都有 Deep / Light 变体(bamboo/bambooDeep/bambooLight、gold/goldDeep、warmRed/warmRedDeep),因为按钮厚底、阴影、暗面都要在同色系里取第二、第三个值。

字体栈有一条容易忽略的理由:

/* 拉丁字体在前:英文的弯引号(’)在 CJK 字体里是全身宽,看起来像多了一个空格;
   中文字形这些拉丁字体没有,会自动回落到后面的中文字体,所以中英都不受影响。 */
export const FONT_STACK =
  '"Segoe UI","Helvetica Neue",Arial,"Noto Sans SC","PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif';

export function textStyle(size: number, color: string = CSS.ink, bold = false) {
  return { fontFamily: FONT_STACK, fontSize: size + 'px', color, fontStyle: bold ? 'bold' : 'normal' };
}

这是一条只有双语产品才会遇到的坑:英文文案里 don’t 的弯引号如果交给中文字体渲染,宽度会变成全角,看起来像 don’ t。把拉丁字体放在最前面,中文字符再回落到后面的中文字体,两边都正常。

textStyle(size, color, bold) 是唯一的文字样式入口,所有 this.add.text(...) 都经过它,所以「换字体、改默认色」是一次改动。模板里再叠 wordWrap 与 lineSpacing 处理长句——英文文案普遍比中文长 30% 以上,换行策略必须显式给宽,否则 Next: Turn off light 这类短句会溢出药丸卡。

8.4 卡通绘制原语

packages/phaser-kit/src/cartoon.ts(316 行)是整套美术的地基,一共十来个小函数,全部只依赖 Graphics:

函数 作用
roundRect(g, x, y, w, h, radius, fill, outline, outlineWidth, alpha) 圆角矩形,「有底有边」的默认值 OUTLINE_WIDTH = 6
circle / ellipse 带默认描边的圆与椭圆
groundShadow(g, cx, cy, w, h, alpha = 0.14) 物体底下的柔和阴影,圆润卡通的「落地感」
wavyBand(g, x, y, w, h, color, alpha, amplitude, waves, segments) 用折线逼近的波浪(水面、被子边缘),不依赖曲线 API
shade(color, factor) 颜色加深 / 变浅,做按钮厚底与亮暗面
squash(scene, target, base, strength) 挤压回弹,给拖放「有重量」的手感
popIn(scene, target, delay) alpha 0 → 1 的 240ms 出场
sparkleAt(scene, x, y, color, count) 星光扩散,替代「答对了」这类文字
tapRipple(scene, x, y, radius, color) 「点这里」的扩散涟漪,替代提示文字
drawHand / createHand 卡通小手(会上下浮动),表示「可以拖我 / 点这里」
starPath / heartPath / cloud 五角星、爱心、云朵

两个函数的实现细节决定了整个视觉风格:

export function roundRect(g, x, y, w, h, radius, fill, outline = THEME.outline, outlineWidth = OUTLINE_WIDTH, alpha = 1): void {
  g.fillStyle(fill, alpha);
  g.fillRoundedRect(x, y, w, h, radius);
  if (outline !== null && outlineWidth > 0) {
    g.lineStyle(outlineWidth, outline, 1);
    g.strokeRoundedRect(x, y, w, h, radius);
  }
}

/** 颜色加深/变浅:做按钮厚底与物体亮暗面 */
export function shade(color: number, factor: number): number {
  const r = Math.max(0, Math.min(255, Math.round(((color >> 16) & 0xff) * factor)));
  const g = Math.max(0, Math.min(255, Math.round(((color >> 8) & 0xff) * factor)));
  const b = Math.max(0, Math.min(255, Math.round((color & 0xff) * factor)));
  return (r << 16) | (g << 8) | b;
}

「先填充再描边」是固定顺序,而且描边永远在内侧对齐——fillRoundedRect 与 strokeRoundedRect 用同一个矩形,视觉上就是一圈等宽的边。shade 用乘法而不是加法,所以 shade(THEME.warmRed, 0.72) 得到的是一个更暗的同色相,任意颜色都能安全地做「厚底」而不需要为每个颜色准备第二个常量。

三个动效函数构成一套反馈层次:popIn 用于「东西出现了」(按钮、道具、完成卡),sparkleAt 用于「你做对了」(清掉一个点、放对一个玩具、答对一题),tapRipple 与 createHand 用于「还不知道该点什么」(开局的小手、卡住时的涟漪)。三条线分别对应「出现 / 成功 / 引导」,都不需要孩子读一个字的提示。

sparkleAt 与 tapRipple 是两条「不用文字也能反馈」的路子:前者在答对时炸出一圈星星(8 个,延迟 i * 36ms,560ms 内扩散到 120px 并淡出),后者在需要引导时画两圈不断扩散的涟漪。4–8 岁的孩子不一定会读提示文字,但一定看得懂星光与涟漪。

8.5 竹竹与道具:零位图

packages/phaser-kit/src/panda.ts 与 packages/phaser-kit/src/props.ts 是两个大文件(13 KB / 25 KB),它们把「所有会出现在屏幕上的东西」都画成了几何图形。竹竹的入口是:

/**
 * 竹竹本体:圆脸、短手短脚、背小书包、围兜可换。
 * 原点取身体中心,正上方是头 —— 模板按「脚踩地面」的位置换算即可。
 */
export function drawPanda(g, x, y, s = 1, opts: PandaOptions = {}): void

PandaOptions 有三个开关:mood(九种:happy / neutral / sleepy / hungry / dirty / fresh / sad / angry / excited)、accessories(五个槽位)、blush / wave / hearts(打脸、举手挥、头顶冒爱心)。mood 只影响眉眼与嘴:困了是两道弯线,饿了是张嘴 + 红舌,生气了是横线嘴 + 眼球偏移,难过了下垂嘴 + 一滴眼泪,excited 额外多一张咧开的红嘴。

配件颜色按 id 前缀取,这一步是刻意的:

/** 与 @bamboo/progress 的装扮 id 对齐;颜色表在这里,是为了渲染层不依赖存档层 */
function accessoryColor(id: string | undefined, fallback: number): number {
  if (!id) return fallback;
  if (id.startsWith('hat-bamboo') || id.startsWith('bib-bamboo') || id.startsWith('backpack-leaf')) return THEME.bamboo;
  if (id.startsWith('hat-straw')) return THEME.sun;
  if (id.startsWith('hat-crown')) return THEME.gold;
  if (id.startsWith('bib-star')) return THEME.sun;
  if (id.startsWith('bib-flower')) return THEME.pink;
  if (id.startsWith('outfit-pjs')) return THEME.lavender;
  if (id.startsWith('outfit-rain')) return THEME.gold;
  if (id.startsWith('backpack-red') || id.startsWith('shoes-red')) return THEME.warmRed;
  if (id.startsWith('shoes-green')) return THEME.bamboo;
  return fallback;
}

渲染层不 import 存档层(@bamboo/progress),否则「装扮目录 + 解锁规则 + 迁移逻辑」会跟着进每一个游戏 chunk。代价是颜色表要跟装扮目录的 id 对齐——这份对齐由 apps/site/src/logic/pandaLook.ts 在小屋那侧再写一遍(同样的前缀规则),两处都改才算改完。

packages/phaser-kit/src/props.ts 的 30 多个函数分三类:

类别 函数 说明
房间背景 drawRoom(g, theme, w, h) 八种主题:墙面色 / 地板色 / 墙裙 / 瓷砖 / 窗 / 月亮与星星 / 书架 / 黑板 / 衣柜 / 竹子
家具与器物 drawWindow / drawRug / drawShelf / drawBed / drawTable / drawPlate / drawSink / drawTub / drawLamp / drawCurtain / drawBlanket / drawBambooStalk / drawMoon / drawCloudPill 每个都是几行到几十行 Graphics 调用
玩法道具 drawFood(六类)/ drawClothing(五类)/ drawToy(四类)/ drawBin / drawOrderIcon(七种)/ drawEmotionFace(五种)/ drawShape(五种)/ drawToothbrush / drawSponge / drawTowel / drawBubbles / drawWaterStream / drawTooth 内容里的枚举值在这里各有一笔画法

零位图带来的三个好处都很实在:包体积里没有图片资源(dist 里唯一的非 JS/CSS 文件是 index.html)、离线与弱网下不会出现「先白块后出图」、截图比对的基准在任何设备上都一致。

8.6 BaseScene:儿童向交互基线

BaseScene(332 行)是八个场景的共同父类,它把「所有玩法都必须做到的事」写死成一处:

export abstract class BaseScene extends Phaser.Scene {
  protected readonly ctx: GameContext;
  protected readonly session: SessionState;

  protected constructor(key: string, ctx: GameContext) {
    super(key);
    this.ctx = ctx;
    this.session = { mistakes: 0, correct: 0, total: 0, startedAt: ctx.services.now() };
  }

  protected get W(): number { return this.ctx.manifest.designSize.width; }
  protected get H(): number { return this.ctx.manifest.designSize.height; }
  protected get autoplay(): boolean { return this.ctx.debug?.autoplay === true; }
  protected get instant(): boolean { return this.ctx.debug?.instant === true; }
  protected get reducedMotion(): boolean { return this.ctx.reducedMotion; }
}

三个 get 直接映射到契约上的三个开关:autoplay 让自动化跑真实处理函数,instant 跳过过场与语音,reducedMotion 尊重系统的「减少动态效果」。this.session 记录 mistakes / correct / total / startedAt,noteCorrect() 与 noteMistake(step) 是唯一两个写入口,后者还会替场景发一次事件:

protected noteMistake(step: string): void {
  noteMistake(this.session);
  this.ctx.emit('task:retry', { taskId: this.ctx.manifest.id, step });
  this.sfx('retry');
}

按钮和完成卡片是两个信息密度最高的方法。addButton 的默认尺寸是 380×112——远大于 44px 的最小可点高度,而且它还画了一层「厚底」来暗示可按下:

protected addButton(x, y, label, onClick, opts: ButtonOptions = {}): Phaser.GameObjects.Container {
  const w = opts.width ?? 380;
  const h = opts.height ?? 112;
  const color = opts.color ?? THEME.warmRed;
  const container = this.add.container(x, y);

  const lip = this.add.graphics();
  roundRect(lip, -w / 2, -h / 2 + 8, w, h, h / 2, shade(color, 0.72), THEME.outline, 5);
  const face = this.add.graphics();
  roundRect(face, -w / 2, -h / 2, w, h, h / 2, color, THEME.outline, 5);

  container.on('pointerdown', () => {
    if (opts.disabled) return;
    this.sfx('tap');
    this.tweens.add({ targets: [face, text], y: 8, duration: 60, yoyo: true });
    onClick();
  });
  return container;
}

厚底是「同一个圆角矩形往下偏 8px、颜色 shade(color, 0.72)」;按下时把面板与文字一起向下移 8px,正好贴到厚底上。不需要额外状态、不需要九宫格图,两行代码换来一个明确的按压反馈。

finishTask() 是一局的唯一收尾,所有模板都调它,所以不存在「某个模板忘了发事件或忘了奖励」:

protected finishTask(opts: CompletionOptions = {}): void {
  if (this.completionShown) return;          // 只做一次
  this.completionShown = true;
  const stars = opts.stars ?? starsForSession(this.session);
  const durationMs = this.ctx.services.now() - this.session.startedAt;
  this.ctx.emit('task:complete', {
    taskId: this.ctx.manifest.id, stars,
    mistakes: this.session.mistakes, correct: this.session.correct,
    total: Math.max(this.session.total, 1), durationMs,
  });
  this.sfx('complete');
  if (!this.reducedMotion) this.cameras.main.flash(260, 255, 250, 220);
  this.showCompletionOverlay(stars, opts.praise ?? this.ctx.task.done);
  if (this.autoplay) {
    this.time.delayedCall(opts.autoExitMs ?? 450, () => this.exit('complete'));
  }
}

starsForSession() 把 mistakes 折成星数:0–1 次失误 3 星、2–3 次 2 星、更多则 1 星。注意它不是「全对才 3 星」——孩子第一次点歪不该只拿一颗星。完成卡片是一张 600×720 的圆角卡:中间一只 excited + hearts 的竹竹、三颗半径 44 的星星(未点亮用 paperDeep 底 + grey 边)、一句夸奖、以及两个出口(「再玩一次」= exit('replay')、「收下」= exit('complete'))。两个按钮的坐标必须与 @bamboo/templates/layout 的 COMPLETE_LAYOUT 保持一致,因为 e2e 要点它们——完成卡片是 Phaser 内绘的,没有 DOM testid,只能靠坐标。

音效键在契约里一共十二个(tap / correct / retry / pop / bubble / water / chew / jump / sleep / unlock / complete / sparkle),场景只写「发生了什么」而不是「播哪个音」,具体波形由平台层合成(第 9 章);需要音高变化的场合则走 audio.tone(freq, durationMs),例如节奏模板里 registerTap 算出的 440 + streak * 45。reducedMotion 是另一个贯穿基类的能力:它同时关掉相机淡入(fadeIn)、完成时的白闪(cameras.main.flash)与各种补间——系统开了「减少动态效果」的设备上,一局游戏仍然完整可玩,只是不再晃。

场景侧还有四个小工具:tapAnywhere()(整屏一个大 Zone,配 isTapArmed(now, lastAdvanceAt) 的 350ms 连点锁)、showHint()(一个自适应宽度的药丸气泡,默认停留 2.6 秒)、say(text, hintMs, hintY)(念一句 + 屏幕上给同一句,听不见的孩子也能读)、tr(key)(缓存一份取词器)。最后 onShutdownCleanup(fn) 把清理注册到场景的 shutdown 事件上,每个场景都靠它撤掉自己的输入监听与定时器。

8.7 实例化与缩放

Phaser 实例由 createPhaserGame() 创建,参数几乎全部来自契约:

export function createPhaserGame(ctx: GameContext, opts: CreateGameOptions): Phaser.Game {
  const { width, height } = ctx.manifest.designSize;
  return new Phaser.Game({
    type: Phaser.AUTO,
    parent: ctx.container,
    width,
    height,
    backgroundColor: THEME.ink,
    scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
    audio: { noAudio: true },
    scene: opts.scenes,
  });
}

四个参数各有理由:parent: ctx.container 让 Vue 决定画布挂在哪个 DOM 节点上;width/height 是逻辑分辨率(DESIGN = { width: 750, height: 1334 },竖屏);Scale.FIT + CENTER_BOTH 让同一份布局在 375×812、430×932 与桌面窗口里等比铺满并居中,模板因此可以一直用 0–750 / 0–1334 的绝对坐标;audio: { noAudio: true } 明确关掉 Phaser 的音频系统——声音由 ctx.services.audio 负责(第 9 章),这也是裁剪能省掉 Sound 模块的前提。

缩放导致的一个直接后果是逻辑像素与 CSS 像素不是 1:1。设计尺寸 750 宽铺到 375 宽的屏幕上是一半,所以「88×88 逻辑像素」正好等于 44 CSS px——正好踩在可点区域的下限上。packages/templates/src/layout.ts 因此把上限单独列出来:

export const UI = {
  headerH: 140,
  hintY: 260,
  buttonH: 112,
  /** 可点区域下限(88 逻辑像素 ≈ 44 CSS px 的两倍) */
  minTap: 96,
} as const;

约定是 96 而不是 88:模板一律按「比下限宽一点」做,因为手指落在边缘时的实际命中会比视觉小一圈。八个模板里的判定半径(130、150)也遵循同一思路。

Scale.FIT 还有一个附带好处:它在不同手机上的表现是「等比缩放 + 上下留黑边或背景色」,而不是拉伸。设计里所有圆形(细菌、星星、心形、按钮)在任何屏幕上都不会变成椭圆,这对儿童产品的视觉稳定性很重要。容器侧的分工也很清楚——apps/site/src/style.css 只写 touch-action: none 与 overflow: hidden,画布尺寸完全交给 ScaleManager,Vue 不参与游戏内的布局计算。

8.8 拖拽与命中测试:不用 setInteractive 的 hitArea

packages/phaser-kit/src/drag.ts 只有 90 行,却省掉了后面无数个诡异 bug:

/**
 * 不依赖 GameObject 的命中区域,而是「按下时抓住最近的、在半径内的目标」。
 *
 * 原因(CHAGS 实测踩坑):Container 的 setInteractive(hitArea) 在 Phaser 4 里的
 * 局部坐标约定与直觉不符,会出现「点在 A 上却抓到 80 像素外的 B」。
 * 自己算距离既确定,又对小朋友更友好 —— 手指粗、点不准也能抓住。
 */

接口本身很小:add({ obj, radius, enabled?, onGrab?, onDrop? }) / remove(obj) / destroy()。控制器监听整个场景的 pointerdown / pointermove / pointerup / pointerupoutside,按下时在全部目标里挑「距离指针最近且在 radius 内」的那个(enabled?.() 为假则跳过,用于「已经放好的不能再拖」),移动时按抓取偏移跟随,松手时回调 onDrop。pointerupoutside 是必须的:手指滑出画布再松开,只监听 pointerup 会让对象永远粘在指针上。

模板里的取数习惯是「判定半径 > 视觉大小」:

模板 抓取半径 判定半径
scrub 130(工具) 内容点的 0.11 * size(0–1 空间)
feed 100(食物) 嘴边 150
dress 92(衣服) 部位 130(约定下限 120)
tidy 86(玩具本身约 70) 箱子 130

springBack(scene, obj, x, y) 是松手时的统一动作:先 killTweensOf(obj)(避免上一次回弹的补间还在和这一次抢位置),再用 Back.easeOut 在 240ms 内弹回原位。放对了则走另一条路——把对象 remove 出控制器、补到目标位置并淡出,孩子立刻知道「这件不用再拖了」。

不是所有点击都走 createDragController。order、pick、learn 三个模板是「点选」,它们自己算矩形或圆:

// pick.ts:安慰按钮的命中(不使用 Phaser 的容器命中区)
Math.abs(pointer.x - COMFORT.cx) <= COMFORT.w / 2 &&
Math.abs(pointer.y - COMFORT.cy) <= COMFORT.h / 2

这是同一个决定的两面:命中判定自己算,规则就写在眼前,测试也能照抄同一组数字。

8.9 与外壳的分工边界

渲染层与外壳之间只有一条通道:GameContext。模板里能做的三件事是「画、判定、发事件」,不能做的两件事是「读写存档」与「直接联网」:

  • 声音走 ctx.services.audio.play(key) 与 .speak(text, locale),存档走外壳的 task:complete 事件回调,二者都不在 phaser-kit 里;
  • packages/phaser-kit 的依赖只有 phaser、@bamboo/game-kit(类型)与 @bamboo/i18n(取词器),没有 @bamboo/progress、没有 @bamboo/platform。渲染层在依赖图里是一个叶子。

这条边界的收益在依赖图与产物里都能验证:phaser-kit 的三个依赖里两个是 types-only(@bamboo/game-kit 的 GameContext、Phaser 的类型)或者纯函数(@bamboo/i18n 的取词器),所以它不可能把 IndexedDB 封装、存档迁移、平台探测这些代码拖进游戏 chunk。产物那边就是 assets/vendor-phaser 与十个 game-<id> chunk 里没有一行存档逻辑——这是裁剪之外的第二层「体积纪律」。

packages/phaser-kit/src/session.ts 是这个边界上一个有意思的例外,它被刻意做成「重复实现」:

/** 与 progress 的 starsFor 同一口径(模板包不 import 存档层,避免把迁移逻辑带进游戏 chunk) */
export function starsForSession(session: SessionState): Stars {
  if (session.mistakes <= 1) return 3;
  if (session.mistakes <= 3) return 2;
  return 1;
}

星数规则在存档层里也有一份(packages/progress 的 starsFor),但因为模板包不能 import 存档层,这里重写了一遍,并用注释锁住「同一口径」。这是一个用少量重复换清晰依赖方向的取舍:如果哪天两边算法不一致,表现是「完成卡片显示 3 星,任务页显示 2 星」——很容易发现,但也要记得两处一起改。

packages/phaser-kit/src/tapGuard.ts 与 packages/templates/src/layout.ts 是这条边界的另外两个注脚:前者把「连点保护」的 350ms 做成一个纯函数常量,后者把所有需要被 e2e 复用的坐标做成不 import Phaser 的纯常量。三者的共同点是把「逻辑」从「渲染」里择出来——这也是整个工具箱唯一的设计原则。

下一章离开画面,去看那几个模板一直在调用、却完全不属于 Phaser 的东西:ctx.services 背后的平台适配层与音频实现。


← 上一章 目录 下一章 →