站内已有一整卷 Phaser 引擎专书 ——那 127 篇是 Phaser 的工程实操(含它的动画与粒子系统),讲一套具体引擎的 API 怎么用。本章讲的是与引擎无关的手感原理:缓动曲线的数学性质、时间轴的调度与打断、打击感的构成要素、震动的分级——换成任何引擎都成立。那卷教你「在 Phaser 里怎么调一个 tween」,本章把「手感」拆成可计算、可断言、可复现的整数模型。
上一节把 UI 的布局、弹窗与合批做完了,界面已经能画出来。但「画出来」和「好手感」是两件事:同一个按钮,点下去是瞬间跳变还是先快后慢地滑过去,玩家感受完全不同。这一节处理的就是动效。
手感的两块地基:曲线与调度
任何一段动效都能拆成两个独立问题。第一个是曲线:一个值从起点走到终点,中间该怎么走——匀速?先慢后快?还是冲过头再弹回来?这就是缓动函数。第二个是调度:多个动画谁先谁后、能不能并行、中途被打断了怎么办——这就是时间轴。分开是有道理的:缓动是纯数学,给定输入必得输出;时间轴是状态机,管「什么时候轮到谁」。分开之后,曲线能单独测(端点、单调性、过冲),调度也能单独测(起点、完成、打断)。
五种缓动:形状与用途
五种缓动全部用千分比整数表示:输入 t∈[0,1000] 是进度(0 起点、1000 终点),输出是同量纲进度。这样做有两个原因:一是避开浮点,跨语言(TS / Python / 浏览器)能逐位对齐;二是小游戏对「几毫秒的差别」本就不敏感,千分之一精度足够。
| 缓动 | 公式(T 为千分比进度) | 形状 | 典型用途 |
|---|---|---|---|
| linear | T | 匀速 | 进度条、读条 |
| inQuad | T²/1000 | 起步慢、越来越快 | 加速、下落 |
| outQuad | T·(2000−T)/1000 | 起步快、越来越慢 | 淡出、减速停靠 |
| inCubic | T³/1e6 | 起步更慢 | 重物落地 |
| outCubic | 1000−(1000−T)³/1e6 | 收尾更稳 | 弹窗、面板滑入 |
| outBack | 1000+C3u³/1e9+C1u²/1e6(u=T−1000) | 冲过终点再落回 | 合成、盖章 |
| outElastic | 几何衰减的整数正弦波 | 来回振荡后落定 | 升级、开宝箱 |
注意 outBack 与 outElastic 的输出会越界:前者先冲到 1000 以上再落回,后者在 1000 上下反复振荡。这不是 bug,正是「回弹」「弹性」的手感来源;但它们必须在端点处精确(t=0 出 0、t=1000 出 1000),否则动画会停在错位置。这条被写进了测试。弹性还需要正弦,这里用 Bhaskara I 近似公式在模块加载时生成一张 360 项整数正弦表,之后只查表、只做整数乘除,振幅则用右移做几何衰减。
时间轴:绝对起点与打断
时间轴的核心是给每一步算一个绝对起点:串行接在上一节末尾(起点 = 前面所有 delay+dur 之和),并行则只由该步自己的 delay 决定。算完起点,「当前进度」就是一个减法:floor((t − 起点) × 1000 / 时长),再夹到 [0,1000]。
「打断」的语义是冻结而非取消:给该步记一个 stopMs,之后采样用 min(t, stopMs) 代替 t,进度自然冻住并标记完成,串行链条继续往下走。「onComplete」也不需要回调注册表——advance() 每次返回「本次刚完成的步 id 列表」,调用方按需处理。这样时间轴本身是纯函数,可测、可回放。
P 层:整数缓动与时间轴内核
下面这段是 P 层(内核层)产物 core/src/e12/easing.ts:缓动函数族(7 个)+ 时间轴调度器(并行/串行、延迟、打断、完成事件)。全整数、无 Math.random(),既能被浏览器原型内联,也能被单元测试直接跑。
// file: core/src/e12/easing.ts
// tier: P
// verify: node --test 'core/test/e12_*.test.ts'
//
// 《合成小岛》缓动函数族 + 时间轴调度器。缓动进度用千分比(0–1000)整数定点:
// 输入 t∈[0,1000],输出同量纲进度;outBack / outElastic 会越界表示过冲。
// 时间轴支持 并行/串行、延迟、打断、onComplete。全整数、无 Math.random()。
export type EaseKind = "linear" | "inQuad" | "outQuad" | "inCubic" | "outCubic" | "outBack" | "outElastic";
export interface EasingConfig {
version: number; seed: number;
backC1Milli: number; backC3Milli: number;
elasticAmplitudeMilli: number; elasticTurns: number; elasticDecayShift: number;
}
/** 整数地板除(对负数也向下取整,与 Python // 一致)。 */
export function ediv(a: number, b: number): number { return Math.floor(a / b); }
export function eclamp(v: number, lo: number, hi: number): number { return v < lo ? lo : v > hi ? hi : v; }
// 整数正弦表:360 项、值域 [-1000, 1000],角度单位为「度」。
// Bhaskara I 近似 sin(x°) ≈ 4x(180−x) / (40500 − x(180−x)),纯整数、可复现。
const SIN_MILLI: number[] = (() => {
const table: number[] = new Array<number>(360);
for (let d = 0; d < 360; d += 1) {
const x = d % 180, p = x * (180 - x);
table[d] = (d < 180 ? 1 : -1) * ediv(4 * p * 1000, 40500 - p);
}
return table;
})();
/** 缓动函数族:输入千分比进度 t,输出同量纲进度;回弹/弹性可越界。 */
const CURVES: Record<EaseKind, (t: number, cfg: EasingConfig) => number> = {
linear: (t) => eclamp(t, 0, 1000),
inQuad: (t) => { const T = eclamp(t, 0, 1000); return ediv(T * T, 1000); },
outQuad: (t) => { const T = eclamp(t, 0, 1000); return ediv(T * (2000 - T), 1000); },
inCubic: (t) => { const T = eclamp(t, 0, 1000); return ediv(T * T * T, 1000000); },
outCubic: (t) => { const T = eclamp(t, 0, 1000); const u = 1000 - T; return 1000 - ediv(u * u * u, 1000000); },
outBack: (t, cfg) => { const u = eclamp(t, 0, 1000) - 1000;
return 1000 + ediv(cfg.backC3Milli * u * u * u, 1000000000) + ediv(cfg.backC1Milli * u * u, 1000000); },
outElastic: (t, cfg) => { const T = eclamp(t, 0, 1000); if (T === 0 || T === 1000) return T;
const phase = ediv(T * 360 * cfg.elasticTurns, 1000), amp = cfg.elasticAmplitudeMilli >> (T >> cfg.elasticDecayShift);
return 1000 + ediv(amp * SIN_MILLI[(phase + 270) % 360], 1000); },
};
/** 按名字取缓动值。 */
export function ease(kind: EaseKind, t: number, cfg: EasingConfig): number { return CURVES[kind](t, cfg); }
// ------------------------------------------------------------------ 时间轴
export interface EaseStep { id: string; easing: EaseKind; delayMs: number; durMs: number }
export interface TimelineDef { id: string; mode: "serial" | "parallel"; steps: EaseStep[] }
export interface StepRuntime { id: string; stopMs: number | null }
export interface TimelineState { id: string; mode: string; tMs: number; steps: StepRuntime[] }
export interface StepSample { id: string; progress: number; eased: number; done: boolean; cancelled: boolean }
export function newTimeline(def: TimelineDef): TimelineState {
return { id: def.id, mode: def.mode, tMs: 0, steps: def.steps.map((s) => ({ id: s.id, stopMs: null })) };
}
/** 各步的绝对起点(毫秒):串行接在上一节末尾,并行只算各自 delay。 */
export function stepStarts(def: TimelineDef): number[] {
const out: number[] = [];
let cursor = 0;
for (const s of def.steps) {
if (def.mode === "serial") { out.push(cursor + s.delayMs); cursor += s.delayMs + s.durMs; }
else out.push(s.delayMs);
}
return out;
}
export function sampleTimeline(state: TimelineState, def: TimelineDef, cfg: EasingConfig): StepSample[] {
const starts = stepStarts(def);
return def.steps.map((s, i) => {
const st = state.steps[i];
const eff = st.stopMs === null ? state.tMs : Math.min(state.tMs, st.stopMs);
const local = eff - starts[i];
const progress = local <= 0 ? 0 : local >= s.durMs ? 1000 : ediv(local * 1000, s.durMs);
const cancelled = st.stopMs !== null;
return { id: s.id, progress, eased: ease(s.easing, progress, cfg),
done: cancelled || state.tMs >= starts[i] + s.durMs, cancelled };
});
}
/** 推进 dtMs,返回新状态与本次「刚刚完成」的步 id(即 onComplete 回调流)。 */
export function advance(state: TimelineState, def: TimelineDef, cfg: EasingConfig, dtMs: number): { state: TimelineState; completed: string[] } {
const before = sampleTimeline(state, def, cfg);
const next: TimelineState = { id: state.id, mode: state.mode, tMs: state.tMs + dtMs, steps: state.steps.map((s) => ({ ...s })) };
const after = sampleTimeline(next, def, cfg);
const completed: string[] = [];
for (let i = 0; i < after.length; i += 1) if (!before[i].done && after[i].done) completed.push(after[i].id);
return { state: next, completed };
}
/** 打断某一步:冻结在当前进度并标记完成,后续步照常推进。 */
export function interrupt(state: TimelineState, id: string): TimelineState {
return { ...state, steps: state.steps.map((s) => (s.id === id && s.stopMs === null ? { ...s, stopMs: state.tMs } : s)) };
}
export function isFinished(state: TimelineState, def: TimelineDef): boolean {
const starts = stepStarts(def);
return def.steps.every((s, i) => state.steps[i].stopMs !== null || state.tMs >= starts[i] + s.durMs);
}
上面这张 CURVES 表把七个缓动写成七个纯函数:简单的一行一个,回弹与弹性因为要过冲而多几行。ediv 是整数地板除(对负数也向下取整,与 Python // 一致)——这点很关键,因为回弹的 u 是负数,若用 Math.trunc 就会在端点处差 1。
下面这段是 P 层测试 core/test/e12_easing.test.ts 的实跑输出,它把端点、单调性、过冲、调度与打断都锁进了断言(同机相对量级,耗时不可移植):
✔ 端点精确:所有缓动 t=0 出 0,t=1000 出 1000
✔ 单调性:线性 / 二次 / 三次在 0..1000 上非递减
✔ 回弹越界:outBack 先冲过终点再落回
✔ 弹性越界:outElastic 峰值 > 1000 且带负向回落
✔ 串行时间轴:起点依次接续
✔ onComplete:按帧推进收集完成事件流
✔ 打断语义:冻结当前进度并立即算完成
ℹ pass 11 / fail 0
「单调性」只对线性、二次、三次断言——回弹和弹性本就该非单调,越界正是设计意图。「打断语义」那条最值得看:推进 200ms 后 slide 进度是 500‰,打断后再推进 500ms,进度仍是 500‰、缓动值也一模一样。
W 层:单文件 HTML 原型
手感光看数字不直观,得能在浏览器里真跑一遍。下面这段是 W 层(网页层)的构建脚本 web/build_e12.mjs:它复用 web/pack.mjs 的擦除逻辑,把 easing.ts 内联进一个单文件 HTML,产物 web/dist/e12.html。注意它没有自己重写 stripTypeScriptTypes,而是 import { bundle, page, writePage, inlineConfig } from "./pack.mjs"——这是本套所有 W 层的统一约定。
#!/usr/bin/env node
// file: web/build_e12.mjs
// tier: W
// verify: node web/build_e12.mjs
// 把 core/src/e12/easing.ts 打成单文件 HTML 原型;复用 web/pack.mjs 的擦除逻辑,产物 web/dist/e12.html。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "e12_easing.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e12/easing.ts"]);
const script = `const CFG = ${inlineConfig(cfg)};
${js}
const cv = document.getElementById("track"), g = cv.getContext("2d"), W = cv.width, H = cv.height;
const P0 = 60, P1 = 300, P2 = 420;
const DEF = { id: "marker", mode: "serial", steps: [
{ id: "slide", easing: "outCubic", delayMs: 0, durMs: CFG.timeline.slideMs },
{ id: "settle", easing: "outBack", delayMs: 0, durMs: CFG.timeline.settleMs },
]};
let state = newTimeline(DEF);
function markerX() { const s = sampleTimeline(state, DEF, CFG);
return s[0].done ? P1 + Math.floor((P2 - P1) * s[1].eased / 1000) : P0 + Math.floor((P1 - P0) * s[0].eased / 1000); }
function draw() { const x = markerX();
g.clearRect(0, 0, W, H); g.strokeStyle = "#1e293b"; g.lineWidth = 2;
g.beginPath(); g.moveTo(P0, H / 2); g.lineTo(P2, H / 2); g.stroke();
g.fillStyle = "#38bdf8"; g.beginPath(); g.arc(x, H / 2, 14, 0, Math.PI * 2); g.fill();
document.getElementById("tms").textContent = String(state.tMs);
document.getElementById("x").textContent = String(x);
document.getElementById("done").textContent = String(isFinished(state, DEF));
globalThis.__pebble = { tMs: state.tMs, x, finished: isFinished(state, DEF), canvasDrawn: true, api }; }
const api = {
advance(ms) { state = advance(state, DEF, CFG, ms).state; draw();
return { tMs: state.tMs, x: markerX(), samples: sampleTimeline(state, DEF, CFG), finished: isFinished(state, DEF) }; },
sample() { return { tMs: state.tMs, samples: sampleTimeline(state, DEF, CFG), x: markerX() }; },
interrupt(id) { state = interrupt(state, id); draw(); return sampleTimeline(state, DEF, CFG); },
reset() { state = newTimeline(DEF); draw(); return { tMs: state.tMs }; },
starts() { return stepStarts(DEF); },
};
draw();
`;
const body = ` <h1>《合成小岛》· 缓动与时间轴原型</h1>
<div class="sub">缓动 + 时间轴来自 core/src/e12/easing.ts:全整数、无 Math.random</div>
<div class="card row"><div><div class="k">时间 (ms)</div><div class="big" id="tms">0</div></div><div><div class="k">标记 x</div><div class="big" id="x">60</div></div><div><div class="k">结束</div><div class="big" id="done">false</div></div></div>
<div class="card"><canvas id="track" width="480" height="140"></canvas></div>
<div class="sub">串行两段:先 outCubic 滑到中点,再 outBack 回弹落定</div>`;
const css = "#track { width: 480px; height: 140px; }";
const bytes = writePage(join(ROOT, "web/dist/e12.html"), page({ title: "《合成小岛》缓动与时间轴原型 · e12", body, script, css }));
console.log(JSON.stringify({ html: "web/dist/e12.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
页面里排了一条串行时间轴:先 outCubic 滑到中点,再 outBack 回弹落定。下面这段是 W 层验证脚本 web/verify_e12.mjs:Playwright 打开单文件 HTML,真的推进时间轴——每 50ms 调一次 advance,采样标记位置与各步进度,断言串行接续、端点精确、打断冻结,并截图存档。playwright 用 createRequire(import.meta.url) 加载,因为 NODE_PATH 对 ESM 的 import 无效。
#!/usr/bin/env node
// file: web/verify_e12.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e12.mjs web/dist/e12.html
// W 层验收:Playwright 无头打开单文件 HTML,推进时间轴、采样动画值(标记 x 与各步进度),
// 断言串行接续、端点精确、打断冻结,并截图存档。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/e12.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const errors = [];
const open = async () => {
const p = await browser.newPage({ viewport: { width: 800, height: 900 } });
p.on("pageerror", (e) => errors.push(String(e)));
await p.goto(url);
await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
return p;
};
// 推进时间轴:每 50ms 采样一次标记 x 与步进度,直到结束。
const sweep = (page) => page.evaluate(() => {
const a = globalThis.__pebble.api; a.reset();
const xs = [], slideProgress = [];
for (let i = 0; i < 16; i += 1) {
const r = a.advance(50);
xs.push(r.x); slideProgress.push(r.samples[0].progress);
if (r.finished) break;
}
const s = a.sample();
return { xs, slideProgress, finished: globalThis.__pebble.finished, x: s.x, starts: a.starts() };
});
// 打断:推进 200ms 后打断 slide,再推进 500ms,看进度是否冻结。
const interruptRun = (page) => page.evaluate(() => {
const a = globalThis.__pebble.api; a.reset(); a.advance(200);
const before = a.sample().samples[0], frozen = a.interrupt("slide")[0];
const after = a.advance(500).samples[0];
return { beforeProgress: before.progress, frozenProgress: frozen.progress, frozenCancelled: frozen.cancelled,
afterProgress: after.progress, afterEased: after.eased, frozenEased: frozen.eased };
});
const page = await open();
const run = await sweep(page);
const frozen = await interruptRun(page);
await page.screenshot({ path: join(ROOT, "out/shots/e12_timeline.png"), fullPage: true });
await page.close();
const page2 = await open();
const again = await sweep(page2);
await page2.close();
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.deepEqual(run.starts, [0, 400], "串行起点应为 [0,400]");
assert.ok(run.xs[0] > 60 && run.xs[0] < run.xs[run.xs.length - 1], "标记应向右移动");
assert.equal(run.finished, true, "推进后时间轴应结束");
assert.equal(run.x, 420, "结束时标记应落在 P2=420");
for (let i = 1; i < run.slideProgress.length; i += 1) {
assert.ok(run.slideProgress[i] >= run.slideProgress[i - 1], "slide 进度应非递减");
}
assert.equal(frozen.beforeProgress, 500, "推进 200ms 后 slide 进度应为 500");
assert.equal(frozen.frozenProgress, 500, "打断后进度应冻结在 500");
assert.equal(frozen.frozenCancelled, true, "打断后应标记 cancelled");
assert.equal(frozen.afterProgress, 500, "打断后继续推进进度不应变化");
assert.equal(frozen.afterEased, frozen.frozenEased, "打断后缓动值应冻结");
assert.deepEqual(again.xs, run.xs, "同一次推进应可复现");
console.log(JSON.stringify({ page: "web/dist/e12.html", starts: run.starts, xs: run.xs,
slideProgress: run.slideProgress, finalX: run.x, finished: run.finished, interrupt: frozen,
rerunXsMatch: JSON.stringify(again.xs) === JSON.stringify(run.xs), screenshot: "out/shots/e12_timeline.png" }, null, 2));
console.log("SMOKE-W12: PASS");
先构建、再验证,本机实跑输出如下(同机相对量级,绝对数值不可移植):
$ node web/build_e12.mjs
{ "html": "web/dist/e12.html", "htmlBytes": 8614, "bundleLines": 89 }
$ NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e12.mjs web/dist/e12.html
{
"starts": [0, 400],
"xs": [139, 198, 241, 270, 287, 296, 299, 300, 373, 414, 430, 430, 424, 420],
"finalX": 420, "finished": true,
"interrupt": { "beforeProgress": 500, "frozenProgress": 500, "afterProgress": 500 },
"rerunXsMatch": true, "screenshot": "out/shots/e12_timeline.png"
}
SMOKE-W12: PASS
这段验证到底验证了什么
看 xs 这一串:139…300 是 outCubic 段的采样——每 50ms 走一步,步长越来越小,最后贴到中点 P1=300;接着 373, 414, 430, 430, 424, 420 是 outBack 段——先冲过终点到 430,再回落稳定到 P2=420。这一「冲过头再落回」就是回弹的观感来源,而它完全由整数公式算出来,不是手调的。interrupt 那组断言说明打断是「冻结」而非「取消」:进度停在打断那一刻,不继续走、也不归零。rerunXsMatch=true 则说明整条时间轴没有隐藏随机——两次全新加载、同样的推进序列,得到逐点相同的轨迹,这正是「一份逻辑、两种宿主」的必然结果。
小结与下一步
这一节把「动效」拆成曲线与调度两块:曲线用整数定点实现(含一张整数正弦表算弹性),调度用绝对起点 + 冻结实现(含并行、串行、延迟、打断、完成事件),两者都是纯函数,因此可测、可回放、能在浏览器里真跑一遍。但玩家感受到的「打击感」是多个反馈叠在一起的结果:屏幕在抖、方块在放大、画面还会停一帧。下一节就把这些要素拆开,量化成可衰减、可断言的一组参数。
阅读导航:上一节:UI 性能与合批 · 下一节:打击感、粒子与反馈 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。