JavaScript-游戏制作教程-全-

JavaScript 游戏制作教程(全)

原文:Making Games With JavaScript

协议:CC BY-NC-SA 4.0

一、简介

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-2493-9_​1) contains supplementary material, which is available to authorized users.

我是个游戏玩家。在我成为程序员之前,我就是一名游戏玩家。然而,我从来没有试图建立一个游戏…直到现在。几周前,一个同事画了一个简单漂亮的平台游戏草图。我看到它的那一刻;我知道我想建造它。

知道从哪里开始总是很难。很多关于制作游戏的书籍和教程,往往都是用 Java,C++,或者其他一些你还没学过的语言写的。因此,当你还需要学习新的语言时,制作游戏的复杂性就增加了。

取而代之的是,我要用现代的 JavaScript 来制作网页浏览器的游戏。它们使用起来毫不费力,而且在任何地方都适用。如果你想做一个游戏,加入我吧。我们将从零开始,并在任何时候都建立有趣的游戏。

如果你有问题,可以在推特上问我: https://twitter.com/assertchris

二、游戏循环

游戏循环是每个游戏的重要组成部分。在这一章中,我们将通过创建一个可靠的工作流程和环境来为我们的游戏搭建舞台。我们会看到一些有用的库,并渲染我们的第一个游戏角色。这会很有趣的!

为我们的游戏搭建舞台

我们将使用最新的 JavaScript 标准和语言特性来构建游戏。这通常是我向您展示如何创建 JavaScript 构建的链的地方,但是我们将做一些不同的事情....

对于这本书,我们将在一个名为 CodePen 的托管服务中完成所有的编码工作。看起来像图 2-1 中的代码。

A435434_1_En_2_Fig1_HTML.jpg

图 2-1。

CodePen

这种方法有几个好处:

  1. 我们不需要讨论 JavaScript 构建链。它们是主观的、脆弱的、分散注意力的。
  2. 我们可以免费使用 SCSS(增强型样式表)。
  3. 您不需要设置任何东西就可以开始与源代码进行交互。

我们将使用一个 JavaScript 库,名为 PixiJS ( www.pixijs.com )。这是一个渲染库,可以消除浏览器的不一致性。它不是游戏引擎或物理引擎,所以我们仍然要自己学习和编写这些方面的代码。PixiJS 将允许我们更快地得到那些东西。

我们使用 PixiJS v4。当你读到这篇文章的时候,更新的版本是完全可能的。这些概念应该是通用的,尽管语法可能已经改变。

我们可以通过点击设置➤ JavaScript 将 PixiJS 添加到我们的 CodePens,并将以下 URL 添加到 PixiJS CDN 脚本: https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.0.0/pixi.min.js 。该过程如图 2-2 所示。

A435434_1_En_2_Fig2_HTML.jpg

图 2-2。

Adding PixiJS to CodePen

创建精灵

精灵是游戏中视觉对象的常见名称。我们可以在屏幕上移动它们(尽管通常它们负责移动自己)。我们可以和他们互动。

马里奥( https://en.wikipedia.org/wiki/Mario )是一个精灵,他走的平台是精灵,背景中的云是精灵。可以把精灵想象成设计文件的一部分,我们在抽象数据结构上绘制。这些抽象数据结构有一个位置,有时还有一个速度。这些抽象的数据结构是我们应用游戏规则的对象。他们是我们的力量和敌人。

那么我们如何制造它们呢?让我们首先创建一个小的 PNG 图像。我们正在使用 PNG,因为它允许我们使精灵纹理的一部分透明。如果你愿意,你也可以为你的精灵使用 JPEG 图片。

然后我们需要创建一个渲染器、一个舞台和一个精灵:

const renderer = new PIXI.autoDetectRenderer(
  window.innerWidth,
  window.innerHeight,
  {
    "antialias": true,
    "autoResize": true,
    "transparent": true,
    "resolution": 2,
  },
)

document.body.appendChild(renderer.view)

const sprite = new PIXI.Sprite.fromImage(
  "path/to/sprites/player-idle.png",
)

sprite.x = window.innerWidth / 2
sprite.y = window.innerHeight / 2

const stage = new PIXI.Container()
stage.addChild(sprite)

const animate = function() {
  requestAnimationFrame(animate)
  renderer.render(stage)
}

animate()

这是出自 http://codepen.io/assertchris/pen/qaobAz

好了,这里发生了很多事!让我们一步步来看这段代码:

  1. 我们创建一个渲染器。渲染器是转换像素抽象(精灵、纹理等)的东西。)转换成画布或 WebGL 图形。我们不需要与它们交互,但是我们总是需要一个渲染器来显示我们的 PixiJS 内容。我们告诉渲染器占据浏览器窗口的整个宽度和高度。我们还告诉它反走样我们的图形,并提高到视网膜分辨率。我们告诉它有一个透明的背景,并调整所有图形的大小以适应屏幕。
  2. 然后,我们使用之前创建的 PNG 图像创建一个新的PIXI.Sprite实例。默认情况下,它有一个位置x = 0y = 0。我们可以把它放在屏幕的中央。我们必须创建一个根 sprite 容器,通常称为 stage,并将 sprite 附加到 stage。它没有听起来那么复杂。想想 HTML 文档。它们有一个根html元素,我们将所有其他元素添加到其中。这是同类的东西。
  3. 最后,我们创建一个动画函数,并确保渲染器渲染我们的舞台和精灵。我们使用requestAnimationFrame函数作为一种不阻塞 JavaScript 线程的渲染方式。我们可以就此进行长时间的讨论。现在,重要的是知道requestAnimationFrame每秒钟发生多次。我们游戏的目标是每秒渲染 30 到 60 帧,同时在后台进行各种玩家和世界的计算。这是我们需要使用的函数,以便一切顺利进行。

游戏循环

游戏循环将控制我们游戏的流程。这是一个重复的过程,读取输入,计算状态变化,并将输出呈现到屏幕上。

到目前为止,我们已经建立了许多脚手架,我们所做的只是渲染一个静态图像。让我们把它移动一下!首先,我们将创建一个Player类,这样我们可以跟踪玩家的位置:

class Player {
  constructor(sprite, x, y) {
    this.sprite = sprite
    this.x = x
    this.y = y

    this.sprite.x = this.x
    this.sprite.y = this.y
  }

  animate(state) {
    this.x += 5

    if (this.x > window.innerWidth) {
      this.x = 0
    }

    this.sprite.x = this.x
    this.sprite.y = this.y
  }
}

这是出自 http://codepen.io/assertchris/pen/qaobAz

这就是 ES2015 及以后版本中 JavaScript 类的样子。Player类有一个构造函数,我们用它来存储对精灵的引用,以及初始的xy位置。

它还有一个animate方法,我们会在一秒钟内多次调用它。我们可以使用该方法来判断玩家是否需要改变位置或做其他事情。在这种情况下,我们将子画面x的位置增加一点。如果玩家/精灵离开了屏幕的右边,我们就把它移回到屏幕的左边。

我们还必须改变创建精灵的方式:

const player = new Player(
  sprite,
  window.innerWidth / 2,
  window.innerHeight / 2,
)

stage.addChild(sprite)

let state = {
  "renderer": renderer,
  "stage": stage,
}

const animate = function() {
  requestAnimationFrame(animate)

  player.animate(state)
  renderer.render(stage)
}

这是出自 http://codepen.io/assertchris/pen/qaobAz

现在我们开始看到我们游戏中的循环。正如你在图 2-3 中看到的,一秒钟内,我们多次告诉我们的玩家制作动画。播放器考虑了一些内部逻辑并调整了它的精灵。渲染器渲染输出,我们看到的是流畅的动画。

A435434_1_En_2_Fig3_HTML.jpg

图 2-3。

Moving sprites

我们这里唯一缺少的是输入,但我们很快就会做到这一点…

摘要

在这一章中,我们设法建立了一个游戏环境(使用 CodePen)。我们简要地了解了 PixiJS,并看到了如何在屏幕上渲染玩家的精灵。

我们还制作了玩家精灵的动画,并学习了如何使用requestAnimationFrame功能。

花点时间为你的游戏寻找精灵图形。分叉 CodePen,稍微定制一下。重要的是你要对创建精灵和操纵它们有一个感觉。这是你游戏的开始!

三、玩家输入

电影和游戏有什么区别?玩家输入!事实上,这是游戏设计中如此关键的一部分,以至于游戏经常被定义为如何接受玩家的输入。

赛车游戏依赖于持续的、微妙的输入。如果你把手指从键盘上抬起来,或者把脚从踏板上抬起来,你就失去了动量。如果您没有输入正确的组合键,您的飞行模拟将会崩溃。

平台游戏,比如我们正在开发的这个,需要双手参与。通常键盘的右手边是用来移动的。通常键盘的左手边是玩家的动作,比如射击或开门。

也许你想设计一个类似 Terraria ( https://terraria.org )的平台游戏,它使用鼠标进行玩家动作和投射瞄准。也许你想用 WASD 在屏幕上移动你的玩家。我们开始工作吧!

检测输入

当我第一次尝试检测输入时,我试图给所有东西添加事件监听器。我尝试向播放器添加事件侦听器,向舞台添加事件侦听器。事情失去了控制。

诀窍是检测最高级别的事件,并将它们的细节保存到游戏状态对象中。上次我们创建了这种状态对象,并将其传递给了播放器的animate方法。让我们扩展一下这个状态:

let state = {
  "renderer": renderer,
  "stage": stage,
  "keys": {},
  "clicks": {},
  "mouse": {},
}

window.addEventListener("keydown", function(event) {
  state.keys[event.keyCode] = true
})

window.addEventListener("keyup", function(event) {
  state.keys[event.keyCode] = false
})

window.addEventListener("mousedown", function(event) {
  state.clicks[event.which] = {
    "clientX": event.clientX,
    "clientY": event.clientY,
  }
})

window.addEventListener("mouseup", function(event) {
  state.clicks[event.which] = false
})

window.addEventListener("mousemove", function(event) {
  state.mouse.clientX = event.clientX
  state.mouse.clientY = event.clientY
})

这是出自 http://codepen.io/assertchris/pen/rrdZYB

我们首先向游戏状态对象添加渲染器和状态。现在我们已经添加了keysclicksmouse属性,它们跟踪按钮和动作。

Note

对于下一个部分,我必须调整index.html的 CSS,以便画布被绝对定位。这使得更准确地跟踪鼠标运动成为可能。签出源代码以查看更改。

随着我们向游戏中添加更多的对象,我们将把游戏状态对象传递给每个对象。当玩家与游戏互动时,他们可以用它来计算他们应该做什么。让我们移除当前的玩家移动,并添加输入驱动的移动:

animate(state) {
  if (state.keys[37]) { // left
    this.x = Math.max(
      0, this.x - 5
    )
  }

  if (state.keys[39]) { // right
    this.x = Math.min(
      window.innerWidth - 64, this.x + 5
    )
  }

  if (state.clicks[1]) { // left click
    this.x = state.clicks[1].clientX
  }

  this.sprite.x = this.x
  this.sprite.y = this.y
}

这是出自 http://codepen.io/assertchris/pen/rrdZYB

animate方法中几乎没有什么变化。我们检查两个箭头键,如果玩家按下其中一个就移动。我们还检查玩家是否点击了鼠标,如果是,就立即移动。

Tip

不要理会Math功能和幻数64。这些只是为了防止玩家精灵离开屏幕。当我们开始建墙的时候,我们会移除这些东西…

A435434_1_En_3_Fig1_HTML.jpg

图 3-1。

Player input

自然球员运动

玩家现在可以移动一点了。虽然感觉不太好。当我们放开箭头键时,动作就嘎然而止了。游戏只有一个速度:慢。

我们需要的是一些加速度,让玩家在给定的方向上加速。当玩家不再加速时,我们也可以利用摩擦力让他们慢下来。让我们加上这些:

class Player {
  constructor(sprite, x, y) {
    this.sprite = sprite
    this.x = x
    this.y = y

    this.velocityX = 0
    this.maximumVelocityX = 8
    this.accelerationX = 2
    this.frictionX = 0.9

    this.sprite.x = this.x
    this.sprite.y = this.y
  }

  animate(state) {
    if (state.keys[37]) { // left
      this.velocityX = Math.max(
        this.velocityX - this.accelerationX,
        this.maximumVelocityX * -1
      )
    }

    if (state.keys[39]) { // right
      this.velocityX = Math.min(
        this.velocityX + this.accelerationX,
        this.maximumVelocityX
      )
    }

    this.velocityX *= this.frictionX

    this.x += this.velocityX

    // if (state.clicks[1]) { // left click
    //   this.x = state.clicks[1].clientX
    // }

    this.sprite.x = this.x
    this.sprite.y = this.y
  }
}

这是出自 http://codepen.io/assertchris/pen/rrdZYB

哇哦,老虎!让我们逐步回顾一下:

  1. 我们已经创建了一些属性:速度、最大速度、加速度和摩擦力。我们已经将它们设置为合理的默认值。随意试验他们的价值观,直到你欣赏他们的所作所为。
  2. 我们可以开始追踪玩家在任一方向上的加速度。如果我们按下左键,我们开始让玩家向那个方向加速。玩家可以有一个最大加速度,这样他们就不会继续加速。
  3. 如果没有某种反作用力,玩家会继续朝同一个方向移动。这就像在太空中没有空气阻力或重力来抵消动量一样。我们把这个反作用力定义为摩擦力,并用它乘以速度。没有加速度,这意味着动量趋向于零。这给人一种玩家已经停止的感觉。

摘要

我们已经开始让我们的游戏变得互动。我们现在可以按下箭头键,看到我们的球员的精灵顺利地在场景中移动。

我们还通过给精灵的运动增加加速和减速来使事情不那么不和谐。这个逻辑将告诉我们如何创造其他力学(如重力)。

花一点时间来调整你的加速属性,这样你的玩家精灵的移动就正好适合你的游戏。您也可以尝试将其他键映射到左右动作。

四、碰撞检测

我们该谈谈碰撞检测了。它影响了我们游戏中明显的部分,比如我们无法穿过的墙。就像我们不会掉下去的地板。它还会影响到我们游戏中模糊的部分,比如武器发射和关卡。

我们还没到达重力。我们也不会关注玩家的健康和重生。地板、投射物和检查站很有趣,但是它们应该有自己的部分。在这一章中,我们将创建不可逾越的对象。我们将学习知道两个物体是否占据同一个空间的方法。

我花了一些时间研究这个话题。似乎有很多方法可以计算出两个物体是否占据相同的空间。其中一些很容易解释和实现。我们会看的。其他方式都不容易。不过,他们还是很酷。

创建盒子

玩家只是同时存在于屏幕上的众多对象中的一个。我们的游戏是一个平台游戏,所以我们可以期待在任何给定的时间屏幕上至少有一个平台。

平台有一些有趣的特征。有时他们会让玩家摔倒。就像当你站在一个平台上,你按住并按下跳跃(同时)。一些游戏认为这个顺序意味着你想从平台上掉下去。

同样,一些游戏允许玩家在平台底部跳跃。这使得垂直移动在高架平台上没有间隙。

有时候平台甚至会动!

平台是如此的特殊,以至于我们将花几个小节来实现它们不同的行为。但是现在我们要关注另一个常见的物体。通用盒子。

把这个盒子想象成平台的祖先。它可能会共享一些平台功能,但它存在的主要原因是为了与它发生冲突。尤其是像播放器这样的东西。

我们要做的盒子可能看起来都不像盒子。当我们开始实现重力时,我们需要一个又宽又薄的盒子来防止玩家从世界中掉出来。我们还需要又高又薄的盒子来防止球员从地板上跑出来。我们会用它们做墙。我们甚至可以用盒子做盒子。又大又木的“跳到我身上去够更高的东西”的盒子。

好了,说够了。

class Box {
  constructor(sprite, x, y, w, h) {
    this.sprite = sprite
    this.x = x
    this.y = y
    this.w = w
    this.h = h

    this.sprite.x = this.x
    this.sprite.y = this.y
  }

  animate(state) {
    this.sprite.x = this.x
    this.sprite.y = this.y
  }
}

这是出自 http://codepen.io/assertchris/pen/qaokJj

为了制作这个类,我复制粘贴了Player类,删除了一堆东西。我确实需要给它添加宽度和高度属性。我们一会儿会谈到这一点。

接下来,我们需要在舞台上添加两个这样的盒子:

const playerSprite = new PIXI.Sprite.fromImage(
  "path/to/sprites/player-idle.png",
)

const player = new Player(
  playerSprite,
  window.innerWidth / 2,
  window.innerHeight / 2,
  44,
  56,
)

const blob1Sprite = new PIXI.Sprite.fromImage(
  "path/to/sprites/blob-idle-1.png",
)

const blob1 = new Player(
  blob1Sprite,
  (window.innerWidth / 2) - 150,
  (window.innerHeight / 2) - 35,
  48,
  48,
)

const blob2Sprite = new PIXI.Sprite.fromImage(
  "path/to/sprites/blob-idle-2.png",
)

const blob2 = new Player(
  blob2Sprite,
  (window.innerWidth / 2) + 150,
  (window.innerHeight / 2) + 35,
  48,
  48,
)

const stage = new PIXI.Container()
stage.addChild(playerSprite)
stage.addChild(blob1Sprite)
stage.addChild(blob2Sprite)

let state = {
  "renderer": renderer,
  "stage": stage,
  "keys": {},
  "clicks": {},
  "mouse": {},
  "objects": [
    player,
    blob1,
    blob2,
  ],
}

这是出自 http://codepen.io/assertchris/pen/qaokJj

那就奇怪了!我已经创建了两个新的Box实例,并将其命名为blob s。这是因为我们即将看到…

检测圆形碰撞

我要你为这几个盒子画圈。我们首先要做的碰撞检测类型是圆形。盒子有宽度和高度而不是半径是没问题的。你不会经常用这种碰撞检测,除非你的平台游戏里面有很多圈。

让我们看看这种检测是如何工作的:

class Player {
  constructor(sprite, x, y, w, h) {
    this.sprite = sprite
    this.x = x
    this.y = y
    this.w = w
    this.h = h

    this.velocityX = 0
    this.maximumVelocityX = 8
    this.accelerationX = 2
    this.frictionX = 0.9

    this.sprite.x = this.x
    this.sprite.y = this.y
  }

  animate(state) {
    if (state.keys[37]) { // left
      this.velocityX = Math.max(
        this.velocityX - this.accelerationX,
        this.maximumVelocityX * -1,
      )
    }

    if (state.keys[39]) { // right
      this.velocityX = Math.min(
        this.velocityX + this.accelerationX,
        this.maximumVelocityX,
      );

    }

    this.velocityX *= this.frictionX

    let move = true

    state.objects.forEach((object) => {
      if (object === this) {
        return
      }

      var deltaX = this.x - object.x
      var deltaY = this.y - object.y

      var distance = Math.sqrt(
        deltaX * deltaX + deltaY * deltaY,
      );

      if (distance < this.w / 2 + object.w / 2) {
        if (this.velocityX < 0 && object.x <= this.x) {
          move = false
        }

        if (this.velocityX > 0 && object.x >= this.x) {
          move = false
        }
      }
    });

    if (move) {
      this.x += this.velocityX
    }

    this.sprite.x = this.x
    this.sprite.y = this.y
  }
}

这是出自 http://codepen.io/assertchris/pen/qaokJj

我们需要做的第一件事是定义宽度和高度。虽然我们假装我们的玩家和盒子是圆,但我们只需要半个宽度作为半径。

接下来,我们检查状态中的每个对象。我们可以忽略玩家对象,因为我们不需要知道什么时候有东西和自己碰撞。不过,我们确实需要检查其他的东西。

当圆的原点之间的距离小于它们的组合半径时,圆会发生碰撞。它们的中点如此接近,以至于它们的线必须重叠。

我们快速检查玩家移动的方向是否是盒子所在的位置。如果是这种情况,那么我们阻止玩家向那个方向移动。

试一试。看到非正方形如何互相阻挡是相当有趣的。当然,为了这个简单的算法,它们都必须是正圆。

检测矩形碰撞

检测矩形的碰撞几乎和圆一样容易。继续把斑点图像换成方框图像。你甚至可以调整精灵的名字来反映你的盒子的方形。

这一次,我们将把玩家视为一个矩形。代替半径,我们需要检查玩家矩形和任一框之间是否有间隙。我们称之为轴对齐包围盒碰撞检测(简称 AABB)。

如果没有间隙,玩家想要向盒子的方向移动,那么我们会阻止这种情况发生:

let move = true

state.objects.forEach((object) => {
  if (object === this) {
    return
  }

  if (this.x < object.x + object.w &&
      this.x + this.w > object.x &&
      this.y < object.y + object.h &&
      this.y + this.h > object.y) {

    if (this.velocityX < 0 && object.x <= this.x) {
      move = false
    }

    if (this.velocityX > 0 && object.x >= this.x) {
      move = false
    }
  }
})

if (move) {
  this.x += this.velocityX
}

这是出自 http://codepen.io/assertchris/pen/qaokJj

这些是检测碰撞的简单方法,但还有其他方法。有一种使用基于投影的矢量数学(www.sevenson.com.au/actionscript/sat)来确定重叠。还有一个检查几个多边形中的每条线,看看是否有任何线相交( http://stackoverflow.com/questions/9043805/test-if-two-lines-intersect-javascript-function )。太疯狂了。

你甚至可以用几组圆碰撞在一起做实验。那可能会很有趣。在图 4-1 中,我将这个小角色放进这些小盒子里一会儿…

A435434_1_En_4_Fig1_HTML.jpg

图 4-1。

Players and boxes

摘要

在这一章中,我们看了一些方法,可以用来检测游戏中玩家唾液和各种其他物体之间的碰撞。

花些时间重新排列方块和斑点,这样你就能感觉到你的第一关会是什么样子。

五、重力

在这一章中,我们将致力于我们的代码结构,并为我们的游戏增加重力。我们已经完成了重力的大部分工作,所以完成它应该相对简单。

清理我们现有的代码

我们需要清理一些东西!先把xywh(都在BoxPlayer)换成PIXI.Rectangle。他们有这些属性,但他们也以有趣的方式与 PIXI 其他地方互动。

class Player {
  constructor(sprite, rectangle) {
    this.sprite = sprite
    this.rectangle = rectangle

    this.velocityX = 0
    this.maximumVelocityX = 8
    this.accelerationX = 2
    this.frictionX = 0.9
  }

  animate(state) {
    if (state.keys[37]) { // left
      this.velocityX = Math.max(
        this.velocityX - this.accelerationX,
        this.maximumVelocityX * -1,
      )
    }

    if (state.keys[39]) { // right
      this.velocityX = Math.min(
        this.velocityX + this.accelerationX,
        this.maximumVelocityX,
      );
    }

    this.velocityX *= this.frictionX

    let move = true

    state.objects.forEach((object) => {
      if (object === this) {
        return
      }

      const me = this.rectangle
      const you = object.rectangle

      if (me.x < you.x + you.width &&
          me.x + me.width > you.x &&
          me.y < you.y + you.height &&

          me.y + me.height > you.y) {

        if (this.velocityX < 0 && you.x <= me.x) {
          move = false
        }

        if (this.velocityX > 0 && you.x >= me.x) {
          move = false
        }
      }
    })

    if (move) {
      this.rectangle.x += this.velocityX
    }

    this.sprite.x = this.rectangle.x
    this.sprite.y = this.rectangle.y
  }
}

class Box {
  constructor(sprite, rectangle) {
    this.sprite = sprite
    this.rectangle = rectangle
  }

  animate(state) {
    this.sprite.x = this.rectangle.x
    this.sprite.y = this.rectangle.y
  }

}

这是出自 http://codepen.io/assertchris/pen/ALyXKq

注意我们可以删除多少代码?比较有点冗长,但是没有一两个局部变量不能解决的问题。我还意识到我们可以将初始的xy集合操作移到animate

接下来,我想将事件、渲染器和舞台逻辑封装到一个Game类中:

class Game {
  constructor() {
    this.state = {
      "keys": {},
      "clicks": {},
      "mouse": {},
      "objects": [],
    }

    this.animate = this.animate.bind(this)
  }

  get stage() {
    if (!this._stage) {
      this._stage = this.newStage()
    }

    return this._stage
  }

  set stage(stage) {
    this._stage = stage
  }

  newStage() {
    return new PIXI.Container()
  }

  get renderer() {
    if (!this._renderer) {
      this._renderer = this.newRenderer()
    }

    return this._renderer
  }

  set renderer(renderer) {
    this._renderer = renderer
  }

  newRenderer() {
    return new PIXI.autoDetectRenderer(
      window.innerWidth,
      window.innerHeight,
      this.newRendererOptions(),
    )
  }

  newRendererOptions() {
    return {
      "antialias": true,
      "autoResize": true,
      "transparent": true,
      "resolution": 2,
    }
  }

  animate() {
    requestAnimationFrame(this.animate)

    this.state.renderer = this.renderer
    this.state.stage = this.stage

    this.state.objects.forEach((object) => {
      console.log(object)
      object.animate(this.state)
    })

    this.renderer.render(this.stage)
  }

  addEventListenerTo(element) {
    element.addEventListener("keydown", (event) => {
      this.state.keys[event.keyCode] = true
    })

    element.addEventListener("keyup", (event) => {
      this.state.keys[event.keyCode] = false
    })

    element.addEventListener("mousedown", (event) => {
      this.state.clicks[event.which] = {
        "clientX": event.clientX,
        "clientY": event.clientY,
      }
    })

    element.addEventListener("mouseup", (event) => {
      this.state.clicks[event.which] = false
    })

    element.addEventListener("mousemove", (event) => {
      this.state.mouse.clientX = event.clientX
      this.state.mouse.clientY = event.clientY
    })
  }

  addRendererTo(element) {
    element.appendChild(this.renderer.view)
  }

  addObject(object) {
    this.state.objects.push(object)

    this.stage.addChild(object.sprite)
  }
}

这是出自 http://codepen.io/assertchris/pen/ALyXKq

注意类的 getters 和 setters。它们对于根据需要填充可选的依赖项很有用。如果需要,我们可以覆盖rendererstage,但是它们也有合理的默认值。

这里唯一值得注意的区别是,我们不再要求将精灵添加到舞台上,与将对象添加到状态分开。

我对此犹豫不决。一方面,如果我们手动将精灵添加到舞台上,那么发生的事情会清楚得多。另一方面,我们会添加精灵(连接到对象)而不添加对象吗?我不这么认为。

也许我们以后会回来改变这一点。现在,它让事情变得更干净了。让我们添加之前的精灵:

const game = new Game()

game.addObject(
  new Box(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/box.png",
    ),
    new PIXI.Rectangle(
      (window.innerWidth / 2) - 150,
      (window.innerHeight / 2) - 35,
      44,
      44,
    ),
  ),
)

game.addObject(
  new Box(
    new PIXI.Sprite.fromImage(

      "path/to/sprites/box.png",
    ),
    new PIXI.Rectangle(
      (window.innerWidth / 2) + 150,
      (window.innerHeight / 2) + 35,
      44,
      44,
    ),
  ),
)

game.addObject(
  new Player(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/player-idle.png",
    ),
    new PIXI.Rectangle(
      window.innerWidth / 2,
      window.innerHeight / 2,
      44,
      56,
    ),
  ),
)

game.addEventListenerTo(window)
game.addRendererTo(document.body)
game.animate()

这是出自 http://codepen.io/assertchris/pen/ALyXKq

这样看起来好多了!我们现在有足够的控制来设置每个游戏对象的起点。但是你会看到在第一帧之后,animate方法接管了。像重力和碰撞这样的事情将开始控制游戏的进程。然而,它一直都是这样的,所以这个文件现在感觉也是这样的。

此时,我们可以将事件和呈现器限制在一个较小的元素集合中。我们也可以从这里添加任意数量的物体到游戏中。其他与游戏相关的东西都在Game类中。其他所有与玩家或盒子相关的东西都在这些类中。很整洁!

给世界增加重力

让平台游戏变得有趣的一件事是适度的物理量在起作用。首先,让我们添加墙壁和地板:

const game = new Game()

game.addObject

(
  new Box(
    new PIXI.extras.TilingSprite.fromImage(
      "path/to/sprites/floor-tile.png",
      window.innerWidth,
      64,
    ),
    new PIXI.Rectangle(
      0,
      window.innerHeight - 64,
      window.innerWidth,
      64,
    ),
  ),
)

game.addObject(
  new Box(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/box.png",
    ),
    new PIXI.Rectangle(
      0 + 32,
      window.innerHeight - 44 - 64,
      44,
      44,
    ),
  ),
)

game.addObject(
  new Box(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/box.png",
    ),
    new PIXI.Rectangle(
      window.innerWidth - 32 - 44,
      window.innerHeight - 44 - 64,
      44,
      44,
    ),
  ),
)

game.addObject(
  new Player(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/player-idle.png",
    ),
    new PIXI.Rectangle(
      window.innerWidth / 2,
      window.innerHeight / 2,
      44,
      56,
    ),
  ),
)

这是出自 http://codepen.io/assertchris/pen/ALyXKq

在这里,我们添加了一个与整个场景一样宽的地板。我使用了平铺纹理和new PIXI.extras. TilingSprite.fromImage在场景中渲染它。我还移动了两个板条箱来阻止玩家从地板边缘跑出来。它们以和以前一样的方式碰撞。现在,让我们看看如何添加重力:

class Player {
  constructor(sprite, rectangle) {
    this.sprite = sprite
    this.rectangle = rectangle

    this.velocityX = 0
    this.maximumVelocityX = 8
    this.accelerationX = 2
    this.frictionX = 0.9

    this.velocityY = 0
    this.maximumVelocityY = 30
    this.accelerationY = 3
    this.jumpVelocity = -30

    this.isOnGround = false
  }

  animate(state) {
    if (state.keys[37]) { // left
      this.velocityX = Math.max(
        this.velocityX - this.accelerationX,
        this.maximumVelocityX * -1,
      )
    }

    if (state.keys[39]) { // right
      this.velocityX = Math.min(
        this.velocityX + this.accelerationX,
        this.maximumVelocityX,
      )
    }

    this.velocityX *= this.frictionX

    this.velocityY = Math.min(
      this.velocityY + this.accelerationY,
      this.maximumVelocityY,
    )

    state.objects.forEach((object) => {
      if (object === this) {
        return
      }

      var me = this.rectangle
      var you = object.rectangle

      if (me.x < you.x + you.width &&
          me.x + me.width > you.x &&
          me.y < you.y + you.height &&
          me.y + me.height > you.y) {

        if (this.velocityY > 0 && you.y >= me.y) {
          this.velocityY = 0
          return
        }

        if (this.velocityY < 0 && you.y <= me.y) {
          this.velocityY = this.accelerationY
          return
        }

        if (this.velocityX < 0 && you.x <= me.x) {
          this.velocityX = 0
          return
        }

        if (this.velocityX > 0 && you.x >= me.x) {
          this.velocityX = 0
          return
        }
      }
    })

    this.rectangle.x += this.velocityX

    this.rectangle.y += this.velocityY

    this.sprite.x = this.rectangle.x
    this.sprite.y = this.rectangle.y
  }
}

这是出自 http://codepen.io/assertchris/pen/ALyXKq

我们首先创建一组属性来匹配我们用来跟踪水平运动的属性。我们不需要垂直摩擦,因为平台游戏经常忽略这种细节。

我们还必须跟踪垂直和水平碰撞。当碰撞发生在玩家和平台/地板之间时,我们停止向下的速度。当它碰到天花板时,我们用重力代替向上的速度。

允许玩家跳跃

跳跃只是在短时间内逆转重力:

animate(state) {
  if (state.keys[37]) { // left
    this.velocityX = Math.max(
      this.velocityX - this.accelerationX,
      this.maximumVelocityX * -1,
    )
  }

  if (state.keys[39]) { // right
    this.velocityX = Math.min(
      this.velocityX + this.accelerationX,
      this.maximumVelocityX,
    )
  }

  this.velocityX *= this.frictionX

  this.velocityY = Math.min(
    this.velocityY + this.accelerationY,
    this.maximumVelocityY,
  )

  state.objects.forEach((object) => {
    if (object === this) {
      return
    }

    var me = this.rectangle
    var you = object.rectangle

    // ...snip

  if (state.keys[32] && this.isOnGround) {
      this.velocityY = this.jumpVelocity
      this.isOnGround = false
  }

  this.rectangle.x += this.velocityX
  this.rectangle.y += this.velocityY

  this.sprite.x = this.rectangle.x

  this.sprite.y = this.rectangle.y
}

这是出自 http://codepen.io/assertchris/pen/ALyXKq

通过这段代码,我们将空格键映射为跳跃。我们在碰撞检查后添加了键盘检查,因为我们只希望玩家站在平台或地板上时能跳起来。

A435434_1_En_5_Fig1_HTML.jpg

图 5-1。

Adding gravity

现在可以用盒子创建关卡,给它们一些可见的纹理,并在它们周围跳跃。花点时间做个关卡,在里面跳来跳去!

摘要

在这一章中,我们清理了游戏代码。每个部分现在都是独立的——从盒子到玩家到游戏本身。这将使单个游戏对象更容易管理自己。

我们还创造了一个地板和基本的墙壁(用板条箱),这样玩家就不会从世界上掉下来。最后,我们增加了玩家跳跃的能力。现在可以建造一个可穿越的关卡了!

六、梯子

如果玩家只能通过跳过缝隙或跳到箱子上来向上移动,这将是非常有限的。然而,这些并不是唯一的选择。我们仍然需要学习电梯、楼梯和梯子。先从搭丨梯丨子开始吧!

创造我们的第一架梯子

让我们从复制Box类开始我们的阶梯:

class Ladder {
  constructor(sprite, rectangle) {
    this.sprite = sprite
    this.rectangle = rectangle
  }

  animate(state) {
    this.sprite.x = this.rectangle.x
    this.sprite.y = this.rectangle.y
  }
}

// ...later

game.addObject(
  new Box(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/platform.png",
    ),
    new PIXI.Rectangle(
      window.innerWidth - 400,
      window.innerHeight - 64 - 200,
      256,
      64,
    ),
  ),
)

game.addObject(
  new Ladder(
    new PIXI.extras.TilingSprite.fromImage(
      "path/to/sprites/ladder.png",
      44,
      200,
    ),
    new PIXI.Rectangle(
      window.innerWidth - 250,
      window.innerHeight - 64 - 200,
      44,
      200,
    ),
  ),

)

这是出自 http://codepen.io/assertchris/pen/jrzrPw

你需要制作ladder.pngplatform.png图像。一定要在添加玩家之前将这些添加到游戏中,否则梯子精灵会在玩家精灵的前面。

你会注意到玩家撞上了梯子,就好像它是一个盒子。我们需要给我们的盒子和梯子添加一些 getters,这样碰撞检测可以决定它们是否仍然相互碰撞:

state.objects.forEach((object) => {
  if (object === this) {
    return
  }

  const me = this.rectangle
  const you = object.rectangle
  const collides = object.collides

  if (me.x < you.x + you.width &&
      me.x + me.width > you.x &&
      me.y < you.y + you.height &&
      me.y + me.height > you.y) {

    if (collides && this.velocityY > 0 && you.y >= me.y) {
      this.isOnGround = true
      this.velocityY = 0
      return
    }

    if (collides && this.velocityY < 0 && you.y <= me.y) {
      this.velocityY = this.accelerationY
      return
    }

    if (collides && this.velocityX < 0 && you.x <= me.x) {
      this.velocityX = 0
      return
    }

    if (collides && this.velocityX > 0 && you.x >= me.x) {
      this.velocityX = 0
      return
    }

  }
})

这是出自 http://codepen.io/assertchris/pen/jrzrPw

我们给BoxLadder一个collides属性,这样Player.animate就可以忽略与玩家不应该碰撞的物体的碰撞。如果我们要允许多个玩家在同一个游戏/关卡中,那么我们也要给Player添加一个collides属性。也就是说,除非我们希望多个玩家互相碰撞。

A435434_1_En_6_Fig1_HTML.jpg

图 6-1。

Platforms and ladders

允许玩家爬梯子

为了让玩家能够爬梯子,我们必须能够判断他们是否在试图爬梯子。我们还必须暂停重力和侧向运动,这样它们才不会掉下来或滑落:

class Player

{
  constructor(sprite, rectangle) {
    this.sprite = sprite
    this.rectangle = rectangle

    this.velocityX = 0
    this.maximumVelocityX = 8
    this.accelerationX = 2
    this.frictionX = 0.9

    this.velocityY = 0
    this.maximumVelocityY = 30
    this.accelerationY = 3
    this.jumpVelocity = -30

    this.climbingSpeed = 10

    this.isOnGround = false
    this.isOnLadder = false
  }

  animate(state) {
    if (state.keys[37]) { // left
      this.velocityX = Math.max(
        this.velocityX - this.accelerationX,
        this.maximumVelocityX * -1,
      )
    }

    if (state.keys[39]) { // right
      this.velocityX = Math.min(
        this.velocityX + this.accelerationX,
        this.maximumVelocityX,
      )
    }

    this.velocityX *= this.frictionX

    this.velocityY = Math.min(
      this.velocityY + this.accelerationY,
      this.maximumVelocityY,
    )

    state.objects.forEach((object) => {
      if (object === this) {
        return

      }

      const me = this.rectangle
      const you = object.rectangle
      const collides = object.collides

      if (me.x < you.x + you.width &&
          me.x + me.width > you.x &&
          me.y < you.y + you.height &&
          me.y + me.height > you.y) {

        if (object.constructor.name === "Ladder") {
          if (state.keys[38] || state.keys[40]) {
            this.isOnLadder = true
            this.isOnGround = false
            this.velocityY = 0
            this.velocityX = 0
          }

          if (state.keys[38]) {
            this.rectangle.y -= this.climbingSpeed
          }

          if (state.keys[40] &&
            me.y + me.height < you.y + you.height) {
            this.rectangle.y += this.climbingSpeed
          }
        }

        // ...snip
      }
    })

    if (state.keys[32] && this.isOnGround) {
        this.velocityY = this.jumpVelocity
        this.isOnGround = false
    }

    this.rectangle.x += this.velocityX

    if (!this.isOnLadder) {
      this.rectangle.y += this.velocityY
    }

    this.sprite.x = this.rectangle.x
    this.sprite.y = this.rectangle.y
  }
}

这是出自 http://codepen.io/assertchris/pen/jrzrPw

在这段代码中,我们创建了一个isOnLadder变量,这样我们就可以知道玩家是否正站在梯子上。在通常的碰撞检测中,我们会注意玩家碰撞的物体是否是梯子。如果是这样,并且他们正在按向上的箭头,我们开始他们攀登。只有当他们按下向上箭头时,isOnLadder才会被设置,这就是为什么我们需要从这个变量开始。

然后我们重置玩家速度和与跳跃相关的属性。我们也直接改变球员矩形。如果向上箭头被按下,我们将玩家向上移动。

摘要

在本章中,我们添加了梯子和固定平台。我们的玩家现在可以跳跃和爬梯子来浏览关卡。这是 2D 平台游戏的两个基本要素。

你应该知道此时你希望你的游戏是什么样子的。我投资了一个很棒的雪碧包。你可以在 Graphic River ( https://graphicriver.net )找到其他人。

七、楼梯

我们实现了跳跃和攀爬作为垂直穿越一个关卡的主要方式。这些行动是伟大的,但他们限制了平台和梯子组成的水平设计。让我们通过实现楼梯(或类似的爬坡动作)来扩展我们的设计选项。

我们需要为这个部分创建更多的精灵作品,并构建由多种精灵类型组成的更复杂的盒子。让我告诉你我的意思....

建造一个斜坡

我已经创建了新的斜坡精灵。我们还需要添加一些新的对象类型:贴花和斜坡。贴花将会是非碰撞类型,而斜坡将会被玩家的碰撞检测算法识别:

class LeftSlope {
  get collides() {
    return false
  }

  constructor(sprite, rectangle) {
    this.sprite = sprite
    this.rectangle = rectangle
  }

  animate(state) {
    this.sprite.x = this.rectangle.x
    this.sprite.y = this.rectangle.y
  }
}

class RightSlope {
  get collides() {
    return false
  }

  constructor(sprite, rectangle) {
    this.sprite = sprite
    this.rectangle = rectangle
  }

  animate(state) {
    this.sprite.x = this.rectangle.x
    this.sprite.y = this.rectangle.y
  }

}

class Decal {
  get collides() {
    return false
  }

  constructor(sprite, rectangle) {
    this.sprite = sprite
    this.rectangle = rectangle
  }

  animate(state) {
    this.sprite.x = this.rectangle.x
    this.sprite.y = this.rectangle.y
  }
}

// ...later

game.addObject(
  new LeftSlope(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/slope-left.png",
    ),
    new PIXI.Rectangle(
      0 + 250,
      window.innerHeight - 64 - 64 + 1,
      64,
      64,
    ),
  ),
)

game.addObject(
  new RightSlope(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/slope-right.png",
    ),
    new PIXI.Rectangle(
      0 + 250 + 64 + 128,
      window.innerHeight - 64 - 64 + 1,
      64,
      64,
    ),
  ),
)

game.addObject(

  new Decal(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/hill-base.png",
    ),
    new PIXI.Rectangle(
      0 + 250,
      window.innerHeight - 64 + 1,
      128,
      64,
    ),
  ),
)

game.addObject(
  new Box(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/hill-top.png",
    ),
    new PIXI.Rectangle(
      0 + 250 + 64,
      window.innerHeight - 64 - 64 + 1,
      128,
      64,
    ),
  ),
)

这是出自 http://codepen.io/assertchris/pen/dpmOEJ

我们需要在地板后添加这些对象,否则地板会遮住它们。它们一起创造了一座令人愉悦的小山。我们可以跳到它上面,但是当我们试图爬上或爬下这些物体时,事情开始变得令人毛骨悚然。尽管如此,它看起来还是不错的,如图 7-1 所示:

A435434_1_En_7_Fig1_HTML.jpg

图 7-1。

A Little hill

爬坡

为了让我们的玩家爬上斜坡,我们需要再次调整碰撞检测算法:

if (me.x < you.x + you.width &&
    me.x + me.width > you.x &&
    me.y < you.y + you.height &&
    me.y + me.height > you.y) {

  if (object.constructor.name === "LeftSlope") {
    const meCenter = Math.round(me.x + (me.width / 2))
    const youRight = you.x + you.width
    const youBottom = you.y + you.height
    const highest = you.y - me.height
    const lowest = youBottom - me.height

    this.isOnGround = true
    this.isOnSlope = true

    me.y = lowest - (meCenter - you.x)
    me.y = Math.max(me.y, highest)
    me.y = Math.min(me.y, lowest)

    if (me.y >= lowest || me.y <= highest) {
      this.isOnSlope = false
    }

    return
  }

  if (object.constructor.name === "RightSlope") {
    const meCenter = Math.round(me.x + (me.width / 2))
    const youBottom = you.y + you.height
    const highest = you.y - me.height
    const lowest = youBottom - me.height

    this.isOnGround = true
    this.isOnSlope = true

    me.y = highest + (meCenter - you.x)
    me.y = Math.max(me.y, highest)

    me.y = Math.min(me.y, lowest)

    if (me.y >= lowest || me.y <= highest) {
      this.isOnSlope = false
    }

    return
  }

  if (collides && this.velocityY > 0 && you.y >= me.y) {
    this.isOnGround = true
    this.velocityY = 0
    return
  }

  // ...remaining collision detection code
}

if (state.keys[32] && this.isOnGround) {
    this.velocityY = this.jumpVelocity
    this.isOnGround = false
    this.isOnSlope = false
}

这是出自 http://codepen.io/assertchris/pen/dpmOEJ

如果玩家和LeftSlope碰撞,我们不会像和盒子碰撞一样。相反,我们减少玩家的y坐标(向上移动玩家精灵),无论玩家在斜坡的右边多远。换句话说,随着玩家的x增加,他们的顶端减少。

对于RightSlope,我们只是交换了xy的关系,随着玩家的x增加,玩家向下移动。我们还希望玩家能够跳上斜坡,所以我们需要将isOnGround设置为true

最后,如果玩家的底部边缘与斜坡的顶部边缘相同(或更少),我们将isOnSlope设置为false,这样玩家将在水平轴上正确移动。

停留在地面以上

如果您一直遵循代码,您可能会注意到一个奇怪的视觉缺陷。似乎在碰撞检测阻止他们之前,玩家可能会中途摔倒在地板上。

我们可以通过重置适当碰撞的y玩家坐标来解决这个问题:

if (collides && this.velocityY > 0 && you.y >= me.y) {
  me.y = you.y - me.height + 1
  this.isOnGround = true
  this.velocityY = 0
  return
}

if (collides && this.velocityY < 0 && you.y <= me.y) {
  this.velocityY = this.accelerationY
  return
}

if (collides && this.velocityX < 0 && you.x <= me.x) {
  this.velocityX = 0
  return
}

if (collides && this.velocityX > 0 && you.x >= me.x) {
  this.velocityX = 0
  return
}

This is from http://codepen.io/assertchris/pen/dpmOEJ.

现在,当玩家碰到什么东西时,它会再次被推到外面。这将阻止玩家沉入地板。

摘要

在这一章中,我们学习了如何制作玩家可以走上去的斜坡(不需要跳跃)。这些概念转化为一个古老城堡中楼梯的实现。

试着创造你自己的楼梯。也许你的楼梯会比我的更陡或更浅,你需要调整你的玩家上升或下降的速度。

八、相机锁定

到目前为止,我们已经将级别限制到了浏览器窗口的大小。这对于概念验证来说没问题,但是真实的关卡需要比我们目前所能提供的更多的空间。

我已经计划了一些事情来克服这个限制。首先,我们要将摄像机锁定到玩家,这样它会随着玩家的移动而移动。然后,我们将增加级别的大小,以便它可以是我们需要的任何大小。

用照相机包装

PixiJS 不支持任何自由移动的摄像机来渲染场景,但是我们不需要。我们可以将渲染器或场景包装在 HTML 元素中并移动,而不是移动渲染器或场景。我们需要改变嵌入游戏的方式:

<div class="camera"></div>
<div class="focus-target">click to focus</div>
------

.camera, .focus-target {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.focus-target {
  padding: 25px;
  text-align: center;
}
------

game.addEventListenerTo(window)
game.addRendererTo(document.querySelector(".camera"))
game.animate()

这些都出自 http://codepen.io/assertchris/pen/WGzkym

我们首先创建一个占据整个屏幕空间的摄像机。然后,不是将场景附加到document.body,而是将其附加到.camera。这让我们有能力以有趣的方式改造.camera:

animate() {
  requestAnimationFrame(this.animate)

  this.state.renderer = this.renderer
  this.state.stage = this.stage

  this.state.objects.forEach((object) => {
    object.animate(this.state)
  })

  if (this.player) {
    const offsetLeft = Math.round(
      this.player.rectangle.x - (window.innerWidth / 2)
    ) * -1

    const offsetTop = Math.round(
      this.player.rectangle.y - (window.innerHeight / 2)
    ) * -1

    this.element.style = `
      transform:
      scale(1.2)
      translate(${offsetLeft}px)
      translateY(${offsetTop}px)
    `
  }

  this.renderer.render(this.stage)
}

// ...later

const player = new Player(
  new PIXI.Sprite.fromImage(
    "path/to/sprites/player-idle.png",
  ),
  new PIXI.Rectangle(
    Math.round(window.innerWidth / 2),
    Math.round(window.innerHeight / 2),
    44,
    56,
  ),
)

game.addObject(player)
game.player = player

这是出自 http://codepen.io/assertchris/pen/WGzkym

我们需要稍微不同地创建player对象。我们没有直接把它加到game上,而是声明它是一个常量。我们仍然把它添加到game,但是我们也把它作为一个属性分配。

这意味着我们可以在Game.animate中引用它。我们得到玩家的xy坐标,然后减去半个屏幕的innerWidthinnerHeight,这样玩家就会大致在屏幕的中央。

然后我们使用 CSS 转换将.camera向左和向上移动我们刚刚计算的量。我们也可以把相机放大一点,这样东西看起来会稍微放大一点(见图 8-1 )。由于这段代码在Game.animate中,所以每次渲染玩家时都会更新,也就是说它会随着玩家的移动而移动。

A435434_1_En_8_Fig1_HTML.jpg

图 8-1。

Zoomed and locked

增长水平

这导致了一个有趣的发现。当玩家站在我的平台上然后跳跃时,他们穿过渲染场景的顶部(然后消失)。这是因为场景的高度是固定的(与窗口高度相同)。

为了解决这个问题,我们需要改变我们定义游戏尺寸的方式,以及我们在游戏中放置物体的方式:

constructor(w, h) {
  this.w = w
  this.h = h

  this.state = {
    "keys": {},
    "clicks": {},
    "mouse": {},
    "objects": [],
  }

  this.animate = this.animate.bind(this)
}

newRenderer() {
  return new PIXI.autoDetectRenderer(
    this.w, this.h, this.newRendererOptions(),
  )
}

// ...later

const width = window.innerWidth
const height = window.innerHeight + 200

const game = new Game(
  width,
  height,
)

game.addObject(
  new Box(
    new PIXI.extras.TilingSprite.fromImage(
      "path/to/sprites/floor-tile.png",
      width,
      64,
    ),
    new PIXI.Rectangle(
      0,
      height - 64,
      width,
      64,
    ),
  ),
)

// ...remaining object definitions

这是出自 http://codepen.io/assertchris/pen/WGzkym

我们更改Game来接受widthheight构造函数参数。然后,我们使用与提供给game相同的宽度和高度,而不是到处使用window.innerWidthwindow.innerHeight

到目前为止,我们已经相对于屏幕边缘添加了游戏对象。这使得我们可以很容易地改变宽度和高度,而不必重新定位所有的游戏对象。现在我们需要用widthheight常量替换先前(全局)宽度和高度的所有实例。

我们唯一没有删除全局引用的地方是我们刚刚添加的摄像机锁定代码。这些需要相对于窗口(而不是整个级别的大小),所以它们可以保持原样。

摘要

在这一章中,我们学习了如何将我们的层次扩展到窗口之外。既然我们可以将相机锁定在玩家身上,我们就可以创建庞大的迷宫和令人敬畏的城堡。

花些时间考虑如何改善每个关卡的背景和边框,这样它们就不会像我的一样空了。也许你想创建动画背景来表达每一层的基调和位置。

九、发射物

我们的游戏全是键盘控制,但是鼠标呢?这一章都是关于射弹的;如何解雇他们,他们如何移动。

在这一章,我们将添加一些新的,流行的键盘控制(WASD),鼠标瞄准和射击。在这个过程中,我们将添加一个自定义的十字准线。这将会很有趣....

自定义十字线

在显示自定义十字光标之前,我们需要一种方法来禁用默认的鼠标光标。幸运的是,CSS 已经包含了这种机制:

body {
  background: url("path/to/sprites/background.png");
  color: grey;
  font-family: helvetica, arial;
  font-size: 20px;
  cursor: none;
}

这是出自 http://codepen.io/assertchris/pen/YGaYvy

只需将cursor: none加到body上,我们就可以在游戏屏幕上隐藏默认光标。然后,让我们添加十字准线作为新的贴花:

const crosshair = new Decal(
  new PIXI.Sprite.fromImage(
    "path/to/sprites/crosshair.png",
  ),
  new PIXI.Rectangle(
    0, 0, 18, 18,
  ),
)

game.addObject(crosshair)
game.crosshair = crosshair

这是出自 http://codepen.io/assertchris/pen/YGaYvy

十字准线现在可以在游戏屏幕的左上角看到(缩小你的浏览器窗口)。不过,这不是我们想要的。我们真正想要的是它靠近玩家,但是在玩家中心和鼠标光标之间有一个角度。

让我们捕捉光标位置并计算鼠标光标和玩家之间的角度:

element.addEventListener("mousemove", (event) => {
  this.state.mouse.clientX = event.clientX
  this.state.mouse.clientY = event.clientY

  const rect = this.player.rectangle

  const centerX = (window.innerWidth / 2) + (rect.width / 2)
  const centerY = (window.innerHeight / 2) + (rect.height / 2)

  const deltaX = event.clientX - centerX
  const deltaY = centerY - event.clientY

  this.state.angle = Math.atan2(deltaY, deltaX)
})

这段代码出自 http://codepen.io/assertchris/pen/YGaYvy

这段代码读起来有点棘手,但它所做的只是获取鼠标光标和播放器之间的三角形的宽度和高度。我们从三角形中获取角度,并将其存储在游戏状态中。光标将使用这个(一旦我们用cursor = new Cursor替换cursor = new Decal):

class Ladder extends Box {
  get collides() {
    return false
  }
}

class LeftSlope extends Box {
  get collides() {
    return false
  }
}

class RightSlope extends Box {
  get collides() {
    return false
  }
}

class Decal extends Box {
  get collides() {
    return false

  }
}

class Crosshair extends Decal {
  animate(state) {
    const rect = state.player.rectangle

    const centerX = rect.x + (rect.width / 2)
    const centerY = rect.y + (rect.height / 2)
    const radius = 70

    const targetX = centerX + Math.cos(state.angle) * radius
    const targetY = centerY - Math.sin(state.angle) * radius

    this.sprite.x = targetX
    this.sprite.y = targetY
  }
}

这段代码出自 http://codepen.io/assertchris/pen/YGaYvy

在我们深入研究Crosshair.animate方法之前,请注意我是如何缩短LadderLeftSlopeRightSlopeDecal的定义的?我们可以使用extends关键字来继承Box的行为。

Note

继承并不总是带来好的代码架构,但是在这个简单的例子中,它对我们来说很好。

使用我们计算的角度(在mousemove事件监听器中),我们计算十字准线需要渲染的点(见图 9-1 )。

A435434_1_En_9_Fig1_HTML.jpg

图 9-1。

Fixed crosshair

自定义键

到目前为止,我们一直用箭头键移动播放器。如果我们只需要使用键盘,那没问题,但我们刚刚添加了鼠标目标。让我们把流行的 WASD 机芯钥匙添加到Player.animate:

animate(state) {
  if (state.keys[37] || state.keys[65]) { // left
    this.velocityX = Math.max(
      this.velocityX - this.accelerationX,
      this.maximumVelocityX * -1,
    )
  }

  if (state.keys[39] || state.keys[68]) { // right
    this.velocityX = Math.min(
      this.velocityX + this.accelerationX,
      this.maximumVelocityX,
    )
  }

   // ...velocity calculations

  state.objects.forEach((object) => {
    if (object === this) {
      return
    }

    const me = this.rectangle
    const you = object.rectangle
    const collides = object.collides

    if (me.x < you.x + you.width &&
        me.x + me.width > you.x &&
        me.y < you.y + you.height &&
        me.y + me.height > you.y) {

      if (object.constructor.name === "Ladder") {
        if (state.keys[38] || state.keys[40] ||
          state.keys[87] || state.keys[83]) {
          this.isOnLadder = true
          this.isOnGround = false
          this.velocityY = 0
          this.velocityX = 0
        }

        if (state.keys[38] || state.keys[87]) {
          this.rectangle.y -= this.climbingSpeed
        }

        if (state.keys[40] || state.keys[83] &&
          me.y + me.height < you.y + you.height) {
          this.rectangle.y += this.climbingSpeed
        }

        if (me.y <= you.x - me.height) {
          this.isOnLadder = false
        }

        return
      }

      // ...remaining collision detection
    }
  })

  // ...remaining calculations
}

This is from http://codepen.io/assertchris/pen/YGaYvy.

我们添加了state.keys[65]state.keys[68]作为可选的左右键。然后,为了上下移动梯子,我们还增加了state.keys[87]state.keys[83]

射击

最后,让我们添加向十字准线方向射击的功能。为此,我们需要创建另一种类型的对象:

class Bullet extends Decal {
  animate(state) {
    const rect = state.player.rectangle

    this.x = this.x || rect.x + rect.width
    this.y = this.y || rect.y + (rect.height / 2)

    this.angle = this.angle || state.angle
    this.rotation = this.rotation || state.rotation

    this.radius = this.radius || 0
    this.radius += 15

    const targetX = this.x + Math.cos(this.angle) * this.radius
    const targetY = this.y - Math.sin(this.angle) * this.radius

    this.sprite.x = targetX
    this.sprite.y = targetY
    this.sprite.rotation = this.rotation
  }
}

这是出自 http://codepen.io/assertchris/pen/YGaYvy

与大多数其他对象类型的动画不同,它会随着每一次滴答修改其状态。this.x = this.x || something的模式对于初始化一次值很有用。重要的是,我们只在子弹发射时存储像xyanglerotation这样的东西,否则它们会随着我们移动玩家或十字准线而不断变化。

radius从玩家的前方开始,随着子弹射出时十字准线的方向增加。我们也旋转子弹精灵来匹配相同的角度。这要求我们将rotationangle一起存储在鼠标事件监听器中。我们还必须在单击鼠标时创建新的项目符号:

element.addEventListener("mousedown", (event) => {
  this.state.clicks[event.which] = {
    "clientX": event.clientX,
    "clientY": event.clientY,
  }

  if (event.button === 0) { // left click
    const rect = this.player.rectangle

    const bullet = new Bullet(
      new PIXI.Sprite.fromImage(
        "path/to/sprites/bullet.png",
      ),
      new PIXI.Rectangle(
        rect.x + rect.width, rect.y, 8, 8,
      ),
    )

    this.addObject(bullet)

    setTimeout(() => {
      this.removeObject(bullet)
    }, 250)
  }
})

element.addEventListener("mousemove", (event) => {
  this.state.mouse.clientX = event.clientX
  this.state.mouse.clientY = event.clientY

  const rect = this.player.rectangle

  const centerX = (window.innerWidth / 2) + (rect.width / 2)
  const centerY = (window.innerHeight / 2) + (rect.height / 2)

  const deltaX = event.clientX - centerX
  const deltaY = centerY - event.clientY

  const rotationX = event.clientX - centerX
  const rotationY = event.clientY - centerY

  this.state.angle = Math.atan2(deltaY, deltaX)
  this.state.rotation = Math.atan2(rotationY, rotationX)
})

// ...remaining event listeners

这是出自 http://codepen.io/assertchris/pen/YGaYvy

我们计算rotation的方式与计算angle的方式类似,但是我们翻转了垂直轴。当玩家点击时,我们创建一个新的bullet(位于玩家前面)并将其添加到游戏中。

250毫秒后,我们要取出子弹。这是为了当不再需要项目符号时,它们不会减慢动画周期。我们需要将removeObject方法添加到Game类中:

removeObject(object) {
  this.state.objects = this.state.objects.filter(
    function(next) {
      return next !== object
    }
  )

  this.stage.removeChild(object.sprite)
}

这是出自 http://codepen.io/assertchris/pen/YGaYvy

我们将在下一章创建怪物射击时探索更多的投射行为。

摘要

在这一章中,我们看了创建自定义光标和发射物体的方法。我们发现了一点有用的三角学,将十字准线限制在玩家周围的某个半径范围内,然后将子弹推过这个范围。

试验你的投射物的速度和外观。也许你正在建造一个中世纪的游戏,你的投射物是魔法箭。考虑一下增加垂直加速度,这样你的子弹会在一段距离后落到地上。

十、暴徒

如果玩家独自经历这一切,这个游戏会有什么乐趣呢?我们现在需要的是无脑的暴民(或者你喜欢的一群人)巡逻和/或以其他方式使英雄的道路复杂化。

巡逻队

在这一章中,我们将添加巡逻 blobs,这意味着我们需要另一个类来封装它们的逻辑:

class Blob extends Box {
  constructor(sprite, rectangle) {
    super(sprite, rectangle)

    this.limit = 200
    this.left = true
  }

  animate(state) {
    if (this.left) {
      this.rectangle.x -= 2
    }

    if (!this.left) {
      this.rectangle.x += 2
    }

    this.limit -= 2

    if (this.limit <= 0) {
      this.left = !this.left
      this.limit = 200
    }

    this.sprite.x = this.rectangle.x
    this.sprite.y = this.rectangle.y
  }
}

这是出自 http://codepen.io/assertchris/pen/XjEExz

这次我们在构造函数中设置了几个属性。我们仍然希望应用父构造函数,所以我们调用super,提供预期的spriterectangle参数。

然后,在animate方法中,我们向左或向右移动斑点2像素(取决于斑点是否在移动left)。一旦斑点在同一个方向移动了200像素,我们就把它转过来(并重置另一个方向的200像素)。

我们应该在我们的关卡周围点缀一些:

game.addObject(
  new Blob(
    new PIXI.Sprite.fromImage(
      "path/to/sprites/blob-idle-1.png",
    ),
    new PIXI.Rectangle(
      width - 450,
      height - 64 - 48,
      48,
      48,
    ),
  ),
)

这是出自 http://codepen.io/assertchris/pen/XjEExz

这段代码会把 blob 放在玩家的起始位置旁边,如图 10-1 所示(至少在我这个级别)。看着它来回移动,甚至让玩家跳到它头上,这很有趣。

A435434_1_En_10_Fig1_HTML.jpg

图 10-1。

Patrolling blob

射杀暴徒

在上一章中,我们增加了玩家发射射弹的能力。让我们通过给子弹本身添加一些碰撞检测来使用它们:

class Bullet extends Decal {
  animate(state) {
    const rect = state.player.rectangle

    this.x = this.x || rect.x + rect.width
    this.y = this.y || rect.y + (rect.height / 2)
    this.angle = this.angle || state.angle
    this.rotation = this.rotation || state.rotation
    this.radius = this.radius || 0

    this.radius += 10

    const targetX = this.x + Math.cos(this.angle) * this.radius
    const targetY = this.y - Math.sin(this.angle) * this.radius

    this.rectangle.x = targetX
    this.rectangle.y = targetY

    this.sprite.x = targetX
    this.sprite.y = targetY
    this.sprite.rotation = this.rotation

    state.objects.forEach((object) => {
      if (object === this) {
        return
      }

      const me = this.rectangle
      const you = object.rectangle

      if (me.x < you.x + you.width &&
          me.x + me.width > you.x &&
          me.y < you.y + you.height &&
          me.y + me.height > you.y) {

        if (object.constructor.name === "Blob") {
          state.game.removeObject(object)
          state.game.removeObject(this)
        }
      }
    })
  }
}

这是出自 http://codepen.io/assertchris/pen/XjEExz

这个冲突检测逻辑类似于我们在Player类中做的第一遍。我们不需要检查斜坡或梯子之类的东西。我们感兴趣的是子弹是否会撞上一个斑点。如果是这样,我们将两者从游戏中移除。

Note

我还稍微降低了子弹速度。认为一个玩家几乎可以追上一颗飞驰的子弹是不太现实的,但是这样确实感觉好一点。

这并不像 blob 的生命值稳步下降那么优雅,但是我们稍后会重新讨论这个话题。

摘要

在这一章中,我们在游戏中加入了一种简单的暴徒。我们也授权我们的子弹去派遣这些暴徒。他们不是最聪明的(甚至不是最持久的)暴民,但他们是一个开始。

这是你可以真正发挥创造力的地方。如果你给小怪的运动加上重力会怎么样?还是让它们静止不动,直到玩家靠近?如果他们能还击呢?

十一、健康

我很少玩第一次失误就导致立即失败的游戏。通常,在失败的最后时刻会有一个相当大的引导。索尼克( https://en.wikipedia.org/wiki/Sonic_the_Hedgehog )失去了戒指,马里奥失去了力量。

本章的目标是让我们实现一个健康系统,这样玩家就有机会犯错并从中吸取教训。

受到伤害

玩家只有一种可能失败的方式(在我们的游戏中,到目前为止):从关卡边缘跳下。然而,这并不是一个现实的失败条件,因为构建良好的关卡会以这样一种方式建立,玩家永远不能到达关卡的边界之外。

在我们能够计算出健康损失的细节之前,我们需要引入另一种导致健康损失的机制。在这里,我们将介绍一种机制,当我们接触到粘液时,它会导致我们失去健康(并暂时失去控制)。

首先,我们将不得不在Player.animate中引入更多的冲突检测逻辑:

if (object.constructor.name === "Blob" && !this.invulnerable) {
  if (this.velocityX >= 0) {
    this.velocityX = -10
  } else {
    this.velocityX = 10
  }

  this.velocityY *= -1

  this.invulnerable = true
  this.sprite.alpha = 0.5

  setTimeout(() => {
    this.invulnerable = false
    this.sprite.alpha = 1
  }, 2000)

  if (typeof this.onHurt === "function") {
    this.onHurt.apply(this)

  }
}

这是出自 http://codepen.io/assertchris/pen/qaoyPo

我们之前在Bullet.animate中添加了Blob特有的碰撞检测。现在,我们将它添加到Player.animate中,这样玩家在接触到Blob时就会“受到伤害”。

Note

我在这里和之前已经硬编码了很多东西。您可以自由地将硬编码的值抽象出来,但是为了节省时间和保持简单,我选择不在每个实例中都这样做。例如,您可以完全移除alpha逻辑,并为构造函数提供invulnerable持续时间。

现在,当玩家和 blob 连接时,玩家被从 blob 向上和向后抛出。玩家也进入无敌状态,这意味着他们不会在2000毫秒内失去所有生命值。既然我们有了伤害玩家的方法,那就来做点什么吧。

显示健康

注意到关于onHurt的那一点了吗?我不想硬编码显示玩家当前健康状况的界面变化。通过调用用户提供的函数,我们可以将该行为外包给创建播放器的代码。

尝试将所有东西都放入 PixiJS 模型很诱人,但我们是在 web 环境中编码。我们将使用 HTML 和 CSS 显示健康栏,而不是通过我们的 PixiJS 渲染器和场景来呈现玩家的健康状况。而且,既然我们有办法将内部破坏行为与外部环境联系起来(通过onHurt),这应该不会太难。

让我们创建所需的 HTML 元素:

<div class="camera"></div>
<div class="hud">
  <div class="heart heart-1"></div>
  <div class="heart heart-2"></div>
  <div class="heart heart-3"></div>
</div>
<div class="focus-target">click to focus</div>
-------

.camera, .hud, .focus-target {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.hud {
  .heart {
    width: 32px;
    height: 28px;
    background-image: url("path/to/sprites/heart-red.png");
    position: absolute;
    top: 15px;
  }

  .heart-1 {
    left: 15px;
  }

  .heart-2 {
    left: 57px;
  }

  .heart-3 {
    left: 99px;
  }

  .heart-grey {
    background-image: url("path/to/sprites/heart-grey.png");
  }
}

这是出自 http://codepen.io/assertchris/pen/qaoyPo

这个标记在屏幕的左上方添加了三个红心。当玩家受伤时,我们会将每个颜色变成灰色:

let hearts = 3

player.onHurt = function()

{
  document.querySelector(".heart-" + hearts)
    .className += " heart-grey"

  hearts--

  if (hearts < 1) {
    alert("game over!")
    game.removeObject(player)
    game.removeObject(crosshair)
  }
}

这是出自 http://codepen.io/assertchris/pen/qaoyPo

这比我一开始预想的要简单很多。每次调用onHurt函数时,我们获取与我们剩余的红心数量相关的元素,并将其变为灰色,如图 11-1 所示(这要感谢我们之前添加的.heart-grey类)。

A435434_1_En_11_Fig1_HTML.jpg

图 11-1。

Taking damage

如果玩家用完了最后一颗心,我们会弹出一个警告(虽然这可能是一个程式化的“游戏结束”消息),并从游戏中移除玩家和十字准线。

摘要

在这一章中,我们增加了玩家失败的第一种合法方式。现在有一些危险,这种经历应该更愉快。

我们还创造了一种将内部事件(比如玩家受伤)与外部行为联系起来的方法。当玩家受伤时,你不必杀死他们。你可以试着把他们传送回关卡的起点或者减少他们的能力。选择权在你。

十二、动画

我们即将结束短暂而愉快的旅程。是时候开始添加一些收尾工作了。例如,我们的精灵过于坚忍。让我们来制作动画吧!

为玩家制作动画

玩家精灵是整个游戏中最重要的部分之一。我们可以花很长时间盯着这个小动物,如果我们不添加一点动画,它会非常无聊。

幸运的是,PixiJS 提供了一些工具来简化这个过程。让我们开始使用其中的一个:

const playerIdleLeftImages = [
  "path/to/sprites/player-idle-1.png",
  "path/to/sprites/player-idle-2.png",
  "path/to/sprites/player-idle-3.png",
  "path/to/sprites/player-idle-4.png",
]

const playerIdleLeftTextures =
  playerIdleLeftImages.map(function(image) {
    return PIXI.Texture.fromImage(image)
  })

const playerIdleLeftSprite =
  new PIXI.MovieClip(playerIdleLeftTextures)

playerIdleLeftSprite.play()
playerIdleLeftSprite.animationSpeed = 0.12

const player = new Player(
  playerIdleLeftSprite,
  new PIXI.Rectangle(
    Math.round(width / 2),
    Math.round(height / 2),
    48,
    56,
  ),
)

player.idleLeftSprite = playerIdleLeftSprite

这是出自 http://codepen.io/assertchris/pen/ALyPGw

我们用new PIXI.MovieClip代替了new PIXI.Sprite.fromImage。我们首先创建一个图像数组(任何好的精灵包都应该有)并为每个图像创建新的PIXI.Texture对象。

我们需要调用play让动画开始,调整动画速度也无妨。我们将很快看到为什么存储动画的引用是一个好主意。

Tip

没有静态图像可以做到这一点。看看 CodePen,看看它在动!

交换动画

我们不仅可以让玩家看起来静止不动。例如,我们可以添加行走、跳跃甚至受伤的动画。让我们开始添加运行动画。动画初始化代码如下所示:

const playerIdleLeftImages = [
  "path/to/sprites/player-idle-1.png",
  "path/to/sprites/player-idle-2.png",
  "path/to/sprites/player-idle-3.png",
  "path/to/sprites/player-idle-4.png",
]

const playerIdleLeftTextures =
  playerIdleLeftImages.map(function(image) {
    return PIXI.Texture.fromImage(image)
  })

const playerIdleLeftSprite = new PIXI.MovieClip(playerIdleLeftTextures)

playerIdleLeftSprite.play()
playerIdleLeftSprite.animationSpeed = 0.12

const playerRunLeftImages = [
  "path/to/sprites/player-run-1.png",
  "path/to/sprites/player-run-2.png",
  "path/to/sprites/player-run-3.png",
  "path/to/sprites/player-run-4.png",
  "path/to/sprites/player-run-5.png",
  "path/to/sprites/player-run-6.png",
  "path/to/sprites/player-run-7.png",
  "path/to/sprites/player-run-8.png",
  "path/to/sprites/player-run-9.png",
  "path/to/sprites/player-run-10.png",
]

const playerRunLeftTextures =
  playerRunLeftImages.map(function(image) {
    return PIXI.Texture.fromImage(image)
  })

const playerRunLeftSprite = new PIXI.MovieClip(playerRunLeftTextures)

playerRunLeftSprite.play()
playerRunLeftSprite.animationSpeed = 0.2

const player = new Player(
  playerIdleLeftSprite,
  new PIXI.Rectangle(
    Math.round(width / 2),
    Math.round(height / 2),
    48,
    56,
  ),
)

player.idleLeftSprite = playerIdleLeftSprite
player.runLeftSprite = playerRunLeftSprite

game.addObject(player)
game.player = player

这是出自 http://codepen.io/assertchris/pen/ALyPGw

添加动画确实很有趣,但也可能相当乏味。我们需要裁剪和导出每个动作的每一帧。然后,我们需要将它们拼接成许多图像数组和动画精灵。

最好将每个动画精灵的引用分配给player。这样,我们可以在Player.animate中交换它们:

this.rectangle.x += this.velocityX

if (!this.isOnLadder && !this.isOnSlope) {
  this.rectangle.y += this.velocityY
}

if (this.isOnGround && Math.abs(this.velocityX) < 0.5) {
  state.game.stage.removeChild(this.sprite)
  state.game.stage.addChild(this.idleLeftSprite)
  this.sprite = this.idleLeftSprite
}

if (this.isOnGround && Math.abs(this.velocityX) > 0.5) {
  state.game.stage.removeChild(this.sprite)
  state.game.stage.addChild(this.runLeftSprite)
  this.sprite = this.runLeftSprite
}

this.sprite.x = this.rectangle.x
this.sprite.y = this.rectangle.y

这是出自 http://codepen.io/assertchris/pen/ALyPGw

animate方法的最后,我们可以检查玩家是否还在移动(很多)。如果没有,我们用空闲的动画来交换当前可见的精灵。

如果玩家还在移动,我们就把可见的精灵换成奔跑的动画。这个逻辑只在一个方向上起作用,但是我们可以推断出这个行为包含了很大范围的动画和方向。

摘要

在这一章中,我们给游戏添加了一些动画(不是巡逻的暴徒那种)。我们可以花几个小时观察运动员的头发上下起伏。

我们可以添加许多不同种类的动画:

  • 跳跃和/或坠落
  • 受伤了
  • 着陆(带着一阵灰尘)
  • 爬梯
  • 发射射弹(手持武器)

我们只添加了两个,但您还可以添加更多。这些的关键是找到(或设计)一个好的雪碧包;把所有的东西都缝好。如果你能做到这一点,你就能制作出一个精美的动画游戏!

十三、声音

我最喜欢的游戏记忆是我玩我最喜欢的游戏时听的音乐和声音。无论是 Bastion 和 Fez 的音乐还是 Stardew 的声音,我们的耳朵都可以帮助我们充分欣赏游戏。

添加背景音乐

没有什么比好的配乐更能让你沉浸在游戏中了。最好的游戏会根据玩家的心情和位置来交换背景音乐。我们将从更简单的事情开始:

game.addEventListenerTo(window)
game.addRendererTo(document.querySelector(".camera"))
game.animate()

const music = new Audio("path/to/sounds/background.mp3")
music.loop = true
music.play()

点击此链接前,请调低音量,它会播放音乐。

这是出自 http://codepen.io/assertchris/pen/wzmQWb

在现代浏览器中播放声音其实很容易。快速搜索一个循环 MP3 和这个新的Audio对象正是我们所需要的。然而,如果我们想让背景音乐循环播放,我们需要将loop属性设置为true

Note

没有一种简单的方法可以让Audio对象循环而不在它们之间留有空隙。你可以考虑一个替代方案,比如 SoundManager2 ( https://github.com/scottschiller/SoundManager2 )。

添加动作和事件声音

添加游戏音效(为玩家发起的事件和动作),需要我们跳到动作和事件发生的地方:

element.addEventListener("keydown", (event) => {
  this.state.keys[event.keyCode] = true

  if (event.keyCode === 32) {
    new Audio("path/to/sounds/jump.wav").play()
  }
})

这是出自 http://codepen.io/assertchris/pen/wzmQWb

我们第一次尝试跳跃时,音效似乎需要一段时间加载才能播放。我们可以通过在玩家开始移动之前预加载所有声音来解决这个问题。

事实上,在游戏开始前预加载所有游戏资源(如字体、图像和声音)通常是个好习惯。在声音的情况下,我们只需要添加以下代码,在游戏开始预加载它之前:

new Audio("path/to/sounds/jump.wav").load()

这将启动加载过程,声音文件应该在使用时被加载(只要互联网连接足够快)。还有其他方法来确保所有的声音文件都被加载,但是它们使这个过程变得相当复杂。我想这是另一个时间的话题…

摘要

在这一章中,我们简单看了一下如何在游戏中嵌入背景音乐和动作/事件声音。现代浏览器为此提供了很好的工具,但如果我们需要支持旧浏览器或需要对播放进行更大的控制,我们总是可以求助于像 SoundManager2 这样的库。

购买许多声音、音乐文件和精灵(或精灵包)可能会很贵。你可能想让图形和声音艺术家参与到游戏的制作过程中来。

十四、游戏手柄

我们差不多完成了。在我们分道扬镳之前,我认为尝试一下游戏手柄会很有趣。只有少数浏览器通过 JavaScript 支持它们,但它们使用起来确实很有趣!

处理事件

游戏手柄事件的工作方式与我们目前看到的键盘和鼠标事件略有不同。由于它们的实验支持,我们需要以某种方式捕捉它们,在Game.animate内部:

constructor(w, h) {
  this.w = w
  this.h = h

  this.state = {
    "game": this,
    "keys": {},
    "clicks": {},
    "mouse": {},
    "buttons": {},
    "objects": [],
    "player": null,
    "crosshair": null,
  }

  this.animate = this.animate.bind(this)
}

// ...later

animate() {
  requestAnimationFrame(this.animate)

  this.state.renderer = this.renderer
  this.state.stage = this.stage
  this.state.player = this.player
  this.state.crosshair = this.crosshair

  let gamepads = []

  if (navigator.getGamepads) {
    gamepads = navigator.getGamepads()
  }

  if (navigator.webkitGetGamepads) {
    gamepads = navigator.webkitGetGamepads
  }

  if (gamepads) {
    const gamepad = gamepads[0]

    gamepad.buttons.forEach((button, i) => {
      this.state.buttons[i] = button.pressed
    })
  }

  // ...remaining animation code
}

这是出自 http://codepen.io/assertchris/pen/WGzYgA

在找到我们使用的浏览器支持的游戏手柄列表之前,我们需要尝试一些不同的方法。我用的是 Chrome 的现代版本,支持 JavaScript Gamepad API。

我们捕获每个按钮的按下状态,并将其存储在state.buttons对象中(我们在constructor中初始化了该对象)。考虑到我们给Player. animate增加了多少复杂性,我认为是时候对其进行一点重构了:

animate(state) {
  const leftKey = state.keys[37] || state.keys[65]
  const leftButton = state.buttons && state.buttons[13]
  const rightKey = state.keys[39] || state.keys[68]
  const rightButton = state.buttons && state.buttons[14]
  const upKey = state.keys[38] || state.keys[87]
  const upButton = state.buttons && state.buttons[11]
  const jumpKey = state.keys[32]
  const jumpButton = state.buttons && state.buttons[0]

  if (leftKey || leftButton) {
    this.velocityX = Math.max(
      this.velocityX - this.accelerationX,
      this.maximumVelocityX * -1,
    )
  }

  if (rightKey || rightButton) {
    this.velocityX = Math.min(
      this.velocityX + this.accelerationX,
      this.maximumVelocityX,
    )
  }

  this.velocityX *= this.frictionX

  this.velocityY = Math.min(
    this.velocityY + this.accelerationY,
    this.maximumVelocityY,

  )

  state.objects.forEach((object) => {
    if (object === this) {
      return
    }

    const me = this.rectangle
    const you = object.rectangle
    const collides = object.collides

    if (me.x < you.x + you.width &&
        me.x + me.width > you.x &&
        me.y < you.y + you.height &&
        me.y + me.height > you.y) {

      if (object.constructor.name === "Ladder") {
        if (upKey || upButton) {
          this.rectangle.y -= this.climbingSpeed
          this.isOnLadder = true
          this.isOnGround = false
          this.velocityY = 0
          this.velocityX = 0
        }

        if (me.y <= you.x - me.height) {
          this.isOnLadder = false
        }

        return
      }

      // ...remaining collision detection code
    }
  })

  if ((jumpKey || jumpButton) && this.isOnGround) {
      this.velocityY = this.jumpVelocity
      this.isOnGround = false
      this.isOnSlope = false

  }

  // ...remaining movement code
}

这是出自 http://codepen.io/assertchris/pen/WGzYgA

这里我们定义了一些常量(代表按下的键盘按键和游戏手柄按钮)。假设键盘存在是很容易的,但是游戏手柄就不那么确定了。

这就是为什么我们将几个不同的键盘按键合并到一个检查中,但是每个游戏手柄按钮检查都要求我们首先确保已经定义了任何游戏手柄按钮。

通过这段代码,我们将方向按钮(D-Pad)和跳转按钮(PS 兼容控制器上的一个)映射到相应的播放器动作。

触发器和操纵杆

触发器和操纵杆比按钮要困难得多(而且容易在游戏手柄设计上产生差异)。我使用的是兼容 PS 的 Logitech 游戏手柄,触发器被映射到gamepad.axes对象,操纵杆也是如此。

在所有 6 个轴上,捕捉值的范围从-11。静止时,操纵杆不完全是0,这意味着我们需要使用一些ε值( https://en.wikipedia.org/wiki/Epsilon )或舍入来确定轴是否静止。

我们还需要修改三角方程,以考虑输入值/比例的差异。我想我要说的是,我们需要考虑我们想要支持哪些游戏手柄,以及我们的玩家需要哪些浏览器来使用它们。

摘要

在这一章中,我们尝试了游戏手柄的海洋。我们很幸运生活在一个浏览器越来越支持使用带有 JavaScript 的游戏手柄的时代,但是在它变得简单或普遍之前还有很多工作要做。

如果你觉得自己很勇敢,也许你会尝试将游戏手柄的触发器和操纵杆映射到游戏动作上(比如瞄准和发射射弹)。

我想花点时间感谢您阅读这本书。对我来说,这是一个短暂但激动人心的项目,我希望你和我一样(如果不是更多的话)了解并喜欢它。

正如我在开头所说的:如果有任何问题或改进建议,请随时联系我。鉴于示例的动态性质,我将能够修复错误并添加关于如何改进它们的评论。

我的推特账号是 https://twitter.com/assertchris

有时,我也会边编码边流式传输。你可以加入进来,问问题(实时),和我一起学习。

我的抽搐通道是 https://www.twitch.tv/assertchris

posted @ 2024-08-19 17:24  绝不原创的飞龙  阅读(13)  评论(0编辑  收藏  举报