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

137 阅读1小时+

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

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

协议:CC BY-NC-SA 4.0

十、使用粒子系统创建效果

完成本章后,您将能够

  • 理解粒子、粒子发射器和粒子系统的基础知识

  • 意识到许多有趣的物理效果可以基于专用粒子的集合来建模

  • 近似粒子的基本行为,使得这些粒子集合的再现类似于简单的爆炸效果

  • 实现一个简单的粒子系统,它与物理组件的RigidShape系统集成在一起

介绍

到目前为止,在您的游戏引擎中,假设游戏世界可以由一组几何图形来描述,其中所有对象都是具有纹理或动画精灵的Renderable实例,并且可能被光源照亮。这个游戏引擎非常强大,能够描述现实世界中的大部分物体。然而,对你的游戏引擎来说,描述许多日常遭遇也是具有挑战性的,例如,火花、火、爆炸、污垢、灰尘等等。这些观测中有许多是由改变物理状态的物质或对物理扰动作出反应的非常小的实体的集合产生的瞬时效应。总的来说,这些观察结果通常被称为特殊效果,通常不适合用带有纹理的固定形状的几何图形来表示。

粒子系统通过发射一组粒子来描述特殊效果,这些粒子的属性可能包括位置、大小、颜色、寿命和策略选择的纹理贴图。这些粒子定义了特定的行为,一旦发射,它们的属性就会更新以模拟物理效果。例如,发射的火粒子可能会向上移动并带有红色。随着时间的推移,粒子的大小可能会减小,向上运动的速度会变慢,颜色会变黄,并在一定次数的更新后最终消失。通过精心设计的更新功能,这种粒子集合的再现可以类似于燃烧的火焰。

在本章中,您将学习、设计和创建一个简单而灵活的粒子系统,该系统包括实现常见效果(如爆炸和魔法效果)所需的基本功能。此外,您将实现一个粒子着色器,以正确地将粒子集成到场景中。粒子会相应地与RigidShape物体发生碰撞和相互作用。您还将发现需要并定义粒子发射器,以在一段时间内生成粒子,如篝火或火炬。

本章的主要目标是理解粒子系统的基础:简单粒子的属性和行为,粒子发射器的细节,以及与游戏引擎其余部分的集成。这一章不会引导你创建任何特定类型的特效。这类似于在第八章中学习一个照明模型,没有创建任何灯光效果的细节。操纵光源参数和材料属性以创建引人入胜的照明条件,以及模拟特定物理效果的粒子行为是游戏开发人员的职责。游戏引擎的基本职责是定义足够的基本功能,以确保游戏开发者能够完成他们的工作。

粒子和粒子系统

粒子是一个没有维度的纹理位置。这种描述可能看起来矛盾,因为你已经知道纹理是一种图像,图像总是由宽度和高度定义,并且肯定会占用一个区域。重要的澄清是,游戏引擎逻辑将粒子处理为没有区域的位置,而绘图系统将粒子显示为具有适当尺寸的纹理。这样,即使显示了实际显示的区域,纹理的宽度和高度尺寸也会被底层逻辑忽略。

除了位置,粒子还具有大小(用于缩放纹理)、颜色(用于给纹理着色)和寿命等属性。与典型的游戏对象类似,每个粒子都定义有在每次更新期间修改其属性的行为。这个更新函数的责任是确保粒子集合的再现类似于熟悉的物理效果。粒子系统是控制每个粒子生成、更新和移除的实体。在你的游戏引擎中,粒子系统将被定义为一个独立的组件,就像物理组件一样。

在以下项目中,您将首先了解绘制粒子对象所需的支持。之后,您将研究如何创建实际粒子对象并定义其行为的细节。粒子是游戏引擎的一种新型对象,需要整个绘图系统的支持,包括自定义 GLSL 着色器、默认可共享着色器实例和一个新的Renderable对。

粒子项目

这个项目演示了如何实现一个粒子系统来模拟爆炸或类似法术的效果。你可以在图 10-1 中看到这个项目运行的例子。这个项目的源代码位于chapter10/10.1.particles文件夹中。

img/334805_2_En_10_Fig1_HTML.jpg

图 10-1

运行粒子项目

这个项目是上一章的延续,支持所有的刚性形状和碰撞控制。为简洁起见,本章不再重复这些控制的细节。项目的粒子系统特定控制如下:

  • Q 键:在当前鼠标位置产生粒子

  • E 键:切换粒子边界的绘制

该项目的目标如下:

  • 要了解如何绘制粒子并定义其行为的细节

  • 实现一个简单的粒子系统

您可以在assets文件夹中找到以下外部资源:包含默认系统字体的fonts文件夹,包含particle.png的粒子文件夹,默认粒子纹理,以及之前项目中相同的四个纹理图像。

  • 定义英雄和小兵的精灵元素。

  • platform.png定义平台、地板和天花板。

  • wall.png定义墙壁。

  • target.png标识当前选择的对象。

支持粒子的绘制

粒子是没有区域的纹理位置。然而,正如介绍中所讨论的,你的引擎会将每个粒子绘制成一个纹理矩形。出于这个原因,你可以简单地重用现有的纹理顶点着色器texture_vs.glsl

创建 GLSL 粒子片段着色器

当涉及到每个像素颜色的实际计算时,必须创建一个新的 GLSL 片段着色器particle_fs.glsl,以忽略全局环境项。火焰和爆炸等物理效果不参与照明计算。

  1. src/glsl_shaders文件夹下,新建一个文件,命名为particle_fs.glsl

  2. 类似于在texture_fs.glsl中定义的纹理片段着色器,您需要声明uPixelColorvTexCoord来从游戏引擎接收这些值,并定义uSampler来采样纹理:

  3. 现在实现 main 函数来累积颜色,不考虑全局环境效果。这是计算粒子颜色的一种方法。这个函数可以被修改以支持不同种类的粒子效果。

precision mediump float;
// sets the precision for floating point computation

// The object that fetches data from texture.
// Must be set outside the shader.
uniform sampler2D uSampler;

// Color of pixel
uniform vec4 uPixelColor;

// "varying" signifies that the texture coordinate will be
// interpolated and thus varies.
varying vec2 vTexCoord;

void main(void)  {
    // texel color look up based on interpolated UV value in vTexCoord
    vec4 c = texture2D(uSampler, vec2(vTexCoord.s, vTexCoord.t));

    vec3 r = vec3(c) * c.a * vec3(uPixelColor);
    vec4 result = vec4(r, uPixelColor.a);

    gl_FragColor = result;
}

定义默认 ParticleShader 实例

现在,您可以定义要共享的默认粒子着色器实例。回想在前面章节中使用其他类型的着色器时,着色器创建一次,并在src/engine/core文件夹的shader_resoruces.js文件中共享引擎范围。

  1. 首先编辑src/engine/core文件夹中的shader_resources.js文件,为默认粒子着色器定义常量、变量和访问函数:

  2. init()函数中,确保加载新定义的particle_fs GLSL 片段着色器:

// Particle Shader
let kParticleFS = "src/glsl_shaders/particle_fs.glsl";
let mParticleShader = null;
function getParticleShader() { return mParticleShader }

  1. 正确加载新的 GLSL 片段着色器particle_fs,当调用createShaders()函数时,可以实例化一个新的粒子着色器:
function init() {
    let loadPromise = new Promise(
        async function(resolve) {
            await Promise.all([

                ... identical to previous code ...

                text.load(kShadowReceiverFS),
                text.load(kParticleFS)
            ]);
            resolve();
        }).then(
            function resolve() { createShaders(); }
        );
    map.pushPromise(loadPromise);
}

  1. cleanUp()功能中,记得执行正确的清理和卸载操作:
function createShaders() {

    ... identical to previous code ...

    mShadowReceiverShader = new SpriteShader(kTextureVS,
                                             kShadowReceiverFS);
    mParticleShader = new TextureShader(kTextureVS, kParticleFS);
}

  1. 最后,不要忘记导出新定义的函数:
function cleanUp() {

    ... identical to previous code ...

    mShadowCasterShader.cleanUp();
    mParticleShader.cleanUp();

    ... identical to previous code ...

    text.unload(kShadowReceiverFS);
    text.unload(kParticleFS);
}

export {init, cleanUp,
        getConstColorShader, getTextureShader,
        getSpriteShader, getLineShader,
        getLightShader, getIllumShader,
        getShadowReceiverShader, getShadowCasterShader,
        getParticleShader}

创建粒子可渲染对象

使用定义为 GLSL particle_fs着色器接口的默认粒子着色器类,您现在可以创建新的Renderable对象类型来支持粒子的绘制。幸运的是,一个粒子或者一个纹理位置的详细行为与一个TextureRenderable是相同的,除了不同的着色器。因此,ParticleRenderable对象的定义是琐碎的。

src/engine/renderables文件夹中,创建particle_renderable.js文件;从defaultShaders导入以访问粒子着色器,从TextureRenderable导入以访问基类。将ParticleRenderable定义为TextureRenderable的子类,并在构造函数中设置合适的默认着色器。记得导出类。

import * as defaultShaders from "../core/shader_resources.js";
import TextureRenderable from "./texture_renderable.js";

class ParticleRenderable extends TextureRenderable {
    constructor(myTexture) {
        super(myTexture);
        this._setShader(defaultShaders.getParticleShader());
    }
}
export default ParticleRenderable;

加载默认粒子纹理

为了绘制时的方便,游戏引擎会预加载默认的粒子纹理particle.png,位于assets/particles文件夹。该操作可以作为defaultResources初始化过程的一部分。

  1. src/engine/resources文件夹中编辑default_resources.js,从texture.js添加一个导入来访问纹理加载功能,并为粒子纹理贴图的位置定义一个常量字符串和这个字符串的一个访问器:

  2. init()函数中,调用texture.load()函数加载默认的粒子纹理贴图:

import * as font from "./font.js";
import * as texture from "../resources/texture.js";
import * as map from "../core/resource_map.js";

// Default particle texture
let kDefaultPSTexture = "assets/particles/particle.png";

function getDefaultPSTexture() { return kDefaultPSTexture; }

  1. cleanUp()功能中,确保卸载默认纹理:
function init() {
    let loadPromise = new Promise(
        async function (resolve) {
            await Promise.all([
                font.load(kDefaultFont),
                texture.load(kDefaultPSTexture)
            ]);
            resolve();
        })

    ... identical to previous code ...
}

  1. 最后,记住导出访问器:
function cleanUp() {
    font.unload(kDefaultFont);
    texture.unload(kDefaultPSTexture);
}

export {

    ... identical to previous code ...

    getDefaultFontName, getDefaultPSTexture,

    ... identical to previous code ...
}

通过这种集成,默认的粒子纹理文件将在系统初始化期间加载到resource_map中。这个默认的纹理贴图可以很容易地用从getDefaultPSTexture()函数返回的值来访问。

定义引擎粒子组件

定义了绘图基础结构后,现在可以定义引擎组件来管理粒子系统的行为。目前,唯一需要的功能是包括所有粒子的默认系统加速。

src/engine/components文件夹中,创建particle_system.js文件,并为默认粒子系统加速定义变量、getter 和 setter 函数。记得导出新定义的功能。

let mSystemAcceleration = [30, -50.0];

function getSystemAcceleration() {
    return vec2.clone(mSystemAcceleration); }
function setSystemAcceleration(x, y) {
    mSystemAcceleration[0] = x;
    mSystemAcceleration[1] = y;
}

export {getSystemAcceleration, setSystemAcceleration}

在继续之前,请确保更新引擎访问文件index.js,以允许游戏开发者访问新定义的功能。

定义粒子和粒子游戏类

现在,您已经准备好定义实际的粒子、其默认行为以及粒子集合的类。

创建粒子

粒子是轻量级的游戏对象,具有简单的属性,缠绕在ParticleRenderable周围进行绘制。为了恰当地支持运动,粒子也用辛欧拉积分实现运动近似。

  1. 首先在src/engine文件夹中创建particles子文件夹。该文件夹将包含特定于粒子的实现文件。

  2. src/engine/particles文件夹中,创建particle.js,并定义构造函数以包含用于调试的位置、速度、加速度、阻力和绘图参数的变量:

  3. 定义draw()函数将粒子绘制为TextureRenderabledrawMarker()调试函数在粒子位置绘制一个 X 标记:

import * as loop from "../core/loop.js";
import * as particleSystem from "../components/particle_system.js";
import ParticleRenderable from "../renderables/particle_renderable.js";
import * as debugDraw from "../core/debug_draw.js";

let kSizeFactor = 0.2;

class Particle {
    constructor(texture, x, y, life) {
        this.mRenderComponent = new ParticleRenderable(texture);
        this.setPosition(x, y);

        // position control
        this.mVelocity = vec2.fromValues(0, 0);
        this.mAcceleration = particleSystem.getSystemAcceleration();
        this.mDrag = 0.95;

        // Color control
        this.mDeltaColor = [0, 0, 0, 0];

        // Size control
        this.mSizeDelta = 0;

        // Life control
        this.mCyclesToLive = life;
    }

    ... implementation to follow ...

}

export default Particle;

  1. 您现在可以实现update()函数来计算基于辛欧拉积分的粒子位置,其中使用mDrag变量的缩放模拟粒子上的阻力。请注意,该函数还对其他参数(包括颜色和大小)执行增量更改。mCyclesToLive变量通知粒子系统何时该移除这个粒子。
draw(aCamera) {
    this.mRenderComponent.draw(aCamera);
}

drawMarker(aCamera) {
    let size = this.getSize();
    debugDraw.drawCrossMarker(aCamera, this.getPosition(),
                              size[0] * kSizeFactor, [0, 1, 0, 1]);
}

  1. 定义简单的getset访问器。这些函数很简单,这里没有列出。
update() {
    this.mCyclesToLive--;

    let dt = loop.getUpdateIntervalInSeconds();

    // Symplectic Euler
    //    v += a * dt
    //    x += v * dt
    let p = this.getPosition();
    vec2.scaleAndAdd(this.mVelocity,
                     this.mVelocity, this.mAcceleration, dt);
    vec2.scale(this.mVelocity, this.mVelocity, this.mDrag);
    vec2.scaleAndAdd(p, p, this.mVelocity, dt);

    // update color

    let c = this.mRenderComponent.getColor();
    vec4.add(c, c, this.mDeltaColor);

    // update size
    let xf = this.mRenderComponent.getXform();
    let s = xf.getWidth() * this.mSizeDelta;
    xf.setSize(s, s);
}

创建粒子集

为了处理一组粒子,你现在可以创建ParticleSet来支持方便的Particle循环。出于轻量级的目的,Particle类没有从更复杂的GameObject派生出子类;然而,由于 JavaScript 是一种非类型化的语言,ParticleSet仍然有可能继承并提炼GameObjectSet来利用现有的特定于集合的功能。

  1. src/engine/particles文件夹中,创建particle_set.js,并将ParticleSet定义为GameObjectSet的子类:

  2. 覆盖GameObjectSetdraw()功能,以确保使用添加剂混合绘制粒子:

import * as glSys from "../core/gl.js";
import GameObjectSet from "../game_objects/game_object_set.js";

class ParticleSet extends GameObjectSet {
    constructor() {
        super();
    }

    ... implementation to follow ...

}

export default ParticleSet;

Note

回想一下第五章,默认的gl.blendFunc()设置通过根据阿尔法通道值混合来实现透明度。这被称为阿尔法混合。在这种情况下,gl.blendFunc()设置只是累积颜色,而不考虑 alpha 通道。这被称为添加剂混合。加法混合通常会导致像素颜色过饱和,即 RGB 分量的值大于最大显示值 1.0。当模拟火焰和爆炸的强烈亮度时,像素颜色的过饱和通常是可取的。

  1. 覆盖update()功能,确保清除过期颗粒:
draw(aCamera) {
    let gl = glSys.get();
    gl.blendFunc(gl.ONE, gl.ONE);  // for additive blending!
    super.draw(aCamera);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
                                  // restore alpha blending
}

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

update() {
    super.update();
    // Cleanup Particles
    let i, obj;
    for (i = 0; i < this.size(); i++) {
        obj = this.getObjectAt(i);
        if (obj.hasExpired()) {
            this.removeFromSet(obj);
        }
    }
}

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

测试粒子系统

测试应该验证两个主要目标。首先,实现的粒子系统能够产生视觉上令人愉快的效果。第二,粒子被正确地处理,被正确地创建、破坏,并表现出预期的行为。这个测试用例主要基于之前的项目,带有一个新的_createParticle()函数,当 Q 键被按下时,这个函数被调用。在my_game_main.js文件中实现的_createParticle()函数创建具有伪随机行为的粒子,如下所示:

function _createParticle(atX, atY) {
    let life = 30 + Math.random() * 200;
    let p = new engine.Particle(
            engine.defaultResources.getDefaultPSTexture(),
            atX, atY, life);
    p.setColor([1, 0, 0, 1]);

    // size of the particle
    let r = 5.5 + Math.random() * 0.5;
    p.setSize(r, r);

    // final color
    let fr = 3.5 + Math.random();
    let fg = 0.4 + 0.1 * Math.random();
    let fb = 0.3 + 0.1 * Math.random();
    p.setFinalColor([fr, fg, fb, 0.6]);

    // velocity on the particle
    let fx = 10 - 20 * Math.random();
    let fy = 10 * Math.random();
    p.setVelocity(fx, fy);

    // size delta
    p.setSizeDelta(0.98);

    return p;
}

关于_createParticle()函数有两个重要的观察结果。首先,多次使用random()函数来配置每个创建的Particle。粒子系统利用大量相似但略有不同的粒子来构建和传达所需的视觉效果。使用随机性来避免任何模式是很重要的。第二,配置中使用了很多看似任意的数字,比如将粒子的寿命设置在 30 到 230 之间,或者将最终的红色分量设置为 3.5 到 4.5 之间的数字。不幸的是,这就是使用粒子系统的本质。经常有相当多的临时实验。商业游戏引擎通常通过发布其粒子系统的预设值集合来缓解这一困难。这样,游戏设计者可以通过调整提供的预设来微调特定的期望效果。

观察

运行项目,按 Q 键观察生成的粒子。看起来好像有燃烧发生在鼠标指针下面。按住 Q 键并慢慢移动鼠标指针来观察燃烧,就好像在鼠标下面有一个引擎产生火焰一样。键入 E 键来切换单个粒子位置的绘制。现在,您可以观察到一个绿色的 X 标记了每个生成粒子的位置。

如果您快速移动鼠标指针,您可以观察到带有绿色 X 中心的单个粉红色圆圈在向地板下落时会改变颜色。虽然所有粒子都是由_createParticle()函数创建的,并且在改变颜色时都具有类似的向地板下落的行为,但是每个粒子看起来都略有不同,并且没有表现出任何行为模式。现在,您可以清楚地观察到在创建的粒子中整合随机性的重要性。

修改_createParticle()函数的方式有无限多种。例如,只需将初始和最终颜色更改为不同的灰色和透明度,就可以将类似爆炸的效果更改为蒸汽或烟雾。此外,可以通过反转颜色来修改默认粒子纹理,以创建黑烟效果。您也可以将大小变化增量修改为大于 1,以随着时间的推移增加粒子的大小。事实上,粒子的产生是没有限制的。您实现的粒子系统允许游戏开发者创建具有自定义行为的粒子,这些行为最适合他们正在构建的游戏。

最后,请注意生成的粒子不与RigidShape对象交互,看起来好像粒子被绘制在游戏场景中的其余对象上。这个问题将在下一个项目中研究和解决。

粒子碰撞

将粒子整合到游戏场景中的方法是让粒子遵循场景的隐含规则,并相应地与非粒子对象进行交互。检测碰撞的能力是物体之间交互的基础。出于这个原因,有时支持粒子与其他非粒子游戏对象的碰撞是很重要的。

由于粒子仅由它们的位置定义,没有维度,实际的碰撞计算可能相对简单。然而,通常有大量的粒子;同样地,要执行的冲突的数量也可以是很多的。作为计算成本的折衷和优化,粒子碰撞可以基于RigidShape而不是实际的Renderable对象。这类似于物理组件的情况,其中实际模拟是基于简单的刚性形状来近似潜在的几何复杂的Renderable物体。

粒子碰撞项目

这个项目演示了如何实现一个粒子碰撞系统,它能够解决粒子和现有的RigidShape对象之间的碰撞。你可以在图 10-2 中看到这个项目运行的例子。这个项目的源代码位于chapter10/10.2.particle_collisions文件夹中。

img/334805_2_En_10_Fig2_HTML.jpg

图 10-2

运行粒子碰撞项目

该项目的控件与上一个项目相同,并支持所有刚性形状和碰撞控件。特定于粒子系统的控件如下:

  • Q 键:在当前鼠标位置产生粒子

  • E 键:切换粒子边界的绘制

  • 1 键:切换Particle / RigidShape碰撞

该项目的目标如下:

  • 了解并解决单个粒子位置和RigidShape对象之间的碰撞

  • 构建支持与RigidShape交互的粒子引擎组件

修改粒子系统

有了设计良好的基础设施,新功能的实现可以本地化。在粒子碰撞的情况下,所有的修改都在src/engine/components文件夹的particle_system.js文件中。

  1. 编辑particle_system.js定义并初始化临时局部变量,以解决与RigidShape对象的冲突。mCircleCollider物体将被用来代表碰撞中的单个粒子。

  2. 定义resolveCirclePos()函数,通过将位置推到圆形之外来解决RigidCircle和位置之间的冲突:

import Transform from "../utils/transform.js";
import RigidCircle from "../rigid_shapes/rigid_circle.js";
import CollisionInfo from "../rigid_shapes/collision_info.js";

let mXform = null;  // for collision with rigid shapes
let mCircleCollider = null;
let mCollisionInfo = null;
let mFrom1to2 = [0, 0];

function init() {
    mXform = new Transform();
    mCircleCollider = new RigidCircle(mXform, 1.0);
    mCollisionInfo = new CollisionInfo();
}

  1. 定义resolveRectPos()函数,通过将mCircleCollider局部变量包裹在位置周围并调用RigidCircleRigidRectangle碰撞函数来解决RigidRectangle和位置之间的碰撞。当检测到穿插时,根据计算出的mCollisionInfo,该位置被推到矩形形状之外。
function resolveCirclePos(circShape, particle) {
    let collision = false;
    let pos = particle.getPosition();
    let cPos = circShape.getCenter();
    vec2.subtract(mFrom1to2, pos, cPos);
    let dist = vec2.length(mFrom1to2);
    if (dist < circShape.getRadius()) {
        vec2.scale(mFrom1to2, mFrom1to2, 1/dist);
        vec2.scaleAndAdd(pos, cPos, mFrom1to2, circShape.getRadius());
        collision = true;
    }
    return collision;
}

  1. 实现resolveRigidShapeCollision()resolveRigidShapeSetCollision()以方便客户端游戏开发者调用。这些函数解决单个或一组RigidShape对象与ParticleSet对象之间的碰撞。
function resolveRectPos(rectShape, particle) {
    let collision = false;
    let s = particle.getSize();
    let p = particle.getPosition();
    mXform.setSize(s[0], s[1]); // referred by mCircleCollision
    mXform.setPosition(p[0], p[1]);
    if (mCircleCollider.boundTest(rectShape)) {
        if (rectShape.collisionTest(mCircleCollider, mCollisionInfo)) {
            // make sure info is always from rect towards particle
            vec2.subtract(mFrom1to2,
                 mCircleCollider.getCenter(), rectShape.getCenter());
            if (vec2.dot(mFrom1to2, mCollisionInfo.getNormal()) < 0)
                mCircleCollider.adjustPositionBy(
                 mCollisionInfo.getNormal(), -mCollisionInfo.getDepth());
            else
                mCircleCollider.adjustPositionBy(
                 mCollisionInfo.getNormal(), mCollisionInfo.getDepth());
            p = mXform.getPosition();
            particle.setPosition(p[0], p[1]);
            collision = true;
        }
    }
    return collision;
}

  1. 最后,记住导出新定义的函数:
// obj: a GameObject (with potential mRigidBody)
// pSet: set of particles (ParticleSet)
function resolveRigidShapeCollision(obj, pSet) {
    let i, j;
    let collision = false;

    let rigidShape = obj.getRigidBody();
    for (j = 0; j < pSet.size(); j++) {
        if (rigidShape.getType() == "RigidRectangle")
            collision = resolveRectPos(rigidShape, pSet.getObjectAt(j));
        else if (rigidShape.getType() == "RigidCircle")
            collision = resolveCirclePos(rigidShape,pSet.getObjectAt(j));
    }

    return collision;
}

// objSet: set of GameObjects (with potential mRigidBody)
// pSet: set of particles (ParticleSet)
function resolveRigidShapeSetCollision(objSet, pSet) {
    let i, j;
    let collision = false;
    if ((objSet.size === 0) || (pSet.size === 0))
        return false;
    for (i=0; i<objSet.size(); i++) {
        let rigidShape = objSet.getObjectAt(i).getRigidBody();
        for (j = 0; j<pSet.size(); j++) {
            if (rigidShape.getType() == "RigidRectangle")
                collision = resolveRectPos(rigidShape,
                                       pSet.getObjectAt(j)) || collision;
            else if (rigidShape.getType() == "RigidCircle")
                    collision = resolveCirclePos(rigidShape,
                                       pSet.getObjectAt(j)) || collision;
        }
    }
    return collision;
}

export {init,
        getSystemAcceleration, setSystemAcceleration,
        resolveRigidShapeCollision, resolveRigidShapeSetCollision}

初始化粒子系统

particle_system.js中定义的临时变量必须在游戏循环开始前初始化。编辑loop.js,从particle_system.js导入,在start()函数中完成异步加载后调用init()函数。

... identical to previous code ...

import * as debugDraw from "./debug_draw.js";
import * as particleSystem from "../components/particle_system.js";

... identical to previous code ...

async function start(scene) {

    ... identical to previous code ...

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

    // system init that can only occur after all resources are loaded
    particleSystem.init();

    ... identical to previous code ...
}

测试粒子系统

MyGame类所需的修改非常简单。必须定义一个新的变量来支持冲突解决的切换,在my_game_main.js中定义的update()函数修改如下:

update() {

    ... identical to previous code ...

    if (engine.input.isKeyClicked(engine.input.keys.One))
            this.mPSCollision = !this.mPSCollision;
    if (this.mPSCollision) {
        engine.particleSystem.resolveRigidShapeSetCollision(
                                     this.mAllObjs, this.mParticles);
        engine.particleSystem.resolveRigidShapeSetCollision(
                                     this.mPlatforms, this.mParticles);
    }

    ... identical to previous code ...
}

观察

与之前的项目一样,您可以运行项目并使用 Q 和 E 键创建粒子。但是,请注意,生成的粒子不会与任何对象重叠。您甚至可以尝试将鼠标指针移动到其中一个RigidShape对象的边界内,然后键入 Q 键。请注意,在所有情况下,粒子都是在形状外部生成的。

您可以尝试键入 1 键来切换与刚性形状的碰撞。请注意,启用碰撞后,粒子有点类似于火灾或爆炸中的琥珀色粒子,它们从场景中的RigidShape对象的表面反弹回来。当“碰撞”关闭时,正如您在之前的项目中所观察到的,粒子看起来像是在其他对象前面燃烧或爆炸。这样,碰撞只是控制粒子系统与游戏引擎其余部分集成的另一个参数。

你可能会觉得继续按 Q 键生成粒子很麻烦。在下一个项目中,你将学习在一段固定的时间内粒子的产生。

粒子发射器

使用当前的粒子系统实现,您可以在特定的点和时间创建粒子。这些粒子可以根据它们的性质移动和改变。然而,只有当有一个明确的状态变化时,如按键点击,才能创建粒子。当需要在状态改变后持续生成粒子时,这就变得很受限制,例如在创建一个新的RigidShape对象后持续一段时间的爆炸或烟火。粒子发射器通过定义在一段时间内生成粒子的功能来解决这个问题。

粒子发射器项目

这个项目演示了如何为你的粒子系统实现一个粒子发射器来支持粒子发射。你可以在图 10-3 中看到这个项目运行的例子。这个项目的源代码位于chapter10/10.3.particle_emitters文件夹中。

img/334805_2_En_10_Fig3_HTML.jpg

图 10-3

运行粒子发射器项目

该项目的控件与上一个项目相同,并支持所有刚性形状和碰撞控件。项目的粒子系统特定控制如下:

  • Q 键:在当前鼠标位置产生粒子

  • E 键:切换粒子边界的绘制

  • 1 键:切换Particle / RigidShape碰撞

该项目的目标如下:

  • 为了理解对粒子发射器的需求

  • 体验实现粒子发射器

定义粒子发射器类

您已经观察并体验了在处理粒子时避免模式的重要性。在这种情况下,随着ParticleEmitter对象随着时间的推移生成新的粒子,再次强调注入随机性以避免出现任何图案是很重要的。

  1. src/engine/particles文件夹中,创建particle_emitter.js;用接收位置、数量和如何发射新粒子的构造函数定义ParticleEmitter类。注意mParticleCreator变量需要一个回调函数。需要时,将调用该函数来创建粒子。

  2. 定义一个函数来返回发射器的当前状态。当没有更多的粒子发射时,发射器应该被移除。

let kMinToEmit = 5; // Smallest number of particle emitted per cycle

class ParticleEmitter {
    constructor(px, py, num, createrFunc) {
        // Emitter position
        this.mEmitPosition = [px, py];

        // Number of particles left to be emitted
        this.mNumRemains = num;

        // Function to create particles (user defined)
        this.mParticleCreator = createrFunc;
    }

    ... implementation to follow ...
}

export default ParticleEmitter;

  1. 创建一个函数来实际创建或发射粒子。注意实际发射的粒子数量的随机性以及对mParticleCreator()回调函数的调用。采用这种设计,不太可能遇到随着时间推移而产生的粒子数量的模式。此外,发射器仅定义粒子发射的方式、时间和位置的机制,而不定义所创建粒子的特征。mParticleCreator指向的函数负责定义每个粒子的实际行为。
expired() { return (this.mNumRemains <= 0); }

emitParticles(pSet) {
    let numToEmit = 0;
    if (this.mNumRemains < this.kMinToEmit) {
        // If only a few are left, emits all of them
        numToEmit = this.mNumRemains;
    } else {
        // Otherwise, emits about 20% of what's left
        numToEmit = Math.trunc(Math.random() * 0.2 * this.mNumRemains);
    }
    // Left for future emitting.
    this.mNumRemains -= numToEmit;
    let i, p;
    for (i = 0; i < numToEmit; i++) {
        p = this.mParticleCreator(
                          this.mEmitPosition[0], this.mEmitPosition[1]);
        pSet.addToSet(p);
    }
}

最后,记得更新引擎访问文件index.js,以允许游戏开发者访问ParticleEmitter类。

修改粒子集

定义的ParticleEmitter类需要集成到ParticleSet中来管理发射的粒子:

  1. 编辑src/engine/particles文件夹中的particle_set.js,定义一个新变量用于维护发射器:

  2. 定义一个函数来实例化一个新的发射器。记下func参数。这是负责实际创建单个Particle对象的回调函数。

constructor() {
    super();
    this.mEmitterSet = [];
}

  1. 修改更新函数以循环通过发射器集,从而生成新粒子并移除过期发射器:
addEmitterAt(x, y, n, func) {
    let e = new ParticleEmitter(x, y, n, func);
    this.mEmitterSet.push(e);
}

update() {
    super.update();
    // Cleanup Particles
    let i, obj;
    for (i = 0; i < this.size(); i++) {
        obj = this.getObjectAt(i);
        if (obj.hasExpired()) {
            this.removeFromSet(obj);
        }
    }

    // Emit new particles
    for (i = 0; i < this.mEmitterSet.length; i++) {
        let e = this.mEmitterSet[i];
        e.emitParticles(this);
        if (e.expired()) {  // delete the emitter when done
            this.mEmitterSet.splice(i, 1);
        }
    }
}

测试粒子发射器

这是对ParticleEmitter对象正确运行的直接测试。修改MyGameupdate()功能,当按下 G 或 H 键时,在RigidShape对象的位置创建一个新的ParticleEmitter。这样,当创建新的RigidShape对象或给RigidShape对象分配新的速度时,看起来好像发生了爆炸。

在这两种情况下,本章第一个项目中讨论的_createParticle()函数都是作为ParticleEmitter构造函数中的createrFunc回调函数参数的参数传递的。

观察

运行该项目,并在创建初始RigidShape对象的位置观察初始的类似烟火的爆炸。键入 G 键,观察新创建的RigidShape对象附近伴随的爆炸。或者,您可以键入 H 键将速度应用到所有形状,并观察每个RigidShape对象旁边类似爆炸的效果。为了粗略了解这个粒子系统在游戏中的样子,你可以试着启用纹理(用 T 键),禁用RigidShape绘制(用 R 键),并键入 H 键来应用速度。注意看起来好像Renderable物体正在被爆炸炸开。

注意每次爆炸是如何持续一段时间,然后逐渐消失的。将这种效果与短按 Q 键产生的效果进行比较,可以观察到,如果没有专用的粒子发射器,爆炸似乎在开始前就已经失败了。

与粒子类似,发射器也可以具有完全不同的特性来模拟不同的物理效果。例如,您实现的发射器由要创建的粒子数驱动。可以很容易地修改此行为,以使用时间作为驱动因素,例如,在给定的时间段内发射近似数量的粒子。发射器的其他潜在应用包括但不限于

  • 允许发射器的位置随时间变化,例如,将发射器连接到火箭的末端

  • 允许发射器影响创建的粒子的属性,例如,改变所有创建的粒子的加速度或速度来模拟风的效果

基于你已经实现的简单而灵活的粒子系统,你现在可以用一种简单的方式试验所有这些想法。

摘要

这一章有三个简单的要点。首先,你已经学习了粒子,具有适当纹理和没有维度的位置,在描述有趣的物理效果时是有用的。第二,与其他物体碰撞和互动的能力有助于在游戏场景中整合和放置粒子。最后,为了实现常见的物理效果,粒子的发射应该持续一段时间。

您已经开发了一个简单而灵活的粒子系统,以支持单个粒子及其发射器的一致管理。您的系统很简单,因为它由一个组件组成,定义在particle_system.js中,只有三个简单的支持类定义在src/engine/particles文件夹中。该系统是灵活的,因为实际创建粒子的回调机制,游戏开发者可以自由地定义和生成具有任意行为的粒子。

你建立的粒子系统用来演示基本原理。为了增加粒子行为的复杂性,你可以从简单的Particle类派生出子类,定义额外的参数,并相应地修改update()函数。为了支持额外的物理效果,你可以考虑从ParticleEmitter类修改或子类化,并根据你想要的公式发射粒子。

游戏设计注意事项

正如在第九章中所讨论的,在游戏中的存在感不仅仅是通过在游戏环境中重建我们的物理世界体验来实现的;虽然引入真实世界的物理通常是将玩家带入虚拟世界的有效方式,但还有许多其他设计选择可以非常有效地将玩家吸引到游戏中,无论是与对象物理合作还是独立进行。例如,想象一个 2D 漫画书视觉风格的游戏,显示“嘣!”每当有东西爆炸时,基于文本的图像;物体不显示“轰!”当它们在物理世界爆炸时,当然,但程式化和熟悉的使用“轰!”在漫画视觉美学的背景下,如图 10-4 本身就可以非常有效地将玩家与游戏世界中发生的事情联系起来。

img/334805_2_En_10_Fig4_HTML.jpg

图 10-4

像本图中所示的视觉技术经常在漫画小说中使用,以表现各种快速移动或高冲击力的动作,如爆炸、拳击、撞车等;类似的视觉技术也在电影和视频游戏中得到了有效的应用

粒子效果也可以用于模拟我们期望它们在现实世界中的行为的现实方式,或者用于与现实世界物理无关的更具创造性的方式。试着用你从本章的例子中学到的东西,在你当前的游戏原型中试验粒子,就像我们在第九章中离开时一样:你能想出当前关卡中粒子的一些用途来支持和加强现有游戏元素的存在吗(例如,如果玩家角色接触力场,火花就会飞溅)?引入可能与游戏不直接相关但能增强和增加游戏设置趣味性的粒子效果怎么样?

十一、支持摄像机背景

完成本章后,您将能够

  • 在任何给定的相机 WC 边界内,用任何图像实现背景平铺

  • 理解视差和用视差滚动模拟运动视差

  • 理解 2D 游戏中分层物体的需要,并支持分层绘图

介绍

至此,您的游戏引擎能够照亮 2D 图像以生成高光和阴影,并模拟基本的物理行为。作为本书引擎开发的总结,这一章主要关注对使用背景平铺和视差创建游戏世界环境的一般支持,以及减轻游戏程序员管理绘制顺序的负担。

包括背景图像或物体来装饰游戏世界,以进一步吸引玩家。这通常要求图像规模庞大,视觉复杂微妙。例如,在侧滚游戏中,背景必须始终存在,简单的运动视差可以创建深度感,并进一步捕捉玩家的兴趣。

在计算机图形和视频游戏的上下文中,平铺指的是沿着 x 和 y 方向复制图像或图案。在视频游戏中,用于平铺的图像通常被战略性地构建,以确保内容在复制边界上的连续性。图 11-1 显示了一个策略性绘制的背景图像示例,该图像在 x 方向平铺三次,在 y 方向平铺两次。注意跨越复制边界的完美延续。适当的拼贴通过只创建一个单一的图像来传达无限游戏世界中的复杂性。

img/334805_2_En_11_Fig1_HTML.png

图 11-1

策略性绘制的背景图像的平铺

视差是当从不同的位置观看时物体的明显位移。图 11-2 显示了一个阴影圆的视差示例。当从中间的眼睛位置观察时,中心阴影圆看起来覆盖了中心矩形块。然而,当从底部眼睛位置观察时,这个相同的阴影圆似乎覆盖了顶部的矩形块。运动视差是观察到当一个人在运动时,附近的物体似乎比远处的物体移动得更快。这是传达深度知觉的基本视觉线索。在 2D 游戏中,运动视差的模拟是一种引入深度复杂性以进一步吸引玩家的直接方法。

img/334805_2_En_11_Fig2_HTML.png

图 11-2

视差:从不同的角度观察时,物体出现在不同的位置

这一章介绍了一个用于平铺摄像机 WC 边界的通用算法,并描述了一个隐藏视差滚动细节的抽象。随着背景视觉复杂性的增加,本章讨论了图层管理器的重要性,并创建了一个图层管理器,以减轻游戏程序员对绘制顺序的关注。

背景平铺

在 2D 游戏中平铺背景时,重要的是要认识到只需要绘制覆盖摄像机 WC 边界的平铺。如图 11-3 所示。在本例中,要平铺的背景对象在 WC 原点定义,具有自己的宽度和高度。然而,在这种情况下,相机 WC 边界不与定义的背景对象相交。图 11-3 显示背景对象需要平铺六次以覆盖摄像机 WC 边界。请注意,由于它在摄像机中不可见,因此不需要在原点绘制玩家定义的背景对象。

img/334805_2_En_11_Fig3_HTML.png

图 11-3

为摄像机 WC 边界生成平铺背景

有许多方法可以计算给定背景对象和相机 WC 边界所需的平铺。一种简单的方法是确定覆盖 WC 边界左下角的图块位置,并在正 x 和 y 方向上图块。

平铺对象项目

这个项目演示了如何实现简单的背景平铺。你可以在图 11-4 中看到这个项目运行的例子。这个项目的源代码在chapter11/11.1.tiled_objects文件夹中定义。

img/334805_2_En_11_Fig4_HTML.jpg

图 11-4

运行平铺对象项目

该项目的控制如下:

  • WASD 键:移动Dye角色(英雄)来平移厕所窗口边界

该项目的目标如下:

  • 体验使用多层背景

  • 为摄像机 WC 窗口边界实现背景对象的平铺

您可以在assets文件夹中找到以下外部资源。fonts文件夹包含默认的系统字体和六幅纹理图像:minion_sprite.pngminion_sprite_normal.pngbg.pngbg_normal.pngbg_layer.pngbg_layer_normal.pngHeroMinion对象由minion_sprite.png图像中的 sprite 元素表示,bg.pngbg_layer.png是两层背景图像。对应的_normal文件是法线贴图。

定义 TiledGameObject

回想一下,GameObject抽象了游戏中一个对象的基本行为,它的外观由它引用的Renderable对象决定。一个TiledGameObject是一个GameObject,它能够平铺被引用的Renderable对象以覆盖给定Camera对象的 WC 边界。

  1. src/engine/game_objects文件夹中创建一个新文件,并将其命名为tiled_game_object.js。添加以下代码来构造对象。mShouldTile变量提供了停止平铺过程的选项。

  2. mShouldTile定义 getter 和 setter 函数:

class TiledGameObject extends GameObject {
    constructor(renderableObj) {
        super(renderableObj);
        this.mShouldTile = true; // can switch this off if desired
    }
… implementation to follow …
export default TiledGameObject;

  1. 定义函数来平铺并绘制Renderable对象,以覆盖aCamera对象的 WC 边界:
setIsTiled(t) { this.mShouldTile = t; }
shouldTile() { return this.mShouldTile; }

_drawTile(aCamera) {
    // Step A: Compute the positions and dimensions of tiling object.
    let xf = this.getXform();
    let w = xf.getWidth();
    let h = xf.getHeight();
    let pos = xf.getPosition();
    let left = pos[0] - (w / 2);
    let right = left + w;
    let top = pos[1] + (h / 2);
    let bottom = top - h;

    // Step B: Get WC positions and dimensions of the drawing camera.
    let wcPos = aCamera.getWCCenter();
    let wcLeft = wcPos[0] - (aCamera.getWCWidth() / 2);
    let wcRight = wcLeft + aCamera.getWCWidth();
    let wcBottom = wcPos[1] - (aCamera.getWCHeight() / 2);
    let wcTop = wcBottom + aCamera.getWCHeight();

    // Step C: Determine offset to camera window's lower left corner.
    let dx = 0, dy = 0; // offset to the lower left corner
    // left/right boundary?
    if (right < wcLeft) { // left of WC left
        dx = Math.ceil((wcLeft - right) / w) * w;
    } else {
        if (left > wcLeft) { // not touching the left side
            dx = -Math.ceil((left - wcLeft) / w) * w;
        }
    }
    // top/bottom boundary
    if (top < wcBottom) { // Lower than the WC bottom
        dy = Math.ceil((wcBottom - top) / h) * h;
    } else {
        if (bottom > wcBottom) {  // not touching the bottom
            dy = -Math.ceil((bottom - wcBottom) / h) * h;
        }
    }

    // Step D: Save the original position of the tiling object.
    let sX = pos[0];
    let sY = pos[1];

    // Step E: Offset tiling object and update related position variables
    xf.incXPosBy(dx);
    xf.incYPosBy(dy);
    right = pos[0] + (w / 2);
    top = pos[1] + (h / 2);

    // Step F: Determine number of times to tile in x and y directions.
    let nx = 1, ny = 1; // times to draw in the x and y directions
    nx = Math.ceil((wcRight - right) / w);
    ny = Math.ceil((wcTop - top) / h);

    // Step G: Loop through each location to draw a tile

    let cx = nx;
    let xPos = pos[0];
    while (ny >= 0) {
        cx = nx;
        pos[0] = xPos;
        while (cx >= 0) {
            this.mRenderComponent.draw(aCamera);
            xf.incXPosBy(w);
            --cx;
        }
        xf.incYPosBy(h);
        --ny;
    }

    // Step H: Reset the tiling object to its original position.
    pos[0] = sX;
    pos[1] = sY;
}

_drawTile()函数计算并重新定位Renderable对象,以覆盖摄像机 WC 边界的左下角,并在正 x 和 y 方向平铺对象。请注意以下几点:

  1. 启用平铺时,覆盖draw()函数以调用_drawTile()函数:

  2. 步骤 A 和 B 计算平铺对象和摄像机 WC 边界的位置和尺寸。

  3. 步骤 C 计算dxdy偏移量,这将平移Renderable对象,其边界覆盖了aCamera WC 边界的左下角。对Math.ceil()函数的调用确保计算出的dxdyRenderable宽度和高度的整数倍。这对于确保平铺过程中没有重叠或间隙非常重要。

  4. 在偏移和绘制之前,步骤 D 保存Renderable对象的原始位置。步骤 E 偏移Renderable对象以覆盖摄像机 WC 边界的左下角。

  5. 步骤 F 计算所需的重复次数,步骤 G 在正 x 和 y 方向上平铺Renderable对象,直到结果覆盖整个摄像机 WC 边界。对Math.ceil()函数的调用确保计算出的nxny(在 x 和 y 方向上平铺的次数)是整数。

  6. 步骤 H 将平铺对象的位置重置为原始位置。

draw(aCamera) {
    if (this.isVisible() && (this.mDrawRenderable)) {
        if (this.shouldTile()) {
            // find out where we should be drawing
            this._drawTile(aCamera);
        } else {
            this.mRenderComponent.draw(aCamera);
        }
    }
}

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

修改我的游戏来测试平铺的对象

MyGame应该测试对象平铺的正确性。为了测试多层平铺,创建了两个单独的实例TiledGameObjectCamera。这两个TiledGameObject实例位于离摄像机不同的距离(z 深度),并由不同的光源组合照亮。增加的第二个摄像头聚焦在一个Hero物体上。

只对TiledGameObject实例的创建感兴趣。这是因为一旦创建了一个TiledGameObject实例,就可以像处理一个GameObject实例一样处理它。出于这个原因,只详细检查MyGame类的init()函数。

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

    this.mHeroCam = new engine.Camera(
        vec2.fromValues(20, 30.5), // position of the camera
        14,                        // width of camera
        [0, 420, 300, 300],        // viewport (X, Y, width, height)
        2
    );
    this.mHeroCam.setBackgroundColor([0.5, 0.5, 0.9, 1]);

    // Step B: the lights
    this._initializeLights();   // defined in MyGame_Lights.js

    // Step C: the far Background
    let bgR = new engine.IllumRenderable(this.kBg, this.kBgNormal);
    bgR.setElementPixelPositions(0, 1024, 0, 1024);
    bgR.getXform().setSize(30, 30);
    bgR.getXform().setPosition(0, 0);
    bgR.getMaterial().setSpecular([0.2, 0.1, 0.1, 1]);
    bgR.getMaterial().setShininess(50);
    bgR.getXform().setZPos(-5);
    bgR.addLight(this.mGlobalLightSet.getLightAt(1));
                                     // only the directional light
    this.mBg = new engine.TiledGameObject(bgR);

    // Step D: the closer Background
    let i;
    let bgR1 = new engine.IllumRenderable(
                                    this.kBgLayer, this.kBgLayerNormal);
    bgR1.getXform().setSize(30, 30);
    bgR1.getXform().setPosition(0, 0);
    bgR1.getXform().setZPos(-2);
    for (i = 0; i < 4; i++) {
        bgR1.addLight(this.mGlobalLightSet.getLightAt(i)); // all lights
    }
    bgR1.getMaterial().setSpecular([0.2, 0.2, 0.5, 1]);
    bgR1.getMaterial().setShininess(10);
    this.mBgL1 = new engine.TiledGameObject(bgR1);

    ... identical to previous code ...
}

在列出的代码中,首先在步骤 A 中创建两个摄像机,然后在_initializeLights()函数中创建并初始化所有光源。步骤 C 将bgR定义为一个被一个光源照亮的IllumRenderableTiledGameObject。步骤 D 基于被四个光源照亮的另一个IllumRenderable定义第二个TiledGameObject。由于TileGameObject类的mShouldTile变量默认为 true,两个平铺对象将平铺它们正在绘制的摄像机。

观察

现在,您可以运行项目并使用 WASD 键移动Hero对象。正如所料,两层平铺背景清晰可见。您可以通过选择并关闭光源 1(键入 1 键,然后键入 H 键)来关闭对更远背景的照明。移动Hero对象平移摄像机,以验证两个摄像机中的平铺和背景移动行为是否正确。

一个有趣的观察结果是,当两层背景位于离摄像机不同的距离时,当摄像机平移时,两个背景图像同步滚动。如果不是因为光源照明的差异,看起来好像背景实际上是一个单一的图像。这个例子说明了模拟运动视差的重要性。

用视差滚动模拟运动视差

视差滚动通过以不同的速度定义和滚动对象来模拟运动视差,以传达这些对象位于离相机不同距离的感觉。图 11-5 用俯视图说明了这个想法,显示了物体与摄像机的概念距离。由于这是一个鸟瞰图,摄像机 WC 边界的宽度在底部显示为一条水平线。在两层背景Layer1Layer2的前面,Hero物体是离摄像机最近的。对于典型的 2D 游戏来说,游戏中的绝大多数物体将位于离摄像机的这个默认距离处。背景对象位于离相机更远的位置,在默认距离之后。距离感可以通过背景物体上的战略图来传达(例如Layer1的草地和Layer2的远山),并伴有适当的滚动速度。注意背景物体Layer1Layer2上的位置 P 1P 2Hero物体的正后方。

img/334805_2_En_11_Fig5_HTML.png

图 11-5

具有两个不同距离的背景对象的场景的俯视图

图 11-6 显示了固定摄像机向左视差滚动的结果。随着Layer1以比Layer2更快的速度滚动,位置P1 比P2 从其原始位置有更大的位移。连续滚动将使Layer1Layer2移动得更快,并恰当地传达出它比Layer2更近的感觉。在视差滚动中,离相机较近的对象总是比较远的对象滚动速度快。

img/334805_2_En_11_Fig6_HTML.png

图 11-6

固定相机的视差滚动俯视图

在摄像机运动的情况下,当实现视差滚动时,必须考虑物体的相对速度。图 11-7 用俯视图展示了移动的摄像机和静止的物体的情况。在本例中,摄像机 WC 边界向右移动了d个单位。由于运动是在相机中进行的,所以相机视图中的所有静止物体将看起来被相机运动的逆运动所取代。例如,静止的Hero对象从中心向左移动到新 WC 边界的左边缘。为了正确地模拟运动视差,两个背景Layer1Layer2必须移动不同的相对距离。在这种情况下,必须计算相对距离,使得更远的物体看起来移动得更慢。在摄像机移动结束时,在新的 WC 边界内,离摄像机最近的Hero对象看起来已经向左移动了d个单位,Layer1对象移动了0.75d,而Layer2对象移动了0.25d。这样,对象的位移反映了它们与相机的相对距离。为此,Hero对象的平移为零,Layer1Layer2对象必须分别向右平移0.25d0.75d。请注意,背景向右平移的量小于相机移动的量,因此,背景实际上是向左移动的。例如,虽然Layer1对象被0.25d向右平移,但是当从已经被d向右移动的摄像机来看时,产生的相对移动使得Layer1对象已经被0.75d向左移位。

img/334805_2_En_11_Fig7_HTML.png

图 11-7

相机运动时视差滚动的俯视图

重要的是要注意,在所描述的为移动的摄像机实现视差滚动的方法中,静止的背景物体被移动。这种方法有两个局限性。首先,改变对象位置是为了传达视觉提示,并不反映任何特定的游戏状态逻辑。如果游戏逻辑要求精确控制背景物体的运动,这会产生具有挑战性的冲突。幸运的是,背景物体通常是为了装饰环境和吸引玩家而设计的。背景物体通常不参与实际的游戏逻辑。第二个限制是静止的背景物体实际上是运动的,并且当从除了引起运动视差的摄像机之外的摄像机观看时,将会出现这种情况。当在存在运动视差的情况下需要来自多个摄像机的视图时,仔细协调它们以避免玩家混淆是很重要的。

视差物体项目

这个项目演示了视差滚动。你可以在图 11-8 中看到这个项目运行的例子。这个项目的源代码在chapter11/11.2.parallax_objects文件夹中定义。

img/334805_2_En_11_Fig8_HTML.jpg

图 11-8

运行视差对象项目

该项目的控制措施如下:

  • P 键:在模拟视差滚动时,切换不运动的第二台摄像机的画面,以突出背景物体的运动

  • WASD 键:移动Dye角色(英雄)来平移厕所窗口边界

该项目的目标如下:

  • 为了理解和欣赏运动视差

  • 使用视差滚动模拟运动视差

定义 ParallaxGameObject 来实现视差滚动

视差滚动涉及到物体的连续滚动,TiledGameObject为永不停止的滚动提供了一个方便的平台。因此,ParallaxGameObject被定义为TiledGameObject的子类。

  1. src/engine/game_objects文件夹中创建parallax_game_object.js,并添加以下代码来构造对象:
import TiledGameObject from "./tiled_game_object.js";

class ParallaxGameObject extends TiledGameObject {
    constructor(renderableObj, scale, aCamera) {
        super(renderableObj);
        this.mRefCamera = aCamera;
        this.mCameraWCCenterRef =
                              vec2.clone(this.mRefCamera.getWCCenter());
        this.mParallaxScale = 1;
        this.setParallaxScale(scale);
    }
    ... implementation to follow ...
}
export default ParallaxGameObject;

ParallaxGameObject对象维护mRefCamera,一个对aCameramCameraWCCenterRef的引用,当前 WC 边界中心。这些值用于根据参考摄像机的运动计算相对运动,以支持视差滚动。scale参数是一个正值。scale值 1 表示对象位于默认距离,小于 1 的值表示对象在默认距离的前面。大于 1 的scale表示在默认距离之后的对象。scale值越大,物体离相机越远。

  1. mParallaxScale定义 getter 和 setter 函数。注意负值的箝位;此变量必须是正数。

  2. 覆盖update()函数实现视差滚动:

getParallaxScale() { return this.mParallaxScale; }
setParallaxScale(s) {
    this.mParallaxScale = s;
    if (s <= 0) {
        this.mParallaxScale = 1;
    }
}

update() {
    // simple default behavior
    this._refPosUpdate(); // check to see if the camera has moved
    super.update();
}

_refPosUpdate()功能是根据参考摄像机的 WC 中心位置计算相对位移的功能。

  1. 定义_refPosUpdate()功能:
_refPosUpdate() {
    // now check for reference movement
    let deltaT = vec2.fromValues(0, 0);
    vec2.sub(deltaT,
               this.mCameraWCCenterRef, this.mRefCamera.getWCCenter());
    this.setWCTranslationBy(deltaT);

    // update WC center ref position
    vec2.sub(this.mCameraWCCenterRef, this.mCameraWCCenterRef, deltaT);
}

deltaT变量记录摄像机的移动,setWCTranslationBy()移动物体模拟视差滚动。

  1. 定义函数来平移对象以实现视差滚动。底片delta的设计目的是将物体向与相机相同的方向移动。注意变量f是 1 减去mParallaxScale的倒数。
setWCTranslationBy(delta) {
    let f = (1 – (1/this.mParallaxScale));
    this.getXform().incXPosBy(-delta[0] * f);
    this.getXform().incYPosBy(-delta[1] * f);
}

mParallaxScale小于 1 时,倒数大于 1,f变为负数。在这种情况下,当相机移动时,对象将向相反的方向移动,从而产生对象在默认距离前面的感觉。

相反,当mParallaxScale大于 1 时,其倒数将小于 1,并导致正f的值小于 1。在这种情况下,物体的运动方向与相机的运动方向相同,只是速度较慢。更大的mParallaxScale将对应于更接近 1 的f值,并且对象的移动将更接近相机的移动,或者对象将看起来离相机更远。

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

在 MyGame 中测试 ParallaxGameObject

ParallaxGameObject的测试包括在摄像机运动时测试视差滚动的正确性,默认距离的前面和后面都有物体,同时从一个单独的固定摄像机观察ParallaxGameObjectMyGame级别的源代码与上一个项目的很大程度上相似,细节就不列出来了。为了说明如何创建ParallaxGameObject实例,列出了init()函数的相关部分。

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

    this.mParallaxCam = new engine.Camera(
        vec2.fromValues(40, 30), // position of the camera
        45,                      // width of camera
        [0, 420, 600, 300],      // viewport (orgX, orgY, width, height)
        2
    );
    this.mParallaxCam.setBackgroundColor([0.5, 0.5, 0.9, 1]);

    // Step B: the lights
    this._initializeLights();   // defined in MyGame_Lights.js

    // Step C: the far Background
    let bgR = new engine.IllumRenderable(this.kBg, this.kBgNormal);
    bgR.setElementPixelPositions(0, 1024, 0, 1024);
    bgR.getXform().setSize(30, 30);
    bgR.getXform().setPosition(0, 0);
    bgR.getMaterial().setSpecular([0.2, 0.1, 0.1, 1]);
    bgR.getMaterial().setShininess(50);
    bgR.getXform().setZPos(-5);

    // only the directional light
    bgR.addLight(this.mGlobalLightSet.getLightAt(1));
    this.mBg = new engine.ParallaxGameObject(bgR, 5, this.mCamera);

    // Step D: the closer Background
    let i;
    let bgR1 = new engine.IllumRenderable(
                                    this.kBgLayer, this.kBgLayerNormal);
    bgR1.getXform().setSize(25, 25);
    bgR1.getXform().setPosition(0, -15);
    bgR1.getXform().setZPos(0);
    // the directional light
    bgR1.addLight(this.mGlobalLightSet.getLightAt(1));
    // the hero spotlight light
    bgR1.addLight(this.mGlobalLightSet.getLightAt(2));
    // the hero spotlight light
    bgR1.addLight(this.mGlobalLightSet.getLightAt(3));
    bgR1.getMaterial().setSpecular([0.2, 0.2, 0.5, 1]);
    bgR1.getMaterial().setShininess(10);
    this.mBgL1 = new engine.ParallaxGameObject(bgR1, 3, this.mCamera);

    // Step E: the front layer
    let f = new engine.TextureRenderable(this.kBgLayer);
    f.getXform().setSize(50, 50);
    f.getXform().setPosition(-3, 2);
    this.mFront = new engine.ParallaxGameObject(f, 0.9, this.mCamera);

    ... identical to previous code ...
}

mBg对象创建为scale为 5 的ParallaxGameObjectscale为 3 的mBgL1scale为 0.9 的mFront。回想一下scaleParallaxGameObject构造函数的第二个参数。此参数表示物体与相机的距离,大于 1 的值表示距离更远,小于 1 的值表示距离更近。在这种情况下,mBg离摄像机最远,而mBgL1更近。无论如何,两者仍落后于默认距离。mFront物体离摄像机最近,在默认距离前或在Hero物体前。

观察

现在你可以运行这个项目,观察较暗的前景层部分遮挡了HeroMinion对象。你可以移动Hero物体来平移相机,观察两个背景层以不同的速度滚动。mBg物体距离更远,因此滚动速度比mBgL1物体慢。你还会注意到前层视差滚动的速度比所有其他物体都快,因此,平移相机会显示静止Minion物体的不同部分。

按 P 键启用第二台摄像机的绘制。请注意,当Hero静止时,该相机中的视图与预期一样,没有移动。现在,如果您移动Hero对象来平移主摄像机,请注意第二个摄像机视图中的前景和背景对象也在移动,并且表现出运动视差,即使第二个摄像机没有移动!作为游戏设计者,确保这种副作用不会困扰玩家是很重要的。

层管理

虽然你正在开发的引擎是为 2D 游戏开发的,但是你已经处理过一些深度排序和绘制顺序很重要的情况。例如,阴影接收器必须始终定义在阴影投射器的后面,正如前面的示例中所讨论的,前景和背景视差对象必须仔细定义,并按照其深度排序的顺序绘制。游戏引擎提供一个工具管理器来帮助游戏程序员管理和使用深度分层是很方便的。一个典型的 2D 游戏可以有以下层,按照离摄像机的距离从最近到最远的顺序:

  • 平视显示器(HUD)层:通常,最靠近显示重要用户界面信息的摄像机

  • 前景或前层:游戏对象前面的层,用于装饰或部分遮挡游戏对象

  • 演员层:图 11-5 中默认的距离层,所有游戏对象都驻留在此

  • 阴影接收层:演员层后面的层,用于接收潜在的阴影

  • 背景层:装饰背景

每个图层都将引用为该图层定义的所有对象,这些对象将按照它们插入图层的顺序进行绘制,最后插入的对象在最后绘制,覆盖对象在它之前。本节介绍了支持所述五层的Layer引擎组件,以将游戏程序员从管理更新和绘制对象的细节中解放出来。请注意,游戏引擎应该支持的层数由引擎设计构建的游戏种类决定。所呈现的五层对于简单的游戏来说是合乎逻辑和方便的。你可以选择在你自己的游戏引擎中增加层数。

图层管理器项目

这个项目演示了如何开发一个工具组件来帮助游戏程序员管理游戏中的层。你可以在图 11-9 中看到这个项目运行的例子。这个项目的源代码在chapter11/11.3.layer_manager文件夹中定义。

img/334805_2_En_11_Fig9_HTML.jpg

图 11-9

运行图层管理器项目

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

  • P 键:在模拟视差滚动时,切换不运动的第二台摄像机的画面,以突出背景物体的运动

  • WASD 键:移动染色角色(英雄)来平移厕所窗口边界

该项目的目标如下:

  • 为了理解分层在 2D 游戏中的重要性

  • 开发图层管理器引擎组件

引擎中的层管理

例如,遵循定义引擎组件的模式,类似于物理和粒子系统的模式:

  1. src/engine/components文件夹中创建一个新文件,并将其命名为layer.js。这个文件将实现Layer引擎组件。

  2. 为层定义枚举器:

  3. 定义适当的常数和实例变量来跟踪层。mAllLayers变量是代表五层中每一层的GameObjectSet实例的数组。

const eBackground = 0;
const eShadowReceiver = 1;
const eActors = 2;
const eFront = 3;
const eHUD = 4;

  1. 定义一个init()函数来创建GameObjectSet实例的数组:
let kNumLayers = 5;
let mAllLayers = [];

  1. 定义一个cleanUp()函数来重置mAllLayer数组:
function init() {
    mAllLayers[eBackground] = new GameObjectSet();
    mAllLayers[eShadowReceiver] = new GameObjectSet();
    mAllLayers[eActors] = new GameObjectSet();
    mAllLayers[eFront] = new GameObjectSet();
    mAllLayers[eHUD] = new GameObjectSet();
}

  1. 定义添加、移除和查询图层的函数。注意addAsShadowCaster()函数假设阴影接收器对象已经插入到eShadowReceiver层,并将投射对象添加到该层的所有接收器中。
function cleanUp() {
    init();
}

  1. 定义绘制特定层或所有层的函数,从最远到最近的摄像机:
function addToLayer(layerEnum, obj) {
    mAllLayers[layerEnum].addToSet(obj); }
function removeFromLayer(layerEnum, obj) {
    mAllLayers[layerEnum].removeFromSet(obj); }
function layerSize(layerEnum) { return mAllLayers[layerEnum].size(); }

function addAsShadowCaster(obj) {
    let i;
    for (i = 0; i < mAllLayers[eShadowReceiver].size(); i++) {
        mAllLayers[eShadowReceiver].getObjectAt(i).addShadowCaster(obj);
    }
}

  1. 定义一个函数来移动特定对象,使其最后绘制(在顶部):
function drawLayer(layerEnum, aCamera) {
    mAllLayers[layerEnum].draw(aCamera); }
function drawAllLayers(aCamera) {
    let i;
    for (i = 0; i < kNumLayers; i++) {
        mAllLayers[i].draw(aCamera);
    }
}

  1. 定义更新特定图层或所有图层的函数:
function moveToLayerFront(layerEnum, obj) {
    mAllLayers[layerEnum].moveToLast(obj);
}

  1. 记住导出所有已定义的功能:
function updateLayer(layerEnum) { mAllLayers[layerEnum].update(); }
function updateAllLayers() {
    let i;
    for (i = 0; i < kNumLayers; i++) {
        mAllLayers[i].update();
    }
}

export {
    // array indices
    eBackground, eShadowReceiver, eActors, eFront, eHUD,

    // init and cleanup
    init, cleanUp,

    // draw/update
    drawLayer, drawAllLayers,
    updateLayer, updateAllLayers,

    // layer-specific support
    addToLayer, addAsShadowCaster,
    removeFromLayer, moveToLayerFront,
    layerSize
}

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

修改引擎组件和对象

你必须稍微修改游戏引擎的其余部分来整合新的Layer组件。

增强游戏对象集功能

添加以下函数以支持将对象移动到集合数组的末尾:

moveToLast(obj) {
    this.removeFromSet(obj);
    this.addToSet(obj);
}

初始化 index.js 中的图层

除了导入/导出Layer组件外,修改index.js中的引擎init()cleanUp()函数来初始化和清理组件:

... identical to previous code ...
function init(htmlCanvasID) {
    glSys.init(htmlCanvasID);
    vertexBuffer.init();
    input.init(htmlCanvasID);
    audio.init();
    shaderResources.init();
    defaultResources.init();
    layer.init();
}

function cleanUp() {
    layer.cleanUp();
    loop.cleanUp();
    shaderResources.cleanUp();
    defaultResources.cleanUp();
    audio.cleanUp();
    input.cleanUp();
    vertexBuffer.cleanUp();
    glSys.cleanUp();
}

定义图层成员的更新函数

为可能作为成员出现在Layer : RenderableShadowReceiver中的对象定义更新函数。

修改 MyGame 以使用层组件

MyGame级别实现了与前一个项目相同的功能。唯一的区别是层管理委托给了Layer组件。以下描述仅关注与层管理相关的函数调用。

  1. 修改unload()函数来清理Layer:

  2. 修改init()函数,将游戏对象添加到Layer组件中的相应层:

unload() {
    engine.layer.cleanUp();

    engine.texture.unload(this.kMinionSprite);
    engine.texture.unload(this.kBg);
    engine.texture.unload(this.kBgNormal);
    engine.texture.unload(this.kBgLayer);
    engine.texture.unload(this.kBgLayerNormal);
    engine.texture.unload(this.kMinionSpriteNormal);
}

  1. 修改draw()功能,以依赖Layer组件进行实际绘图:
init() {
    ... identical to previous code ...

    // add to layer managers ...
    engine.layer.addToLayer(engine.layer.eBackground, this.mBg);
    engine.layer.addToLayer(engine.layer.eShadowReceiver,
                                               this.mBgShadow1);

    engine.layer.addToLayer(engine.layer.eActors, this.mIllumMinion);
    engine.layer.addToLayer(engine.layer.eActors, this.mLgtMinion);
    engine.layer.addToLayer(engine.layer.eActors, this.mIllumHero);
    engine.layer.addToLayer(engine.layer.eActors, this.mLgtHero);

    engine.layer.addToLayer(engine.layer.eFront, this.mBlock1);
    engine.layer.addToLayer(engine.layer.eFront, this.mBlock2);
    engine.layer.addToLayer(engine.layer.eFront, this.mFront);

    engine.layer.addToLayer(engine.layer.eHUD, this.mMsg);
    engine.layer.addToLayer(engine.layer.eHUD, this.mMatMsg);
}

  1. 修改update()函数,依靠Layer组件对所有游戏对象进行实际更新:
draw() {
    engine.clearCanvas([0.9, 0.9, 0.9, 1.0]); // clear to light gray

    this.mCamera.setViewAndCameraMatrix();
    engine.layer.drawAllLayers(this.mCamera);

    if (this.mShowParallaxCam) {
        this.mParallaxCam.setViewAndCameraMatrix();
        engine.layer.drawAllLayers(this.mParallaxCam);
    }
}

update() {
    this.mCamera.update();  // to ensure proper interpolated movement
    this.mParallaxCam.update();

    engine.layer.updateAllLayers();

    ... identical to previous code ...
}

观察

您现在可以运行该项目,并观察与上一个项目相同的输出和交互。对这个项目的重要观察是在实现中。通过在init()期间将游戏对象插入到Layer组件的适当层,游戏关卡的draw()update()功能可以更加简洁。更简单、更干净的update()功能尤为重要。这个函数现在可以专注于实现游戏逻辑和控制游戏对象之间的交互,而不是被平凡的游戏对象update()函数调用所困扰。

摘要

本章解释了平铺的必要性,并介绍了TileGameObject来实现一个简单的算法,平铺并覆盖给定的摄像机 WC 边界。介绍了视差的基本原理和用视差滚动模拟运动视差的方法。检查了静止和移动摄像机的运动视差,并推导和实现了解决方案。您已经了解到,计算相对于摄像机运动的移动来替换背景对象会产生视觉上令人满意的运动视差,但当从不同的摄像机观看时,可能会导致玩家混淆。随着早期引入的阴影计算和现在的视差滚动,游戏程序员必须投入代码和注意力来协调不同类型对象的绘制顺序。为了促进游戏引擎的可编程性,Layer引擎组件被呈现为一个实用工具,以将游戏程序员从管理层的绘制中解放出来。

这本书提出的游戏引擎现在已经完成。它可以用纹理贴图、精灵动画来绘制对象,甚至支持各种光源的照明。该引擎为简单行为定义适当的抽象,实现近似和精确计算碰撞的机制,并模拟物理行为。来自多个摄像机的视图可以方便地显示在相同的游戏屏幕上,具有平滑插值的操作功能。支持键盘/鼠标输入,现在背景对象可以无边界滚动,模拟运动视差。

重要的下一步,正确地测试你的引擎,是通过一个简单的游戏设计过程,并实现一个基于你新完成的游戏引擎的游戏。

游戏设计注意事项

在前面的章节中,您已经探索了如何从头开始开发一个简单的游戏机制,它可以引导许多方向并应用于各种类型的游戏。游戏设计工作室的创意团队经常争论游戏设计的哪些元素在创意过程中起主导作用:作家通常认为故事是第一位的,而许多设计师认为故事和其他一切都必须从属于游戏性。当然,没有正确或错误的答案;创作过程是一个混乱的系统,每个团队和工作室都是独一无二的。一些创意总监希望讲述一个特定的故事,并将寻找最适合支持特定叙事的机制和类型,而其他人则是游戏纯粹主义者,完全致力于“游戏性优先,其次,最后”的文化这个决定通常归结为理解你的听众;例如,如果你正在创建竞争性的多人第一人称射击游戏体验,消费者将对游戏的许多核心元素有特定的期望,确保游戏性驱动设计通常是一个明智的举动。然而,如果你正在创建一个冒险游戏,旨在讲述一个故事,并为玩家提供新的体验和意想不到的转折,故事和设定可能会引领潮流。

许多游戏设计者(包括经验丰富的老手和新手)开始新的项目时,设计的体验是对现有的熟知机制的相对较小的改变;虽然这种方法有很好的理由(如 AAA 工作室为特别苛刻的观众开发内容,或者希望与许多游戏中已被证明成功的机制合作),但它往往会极大地限制对新领域的探索,这也是许多游戏玩家抱怨同一类型游戏之间创意停滞和缺乏游戏多样性的一个原因。许多专业游戏设计师从小就喜欢某些类型的游戏,并梦想着基于我们所知道和喜爱的机制创造新的体验,几十年来,这种文化已经将行业的大部分注意力集中在相对较少的类似机制和惯例上。也就是说,近年来,一个快速增长的独立小型工作室社区已经大胆地开始抛弃长期存在的类型传统,移动应用商店和 Valve 的 Steam 等易于访问的分发平台为各种新的游戏机制和体验的蓬勃发展提供了机会。

如果你继续探索游戏设计,你会意识到完全独特的核心机制相对较少,但随着你将这些基本的交互构建到更复杂的因果链中,并通过与游戏设计的其他元素的优雅集成来添加独特的味道和纹理,创新的机会是无穷的。一些最具突破性和最成功的游戏是通过练习创建的,这些练习非常类似于您在这些“游戏设计考虑事项”部分中所做的机械探索;例如,Valve 的门户网站是基于你一直在探索的同类“逃离房间”沙盒,并且是围绕一个类似的简单基础机制设计的。是什么让《传送门》取得如此突破性的成功?虽然创建一个热门游戏需要许多东西,但 Portal 受益于一个设计团队,该团队开始从最基本的机制开始构建体验,并随着他们对其独特的结构和特征越来越熟悉而智能地增加复杂性,而不是从 10,000 英尺的高度开始,采用一种已编纂的类型和一套预定的设计规则。

当然,没有人在谈论传送门时不提到流氓人工智能角色 GLaDOS 和她的 Aperture Laboratories 游乐场:设置、叙事和视听设计对于传送门体验来说与传送门启动游戏机制一样重要,鉴于它们是如何巧妙地交织在一起,很难将游戏性与叙事分开。本章中的项目提供了一个很好的机会,让我们从*“游戏设计考虑事项”*部分开始,在一个独特的环境和背景中类似地定位游戏机制:你可能已经注意到本书中的许多项目都朝着科幻视觉主题发展,有穿着宇航服的英雄角色,各种各样的飞行机器人,现在在第十一章中引入了视差环境。虽然你没有像《传送门》一样构建一个环境和交互复杂程度相同的游戏,但这并不意味着你没有同样的机会来开发一个高度引人入胜的游戏环境、背景和角色。

关于平铺对象项目,你应该注意到的第一件事是与早期项目相比,对环境体验和规模的巨大影响。在这个项目中,增强存在感的因素是三个独立移动的层(英雄人物、移动的墙和静止的墙)以及两个背景层的无缝拼接。将平铺对象项目与第八章中的阴影着色器项目进行比较,并注意当环境被分成多个层时存在的差异,这些层似乎以类似于(如果不是物理上准确的)您在物理世界中体验运动的方式移动。在视差物体项目中加入多个视差运动的背景层,存在感进一步加强;当你在物质世界中移动时,环境似乎以不同的速度移动,较近的物体似乎快速通过,而靠近地平线的物体似乎移动缓慢。视差环境对象模拟了这种效果,为游戏环境增加了相当大的深度和趣味性。层管理器项目将所有的事情整合在一起,并开始展示游戏设置的潜力,立即吸引玩家的想象力。只需几项技术,你就能创造出一个巨大环境的印象,这个环境可能是古代外星机器的内部,大型宇宙飞船的外部,或者你想创造的任何东西。尝试用这种技术使用不同种类的图像资产:外部景观、水下位置、抽象形状等等都是有趣的探索。你经常会通过尝试一些基本元素来寻找游戏设置的灵感,就像你在第十一章中所做的那样。

将环境设计(音频和视觉)与交互设计(偶尔包括类似控制器振动的触觉反馈)相结合,是一种可以用来创建和增强临场感的方法,环境和交互与游戏机制的关系贡献了玩家在游戏中的大部分体验。环境设计和叙事背景创造了游戏设定,正如前面提到的,最成功和最令人难忘的游戏实现了游戏设定和玩家体验之间的完美和谐。在这一点上,来自第九章*【游戏设计注意事项】部分的游戏机制已经被有意地去除了任何游戏设定背景,你只是简单地考虑了交互设计,让你自由地探索任何你感兴趣的设定。在第十二章中,你将通过“游戏设计注意事项”*部分的解锁机制进一步发展主要章节项目中使用的科幻设定和图像资产,以创建一个相当先进的 2D 平台游戏级原型。

十二、构建一个示例游戏:从设计到完成

第 1 到 11 章节主要章节所包含的项目,从简单的形状开始,慢慢引入人物和环境,来说明每一章的概念;这些项目专注于个人行为和技术(如碰撞检测、物体物理、照明等),但缺乏提供完整游戏体验所需的结构化挑战。*“设计考虑”部分的项目展示了如何引入将基本行为转化为良好的游戏机制所需的逻辑规则和挑战。本章现在改变重点,强调从早期概念到功能原型的设计过程,通过使用前几章中的一些角色和环境以及第十一章“设计考虑事项”*部分中解锁平台游戏的基本思想,将早期项目中所做的工作汇集并扩展。和前面的章节一样,这里使用的设计框架从一个简单灵活的初始模板开始,并有意识地增加复杂性,以允许游戏以可控的方式发展。

到目前为止,设计练习一直避免考虑游戏设计的九个要素中的大部分,这些要素在*“你如何制作一个伟大的视频游戏?”第一章的*部分,而是专注于制作基本的游戏机制,以便清晰地定义和提炼游戏本身的核心特征。本书中使用的设计方法是一个全新的框架,强调在考虑游戏的类型或设定之前,首先使用一个孤立的游戏机制;当你开始加入一个设定,并在核心机制的基础上构建包含额外设计元素的关卡时,随着游戏世界的发展,游戏性将会朝着独特的方向发展和进化。一个游戏的机制和你设计的相关游戏循环有无穷无尽的潜在变化。你会惊讶地发现,基于你所做的创造性选择,同样的游戏基本元素会有多么不同的发展和演变。

第一部分:提炼概念

至此,您应该已经有了使用 2D 跳跃和解谜机制的概念,该机制围绕解锁障碍和获得奖励展开。从第十一章中调出图 12-1 作为最终的屏幕布局和设计。

img/334805_2_En_12_Fig1_HTML.jpg

图 12-1

第十一章 2D 实现从

这个设计已经有了一个多阶段的解决方案,要求玩家既要展示基于时间的灵活性,又要展示解谜逻辑。在目前的设计中,玩家控制英雄角色(可能通过使用 A 和 D 键左右移动,使用空格键跳跃)。玩家可以在同一层的水平平台之间跳跃,但如果不使用升降的中间“电梯”平台,就无法到达上面的平台。如果玩家触摸一个水平的“能量场”,它就会电击玩家,导致游戏重置。完成该级别的明确步骤如下:

  1. 玩家必须在移动的电梯平台(图 12-1 中的#1)上跳下英雄角色(图 12-1 中间带字母 p 的圆圈)并跳下到右侧立柱的中间平台,才能触碰到能量场。

  2. 玩家通过碰撞英雄角色来激活能量场的关闭开关(#2,由图 12-1 中的灯泡图标表示)。

  3. 当能量场关闭时,玩家乘坐电梯平台到达顶部(#3),并使英雄跳到右栏的顶部平台。

  4. 玩家将英雄与代表锁图标上三分之一的小圆圈(#4)碰撞,激活锁图标的相应部分,使其发光。

  5. 玩家将英雄跳回电梯平台(#5),然后将英雄跳到右栏的底部平台。

  6. 玩家将英雄与锁图标中间部分对应的形状(#6)碰撞,激活锁图标对应的部分,使其发光。三分之二的锁图标现在发光,表示进度。

  7. 玩家在电梯平台上再跳一次英雄(#7),然后将英雄跳至左栏顶部平台。

  8. 玩家将英雄与锁图标(#8)底部对应的形状碰撞,激活图标的最后一部分,解锁关卡。

鉴于你已经创建的模拟屏幕,写出这个序列(或游戏流程图)可能看起来没有必要。然而,对于设计师来说,重要的是要理解玩家必须按照准确的顺序和细节做的每一件事,以确保你能够调整、平衡和发展游戏,而不会陷入复杂性或忘记玩家如何通过关卡。从前面的游戏流程图中可以清楚地看到,例如,电梯平台是这一关的核心,是完成每个动作所必需的;这是示意图和游戏流程描述中可用的重要信息,因为它提供了一个机会来智能地完善游戏逻辑,使您可以可视化每个变化对整个关卡流程的影响。

你可以继续构建机制,使关卡更有趣和更具挑战性(例如,你可以在能量场的关闭开关上包括一个计时器,要求玩家在有限的时间内碰撞所有的锁部件)。然而,在概念开发的这个阶段,从游戏性后退一步,开始考虑游戏设置和类型,使用这些元素来帮助告知游戏机制如何从这里演变,通常是有帮助的。

回想一下第十一章,这些项目以一系列支持科幻场景的概念探索结束。图 12-2 展示了一个未来派的工业环境设计,一个穿着太空服的英雄角色,以及看起来像是会飞的机器人。

img/334805_2_En_12_Fig2_HTML.jpg

图 12-2

第十一章中的概念

请注意,你一直在创造的游戏机制没有任何具体的东西会把你引向科幻的方向;游戏机制是抽象的互动结构,通常可以与任何类型的设置或视觉风格相结合。在这种情况下,作者选择了一个发生在宇宙飞船上的背景,所以本章将使用这个主题作为游戏原型的背景。在设计过程中,考虑探索其他场景:第十一章中的游戏机制如何适应丛林场景、当代城市场景、中世纪幻想世界或水下大都市?

第二部分:集成设置

现在是开始分配一些基本的虚构背景的好时机,以独特的方式进化和扩展游戏机制,增强你选择的设置(不要担心,如果现在这还不清楚;随着关卡设计的进行,这个机制会变得更加明显。例如,想象一下,英雄人物是一艘大型宇宙飞船上的一名船员,她必须完成许多目标以防止飞船爆炸。再一次,没有任何关于驱动这个故事的游戏机制的现状;这一阶段的设计任务包括头脑风暴一些虚构的背景,推动玩家通过游戏并捕捉他们的想象力。使用已经创建/提供的一些概念艺术资产,英雄可以很容易地参与一场比赛,寻找丢失的东西,探索一艘被遗弃的外星船只,或者一百万种其他可能性中的任何一种。

背景图片赋予场景以生命

现在,您已经描述了一个基本的叙事和虚构的包装,内容类似于“玩家必须完成一系列平台谜题关卡,以在飞船爆炸前拯救飞船”,只需将之前早期原型的一些形状与一些包含的概念元素进行交换。图 12-3 介绍了一个人形英雄角色,感觉有点像飞船部件的平台,以及一个带锁的门的障碍墙,以取代机械设计中的抽象锁。

img/334805_2_En_12_Fig3_HTML.jpg

图 12-3

引入几个视觉设计元素来支持游戏设定和不断发展的叙事

虽然你只做了一些小的替换,还没有将视觉元素固定在一个环境中,但图 12-3 比图 12-1 的抽象形状传达了更多的虚构背景,并对在场做出了更大的贡献。英雄角色现在建议了一个尺度,当玩家将相对大小与人形进行比较时,该尺度将自然地被上下文化,这将整个游戏环境的相对大小带入玩家的焦点。在第十章中描述的英雄角色的物理实现也成为游戏的一个重要组成部分:模拟的重力、动量等将玩家与英雄角色在游戏世界中的移动联系起来。通过实现图 12-3 中描述的设计,你已经完成了一些令人印象深刻的认知壮举,仅仅通过添加一些视觉元素和一些物体物理就可以支持在场。

定义可玩空间

在设计过程的这一点上,你已经充分描述了游戏的核心机制和设置,开始将单个屏幕扩展为一个完整的概念。在这个阶段定义一个最终的视觉风格并不重要,但是包含一些概念艺术将有助于指导水平如何增长。(图 12-3 提供了一个很好的视觉展示,展示了在给定对象比例的情况下,在单个屏幕上将会发生的游戏数量。)这也是一个很好的阶段,将图 12-3 中的元素“封闭”在一个工作原型中,开始感受运动的感觉(例如,英雄角色奔跑的速度,英雄可以跳跃的高度,等等),环境中物体的比例,相机的变焦水平,等等。在这个阶段不需要包括交互和行为,比如锁组件或能量场,因为你还没有设计关卡将如何进行。现在你正在试验基本的英雄角色移动、物体放置和碰撞。下一组任务包括布局整个级别和调整所有交互。

图 12-3 中设计的当前状态仍然需要一些工作来提供足够的挑战。虽然一个结构良好的关卡的所有要素都已到位,但当前的难度是微不足道的,大多数玩家将可能能够快速完成该关卡。然而,有一个强大的基础来开始扩展跳跃和排序机制;首先,你可以扩展水平游戏空间以包含更多可玩的区域,并为角色提供额外的活动空间,如图 12-4 所示。

img/334805_2_En_12_Fig4_HTML.jpg

图 12-4

关卡设计增加了额外的可玩区域

回想一下第七章中的简单相机操作项目,你可以通过移动角色靠近边界区域的边缘来“推动”游戏屏幕向前,这允许你设计一个远远超出单一静态屏幕尺寸的关卡。当然,你可能会选择将这个级别限制在原始游戏屏幕的大小之内,并增加基于时间的敏捷性和逻辑顺序挑战的复杂性(事实上,这是一个挑战自己在空间限制内工作的良好设计练习),但出于这种设计的目的,水平滚动演示增加了兴趣和挑战。

向可玩空间添加布局

现在是时候开始布置楼层了,以充分利用额外的水平空间。在这一点上没有必要改变基本的游戏玩法;你只需要扩展当前的关卡设计来适应游戏屏幕的新尺寸。图 12-5 包括一些额外的平台,除了确保玩家可以成功到达每个平台外,没有特别的方法。

img/334805_2_En_12_Fig5_HTML.jpg

图 12-5

扩展布局以使用额外的屏幕空间,该图显示了第一阶段的整个长度,玩家可以在任何时候看到整个关卡的大约 50%。当玩家向屏幕边界区域移动英雄角色时,摄像机向前或向后滚动屏幕。注意:对于所示的移动平台,深色箭头表示方向,浅色箭头表示平台移动的范围

现在这个级别有了一些额外的工作空间,有几个因素需要评估和调整。例如,图 12-5 中的英雄角色的比例已经缩小,以增加单个屏幕上可以执行的垂直跳跃次数。请注意,在这一点上,如果需要的话,你也有机会在设计中加入额外的垂直游戏,实现你用来左右移动相机的相同机制来上下移动相机;许多 2D 平台游戏允许玩家在游戏世界中横向和纵向移动。为了简单起见,这个关卡原型将限制移动到 x 平面(左和右),尽管您可以很容易地扩展关卡设计,以便在未来的迭代和/或后续的关卡中包括垂直游戏。

当你在关卡中放置平台时,你会再次希望在阻止游戏流程的同时最小化设计的复杂性。图 12-5 增加了一个额外的设计元素:一个从左向右移动的平台。使用与图 12-1 所示相同的编号方法,尝试列出图 12-5 中三个锁段启动所需的详细顺序。当你完成绘制顺序后,将其与图 12-6 进行比较。

img/334805_2_En_12_Fig6_HTML.jpg

图 12-6

打开屏障最有效的顺序

你的顺序符合图 12-6 吗,或者你有额外的步骤?有许多潜在的路径可供玩家选择来完成这一关,很可能没有两个玩家会选择相同的路线(机械设计的唯一要求是从上到下按顺序激活锁部分)。

调整挑战并增加乐趣

在设计的这个阶段是拼图制作过程真正开始的时候;图 12-6 展示了只用你一直在使用的几个基本元素来创造高度吸引人的游戏的潜力。作者在多种游戏的头脑风暴会议中使用了之前的模板和类似的变化——向一个众所周知的机制引入一两个新元素,并探索新添加的元素对游戏性的影响——结果通常会开辟令人兴奋的新方向。作为一个例子,你可以介绍出现和消失的平台,开关被激活后旋转的平台,移动的能量场,传送站,等等。构建这种机制的方法当然是无限的,但是当前的模板有足够的定义,添加一个新元素是相当容易试验和测试的,即使是在纸上。

新扩展的关卡设计有两个因素增加了挑战。首先,水平移动平台的增加需要玩家更精确地计算跳跃到“电梯”平台的时间(如果他们在平台上升时跳跃,在它电击他们之前几乎没有时间停用能量场)。第二个因素不太明显,但同样具有挑战性:在任何时候只有一部分关卡是可见的,所以玩家无法轻松地创建整个关卡序列的心理模型,就像他们可以在单个屏幕上看到整个布局一样。对于设计师来说,理解明确的挑战(例如要求玩家在两个移动平台之间进行时间跳跃)和不太明显(通常是无意的)的挑战(例如在任何给定时间只能看到关卡的一部分)都很重要。回想一下你玩过的一个游戏,感觉设计者期望你记住太多的元素;这种挫折感通常是由于无意的挑战超过了玩家在短期记忆中可以合理保持的内容。

作为一名设计师,你需要意识到隐藏的挑战和无意的挫折或困难;这些是为什么尽可能早和经常地观察人们玩你的游戏是至关重要的关键原因。一般来说,任何时候你 100%确定你已经设计出完美的东西,至少有一半玩你的游戏的人会告诉你完全相反的事情。虽然详细讨论用户测试的好处超出了本书的范围,但是你应该计划观察人们从最早的概念验证一直到最终发布都在玩你的游戏。没有什么可以替代你从观看不同的人演奏你设计的作品中获得的洞察力。

前面图中描述的关卡目前假设英雄角色只能在平台上休息;虽然没有设计计划说明如果角色错过一次跳跃并掉到屏幕底部会发生什么,但玩家可能会合理地想象这会导致失败并触发游戏重置。如果你在关卡中增加了一个“地板”,玩家的策略将会发生显著的变化;除了消除重大风险,玩家还可以直接进入电梯平台,如图 12-7 所示。

img/334805_2_En_12_Fig7_HTML.jpg

图 12-7

游戏世界增加了一个“地板”,极大地改变了关卡挑战

进一步调整:引入敌人

你现在正在尝试关卡布局的变化,以使其随着设定而发展,并寻找增加玩家参与度的方法,同时也增加挑战(如果需要的话)。在添加楼层之前,关卡有两个风险:未能降落在平台上并触发损失条件,以及与能量场碰撞并触发损失条件。地板的添加消除了坠落的风险,并潜在地降低了关卡的挑战性,但你可能会决定地板鼓励玩家更自由地探索、实验和更密切地关注环境。你现在也越来越熟悉这种机制和布局的游戏性和流程,所以让我们引入一个新元素:攻击敌人(我们不能让前几章的机器人设计浪费掉)!图 12-8 介绍了两种基本的敌方机器人类型:一种发射炮弹,一种只是巡逻。

img/334805_2_En_12_Fig8_HTML.jpg

图 12-8

关卡中引入了两种新的物体类型:垂直移动并以恒定速度射击的射击机器人(#1)和在特定范围内来回移动的巡逻机器人(#2)

你现在已经到达了这一关设计的转折点,这里的设置开始对机制和游戏循环的发展产生重大影响。机制的核心从第十一章开始就没有改变,这一关基本上仍然是按照正确的顺序激活锁的各个部分来移除障碍,但是移动的平台和攻击的敌人是额外的障碍,并且受到你选择的特定设置的强烈影响。

当然,你当然可以添加攻击敌人的行为,同时仍然使用抽象的形状和纯力学。然而,值得注意的是,一个机制变得越复杂和多阶段,设置就越需要符合实现;这就是为什么从纯粹抽象的机械设计过渡到在一个特殊的环境中设计一个关卡(或者一个关卡的一部分)是很有帮助的,因为机械仍然是相当基本的。设计者通常希望游戏机制能与游戏环境深度融合,因此让两者协同发展是有益的。找到最佳点可能很有挑战性:有时机械师主导设计,但随着场景的演变,它通常会进入驾驶员的位置。过早引入设定,你会失去对精炼纯粹游戏性的关注;太晚引入设定,游戏世界可能会感觉像是一种事后的想法或者是附加的东西。

总则

回到当前的设计,如图 12-8 所示,你现在拥有了在新兴环境中创造一个真正吸引人的序列所需的所有元素。你也可以很容易地调整单个单位的移动和位置,使事情变得更具挑战性或更不具挑战性。玩家将需要观察平台和敌人的运动模式来计时他们的跳跃,这样他们就可以在不被电击或撞击的情况下导航,同时发现并解决解锁难题。请注意这一关是如何迅速地从非常容易完成变成潜在的相当具有挑战性:使用多个移动平台增加了复杂性,并且需要使用跳跃的时间和避免攻击敌人——即使是图 12-8 中锁定基本运动模式的简单敌人——以可控和故意的方式创造出几乎无限的可能性。

如果你还没有,现在是一个用代码原型化你的关卡设计(包括交互)来验证游戏性的好时机。对于这个早期的原型,重要的是主要的行为(奔跑、跳跃、发射炮弹、移动平台、物体激活等等)和完成关卡所需的步骤(拼图序列)都要正确实现。一些设计师在这个阶段坚持认为,以前从未遇到过这个关卡的玩家应该能够在很少或没有帮助的情况下玩完整个游戏,并完全理解他们需要做什么,而其他人则愿意提供指导,并填补屏幕上缺失的 UI 和不完整的谜题序列。通常的做法是在这个阶段测试和验证游戏的主要部分,并为玩家提供额外的指导,以弥补不完整的 UI 或序列中未实现的部分。一般来说,在这个阶段,你越不需要依赖玩家的指导,你对整体设计的洞察力就越强。您在这个阶段实现的早期原型的数量也取决于您设计的规模和复杂性。大型和高度复杂的关卡可以在整个关卡可以一次玩完之前分几个(或许多)部分来实现和测试,但是即使在大型和复杂的关卡的情况下,目标也是尽可能早地获得完整的可玩体验。

Note

如果你一直在探索这本书里的工作原型,你会发现在这一章的设计概念和可玩等级之间有一些细微的变化(例如,能量场不包括在工作原型里)。考虑使用包含的资产探索替代设计实现;探索和即兴创作是创造性设计过程的关键要素。你能创建多少个当前机械师的扩展?

第三部分:集成附加设计元素

您在本章中构建的原型可以作为当前开发水平的完整游戏的有效概念证明,但它仍然缺少完整游戏体验通常所需的许多元素(包括视觉细节和动画、声音、评分系统、获胜条件、菜单和用户界面[UI]元素等)。在游戏术语中,原型水平现在处于封锁加阶段(封锁是一个用于描述原型的术语,包括布局和功能游戏,但缺乏其他设计元素;包含一些额外的概念艺术是这里的“加分项”)。现在是开始探索音频、评分系统、菜单和屏幕 UI 等的好时机。如果这个原型是在一个游戏工作室生产的,一个小团队可能会把当前的水平提高到最终的生产水平,同时另一个团队工作来设计和制作其他水平的原型。一个单独的关卡或关卡的一部分被称为垂直部分,这意味着游戏的一个小部分包括了将随最终产品一起发布的所有东西。创建垂直切片有助于团队关注最终体验的外观、感觉和声音,并可用于验证 playtesters 的创意方向。

视觉设计

尽管你已经开始整合一些与设定和叙事相一致的视觉设计资产,但游戏通常在这个时候只有很少(如果有的话)的最终制作资产,任何动画都将是粗糙的或尚未实现的(游戏音频也是如此)。虽然让游戏性与游戏设置并行发展是一个很好的做法,但工作室不希望浪费时间和资源来创建制作资产,直到团队确信关卡设计已经锁定,并且他们知道需要什么对象以及它们将被放置在哪里。

你现在应该有了一个相当好的关卡设计的布局和序列(如果你一直在试验一个与示例中所示不同的布局,确保你有一个完整的游戏流程,如图 12-1 和 12-6 所示)。)在项目的这一点上,你可以自信地开始“重新调整”生产资产(中的重新调整是游戏工作室使用的一个术语,意思是随着时间的推移增加分辨率——在这种情况下,是关卡的视觉效果和整体生产质量)。重新分区通常是一个多阶段的过程,从关卡设计的主要元素被锁定时开始,并且可以持续大部分活动生产计划。通常有数百个(或数千个)单独的资产、动画、图标等,它们通常需要基于它们在游戏构建外部和游戏构建内部的不同而被调整多次。在孤立和实体模型中看起来很和谐的元素,在整合到游戏中后往往会有很大的不同。

*重新划分资产的过程可能是乏味和令人沮丧的(资产似乎总是比你想象的要多一个数量级)。让游戏中的东西看起来像艺术家的模型一样棒也是一个挑战。然而,当这一切开始走到一起时,这通常是一种令人满意的体验:当关卡设计从封闭过渡到完美的生产关卡时,神奇的事情发生了,通常会有一个构建,其中一些关键的视觉资产已经进来,使团队评论说:“哇,现在这感觉像我们的游戏!”对于 AAA 3D 游戏,这些“哇”的时刻经常发生,因为高分辨率纹理被添加到 3D 模型中,复杂的动画、灯光和阴影使世界变得栩栩如生;对于目前的原型水平,添加平行的背景和一些局部的灯光效果应该真的使飞船设置流行。

这本书包含的工作原型代表了最终游戏的构建,通常介于封锁和成品润色之间。英雄角色包括几个动画状态(空闲、奔跑、跳跃),英雄和机器人上的局部照明增加了视觉兴趣和戏剧性,关卡具有两层平行平行背景,法线贴图对照明做出响应,主要游戏行为都在适当的位置。你可以在这个原型的基础上继续完善游戏,或者按照你认为合适的方式修改它。

游戏音频

许多新的游戏设计师(甚至一些资深设计师)错误地认为音频没有视觉设计重要,但正如每个游戏玩家都知道的那样,在某些情况下,糟糕的音频可能意味着你喜欢的游戏和你很快就停止玩的游戏之间的差异。与视觉设计一样,音频通常直接有助于游戏机制(例如,倒计时定时器、警笛、指示敌人位置的位置音频),背景音乐增强了戏剧性和情感,就像导演使用乐谱来支持电影中的动作一样。然而,手机游戏中的音频通常被认为是可选的,因为许多玩家在他们的移动设备上静音。然而,设计良好的音频甚至可以对手机游戏的临场感产生巨大的影响。除了与游戏对象相对应的声音(行走的角色的行走声音,开火的敌人的射击声音,弹出的东西的弹出声音等等),游戏内动作附带的上下文音频是玩家重要的反馈机制。菜单选择、激活游戏中的开关等都应该评估潜在的音频支持。作为一般规则,如果游戏中的对象响应玩家的交互,应该评估它的上下文音频。

音频设计师与关卡设计师一起工作,创建一个需要声音的游戏对象和事件的综合回顾,随着视觉效果的重新调整,相关的声音通常会随之而来。游戏声音经常落后于视觉设计,因为音频设计师想知道他们创造声音的目的;例如,如果你看不到机器人长什么样,也看不到它是如何移动的,就很难创造出“机器人行走”的声音。就像设计师希望将游戏设置和机械紧密结合一样,音频工程师希望确保视觉和音频设计能够很好地协同工作。

交互模型

目前的原型使用一种常见的交互模型:键盘上的 A 和 D 键左右移动角色,空格键用于跳跃。世界中的物体激活仅仅是通过英雄人物与物体的碰撞而发生的,对于这些交互来说,设计的复杂性是相当低的。然而,想象一下,当你继续构建这个机制时(也许在后面的关卡中),你包括了角色发射射弹和收集游戏物品以储存在库存中的能力。随着游戏中可能的交互范围的扩大,复杂性可能会急剧增加,并且无意的挑战(如前所述)可能会开始积累,这可能会导致坏的玩家沮丧(与“好的”玩家沮丧相反,如前所述,好的玩家沮丧是有意设计的挑战的结果)。

了解在不同平台之间调整交互模型时遇到的挑战也很重要。最初为鼠标和键盘设计的交互在转移到游戏控制台或基于触摸的移动设备时通常面临相当大的困难。与游戏控制器不精确的拇指棒相比,鼠标和键盘交互方案允许极其精确和快速的移动,尽管触摸交互可以是精确的,但移动屏幕往往明显较小,并且被覆盖游戏区域的手指所遮挡。该行业花了多年时间和迭代来适应第一人称射击游戏(FPS)类型,从使用鼠标和键盘到游戏控制台,并且在第一次移动 FPS 体验推出十多年后,触摸设备的 FPS 惯例仍然高度可变(部分是由于市场上许多手机和平板电脑的处理能力和屏幕尺寸的差异)。如果你计划开发一款跨平台的游戏,确保你在开发游戏时考虑了每个平台的独特需求。

游戏系统和元游戏

目前的原型有几个系统需要平衡,也没有整合元游戏,但想象一下添加需要平衡的元素,如对象激活或能量场的可变长度计时器。如果你不确定这意味着什么,考虑下面的场景:英雄人物有两种潜在的方法去激活能量场,每种选择都是一种权衡。第一个选项可能会永久停用能量场,但会产生更多的敌人机器人,并大大增加到达目标对象的难度,而第二个选项不会产生更多的机器人,但只会在短时间内停用能量场,这需要玩家选择最有效的路径并执行近乎完美的计时。为了在这两个选项之间取得有效的平衡,您需要理解与每个系统相关的设计和挑战程度(无限时间和有限时间)。类似地,如果你给英雄角色增加生命值,让射击机器人创造 x 的伤害量,而冲锋的仆从每击创造 y 的伤害量,你会想要理解通往目标的路径之间的相对权衡,也许会使一些路径不那么危险但导航更复杂,而另一些路径可能导航更快但更危险。

与当前设计的大多数其他方面一样,在元游戏的开发中有许多方向可以选择;当玩家玩一个原型级别风格的完整游戏时,你能为他们提供什么额外的正面强化或总体环境?举一个例子,想象玩家必须收集一定数量的物体才能进入最终区域,并防止船只爆炸。也许每一关都有一个对象,要求玩家在进入之前解决某种谜题,只有在收集到该对象后,他们才能解决该关的开门组件。或者,也许每个级别都有一个对象,玩家可以访问以解锁电影,并了解更多关于船上发生的事情,以达到如此可怕的状态。或者也许玩家能够以某种方式禁用敌人的机器人并收集分数,目标是在游戏结束时收集尽可能多的分数。也许你会选择完全放弃传统的输赢条件。游戏并不总是将明确的输赢条件作为元游戏的核心组成部分,对于越来越多的当代游戏,尤其是独立游戏,它更多的是关于旅程而不是竞争体验(或者竞争元素变得可选)。也许你可以找到一种方法,将竞争性方面(例如,获得最高分或在最短时间内完成每一关)和更注重提高游戏性的元游戏元素结合起来。

关于系统和元游戏的最后一点说明:玩家教育(通常通过游戏内教程实现)通常是这些过程的重要组成部分。设计者非常熟悉他们设计的机制是如何工作的,控制是如何工作的,并且很容易(也很常见)忘记游戏对于第一次遇到它的人会是什么样子。早期和频繁的游戏测试有助于提供关于玩家需要多少解释才能理解他们需要做什么的信息,但是大多数游戏需要某种程度的教程支持来帮助教授游戏世界的规则。教程设计技术超出了本书的范围,但是当玩家玩一个或多个入门关卡时,教他们游戏的逻辑规则和交互通常是最有效的。向玩家展示你想让他们做的事情也比让他们阅读大段文字更有效(研究表明,许多玩家从不访问可选教程,并且会在不阅读的情况下解雇文本过多的教程;每个辅导活动一两个非常简短的句子是一个合理的目标)。如果你正在为你的原型创建一个内部教程系统,你将如何实现它?你认为玩家自己会合理地发现什么,而你可能需要在教程中向他们展示什么?

用户界面(UI)设计

游戏 UI 设计不仅从功能的角度(游戏中的菜单、教程和上下文相关的重要信息,如健康、分数等)来看很重要,而且作为体验的整体设置和视觉设计的贡献者也很重要。游戏 UI 是视觉游戏设计的核心组成部分,经常被新设计师忽略,这可能意味着人们喜欢的游戏和没人玩的游戏之间的差异。回想一下你玩过的游戏,这些游戏利用了复杂的库存系统,或者有许多级别的菜单,你必须通过这些菜单才能访问常用功能或物品;你还记得在那些游戏中,你经常需要浏览多个子关卡来完成经常使用的任务吗?或者是需要你记住复杂的按钮组合来访问普通游戏对象的游戏?

优雅且符合逻辑的用户界面对玩家的理解至关重要,但是集成到游戏世界中的用户界面也支持游戏设置和叙事。使用当前的原型和提议的系统设计作为参考,你将如何以一种支持设定和美学的方式可视化地表现游戏 UI?如果你以前没有花时间评估 UI(即使你有),重新访问几个具有科幻设置的游戏,并特别注意它们如何在游戏屏幕中视觉上集成 UI 元素。图 12-9 显示了内脏游戏的死亡空间 3 中的武器定制 UI:注意界面设计是如何完全嵌入到游戏设定中的,表现为虚拟飞船上的信息屏幕。

img/334805_2_En_12_Fig9_HTML.jpg

图 12-9

内脏游戏《死亡空间 3》中的大部分用户界面元素完全呈现在游戏场景和小说中,菜单以全息投影的形式出现,由英雄角色调用,或者出现在游戏世界中的物体上(图片版权归电子艺界所有)

许多游戏选择将它们的 UI 元素放在游戏屏幕的保留区域(通常在外部边缘周围),这些区域不与游戏世界直接交互;然而,将视觉美感与游戏设置相结合是另一种直接促成游戏存在的方式。想象一下当前的科幻原型例子,它有一个以幻想为主题的用户界面和菜单系统,使用中世纪的美学设计和书法字体,用于像 Bioware 的龙腾世纪这样的游戏;由此产生的不匹配将是不和谐的,很可能会把玩家从游戏环境中拉出来。用户界面设计是一门复杂的学科,很难掌握;然而,花时间确保将直观、可用、美观的 UI 集成到你创建的游戏世界中,会对你有好处。

游戏叙事

在这个阶段,您只是向原型示例添加了一个基本的叙事包装:一个英雄角色必须完成许多目标,以防止他们的飞船爆炸。目前,你还没有明确地与玩家分享这个故事,他们没有办法知道环境是在一艘宇宙飞船上,或者除了最终打开屏幕最右边的门之外的目标是什么。设计师有很多向玩家展示游戏叙事的选择;您可以创建一个介绍性的电影或动画序列,向玩家介绍英雄人物、他们的飞船和危机,也许可以选择一些简单的东西,如在关卡开始时弹出一个窗口,其中有简短的介绍文本,为玩家提供所需的信息。或者,你可以不提供任何关于游戏开始时发生了什么的信息,而是选择随着玩家在游戏世界中的进展,慢慢揭示船的可怕情况和目标。你甚至可以选择保留任何隐含的叙事元素,允许玩家覆盖他们自己的解释。与游戏设计的许多其他方面一样,没有单一的方式向玩家介绍叙事,也没有通用的指南来说明要获得令人满意的体验需要多少叙事。

叙事也可以被设计师用来影响关卡的发展和构建方式,即使这些元素从来没有向玩家展示过。在这种原型的情况下,它有助于设计师想象爆炸船只的威胁,以推动英雄人物带着紧迫感通过一系列挑战;然而,玩家可能会体验到一个结构良好的侧滚动作平台,只有一系列非常聪明的关卡。你可以围绕感染了病毒的机器人创造额外的虚构故事,使它们转而攻击英雄,作为它们攻击行为的一个原因(这只是一个例子)。通过创建一个叙事框架来展开动作,你可以做出明智的决定来扩展机制,即使你不与玩家分享所有的背景,也会感觉很好地融入到设置中。

当然,一些游戏体验实际上没有明确的叙事元素,无论是否向玩家公开,只是简单地实现了新颖的机制。像 Zynga 的 Words with Friends 和 Gabriele Cirulli 的超休闲 2048 这样的游戏是纯粹基于没有叙事包装的机制的游戏体验的例子。

如果你继续开发这个原型,你会选择包含多少叙事,你会向玩家透露多少以使游戏变得生动?

奖励内容:在关卡中增加第二个阶段

如果你已经完成了包括原型的第一阶段,你将进入第二个房间,有一个大的移动单元;这是一个沙盒,有一组资产供您探索。原型实现只包括一些激发你想象力的基本行为:一个大型的动画 boss 单元在房间里盘旋,产生一种新的敌人机器人,它寻找英雄角色,每隔几秒钟就产生一个新的单元。

图 12-10 显示了一个你一直用于原型基本机械的布局。

img/334805_2_En_12_Fig10_HTML.jpg

图 12-10

一个可能的第二阶段,英雄人物可以在第一阶段打开门后进入。这个概念包括一个具有三个节点的大型“boss”单元;此阶段的一个目标可能是禁用每个节点以关闭 boss

从图 12-10 中的图表开始机械探索是一条捷径,但是因为你已经确定了设置和一些视觉元素,用一些已经存在的视觉资产继续开发新的舞台会很有帮助。该图包括第一阶段中使用的相同类型的平台,但如果,例如,这个区域没有重力,英雄人物能够自由飞行,会怎么样?将这一区域与第一阶段进行比较,并思考如何在不从根本上改变游戏的情况下,稍微改变一下体验,将事情混合起来;理想情况下,你已经相当熟练地掌握了第一阶段的测序机制,第二阶段的经历可能是该机制或多或少的演变。

如果你选择包括寻找英雄的飞行机器人单位,游戏流程图将变得比第一阶段使用的模型更复杂,因为新机器人类型的移动不可预测。你可能还想考虑一个机制让英雄角色消灭机器人单位(也许甚至可以把机器人单位的移除加入到禁用 boss 节点的机制中)。如果你发现你的设计变得难以作为一个明确的和可重复的游戏流程的一部分来描述,这可能表明你正在与更复杂的系统一起工作,可能需要在一个可玩的原型中评估它们,然后你才能有效地平衡它们与关卡的其他组件的集成。当然,你也可以重用阶段 1 中的约定和单元;例如,你可以选择将巡逻机器人与寻找英雄的机器人和一个能量场结合起来,为玩家创建一个具有挑战性的潜在风险网络,让他们在工作中禁用 boss 节点。

你也可以决定这个关卡的主要目标是启用boss 节点以解锁游戏的下一个阶段或关卡。你可以向你喜欢的任何方向扩展故事,因此单位可以是有益的或有害的,目标可以涉及禁用或启用,英雄人物可以奔向某物或远离某物,或者你可以想象的任何其他可能的场景。请记住,叙事发展和关卡设计将相互影响,推动体验向前发展,所以当你越来越熟悉这个原型的关卡设计时,请保持警惕,寻找灵感。

摘要

游戏设计在创意艺术中是独一无二的,因为它要求玩家在体验中成为积极的伙伴,这可能会因玩家的不同而发生巨大的变化。尽管一些游戏与电影有很多相似之处(尤其是随着故事驱动的游戏变得越来越流行),但当玩家或多或少地控制屏幕上的动作时,总会有不可预测的因素。与电影和书籍不同,视频游戏是互动的体验,需要与玩家持续的双向互动,设计糟糕的机制或规则不明确的关卡会阻止玩家享受你创造的体验。

本书介绍的设计方法首先着重于教你设计字母表的字母(基本交互),引导你创造单词(游戏机制和游戏性),然后是句子(关卡);我们希望你能迈出下一步,开始写下一部伟大的小说(现有或全新类型的完整游戏体验)。这里介绍的“逃离房间”设计模板可用于快速原型化多种游戏体验的各种机制,从附带的 2D 侧滚轮到等距游戏,再到第一人称体验等等。请记住,游戏机制从根本上来说是结构良好的抽象谜题,可以根据需要进行调整。如果你发现自己在开始时难以集思广益新的机制,从普通休闲游戏中借用一些简单的现有机制(“比赛 3”的变体是灵感的伟大来源),并从那里开始,随着你的进行添加一两个简单的变体。与任何创造性学科一样,你对基础练习得越多,你对这个过程就越流畅,在你获得一些简单的机械和系统的经验后,你可能会惊讶于你能快速创造的有趣变化的数量。其中一些变化可能会促成下一个突破性的标题。

这本书展示了游戏设计的技术和经验方面的关系。设计师、开发人员、艺术家和音频工程师必须紧密合作,提供最佳体验,在整个制作过程中考虑性能/响应、用户输入、系统稳定性等问题。你在本书中开发的游戏引擎非常适合本章中描述的游戏类型(以及许多其他类型)。现在,您应该已经准备好探索自己的游戏设计,具备强大的技术基础,并对游戏设计的九个要素如何协同工作以创造玩家喜爱的体验有了全面的了解。*