序列帧动画 – 弱音 SilkyTone
前文
什么是序列帧动画,序列帧动画(Sprite Animation)是通过快速切换连续图像产生运动效果的技术,广泛应用于游戏角色动画、加载动效、交互反馈等场景。与传统CSS动画相比,序列帧能实现更复杂的视觉效果且资源控制更灵活。
作者已将其实现并发布到 npm 上,快来使用吧!@silkytone/sequence-frame
原理与核心技术
先说原理,序列帧和动画应该分开描述,这样你是不是好理解多了,一个是序列帧,一个是动画,序列帧是按顺序排列的静态图像集合,动画是在一秒切换的多少张图片的实现。
那么核心的技术是什么呢?canvas 和 requestAnimationFrame,canvas 我们应该都知道,是一个 html 中的元素标签,可以显示图片,那么 requestAnimationFrame 呢,是浏览器的一个帧定时器,与 setTimeout,setInterval 定时器不同,requestAnimationFrame 它要求浏览器在下一次重绘之前,调用用户提供的回调函数。下面实现了简单的使用,打开控制台可以观看打印的内容。
function animate(timestamp){ console.log('animate', timestamp); requestAnimationFrame(animate);}
封装与实现
在上文说过了,使用 canvas 和 requestAnimationFrame 就可以实现一个序列帧动画,那么我们现在一步步的实现,先简单的封装,让我们使用;
我们创建一个 Animate 类,为其实现 run,start,stop,三个方法和 fps,handler 两个传参,handler 是我们需要处理的方法,fps 为限制帧率。start,stop 用来控制动画,run 方法用来处理 handler 以及实现动画。
class Animate {
status = false;
constructor(handler,fps) {
this.fps = fps || 30;
this.handler = handler;
}
run(timestamp) {}
start() {
if (this.status) return;
this.status = true;
this.run();
}
stop() {
if (!this.status) return;
this.status = false;
}
}
有小伙伴问,为什么要创建一个 status 参数,这里我解释一下在 run 方法执行后,我们需要一个状态来判断 run 方法是否进行执行。
status 创建好了以后我们来实现 run 方法。在 run 方法中我们判断 status 的值为 false 我们退出执行。
创建了 get frameTime 参数用于获取多少毫秒执行一帧 ,
run 方法很简单,获取上一帧的时间,获取当前时间,当前时间减去上一帧的时候就得到了,上一帧到当前的用时;同时使用 setTimeout 定时器,将其限制在我们规定的 fps 范围内。然后就是使用我们的 requestAnimationFrame 方法,进入下一次浏览器重绘之前,运行 run 方法
class Animate {
...// 保持原来的代码
get frameTime() {
return 1000 / this.fps;
}
run(timestamp) {
if(!this.status) return;
//
const last = this.lastTime || Date.now();
const now = Date.now();
this.lastTime = now;
//
setTimeout(() => {
this.handler(timestamp);
}, this.frameTime - ((now - last) % this.frameTime));
//
requestAnimationFrame(this.run.bind(this));
}
}
下面是完整的 Animate 类的封装
class Animate {
status = false;
constructor(handler, fps) {
this.fps = fps || 30;
this.handler = handler;
}
get frameTime() {
return 1000 / this.fps;
}
run(timestamp) {
if (!this.status) return;
//
const last = this.lastTime || Date.now();
const now = Date.now();
this.lastTime = now;
//
setTimeout(() => {
this.handler(timestamp);
}, this.frameTime - ((now - last) % this.frameTime));
//
requestAnimationFrame(this.run.bind(this));
}
start() {
if (this.status) return;
this.status = true;
this.run();
}
stop() {
if (!this.status) return;
this.lastTime = undefined;
this.status = false;
}
}
接下来我们将 canvas 和 Animate 类封装在一起让我们的使用更加简单;
然我们来实现 CanvasAnimate 类型并实现 createCanvas ,handler,add,play,stop 几个方法,以及两个传参,el,fps。
play,stop 用于控制 Animate 类实现 播放/停止,add 用于添加序列帧图片,createCanvas 用户绑定或创建一个 canvas,handler 的实现,canvas 的绘画。
class CanvasAnimate {
get length() {
return this.images.length;
}
constructor(el, fps) {
this.count = 0;
this.images = [];
this.createCanvas(el);
this.animate = new Animate(this.handler.bind(this), fps);
}
createCanvas(el) {}
add(image) {
this.images.push(image);
}
handler() {}
play() {
this.animate.start();
}
stop() {
this.animate.stop();
}
}
接下来我们实现 createCanvas 和 handler 方法。createCanvas 查询或创建一个 canvas 绑定,并调用方法 getContext(‘2d’) 创建画布。handler 用户处理擦除画布的内容和将帧绘画到画布上。
class CanvasAnimate {
...// 保持原来的代码
createCanvas(el) {
if (typeof el === "string") {
this.canvas = document.querySelector(el);
if (!this.canvas) {
throw new Error(`el: ${el} 不存在`);
}
} else if (el.nodeName.toLowerCase() === "canvas") {
this.canvas = el;
} else {
this.canvas = document.createElement("canvas");
el.appendChild(this.canvas);
this.canvas.getContext("2d");
}
this.ctx = this.canvas.getContext("2d");
}
handler() {
const { width, height } = this.canvas;
this.ctx.clearRect(0, 0, width, height);
//
this.count = (this.count + 1) % this.length;
const image = this.images[this.count];
//
this.ctx.drawImage(image, 0, 0, width, height);
}
}
完整的代码如下
class CanvasAnimate {
constructor(el, fps) {
this.count = 0;
this.images = [];
this.createCanvas(el);
this.animate = new Animate(this.handler.bind(this), fps);
}
createCanvas(el) {
if (typeof el === "string") {
this.canvas = document.querySelector(el);
if (!this.canvas) {
throw new Error(`el: ${el} 不存在`);
}
} else if (el.nodeName.toLowerCase() === "canvas") {
this.canvas = el;
} else {
this.canvas = document.createElement("canvas");
el.appendChild(this.canvas);
this.canvas.getContext("2d");
}
this.ctx = this.canvas.getContext("2d");
}
add(image) {
this.images.push(image);
}
get length() {
return this.images.length;
}
handler() {
const { width, height } = this.canvas;
this.ctx.clearRect(0, 0, width, height);
//
this.count = (this.count + 1) % this.length;
const image = this.images[this.count];
//
this.ctx.drawImage(image, 0, 0, width, height);
}
play() {
this.animate.start();
}
stop() {
this.animate.stop();
}
}
使用
用我们创建的和封装的代码来使用吧,将我们封装的代码 canvasAnimate 类实例化,创建一个长度为 100 数组遍历数组,加载序列帧图片,并返回 Promise 用于等待图片的加载。等待全部加载完成后,开始播放序列帧动画,这样在你的页面中就能看到动画了;
jshtmlCanvasAnimate.jsAnimate.js
const canvasAnimate = new CanvasAnimate("#canvas", 30);
const images = Array.from(new Array(100)).map((_, index) => {
const img = document.createElement("img");
canvasAnimate.add(img);
return new Promise((resolve) => {
img.onload = function () {
resolve(true);
};
img.src = `url/${index}.png`;
});
});
Promise.all(images).then(() => {
canvasAnimate.play();
});
class CanvasAnimate {
constructor(el, fps) {
this.count = 0;
this.images = [];
this.createCanvas(el);
this.animate = new Animate(this.handler.bind(this), fps);
}
createCanvas(el) {
if (typeof el === "string") {
this.canvas = document.querySelector(el);
if (!this.canvas) {
throw new Error(`el: ${el} 不存在`);
}
} else if (el.nodeName.toLowerCase() === "canvas") {
this.canvas = el;
} else {
this.canvas = document.createElement("canvas");
el.appendChild(this.canvas);
this.canvas.getContext("2d");
}
this.ctx = this.canvas.getContext("2d");
}
add(image) {
this.images.push(image);
}
get length() {
return this.images.length;
}
handler() {
const { width, height } = this.canvas;
this.ctx.clearRect(0, 0, width, height);
//
this.count = (this.count + 1) % this.length;
const image = this.images[this.count];
//
this.ctx.drawImage(image, 0, 0, width, height);
}
play() {
this.animate.start();
}
stop() {
this.animate.stop();
}
}
class Animate {
status = false;
constructor(handler, fps) {
this.fps = fps || 30;
this.handler = handler;
}
get frameTime() {
return 1000 / this.fps;
}
run(timestamp) {
if (!this.status) return;
//
const last = this.lastTime || Date.now();
const now = Date.now();
this.lastTime = now;
//
setTimeout(() => {
this.handler(timestamp);
}, this.frameTime - ((now - last) % this.frameTime));
//
requestAnimationFrame(this.run.bind(this));
}
start() {
if (this.status) return;
this.status = true;
this.run();
}
stop() {
if (!this.status) return;
this.lastTime = undefined;
this.status = false;
}
}