HTML5-和-JavaScript-高级游戏设计-三-

193 阅读38分钟

HTML5 和 JavaScript 高级游戏设计(三)

原文:Advanced Game Design with HTML5 and JavaScript

协议:CC BY-NC-SA 4.0

五、让东西动起来

快谢幕了!你的小精灵们都打扮好了,他们背好了台词,准时到达排练厅,耐心地站在舞台上等着你指挥他们。现在怎么办?你需要让它们生动起来!

游戏中有两种主要的精灵动画制作方式:

  • 脚本动画:让精灵在屏幕上移动。
  • 关键帧动画:改变精灵的外观。显示一系列略有不同的预渲染图像,就像绘制手绘卡通或动画书一样。

关键帧动画是关于动画精灵的外观,而脚本动画是关于动画其在屏幕上的 x,y 位置。在这一章中,你将学习如何使用脚本动画让精灵移动。在第八章中,你将学习如何制作关键帧动画来创造游戏角色行走的效果。

在这一章中,我们还将详细了解游戏循环:让精灵移动的循环功能。您将学习一些将游戏的更新逻辑与其渲染逻辑分离的策略,以实现尽可能平滑的精灵动画。

基本运动

要使用脚本动画制作精灵动画,你需要改变它在游戏循环中的 x,y 位置。游戏循环是一种每秒更新 60 次的功能,这样你就可以逐渐改变精灵的位置来创造运动的幻觉。所有的精灵动画和大部分游戏逻辑都发生在游戏循环中。用 JavaScript 和 HTML5 做游戏循环最好的方法就是用一个叫window.requestAnimationFrame的方法。这里有一些代码来说明如何使用这个方法让一个球从画布的边缘反弹出去。(图 5-1 说明了这段代码的作用。)

//Import code from the library
import {makeCanvas, circle, stage, render} from "../library/display";

//Create the canvas and stage
let canvas = makeCanvas(256, 256);
stage.width = canvas.width;
stage.height = canvas.height;

//Create a ball sprite
//`circle` arguments: diameter, fillStyle, strokeStyle, lineWidth, x, y
let ball = circle(32, "gray", "black", 2, 96, 128);

//Set the ball's velocity
ball.vx3;
ball.vy2;

//Start the game loop
gameLoop();

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Move the ball
  ball.x += ball.vx;
  ball.y += ball.vy;

  //Bounce the ball off the canvas edges.
  //Left and right
  if(ball.x < 0
  || ball.x + ball.diameter > canvas.width) {
    ball.vx = -ball.vx;
  }

  //Top and bottom
  if(ball.y < 0
  || ball.y + ball.diameter > canvas.height) {
    ball.vy = -ball.vy;
  }

  //Render the animation
  render(canvas);
}

9781430258001_Fig05-01.jpg

图 5-1 。以每秒 60 帧的速度在画布上拍球

Image 注意这段代码使用了从library文件夹中的display模块导入的方法和对象。第四章展示了如何导入和使用这些工具。你会在本章的源文件中找到library文件夹。

速度是球运行的速度和方向。它由两个 sprite 属性表示:vxvy

ball.vx3;
ball.vy2;

vx代表球的水平速度:向右或向左移动的速度。vy代表它的垂直速度:上下移动的速度有多快。

Image vxvy这两个属性实际上代表一个矢量。请参阅附录,了解什么是矢量以及如何在游戏中利用矢量的力量。

球移动是因为它的速度被加到了它在requestAnimationFrame循环中的当前位置:

gameLoop();

function gameLoop() {
  requestAnimationFrame(gameLoop);

  ball.x += ball.vx;
  ball.y += ball.vy;

  //...
}

对于每个新帧,球的 x 位置将改变 3 个像素,其 y 位置将改变 2 个像素。因为运动的变化很小,而且代码每秒钟更新 60 次,所以会产生运动的错觉。这是脚本动画的基础。

requestAnimationFrame方法是驱动连续循环代码的引擎。它的参数是应该在循环中调用的函数:

requestAnimationFrame(functionToLoop);

告诉浏览器应该以 16 毫秒(每秒 60 次)的间隔调用循环函数。每个循环更新都被称为一个动画帧,因为该效果模拟了一个运行的视频或连续画面。(视频和电影由一系列静止图像组成,称为,它们按顺序播放以创造运动的错觉。)显示这些帧的实际速率与显示器的屏幕刷新率同步,通常为 60Hz(但不总是这样;在本章快结束时,你会学到一些应对这些差异的策略。这种同步使循环高度优化,因为浏览器只会在最合适的时候调用循环代码,而不会忙于执行其他任务。

Image 注意无论你是在循环函数的开头还是结尾调用requestAnimationFrame都没关系。那是因为requestAnimationFrame没有在代码中出现的地方调用循环函数;它只是允许浏览器随时调用循环函数。

requestAnimationFrame可以向循环函数传递一个可选的时间戳参数。时间戳告诉你自从requestAnimationFrame第一次开始循环以来已经过了多少毫秒。下面是访问时间戳的方法:

function gameLoop(timestamp) {
  requestAnimationFrame(gameLoop);
  console.log(`elapsed time: ${timestamp}`);
}

在这一章的结尾,你会学到如何使用这个时间戳来微调游戏循环。

Image 注意你也可以用Date.now()来捕捉当前的 UTC 时间,单位是毫秒。UTC(协调世界时),也称为 Unix 时间,是一种标准的时间度量单位,它告诉您自 1970 年 1 月 1 日以来已经过去了多少毫秒。您可以通过捕获当前的 UTC 时间,将其与更早或更晚的时间进行比较,并使用差值来计算运行时间,从而将它用作通用时间戳。为了获得更高的精度,使用Performance.now(),它会给你一个精确到千分之一毫秒的时间。

在第一个例子中,球从画布的边缘反弹回来。它是怎么做到的?两个if语句检查球的边缘是否碰到画布的边缘。如果这是真的,球的速度是相反的。

//Left and right
if(ball.x < 0
|| ball.x + ball.diameter > canvas.width) {
  ball.vx = -ball.vx;
}

//Top and bottom
if(ball.y < 0
|| ball.y + ball.diameter > canvas.height) {
  ball.vy = -ball.vy;
}

在速度(ball.vxball.vy)前加一个负号会使球在碰到画布边缘时改变方向。

增加加速度和摩擦力

您可以通过添加物理属性,如加速度和摩擦力,使对象以更自然的方式移动。加速度使物体逐渐加速,摩擦力使其减速。图 5-2 显示了一个逐渐加速的球的例子。当它碰到画布的边缘时,会反弹回来并减速停止。

9781430258001_Fig05-02.jpg

图 5-2 。用加速度加速,用摩擦力减速

为了实现这一点,向 sprite 添加一些新的accelerationfriction属性,并将其速度初始化为零:

let ball = circle(32, "gray", "black", 2, 64, 96);

//Set the ball's velocity to 0
ball.vx0;
ball.vy0;

//Acceleration and friction properties
ball.accelerationX0.2;
ball.accelerationY = -0.2;
ball.frictionX1;
ball.frictionY1;

加速度值 xy 分别为 0.2 和–0.2,这是您希望球的速度在每一帧中增加的量。摩擦力 xy 的值 1 是速度应该乘以的量,以使其减速。你不希望最初对球施加任何摩擦力,所以将值指定为 1 实质上意味着“没有摩擦力”(那是因为速度乘以 1 只是得到相同的速度值,没有任何变化。1 乘以 1 等于 1,对吗?)任何小于 1 的摩擦力值,比如 0.98,都会使物体逐渐减速。加速度和摩擦力可以不同地影响 xy 轴,因此每个轴都有摩擦力和加速度属性。

下面是使用这些属性使球加速的游戏循环。球一碰到画布边缘,加速度就设置为零,摩擦力设置为 0.98,使球逐渐减速并停止。

//Start the game loop
gameLoop();

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Apply acceleration to the velocity
  ball.vx += ball.accelerationX;
  ball.vy += ball.accelerationY;

  //Apply friction to the velocity
  ball.vx *= ball.frictionX;
  ball.vy *= ball.frictionY;

  //Move the ball by applying the new calculated velocity
  //to the ball's x and y position
  ball.x += ball.vx;
  ball.y += ball.vy;

  //Bounce the ball off the canvas edges and slow it to a stop

  //Left and right
  if(ball.x < 0
  || ball.x + ball.diameter > canvas.width) {

    //Turn on friction
    ball.frictionX0.98;
    ball.frictionY0.98;

    //Turn off acceleration
    ball.accelerationX0;
    ball.accelerationY0;

    //Bounce the ball on the x axis
    ball.vx = -ball.vx;
  }

  //Top and bottom
  if(ball.y < 0
  || ball.y + ball.diameter > canvas.height) {

    //Turn on friction
    ball.frictionX0.98;
    ball.frictionY0.98;

    //Turn off acceleration
    ball.accelerationX0;
    ball.accelerationY0;

    //Bounce the ball on the y axis
    ball.vy = -ball.vy;
  }

  //Render the animation
  render(canvas);
}

你可以在前面的代码中看到加速度是加到球的速度上的:

ball.vx += ball.accelerationX;
ball.vy += ball.accelerationY;

摩擦力是乘以球的速度:

ball.vx *= ball.frictionX;
ball.vy *= ball.frictionY;

要使球移动,将其新速度添加到当前位置:

ball.x += ball.vx;
ball.y += ball.vy;

通过在游戏中的某个地方改变球的摩擦力和加速度值,例如当它撞到画布边缘时,该代码将正确地重新计算球的速度。在下一章,你将学习如何使用鼠标、键盘和触摸来改变精灵的加速度和摩擦力。

重力

重力是作用在物体上的持续向下的力。您可以通过将一个恒定的正值应用到精灵的垂直速度(vy)来将其添加到精灵,如下所示:

ball.vy += 0.3;

Image 注意记住,画布的 y 位置随着你从画布的顶部移动到底部而增加。这意味着如果你想把一个物体拉下来,你必须给它的 y 位置加上一个值,而不是减去它。

如果你混合重力和弹跳效果,你可以创建一个非常真实的弹跳球模拟。从本章的源代码中运行gravity.html文件以获得一个工作示例。球以随机速度开始,在画布上反弹,并逐渐滚动到底部停止。图 5-3 说明了你将会看到的东西。

9781430258001_Fig05-03.jpg

图 5-3 。添加一些重力来制作一个真实的弹跳球

不仅仅是重力有助于创造这种效果。为了增加一点真实性,球也有质量和摩擦力。当球击中画布的一个边缘时,其质量从速度中扣除,以模拟表面吸收一些冲击力。当球在地面上时,一些摩擦力作用于其在 x 轴上的速度。两种力量都以一种非常现实的方式让球慢下来。没有它们,球将继续在画布上无休止地反弹,而不会失去任何动量。让我们看看完成所有这些的代码。

ball sprite 增加了四个新属性 : gravitymassfrictionXfrictionY。它还被初始化为随机的vxvy速度,这样每次程序运行时球以不同的力和方向值移动:

let ball = circle(32, "gray", "black", 2, 96, 128);

//Random velocity
ball.vxrandomInt(5, 15);
ball.vyrandomInt(5, 15);

//Physics properties
ball.gravity0.3;
ball.frictionX1;
ball.frictionY0;
ball.mass1.3;

mass应该是大于 1 的任何数字。

可以看到 random vxvy属性被初始化为 5 到 15 之间的随机数。我们的代码在你在第一章中学到的自定义randomInt函数的帮助下做到了这一点:

function randomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

如果你在游戏中需要随机的浮点数,你可以使用相关的函数randomFloat :

function randomFloat(min, max) {
   return min + Math.random() * (max - min);
}

下面是一个游戏循环,它使用这些属性使球下落并从画布边缘弹开:

//Start the game loop
gameLoop();

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Apply gravity to the vertical velocity
  ball.vy += ball.gravity;

  //Apply friction. `ball.frictionX` will be 0.96 if the ball is
  //on the ground, and 1 if it's in the air
  ball.vx *= ball.frictionX;

  //Move the ball by applying the new calculated velocity
  //to the ball's x and y position
  ball.x += ball.vx;
  ball.y += ball.vy;

  //Bounce the ball off the canvas edges and slow it to a stop

  //Left
  if (ball.x < 0) {
    ball.x0;
    ball.vx = -ball.vx / ball.mass;
  }

  //Right
  if (ball.x + ball.diameter > canvas.width) {
    ball.x = canvas.width - ball.diameter;
    ball.vx = -ball.vx / ball.mass;
  }

  //Top
  if (ball.y < 0) {
    ball.y0;
    ball.vy = -ball.vy / ball.mass;
  }

  //Bottom
  if(ball.y + ball.diameter > canvas.height) {

    //Position the ball inside the canvas
    ball.y = canvas.height - ball.diameter;

    //Reverse its velocity to make it bounce, and dampen the effect with mass
    ball.vy = -ball.vy / ball.mass;

    //Add some friction if it's on the ground
    ball.frictionX0.96;
  } else {

    //Remove friction if it's not on the ground
    ball.frictionX1;
  }

  //Render the animation
  render(canvas);
}

你可以看到重力是加到球的vy属性上的:

ball.vy += ball.gravity;

随着时间的推移,这逐渐将球拉向画布的底部。

Image 注意在这个例子中,重力是球的一个属性,但是在游戏中,你可能想把它创建为一个影响所有精灵的全局值。

四个if语句检查球和画布边缘之间的碰撞。如果球越过画布边界,它会被移回,这样它就刚好在边界内。例如,如果球穿过画布的右侧,其 x 位置被设置为等于画布的宽度减去球直径的一半。

ball.x = canvas.width - ball.diameter;

这些检查确保球完全脱离画布边界,并且当它的速度改变时不会被它们粘住。

接下来,球的速度被逆转,使其反弹。该代码还将球的速度除以它的mass (1.3),以便在撞击中损失一点力。当球击中画布的右侧时会发生这种情况:

ball.vx = -ball.vx / ball.mass;

这将使球在每次撞击表面时逐渐减速。

检查球是否击中画布底部的if语句还做了一件事。它将球的摩擦力设置为 0.96,这样如果球在地面上,速度会更慢。

if(ball.y + ball.diameter > canvas.height) {
  ball.y = canvas.height - ball.radius;
  ball.vy = -ball.vy / ball.mass;
  ball.frictionX0.96;
} else {
  ball.frictionX1;
}

如果球不在地面上,else块将球的摩擦力设置回 1,这样它就可以在空中自由移动。

从这些例子中你可以看到,只需要一些简单的物理属性和一点逻辑,就可以很容易地让精灵以复杂而有趣的方式运行。我希望这个物理学的快速介绍会启发你开始在你自己的游戏中使用它。真的没有比这更复杂的了。

在一个区域内包含精灵

在一个区域中包含一个精灵,比如画布的边缘,是一个非常常见的游戏设计需求。因此,让我们创建一个名为contain的函数,您可以在任何游戏项目中使用它,就像这样:

let collision = contain(sprite, bounds, bounce, callbackFunction)

第一个参数是要包含的 sprite,第二个参数bounds是一个对象,它具有定义包含区域的 xy 、width 和 height 属性。如果精灵从边界边缘反弹,将bounce(第三个参数)设置为true。您还可以提供一个可选的额外回调函数(第四个参数),如果 sprite 碰到任何边界边缘,该函数将运行。contain函数返回一个名为collision 的变量,它告诉你精灵是否碰到了边界的"top""left""bottom""right"边缘。下面是完整的contain功能:

export function contain (sprite, bounds, bounce = false, extra = undefined){

  let x = bounds.x,
      y = bounds.y,
      width = bounds.width,
      height = bounds.height;

  //The `collision` object is used to store which
  //side of the containing rectangle the sprite hits
  let collision;

  //Left
  if (sprite.x < x) {

    //Bounce the sprite if `bounce` is true
    if (bounce) sprite.vx *= -1;

    //If the sprite has `mass`, let the mass
    //affect the sprite's velocity
    if(sprite.mass) sprite.vx /= sprite.mass;
    sprite.x = x;
    collision = "left";
  }

  //Top
  if (sprite.y < y) {
    if (bounce) sprite.vy *= -1;
    if(sprite.mass) sprite.vy /= sprite.mass;
    sprite.y = y;
    collision = "top";
  }

  //Right
  if (sprite.x + sprite.width > width) {
    if (bounce) sprite.vx *= -1;
    if(sprite.mass) sprite.vx /= sprite.mass;
    sprite.x = width - sprite.width;
    collision = "right";
  }

  //Bottom
  if (sprite.y + sprite.height > height) {
    if (bounce) sprite.vy *= -1;
    if(sprite.mass) sprite.vy /= sprite.mass;
    sprite.y = height - sprite.height;
    collision = "bottom";
  }

  //The `extra` function runs if there was a collision
  //and `extra` has been defined
  if (collision && extra) extra(collision);

  //Return the `collision` object
  return collision;
};

下面是如何使用这个contain函数来替换前一个例子中重力代码的原始版本中的四个if语句:

let collision = contain(ball, stage.localBounds, true);

如果球击中了舞台的任何边界,collision变量将具有值"top""right""bottom""left"。第二个参数显示了如何使用 sprite 的localBounds属性,你在第四章的中学到了这一点。localBounds是一个具有 xy 、宽度和高度属性的对象,定义了一个矩形区域。您也可以通过提供一个自定义的bounds对象作为第二个参数来达到同样的效果,如下所示:

let collision = contain(
  ball,
  {x: 0, y: 0, width: canvas.width, height: canvas.height},
  true
);

使用 stage 的localBounds属性是一种方便的快捷方式。

如果球碰到了舞台的边界,collision变量会告诉你碰撞发生在舞台的哪一边。在这个重力的例子中,如果球碰到画布的底部,你想让球慢下来。这意味着你可以检查collision是否有"bottom"的值,如果有,给球施加一些摩擦力:

if (collision === "bottom") {
  ball.frictionX0.96;
} else {
  ball.frictionX1;
}

contain函数的另一个特性是可以添加一个额外的可选回调函数作为第四个参数。以下是如何:

let collision = contain(
  ball, stage.localBounds, true,
  () => {
    console.log("Hello from the extra function!");
  }
);

每当球碰到舞台边界时,额外回调函数中的代码就会运行。这是一种将一些定制代码注入到contain函数中的便捷方式,无需修改函数本身。contain函数检查这个额外回调的存在,并在返回collision值之前运行它。下面是来自contain函数的代码:

if (collision && extra) extra(collision);

您可以看到,extra回调也可以访问collision值,所以如果需要的话,它可以使用这些信息。这是一个非常有用技巧,你会在本书中看到更多的例子。

仅供参考,这里是重力示例的整个gameLoop,其中四个if语句被contain函数替换为

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Move the ball
  ball.vy += ball.gravity;
  ball.vx *= ball.frictionX;
  ball.x += ball.vx;
  ball.y += ball.vy;

  //Check for a collision between the ball and the stage boundaries
  let collision = contain(ball, stage.localBounds, true);
  if (collision === "bottom") {
    //Slow the ball down if it hits the bottom
    ball.frictionX0.96;
  } else {
    ball.frictionX1;
  }

  //Render the animation
  render(canvas);
}

这比原来的代码少得多,您可以在任何其他项目中重用contain函数。

微调游戏循环

现在你知道了用代码让精灵移动的所有基础知识。很简单,不是吗?但是你还需要知道一些关于游戏循环的东西,这样你才能最大限度地控制精灵在屏幕上的移动。在本章的后半部分,你将学习如何设置游戏的帧率,以及如何微调游戏循环以获得最平滑的精灵动画。

第一步,也是最重要的一步,是将游戏逻辑和渲染逻辑分离开来。你已经完成了一半:所有的精灵渲染都发生在你在前一章学过的render函数中。你需要做的唯一新的事情就是将游戏逻辑保持在自己的函数update 中。当游戏循环运行时,它将首先更新游戏逻辑,然后渲染精灵:

gameLoop();

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //update the game logic
  update();

  //Render the sprites
  render(canvas);
}

function update() {
  //All your game logic goes here
}

function render(canvas) {
  //The same rendering code from Chapter 4
}

在前面的例子中,您将会看到这个额外的模块化变得多么重要。

设置帧速率

显示一系列动画帧的速度被称为帧率 ,以每秒帧数衡量,或 fps 。它是动画每秒钟更新或改变的次数。帧速率越高,动画越流畅,帧速率越低,动画越不流畅。任何动画的帧速率通常由一个名为fps的变量的值决定。如果你想让你的动画每秒播放 12 帧,你可以这样设置fps:

fps = 12

Fps 对于我们人类来说是一个容易理解的度量,但是 JavaScript 是以毫秒为单位,而不是以帧为单位。因此,如果我们想要使用fps值以固定的间隔播放帧,我们需要使用基于毫秒的帧速率。将fps除以 1000,计算出每帧之间应该间隔多少毫秒,如下所示:

frameRate = 1000 / fps

如果fps是 12,那么frameRate将是 83 毫秒。这是总的思路,但是怎么用它来控制我们游戏的帧率呢?

在大多数系统上,以 60Hz 的速度更新画布,这意味着你的游戏循环将以每秒 60 帧的速度运行。又快又流畅。requestAnimationFrame为您提供最流畅的动画,因为浏览器会在一次屏幕刷新中以固定的帧间隔绘制所有屏幕图形。它充分利用了宝贵的 CPU 时间。

每秒 60 帧的帧速率意味着你有大约 16 毫秒的空闲时间来运行你的游戏代码。实际上,两帧之间只有 13 毫秒,因为浏览器进程会到处消耗掉额外的几毫秒。为了安全起见,再多加几毫秒,假装你真的只有 10 毫秒。因此,这是 10 毫秒空闲 CPU 时间的预算,用于完成所有的游戏逻辑、物理、UI 处理和图形渲染。这并不多,这也是为什么游戏开发者倾向于痴迷于性能和优化。如果您在某处只能节省 1 毫秒的处理时间,那么这大约是您性能预算的 10%。如果你的游戏代码需要超过 10 毫秒来运行,帧速率将会下降,动画将会变成 janky 。我们都知道简洁性是什么:紧张、跳动和口吃的动画打破了游戏世界的沉浸感。

邱建缉捕队

简洁性经常是由 JavaScript 的垃圾收集器执行自动化内存管理任务造成的。不幸的是,您无法控制垃圾收集器何时运行,也无法控制它的工作效率。垃圾收集由 JavaScript 运行时环境(通常是 web 浏览器)管理,您只能希望它能很好地完成工作。

大多数 web 浏览器开发工具都有一个帧率图,告诉你游戏运行的速度。如果你在游戏运行的时候看图表,你可能会注意到一切看起来都很好,然后,不知从哪里,帧速率会有一个短暂的下降。这叫做尖峰,如图图 5-4 所示。尖刺是 jankiness 的常见原因。

9781430258001_Fig05-04.jpg

图 5-4 。游戏设计者的克星:帧速率峰值!

当游戏或浏览器的处理量突然增加时,就会出现峰值。您可以通过在开发人员工具中打开浏览器的帧时间线查看器并查看帧图形来查看其原因。帧图告诉你游戏中的每一帧花了多少毫秒来处理。它还显示了游戏中每段代码在每一帧中运行的确切时间。如果你注意到任何不寻常的峰值,检查一下是什么引起的。通常,最大的峰值是由浏览器自动垃圾收集引起的,如图 5-5 所示。

9781430258001_Fig05-05.jpg

图 5-5 。消耗 33 毫秒处理时间的垃圾收集事件

图 5-5 中的垃圾收集事件花费了 33 毫秒。因此,即使你的游戏以 60 帧/秒的速度流畅运行,垃圾收集事件也会使它突然下降到 20 帧/秒,导致短暂的抖动。那么,垃圾收集器实际上在做什么,用 33 毫秒来运行呢?

有时候很难说。但是最大的垃圾收集高峰通常是由浏览器清除图形渲染所需的临时数据引起的。最好的解决方案是尽可能使用 Canvas 或 WebGL 来渲染你的精灵,因为它们比 DOM 渲染更节省资源。如果你不认为你有一个渲染瓶颈,你有任何循环,递归函数,或排序算法可能会导致它吗?你正在使用一个进行大量密集计算的物理库吗?如果你已经做了你认为你能做的所有优化,而你的游戏仍然是 janky,考虑降低你游戏的帧速率。

Image 现代垃圾收集器学习如何在游戏运行时管理游戏内存。因此,在最初的几秒钟内,您可能会看到许多内存峰值,然后随着游戏的继续运行,这些峰值会越来越少。

设置游戏的帧速率

如果你将游戏的帧速率设置在 30 到 50 fps 之间,你将赢得一点不错的开销,而你的游戏看起来仍然运行流畅。30 fps 是你应该考虑的最低速度。

以下是设置帧速率的方法:

  1. 决定你的速率:12 fps,30 fps,40 fps,或者任何你想使用的速率。
  2. 算出帧时长。每帧之间应该间隔多少毫秒?这将是 1000 毫秒除以帧速率。例如,如果您的帧速率为 30 fps,则每帧的持续时间应为 33 毫秒。
  3. 仅当自上一帧以来经过的时间与帧持续时间匹配时,才更新该帧。
  4. 将下一帧的更新时间设置为当前时间加上帧持续时间。

下面是一些代码的例子,这些代码可以让游戏以每秒 30 帧的速度运行:

//Set the frame rate and find the frame duration in milliseconds
let fps = 30,
    start = 0,
    frameDuration = 1000 / fps;

//Start the game loop
gameLoop();

function gameLoop(timestamp) {
  requestAnimationFrame(gameLoop);
  if (timestamp >= start) {

    //update the game logic
    update();

    //Render the sprites
    render(canvas);

    //Reset the frame start time
    start = timestamp + frameDuration;
  }
}

这段代码并不能保证你的游戏运行速度不会低于 30fps,只是保证不会超过 30 fps。代码将帧速率钳位在上限 30 fps。如果它运行得更慢,你可能在你的游戏代码中的某个地方有更大的问题,无论如何调整帧速率都不可能解决。这时就该深入代码,查找内存泄漏或进行一些真正激进的优化了。

像这样固定帧速率还有另一个优势:它让你的游戏在所有设备上以一致的速度运行。requestAnimationFrame方法与显示器的刷新率同步,通常是 60Hz——但并不总是如此。许多较新的显示器和设备屏幕有 120Hz 的刷新率,这使得requestAnimationFrame以双倍的速度运行:每秒 120 帧。通过设置帧率,可以保证你的游戏在任何平台上运行都不会太快。

固定更新时间,可变渲染时间

还有一个更进一步的方法来优化游戏循环。不是以相同的帧速率运行updaterender函数,而是以不同的速率运行它们。例如,您可以以固定的每秒 30 帧的速度运行update游戏逻辑,并让render功能以每个用户系统能够运行的最大速度运行——60 fps、120 fps 或更高。为此,你的精灵需要两个位置:一个由游戏逻辑设置,另一个由渲染器设置。但是您还想消除由系统处理开销引起的帧速率波动,这样动画就不会抖动。在一些简单数学的帮助下,你可以使渲染的精灵的位置与游戏逻辑保持紧密同步,即使它们在不同的时间被调用,即使在帧速率上可能有小的停顿。这是一种叫做的技术,固定时间步长,可变渲染。在一个完美的世界里,它会给你最大的游戏逻辑计算空间。而且只要你的游戏逻辑帧率小于你的渲染帧率,你的精灵动画看起来就会很流畅,没有 jank。

它是如何工作的?假设您决定以 15 fps 的速度更新游戏逻辑,以 60 fps 的速度更新渲染逻辑。你运行一个普通的requestAnimationFrame循环,它以大约 60 fps 的速度运行。每一帧都调用render函数,但是直到最后一帧等于 15 fps 时才更新游戏逻辑。理想情况下,您最终会得到对updaterender的调用,如下所示:

update
render
render
render
render
update
render
render
render
render
update

但是由于不可避免的现实世界的简洁性,一些渲染调用可能会间歇性地下降到 30 fps 或更低。这意味着对updaterender的调用实际上可能是这样的:

update
render
render
update
render
update
render
render
render
update

你可以看到update仍然以固定的速率被调用,但是render是波动的。如果您在两次更新调用之间遇到滞后的render调用,会发生什么?

update
render
update

除非我们采用一种叫做插值的技术,它在两次更新调用之间平均出一个精灵的渲染位置,否则动画看起来仍然很滑稽。插值消除帧速率中的不一致,这样即使在低帧速率下你也可以有非常平滑的精灵动画。让我们来看看它是如何工作的。

编写代码

这是实现所有这些概念的新游戏循环。注意,代码调用了一个名为renderWithInterpolation 的新函数来呈现精灵。我将在前面解释这是如何工作的。

let fps = 30,
    previous = 0,
    frameDuration = 1000 / fps,
    lag = 0;

function gameLoop(timestamp) {
  requestAnimationFrame(gameLoop);

  //Calculate the time that has elapsed since the last frame
  if (!timestamp) timestamp = 0;
  let elapsed = timestamp - previous;

  //Optionally correct any unexpected huge gaps in the elapsed time
  if (elapsed > 1000) elapsed = frameDuration;

  //Add the elapsed time to the lag counter
  lag += elapsed;

  //Update the frame if the lag counter is greater than or
  //equal to the frame duration
  while (lag >= frameDuration) {

    //Update the game logic
    update();

    //Reduce the lag counter by the frame duration
    lag -= frameDuration;
  }

  //Calculate the lag offset. This tells us how far
  //we are into the next frame
  let lagOffset = lag / frameDuration;

  //Render the sprites using the `lagOffset` to
  //interpolate the sprites' positions
  renderWithInterpolation(canvas, lagOffset);

  //Capture the current time to be used as the previous
  //time in the next frame
  previous = timestamp;
}

gameLoop首先计算出自前一帧以来已经过去了多长时间:

let elapsed = timestamp - previous;

看一下gameLoop中的最后一行,您会看到previous是对timestamp?? 的当前值的引用:

previous = timestamp;

因此,当下一帧摆动时,previous仍将包含旧值。这就是为什么我们可以在循环开始时使用它来帮助计算自上一帧以来经过的时间。

作为额外的预防措施,代码首先检查elapsed时间是否不大于某个非常大的值,比如 1 秒。如果是这种情况,游戏代码可能已经崩溃,或者用户可能已经切换了浏览器标签:

if (elapsed > 1000) elapsed = frameDuration;

这只是将elapsed时间设置回合理时间的安全网。

名为lag 的变量用于计算帧之间经过的时间:

lag += elapsed;

lag累积的量大于或等于帧速率时,它复位并调用update函数:

while (lag >= frameDuration) {
  update();
  lag -= frameDuration;
}

正是这个while循环充当了一种帧速率不一致的减震器。它会根据需要多次调用update函数,直到lag赶上当前的帧速率。

接下来,代码使用滞后量来计算更新帧速率和渲染帧速率之间的差异。该值保存在名为lagOffset 的变量中:

let lagOffset = lag / frameDuration;

lagOffset值给出了更新帧速率和渲染帧速率之间的比例差。它将是一个介于 0 和 1 之间的正常数字。(这个值通常被称为时间增量,或 dt。)这是我们需要的值,使用新的renderWithInterpolation函数来帮助计算出小精灵应该被渲染的精确位置。

renderWithInterpolation(canvas, lagOffset);

renderWithInterpolation如何在更新帧速率和渲染帧速率之间找到折衷的精灵位置?

插入文字

让我们想象游戏逻辑以每秒 15 帧的速度更新,精灵以每秒 60 帧的速度渲染。这意味着每个更新调用将有四个呈现调用。为了保持动画流畅,渲染器需要猜测在每次更新之间应该在哪些位置显示精灵,并在这些位置绘制精灵。这叫做插值。下面是计算它的基本公式:

renderedPosition = (currentPosition - previousPosition) * lagOffset + previousPosition;

公式的关键部分是精灵的速度是通过从它在当前帧中的位置减去它在上一帧中的位置来动态计算的。怎么知道小精灵之前的位置是什么?在对精灵做任何改变之前,你的第一步就是在每一帧捕捉精灵的当前位置。然后,您可以使用该捕捉位置作为精灵在下一帧中的前一个位置。下面是如何实施这一策略:

function update() {

  sprite.previousX = sprite.x;
  sprite.previousY = sprite.y;

  //Next, change the sprite’s velocity and position
  //as you normally would for the current frame
}

下面是如何在我们的弹跳球示例中实现这一点:

function update() {

  //Capture the ball's previous positions
  ball.previousX = ball.x;
  ball.previousY = ball.y;

  //Move the ball and bounce it off the stage’s edges
  ball.vy += ball.gravity;
  ball.vx *= ball.frictionX;
  ball.x += ball.vx;
  ball.y += ball.vy;

  let collision = contain(ball, stage.localBounds, true);
  if (collision === "bottom") {
    ball.frictionX0.96;
  } else {
    ball.frictionX1;
  }
}

当前帧完成更新后,ball.previousXball.previousY仍将包含前一帧的精灵位置值。

现在你可以使用renderWithInterpolation函数获取所有这些新数据,并用它来插值精灵的位置。renderWithInterpolation函数与你在前一章中学习使用的旧的render函数相同,除了计算精灵渲染位置的新代码。

function renderWithInterpolation(canvas, lagOffset) {

  //...

  //Interpolate the position
  if (sprite.previousX) {
    sprite.renderX = (sprite.x - sprite.previousX) * lagOffset + sprite.previousX;
  } else {
    sprite.renderX = sprite.x;
  }

  if (sprite.previousY) {
    sprite.renderY = (sprite.y - sprite.previousY) * lagOffset + sprite.previousY;
  } else {
    sprite.renderY = sprite.y;
  }

  //Draw the sprite at its interpolated position
  ctx.translate(
    sprite.renderX + (sprite.width * sprite.pivotX),
    sprite.renderY + (sprite.height * sprite.pivotY)
  );

  //...
}

(你会在本书源代码的library/display文件夹中找到完整的renderWithInterpolation函数。)

现在,如果你将游戏的 fps 设置为 15,精灵仍然会以 60 fps 渲染,给你平滑,无抖动的动画和最小的开销。

Image 注意你可以用同样的方法插入其他的精灵属性,比如旋转和阿尔法。

插值多个精灵

如果你的游戏中有很多精灵,你需要在改变他们的当前位置之前捕捉他们所有的先前位置。一个简单的方法是在游戏循环中调用update之前运行一个capturePreviousPositions函数,就像这样:

  while (lag >= frameDuration) {
    capturePreviousPositions(stage);
    update();
    lag -= frameDuration;
  }

capturePreviousPositions函数循环遍历所有精灵及其子精灵,并将它们之前的位置值设置为当前位置。

function capturePreviousPositions(stage) {

  //Loop through all the children of the stage
  stage.children.forEach(sprite => {
    setPreviousPosition(sprite);
  });

  function setPreviousPosition(sprite) {

    //Set the sprite’s `previousX` and `previousY`
    sprite.previousX = sprite.x;
    sprite.previousY = sprite.y;

    //Loop through all the sprite's children
    if (sprite.children && sprite.children.length > 0) {
      sprite.children.forEach(child => {

        //Recursively call `setPosition` on each sprite
        setPreviousPosition(child);
      });
    }
  }
}

代码的其余部分将是相同的。图 5-6 显示了一个 500 个球的例子,游戏逻辑以 15 fps 运行,渲染器以 60 fps 运行。你可以在本章的源文件中找到完整的源代码。

9781430258001_Fig05-06.jpg

图 5-6 。插值允许在低帧速率下平滑动画

你应该这样做吗?

插值的取舍是什么?有几个。

因为插值的位置是基于精灵之前的位置,所以游戏世界的渲染视图总是稍微落后于游戏逻辑。如果逻辑以 30 fps 的速度运行,而渲染器以 60 fps 的速度运行,则时间差大约为 33 毫秒。这并不重要——玩家永远不会注意到它——但你应该意识到它正在发生。

另一个代价是您的代码变得更加复杂。如果你有一个需要大量处理开销的物理密集型游戏,这可能是值得的。但大多数游戏的最大瓶颈通常是渲染,而不是游戏逻辑计算。

还有一个副作用需要注意。如果有一个异常大的系统峰值,并且更新游戏逻辑比渲染精灵需要更长的时间,你会在动画中看到一个跳跃。动画将会停止,过一会儿你会看到精灵出现在动画没有中断的位置。在我们之前看到的不太复杂的游戏循环中,一个大的峰值只会导致动画变慢。这些影响并不一定更好或更坏;你只需要决定你更喜欢哪个。

最后,我应该提供一个关于过早优化的一般警告。我们看到的第一个,也是最基本的游戏循环很简单。这是在同一个循环中以最大帧速率调用updaterender函数的地方:

gameLoop();
function gameLoop() {
  requestAnimationFrame(gameLoop);
  update();
  render(canvas);
}

这可能就是你所需要的。浏览器厂商在不断调整、改变、改进和试验他们如何渲染图形,有时是以不可思议的方式。您需要小心,不要意外地编写一些与浏览器自己的渲染引擎相冲突的代码,最终导致性能比开始时更差。微调游戏循环和精灵渲染是一门艺术,而不是科学,所以保持开放的心态,尝试一切,并记住这一点:如果你的游戏看起来不错,运行良好,那么它就是好的。

摘要

在前一章你学习了如何创建精灵,在这一章你学习了如何让他们移动。您学习了如何通过使用requestAnimationFrame修改游戏循环中精灵的速度来更新其位置。您看到了通过应用加速度、摩擦力和重力来给精灵添加物理属性是多么容易,也看到了如何从边界墙上反弹精灵。我们仔细研究了使用可重用的contain函数模块化您的代码,您了解了一些优化游戏逻辑和渲染性能的详细策略。

现在我们可以制作精灵并移动它们,我们如何与它们互动呢?HTML5 和 JavaScript 具有使用鼠标、键盘和触摸来控制游戏的内置功能,你将在下一章了解这些功能是如何工作的。

六、交互性

欢迎来到最重要的游戏代码倒计时,你需要知道这些代码来增加你的精灵的互动性。现在你知道了如何让精灵移动,你将学习如何让他们与他们生活的游戏世界互动。

您还将学习如何为游戏创建两个最有用的交互对象:可点击的按钮和可拖动的精灵。你需要知道的关于给你的游戏世界增加丰富交互性的一切都在这一章里。

键盘、鼠标和触摸

添加键盘、鼠标或触摸交互的第一步是设置一个事件监听器。事件监听器是内置在浏览器中的一段代码,它“监听”玩家是否按下了键盘上的按键、触摸了屏幕或者移动或点击了鼠标。如果监听器检测到一个事件,它会调用一个事件处理程序,这只是一个在你的游戏中执行某种重要动作的函数。事件处理程序可能会让玩家的角色移动,计算鼠标的速度,或者接受一些输入。事件监听器和处理程序就像是人类世界和游戏世界之间的桥梁。在本章的第一部分,你将学习如何为键盘、触摸和鼠标事件添加事件监听器和处理程序,以及如何使用它们在游戏世界中创造有趣的事情。先说键盘交互性。

Image 注意html 5 规范还包括一个游戏手柄 API,可以让你捕捉游戏控制器按钮的输入。查看dvcs.w3.org处的规格。它像键盘、鼠标和触摸事件一样易于使用。

捕捉键盘事件

如果您想知道玩家是否按下了键盘上的某个键,可以添加一个带有keydown事件的事件监听器。然后编写一个使用 ASCII 码keyCodekeyDownHandler来找出哪个键被按下了。以下是你如何发现一个玩家是否按下了空格键。(空格键的键码是 32,在网上快速搜索“ASCII 键码”会显示一个完整的列表。)

window.addEventListener("keydown", keydownHandler, false)
function keydownHandler(event) {
  if(event.keyCode === 32) {
    console.log("Space key pressed");
  }
}

这段代码运行良好,但是对于大多数游戏来说,你还需要使用一个keyup事件来告诉你这个键是否已经被释放。检查这一点的一个好方法是创建一个属性为isDownisUpkey对象。根据按键的状态,将这些属性设置为truefalse。这将允许您使用if语句检查击键,如下所示:

if (space.isDown) {
  //do this!
}
if (space.isUp) {
  //do this!
}

这里是space 键对象,它将让您编写刚刚显示的if语句:

let space = {
  code: 32,
  isDown: false,
  isUp: true,
  downHandler(event) {
    if(event.keyCode === this.code) {
      this.isDowntrue;
      this.isUpfalse;
    }
  },
  upHandler(event) {
    if(event.keyCode === this.code) {
      this.isUptrue;
      this.isDownfalse;
    }
  }
};

//Add the event listeners and bind them to the space object
window.addEventListener(
  "keydown", space.downHandler.bind(space), false
);
window.addEventListener(
  "keyup", space.upHandler.bind(space), false
);

Image 注意注意如何使用bind方法将监听器连接到space.downHandlerspace.upHandler方法。它确保在空间对象中引用“this”是指空间对象本身,而不是窗口对象。

这样做很好,但是如果我们还想为四个键盘箭头键(向上、向右、向下和向左)添加侦听器呢?我们不想把这 20 行重复的代码写五遍。我们可以做得更好!

让我们创建一个keyboard函数,该函数创建监听特定键盘事件的key对象。我们将能够像这样创建一个新的key对象:

let keyObject = keyboard(asciiKeyCodeNumber);

然后,我们可以将pressrelease方法分配给关键对象,如下所示:

keyObject.pressfunction() {
  //key object pressed
};
keyObject.releasefunction() {
  //key object released
};

我们的关键对象也将有isDownisUp布尔属性,如果你需要的话可以检查它们。

下面是让我们实现的keyboard函数:

export function keyboard(keyCode) {
  let key = {};
  key.code = keyCode;
  key.isDownfalse;
  key.isUptrue;
  key.pressundefined;
  key.releaseundefined;

  //The `downHandler`
  key.downHandlerfunction(event) {
    if (event.keyCode === key.code) {
      if (key.isUp && key.press) key.press();
      key.isDowntrue;
      key.isUpfalse;
    }

    //Prevent the event's default behavior
    //(such as browser window scrolling)
    event.preventDefault();
  };

  //The `upHandler`
  key.upHandlerfunction(event) {
    if (event.keyCode === key.code) {
      if (key.isDown && key.release) key.release();
      key.isDownfalse;
      key.isUptrue;
    }
    event.preventDefault();
  };

  //Attach event listeners
  window.addEventListener(
    "keydown", key.downHandler.bind(key), false
  );
  window.addEventListener(
    "keyup", key.upHandler.bind(key), false
  );

  //Return the `key` object
  return key;
}

你会在本章源代码的library/interactive.js文件中找到这个完整的keyboard函数。

打开本章的源文件中的keyObject.html,查看这段代码的运行示例。按下并释放空格键,你会看到画布上显示“按下”和“释放”(图 6-1 )。

9781430258001_Fig06-01.jpg

图 6-1 。使用文本精灵告诉你一个键是被按下还是被释放

这是通过使用游戏循环来显示文本精灵的字符串内容来实现的。这个精灵的内容是由一个space键对象的pressrelease方法设置的。代码还使用assets对象加载自定义字体,并在准备就绪时调用setup函数。下面是完成这一切的完整代码:

//Import code from the library
import {makeCanvas, text, stage, render} from "../library/display";
import {assets} from "../library/utilities";
import {keyboard} from "../library/interactive";

//Load a custom font
assets.load(["fonts/puzzler.otf"]).then(() => setup());

//Declare any variables shared between functions
let canvas;

function setup() {

  //Make the canvas and initialize the stage
  canvas = makeCanvas(256, 256);
  stage.width = canvas.width;
  stage.height = canvas.height;

  //Make a text sprite
  let message = text("Press space", "16px puzzler", "black", 16, 16);

  //Make a space key object
  let space = keyboard(32);

  //Assign `press` and `release` methods
  space.press() => message.content"pressed";
  space.release() => message.content"released";

  //Use a loop to display any changes to the text sprite's
  //`content` property
  gameLoop();
}

function gameLoop() {
  requestAnimationFrame(gameLoop);
  render(canvas);
}

现在,您已经有了一个通用系统,可以快速创建和监听键盘输入。在本章的后面,你会看到一个如何使用关键物体来控制一个互动游戏角色的例子。

捕获指针事件

既然你已经知道了如何增加键盘的交互性,让我们来看看如何创建交互式鼠标和触摸事件。鼠标和触摸的行为方式相似,所以把它们看作一个叫做“指针”的东西是很有用的在本节中,您将学习如何创建一个统一鼠标和触摸事件的通用pointer对象。然后你将学习如何使用新的pointer对象来增加游戏的交互性。

Image 在撰写本文时,一个名为指针事件的 HTML5 规范正在开发中。如果当你读到这篇文章时,它已经被广泛实现了,这意味着你不再需要分叉你的代码来适应鼠标和触摸;指针事件对两者都适用。而且,非常方便的是,指针事件 API 几乎与鼠标事件 API 完全相同,所以没有什么新东西需要学习。只需在任何鼠标事件代码中将“鼠标”替换为“指针”,就可以了。然后将指针敏感元素的 CSS touch-action 属性设置为“none ”,以禁用浏览器的默认平移和缩放操作。关注这个规范,如果可以的话就使用它(http://www.w3.org/TR/pointerevents/)。

要创建鼠标或触摸事件,请将一个事件侦听器附加到要使其对指针敏感的 HTML 元素,如画布。然后让监听器在事件发生时调用事件处理程序:

canvas.addEventListener("mousedown", downHandler, false);

function downHandler(event) {
  console.log("Pointer pressed down");
}

然而,对于我们游戏开发者来说,有一个小问题。如何检测玩家是否点击了什么东西?点击或点击只是指针非常快速的上下移动。任何超过 200 毫秒的时间都可以被定义为点击或点击。您可以通过比较 down 和 up 事件之间的时间来判断这是否已经发生。如果少于 200 毫秒,你可以假设玩家点击或点击了。下面是解决这个问题的一般方法。

首先,当指针向下时,用Date.now()捕捉当前时间:

function downHandler(event) {
  downTime = Date.now();
}

downTime值现在包含了指针被按下的精确时间,以毫秒为单位。当指针上升时,捕捉新的时间,并计算从downTime开始已经过去了多长时间。如果少于 200 毫秒,那么你知道有一个点击或点击。

function upHandler(event) {
  elapsedTime = Math.abs(downTime - Date.now());
  if (elapsedTime <= 200) {
    console.log("Tap or click!");
  }
}

为了帮助我们管理这一切,让我们创建一个pointer对象。除了点击或点击,指针还应该能够告诉我们它的 xy 位置,以及它当前是向上还是向下。为了获得最大的灵活性,我们还将让用户定义可选的presstaprelease方法,这些方法可以在这些事件发生时运行一些自定义代码。此外,我们将赋予指针centerXcenterYposition属性,这样它的 API 就能很好地反映前一章中精灵的 API。正如你将在本书后面看到的,这是一种便利,它将使我们更容易使用带有碰撞检测功能的指针,你将在第七章中学习使用。

指针还会有一个前瞻性的属性叫做scale 。如果画布在浏览器窗口中被放大或缩小,属性scale将帮助我们调整指针的坐标。对于大多数游戏来说,默认比例值 1 就是你所需要的。但是如果你改变游戏的显示尺寸,你需要按比例修改指针的 xy 坐标。(你会在第十一章中看到这有多有用。)

这里有一个makePointer函数,它创建并返回一个pointer对象,为我们完成所有这些工作。它如何工作的本质细节在注释中,我将在代码清单之后向您展示如何使用它。

export function makePointer(element, scale = 1) {

  let pointer = {
    element: element,
    scale: scale,

    //Private x and y properties
    _x: 0,
    _y: 0,

    //The public x and y properties are divided by the scale. If the
    //HTML element that the pointer is sensitive to (like the canvas)
    //is scaled up or down, you can change the `scale` value to
    //correct the pointer's position values
    get x() {
      return this._xthis.scale;
    },
    get y() {
      return this._ythis.scale;
    },

    //Add `centerX` and `centerY` getters so that we
    //can use the pointer's coordinates with easing
    //and collision functions
    get centerX() {
      return this.x;
    },
    get centerY() {
      return this.y;
    },

    //`position` returns an object with x and y properties that
    //contain the pointer's position
    get position() {
      return {x: this.x, y: this.y};
    },

    //Booleans to track the pointer state
    isDown: false,
    isUp: true,
    tapped: false,

    //Properties to help measure the time between up and down states
    downTime: 0,
    elapsedTime: 0,

    //Optional, user-definable `press`, `release`, and `tap` methods
    press: undefined,
    release: undefined,
    tap: undefined,

    //The pointer's mouse `moveHandler`
    moveHandler(event) {

      //Get the element that's firing the event
      let element = event.target;

      //Find the pointer’s x,y position (for mouse).
      //Subtract the element's top and left offset from the browser window
      this._x = (event.pageX - element.offsetLeft);
      this._y = (event.pageY - element.offsetTop);

      //Prevent the event's default behavior
      event.preventDefault();
    },

    //The pointer's `touchmoveHandler`
    touchmoveHandler(event) {
      let element = event.target;

      //Find the touch point's x,y position
      this._x = (event.targetTouches[0].pageX - element.offsetLeft);
      this._y = (event.targetTouches[0].pageY - element.offsetTop);
      event.preventDefault();
    },

    //The pointer's `downHandler`
    downHandler(event) {

      //Set the down states
      this.isDowntrue;
      this.isUpfalse;
      this.tappedfalse;

      //Capture the current time
      this.downTimeDate.now();

      //Call the `press` method if it's been assigned by the user
      if (this.press) this.press();
      event.preventDefault();
    },

    //The pointer's `touchstartHandler`
    touchstartHandler(event) {
      let element = event.target;

      //Find the touch point's x,y position
      this._x = event.targetTouches[0].pageX - element.offsetLeft;
      this._y = event.targetTouches[0].pageY - element.offsetTop;

      //Set the down states
      this.isDowntrue;
      this.isUpfalse;
      this.tappedfalse;

      //Capture the current time
      this.downTimeDate.now();

      //Call the `press` method if it's been assigned by the user
      if (this.press) this.press();
      event.preventDefault();
    },

    //The pointer's `upHandler`
    upHandler(event) {

      //Figure out how much time the pointer has been down
      this.elapsedTimeMath.abs(this.downTimeDate.now());

      //If it's less than 200 milliseconds, it must be a tap or click
      if (this.elapsedTime <= 200 && this.tapped === false) {
        this.tappedtrue;

        //Call the `tap` method if it's been assigned
        if (this.tap) this.tap();
      }
      this.isUptrue;
      this.isDownfalse;

      //Call the `release` method if it's been assigned by the user
      if (this.release) this.release();
      event.preventDefault();
    },

    //The pointer's `touchendHandler`
    touchendHandler(event) {

      //Figure out how much time the pointer has been down
      this.elapsedTimeMath.abs(this.downTimeDate.now());

      //If it's less than 200 milliseconds, it must be a tap or click
      if (this.elapsedTime <= 200 && this.tapped === false) {
        this.tappedtrue;

        //Call the `tap` method if it's been assigned by the user
        if (this.tap) this.tap();
      }
      this.isUptrue;
      this.isDownfalse;

      //Call the `release` method if it's been assigned by the user
      if (this.release) this.release();
      event.preventDefault();
    },

  //Bind the events to the handlers’
  //Mouse events
  element.addEventListener(
    "mousemove", pointer.moveHandler.bind(pointer), false
  );
  element.addEventListener(
    "mousedown", pointer.downHandler.bind(pointer), false
  );

  //Add the `mouseup` event to the `window` to
  //catch a mouse button release outside of the canvas area
  window.addEventListener(
    "mouseup", pointer.upHandler.bind(pointer), false
  );

  //Touch events

  element.addEventListener(
    "touchmove", pointer.touchmoveHandler.bind(pointer), false
  );
  element.addEventListener(
    "touchstart", pointer.touchstartHandler.bind(pointer), false
  );

  //Add the `touchend` event to the `window` object to
  //catch a mouse button release outside the canvas area
  window.addEventListener(
    "touchend", pointer.touchendHandler.bind(pointer), false
  );

  //Disable the default pan and zoom actions on the `canvas`
  element.style.touchAction"none";

  //Return the pointer
  return pointer;
}

你会在源代码的library/display文件夹中找到完整的makePointer函数。下面是如何使用这个函数来创建和初始化一个pointer对象:

pointer = makePointer(canvas);

打开并运行pointer.html程序,查看如何使用它的工作示例;样品运行如图 6-2 中的所示。当您在画布上移动、点击、点按、按下或释放指针时,HTML 文本会显示指针的状态。这是完整的程序,包括 HTML 代码,这样你就可以看到所有的部分是如何组合在一起的。

9781430258001_Fig06-02.jpg

图 6-2 。鼠标和触摸的通用指针对象

<!doctype html>
<meta charset="utf-8">
<title>Pointer</title>
<p id="output"></p>
<script type="module">

//Import code from the library
import {makeCanvas, stage, render} from "../library/display";
import {assets} from "../library/utilities";
import {makePointer} from "../library/interactive";

//Make the canvas and initialize the stage
let canvas = makeCanvas(256, 256);
stage.width = canvas.width;
stage.height = canvas.height;

//Get a reference to the output <p> tag
let output = document.querySelector("p");

//Make the pointer
let pointer = makePointer(canvas);

//Add a custom `press` method
pointer.press = () => console.log("The pointer was pressed");

//Add a custom `release` method
pointer.release = () => console.log("The pointer was released");

//Add a custom `tap` method
pointer.tap = () => console.log("The pointer was tapped");

//Use a loop to display changes to the output text
gameLoop();

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Display the pointer properties in the
  //HTML <p> tag called `output`
  output.innerHTML
    = `Pointer properties: <br>
    pointer.x: ${pointer.x} <br>
    pointer.y: ${pointer.y} <br>
    pointer.isDown: ${pointer.isDown} <br>
    pointer.isUp: ${pointer.isUp} <br>
    pointer.tapped: ${pointer.tapped}`;

}
</script>

Image 注意这个例子也展示了你需要编写的最少的 HTML5 代码,并且仍然有一个有效的 HTML 文档。短小精悍!<html><body>标签是可选的。您可能仍然需要一个<body>标签作为添加和删除 HTML 元素的钩子,但是如果您忽略它,HTML5 规范会认为它是隐含的。

注意可选的pressreleasetap功能是如何定义的:

pointer.press() => console.log("The pointer was pressed");
pointer.release() => console.log("The pointer was released");
pointer.tap() => console.log("The pointer was tapped");

这些都是方便的方法,允许您在发生任何这些操作时注入一些自定义代码。你将在前面的章节中看到如何使用它们。

我们现在有了键盘、鼠标和触摸交互功能——太酷了!现在我们知道如何与游戏世界互动,让我们开始做吧!

互动运动

让我们把你在前一章学到的关于如何移动精灵的知识和你在本章学到的关于交互性的知识结合起来。在下一节中,您将学习一些最有用的代码片段来让精灵移动。这些是视频游戏历史上的经典技术,你会发现它们在你的游戏中有无数的用途。您将在源代码的library/utilities文件夹中找到我们在本节中使用的所有自定义函数。

寻找精灵之间的距离

你的游戏经常需要计算精灵之间的像素数。这对于找出精灵是否碰撞,或者接近碰撞是有用的。如果你的精灵有centerXcenterY属性,你可以使用下面的函数计算出它们之间的距离(s1代表“精灵 1”,s2代表“精灵 2”):

function distance(s1, s2) {
  let vx = s2.centerX - s1.centerX,
      vy = s2.centerY - s1.centerY;
  return Math.sqrt(vx * vx + vy * vy);
}

vxvy值描述了从第一个子画面的中心到第二个子画面的中心的直线。(“v”代表“矢量”你可以把向量想象成任意两个 x,y 位置之间的一条线。关于矢量你需要知道的一切见附录)。Math.sqrt用于应用毕达哥拉斯定理,它告诉你这条线有多长,以像素为单位。

在本章的源文件中,你会发现一个名为distance.html的示例程序,它展示了这个函数的运行。有两个圆形精灵,用一条线将它们连接起来。当您移动指针时,一个文本精灵会告诉您两个圆圈之间的像素距离。图 6-3 说明了这一点。

9781430258001_Fig06-03.jpg

图 6-3 。找出精灵之间的距离

让我们看看程序如何使用这个distance函数,并借此机会学习更多关于如何使用精灵的知识。下面是distance.html程序的完整 JavaScript 代码:

import {makeCanvas, text, circle, line, stage, render} from "../library/display";
import {assets, distance} from "../library/utilities";
import {makePointer} from "../library/interactive";

//Load a custom font
assets.load(["fonts/puzzler.otf"]).then(() => setup());

//Declare any variables shared between functions
let canvas, c1, c2, message, connection, pointer;

function setup() {

  //Make the canvas and initialize the stage
  canvas = makeCanvas(256, 256);
  stage.width = canvas.width;
  stage.height = canvas.height;

  //Make a text sprite
  message = text("", "12px puzzler", "black", 8, 8);

  //Create a circle sprite offset by 32 pixels to the
  //left and top of the stage
  c1 = circle(32, "gray");
  stage.putCenter(c1, -32, -32);

  //Create a circle sprite offset by 32 pixels to the
  //right and bottom of the stage
  c2 = circle(32, "gray");
  stage.putCenter(c2, 32, 32);

  //Create a line between the centers of the circles
  connection = line(
    "black", 2, c1.centerX, c1.centerY, c2.centerX, c2.centerY
  );

  //Make the pointer
  pointer = makePointer(canvas);

  //Use a loop to update the sprites' positions
  gameLoop();
}

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Keep the center of c2 aligned with the
  //pointer's position
  c2.x = pointer.x - c2.halfWidth;
  c2.y = pointer.y - c2.halfHeight;

  //Draw the connecting line between the circles
  connection.ax = c1.centerX;
  connection.ay = c1.centerY;
  connection.bx = c2.centerX;
  connection.by = c2.centerY;

  //Use the imported `distance` function to figure
  //out the distance between the circles
  let distanceBetweenCircles = distance(c1, c2);

  //Use the message text sprite to display the distance.
  //Use `Math.floor` to truncate the decimal values
  message.contentMath.floor(distanceBetweenCircles);

  //Render the canvas
  render(canvas);
}

在这个程序中有相当多的显示元素在一起工作:圆形、线条和文本精灵,还有舞台。游戏循环也在动态计算圆圈之间的距离,并在每一帧中不断重画连接线。

put方法定位精灵

这也是你第一次看到神秘的“put”sprite 方法之一的运行(我们在第四章中为 sprite 添加了“put”方法)。所有显示对象(精灵和舞台)都有称为putCenterputTopputRightputBottomputLeft的方法,您可以使用这些方法来方便地对齐和定位精灵。在这个示例程序中,stage对象使用putCenter来定位舞台内部的圆:

c1 = circle(32, "gray");
stage.putCenter(c1, -32, -32);

c2 = circle(32, "gray");
stage.putCenter(c2, 32, 32);

第一个参数是应该居中的 sprite:c1或者c2。第二个和第三个参数定义了精灵应该在 xy 轴上从中心偏移多少。这段代码将c1放在舞台的左上角,将c2放在右下角。你会发现你经常需要在游戏中做这种定位,而“put”方法让你不必编写大量繁琐的定位代码。

缓和

在前面的示例中,圆精确地跟随指针的位置。你可以使用一个叫做放松的标准公式,让圆圈移动得更优雅一点。“缓动”会使精灵在目标点上轻轻就位。这里有一个followEase函数,你可以用它让一个精灵跟随另一个精灵。

function followEase(follower, leader, speed) {

  //Figure out the distance between the sprites
  let vx = leader.centerX - follower.centerX,
      vy = leader.centerY - follower.centerY,
      distance = Math.sqrt(vx * vx + vy * vy);

  //Move the follower if it's more than 1 pixel
  //away from the leader
  if (distance >= 1) {
    follower.x += vx * speed;
    follower.y += vy * speed;
  }
}

该函数计算精灵之间的距离。如果它们之间的距离超过 1 个像素,代码会以一定的速度移动跟随器,该速度会随着接近引导器而成比例降低。介于 0.1 和 0.3 之间的速度值是一个很好的起点(较高的数字使精灵移动得更快)。跟随者将逐渐减速,直到越过领头者的位置。打开本章源文件中的easing.html文件,看看如何使用这个函数让精灵轻松地跟随鼠标。图 6-4 展示了你将会看到的东西。

9781430258001_Fig06-04.jpg

图 6-4 。轻松移动精灵

程序代码与前面的例子非常相似。游戏循环中使用的followEase函数是这样的:

function gameLoop() {
  requestAnimationFrame(gameLoop);
  followEase(c1, pointer, 0.1);
  render(canvas);
}

让一个精灵跟随另一个精灵是游戏的常见要求,所以让我们看看另一种方法。

匀速跟随

前几节中的缓动公式使精灵以可变速度移动,该速度与到其目的地的距离成比例。只要对公式稍加修改,你就可以使它以固定的恒定速度运动。这里有一个follow函数实现了这一点:

function followConstant(follower, leader, speed) {

  //Figure out the distance between the sprites
  let vx = leader.centerX - follower.centerX,
      vy = leader.centerY - follower.centerY,
      distance = Math.sqrt(vx * vx + vy * vy);

  //Move the follower if it's more than 1 move
  //away from the leader
  if (distance >= speed) {
    follower.x += (vx / distance) * speed;
    follower.y += (vy / distance) * speed;
  }
}

speed值应该是您希望跟随器移动的每帧像素数;在下面的示例代码中,每帧 3 个像素:

function gameLoop() {
  requestAnimationFrame(gameLoop);
  followConstant(c1, pointer, 3);
  render(canvas);
}

这对于创造一个追逐玩家的敌人 AI 精灵来说是一个非常有用的功能。

向某物旋转

您可以使用以下函数找到两个精灵之间的旋转角度:

function angle(s1, s2) {
  return Math.atan2(
    s2.centerY - s1.centerY,
    s2.centerX - s1.centerX
  );
}

它返回以弧度为单位的旋转角度。您可以用下面的语句将它应用到 sprite 的rotation属性,使 sprite 向另一个 sprite 或指针旋转:

box.rotationangle(box, pointer);

你可以在本章的源文件中的rotateTowards.html文件中看到一个这样的例子;输出如图 6-5 中的所示。框向指针旋转,一条 32 像素长的红线从框的中心向旋转方向延伸。

9781430258001_Fig06-05.jpg

图 6-5 。向指针旋转精灵

只要有点想象力,你可能会意识到红线框实际上是一个主要的视频游戏组件:一个旋转炮塔。是怎么做出来的?这是一个很好的例子,展示了如何使用父/子层次结构构建一个简单的复合 sprite。turret(红线)是box的子节点。代码如下:

//Make a square and center it in the stage
box = rectangle(32, 32, "gray", "black", 2);
stage.putCenter(box);

//Make a turret by drawing a red, 4 pixel wide
//line that's 32 pixels long
turret = line("red", 4, 0, 0, 32, 0);

//Add the line as a child of the box and place its
//start point at the box's center
box.addChild(turret);
turret.x16;
turret.y16;

现在当游戏循环使用angle功能使boxpointer旋转时,turret会自动跟随box的旋转。

function gameLoop() {
  requestAnimationFrame(gameLoop);
  box.rotationangle(box, pointer);
  render(canvas);
}

这就是我们在第四章中所做的额外工作的回报。它让我们不必写一些复杂的数学来手动保持转台的旋转与盒子对齐。

围绕精灵旋转

使用下面的rotateSprite函数让一个精灵围绕另一个精灵旋转:

function rotateSprite(rotatingSprite, centerSprite, distance, angle) {
  rotatingSprite.x
    = centerSprite.centerX - rotatingSprite.parent.x
    + (distance * Math.cos(angle))
    - rotatingSprite.halfWidth;

  rotatingSprite.y
    = centerSprite.centerY - rotatingSprite.parent.y
    + (distance * Math.sin(angle))
    - rotatingSprite.halfWidth;
}

下面介绍如何用它让一个球绕着一个盒子旋转,如图图 6-6 所示。

9781430258001_Fig06-06.jpg

图 6-6 。围绕另一个精灵旋转一个精灵

//Create a box and position it
box = rectangle(32, 32, "gray");
stage.putCenter(box, 32, -48);

//Create a circle sprite offset by 32 pixels to the
//left of the box
ball = circle(32, "gray");
box.putLeft(ball, -32);

//Add an `angle` property to the ball that we'll use to
//help make the ball rotate around the box
ball.angle0;

//Start the game loop
gameLoop();

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Update the ball’s rotation angle
  ball.angle += 0.05;

  //Use the ball’s `angle` value to make it rotate around the
  //box at a distance of 48 pixels from the box’s center
  rotateSprite(ball, box, 48, ball.angle);
}

围绕一个点旋转

有时候,能够围绕另一个点旋转空间中的一个点是很有用的。例如,你可以通过让一条线的两端围绕空间中不可见的点旋转来创造一种“摇摆线”的效果,如图 6-7 所示。像这样把不稳定的线条连接在一起,你可以创造出不稳定的形状。

9781430258001_Fig06-07.jpg

图 6-7 。使点围绕其他点旋转。您可以使用一个名为rotatePoint的函数来帮助创建这种效果

export function rotatePoint(pointX, pointY, distanceX, distanceY angle) {
  let point = {};
  point.x = pointX + Math.cos(angle) * distanceX;
  point.y = pointY + Math.sin(angle) * distanceY;
  return point;
}

rotatePoint函数返回一个带有代表旋转轴的 xy 值的point对象。distanceXdistanceY参数定义了从旋转中心到在空间中被描绘的假想圆的边缘的半径。如果distanceXdistanceY具有相同的值,该功能将描绘一个圆。如果给它们不同的值,该函数将跟踪一个椭圆。您可以使用rotatePoint返回的point对象使任何其他 x/y 点围绕该轴旋转。这里有一些代码使用rotatePoint来创建如图图 6-7 所示的摇摆线条效果。

movingLine = line("black", 4, 64, 160, 192, 208);

//We're going to make the line's start and end points
//rotate in space. The line will need two new angle properties
//to help us do this. Both are initialized to 0
movingLine.angleA0;
movingLine.angleB0;

//Start the game loop
gameLoop();

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Make the line's `ax` and `ay` points rotate clockwise around
  //point 64, 160\. `rotatePoint` returns an
  //object with `x` and `y` properties
  //containing the point's new rotated position
  movingLine.angleA += 0.02;
  let rotatingA = rotatePoint(64, 160, 20, 20, movingLine.angleA);
  movingLine.ax = rotatingA.x;
  movingLine.ay = rotatingA.y;

  //Make the line's `bx` and `by` point rotate counter-
  //clockwise around point 192, 208
  movingLine.angleB -= 0.03;
  let rotatingB = rotatePoint(192, 208, 20, 20, movingLine.angleB);
  movingLine.bx = rotatingB.x;
  movingLine.by = rotatingB.y;

  //Render the canvas
  render(canvas);
}

效果就像曲轴转动一个看不见的轮子。看起来很有趣,甚至有点吓人,所以请确保查看本章源代码中的rotateAround.html文件中的这个代码的工作示例。

向旋转方向移动

如果你知道精灵的角度,你可以让它朝它所指的方向移动。运行moveTowards.html文件,你会发现一个可以使用箭头键移动的精灵的例子。向左和向右旋转精灵,向上使它向它所指的方向移动。释放向上箭头键,它会慢慢停下来。图 6-8 显示了您将看到的内容。

9781430258001_Fig06-08.jpg

图 6-8 。使用箭头键旋转一个精灵,并向它所指的方向移动它

让我们再次发挥我们的想象力,把标有红线的移动箱想象成一辆“坦克”现在让我们重新思考一下我们在前面的例子中构建这个对象的方式。把盒子和炮塔放在一个叫做tank 的小组里可能是有意义的。我们可以使用我们在第四章的中创建的group函数来完成这项工作;方法如下:

//Make the box and turret
let box = rectangle(32, 32, "gray");
let turret = line("red", 4, 0, 0, 32, 0);
turret.x16;
turret.y16;

//Group them together as a compound sprite called `tank`
tank = group(box, turret);
stage.putCenter(tank);

tank组现在是boxturret精灵的父容器。这只是你可以用来制作复合精灵的另一种方法。tank组现在是你控制的主要精灵。给它添加一些属性来帮助它移动:

//Add some physics properties
tank.vx0;
tank.vy0;
tank.accelerationX0.2;
tank.accelerationY0.2;
tank.frictionX0.96;
tank.frictionY0.96;

//The speed at which the tank should rotate,
//initialized to 0
tank.rotationSpeed0;

//Whether or not the tank should move forward
tank.moveForwardfalse;

rotationSpeed决定坦克向左或向右旋转的速度,而moveForward只是一个布尔值,告诉我们游戏循环中的代码是否应该让坦克移动。这两个属性都由箭头键设置:右键和左键使坦克旋转,向上键使它向前移动。下面是使用我们在本章前面编写的keyboard函数对箭头键进行编程的代码:

//Make key objects
let leftArrow = keyboard(37),
    rightArrow = keyboard(39),
    upArrow = keyboard(38);

//Set the tank's `rotationSpeed` to -0.1 (to rotate left) if the
//left arrow key is being pressed
leftArrow.press() => tank.rotationSpeed = -0.1;

//If the left arrow key is released and the right arrow
//key isn't being pressed down, set the `rotationSpeed` to 0
leftArrow.release() => {
  if (!rightArrow.isDown) tank.rotationSpeed0;
}

//Do the same for the right arrow key, but set
//the `rotationSpeed` to 0.1 (to rotate right)
rightArrow.press() => tank.rotationSpeed0.1;
rightArrow.release() => {
  if (!leftArrow.isDown) tank.rotationSpeed0;
}

//Set `tank.moveForward` to `true` if the up arrow key is
//pressed, and set it to `false` if it's released
upArrow.press() => tank.moveForwardtrue;
upArrow.release() => tank.moveForwardfalse;

我们现在可以利用这些特性,以及我们在前一章中学到的关于加速度和摩擦力的知识,使坦克沿着它旋转的方向运动。下面是游戏循环中的代码:

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Use the `rotationSpeed` to set the tank's rotation
  tank.rotation += tank.rotationSpeed;

  //If `tank.moveForward` is `true`, use acceleration with a
  //bit of basic trigonometry to make the tank move in the
  //direction of its rotation
  if (tank.moveForward) {
    tank.vx += tank.accelerationXMath.cos(tank.rotation);
    tank.vy += tank.accelerationYMath.sin(tank.rotation);
  }

  //If `tank.moveForward` is `false`, use
  //friction to slow the tank down
  else {
    tank.vx *= tank.frictionX;
    tank.vy *= tank.frictionY;
  }

  //Apply the tank's velocity to its position to make the tank move
  tank.x += tank.vx;
  tank.y += tank.vy;

  //Display the tank's angle of rotation
  message.content = tank.rotation;

  //Render the canvas
  render(canvas);
}

让坦克沿其旋转方向移动的秘密是这两行代码:

tank.vx += tank.accelerationXMath.cos(tank.rotation);
tank.vy += tank.accelerationYMath.sin(tank.rotation);

这只是一点基本的三角学,把坦克的加速度和它的旋转结合起来。当应用到坦克的 x,y 位置时,产生的vxvy值将使坦克向正确的方向移动。

tank.x += tank.vx;
tank.y += tank.vy;

你可以使用这个基本系统作为许多种旋转视频游戏对象的起点,如宇宙飞船或汽车。在这个例子中,我们的坦克实际上更像一艘宇宙飞船,而不是一辆真正的坦克。这是因为当它向左或向右旋转时,它会继续向前漂移,而不是像有轮子的车辆那样随着旋转改变方向。我们将很快解决这个问题。首先,让我们赋予坦克发射子弹的能力。

发射子弹

只需多一点代码,你就可以让你的精灵发射子弹。运行bullets.html按空格键向坦克指向的方向发射子弹,如图图 6-9 所示。

9781430258001_Fig06-09.jpg

图 6-9 。向四面八方发射子弹

这段代码的一个特性是当子弹击中画布边缘时会被移除,一个文本精灵会告诉你子弹击中了哪个边缘。

制作子弹的第一步是创建一个数组来存储你将要制作的新子弹精灵:

let bullets = [];

接下来,您需要一个shoot函数,它允许您使用一些参数创建项目符号:

shoot(
  tank,           //The shooter
  tank.rotation,  //The angle at which to shoot
  32,             //The bullet's offset from the center
  7,              //The bullet's speed (pixels per frame)
  bullets,        //The array used to store the bullets

  //A function that returns the sprite that should
  //be used to make each bullet
  () => circle(8, "red")
);

shoot功能分配发射子弹所需的所有参数。最重要的是最后一条:

() => circle(8, "red")

这是一个函数,它创建并返回你想用作项目符号的精灵类型。在这种情况下,它是一个直径为 8 个像素的红色圆圈。你可以使用到目前为止在本书中学到的任何精灵创建函数,或者创建你自己的自定义函数。

下面是使用这些参数创建一个新的bullet精灵并将其添加到bullets数组的shoot函数定义。

function shoot(
  shooter, angle, offsetFromCenter,
  bulletSpeed, bulletArray, bulletSprite
) {

  //Make a new sprite using the user-supplied `bulletSprite` function
  let bullet = bulletSprite();

  //Set the bullet's start point
  bullet.x
    = shooter.centerX - bullet.halfWidth
    + (offsetFromCenter * Math.cos(angle));
  bullet.y
    = shooter.centerY - bullet.halfHeight
    + (offsetFromCenter * Math.sin(angle));

  //Set the bullet's velocity
  bullet.vxMath.cos(angle) * bulletSpeed;
  bullet.vyMath.sin(angle) * bulletSpeed;

  //Push the bullet into the `bulletArray`
  bulletArray.push(bullet);
}

你可以看到shoot函数正在使用射手精灵的旋转角度来计算子弹的起点和速度。shoot函数被设计成灵活和通用的,所以你可以在各种不同的游戏项目中使用它。

你的游戏如何让玩家发射子弹?在这个例子中,每按一次空格键,子弹就发射一次,而且只有一次。释放空格键重置子弹发射机制,以便您可以在下一次按下它时再次发射。这是一个使用我们在本章前面创建的keyboard函数来设置的简单机制。首先,创建一个space关键对象:

let space = keyboard(32);

然后给space键的press方法赋值,让它调用shoot函数发射子弹:

space.press() => {
  shoot(
    tank, tank.rotation, 32, 7, bullets,
    () => circle(8, "red")
  );
};

既然我们能够发射子弹,我们需要在画布上移动它们。我们还需要检查它们的屏幕边界,这样如果它们碰到画布的边缘,我们就可以移除它们(实际上是根父对象stage)。这必须发生在游戏循环中:

function gameLoop() {
  requestAnimationFrame(gameLoop);
  //Move the bullets here...
}

移动项目符号并检查与stage边界冲突的代码也发生在循环内部。我们将使用一个filter循环,这样如果一颗子弹击中舞台的边缘,它将从bullets数组中移除。我们还将使用一个名为outsideBounds 的自定义函数,它将告诉我们子弹是否穿过了舞台的边界,以及子弹击中了边界的哪一侧。下面是完成这一切的filter循环:

bullets = bullets.filter(bullet => {

  //Move the bullet
  bullet.x += bullet.vx;
  bullet.y += bullet.vy;

  //Check for a collision with the stage boundary
  let collision = outsideBounds(bullet, stage.localBounds);

  //If there's a collision, display the side that the collision
  //happened on, remove the bullet sprite, and filter it out of
  //the `bullets` array
  if(collision) {

    //Display the boundary side that the bullet crossed
    message.content"The bullet hit the " + collision;

    //The `remove` function will remove a sprite from its parent
    //to make it disappear
    remove(bullet);

    //Remove the bullet from the `bullets` array
    return false;
  }

  //If the bullet hasn't hit the edge of the stage,
  //keep it in the `bullets` array
  return true;
});

outsideBounds函数返回一个collision变量,其值为"top""right""bottom""left",这取决于子弹穿过边界的哪一侧。如果没有碰撞,它将返回undefinedoutsideBounds与你在前一章学到的contain函数非常相似——只是简单得多。它检查 sprite 的整个形状是否已经越过了包含边界,并由您来决定如何处理这些信息。

function outsideBounds(sprite, bounds, extra = undefined){

  let x = bounds.x,
      y = bounds.y,
      width = bounds.width,
      height = bounds.height;

  //The `collision` object is used to store which
  //side of the containing rectangle the sprite hits
  let collision;

  //Left
  if (sprite.x < x - sprite.width) {
    collision = "left";
  }
  //Top
  if (sprite.y < y - sprite.height) {
    collision = "top";
  }
  //Right
  if (sprite.x > width) {
    collision = "right";
  }
  //Bottom
  if (sprite.y > height) {
    collision = "bottom";
  }

  //The `extra` function runs if there was a collision
  //and `extra` has been defined
  if (collision && extra) extra(collision);

  //Return the `collision` object
  return collision;
};

你会在本书源文件的library/display文件夹中找到shootoutsideBounds函数。

移动水箱

在这个新的例子中,坦克的行为就像一个真正的轮式车辆。改变其旋转也会改变其向前运动的方向,如图图 6-10 所示。这不是一个很难达到的效果;我们只需要稍微重新思考如何计算和应用坦克的物理属性。

9781430258001_Fig06-10.jpg

图 6-10 。储罐沿其旋转方向移动

首先,给坦克新的speedfriction值。我们将使用speed来帮助决定坦克应该跑多快,使用friction来帮助我们减速。(这个新的friction值取代了前一个例子中的frictionXfrictionY。)

tank.friction0.96;
tank.speed0;

以下是坦克新物理属性的所有初始值:

tank.vx0;
tank.vy0;
tank.accelerationX0.1;
tank.accelerationY0.1;
tank.rotationSpeed0;
tank.moveForwardfalse;
tank.friction0.96;
tank.speed0;

游戏循环使用speedfriction来计算坦克应该跑多快,让坦克移动。坦克的加速度是通过将其速度应用于其旋转来计算的:

//Use the `rotationSpeed` to set the tank's rotation
tank.rotation += tank.rotationSpeed;

//If `tank.moveForward` is `true`, increase the speed
if (tank.moveForward) {
  tank.speed += 0.1;
}

//If `tank.moveForward` is `false`, use
//friction to slow the tank down
else {
  tank.speed *= tank.friction;
}

//Use the `speed` value to figure out the acceleration in the
//direction of the tank’s rotation
tank.accelerationX = tank.speedMath.cos(tank.rotation);
tank.accelerationY = tank.speedMath.sin(tank.rotation);

//Apply the acceleration to the tank's velocity
tank.vx = tank.accelerationX;
tank.vy = tank.accelerationY;

//Apply the tank's velocity to its position to make the tank move
tank.x += tank.vx;
tank.y += tank.vy;

对代码的这一调整消除了第一个例子中飞船风格的漂移效应。你可以用它作为移动任何轮式车辆的基础。

交互式鼠标和触摸事件

到目前为止,在这一章中,你已经学会了如何通过给键盘键分配pressrelease方法来让精灵移动,以及如何让精灵跟随指针的位置。但是如果你想以一种更复杂的方式和精灵互动呢?你的游戏精灵可能需要对点击、触摸或拖动做出反应,你可能想要制作按钮来为你的游戏构建 UI。

在本章的后半部分,你将学习如何做到这一点。我们将建立一个通用的框架来制作各种各样的交互式精灵,然后定制它来制作按钮和拖放精灵。让我们来看看是如何做到的!

找出指针是否接触到一个精灵

最重要的第一步是我们需要某种方法来判断指针是否接触到了一个精灵。我们可以通过向名为hitTestSpritepointer对象添加一个方法来做到这一点。它的工作是检查指针的 x/y 位置是否在精灵的区域内。我们将把hitTestSprite添加到我们在本章开始时使用makePointer函数创建的同一个pointer对象中:

function makePointer(element, scale = 1) {
  let pointer = {

    //... the pointer's previous properties and methods...

    hitTestSprite(sprite) {
      //The new code goes here
    }
  };

  //... the rest of the makePointer function...

  return pointer;
}

hitTestSprite是做什么的?它将指针的位置与 sprite 定义的区域进行比较。如果指针在该区域内,该方法返回true;如果不是,它返回false。作为一个额外的特性,hitTestSprite对圆形精灵和矩形精灵都有效。(你会记得在第四章中我们所有的精灵都有一个叫做circular的布尔属性,你可以用它来找到精灵的大致形状。)

hitTestSprite(sprite) {

  //The `hit` variable will become `true` if the pointer is
  //touching the sprite and remain `false` if it isn't
  let hit = false;

  //Is the sprite rectangular?
  if (!sprite.circular) {

    //Yes, it is.
    //Get the position of the sprite's edges using global
    //coordinates
    let left = sprite.gx,
        right = sprite.gx + sprite.width,
        top = sprite.gy,
        bottom = sprite.gy + sprite.height;

    //Find out if the pointer is intersecting the rectangle.
    //`hit` will become `true` if the pointer is inside the
    //sprite's area
    hit
      = this.x > left && this.x < right
      && this.y > top && this.y < bottom;
  }

  //Is the sprite circular?
  else {

    //Yes, it is.
    //Find the distance between the pointer and the
    //center of the circle
    let vx = this.x - (sprite.gx + sprite.radius),
        vy = this.y - (sprite.gy + sprite.radius),
        distance = Math.sqrt(vx * vx + vy * vy);

    //The pointer is intersecting the circle if the
    //distance is less than the circle's radius
    hit = distance < sprite.radius;
  }

  return hit;
}

在矩形精灵的情况下,代码检查指针的 x,y 位置是否在精灵的区域内。在圆形精灵的情况下,它检查指针中心和精灵中心之间的距离是否小于圆的半径。在这两种情况下,如果指针接触到子画面,代码将设置hit为真。指针的 x,y 坐标总是相对于画布的,这就是为什么代码使用精灵的全局gxgy坐标。

在游戏代码中使用hitTestSprite,如下所示:

pointer.hitTestSprite(anySprite);

运行pointerCollision.html文件,交互演示hitTestSprite如何工作,如图图 6-11 所示。一个文本精灵会告诉你指针是否接触到了盒子或球精灵。

9781430258001_Fig06-11.jpg

图 6-11 。找出指针是否接触到一个精灵

下面是来自游戏循环的代码,它使这个工作:

if(pointer.hitTestSprite(ball)) {
  message.content"Ball!"
} else if(pointer.hitTestSprite(box)) {
  message.content"Box!"
} else {
  message.content"No collision..."
}

这个例子实际上是对一个叫做碰撞检测的游戏设计主题的一个偷偷摸摸的介绍,你将在下一章了解到。但是,现在,我们如何使用hitTestPoint来制作交互式精灵呢?让我们通过学习如何制作最有用的交互精灵来找出答案:按钮。

小跟班

按钮是一个重要的用户界面组件,你肯定想在你的游戏中使用。你可以使用 HTML 和 CSS 很容易地创建它们,但是为基于画布的精灵渲染系统创建你自己的定制按钮,比如我们在本书中开发的那个,还有很多要说的。您将能够将按钮集成到您现有的场景图形和渲染器中,像操作任何其他游戏精灵一样操作它们,并保持您的代码库统一在 JavaScript 中,而不必跳过 HTML 和 CSS。使用我们在上一节中学到的指针交互性,并通过对 sprite 系统做一些小的添加,我们可以创建一个多功能的新按钮 sprite 对象。你可以把我们要做的按钮想象成“可点击/可触摸的精灵”,你可以在各种各样的游戏中使用它们。

关于按钮,你需要知道的最重要的事情是它们有状态动作。状态定义按钮的外观,动作定义按钮的功能。

大多数按钮有三种状态:

  • 向上:当指针没有碰到按钮时
  • 上:当指针在按钮上时
  • 按下:当指针按下按钮时

图 6-12 显示了这三种按钮状态的例子。

9781430258001_Fig06-12.jpg

图 6-12 。向上、向上和向下按钮状态

基于触摸的游戏只需要两种状态:向上和向下。

使用我们将要创建的按钮精灵,您将能够以字符串属性的形式访问这些状态,如下所示:

playButton.state

state属性可以有值"up""over""down",你可以在你的游戏逻辑中使用它们。

按钮也有动作:

  • 按下:当指针按下按钮时
  • 释放:当指针从按钮上释放时
  • 结束:当指针移动到按钮区域时
  • Out:当指针移出按钮区域时
  • 点击:点击(或点击)按钮时

您可以将这些操作定义为用户可定义的方法,如下所示:

playButton.press() => console.log("pressed");
playButton.release() => console.log("released");
playButton.over() => console.log("over");
playButton.out() => console.log("out");
playButton.tap() => console.log("tapped");

您还应该能够在一个字符串属性中访问按钮的“按下”和“释放”动作,如下所示:

playButton.action

明白了吗?很好!那么我们实际上是怎么做纽扣的呢?

创建按钮

首先,从定义三种按钮状态的三幅图像开始。你可以称他们为"up.png""over.png""down.png"。然后将这三幅图像添加到 tileset 中,或者作为纹理贴图集中的帧。图 6-13 显示了包含这三种状态的简单纹理图谱。

9781430258001_Fig06-13.jpg

图 6-13 。将按钮图像状态添加到纹理贴图集

Image 注意虽然有三种图像状态是标准的,但有时候按钮只有两种图像状态。这对于只有触摸的按钮来说尤其如此,因为它们没有“结束”状态。我们将在前面创建的按钮 sprite 将使用三个图像(如果它们可用的话),但是如果它只有两个图像,代码将假定它们指的是“向上”和“向下”状态。

接下来,将纹理地图加载到游戏程序中:

assets.load(["img/button.json"]).then(() => setup());

同样通过使用三个帧作为按钮的源参数来初始化一个新的button sprite:

let buttonFrames = [
  assets["up.png"],
  assets["over.png"],
  assets["down.png"]
];

playButton = button(buttonFrames, 32, 96);

要查看这个按钮的运行,运行button.html文件,其输出显示在图 6-14 中。当您将指针移到按钮上时,光标会变成手形图标。游戏循环更新了一些显示按钮状态和动作的文本。

stateMessage.content`State: ${playButton.state}`;
actionMessage.content`Action: ${playButton.action}`;

9781430258001_Fig06-14.jpg

图 6-14 。交互式按钮精灵

这是我们最终想要实现的,但是我们需要多写一点代码来使所有这些细节工作。让我们来看看我们到底需要做些什么来创建这些完全交互的按钮。

新的Button

您可能在一个游戏中有许多按钮,并且,正如您将很快看到的,您需要在每一帧中更新它们。所以你首先需要一个buttons数组来存储游戏中的所有按钮:

export let buttons = [];

将这个buttons数组与stage对象和 sprite 类一起保存在你的display模块中。确保导出它,因为您需要将它导入到任何需要按钮的游戏的应用程序代码中。

接下来,做一个Button类。它所做的就是扩展Sprite类并将 sprite 的interactive属性设置为true。为按钮创建一个全新的类并不重要,但是您很快就会看到,这样做将帮助我们基于指针交互性自动显示图像状态。

class Button extends Sprite {
  constructor(source, x = 0, y = 0) {
    super(source, x, y);
    this.interactivetrue;
  }
}

export function button(source, x, y) {
  let sprite = new Button(source, x, y);
  stage.addChild(sprite);
  return sprite;
}

interactive设置为true实际上是做什么的?

this.interactivetrue;

你会记得,当我们在第四章的中制作精灵系统时,我们给了所有的精灵一个名为interactive的属性,你可以将它设置为true。下面是从DisplayObject类中摘录的代码(如果你需要的话,可以查看第四章中的代码)。

get interactive() {
  return this._interactive;
}

set interactive(value) {

  if (value === true) {

    //Add interactive properties to the sprite
    //so that it can act like a button
    makeInteractive(this);

    //Add the sprite to the global `buttons` array so
    //it can be updated each frame
    buttons.push(this);

    this._interactivetrue;
  }

  if (value === false) {

    //Remove the sprite's reference from the
    //`buttons` array so that it's no longer affected
    //by mouse and touch interactivity
    buttons.splice(buttons.indexOf(this), 1);
    this._interactivefalse;
  }
}

interactive设置为true会将精灵发送给一个名为makeInteractive的函数,并将其添加到buttons数组中。将其设置为false会将其从buttons数组中拼接出来。让我们看看这个makeInteractive函数是做什么的,以及它如何将任何精灵转换成一个可点击、可触摸的按钮。你会记得我在第四章告诉过你,“现在不要担心这些东西——我以后会解释的!”好吧,现在是时候了!

添加交互性

makeInteractive函数为 sprite 分配了一些新方法:pressreleaseovertapout。它还向 sprite 添加了一些属性,以便我们可以监视它的交互状态。这些方法将使任何精灵的行为像一个按钮。但是如果 sprite 实际上是Button类的一个实例,makeInteractive增加了一个额外的特性:它根据指针所做的事情将 sprite 的图像状态设置为"up""over""down"

即使对我来说,这也是一段相当复杂的代码。这是因为它必须根据按钮的前一状态和指针正在做的事情来计算出按钮的当前状态。这碰巧是一项不可避免的微妙事业——有点像耍弄匕首。注释解释了它是如何工作的,但是真正理解其微妙之处的最好方法是阅读代码,同时观察button.html示例文件中的效果。编写这段代码只是一个渐进的试错过程,以及许许多多的测试。而我直到做完才完全明白我要解决的问题。这种探索和发现是编程如此有趣的原因之一!下面是来自library/display文件的完整的makeInteractive函数。

function makeInteractive(o) {

  //The `press`, `release`, `over`, `out`, and `tap` methods. They're `undefined`
  //for now, but they can be defined in the game program
  o.press = o.press || undefined;
  o.release = o.release || undefined;
  o.over = o.over || undefined;
  o.out = o.out || undefined;
  o.tap = o.tap || undefined;

  //The `state` property tells you the button's
  //current state. Set its initial state to "up"
  o.state"up";

  //The `action` property tells you whether it’s being pressed or
  //released
  o.action"";

  //The `pressed` and `hoverOver` Booleans are mainly for internal
  //use in this code to help figure out the correct state.
  //`pressed` is a Boolean that helps track whether
  //the sprite has been pressed down
  o.pressedfalse;

  //`hoverOver` is a Boolean that checks whether the pointer
  //has hovered over the sprite
  o.hoverOverfalse;

  //The `update` method will be called each frame
  //inside the game loop
  o.update(pointer, canvas) => {

    //Figure out if the pointer is touching the sprite
    let hit = pointer.hitTestSprite(o);

    //1\. Figure out the current state
    if (pointer.isUp) {

      //Up state
      o.state"up";

      //Show the first image state frame, if this is a `Button` sprite
      if (o instanceof Button) o.gotoAndStop(0);
    }

    //If the pointer is touching the sprite, figure out
    //if the over or down state should be displayed
    if (hit) {

      //Over state
      o.state"over";

      //Show the second image state frame if this sprite has
      //3 frames and it's a `Button` sprite
      if (o.frames && o.frames.length === 3 && o instanceof Button) {
        o.gotoAndStop(1);
      }

      //Down state
      if (pointer.isDown) {
        o.state"down";

        //Show the third frame if this sprite is a `Button` sprite and it
        //has only three frames, or show the second frame if it
        //has only two frames
        if(o instanceof Button) {
          if (o.frames.length === 3) {
            o.gotoAndStop(2);
          } else {
            o.gotoAndStop(1);
          }
        }
      }
    }

    //Perform the correct interactive action

    //a. Run the `press` method if the sprite state is "down" and
    //the sprite hasn't already been pressed
    if (o.state === "down") {
      if (!o.pressed) {
        if (o.press) o.press();
        o.pressedtrue;
        o.action"pressed";
      }
    }

    //b. Run the `release` method if the sprite state is "over" and
    //the sprite has been pressed
    if (o.state === "over") {
      if (o.pressed) {
        if (o.release) o.release();
        o.pressedfalse;
        o.action"released";

        //If the pointer was tapped and the user assigned a `tap`
        //method, call the `tap` method
        if (pointer.tapped && o.tap) o.tap();
      }

      //Run the `over` method if it has been assigned
      if (!o.hoverOver) {
        if (o.over) o.over();
        o.hoverOvertrue;
      }
    }

    //c. Check whether the pointer has been released outside
    //the sprite's area. If the button state is "up" and it has
    //already been pressed, then run the `release` method
    if (o.state === "up") {
      if (o.pressed) {
        if (o.release) o.release();
        o.pressedfalse;
        o.action"released";
      }

      //Run the `out` method if it has been assigned
      if (o.hoverOver) {
        if (o.out) o.out();
        o.hoverOverfalse;
      }
    }
  };
}

前面代码中的一个重要特性是makeInteractive向 sprite 添加了一个名为update的方法:

o.update(pointer, canvas) =>/*...*/ }

这个update方法实际上计算出了按钮的状态。唯一可行的方法是在每个动画帧的上调用它。这意味着游戏中的每个按钮都需要在游戏循环中调用它的update方法。幸运的是,每一个将interactive设置为true的精灵都会被推入buttons数组。这意味着你可以用这样的功能更新游戏中的所有按钮:

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Only run the code if there are buttons in the array
  if (buttons.length > 0) {

    //Set the mouse pointer to the default arrow icon
    canvas.style.cursor"auto";

    //Loop through all the buttons
    buttons.forEach(button => {

      //Update the buttons
      button.update(pointer, canvas);

      //Figure out if the mouse arrow should be a hand icon
      if (button.state === "over" || button.state === "down") {

        //If the button (or interactive sprite) isn't the
        //stage, change the cursor to a pointer.
        //(This works because the `stage` object has a
        //`parent` value of `undefined`)
        if(button.parent !== undefined) {

          //Display the mouse arrow as a hand
          canvas.style.cursor"pointer";
        }
      }
    });
  }

  //Render the canvas
  render(canvas);
}

代码在所有按钮中循环,并对所有按钮调用update方法。另外,这段代码还能判断出鼠标箭头是否应该显示一个手形图标。如果按钮状态是"over""down",并且交互子画面不是stage对象,则显示手图标。

把这一切放在一起

既然您已经知道了所有这些组件是如何工作的,那么让我们来看看button.html文件的完整 JavaScript 代码。您可以将此作为在自己的项目中使用按钮的起点。

//Import code from the library
import {
  makeCanvas, button, buttons, frames,
  text, stage, render
} from "../library/display";
import {assets} from "../library/utilities";
import {makePointer} from "../library/interactive";

//Load the button’s texture atlas and the custom font
assets.load([
  "fonts/puzzler.otf",
  "img/button.json"
]).then(() => setup());

//Declare any variables shared between functions
let canvas, playButton, stateMessage, actionMessage, pointer;

function setup() {

  //Make the canvas and initialize the stage
  canvas = makeCanvas(256, 256);
  stage.width = canvas.width;
  stage.height = canvas.height;

  //Define the button's frames
  let buttonFrames = [
    assets["up.png"],
    assets["over.png"],
    assets["down.png"]
  ];

  //Make the button sprite
  playButton = button(buttonFrames, 32, 96);

  //Define the button's actions
  playButton.over() => console.log("over");
  playButton.out() => console.log("out");
  playButton.press() => console.log("pressed");
  playButton.release() => console.log("released");
  playButton.tap() => console.log("tapped");

  //Add some message text
  stateMessage = text("State:", "12px puzzler", "black", 12, 12);
  actionMessage = text("Action:", "12px puzzler", "black", 12, 32);

  //Make the pointer
  pointer = makePointer(canvas);

  //Start the game loop
  gameLoop();
}

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Update the buttons
  if (buttons.length > 0) {
    canvas.style.cursor"auto";
    buttons.forEach(button => {
      button.update(pointer, canvas);
      if (button.state === "over" || button.state === "down") {
        if(button.parent !== undefined) {
          canvas.style.cursor"pointer";
        }
      }
    });
  }

  //Display the button's state and action
  stateMessage.content`State: ${playButton.state}`;
  actionMessage.content`Action: ${playButton.action}`;

  //Render the canvas
  render(canvas);
}

我前面提到过,按钮只是一种交互式精灵,碰巧有三种定义的图像状态。这意味着你可以给任何 sprite 添加类似按钮的交互性。让我们找出方法。

制作互动精灵

您可以通过将任何 sprite 的interactive属性设置为true来使其行为像一个按钮:

anySprite.interactivetrue;

这会将 sprite 添加到buttons数组中,并赋予它与任何其他按钮相同的方法属性。这意味着你可以分配pressrelease方法给精灵,并访问它的状态和动作属性。

Image 注意你也可以让stage对象交互。如果您想知道玩家是否点击或按下了画布,这将非常有用。

运行interacteractiveSprites.html演示该功能,如图图 6-15 所示。如果你点击这个圆,它的填充和描边颜色会随机改变。

9781430258001_Fig06-15.jpg

图 6-15 。点按以使圆显示随机颜色

下面是实现这种效果的代码:

//Make the pointer
pointer = makePointer(canvas);

//Create the sprite and put it in the center of the stage
ball = circle(96, "red", "blue", 8);
stage.putCenter(ball);

//Make the ball interactive
ball.interactivetrue;

//Assign the ball's `press` method
ball.press() => {

  //An array of color names
  var colors = ["Gold", "Lavender", "Crimson", "DarkSeaGreen"];

  //Set the ball's `fillStyle` and `strokeStyle` to a random color
  ball.fillStyle = colors[randomInt(0, 3)];
  ball.strokeStyle = colors[randomInt(0, 3)];
};

如果你想制作一个可点击/点击的精灵来与指针交互,你现在有一个方法可以做到。

Image 注意记住如果你设置一个 sprite 的交互属性为true,你还需要导入buttons数组,更新游戏循环中的所有按钮。

拖放

我们需要让我们的精灵完全交互的最后一件事是给他们拖放功能。在第四章的中,我们给精灵添加了一个draggable属性,你可以设置为true或者false:

anySprite.draggabletrue;

这是我的另一个“以后我会告诉你的!”时刻。设置draggabletrue的作用是让你用指针在画布上拖动精灵。图 6-16 显示了让你这样做的draggableSprites.html示例文件的输出。您可以在画布上拖动精灵,并将它们堆叠在一起。选定的精灵显示在栈的顶部,当鼠标箭头光标在可拖动的精灵上时会变成一个手形。

9781430258001_Fig06-16.jpg

图 6-16 。拖拽小精灵

当你设置draggabletrue时,实际上会发生什么,这是如何工作的?

首先,你需要一个名为draggableSprites的数组。您可以在library/display模块中找到它。

export let draggableSprites = [];

当设置为true时,DisplayObject类上的draggable属性将 sprite 推入draggableSprites,如果设置为false则将其拼接出来。下面是来自DisplayObject类的代码:

get draggable() {
  return this._draggable;
}

set draggable(value) {

  if (value === true) {

    //Push the sprite into the `draggableSprites` array
    draggableSprites.push(this);
    this._draggabletrue;
  }

  if (value === false) {

    //Splice the sprite from the `draggableSprites` array
    draggableSprites.splice(draggableSprites.indexOf(this), 1);
  }
}

接下来,您需要一些新的属性和一个关于pointer对象的新方法来帮助您控制拖放行为。它们被添加到我们在本章前面创建的makePointer函数的pointer中:

function makePointer(element, scale = 1) {

  let pointer = {

    //... the pointer's existing properties and methods...

    //New drag and drop properties:

    dragSprite: null,
    dragOffsetX: 0,
    dragOffsetY: 0,

    //New `updateDragAndDrop` method:

    updateDragAndDrop(sprite) {
      //The new code goes here
    }

  };

  //... the rest of the `makePointer` function...

  return pointer;
}

dragSprite对象是指针当前拖动的 sprite,dragOffsetXdragOffsetY用于帮助移动dragSpriteupdateDragAndDrop方法负责选择可拖动的精灵,在画布上拖动它们,并使选中的精灵堆叠在未选中的精灵之上。

updateDragAndDrop(draggableSprites) {

  //Check whether the pointer is pressed down
  if (this.isDown) {

    //You need to capture the coordinates at which the pointer was
    //pressed down and find out if it's touching a sprite

    //Only run this code if the pointer isn't already dragging a sprite
    if (this.dragSprite === null) {

      //Loop through the `draggableSprites` in reverse so that
      //you start searching at the top of the stack.
      //This means the last array element
      //will be the first one checked.
      //(Sprites at the end of the array are displayed
      //above sprites at the beginning of the array)
      for (let i = draggableSprites.length1; i > -1; i--) {
        let sprite = draggableSprites[i];

       //Check for a collision with the pointer using `hitTestSprite`
        if (this.hitTestSprite(sprite) && sprite.draggable) {

         //Calculate the difference between the pointer's
         //position and the sprite's position
          this.dragOffsetXthis.x - sprite.gx;
          this.dragOffsetYthis.y - sprite.gy;

         //Set the sprite as the pointer's `dragSprite` property
          this.dragSprite = sprite;

         //The next two lines reorder the `sprites` array so that the
         //selected sprite is displayed above all the others.
         //First, splice the sprite out of its current position in
         //its parent's `children` array
          let children = sprite.parent.children;
          children.splice(children.indexOf(sprite), 1);

          //Next, push the `dragSprite` to the end
          //of its `children` array so that it's
          //displayed last, above all the other sprites
          children.push(sprite);

          //Reorganize the `draggableSprites` array in the same way
          draggableSprites.splice(draggableSprites.indexOf(sprite), 1);
          draggableSprites.push(sprite);

          //Break the loop, because we only need to drag the topmost sprite
          break;
        }
      }
    }

    //If the pointer is down and it has a `dragSprite`, make the
    //sprite follow the pointer's position, with the calculated offset
    else {
      this.dragSprite.xthis.xthis.dragOffsetX;
      this.dragSprite.ythis.ythis.dragOffsetY;
    }
  }

  //If the pointer is up, drop the `dragSprite` by setting it to `null`
  if (this.isUp) {
    this.dragSpritenull;
  }

  //Change the mouse arrow pointer to a hand if it's over a
  //draggable sprite
  draggableSprites.some(sprite => {
    if (this.hitTestSprite(sprite) && sprite.draggable) {
      this.element.style.cursor"pointer";
      return true;
    } else {
      this.element.style.cursor"auto";
      return false;
    }
  });
}

要实现这一点,您需要在游戏循环内部调用指针的updateDragAndDrop方法。这使子画面和指针位置与帧速率保持同步。

function gameLoop() {
  requestAnimationFrame(gameLoop);
  pointer.updateDragAndDrop(draggableSprites);
  render(canvas);
}

为了让你明白这一切是如何在适当的环境下工作的,这里是来自draggableSprites.html文件的所有 JavaScript 代码,它创建了三个可拖动的动物精灵,如图图 6-16 所示。

//Import code from the library, including the `draggableSprites` array
import {makeCanvas, stage, draggableSprites, sprite, render} from "../library/display";
import {assets} from "../library/utilities";
import {makePointer} from "../library/interactive";

//Load the texture atlas containing the animal sprite images
assets.load(["img/animals.json"]).then(() => setup());

//Declare any variables shared between functions
let canvas, cat, tiger, hedgehog, pointer;

function setup() {

  //Make the canvas and initialize the stage
  canvas = makeCanvas(256, 256);
  stage.width = canvas.width;
  stage.height = canvas.height;

  //Make three sprites and set their `draggable` properties to `true`
  cat = sprite(assets["cat.png"]);
  stage.putCenter(cat, -32, -32);
  cat.draggabletrue;

  tiger = sprite(assets["tiger.png"]);
  stage.putCenter(tiger);
  tiger.draggabletrue;

  hedgehog = sprite(assets["hedgehog.png"]);
  stage.putCenter(hedgehog, 32, 32);
  hedgehog.draggabletrue;

  //Make the pointer
  pointer = makePointer(canvas);

  //Start the game loop
  gameLoop();
}

function gameLoop() {
  requestAnimationFrame(gameLoop);

  //Update the pointer's drag and drop system
  pointer.updateDragAndDrop(draggableSprites);

  //Render the canvas
  render(canvas);
}

您可以将此代码用作向任何 sprite 添加拖放功能的基本模型。

摘要

游戏都是关于交互性的,在这一章中,你已经学习了为任何游戏增加交互性所需要知道的最重要的技术。您已经学习了计算距离的经典函数,使小精灵向其他小精灵旋转,使小精灵跟随其他小精灵或指针,并使小精灵沿其旋转方向移动。您还了解了如何编写和实现一个有用的keyboard函数来快速为游戏添加键盘交互性,以及如何创建一个通用的pointer对象,它既适用于鼠标,也适用于触摸。如果这还不够,您还发现了如何制作可点击和可触摸的按钮,以及如何在画布上拖放精灵。

但是在我们真正开始制作游戏之前,我们还需要完成一个重要的拼图。我们必须学会当精灵们撞到一起时该怎么做。这就是下一章要讲的:碰撞。