《Cocos 游戏联机实战》9.1 同步表现与插值动画

前几章把插值与预测写成了可测的纯逻辑,本节把它接到 Cocos 节点与帧循环:讲清逻辑 60 Hz 定点与渲染浮动帧率的分工,远端玩家如何用插值位置驱动 setPosition、用插值速度切换动画,为什么特效必须由服务端事件触发而非状态推断,并给出 L1 纯函数插值器与 L2 组件脚本。

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 / ynode.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/ 专题与《游戏服务端编程实践》,本卷不重复):

字段类型方向含义
typestringS→C固定为 "event"
ticknumberS→C该事件归属的逻辑帧号
entityIdstringS→C事件主体(哪名玩家 / 哪个道具)
eventstringS→C事件名,如 jump / hit / pickup / finish
x / ynumberS→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 与提示 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》8.3 通信加密与防篡改