第08章 - Phaser渲染工具箱
8.1 phaser-kit 的职责边界
packages/phaser-kit 是模板与游戏包脚下的「地基」:它封装引擎启动、基础场景能力、主题配色、卡通绘制原语与拖拽控制器。它不含任何具体玩法(那是 templates 的事),也不认识任何典故(那是 games 的事)。
packages/phaser-kit/src/
├─ createGame.ts # Phaser.Game 启动器(29 行)
├─ baseScene.ts # BaseScene:按钮 / 提示 / 小手 / 翻译 / 音频捷径
├─ theme.ts # 配色、字体栈、文字样式(含对比度依据)
├─ cartoon.ts # 圆润卡通绘制原语(厚描边、落地阴影、星光……)
├─ drag.ts # 按距离判定的拖拽控制器 + springBack
└─ index.ts # 统一导出
8.2 createPhaserGame:29 行的启动器
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,
});
}
每个决定的依据:
| 配置 | 依据 |
|---|---|
type: Phaser.AUTO |
有 WebGL 用 WebGL,没有回退 Canvas —— 旧安卓机的兼容底线 |
width/height 取 manifest 的 designSize |
全部坐标都在 750×1334 的逻辑分辨率里,缩放交给 ScaleManager |
Scale.FIT + CENTER_BOTH |
任意屏幕一次性适配;e2e 的坐标换算就是这套缩放的逆运算(第 13 章) |
audio: { noAudio: true } |
引擎音频关闭:音效与 TTS 统一走 PlatformServices.audio,便于换平台、静音与 iOS 解锁 |
| 注释里的 destroy 约定 | 游戏包必须在 unmount 时 game.destroy(true),否则残留 canvas 与 RAF 循环 |
8.3 BaseScene:全部场景的公共父类
所有模板场景与游戏专属场景都 extends BaseScene。它提供的 protected API 一览:
| API | 作用 |
|---|---|
W / H |
逻辑分辨率宽高(读 manifest) |
autoplay |
当前是否无头自动模式 |
drawBackdrop(label?) |
天空底色 + 150px 深色标题条 + 居中标题 |
addButton(x, y, label, onClick, opts?) |
卡通按钮(默认 520×136) |
addBody(x, y, content, size?, color?, wrapWidth?) |
正文文字(默认换行宽度 W - 160) |
showHint(message, durationMs?, y?) |
卡住时出现的提示气泡(底部其它元素之上,depth 950) |
tapToContinue(onDone, handAt?) |
全屏点击继续 + 小手(故事页用它替代「下一幕」按钮) |
sfx(key) / speak(text) |
音效与语音的快捷方法(内部走 ctx.services.audio) |
tr(key) |
界面文案取词(典故文字不走这里,走 ctx.content) |
fadeIn() |
场景淡入,尊重 reducedMotion |
onShutdownCleanup(fn) |
场景销毁时清资源(拖拽控制器等) |
几个关键实现的取法与理由:
按钮:可点区域、按压反馈与文字换行
protected addButton(x, y, label, onClick, opts: ButtonOptions = {}) {
const w = opts.width ?? 520;
const h = opts.height ?? 136;
const color = opts.color ?? THEME.red;
const container = this.add.container(x, y);
const lip = this.add.graphics();
roundRect(lip, -w / 2, -h / 2 + 9, 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);
const text = this.add.text(0, -2, label, {
...textStyle(opts.fontSize ?? 44, opts.textColor ?? CSS.card, true),
align: 'center',
// 默认开 wordWrap:长选项曾经直接溢出按钮
wordWrap: opts.wrap === false ? undefined : { width: w - 64, useAdvancedWrap: true },
}).setOrigin(0.5);
container.add([lip, face, text]);
container.setSize(w, h);
container.setInteractive(
new Phaser.Geom.Rectangle(-w / 2, -h / 2, w, h),
Phaser.Geom.Rectangle.Contains,
);
container.on('pointerdown', () => {
if (opts.disabled) return;
this.sfx('tap');
this.tweens.add({ targets: [face, text], y: 9, duration: 60, yoyo: true });
onClick();
});
return container;
}
- 默认 520×136 远超「可点击区域 ≥ 88×88 逻辑像素」的儿童交互规范;
- 厚底(
lip,颜色加深 0.72)+ 亮面(face)+ 描边是三段式,按下时亮面下沉 9px —— 触屏没有 hover,反馈必须发生在按下瞬间; wordWrap默认开启是修 bug 修出来的:长选项文字不换行会溢出按钮。
tapToContinue:小手代替「下一步」
protected tapToContinue(onDone: () => void, handAt?: { x?: number; y?: number }): void {
const hand = createHand(this, handAt?.x ?? this.W / 2, handAt?.y ?? this.H - 150, 1.15);
const zone = this.add.zone(this.W / 2, this.H / 2, this.W, this.H).setInteractive({ useHandCursor: true });
let done = false;
const finish = () => { /* 去重 + 清理 */ };
zone.once('destroy', () => { done = true; });
// 连点保护:孩子连点两下时,第二下会落在**下一幕刚建好的**点击区上
const armedAt = this.ctx.services.now() + 350;
zone.on('pointerdown', () => {
if (this.ctx.services.now() < armedAt) return;
this.sfx('tap');
finish();
});
if (this.autoplay) this.time.delayedCall(120, finish);
}
350ms 输入锁的来源是一次真实故障:孩子连点两下,第二下落在下一幕刚建好的 Region 上,一屏故事被整段跳过。锁很短,正常阅读完全感觉不到。
提示气泡:只在卡住时出现
showHint 是一个圆角药丸 + 居中文字,自动弹出、自动淡出、出现时有 popIn 动画;换行宽度是 W - 180(570px)—— 这个数字也来自修复:提示语里有长句(「想明白了就不怕 —— 天是积起来的气……」),窄了会把句号挤成孤零零的第三行。提示语要求「文字尽量短」,24 字以内(schema 对模板 hint 字段有 max(24) 约束)。
8.4 theme:配色、字体与对比度
/** 圆润卡通配色:厚描边 + 暖亮色,比纯黑描边更柔和 */
export const THEME = {
outline: 0x3a2f26, ink: 0x3a2f26, inkSoft: 0x6b5a49,
paper: 0xfdf3dc, paperDeep: 0xf0e0bd, card: 0xfffaf0,
red: 0xe0554a, redDeep: 0xb03a31,
teal: 0x3d8f86, tealDeep: 0x2c6b64,
gold: 0xf2b134, goldDeep: 0xc98a1c,
sky: 0xc6e8f5, skyDeep: 0x9fd4ea,
water: 0x59abda, waterDeep: 0x3283b3,
sand: 0xf3e0b0, sandDeep: 0xdfc78d,
wood: 0xc98a4b, woodDeep: 0x96612b,
stone: 0xaeaaa2, stoneDeep: 0x827d75,
elephant: 0xb6b1a8, elephantDeep: 0x918c84,
leaf: 0x74b96c,
} as const;
CSS 里另有一份字符串色值(给 Phaser 文字用),并且深色版本是有意为之的:
export const CSS = {
ink: '#3a2f26', inkSoft: '#6b5a49', paper: '#fdf3dc', card: '#fffaf0',
red: '#e0554a', teal: '#3d8f86', gold: '#c98a1c', white: '#ffffff',
/**
* 深色版本,用于浅底上的文字与图标。
* 原始色放在浅底上对比度不够:红/纸 3.42:1、青/纸 3.48:1、金/卡 2.83:1,
* 都过不了 WCAG AA。下面这三个分别是 5.44 / 5.61 / 5.42。
*/
redDeep: '#b03a31', tealDeep: '#2c6b64', goldDeep: '#8a5f10',
} as const;
对比度不是美学问题:一次全仓审查发现「整局最高潮的数字」(曹冲称象的读数)红字压水面只有 1.48:1,几乎看不见;答案为 2.92:1,界面主按钮 3.42:1…… 这些都在第 16 章有完整清单。修法就是「换深色变体 + 垫底色」:读数垫卡片底后达到 12.5:1。
字体栈选 Noto Sans SC 并为各平台准备回退:
export const FONT_STACK =
'"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' };
}
儿童向的字号下限是 28 设计像素(约 16 CSS px @ 375 宽手机)—— 曾经典故原文与出处只有 13.8 CSS px,已全部上调。
8.5 cartoon:圆润卡通的绘制原语
阶段 0 的美术策略是零图片资源,全部用几何图形绘制。cartoon.ts 把这些绘制收敛成一套原语,保证所有典故观感统一:
| 原语 | 作用 |
|---|---|
roundRect(g, x, y, w, h, radius, fill, stroke?, lineWidth?) |
厚描边圆角矩形(radius 支持四角对象,如船底更圆) |
circle(g, x, y, r, fill, stroke?, lineWidth?) |
厚描边圆 |
shade(color, factor) |
颜色加深/提亮(同色系阴影面,如 shade(THEME.wood, 1.22)) |
groundShadow(g, cx, cy, w, h, alpha) |
落地椭圆阴影 —— 角色「站在地上」的关键 |
popIn(scene, obj, delay?) |
弹出式出现动画(Back.easeOut) |
springBack(scene, obj, x, y) |
松手弹回原位(先杀掉旧补间,避免与拖拽抢位置) |
squash(scene, obj, scale, duration) |
挤压回弹(石头落船、大象上船的重量感) |
sparkleAt(scene, x, y, color, count) |
星光爆点(成功反馈的固定语言) |
wavyBand(g, x, y, w, h, color, alpha, amplitude, step) |
波浪水面(曹冲的江面由两层叠加画成) |
createHand(scene, x, y, scale) |
引导用的小手(会轻轻点动) |
使用示例(大象的耳朵):
circle(g, cx + 92 * s, cy - 12 * s, 58 * s, body, THEME.outline, stroke); // 头
circle(g, cx + 58 * s, cy - 30 * s, 44 * s, dark, THEME.outline, stroke); // 耳朵
所有角色都遵循同一种画法约定:fillStyle 填色 + lineStyle(粗描边) 描边,描边颜色统一 THEME.outline(暖黑 0x3a2f26),不纯黑 —— 这是「柔和」观感的来源。美术到位后,替换的是游戏包里的 art.ts,模板与工具箱一行不改。
8.6 drag:按距离判定的拖拽控制器
Phaser 4 里 Container.setInteractive(hitArea) 的局部坐标约定与直觉不符,实测出现过「点在 A 上却抓到了 80 像素外的 B」——按钮那种「点在正中心」的场景察觉不到,拖拽一上来就暴露。工具箱的做法是完全不依赖命中区域,按下时抓住半径内最近的目标:
export function createDragController(scene: Phaser.Scene): DragController {
const targets: GrabTarget[] = [];
let active: { target: GrabTarget; dx: number; dy: number } | null = null;
const onDown = (pointer: Phaser.Input.Pointer): void => {
if (active) return;
let best: GrabTarget | null = null;
let bestDist = Number.POSITIVE_INFINITY;
for (const target of targets) {
if (target.enabled && !target.enabled()) continue;
const dist = Math.hypot(pointer.x - target.obj.x, pointer.y - target.obj.y);
if (dist <= target.radius && dist < bestDist) { best = target; bestDist = dist; }
}
if (!best) return;
active = { target: best, dx: pointer.x - best.obj.x, dy: pointer.y - best.obj.y };
best.onGrab?.(best.obj);
};
const onMove = (pointer) => { if (active) active.target.obj.setPosition(pointer.x - active.dx, pointer.y - active.dy); };
const onUp = () => { /* 松手 → onDrop */ };
scene.input.on('pointerdown', onDown);
scene.input.on('pointermove', onMove);
scene.input.on('pointerup', onUp);
scene.input.on('pointerupoutside', onUp);
return { add, remove, destroy };
}
export interface GrabTarget {
obj: Phaser.GameObjects.Container;
/** 抓取半径:小朋友手指粗,判定必须宽松 */
radius: number;
/** 什么时候可以被抓(例如"大象上船后就不能再拖") */
enabled?: () => boolean;
onGrab?: (obj) => void;
onDrop?: (obj) => void;
}
设计要点:
- 抓取半径普遍给得很大(光球 92、石头
radius * 3),「手指粗、点不准也能抓住」; enabled让「现在能不能拖」成为每个目标自己的事(大象在stage !== 'load'时不可拖);- 拖动偏移
dx/dy以按下点为基准,物体不会跳到手指中心; - 场景
shutdown时调drag.destroy()摘掉监听(模板里通过onShutdownCleanup统一做)。
8.7 深度(depth)约定与重画策略
没有全局的 depth 枚举,但各场景遵循同一种分层习惯(以曹冲的等价代换场景为例):
| 层 | depth | 内容 |
|---|---|---|
| 背景 | 1–2 | 天空、水面、水带、沙滩、云 |
| 世界静态 | 10 | 船(含水面半透明覆盖层) |
| 可交互物 | 20–30 | 大象、石堆、秤、船上货物 |
| 拖拽中 | 70–90 | 被抓起的目标临时提到最上 |
| 特效与读白 | 700–950 | 箭头、十字、提示气泡、星光 |
两条策略在模板里反复出现:
- 静态世界一次画、状态变化时重画(如船身吃水与刻痕);
- 需要持续动画的部分用逐帧
drawStage(g, metrics)(水位、鸟飞、太阳跑)—— 不维护 Sprite 列表,避免状态与画面不同步。
8.8 reducedMotion 与无障碍
fadeIn()在ctx.reducedMotion(系统prefers-reduced-motion)开启时直接跳过;- 场景切换、压平/挤压等大幅动效在 UI 层都有对应的克制;模板里所有「理解性动画」(水痕移动、太阳变暖)保留,因为它们承载信息;
- 关键信息不靠颜色单独传达:对/错同时有图形与音效;女娲的配对判定靠形状不靠颜色(色弱友好)。
8.9 小结
- phaser-kit 把「引擎怎么起、按钮怎么画、声音怎么发、拖拽怎么判」收敛成一套小原语,让模板与游戏只关心内容本身;
- 对比度、点击区域、输入锁、字体栈这些儿童向约束都落在工具箱一层,全仓共享;
- 拖拽控制器的教训(Phaser 4 hitArea 坐标问题)与输入锁的教训(连点跳页)说明:工具箱层是修一次、全仓受益的地方。
下一章进入平台层:存档、音频与运行环境适配 —— 讲清楚「游戏不碰环境」这条铁律背后的实现。