构建你自己的-2D-游戏引擎-四-

99 阅读1小时+

构建你自己的 2D 游戏引擎(四)

原文:Build Your Own 2D Game Engine and Create Great Web Games

协议:CC BY-NC-SA 4.0

六、定义行为和检测碰撞

完成本章后,您将能够

  • 实现自主行为,如锁定目标追逐和逐步转向

  • 精确碰撞纹理物体

  • 了解像素精确碰撞的效率问题

  • 有效且高效地使用像素精确碰撞编程

介绍

至此,您的游戏引擎能够在方便的坐标系中实现游戏,并呈现和动画化视觉上吸引人的对象。然而,缺乏对对象行为的抽象支持。你可以在之前所有项目中的MyGame对象的init()update()函数中看到这一缺点的直接结果:init()函数经常挤满了平凡的每游戏对象设置,而update()函数经常挤满了控制对象的条件语句,例如检查移动英雄的按键。

一个设计良好的系统应该用适当的面向对象的抽象或类来隐藏单个对象的初始化和控制。应该引入一个抽象的GameObject类来封装和隐藏其初始化和行为的细节。这种方法有两个主要优点。首先,游戏级别的init()update()功能可以专注于管理单个游戏对象和这些对象的交互,而不与特定于不同类型对象的细节聚集在一起。第二,正如你已经体验过的RenderableSimpleShader类的层次结构一样,适当的面向对象抽象创建了一个标准化的接口,并促进了代码的共享和重用。

当你从单纯的绘制对象(换句话说,Renderable)过渡到对对象的行为进行编程(换句话说,GameObject)时,你会立即注意到,为了让游戏变得有趣,对象需要进行交互。物体有趣的行为,比如面对或躲避敌人,往往需要知道游戏中其他物体的相对位置。一般来说,解析 2D 世界中所有物体的相对位置并不简单。幸运的是,典型的视频游戏只需要知道那些彼此非常接近或者将要碰撞或者已经碰撞的物体。

检测碰撞的一种有效但有些粗糙的近似方法是计算对象的边界,并基于碰撞边界框来近似对象碰撞。在最简单的情况下,边界框是边缘与 x/y 轴对齐的矩形框。这些被称为轴对齐边界框或 AABBs。由于轴对齐,检测两个 AABBs 何时重叠或何时将要发生碰撞在计算上是有效的。

许多 2D 游戏引擎还可以通过比较两个对象的像素位置并检测至少一个不透明像素重叠的情况,来检测两个纹理对象之间的实际碰撞。这种计算密集型过程称为逐像素精确碰撞检测、逐像素精确碰撞或逐像素碰撞。

本章首先介绍了GameObject类,它提供了一个抽象游戏对象行为的平台。然后将GameObject类一般化,引入常见的行为属性,包括速度、运动方向和锁定目标的追逐。本章的其余部分集中在派生一个有效的每像素精确碰撞实现,支持纹理和动画精灵对象。

游戏对象

如上所述,应该引入封装典型游戏对象的内在行为的抽象,以最小化游戏级别的init()update()函数中的代码集群,并促进重用。本节介绍了简单的GameObject类,以说明干净整洁的init()update()函数如何清晰地反映游戏中的逻辑,并演示抽象对象行为的基本平台如何促进设计和代码重用。

游戏对象项目

这个项目将简单的GameObject类定义为构建抽象的第一步,用游戏中的行为来表示实际的对象。你可以在图 6-1 中看到这个项目运行的例子。请注意,许多奴才从右向左冲锋,并在到达左边界时绕回。这个项目引导您创建基础设施来支持许多奴才,同时保持MyGame级别的逻辑简单。这个项目的源代码在chapter6/6.1.game_objects文件夹中定义。

img/334805_2_En_6_Fig1_HTML.jpg

图 6-1

运行游戏对象项目

该项目的控制措施如下:

  • WASD 键:上下左右移动英雄

该项目的目标如下:

  • 开始定义GameObject类来封装游戏中的对象行为

  • 演示如何创建GameObject类的子类以保持MyGameupdate()函数的简单性

  • 引入GameObjectSet类,展示对一组具有相同接口的同质对象的支持

您可以在assets文件夹中找到以下外部资源文件:minion_sprite.png;你还会找到包含默认系统字体的fonts文件夹。注意,如图 6-2 所示,minion_sprite.png图像文件已经从之前的项目更新为包含两个额外的 sprite 元素:DyePackBrain minion。

img/334805_2_En_6_Fig2_HTML.png

图 6-2

minion_sprite.png图像的新 sprite 元素

定义游戏对象类

目标是定义一个逻辑抽象来封装游戏中典型对象的所有相关行为特征,包括控制位置、绘图等的能力。就像前一章中的Scene对象一样,主要结果是提供了一个定义良好的接口来管理子类实现的功能。更复杂的行为将在下一节介绍。这个例子仅仅展示了定义了最少行为的GameObject类的潜力。

  1. 添加一个新的文件夹src/engine/game_objects,用于存储与GameObject相关的文件。

  2. 在该文件夹中创建一个新文件,将其命名为game_object.js,并添加以下代码:

class GameObject {
    constructor(renderable) {
        this.mRenderComponent = renderable;
    }

    getXform() { return this.mRenderComponent.getXform(); }

    getRenderable() { return this.mRenderComponent; }

    update() {  }

    draw(aCamera) {
        this.mRenderComponent.draw(aCamera);
    }
}

export default GameObject;

定义了RenderableTransform对象的评估者后,所有的GameObject实例都可以被绘制出来,并具有定义的位置和大小。请注意,update()函数是为子类设计的,可以覆盖特定于对象的行为,因此,它是空的。

管理集合中的游戏对象

因为大多数游戏由许多交互对象组成,所以定义一个实用程序类来支持使用一组GameObject实例是很有用的:

  1. src/engine/game_objects文件夹中创建一个新文件,并将其命名为game_object_set.js。定义GameObjectSet类和构造函数来初始化保存GameObject实例的数组。

  2. 定义用于管理集合成员资格的函数:

class GameObjectSet {
    constructor() {
        this.mSet = [];
    }

... implementation to follow ...

export default GameObjectSet;

  1. 定义函数来更新和绘制集合中的每个GameObject实例:
size() { return this.mSet.length; }
getObjectAt(index) { return this.mSet[index]; }
addToSet(obj) { this.mSet.push(obj); }
removeFromSet(obj) {
    let index = this.mSet.indexOf(obj);
    if (index > -1)
        this.mSet.splice(index, 1);
}

update() {
    let i;
    for (i = 0; i < this.mSet.length; i++) {
        this.mSet[i].update();
    }
}

draw(aCamera) {
    let i;
    for (i = 0; i < this.mSet.length; i++) {
        this.mSet[i].draw(aCamera);
    }
}

将类导出到客户端

将任何新功能集成到引擎中的最后一步涉及修改引擎访问文件index.js。编辑index.js并添加以下导入和导出语句,以授予客户端对GameObjectGameObjectSet类的访问权限:

... identical to previous code ...

// game objects
import GameObject from "./game_objects/game_object.js";
import GameObjectSet from "./game_objects/game_object_set.js";

... identical to previous code ...

export default {
    ... identical to previous code ...

    // Game Objects
    GameObject, GameObjectSet,

    ... identical to previous code ...
}

Note

对于每个新定义的功能,必须重复通过引擎访问文件index.js导入/导出类的过程。从今以后,将只提供一个提醒,简单的代码更改将不再显示。

测试游戏对象和游戏对象集

这个项目的目标是确保新的GameObject类的正常运行,演示单个对象类型的行为定制,并观察一个更清晰的MyGame实现,清楚地反映游戏中的逻辑。为了实现这些目标,定义了三种对象类型:DyePackHeroMinion。在开始研究这些对象的详细实现之前,遵循良好的源代码组织实践,创建一个新文件夹src/my_game/objects来存储新的对象类型。

迪埃帕克游戏对象

DyePack类从GameObject类派生而来,演示了最基本的GameObject的例子:一个没有任何行为的对象,它只是被绘制到屏幕上。

src/my_game/objects文件夹中创建一个新文件,并将其命名为dye_pack.js。从引擎访问文件index.js导入,获得游戏引擎的所有访问功能。将DyePack定义为GameObject的子类,并如下实现构造函数:

import engine from "../../engine/index.js";
class DyePack extends engine.GameObject {
    constructor(spriteTexture) {
        super(null);
        this.kRefWidth = 80;
        this.kRefHeight = 130;
        this.mRenderComponent =
                             new engine.SpriteRenderable(spriteTexture);
        this.mRenderComponent.setColor([1, 1, 1, 0.1]);
        this.mRenderComponent.getXform().setPosition(50, 33);
        this.mRenderComponent.getXform().setSize(
                             this.kRefWidth / 50, this.kRefHeight / 50);
        this.mRenderComponent.setElementPixelPositions(510,595,23,153);
    }
}
export default DyePack;

注意,即使没有特定的行为,DyePack也在实现曾经在MyGame级别的init()函数中找到的代码。这样,DyePack对象隐藏了特定的几何信息,简化了MyGame层次。

Note

从引擎访问文件index.js导入的需要,几乎适用于所有的客户端源代码文件,在此不再赘述。

英雄游戏对象

Hero类支持直接的用户键盘控制。这个对象演示了从MyGameupdate()函数中隐藏游戏对象控制逻辑。

  1. src/my_game/objects文件夹中创建一个新文件,并将其命名为hero.js。将Hero定义为GameObject的子类,并实现构造函数来初始化 sprite UV 值、大小和位置。请确保导出并共享此类。

  2. 添加一个函数来支持通过用户键盘控制来更新这个对象。Hero对象根据键盘的 WASD 输入以kDelta速率移动。

class Hero extends engine.GameObject {
    constructor(spriteTexture) {
        super(null);
        this.kDelta = 0.3;

        this.mRenderComponent =
                            new engine.SpriteRenderable(spriteTexture);
        this.mRenderComponent.setColor([1, 1, 1, 0]);
        this.mRenderComponent.getXform().setPosition(35, 50);
        this.mRenderComponent.getXform().setSize(9, 12);
        this.mRenderComponent.setElementPixelPositions(0, 120, 0, 180);
}

... implementation to follow ...

export default Hero;

update() {
    // control by WASD
    let xform = this.getXform();
    if (engine.input.isKeyPressed(engine.input.keys.W)) {
        xform.incYPosBy(this.kDelta);
    }
    if (engine.input.isKeyPressed(engine.input.keys.S)) {
        xform.incYPosBy(-this.kDelta);
    }
    if (engine.input.isKeyPressed(engine.input.keys.A)) {
        xform.incXPosBy(-this.kDelta);
    }
    if (engine.input.isKeyPressed(engine.input.keys.D)) {
            xform.incXPosBy(this.kDelta);
    }
}

迷你游戏对象

Minion类演示了简单的自主行为也可以被隐藏:

  1. src/my_game/objects文件夹中创建一个新文件,并将其命名为minion.js。将Minion定义为GameObject的子类,并实现构造函数来初始化精灵 UV 值、精灵动画参数、大小和位置,如下所示:

  2. 添加一个函数来更新 sprite 动画,支持简单的从右向左移动,并提供包装功能:

class Minion extends engine.GameObject {
    constructor(spriteTexture, atY) {
        super(null);
        this.kDelta = 0.2;

        this.mRenderComponent =
                      new engine.SpriteAnimateRenderable(spriteTexture);

        this.mRenderComponent.setColor([1, 1, 1, 0]);
        this.mRenderComponent.getXform().setPosition(
                                              Math.random() * 100, atY);
        this.mRenderComponent.getXform().setSize(12, 9.6);
        // first element pixel position: top-left 512 is top of image
        // 0 is left of the image
        this.mRenderComponent.setSpriteSequence(512, 0,
            204, 164,   // width x height in pixels
            5,          // number of elements in this sequence
            0);         // horizontal padding in between
        this.mRenderComponent.setAnimationType(
                                          engine.eAnimationType.eSwing);
        this.mRenderComponent.setAnimationSpeed(15);
        // show each element for mAnimSpeed updates
    }

    ... implementation to follow ...

}
export default Minion;

update() {
    // remember to update this.mRenderComponent's animation
    this.mRenderComponent.updateAnimation();

    // move towards the left and wraps
    let xform = this.getXform();
    xform.incXPosBy(-this.kDelta);

    // if fly off to the left, re-appear at the right
    if (xform.getXPos() < 0) {
        xform.setXPos(100);
        xform.setYPos(65 * Math.random());
    }
}

我的游戏场景

和所有情况一样,MyGame级别在my_game.js文件中实现。定义了三个特定的GameObject子类后,按照以下步骤操作:

  1. 除了引擎访问文件index.js,为了访问新定义的对象,必须导入相应的源代码:
import engine from "../engine/index.js";

// user stuff
import DyePack from "./objects/dye_pack.js";
import Minion from "./objects/minion.js";
import Hero from "./objects/hero.js";

Note

与其他导入/导出报表的情况一样,除非有其他特定原因,否则不会再次显示此提醒。

  1. 构造函数和load()unload()以及draw()函数与前面项目中的类似,所以这里不显示细节。

  2. 编辑init()函数并添加以下代码:

init() {
    ... identical to previous code ...

    // Step B: The dye pack: simply another GameObject
    this.mDyePack = new DyePack(this.kMinionSprite);

    // Step C: A set of Minions
    this.mMinionset = new engine.GameObjectSet();
    let i = 0, randomY, aMinion;
    // create 5 minions at random Y values
    for (i = 0; i < 5; i++) {
        randomY = Math.random() * 65;
        aMinion = new Minion(this.kMinionSprite, randomY);
        this.mMinionset.addToSet(aMinion);
    }

    // Step D: Create the hero object
    this.mHero = new Hero(this.kMinionSprite);

    // Step E: Create and initialize message output
    this.mMsg = new engine.FontRenderable("Status Message");
    this.mMsg.setColor([0, 0, 0, 1]);
    this.mMsg.getXform().setPosition(1, 2);
    this.mMsg.setTextHeight(3);
}

步骤 A 的细节,即相机的创建和背景色的初始化,没有显示出来,因为它们与前面的项目相同。步骤 B、C 和 D 显示了三种对象类型的实例化,步骤 C 显示了从右向左移动的Minion对象的创建和插入到mMinionset中,这是GameObjectSet类的一个实例。请注意,init()函数不需要设置每个对象的纹理、几何图形等。

  1. 编辑update()功能更新游戏状态:
update() {
    this.mHero.update();
    this.mMinionset.update();
    this.mDyePack.update();
}

抽象出每个对象类型的明确定义的行为后,clean update()函数清楚地显示出游戏由三个不交互的对象组成。

观察

现在你可以运行这个项目了,你会注意到六个小喽啰稍微复杂一点的动作是用更加简洁的init()update()函数完成的。init()功能仅包括在游戏世界中放置创建的对象的逻辑和控制,不包括不同对象类型的任何特定设置。随着Minion对象在它自己的update()函数中定义它的运动行为,MyGame update()函数中的逻辑可以关注关卡的细节。注意,这个函数的结构清楚地表明,这三个对象是独立更新的,彼此之间没有交互。

Note

在本书中,几乎在所有情况下,MyGame类都是为了展示引擎功能而设计的。因此,大多数MyGame类中的源代码组织可能并不代表实现游戏的最佳实践。

创建追逐行为

对前一个项目的进一步研究表明,尽管有相当多的小黄人在屏幕上移动,但他们的动作简单而乏味。即使在速度和方向上有变化,这些动作也没有目的,也不知道场景中的其他游戏对象。为了支持更复杂或有趣的运动,GameObject需要知道其他物体的位置,并根据这些信息确定运动。

追逐行为就是这样一个例子。追逐对象的目标通常是抓住它所瞄准的游戏对象。这需要对追踪者的前方方向和速度进行程序化的操作,这样它就能锁定目标。然而,通常重要的是避免实现一个具有完美目标并且总是击中目标的追逐者——因为如果玩家无法避免被击中,游戏将变得不可能的困难。尽管如此,这并不意味着如果你的游戏设计需要的话,你不应该实现一个完美的追逐者。您将在下一个项目中实现一个追赶器。

向量和相关操作是实现对象运动和行为的基础。在用向量编程之前,先快速回顾一下。与矩阵和变换运算符的情况一样,下面的讨论并不意味着对向量的全面覆盖。相反,重点是应用与游戏引擎实现相关的少量概念。这不是对数学背后的理论的研究。如果你对向量的细节以及它们与游戏的关系感兴趣,请参考第一章中的讨论,在那里你可以通过钻研线性代数和游戏的相关书籍来深入了解这些主题。

媒介评论

向量被用于许多研究领域,包括数学、物理、计算机科学和工程。它们在游戏中特别重要;几乎每个游戏都以这样或那样的方式使用向量。因为它们被广泛使用,这一节将致力于理解和利用游戏中的向量。

Note

对于向量的介绍和全面覆盖,可以参考 www.storyofmathematics.com/vectors 。关于游戏中矢量应用的更详细报道,可以参考【Unity 3D 游戏开发的基础数学:数学基础初学者指南,Apress,2019。

向量最常见的用途之一是表示物体的位移和方向或速度。这很容易做到,因为向量是由其大小和方向定义的。仅使用这少量信息,您就可以表示物体的速度或加速度等属性。如果你有一个物体的位置、方向和速度,那么你就有足够的信息在游戏世界中移动它,而不需要用户输入。

在进一步讨论之前,回顾一下向量的概念是很重要的,从如何定义向量开始。可以使用两点来指定向量。例如给定任意位置Pa=(xay**a)和Pb=(xby 可以将从 P aP b{\overset{\rightharpoonup }{V}}_{ab}的向量定义为P*bPa。 你可以在下面的等式和图 6-3 中看到这一点:*

img/334805_2_En_6_Fig3_HTML.png

图 6-3

由两点定义的向量

  • PT3a=(xay a )

  • Pb=(xby b )

  • {\overset{\rightharpoonup }{V}}_{ab}={P}_b-{P}_a=\left({x}_b-{x}_a,{y}_b-{y}_a\right)

现在您有了一个向量{\overset{\rightharpoonup }{V}}_{ab},您可以很容易地确定它的长度(或大小)和方向。向量的长度等于创建它的两点之间的距离。在这个例子中,{\overset{\rightharpoonup }{V}}_{ab}的长度等于 P * a P b 之间的距离,而{\overset{\rightharpoonup }{V}}_{ab}的方向则是从 P a 朝向 P b * 。

Note

向量的大小通常被称为其长度或大小

gl-matrix库中,vec2对象实现了 2D 向量的功能。方便的是,你也可以使用vec2物体来代表空间中的 2D 点或位置。在前面的例子中, P aP b{\overset{\rightharpoonup }{V}}_{ab}都可以实现为vec2对象的实例。然而,{\overset{\rightharpoonup }{V}}_{ab}是数学上唯一定义的向量。 P * a P b * 代表用来创建矢量的位置或点。

回想一下,向量也可以归一化。一个归一化的向量(也称为单位向量)的大小总是为 1。通过下面的函数可以看到一个归一化的矢量,如图 6-4 所示。注意,常规向量的数学符号是{\overset{\rightharpoonup }{V}}_a,归一化向量的数学符号是{\hat{V}}_a:

img/334805_2_En_6_Fig4_HTML.png

图 6-4

被标准化的向量

  • vec2\. normalized\left({\overset{\rightharpoonup }{V}}_a\ \right):归一化矢量{\overset{\rightharpoonup }{V}}_a,并将结果存储到vec2对象

到一个位置的向量也可以旋转。例如,如果矢量\overset{\rightharpoonup }{V}=\left({x}_v,{y}_v\right)表示从原点到位置的方向( x * v y v ),你想把它旋转θ,那么,如图 6-5 所示,你可以用下面的等式导出 x r y*

*img/334805_2_En_6_Fig5_HTML.png

图 6-5

从原点到位置( x vy v )旋转角度θ的向量

  • x【r】=【v】【θ】**

  • 【r】=【v】【sin】

*Note

JavaScript 三角函数,包括Math.sin()Math.cos()函数,假设输入是弧度而不是角度。回想一下,1 度等于\frac{\uppi}{180}弧度。

记住向量是由它们的方向和大小定义的,这一点很重要。换句话说,两个向量可以彼此相等,而与向量的位置无关。图 6-6 显示了两个矢量{\overset{\rightharpoonup }{V}}_a{\overset{\rightharpoonup }{V}}_{bc},它们位于不同的位置,但方向和大小相同,因此相等。相比之下,矢量{\overrightarrow{V}}_d并不相同,因为它的方向和大小与其他矢量不同。

img/334805_2_En_6_Fig6_HTML.png

图 6-6

2D 空间中的三个向量,其中两个向量相等

点积

两个归一化向量的点积为您提供了一种方法,可以找到这两个向量之间的角度。例如,假设如下:

  • {\overset{\rightharpoonup }{V}}_1=\left({x}_1,{y}_1\right)

  • {\overset{\rightharpoonup }{V}}_2=\left({x}_2,{y}_2\right)

那么下面是真的:

  • {\overset{\rightharpoonup }{V}}_1\bullet {\overset{\rightharpoonup }{V}}_2={\overset{\rightharpoonup }{V}}_2\bullet {\overset{\rightharpoonup }{V}}_1={x}_1{x}_2+{y}_1{y}_2

此外,如果向量{\overset{\rightharpoonup }{V}}_1{\overset{\rightharpoonup }{V}}_2都被归一化,则

  • {\hat{V}}_1\bullet {\hat{V}}_2=\cos \theta

图 6-7 描绘了{\overset{\rightharpoonup }{V}}_1{\overset{\rightharpoonup }{V}}_2向量之间有一个角度 θ 的例子。同样重要的是要认识到,如果{\overset{\rightharpoonup }{V}}_1\bullet {\overset{\rightharpoonup }{V}}_2=0,那么这两个向量是垂直的。

img/334805_2_En_6_Fig7_HTML.png

图 6-7

两个向量之间的角度,可以通过点积找到

Note

如果需要复习或刷新点积的概念,请参考 www.mathsisfun.com/algebra/vectors-dot-product.html

叉积

两个向量的叉积产生一个与两个原始向量正交的向量。在 2D 游戏中,2D 维度平放在屏幕上,叉积的结果是一个指向内(朝向屏幕)或外(远离屏幕)的向量。这可能看起来很奇怪,因为在 2D 或 x/y 平面上交叉两个向量会产生一个位于第三维或沿 z 轴的向量,这并不直观。然而,在第三维空间中产生的矢量携带着重要的信息。例如,这个向量在第三维中的方向可以用来确定游戏对象需要顺时针还是逆时针方向旋转。仔细看看以下内容:

  • {\overset{\rightharpoonup }{V}}_1=\left({x}_1,{y}_1\right)

  • {\overset{\rightharpoonup }{V}}_2=\left({x}_2,{y}_2\right)

鉴于上述情况,以下情况属实:

  • {\overset{\rightharpoonup }{V}}_3={\overset{\rightharpoonup }{V}}_1\times {\overset{\rightharpoonup }{V}}_2是垂直于{\overset{\rightharpoonup }{V}}_1{\overset{\rightharpoonup }{V}}_2的向量。

此外,您知道 x/y 平面上两个向量的叉积会产生 z 方向的向量。当{\overset{\rightharpoonup }{V}}_1\times {\overset{\rightharpoonup }{V}}_2&gt;0时,你知道{\overset{\rightharpoonup }{V}}_1是从{\overset{\rightharpoonup }{V}}_2;顺时针方向,同样,当{\overset{\rightharpoonup }{V}}_1\times {\overset{\rightharpoonup }{V}}_2&lt;0时,你知道{\overset{\rightharpoonup }{V}}_1是逆时针方向。图 6-8 应该有助于澄清这个概念。

img/334805_2_En_6_Fig8_HTML.png

图 6-8

两个向量的叉积

Note

如果需要回顾或刷新交叉产品的概念,请参考 www.mathsisfun.com/algebra/vectors-cross-product.html

前沿与追逐项目

这个项目实现了更有趣、更复杂的行为,这些行为基于已经被回顾过的向量概念。你将体验定义和改变一个对象的正面方向,并引导一个对象在场景中追逐另一个对象的过程,而不是恒定和无目的的运动。你可以在图 6-9 中看到这个项目运行的例子。这个项目的源代码在chapter6/6.2.front_and_chase文件夹中定义。

img/334805_2_En_6_Fig9_HTML.jpg

图 6-9

运行前端和追踪项目

该项目的控制措施如下:

  • WASD 键:移动Hero对象

  • 左/右箭头键:在用户控制下改变Brain对象的前方方向

  • 上下箭头键:增加/减少Brain物体的速度

  • H 键:将Brain对象切换到用户箭头键控制下

  • J 键:切换Brain对象始终指向并向当前Hero对象位置移动

  • K 键:切换Brain物体转向并逐渐向当前Hero物体位置移动

该项目的目标如下:

  • 体验工作的速度和方向

  • 练习沿着预先定义的方向行进

  • 用矢量点积和叉积实现算法

  • 检查和实施追逐行为

您可以在assets文件夹中找到与上一个项目相同的外部资源文件。

将矢量旋转添加到 gl 矩阵库中

gl-matrix库不支持旋转 2D 空间中的位置。这可以通过将以下代码添加到lib文件夹中的gl-matrix.js文件来纠正:

vec2.rotate = function(out, a, c){
    var r=[];
    // perform rotation
    r[0] = a[0]*Math.cos(c) - a[1]*Math.sin(c);
    r[1] = a[0]*Math.sin(c) + a[1]*Math.cos(c);
    out[0] = r[0];
    out[1] = r[1];
    return r;
};

Note

从现在开始,对gl-matrix库的修改必须出现在所有项目中。

修改游戏对象以支持有趣的行为

GameObject类抽象并实现所需的新对象行为:

  1. 编辑game_object.js文件并修改GameObject构造函数以定义可见度、前方方向和速度:

  2. 为实例变量添加评估器和设置器函数:

constructor(renderable) {
    this.mRenderComponent = renderable;
    this.mVisible = true;
    this.mCurrentFrontDir = vec2.fromValues(0, 1); // front direction
    this.mSpeed = 0;
}

  1. 执行一个功能,将前方旋转到一个位置,p:
getXform() { return this.mRenderComponent.getXform(); }

setVisibility(f) { this.mVisible = f; }
isVisible() { return this.mVisible; }

setSpeed(s) { this.mSpeed = s; }
getSpeed() { return this.mSpeed; }
incSpeedBy(delta) { this.mSpeed += delta; }

setCurrentFrontDir(f) { vec2.normalize(this.mCurrentFrontDir, f); }
getCurrentFrontDir() { return this.mCurrentFrontDir; }

getRenderable() { return this.mRenderComponent; }

rotateObjPointTo(p, rate) {
    // Step A: determine if reached the destination position p
    let dir = [];
    vec2.sub(dir, p, this.getXform().getPosition());
    let len = vec2.length(dir);
    if (len < Number.MIN_VALUE) {
        return; // we are there.
    }
    vec2.scale(dir, dir, 1 / len);

    // Step B: compute the angle to rotate
    let fdir = this.getCurrentFrontDir();
    let cosTheta = vec2.dot(dir, fdir);

    if (cosTheta > 0.999999) { // almost exactly the same direction
        return;
    }

    // Step C: clamp the cosTheta to -1 to 1
    // in a perfect world, this would never happen! BUT ...
    if (cosTheta > 1) {
        cosTheta = 1;
    } else {
        if (cosTheta < -1) {
            cosTheta = -1;
        }
    }

    // Step D: compute whether to rotate clockwise, or counterclockwise
    let dir3d = vec3.fromValues(dir[0], dir[1], 0);
    let f3d = vec3.fromValues(fdir[0], fdir[1], 0);
    let r3d = [];
    vec3.cross(r3d, f3d, dir3d);

    let rad = Math.acos(cosTheta);  // radian to roate
    if (r3d[2] < 0) {
        rad = -rad;
    }

    // Step E: rotate the facing direction with the angle and rate
    rad *= rate;  // actual angle need to rotate from Obj's front
    vec2.rotate(this.getCurrentFrontDir(),this.getCurrentFrontDir(),rad);
    this.getXform().incRotationByRad(rad);
}

rotateObjPointTo()功能以参数rate指定的速率旋转mCurrentFrontDir指向目的位置p。以下是每个操作的详细信息:

img/334805_2_En_6_Fig10_HTML.png

图 6-10

一个游戏对象(Brain)追逐一个目标(Hero)

  1. 步骤 A 计算当前对象和目的位置p之间的距离。如果该值很小,则意味着当前对象和目标位置很接近。函数返回,不做进一步处理。

  2. 步骤 B,如图 6-10 所示,计算点积,确定物体当前前方方向(fdir)与朝向目的位置方向p ( dir)之间的角度θ。如果这两个向量指向相同的方向(cosθ几乎为 1 或θ几乎为零),则函数返回。

  3. 添加一个函数,用物体的方向和速度更新物体的位置。注意,如果mCurrentFrontDirrotateObjPointTo()函数修改,那么这个update()函数将把对象移向目标位置p,对象将表现得好像在追逐目标。

  4. 步骤 C 检查cosTheta的范围。由于 JavaScript 中浮点运算的不准确性,这是必须执行的步骤。

  5. 步骤 D 使用叉积的结果来确定当前的GameObject应该顺时针还是逆时针转动以朝向目的位置p

  6. 步骤 E 旋转mCurrentFrontDir并在Renderable对象的Transform中设置旋转。识别两个独立的对象旋转控件非常重要。Transform控制被画物体的旋转,mCurrentFrontDir控制行进方向。在这种情况下,两者是同步的,因此必须同时用新值更新。

  7. 添加一个基于可见性设置绘制对象的函数:

update() {
    // simple default behavior
    let pos = this.getXform().getPosition();
    vec2.scaleAndAdd(pos, pos,this.getCurrentFrontDir(),this.getSpeed());
}

draw(aCamera) {
    if (this.isVisible()) {
        this.mRenderComponent.draw(aCamera);
    }
}

测试追踪功能

这个测试用例的策略和目标是创建一个可操纵的Brain对象来演示沿着预定义的前方方向行进,并引导Brain去追逐Hero来演示追逐功能。

定义大脑游戏对象

Brain对象将在用户左/右箭头键的控制下沿其前方方向移动,以进行转向:

  1. src/my_game/objects文件夹中创建一个新文件,并将其命名为brain.js。将Brain定义为GameObject的子类,实现构造函数初始化外观和行为参数。

  2. 超越update()功能,支持用户转向和控制速度。注意,必须调用GameObject中默认的update()函数,以支持物体根据其速度沿前方的基本移动。

class Brain extends engine.GameObject {
    constructor(spriteTexture) {
        super(null);
        this.kDeltaDegree = 1;
        this.kDeltaRad = Math.PI * this.kDeltaDegree / 180;
        this.kDeltaSpeed = 0.01;
        this.mRenderComponent =
                              new engine.SpriteRenderable(spriteTexture);
        this.mRenderComponent.setColor([1, 1, 1, 0]);
        this.mRenderComponent.getXform().setPosition(50, 10);
        this.mRenderComponent.getXform().setSize(3, 5.4);
        this.mRenderComponent.setElementPixelPositions(600, 700, 0, 180);

        this.setSpeed(0.05);
    }

    ... implementation to follow ...
}
export default Brain;

update() {
    super.update();
    let xf = this.getXform();
    let fdir = this.getCurrentFrontDir();
    if (engine.input.isKeyPressed(engine.input.keys.Left)) {
        xf.incRotationByDegree(this.kDeltaDegree);
        vec2.rotate(fdir, fdir, this.kDeltaRad);
    }
    if (engine.input.isKeyPressed(engine.input.keys.Right)) {
        xf.incRotationByRad(-this.kDeltaRad);
        vec2.rotate(fdir, fdir, -this.kDeltaRad);
    }
    if (engine.input.isKeyClicked(engine.input.keys.Up)) {
        this.incSpeedBy(this.kDeltaSpeed);
    }
    if (engine.input.isKeyClicked(engine.input.keys.Down)) {
        this.incSpeedBy(-this.kDeltaSpeed);
    }
}

我的游戏场景

修改MyGame场景来测试Brain物体的移动。在这种情况下,除了update()函数之外,my_game.js中的其余源代码与之前的项目类似。因此,只显示了update()功能的细节:

update() {
    let msg = "Brain [H:keys J:imm K:gradual]: ";
    let rate = 1;

    this.mHero.update();

    switch (this.mMode) {
        case 'H':
            this.mBrain.update();  // player steers with arrow keys
            break;
        case 'K':
            rate = 0.02;    // gradual rate
            // In gradual mode, the following should also be executed
        case 'J':
            this.mBrain.rotateObjPointTo(
                this.mHero.getXform().getPosition(), rate);

            // the default GameObject: only move forward
            engine.GameObject.prototype.update.call(this.mBrain);
            break;
        }

    if (engine.input.isKeyClicked(engine.input.keys.H)) {
        this.mMode = 'H';
    }
    if (engine.input.isKeyClicked(engine.input.keys.J)) {
        this.mMode = 'J';
    }
    if (engine.input.isKeyClicked(engine.input.keys.K)) {
        this.mMode = 'K';
    }
    this.mMsg.setText(msg + this.mMode);
}

update()函数中,switch语句使用mMode来决定如何更新Brain对象。在JK模式下,Brain对象通过rotateObjPointTo()函数调用转向Hero对象位置。在H模式下,调用Brain对象的update()函数,让用户用箭头键操纵对象。最后三个if语句只是根据用户输入设置mMode变量。

注意在JK模式下,为了绕过rotateObjPointTo()后的用户控制逻辑,被调用的update()函数是由GameObject定义的函数,而不是由Brain定义的函数。

Note

JavaScript 语法ClassName.prototype.FunctionName.call(anObj)调用由ClassName定义的FunctionName,其中anObjClassName的子类。

观察

您现在可以尝试运行该项目。最初,Brain对象处于用户的控制之下。您可以使用左箭头键和右箭头键来改变Brain对象的前方方向,并体验操纵该对象。按下J键会导致Brain对象立即指向并移向Hero对象。这是默认转弯rate值为 1.0 的结果。K键导致更自然的行为,其中Brain对象继续向前移动,并逐渐转向向Hero对象移动。随意更改rate变量的值或修改Brain对象的控制值。例如,更改kDeltaRadkDeltaSpeed来试验不同的行为设置。

游戏对象之间的碰撞

在之前的项目中,Brain物体永远不会停止移动。注意在JK模式下,Brain物体到达目标位置时会绕轨道运行或快速翻转方向。Brain物体失去了检测到它与Hero物体相撞的关键能力,因此,它永远不会停止移动。本节描述了轴对齐包围盒(AABBs),它是用于近似物体碰撞的最直接的工具之一,并演示了基于 AABB 的碰撞检测的实现。

轴对齐的边界框(AABB)

AABB 是一个 x/y 轴对齐的矩形框,它限定了给定对象的边界。术语 x/y 轴对齐是指 AABB 的四条边平行于水平 x 轴或垂直 y 轴。图 6-11 显示了一个用左下角(mLL)、宽度和高度表示Hero对象边界的例子。这是表示 AABB 的一种相当常见的方式,因为它仅使用一个位置和两个浮点数来表示维度。

img/334805_2_En_6_Fig11_HTML.png

图 6-11

对象边界的左下角和大小

有趣的是,除了表示对象的边界,边界框还可以用来表示任何给定矩形区域的边界。例如,回想一下通过Camera可见的 WC 是一个矩形区域,相机的位置位于中心,WC 的宽度/高度由游戏开发者定义。可以定义一个 AABB 来表示可见的 WC 矩形区域或 WC 窗口,并用于检测 WC 窗口和游戏世界中的GameObject实例之间的碰撞。

Note

在本书中,AABB 和“边界框”可以互换使用。

边界框和碰撞项目

这个项目演示了如何为一个GameObject实例定义一个边界框,并根据它们的边界框检测两个GameObject实例之间的冲突。重要的是要记住,边界框是轴对齐的,因此,本节介绍的解决方案不支持旋转对象之间的碰撞检测。你可以在图 6-12 中看到这个项目运行的例子。这个项目的源代码在chapter6/6.3.bbox_and_collisions文件夹中定义。

img/334805_2_En_6_Fig12_HTML.jpg

图 6-12

运行边界框和碰撞项目

该项目的控件与之前的项目相同:

  • WASD 键:移动Hero对象

  • 左/右箭头键:在用户控制下改变Brain对象的前方方向

  • 上下箭头键:增加/减少Brain物体的速度

  • H 键:将Brain对象切换到用户箭头键控制下

  • J 键:切换Brain对象始终指向并向当前Hero对象位置移动

  • K 键:切换Brain物体转向并逐渐向当前Hero物体位置移动

该项目的目标如下:

  • 理解边界框类的实现

  • 体验使用GameObject实例的边界框

  • 计算并使用Camera WC 窗口的边界

  • 使用对象碰撞和对象与摄影机 WC 窗口碰撞进行编程

您可以在assets文件夹中找到与上一个项目相同的外部资源文件。

定义一个边界框类

定义一个BoundingBox类来表示矩形区域的边界:

  1. src/engine文件夹中新建一个文件;命名为bounding_box.js。首先,定义一个枚举数据类型,其值标识边界框的冲突边。
const eBoundCollideStatus = Object.freeze({
    eCollideLeft: 1,
    eCollideRight: 2,
    eCollideTop: 4,
    eCollideBottom: 8,
    eInside: 16,
    eOutside: 0
});

注意,每个枚举值只有一个非零位。这允许枚举值与按位“或”操作符组合来表示多边冲突。例如,如果一个对象同时与一个边界框的顶部和左侧发生碰撞,碰撞状态将为eCollideLeft | eCollideTop = 1 | 4 = 5

  1. 现在,用实例变量定义BoundingBox类和构造函数来表示一个边界,如图 6-11 所示。注意,eBoundCollideStatus也必须被导出,这样引擎的其他部分,包括客户端,也可以访问。

  2. setBounds()函数计算并设置边界框的实例变量:

class BoundingBox {
    constructor(centerPos, w, h) {
        this.mLL = vec2.fromValues(0, 0);
        this.setBounds(centerPos, w, h);
    }

    ... implementation to follow ...
}

export {eBoundCollideStatus}
export default BoundingBox;

  1. 定义一个函数来确定给定位置(x,y)是否在框的边界内:
setBounds(centerPos, w, h) {
    this.mWidth = w;
    this.mHeight = h;
    this.mLL[0] = centerPos[0] - (w / 2);
    this.mLL[1] = centerPos[1] - (h / 2);
}

  1. 定义一个函数来确定给定边界是否与当前边界相交:
containsPoint(x, y) {
    return ((x > this.minX()) && (x < this.maxX()) &&
        (y > this.minY()) && (y < this.maxY()));
}

  1. 定义一个函数来计算给定边界和当前边界之间的相交状态:
intersectsBound(otherBound) {
    return ((this.minX() < otherBound.maxX()) &&
        (this.maxX() > otherBound.minX()) &&
        (this.minY() < otherBound.maxY()) &&
        (this.maxY() > otherBound.minY()));
}

boundCollideStatus(otherBound) {
    let status = eBoundCollideStatus.eOutside;

    if (this.intersectsBound(otherBound)) {
        if (otherBound.minX() < this.minX()) {
            status |= eBoundCollideStatus.eCollideLeft;
        }
        if (otherBound.maxX() > this.maxX()) {
            status |= eBoundCollideStatus.eCollideRight;
        }
        if (otherBound.minY() < this.minY()) {
            status |= eBoundCollideStatus.eCollideBottom;
        }
        if (otherBound.maxY() > this.maxY()) {
            status |= eBoundCollideStatus.eCollideTop;
        }

        // if the bounds intersects and yet none of the sides overlaps
        // otherBound is completely inside thisBound
        if (status === eBoundCollideStatus.eOutside) {
            status = eBoundCollideStatus.eInside;
        }
    }
    return status;
}

请注意intersectsBound()boundCollideStatus()函数之间微妙而重要的区别,前者只能返回一个truefalse条件,而后者在返回的status中对冲突双方进行编码。

  1. 实现将 X/Y 值返回到边界框的最小和最大边界的函数:
minX() { return this.mLL[0]; }
maxX() { return this.mLL[0] + this.mWidth; }
minY() { return this.mLL[1]; }
maxY() { return this.mLL[1] + this.mHeight; }

最后,记得更新引擎访问文件index.js,以便将新定义的功能转发给客户端。

使用引擎中的边界框

新定义的功能将用于检测对象之间以及对象和 WC 边界之间的碰撞。为了实现这一点,必须修改GameObjectCamera类。

  1. 编辑game_object.js以导入新定义的功能并修改GameObject类;实现getBBox()函数返回未旋转的Renderable对象的边界框:

  2. 编辑camera.js从边界框导入,修改Camera类计算Transform对象(通常在Renderable对象中定义)边界和 WC 窗口边界之间的碰撞状态:

import BoundingBox from "../bounding_box.js";
class GameObject {
    ... identical to previous code ...
    getBBox() {
        let xform = this.getXform();
        let b = new BoundingBox(
                            xform.getPosition(),
                            xform.getWidth(),
                            xform.getHeight());
        return b;
    }
    ... identical to previous code ...
}

import BoundingBox from "./bounding_box.js";
class Camera {
    ... identical to previous code ...
    collideWCBound(aXform, zone) {
        let bbox = new BoundingBox(
                            aXform.getPosition(),
                            aXform.getWidth(),
                            aXform.getHeight());
        let w = zone * this.getWCWidth();
        let h = zone * this.getWCHeight();
        let cameraBound = new BoundingBox(this.getWCCenter(), w, h);
        return cameraBound.boundCollideStatus(bbox);
    }
}

请注意,zone参数定义了应该在碰撞计算中使用的 WC 的相对大小。例如,zone值为 0.8 意味着根据当前 WC 窗口大小的 80%计算交叉点状态。图 6-13 显示了相机如何与物体碰撞。

img/334805_2_En_6_Fig13_HTML.png

图 6-13

Camera WC 边界与定义一个Transform对象的边界冲突

用我的游戏测试边界框

这个测试用例的目标是验证在检测对象-对象和对象-摄像机相交时边界框实现的正确性。同样,除了update()函数之外,my_game.js文件中的大部分代码与前面的项目相似,这里不再重复。update()函数是对之前项目的修改,用来测试边界框的交叉点。

update() {
    ... identical to previous code ...

    switch (this.mMode) {
        case 'H':
            this.mBrain.update();  // player steers with arrow keys
            break;
        case 'K':
            rate = 0.02;    // gradual rate
            // no break here on purpose
        case 'J':
            // stop the brain when it touches hero bound
            if (!hBbox.intersectsBound(bBbox)) {
                this.mBrain.rotateObjPointTo(
                        this.mHero.getXform().getPosition(), rate);
                // the default GameObject: only move forward
                engine.GameObject.prototype.update.call(this.mBrain);
            }
            break;
    }

    // Check for hero going outside 80% of the WC Window bound
    let status = this.mCamera.collideWCBound(this.mHero.getXform(), 0.8);

    ... identical to previous code ...

    this.mMsg.setText(msg + this.mMode + " [Hero bound=" + status + "]");
}

switch语句的JK情况下,在调用Brain.rotateObjPointTo()update()导致追逐行为之前,修改测试BrainHero对象之间的包围盒碰撞。这样,Brain对象一碰到Hero对象的边界就会停止移动。此外,计算并显示Hero对象和 80%的摄像机 WC 窗口之间的碰撞结果。

观察

现在,您可以运行项目并观察到Brain对象在自主模式(J 或 K 键)下,一接触到Hero对象就停止移动。当你四处移动Hero对象时,在Hero对象实际接触 WC 窗口边界之前,观察到Hero bound输出消息开始回应 WC 窗口碰撞。这是传递给mCamera.collideWCBound()函数的参数 0.8 或 80%的结果,将碰撞计算配置为当前 WC 窗口大小的 80%。当Hero对象完全在 WC 窗口边界的 80%以内时,输出Hero bound值为 16 或eboundcollideStatus.eInside的值。试着移动Hero物体接触窗口边界的顶部 20 %,观察Hero bound值 4 或eboundcollideStatus.eCollideTop值。现在将Hero对象移向窗口的左上角,观察Hero bound值 5 或eboundcollideStatus.eCollideTop | eboundcollideStatus.eCollideLeft。这样,碰撞状态是所有碰撞边界的按位或结果。

每像素碰撞

在前面的示例中,您看到了边界框碰撞近似的结果。也就是说,Brain对象的运动一与Hero对象的边界重叠就停止。这比最初的情况有了很大的改进,在最初的情况下,Brain物体永远不会停止移动。然而,如图 6-14 所示,基于边界框的碰撞有两个严重的限制。

img/334805_2_En_6_Fig14_HTML.png

图 6-14

基于边界框的碰撞限制

  1. 在前一个例子中引入的BoundingBox对象不考虑旋转。这是 AABB 的一个众所周知的限制:尽管这种方法计算效率高,但它不支持旋转的对象。

  2. 这两个物体实际上没有碰撞。两个物体的边界重叠的事实并不自动等同于两个物体的碰撞。

在本项目中,您将实现逐像素精确碰撞检测、逐像素精确碰撞检测或逐像素碰撞检测,以检测两个碰撞对象的不透明像素的重叠。然而,请记住,这是而不是一个终极解决方案。虽然每像素碰撞检测是精确的,但代价是潜在的性能成本。随着图像变得越来越大和越来越复杂,它也有更多的像素需要进行碰撞检查。这与包围盒碰撞检测所需的恒定计算成本形成对比。

每像素碰撞项目

这个项目演示了如何检测一个大的纹理对象,即Collector minion 和一个小的纹理对象,即Portal minion 之间的碰撞。这两种纹理都包含透明和不透明区域。只有当不透明像素重叠时,才会发生碰撞。在这个项目中,当碰撞发生时,一个黄色的DyePack出现在碰撞点。你可以在图 6-15 中看到这个项目运行的例子。这个项目的源代码在chapter6/6.4.per_pixel_collisions文件夹中定义。

img/334805_2_En_6_Fig15_HTML.jpg

图 6-15

运行逐像素碰撞项目

该项目的控制措施如下:

  • **箭头键:**移动小纹理对象,Portal宠臣

  • **WASD 键:**移动大型纹理对象,Collector爪牙

该项目的目标如下:

  • 演示如何检测不透明像素重叠

  • 为了理解使用逐像素精确碰撞检测的优点和缺点

Note

“透明”像素是一个你可以完全看透的像素,在这个引擎中,它的 alpha 值为 0。“不透明”像素的 alpha 值大于 0,或者该像素没有完全遮挡其后面的内容;它可能闭塞也可能不闭塞。“不透明”像素会遮挡其后面的内容,是“不透明的”,alpha 值为 1。例如,请注意您可以“透视”到Portal对象的顶部区域。这些像素是不透明的,但也不是不透明的,当根据项目定义的参数发生重叠时,应该会导致冲突。

您可以在assets文件夹中找到以下外部资源:包含默认系统字体的fonts文件夹、minion_collector.pngminion_portal.pngminion_sprite.png。注意minion_collector.png是大的,1024x1024 的图像,而minion_portal.png是小的,64x64 的图像;minion_sprite.png定义DyePack sprite 元素。

逐像素碰撞算法概述

在继续之前,确定检测两个纹理对象之间的碰撞的要求很重要。最重要的是,纹理本身需要包含一个透明区域,以便这种类型的碰撞检测能够提高精确度。如果纹理中没有透明度,您可以并且应该使用简单的边界框碰撞检测。如果一个或两个纹理包含透明区域,那么你需要处理两种碰撞情况。第一种情况是检查两个对象的边界是否冲突。你可以在图 6-16 中看到这一点。请注意对象的边界是如何重叠的,然而没有一个不透明的彩色像素相接触。

img/334805_2_En_6_Fig16_HTML.png

图 6-16

没有实际碰撞的重叠边界框

下一种情况是检查纹理的不透明像素是否重叠。看一下图 6-17 。来自CollectorPortal对象纹理的不透明像素清楚地彼此接触。

img/334805_2_En_6_Fig17_HTML.png

图 6-17

大纹理和小纹理之间发生像素冲突

既然问题已经明确定义,下面是每像素精确碰撞检测的逻辑或伪代码:

Given two images, Image-A and Image-B
If the bounds of the two collide then
    For each Pixel-A in Image-A
        If Pixel-A is not completely transparent
            pixelCameraSpace = Pixel-A position in camera space
            Transform pixelCameraSpace to Image-B space
            Read Pixel-B from Image-B
            If Pixel-B is not completely transparent then
                A collision has occurred

需要从pixelCameraSpace到 Image-B 空间的逐像素转换,因为碰撞检查必须在相同的坐标空间内进行。

请注意,在算法中,图像 A 和图像 B 是可交换的。也就是说,当测试两个图像之间的冲突时,哪个图像是图像 A 还是图像 b 并不重要。冲突结果将是相同的。这两幅图像要么重叠,要么不重叠。另外,注意这个算法的运行时间。必须处理图像 A 中的每个像素;因此,运行时间是 O(N),其中 N 是 Image-A 或 Image-A 的分辨率中的像素数。出于这个原因,出于性能原因,选择两个图像中较小的一个(本例中为Portal minion)作为 Image-A 是很重要的。

此时,您可能会明白为什么像素精确碰撞检测的性能令人担忧。在每次更新许多高分辨率纹理时检查这些碰撞会很快降低性能。现在,您可以检查每像素精确碰撞的实现了。

修改纹理以颜色数组的形式加载纹理

回想一下,Texture组件从服务器文件系统读取图像文件,将图像加载到 GPU 内存,并将图像处理成 WebGL 纹理。通过这种方式,纹理图像存储在 GPU 上,并且不能被运行在 CPU 上的游戏引擎访问。为了支持逐像素碰撞检测,必须从 GPU 中检索颜色信息,并将其存储在 CPU 中。可以修改Texture组件来支持这个需求。

  1. texture.js文件中,扩展TextureInfo对象以包含一个新变量,用于存储文件纹理的颜色数组:

  2. 定义并导出从 GPU 内存中检索颜色数组的函数:

class TextureInfo {
    constructor(w, h, id) {
        this.mWidth = w;
        this.mHeight = h;
        this.mGLTexID = id;
        this.mColorArray = null;
    }
}

function getColorArray(textureName) {
    let gl = glSys.get();
    let texInfo = get(textureName);
    if (texInfo.mColorArray === null) {
        // create framebuffer bind to texture and read the color content
        let fb = gl.createFramebuffer();
        gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
        gl.framebufferTexture2D(gl.FRAMEBUFFER,
                          gl.COLOR_ATTACHMENT0,
                          gl.TEXTURE_2D, texInfo.mGLTexID, 0);
        if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) ===
            gl.FRAMEBUFFER_COMPLETE) {
            let pixels = new Uint8Array(
                          texInfo.mWidth * texInfo.mHeight * 4);
            gl.readPixels(0, 0, texInfo.mWidth, texInfo.mHeight,
                gl.RGBA, gl.UNSIGNED_BYTE, pixels);
            texInfo.mColorArray = pixels;
        } else {
            throw new Error("...");
            return null;
        }
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
        gl.deleteFramebuffer(fb);
    }
    return texInfo.mColorArray;
}

export {has, get, load, unload,

    TextureInfo,

    activate, deactivate,

    getColorArray
}

getColorArray()函数创建一个 WebGL FRAMEBUFFER,用所需的纹理填充缓冲区,并将缓冲区内容检索到由texInfo.mColorArray引用的 CPU 内存中。

修改 TextureRenderable 以支持逐像素冲突

TextureRenderable是最适合实现逐像素碰撞功能的类。这是因为TextureRenderable是所有渲染纹理的类的基类。在这个基类中实现意味着所有子类都可以继承这个功能,只需做很少的额外修改。

随着TextureRenderable类功能的增加,实现源代码的复杂性和规模也会增加。为了可读性和可扩展性,保持源代码文件的大小很重要。一种有效的方法是根据功能将一个类的源代码分成多个文件。

组织源代码

在下面的步骤中,TextureRenderable类将被分成三个源代码文件:texture_renderable_main.js用于实现以前项目的基本功能,texture_renderable_pixel_collision.js用于实现新引入的每像素精确碰撞,texture_renderable.js用作类访问点。

  1. 重命名texture_renderable.js。到texture_renderable_main.js。这个文件定义了TextureRenderable类的基本功能。

  2. src/engine/renderables中创建一个新文件,命名为texture_renderable_pixel_collision.js。这个文件将用于扩展TextureRenderable类的功能,以支持每像素精确的碰撞。添加以下代码,从Texture模块和基本的TextureRenderable类导入,并重新导出TextureRenderable类。目前,这个文件没有任何用途;您将在下面的小节中添加适当的扩展函数。

  3. 通过添加以下代码,创建一个新的texture_renderable.js文件作为TextureRenderable访问点:

"use strict";
import TextureRenderable from "./texture_renderable_main.js";
import * as texture from "../resources/texture.js";

... implementation to follow ...

export default TextureRenderable;

"use strict";
import TextureRenderable from "./ texture_renderable_pixel_collision.js";
export default TextureRenderable;

有了这个结构,texture_renderable_main.js文件实现了所有的基本功能,并导出到texture_renderable_pixel_collision.js,后者将附加的功能添加到TextureRenderable类中。最后,texture_renderable.jstexture_renderable_pixel_collision.js导入扩展功能。TextureRenderable类的用户可以简单地从texture_renderable.js导入,并且可以访问所有已定义的功能。

这样,从游戏开发者的角度来看,texture_renderable.js充当了TextureRenderable类的访问点,隐藏了实现源代码结构的细节。同时,从引擎开发人员的角度来看,复杂的实现被分离到源代码文件中,这些文件的名称表明了实现每个单独文件可读性的内容。

定义对纹理颜色数组的访问

回想一下,您通过首先编辑Texture模块从 GPU 到 CPU 检索表示纹理的颜色数组来开始这个项目。您现在必须编辑TextureRenderable才能访问该颜色数组。

  1. 编辑texture_renderable_main.js文件,并修改构造函数以添加实例变量来保存纹理信息,包括对检索到的颜色数组的引用,以支持每像素碰撞检测和以后的子类覆盖:

  2. 修改setTexture()函数以相应地初始化实例变量:

class TextureRenderable extends Renderable {
    constructor(myTexture) {
        super();
        // Alpha of 0: switch off tinting of texture
        super.setColor([1, 1, 1, 0]);
        super._setShader(shaderResources.getTextureShader());

        this.mTexture = null;
        // these two instance variables are to cache texture information
        // for supporting per-pixel accurate collision
        this.mTextureInfo = null;
        this.mColorArray = null;
        // defined for subclass to override
        this.mElmWidthPixels = 0;
        this.mElmHeightPixels = 0;
        this.mElmLeftIndex = 0;
        this.mElmBottomIndex = 0;

        // texture for this object, cannot be a "null"
        this.setTexture(myTexture);
    }

setTexture(newTexture) {
    this.mTexture = newTexture;
    // these two instance variables are to cache texture information
    // for supporting per-pixel accurate collision
    this.mTextureInfo = texture.get(newTexture);
    this.mColorArray = null;
    // defined for one sprite element for subclass to override
    // For texture_renderable, one sprite element is the entire texture
    this.mElmWidthPixels = this.mTextureInfo.mWidth;
    this.mElmHeightPixels = this.mTextureInfo.mHeight;
    this.mElmLeftIndex = 0;
    this.mElmBottomIndex = 0;
}

注意,默认情况下,mColorArry被初始化为null。对于 CPU 内存优化,仅对于参与逐像素碰撞的纹理,从 GPU 获取颜色数组。mElmWidthPixelsmElmHeightPixels变量是纹理的宽度和高度。这些变量是为以后的子类覆盖定义的,这样算法可以支持 sprite 元素的冲突。

实现逐像素碰撞

现在,您可以在新创建的texture_renderable_pixel_collision.js文件中实现逐像素碰撞算法了。

  1. 编辑texture_renderable_pixel_collision.js文件,为TextureRenderable类定义一个新函数来设置mColorArray:
TextureRenderable.prototype.setColorArray = function() {
    if (this.mColorArray === null) {
        this.mColorArray = texture.getColorArray(this.mTexture);
    }
}

Note

JavaScript 类是基于原型链实现的。在类构造之后,实例方法可以通过类的原型或aClass.prototype.method来访问和定义。关于 JavaScript 类和原型的更多信息,请参考 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain

  1. 定义一个新函数来返回任何给定像素(xy)的 alpha 值或透明度:
TextureRenderable.prototype._pixelAlphaValue = function(x, y) {
    x = x * 4;
    y = y * 4;
    return this.mColorArray[(y * this.mTextureInfo.mWidth) + x + 3];
}

注意mColorArray是一个 1D 数组,其中像素的颜色存储为四个浮点数,并按行组织。

  1. 定义一个函数来计算给定像素(i, j)的 WC 位置(returnWCPos):

  2. 现在,实现前面函数的逆函数,并使用 WC 位置(wcPos)来计算纹理像素索引(returnIndex):

TextureRenderable.prototype._indexToWCPosition =
function(returnWCPos, i, j) {
    let x = i * this.mXform.getWidth() / this.mElmWidthPixels;
    let y = j * this.mXform.getHeight() / this.mElmHeightPixels;
    returnWCPos[0] = this.mXform.getXPos() +
                     (x - (this.mXform.getWidth() * 0.5));
    returnWCPos[1] = this.mXform.getYPos() +
                     (y - (this.mXform.getHeight() * 0.5));
}

  1. 现在可以实现概述的逐像素碰撞算法了:
TextureRenderable.prototype._wcPositionToIndex =
function(returnIndex, wcPos) {
    // use wcPos to compute the corresponding returnIndex[0 and 1]
    let delta = [];
    vec2.sub(delta, wcPos, this.mXform.getPosition());
    returnIndex[0] = this.mElmWidthPixels *
                     (delta[0] / this.mXform.getWidth());
    returnIndex[1] = this.mElmHeightPixels *
                     (delta[1] / this.mXform.getHeight());

    // recall that xForm.getPosition() returns center, yet
    // Texture origin is at lower-left corner!
    returnIndex[0] += this.mElmWidthPixels / 2;
    returnIndex[1] += this.mElmHeightPixels / 2;

    returnIndex[0] = Math.floor(returnIndex[0]);
    returnIndex[1] = Math.floor(returnIndex[1]);
}

TextureRenderable.prototype.pixelTouches = function(other, wcTouchPos) {
    let pixelTouch = false;
    let xIndex = 0, yIndex;
    let otherIndex = [0, 0];

    while ((!pixelTouch) && (xIndex < this.mElmWidthPixels)) {
        yIndex = 0;
        while ((!pixelTouch) && (yIndex < this.mElmHeightPixels)) {
            if (this._pixelAlphaValue(xIndex, yIndex) > 0) {
                this._indexToWCPosition(wcTouchPos, xIndex, yIndex);
                other._wcPositionToIndex(otherIndex, wcTouchPos);
                if ((otherIndex[0] >= 0) &&
                    (otherIndex[0] < other.mElmWidthPixels) &&
                    (otherIndex[1] >= 0) &&
                    (otherIndex[1] < other.mElmHeightPixels)) {
                    pixelTouch = other._pixelAlphaValue(
                                       otherIndex[0], otherIndex[1]) > 0;
                }
            }
            yIndex++;
        }
        xIndex++;
    }
    return pixelTouch;
}

参数other是对正在进行碰撞测试的另一个TextureRenderable对象的引用。如果像素在对象之间重叠,那么wcTouchPos的返回值是 WC 空间中第一个检测到的碰撞位置。请注意,一旦检测到一个像素重叠或当pixelTouch变为真时,嵌套循环就会终止。这是效率问题的一个重要特征。然而,这也意味着返回的wcTouchPos只是许多潜在碰撞点中的一个。

支持游戏对象中的逐像素碰撞

编辑game_object.js文件,将pixelTouches()函数添加到GameObject类中:

pixelTouches(otherObj, wcTouchPos) {
    // only continue if both objects have getColorArray defined
    // if defined, should have other texture intersection support!
    let pixelTouch = false;
    let myRen = this.getRenderable();
    let otherRen = otherObj.getRenderable();

    if ((typeof myRen.pixelTouches === "function") &&
        (typeof otherRen.pixelTouches === "function")) {
        let otherBbox = otherObj.getBBox();
        if (otherBbox.intersectsBound(this.getBBox())) {
            myRen.setColorArray();
            otherRen.setColorArray();
            pixelTouch = myRen.pixelTouches(otherRen, wcTouchPos);
        }
        return pixelTouch;
    }
}

该函数检查以确保对象发生碰撞,并将实际的每像素碰撞委托给TextureRenderable对象。在调用潜在昂贵的TextureRenderable.pixelTouches()函数之前,注意用于边界框相交检查的intersectsBound()函数。

在我的游戏中测试每像素碰撞

如图 6-15 所示,每像素碰撞的测试相当简单,包括三个GameObject实例:大的Collector小的Portal小的DyePackCollectorPortal爪牙分别由箭头键和 WASD 键控制。MyGame的实现细节与前面的项目类似,不再赘述。

值得注意的代码片段是update()函数中的冲突测试,如下所示:

update() {
    let msg = "No Collision";

    this.mCollector.update(engine.input.keys.W, engine.input.keys.S,
            engine.input.keys.A, engine.input.keys.D);
    this.mPortal.update(engine.input.keys.Up, engine.input.keys.Down,
            engine.input.keys.Left, engine.input.keys.Right);

    let h = [];

    // Portal's resolution is 1/16 x 1/16 that of Collector!
    // VERY EXPENSIVE!!
    // if (this.mCollector.pixelTouches(this.mPortal, h)) {

    if (this.mPortal.pixelTouches(this.mCollector, h)) {
            msg = "Collided!: (" + h[0].toPrecision(4) + " " +
                  h[1].toPrecision(4) + ")";
        this.mDyePack.setVisibility(true);
        this.mDyePack.getXform().setXPos(h[0]);
        this.mDyePack.getXform().setYPos(h[1]);
    } else {
        this.mDyePack.setVisibility(false);
    }
    this.mMsg.setText(msg);

}

观察

现在,您可以通过移动两个小东西并使它们在不同位置相交(例如,顶部与底部碰撞,左侧与右侧碰撞)或移动它们以使它们有较大的重叠区域来测试碰撞准确性。注意,预测实际报告的交叉点位置(DyePack的位置)即使不是不可能,也是相当困难的。重要的是要记住,每像素碰撞函数主要是返回指示是否有碰撞的truefalse的函数。你不能依靠这个函数来计算实际的碰撞位置。

最后,尝试切换到调用Collector.pixelTouches()函数来检测冲突。请注意不到实时的性能!在这种情况下,Collector.pixelTouches()函数的计算成本是Portal.pixelTouches()函数的 16×16=256 倍。

广义的每像素碰撞

在上一节中,您看到了实现每像素精确碰撞检测所需的基本操作。然而,你可能已经注意到,只有当纹理沿 x/y 轴对齐时,前面的项目才适用。这意味着您的实现不支持旋转对象之间的碰撞。

本节解释当对象旋转时,如何实现每像素精确的碰撞检测。这个项目的基本概念与前一个项目相同;然而,这个版本涉及到向量分解,快速回顾会有所帮助。

向量回顾:组件和分解

回想一下,可以用两个垂直方向将一个矢量分解成相应的分量。例如,图 6-18 包含两个归一化向量,或者分量向量,可以用来分解向量\overset{\rightharpoonup }{V}=\left(2,3\right):归一化分量向量\hat{i}=\left(1,0\right)\hat{j}=\left(1,0\right)将向量\overset{\rightharpoonup }{V}分解成分量2\hat{i}3\hat{j}

img/334805_2_En_6_Fig18_HTML.png

图 6-18

向量的分解\overset{\rightharpoonup }{V}

一般情况下,如图 6-19 所示,给定归一化垂直分量矢量\hat{L}\hat{M}以及任意矢量\overset{\rightharpoonup }{V},以下公式始终成立:

\overset{\rightharpoonup }{V}=\left(\overset{\rightharpoonup }{V}\bullet \hat{i}\right)\ \hat{i}+\left(\overset{\rightharpoonup }{V}\bullet \hat{j}\right)\ \hat{j}

\overset{\rightharpoonup }{V}=\left(\overset{\rightharpoonup }{V}\bullet \hat{L}\right)\ \hat{L}+\left(\overset{\rightharpoonup }{V}\bullet \hat{M}\right)\ \hat{M}

img/334805_2_En_6_Fig19_HTML.png

图 6-19

用两个归一化分量向量分解一个向量

由于旋转的图像轴,矢量分解与本项目相关。在没有旋转的情况下,图像可以由沿着默认 x 轴(\hat{i})和 y 轴(\hat{j})的熟悉的归一化垂直向量集来参考。你在之前的项目中处理过这个案子。你可以在图 6-20 中看到这样的例子。

img/334805_2_En_6_Fig20_HTML.png

图 6-20

轴对齐的纹理

然而,图像旋转后,参考向量集不再沿 x/y 轴。因此,碰撞计算必须考虑新旋转的轴\hat{L}\hat{M},如图 6-21 所示。

img/334805_2_En_6_Fig21_HTML.png

图 6-21

旋转纹理及其分量向量

通用像素碰撞项目

这个项目演示了如何以每像素的精度检测两个旋转的TextureRenderable对象之间的碰撞。与之前的项目类似,在检测到的碰撞位置会显示一个黄色的DyePack物体(作为测试确认)。你可以在图 6-22 中看到这个项目运行的例子。这个项目的源代码在chapter6/6.5.general_pixel_collisions文件夹中定义。

img/334805_2_En_6_Fig22_HTML.jpg

图 6-22

运行通用像素碰撞项目

该项目的控制措施如下:

  • 箭头键:移动小的纹理物体,即Portal小精灵

  • P 键:旋转小的纹理物体,即Portal小精灵

  • WASD 键:移动大型纹理物体,Collector爪牙

  • E 键:旋转大的纹理物体,即Collector小精灵

该项目的目标如下:

  • 通过矢量分解访问旋转图像的像素

  • 为了支持两个旋转纹理对象之间的每像素精确碰撞检测

您可以在assets文件夹中找到与上一个项目相同的外部资源文件。

修改像素碰撞以支持旋转
  1. 编辑texture_renderable_pixel_collision.js文件,修改_indexToWCPosition()功能:
TextureRenderable.prototype._indexToWCPosition =
function (returnWCPos, i, j, xDir, yDir) {
    let x = i * this.mXform.getWidth() / this.mElmWidthPixels;
    let y = j * this.mXform.getHeight() / this.mElmHeightPixels;
    let xDisp = x - (this.mXform.getWidth() * 0.5);
    let yDisp = y - (this.mXform.getHeight() * 0.5);
    let xDirDisp = [];
    let yDirDisp = [];

    vec2.scale(xDirDisp, xDir, xDisp);
    vec2.scale(yDirDisp, yDir, yDisp);
    vec2.add(returnWCPos, this.mXform.getPosition(), xDirDisp);
    vec2.add(returnWCPos, returnWCPos, yDirDisp);
}

在列出的代码中,xDiryDir\hat{L}\hat{M}归一化分量向量。变量xDispyDisp分别是沿xDiryDir偏移的位移。returnWCPos的返回值是沿着xDirDispyDirDisp向量从对象中心位置的简单位移。注意xDirDispyDirDisp是缩放后的xDiryDir向量。

  1. 以类似的方式,修改_wcPositionToIndex()函数以支持旋转的归一化矢量分量:

  2. 需要修改pixelTouches()函数来计算旋转的归一化分量向量:

TextureRenderable.prototype._wcPositionToIndex =
function (returnIndex, wcPos, xDir, yDir) {
    // use wcPos to compute the corresponding returnIndex[0 and 1]
    let delta = [];
    vec2.sub(delta, wcPos, this.mXform.getPosition());
    let xDisp = vec2.dot(delta, xDir);
    let yDisp = vec2.dot(delta, yDir);
    returnIndex[0] = this.mElmWidthPixels *
                     (xDisp / this.mXform.getWidth());
    returnIndex[1] = this.mElmHeightPixels *
                     (yDisp / this.mXform.getHeight());

    // recall that xForm.getPosition() returns center, yet
    // Texture origin is at lower-left corner!
    returnIndex[0] += this.mElmWidthPixels / 2;
    returnIndex[1] += this.mElmHeightPixels / 2;

    returnIndex[0] = Math.floor(returnIndex[0]);
    returnIndex[1] = Math.floor(returnIndex[1]);
}

TextureRenderable.prototype.pixelTouches = function (other, wcTouchPos) {
    let pixelTouch = false;
    let xIndex = 0, yIndex;
    let otherIndex = [0, 0];

    let xDir = [1, 0];
    let yDir = [0, 1];
    let otherXDir = [1, 0];
    let otherYDir = [0, 1];
    vec2.rotate(xDir, xDir, this.mXform.getRotationInRad());
    vec2.rotate(yDir, yDir, this.mXform.getRotationInRad());
    vec2.rotate(otherXDir, otherXDir, other.mXform.getRotationInRad());
    vec2.rotate(otherYDir, otherYDir, other.mXform.getRotationInRad());

    while ((!pixelTouch) && (xIndex < this.mElmWidthPixels)) {
        yIndex = 0;
        while ((!pixelTouch) && (yIndex < this.mElmHeightPixels)) {
            if (this._pixelAlphaValue(xIndex, yIndex) > 0) {
                this._indexToWCPosition(wcTouchPos,
                                        xIndex, yIndex, xDir, yDir);
                other._wcPositionToIndex(otherIndex, wcTouchPos,
                                         otherXDir, otherYDir);
                if ((otherIndex[0] >= 0) &&
                    (otherIndex[0] < other.mElmWidthPixels) &&
                    (otherIndex[1] >= 0) &&
                    (otherIndex[1] < other.mElmHeightPixels)) {
                    pixelTouch = other._pixelAlphaValue(
                                 otherIndex[0], otherIndex[1]) > 0;
                }
            }
            yIndex++;
        }
        xIndex++;
    }
    return pixelTouch;
}

变量xDiryDir是这个TextureRenderable物体旋转后的归一化分量向量\hat{L}\hat{M},而otherXDirotherYDir是碰撞物体的归一化分量向量。这些向量被用作计算从纹理索引到 WC 和从 WC 到纹理索引的变换的参考。

修改游戏对象以支持旋转

回想一下,GameObject类首先测试两个对象之间的边界框碰撞,然后才真正调用昂贵得多的每像素碰撞计算。如图 6-14 所示,BoundingBox对象不能正确支持对象旋转,下面的代码弥补了这个缺陷:

pixelTouches(otherObj, wcTouchPos) {
    // only continue if both objects have getColorArray defined
    // if defined, should have other texture intersection support!
    let pixelTouch = false;
    let myRen = this.getRenderable();
    let otherRen = otherObj.getRenderable();

    if ((typeof myRen.pixelTouches === "function") &&
        (typeof otherRen.pixelTouches === "function")) {
        if ((myRen.getXform().getRotationInRad() === 0) &&
            (otherRen.getXform().getRotationInRad() === 0)) {
            // no rotation, we can use bbox ...
            let otherBbox = otherObj.getBBox();
            if (otherBbox.intersectsBound(this.getBBox())) {
                myRen.setColorArray();
                otherRen.setColorArray();
                pixelTouch = myRen.pixelTouches(otherRen, wcTouchPos);
            }
        } else {
            // One or both are rotated, compute an encompassing circle
            // by using the hypotenuse as radius
            let mySize = myRen.getXform().getSize();
            let otherSize = otherRen.getXform().getSize();
            let myR = Math.sqrt(0.5*mySize[0]*0.5*mySize[0] +
                                0.5*mySize[1]*0.5*mySize[1]);
            let otherR = Math.sqrt(0.5*otherSize[0]*0.5*otherSize[0] +
                                   0.5*otherSize[1]*0.5*otherSize[1]);
            let d = [];
            vec2.sub(d, myRen.getXform().getPosition(),
                        otherRen.getXform().getPosition());
            if (vec2.length(d) < (myR + otherR)) {
                myRen.setColorArray();
                otherRen.setColorArray();
                pixelTouch = myRen.pixelTouches(otherRen, wcTouchPos);
            }
        }
    }
    return pixelTouch;
}

列出的代码显示,如果旋转了任何一个碰撞对象,那么将使用两个包含的圆来确定对象是否足够接近,以进行昂贵的每像素碰撞计算。这两个圆的半径等于相应TextureRenderable对象的 x/y 尺寸的斜边。仅当这两个圆之间的距离小于半径之和时,才会调用逐像素碰撞检测。

测试广义的每像素碰撞

测试旋转后的TextureRenderable对象的代码与上一个项目中的代码基本相同,只是增加了两个旋转控件。没有示出实现的细节。现在可以运行项目,旋转两个对象,并观察精确的碰撞结果。

精灵的逐像素碰撞

之前的项目隐含地假设Renderable对象被整个纹理贴图覆盖。这种假设意味着逐像素碰撞实现不支持精灵或动画精灵对象。在本节中,您将弥补这一不足。

精灵像素碰撞项目

这个项目演示了如何在屏幕上移动一个动画 sprite 对象,并执行与其他对象的逐像素碰撞检测。该项目测试TextureRenderableSpriteRenderableSpriteAnimateRenderable对象碰撞的正确性。你可以在图 6-23 中看到这个项目运行的例子。这个项目的源代码在chapter6/6.6.sprite_pixel_collisions文件夹中定义。

img/334805_2_En_6_Fig23_HTML.jpg

图 6-23

运行精灵像素碰撞项目

该项目的控制措施如下:

  • 箭头和 P 键:移动和旋转Portal小工具

  • WASD 键:移动Hero

  • L、R、H、B 键:选择与Portal小精灵碰撞的目标

该项目的目标如下:

  • 推广 sprite 和动画 sprite 对象的逐像素碰撞实现

您可以在assets文件夹中找到以下外部资源文件:包含默认系统字体的fonts文件夹、minion_sprite.pngminion_portal.png

为 SpriteRenderable 实现逐像素碰撞

编辑sprite_renderable.js以实现对SpriteRenderable对象的每像素特定支持:

  1. 修改SpriteRenderable构造函数调用_setTexInfo()函数初始化逐像素碰撞参数;该功能将在下一步中定义:

  2. 定义_setTexInfo()函数来覆盖在TextureRenderable超类中定义的实例变量。实例变量现在标识当前活动的 sprite 元素,而不是整个纹理图像。

constructor(myTexture) {
    super(myTexture);
    super._setShader(shaderResources.getSpriteShader());
    // sprite coordinate
    // bounds of texture coordinate (0 is left, 1 is right)
    this.mElmLeft = 0.0;
    this.mElmRight = 1.0;
    this.mElmTop = 1.0;    //   1 is top and 0 is bottom of image
    this.mElmBottom = 0.0; //

    // sets info to support per-pixel collision
    this._setTexInfo();
}

_setTexInfo() {
    let imageW = this.mTextureInfo.mWidth;
    let imageH = this.mTextureInfo.mHeight;

    this.mElmLeftIndex = this.mElmLeft * imageW;
    this.mElmBottomIndex = this.mElmBottom * imageH;

    this.mElmWidthPixels = ((this.mElmRight - this.mElmLeft)*imageW)+1;
    this.mElmHeightPixels = ((this.mElmTop - this.mElmBottom)*imageH)+1;
}

注意,mElmWidthPixelmElmHeightPixel现在包含的像素值对应于 sprite 表中单个 sprite 元素的尺寸,而不是整个纹理贴图的尺寸。

  1. 当当前 sprite 元素在setElementUVCoordinate()setElementPixelPositions()函数中更新时,记得调用_setTexInfo()函数:
setElementUVCoordinate(left, right, bottom, top) {
    this.mElmLeft = left;
    this.mElmRight = right;
    this.mElmBottom = bottom;
    this.mElmTop = top;
    this._setTexInfo();
}

setElementPixelPositions(left, right, bottom, top) {
    // entire image width, height
    let imageW = this.mTextureInfo.mWidth;
    let imageH = this.mTextureInfo.mHeight;

    this.mElmLeft = left / imageW;
    this.mElmRight = right / imageW;
    this.mElmBottom = bottom / imageH;
    this.mElmTop = top / imageH;
    this._setTexInfo();
}

支持在 TextureRenderable 中访问 Sprite 像素

编辑texture_renderable_pixel_collision.js文件,并修改_pixelAlphaValue()函数以支持使用 sprite 元素索引偏移量的像素访问:

TextureRenderable.prototype._pixelAlphaValue = function (x, y) {
    y += this.mElmBottomIndex;
    x += this.mElmLeftIndex;
    x = x * 4;
    y = y * 4;
    return this.mColorArray[(y * this.mTextureInfo.mWidth) + x + 3];
}

测试 MyGame 中精灵的每像素碰撞

测试这个项目的代码是对以前项目的简单修改,细节没有列出。请务必注意场景中不同的对象类型。

  • Portal 宠臣:一个简单的TextureRenderable对象

  • Hero Brain : SpriteRenderable对象,其中几何体上显示的纹理是在minion_sprite.png sprite 表中定义的 sprite 元素

  • 左右爪牙 : SpriteAnimateRenderableminion_sprite.png动画精灵表的上两行定义了精灵元素的对象

观察

现在,您可以运行该项目,并观察不同对象类型碰撞的正确结果:

  1. 试着移动Hero物体,观察Brain物体如何不断寻找并与它碰撞。这就是两个SpriteRenderable物体碰撞的情况。

  2. 按下 L/R 键,然后用 WASD 键移动Portal小兵,与左右小兵相撞。请记住,您可以使用 P 键旋转Portal小工具。这就是TextureRenderableSpriteAnimatedRenderable物体碰撞的情况。

  3. 按下 H 键,然后移动Portal小人与Hero物体碰撞。这就是TextureRenderableSpriteRenderable物体碰撞的情况。

  4. 按下 B 键,然后移动Portal小人与Brain物体碰撞。这是旋转的TextureRenderableSpriteRenderable物体之间碰撞的情况。

摘要

本章向您展示了如何封装游戏中对象的常见行为,并展示了在客户端的MyGame测试级别中以更简单、更有组织的控制逻辑的形式进行封装的好处。你复习了 2D 空间中的向量。矢量由其方向和大小来定义。矢量便于描述位移(速度)。您回顾了一些基本的向量运算,包括向量的归一化以及如何计算点积和叉积。您与这些操作符一起实现了面向前方的方向功能,并创建了简单的自主行为,如指向特定对象和追逐。

随着物体的行为越来越复杂,检测物体碰撞的需要就成了一个突出的遗漏。轴对齐边界框,或 AABBs,是作为一种粗略的,但计算有效的解决方案,用于近似物体碰撞。您了解了每像素精确碰撞检测的算法,以及它的精确性是以牺牲性能为代价的。现在,您已经了解了如何通过两种方式降低计算成本。首先,只有当对象彼此足够接近时,例如当它们的边界框碰撞时,才调用像素精确过程。第二,基于较低分辨率的纹理调用像素迭代过程。

当实现像素精确碰撞时,您从处理轴对齐纹理的基本情况开始。实现之后,您返回并添加了对旋转纹理之间的碰撞检测的支持。最后,您将实现一般化以支持 sprite 元素之间的冲突。首先解决最简单的情况,让您测试和观察结果,并帮助定义更高级的问题(在这种情况下,旋转和纹理的子区域)可能需要什么。

在这一章的开始,你的游戏引擎支持有趣的复杂绘图,包括定义 WC 空间,用Camera对象查看 WC 空间,以及在对象上绘制视觉上令人愉悦的纹理和动画。然而,没有支持对象行为的基础设施。这个缺点导致了客户端实现中初始化和控制逻辑的聚集。通过本章介绍和实现的对象行为抽象、数学和碰撞算法,你的游戏引擎功能现在得到了更好的平衡。游戏引擎的客户端现在有了封装特定行为和检测碰撞的工具。下一章重新检查并增强了Camera对象的功能。你将学会控制和操纵Camera物体,并在同一个游戏中处理多个Camera物体。

游戏设计注意事项

第 1–5 章介绍了在屏幕上绘制、移动和动画显示对象的基础技术。第四章的场景对象项目描述了一个简单的交互行为,并向您展示了如何根据矩形的位置来改变游戏屏幕:回想一下,将矩形移动到左边界会导致级别在视觉上发生变化,而音频支持项目添加了上下文声音来加强整体的现场感。虽然只使用第 1 到第五章中的元素可以构建一个有趣(尽管简单)的益智游戏,但当你可以集成物体检测和碰撞触发时,事情会变得有趣得多;这些行为构成了许多常见游戏机制的基础,并为设计各种有趣的游戏场景提供了机会。

游戏对象项目开始,你可以看到屏幕元素如何开始协同工作来传达游戏设定;即使这个项目中的互动仅限于角色的移动,场景也开始转变为传达一种场所感的东西。主人公似乎正在一个由许多机械化机器人组成的移动场景中飞行,在屏幕中央有一个小物体,你可能会认为它可能会成为某种特殊的拾取器。

即使在这个开发的基础阶段,头脑风暴游戏机制也是可能的,它有可能成为一个完整游戏的基础。如果你仅仅基于游戏对象项目中的屏幕元素设计一个简单的游戏机制,你会选择什么样的行为,你会要求玩家执行什么样的动作?作为一个例子,想象英雄角色必须避免与飞行机器人相撞,并且也许一些机器人会探测并追逐英雄以试图阻止玩家前进;也许英雄在某种程度上也受到了惩罚,如果他们与机器人接触的话。想象一下,屏幕中央的小物体可以让英雄在一段固定的时间内不可战胜,我们设计的关卡需要暂时不可战胜才能达到目标,因此创建了一个更复杂、更有趣的游戏循环(例如,避免追逐机器人到达电源,激活电源并成为暂时不可战胜,使用不可战胜来达到目标)。有了这些基本的互动,我们就有机会探索许多不同种类的游戏中感觉非常熟悉的机制和关卡设计,所有这些都包含了第六章中涉及的物体探测、追逐和碰撞行为。使用游戏对象项目中显示的元素亲自尝试这个设计练习:你可以设计什么样的简单条件和行为来使你的体验独一无二?你能想到多少种方法来使用屏幕中央的小物体?第十二章的最终设计项目将更详细地探讨这些主题。

这也是一个很好的机会来头脑风暴第一章中讨论的游戏设计的其他九个元素。如果游戏不是以机器人为背景在太空中会怎样?也许背景是在森林里,或者在水下,甚至是完全抽象的东西。你如何加入音频来增强现场感并强化游戏设置?你可能会对你想出的各种各样的设置和场景感到惊讶。将自己限制在第六章涵盖的元素和交互上实际上是一个有益的练习,因为设计约束通常通过塑造和引导你的想法来帮助创作过程。即使是最先进的视频游戏通常也有一套相当基本的核心游戏循环作为基础。

从游戏机制和存在的角度来看, Vectors: Front and Chase 项目都很有趣。当然,许多游戏需要游戏世界中的物体来检测英雄角色,并且会追逐或试图避开玩家(或者两者都有,如果物体有多个状态的话)。该项目还演示了两种不同的追逐行为方法,即时和平滑的追逐,游戏设置通常会影响你选择实施的行为。在即时和平稳追求之间的选择是微妙行为的一个很好的例子,它可以显著地影响存在感。例如,如果你正在设计一个游戏,其中船只在海洋上互动,你可能会希望他们的追逐行为考虑到现实世界的惯性和动量,因为船只不能立即转向并对运动中的变化做出反应;相反,它们平稳而渐进地移动,在对移动目标的反应速度上表现出明显的延迟。物理世界中的大多数对象将在某种程度上显示相同的惯性和动量约束,但也有一些情况下,您可能希望游戏对象直接响应路径变化(或者,您可能希望故意无视现实世界的物理,并创建一个不是基于物理对象限制的行为)。关键是要对你的设计选择有意识,并且要记住几乎没有实现细节小到玩家不会注意到。

边界框和碰撞项目将检测的关键元素引入到你的设计武器库中,允许你开始包含更强大的因果机制,这些机制构成了许多游戏交互的基础。第六章讨论了在精度较低但性能更高的包围盒碰撞检测方法和精度较高但资源密集型的逐像素检测方法之间进行取舍。在许多情况下,边界框方法是足够的,但是如果玩家认为碰撞是任意的,因为边界框与实际的视觉对象相差太大,这会对临场感产生负面影响。当结合每像素碰撞项目的结果时,检测和碰撞甚至是更强大的设计工具。虽然本例中的染料包用于指示第一个碰撞点,但您可以想象围绕两个对象碰撞产生的新对象建立有趣的因果链(例如,玩家追逐对象,玩家与对象碰撞,对象“放弃”新对象,使玩家能够做他们以前不能做的事情)。当然,在游戏屏幕上移动的游戏对象通常是动画,所以精灵像素碰撞项目描述了当对象边界不固定时如何实现碰撞检测。

随着第六章中技术的加入,你现在有了一个临界质量的行为,可以组合起来创建真正有趣的游戏机制,涵盖从动作游戏到谜题。当然,游戏机械行为只是游戏设计的九个元素之一,通常它们本身不足以创造一个神奇的游戏体验:设置、视觉风格、元游戏元素等等都有重要的贡献。好消息是,创造一个令人难忘的游戏体验不需要像你通常认为的那样复杂,伟大的游戏将继续基于第 1—6 章所涵盖的行为和技术的相对基本的组合而产生。最耀眼的游戏并不总是最复杂的,相反,在这些游戏中,九个设计元素的每一个方面都是精心设计的,并且和谐地协同工作。如果你对游戏设计的各个方面都给予了适当的关注和重视,无论你是独自工作还是作为一个大团队的一员,你都有可能创造出伟大的东西。**

七、操纵摄像机

完成本章后,您将能够

  • 实现在操作照相机时通常采用的操作

  • 在新旧值之间插值以创建平滑过渡

  • 理解如何用简单的数学公式描述一些运动或行为

  • 使用多个摄像机视图构建游戏

  • 将位置从鼠标单击的像素转换到世界坐标(WC)位置

  • 在具有多个摄像机的游戏环境中使用鼠标输入的程序

介绍

您的游戏引擎现在能够表示和绘制对象。有了上一章介绍的基本抽象机制,引擎也可以支持这些对象的交互和行为。本章将注意力重新集中在控制和与Camera对象的交互上,该对象抽象并促进了游戏对象在画布上的呈现。通过这种方式,你的游戏引擎将能够控制和操纵具有良好结构行为的视觉上吸引人的游戏对象的呈现。

图 7-1 简要回顾了在第三章中介绍的Camera对象抽象。Camera对象允许游戏程序员定义游戏世界的世界坐标(WC)窗口,显示在 HTML 画布上的视口中。WC 窗口是由 WC 中心和尺寸WWC×HWC定义的边界。viewport 是 HTML 画布上的一个矩形区域,左下角位于( V xV y ),尺寸为WV×HVCamera对象的setViewAndCameraMatrix()函数封装了细节,并使 WC 窗口边界内所有游戏对象的图形能够显示在相应的视窗中。

img/334805_2_En_7_Fig1_HTML.png

图 7-1

查看定义相机对象的 WC 参数

Note

在本书中,WC 窗口或 WC 边界用于指代 WC 窗口边界。

Camera对象抽象允许游戏程序员忽略 WC 边界和 HTML 画布的细节,专注于设计有趣的游戏体验。在游戏等级中用一个Camera对象编程应该反映真实世界中物理摄像机的使用。例如,您可能希望平移摄像机以向观众展示环境,您可能希望将摄像机安装在女演员身上并与观众分享她的旅程,或者您可能希望扮演导演的角色并指导场景中的演员保持在摄像机的可视范围内。这些例子的独特特征,比如平移或者跟随角色的视角,是高级功能规范。请注意,在现实世界中,您不需要指定窗口的坐标位置或边界。

本章介绍了一些最常见的相机操作,包括夹紧、平移和缩放。将推导出插值形式的解决方案,以减轻由相机操作导致的恼人或混乱的突然转变。您还将了解如何在同一游戏关卡中支持多个摄像头视图,以及如何使用鼠标输入。

相机操作

在 2D 世界中,您可能希望将对象的移动限制在相机的范围内,平移或移动相机,或者将相机缩放到特定区域或远离特定区域。这些高级别的功能规范可以通过策略性地改变Camera对象的参数来实现:wc 中心和 WC 窗口的WWC×HWC。关键是为游戏开发人员创建方便的函数,以便在游戏环境中操作这些值。例如,可以为程序员定义缩放功能,而不是增加/减少 WC 窗口的宽度/高度。

相机操作项目

这个项目演示了如何通过使用Camera对象的 WC 中心、宽度和高度来实现直观的摄像机操作。你可以在图 7-2 中看到这个项目运行的例子。这个项目的源代码在chapter7/7.1.camera_manipulations文件夹中定义。

img/334805_2_En_7_Fig2_HTML.jpg

图 7-2

运行相机操作项目

该项目的控制措施如下:

  • WASD 键:移动Dye角色(Hero对象)。请注意,当Hero对象试图移动超过 WC 边界的 90%时,相机 WC 窗口会随之更新。

  • 箭头键:移动Portal对象。注意Portal对象不能移动超过 WC 边界的 80%。

  • L/R/P/H 键:选择Left minion、Right minion、Portal object 或Hero object 作为焦点对象;L/R 键还将相机设置在LeftRight迷你按钮的中心。

  • N/M 键:放大或缩小相机中心。

  • J/K 键:放大或缩小,同时保证当前焦点物体的相对位置不变。换句话说,当相机缩放时,除了焦点对准的对象之外,所有对象的位置都将改变。

该项目的目标如下:

  • 体验一些常见的相机操作

  • 为了理解从操纵操作到必须改变的相应相机参数值的映射

  • 为了实现相机操纵操作

您可以在assets文件夹中找到以下外部资源:fonts文件夹,包含默认的系统字体和三个纹理图像(minion_portal.pngminion_sprite.pngbg.png)。第一个纹理图像表示Portal对象,其余对象为minion_sprite.png的 sprite 元素,背景为用bg.png映射的大TextureRenderable对象纹理。

组织源代码

为了适应功能的增加和Camera类的复杂性,您将创建一个单独的文件夹来存储相关的源代码文件。类似于将TextureRenderable复杂的源代码分成多个文件的情况,在这个项目中Camera类的实现将被分成三个文件。

  • camera_main.js用于实现以前项目的基本功能

  • camera_manipulation.js用于支持新引入的操纵操作

  • camera.js用作班级接入点

实施步骤如下:

  1. src/engine中新建一个名为cameras的文件夹。将camera.js文件移入该文件夹,并重命名为camera_main.js

  2. src/engine/cameras中创建一个新文件,命名为camera_manipulation.js。该文件将用于扩展Camera类的功能以支持操作。添加以下代码来导入和导出基本的Camera类功能。目前,这个文件不包含任何有用的源代码,因此没有任何用途。您将在下面的小节中定义适当的扩展函数。

  3. 通过添加以下代码,创建一个新的camera.js作为Camera访问点:

import Camera from "./camera_main.js";

// new functionality to be defined here in the next subsection

export default Camera;

import Camera from "./camera_manipulation.js";
export default Camera;

有了源代码文件的这种结构,camera_main.js实现了所有的基本功能,并导出到camera_manipulation.js,后者为Camera类定义了附加功能。最后,camera.jscamera_manipulation.js导入扩展功能。Camera类的用户可以简单地从camera.js导入,并且可以访问所有已定义的功能。这允许camera.js作为Camera类的访问点,同时隐藏实现源代码结构的细节。

支持夹紧到摄像机 WC 边界

编辑camera_main.jsimport边界框功能,并定义一个函数,将与Transform对象相关的边界固定到摄像机 WC 边界:

import * as glSys from "../core/gl.js";
import BoundingBox from "../bounding_box.js";
import { eBoundCollideStatus } from "../bounding_box.js";

... identical to previous code ...

clampAtBoundary(aXform, zone) {
    let status = this.collideWCBound(aXform, zone);
    if (status !== eBoundCollideStatus.eInside) {
        let pos = aXform.getPosition();
        if ((status & eBoundCollideStatus.eCollideTop) !== 0) {
            pos[1] = (this.getWCCenter())[1] +
                     (zone * this.getWCHeight() / 2) –
                     (aXform.getHeight() / 2);
        }
        if ((status & eBoundCollideStatus.eCollideBottom) !== 0) {
            pos[1] = (this.getWCCenter())[1] –
                     (zone * this.getWCHeight() / 2) +
                     (aXform.getHeight() / 2);
        }

        if ((status & eBoundCollideStatus.eCollideRight) !== 0) {
            pos[0] = (this.getWCCenter())[0] +
                     (zone * this.getWCWidth() / 2) –
                     (aXform.getWidth() / 2);
        }
        if ((status & eBoundCollideStatus.eCollideLeft) !== 0) {
            pos[0] = (this.getWCCenter())[0] –
                     (zone * this.getWCWidth() / 2) +
                     (aXform.getWidth() / 2);
        }
    }
    return status;
}

aXform对象可以是GameObjectRenderable对象的TransformclampAtBoundary()功能通过夹紧aXform位置确保aXform的边界保持在摄像机的 WC 边界内。zone变量定义了 WC 边界的夹紧百分比。例如,1.0 表示钳制到精确的 WC 边界,而 0.9 表示钳制到当前 WC 窗口大小的 90%的边界。值得注意的是,clampAtBoundary()功能仅在与摄像机 WC 边界冲突的边界上运行。例如,如果aXform对象的边界完全在摄像机 WC 边界之外,它将保持在外部。

在 camera_manipulation.js 文件中定义相机操纵操作

回想一下,您已经创建了一个空的camera_manipulation.js源代码文件。现在您已经准备好编辑这个文件,并在Camera类上定义额外的函数来操作摄像机。

  1. 编辑camera_manipulate.js。确保在Camera类功能的初始导入和最终导出之间添加代码。

  2. 导入边界框碰撞状态,并定义panWidth()函数根据Transform对象的边界平移摄像机。该功能是对clampAtBoundary()功能的补充,它不是改变aXform位置,而是移动摄像机以确保正确包含aXform边界。与clampAtBoundary()功能的情况一样,如果aXform边界完全在测试的 WC 边界区域之外,则不会改变摄像机。

  3. 通过添加到Camera类原型来定义摄像机平移功能panBy()panTo()。这两个函数通过增加一个增量或者移动它到一个新的位置来改变摄像机的 WC 中心。

import { eBoundCollideStatus } from "../bounding_box.js";

Camera.prototype.panWith = function (aXform, zone) {
    let status = this.collideWCBound(aXform, zone);
    if (status !== eBoundCollideStatus.eInside) {
        let pos = aXform.getPosition();
        let newC = this.getWCCenter();

        if ((status & eBoundCollideStatus.eCollideTop) !== 0) {
            newC[1] = pos[1]+(aXform.getHeight() / 2) –
                      (zone * this.getWCHeight() / 2);
        }
        if ((status & eBoundCollideStatus.eCollideBottom) !== 0) {
            newC[1] = pos[1] - (aXform.getHeight() / 2) +
                      (zone * this.getWCHeight() / 2);
        }
        if ((status & eBoundCollideStatus.eCollideRight) !== 0) {
            newC[0] = pos[0] + (aXform.getWidth() / 2) –
                      (zone * this.getWCWidth() / 2);
        }
        if ((status & eBoundCollideStatus.eCollideLeft) !== 0) {
            newC[0] = pos[0] - (aXform.getWidth() / 2) +
                      (zone * this.getWCWidth() / 2);
        }
    }
}

  1. 定义相对于中心或目标位置缩放摄像机的功能:
Camera.prototype.panBy = function (dx, dy) {
    this.mWCCenter[0] += dx;
    this.mWCCenter[1] += dy;
}

Camera.prototype.panTo = function (cx, cy) {
    this.setWCCenter(cx, cy);
}

Camera.prototype.zoomBy = function (zoom) {
    if (zoom > 0) {
        this.setWCWidth(this.getWCWidth() * zoom);
    }
}

Camera.prototype.zoomTowards = function (pos, zoom) {
    let delta = [];
    vec2.sub(delta, pos, this.mWCCenter);
    vec2.scale(delta, delta, zoom - 1);
    vec2.sub(this.mWCCenter, this.mWCCenter, delta);
    this.zoomBy(zoom);
}

zoomBy()功能相对于摄像机的中心进行缩放,而zoomTowards()功能相对于世界坐标位置进行缩放。如果zoom变量大于 1,WC 窗口变得更大,你会在我们直观地称为缩小的过程中看到更多的世界。小于 1 的zoom值放大。图 7-3 显示了zoom=0.5相对于 WC 中心和Hero物体位置的缩放结果。

img/334805_2_En_7_Fig3_HTML.png

图 7-3

向 WC 中心和目标位置缩放

在我的游戏中操纵摄像机

有两个重要的功能需要测试:平移和缩放。对MyGame类唯一值得注意的变化是在update()函数中。init()load()unload()draw()功能与之前的项目类似,可以在项目源代码中找到。

update() {
    let zoomDelta = 0.05;
    let msg = "L/R: Left or Right Minion; H: Dye; P: Portal]: ";

    // ... code to update each object not shown

    // Brain chasing the hero
    let h = [];
    if (!this.mHero.pixelTouches(this.mBrain, h)) {
        this.mBrain.rotateObjPointTo(
                    this.mHero.getXform().getPosition(), 0.01);
        engine.GameObject.prototype.update.call(this.mBrain);
    }

    // Pan camera to object
    if (engine.input.isKeyClicked(engine.input.keys.L)) {
        this.mFocusObj = this.mLMinion;
        this.mChoice = 'L';
        this.mCamera.panTo(this.mLMinion.getXform().getXPos(),
                           this.mLMinion.getXform().getYPos());
    }
    if (engine.input.isKeyClicked(engine.input.keys.R)) {
        this.mFocusObj = this.mRMinion;
        this.mChoice = 'R';
        this.mCamera.panTo(this.mRMinion.getXform().getXPos(),
                           this.mRMinion.getXform().getYPos());
    }
    if (engine.input.isKeyClicked(engine.input.keys.P)) {
        this.mFocusObj = this.mPortal;
        this.mChoice = 'P';
    }
    if (engine.input.isKeyClicked(engine.input.keys.H)) {
        this.mFocusObj = this.mHero;
        this.mChoice = 'H';
    }

    // zoom
    if (engine.input.isKeyClicked(engine.input.keys.N)) {
        this.mCamera.zoomBy(1 - zoomDelta);
    }
    if (engine.input.isKeyClicked(engine.input.keys.M)) {
        this.mCamera.zoomBy(1 + zoomDelta);
    }
    if (engine.input.isKeyClicked(engine.input.keys.J)) {
        this.mCamera.zoomTowards(
                         this.mFocusObj.getXform().getPosition(),
                         1 - zoomDelta);
    }
    if (engine.input.isKeyClicked(engine.input.keys.K)) {
        this.mCamera.zoomTowards(
                         this.mFocusObj.getXform().getPosition(),
                         1 + zoomDelta);
    }

    // interaction with the WC bound
    this.mCamera.clampAtBoundary(this.mBrain.getXform(), 0.9);
    this.mCamera.clampAtBoundary(this.mPortal.getXform(), 0.8);
    this.mCamera.panWith(this.mHero.getXform(), 0.9);

    this.mMsg.setText(msg + this.mChoice);
}

在列出的代码中,前四个if语句选择焦点对准的对象,其中 L 和 R 键还通过调用具有适当 WC 位置的panTo()函数来重新定位相机。第二组四个if语句控制zoom,要么朝向 WC 中心,要么朝向当前聚焦对象。然后,该功能将BrainPortal对象分别限制在 WC 边界的 90%和 80%以内。基于Hero对象的变换(或位置)平移摄像机,该功能最终结束。

现在,您可以运行项目并使用 WASD 键移动Hero对象。向厕所边界移动Hero对象,观察被推动的摄像机。用Hero物体继续推动相机;请注意,由于clampAtBoundary()函数的调用,Portal对象将依次被推动,使其永远不会离开摄像机的 WC 边界。现在按下 L/R 键,观察相机中心切换到LeftRight迷你按钮的中心。N/M 键演示了相对于中心的直接缩放。要体验相对于目标的缩放,将Hero对象移向画布的左上方,然后按 H 键选择它作为zoom焦点。现在,鼠标指针指向英雄对象的头部,可以先按 K 键缩小,然后按 J 键放大。请注意,当您zoom时,场景中的所有对象都会改变位置,除了Hero对象周围的区域。对于有许多应用的游戏开发者来说,放大到世界的一个期望区域是一个有用的特性。您可以体验在放大/缩小时移动Hero对象。

插入文字

现在可以根据高级功能(如平移或缩放)来操纵摄像机。然而,结果通常是渲染图像的突然或视觉上不连贯的变化,这可能导致烦恼或混乱。例如,在之前的项目中,L 或 R 键通过简单分配新的 WC 中心值来使摄像机重新居中。摄像机位置的突然改变导致一个看似新的游戏世界的突然出现。这不仅会在视觉上分散注意力,还会让玩家搞不清发生了什么。

当摄像机参数的新值可用时,不是分配它们并导致突然的变化,而是希望随着时间的推移将值从旧值逐渐变形为新值,或者对值进行插值。例如,如图 7-4 所示,在时间t1 处,一个具有旧值的参数将被赋予一个新值。在这种情况下,插值不是突然更新值,而是随着时间的推移逐渐改变值。它将计算具有递减值的中间结果,并在稍后完成对新值的更改t2。

img/334805_2_En_7_Fig4_HTML.png

图 7-4

基于线性和指数函数的插值

图 7-4 显示了随着时间推移有多种插值方式。例如,线性插值根据新旧值连线的斜率计算中间结果。相反,指数函数可以根据以前值的百分比计算中间结果。这样,利用线性插值,摄像机位置将以恒定速度从旧位置移动到新位置,类似于以某个恒定速度移动(或摇摄)摄像机。相比之下,基于给定指数函数的插值将首先快速移动相机位置,然后随着时间的推移快速减慢,给人一种移动相机并将相机聚焦在新目标上的感觉。

人类的动作和运动通常遵循指数插值函数。例如,试着把你的头从正面转向右边或者移动你的手去拿你桌子上的一个物体。注意,在这两种情况下,你都是以相对较快的速度开始运动,当目的地很近的时候,你的速度明显慢了下来。也就是说,你可能开始时快速转动你的头,然后随着你的视线接近你的右侧而快速减速,很可能你的手开始快速向物体移动,当手快要到达物体时明显减速。在这两个例子中,你的位移遵循指数插值函数,如图 7-4 所示,随着目的地的接近,快速变化之后是快速减速。这是您将在游戏引擎中实现的功能,因为它模仿人类的运动,并且对人类玩家来说似乎很自然。

Note

线性插值通常被称为 LERPlerp 。lerp 的结果是初始值和最终值的线性组合。在本章中,几乎在所有情况下,图 7-4 中描绘的指数插值都是通过重复应用 lerp 函数来近似的,其中在每次调用中,初始值都是前一次 lerp 调用的结果。这样,指数函数就用分段线性函数来近似了。

本节介绍了LerpLerpVec2实用程序类,以支持相机操纵操作产生的平滑和渐进的相机移动。

相机插值项目

这个项目展示了更平滑和视觉上更吸引人的相机操作插值结果。你可以在图 7-5 中看到这个项目运行的例子。这个项目的源代码在chapter7/7.2.camera_interpolations文件夹中定义。

img/334805_2_En_7_Fig5_HTML.jpg

图 7-5

运行相机插值项目

该项目的控件与之前的项目相同:

  • WASD 键:移动Dye角色(Hero对象)。请注意,当Hero对象试图移动超过 WC 边界的 90%时,相机 WC 窗口会随之更新。

  • 箭头键:移动Portal对象。注意Portal对象不能移动超过 WC 边界的 80%。

  • L/R/P/H 键:选择Left minion、Right minion、Portal object 或Hero object 成为焦点对象。L/R 键还将相机设置为聚焦在LeftRight小按钮上。

  • N/M 键:放大或缩小相机中心。

  • J/K 键:放大或缩小,同时保证当前焦点物体的相对位置不变。换句话说,当相机缩放时,除了焦点对准的对象之外,所有对象的位置都将改变。

该项目的目标如下:

  • 为了理解给定值之间插值的概念

  • 为了实现支持相机参数逐渐变化的插值

  • 体验相机参数的插值变化

与前面的项目一样,您可以在assets文件夹中找到外部资源文件。

插值作为一种工具

类似于支持转换功能的Transform类和支持冲突检测的BoundingBox类,可以定义一个Lerp类来支持值的插值。为了保持源代码有条理,应该定义一个新的文件夹来存储这些实用程序。

创建src/engine/utils文件夹,并将transform.jsbounding_box.js文件移动到该文件夹中。

Lerp 类

定义Lerp类来计算两个值之间的插值:

  1. src/engine/utils文件夹中创建一个新文件,命名为lerp.js,并定义构造函数。该类设计用于在mCycles的持续时间内从mCurrentValuemFinalValue插值。在每次更新期间,基于mCurrentValuemFinalValue之差的mRate增量计算中间结果,如下所示。

  2. 定义计算中间结果的函数:

class Lerp {
    constructor(value, cycles, rate) {
        this.mCurrentValue = value;    // begin value of interpolation
        this.mFinalValue = value;      // final value of interpolation
        this.mCycles = cycles;
        this.mRate = rate;

        // Number of cycles left for interpolation
        this.mCyclesLeft = 0;
    }

    ... implementation to follow ...
}

// subclass should override this function for non-scalar values
_interpolateValue() {
    this.mCurrentValue = this.mCurrentValue + this.mRate *
                         (this.mFinalValue - this.mCurrentValue);
}

注意,_interpolateValue()函数计算出在mCurrentValuemFinalValue之间线性插值的结果。通过这种方式,mCurrentValue将在每次迭代逼近指数曲线时被设置为中间值,因为它接近mFinalValue的值。

  1. 定义一个函数来配置插值。mRate变量定义插值结果接近最终值的速度。0.0 的mRate将导致完全没有变化,其中 1.0 导致瞬时变化。mCycle变量定义了插值过程的持续时间。

  2. 定义相关的 getter 和 setter 函数。注意,setFinal()函数既设置最终值,又触发新一轮插值计算。

config(stiffness, duration) {
    this.mRate = stiffness;
    this.mCycles = duration;
}

  1. 定义函数来触发每个中间结果的计算:
get() { return this.mCurrentValue; }

setFinal(v) {
    this.mFinalValue = v;
    this.mCyclesLeft = this.mCycles;     // will trigger interpolation
}

  1. 最后,确保导出已定义的类:
update() {
    if (this.mCyclesLeft <= 0) { return; }

    this.mCyclesLeft--;
    if (this.mCyclesLeft === 0) {
        this.mCurrentValue = this.mFinalValue;
    } else {
        this._interpolateValue();
    }
}

export default Lerp

;

LerpVec2 类

由于许多摄像机参数是vec2对象(例如,厕所中心位置),因此重要的是要泛化Lerp类以支持vec2对象的插值:

  1. src/engine/utils文件夹中新建一个文件,命名为lerp_vec2.js,并定义其构造函数:

  2. 覆盖_interpolateValue()函数以计算vec2的中间结果:

class LerpVec2 extends Lerp {
    constructor(value, cycle, rate) {
        super(value, cycle, rate);
    }

    ... implementation to follow ...
}

_interpolateValue() {
    vec2.lerp(this.mCurrentValue, this.mCurrentValue,
                                  this.mFinalValue, this.mRate);
}

gl-matrix.js文件中定义的vec2.lerp()函数计算 x 和 y 的vec2分量。涉及的计算与Lerp类中的_interpolateValue()函数相同。

最后,记得更新引擎访问文件index.js,以便将新定义的LerpLerpVec2功能转发给客户端。

用 CameraState 表示插值中间结果

必须将Camera对象的状态一般化,以支持插值中间结果的渐变。引入CameraState类就是为了实现这个目的。

  1. src/engine/cameras文件夹中新建一个文件,命名为camera_state.js,导入定义好的Lerp功能,定义构造函数:
import Lerp from "../utils/lerp.js";
import LerpVec2 from "../utils/lerp_vec2.js";

class CameraState {
    constructor(center, width) {
        this.kCycles = 300;  // cycles to complete the transition
        this.kRate = 0.1;    // rate of change for each cycle
        this.mCenter = new LerpVec2(center, this.kCycles, this.kRate);
        this.mWidth = new Lerp(width, this.kCycles, this.kRate);
    }

    ... implementation to follow ...
}

export default CameraState;

注意mCentermWidth是支持摄像机平移(mCenter的改变)和变焦(mWidth的改变)所需的唯一变量。这两个变量都是相应的Lerp类的实例,能够插值和计算中间结果以实现渐变。

  1. 定义 getter 和 setter 函数:

  2. 定义更新函数以触发插值计算:

getCenter() { return this.mCenter.get(); }
getWidth() { return this.mWidth.get(); }

setCenter(c) { this.mCenter.setFinal(c); }
setWidth(w) { this.mWidth.setFinal(w); }

  1. 定义一个函数来配置插值:
update() {
    this.mCenter.update();
    this.mWidth.update();
}

config(stiffness, duration) {
    this.mCenter.config(stiffness, duration);
    this.mWidth.config(stiffness, duration);
}

stiffness变量是LerpmRate。它定义了插值中间结果收敛到最终值的速度。正如在Lerp类定义中所讨论的,这是一个介于 0 和 1 之间的数,其中 0 表示永远不会收敛,1 表示瞬时收敛。duration变量是LerpmCycle。它定义了结果收敛所需的更新周期数。这必须是正整数值。

请注意,随着引擎复杂性的增加,支持代码的复杂性也在增加。在这种情况下,您已经设计了一个内部实用程序类CameraState,用于存储一个Camera对象的内部状态以支持插值。这是一个内部发动机操作。游戏程序员没有理由访问这个类,因此,引擎访问文件index.js不应该被修改来转发定义。

将插值集成到相机操作中

必须修改camera_main.js中的Camera类,以使用新定义的CameraState来表示 WC 中心和宽度:

  1. 编辑camera_main.js文件并导入新定义的CameraState类:

  2. 修改Camera构造函数,用CameraState的实例替换 center 和 width 变量:

import CameraState from "./camera_state.js";

  1. 现在,编辑camera_manipulation.js文件以定义函数来更新和配置CameraState对象的插值功能:
constructor(wcCenter, wcWidth, viewportArray) {
    this.mCameraState = new CameraState(wcCenter, wcWidth);

    ... identical to previous code ...
}

  1. 修改panBy()相机操作功能,以支持CameraState对象,如下所示:
Camera.prototype.update = function () {
    this.mCameraState.update();
}

// For LERP function configuration
Camera.prototype.configLerp = function (stiffness, duration) {
    this.mCameraState.config(stiffness, duration);
}

  1. 更新panWith()zoomTowards()函数接收并设置 WC 中心到新定义的CameraState对象:
Camera.prototype.panBy = function (dx, dy) {
    let newC = vec2.clone(this.getWCCenter());
    newC[0] += dx;
    newC[1] += dy;
    this.mCameraState.setCenter(newC);
}

Camera.prototype.panWith = function (aXform, zone) {
    let status = this.collideWCBound(aXform, zone);
    if (status !== eBoundCollideStatus.eInside) {
        let pos = aXform.getPosition();
        let newC = vec2.clone(this.getWCCenter());
        if ((status & eBoundCollideStatus.eCollideTop) !== 0)

        ... identical to previous code ...

        this.mCameraState.setCenter(newC);
    }
}

Camera.prototype.zoomTowards = function (pos, zoom) {
    ... identical to previous code ...
    this.zoomBy(zoom);
    this.mCameraState.setCenter(newC);
}

在 MyGame 中测试插值

回想一下,这个项目的用户控件与上一个项目的用户控件是相同的。唯一的区别是,在这个项目中,你可以期待不同相机设置之间的渐进和平滑的过渡。为了观察正确的插值结果,必须在每次游戏场景更新时调用 camera update()功能。

update() {
    let zoomDelta = 0.05;
    let msg = "L/R: Left or Right Minion; H: Dye; P: Portal]: ";

    this.mCamera.update();  // for smoother camera movements

    ... identical to previous code ...
}

更新摄像机以计算插值中间结果的调用是my_game.js文件中唯一的变化。现在,您可以运行该项目,并试验由相机操纵操作产生的平滑和渐变。请注意,插值结果不会突然更改渲染图像,从而保持了操纵命令前后的空间连续性。您可以尝试更改stiffnessduration变量,以更好地了解不同的插值收敛速度。

相机抖动和物体振动效果

在视频游戏中,摇动相机可以方便地表达事件的重要性或强烈程度,例如敌人首领的出现或大型物体之间的碰撞。类似于值的插值,相机抖动运动也可以通过简单的数学公式来建模。

考虑在现实生活中相机抖动是如何发生的。例如,在用摄像机拍摄时,说你被某人或某物撞到你而感到惊讶或震惊。你的反应可能是轻微的迷失方向,然后迅速重新聚焦于原来的目标。从相机的角度来看,这种反应可以描述为从原始相机中心的初始大位移,随后是快速调整以使相机重新居中。数学上,如图 7-6 所示,阻尼简谐运动可以用三角函数的阻尼来表示,可以用来描述这些类型的位移。

注意,直接的数学公式是精确的,具有完美的可预测性。这种公式适用于描述规则的、正常的或预期的行为,例如球的弹跳或钟摆的摆动。抖动效果应该包含轻微的混乱和不可预测的随机性,例如,在意外碰撞后端着咖啡的手的稳定,或者像前面的例子一样,在受到惊吓后摄像机的稳定。按照这个推理,在本节中,您将定义一个通用的阻尼振荡函数,然后注入伪随机性来模拟轻微的混沌,以实现抖动效果。

img/334805_2_En_7_Fig6_HTML.png

图 7-6

阻尼简谐运动的位移

相机抖动和物体振荡投影

这个项目演示了如何实现阻尼简谐运动来模拟物体振荡,以及如何注入伪随机性来创建相机抖动效果。你可以在图 7-7 中看到这个项目运行的例子。这个项目与上一个项目相同,除了一个创建物体摆动和相机抖动效果的附加命令。这个项目的源代码在chapter7/7.3.camera_shake_and_object_oscillate文件夹中定义。

img/334805_2_En_7_Fig7_HTML.jpg

图 7-7

运行相机抖动和对象振荡项目

以下是该项目的新控件:

  • Q 键:启动染料角色的位置摆动和相机抖动效果。

以下控件与之前的项目相同:

  • WASD 键:移动Dye角色(Hero对象)。请注意,当Hero对象试图移动超过 WC 边界的 90%时,相机 WC 窗口会随之更新。

  • 箭头键:移动Portal对象。注意Portal对象不能移动超过 WC 边界的 80%。

  • L/R/P/H 键:选择Left minion、Right minion、Portal object 或Hero object 成为焦点对象。L/R 键还将相机设置为聚焦在LeftRight小按钮上。

  • N/M 键:放大或缩小相机中心。

  • J/K 键:放大或缩小,同时保证当前焦点物体的相对位置不变。换句话说,当相机缩放时,除了焦点对准的对象之外,所有对象的位置都将改变。

该项目的目标如下:

  • 为了深入了解用简单的数学函数模拟位移

  • 要体验对象的振荡效果

  • 体验相机的抖动效果

  • 将振荡实现为阻尼简谐运动,并引入伪随机性以产生相机抖动效果

与前面的项目一样,您可以在assets文件夹中找到外部资源文件。

抽象摇动行为

在许多游戏中,摇动相机是一种常见的动态行为。但是,重要的是要认识到抖动行为不仅可以应用于摄像机。也就是说,抖动效果可以被抽象为诸如尺寸、点或位置的数值的扰动(抖动)。在相机抖动的情况下,恰好被抖动的数值代表相机的 x 和 y 位置。由于这个原因,抖动和相关的支持应该是游戏引擎的一般效用函数,以便游戏开发者可以将它们应用于任何数值。以下是将要定义的新实用程序:

  • Oscillate:实现一个值随时间的简谐振荡的基类

  • Shake:对Oscillate类的一个扩展,它将随机性引入到振荡的幅度中,以模拟对一个值的抖动效果的轻微混乱

  • ShakeVec2:对Shake类的扩展,将Shake行为扩展为两个值,比如一个位置

创建振荡类来模拟简谐运动

因为所有描述的行为都依赖于简单振荡,所以应该首先实现这一点;

  1. src/engine/utils文件夹中创建一个新文件,并将其命名为oscillate.js。定义一个名为Oscillate的类,并添加以下代码来构建该对象:
class Oscillate {
    constructor(delta, frequency, duration) {
        this.mMag = delta;

        this.mCycles = duration; // cycles to complete the transition
        this.mOmega = frequency * 2 * Math.PI; // Converts to radians
        this.mNumCyclesLeft = duration;
    }

    ... implementation to follow ...
}

export default Oscillate;

delta变量代表 WC 空间中阻尼前的初始位移。frequency参数指定值为 1 代表余弦函数的一个完整周期时的振荡幅度。duration参数定义了以游戏循环更新为单位振荡多长时间。

  1. 定义阻尼简谐运动:
_nextDampedHarmonic() {
    // computes (Cycles) * cos(Omega * t)
    let frac = this.mNumCyclesLeft / this.mCycles;
    return frac * frac * Math.cos((1 - frac) * this.mOmega);
}

参见图 7-8 。mNumCyclesLeft是振荡中剩余的周期数,即k-tfrac变量\frac{k-t}{k}是阻尼因子。该函数返回一个介于-1 和 1 之间的值,可以根据需要进行缩放。

img/334805_2_En_7_Fig8_HTML.png

图 7-8

指定值振荡的阻尼简谐振动

  1. 定义一个受保护的函数来检索下一个阻尼谐波运动的值。这个功能可能看起来微不足道,没有必要。然而,正如您将在下一小节中看到的,这个函数允许 shake 子类覆盖和注入随机性。

  2. 定义检查振荡结束和重启振荡的功能:

// local/protected methods

_nextValue() {
    return (this._nextDampedHarmonic());
}

  1. 最后,定义一个公共函数来触发振荡的计算。请注意,计算的振荡结果必须按所需幅度mMag进行缩放:
done() { return (this.mNumCyclesLeft <= 0); }
reStart() { this.mNumCyclesLeft = this.mCycles; }

getNext() {
    this.mNumCyclesLeft--;
    let v = 0;
    if (!this.done()) {
        v = this._nextValue();
    }
    return (v * this.mMag);
}

创建 Shake 类来随机化振荡

现在,您可以通过在效果中引入伪随机性来扩展振荡行为,以传达震动感。

  1. src/engine/utils文件夹中创建一个新文件shake.js。定义Shake类来扩展Oscillate,并添加以下代码来构造对象:

  2. 覆盖_nextValue()以随机化振荡结果的符号,如下所示。回想一下,从公共的getNext()函数调用_nextValue()函数来检索振荡值。虽然来自阻尼简谐振动的结果在幅度上连续地和可预测地减小,但是值的相关符号被随机化,导致突然的和意外的不连续,传达了来自摇动结果的混乱感。

import Oscillate from "./oscillate.js";

class Shake extends Oscillate {
    constructor(delta, frequency, duration) {
        super(delta, frequency, duration);
    }

    ... implementation to follow ...
}

export default Shake;

_nextValue() {
    let v = this._nextDampedHarmonic();
    let fx = (Math.random() > 0.5) ? -v : v;
    return fx;
}

创建 ShakeVec2 类来模拟 Vec2 或位置的晃动

现在,您可以推广 shake 效果,以同时支持两个值的摇动。这是一个有用的工具,因为 2D 游戏中的位置是两个值的实体,位置是震动效果的方便目标。比如这个项目中,相机位置的抖动,一个二值实体,模拟相机抖动效果。

ShakeVec2类扩展了Shake类以支持vec2对象的摇动,摇动 x 和 y 维度上的值。x 维度的摇动通过Shake对象的实例来支持,而 y 维度则通过超类中定义的Shake类功能来支持。

  1. src/engine/utils文件夹中创建一个新文件shake_vec2.js。定义ShakeVec2类来扩展Shake类。类似于Shake超类的构造函数参数,deltasfreqs参数是 2D,或vec2,在 x 和 y 维度上振动的幅度和频率的版本。在构造函数中,xShake实例变量跟踪 x 维度上的震动效果。注意在super()构造函数调用中的 y 组件参数,数组索引为 1。Shake超类跟踪 y 维度上的抖动效果。

  2. 扩展reStart()getNext()函数以支持第二维度:

class ShakeVec2 extends Shake {
    constructor(deltas, freqs, duration) {
        super(deltas[1], freqs[1], duration);  // super in y-direction
        this.xShake = new Shake(deltas[0], freqs[0], duration);
    }

    ... implementation to follow ...
}

export default ShakeVec2;

reStart() {
    super.reStart();
    this.xShake.reStart();
}

getNext() {
    let x = this.xShake.getNext();
    let y = super.getNext();
    return [x, y];
}

最后,记得更新引擎访问文件index.js,以便将新定义的OscillateShakeShakeVec2功能转发给客户端。

定义 CameraShake 类来抽象相机抖动效果

通过定义的ShakeVec2类,可以方便地将伪随机阻尼简谐运动的位移应用于Camera的位置。然而,Camera对象需要一个额外的抽象层。

  1. src/engine/cameras文件夹下创建一个新文件camera_shake.js,定义接收相机状态、state参数和震动配置的构造函数:deltasfreqsshakeDuration。参数state的数据类型为CameraState,由摄像机中心位置和宽度组成。

  2. 定义触发位移计算的函数,以实现摇动效果。请注意,抖动结果是从原始位置偏移的。给定的代码将此偏移添加到原始摄像机中心位置。

import ShakeVec2 from "../utils/shake_vec2.js";

class CameraShake {
    // state is the CameraState to be shaken
    constructor(state, deltas, freqs, shakeDuration) {
        this.mOrgCenter = vec2.clone(state.getCenter());
        this.mShakeCenter = vec2.clone(this.mOrgCenter);
        this.mShake = new ShakeVec2(deltas, freqs, shakeDuration);
    }

    ... implementation to follow ...
}
export default CameraShake;

  1. 定义实用函数:查询摇动是否完成,重启摇动,以及 getter/setter 函数。
update() {
    let delta = this.mShake.getNext();
    vec2.add(this.mShakeCenter, this.mOrgCenter, delta);
}

done() { return this.mShake.done(); }
reShake() {this.mShake.reStart();}
getCenter() { return this.mShakeCenter; }
setRefCenter(c) {
    this.mOrgCenter[0] = c[0];
    this.mOrgCenter[1] = c[1];
}

CameraState类似,CameraShake也是游戏引擎内部实用程序,不应该导出给客户端游戏程序员。不应更新引擎访问文件index.js来导出此类。

修改相机以支持抖动效果

通过适当的CameraShake抽象,支持相机的抖动仅仅意味着启动和更新抖动效果:

  1. 修改camera_main.jscamera_manipulation.js导入camera_shake.js,如图所示:

  2. camera_main.js中,修改Camera构造函数初始化一个CameraShake对象:

import CameraShake from "./camera_shake.js";

  1. 修改setViewAndCameraMatrix()函数的步骤 B,使用CameraShake对象的中心(如果已定义):
constructor(wcCenter, wcWidth, viewportArray) {
    this.mCameraState = new CameraState(wcCenter, wcWidth);
    this.mCameraShake = null;

    ... identical to previous code ...
}

  1. 修改camera_manipulation.js文件,添加对启动和重启摇动效果的支持:
setViewAndCameraMatrix() {
    ... identical to previous code ...

    // Step B: Compute the Camera Matrix
    let center = [];
    if (this.mCameraShake !== null) {
        center = this.mCameraShake.getCenter();
    } else {
        center = this.getWCCenter();
    }

    ... identical to previous code ...
}

  1. 继续使用camera_manipulation.js文件,并修改update()函数以触发相机抖动更新(如果定义了一个的话):
Camera.prototype.shake = function (deltas, freqs, duration) {
    this.mCameraShake = new CameraShake(this.mCameraState,
                                        deltas, freqs, duration);
}

// Restart the shake
Camera.prototype.reShake = function () {
    let success = (this.mCameraShake !== null);
    if (success)
        this.mCameraShake.reShake();
    return success;
}

Camera.prototype.update = function () {
    if (this.mCameraShake !== null) {
        if (this.mCameraShake.done()) {
            this.mCameraShake = null;
        } else {
            this.mCameraShake.setRefCenter(this.getWCCenter());
            this.mCameraShake.update();
        }
    }
    this.mCameraState.update();
}

在 MyGame 中测试相机抖动和振荡效果

init()update()功能中只需要对my_game.js文件稍加修改,就可以支持用 Q 键触发振荡和相机抖动效果;

  1. 为在Dye角色上创建振动或弹跳效果定义一个新的实例变量:

  2. 修改update()功能,用 Q 键触发弹跳和相机抖动效果。在下面的代码中,请注意设计良好的抽象的优势。例如,相机抖动效果是不透明的,程序员需要指定的唯一信息是实际的抖动行为,即抖动幅度、频率和持续时间。相比之下,Dye角色位置的振荡或弹跳效果是通过明确查询和使用mBounce结果来实现的。

init() {
    ... identical to previous code ...

    // create an Oscillate object to simulate motion
    this.mBounce = new engine.Oscillate(2, 6, 120);
                                     // delta, freq, duration
}

update() {
    ... identical to previous code ...

    if (engine.input.isKeyClicked(engine.input.keys.Q)) {
        if (!this.mCamera.reShake())
            this.mCamera.shake([6, 1], [10, 3], 60);

            // also re-start bouncing effect
            this.mBounce.reStart();
    }

    if (!this.mBounce.done()) {
        let d = this.mBounce.getNext();
        this.mHero.getXform().incXPosBy(d);
    }

    this.mMsg.setText(msg + this.mChoice);
}

您现在可以运行该项目,体验模拟相机抖动效果的伪随机阻尼简谐运动。还可以观察Dye人物 x 位置的摆动。请注意,相机中心位置的位移将进行插值,从而产生更平滑的最终抖动效果。你可以在创建mBounce对象时或者调用mCamera.shake()函数时尝试改变参数,以试验不同的振荡和摇动配置。回想一下,在这两种情况下,前两个参数控制初始位移和frequency(余弦周期数),第三个参数是影响应该持续多长时间的duration

多个摄像头

视频游戏通常向玩家呈现游戏世界的多个视图,以传达重要或有趣的游戏信息,例如显示小地图来帮助玩家导航世界,或者提供敌人老板的视图来警告玩家将要发生什么。

在您的游戏引擎中,Camera类根据绘图的源区域和目的区域抽象出游戏世界的图形表示。绘图的源区域是游戏世界的 WC 窗口,目的区域是画布上的视口区域。这种抽象已经用多个Camera实例有效地封装和支持了多视图思想。游戏中的每一个视图都可以用一个单独的Camera对象实例来处理,这个对象有不同的 WC 窗口和视口配置。

多摄像机项目

这个项目演示了如何用多个Camera对象来表示游戏世界中的多个视图。你可以在图 7-9 中看到这个项目运行的例子。这个项目的源代码在chapter7/7.4.multiple_cameras文件夹中定义。

img/334805_2_En_7_Fig9_HTML.jpg

图 7-9

运行多摄像机项目

该项目的控件与之前的项目相同:

  • Q 键:启动Dye角色的位置摆动和相机抖动效果。

  • WASD 键:移动Dye角色(Hero对象)。请注意,当Hero对象试图移动超过 WC 边界的 90%时,相机 WC 窗口会随之更新。

  • 箭头键:移动Portal对象。注意Portal对象不能移动超过 WC 边界的 80%。

  • L/R/P/H 键:选择Left minion、Right minion、Portal object 或Hero object 成为焦点对象。L/R 键还将相机设置为聚焦在LeftRight小按钮上。

  • N/M 键:放大或缩小相机中心。

  • J/K 键:放大或缩小,同时保证当前焦点物体的相对位置不变。换句话说,当相机缩放时,除了焦点对准的对象之外,所有对象的位置都将改变。

该项目的目标如下:

  • 理解将视图呈现到游戏世界的摄像机抽象

  • 体验在同一个游戏关卡中使用多台摄像机

  • 为了理解插值配置对于具有特定目的的相机的重要性

与前面的项目一样,您可以在assets文件夹中找到外部资源文件。

改装相机

相机对象将被稍微修改,以允许绘制带有边界的视口。这将允许在画布上轻松区分相机视图。

  1. 编辑camera_main.js并修改Camera构造函数,允许程序员定义一个bound数量的像素来包围摄像机的视口:
constructor(wcCenter, wcWidth, viewportArray, bound) {
    this.mCameraState = new CameraState(wcCenter, wcWidth);
    this.mCameraShake = null;

    this.mViewport = [];  // [x, y, width, height]
    this.mViewportBound = 0;
    if (bound !== undefined) {
        this.mViewportBound = bound;
    }
    this.mScissorBound = [];  // use for bounds
    this.setViewport(viewportArray, this.mViewportBound);

    // Camera transform operator
    this.mCameraMatrix = mat4.create();

    // background color
    this.mBGColor = [0.8, 0.8, 0.8, 1]; // RGB and Alpha
}

请参考下面的setViewport()功能。默认情况下,bound被假设为零,相机会绘制到整个mViewport。当非零时,mViewport周围的bound个像素将作为背景色,从而允许在画布上轻松区分多个视口。

  1. 定义setViewport()功能:
setViewport(viewportArray, bound) {
    if (bound === undefined) {
        bound = this.mViewportBound;
    }
    // [x, y, width, height]
    this.mViewport[0] = viewportArray[0] + bound;
    this.mViewport[1] = viewportArray[1] + bound;
    this.mViewport[2] = viewportArray[2] - (2 * bound);
    this.mViewport[3] = viewportArray[3] - (2 * bound);
    this.mScissorBound[0] = viewportArray[0];
    this.mScissorBound[1] = viewportArray[1];
    this.mScissorBound[2] = viewportArray[2];
    this.mScissorBound[3] = viewportArray[3];
}

回想一下,当设置相机视口时,调用gl.scissor()函数来定义要清除的区域,调用gl.viewport()函数来标识要绘制的目标区域。以前,剪刀和视口边界是相同的,而在这种情况下,请注意实际的mViewport边界是比mScissorBound小的bound个像素。这些设置允许mScissorBound标识将被清除为背景色的区域,而mViewport边界定义用于绘制的实际画布区域。这样,视窗周围的bound数量的像素将保持背景色。

  1. 定义getViewport()函数来返回为该摄像机保留的实际边界。在这种情况下,它是mScissorBound,而不是可能更小的视口边界。

  2. 修改setViewAndCameraMatrix()函数,用mScissorBound绑定剪刀边界,而不是视口边界:

getViewport() {
    let out = [];
    out[0] = this.mScissorBound[0];
    out[1] = this.mScissorBound[1];
    out[2] = this.mScissorBound[2];
    out[3] = this.mScissorBound[3];
    return out;
}

setViewAndCameraMatrix() {
    let gl = glSys.get();
    ... identical to previous code ...
    // Step A2: set up corresponding scissor area to limit clear area
    gl.scissor(this.mScissorBound[0], // x of bottom-left corner
        this.mScissorBound[1], // y position of bottom-left corner
        this.mScissorBound[2], // width of the area to be drawn
        this.mScissorBound[3]);// height of the area to be drawn

    ... identical to previous code ...
}

在我的游戏中测试多个摄像头

MyGame关卡必须创建多个摄像头,对其进行适当配置,并独立绘制每个摄像头。为了便于演示,将创建两个新的Camera对象,一个聚焦于Hero对象,另一个聚焦于追逐的Brain对象。和前面的例子一样,MyGame级别的实现基本上是相同的。在这个例子中,init()draw()update()功能的一些部分被修改以处理多个Camera对象,并且被突出显示。

  1. 修改init()函数来定义三个Camera对象。mHeroCammBrainCam都为它们的视窗定义了一个两像素的边界,其中mHeroCam的边界被定义为灰色(背景色),而mBrainCam为白色。注意mBrainCam对象的刚性插值设置通知相机插值在十个周期内收敛到新值。

  2. 定义一个辅助函数来绘制三台摄像机共有的世界:

init() {
    // Step A: set up the cameras
    this.mCamera = new engine.Camera(
       vec2.fromValues(50, 36), // position of the camera
       100,                     // width of camera
       [0, 0, 640, 480]         // viewport (orgX, orgY, width, height)
    );
    this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
    // sets the background to gray

    this.mHeroCam = new engine.Camera(
        vec2.fromValues(50, 30), // update each cycle to point to hero
        20,
        [490, 330, 150, 150],
        2                           // viewport bounds
     );
    this.mHeroCam.setBackgroundColor([0.5, 0.5, 0.5, 1]);

    this.mBrainCam = new engine.Camera(
        vec2.fromValues(50, 30),  // update each cycle to point to brain
        10,
        [0, 330, 150, 150],
        2                           // viewport bounds
    );
    this.mBrainCam.setBackgroundColor([1, 1, 1, 1]);
    this.mBrainCam.configLerp(0.7, 10);

    ... identical to previous code ...
}

  1. 修改MyGame对象draw()函数来绘制所有三个摄像机。注意到mMsg物体只被吸引到主摄像机mCamera。因此,回声消息将只出现在主摄像机的视窗中。
_drawCamera(camera) {
    camera.setViewAndCameraMatrix();
    this.mBg.draw(camera);
    this.mHero.draw(camera);
    this.mBrain.draw(camera);
    this.mPortal.draw(camera);
    this.mLMinion.draw(camera);
    this.mRMinion.draw(camera);
}

  1. 修改update()函数,用相应的对象平移mHeroCammBrainCam,并连续移动mHeroCam视口;
draw() {
    // Step A: clear the canvas
    engine.clearCanvas([0.9, 0.9, 0.9, 1.0]); // clear to light gray

    // Step  B: Draw with all three cameras
    this._drawCamera(this.mCamera);
    this.mMsg.draw(this.mCamera);   // only draw status in main camera
    this._drawCamera(this.mHeroCam);
    this._drawCamera(this.mBrainCam);

}

Note

在游戏过程中,视窗通常不会改变它们的位置。出于测试目的,以下代码在画布中从左到右连续移动mHeroCam视口。

update() {
    let zoomDelta = 0.05;
    let msg = "L/R: Left or Right Minion; H: Dye; P: Portal]: ";

    this.mCamera.update();  // for smoother camera movements
    this.mHeroCam.update();
    this.mBrainCam.update();

    ... identical to previous code ...

    // set the hero and brain cams
    this.mHeroCam.panTo(this.mHero.getXform().getXPos(),
                        this.mHero.getXform().getYPos());
    this.mBrainCam.panTo(this.mBrain.getXform().getXPos(),
                         this.mBrain.getXform().getYPos());

    // Move the hero cam viewport just to show it is possible
    let v = this.mHeroCam.getViewport();
    v[0] += 1;
    if (v[0] > 500) {
        v[0] = 0;
    }
    this.mHeroCam.setViewport(v);

    this.mMsg.setText(msg + this.mChoice);
}

现在,您可以运行项目,并注意 HTML 画布上显示的三个不同的视口。围绕mHeroCammBrainCam视口的两个像素宽的边界允许对三个视图进行简单的视觉解析。注意到mBrainCam视口被绘制在mHeroCam的顶部。这是因为在MyGame.draw()功能中,mBrainCam是最后绘制的。最后绘制的对象总是出现在顶部。您可以移动Hero对象来观察mHeroCam跟随英雄,并体验平移相机的平滑插值结果。

现在尝试更改mBrainCam.configLerp()函数的参数以生成更平滑的插值结果,例如将刚度设置为 0.1,持续时间设置为 100 次循环。请注意,似乎摄像机一直在试图捕捉Brain物体。在这种情况下,相机需要一个硬插值设置,以确保主对象保持在相机视图的中心。为了获得更激烈和有趣的效果,你可以尝试设置mBrainCam以获得更平滑的插值结果,例如刚度值为 0.01,持续时间为 200 个周期。有了这些值,摄像机永远也追不上Brain物体,看起来就像在游戏世界里漫无目的地游荡。

通过相机的鼠标输入

鼠标是一种指示输入设备,它报告画布坐标空间中的位置信息。回想一下第三章的讨论,画布坐标空间只是相对于画布左下角沿 x/y 轴的像素偏移的度量。请记住,游戏引擎定义并使用 WC 空间,其中所有对象和度量都在 WC 中指定。为了让游戏引擎使用报告的鼠标位置,这个位置必须从画布坐标空间转换到 WC。

图 7-10 左侧的图显示了一个鼠标位置位于画布上(mouseX, mouseY)的例子。图 7-10 右侧的图显示,当左下角的视口位于( V xV y )且尺寸为WV×HV

  • mousecx = mouex-vx

  • mouseDCY = mouseYVy

这样,(mouseDCX, mouseDCY)就是从( V xV y )开始的偏移量,视口的左下角。

img/334805_2_En_7_Fig10_HTML.png

图 7-10

鼠标在画布和视窗上的位置

图 7-11 中的左图显示了设备坐标(DC)空间定义了视口内的像素位置,其偏移量是相对于视口的左下角测量的。为此,DC 空间也被称为像素空间。计算出的(mouseDCX, mouseDCY)位置是 DC 空间中位置的一个例子。图 7-11 中的右图显示,根据这些公式,该位置可以转化为左下角位于(minWCX, minWCY)且尺寸为WWC×H*WC的 WC 空间:*

*img/334805_2_En_7_Fig11_HTML.png

图 7-11

视口 DC 空间和厕所空间中的鼠标位置

  • \mathrm{mouseWCX}=\mathrm{minWCX}+\left(\mathrm{mouseDCX}\times \frac{W_{wc}}{W_v}\right)

  • \mathrm{mouseWCY}=\mathrm{minWCY}+\left(\mathrm{mouseDCY}\times \frac{H_{wc}}{H_v}\right)

了解了如何将位置从画布坐标空间转换到 WC 空间后,现在就可以在游戏引擎中实现鼠标输入支持了。

鼠标输入项目

这个项目演示了游戏引擎中的鼠标输入支持。你可以在图 7-12 中看到这个项目运行的例子。这个项目的源代码在chapter7/7.5.mouse_input文件夹中定义。

img/334805_2_En_7_Fig12_HTML.jpg

图 7-12

运行鼠标输入项目

该项目的新控件如下:

  • 在主 Camera 视图中点击鼠标左键:拖动Portal对象

  • HeroCam 视图中点击鼠标中键:拖动Hero对象

  • 在任何视图中点击鼠标右键/中键:隐藏/显示Portal对象

以下控件与之前的项目相同:

  • Q 键:启动Dye角色的位置摆动和相机抖动效果

  • WASD 键:移动Dye角色(Hero对象)并推动摄像机 WC 边界

  • 箭头键:移动Portal对象

  • L/R/P/H 键:用 L/R 键选择焦点对准的物体,将相机重新聚焦到LeftRight迷你

  • N/M 和 J/K 键:放大或缩小相机中心或对焦对象

该项目的目标如下:

  • 理解画布坐标空间到 WC 空间的转换

  • 为了理解区分鼠标事件的视口的重要性

  • 实现坐标空间之间的转换

  • 支持和体验使用鼠标输入

与前面的项目一样,您可以在assets文件夹中找到外部资源文件。

修改 index.js 以将画布 ID 传递给输入组件

为了接收鼠标输入信息,input组件需要访问 HTML 画布。编辑index.js并修改init()函数,以便在初始化时将htmlCamvasID传递给input组件。

... identical to previous code ...

// general engine utilities
function init(htmlCanvasID) {
    glSys.init(htmlCanvasID);
    vertexBuffer.init();
    input.init(htmlCanvasID);
    audio.init();
    shaderResources.init();
    defaultResources.init();
}

... identical to previous code ...

在 input.js 中实现鼠标支持

与键盘输入类似,您应该通过编辑input.js为输入模块添加鼠标支持:

  1. 编辑input.js并定义代表三个鼠标按钮的常量:

  2. 定义支持鼠标输入的变量。与键盘输入类似,鼠标按钮状态是三个布尔元素的数组,每个元素代表三个鼠标按钮的状态。

// mouse button enums
const eMouseButton = Object.freeze({
    eLeft: 0,
    eMiddle: 1,
    eRight: 2
});

  1. 定义鼠标移动事件处理程序:
let mCanvas = null;
let mButtonPreviousState = [];
let mIsButtonPressed = [];
let mIsButtonClicked = [];
let mMousePosX = -1;
let mMousePosY = -1;

function onMouseMove(event) {
    let inside = false;
    let bBox = mCanvas.getBoundingClientRect();
    // In Canvas Space now. Convert via ratio from canvas to client.
    let x = Math.round((event.clientX - bBox.left) *
                       (mCanvas.width / bBox.width));
    let y = Math.round((event.clientY - bBox.top) *
                       (mCanvas.height / bBox.height));

    if ((x >= 0) && (x < mCanvas.width) &&
        (y >= 0) && (y < mCanvas.height)) {
        mMousePosX = x;
        mMousePosY = mCanvas.height - 1 - y;
        inside = true;
    }
    return inside;
}

请注意,鼠标事件处理程序将原始像素位置转换到画布坐标空间,首先检查该位置是否在画布的边界内,然后翻转 y 位置,以便相对于左下角测量位移。

  1. 定义鼠标按钮单击处理程序来记录按钮事件:

  2. 定义鼠标按钮释放处理程序,以便于检测鼠标按钮单击事件。回想一下第四章中关于键盘输入的讨论,为了检测按钮弹起事件,你应该测试之前被释放并且当前被点击的按钮状态。mouseUp()处理程序记录鼠标按钮的释放状态。

function onMouseDown(event) {
    if (onMouseMove(event)) {
        mIsButtonPressed[event.button] = true;
    }
}

  1. 修改init()函数以接收canvasID参数并初始化鼠标事件处理程序:
function onMouseUp(event) {
    onMouseMove(event);
    mIsButtonPressed[event.button] = false;
}

  1. 修改update()函数,以类似于键盘的方式处理鼠标按钮的状态变化。请注意鼠标单击条件,即以前没有单击的按钮现在被单击了。
function init(canvasID) {
    let i;

    // keyboard support
    ... identical to previous code ...

    // Mouse support
    for (i = 0; i < 3; i++) {
        mButtonPreviousState[i] = false;
        mIsButtonPressed[i] = false;
        mIsButtonClicked[i] = false;
    }
    window.addEventListener('mousedown', onMouseDown);
    window.addEventListener('mouseup', onMouseUp);
    window.addEventListener('mousemove', onMouseMove);
    mCanvas = document.getElementById(canvasID);
}

  1. 定义检索鼠标位置和鼠标按钮状态的函数:
function update() {
    let i;
    // update keyboard input state
    ... identical to previous code ...

    // update mouse input state
    for (i = 0; i < 3; i++) {
        mIsButtonClicked[i] = (!mButtonPreviousState[i]) &&
                              mIsButtonPressed[i];
        mButtonPreviousState[i] = mIsButtonPressed[i];
    }
}

  1. 最后,记住导出新定义的功能:
function isButtonPressed(button) { return mIsButtonPressed[button]; }
function isButtonClicked(button) { return mIsButtonClicked[button]; }

function getMousePosX() { return mMousePosX; }
function getMousePosY() { return mMousePosY; }

export {
    keys, eMouseButton,

    init, cleanUp, update,

    // keyboard
    isKeyClicked, isKeyPressed,

    // mouse
    isButtonClicked, isButtonPressed, getMousePosX, getMousePosY
}

修改相机以支持视口到 WC 空间的转换

Camera类封装了 WC 窗口和视口,因此应该负责转换鼠标位置。回想一下,为了保持可读性,Camera类的源代码文件是根据功能进行分离的。该类的基本功能在camera_main.js中定义。camera_manipulate.js文件从camera_main.js导入并定义额外的操作功能。最后,camera.js文件从camera_manipulate.js导入以包含所有已定义的函数,并导出Camera类以供外部访问。

对于Camera类,这种从后续源代码文件导入以定义额外函数的链接将继续,其中camera_input.js定义输入功能:

  1. src/engine/cameras文件夹中创建一个新文件,并将其命名为camera_input.js。这个文件将通过定义鼠标输入支持函数来扩展Camera类。导入以下文件:

    • camera_manipulation.jsCamera类定义的所有函数

    • eViewport用于访问视窗阵列的常数

    • input访问鼠标相关功能的模块

  2. 定义函数将鼠标位置从画布坐标空间转换到 DC 空间,如图 7-10 所示:

import Camera from "./camera_manipulation.js";
import { eViewport } from "./camera_main.js";
import * as input from "../input.js";

... implementation to follow ...

export default Camera;

  1. 定义一个函数来确定给定的鼠标位置是否在摄像机的视口边界内:
Camera.prototype._mouseDCX = function () {
    return input.getMousePosX() - this.mViewport[eViewport.eOrgX];
}

Camera.prototype._mouseDCY = function() {
    return input.getMousePosY() - this.mViewport[eViewport.eOrgY];
}

  1. 定义将鼠标位置转换到 WC 空间的函数,如图 7-11 所示:
Camera.prototype.isMouseInViewport = function () {
    let dcX = this._mouseDCX();
    let dcY = this._mouseDCY();
    return ((dcX >= 0) && (dcX < this.mViewport[eViewport.eWidth]) &&
            (dcY >= 0) && (dcY < this.mViewport[eViewport.eHeight]));
}

Camera.prototype.mouseWCX = function () {
    let minWCX = this.getWCCenter()[0] - this.getWCWidth() / 2;
    return minWCX + (this._mouseDCX() *
           (this.getWCWidth() / this.mViewport[eViewport.eWidth]));
}

Camera.prototype.mouseWCY = function () {
    let minWCY = this.getWCCenter()[1] - this.getWCHeight() / 2;
    return minWCY + (this._mouseDCY() *
           (this.getWCHeight() / this.mViewport[eViewport.eHeight]));
}

最后,更新Camera类访问文件以正确导出新定义的输入功能。这是通过编辑camera.js文件并用camera_input.js替换从camera_manipulate.js的导入来完成的:

import Camera from "./camera_input.js";
export default Camera;

在 MyGame 中测试鼠标输入

要测试的主要功能包括检测哪个视图应该接收鼠标输入、对鼠标按钮状态变化做出反应以及将鼠标单击像素位置转换到 WC 空间的能力。和前面的例子一样,my_game.js的实现和前面的项目很相似。在这种情况下,只有update()函数包含与新的鼠标输入功能一起工作的值得注意的变化。

update() {
    ... identical to previous code ...

    msg = "";
    // testing the mouse input
    if (engine.input.isButtonPressed(engine.input.eMouseButton.eLeft)) {
        msg += "[L Down]";
        if (this.mCamera.isMouseInViewport()) {
            this.mPortal.getXform().setXPos(this.mCamera.mouseWCX());
            this.mPortal.getXform().setYPos(this.mCamera.mouseWCY());
        }
    }

    if (engine.input.isButtonPressed(engine.input.eMouseButton.eMiddle)){
        if (this.mHeroCam.isMouseInViewport()) {
            this.mHero.getXform().setXPos(this.mHeroCam.mouseWCX());
            this.mHero.getXform().setYPos(this.mHeroCam.mouseWCY());
        }
    }
    if (engine.input.isButtonClicked(engine.input.eMouseButton.eRight)) {
        this.mPortal.setVisibility(false);
    }

    if (engine.input.isButtonClicked(engine.input.eMouseButton.eMiddle)){
        this.mPortal.setVisibility(true);
    }

    msg += " X=" + engine.input.getMousePosX() +
           " Y=" + engine.input.getMousePosY();
    this.mMsg.setText(msg);
}

当视口环境很重要时,检查camera.isMouseInViewport()条件,如在主摄像机视图中单击鼠标左键或在mHeroCam视图中单击鼠标中键。这与点击鼠标右键或中键来设置Portal对象的可见性形成对比。无论鼠标位置在哪里,这两次鼠标点击都会导致执行。

您现在可以运行项目并验证到 WC 空间的转换的正确性。在主视图中点击并拖动鼠标左键,或在mHeroCam视图中点击并拖动鼠标中键,观察相应对象随着鼠标位置变化的准确移动。在错误视图中的鼠标左键或中键拖动操作对相应的对象没有影响。例如,在mHeroCammBrainCam视图中拖动鼠标左键对Portal对象没有影响。但是,请注意,鼠标右键或鼠标中键点击控制Portal对象的可见性,与鼠标指针的位置无关。请注意,浏览器会将鼠标右键单击映射到默认的弹出菜单。因此,你应该避免在游戏中点击鼠标右键。

摘要

这一章是关于控制和交互Camera对象的。您已经了解了最常见的相机操作,包括夹紧、平移和缩放。这些操作在游戏引擎中实现,具有将高级规范映射到实际 WC 窗口边界参数的效用函数。插值的引入缓解了相机操作带来的突然、通常令人讨厌且可能令人困惑的移动。通过实现相机抖动效果,您已经发现一些运动可以通过简单的数学公式来建模。您还体验了有效的Camera对象抽象在支持多个摄像机视图中的重要性。最后一节指导您完成了将鼠标位置从画布坐标空间转换到 WC 空间的实现。

在第五章中,您了解了如何用视觉上吸引人的图像来表示和绘制一个对象,以及如何控制这个对象的动画。在第六章中,你会读到如何定义一个抽象来封装一个对象的行为,以及检测对象间冲突所需的基本支持。这一章是关于这些对象的“指导”:什么应该是可见的,焦点应该在哪里,要显示多少世界,如何确保焦点之间的平滑过渡,以及如何从鼠标接收输入。有了这些功能,您现在就有了一个全面的游戏引擎框架,可以表示和绘制对象,建模和管理对象的行为,并控制如何、在哪里以及显示什么对象。

接下来的章节将继续在更高级的水平上检查对象的外观和行为,包括在 2D 世界中创建灯光和照明效果,并基于简单的经典力学模拟和集成行为。

游戏设计注意事项

您已经学习了对象交互的基础知识,现在是开始考虑创建您的第一个简单游戏机制并尝试构成良好游戏体验的逻辑条件和规则的好时机。许多设计师自上而下地进行游戏创作(这意味着他们从实现特定类型的想法开始,如实时策略、塔防或角色扮演游戏),这可能是我们在视频游戏等行业中所期望的,在视频游戏行业中,创作者通常会花相当多的时间作为内容消费者,然后转变为内容制作者。游戏工作室经常强化这种自上而下的设计方法,指派新员工在经验丰富的领导下工作,以学习特定工作室工作的任何类型的最佳实践。事实证明,这对于训练能够胜任地重复已知风格的设计师是有效的,但这并不总是培养能够从头开始设计全新系统和机制的全面创作者的最佳途径。

前面提到的可能会让我们问,“是什么让游戏性形成的很好?”从根本上来说,游戏是一种互动的体验,在这种体验中,必须学习并应用规则来达到特定的结果;所有游戏都必须满足这一最低标准,包括卡牌、棋盘、实体、视频和其他游戏类型。更进一步说,一个好的游戏是一种互动的体验,人们喜欢学习和应用规则,以达到他们觉得投入的结果。当然,在这个简短的定义中有相当多的东西要解开,但作为一个一般规则,当规则是可发现的,一致的,有逻辑意义的,并且当结果感觉像是对掌握这些规则的满意奖励时,玩家会更喜欢游戏。这个定义适用于单个游戏机制和整个游戏体验。用一个比喻来说,把游戏设计想象成由字母(交互)组成的单词(机制)组成的句子(层次)最终形成可读的内容(类型)是很有帮助的。大多数新设计师试图在他们知道字母表之前写小说,每个人都玩过这样的游戏,其中的机制和水平充其量感觉像是用糟糕的语法写的句子,最糟糕的感觉像是令人不满意的、随机混杂的不知所云的字母。

在接下来的几章中,你将了解到 2D 游戏引擎中更多的高级特性,包括照明和物理行为的模拟。您还将了解一套设计技术,使您能够交付一个完整且结构良好的游戏关卡,整合这些技术,并有意识地利用第四章中讨论的游戏设计的九个元素,从头开始提供统一的体验。在设计探索的早期阶段,只关注创建和提炼基本的游戏机制和交互模型通常是有帮助的;在这个阶段,尽量避免考虑设定、元游戏、系统设计之类的东西(随着设计的进展,这些会被合并到设计中)。

我们将探索的第一个设计技巧是一个简单的练习,它允许你开始学习游戏设计字母表:一个“逃离房间”的场景,其中有一个简单的机械装置,你必须完成一个任务才能打开一扇门并获得奖励。这个练习将帮助您深入了解如何创建可发现且一致的格式良好的逻辑规则,当任务被划分为基本的交互时,这将更容易完成。你已经在早期的项目中探索了潜在的基于规则的场景的开端:回想一下第四章中的键盘支持项目,它建议你可以让玩家将一个较小的方块完全移动到一个较大方块的边界,以触发某种行为。那种单一的互动(或“游戏字母表的字母”)如何结合起来形成一种有意义的游戏机制(或“单词”)?图 7-13 为上锁的房间拼图搭建舞台。

img/334805_2_En_7_Fig13_HTML.jpg

图 7-13

该图像表示一个分成三个区域的游戏屏幕。左边是一个可玩的区域,有一个英雄人物(标有 P 的圆圈),一个标有锁图标的不可逾越的障碍,右边是一个奖励区

图 7-13 所示的屏幕是探索新机制的有用起点。这个练习的目标是创建一个玩家必须完成的逻辑挑战,以解锁障碍并获得奖励。任务的具体性质可以基于广泛的基础力学:它可能涉及跳跃或射击、解谜、叙事情境等。关键是保持第一次迭代的简单(第一次挑战应该有有限数量的组成部分有助于解决问题)和可发现性(玩家必须能够试验和学习参与规则,以便他们能够有意识地解决挑战)。在以后的迭代中,您将为这个机制增加复杂性和趣味性,并且您将看到基本机制如何发展以支持多种类型的游戏。

图 7-14 为逻辑关系机制搭建了舞台,玩家必须与环境中的物体互动以学习规则。

img/334805_2_En_7_Fig14_HTML.jpg

图 7-14

游戏屏幕上有各种各样的单个对象

仅仅看着图 7-14 并不能立即看出玩家需要做什么来解锁障碍,因此他们必须进行实验以了解游戏世界的运行规则;正是这种实验形成了游戏机制的核心元素,推动玩家在关卡中前进,而基于其规则的可发现性和逻辑一致性,该机制或多或少会令人满意。在这个例子中,想象一下,当玩家在游戏屏幕上四处移动时,他们注意到当英雄人物与一个物体交互时,它总是以高亮的方式“激活”,如图 7-15 所示,有时会导致锁图标的一部分和锁图标周围三分之一的圆环发光。然而,有些形状在激活时不会使锁和环发光,如图 7-16 所示。

img/334805_2_En_7_Fig16_HTML.jpg

图 7-16

激活某些形状(#3)不会导致锁和环发光(#4)

img/334805_2_En_7_Fig15_HTML.jpg

图 7-15

当玩家在游戏屏幕上移动英雄角色时,这些形状会高亮显示(# 1);激活某些形状会使锁的一部分和周围环的三分之一发光(#2)

精明的玩家会很快学会这个谜题的规则。仅从图 7-15 和 7-16 中你能猜出它们可能是什么吗?如果你觉得卡住了,图 7-17 应该提供足够的信息来解决这个难题。

img/334805_2_En_7_Fig17_HTML.jpg

图 7-17

如图 7-15 所示,激活第一个物体(右上角的圆圈)并使锁的顶部和环的前三分之一发光后,正确序列中的第二个物体(#5)使锁的中部和环的前三分之二发光(#6)

你(和玩家)现在应该有所有需要的线索来学习这个机制的规则和解决这个难题。玩家可以与三种形状进行交互,每一行中每种形状只有一个实例;这些形状分别代表锁图标的顶部、中部和底部,如图 7-15 所示,激活圆形会使锁的相应部分发光。然而,图 7-16 并没有使锁的相应部分发光,不同之处在于这种机制的“挂钩”:锁的各个部分必须在正确的相对位置被激活:顶在顶行的顶部,中间在中间行,底部在底部(你也可以选择要求玩家从顶部开始以正确的顺序激活它们,尽管这一要求仅从图 7-15 到 7-17 中看不出)。

恭喜你,你现在已经创建了一个格式良好且逻辑一致(如果简单)的谜题,具备了构建更大、更有野心的关卡所需的所有元素!这种解锁序列是一种没有叙事背景的游戏机制:在设计的这个阶段,游戏屏幕故意没有游戏设置、视觉风格或流派排列,因为我们不想让任何先入为主的预期给我们的探索带来负担。作为一名设计师,在添加更高层次的游戏元素(如叙事和流派)之前,花时间探索最纯粹的游戏机制会让你受益匪浅,你可能会对意想不到的方向感到惊讶,这些简单的机制将带你构建它们。

像这个例子中的简单机制可以被描述为“以正确的顺序完成一个多阶段的任务以达到一个目标”,并且在许多种类的游戏中有特色;例如,任何需要玩家收集一个物体的各个部分并把它们组合成一个清单来完成挑战的游戏,都利用了这种机制。单独的机制也可以与其他机制和游戏功能相结合,形成复合元素,为您的游戏体验增加复杂性和风味。

这一章中的相机练习提供了很好的例子,告诉你如何增加一个机械师的兴趣;例如,简单的相机操作项目演示了一种推进游戏动作的方法。想象一下,在前面的例子中,当一个玩家获得解锁屏障的奖励后,他们将英雄对象移动到屏幕的右侧,并前进到一个新的“房间”或区域。现在想象一下,当关卡开始时,如果相机以固定的速度推进屏幕,游戏将会发生怎样的变化;自动滚动的加入极大地改变了这种机制,因为玩家必须在前进的障碍将玩家推出屏幕之前解决难题并解锁障碍。第一个实例创建了一个悠闲的解谜游戏体验,而后者通过给玩家有限的时间来完成每个屏幕,大大增加了紧张感。在自动滚动实现中,你如何安排游戏屏幕以确保玩家有足够的时间学习规则和解决难题?

多摄像机项目作为一个小地图特别有用,它提供了游戏世界中当前没有显示在游戏屏幕上的地方的信息;在前面的练习中,假设锁定的关卡出现在游戏世界中除了玩家当前屏幕之外的其他地方,并且充当小地图的辅助相机显示整个游戏世界地图的缩小视图。作为游戏设计者,您可能希望让玩家知道他们何时完成了允许他们前进的任务,并提供关于他们下一步需要去哪里的信息,因此在这种情况下,您可以在小地图上闪烁一个信号灯,以引起对刚刚解锁的关卡的注意,并向玩家显示去哪里。在我们“游戏设计就像一种书面语言”的比喻中,添加额外的元素,如相机行为,以增强或扩展一个简单的机制,是开始形成“形容词”的一种方式,这些“形容词”增加了我们从游戏设计字母表中的字母创建的基本名词和动词的兴趣。

游戏设计师的主要挑战通常是创建需要巧妙实验的场景,同时保持逻辑一致性;通过创造需要创造性解决问题的曲折场景来挫败玩家是完全可以的(我们称之为“好的”挫败感),但是通过创造逻辑上不一致的场景来挫败玩家,让玩家觉得他们在挑战中成功只是靠随机运气(“坏的”挫败感),通常被认为是糟糕的设计。回想一下你玩过的导致糟糕挫败感的游戏:它们哪里出错了,设计者可以做些什么来改善体验?

上锁的房间场景是一个有用的设计工具,因为它迫使您构建基本的机制,但您可能会惊讶于此练习可以产生的各种场景。尝试一些不同的方法来解决上锁房间的难题,看看设计过程会把你带到哪里,但要保持简单。现在,保持专注于单步项目,以打开只需要玩家学习一个规则的空间。在下一章中,你将重温这个练习,并开始创建更有挑战性的机制。*