强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Pixi.js 游戏开发教程 / 动画基础(Ticker)

动画基础(Ticker)

概述

动画是游戏和交互式应用的灵魂。Pixi.js 通过 Ticker 提供稳定的帧更新机制,配合补间动画、帧动画等技术,实现流畅的视觉效果。本章将深入讲解 Ticker 原理、动画实现方式,以及游戏循环的最佳实践。


Ticker 原理

Ticker 基于 requestAnimationFrame (rAF) 实现,与浏览器的刷新率同步(通常 60fps)。

浏览器刷新周期(~16.67ms @ 60fps)
  │
  ├─ requestAnimationFrame 触发
  │   ├─ Ticker.update()
  │   │   ├─ 计算 deltaTime
  │   │   ├─ 执行 ticker.add() 注册的回调
  │   │   └─ 触发 renderer.render()
  │   └─ 等待下一帧
  └─ 重复
import { Application, Ticker } from 'pixi.js';

const app = new Application();
await app.init({ width: 800, height: 600, background: '#1a1a2e' });
document.body.appendChild(app.canvas);

// 查看 Ticker 状态
console.log('FPS:', app.ticker.FPS);
console.log('deltaTime:', app.ticker.deltaTime);
console.log('deltaMS:', app.ticker.deltaMS);

ticker.add 回调

// 基本用法
app.ticker.add((ticker) => {
  const delta = ticker.deltaTime; // 归一化帧间隔
  const ms = ticker.deltaMS;      // 毫秒

  sprite.rotation += 0.01 * delta;
});

// 带优先级(priority 越小越先执行)
app.ticker.add(() => { console.log('优先级 0'); }, undefined, 0);
app.ticker.add(() => { console.log('优先级 1'); }, undefined, 1);

// 通过对象添加
const myUpdater = {
  update(ticker: Ticker) {
    // 更新逻辑
  },
};
app.ticker.add(myUpdater);

// 移除
app.ticker.remove(myUpdater);

回调执行顺序

ticker.add(callback, undefined, UPDATE_PRIORITY.LOW)     // -10
ticker.add(callback, undefined, UPDATE_PRIORITY.NORMAL)   // 0  ← 默认
ticker.add(callback, undefined, UPDATE_PRIORITY.HIGH)     // 10
ticker.add(callback, undefined, UPDATE_PRIORITY.UTILITY)   // 20
ticker.add(callback, undefined, UPDATE_PRIORITY.INTERACTIVE)// 50
优先级常量典型用途
INTERACTIVE50输入处理
HIGH10粒子系统
NORMAL0一般游戏逻辑
LOW-10网络同步
UTILITY20统计、调试

deltaTime 使用

deltaTime 是帧间隔的归一化值,以 60fps 为基准(60fps 时 deltaTime ≈ 1)。

// ❌ 错误 —— 不同帧率下速度不一致
sprite.x += 5;

// ✅ 正确 —— 帧率无关的移动
sprite.x += 5 * ticker.deltaTime;

// 计算实际每秒移动像素
const SPEED = 300; // 每秒 300 像素
const pixelsPerFrame = SPEED / 60; // 60fps 时每帧 5 像素
sprite.x += pixelsPerFrame * ticker.deltaTime;

deltaTime 与 deltaMS 对比

属性60fps30fps说明
deltaTime~1.0~2.0归一化值(60fps 为 1)
deltaMS~16.67~33.33实际毫秒间隔
FPS6030当前帧率

⚠️ 注意: deltaTime 可能出现异常大的值(如切标签页后恢复),建议设置上限:

app.ticker.add((ticker) => {
  const delta = Math.min(ticker.deltaTime, 5); // 最大 5 帧
  update(delta);
});

帧率控制

// 限制最大帧率
app.ticker.maxFPS = 30;

// 暂停/恢复
app.ticker.stop();
app.ticker.start();

// 设置速度倍率(影响 deltaTime)
app.ticker.speed = 2; // 2 倍速

补间动画(Tween)

Pixi.js 不内置 Tween 系统,但可以轻松实现:

基础 Tween 实现

interface TweenConfig {
  target: any;
  props: Record<string, number>;
  duration: number;       // 毫秒
  easing?: (t: number) => number;
  onUpdate?: () => void;
  onComplete?: () => void;
}

class Tween {
  private target: any;
  private startValues: Record<string, number> = {};
  private endValues: Record<string, number>;
  private duration: number;
  private elapsed: number = 0;
  private easing: (t: number) => number;
  private onUpdate?: () => void;
  private onComplete?: () => void;
  public finished: boolean = false;

  constructor(config: TweenConfig) {
    this.target = config.target;
    this.endValues = config.props;
    this.duration = config.duration;
    this.easing = config.easing || ((t) => t); // 线性
    this.onUpdate = config.onUpdate;
    this.onComplete = config.onComplete;

    // 记录起始值
    for (const key in this.endValues) {
      this.startValues[key] = this.target[key];
    }
  }

  update(deltaMS: number): boolean {
    if (this.finished) return true;

    this.elapsed += deltaMS;
    const progress = Math.min(this.elapsed / this.duration, 1);
    const easedProgress = this.easing(progress);

    for (const key in this.endValues) {
      const start = this.startValues[key];
      const end = this.endValues[key];
      this.target[key] = start + (end - start) * easedProgress;
    }

    this.onUpdate?.();

    if (progress >= 1) {
      this.finished = true;
      this.onComplete?.();
      return true;
    }

    return false;
  }
}

// Tween 管理器
class TweenManager {
  private tweens: Tween[] = [];

  add(config: TweenConfig): Tween {
    const tween = new Tween(config);
    this.tweens.push(tween);
    return tween;
  }

  update(deltaMS: number) {
    this.tweens = this.tweens.filter((t) => !t.update(deltaMS));
  }
}

// 使用
const tweenManager = new TweenManager();

app.ticker.add((ticker) => {
  tweenManager.update(ticker.deltaMS);
});

// 移动动画
tweenManager.add({
  target: sprite,
  props: { x: 600, y: 400 },
  duration: 1000,
  easing: easeInOutQuad,
  onComplete: () => console.log('移动完成'),
});

缓动函数

// 常用缓动函数
const easing = {
  // 线性
  linear: (t: number) => t,

  // 二次
  easeInQuad: (t: number) => t * t,
  easeOutQuad: (t: number) => t * (2 - t),
  easeInOutQuad: (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,

  // 三次
  easeInCubic: (t: number) => t * t * t,
  easeOutCubic: (t: number) => (--t) * t * t + 1,
  easeInOutCubic: (t: number) =>
    t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,

  // 弹性
  easeOutElastic: (t: number) => {
    const p = 0.3;
    return Math.pow(2, -10 * t) * Math.sin((t - p / 4) * (2 * Math.PI) / p) + 1;
  },

  // 弹跳
  easeOutBounce: (t: number) => {
    if (t < 1 / 2.75) return 7.5625 * t * t;
    if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
    if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
    return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
  },
};

缓动效果对比

函数曲线特征适用场景
linear匀速滚动、旋转
easeInQuad慢 → 快物体启动
easeOutQuad快 → 慢物体停止
easeInOutQuad慢 → 快 → 慢平滑过渡
easeOutElastic弹性振荡强调效果、弹窗
easeOutBounce弹跳落地效果

状态机动画

游戏中的角色动画通常使用有限状态机(FSM)管理:

type AnimState = 'idle' | 'run' | 'jump';

class AnimFSM {
  private current: AnimState = 'idle';
  private states: Record<AnimState, () => void>;

  constructor(states: Record<AnimState, () => void>) {
    this.states = states;
  }

  set(state: AnimState) {
    if (this.current === state) return;
    this.current = state;
    this.states[state]();
  }
}

// 使用:根据输入切换状态
const fsm = new AnimFSM({
  idle:  () => sprite.texture = idleTexture,
  run:   () => sprite.texture = runTexture,
  jump:  () => sprite.texture = jumpTexture,
});

app.ticker.add(() => {
  if (keys.has('ArrowRight')) fsm.set('run');
  else if (keys.has('Space')) fsm.set('jump');
  else fsm.set('idle');
});

Sprite 帧动画

import { Assets, AnimatedSprite } from 'pixi.js';

// 加载精灵表
const sheet = await Assets.load('/image/hero-sheet.json');

// 获取动画帧
const frames = [
  sheet.textures['walk-0'],
  sheet.textures['walk-1'],
  sheet.textures['walk-2'],
  sheet.textures['walk-3'],
];

// 创建帧动画
const animSprite = new AnimatedSprite(frames);
animSprite.anchor.set(0.5);
animSprite.position.set(400, 300);
animSprite.animationSpeed = 0.15; // 每帧推进 0.15 帧
animSprite.loop = true;
animSprite.play();
app.stage.addChild(animSprite);

物理步进

对于需要物理模拟的游戏,使用固定时间步长:

const PHYSICS_STEP = 1 / 60; // 60Hz 物理更新
let accumulator = 0;

app.ticker.add((ticker) => {
  accumulator += ticker.deltaMS / 1000;

  // 固定步长物理更新
  while (accumulator >= PHYSICS_STEP) {
    physicsStep(PHYSICS_STEP);
    accumulator -= PHYSICS_STEP;
  }

  // 插值渲染
  const alpha = accumulator / PHYSICS_STEP;
  renderInterpolated(alpha);
});

Ticker 与游戏循环

标准游戏循环模板

class GameLoop {
  app: Application;
  private systems: Array<(delta: number) => void> = [];

  constructor(app: Application) {
    this.app = app;
  }

  addSystem(updateFn: (delta: number) => void) {
    this.systems.push(updateFn);
  }

  start() {
    this.app.ticker.add((ticker) => {
      const delta = ticker.deltaTime;
      for (const system of this.systems) {
        system(delta);
      }
    });
  }
}

// 使用
const gameLoop = new GameLoop(app);
gameLoop.addSystem((d) => inputSystem.update(d));
gameLoop.addSystem((d) => physicsSystem.update(d));
gameLoop.addSystem((d) => aiSystem.update(d));
gameLoop.addSystem((d) => renderSystem.update(d));
gameLoop.start();

游戏开发场景

场景:弹幕旋转发射

const bullets: Sprite[] = [];
let spawnTimer = 0;
const BULLET_SPEED = 3;
const BULLET_INTERVAL = 3;

app.ticker.add((ticker) => {
  const delta = ticker.deltaTime;
  spawnTimer += delta;
  if (spawnTimer > BULLET_INTERVAL) {
    spawnTimer = 0;
    const bullet = new Sprite(bulletTexture);
    bullet.anchor.set(0.5);
    bullet.position.set(400, 300);
    bullet.rotation = Math.random() * Math.PI * 2;
    app.stage.addChild(bullet);
    bullets.push(bullet);
  }
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.x += Math.cos(b.rotation) * BULLET_SPEED * delta;
    b.y += Math.sin(b.rotation) * BULLET_SPEED * delta;
    if (b.x < -20 || b.x > 820 || b.y < -20 || b.y > 620) {
      b.destroy();
      bullets.splice(i, 1);
    }
  }
});

⚠️ 常见问题

问题原因解决方案
动画速度不一致未使用 deltaTime始终乘以 deltaTime
切标签页后动画跳跃deltaTime 过大限制 deltaTime 上限
帧率不稳定update 回调中做重计算将重计算分散到多帧
Ticker 回调未移除组件销毁时未清理在 destroy 时 remove ticker 回调
固定步长物理抖动插值缺失使用 accumulator 插值渲染

💡 进阶提示

  1. Ticker 节流: 对不需要每帧更新的系统降低频率:

    let uiTimer = 0;
    app.ticker.add((ticker) => {
      uiTimer += ticker.deltaTime;
      if (uiTimer >= 3) { // 每 3 帧更新一次 UI
        uiTimer = 0;
        updateUI();
      }
    });
    
  2. rAF 节流 vs Ticker: 低频率更新(如网络心跳)直接用 setInterval,游戏逻辑始终用 ticker。


扩展阅读


上一章:06 - 文本渲染(Text / BitmapText) 下一章:08 - 输入交互(事件系统)