9.1 同步表现与插值动画
第 4 章把插值写成了纯逻辑:给一串快照和一个渲染时刻,算出那一刻的位置。可那个结果只是几个浮点数,屏幕上看不见。本节把它接到 Cocos 的节点与帧循环,让远端玩家真正动起来。
与站内 game/client/ 那批表现文的分工:那边讲「插值是什么、为什么需要」;这里讲怎么把第 4.3 节的插值器接进 Node 与 Animation,以及联机表现里哪些坑只有联网才会踩。
9.1.1 逻辑定点、渲染浮动:两条不能混的时间轴
联机游戏里有两条时间轴,把它们混起来是同步问题的头号来源。
| 时间轴 | 步长 | 数值 | 谁在跑 | 作用 |
|---|---|---|---|---|
| 逻辑帧 | 固定 60 Hz(约 16.67 ms) | 整数定点 | 固定步长累积器(卷一 4.2) | 推进世界、产出快照 |
| 渲染帧 | 浮动(30 / 60 / 120 Hz) | 浮点 | 引擎每帧 update | 读取快照、插值、画 |
逻辑帧必须固定。如果拿渲染的 dt 去推逻辑,同一个输入在 30 Hz 和 120 Hz 的设备上会算出不同结果,两端立刻分叉。所以逻辑只用累积器喂进来的那 16.67 ms,渲染帧率爱怎么抖就怎么抖。
渲染侧则相反,它要的正是「浮动」。第 4.3 节的插值器接受一个 renderTime,在两条快照之间按浮点比例取值。渲染帧越快,取样点越密,画面越顺——但它取出来的永远是逻辑帧之间的过渡态,不写回逻辑状态。这条「渲染只读、逻辑唯一写者」的分工,就是所谓渲染与逻辑分离。
一个具体数字:渲染比逻辑慢一点,通常给 100 ms 的插值延迟(interpDelay)。也就是说,画面渲染的是「100 ms 前那个时刻的世界」,而不是此刻。这 100 ms 换来的是「永远有两条快照可插」,代价是所有表现都慢半拍——后面 9.3 讲的延迟补偿,一部分就是把这个半拍补回来。
9.1.2 插值输出两样东西:位置与速度
插值器不该只吐位置。远端玩家节点需要两类信息:
- 位置:直接写进
node.setPosition,决定它画在哪。 - 速度:决定它该播「跑」还是「站」、朝左还是朝右。
速度不要用「这一帧位置减上一帧位置」去算。那个差分被渲染帧率污染:60 Hz 和 120 Hz 下同一个位移会算出两倍差距,方向也会在低帧率时抖。正确做法是让插值器直接返回当前区段两条快照的斜率 (b - a) / (b.t - a.t),它是逻辑层的事实,与渲染帧率无关。
9.1.3 L1:把插值器写成纯函数
下面这个采样器不碰任何 Cocos API,输入快照缓冲与渲染时刻,输出位置和速度。它就是把第 4.3 节结论固化下来的可测单元。
// sync/interpolate.ts —— 纯 TS 插值采样器,无 cc 依赖
export interface Snapshot {
t: number; // 该快照对应的逻辑时刻(毫秒)
x: number;
y: number;
}
export interface RenderState {
x: number;
y: number;
vx: number; // 当前区段速度,单位:像素 / 毫秒
vy: number;
}
/** 在 renderTime 处对按 t 升序排列的快照做线性插值。 */
export function sampleAt(buffer: readonly Snapshot[], renderTime: number): RenderState {
if (buffer.length === 0) return { x: 0, y: 0, vx: 0, vy: 0 };
if (buffer.length === 1) {
const only = buffer[0];
return { x: only.x, y: only.y, vx: 0, vy: 0 };
}
if (renderTime <= buffer[0].t) {
const first = buffer[0];
return { x: first.x, y: first.y, vx: 0, vy: 0 };
}
const last = buffer[buffer.length - 1];
if (renderTime >= last.t) {
const prev = buffer[buffer.length - 2];
const span = last.t - prev.t;
const inv = span > 0 ? 1 / span : 0;
return { x: last.x, y: last.y, vx: (last.x - prev.x) * inv, vy: (last.y - prev.y) * inv };
}
// 二分找到 renderTime 落在 [lo, hi)
let lo = 0;
let hi = buffer.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (buffer[mid].t <= renderTime) lo = mid;
else hi = mid;
}
const a = buffer[lo];
const b = buffer[hi];
const span = b.t - a.t;
const alpha = span > 0 ? (renderTime - a.t) / span : 0;
const inv = span > 0 ? 1 / span : 0;
return {
x: a.x + (b.x - a.x) * alpha,
y: a.y + (b.y - a.y) * alpha,
vx: (b.x - a.x) * inv,
vy: (b.y - a.y) * inv,
};
}
三个约定写进了函数签名里:缓冲必须按 t 升序(网络层推入时就排好,别在这里排);renderTime 落在缓冲之外一律夹紧到端点,不外推(外推交给 4.3 的另一条路径,它需要额外标志位);速度永远来自包含 renderTime 的那个区段,不跨段平均。
9.1.4 L1:单测钉死边界
插值器最容易写错的是边界夹紧和「速度取哪一段」。用测试把这两条钉死:
import { describe, expect, it } from 'vitest';
import { sampleAt, type Snapshot } from './interpolate';
const buf: Snapshot[] = [
{ t: 0, x: 0, y: 0 },
{ t: 100, x: 100, y: 0 },
{ t: 200, x: 200, y: 40 },
];
describe('sampleAt', () => {
it('在两条快照之间线性插值', () => {
const s = sampleAt(buf, 50);
expect(s.x).toBeCloseTo(50);
expect(s.y).toBeCloseTo(0);
});
it('速度取当前区段斜率,而非相邻帧差分', () => {
const s = sampleAt(buf, 150);
expect(s.vx).toBeCloseTo(1); // (200 - 100) / 100
expect(s.vy).toBeCloseTo(0.4); // (40 - 0) / 100
});
it('早于首帧夹紧到首帧', () => {
expect(sampleAt(buf, -10).x).toBe(0);
});
it('晚于末帧夹紧到末帧,速度沿用末段', () => {
const s = sampleAt(buf, 999);
expect(s.x).toBe(200);
expect(s.vx).toBeCloseTo(1);
});
it('空缓冲返回原点', () => {
expect(sampleAt([], 0)).toEqual({ x: 0, y: 0, vx: 0, vy: 0 });
});
});
第二条测试是重点:如果实现里把速度写成「拿相邻两条快照做差分」,在 renderTime = 150 时它仍然返回 1——看起来对。把缓冲改成三条不等长区段,或让 renderTime 恰好落在段边界上,差分实现立刻和区段实现分道扬镳。
9.1.5 远端玩家:位置驱动节点,速度驱动动画
拿到 { x, y, vx, vy } 后,表现层的映射是固定的:
| 插值输出 | 去处 | 说明 |
|---|---|---|
x / y | node.setPosition | 写本地坐标,远端玩家的父节点在世界坐标里 |
vx / vy 的方向 | 动画朝向(scale.x 正负或翻转) | 只取符号,不取大小 |
hypot(vx, vy) 的档位 | 动画状态(run / idle) | 用迟滞避免临界抖动 |
「迟滞」指用两个阈值而不是一个:速度升过 runThreshold 才切到 run,降到 idleThreshold 才切回 idle,且 idleThreshold < runThreshold。单阈值会在玩家速度恰好在阈值附近时每帧来回切动画,看起来像抽筋。竞速里玩家速度很少长时间停在临界值,但起跑、被道具减速、撞墙回弹这几个瞬间一定会擦着阈值过。
9.1.6 L2:把插值器接到节点与 Animation
组件层的职责很薄:收快照、推进渲染时钟、每帧采样、写节点、按状态切动画。下面是 assets/scripts/net/sync/RemotePlayerView.ts 的骨架(L2,import ... from 'cc'):
// assets/scripts/net/sync/RemotePlayerView.ts
import { _decorator, Animation, Component, Node, Vec3 } from 'cc';
import { sampleAt, type RenderState, type Snapshot } from '../sync/interpolate';
const { ccclass, property } = _decorator;
@ccclass('RemotePlayerView')
export class RemotePlayerView extends Component {
@property(Node)
target: Node | null = null;
@property(Animation)
anim: Animation | null = null;
@property
interpDelayMs = 100;
@property
runThreshold = 0.5;
@property
idleThreshold = 0.2;
private readonly buffer: Snapshot[] = [];
private readonly pos = new Vec3();
private clockMs = 0;
private state = '';
/** 网络层收到一条服务端快照时调用。缓冲保持升序、只留插值窗口。 */
pushSnapshot(t: number, x: number, y: number): void {
this.buffer.push({ t, x, y });
while (this.buffer.length > 16) this.buffer.shift();
}
update(dt: number): void {
this.clockMs += dt * 1000;
const renderTime = this.clockMs - this.interpDelayMs;
const s = sampleAt(this.buffer, renderTime);
this.pos.set(s.x, s.y, 0);
this.target?.setPosition(this.pos);
this.applyAnim(s);
}
private applyAnim(s: RenderState): void {
const speed = Math.hypot(s.vx, s.vy);
const next = this.nextState(speed);
if (next === this.state) return;
this.state = next;
this.anim?.crossFade(next, 0.12);
}
private nextState(speed: number): string {
if (this.state === 'run') return speed < this.idleThreshold ? 'idle' : 'run';
return speed > this.runThreshold ? 'run' : 'idle';
}
}
两个刻意的选择。第一,只在状态变化时调 crossFade:每帧无脑 play 会反复重置动画进度,跑动看起来一顿一顿。第二,clockMs 用组件自己的累积时钟而不是 director.getTotalTime(),是为了让单测能注入假时钟——组件的渲染时钟就是它自己的时间轴。
如果还要在特效动画播完时做收尾,监听事件即可。这里有个高频错误要记住:
import { _decorator, Animation, AnimationState, Component } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('FxPlayer')
export class FxPlayer extends Component {
@property(Animation)
anim: Animation | null = null;
start(): void {
// 正确:事件常量挂在 Animation 上,不是 AnimationState
this.anim?.on(Animation.EventType.FINISHED, this.onDone, this);
}
private onDone(type: string, state: AnimationState): void {
console.log('特效播完', type, state.name);
}
onDestroy(): void {
this.anim?.off(Animation.EventType.FINISHED, this.onDone, this);
}
}
AnimationState 上没有静态 EventType——Animation.EventType.FINISHED 的值是字符串 "finished",事件派发归 Animation 组件,AnimationState 只管播放逻辑。写 AnimationState.EventType.FINISHED 会直接编译失败。这跟卷三 6.1 讲动画求值时是同一个坑,联网表现里同样会踩。
9.1.7 特效:由服务端事件触发,不要靠状态推断
新手最容易自作聪明的地方是用状态变化去猜特效:位置突然往上跳,就播一个跳跃特效。这在联机里必错,因为「位置跳变」的成因至少有四种——跳跃、被击退、插值修正回拉、重连后的快照跳变。用状态推断,会在插值修正时给玩家播一个莫名其妙的跳跃。
正确做法是让服务端在权威逻辑里判定「发生了什么」,把事件下发,客户端只负责播。事件是一次性的、带语义的;状态是持续的、需要推断的。二者别混。
客户端与服务端之间的事件消息约定如下(服务端实现见站内 game/server/ 专题与《游戏服务端编程实践》,本卷不重复):
| 字段 | 类型 | 方向 | 含义 |
|---|---|---|---|
type | string | S→C | 固定为 "event" |
tick | number | S→C | 该事件归属的逻辑帧号 |
entityId | string | S→C | 事件主体(哪名玩家 / 哪个道具) |
event | string | S→C | 事件名,如 jump / hit / pickup / finish |
x / y | number | S→C | 事件发生位置(可选,用于定位特效) |
客户端收到后,按 event 名查一张本地特效表,在 (x, y) 处实例化并播放。事件只下发触发,特效的时长、粒子数量、音效全部在客户端配置——服务端不该知道「跳跃特效有几帧」。
9.1.8 工程决策:本节之后客户端多了什么
- 新增
assets/scripts/net/sync/interpolate.ts(L1 纯函数)与RemotePlayerView.ts(L2 组件)。 - 可配置项:
interpDelayMs(默认 100,弱网可加大到 150~200)、runThreshold/idleThreshold(迟滞阈值)、快照缓冲上限(默认 16 条)。 - 一条硬规则:渲染层只读快照,任何表现逻辑都不得写回逻辑状态。插值结果只进
setPosition和动画选择,绝不进物理或判定。 - 一条待办:把
sampleAt的插值延迟与 5.3 的弱网降级档位联动——延迟档位越高,interpDelayMs越大,画面越稳但越滞后。这条联动的决策函数放在 9.2。
下一节把网络本身的「健康状态」变成玩家看得见的 UI:延迟多少、是否在重连、对手是不是掉线了,以及哪些数据不该给玩家看。
阅读导航:上一节:目录 · 下一节:9.2 网络状态 UI 与提示 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。