游戏开发

适于 Web 的 2D 游戏引擎

Michael Oneppo

下载代码示例

假设有一名开发人员想要编写一个游戏。他想要查找可用的工具,但可供选择的选项却令人失望。因此,他决定构建自定义引擎来满足当前游戏概念的所有需求。自定义引擎还会满足任何人都可能提出的关于所有未来的游戏开发需求。因此,开发人员永远不会实际编写一个游戏。

这种悲剧经常发生。幸运的是,上面的情况诞生了许多适于所有可能平台的游戏开发引擎。这些引擎涵盖范围广泛的方法、工具和许可模型。例如,JavaScripting.com 目前列出了 14 个专用开源游戏库。这还不包括游戏的物理引擎、音频库和专用输入系统。开发人员做出这种选择后,势必会促使游戏引擎很好地与您构想的任何游戏项目配合使用。

本文将介绍适于 Web 的三个热门开源 2D 游戏引擎:Crafty、Pixi 和 Phaser。为了对这些库进行比较和对比,我已经将我第一篇文章 (msdn.microsoft.com/magazine/dn913185) 中的 Ping 游戏移植到每个库中,以便查看其异同。请注意,为重点介绍 2D 游戏引擎,我略去了大量适于 Web 的 3D 游戏引擎。这些将在以后的文章中进行介绍。现在,我将重点介绍有关 2D 的引擎以及可获得的机会。

Crafty

Crafty (craftyjs.com) 主要针对基于磁贴的游戏,尽管它非常适合各种 2D 游戏(包括我的 Ping 游戏)。Crafty 的基础是它调用组件的对象模型/依赖关系系统。

组件类似于您使用特定属性和操作定义的类。组件的每个实例都称为一个实体。组件旨在高度混合,以生成复杂而功能丰富的实体。例如,如果您要在 Crafty 中制作子画面表单,可以使用 sprite 函数来生成能表现您的子画面的组件。下面是我从图像定义子画面类型的方法:

Crafty.sprite("sprites.png", {
  PlayerSprite: [0, 128, 128, 128],
  OpponentSprite: [0, 0, 128, 128],
  BallSprite: [128, 128, 64, 64],
  UpSprite: [128,0,64,64],
  DownSprite: [128,64,64,64],
  LeftSprite: [192,0,64,64],
  RightSprite: [192,64,64,64]});

现在,如果您想要生成使用子画面表单上的球图像的球实体,可以在创建实体时使用“e”函数加入 BallSprite 组件,如下所示:

Crafty.e("Ball, BallSprite");

但这不会将其绘制到屏幕。您需要让 Crafty 知道,您想要在 2D 中加入此实体(因此它将具有“x”和“y”位置),并且您想要它使用 DOM 元素进行绘制:

Crafty.e("Ball, 2D, DOM, BallSprite");

如果您想要在画布中绘制元素,此操作就像将文档对象模型 (DOM) 组件替换为 Canvas 一样简单。

实体在对事件做出反应时表现得像屏幕上独立的对象。类似于许多 JavaScript 库,Crafty 实体具有对事件做出反应的绑定函数。请务必记住,这些事件是特定于 Crafty 的事件。例如,如果您想让实体在每个帧中执行一些操作,则需要让它对 EnterFrame 事件做出反应。这正是根据谁持有球及相应的物理引擎(如有必要)来移动球的操作所需要的。图 1 显示了球实体初始化以及涵盖球的运动过程的 EnterFrame 事件函数。

图 1 球实体定义

Crafty.e("Ball, 2D, DOM, BallSprite, Collision")
  .attr({ x: width/2, y: height/2, velocity: [0,0], owner: 'User' })
  .bind('EnterFrame', function () {
    // If there's an owner, have the ball follow the owner.
    // Nudge the ball to the left or right of the player
    // so it doesn't overlap.
    if (this.owner) {
      var owner = Crafty(this.owner);
      switch(this.owner) {
        case 'User':
          this.x = owner.x + 128;
          break;
        case 'Opponent':
          this.x = owner.x - 64;
            }
            this.y = owner.y + 32;
            return;
    }
    // Bounce the ball off the ceiling or floor.
    if (this.y <= 0 || this.y >= (height - 64))
      this.velocity[1] = -this.velocity[1];
    // Move the ball based on its velocity, which is defined in
    // pixels per millisecond.
    var millis = 1000 / Crafty.timer.FPS();
    this.x += this.velocity[0] * millis;
    this.y += this.velocity[1] * millis;
  })

如果您仔细查看,将会看到“this”常用来指代实体属性。内置的 Crafty“this.x”和“this.y”将定义球的位置。您在使用 2D 组件创建球时,就添加了此功能。“this.velocity”语句是我的代码中惯用的。您可以看到使用 attr 函数将其定义为一个属性。对于 this.owner 也是如此,我用它算出是否有一方球员持有球。

Crafty 拥有几个内置组件,但我在 Ping 示例中并未大量使用这些组件。下面列出了部分问题:

  • 重力:Crafty 具有一个简单的重力引擎,可以自动将实体向下拉动。您可以定义任意数量的元素类型来停止此运动。
  • TwoWay/FourWay/MultiWay 运动:您可以使用这些组件获得各种拱廊风格的运动输入方法。TwoWay 适于各种平台动作游戏,其向左、向右和跳跃选项可绑定到所需的任何键。FourWay 允许主要方向中的自上而下的运动。MultiWay 允许自定义的输入,每个输入都具有任意方向。
  • 微粒:Crafty 包含一个快速的微粒系统,您只能使用它来进行 Canvas 绘制。微粒是柔和、圆润的单色图像,特别适合生成烟雾、焰火和爆炸的画面。

本文随附的代码下载中提供了通过 Crafty 实现的 Ping 的代码。我们来全面了解移植的方式,尤其是对手的 AI。

Pixi.js

Pixi.js (pixijs.com) 是基本的、更接近本质的 2D Web 呈现器。Pixi.js 可以放弃 2D 画布而直接使用 WebGL,这可以让其获得显著的性能提升。Pixi.js 游戏有两个主要部分:一个通过场景图介绍游戏布局的舞台,和一个使用画布或 WebGL 在舞台中将元素实际绘制到屏幕的呈现器。默认情况下,Pixi.js 将使用 WebGL(如果可用):

$(document).ready(function() {
  // The stage to play your game.
  stage = new PIXI.Stage(0xFFFFFFFF);
  lastUpdate = 0;
  // autoDetectRenderer() finds the fastest renderer you've got.
  renderer = PIXI.autoDetectRenderer(innerWidth, innerHeight);
  document.body.appendChild(renderer.view);
});

Pixi.js 具有场景图和转换,但值得注意的是,您需要自行呈现场景。因此,对于我的 Ping 游戏,我使用了 requestAnimationFrame 来保持呈现循环持续执行,如下所示:

function update(time) {
  var t = time - lastUpdate;
  lastUpdate = time;
  ball.update(t);
  ai.update();
  // New: You actually have to render the scene
  renderer.render(stage);
  requestAnimationFrame(update);
}

与 Crafty 不同的是,Pixi.js 对于如何构建游戏并没有太多规定,因此,我完全可以使用原始 Ping 游戏中的相同代码,只需进行细微修改。

您将看到的最大区别是 Pixi.js 可能会允许您相继从文件中将子画面加载为图像,或者使用称作 TexturePacker 的特殊工具将其加载为平铺的图像。此工具会自动将一组独立图像合并到一个优化的文件中,并随附一个将描述图像位置的 JSON 文件。我使用 TexturePacker 制作了子画面表单。图 2 显示了更新的 ready 函数,同时加载子画面图像。

图 2 在 Pixi.js 中加载子画面表单

$(document).ready(function() {
  // Create an new instance of a Pixi stage.
  stage = new PIXI.Stage(0xFFFFFFFF);
  lastUpdate = 0;
  // Create a renderer instance.
  renderer = PIXI.autoDetectRenderer(innerWidth, innerHeight);
  document.body.appendChild(renderer.view);
  var images = ["sprites.json"];
  var loader = new PIXI.AssetLoader(images);
  loader.onComplete = startGame;
  loader.load();
});

加载程序是异步加载程序,因为它需要在加载完成后调用函数。剩余的游戏初始化操作(如创建玩家、对手和球等)均通过新的 startGame 函数执行。若要使用这些最新初始化的子画面,可以使用 PIXI.Sprite.from-­Frame 方法,这会将索引放入 JSON 文件中的子画面数组中。

Pixi.js 的最后一个不同之处是触摸和鼠标输入系统。为了使所有子画面输入准备就绪,我将交互式属性设置为“true”,并直接将一些事件处理程序添加到子画面。例如,对于向上/向下按钮,我已绑定事件,以便向上或向下移动玩家,如图 3 中所示。

图 3 绑定事件以向上和向下移动游戏玩家

up = new PIXI.Sprite.fromFrame(3);
up.interactive = true;
stage.addChild(up);
up.touchstart = up.mousedown = function() {
  player.move(-distance);
}
down = new PIXI.Sprite.fromFrame(4);
down.interactive = true;
stage.addChild(down);
down.touchstart = down.mousedown = function() {
  player.move(distance);
}

请务必注意,我需要将每个子画面手动添加到场景中,从而使其可见。您可以从本文随附的代码下载中查找我在 Pixi.js 中构建的 Ping 版本。

Phaser

Phaser (phaser.io) 是一个功能完备的游戏引擎,实质上是使用 Pixi 来执行所有呈现任务。Phaser 提供了很多功能,包括基于帧的子画面动画、音乐和音频、游戏状态系统及包含三个不同重要库的物理引擎。

由于我已经将 Ping 移植到 Pixi.js 上,因此移植到 Phaser 就非常简单。Phaser 简化了基于 Pixi.js 创建对象的流程:

ball = game.add.sprite(x, y, 'sprites');

虽然 Phaser 可以更加简洁,但有些方面可以稍微复杂一些。例如,前面代码中的最后一个参数“sprite”指的是在启动时加载的图像:

game.load.image('sprites', 'sprites.png');

Phaser 拥有适于 Pixi.js 中图像的不同的子画面表单系统。您可以将一个图像分成多个磁贴。要使用该引擎,可以调用类似以下的内容:

game.load.spritesheet('sprites', 'mySprites.png',
  spriteWidth, spriteHeight, totalSprites);

在我的示例中,我实际上不会使用此函数。我刚才演示的代码假定所有子画面尺寸相同,但 Ping 子画面表单的子画面为64 像素和 128 像素。因此,我不得不手动裁剪每个子画面图像。为了裁剪下球的图像,我在子画面上设置了裁剪矩形:

ball.cropRect = new Phaser.Rectangle(0, 64, 64, 64);
ball.updateCrop();

但愿这会体现 Phaser 的部分灵活性。这一灵活性同样在其他方面得以表现。您可以将游戏构建为基于事件的游戏,或查看事件并在更新函数中相继对它们做出响应。例如,若要处理键输入,您可以对事件做出响应:

game.input.keyboard.onDownCallback = function(key) {
  console.log(key + " was pressed.");
}

或查看是否在更新循环中按下了键:

function update() {
  if (game.input.keyboard.isDown('a'.charCodeAt(0)) {
    console.log("'A' key was pressed");
  }
}

它适于 Phaser 中的大量事件,根据您的首选项或需求使用连续的或异步编码。Phaser 有时仅提供后一种事件处理形式。一个典型示例是拱廊物理引擎系统,其中,您必须针对每次更新显式调用函数,以检查各对象之间是否存在冲突:

game.physics.arcade.collide(player, ball, function() {
  ballOwner = player;
});

此代码确定球是否与玩家子画面有接触,并使玩家可以在接触时控制球。您可以从本文的代码下载中查看我是如何使用 Phaser 实现 Ping 的。

总结

如果您正在寻找专用的 2D JavaScript 游戏引擎,有大量的选择可以满足您的需求。在选择框架时,需要记住两个重要因素:

只使用需要的功能:别被大量功能、纷繁复杂的整体框架和其他花哨功能弄得眼花缭乱。除非这些点缀的功能可以解决您要构建的游戏特定方面的问题,在这种情况下,可以使用这些功能。此外应考虑功能的组件化方式。如果移除了物理引擎系统,整个系统是否仍会正常工作?框架是否要依赖物理引擎系统来提供其他功能?

了解每个引擎的工作原理:尝试了解如何自定义或扩展框架。如果您想要达到一些不同寻常的效果,那么您很可能需要编写极度依赖所选框架的自定义代码。在这种情况下,您可以编写一个新的独立功能,也可以扩展框架功能。如果您扩展框架本身而不是向其添加内容,结果将需要编写更多的可维护代码。

此处介绍的框架可以解决许多此类问题,因此确实值得一试。引擎、库和框架共有几十种,因此可以在进行研究后,再着手下一步的游戏开发旅程。


Michael Oneppo *是富有创造性思维的技术专家并且以前是 Microsoft Direct3D 团队的项目经理。他最近的工作包括担任 Library For All(非盈利技术)的 CTO 以及致力于探索获得 NYU 交互式电信计划方面的硕士学位。*​

衷心感谢以下 Microsoft 技术专家对本文的审阅:Justin Garrett