HTML5 GAME TUTORIAL(七): Draw images and sprite animations(译)

原文地址:Draw images and sprite animations

在画布上绘制自己的图像,并学习如何拉伸,缩放和旋转它们。对精灵图剪裁来创建精灵动画。在本教程结束时,您可以在画布上绘制自己的图像和动画,并在游戏中使用它们。

在本教程中,您将继续构建HTML5游戏,但首先要对如何在画布上绘制图像以及如何绘制精灵动画进行简单的说明。

获取HTML图像元素的引用

在开始在画布上实际绘制图像之前,您将需要以某种方式获取要绘制的图像的引用。有多种方法可以执行此操作,但是在第一个示例中,您将轻松进行操作并在网页中引用图像元素

图像元素是图片的HTML容器。您可以使用网页上的<img>标记来创建一个。您可以使用src属性指定图像来源。浏览器将加载源并在加载后在元素内显示图像。这是通常用于在网页上显示图像的方式。您可以使用显示在image元素中的图片,对其进行引用并将其绘制在画布上。

这是<img>标签的示例实现。这是药水瓶绘制教程的图像。稍后将在本教程中引用图像的HTML元素,来演示图像绘制操作。

An example image for drawing images on the canvas

要创建自己的图像元素,需在页面上添加<img>标签。并且设置src和id属性。

<img id="myImage" src="/img/my-image.png">

id只是一个可帮助您识别元素的标签,它实际上并不能单独执行任何操作。您可以在JavaScript代码中引用ID。使用getElementById()并接收id作为参数,以获取图像元素。

let img = document.getElementById("myImage");

img变量现在保存对图像元素的有效引用。 您可以使用此变量在画布上绘制图像。

在画布上绘制图像

在画布上绘制图像非常简单。您可以直接在2dContext上使用drawImage()函数来绘制。

在本系列教程的前面一节中介绍了设置HTML5画布并获得对上下文的引用。您可以在创建画布教程上阅读全部内容。

该函数需要接收图像和位置两个参数。方法头看起来像这样:context.drawImage(img,x,y); 您刚刚存储了参考,所以您只需要x和y位置即可。您可以像这样使用它:

context.drawImage(img, 10, 30);

图片将绘制在画布上,img变量是对图像元素的引用。在此示例中,它将绘制在(10,30)的位置上。它看起来与image元素中的原始图像完全一样。

为什么看不到图像?

到目前为止,您是否执行了所有步骤,但是看不到绘制的图像?还是仅在某些时候绘制了图像?很有可能是因为您正在引用页面上尚未完全加载的图像元素。

图像元素加载源需要花费一些时间。如果在资源未加载的情况下引用该元素,则无任何绘制。在所有资源完全加载完成之前,然后才执行页面上的代码。

您可以通过使用window.onLoad事件等待页面及其所有资源完全加载后再进行绘制操作来解决此问题,然后再将图像元素引用并绘制到画布上。这样,您可以确保图像元素和图像本身都出现在页面上,以供您参考以进行绘图。

您可以通过将函数设置为事件处理程序来实现onLoad事件。事件触发时,再调用该函数。这是一个实现示例:

window.onload = function (){
    // The page is completely loaded now
    // You can reference the image element
    let img = document.getElementById("myImage");
    context.drawImage(img, 10, 30);
}

通过URL加载图像

您刚刚学习了如何在页面上显示的图像元素内引用图像。对于游戏,您不能指望它能同样起作用。您将可以在网页上看到游戏中使用的所有图像和精灵。

您可能想通过一个URL加载图像,而用户却看不到它们。通过定义一个新的Image对象并设置其src属性,可以使用JavaScript从URL加载图像。加载图像源后,使用图像上的onload事件开始绘制。

这是一个简单的实现,可在画布加载后立即在画布上的(10,10)的位置上绘制yourimage.png:

let img = new Image();
img.onload = function() {
   context.drawImage(img, 10, 10);
};
img.src = 'https://www.linktoyourimage.com/yourimage.png';

调整图像大小

使用drawImage()方法,您可以轻松拉伸和缩放图像。只需将宽度和高度添加到函数调用的对应的参数中,如下所示:context.drawImage(img,x,y,width,height);

这是一个将图像从之前拉伸到100x200大小的示例。

context.drawImage(img, 10, 30, 100, 200);

如您所见,图像的比例已更改。 现在图像看起来变形了。

缩放同时保持图像比例

若要调整图像大小,但保持图像的宽高比相同,则可以对图片设置width和height属性。

在下一个示例中,图片的大小是原始图片的两倍:

context.drawImage(img, 10, 30, img.width / 2, img.height / 2);

结果如下,原始图像旁边显示。

图像已缩放,但不再变形。宽高比保持不变,并与原始图像相同。请记住,如果要保留图像的宽高比,则需要按相同的比例缩放宽度和高度。

如何修复缩放带来的质量问题?

当将图像缩放到原始大小以上(或以下)时,您可能会注意到一些质量问题。 您可以看到锯齿状边缘或其他缩放伪影。平滑可以帮助解决此问题,并使缩放后的图像看起来更好。

默认情况下,在画布上启用图像平滑。您可以使用imageSmoothingEnabled 上下文属性手动切换平滑。您甚至可以说出平滑质量。您可以将imageSmoothingQuality设置为“低”,“中”或“高”,尽管并非所有浏览器都支持此选项。请记住,启用平滑或将其设置为高平滑质量可能会对性能产生影响。

context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(img, 10, 30, img.width * 3, img.height * 3);

这是一个简单的例子。左边是没有平滑的图像,右边是平滑的。查看边缘时,您可以清楚地看到差异。

仅绘制图像的一部分

有时,您只想绘制原始图像的一小部分。忽略源图像的一部分称为裁剪。 drawImage()函数可以扩展为支持裁剪。您需要做的就是添加一些额外的参数。

首先定义图像的源矩形,然后定义目标矩形。方法标题看起来像这个context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height);。这是一个实现示例:

context.drawImage(img, 100, 0, 200, 50, 10, 30, 200, 50);

在此示例中,从(100,0)位置中拾取图像的片段,其宽度为200,高度为50。将该片段绘制为(10,30),其宽度和高度与源相同。结果将如下所示:

绘制了源图像的一小部分。 其余图像被裁剪。

同时剪辑和调整大小

如果要为drawImage()函数选择一个小于或大于目标的源矩形,则将缩放或拉伸图像。

在下一个示例中,目标矩形是源矩形的两倍。裁剪的源的大小为200x50,而目标的大小为400x100。图像同时被裁剪和缩放。

context.drawImage(img, 100, 0, 200, 50, 10, 30, 400, 100);

如您所见,图像现在已被剪切并绘制为上一个示例大小的两倍。

对精灵使用剪切

您可以使用剪切技术显示Sprite表中的图像。子画面是图像的集合,所有图像都合并在同一源图像上。它是游戏中最常使用的一种技术,用于将动画或一组资产存储在单个图像文件中。

这是一个示例精灵图像,包含10帧动画:

Example sprite animation of potion bottles

这是一幅图像,但它包含许多合并在一起的较小图片。每个子图像都可以视为一个帧。在该示例中,您看到药水瓶在每一帧中都会稍微改变颜色。这样,可以将多个动画帧打包到一个图像中。

假设您要显示示例子画面的第九个图像。您需要首先知道它的坐标。对于精灵动画,最常见的是对精灵内的每一帧使用相同的宽度和高度。因此,要显示单个图像,只需跟踪列和行并将其乘以帧的宽度或高度即可。

这是一个如何从子画面内部显示第九帧的示例实现(即第四列,第二行):

// Define the size of a frame
let frameWidth = 50;
let frameHeight = 61;

// Rows and columns start from 0
let row = 1;
let column = 3;

context.drawImage(sprite, column*frameWidth, row*frameHeight, frameWidth, frameHeight, 10, 30, frameWidth, frameHeight);

它将切出并仅显示所需的药水瓶。 它看起来像这样:

创建一个精灵动画

在前面的示例中,您仅将一个图像绘制到画布上。但是,如果要从精灵创建动画,则需要以较高的间隔显示更多的帧。

为此,您基本上要不断更改精灵图像源矩形的坐标。以足够快的速度绘制新图像,您才能获得动画效果。

这是一个使用间隔以每秒10次绘制新帧的示例实现。这是一个使用setInterval()的简单示例,跳过了不必再次解释时间戳的麻烦,但是,当然,您通常会在游戏循环中合并此代码,并使用[每帧之间的时间](https: //spicyyoghurt.com/tutorials/html5-javascript-game-development/create-a-smooth-canvas-animation)决定何时选择新的Sprite图片。

尚未设置游戏循环吗?在此处了解如何创建适当的游戏循环

// Define the number of columns and rows in the sprite
let numColumns = 5;
let numRows = 2;

// Define the size of a frame
let frameWidth = sprite.width / numColumns;;
let frameHeight = sprite.height / numRows;;

// The sprite image frame starts from 0
let currentFrame = 0;

setInterval(function()
{
    // Pick a new frame
    currentFrame++;

    // Make the frames loop
    let maxFrame = numColumns * numRows - 1;
    if (currentFrame > maxFrame){
        currentFrame = 0;
    }

    // Update rows and columns
    let column = currentFrame % numColumns;
    let row = Math.floor(currentFrame / numColumns);

    // Clear and draw
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.drawImage(sprite, column * frameWidth, row * frameHeight, frameWidth, frameHeight, 10, 30, frameWidth, frameHeight);

//Wait for next step in the loop
}, 100);

精灵图的多个帧是通过子图快速交替来绘制的。结果是药水瓶变色的动画。这是一个非常基本的示例,可以让您大致了解。您可以将相同的原理应用于更复杂的精灵图动画。

哪里可以获取精灵图?

在网络上,您可以找到免版税游戏资产,包括子画面。您可以尝试将本教程应用到它们上并创建自己的动画。您甚至可以更进一步,自己创建创建动画精灵表

您如何将此应用于您的游戏?

绘制单个图像很有趣,但是游戏需要更多实例。在上一个教程中,您已经实现了物理学的知识应用于游戏。如果您将关于精灵图的知识应用于游戏示例,则可以创建更多类似游戏的东西。

在旧示例中,画布上绘制出圆形。我们用实际图像替换它们。还记得碰撞吗? 对于每次碰撞,图像将拍摄精灵的下一帧。您将得到以下效果:

碰撞的药水瓶会改变颜色?这是一个怪异的例子,希望它可以帮助演示精灵动画的实际应用。添加图像可以使其看起来更像是一个实际的游戏。要实现此示例,您需要执行以下步骤:

  • 加载所需的图像(不超过一次)
  • 为每次碰撞更新当前动画帧
  • 在draw()函数中展示精灵图正确的那部分

您的Circle类如下所示:

class Circle extends GameObject
{
    // Define the number of columns and rows in the sprite
    static numColumns = 5;
    static numRows = 2;
    static frameWidth = 0;
    static frameHeight = 0;
    static sprite;

    constructor (context, x, y, vx, vy, mass)
    {
        // Pass params to super class
        super(context, x, y, vx, vy, mass);

        // Set the size of the hitbox
        this.radius = 10;

        // Supply the sprite. Only load it once and reuse it
        loadImage();
    }

    loadImage()
    {
        // Check for an existing image
        if (!Circle.sprite)
        {
            // No image found, create a new element
            Circle.sprite = new Image();

            // Handle a successful load
            Circle.sprite.onload = () =>
            {
                // Define the size of a frame
                Circle.frameWidth = Circle.sprite.width / Circle.numColumns;
                Circle.frameHeight = Circle.sprite.height / Circle.numRows;
            };

            // Start loading the image
            Circle.sprite.src = '/path-to/your-sprite-image.png';
        }
    }

    draw()
    {
        // Limit the maximum frame
        let maxFrame = Circle.numColumns * Circle.numRows - 1;
        if (this.currentFrame > maxFrame){
            this.currentFrame = maxFrame;
        }

        // Update rows and columns
        let column = this.currentFrame % Circle.numColumns;
        let row = Math.floor(this.currentFrame / Circle.numColumns);

        // Draw the image
        this.context.drawImage(Circle.sprite, column * Circle.frameWidth, row * Circle.frameHeight, Circle.frameWidth, Circle.frameHeight, (this.x - this.radius), (this.y - this.radius) - this.radius * 0.4, this.radius * 2, this.radius * 2.42);
    }

    handleCollision()
    {
        // Pick the next frame of the animation
        this.currentFrame++;
    }

    update(secondsPassed)
    {
        // Move with velocity x/y
        this.x += this.vx * secondsPassed;
        this.y += this.vy * secondsPassed;
    }
}

hitboxes的实际使用

也许你注意到了,上一个示例的代码中图像的位置存在一个小的偏移量。在这行的结尾:

// Draw the image
this.context.drawImage(Circle.sprite, column * Circle.frameWidth, row * Circle.frameHeight, Circle.frameWidth, Circle.frameHeight, this.x - this.radius, this.y - this.radius * 1.42, this.radius * 2, this.radius * 2.42);

// The y-offset is 42% of the radius. When radius = 10px, entire bottle = 20px, neck = 4.2px
// To maintain the image aspect ratio, the height is 21% larger than the width (2.42 vs 2 times the radius)
// You can calculate it by dividing the image height by image width. You could automate it further.

偏移量可确保药瓶的瓶体准确覆盖用于碰撞检测的圆。下图说明了使用offset(在左侧)和仅将Hitbox居中放置(在右侧)之间的区别。

A practical example of using hitboxes

如上一教程所述,许多游戏都使用这种方法,将简单的形状(在这种情况下为圆形)用作更复杂的形状(不规则圆形的药水瓶)的hitbox。但是有一个小缺陷。瓶子的颈部不会触发碰撞,因为它没有hitbox,它会在处于圆形范围之外。如果您的游戏不能接受这个问题,请尝试使用其他或更复杂的Hitbox。可能是矩形,也可能是矩形和圆形的组合。

您随时可以在屏幕上绘制hitbox,以查看效果如何:

this.context.beginPath();
this.context.arc(this.x, this.y, this.radius, 0, 2 * Math.PI);
this.context.fill();

有关更多信息和示例,请参阅上一教程中有关hitboxes的部分。

在画布上旋转图像

在上一个游戏示例中使用了精灵图,使外观看起来更友好,但物品的移动不是很真实。无论对象的运动方向如何,碰撞后图像朝向均保持不变。如果移动方向与图片朝向匹配会显得更自然。

您可以通过旋转画布上的图像来实现此效果。要开始旋转,您首先需要使用物体的速度来计算旋转角度(如这里。您可以在update()函数中执行此操作,并自动保持对象角度更新。这是一个例子:

update(secondsPassed)
{
    // Move with velocity x/y
    this.x += this.vx * secondsPassed;
    this.y += this.vy * secondsPassed;

    // Calculate the angle
    let radians = Math.atan2(this.vy, this.vx);
    this.angle = 180 * radians / Math.PI;
}

有了角度后,您需要对Circle类中的draw()函数进行一些小的调整。用以下代码块来调用drawImage():

// Set the origin to the center of the circle, rotate the context, move the origin back
this.context.translate(this.x, this.y);
this.context.rotate(Math.PI / 180 * (this.angle + 90));
this.context.translate(-this.x, -this.y);

// Draw the image, rotated
this.context.drawImage(Circle.image, this.column * Circle.frameWidth, this.row * Circle.frameHeight, Circle.frameWidth, Circle.frameHeight, (this.x - this.radius), (this.y - this.radius) - this.radius * 0.4, this.radius * 2, this.radius * 2.42);

// Reset transformation matrix
this.context.setTransform(1, 0, 0, 1, 0, 0);

让我们进一步解释一下。您可以通过设置转换矩阵来更改项目在画布上的绘制方式。基本上,这是一种应用于每个绘图操作的过滤器。有多种函数可以帮助您进行操作。现在需要向矩阵添加旋转的一个函数是rotation()函数。但是使用它会绕左上角旋转,这会使图像位置远离。

要纠正此问题,请添加围绕图像中心的旋转。您需要手动设置中心点才能实现此目标。这可以通过在旋转之前调用translate()函数来完成。

您可以将其视为移动画布的一种方式,以便将x轴和y轴的原点放置在要旋转的图像的中心。现在旋转时,图像将绕其中心旋转。之后,画布将转换回其原始位置。现在,原点回到(0,0),并且变换矩阵包含一个旋转。这一切都发生在代码块的前几行。

围绕图像的中心点旋转图像

Correct way of rotating an image on the canvas

上图显示了旋转过程。它包括4个步骤。

  • 步骤1-使用原始的转换矩阵。图像将在原点绘制。
  • 步骤2-向矩阵添加平移。它将图像中心直接与原点对齐。
  • 步骤3-向矩阵添加旋转。图像将以原点为枢轴点旋转。
  • 步骤4-还原转换平移。图像将以其原始位置绘制,但是这次旋转。

之后,您可以像平常一样绘制图像。只有这次矩阵才可以改变输出。图像是否被裁剪都没有关系,它甚至可以用于精灵图动画。

完成绘制后,需要还原上下文。否则它将保持旋转位置,并且画布的下一次绘制也将旋转。那就是setTransform()出现的地方。

在这种情况下,它用于通过旋转使变换矩阵无效,并将矩阵恢复为其原始状态。作为替代方案,您也可以将代码夹在context.save()和context.restore()调用之间,但在这个示例中setTransform()的效果会更好

这是运行代码时得到的。这是运动对象的自然视图!

下一步是什么?

您已经了解了如何在游戏中绘制图像和使用动画。您还可以旋转图像并一次绘制许多实例。拥有这些基本原理和自己的创意,您可以想出更有趣的原型,这些原型开始越来越像实际的游戏。

您已经到达了本系列的最后一个教程。至少现在是这样。我们正在努力创建下一个,因此有时请回来查看!

如果您想支持辣酸奶,请在社交媒体上分享我们的文章或关注我们。

posted @ 2020-10-30 17:08  mingL  阅读(182)  评论(0编辑  收藏  举报