第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,
};

裁剪版还有三件必须一起做的事:

  1. 许可。LICENSE.phaser.md 与 phaser-trim.esm.js.LICENSE.txt 必须随产物保留,pack:check 会检查它们是否就位。
  2. 重建要跑全量 e2e。裁掉的对象如果被某个关卡用到,TypeScript 查不出来,只会在运行时炸;vendor/README.md 明确要求合并前跑 pnpm e2e。
  3. 当前没有 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 有三层,缺一层就可能漏:

  1. WorkshopView.start() 用 window.matchMedia('(prefers-reduced-motion: reduce)').matches 读一次,同时传给 createRuntime 与 createLevelContext;
  2. 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' });
    }
    
  3. 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 如何被补齐成事件、聚合成四维画像,再变成九枚徽章。