《游戏引擎实现原理》7.3 打磨与表现

打磨层把玩法事件翻译成玩家看到、听到的反馈:屏幕震动、粒子、音效触发、转场与 UI 提示。本节给出四件反馈的确定性实现与单测,并在 headless Chromium 里把收集瞬间真正渲染出来——粒子亮点与 UI 横幅逐像素回读断言,附一张可复现的 L2 截图。

7.3 打磨与表现

到上一节为止,《微光岛》已经「能玩」了:玩家能走、能收微光、能存档。但「能玩」和「好玩」之间隔着一层东西——反馈。收集一个微光时,屏幕上要有光点炸开、镜头要轻轻一震、耳朵里要有「叮」的一声;一天结束时,要有一个淡入的提示告诉玩家「第 2 天」。

这一节做的就是这个打磨层。它有一条铁律:打磨只读玩法事件,绝不反过来影响玩法判定。震动不会改变玩家位置,粒子不会阻挡移动,音效不会让收集失败。7.1 那条确定性玩法链之所以能保持确定性,正是因为打磨层被严格地关在「只读消费者」的位置上。

一旦这条铁律被破坏——比如「收集时给玩家一个击退」——玩法链立刻不再确定,前一节辛苦建立的「同输入两遍逐位相同」也就失效了。所以打磨层的第一行注释写的是:它们绝不反过来影响玩法判定。

这也解释了为什么打磨层要单独成一个模块,而不是散落在收集系统里。反馈代码天然容易「顺手改一下状态」——震动的时候挪一下相机、粒子炸开的时候推一下玩家。一旦允许这种顺手,玩法链和表现层就缠在一起,再想拆开就难了。把打磨层做成一个只读消费者,是用结构强迫纪律。

反馈四件套:震动、粒子、音效、转场

四件反馈各有自己的机制,但共享同一个输入:7.1 的玩法事件(collect / hurt)。

反馈机制关键约束
屏幕震动创伤(trauma)模型,强度 = trauma²二次方让轻微事件几乎不可见;偏移用确定性噪声采样
粒子有界对象池,事件触发 burst容量封顶,超出即丢弃,池绝不无限增长
音效事件 → 音效 id,带冷却冷却期内重复事件被抑制,避免「叮叮叮」连成一片
转场 / UI 提示idle → in → hold → out 状态机缓动曲线固定,透明度随时间确定地起落

三点设计值得说明:

  • 为什么震动用 trauma² 而不是线性。事件加的「创伤」是输入,玩家感受到的「抖动幅度」是输出。用二次方,小事件(收集,+0.25)几乎感觉不到抖动,大事件(受击,+0.6)才明显——这正是「重要的事情才该震动」的手感。线性模型会让每次收集都晃得很凶,很快让人烦躁。
  • 为什么粒子必须有界。玩家可能在极短时间内触发大量收集事件(连收、连击)。如果粒子按事件数无上限增长,一次爆发就能把帧时间拖垮。ParticleField 用 capacity 封顶,超出的直接丢弃——画面损失一点点,帧率稳住了。这和第 3 章的性能预算是同一条思路。
  • 为什么音效要有冷却。同一个音效在极短时间内重复触发,会叠加成刺耳的噪音。SfxRouter 给每个事件配一个冷却,冷却期内的重复请求返回 null,调用方据此跳过播放。

转场状态机则复用了卷三讲过的思路(见 helio-subsys-03-2 状态机与混合 ):in → hold → out 三段,每段有固定时长,透明度由 smoothstep 缓动。UI 横幅在 hold 段显示,out 段淡出。

为什么这四件反馈都要做成「纯逻辑」而不是直接调浏览器 API?因为只有纯逻辑才能被单测、被回放。ScreenShake.offset() 返回一个二维位移向量,至于这个向量是喂给相机还是喂给整幅画面平移,是渲染层的事;SfxRouter.trigger() 返回一个音效 id 字符串,至于这个 id 怎么变成 AudioContext 里的一段波形,是音频子系统的事。打磨层只负责决定该发生什么,不负责怎么让它发生——这和第 7.2 节「加载器只做三件事」是同一种分层。

可运行产物:确定性打磨层

四件反馈全在下面这个文件里,纯 TS、Node 26 直跑,不含任何 GPU 概念。事件入口是 applyEvent:加创伤、炸粒子、播音效,一次做完。

// file: src/game/presentation.ts
// tier: L1
// verify: npx vitest run src/game/presentation.test.ts
//
// 《微光岛》打磨层。玩法判定(7.1)产出「发生了什么」的事件,打磨层把它翻译成
// 「玩家看到 / 听到什么」:屏幕震动、粒子反馈、音效触发、转场与 UI 提示。四者都是
// 纯逻辑、确定性、可单测——它们绝不反过来影响玩法判定。

import { hashUnit } from '../render/ui2d/sprite-batch.ts';

export interface Vec2 { readonly x: number; readonly z: number }

// ── 屏幕震动:创伤(trauma)模型。事件只加创伤,强度 = trauma²,偏移由确定性噪声采样。
export class ScreenShake {
  #trauma = 0;
  #time = 0;
  readonly maxOffset: number;
  readonly decayPerSecond: number;
  constructor(maxOffset = 0.35, decayPerSecond = 1.4) {
    this.maxOffset = maxOffset;
    this.decayPerSecond = decayPerSecond;
  }
  get trauma(): number { return this.#trauma; }
  add(amount: number): void { this.#trauma = Math.min(1, Math.max(0, this.#trauma + amount)); }
  update(dt: number): void {
    this.#time += dt;
    this.#trauma = Math.max(0, this.#trauma - this.decayPerSecond * dt);
  }
  /** 当前帧的震动偏移:确定性噪声 × trauma²。 */
  offset(seed = 1): Vec2 {
    const s = this.#trauma * this.#trauma * this.maxOffset;
    if (s === 0) return { x: 0, z: 0 };
    const k = Math.floor(this.#time * 60);
    return { x: (hashUnit(k, seed) * 2 - 1) * s, z: (hashUnit(k, seed + 777) * 2 - 1) * s };
  }
}

// ── 粒子反馈:有界对象池,超出容量即丢弃,绝不无限增长。
export interface Particle { x: number; z: number; vx: number; vz: number; life: number }

export class ParticleField {
  readonly capacity: number;
  readonly particles: Particle[] = [];
  readonly speed: number;
  constructor(capacity = 128, speed = 3) { this.capacity = capacity; this.speed = speed; }
  /** 在 (x,z) 炸开一圈粒子。超出容量就丢弃。 */
  burst(x: number, z: number, count: number, seed: number): number {
    let emitted = 0;
    for (let i = 0; i < count; i++) {
      if (this.particles.length >= this.capacity) break;
      const a = (i / count) * Math.PI * 2 + hashUnit(i, seed) * 0.5;
      const sp = this.speed * (0.5 + hashUnit(i, seed + 31));
      this.particles.push({ x, z, vx: Math.cos(a) * sp, vz: Math.sin(a) * sp, life: 0.6 });
      emitted++;
    }
    return emitted;
  }
  update(dt: number): void {
    const keep: Particle[] = [];
    for (const p of this.particles) {
      p.x += p.vx * dt; p.z += p.vz * dt; p.life -= dt;
      if (p.life > 0) keep.push(p);
    }
    this.particles.length = 0;
    this.particles.push(...keep);
  }
}

// ── 音效触发:事件 → 音效 id,带冷却。
export interface SfxRule { readonly soundId: string; readonly cooldownSeconds: number }

export class SfxRouter {
  readonly #rules = new Map<string, SfxRule>();
  readonly #lastPlayed = new Map<string, number>();
  constructor(rules: Record<string, SfxRule>) {
    for (const [event, rule] of Object.entries(rules)) this.#rules.set(event, rule);
  }
  /** 事件在 now 时刻触发。冷却未过返回 null,否则返回音效 id 并记录时间。 */
  trigger(event: string, now: number): string | null {
    const rule = this.#rules.get(event);
    if (rule === undefined) return null;
    const last = this.#lastPlayed.get(event);
    if (last !== undefined && now - last < rule.cooldownSeconds) return null;
    this.#lastPlayed.set(event, now);
    return rule.soundId;
  }
}

// ── 转场与 UI 提示:一个带缓动的淡入淡出状态机。
export type TransitionPhase = 'idle' | 'in' | 'hold' | 'out';

export class Transition {
  readonly inSeconds: number;
  readonly holdSeconds: number;
  readonly outSeconds: number;
  phase: TransitionPhase = 'idle';
  #elapsed = 0;
  constructor(inSeconds = 0.3, holdSeconds = 0.8, outSeconds = 0.3) {
    this.inSeconds = inSeconds; this.holdSeconds = holdSeconds; this.outSeconds = outSeconds;
  }
  begin(): void { this.phase = 'in'; this.#elapsed = 0; }
  /** 推进转场,返回当前不透明度 [0,1]。 */
  step(dt: number): number {
    this.#elapsed += dt;
    if (this.phase === 'in') {
      const k = Math.min(1, this.#elapsed / this.inSeconds);
      if (k >= 1) { this.phase = 'hold'; this.#elapsed = 0; }
      return smoothstep(k);
    }
    if (this.phase === 'hold') {
      if (this.#elapsed >= this.holdSeconds) { this.phase = 'out'; this.#elapsed = 0; }
      return 1;
    }
    if (this.phase === 'out') {
      const k = Math.min(1, this.#elapsed / this.outSeconds);
      if (k >= 1) { this.phase = 'idle'; this.#elapsed = 0; }
      return 1 - smoothstep(k);
    }
    return 0;
  }
}

function smoothstep(k: number): number {
  const t = Math.min(1, Math.max(0, k));
  return t * t * (3 - 2 * t);
}

export interface PresentationState {
  readonly shake: ScreenShake;
  readonly particles: ParticleField;
  readonly sfx: SfxRouter;
  readonly transition: Transition;
  time: number;
}

export function createPresentation(): PresentationState {
  return {
    shake: new ScreenShake(),
    particles: new ParticleField(),
    sfx: new SfxRouter({
      collect: { soundId: 'sfx_collect', cooldownSeconds: 0.05 },
      hurt: { soundId: 'sfx_hurt', cooldownSeconds: 0.4 },
    }),
    transition: new Transition(),
    time: 0,
  };
}

export interface PresentationEvent { readonly kind: 'collect' | 'hurt'; readonly x: number; readonly z: number; readonly seed: number }
export interface Feedback { readonly shakeTrauma: number; readonly emitted: number; readonly sound: string | null }

/** 处理一个玩法事件:加创伤、炸粒子、播音效。 */
export function applyEvent(p: PresentationState, e: PresentationEvent): Feedback {
  p.shake.add(e.kind === 'collect' ? 0.25 : 0.6);
  const emitted = p.particles.burst(e.x, e.z, e.kind === 'collect' ? 12 : 20, e.seed);
  const sound = p.sfx.trigger(e.kind, p.time);
  return { shakeTrauma: p.shake.trauma, emitted, sound };
}

单测共 9 条断言,npx vitest run src/game/presentation.test.ts 全过,覆盖:创伤加满后随时间衰减到 0、创伤钳制在 [0,1]、粒子超容量即丢弃(capacity 20 时 burst(30) 只出 20 颗)、粒子寿命耗尽被回收、同种子两遍逐位相同、音效冷却期内重复被抑制、转场四段状态正确、以及把 7.1 整段模拟的 6 个收集事件都喂进打磨层并拿到反馈。

可跑的 L2 渲染截图

L1 只证明了「逻辑对」,还要证明「画面真的出来了」。这一节的 L2 在 headless Chromium 里把收集瞬间渲染出来:先跑 7.1 的模拟到第一次收集,触发震动 + 粒子;把震动偏移烘进实例坐标,用 scene-assembly.wgsl 画小岛 / 微光 / 玩家 / 粒子,再用 ui-composite.wgsl 叠一条 UI 提示横幅(alpha 混合 + setScissorRect 限域到画布下缘,见 helio-render-16-3 UI 与 3D 合成 )。

// snippet: src/game/presentation-l2.ts 的渲染通道(画面装配 + UI 横幅)
const pass = enc.beginRenderPass({
  colorAttachments: [{ view: target.createView(), clearValue: { r: 0.03, g: 0.04, b: 0.08, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
});
pass.setPipeline(scenePipe);
pass.setBindGroup(0, device.createBindGroup({
  layout: scenePipe.getBindGroupLayout(0),
  entries: [{ binding: 0, resource: { buffer: sBuf } }, { binding: 1, resource: { buffer: uBuf } }],
}));
pass.draw(6, scene.quads.length);
// UI 横幅:限域到画布下 1/4,alpha 混合叠上去
pass.setScissorRect(0, Math.floor(H * 3 / 4), W, Math.floor(H / 4));
pass.setPipeline(bannerPipe);
pass.setBindGroup(0, device.createBindGroup({ layout: bannerPipe.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: bannerBuf } }] }));
pass.draw(3);
pass.end();

本机 node tools/run-l2.mjs src/game/presentation-l2.ts 的实跑结果:

PASS  wgsl 0 error  0
PASS  收集触发了非零屏幕震动  {"x":0.000355445165769197,"z":0.021500340482452883}
PASS  粒子亮点出现在屏幕上(暖色)  px=255,242,178
PASS  UI 横幅出现在画布下缘(暖色高亮)  px=207,158,46
PASS  横幅之上仍是背景(未被覆盖)  px=8,10,20

四条断言逐像素回读:离玩家最远的那颗粒子落在暖色(255,242,178),UI 横幅在下缘是暖色高亮(207,158,46),而横幅上方仍是深蓝背景(8,10,20)——证明限域生效、横幅没有糊满整屏。同一帧导出的 PNG 里,可以看到 3×3 的微光网格、被收集后变暗的中心微光、玩家方块周围飞散的粒子光点,以及底部那条横幅。渲染结果需在浏览器中经本地 http 服务运行,命令行不可复现。

这里为什么要「离玩家最远的那颗粒子」而不是第一颗?因为所有粒子都从玩家的收集位置炸开,第一颗粒子还贴在玩家方块上,会被后画的玩家方块盖住——采样它会读到青色而不是粒子色。挑最远的一颗,是为了让采样点必然落在玩家方块之外。这是一个很小的测试细节,但它说明了一件事:L2 的断言要挑对采样点,否则你以为在验证粒子,其实一直在验证玩家。

为什么打磨层要 L1 与 L2 两道验收

只跑 L1 不够,只跑 L2 也不够——两者验的是不同的东西:

层级验什么本节的证据
L1逻辑对不对、是否确定9 条断言:衰减、封顶、冷却、状态机、事件驱动
L2画面出没出来、位置对不对4 条逐像素断言 + 一张 PNG

L1 能证明「震动最终会归零」,但证明不了「粒子真的画在了屏幕上」;L2 能证明「屏幕上有个暖色亮点」,但证明不了「这个亮点是确定性的、不是碰巧」。打磨层这种「逻辑简单、表现敏感」的代码,恰恰最需要两道都过——因为它的 bug 往往不是崩溃,而是「看起来对但感觉不对」,只有把逻辑与像素都钉死,才能把这种模糊的手感问题变成可回归的断言。

小结

打磨层的边界很清晰:它消费玩法事件,产出视觉与听觉反馈,且不改变任何玩法状态。这一边界既保住了 7.1 的确定性,又让「手感」这件事本身可测、可回放。三节合起来,《微光岛》作为一个垂直切片已经闭环:状态能动(7.1)、内容能进(7.2)、反馈能出(7.3)。下一章我们不再加功能,而是回到工程——用真实的性能数据回答「这个切片达标了吗」。

阅读导航:上一节:7.2 关卡与内容管线 · 下一节:8.1 性能预算达标 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径