使用 JavaScript 制作 Sprite Sheet 动画

2025-06-07

使用 JavaScript 制作 Sprite Sheet 动画

让我们看看如何使用 JavaScript 在 HTML5 画布上为精灵表制作动画。

一些设置

首先,让我们创建画布元素。

<canvas width="300" height="200"></canvas>
Enter fullscreen mode Exit fullscreen mode

添加边框(以便我们可以看到可用区域)。

canvas {
  border: 1px solid black;
}
Enter fullscreen mode Exit fullscreen mode

并加载精灵表 ( https://opengameart.org/content/green-cap-character-16x18 )。趁着这个机会,我们来访问画布及其 2D 上下文。

let img = new Image();
img.src = 'https://opengameart.org/sites/default/files/Green-Cap-Character-16x18.png';
img.onload = function() {
  init();
};

let canvas = document.querySelector('canvas');
let ctx = canvas.getContext('2d');

function init() {
  // future animation code goes here
}
Enter fullscreen mode Exit fullscreen mode

init函数在图像加载后通过 调用img.onload。这是为了确保图像在我们尝试处理之前已加载完毕。所有动画代码都将放在该init函数中。就本教程而言,这样做是可行的。如果我们要处理多张图片,我们可能需要使用 Promises 等待所有图片加载完毕后再进行任何操作。

精灵表

现在我们已经设置好了,让我们看一下图像。

角色精灵表

每一行代表一个动画周期。第一行(顶部)表示角色向下行走,第二行表示向上行走,第三行表示向左行走,第四行(底部)表示向右行走。严格来说,左侧一列表示站立(无动画),中间和右侧一列表示动画帧。不过,我觉得我们可以同时使用这三列动画来获得更流畅的行走动画。😊

Context 的drawImage方法

在开始为图像制作动画之前,让我们先了解一下drawImagecontext 方法,因为我们将使用该方法自动切分精灵表并将其应用到画布上。

MDN 文档 - drawImage

哇,这个方法里参数好多啊!尤其是第三种形式,也就是我们接下来要用到的。别担心,其实没那么难。它有逻辑分组。

drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
Enter fullscreen mode Exit fullscreen mode

参数image是源图像。接下来的四个(sxsysWidthsHeight)与源图像(精灵表)相关。最后四个(dxdydWidthdHeight)与目标(画布)相关。

“x” 和 “y” 参数(sx, sy, dx, dy)分别与精灵表(源)和画布(目标)的起始位置相关。它本质上是一个网格,左上角从 (0, 0) 开始,向右下方正方向移动。换句话说,(50, 30) 表示向右移动 50 像素,向下移动 30 像素。

“宽度”和“高度”参数(sWidthsHeightdWidthdHeight)分别表示精灵表和画布的宽度和高度,分别从它们各自的“x”和“y”位置开始。我们将其分解成一个部分,比如源图像。如果源参数(sxsysWidthsHeight)为 (10, 15, 20, 30),则起始位置(网格坐标系)为 (10, 15),并拉伸至 (30, 45)。然后,结束坐标的计算公式为 ( sx+ sWidthsy+ sHeight)。

绘制第一帧

现在我们已经了解了该drawImage方法,让我们实际看看它的实际效果。

我们的精灵表的角色帧大小在文件名 ( 16x18) 中很方便地标记出来,这样我们就有了宽度和高度属性。第一帧将从 (0, 0) 开始,到 (16, 18) 结束。让我们把它绘制到画布上。我们将从画布上的 (0, 0) 开始绘制这一帧,并保持比例不变。

function init() {
  ctx.drawImage(img, 0, 0, 16, 18, 0, 0, 16, 18);
}
Enter fullscreen mode Exit fullscreen mode

我们现在有了第一帧!不过它有点小。我们把它放大一点,这样更容易看清。

将上面的内容改为:

const scale = 2;
function init() {
  ctx.drawImage(img, 0, 0, 16, 18, 0, 0, 16 * scale, 18 * scale);
}
Enter fullscreen mode Exit fullscreen mode

您应该会看到画布上绘制的图像在水平和垂直方向上都放大了一倍。通过更改dWidth和 的dHeight值,我们可以将原始图像在画布上缩放。不过,操作时要小心,因为处理的是像素,它很快就会变得模糊。尝试更改 的scale值,看看输出如何变化。

下一帧

要绘制第二帧,我们唯一需要做的就是更改源集的某些值。具体来说,sxsy。每个帧的宽度和高度相同,所以我们永远不需要更改这些值。实际上,让我们提取这些值,创建几个缩放值,然后在当前帧的右侧绘制接下来的两个帧。

const scale = 2;
const width = 16;
const height = 18;
const scaledWidth = scale * width;
const scaledHeight = scale * height;

function init() {
  ctx.drawImage(img, 0, 0, width, height, 0, 0, scaledWidth, scaledHeight);
  ctx.drawImage(img, width, 0, width, height, scaledWidth, 0, scaledWidth, scaledHeight);
  ctx.drawImage(img, width * 2, 0, width, height, scaledWidth * 2, 0, scaledWidth, scaledHeight);
}
Enter fullscreen mode Exit fullscreen mode

现在看起来是这样的:

现在,我们得到了精灵表的整个顶行,但位于三个独立的帧中。如果你查看调用ctx.drawImage,会发现只有 4 个值发生了变化—— sxsydxdy

让我们稍微简化一下。既然如此,我们就使用精灵表中的帧编号,而不是处理像素。

将所有ctx.drawImage调用替换为:

function drawFrame(frameX, frameY, canvasX, canvasY) {
  ctx.drawImage(img,
                frameX * width, frameY * height, width, height,
                canvasX, canvasY, scaledWidth, scaledHeight);
}

function init() {
  drawFrame(0, 0, 0, 0);
  drawFrame(1, 0, scaledWidth, 0);
  drawFrame(0, 0, scaledWidth * 2, 0);
  drawFrame(2, 0, scaledWidth * 3, 0);
}
Enter fullscreen mode Exit fullscreen mode

我们的drawFrame函数处理精灵表数学,所以我们只需要传入帧号(从 0 开始,像一个数组,所以“x”帧是 0、1 和 2)。

画布的“x”和“y”值仍然采用像素值,以便我们更好地控制角色的位置。scaledWidth在函数内部移动乘数(即scaledWidth * canvasX)意味着所有内容都会一次移动/改变整个缩放角色的宽度。如果角色每帧移动 4 或 5 个像素,那么这对于行走动画就不起作用。所以我们保持原样。

调用列表中还多了一行drawFrame。这行代码是为了展示动画循环的样子,而不是仅仅绘制精灵图的前三帧。动画循环不再重复“左步,右步”,而是重复“站立,左步,站立,右步”——这是一个稍微好一点的动画循环。不过两种方法都可以——80 年代的许多游戏都使用了两步动画。

这是我们目前的情况:

让我们为这个角色制作动画!

现在我们可以开始为角色添加动画了!我们来看看MDNrequestAnimationFrame文档

这就是我们用来创建循环的方法。我们也可以使用setInterval,但它requestAnimationFrame已经有一些很好的优化,比如以每秒 60 帧(或尽可能接近)的速度运行,并在浏览器/标签失去焦点时停止动画循环。

本质上,这requestAnimationFrame是一个递归函数——为了创建动画循环,我们将requestAnimationFrame再次调用作为参数传递的函数。像这样:

window.requestAnimationFrame(step);

function step() {
  // do something
  window.requestAnimationFrame(step);
}
Enter fullscreen mode Exit fullscreen mode

walk函数开始循环之前单独调用,然后在循环内部不断调用。

在开始使用它之前,我们需要了解并使用另一个上下文方法 - clearRectMDN 文档)。在画布上绘制时,如果我们反复drawFrame在同一个位置调用该方法,它将一直绘制在已有内容之上。为了简单起见,我们将在每次绘制之间清除整个画布,而不仅仅是我们绘制的区域。

因此,我们的绘制循环看起来会像清除、绘制第一帧、清除、绘制第二帧等等。

换句话说:

ctx.clearRect(0, 0, canvas.width, canvas.height);
drawFrame(0, 0, 0, 0);
// repeat for each frame
Enter fullscreen mode Exit fullscreen mode

好了,让我们来给这个角色制作动画吧!我们先创建一个循环数组 (0, 1, 0, 2),并记录当前循环的进度。然后,创建一个step函数,作为主动画循环。

步进函数清除画布,绘制框架,前进(或重置)我们在循环循环中的位置,然后通过调用自身requestAnimationFrame

const cycleLoop = [0, 1, 0, 2];
let currentLoopIndex = 0;

function step() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawFrame(cycleLoop[currentLoopIndex], 0, 0, 0);
  currentLoopIndex++;
  if (currentLoopIndex >= cycleLoop.length) {
    currentLoopIndex = 0;
  }
  window.requestAnimationFrame(step);
}
Enter fullscreen mode Exit fullscreen mode

为了开始动画,让我们更新该init函数。

function init() {
  window.requestAnimationFrame(step);
}
Enter fullscreen mode Exit fullscreen mode

那个角色进展很快!😂

放慢速度!

看起来我们的角色有点失控了。如果浏览器允许,角色将以每秒 60 帧的速度绘制,或者尽可能接近这个速度。我们来限制一下,让它每 15 帧绘制一次。我们需要跟踪当前处于哪一帧。然后,在step函数中,每次调用时都会增加计数器,但仅在 15 帧后绘制。15 帧过后,重置计数器,然后绘制该帧。

const cycleLoop = [0, 1, 0, 2];
let currentLoopIndex = 0;
let frameCount = 0;

function step() {
  frameCount++;
  if (frameCount < 15) {
    window.requestAnimationFrame(step);
    return;
  }
  frameCount = 0;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawFrame(cycleLoop[currentLoopIndex], 0, 0, 0);
  currentLoopIndex++;
  if (currentLoopIndex >= cycleLoop.length) {
    currentLoopIndex = 0;
  }
  window.requestAnimationFrame(step);
}
Enter fullscreen mode Exit fullscreen mode

好多了!

其他方向

到目前为止,我们只处理了向下的方向。我们稍微修改一下动画,让角色在每个方向上完成完整的4步循环怎么样?

记住,在我们的代码中,“向下”的帧位于第 0 行(精灵表的第一行),向上是第 1 行,向左是第 2 行,向右是第 3 行(精灵表的底行)。循环保持每行 0、1、0、2 的顺序。由于我们已经处理了循环变化,因此唯一需要更改的是行号,它是drawFrame函数的第二个参数。

我们将添加一个变量来跟踪当前的方向。为了简单起见,我们将按照精灵表的顺序(下、上、左、右)进行,这样它就是连续的(0、1、2、3,重复)。

当循环重置时,我们将转向下一个方向。一旦我们完成了每个方向,我们就会重新开始。因此,我们更新后的step函数和相关变量如下所示:

const cycleLoop = [0, 1, 0, 2];
let currentLoopIndex = 0;
let frameCount = 0;
let currentDirection = 0;

function step() {
  frameCount++;
  if (frameCount < 15) {
    window.requestAnimationFrame(step);
    return;
  }
  frameCount = 0;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawFrame(cycleLoop[currentLoopIndex], currentDirection, 0, 0);
  currentLoopIndex++;
  if (currentLoopIndex >= cycleLoop.length) {
    currentLoopIndex = 0;
    currentDirection++; // Next row/direction in the sprite sheet
  }
  // Reset to the "down" direction once we've run through them all
  if (currentDirection >= 4) {
    currentDirection = 0;
  }
  window.requestAnimationFrame(step);
}
Enter fullscreen mode Exit fullscreen mode

就这样!我们的角色可以向四个方向行走,所有动画都来自一张图片。

文章来源:https://dev.to/martyhimmel/animating-sprite-sheets-with-javascript-ag3
PREV
防止 Flexbox 溢出的快速技巧
NEXT
Wasp - 用于开发无样板的全栈 Javascript Web 应用程序的语言