第13章 - 画布系统与双视图
工具台是 Vue 组件树,画布是 Phaser 场景树,两套渲染体系同时挂在同一个关卡宿主里。把它们绑在一起的不是”共享一个 store”,而是一条更窄的约定:两边读同一份 RunSnapshot,派发同一组 LevelAction。本章从启动器讲到命中区,解释为什么 20 关里只有 4 关真的创建了 Phaser 实例,以及裁剪版 Phaser 的 220.3 KB 是怎么来的。
13.1 双视图契约:一份快照,两条渲染路径
关卡宿主 apps/site/src/views/WorkshopView.vue 只做一件事:把 LevelContext 装配好,然后同时渲染画布容器与工具台。画布区是否出现由 manifest.hasCanvas 决定:
<!-- apps/site/src/views/WorkshopView.vue(节选) -->
<section v-if="module?.manifest.hasCanvas"
class="relative min-h-[38vh] flex-1 min-w-0 border-b border-grid bg-paper lg:min-h-0 lg:border-b-0 lg:border-r">
<div ref="canvasHost" data-testid="level-canvas" class="game-host h-full w-full" />
</section>
<aside class="flex min-h-0 w-full flex-col"
:class="module?.manifest.hasCanvas ? 'lg:w-[420px] lg:flex-none' : ''">
<div ref="host" data-testid="level-dock" class="min-h-0 flex-1 overflow-y-auto p-2.5">
<ToolDock v-if="runtime && detail" :tools="detail.tools" :runtime="runtime" :detail="detail" :locale="locale" />
</div>
</aside>
工具台拿到的不是快照,而是运行时本身:
// packages/toolkit-ui/src/ToolDock.vue(节选)
const snapshot = shallowRef<RunSnapshot>(props.runtime.snapshot());
const unsubscribe = props.runtime.subscribe((next) => {
snapshot.value = next;
});
onBeforeUnmount(() => {
unsubscribe();
});
工具面板按 snapshot.toolState 渲染列表与表单,用户操作时调用 runtime.dispatch(action)。画布走同一条链路:runtime.subscribe() 拿快照重绘,指针事件里 runtime.dispatch()。两边都不持有副本,也不互相通知。
| 关注点 | 画布(Phaser Scene) | 工具台(Vue) |
|---|---|---|
| 擅长 | 空间位置、拖放、纸面质感、即时动画 | 文字、表格、表单、键盘、屏幕阅读器 |
| 读 | runtime.snapshot() / subscribe 回调 |
同左 |
| 写 | runtime.dispatch({ type, payload }) |
同左 |
| 判定 | 不判定,只画 | 不判定,只收集输入 |
| 测试 | 依赖 Phaser,主要靠 e2e | 数据从快照来,组件层可单测 |
收益在改动时最明显:新增一件工具只需在 ToolDock 加一个分支,画布不认识它;调整画布命中区不需要动任何工具面板。反过来,如果哪天在场景里看到”自己算通过没通过”,那就是把判定复制进了渲染层。
hasCanvas 是关卡入口声明的,不是运行时猜的;games/forge/src/forge.test.ts 用一条漂移检查把它锁死——只有 pattern / rule 模板允许为 true。
13.2 启动器:设计尺寸与 FIT 适配
Phaser 的启动代码只有 35 行,全部在 packages/canvas-kit/src/createGame.ts:
// packages/canvas-kit/src/createGame.ts
export interface CreateGameOptions {
designSize: { width: number; height: number };
scenes: Phaser.Types.Scenes.SceneType[];
}
/**
* 在 host 里启动 Phaser:AUTO 渲染器、FIT + CENTER_BOTH 适配、无音频。
* 尺寸始终用设计尺寸,CSS 缩放交给 Scale Manager,绘制坐标因此可以写死。
*/
export function createPhaserGame(host: HTMLElement, opts: CreateGameOptions): Phaser.Game {
const width = opts.designSize.width;
const height = opts.designSize.height;
return new Phaser.Game({
type: Phaser.AUTO,
parent: host,
width,
height,
backgroundColor: PALETTE.paper,
banner: false,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
width,
height,
},
audio: { noAudio: true },
scene: opts.scenes,
});
}
几个选择值得说明:
Scale.FIT+CENTER_BOTH:浏览器窗口怎么变,逻辑坐标始终是设计尺寸。场景里写x = 70就永远是纸带起点,不必处理 DPR 与容器比例。audio: { noAudio: true }:音效由平台层AudioBus用 WebAudio 合成,Phaser 不加载音频资源,裁剪清单里才能放心删掉 sound 系统。backgroundColor: PALETTE.paper:首帧绘制前短暂露出的画布底色用纸色而非默认黑,避免闪黑块。scene: opts.scenes:数组第一个自动启动,当前关卡都是一个场景。
设计尺寸来自模板:pattern 为 900×560(packages/templates/src/layout.ts 的 DESIGN),rule 为 720×720(packages/templates/src/rule/index.ts 的 RULE_DESIGN)。关卡入口把模板导出的尺寸写进 manifest,再交给模板 mount():
// packages/templates/src/pattern/index.ts(节选)
async mount(ctx: LevelContext, runtime: LevelRuntime): Promise<void> {
if (!ctx.canvasHost) return;
game = createPhaserGame(ctx.canvasHost, {
designSize: manifest.designSize,
scenes: [createPatternScene(ctx, runtime)],
});
},
async unmount(): Promise<void> {
game?.destroy(true);
game = null;
},
pause(): void { game?.loop.sleep(); },
resume(): void { game?.loop.wake(); },
destroy(true) 的 true 是”连 canvas 一起删”;少了它,路由切走后 DOM 里会留一块不再绘制的 canvas。暂停/恢复走 game.loop.sleep() / wake() 而不是 scene.pause():切后台时停掉渲染循环,不会留下补间在后台空转。
宿主必须等 DOM 就位再创建游戏。WorkshopView.start() 里先 await nextTick() 才组装 LevelContext,注释写明了原因:”hasCanvas 的画布容器由 v-if 渲染:等这一拍 DOM 出来,ctx.canvasHost 才不会为 null”。
13.3 BaseScene:把生命周期收在一处
每个场景都继承 packages/canvas-kit/src/baseScene.ts 的 BaseScene。它提供的不只是几个绘图方法,更重要的是把”场景销毁时该做什么”变成唯一入口:
// packages/canvas-kit/src/baseScene.ts(节选)
export abstract class BaseScene extends Phaser.Scene {
protected readonly ctx: LevelContext;
private readonly cleanups: CleanupFn[] = [];
private cleanedUp = false;
get W(): number { return this.ctx.manifest.designSize.width; }
get H(): number { return this.ctx.manifest.designSize.height; }
protected get headerHeight(): number { return CANVAS_HEADER_HEIGHT; }
protected get reducedMotion(): boolean { return this.ctx.reducedMotion; }
/** 只在这里挂生命周期监听,子类不应覆盖 init。 */
init(): void {
this.cleanedUp = false;
this.sys.events.once(Phaser.Scenes.Events.SHUTDOWN, this.flushCleanups, this);
this.sys.events.once(Phaser.Scenes.Events.DESTROY, this.flushCleanups, this);
}
protected onShutdownCleanup(fn: CleanupFn): void {
if (this.cleanedUp) { fn(); return; }
this.cleanups.push(fn);
}
private flushCleanups(): void {
if (this.cleanedUp) return;
this.cleanedUp = true;
const pending = this.cleanups.splice(0, this.cleanups.length);
for (const fn of pending) fn();
}
}
子类在 create() 里注册的输入监听与运行时订阅全部走 onShutdownCleanup。pattern 场景注册了 pointerdown / pointermove / gameout 三个监听和一个 runtime.subscribe,清理函数里逐条 off、退订、清空悬停层、还原光标。
为什么不让子类各自写 shutdown()?因为场景重启(scene.restart)与整局销毁(game.destroy)都会触发清理,两者不一定按同一顺序发生;init() 里用 once 挂两个事件、flushCleanups 幂等,覆盖了两种路径。当前清理函数已执行过时再注册,会立刻执行一次,避免”注册得晚就永远不执行”。
BaseScene 还统一了纸面:drawPaperBackground() 画纸色底 + 32 逻辑像素细网格 + 表头双线(网格纯装饰,不参与命中);drawHeader(text) 画左侧书脊、标题、铜色短横线。表头高度常量 CANVAS_HEADER_HEIGHT = 84 与模板 layout.ts 的 headerH: 84 对齐,但 canvas-kit 不 import templates——注释写得很直白:”canvas-kit 不反向依赖 templates”。
13.4 主题与绘制:零位图,全部程序化
theme.ts 只放常量和纯函数,不 import Phaser 运行时,这样颜色与字体可以被纯逻辑单测引用,也不会把 Phaser 拖进不该出现的 chunk:
// packages/canvas-kit/src/theme.ts(节选)
export const PALETTE = {
paper: '#f7f1e6',
card: '#fffdf8',
ink: '#22303c',
// cream 上的对比度 4.91:1(旧值 #5b6b78 只有 4.37,AA 不达标)
inkSoft: '#55636f',
blueprint: '#1f4e6b',
brass: '#b07d2b',
teal: '#2f7d78',
red: '#b4473f',
grid: '#d8e2e8',
} as const;
export function toColor(value: string | number): number {
if (typeof value === 'number') return value;
const raw = value.trim().replace(/^#/, '');
const hex = raw.length === 3 ? raw.replace(/./g, (char) => char + char) : raw;
const parsed = Number.parseInt(hex, 16);
return Number.isNaN(parsed) ? 0x000000 : parsed;
}
色值与 Tailwind @theme 里的 token 同名同值(apps/site/src/style.css),画布纸色与外壳卡片色因此是同一批常量。FONT_SIZES 把字号收敛成 xs/sm/md/lg/xl 五档;字体栈拉丁在前、中文回落,注释说明了原因:避免全角弯引号。
draw.ts 是绘制助手层,只依赖 Phaser 类型、不 import 运行时(调用方本来就在场景里):
| 函数 | 用途 |
|---|---|
roundRect |
圆角矩形,填充与描边都可省略 |
circle |
圆形,参数与 roundRect 同构 |
dashLine |
一条虚线 |
dashedRect |
虚线矩形,用于断开格、待修补区 |
label / centeredLabel |
左对齐 / 居中的文字 |
虚线是纯算术:按 dash + gap 步进,逐段 lineBetween,不依赖纹理或贴图。
为什么零位图:所有块、角色、地图、图表都是 Graphics 或文字拼的。收益有三条——产物里没有图片资源(体积预算中”单个非代码资源 ≤ 200 KB”预期为零)、换色只改常量、形状与颜色两条通道同时表达信息(色盲友好)。代价是绘制代码更长,所以才有 draw.ts 这层助手。
13.5 裁剪版 Phaser:220.3 KB 的来历
完整 Phaser 打出来是 1341.9 KiB 原始 / 347.3 KiB gzip,而本作只用到 Scene、GameObjects(Container / Graphics / Text / Zone / Rectangle)、Input、Tweens、Time、Cameras、Scale;physics、tilemaps、sound、animations、particles 全是死重。于是仓库里放了一份裁剪产物:
packages/canvas-kit/vendor/
├─ phaser-trim.esm.js # 裁剪版 bundle(不要手改)
├─ phaser-trim.esm.js.LICENSE.txt # 许可注释,必须随产物分发
├─ LICENSE.phaser.md # Phaser 的 MIT 全文
└─ README.md # 裁剪清单、重建步骤、回退方式
替换发生在构建配置里,源码永远写 import Phaser from 'phaser':
// apps/site/vite.config.ts(节选)
const phaserTrim = fileURLToPath(
new URL('../../packages/canvas-kit/vendor/phaser-trim.esm.js', import.meta.url),
);
resolve: { alias: [{ find: /^phaser$/, replacement: phaserTrim }] },
Vite 只改运行时解析,TypeScript 仍从官方 phaser 包拿类型,编辑器里补全与签名完整。产物侧再把 Phaser 单独切一个 chunk,并命名成可辨认的文件:codeSplitting.groups 用 /node_modules[\\/]phaser[\\/]|canvas-kit[\\/]vendor[\\/]phaser-trim/ 匹配,chunkFileNames 见到 chunk.name 含 phaser 就输出 assets/vendor-phaser-[hash].js。
实测体积(2026-11-04 的 pnpm size):assets/vendor-phaser-*.js 为 220.3 KB gzip / 849.5 KB 原始,预算是 280 KB / 1100 KB。chunkSizeWarningLimit 设为 1100,等于把”单 chunk 原始体积预算”同步给 Vite 的警告阈值,避免每次构建都刷一条已知告警。门禁逻辑在 scripts/check-size.mjs:
const BUDGETS = {
initialKb: 85,
initialRawKb: 260,
chunkKb: 280,
chunkRawKb: 1100,
distKb: 4000,
assetKb: 200,
assetsKb: 600,
};
裁剪版还有三件必须一起做的事:
- 许可。
LICENSE.phaser.md与phaser-trim.esm.js.LICENSE.txt必须随产物保留,pack:check会检查它们是否就位。 - 重建要跑全量 e2e。裁掉的对象如果被某个关卡用到,TypeScript 查不出来,只会在运行时炸;
vendor/README.md明确要求合并前跑pnpm e2e。 - 当前没有
pnpm phaser:trim脚本。根package.json的 scripts 只有 dev / build / typecheck / lint / test / content:check / audit:levels / pack:check / size / verify / e2e / e2e:dist。裁剪产物是一次性生成后入库的,升级 Phaser 时按vendor/README.md手工重建(需临时安装webpack@5);文档里”改 alias 或PHASER_FULL=1回退”的说法,目前只有前半句在配置里成立。
13.6 命中区与坐标:worldX 就是设计坐标
Scale.FIT 有一个容易被忽略的好处:Phaser 的输入系统会把浏览器坐标换算回设计坐标。场景里拿到的 pointer.worldX / worldY 已经是 900×560 坐标系,不需要写 (event.clientX - rect.left) / rect.width * 900 这类换算。两个场景都没有使用相机缩放或滚动,worldX 与设计坐标直接相等。
命中区不是逐格 setInteractive,而是场景级监听 + 自己算最近格:
// packages/templates/src/pattern/scene.ts(节选)
/**
* 设计坐标命中:整张纸带卡片都算可点,落在卡片里就取横向最近的格子。
* 孩子点偏一点也能观察,e2e 也不必像素级对准格子中心。
*/
private indexAt(x: number, y: number): number {
const total = this.tileTotal;
if (total <= 0) return -1;
const left = PATH_LAYOUT.startX - 26;
const right = PATH_LAYOUT.startX + stripWidth(total) + 26;
const top = PATH_LAYOUT.y - 52;
const bottom = PATH_LAYOUT.y + PATH_LAYOUT.cellH + 72;
if (x < left || x > right || y < top || y > bottom) return -1;
let nearest = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
for (let index = 0; index < total; index += 1) {
const distance = Math.abs(tileCenterX(index) - x);
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = index;
}
}
return nearest;
}
这样做有三个直接好处:
- 命中区可以比图形大。格子 56×76 逻辑像素,但整张纸带卡(左右各 26 px,上下留得更多)都算可点,孩子点偏一点仍有反应;e2e 不必像素级对准格子中心。
- 不需要给每个显示对象挂交互。全仓没有一处
setInteractive,也就不存在 Container 原点与命中矩形不一致的经典坑(见 第20章)。 - 命中判定接近纯函数。
indexAt(x, y)只依赖布局常量与格数,规则关的cellAt(worldX, worldY)同理,可以脱离 Phaser 单独验证。
指针回调里还有两条并发纪律。pattern 场景把 dispatch 延到微任务:
// packages/templates/src/pattern/scene.ts(节选)
private handlePointerDown(pointer: Phaser.Input.Pointer): void {
const index = this.indexAt(pointer.worldX, pointer.worldY);
if (index < 0) return;
// 派发会同步 notify → 重画整条带子(含销毁显示对象)。延到微任务,
// 避免在 Phaser 输入处理的栈里销毁正在派发事件的显示对象。
const visible = this.script?.scenario.visible[index] ?? null;
if (visible !== null) {
queueMicrotask(() => {
void this.runtime.dispatch({ type: 'pattern/inspect', payload: { index } });
});
return;
}
// ……空格则按"照这样做"的预测填入,同样延到微任务派发
}
规则关一次点击会连发多个 rule/place,于是用 dispatching 标志在派发期间忽略新点击,避免 at 下标打架。悬停反馈走独立 Graphics 层(this.hover),pointermove 只重画这一层,不触发整块内容重绘;gameout 时清掉悬停环与手型光标。
场景重绘本身由快照签名驱动:把 phase、revision、已观察格、当前规律、已放块、最近一次运行结果拼成字符串,签名不变就整个跳过。运行时每秒可能发多次通知,只有真正影响画面的字段变化才会拆掉整条纸带的显示对象——这是”订阅式全量重绘”能跑得动的前提。
13.7 无画布关卡:16 关的退化路径
20 关里只有 4 关声明 hasCanvas: true:两关 pattern(k-structure-pattern-path、g13-structure-symmetry)与两关 rule(k-algorithm-train、g13-algorithm-route)。其余 16 关的 mount() 是空实现:
// packages/templates/src/experiment/index.ts(节选)
async mount(_ctx: LevelContext, _runtime: LevelRuntime): Promise<void> {
// 无画布:宿主会把 ToolDock 拉满宽度,这里不需要渲染任何东西。
},
argue 模板同样,论证板与棋盘都在 ToolDock / ArgumentBoard 里。宿主侧的退化只需要两行 class 切换:画布 section 不渲染,工具台不再限宽,而是居中成一栏(max-w-3xl,约 920px)。ArgumentBoard 只在 detail.template === 'argue' 时追加在工具台下面,和工具面板读的是同一份 liveSnapshot。
这条退化路径不是”凑合能看”,而是多数关卡的默认形态:
canvasHost为null,模板mount第一行if (!ctx.canvasHost) return;就兜住了;- 没有 Phaser 实例意味着没有 rAF、没有 WebGL 上下文,关卡进入更快、平板更省电;
- 没有画布时工具区居中,平板竖屏上仍然可读。
这也解释了依赖方向:@mmf/canvas-kit 只被 pattern / rule 两个模板 import,ToolDock 完全不认识 Phaser。若把二者反过来,16 个关卡都会被迫付 Phaser 的加载成本。
13.8 响应式、reducedMotion 与已知缺口
响应式的规则不多,但每条都是实测出来的(见 docs/11 的可玩性改造记录)。2026-11 的一轮修复集中在四处:
| 问题 | 修法 | 结果 |
|---|---|---|
| 1024×768 横向溢出,右栏被压到 184px | 画布区补 min-w-0(flex-1 的 min-content 会被 canvas 固有宽 900 顶住) |
右栏稳定 420px |
| 右栏被压扁 | lg:w-[420px] 加 lg:flex-none |
不再随内容宽度收缩 |
| canvas 把 flex 容器撑宽 | .game-host canvas 加 max-width: 100% |
窄屏横向滚动条消失 |
| 手机竖屏画布占满 | 画布区 min-h-[38vh],竖屏时在上、工具台在下 |
首屏可用 |
/* apps/site/src/style.css(节选) */
.game-host canvas {
display: block;
margin: 0 auto;
/* Phaser 的 FIT 模式会给 canvas 写死像素宽;不限制最大宽度,画布会把
flex 的 min-content 顶到设计尺寸 900px,窄屏就会出现横向滚动条。 */
max-width: 100%;
touch-action: none;
}
桌面/平板是左右两栏(画布 flex-1、工具台固定 420px),手机竖屏是上下两段(画布 min-h-[38vh]、工具台在下可滚动)。工具台内部固定高度的滚动区与”失败横幅钉在滚动容器上方”属于工具台布局,在 第7章 讲过。
reducedMotion 有三层,缺一层就可能漏:
WorkshopView.start()用window.matchMedia('(prefers-reduced-motion: reduce)').matches读一次,同时传给createRuntime与createLevelContext;BaseScene.reveal()直接落到终态,不跑补间:protected reveal(target: AlphaTarget, delay = 0): void { if (this.reducedMotion) { target.setAlpha(1); return; } target.setAlpha(0); this.tweens.add({ targets: target, alpha: 1, delay, duration: 220, ease: 'Sine.easeOut' }); }- CSS 兜底,把动画与过渡压到 0.01ms:
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } }
场景里的大幅动画都显式判断了 this.reducedMotion,例如纸带走通后的”小人”滑动:reducedMotion 下直接把小人放到终点,不注册补间。这不是礼貌性适配——晕动敏感的用户在不减动效的页面里会直接离开。
已知缺口(写清楚比假装没有好):
- 没有
ResizeObserver。容器尺寸变化靠Scale.FIT+ 浏览器重排处理,极端 DPR 或父容器用transform缩放时可能略糊;要补它得先配 Phaser 的测试环境,目前列为 P2。 - 命中区偏宽。pattern 的命中区是整张纸带卡片、取最近格;若要”只有格子本体可点”可以再收窄,但会让低龄用户更容易点空。
- 11 关首屏仍要滚动。
docs/11的遗留项写明原因:单张工具面板本身比 dock 可视区高(probe 类 754px、knob 类 600–710px),不是布局 bug;要全部归零,需要给 probe / knob 面板做分步向导。 - 画布与工具台抢焦点。输入框聚焦时需要暂停画布快捷键,这条还不是自动化用例,只在人工走查里覆盖。
小结
画布系统只有四个源文件,但把”空间”和”结构”两种表达接到了同一个运行时上:
createPhaserGame只负责启动:设计尺寸、FIT 适配、无音频、无横幅;BaseScene统一初始化与清理,子类不覆盖init,所有监听在SHUTDOWN/DESTROY时一次收干净;theme.ts与draw.ts保持”纯常量 + 纯绘制助手”,不 import Phaser 运行时,零位图;- 裁剪版 Phaser 以 alias 替换,整包 220.3 KB gzip / 849.5 KB 原始,落在 280 / 1100 KB 预算内,代价是升级时必须手工重建并跑全量 e2e;
- 命中区由场景自己算,
pointer.worldX已是设计坐标,不需要手写 DPR 换算; - 20 关里 16 关没有画布,
mount()空实现 + 工具台居中就是默认退化路径; - 响应式与 reducedMotion 各有三层保障,缺口(ResizeObserver、滚动高度)在上一节列明。
下一章进入证据层:运行时产生的 EvidenceDraft 如何被补齐成事件、聚合成四维画像,再变成九枚徽章。