四、实现视频游戏的通用组件

完成本章后,您将能够

  • 控制Renderable对象的位置、大小和旋转,以构建复杂的运动和动画

  • 接收来自玩家的键盘输入以控制和动画制作Renderable对象

  • 使用外部资产的异步加载和卸载

  • 从场景文件中定义、加载和执行一个简单的游戏关卡

  • 通过加载新场景来更改游戏级别

  • 使用声音剪辑作为背景音乐和音频提示

介绍

在前面的章节中,构建了一个骨骼游戏引擎来支持基本的绘图操作。绘图是构建游戏引擎的第一步,因为它允许您在继续扩展游戏引擎功能的同时观察输出。在这一章中,两个重要的机制,交互性和资源支持,将被检查并添加到游戏引擎中。交互性允许引擎接收和解释玩家输入,而资源支持指的是使用外部文件(如 GLSL 着色器源代码文件、音频剪辑和图像)的功能。

本章首先向您介绍游戏循环,这是一个在几乎所有视频游戏中创造实时互动和即时感的关键组件。基于游戏循环基础,将通过集成相应的 HTML5 功能来支持玩家键盘输入。将从头开始构建资源管理基础设施,以支持外部文件的有效加载、存储、检索和利用。用于处理外部文本文件(例如,GLSL 着色器源代码文件)和音频剪辑的功能将与相应的示例项目集成。此外,游戏场景架构将被派生以支持处理多个场景和场景转换的能力,包括在外部场景文件中定义的场景。本章结束时,您的游戏引擎将支持玩家通过键盘进行互动,能够提供音频反馈,并能够在不同的游戏关卡之间转换,包括从外部文件加载关卡。

游戏循环

任何视频游戏最基本的操作之一是支持玩家的输入和图形游戏元素之间看似即时的交互。实际上,这些交互被实现为一个连续运行的循环,接收和处理玩家输入,更新游戏状态,并呈现游戏。这个不断运行的循环被称为游戏循环

为了传达适当的即时感,游戏循环的每个周期都必须在一般人的反应时间内完成。这通常被称为实时,即人类视觉上无法察觉的太短的时间。通常,当游戏循环以高于每秒 40–60 个循环的速度运行时,可以实现实时。由于在每个游戏循环周期中通常有一个绘制操作,因此该周期的速率也称为每秒帧数(FPS)或帧速率。FPS 为 60 是一个很好的性能目标。也就是说,你的游戏引擎必须在 1/60 秒内接收玩家输入,更新游戏世界,然后绘制游戏世界全部完成!

游戏循环本身,包括实现细节,是一个游戏最基本的控制结构。以保持实时性能为主要目标,游戏循环操作的细节与游戏引擎的其余部分无关。因此,游戏循环的实现应该紧密封装在游戏引擎的核心中,其详细操作对其他游戏元素隐藏起来。

典型的游戏循环实现

游戏循环是一种机制,通过它逻辑和绘图被连续执行。一个简单的游戏循环包括绘制所有对象、处理玩家输入以及更新这些对象的状态,如下面的伪代码所示:

initialize();
while(game running) {
    draw();
    input();
    update();
}

如前所述,需要 60 的 FPS 来保持实时交互性。当游戏复杂性增加时,可能出现的一个问题是,有时一个循环可能需要 1/60 秒以上的时间才能完成,导致游戏以降低的帧速率运行。当这种情况发生时,整个游戏将会变慢。一个常见的解决方案是将一些操作优先于其他操作。也就是说,引擎可以被设计成将游戏循环固定在完成引擎认为更重要的操作上,而跳过其他操作。由于正确的输入和更新是游戏正常运行所必需的,所以必要时通常会跳过绘制操作。这被称为跳帧,下面的伪代码说明了一个这样的实现:

elapsedTime = now;
previousLoop = now;
while(game running) {
    elapsedTime += now - previousLoop;
    previousLoop = now;

    draw();
    input();
    while( elapsedTime >= UPDATE_TIME_RATE ) {
        update();
        elapsedTime -= UPDATE_TIME_RATE;
    }
}

在前面的伪代码清单中,UPDATE_TIME_RATE是所需的实时更新速率。当游戏循环周期之间经过的时间大于UPDATE_TIME_RATE时,update()将被调用,直到它赶上为止。这意味着当游戏循环运行太慢时,基本上会跳过draw()操作。当这种情况发生时,整个游戏看起来运行缓慢,游戏输入响应滞后,画面被跳过。但是,游戏逻辑将继续正常运行。

注意,包含update()函数调用的while循环模拟了UPDATE_TIME_RATE的固定更新时间步长。这种固定的时间步长更新允许在维持确定性游戏状态中的直接实现。这是一个重要的组成部分,以确保您的游戏引擎的功能,无论运行最佳或缓慢。

为了确保只关注对核心游戏循环的绘制和更新操作的理解,输入将被忽略,直到下一个项目。

游戏循环项目

这个项目演示了如何将一个游戏循环整合到你的游戏引擎中,并通过绘制和更新Renderable对象来支持实时动画。你可以在图 4-1 中看到这个项目运行的例子。这个项目的源代码在chapter4/4.1.game_loop文件夹中定义。

img/334805_2_En_4_Fig1_HTML.jpg

图 4-1

运行游戏循环项目

该项目的目标如下:

  • 为了理解游戏循环的内部操作

  • 实现和封装游戏循环的操作

  • 通过不断绘制和更新来获得创建动画的经验

实现游戏循环组件

游戏循环组件是游戏引擎功能的核心,因此其位置应该与vertex_buffer相似,作为一个在src/engine/core文件夹中定义的文件:

  1. src/engine/core文件夹中为循环模块创建一个新文件,并将该文件命名为loop.js

  2. 定义以下实例变量来跟踪帧速率、每帧的处理时间(毫秒)、游戏循环的当前运行状态以及对当前场景的引用,如下所示:

"use strict"
const kUPS = 60; // Updates per second
const kMPF = 1000 / kUPS; // Milliseconds per update.
// Variables for timing gameloop.
let mPrevTime;
let mLagTime;
// The current loop state (running or should stop)
let mLoopRunning = false;
let mCurrentScene = null;
let mFrameID = -1;

请注意,kUPS是每秒的更新数,类似于我们讨论的FPS,它被设置为每秒 60 或 60 次更新。每次更新可用的时间仅为 1/60 秒。因为一秒钟有 1000 毫秒,所以每次更新的可用时间是 1000 * (1/60),即kMPF

Note

当游戏运行在最佳状态时,帧绘制和更新都保持在相同的速率;FPSkUPS可以互换考虑。然而,当延迟发生时,loop跳过帧绘制并优先更新。在这种情况下,FPS将减少,而kUPS将保持不变。

  1. 添加运行核心循环的函数,如下所示:
function loopOnce() {
    if (mLoopRunning) {
        // Step A: set up for next call to LoopOnce
        mFrameID = requestAnimationFrame(loopOnce);

        // Step B: now let's draw
        //         draw() MUST be called before update()
        //         as update() may stop the loop!
        mCurrentScene.draw();

        // Step C: compute time elapsed since last loopOnce was executed
        let currentTime = performance.now();
        let elapsedTime = currentTime - mPrevTime;
        mPrevTime = currentTime;
        mLagTime += elapsedTime;

        // Step D: update the game the appropriate number of times.
        //      Update only every kMPF (1/60 of a second)
        //      If lag larger then update frames, update until caught up.
        while ((mLagTime >= kMPF) && mLoopRunning) {
            mCurrentScene.update();
            mLagTime -= kMPF;
        }
    }
}

Note

performance.now()是一个 JavaScript 函数,返回以毫秒为单位的时间戳。

请注意前面检查的伪代码与loopOnce()函数的步骤 B、C 和 D 之间的相似性,即步骤 B 中场景或游戏的绘制,步骤 C 中自上次更新以来经过时间的计算,以及如果引擎落后,更新的优先级。

主要区别在于,最外层的 while 循环是基于步骤 a 中的 HTML5 requestAnimationFrame()函数调用实现的。requestAnimationFrame()函数将以大约每秒 60 次的速度调用作为其参数传入的函数指针。在这种情况下,loopOnce()功能将以大约每秒 60 次的速度被连续调用。注意,每次调用requestAnimationFrame()函数都会导致相应的loopOnce()函数执行一次,因此只绘制一次。但是,如果系统滞后,在这一帧中可能会发生多次更新。

Note

requestAnimationFrame()函数是一个 HTML5 实用程序,由托管游戏的浏览器提供。该函数的精确行为取决于浏览器的实现。

现在,步骤 D 中的while循环的mLoopRunning条件是一个冗余检查。当update()可以调用stop()来停止循环时,这个条件在后面的部分会变得很重要(例如,对于关卡转换或游戏结束)。

  1. 声明一个函数来start游戏循环。这个函数初始化游戏或场景、帧时间变量和循环运行标志,然后用loopOnce函数作为参数调用第一个requestAnimationFrame()来开始游戏循环。

  2. 声明一个函数来stop游戏循环。该功能通过将mLoopRunning设置为false来停止循环,并取消最后一个请求的动画帧。

function start(scene) {
    if (mLoopRunning) {
        throw new Error("loop already running")
    }

    mCurrentScene = scene;
    mCurrentScene.init();

    mPrevTime = performance.now();
    mLagTime = 0.0;
    mLoopRunning = true;
    mFrameID = requestAnimationFrame(loopOnce);
}
  1. 最后,记住将期望的功能export给游戏引擎的其余部分,在这种情况下,只有startstop功能:
function stop() {
    mLoopRunning = false;
    // make sure no more animation frames
    cancelAnimationFrame(mFrameID);
}
export {start, stop}

使用游戏循环

为了测试游戏循环的实现,你的游戏类现在必须实现draw()update()init()函数。这是因为为了协调游戏的开始和持续运行,这些函数是从游戏循环的核心调用的——从loop.start()调用init()函数,而从loop.loopOnce()调用draw()update()函数。

  1. 编辑您的my_game.js文件,通过从模块导入来提供对循环的访问。允许游戏开发者访问游戏循环模块是一个临时措施,将在后面的章节中得到纠正。

  2. 用以下内容替换MyGame构造函数:

// Accessing engine internal is not ideal,
//      this must be resolved! (later)
import * as loop from "../engine/core/loop.js";
  1. 添加一个初始化函数来设置一个摄像机和两个Renderable对象:
constructor() {
    // variables for the squares
    this.mWhiteSq = null;        // these are the Renderable objects
    this.mRedSq = null;

    // The camera to view the scene
    this.mCamera = null;
}
  1. 像以前一样通过清除画布、设置相机并绘制每个方块来绘制场景:
init() {
    // Step A: set up the cameras
    this.mCamera = new engine.Camera(
       vec2.fromValues(20, 60),   // position of the camera
       20,                        // width of camera
       [20, 40, 600, 300]         // viewport (orgX, orgY, width, height)
        );
    this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
    // sets the background to gray

    // Step  B: Create the Renderable objects:
    this.mWhiteSq = new engine.Renderable();
    this.mWhiteSq.setColor([1, 1, 1, 1]);
    this.mRedSq = new engine.Renderable();
    this.mRedSq.setColor([1, 0, 0, 1]);

    // Step  C: Init the white Renderable: centered, 5x5, rotated
    this.mWhiteSq.getXform().setPosition(20, 60);
    this.mWhiteSq.getXform().setRotationInRad(0.2); // In Radians
    this.mWhiteSq.getXform().setSize(5, 5);

    // Step  D: Initialize the red Renderable object: centered 2x2
    this.mRedSq.getXform().setPosition(20, 60);
    this.mRedSq.getXform().setSize(2, 2);
}
  1. 添加一个update()函数来激活一个移动的白色方块和一个跳动的红色方块:
draw() {
    // Step A: clear the canvas
    engine.clearCanvas([0.9, 0.9, 0.9, 1.0]); // clear to light gray

    // Step  B: Activate the drawing Camera
    this.mCamera.setViewAndCameraMatrix();

    // Step  C: Activate the white shader to draw
    this.mWhiteSq.draw(this.mCamera);

    // Step  D: Activate the red shader to draw
    this.mRedSq.draw(this.mCamera);
}
update() {
    // Simple game: move the white square and pulse the red
    let whiteXform = this.mWhiteSq.getXform();
    let deltaX = 0.05;

    // Step A: Rotate the white square
    if (whiteXform.getXPos() > 30) // the right-bound of the window
        whiteXform.setPosition(10, 60);
    whiteXform.incXPosBy(deltaX);
    whiteXform.incRotationByDegree(1);

    // Step B: pulse the red square
    let redXform = this.mRedSq.getXform();
    if (redXform.getWidth() > 5)
        redXform.setSize(2, 2);
    redXform.incSizeBy(0.05);
}

回想一下,每秒钟调用update()函数大约 60 次,每次都发生以下情况:

  1. window.onload功能启动游戏loop。注意,对MyGame实例的引用被传递给了loop

  2. 白色正方形的步骤 A:将旋转增加 1 度,将 x 位置增加 0.05,如果得到的 x 位置大于 30,则重置为 10。

  3. 红色方块的步骤 B:将大小增加 0.05,如果结果大小大于 5,则将其重置为 2。

  4. 由于前面的操作以每秒大约 60 次的速度连续执行,您可以预期看到以下内容:

    1. 向右移动时旋转的白色正方形,到达右边界时绕到左边界

    2. 一个红色正方形,尺寸增加,当尺寸达到 5 时减小到 2,因此看起来像是在跳动

window.onload = function () {
    engine.init("GLCanvas");
    let myGame = new MyGame();
    // new begins the game
    loop.start(myGame);
}

现在,您可以运行项目来观察向右移动、旋转的白色正方形和跳动的红色正方形。您可以通过改变incXPosBy()incRotationByDegree()incSizeBy()功能的相应值来控制移动、旋转和脉冲的速率。在这些情况下,位置值、旋转值和大小值在固定的时间间隔内以恒定的量变化。实际上,这些函数的参数是变化率,或速度,incXPosBy(0.05),是 0.05 单位/1/60 秒或 3 单位/秒的向右速度。在此项目中,世界的宽度为 20 个单位,白色方块以每秒 3 个单位的速度移动,您可以验证白色方块从左边界移动到右边界需要 6 秒多一点的时间。

注意,在loop模块的核心中,requestAnimationFrame()函数完全有可能在单个kMPF间隔内多次调用loopOnce()函数。当这种情况发生时,draw()函数将被多次调用,而没有任何update()函数调用。这样,游戏循环可以多次结束绘制相同的游戏状态。请参考以下参考资料,讨论如何在draw()函数中支持外推,以利用高效的游戏循环:

为了清楚地描述游戏引擎的每个组件,并说明这些组件是如何交互的,本书不支持draw()函数的外推。

键盘输入

很明显,对接收玩家输入的适当支持对于交互式视频游戏是很重要的。对于典型的个人计算设备,如 PC 或 Mac,两种常见的输入设备是键盘和鼠标。虽然键盘输入是以字符流的形式接收的,但是鼠标输入是与位置信息打包在一起的,并且与摄像机视图相关。因此,在引擎开发的这个阶段,键盘输入更容易支持。本节将介绍键盘支持并将其集成到您的游戏引擎中。鼠标输入将在第 7 章的鼠标输入项目中考察,在同一游戏支持多个摄像头的覆盖之后。

键盘支持项目

该项目检查键盘输入支持,并将该功能集成到游戏引擎中。这个项目中游戏对象的位置、旋转和大小都在你的输入控制之下。你可以在图 4-2 中看到这个项目运行的例子。这个项目的源代码在chapter4/4.2.keyboard_support文件夹中定义。

img/334805_2_En_4_Fig2_HTML.jpg

图 4-2

运行键盘支持项目

该项目的控制措施如下:

  • 右箭头键:将白色方块向右移动,并将其绕到游戏窗口的左侧

  • 向上箭头键:旋转白色方块

  • 向下箭头键:增加红色方块的大小,然后在阈值处重新设置大小

该项目的目标如下:

  • 实现接收键盘输入的引擎组件

  • 理解按键状态(如果按键被释放或按下)和按键事件(当按键状态改变时)之间的区别

  • 了解如何在游戏循环中集成输入组件

向引擎添加输入组件

回想一下,循环组件是游戏引擎核心的一部分,不应该被客户端游戏开发者访问。相比之下,定义良好的输入模块应该支持客户端游戏开发人员查询键盘状态,而不受任何细节的干扰。因此,输入模块将被定义在src/engine文件夹中。

  1. src/engine文件夹中创建一个新文件,并将其命名为input.js

  2. 定义一个 JavaScript 字典来捕获关键代码映射:

"use strict"
// Key code constants
const keys = {
    // arrows
    Left: 37,
    Up: 38,
    Right: 39,
    Down: 40,

    // space bar
    Space: 32,

    // numbers
    Zero: 48,
    One: 49,
    Two: 50,
    Three: 51,
    Four: 52,
    Five : 53,
    Six : 54,
    Seven : 55,
    Eight : 56,
    Nine : 57,

    // Alphabets
    A : 65,
    D : 68,
    E : 69,
    F : 70,
    G : 71,
    I : 73,
    J : 74,
    K : 75,
    L : 76,
    Q : 81,
    R : 82,
    S : 83,
    W : 87,

    LastKeyCode: 222
}

键码是代表每个键盘字符的唯一数字。请注意,最多有 222 个唯一键。在清单中,字典中只定义了一小部分与这个项目相关的键。

Note

字母的键码是连续的,从 A 的 65 开始,到 z 的 90 结束。你可以随意为你自己的游戏引擎添加任何字符。有关键码的完整列表,请参见 www.cambiaresearch.com/articles/15/javascript-char-codes-key-codes

  1. 创建数组实例变量来跟踪每个键的状态:
// Previous key state
let mKeyPreviousState = []; // a new array
// The pressed keys.
let  mIsKeyPressed = [];
// Click events: once an event is set, it will remain there until polled
let  mIsKeyClicked = [];

这三个数组都将每个键的状态定义为布尔值。mKeyPreviousState记录前一更新周期的密钥状态,mIsKeyPressed记录密钥的当前状态。当按下相应的键盘键时,这两个数组的键码条目为真,否则为假。mIsKeyClicked数组捕获按键点击事件。只有当相应的键盘键在两个连续的更新周期中从释放变为按下时,该数组的键码条目才为真。

需要注意的是,KeyPress是一个键的状态,而KeyClicked是一个事件。例如,如果玩家在释放键之前按下键一秒钟,那么 A 的整个第二KeyPress的持续时间为真,而 AKeyClick只为真一次——该键被按下后的更新周期。

  1. 定义函数来捕捉实际的键盘状态变化:
// Event handler functions
function onKeyDown(event) {
    mIsKeyPressed[event.keyCode] = true;
}

function onKeyUp(event) {
    mIsKeyPressed[event.keyCode] = false;
}

当调用这些函数时,参数中的键码用于记录相应的键盘状态变化。预计这些函数的调用方将在参数中传递适当的关键代码。

  1. 添加一个函数来初始化所有按键状态,并向浏览器注册按键事件处理程序。window.addEventListener()函数向浏览器注册onKeyUp/Down()事件处理程序,这样当玩家按下或释放键盘上的键时,相应的函数将被调用。

  2. 添加一个update()函数来派生按键点击事件。update()功能使用mIsKeyPressedmKeyPreviousState来确定是否发生了按键事件。

function init() {
    let i;
    for (i = 0; i < keys.LastKeyCode; i++) {
        mIsKeyPressed[i] = false;
        mKeyPreviousState[i] = false;
        mIsKeyClicked[i] = false;
    }

    // register handlers
    window.addEventListener('keyup', onKeyUp);
    window.addEventListener('keydown', onKeyDown);
}
  1. 添加查询当前键盘状态的公共函数,以支持客户端游戏开发者:
function update() {
    let i;
    for (i = 0; i < keys.LastKeyCode; i++) {
        mIsKeyClicked[i] = (!mKeyPreviousState[i]) && mIsKeyPressed[i];
        mKeyPreviousState[i] = mIsKeyPressed[i];
    }
}
  1. 最后,导出公共函数和关键常量:
// Function for GameEngine programmer to test if a key is pressed down
function isKeyPressed(keyCode) {
    return mIsKeyPressed[keyCode];
}
function isKeyClicked(keyCode) {
    return mIsKeyClicked[keyCode];
}
export {keys, init,
    update,
    isKeyClicked,
    isKeyPressed
}

修改引擎以支持键盘输入

为了正确支持输入,在游戏循环开始之前,引擎必须初始化mIsKeyPressedmIsKeyClickedmKeyPreviousState数组。为了正确地捕捉玩家的动作,在游戏过程中,从游戏循环的核心,这些数组必须相应地更新。

  1. 输入状态初始化:通过导入input.js模块来修改index.js,将输入的初始化添加到引擎init()函数中,并将input模块添加到导出列表中,以允许客户端游戏开发者访问。

  2. 为了准确地捕捉键盘状态变化,输入组件必须与游戏循环的核心集成在一起。将inputupdate()功能加入到核心游戏loop中,在loop.js中加入以下几行。注意,代码的其余部分是相同的。

import * as input from "./input.js";

function init(htmlCanvasID) {
    glSys.init(htmlCanvasID);
    vertexBuffer.init();
    shaderResources.init();
    input.init();
}

export default {
    // input support
    input,

    // Util classes
    Camera, Transform, Renderable,

    // functions
    init, clearCanvas
}
import * as input from "../input.js";

function loopOnce() {
    if (mLoopRunning) {

        ... identical to previous code ...

        // Step D: update the game the appropriate number of times.
        //      Update only every kMPF (1/60 of a second)
        //      If lag larger then update frames, update until caught up.
        while ((mLagTime >= kMPF) && mLoopRunning) {
            input.update();
            mCurrentScene.update();
            mLagTime -= kMPF;
        }
    }
}

测试键盘输入

您可以通过修改您的MyGame类中的Renderable对象来测试输入功能。用以下代码替换MyGame update()功能中的代码:

update() {
    // Simple game: move the white square and pulse the red

    let whiteXform = this.mWhiteSq.getXform();
    let deltaX = 0.05;

    // Step A: test for white square movement
    if (engine.input.isKeyPressed(engine.input.keys.Right)) {
        if (whiteXform.getXPos() > 30) { // right-bound of the window
            whiteXform.setPosition(10, 60);
        }
        whiteXform.incXPosBy(deltaX);
    }

    // Step  B: test for white square rotation
    if (engine.input.isKeyClicked(engine.input.keys.Up)) {
        whiteXform.incRotationByDegree(1);
    }

    let redXform = this.mRedSq.getXform();
    // Step  C: test for pulsing the red square
    if (engine.input.isKeyPressed(engine.input.keys.Down)) {
        if (redXform.getWidth() > 5) {
            redXform.setSize(2, 2);
        }
        redXform.incSizeBy(0.05);
    }
}

在前面的代码中,步骤 A 确保按住右箭头键将白色方块向右移动。步骤 B 检查上箭头键事件的按下和释放。当检测到这样的事件时,旋转白色方块。请注意,按住向上箭头键不会连续生成按键事件,因此不会导致白色方块连续旋转。步骤 C 测试按住向下箭头键以使红色方块跳动。

您可以运行该项目,并包括用于操纵正方形的附加控件。例如,支持 WASD 键来控制红色方块的位置。请再次注意,通过增加/减少位置变化量,您可以有效地控制对象的移动速度。

Note

术语“ WASD 键”用来指流行的游戏控制键绑定:W 键向上移动,A 键向左,S 键向下,D 键向右。

资源管理和异步加载

视频游戏通常利用大量艺术资产或资源,包括音频剪辑和图像。支持一个游戏所需的资源可能很大。此外,重要的是保持资源和实际游戏之间的独立性,以便它们可以独立地更新,例如,改变背景音频而不改变游戏本身。由于这些原因,游戏资源通常存储在外部的系统硬盘或网络服务器上。由于存储在游戏外部,资源有时被称为外部资源资产

游戏开始后,必须显式加载外部资源。为了有效地利用内存,游戏应该根据需要动态地加载和卸载资源。然而,加载外部资源可能涉及输入/输出设备操作或网络数据包延迟,因此可能是时间密集型的,并可能影响实时交互性。由于这些原因,在游戏的任何情况下,只有一部分资源被保存在内存中,其中加载操作被有策略地执行以避免中断游戏。在大多数情况下,每个级别所需的资源在该级别的游戏过程中都保存在内存中。使用这种方法,外部资源加载可以发生在等级转换期间,此时玩家期望一个新的游戏环境,并且更可能容忍加载的轻微延迟。

一旦加载,资源必须易于访问以支持交互性。高效和有效的资源管理对任何游戏引擎都是至关重要的。请注意资源管理和资源的实际所有权之间的明显区别,资源管理是游戏引擎的责任。例如,游戏引擎必须支持游戏背景音乐的有效加载和播放,并且是游戏(或游戏引擎的客户端)实际拥有并提供背景音乐的音频文件。当实现对外部资源管理的支持时,重要的是要记住实际的资源不是游戏引擎的一部分。

此时,您构建的游戏引擎只处理一种类型的资源——GLSL 着色器文件。回想一下,SimpleShader对象在其构造函数中加载并编译了simple_vs.glslsimple_fs.glsl文件。到目前为止,着色器文件加载已经通过同步XMLHttpRequest.open()完成。这种同步加载是低效资源管理的一个示例,因为当浏览器试图打开和加载着色器文件时,不会发生任何操作。一种有效的替代方法是发出异步加载命令,并允许在打开和加载文件的同时继续其他操作。

本节构建了一个支持异步加载和高效访问加载资源的基础设施。基于这一基础设施,在接下来的几个项目中,游戏引擎将扩展到支持场景转换期间的批量资源加载。

资源贴图和着色器加载器项目

该项目指导您开发resource_map组件,这是一个用于资源管理的基础模块,并演示了如何使用该模块异步加载着色器文件。你可以在图 4-3 中看到这个项目运行的例子。这个项目看起来与前一个项目相同,唯一的区别是如何加载 GLSL 着色器。这个项目的源代码在chapter4/4.3.resource_map_and_shader_loader文件夹中定义。

img/334805_2_En_4_Fig3_HTML.jpg

图 4-3

运行资源贴图和着色器加载器项目

该项目的控制与前一个项目相同,如下所示:

  • 右箭头键:将白色方块向右移动,并将其绕到游戏窗口的左侧

  • 向上箭头键:旋转白色方块

  • 向下箭头键:增加红色方块的大小,然后在阈值处重新设置大小

该项目的目标如下:

  • 要理解异步加载的处理

  • 构建支持未来资源加载和访问的基础设施

  • 通过加载 GLSL 着色器文件来体验异步资源加载

Note

关于异步 JavaScript 操作的更多信息,可以参考网上很多优秀的资源,例如, https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous

将资源映射组件添加到引擎中

resource_map引擎组件管理资源加载、存储和资源加载后的检索。这些操作是游戏引擎内部的,不应该被游戏引擎客户端访问。与所有核心引擎组件一样,例如游戏循环,源代码文件是在src/engine/core文件夹中创建的。详情如下。

  1. src/engine/core文件夹中创建一个新文件,并将其命名为resource_map.js

  2. 定义MapEntry类来支持加载资源的引用计数。引用计数对于避免资源的多次加载或过早卸载至关重要。

  3. 定义一个键值对映射mMap,用于存储和检索资源,定义一个数组mOutstandingPromises,用于捕获所有未完成的异步加载操作:

class MapEntry {
    constructor(data) {
        this.mData = data;
        this.mRefCount = 1;
    }
    decRef() { this.mRefCount--; }
    incRef() { this. mRefCount++; }

    set(data) { this.mData = data;}
    data() { return this.mData; }

    canRemove() { return (this.mRefCount == 0); }
}
let mMap = new Map();
let mOutstandingPromises = [];

Note

JavaScript Map对象保存一组键值对。

  1. 定义用于查询资源是否存在、检索和设置资源的函数。请注意,如参数path的变量名所示,外部资源文件的完整路径将被用作访问相应资源的键,例如,使用src/glsl_shaders/simple_vs.glsl文件的路径作为访问文件内容的键。

  2. 定义函数来指示已请求加载,增加已加载资源的引用计数,并正确卸载资源。由于加载操作的异步性质,加载请求将导致空的MapEntry,当加载操作在将来某个时候完成时,该空的MapEntry将被更新。请注意,每个卸载请求都会减少引用计数,并且可能会也可能不会导致资源被卸载。

function has(path) { return mMap.has(path) }
function get(path) {
    if (!has(path)) {
        throw new Error("Error [" + path + "]: not loaded");
    }
    return mMap.get(path).data();
}
function set(key, value) { mMap.get(key).set(value); }
  1. 定义一个函数,将正在进行的异步加载操作追加到mOutstandingPromises数组中
function loadRequested(path) {
    mMap.set(path, new MapEntry(null));
}
function incRef(path) {
    mMap.get(path).incRef();
}
function unload(path) {
    let entry = mMap.get(path);
    entry.decRef();
    if (entry.canRemove())
        mMap.delete(path)
    return entry.canRemove();
}
  1. 定义一个加载函数,loadDecodeParse()。如果资源已经加载,则相应的引用计数将递增。否则,该函数首先发出一个loadRequest(),在mMap中创建一个空的MapEntry。然后,该函数创建一个 HTML5 fetch承诺,使用资源的路径作为关键字,异步获取外部资源,解码网络打包,将结果解析为适当的格式,并将结果更新到创建的MapEntry中。这个创建的承诺然后被推入mOutstandingPromises数组。
function pushPromise(p) { mOutstandingPromises.push(p); }
// generic loading function,
//   Step 1: fetch from server
//   Step 2: decodeResource on the loaded package
//   Step 3: parseResource on the decodedResource
//   Step 4: store result into the map
// Push the promised operation into an array
function loadDecodeParse(path, decodeResource, parseResource) {
    let fetchPromise = null;
    if (!has(path)) {
        loadRequested(path);
        fetchPromise =  fetch(path)
            .then(res => decodeResource(res) )
            .then(data => parseResource(data) )
            .then(data => { return set(path, data) } )
            .catch(err => { throw err });
        pushPromise(fetchPromise);
    } else {
        incRef(path);  // increase reference count
    }
    return fetchPromise;
}

注意,解码和解析函数是作为参数传入的,因此依赖于正在获取的实际资源类型。例如,简单文本、XML(可扩展标记语言)格式的文本、音频剪辑和图像的解码和解析都有不同的要求。定义这些函数是实际资源加载器的责任。

HTML5 fetch()函数返回一个 JavaScript promise对象。典型的 JavaScript promise对象包含将在未来完成的操作。当操作完成时,promisefulfilled。在这种情况下,当path被正确提取、解码、解析并更新到相应的MapEntry中时,fetchPromisefulfilled。这个promise被保存在mOutstandingPromises数组中。请注意,在loadDecodeParse()功能结束时,异步fetch()加载操作被发出并正在进行,但不保证完成。通过这种方式,mOutstandingPromises是一系列正在进行的、未实现的或未完成的承诺。

  1. 定义一个 JavaScript async函数来阻止执行,并等待所有未完成的承诺得到履行,或者等待所有正在进行的异步加载操作完成:
// will block, wait for all outstanding promises complete
// before continue
async function waitOnPromises() {
    await Promise.all(mOutstandingPromises);
    mOutstandingPromises = []; // remove all
}

Note

JavaScript async / await关键字是成对的,只有async函数可以await用于promiseawait语句阻塞并将执行返回给async函数的调用者。当正在等待的promise完成时,执行将继续到async功能结束。

  1. 最后,将功能导出到游戏引擎的其余部分:
export {has, get, set,
    loadRequested, incRef, loadDecodeParse,
    unload,
    pushPromise, waitOnPromises}

注意,尽管特定于存储的功能——查询、获取和设置——被很好地定义,但是resource_map实际上不能加载任何特定的资源。这个模块是为资源类型特定的模块设计的,在这些模块中可以正确定义解码和解析功能。在下一小节中,将定义一个文本资源加载器来演示这一思想。

定义文本资源模块

本节将定义一个text模块,它利用resource_map模块异步加载您的文本文件。这个模块是如何利用resource_map工具的一个很好的例子,它允许你替换 GLSL 着色器文件的同步加载。用异步加载支持取代同步是对游戏引擎的重大升级。

  1. src/engine/中新建一个文件夹,命名为resources。创建这个新文件夹是为了预期对许多资源类型的必要支持,并维护一个干净的源代码组织。

  2. src/engine/resources文件夹中创建一个新文件,并将其命名为text.js

  3. 导入核心资源管理并重用resource_map中的相关功能:

  4. loadDecodeParse()定义文本解码和解析功能。注意,对解析加载的文本没有要求,因此,文本解析函数不执行任何有用的操作。

"use strict"
import * as map from "../core/resource_map.js";

// functions from resource_map
let unload = map.unload;
let has = map.has;
let get = map.get;
  1. 定义load()函数调用resource_map loadDecodeParse()函数触发异步fetch()操作:
function decodeText(data) {
    return data.text();
}
function parseText(text) {
    return text;
}
  1. 导出功能以提供对游戏引擎其余部分的访问:
function load(path) {
    return map.loadDecodeParse(path, decodeText, parseText);
}
  1. 最后,记得更新在index.js中为客户端定义的功能:
export {has, get, load, unload}
import * as text from "./resources/text.js";

... identical to previous code ...

export default {
    // resource support
    text,

    ... identical to previous code ...
}

异步加载着色器

text资源模块现在可以用来帮助将着色器文件作为纯文本文件异步加载。由于无法预测异步加载操作何时完成,因此在需要资源之前发出加载命令并确保加载操作在继续检索资源之前完成是很重要的。

修改着色器资源以获得异步支持

为了避免同步加载 GLSL 着色器文件,必须在创建SimpleShader对象之前加载这些文件。回想一下,SimpleShader对象的单个实例是在shader_resources模块中创建的,并在所有的Renderable中共享。现在,您可以在创建SimpleShader对象之前异步加载 GLSL 着色器文件。

  1. 编辑shader_resources.js并从textresource_map模块导入功能:

  2. 替换init()功能的内容。定义一个 JavaScript promiseloadPromise,异步加载两个 GLSL 着色器文件,加载完成后,触发createShaders()函数的调用。通过调用map.pushPromise()函数将loadPromise存储在resource_mapmOutstandingPromises数组中:

import * as text from "../resources/text.js";
import * as map from "./resource_map.js";
function init() {
    let loadPromise = new Promise(
        async function(resolve) {
            await Promise.all([
                text.load(kSimpleFS),
                text.load(kSimpleVS)
            ]);
            resolve();
        }).then(
            function resolve() { createShaders(); }
        );
    map.pushPromise(loadPromise);
}

注意在shader_resources init()函数之后,两个 GLSL 着色器文件的加载已经开始了。此时,不能保证加载操作已经完成,并且可能还没有创建SimpleShader对象。然而,基于这些操作完成的承诺存储在resource_map mOutstandingPromises数组中。因此,保证这些操作必须在resource_map waitOnPromises()功能结束时完成。

修改 SimpleShader 以检索着色器文件

理解了 GLSL 着色器文件已经被加载,对SimpleShader类的修改就很简单了。不用在loadAndCompileShader()函数中同步加载着色器文件,这些文件的内容可以简单地通过text资源检索。

  1. 编辑simple_shader.js文件,并从text模块添加一个import,用于检索 GLSL 着色器的内容:

  2. 由于不需要加载操作,您应该将loadAndCompileShader()函数名改为简单的compileShader(),并用text资源检索代替文件加载命令。请注意,同步加载操作被一个对text.get()的调用所取代,该调用基于filePath或着色器文件的唯一资源名来检索文件内容。

import * as text from "./resources/text.js";
  1. 记住,在SimpleShader构造函数中,对loadAndCompileShader()函数的调用应该被新修改的compileShader()函数替换,如下所示:
function compileShader(filePath, shaderType) {
    let shaderSource = null, compiledShader = null;
    let gl = glSys.get();

    // Step A: Access the shader textfile
    shaderSource = text.get(filePath);

    if (shaderSource === null) {
        throw new Error("WARNING:" + filePath + " not loaded!");
        return null;
    }

    ... identical to previous code ...
}
constructor(vertexShaderPath, fragmentShaderPath) {
    ... identical to previous code ...

    // Step A: load and compile vertex and fragment shaders
    this.mVertexShader = compileShader(vertexShaderPath,
                                      gl.VERTEX_SHADER);
    this.mFragmentShader = compileShader(fragmentShaderPath,
                                        gl.FRAGMENT_SHADER);

    ... identical to previous code ...
}
等待异步加载完成

由于突出的加载操作和不完整的着色器创建,客户端的游戏无法初始化,因为没有SimpleShaderRenderable对象无法正确创建。出于这个原因,游戏引擎必须等待所有未完成的承诺得到履行,然后才能初始化客户端的游戏。回想一下,客户端的游戏初始化是在游戏循环start()函数中执行的,就在第一次循环迭代开始之前。

  1. 编辑loop.js文件并从resource_map模块导入:

  2. start()函数修改为async函数,以便现在可以通过调用map.waitOnPromises()来发出await并暂停执行,以等待所有未完成承诺的履行;

import * as map from "./resource_map.js";
async function start(scene) {
    if (mLoopRunning) {
        throw new Error("loop already running")
    }
    // Wait for any async requests before game-load
    await map.waitOnPromises();

    mCurrentScene = scene;
    mCurrentScene.init();

    mPrevTime = performance.now();
    mLagTime = 0.0;
    mLoopRunning = true;
    mFrameID = requestAnimationFrame(loopOnce);
}

测试异步着色器加载

现在,您可以在着色器异步加载的情况下运行项目。虽然输出和交互体验与之前的项目相同,但现在您有了一个更好地管理外部资源加载和访问的游戏引擎。

本章的其余部分进一步开发并形式化了客户端、MyGame和游戏引擎其余部分之间的接口。目标是定义客户端的接口,以便在运行时可以创建和交换多个游戏级别的实例。有了这个新的界面,你将能够定义什么是游戏关卡,并允许游戏引擎以任何顺序加载任何关卡。

场景文件中的游戏关卡

从场景文件启动游戏级别所涉及的操作可以帮助游戏引擎和其客户端之间的正式接口的导出和改进。使用场景文件中定义的游戏级别,游戏引擎必须首先启动异步加载,等待加载完成,然后初始化游戏循环的客户端。这些步骤在游戏引擎和客户端之间提供了一个完整的功能接口。通过检查和获得对这些步骤的适当支持,游戏引擎和它的客户机之间的接口可以被改进。

场景文件项目

这个项目使用场景文件的加载作为工具来检查一个典型的游戏级别的必要的公共方法。你可以在图 4-4 中看到这个项目运行的例子。该项目的外观和交互方式与上一个项目相同,唯一的区别是场景定义是从文件中异步加载的。这个项目的源代码在chapter4/4.4.scene_file文件夹中定义。

img/334805_2_En_4_Fig4_HTML.jpg

图 4-4

运行场景文件项目

该项目的控件与上一个项目相同,如下所示:

  • 右箭头键:将白色方块向右移动,并将其绕到游戏窗口的左侧

  • 向上箭头键:旋转白色方块

  • 向下箭头键:增加红色方块的大小,然后在阈值处重新设置大小

该项目的目标如下:

  • 介绍支持游戏资源异步加载的协议

  • 为了开发适当的游戏引擎支持该协议

  • 识别和定义一般游戏级别的公共接口方法

虽然游戏引擎设计者对场景文件的解析和加载过程很感兴趣,但是客户端永远不需要关心这些细节。这个项目旨在开发一个引擎和客户端之间定义良好的接口。该接口将对客户端隐藏引擎内部核心的复杂性,从而避免诸如在本章的第一个项目中需要从MyGame访问loop模块的情况。

场景文件

不是在init()函数中将所有对象的创建硬编码到游戏中,而是将信息编码到一个文件中,并且可以在运行时加载和解析该文件。在外部文件中进行这种编码的优点是可以灵活地修改场景而不需要改变游戏源代码,而缺点是加载和解析所需的复杂性和时间。一般来说,灵活性的重要性决定了大多数游戏引擎支持从文件中加载游戏场景。

游戏场景中的对象可以用多种方式定义。关键的决定因素是该格式能够恰当地描述游戏对象并易于解析。可扩展标记语言(XML)非常适合作为场景文件的编码方案。

定义 XML 资源模块

为了支持 XML 编码的场景文件,首先需要扩展引擎以支持 XML 文件资源的异步加载。与text资源模块类似,XML 资源模块也应该基于resource_map:将加载的 XML 内容存储在resource_mapmMap中,并为resource_maploadDecodeParse()函数的调用定义解码和解析的细节。

  1. src/engine/resources文件夹中定义一个新文件,并将其命名为xml.js。编辑该文件并从resource_map导入核心资源管理功能。

  2. 实例化一个 XML DOMParser,定义解码和解析函数,用相应的参数调用resource_maploadDecodeParse()函数,开始加载 XML 文件:

"use strict"
import * as map from "../core/resource_map.js";
// functions from resource_map
let unload = map.unload;
let has = map.has;
let get = map.get;
  1. 记住导出定义的功能:
let mParser = new DOMParser();

function decodeXML(data) {
    return data.text();
}

function parseXML(text) {
    return mParser.parseFromString(text, "text/xml");
}

function load(path) {
    return map.loadDecodeParse(path, decodeXML, parseXML);
}
  1. 最后,记住exportindex.js中为客户端定义的功能:
export {has, get, load, unload}
import * as xml from "./resources/xml.js";

... identical to previous code ...

export default {
    // resource support
    text, xml,

    ... identical to previous code ...
}

客户端可以方便地访问新定义的xml模块,并以类似于text模块的方式加载外部 XML 编码的文本文件。

Note

JavaScript DOMParser提供了解析 XML 或 HTML 文本字符串的能力。

修改引擎以集成客户端资源加载

场景文件是由客户端加载的外部资源。对于异步操作,游戏引擎必须停止并等待加载过程的完成,然后才能初始化游戏。这是因为游戏初始化可能需要加载的资源。

在循环模块中协调客户端负载和引擎等待

由于所有的资源加载和存储都基于同一个resource_map,客户端发出加载请求和引擎等待加载完成可以在loop.start()函数中协调如下:

async function start(scene) {
    if (mLoopRunning) {
        throw new Error("loop already running")
    }
    mCurrentScene = scene;
    mCurrentScene.load();

    // Wait for any async requests before game-load
    await map.waitOnPromises();

    mCurrentScene.init();
    mPrevTime = performance.now();
    mLagTime = 0.0;
    mLoopRunning = true;
    mFrameID = requestAnimationFrame(loopOnce);
}

注意,这个函数与上一个项目正好有两行不同— mCurrentScene被赋予一个参数的引用,在引擎等待所有异步加载操作完成之前,调用客户端的load()函数。

为客户端派生一个公共接口

虽然有点复杂,但是 XML 解析的细节没有现在可以加载 XML 文件的事实重要。现在可以使用外部资源的异步加载来检查将游戏级别连接到游戏引擎所需的公共方法。

MyGame 的公共方法

虽然游戏引擎被设计来促进游戏的构建,但是游戏的实际状态是特定于每个单独的客户端的。一般来说,引擎无法预测初始化、更新或绘制任何特定游戏所需的操作。因此,这些操作被定义为游戏引擎和客户端之间的公共接口的一部分。此时,确定MyGame应定义以下内容:

  • constructor():用于声明变量和定义常量。

  • init():用于实例化变量和设置游戏场景。这是在游戏循环第一次迭代之前从loop.start()函数调用的。

  • draw()/update():用于连接游戏循环,这两个函数在loop.loopOnce()函数中从游戏循环的核心连续调用。

根据加载场景文件或任何外部资源的要求,应该定义两个额外的公共方法:

  • load():启动外部资源的异步加载,这里是场景文件。这是在引擎等待所有异步加载操作完成之前从loop.start()函数调用的。

  • unload():游戏结束时卸载外部资源。目前,引擎不会尝试释放资源。这将在下一个项目中得到纠正。

实现客户端

现在,您已经准备好创建一个 XML 编码的场景文件来测试客户端加载的外部资源,并基于所描述的公共方法通过游戏引擎与客户端进行交互。

定义场景文件

定义一个简单的场景文件来捕捉上一个项目中的游戏状态:

  1. 在与src文件夹相同的级别创建一个新文件夹,并将其命名为assets。这是一个文件夹,游戏的所有外部资源或资产都将存储在其中,包括场景文件、音频剪辑、纹理图像和字体。

Tip

区分为组织游戏引擎源代码文件而创建的src/engine/resources文件夹和为存储客户端资源而创建的assets文件夹是很重要的。虽然 GLSL 着色器也在运行时加载,但它们被视为源代码,并将继续存储在src/glsl_shaders文件夹中。

  1. assets文件夹中创建一个新文件,并将其命名为scene.xml。这个文件将存储客户端的游戏场景。增加以下内容。列出的 XML 内容描述了与前面的MyGame类的init()函数中定义的场景相同的场景。
<MyGameLevel>

<!--  *** be careful!! comma (,) is not a supported syntax!!  -->
<!--  make sure there are no comma in between attributes -->
<!--  e.g., do NOT do:  PosX="20", PosY="30" -->
<!--  notice the "comma" between PosX and PosY: Syntax error! -->

    <!-- cameras -->
    <!-- Viewport: x, y, w, h -->
    <Camera CenterX="20" CenterY="60" Width="20"
            Viewport="20 40 600 300"
            BgColor="0.8 0.8 0.8 1.0"
    />

    <!-- Squares Rotation is in degree -->
    <Square PosX="20" PosY="60" Width="5" Height="5"
            Rotation="30" Color="1 1 1 1" />
    <Square PosX="20" PosY="60" Width="2" Height="2"
            Rotation="0"  Color="1 0 0 1" />
</MyGameLevel>

Tip

JavaScript XML 解析器不支持用逗号分隔属性。

解析场景文件

必须为列出的 XML 场景文件定义一个特定的解析器来提取场景信息。由于场景文件是特定于游戏的,所以解析器也应该是特定于游戏的,并在my_game文件夹中创建。

  1. src/my_game文件夹中新建一个文件夹,命名为util。在util文件夹中添加一个新文件,命名为scene_file_parser.js。该文件将包含特定的解析逻辑来解码列出的场景文件。

  2. 定义一个新类,将其命名为SceneFileParser,并添加一个构造函数,代码如下:

import engine from "../../engine/index.js";

class SceneFileParser {
    constructor (xml) {
        this.xml = xml
    }
    ... implementation to follow ...
}

注意,xml参数是加载的 XML 文件的实际内容。

Note

下面的 XML 解析基于 JavaScript XML API。更多详情请参考 https://www.w3schools.com/xml

  1. SceneFileParser添加一个函数,从您创建的xml文件中解析Camera的细节:
parseCamera() {
    let camElm = getElm(this.xml, "Camera");
    let cx = Number(camElm[0].getAttribute("CenterX"));
    let cy = Number(camElm[0].getAttribute("CenterY"));
    let w = Number(camElm[0].getAttribute("Width"));
    let viewport = camElm[0].getAttribute("Viewport").split(" ");
    let bgColor = camElm[0].getAttribute("BgColor").split(" ");
    // make sure viewport and color are number
    let j;
    for (j = 0; j < 4; j++) {
        bgColor[j] = Number(bgColor[j]);
        viewport[j] = Number(viewport[j]);
    }

    let cam = new engine.Camera(
        vec2.fromValues(cx, cy),  // position of the camera
        w,                        // width of camera
        viewport                  // viewport (orgX, orgY, width, height)
        );
    cam.setBackgroundColor(bgColor);
    return cam;
}

相机解析器找到一个相机元素,并用检索到的信息构建一个Camera对象。请注意,视窗和背景颜色是由四个数字组成的数组。这些输入是由空格分隔的四个数字的字符串。字符串可以拆分成数组,这里使用空格分隔符就是这种情况。JavaScript Number()函数确保所有的字符串都被转换成数字。

  1. SceneFileParser添加一个函数,从您创建的xml文件中解析方块的细节:
parseSquares(sqSet) {
    let elm = getElm(this.xml, "Square");
    let i, j, x, y, w, h, r, c, sq;
    for (i = 0; i < elm.length; i++) {
       x = Number(elm.item(i).attributes.getNamedItem("PosX").value);
       y = Number(elm.item(i).attributes.getNamedItem("PosY").value);
       w = Number(elm.item(i).attributes.getNamedItem("Width").value);
       h = Number(elm.item(i).attributes.getNamedItem("Height").value);
       r = Number(elm.item(i).attributes.getNamedItem("Rotation").value);
       c = elm.item(i).attributes.getNamedItem("Color").value.split(" ");
       sq = new engine.Renderable();
       // make sure color array contains numbers
       for (j = 0; j < 4; j++) {
           c[j] = Number(c[j]);
       }
       sq.setColor(c);
       sq.getXform().setPosition(x, y);
       sq.getXform().setRotationInDegree(r); // In Degree
       sq.getXform().setSize(w, h);
       sqSet.push(sq);
   }
}

该函数解析 XML 文件以创建Renderable对象,这些对象将被放入作为参数传入的数组中。

  1. SceneFileParser之外添加一个函数来解析 XML 元素的内容:

  2. 最后,导出SceneFileParser:

function getElm(xmlContent, tagElm) {
    let theElm = xmlContent.getElementsByTagName(tagElm);
    if (theElm.length === 0) {
        console.error("Warning: Level element:[" +
                      tagElm + "]: is not found!");
    }
    return theElm;
}
export default SceneFileParser;
实现我的游戏

本项目描述的公共函数的实现如下:

  1. 编辑my_game.js文件并导入SceneFileParser:

  2. 修改MyGame构造函数来定义场景文件路径、存储Renderable对象的数组mSqSetcamera:

import SceneFileParser from "./util/scene_file_parser.js";
  1. 更改init()函数以创建基于场景解析器的对象。注意通过engine.xml.get()函数检索 XML 文件内容,其中场景文件的文件路径被用作关键字。
constructor() {
    // scene file name
    this.mSceneFile = "assets/scene.xml";
    // all squares
    this.mSqSet = [];        // these are the Renderable objects

    // The camera to view the scene
    this.mCamera = null;
}
  1. 除了引用相应的数组元素之外,draw 和 update 函数与前面的示例类似。
init() {
    let sceneParser = new SceneFileParser(
                          engine.xml.get(this.mSceneFile));

    // Step A: Read in the camera
    this.mCamera = sceneParser.parseCamera();

    // Step B: Read all the squares
    sceneParser.parseSquares(this.mSqSet);
}
  1. 最后,定义加载和卸载场景文件的函数。
draw() {
    // Step A: clear the canvas
    engine.clearCanvas([0.9, 0.9, 0.9, 1.0]);

    this.mCamera.setViewAndCameraMatrix();
    // Step B: draw all the squares
    let i;
    for (i = 0; i < this.mSqSet.length; i++)
        this.mSqSet[i].draw(this.mCamera);
}
update() {
    // simple game: move the white square and pulse the red
    let xform = this.mSqSet[0].getXform();
    let deltaX = 0.05;

    // Step A: test for white square movement
    ... identical to previous code ...
    xform = this.mSqSet[1].getXform();
    // Step C: test for pulsing the red square
    ... identical to previous code ...
}
load() {
    engine.xml.load(this.mSceneFile);
}

unload() {
    // unload the scene file and loaded resources
    engine.xml.unload(this.mSceneFile);
}

您现在可以运行该项目,并看到它的行为与前两个项目相同。虽然这可能看起来不有趣,但通过这个项目,引擎和客户端之间的简单且定义良好的接口已经被派生出来,其中隐藏了每一个的复杂性和细节。基于这个接口,可以引入额外的引擎功能,而不需要修改任何现有的客户端,同时,可以独立于引擎内部来创建和维护复杂的游戏。这个接口的细节将在下一个项目中介绍。

在继续之前,您可能会注意到从未调用过MyGame.unload()函数。这是因为在这个例子中,游戏循环从未停止循环,并且MyGame从未被卸载。这个问题将在下一个项目中解决。

场景对象:游戏引擎的客户端界面

此时,在您的游戏引擎中,会发生以下情况:

  • window.onload函数初始化游戏引擎并调用loop.start()函数,将MyGame作为参数传入。

  • loop.start()函数通过resource_map等待所有异步加载操作完成后,调用初始化MyGame,开始实际的游戏循环周期。

从这个讨论中,有趣的是认识到任何具有适当定义的公共方法的对象都可以替换MyGame对象。实际上,在任何时候,都可以调用loop.start()函数来启动新场景的加载。本节通过介绍用于游戏引擎和客户端接口的Scene对象来扩展这个想法。

场景对象项目

这个项目将Scene定义为一个抽象超类,用于与你的游戏引擎接口。从这个项目开始,所有的客户端代码都必须封装在抽象的Scene类的子类中,游戏引擎将能够以一致和定义良好的方式与这些类进行交互。你可以在图 4-5 中看到这个项目运行的例子。这个项目的源代码在chapter4/4.5.scene_objects文件夹中定义。

img/334805_2_En_4_Fig5_HTML.jpg

图 4-5

使用两个场景运行场景对象项目

在这个项目中有两个不同的级别:MyGame级别,在灰色背景上的红色正方形上方绘制蓝色矩形,以及BlueLevel级别,在深蓝色背景上的旋转白色正方形上方绘制红色矩形。为简单起见,两个级别的控件是相同的。

  • 左/右箭头键:左右移动前矩形

  • Q 键T2:退出游戏

请注意,在每个级别上,向左移动前面的矩形以接触左边界将导致另一个级别的加载。MyGame电平将导致BlueLevel被加载,BlueLevel将导致MyGame电平被加载。

该项目的目标如下:

  • 定义抽象的Scene类来连接游戏引擎

  • 体验游戏引擎对场景转换的支持

  • 创建场景特定的加载和卸载支持

抽象场景类

根据之前项目的经验,一个用于封装游戏引擎接口的抽象Scene类必须至少定义这些功能:init()draw()update()load()unload()。这个列表中缺少的是对等级转换到start,升级到next等级的支持,如果需要的话,还有对stop游戏的支持。

  1. src/engine文件夹中新建一个 JavaScript 文件,命名为scene.js,从loop模块和引擎访问文件index.js中导入。这两个模块是必需的,因为Scene对象必须在游戏关卡开始和结束时开始和结束游戏循环,如果一个关卡应该决定终止游戏,则必须清理引擎。
import  * as loop from "./core/loop.js";
import engine from "./index.js";

Note

Scene开始之前,游戏循环不得运行。这是因为在从正在运行的游戏循环中调用Sceneupdate()函数之前,必须正确加载所需的资源。类似地,只有在游戏循环停止后才能卸载关卡。

  1. 定义 JavaScript Error对象,用于在误用的情况下警告客户端:

  2. 创建一个名为Scene的新类并导出它:

const kAbstractClassError = new Error("Abstract Class")
const kAbstractMethodError = new Error("Abstract Method")
  1. 实现构造函数以确保只有Scene类的子类被实例化:
class Scene { ... implementation to follow ... }
export default Scene;
  1. 定义场景转换功能:start()next()stop()start()函数是一个异步函数,因为它负责启动游戏循环,然后等待所有异步加载完成。next()stop()函数都停止游戏循环,并调用unload()函数卸载加载的资源。不同之处在于next()函数将被覆盖,并从子类中调用,在卸载当前场景后,子类可以继续前进到下一个级别。卸载后,stop()功能假定游戏已经终止,并继续清理游戏引擎。
constructor() {
    if (this.constructor === Scene) {
        throw kAbstractClassError
    }
}
  1. 定义其余的派生接口函数。注意,Scene类是一个抽象类,因为所有的接口函数都是空的。虽然子类可以选择只实现接口函数的一个选择性子集,但是draw()update()函数不是可选的,因为它们一起构成了一个级别的核心。
async start() {
    await loop.start(this);
}

next() {
    loop.stop();
    this.unload();
}

stop() {
    loop.stop();
    this.unload();
    engine.cleanUp();
}
init() { /* to initialize the level (called from loop.start()) */ }
load() { /* to load necessary resources */ }
unload() { /* unload all resources */ }
// draw/update must be over-written by subclass
draw() { throw kAbstractMethodError; }
update() { throw kAbstractMethodError; }

这些功能共同提供了一个与游戏引擎交互的协议。预计子类将覆盖这些函数来实现实际的游戏行为。

Note

JavaScript 不支持抽象类。该语言不阻止游戏程序员实例化一个Scene对象;但是,创建的实例将完全没有用,错误消息将向它们提供适当的警告。

修改游戏引擎以支持场景类

游戏引擎必须在两个重要方面进行修改。首先,必须修改游戏引擎访问文件index.js,以便将新引入的符号导出到客户端,就像所有新功能一样。其次,Scene.stop()函数引入了停止游戏的可能性,并处理所需的清理和资源释放。

将场景类导出到客户端

编辑从scene.js导入的index.js文件,并为客户端导出Scene:

... identical to previous code ...
import Scene from "./scene.js";
... identical to previous code ...
export default {
    ... identical to previous code ...
    Camera, Scene, Transform, Renderable,
    ... identical to previous code ...
}
实现引擎清理支持

当游戏引擎关闭时,释放分配的资源是很重要的。清理过程相当复杂,并且与系统组件初始化的顺序相反。

  1. 再次编辑 index.js ,这次是为了实现对游戏引擎清理的支持。从loop模块导入,然后定义并导出cleanup()函数。
... identical to previous code ...
import * as loop from "./core/loop.js";
... identical to previous code ...
function cleanUp() {
    loop.cleanUp();
    input.cleanUp();
    shaderResources.cleanUp();
    vertexBuffer.cleanUp();
    glSys.cleanUp();
}
... identical to previous code ...
export default {
    ... identical to previous code ...
    init, cleanUp, clearCanvas
    ... identical to previous code ...
}

Note

类似于其他核心引擎内部组件,如glvertex_buffer , loop不应该被客户端访问。为此,loop模块由index.js导入而非导出,导入使得游戏循环清理可被调用,而非导出,使得客户端可被屏蔽于引擎内不相关的复杂性。

请注意,没有一个组件定义了它们相应的清理函数。你现在可以补救了。在以下每种情况下,一定要记得在适当的时候导出新定义的cleanup()函数。

  1. 编辑loop.js以定义并导出一个cleanUp()函数来停止游戏循环并卸载当前活动场景:

  2. 编辑input.js定义并导出一个cleanUp()函数。目前,没有具体的资源需要释放。

... identical to previous code ...
function cleanUp() {
    if (mLoopRunning) {
        stop();
        // unload all resources
        mCurrentScene.unload();
        mCurrentScene = null;
    }
}
export {start, stop, cleanUp}
  1. 编辑shader_resources.js以定义并导出一个cleanUp()函数来清理创建的着色器并卸载其源代码:
... identical to previous code ...
function cleanUp() {}  // nothing to do for now
export {keys, init, cleanUp,
... identical to previous code ...
  1. 编辑simple_shader.jsSimpleShader类定义cleanUp()函数来释放分配的 WebGL 资源:
... identical to previous code ...
function cleanUp() {
    mConstColorShader.cleanUp();
    text.unload(kSimpleVS);
    text.unload(kSimpleFS);
}
export {init, cleanUp, getConstColorShader}
  1. 编辑vertex_buffer.js以定义并导出一个cleanUp()函数来删除分配的缓冲存储器:
cleanUp() {
    let gl = glSys.get();
    gl.detachShader(this.mCompiledShader, this.mVertexShader);
    gl.detachShader(this.mCompiledShader, this.mFragmentShader);
    gl.deleteShader(this.mVertexShader);
    gl.deleteShader(this.mFragmentShader);
    gl.deleteProgram(this.mCompiledShader);
}
  1. 最后,编辑gl.js来定义并导出一个cleanUp()函数来通知玩家引擎现在已经关闭:
... identical to previous code ...
function cleanUp() {
    if (mGLVertexBuffer !== null) {
        glSys.get().deleteBuffer(mGLVertexBuffer);
        mGLVertexBuffer = null;
    }
}
export {init, get, cleanUp}
... identical to previous code ...
function cleanUp() {
    if ((mGL == null) || (mCanvas == null))
        throw new Error("Engine cleanup: system is not initialized.");
    mGL = null;
    // let the user know
    mCanvas.style.position = "fixed";
    mCanvas.style.backgroundColor = "rgba(200, 200, 200, 0.5)";
    mCanvas = null;
    document.body.innerHTML +=
             "<br><br><h1>End of Game</h1><h1>GL System Shut Down</h1>";
}
export {init, get, cleanUp}

测试游戏引擎的场景类接口

通过抽象的Scene类定义和对游戏引擎核心组件的资源管理修改,现在可以随意停止现有场景和加载新场景。本节在场景类的两个子类MyGameBlueLevel之间循环,以说明场景的加载和卸载。

为了简单起见,这两个测试场景与上一个项目中的MyGame场景几乎相同。在这个项目中,MyGameinit()函数中显式定义场景,而BlueScene以与上一个项目相同的方式,从位于assets文件夹中的blue_level.xml文件中加载场景内容。XML 场景文件的内容和解析与前一个项目中的内容和解析相同,因此不再重复。

我的游戏场景

如上所述,这个场景在init()函数中定义了与前一个项目的场景文件中相同的内容。在下一节中,请注意对next()stop()函数的定义和调用。

  1. 编辑my_game.jsindex.js和新定义的blue_level.js导入。注意,有了Scene类的支持,您不再需要从loop模块导入。

  2. MyGame定义为引擎Scene类的子类,记住要导出MyGame:

import engine from "../engine/index.js";
import BlueLevel from "./blue_level.js";
class MyGame extends engine.Scene {
    ... implementation to follow ...
}
export default MyGame;

Note

JavaScript extends关键字定义了父/子关系。

  1. 定义constructor()init()draw()功能。请注意,在init()功能中定义的场景内容,除了摄像机背景颜色之外,与之前的项目相同。

  2. 定义update()功能;注意当mHero对象从右边越过x=11边界时的this.next()调用,以及当 Q 键被按下时的this.stop()调用。

constructor() {
    super();
    // The camera to view the scene
    this.mCamera = null;

    // the hero and the support objects
    this.mHero = null;
    this.mSupport = null;
}

init() {
    // Step A: set up the cameras
    this.mCamera = new engine.Camera(
       vec2.fromValues(20, 60),   // position of the camera
       20,                        // width of camera
       [20, 40, 600, 300]         // viewport (orgX, orgY, width, height)
    );
    this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);

    // Step B: Create the support object in red

    this.mSupport = new engine.Renderable();
    this.mSupport.setColor([0.8, 0.2, 0.2, 1]);
    this.mSupport.getXform().setPosition(20, 60);
    this.mSupport.getXform().setSize(5, 5);

    // Step C: Create the hero object in blue
    this.mHero = new engine.Renderable();
    this.mHero.setColor([0, 0, 1, 1]);
    this.mHero.getXform().setPosition(20, 60);
    this.mHero.getXform().setSize(2, 3);
}

draw() {
    // Step A: clear the canvas
    engine.clearCanvas([0.9, 0.9, 0.9, 1.0]);
    // Step  B: Activate the drawing Camera
    this.mCamera.setViewAndCameraMatrix();

    // Step  C: draw everything
    this.mSupport.draw(this.mCamera);
    this.mHero.draw(this.mCamera);
}
  1. 定义next()函数过渡到BlueLevel场景:
update() {

    // let's only allow the movement of hero,
    // and if hero moves too far off, this level ends, we will
    // load the next level
    let deltaX = 0.05;
    let xform = this.mHero.getXform();

    // Support hero movements
    if (engine.input.isKeyPressed(engine.input.keys.Right)) {
        xform.incXPosBy(deltaX);
        if (xform.getXPos() > 30) { // right-bound of the window
            xform.setPosition(12, 60);
        }
    }

    if (engine.input.isKeyPressed(engine.input.keys.Left)) {
        xform.incXPosBy(-deltaX);
        if (xform.getXPos() < 11) {  // left-bound of the window
            this.next();
        }
    }

    if (engine.input.isKeyPressed(engine.input.keys.Q))
        this.stop();  // Quit the game
}
next() {
    super.next();  // this must be called!

    // next scene to run
    let nextLevel = new BlueLevel();  // next level to be loaded
    nextLevel.start();
}

Note

这个super.next()调用,其中超类可以停止游戏循环并导致这个场景的卸载,在导致场景转换中是必要的并且绝对关键的。

  1. 最后,修改window.onload()函数,用一个客户端友好的myGame.start()函数替换对loop模块的访问:
window.onload = function () {
    engine.init("GLCanvas");

    let myGame = new MyGame();
    myGame.start();
}
蓝色场景

除了支持新的场景类和场景转换之外,BlueLevel场景与上一个项目中的MyGame对象几乎相同:

  1. my_game文件夹下创建并编辑blue_level.js文件,从index.jsMyGameSceneFileParser引擎导入。将BlueLevel定义并导出为engine.Scene类的子类。

  2. 定义init()draw()load()unload()函数,使其与前一个项目的MyGame类中的函数相同。

  3. 定义类似于MyGame场景的update()功能。再一次,注意当对象从右边越过x=11边界时的this.next()调用和当 Q 键被按下时的this.stop()调用。

// Engine Core stuff
import engine from "../engine/index.js";

// Local stuff
import MyGame from "./my_game.js";
import SceneFileParser from "./util/scene_file_parser.js";

class BlueLevel extends engine.Scene {
    ... implementation to follow ...
}
export default BlueLevel
  1. 最后,定义next()函数来过渡到MyGame场景。值得重申的是,调用super.next()是必要的,因为在进入下一个场景之前,停止游戏循环并卸载当前场景是至关重要的。
update() {
    // For this very simple game, let's move the first square
    let xform = this.mSQSet[1].getXform();
    let deltaX = 0.05;

    /// Move right and swap over
    if (engine.input.isKeyPressed(engine.input.keys.Right)) {
        xform.incXPosBy(deltaX);
        if (xform.getXPos() > 30) { // right-bound of the window
            xform.setPosition(12, 60);
        }
    }

    // test for white square movement
    if (engine.input.isKeyPressed(engine.input.keys.Left)) {
        xform.incXPosBy(-deltaX);
        if (xform.getXPos() < 11) { // this is the left-boundary
            this.next(); // go back to my game
        }
    }

    if (engine.input.isKeyPressed(engine.input.keys.Q))
        this.stop();  // Quit the game
}
next() {
    super.next();
    let nextLevel = new MyGame();  // load the next level
    nextLevel.start();
}

现在,您可以运行项目,查看卸载和装载场景,并在互动过程中随时退出游戏。您的游戏引擎现在有了一个定义良好的界面来与它的客户端协同工作。这个接口遵循定义良好的Scene类协议。

  • constructor():用于声明变量和定义常量。

  • start() / stop():用于开始一个场景和停止游戏。这两个方法并不意味着被子类覆盖。

下面的接口方法应该被子类覆盖。

  • init():用于实例化变量和设置游戏场景。

  • load() / unload():用于发起外部资源的异步加载和卸载。

  • draw() / update():连续显示游戏状态,接收玩家输入,执行游戏逻辑。

  • next():用于实例化和过渡到下一个场景。最后,作为最后的提醒,子类调用super.next()来停止游戏循环并卸载场景是绝对关键的。

任何定义这些方法的对象都可以被你的游戏引擎加载并与之交互。您可以尝试创建其他级别。

声音的

音频是所有视频游戏的基本元素。一般来说,游戏中的音效分为两类。第一类是背景音频。这包括背景音乐或环境效果,通常用于给游戏的不同部分带来气氛或情绪。第二类是音效。音效对各种用途都很有用,从通知用户游戏动作到听到你的英雄人物的脚步声。通常,音效代表一个特定的动作,由用户或游戏本身触发。这种声音效果通常被认为是音频提示。

这两种音频的一个重要区别是你如何控制它们。声音效果或提示一旦开始就不能停止或调整其音量;因此,线索一般都很短。另一方面,背景音频可以随意启动和停止。这些功能对于完全停止背景轨道并开始另一个轨道非常有用。

音频支持项目

这个项目有与前一个项目相同的MyGameBlueLevel场景。你可以用箭头键向左或向右移动前面的矩形,与左边界的交点触发另一个场景的加载,Q 键退出游戏。然而,在这个版本中,当按下左/右箭头键时,每个场景都会播放背景音乐并触发一个简短的音频提示。请注意,每种类型的音频剪辑的音量都不同。这个项目的实施也加强了加载和卸载外部资源和音频剪辑本身的概念。你可以在图 4-6 中看到这个项目运行的例子。这个项目的源代码在chapter4/4.6.audio_support文件夹中定义。

img/334805_2_En_4_Fig6_HTML.jpg

图 4-6

在两个场景中运行音频支持项目

该项目的控制措施如下:

  • 左/右箭头键:左右移动前面的矩形,增加或减少背景音乐的音量

  • Q 键T2:退出游戏

该项目的目标如下:

  • 向资源管理系统添加音频支持

  • 为游戏提供播放音频的界面

您可以在assets/sounds文件夹中找到以下音频文件:

  • bg_clip.mp3

  • blue_level_cue.wav

  • my_game_cue.wav

注意,音频文件有两种格式,mp3wav。虽然两者都受支持,但使用这些格式的音频文件时应小心。.mp3格式的文件经过压缩,适合存储更长时间的音频内容,例如背景音乐。.wav格式的文件是未压缩的,应该只包含非常短的音频片段,例如,用于存储提示效果。

定义一个音频资源模块

虽然音频和文本文件完全不同,但从游戏引擎实现的角度来看,有两个重要的相似之处。首先,两者都是外部资源,因此将被实现为类似于src/engine/resources文件夹中的引擎组件。第二,两者都涉及带有定义良好的 API 实用程序的标准化文件格式。网络音频 API 将用于声音文件的实际检索和播放。尽管这个 API 提供了巨大的能力,为了关注游戏引擎开发的其余部分,只讨论了对背景音频和效果提示的基本支持。

Note

感兴趣的读者可以从 www.w3.org/TR/webaudio/ 了解更多 Web 音频 API。

包括 Chrome 在内的一些浏览器的最新政策是,在用户第一次交互之前,不允许播放音频。这意味着上下文创建将导致 Chrome 发出一个初始警告,并输出到运行时浏览器控制台。音频将仅在用户输入(例如,鼠标点击或键盘事件)后播放。

  1. src/engine/resources文件夹中,创建一个新文件并将其命名为audio.js。这个文件将实现音频组件的模块。该组件必须支持两种类型的功能:加载和卸载音频文件以及为游戏开发者播放和控制音频文件的内容。

  2. 加载和卸载类似于textxml模块的实现,其中核心资源管理功能是从resource_map导入的:

  3. 定义解码和解析函数,调用resource_map loadDecodeParse()函数加载音频文件。注意,有了resource_map和引擎基础设施的支持,外部资源的加载和卸载变得简单了。

"use strict";

import * as map from "../core/resource_map.js";
// functions from resource_map
let unload = map.unload;
let has = map.has;
  1. 加载功能完成后,您现在可以定义音频控制和操作功能。声明变量来维护对 Web 音频上下文和背景音乐的引用,并控制音量。
function decodeResource(data) { return data.arrayBuffer(); }
function parseResource(data) {
    return mAudioContext.decodeAudioData(data); }
function load(path) {
    return map.loadDecodeParse(path, decodeResource, parseResource);
}
  1. 定义init()函数,在mAudioContext中创建并存储对 Web 音频上下文的引用,并为影响两者的背景、提示和母版初始化音量增益控制。在所有情况下,音量增益 0 对应于没有音频,1 表示最大音量。
let mAudioContext = null;
let mBackgroundAudio = null;

// volume control support
let mBackgroundGain = null; // background volume
let mCueGain = null;        // cue/special effects volume
let mMasterGain = null;     // overall/master volume

let kDefaultInitGain = 0.1;
  1. 定义playCue()功能,通过适当的音量控制播放音频剪辑的整个持续时间。这个函数使用音频文件路径作为资源名,从resource_map中找到加载的资产,然后调用 Web 音频 API 来播放音频剪辑。注意,没有保存对source变量的引用,因此一旦开始,就没有办法停止相应的音频剪辑。一个游戏应该调用这个函数来播放音频剪辑的小片段作为提示。
function init() {
    try {
        let AudioContext = window.AudioContext ||
                           window.webkitAudioContext;
        mAudioContext = new AudioContext();

        // connect Master volume control
        mMasterGain = mAudioContext.createGain();
        mMasterGain.connect(mAudioContext.destination);
        // set default Master volume
        mMasterGain.gain.value = kDefaultInitGain;

        // connect Background volume control
        mBackgroundGain = mAudioContext.createGain();
        mBackgroundGain.connect(mMasterGain);
        // set default Background volume
        mBackgroundGain.gain.value = 1.0;

        // connect Cuevolume control
        mCueGain = mAudioContext.createGain();
        mCueGain.connect(mMasterGain);
        // set default Cue volume
        mCueGain.gain.value = 1.0;
    } catch (e) {
        throw new Error("...");
    }
}
  1. 定义播放、停止、查询和控制背景音乐音量的功能。在这种情况下,mBackgroundAudio变量保持对当前播放音频的引用,因此,可以停止剪辑或改变其音量。
function playCue(path, volume) {
    let source = mAudioContext.createBufferSource();
    source.buffer = map.get(path);
    source.start(0);

    // volume support for cue
    source.connect(mCueGain);
    mCueGain.gain.value = volume;
}
  1. 定义控制主音量的功能,主音量可调节提示和背景音乐的音量:
function playBackground(path, volume) {
    if (has(path)) {
        stopBackground();
        mBackgroundAudio = mAudioContext.createBufferSource();
        mBackgroundAudio.buffer = map.get(path);
        mBackgroundAudio.loop = true;
        mBackgroundAudio.start(0);

         // connect volume accordingly
         mBackgroundAudio.connect(mBackgroundGain);
         setBackgroundVolume(volume);
    }
}

function stopBackground() {
    if (mBackgroundAudio !== null) {
        mBackgroundAudio.stop(0);
        mBackgroundAudio = null;
    }
}

function isBackgroundPlaying() {
    return (mBackgroundAudio !== null);
}

function setBackgroundVolume(volume) {
    if (mBackgroundGain !== null) {
        mBackgroundGain.gain.value = volume;
    }
}

function  incBackgroundVolume(increment) {
    if (mBackgroundGain !== null) {
        mBackgroundGain.gain.value += increment;

        // need this since volume increases when negative
        if (mBackgroundGain.gain.value < 0) {
            setBackgroundVolume(0);
        }
    }
}
  1. 定义一个cleanUp()函数来释放分配的 HTML5 资源:
function  setMasterVolume(volume) {
    if (mMasterGain !== null) {
        mMasterGain.gain.value = volume;
    }
}

function  incMasterVolume(increment) {
    if (mMasterGain !== null) {
        mMasterGain.gain.value += increment;

        // need this since volume increases when negative
        if (mMasterGain.gain.value < 0) {
            mMasterGain.gain.value = 0;
        }
    }
}
  1. 记住export该模块的功能:
function cleanUp() {
    mAudioContext.close();
    mAudioContext = null;
}
export {init, cleanUp,
      has, load, unload,

      playCue,

      playBackground, stopBackground, isBackgroundPlaying,
      setBackgroundVolume, incBackgroundVolume,

      setMasterVolume, incMasterVolume
}

将音频模块导出到客户端

编辑从audio.js导入的index.js文件,相应地初始化和清理模块,并导出到客户端:

... identical to previous code ...
import * as audio from "./resources/audio.js";
... identical to previous code ...
function init(htmlCanvasID) {
    glSys.init(htmlCanvasID);
    vertexBuffer.init();
    shaderResources.init();
    input.init();
    audio.init();
}

function cleanUp() {
    loop.cleanUp();
    audio.cleanUp();
    input.cleanUp();
    shaderResources.cleanUp();
    vertexBuffer.cleanUp();
    glSys.cleanUp();
}
... identical to previous code ...
export default {
    // resource support
    audio, text, xml
    ... identical to previous code ...
}

测试音频组件

要测试音频组件,您必须将必要的音频文件复制到游戏项目中。在assets文件夹中新建一个文件夹,命名为sounds。将bg_clip.mp3blue_level_cue.wavmy_game_cue.wav文件复制到sounds文件夹中。您现在需要更新MyGameBlueLevel实现来加载和使用这些音频资源。

改变我的游戏. js

更新MyGame场景以加载音频剪辑,播放背景音频,并在箭头键被按下时提示玩家:

  1. 在构造函数中声明音频文件的常量文件路径。回想一下,这些文件路径被用作加载、存储和检索的资源名称。将这些声明为常量以备后用是一个好的软件工程实践。

  2. load()函数中请求加载音频剪辑,并确保定义了相应的unload()函数。请注意,卸载背景音乐之前会停止播放音乐。通常,资源的操作必须在卸载前停止。

constructor() {
    super();

    // audio clips: supports both mp3 and wav formats
    this.mBackgroundAudio = "assets/sounds/bg_clip.mp3";
    this.mCue = "assets/sounds/my_game_cue.wav";
    ... identical to previous code ...
}
  1. init()功能结束时启动背景音频。
load() {
    // loads the audios
    engine.audio.load(this.mBackgroundAudio);
    engine.audio.load(this.mCue);
}

unload() {
    // Step A: Game loop not running, unload all assets
    // stop the background audio
    engine.audio.stopBackground();

    // unload the scene resources
    engine.audio.unload(this.mBackgroundAudio);
    engine.audio.unload(this.mCue);
}
  1. update()功能中,当左右箭头键被按下时提示玩家,并增大和减小背景音乐的音量:
init() {
    ... identical to previous code ...

    // now start the Background music ...
    engine.audio.playBackground(this.mBackgroundAudio, 1.0);
}
update() {
    ... identical to previous code ...
    // Support hero movements
    if (engine.input.isKeyPressed(engine.input.keys.Right)) {
        engine.audio.playCue(this.mCue, 0.5);
        engine.audio.incBackgroundVolume(0.05);
        xform.incXPosBy(deltaX);
        if (xform.getXPos() > 30) { // right-bound of the window
            xform.setPosition(12, 60);
        }
    }

    if (engine.input.isKeyPressed(engine.input.keys.Left)) {
        engine.audio.playCue(this.mCue, 1.5);
        engine.audio.incBackgroundVolume(-0.05);
        xform.incXPosBy(-deltaX);
        if (xform.getXPos() < 11) {  // left-bound of the window
            this.next();
        }
    }
    ... identical to previous code ...
}
更改 BlueLevel.js

BlueLevel场景的改变与对MyGame场景的改变相似,但是具有不同的音频提示:

  1. BlueLevel构造函数中,向音频资源添加以下路径名:

  2. 修改音频剪辑的load()unload()功能:

constructor() {
    super();

    // audio clips: supports both mp3 and wav formats
    this.mBackgroundAudio = "assets/sounds/bg_clip.mp3";
    this.mCue = "assets/sounds/blue_level_cue.wav";
    ... identical to previous code ...
}
  1. MyGame一样,在init()功能中启动背景音频,在update()功能中按下左右键时提示玩家。请注意,在这种情况下,音频提示以不同的音量设置播放。
load() {
    engine.xml.load(this.mSceneFile);
    engine.audio.load(this.mBackgroundAudio);
    engine.audio.load(this.mCue);
}

unload() {
    // stop the background audio
    engine.audio.stopBackground();

    // unload the scene file and loaded resources
    engine.xml.unload(this.mSceneFile);
    engine.audio.unload(this.mBackgroundAudio);
    engine.audio.unload(this.mCue);
}
init() {
    ... identical to previous code ...

    // now start the Background music ...
    engine.audio.playBackground(this.mBackgroundAudio, 0.5);
}

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

    // Move right and swap over
    if (engine.input.isKeyPressed(engine.input.keys.Right)) {
        engine.audio.playCue(this.mCue, 0.5);
        xform.incXPosBy(deltaX);
        if (xform.getXPos() > 30) { // right-bound of the window
            xform.setPosition(12, 60);
        }
    }

    // Step A: test for white square movement
    if (engine.input.isKeyPressed(engine.input.keys.Left)) {
        engine.audio.playCue(this.mCue, 1.0);
        xform.incXPosBy(-deltaX);
        if (xform.getXPos() < 11) { // this is the left-boundary
            this.next(); // go back to my game
        }
    }
    ... identical to previous code ...
}

你现在可以运行项目,并听取美妙的音频反馈。如果你按住箭头键,会有许多线索反复播放。事实上,有太多的线索回响,以至于声音效果被模糊成一个恼人的爆炸。这是一个很好的例子,说明了小心使用音频提示并确保每个单独的提示又好又短的重要性。你可以尝试点击箭头键来听更清晰、更悦耳的提示,或者你可以简单地用isKeyClicked()功能替换isKeyPressed()功能,然后听每个单独的提示。

摘要

在这一章中,你学习了游戏引擎的几个常见组件是如何组合在一起的。从非常重要的游戏循环开始,您了解了它如何实现输入、更新和绘制模式,以便超越人类的感知或欺骗我们的感官,让我们相信系统是连续的、实时运行的。这种模式是任何游戏引擎的核心。您了解了如何灵活和可重用地实现完整的键盘支持,从而为引擎提供可靠的输入组件。此外,您还看到了如何实现资源管理器来异步加载文件,以及如何抽象场景以支持从文件加载场景,这可以大大减少代码中的重复。最后,您了解了音频支持如何为客户端提供一个接口来加载和播放环境背景音频和音频提示。

这些组件单独来看几乎没有共同点,但合在一起构成了几乎所有游戏的核心基础。当您将这些核心组件实现到游戏引擎中时,用该引擎创建的游戏将无需担心每个组件的细节。相反,游戏程序员可以专注于利用功能来加速和简化开发过程。在下一章,你将学习如何用外部图像创建动画的幻觉。

游戏设计注意事项

在这一章中,我们讨论了游戏循环以及有助于玩家行为和游戏反应之间联系的技术基础。例如,如果玩家选择了屏幕上绘制的一个方块,并使用箭头键将其从位置 A 移动到位置 B,您通常会希望该动作在箭头键被按下时立即开始平滑运动,没有口吃、延迟或明显的滞后。游戏循环对游戏设计中所谓的存在做出了重大贡献;存在感是玩家感觉自己与游戏世界联系在一起的能力,而响应能力在让玩家感觉联系在一起方面起着关键作用。当现实世界中的动作(如按下箭头键)无缝地转化为游戏世界中的反应(如移动物体、翻转开关、跳跃等)时,临场感得到加强;当现实世界中的行动遭遇翻译错误(如延迟和滞后)时,临场感就会受到影响。

正如在第 1 章中提到的,有效的游戏机制设计可以从一些简单的元素开始。例如,当你完成本章中的键盘支持项目时,许多部分已经准备好开始构建游戏关卡:你已经为玩家提供了操纵屏幕上两个独立元素(红色和白色方块)的能力,剩下的就是使用这些元素设计一个因果链,当完成时会产生一个新的事件。想象一下键盘支持项目是你的游戏:你会如何利用现有的东西来创建一个因果链?你可能会选择玩方块之间的关系,也许需要将红色方块完全移动到白色方块内,以便解锁下一个挑战;一旦玩家成功地将红色方块放置在白色方块中,这一关就完成了。这种基本机制本身可能不足以创造一种引人入胜的体验,但通过包括游戏设计的其他八个元素(系统设计、设置、视觉设计、音乐和音频等)中的几个,就有可能将这种基本的交互转变为几乎无限种引人入胜的体验,并为玩家创造存在感。在接下来的章节中,你会在这些练习中加入更多的游戏设计元素。

资源地图和 着色器加载器项目、场景文件项目和场景对象项目旨在帮助您开始考虑从头开始构建游戏设计以获得最大效率,从而最大限度地减少诸如资产加载延迟等影响玩家存在感的问题。当你开始设计有多个阶段和关卡以及许多资源的游戏时,资源管理计划变得至关重要。了解可用内存的限制以及如何智能地加载和卸载资产可能意味着一次出色的体验和一次令人沮丧的体验之间的差异。

我们通过我们的感官体验世界,当我们加入额外的感官输入时,我们在游戏中的存在感往往会被放大。音频支持项目以恒定背景分数的形式将基本音频添加到我们从场景对象项目进行的简单状态改变练习中,以提供环境气氛,并包括两个区域中每个区域的不同运动声音。比较这两种体验,并考虑由于声音提示的存在,它们的感觉有多不同;虽然两者之间的视觉和交互体验是相同的,但由于背景音乐的节拍和矩形移动时产生的个体音调,音频支持项目开始添加一些情感线索。音频是互动体验的强大增强,可以极大地增加玩家在游戏环境中的存在感,当你继续阅读这些章节时,你将更详细地探索音频是如何为游戏设计做出贡献的。