Skip to content

第9章:动画与帧循环

9.1 章节概述

前面的章节中,我们学习了如何绑制静态图形。但 Canvas 真正的魅力在于动画——让图形动起来,创建交互式体验。从简单的移动、旋转,到复杂的物理模拟、粒子系统,动画是 Canvas 应用的核心。

本章将深入讲解:

  • 动画原理:帧、刷新率、视觉暂留
  • requestAnimationFrame:高效的动画循环
  • 时间控制:帧率无关的动画
  • 缓动函数:自然流畅的运动效果
  • 动画管理:多对象动画系统
  • 实战应用:粒子系统、物理动画

9.2 动画基础原理

9.2.1 什么是动画?

动画的本质是快速连续显示略有不同的静态图像,利用人眼的"视觉暂留"效应产生运动的错觉。

帧 1        帧 2        帧 3        帧 4
  ●           ●           ●           ●
              →           →→          →→→

当这些帧快速连续播放时,看起来圆在移动

9.2.2 关键概念

概念说明
帧(Frame)单张静态画面
帧率(FPS)每秒显示的帧数
刷新率显示器每秒刷新次数(通常 60Hz)
渲染循环不断重复的 清空→更新→绘制 过程

9.2.3 帧率与流畅度

帧率体验
60 FPS非常流畅(目标值)
30 FPS可接受,略有卡顿
24 FPS电影标准,明显帧感
< 20 FPS明显卡顿

9.2.4 基本动画循环

javascript
// 动画的基本结构
function animate() {
    // 1. 清空画布
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // 2. 更新状态(位置、角度等)
    update();
    
    // 3. 绘制
    draw();
    
    // 4. 请求下一帧
    requestAnimationFrame(animate);
}

// 启动动画
animate();

9.3 requestAnimationFrame

9.3.1 为什么使用 requestAnimationFrame?

不推荐使用 setInterval/setTimeout

javascript
// ❌ 不推荐
setInterval(() => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    update();
    draw();
}, 1000 / 60);  // 尝试 60fps

// 问题:
// 1. 不与显示器刷新同步,可能导致画面撕裂
// 2. 浏览器标签不可见时仍然运行,浪费资源
// 3. 无法保证精确的时间间隔

requestAnimationFrame 的优势

javascript
// ✅ 推荐
function animate() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    update();
    draw();
    requestAnimationFrame(animate);
}

animate();

// 优势:
// 1. 与显示器刷新同步(通常 60fps)
// 2. 标签不可见时自动暂停
// 3. 浏览器优化,省电节能
// 4. 提供高精度时间戳

9.3.2 基本用法

javascript
// 基础用法
let animationId;

function animate() {
    // 动画逻辑...
    
    animationId = requestAnimationFrame(animate);
}

// 启动
animationId = requestAnimationFrame(animate);

// 停止
cancelAnimationFrame(animationId);

9.3.3 使用时间戳

requestAnimationFrame 会传入一个高精度时间戳:

javascript
function animate(timestamp) {
    // timestamp 是从页面加载开始的毫秒数
    console.log(`当前时间: ${timestamp}ms`);
    
    requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

9.3.4 计算实际帧率

javascript
let lastTime = 0;
let frameCount = 0;
let fps = 0;

function animate(timestamp) {
    // 计算与上一帧的时间差
    const deltaTime = timestamp - lastTime;
    
    frameCount++;
    
    // 每秒更新一次 FPS 显示
    if (timestamp - lastFpsUpdate >= 1000) {
        fps = frameCount;
        frameCount = 0;
        lastFpsUpdate = timestamp;
        console.log(`FPS: ${fps}`);
    }
    
    lastTime = timestamp;
    requestAnimationFrame(animate);
}

let lastFpsUpdate = 0;
requestAnimationFrame(animate);

9.4 帧率无关的动画

9.4.1 问题:帧率依赖

javascript
// ❌ 帧率依赖的动画
let x = 0;

function animate() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    x += 5;  // 每帧移动 5 像素
    
    ctx.fillRect(x, 100, 50, 50);
    requestAnimationFrame(animate);
}

// 问题:
// - 60fps 时:每秒移动 300 像素
// - 30fps 时:每秒移动 150 像素
// 不同设备上速度不一致!

9.4.2 解决方案:基于时间的动画

javascript
// ✅ 帧率无关的动画
let x = 0;
const speed = 200;  // 每秒 200 像素
let lastTime = 0;

function animate(timestamp) {
    // 计算时间增量(秒)
    const deltaTime = (timestamp - lastTime) / 1000;
    lastTime = timestamp;
    
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // 基于时间计算位移
    x += speed * deltaTime;  // 速度 × 时间 = 位移
    
    ctx.fillRect(x, 100, 50, 50);
    requestAnimationFrame(animate);
}

requestAnimationFrame((timestamp) => {
    lastTime = timestamp;
    requestAnimationFrame(animate);
});

9.4.3 封装动画循环

javascript
/**
 * 动画循环管理器
 */
class AnimationLoop {
    constructor(updateCallback) {
        this.updateCallback = updateCallback;
        this.lastTime = 0;
        this.isRunning = false;
        this.animationId = null;
    }
    
    start() {
        if (this.isRunning) return;
        
        this.isRunning = true;
        this.lastTime = performance.now();
        this.loop(this.lastTime);
    }
    
    stop() {
        if (!this.isRunning) return;
        
        this.isRunning = false;
        cancelAnimationFrame(this.animationId);
    }
    
    loop(timestamp) {
        if (!this.isRunning) return;
        
        const deltaTime = (timestamp - this.lastTime) / 1000;
        this.lastTime = timestamp;
        
        // 限制 deltaTime 防止长时间暂停后的跳跃
        const clampedDelta = Math.min(deltaTime, 0.1);
        
        this.updateCallback(clampedDelta, timestamp);
        
        this.animationId = requestAnimationFrame(this.loop.bind(this));
    }
}

// 使用
const loop = new AnimationLoop((deltaTime, timestamp) => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // 更新和绘制...
    x += speed * deltaTime;
    ctx.fillRect(x, 100, 50, 50);
});

loop.start();
// loop.stop();

9.5 缓动函数(Easing)

9.5.1 什么是缓动?

缓动使动画更加自然,而不是匀速运动。

匀速运动(线性):
时间 →  0%   25%   50%   75%   100%
位置 →  0%   25%   50%   75%   100%
感觉:机械、不自然

缓入缓出:
时间 →  0%   25%   50%   75%   100%
位置 →  0%   10%   50%   90%   100%
感觉:自然、舒适

9.5.2 常用缓动函数

javascript
/**
 * 缓动函数集合
 * 参数 t: 0 到 1 之间的进度值
 * 返回值: 0 到 1 之间的缓动后的值
 */
const Easing = {
    // 线性(无缓动)
    linear: t => t,
    
    // 缓入(慢开始)
    easeInQuad: t => t * t,
    easeInCubic: t => t * t * t,
    easeInQuart: t => t * t * t * t,
    
    // 缓出(慢结束)
    easeOutQuad: t => t * (2 - t),
    easeOutCubic: t => 1 - Math.pow(1 - t, 3),
    easeOutQuart: t => 1 - Math.pow(1 - t, 4),
    
    // 缓入缓出
    easeInOutQuad: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
    easeInOutCubic: t => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
    
    // 弹性
    easeOutElastic: t => {
        if (t === 0 || t === 1) return t;
        return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI) / 3) + 1;
    },
    
    // 回弹
    easeOutBounce: t => {
        if (t < 1 / 2.75) {
            return 7.5625 * t * t;
        } else if (t < 2 / 2.75) {
            return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
        } else if (t < 2.5 / 2.75) {
            return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
        } else {
            return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
        }
    },
    
    // 过冲
    easeOutBack: t => {
        const c1 = 1.70158;
        const c3 = c1 + 1;
        return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
    }
};

9.5.3 使用缓动函数

javascript
/**
 * 缓动动画类
 */
class TweenAnimation {
    constructor(options) {
        this.from = options.from;
        this.to = options.to;
        this.duration = options.duration || 1000;
        this.easing = options.easing || Easing.linear;
        this.onUpdate = options.onUpdate;
        this.onComplete = options.onComplete;
        
        this.startTime = null;
        this.isRunning = false;
    }
    
    start() {
        this.startTime = performance.now();
        this.isRunning = true;
        this.animate();
    }
    
    animate() {
        if (!this.isRunning) return;
        
        const now = performance.now();
        const elapsed = now - this.startTime;
        const progress = Math.min(elapsed / this.duration, 1);
        
        // 应用缓动
        const easedProgress = this.easing(progress);
        
        // 计算当前值
        const currentValue = this.from + (this.to - this.from) * easedProgress;
        
        this.onUpdate?.(currentValue, progress);
        
        if (progress < 1) {
            requestAnimationFrame(() => this.animate());
        } else {
            this.isRunning = false;
            this.onComplete?.();
        }
    }
    
    stop() {
        this.isRunning = false;
    }
}

// 使用
let ballX = 50;

const tween = new TweenAnimation({
    from: 50,
    to: 500,
    duration: 2000,
    easing: Easing.easeOutBounce,
    onUpdate: (value) => {
        ballX = value;
        // 重绘
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.beginPath();
        ctx.arc(ballX, 200, 30, 0, Math.PI * 2);
        ctx.fillStyle = '#4D7CFF';
        ctx.fill();
    },
    onComplete: () => {
        console.log('动画完成!');
    }
});

tween.start();

9.5.4 缓动函数可视化

javascript
/**
 * 可视化缓动函数曲线
 */
function visualizeEasing(ctx, easingFunc, x, y, width, height) {
    ctx.save();
    ctx.translate(x, y);
    
    // 绘制边框
    ctx.strokeStyle = '#ddd';
    ctx.strokeRect(0, 0, width, height);
    
    // 绘制对角线(线性参考)
    ctx.strokeStyle = '#ccc';
    ctx.setLineDash([5, 5]);
    ctx.beginPath();
    ctx.moveTo(0, height);
    ctx.lineTo(width, 0);
    ctx.stroke();
    ctx.setLineDash([]);
    
    // 绘制缓动曲线
    ctx.strokeStyle = '#4D7CFF';
    ctx.lineWidth = 2;
    ctx.beginPath();
    
    for (let i = 0; i <= width; i++) {
        const t = i / width;
        const eased = easingFunc(t);
        const px = i;
        const py = height - eased * height;
        
        if (i === 0) {
            ctx.moveTo(px, py);
        } else {
            ctx.lineTo(px, py);
        }
    }
    
    ctx.stroke();
    ctx.restore();
}

// 绘制多个缓动函数对比
const easings = [
    { name: 'linear', func: Easing.linear },
    { name: 'easeInQuad', func: Easing.easeInQuad },
    { name: 'easeOutQuad', func: Easing.easeOutQuad },
    { name: 'easeInOutQuad', func: Easing.easeInOutQuad },
    { name: 'easeOutElastic', func: Easing.easeOutElastic },
    { name: 'easeOutBounce', func: Easing.easeOutBounce }
];

easings.forEach((easing, index) => {
    const col = index % 3;
    const row = Math.floor(index / 3);
    const x = 50 + col * 200;
    const y = 50 + row * 180;
    
    visualizeEasing(ctx, easing.func, x, y, 150, 120);
    
    ctx.fillStyle = '#333';
    ctx.font = '12px Arial';
    ctx.textAlign = 'center';
    ctx.fillText(easing.name, x + 75, y + 140);
});

9.6 多对象动画管理

9.6.1 动画对象基类

javascript
/**
 * 可动画对象基类
 */
class AnimatedObject {
    constructor(x, y) {
        this.x = x;
        this.y = y;
        this.vx = 0;  // X 方向速度
        this.vy = 0;  // Y 方向速度
        this.rotation = 0;
        this.rotationSpeed = 0;
        this.scale = 1;
        this.alpha = 1;
        this.isAlive = true;
    }
    
    update(deltaTime) {
        // 更新位置
        this.x += this.vx * deltaTime;
        this.y += this.vy * deltaTime;
        
        // 更新旋转
        this.rotation += this.rotationSpeed * deltaTime;
    }
    
    draw(ctx) {
        // 子类实现
    }
}

9.6.2 动画管理器

javascript
/**
 * 动画场景管理器
 */
class AnimationScene {
    constructor(canvas) {
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');
        this.objects = [];
        this.isRunning = false;
        this.lastTime = 0;
    }
    
    add(object) {
        this.objects.push(object);
    }
    
    remove(object) {
        const index = this.objects.indexOf(object);
        if (index > -1) {
            this.objects.splice(index, 1);
        }
    }
    
    start() {
        this.isRunning = true;
        this.lastTime = performance.now();
        this.loop();
    }
    
    stop() {
        this.isRunning = false;
    }
    
    loop() {
        if (!this.isRunning) return;
        
        const now = performance.now();
        const deltaTime = Math.min((now - this.lastTime) / 1000, 0.1);
        this.lastTime = now;
        
        // 清空画布
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        
        // 更新和绘制所有对象
        this.objects.forEach(obj => {
            if (obj.isAlive) {
                obj.update(deltaTime);
                obj.draw(this.ctx);
            }
        });
        
        // 移除死亡对象
        this.objects = this.objects.filter(obj => obj.isAlive);
        
        requestAnimationFrame(() => this.loop());
    }
}

9.6.3 使用示例

javascript
// 弹跳球
class BouncingBall extends AnimatedObject {
    constructor(x, y, radius, color) {
        super(x, y);
        this.radius = radius;
        this.color = color;
        this.vx = (Math.random() - 0.5) * 400;
        this.vy = (Math.random() - 0.5) * 400;
    }
    
    update(deltaTime) {
        super.update(deltaTime);
        
        // 边界碰撞
        if (this.x - this.radius < 0 || this.x + this.radius > canvas.width) {
            this.vx *= -1;
            this.x = Math.max(this.radius, Math.min(canvas.width - this.radius, this.x));
        }
        if (this.y - this.radius < 0 || this.y + this.radius > canvas.height) {
            this.vy *= -1;
            this.y = Math.max(this.radius, Math.min(canvas.height - this.radius, this.y));
        }
    }
    
    draw(ctx) {
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
        ctx.fillStyle = this.color;
        ctx.fill();
    }
}

// 创建场景
const scene = new AnimationScene(canvas);

// 添加多个球
for (let i = 0; i < 10; i++) {
    const ball = new BouncingBall(
        Math.random() * canvas.width,
        Math.random() * canvas.height,
        20 + Math.random() * 30,
        `hsl(${Math.random() * 360}, 70%, 50%)`
    );
    scene.add(ball);
}

scene.start();

9.7 粒子系统

9.7.1 粒子类

javascript
/**
 * 粒子
 */
class Particle extends AnimatedObject {
    constructor(x, y, options = {}) {
        super(x, y);
        
        // 随机速度
        const angle = options.angle ?? Math.random() * Math.PI * 2;
        const speed = options.speed ?? (50 + Math.random() * 100);
        this.vx = Math.cos(angle) * speed;
        this.vy = Math.sin(angle) * speed;
        
        // 属性
        this.size = options.size ?? (2 + Math.random() * 4);
        this.color = options.color ?? '#fff';
        this.life = options.life ?? (0.5 + Math.random() * 1.5);
        this.maxLife = this.life;
        
        // 物理
        this.gravity = options.gravity ?? 0;
        this.friction = options.friction ?? 1;
    }
    
    update(deltaTime) {
        // 应用重力
        this.vy += this.gravity * deltaTime;
        
        // 应用摩擦力
        this.vx *= this.friction;
        this.vy *= this.friction;
        
        super.update(deltaTime);
        
        // 生命衰减
        this.life -= deltaTime;
        if (this.life <= 0) {
            this.isAlive = false;
        }
        
        // 根据生命计算透明度
        this.alpha = this.life / this.maxLife;
    }
    
    draw(ctx) {
        ctx.save();
        ctx.globalAlpha = this.alpha;
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
        ctx.fillStyle = this.color;
        ctx.fill();
        ctx.restore();
    }
}

9.7.2 粒子发射器

javascript
/**
 * 粒子发射器
 */
class ParticleEmitter {
    constructor(x, y, options = {}) {
        this.x = x;
        this.y = y;
        this.particles = [];
        
        // 发射配置
        this.emitRate = options.emitRate ?? 50;  // 每秒发射数量
        this.emitAccumulator = 0;
        
        // 粒子配置
        this.particleOptions = options.particleOptions ?? {};
        
        this.isEmitting = true;
    }
    
    update(deltaTime) {
        // 发射新粒子
        if (this.isEmitting) {
            this.emitAccumulator += this.emitRate * deltaTime;
            
            while (this.emitAccumulator >= 1) {
                this.emit();
                this.emitAccumulator--;
            }
        }
        
        // 更新所有粒子
        this.particles.forEach(p => p.update(deltaTime));
        
        // 移除死亡粒子
        this.particles = this.particles.filter(p => p.isAlive);
    }
    
    emit() {
        const particle = new Particle(this.x, this.y, this.particleOptions);
        this.particles.push(particle);
    }
    
    draw(ctx) {
        this.particles.forEach(p => p.draw(ctx));
    }
    
    start() {
        this.isEmitting = true;
    }
    
    stop() {
        this.isEmitting = false;
    }
}

9.7.3 烟花效果

javascript
/**
 * 烟花粒子系统
 */
class Firework {
    constructor(canvas) {
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');
        this.particles = [];
        this.isRunning = false;
    }
    
    explode(x, y) {
        const color = `hsl(${Math.random() * 360}, 70%, 60%)`;
        const particleCount = 100 + Math.random() * 50;
        
        for (let i = 0; i < particleCount; i++) {
            const angle = (i / particleCount) * Math.PI * 2;
            const speed = 100 + Math.random() * 200;
            
            this.particles.push(new Particle(x, y, {
                angle,
                speed,
                size: 2 + Math.random() * 3,
                color,
                life: 1 + Math.random() * 1,
                gravity: 200,
                friction: 0.98
            }));
        }
    }
    
    start() {
        this.isRunning = true;
        this.lastTime = performance.now();
        this.loop();
        
        // 定时发射烟花
        this.launchInterval = setInterval(() => {
            this.explode(
                100 + Math.random() * (this.canvas.width - 200),
                100 + Math.random() * (this.canvas.height / 2)
            );
        }, 1000);
    }
    
    stop() {
        this.isRunning = false;
        clearInterval(this.launchInterval);
    }
    
    loop() {
        if (!this.isRunning) return;
        
        const now = performance.now();
        const deltaTime = Math.min((now - this.lastTime) / 1000, 0.1);
        this.lastTime = now;
        
        // 半透明黑色覆盖,产生拖尾效果
        this.ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
        
        // 更新和绘制
        this.particles.forEach(p => {
            p.update(deltaTime);
            p.draw(this.ctx);
        });
        
        // 移除死亡粒子
        this.particles = this.particles.filter(p => p.isAlive);
        
        requestAnimationFrame(() => this.loop());
    }
}

// 使用
const firework = new Firework(canvas);
firework.start();

9.7.4 鼠标跟随粒子

javascript
/**
 * 鼠标跟随粒子效果
 */
class MouseTrailParticles {
    constructor(canvas) {
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d');
        this.particles = [];
        this.mouseX = 0;
        this.mouseY = 0;
        
        this.bindEvents();
    }
    
    bindEvents() {
        this.canvas.addEventListener('mousemove', (e) => {
            const rect = this.canvas.getBoundingClientRect();
            this.mouseX = e.clientX - rect.left;
            this.mouseY = e.clientY - rect.top;
            
            // 每次移动发射粒子
            this.emit(3);
        });
    }
    
    emit(count) {
        for (let i = 0; i < count; i++) {
            this.particles.push(new Particle(this.mouseX, this.mouseY, {
                speed: 20 + Math.random() * 30,
                size: 3 + Math.random() * 5,
                color: `hsl(${Date.now() / 20 % 360}, 70%, 60%)`,
                life: 0.5 + Math.random() * 0.5,
                gravity: 50
            }));
        }
    }
    
    start() {
        this.isRunning = true;
        this.lastTime = performance.now();
        this.loop();
    }
    
    loop() {
        if (!this.isRunning) return;
        
        const now = performance.now();
        const deltaTime = Math.min((now - this.lastTime) / 1000, 0.1);
        this.lastTime = now;
        
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        
        this.particles.forEach(p => {
            p.update(deltaTime);
            p.draw(this.ctx);
        });
        
        this.particles = this.particles.filter(p => p.isAlive);
        
        requestAnimationFrame(() => this.loop());
    }
}

9.8 简单物理动画

9.8.1 重力与弹跳

javascript
/**
 * 带重力和弹跳的球
 */
class GravityBall extends AnimatedObject {
    constructor(x, y, radius) {
        super(x, y);
        this.radius = radius;
        this.vy = 0;
        this.gravity = 980;  // 像素/秒²
        this.bounciness = 0.7;  // 弹性系数
        this.friction = 0.99;
    }
    
    update(deltaTime) {
        // 应用重力
        this.vy += this.gravity * deltaTime;
        
        // 更新位置
        this.y += this.vy * deltaTime;
        
        // 地面碰撞
        const ground = canvas.height - this.radius;
        if (this.y > ground) {
            this.y = ground;
            this.vy *= -this.bounciness;
            
            // 速度太小时停止
            if (Math.abs(this.vy) < 10) {
                this.vy = 0;
            }
        }
        
        // 应用摩擦
        this.vy *= this.friction;
    }
    
    draw(ctx) {
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
        ctx.fillStyle = '#4D7CFF';
        ctx.fill();
        
        // 阴影
        ctx.beginPath();
        const shadowY = canvas.height - 5;
        const shadowScale = 1 - (shadowY - this.y) / canvas.height;
        ctx.ellipse(this.x, shadowY, this.radius * shadowScale, 5 * shadowScale, 0, 0, Math.PI * 2);
        ctx.fillStyle = `rgba(0, 0, 0, ${0.3 * shadowScale})`;
        ctx.fill();
    }
}

9.8.2 弹簧效果

javascript
/**
 * 弹簧连接的两个点
 */
class Spring {
    constructor(pointA, pointB, restLength, stiffness, damping) {
        this.pointA = pointA;
        this.pointB = pointB;
        this.restLength = restLength;
        this.stiffness = stiffness;
        this.damping = damping;
    }
    
    update() {
        const dx = this.pointB.x - this.pointA.x;
        const dy = this.pointB.y - this.pointA.y;
        const distance = Math.sqrt(dx * dx + dy * dy);
        const displacement = distance - this.restLength;
        
        // 弹簧力 = -k * x
        const force = this.stiffness * displacement;
        
        // 方向
        const nx = dx / distance;
        const ny = dy / distance;
        
        // 应用力
        if (!this.pointA.fixed) {
            this.pointA.vx += force * nx;
            this.pointA.vy += force * ny;
            this.pointA.vx *= this.damping;
            this.pointA.vy *= this.damping;
        }
        
        if (!this.pointB.fixed) {
            this.pointB.vx -= force * nx;
            this.pointB.vy -= force * ny;
            this.pointB.vx *= this.damping;
            this.pointB.vy *= this.damping;
        }
    }
    
    draw(ctx) {
        ctx.beginPath();
        ctx.moveTo(this.pointA.x, this.pointA.y);
        ctx.lineTo(this.pointB.x, this.pointB.y);
        ctx.strokeStyle = '#999';
        ctx.lineWidth = 2;
        ctx.stroke();
    }
}

/**
 * 可移动的点
 */
class SpringPoint {
    constructor(x, y, fixed = false) {
        this.x = x;
        this.y = y;
        this.vx = 0;
        this.vy = 0;
        this.fixed = fixed;
    }
    
    update(deltaTime) {
        if (this.fixed) return;
        
        this.x += this.vx * deltaTime;
        this.y += this.vy * deltaTime;
    }
    
    draw(ctx) {
        ctx.beginPath();
        ctx.arc(this.x, this.y, 8, 0, Math.PI * 2);
        ctx.fillStyle = this.fixed ? '#FF6B6B' : '#4D7CFF';
        ctx.fill();
    }
}

9.8.3 简单碰撞检测

javascript
/**
 * 圆形碰撞检测
 */
function circleCollision(circle1, circle2) {
    const dx = circle2.x - circle1.x;
    const dy = circle2.y - circle1.y;
    const distance = Math.sqrt(dx * dx + dy * dy);
    
    return distance < circle1.radius + circle2.radius;
}

/**
 * 解决圆形碰撞(弹性碰撞)
 */
function resolveCircleCollision(circle1, circle2) {
    const dx = circle2.x - circle1.x;
    const dy = circle2.y - circle1.y;
    const distance = Math.sqrt(dx * dx + dy * dy);
    
    if (distance >= circle1.radius + circle2.radius) return;
    
    // 法向量
    const nx = dx / distance;
    const ny = dy / distance;
    
    // 相对速度
    const dvx = circle1.vx - circle2.vx;
    const dvy = circle1.vy - circle2.vy;
    
    // 沿法向的相对速度
    const dvn = dvx * nx + dvy * ny;
    
    // 只处理相向运动
    if (dvn > 0) return;
    
    // 简单情况:质量相等
    circle1.vx -= dvn * nx;
    circle1.vy -= dvn * ny;
    circle2.vx += dvn * nx;
    circle2.vy += dvn * ny;
    
    // 分离重叠
    const overlap = (circle1.radius + circle2.radius - distance) / 2;
    circle1.x -= overlap * nx;
    circle1.y -= overlap * ny;
    circle2.x += overlap * nx;
    circle2.y += overlap * ny;
}

9.9 动画状态机

9.9.1 精灵动画状态机

javascript
/**
 * 动画状态机
 */
class AnimationStateMachine {
    constructor() {
        this.states = {};
        this.currentState = null;
        this.currentAnimation = null;
    }
    
    addState(name, animation, transitions = {}) {
        this.states[name] = {
            animation,
            transitions
        };
    }
    
    setState(name) {
        if (name === this.currentState) return;
        
        const state = this.states[name];
        if (!state) {
            console.warn(`状态 ${name} 不存在`);
            return;
        }
        
        this.currentState = name;
        this.currentAnimation = state.animation;
        this.currentAnimation.reset();
    }
    
    trigger(event) {
        if (!this.currentState) return;
        
        const state = this.states[this.currentState];
        const nextState = state.transitions[event];
        
        if (nextState) {
            this.setState(nextState);
        }
    }
    
    update(deltaTime) {
        this.currentAnimation?.update(deltaTime);
    }
    
    draw(ctx, x, y) {
        this.currentAnimation?.draw(ctx, x, y);
    }
}

// 使用示例
const character = new AnimationStateMachine();

character.addState('idle', idleAnimation, {
    'walk': 'walking',
    'jump': 'jumping'
});

character.addState('walking', walkAnimation, {
    'stop': 'idle',
    'jump': 'jumping'
});

character.addState('jumping', jumpAnimation, {
    'land': 'idle'
});

character.setState('idle');

// 在游戏循环中
function gameLoop(deltaTime) {
    // 处理输入
    if (keys.ArrowRight) {
        character.trigger('walk');
    } else {
        character.trigger('stop');
    }
    
    if (keys.Space) {
        character.trigger('jump');
    }
    
    character.update(deltaTime);
    character.draw(ctx, playerX, playerY);
}

9.10 本章小结

本章深入介绍了 Canvas 动画系统:

核心概念

概念说明
requestAnimationFrame高效的动画循环
deltaTime帧间时间差
帧率无关动画基于时间而非帧数
缓动函数自然的运动效果

动画循环模式

javascript
let lastTime = 0;

function animate(timestamp) {
    const deltaTime = (timestamp - lastTime) / 1000;
    lastTime = timestamp;
    
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    update(deltaTime);
    draw();
    
    requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

常用缓动类型

类型效果
linear匀速
easeIn慢入
easeOut慢出
easeInOut慢入慢出
elastic弹性
bounce弹跳

9.11 练习题

基础练习

  1. 创建一个来回移动的方块动画

  2. 实现一个缓动效果选择器

  3. 创建多个随机运动的圆形

进阶练习

  1. 实现完整的粒子系统

  2. 创建带物理效果的弹球游戏

挑战练习

  1. 构建一个带状态机的游戏角色:
    • 支持多种动画状态
    • 平滑的状态过渡
    • 键盘控制

下一章预告:在第10章中,我们将学习离屏渲染与性能优化。


文档版本:v1.0
字数统计:约 15,000 字
代码示例:50+ 个

如有转载或 CV 的请标注本站原文地址