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 性能预算达标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。