十、暴徒
如果玩家独自经历这一切,这个游戏会有什么乐趣呢?我们现在需要的是无脑的暴民(或者你喜欢的一群人)巡逻和/或以其他方式使英雄的道路复杂化。
巡逻队
在这一章中,我们将添加巡逻 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
,提供预期的sprite
和rectangle
参数。
然后,在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 所示(至少在我这个级别)。看着它来回移动,甚至让玩家跳到它头上,这很有趣。
图 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
类)。
图 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 个轴上,捕捉值的范围从-1
到1
。静止时,操纵杆不完全是0
,这意味着我们需要使用一些ε值( https://en.wikipedia.org/wiki/Epsilon
)或舍入来确定轴是否静止。
我们还需要修改三角方程,以考虑输入值/比例的差异。我想我要说的是,我们需要考虑我们想要支持哪些游戏手柄,以及我们的玩家需要哪些浏览器来使用它们。
摘要
在这一章中,我们尝试了游戏手柄的海洋。我们很幸运生活在一个浏览器越来越支持使用带有 JavaScript 的游戏手柄的时代,但是在它变得简单或普遍之前还有很多工作要做。
如果你觉得自己很勇敢,也许你会尝试将游戏手柄的触发器和操纵杆映射到游戏动作上(比如瞄准和发射射弹)。
我想花点时间感谢您阅读这本书。对我来说,这是一个短暂但激动人心的项目,我希望你和我一样(如果不是更多的话)了解并喜欢它。
正如我在开头所说的:如果有任何问题或改进建议,请随时联系我。鉴于示例的动态性质,我将能够修复错误并添加关于如何改进它们的评论。
我的推特账号是 https://twitter.com/assertchris
。
有时,我也会边编码边流式传输。你可以加入进来,问问题(实时),和我一起学习。
我的抽搐通道是 https://www.twitch.tv/assertchris
。