二十六、游戏物理学

在上一章中,您看到了如何创建动画角色。您还了解了如何从本地存储中加载关卡和关卡状态,以及如何构建基于图块的游戏世界。最重要的一个方面仍然缺失:定义角色如何与游戏世界互动。你可以让一个角色从左向右移动,但是如果你只是简单地把角色放在关卡中,它只能在屏幕的底部行走。这还不够。您希望角色能够跳到墙砖上,并在它离开墙砖时掉下来,并且您不希望角色从屏幕边缘掉下来。对于这些东西,你需要实现一个基本的物理系统。因为它是与世界交互的角色,所以你在Player类中实现这个物理。处理物理有两个方面:赋予角色跳跃或坠落的能力,处理角色与其他游戏对象之间的碰撞并对这些碰撞做出响应。

锁定游戏世界中的角色

你要做的第一件事就是锁定游戏世界中的角色。在第 25 章的例子中,角色可以毫无问题地走出屏幕。你可以通过在屏幕左右放置一堆虚拟的墙式瓷砖来解决这个问题。然后你假设你的碰撞检测机制(你还没有写)将确保角色不能穿过这些墙。你只想防止角色走出屏幕的左侧或右侧。角色应该能够跳出屏幕顶部的视线。这个角色还应该能够通过地上的一个洞从游戏世界中掉下来(很明显,会死掉)。

为了在屏幕的左右两侧构建虚拟的墙砖堆,您必须向墙砖网格添加一些行为。你不想修改GameObjectGrid类。这种行为与游戏对象的网格无关,但它是你的平台游戏特有的。因此,您定义了一个名为TileField的新类,而继承了GameObjectGrid类。您向名为getTileType的类添加一个方法,该方法返回给定其在网格上的 xy 位置的图块的类型。这种方法的好处是允许这些索引落在网格中有效索引的之外。例如,询问位置(-2,500)的牌的牌类型就可以了。通过在该方法中使用if指令,您可以检查 x 步进是否超出范围。如果是,则返回一个普通的(墙)瓷砖类型:

if (x < 0 || x >= this.columns)
    return TileType.normal;

如果 y 指数超出范围,你返回一个背景平铺类型,这样角色可以跳过屏幕的顶部或者掉进一个洞:

if (y < 0 || y >= this.rows)
    return TileType.background;

如果两个if指令的条件都是false,这意味着网格中实际图块的类型被请求,因此您检索该图块并返回其图块类型:

return this.at(x, y).type;

完整的类可以在属于本章的示例程序TickTick2中找到。如果您想以更符合 JavaScript 哲学的方式扩展GameObjectGrid类,您可以在一个单独的 JavaScript 文件中将getTileType方法直接添加到GameObjectGrid类中。您可以调用文件GameObjectGrid_ext.js,它将包含一个添加到GameObjectGrid的方法,该方法将是:

GameObjectGrid.prototype.getTileType = function (x, y) {
    if (x < 0 || x >= this.columns)
        return TileType.normal;
    if (y < 0 || y >= this.rows)
        return TileType.background;
    return this.at(x, y).type;
};

通过这种方式,您不需要创建一个新的类,而是简单地向GameObjectGrid注入您需要的行为。

将字符设置在正确的位置

当你从关卡数据变量中加载关卡时,你使用字符1来表示玩家角色开始的关卡。基于该图块的位置,您必须创建Player对象并将其设置在正确的位置。为此,您向Level类添加一个方法loadStartTile。在这种方法中,首先检索图块字段,然后计算角色的起始位置。因为角色的原点是精灵底部中心的,你可以如下计算这个位置:

var startPosition = new powerupjs.Vector2((x + 0.5) * tiles.cellWidth,
    (y + 1) * tiles.cellHeight);

请注意,您使用了瓷砖的宽度和高度,并将它们乘以角色应该位于的位置的 xy 索引。单元格宽度乘以x + 0.5,因此字符被放置在图块位置的中间,单元格高度乘以y + 1,以将字符放置在图块的底部。然后,您可以创建Player对象并将其添加到游戏世界:

this.add(new Player(startPosition, ID.layer_objects, ID.player));

最后,您仍然需要在这里制作一个可以存储在网格中的实际瓷砖,因为每个字符应该代表一个瓷砖。在这种情况下,您可以创建一个背景单幅图块,放置在角色站立的位置:

return new Tile();

跳跃…

你已经看到了一个角色如何向左或向右行走。你如何处理跳跃和坠落?如果游戏在有键盘的设备上运行,当玩家按下空格键时,角色就会跳跃。

使用空格键跳跃在很大程度上是一种传统。游戏中常用的还有其他按键,比如用 Q 和 E 扫射;用 W、A、D、X 定向移动;用 S 停止或刹车;等等。在你的游戏中使用这些或多或少被接受的标准会给你的用户提供更好的体验,因为他们已经知道这个界面了。

当玩家按下空格键跳跃时,基本上意味着角色获得一个负 y-速度。这可以在Player类的handleInput方法中轻松完成

if (powerupjs.Keyboard.pressed(powerupjs.Keys.space))
    this.jump();

jump方法如下:

Player.prototype.jump = function (speed) {
    speed = typeof speed !== 'undefined' ? speed : 1100;
    this.velocity.y = -speed;
};

所以,在不提供任何参数值的情况下调用jump方法的效果是y-速度被设置为 1100。我随机选择了这个数字。使用更大的数字意味着角色可以跳得更高。较低的数字意味着角色必须更频繁地去健身房,或者戒烟。我选择了这个值,这样角色可以跳得足够高来够到瓷砖,但又不会高到让游戏变得太容易(这样角色就可以跳到关卡的末尾)。

这种方法有一个小问题:你总是允许玩家的角色跳跃,不管角色当前的情况如何。因此,如果角色正在跳下或跌落悬崖,你允许玩家让角色跳回安全的地方。这不是你真正想要的。你想让角色只在站在地上的时候跳。这是可以通过观察角色与墙壁或平台瓷砖(角色可以站立的唯一瓷砖)之间的碰撞来检测的。现在让我们假设您尚未编写的碰撞检测算法将会处理这个问题,并通过使用一个成员变量来跟踪角色是否在地面上:

this.onTheGround = true;

有时候,在编写一个类之前,有必要用英语(相对于 JavaScript)勾画出一个类,这样你就可以编写游戏的其他部分了。在碰撞检测的情况下也是如此。在构建冲突检测算法之前,您无法对其进行测试,但是在创建并测试该算法之前,您不会想要构建它。一个必须先发生,所以你必须在心理上知道另一个发生了什么,并计划它或做笔记。CollisionTest 例子是我写的一个程序,用来测试独立于游戏的碰撞检测算法。您可能会发现,在某些情况下,编写单独的测试程序有助于您理解部分代码应该如何工作。

如果这个成员变量是true,你就知道这个角色是站在地上的。你现在可以改变最初的if指令,这样它只允许一个角色从地上跳,而不允许从空中跳:

if (powerupjs.Keyboard.pressed(powerupjs.Keys.space) && this.onTheGround)
    this.jump();

如果你在没有键盘的设备上玩游戏(比如平板电脑或智能手机),你必须以不同的方式处理玩家的输入。一种方法是在屏幕上添加几个按钮,只有当触摸输入可用时,这些按钮才能控制玩家角色。这是在创建Level对象时完成的:

if (powerupjs.Touch.isTouchDevice) {
    var walkLeftButton = new powerupjs.Button(sprites.buttons_player,
        ID.layer_overlays, ID.button_walkleft);
    walkLeftButton.position = new powerupjs.Vector2(10, 500);
    this.add(walkLeftButton);
    var walkRightButton = new powerupjs.Button(sprites.buttons_player,
        ID.layer_overlays, ID.button_walkright);
    walkRightButton.position = new powerupjs.Vector2(walkRightButton.width +
20, 500);
    walkRightButton.sheetIndex = 1;
    this.add(walkRightButton);
    var jumpButton = new powerupjs.Button(sprites.buttons_player,
        ID.layer_overlays, ID.button_jump);
    jumpButton.position = new powerupjs.Vector2(powerupjs.Game.size.x –
        jumpButton.width - 10, 500);
    jumpButton.sheetIndex = 2;
    this.add(jumpButton);
}

控制字符的方式与处理键盘输入的方式非常相似:

if (powerupjs.Touch.isTouchDevice) {
    var jumpButton = this.root.find(ID.button_jump);
    if (jumpButton.pressed && this.onTheGround)
        this.jump();
}

这是一个很好的例子,说明了如何自动调整游戏界面以适应不同的设备。只有当触摸显示屏可用时,才会添加按钮。另一种选择是在设备中使用内置传感器,例如加速度计。涂鸦跳跃是一个使用这种传感器来控制角色的游戏的好例子。

…然后下落

你目前唯一改变 y 速度的地方是在handleInput方法中,当玩家想要跳跃的时候。因为y-速度无限期地保持 1100 的值,角色在屏幕外的空中向上移动,离开地球的大气层,进入外层空间。因为你不是在做一个关于太空炸弹的游戏,你必须对此做些什么。你忘了加到游戏世界的是重力

您可以遵循一个简单的方法来模拟重力对角色速度的影响。在每个更新步骤中,在 y 方向的速度上增加一个小值:

this.velocity.y += 55;

如果角色有一个负的速度,这个速度慢慢变小,直到它达到零,然后又开始增加。效果是角色跳到某个高度,然后又开始往下掉,就像在现实世界里一样。然而,冲突检测机制现在变得更加重要。如果没有碰撞检测,角色会在游戏开始时就开始倒下!

碰撞检测

检测游戏对象之间的碰撞是模拟交互式游戏世界的一个非常重要的部分。碰撞检测在游戏中用于许多不同的事情:检测角色是否走过电源,检测角色是否与投射物碰撞,检测角色与墙壁或地板之间的碰撞,等等。鉴于这种非常常见的情况,你在以前的游戏中不需要碰撞检测几乎是很奇怪的。还是你没有?请看来自画师游戏的PaintCan类中的update方法的代码

var ball_center = Game.gameWorld.ball.center;
var ball_position = Game.gameWorld.ball.position;
var distance = ball_position.add(ball_center).subtractFrom(this.position)
    .subtractFrom(this.center);
if (Math.abs(distance.x) < this.center.x && Math.abs(distance.y) <
this.center.y) {
    this.color = Game.gameWorld.ball.color;
    Game.gameWorld.ball.reset();
}

您在这里所做的是检测球和油漆罐之间的碰撞(尽管是非常基本的方式)。你取每个物体的中心位置,看看这两个位置之间的距离是否小于某个值。如果是这样,你说它们碰撞了,你改变了罐子的颜色。如果您更仔细地观察这种情况,您可以看到您正在用基本形状表示游戏对象,例如,并且您通过验证中心之间的距离是否小于圆的半径之和来检查它们是否相互碰撞。

这是第一个,在游戏中进行碰撞检查的简单例子。当然,这不是一种非常精确的检查碰撞的方法。球的形状可以近似为圆形,但油漆罐看起来一点也不像圆形。因此,在某些情况下,当没有碰撞时会检测到碰撞,有时当精灵实际碰撞时不会检测到碰撞。尽管如此,许多游戏在进行碰撞检测时还是会使用圆形和矩形等简化形状来代表物体。因为这些形状将对象约束在其中,所以它们也被称为边界圆边界框 。Tick Tick 游戏使用轴对齐的边界框,意味着你不考虑边不平行于 x -和y-轴的框。

不幸的是,使用包围盒进行碰撞检测并不总是足够精确。当游戏对象彼此靠近时,它们的边界形状可能会相交(从而触发碰撞),但实际对象不会。并且当游戏对象被动画化时,它的形状可以随着时间而改变。您可以使边界形状更大,以便对象在任何情况下都适合它,但这将导致更多错误的碰撞触发器。

对此的解决方案是在每个像素的基础上检查碰撞。基本上,您可以编写一个算法,遍历 sprite 中的非透明像素(使用嵌套的for指令),并检查这些像素中的一个或多个是否与另一个 sprite 中的一个像素冲突(同样,通过使用嵌套的for指令遍历它们)。通常,这种高度详细的碰撞检测对于浏览器游戏来说成本太高(即使浏览器变得越来越快)。另一方面,您不必经常执行这个相当昂贵的任务。只有当两个边界形状相交时才需要这样做。然后你只需要对实际相交的形状部分做同样的操作。此外,如果你聪明的话,你可以决定哪种对象应该使用逐像素碰撞检测,这样你就可以只对边界框不能很好工作的对象使用它。

当你使用圆形和矩形来检测碰撞时,你需要处理三种情况(参见图 26-1 ):

  • 一个圆与另一个圆相交。
  • 一个圆与一个矩形相交。
  • 一个矩形与另一个矩形相交。

9781430265382_Fig26-01.jpg

图 26-1 。不同类型的碰撞:圆-圆,圆-矩形和矩形-矩形

第一种情况是最简单的。你唯一需要做的就是检查两个中心之间的距离是否小于半径之和。您已经看到了如何做到这一点的示例。

对于圆与矩形相交的情况,可以使用以下方法:

  1. 找到矩形上最靠近圆心的点。
  2. 计算这个点到圆心的距离。
  3. 如果这个距离小于圆的半径,就有碰撞。

让我们假设您想要找出类型为Rectangle的对象是否与由类型为\expr{Vector2}的对象和半径表示的圆相交。通过巧妙的钳制值,可以找到最接近圆心的点。箝位最大值和最小值之间的一个值通过以下方法完成:

Math.clamp = function (value, min, max) {
    if (value < min)
        return min;
    else if (value > max)
        return max;
    else
        return value;
};

现在看一下下面的代码:

Vector2 closestPoint = Vector2.zero;
closestPoint.x = Math.clamp(circleCenter.x, rectangle.left, rectangle.right);
closestPoint.y = Math.clamp(circleCenter.y, rectangle.top, rectangle.bottom);

通过夹紧矩形边缘之间中心的 xy 值,可以找到最近的点。如果圆的中心在矩形内,这种方法也有效,因为在这种情况下箝位不起作用,并且最近的点与圆的中心相同。下一步是计算最近点和圆心之间的距离:

Vector2 distance = closestPoint.subtract(circleCenter);

如果这个距离小于半径,就会发生碰撞:

if (distance.length < circleRadius)
    // collision!

最后一种情况是检查两个矩形是否冲突。为了进行计算,您需要了解两个矩形的以下信息:

  • 最小的x-矩形(rectangle.left)的值
  • 最小的y-矩形(rectangle.top)的值
  • 最大x-矩形(rectangle.right)的值
  • 最大的y-矩形(rectangle.bottom)的值

假设您想知道矩形 A 是否与矩形 b 冲突。在这种情况下,您必须检查以下条件:

  • A.left (A 的最小x-值)< = B.right (B 的最大x-值)
  • A.right (A 的最大x-值)> = B.left (B 的最小x-值)
  • A.top (A 的最小y-值)< = B.bottom (B 的最大y-值)
  • A.bottom (A 最大的y-值)> = B.top (B 最小的y-值)

如果所有这些条件都满足,那么矩形 A 和 B 就发生了碰撞。为什么会有这些特殊情况?让我们看看第一个条件,看看如果它不为真会发生什么。假设A.left > B.right取而代之。在这种情况下,矩形 A 完全位于矩形 B 的右侧,因此它们不会发生碰撞。如果第二个条件不成立(换句话说,A.right < B.left,那么矩形 A 完全位于 B 的左侧,这意味着它们也不会发生碰撞。你自己也要检查一下另外两个条件。总之,这些条件表明,如果矩形 A 既不完全位于 B 的左侧、右侧、顶部,也不完全位于 B 的底部,那么这两个矩形就会发生碰撞。

在 JavaScript 中,编写检查矩形间冲突的代码很容易。如果你看一下Rectangle类,你可以看到一个方法intersects为你做这件事:

Rectangle.prototype.intersects = function (rect) {
    return (this.left <= rect.right && this.right >= rect.left &&
        this.top <= rect.bottom && this.bottom >= rect.top);
};

检索边界框

为了有效地处理游戏中的碰撞,SpriteGameObject类有一个属性boundingBox,它返回精灵的边界框:

Object.defineProperty(SpriteGameObject.prototype, "boundingBox",
    {
        get: function () {
            var leftTop = this.worldPosition.subtractFrom((this.origin));
            return new powerupjs.Rectangle(leftTop.x, leftTop.y, this.width,
this.height);
        }
    });

正如你所看到的,为了计算盒子的正确位置,它考虑了精灵的原点。还要注意,边界框的位置是用世界位置表示的。当进行碰撞检测时,您希望知道对象在世界上的位置——您不关心它们在游戏对象层次结构中的本地位置。

逐像素碰撞检测

除了boundingBox属性,您还可以在SpriteGameObject类中添加一个方法collidesWith来处理冲突检测。然而,仅仅检查边界框是否重叠通常是不够的。图 26-2 显示了两个精灵的边界框重叠的例子,但是图像实际上并没有碰撞。这可能是因为你绘制的精灵 的部分可以是透明的。因此,如果您想要进行精确的碰撞检测,您需要查看像素级别是否存在碰撞。只有在子画面重叠的矩形中的给定位置,两个子画面都有不透明的像素时,才会发生像素级的冲突。

9781430265382_Fig26-02.jpg

图 26-2 。两个精灵没有碰撞,但是它们的边界框重叠的例子

访问图像中的像素颜色数据

要进行逐像素碰撞检测,您需要访问图像的像素颜色数据。这在 HTML/JavaScript 中并不难做到,但是在了解如何实现之前,您需要知道逐像素碰撞检测是一个开销很大的操作(稍后您会明白为什么)。如果你在游戏世界中的每个精灵之间这样做,你将面临游戏无法在旧的移动设备或平板电脑上运行的风险。因此,只有在真正必要时才进行逐像素碰撞检测。

因为每像素碰撞检测是昂贵的,所以以这样一种方式设计代码是有意义的,即对于某些精灵来说很容易关闭它。为此,在SpriteSheet类中维护一个布尔变量,该变量指示是否应该对该精灵进行逐像素碰撞检测。因为检索像素颜色数据是昂贵的,所以当精灵被加载时,你检索所有的数据并把它存储在一个叫做碰撞遮罩 的数组中。为什么检索像素颜色数据很昂贵?因为为了检索这些数据,你需要首先绘制精灵,然后从画布中检索颜色数据。您不想在玩家可以看到的主画布上绘制这些精灵,所以您在Canvas2D类中定义了另一个画布来实现这个目的:

this._pixeldrawingCanvas = document.createElement('canvas');

SpriteSheet类添加一个名为createPixelCollisionMask的方法,在该方法中,在像素绘图画布上绘制精灵,然后从该画布中提取像素颜色数据。初始化将包含碰撞遮罩的数组,并确保像素绘图画布的大小正确:

this._collisionMask = [];
var w = this._image.width;
var h = this._image.height;
powerupjs.Canvas2D._pixeldrawingCanvas.width = w;
powerupjs.Canvas2D._pixeldrawingCanvas.height = h;

然后使用画布上下文来绘制精灵:

var ctx = powerupjs.Canvas2D._pixeldrawingCanvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.drawImage(this._image, 0, 0, w, h, 0, 0, w, h);
ctx.restore();

canvas 上下文有一个方法getImageData ,它检索每个像素的颜色数据并将其存储在一个数组中。因此,让我们检索当前显示在画布上的所有像素:

var imageData = ctx.getImageData(0, 0, w, h);

变量现在指的是一个非常大的数字数组。对于每个像素,数组中有四个数字,每个数字都在 0 到 255 之间。前三个数字是决定像素颜色的 R(红色)、G(绿色)和 B(蓝色)值。第四个数字是决定像素透明度的 A (alpha)值。alpha 值为 0 表示像素完全透明,值为 255 表示像素不透明。在碰撞遮罩中,您只需要存储 alpha 值,因为碰撞对象的颜色并不重要:重要的是哪些像素代表这些对象。因此,您使用一条for指令来遍历数组,并将每个第四个值存储在碰撞掩码数组中,如下所示:

for (var x = 3, l = w * h * 4; x < l; x += 4) {
    this._collisionMask.push(imageData.data[x]);
}

当创建一个SpriteSheet实例时,只有当用户在调用构造函数时将参数createCollisionMask设置为true时,才会计算碰撞遮罩。例如,您表示希望在加载播放器精灵时对播放器进行精确的碰撞检测:

sprites.player_idle = loadSprite("player/spr_idle.png", true);
sprites.player_run = loadSprite("player/spr_run@13.png", true);
sprites.player_jump = loadSprite("player/spr_jump@14.png", true);

另一方面,您不需要这些图块的精确信息,因为它们或多或少都是矩形的,所以使用矩形边界框就足够了:

sprites.wall = loadSprite("tiles/spr_wall.png");
sprites.wall_hot = loadSprite("tiles/spr_wall_hot.png");
sprites.wall_ice = loadSprite("tiles/spr_wall_ice.png");
sprites.platform = loadSprite("tiles/spr_platform.png");
sprites.platform_hot = loadSprite("tiles/spr_platform_hot.png");
sprites.platform_ice = loadSprite("tiles/spr_platform_ice.png");

为了使访问碰撞遮罩变得更容易,您在SpriteSheet类中添加了一个getAlpha方法来访问碰撞遮罩,同时考虑当前在工作表中选择的元素以及子画面是否被镜像绘制。下面是该方法的标题:

SpriteSheet.prototype.getAlpha = function (x, y, sheetIndex, mirror)

作为参数,该方法需要 xy 像素坐标、工作表索引以及子画面是否被镜像。首先要做的是检查是否有一个碰撞遮罩与这个 sprite sheet 关联,因为不是所有的 sprite 都有这样的遮罩。如果没有碰撞遮罩,只需返回值 255(完全不透明):

if (this._collisionMask === null)
    return 255;

然后,您计算对应于当前工作表索引的列和行索引,使用与在draw方法中相同的方式:

var columnIndex = sheetIndex % this._sheetColumns;
var rowIndex = Math.floor(sheetIndex / this._sheetColumns) % this._sheetRows;

然后,您可以计算图像中的实际像素坐标(或纹理),将工作表索引给出的 sprite 元素考虑在内。通过将一个工作表元素的宽度乘以列索引并加上本地的 x 值来计算 x 坐标:

var textureX = columnIndex * this.width + x;

但是,如果精灵是镜像的,则使用稍微不同的计算方法:

if (mirror)
    textureX = (columnIndex + 1) * this.width - x - 1;

这里发生的事情是,你从 sprite 元素的右边开始,然后减去 x 得到本地的 x 坐标。对于 y 坐标,不需要检查镜像,因为在游戏引擎中你只允许水平镜像:

var textureY = rowIndex * this.height + y;

基于图像中的 xy 坐标,现在计算碰撞遮罩中的相应索引,如下所示:

var arrayIndex = Math.floor(textureY * this._image.width + textureX);

为了确保万无一失,您检查您计算的索引是否落在数组的范围内。如果不是,则返回 0(完全透明):

if (arrayIndex < 0 || arrayIndex >= this._collisionMask.length)
    return 0;

这样,如果您试图访问不存在的像素,getAlpha方法也会返回一个逻辑结果。最后,返回存储在碰撞遮罩中的 alpha 值:

return this._collisionMask[arrayIndex];

为了方便起见,您还向SpriteGameObject添加了一个getAlpha方法,该方法使用正确的参数从SpriteSheet调用getAlpha方法:

SpriteGameObject.prototype.getAlpha = function (x, y) {
    return this.sprite.getAlpha(x, y, this._sheetIndex, this.mirror);
};

注意不是所有的浏览器都允许你访问像素颜色数据。例如,如果你在电脑上将 HTML 页面作为本地文件打开,Chrome 和 Firefox 就不允许这种访问。Internet Explorer 确实允许这样做,所以要测试逐像素碰撞检测,您可以使用该浏览器或将文件放在服务器上,以便使用这两种浏览器中的任何一种。在 TickTick2 的例子中,我注释掉了TickTick.js中的collisionMask参数,因此游戏可以在所有浏览器上运行,但是当然在这种情况下游戏不会执行逐像素碰撞检测。

计算重叠矩形

SpriteGameObject中的collidesWith方法处理碰撞检测的两个步骤:首先检查边界框是否相交,然后在重叠矩形中执行逐像素碰撞检测。该方法的第一步是确定是否需要进行任何碰撞检测。如果两个对象中的任何一个不可见,或者如果它们的边界框不相交,那么从方法:返回

if (!this.visible || !obj.visible ||
    !this.boundingBox.intersects(obj.boundingBox))
    return false;

下一步是计算两个边界框的重叠部分。因为在处理碰撞检测时,这是一个很有用的计算方法,所以在Rectangle类中添加一个名为intersection的方法,该方法返回一个矩形,表示作为参数传递的矩形(边界框)和调用该方法的矩形对象(this)之间的重叠。

为了计算这个重叠矩形,你需要知道矩形的最小和最大 xy 坐标(见图 26-3 )。将Rectangle类中一些有用的属性与Math对象的minmax方法结合使用,可以很容易地计算出这些值:

var xmin = Math.max(this.left, rect.left);
var xmax = Math.min(this.right, rect.right);
var ymin = Math.max(this.top, rect.top);
var ymax = Math.min(this.bottom, rect.bottom);

9781430265382_Fig26-03.jpg

图 26-3 。使用最小和最大 xy 坐标计算重叠矩形

现在,您可以计算重叠矩形的位置和大小,并从方法返回它:

return new powerupjs.Rectangle(xmin, ymin, xmax - xmin, ymax - ymin);

SpriteGameObjectcollidesWith方法中,通过从Rectangle类中调用intersection方法来存储重叠矩形:

var intersect = this.boundingBox.intersection(obj.boundingBox);

检查重叠矩形 中的像素

重叠矩形的坐标用世界坐标表示,因为两个边界框都用世界坐标表示。你首先需要找出重叠矩形在两个重叠精灵中的位置。因此,你需要减去每个精灵的世界位置和它的原点来找到每个精灵中的局部重叠矩形:

var local = intersect.position.subtractFrom(this.worldPosition
.subtractFrom(this.origin));
var objLocal = intersect.position.subtractFrom(obj.worldPosition
.subtractFrom(obj.origin));

要检查重叠矩形内是否有碰撞,使用嵌套的for指令遍历矩形中的所有像素:

for (var x = 0; x < intersect.width; x++)
    for (var y = 0; y < intersect.height; y++) {
        // check transparency of pixel (x, y)...
}

在这个嵌套的for指令中,你检查两个像素在这些局部位置是否都是而不是透明的。如果是这种情况,你有一个碰撞。您使用getAlpha方法来检查这两个像素:

if (this.getAlpha(Math.floor(local.x + x), Math.floor(local.y + y)) !== 0
    && obj.getAlpha(Math.floor(objLocal.x + x), Math.floor(objLocal.y + y)) !== 0)
    return true;

既然已经实现了基本的碰撞检测方法,您可以通过调用collidesWith方法来检查两个游戏对象是否发生碰撞:

if (this.collidesWith(enemy))
    // ouch...

处理字符-图块冲突

在 Tick Tick 游戏中,您需要检测角色和瓷砖之间的碰撞。你可以在一个名为handleCollisions的方法中做到这一点,这个方法是从Player类中的update方法调用的。这个想法是,你做所有的计算 跳跃,下落,并且首先跑(你在这一章的开始就做了)。如果角色和瓷砖之间发生碰撞,您可以修正角色的位置,使其不再发生碰撞。在handleCollisions方法中,你走过方格并检查角色和你正在检查的方格之间是否有冲突。

您不需要检查网格中的所有图块,只需检查靠近角色当前位置的图块。您可以计算距离角色位置最近的牌,如下所示:

var tiles = this.root.find(ID.tiles);
var x_floor = Math.floor(this.position.x / tiles.cellWidth);
var y_floor = Math.floor(this.position.y / tiles.cellHeight);

现在你可以使用嵌套的for指令来查看角色周围的瓷砖。为了考虑快速跳跃和下落,你在 y 方向上考虑了更多的瓷砖。在嵌套的for指令中,然后检查角色是否与瓷砖发生碰撞。但是,只有当图块是而不是背景图块时,您才需要这样做。完成所有这些工作的代码如下:

for (var y = y_floor - 2; y <= y_floor + 1; ++y)
    for (var x = x_floor - 1; x <= x_floor + 1; ++x) {
        var tileType = tiles.getTileType(x, y);
        if (tileType === TileType.background)
            continue;
        var tileBounds = new powerupjs.Rectangle(x * tiles.cellWidth, y *
            tiles.cellHeight, tiles.cellWidth, tiles.cellHeight);
        if (!tileBounds.intersects(this.boundingBox))
            continue;
    }

如您所见,您没有直接访问Tile对象。原因是有时,因为角色靠近屏幕边缘,所以 xy 索引可能为负。这里您看到了使用添加到TileField类中的getTileType方法的优势。您并不关心您是否真的在处理一个图块:只要您知道它的类型和边界框,您就可以完成您的工作。

在嵌套的for指令中,还会看到一个新的关键字:continue。这个关键字可以在forwhile指令中使用,以停止执行循环的当前迭代,并继续下一个迭代。在这种情况下,如果图块的类型为background,则剩余的指令不再执行,您继续增加x并开始新的迭代以检查下一个图块。结果是只考虑不属于类型background的图块。continue关键字与break相关,它完全停止循环。与break不同,continue只停止当前迭代。

然而,这些代码并不总是正确地工作。特别是当角色站在瓷砖上时,计算边界框时的舍入误差会导致算法认为角色不是站在地上。然后,角色的速度会增加,结果角色可能会从瓷砖中掉下来。为了补偿任何舍入误差,可以将边界框的高度增加 1:

var boundingBox = this.boundingBox;
boundingBox.height += 1;
if (!tileBounds.intersects(boundingBox))
    continue;
// handle the collision

处理碰撞

现在,您可以检测游戏世界中角色和瓷砖之间的碰撞,您必须确定当碰撞发生时该做什么。有几种可能性。你可以让游戏崩溃(如果你想把你的游戏卖给很多人,这并不好),你可以警告用户他们不应该与游戏中的物体碰撞(导致许多弹出消息),或者你可以在角色与物体碰撞时自动纠正角色的位置。

为了纠正角色的位置,你需要知道碰撞有多糟糕。例如,如果角色撞上了右边的墙,你必须知道你要向左移动角色多远才能取消碰撞。这也被称为交点深度。让我们用一个叫做calculateIntersectionDepth的方法来扩展Rectangle类,该方法计算两个Rectangle对象在 xy 方向的相交深度。在这个例子中,这些矩形是角色的边界框和与之碰撞的瓷砖的边界框。

可以通过首先确定矩形中心之间的最小允许距离来计算相交深度,使得两个矩形之间没有碰撞:

var minDistance = this.size.addTo(rect.size).divideBy(2);

然后计算两个矩形中心之间的实际距离:

var distance = this.center.subtractFrom(rect.center);

现在,您可以计算最小允许距离和实际距离之间的差异,以获得相交深度。如果你观察两个中心之间的实际距离,两个维度都有两种可能( xy ):距离要么是负的,要么是正的。例如,如果 x 距离为负,这意味着矩形rect被放置在矩形this的右侧(因为rect.center.x > this.center.x)。如果矩形this代表该字符,这意味着您必须将该字符移动到左侧来纠正这个交叉点。因此,你将 x 相交深度返回为一个值,可以计算为-minDistance.x - distance.x。为什么呢?因为有碰撞,所以两个矩形之间的距离小于minDistance。并且因为distance是负的,所以表达式-minDistance.x - distance.x给出两者之差作为负。如果distance为正,表达式minDistance.x - distance.x给出两者之间的差。同样的推理也适用于 y 距离。然后,您可以按如下方式计算深度:

var depth = powerupjs.Vector2.zero;
if (distance.x > 0)
    depth.x = minDistance.x - distance.x;
else
    depth.x = -minDistance.x - distance.x;
if (distance.y > 0)
    depth.y = minDistance.y - distance.y;
else
    depth.y = -minDistance.y - distance.y;

最后,您返回深度向量作为该方法的结果:

return depth;

当您知道角色与瓷砖发生碰撞时,您可以使用刚刚添加到Rectangle类中的方法来计算相交深度:

var depth = boundingBox.calculateIntersectionDepth(tileBounds);

现在你已经计算了相交深度,有两种方法可以解决这个碰撞:在 x 方向移动角色,或者在 y 方向移动角色。通常,您希望将角色移动尽可能短的距离,以避免不自然的运动或位移。所以,如果 x 深度小于 y 深度,你在 x 方向移动角色;否则,沿 y 方向移动。您可以使用if指令来检查这一点。当比较两个深度尺寸时,你必须考虑到它们可能是负数。您可以通过比较绝对值来解决这个问题:

if (Math.abs(depth.x) < Math.abs(depth.y)) {
    // move character in the x direction
}

如果与瓷砖发生碰撞,是否总是要移动角色?这取决于瓷砖的类型。请记住,TileType用于表示三种可能的牌类型:TileType.backgroundTileType.normalTileType.platform。如果角色碰撞的瓷砖是背景瓷砖,你肯定不想移动角色。此外,在向 x 方向移动的情况下,您希望角色能够穿过平台瓷砖。因此,只有当角色与瓷砖(TileType.normal)发生碰撞时,才需要移动角色来纠正碰撞。在这种情况下,通过将 x 深度值添加到角色位置来移动角色:

if (tileType === TileType.normal)
    this.position.x += depth.x;

如果你想在 y 方向修正字符位置,事情会变得稍微复杂一些。因为你正在处理在 y 方向的运动,这也是一个确定角色是否在地面上的好地方。在handleCollisions方法的开始,您将isOnTheGround成员变量设置为false。所以,出发点是假设地上的人物是而不是。在有些的情况下,是在地面上,你要把变量设置成true。你如何能检查角色是否在地面上?如果它不在地面上,它一定在下落。如果是下降,那么先前的 y 位置小于当前位置。为了访问前一个 y 位置,在每次调用handleCollisions方法结束时,将它存储在一个成员变量中:

this._previousYPosition = this.position.y;

现在很容易确定角色是否在地面上。如果先前的 y 位置小于角色正在碰撞的瓷砖的顶部,并且该瓷砖是而不是背景瓷砖,那么角色正在下落并且已经到达瓷砖。如果是这样,您将isOnTheGround变量设置为true并将 y 速度设置为 0:

if (this._previousYPosition <= tileBounds.top && tileType !==
TileType.background) {
    this.onTheGround = true;
    this.velocity.y = 0;
}

在某些情况下,您仍然需要纠正字符位置。如果你与墙砖相撞,你总是想纠正角色的位置。如果角色与平台瓷砖发生碰撞,您只需在角色站在瓷砖顶部时纠正角色位置。如果isOnTheGround变量设置为true,则后者仅为true。因此,您可以将这一切写入下面的if指令:

if (tileType === TileType.normal || this.onTheGround)
    this.position.y += depth.y + 1;

请注意,要校正位置,您需要添加一个额外的像素来补偿您添加到边界框高度的额外像素。

你学到了什么

在本章中,您学习了:

  • 如何在环境中约束角色
  • 如何模拟跳跃和坠落
  • 如何处理游戏中的碰撞