对象构建实践

在之前的文章中,我们学习了所有重要的 JavaScript 对象理论和语法细节,为你打下了坚实的基础。在本文中,我们将深入到一个实践练习,通过一个有趣且多彩的结果,为你提供更多构建自定义 JavaScript 对象的实践。

预备知识 熟悉 JavaScript 基础(尤其是对象基础)和本模块先前课程中涵盖的面向对象 JavaScript 概念。
学习成果 在实际情境中练习使用对象和面向对象技术。

让我们来弹跳一些小球

在本文中,我们将编写一个经典的“弹跳小球”演示,向你展示对象在 JavaScript 中是多么有用。我们的小球将在屏幕上弹跳,并在相互接触时改变颜色。最终的示例看起来会是这样:

Screenshot of a webpage titled "Bouncing balls". 23 balls of various pastel colors and sizes are visible across a black screen with long trails behind them indicating motion.

此示例将利用 Canvas API 在屏幕上绘制小球,并利用 requestAnimationFrame API 为整个显示添加动画 — 你不需要有这些 API 的任何先验知识,我们希望在你完成本文时,你会对它们有兴趣进一步探索。在此过程中,我们将使用一些巧妙的对象,并向你展示一些不错的技术,例如小球从墙壁弹开,以及检查它们是否相互撞击(也称为碰撞检测)。

入门

首先,复制我们的 index.htmlstyle.cssmain.js 文件到本地。它们分别包含以下内容:

  1. 一个非常简单的 HTML 文档,包含一个 h1 元素,一个 <canvas> 元素用于绘制我们的小球,以及用于将 CSS 和 JavaScript 应用到 HTML 的元素。
  2. 一些非常简单的样式,主要用于设置 <h1> 的样式和位置,并去除页面边缘的任何滚动条或边距(使其看起来整洁)。
  3. 一些 JavaScript,用于设置 <canvas> 元素并提供我们将要使用的通用函数。

脚本的第一部分如下所示:

js
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

const width = (canvas.width = window.innerWidth);
const height = (canvas.height = window.innerHeight);

这个脚本获取对 <canvas> 元素的引用,然后在其上调用 getContext() 方法,为我们提供一个可以开始绘制的上下文。生成的常量 (ctx) 是直接表示画布绘图区域的对象,允许我们在其上绘制 2D 图形。

接下来,我们设置了名为 widthheight 的常量,并将 canvas 元素的宽度和高度(由 canvas.widthcanvas.height 属性表示)设置为等于浏览器视口(网页显示的区域 — 这可以通过 Window.innerWidthWindow.innerHeight 属性获取)的宽度和高度。

请注意,我们正在将多个赋值操作链接在一起,以更快地设置所有变量 — 这是完全可以的。

然后我们有两个辅助函数

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

function randomRGB() {
  return `rgb(${random(0, 255)} ${random(0, 255)} ${random(0, 255)})`;
}

random() 函数接受两个数字作为参数,并返回介于两者之间的一个随机数。randomRGB() 函数生成一个表示为 rgb() 字符串的随机颜色。

在程序中建模小球

我们的程序将在屏幕上显示许多弹跳的球。由于这些球都以相同的方式运动,因此用一个对象来表示它们是合理的。让我们从在代码底部添加以下类定义开始。

js
class Ball {
  constructor(x, y, velX, velY, color, size) {
    this.x = x;
    this.y = y;
    this.velX = velX;
    this.velY = velY;
    this.color = color;
    this.size = size;
  }
}

到目前为止,这个类只包含一个构造函数,我们可以在其中初始化每个球在程序中运行所需的属性:

  • xy 坐标 — 球在屏幕上开始的水平和垂直坐标。这可以从 0(左上角)到浏览器视口(右下角)的宽度和高度范围。
  • 水平和垂直速度(velXvelY)— 每个球都被赋予水平和垂直速度;实际上,当我们给球添加动画时,这些值会定期添加到 x/y 坐标值中,以便在每一帧中移动它们这么多。
  • color — 每个球都有一个颜色。
  • size — 每个球都有一个大小 — 这是它的半径,以像素为单位。

这处理了属性,但是方法呢?我们希望让球在程序中实际做些什么。

绘制小球

首先,将以下 draw() 方法添加到 Ball 类中:

js
class Ball {
  // …
  draw() {
    ctx.beginPath();
    ctx.fillStyle = this.color;
    ctx.arc(this.x, this.y, this.size, 0, 2 * Math.PI);
    ctx.fill();
  }
}

使用此函数,我们可以通过调用我们之前定义的 2D canvas 上下文 (ctx) 的一系列成员来告诉小球在屏幕上绘制自己。上下文就像纸张,现在我们想命令我们的笔在上面绘制一些东西。

  • 首先,我们使用 beginPath() 来声明我们想要在纸上绘制一个形状。

  • 接下来,我们使用 fillStyle 定义我们希望形状呈现的颜色 — 我们将其设置为小球的 color 属性。

  • 接下来,我们使用 arc() 方法在纸上描绘一个弧形。它的参数是:

    • 圆弧中心的 xy 位置 — 我们指定了小球的 xy 属性。
    • 圆弧的半径 — 在这种情况下,是球的 size 属性。
    • 最后两个参数指定了圆弧绘制的起始和结束度数。这里我们指定 0 度和 2 * PI,这相当于 360 度(以弧度表示,令人恼火的是你必须以弧度指定)。这给我们一个完整的圆。如果你只指定了 1 * PI,你将得到一个半圆(180 度)。
  • 最后,我们使用 fill() 方法,它基本上表示“完成我们用 beginPath() 开始绘制的路径,并用我们之前在 fillStyle 中指定的颜色填充它所占据的区域。”

你现在就可以开始测试你的对象了。

  1. 保存目前的 L 代码,并在浏览器中加载 HTML 文件。

  2. 打开浏览器的 JavaScript 控制台,然后刷新页面,以便画布大小更改为控制台打开后剩余的较小可见视口。

  3. 输入以下内容以创建一个新的球实例:

    js
    const testBall = new Ball(50, 100, 4, 4, "blue", 10);
    
  4. 尝试调用其成员:

    js
    testBall.x;
    testBall.size;
    testBall.color;
    testBall.draw();
    
  5. 当你输入最后一行时,你应该会看到小球在画布的某个位置绘制出来。

更新小球数据

我们可以在指定位置绘制小球,但要实际移动小球,我们需要某种更新函数。将以下代码添加到 Ball 的类定义中:

js
class Ball {
  // …
  update() {
    if (this.x + this.size >= width) {
      this.velX = -this.velX;
    }

    if (this.x - this.size <= 0) {
      this.velX = -this.velX;
    }

    if (this.y + this.size >= height) {
      this.velY = -this.velY;
    }

    if (this.y - this.size <= 0) {
      this.velY = -this.velY;
    }

    this.x += this.velX;
    this.y += this.velY;
  }
}

函数的前四个部分检查球是否已到达画布边缘。如果已到达,我们会反转相关速度的方向,使球沿相反方向运动。例如,如果球向上运动(负 velY),则垂直速度会改变,使其改为向下运动(正 velY)。

在四种情况下,我们正在检查:

  • 如果 x 坐标大于画布宽度(球正超出右边缘)。
  • 如果 x 坐标小于 0(球正超出左边缘)。
  • 如果 y 坐标大于画布高度(球正超出下边缘)。
  • 如果 y 坐标小于 0(球正超出上边缘)。

在每种情况下,我们都将球的 size 包含在计算中,因为 x/y 坐标位于球的中心,但我们希望球的边缘从边界弹开——我们不希望球在开始反弹之前就超出屏幕一半。

最后两行将 velX 值添加到 x 坐标,并将 velY 值添加到 y 坐标——每次调用此方法时,球实际上都会移动。

暂时就这些了;让我们继续做动画吧!

给小球添加动画

现在让我们来点乐趣。我们现在要开始将小球添加到画布上,并对其进行动画处理。

首先,我们需要创建一个地方来存储我们所有的小球,然后填充它。以下代码将完成这项工作——现在将其添加到代码底部:

js
const balls = [];

while (balls.length < 25) {
  const size = random(10, 20);
  const ball = new Ball(
    // ball position always drawn at least one ball width
    // away from the edge of the canvas, to avoid drawing errors
    random(0 + size, width - size),
    random(0 + size, height - size),
    random(-7, 7),
    random(-7, 7),
    randomRGB(),
    size,
  );

  balls.push(ball);
}

while 循环使用我们的 random()randomRGB() 函数生成的随机值创建一个新的 Ball() 实例,然后将其 push() 到我们的 balls 数组的末尾,但前提是数组中的小球数量少于 25 个。因此,当数组中有 25 个小球时,将不再添加小球。你可以尝试改变 balls.length < 25 中的数字,以获取更多或更少的小球。根据你的计算机/浏览器的处理能力,指定几千个小球可能会使动画变慢很多!

接下来,将以下内容添加到代码底部:

js
function loop() {
  ctx.fillStyle = "rgb(0 0 0 / 25%)";
  ctx.fillRect(0, 0, width, height);

  for (const ball of balls) {
    ball.draw();
    ball.update();
  }

  requestAnimationFrame(loop);
}

所有动画程序通常都包含一个动画循环,该循环用于更新程序中的信息,然后在动画的每一帧上渲染结果视图;这是大多数游戏和其他此类程序的基础。我们的 loop() 函数执行以下操作:

  • 将画布填充颜色设置为半透明黑色,然后使用 fillRect() 在画布的整个宽度和高度上绘制一个相同颜色的矩形(四个参数提供起始坐标以及绘制矩形的宽度和高度)。这用于在绘制下一帧之前覆盖上一帧的绘制。如果你不这样做,你只会看到长长的蛇在画布上蠕动而不是小球移动!填充颜色设置为半透明的 rgb(0 0 0 / 25%),以允许前几帧轻微透出,从而在小球移动时产生小球后面的小轨迹。如果你将 0.25 更改为 1,你将完全看不到它们。尝试改变这个数字以查看其效果。
  • 遍历 balls 数组中的所有小球,并运行每个小球的 draw()update() 函数,在屏幕上绘制每个小球,然后及时更新位置和速度,为下一帧做准备。
  • 使用 requestAnimationFrame() 方法再次运行该函数——当此方法重复运行并传入相同的函数名时,它会每秒运行该函数设定的次数,以创建平滑的动画。这通常是递归完成的——这意味着函数每次运行时都会调用自身,因此它会一遍又一遍地运行。

最后,将以下行添加到代码底部——我们需要调用一次函数以启动动画。

js
loop();

基本内容就是这些了——尝试保存并刷新以测试你的弹跳球!

添加碰撞检测

现在为了增加一些乐趣,让我们在程序中添加一些碰撞检测,这样我们的小球就能知道何时撞到另一个小球。

首先,将以下方法定义添加到你的 Ball 类中。

js
class Ball {
  // …
  collisionDetect() {
    for (const ball of balls) {
      if (this !== ball) {
        const dx = this.x - ball.x;
        const dy = this.y - ball.y;
        const distance = Math.sqrt(dx * dx + dy * dy);

        if (distance < this.size + ball.size) {
          ball.color = this.color = randomRGB();
        }
      }
    }
  }
}

这个方法有点复杂,所以如果现在你还不完全理解它的工作原理,也不必担心。下面是解释:

  • 对于每个球,我们需要检查所有其他球,看它是否与当前球发生碰撞。为此,我们启动另一个 for...of 循环,遍历 balls[] 数组中的所有球。
  • 在 for 循环内部,我们立即使用一个 if 语句来检查当前遍历的球是否与我们正在检查的球是同一个。我们不想检查一个球是否与自己发生碰撞!为此,我们检查当前球(即正在调用其 collisionDetect 方法的球)是否与循环球(即 collisionDetect 方法中 for 循环的当前迭代所引用的球)相同。然后我们使用 ! 来否定检查,这样 if 语句中的代码只有在它们相同的情况下才会运行。
  • 然后,我们使用一种常见的算法来检测两个圆的碰撞。我们基本上是在检查这两个圆的任何区域是否重叠。这在2D 碰撞检测中有进一步的解释。
  • 如果检测到碰撞,则执行内部 if 语句中的代码。在这种情况下,我们只将两个圆的 color 属性设置为新的随机颜色。我们本可以做更复杂的事情,比如让小球更真实地相互弹开,但这会复杂得多。对于此类物理模拟,开发人员倾向于使用游戏或物理库,如 PhysicsJSmatter.jsPhaser 等。

你还需要在动画的每一帧中调用此方法。更新你的 loop() 函数,在 ball.update() 之后调用 ball.collisionDetect()

js
function loop() {
  ctx.fillStyle = "rgb(0 0 0 / 25%)";
  ctx.fillRect(0, 0, width, height);

  for (const ball of balls) {
    ball.draw();
    ball.update();
    ball.collisionDetect();
  }

  requestAnimationFrame(loop);
}

再次保存并刷新演示,你会看到你的小球在碰撞时改变颜色!

注意:如果此示例无法正常工作,请尝试将你的 JavaScript 代码与我们的最终版本进行比较(也可查看其实时运行)。

总结

我们希望你通过使用本模块中的各种对象和面向对象技术,编写自己的真实世界随机弹跳球示例,玩得开心!这应该为你提供了使用对象的一些有益实践,以及良好的实际应用场景。

关于对象课程就到这里了——现在只剩下你在模块挑战中测试你的技能了。

另见