65.9K
CodeProject 正在变化。 阅读更多。
Home

使用Dart进行游戏开发

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (12投票s)

2014年4月3日

CPOL

27分钟阅读

viewsIcon

60360

downloadIcon

845

如何使用 Dart 实现一个简单的 HTML5 游戏。

简介

在本文中,我将通过尝试使用 Dart 语言实现一个简单的基于网页的游戏来探索它。我上传了一个演示游戏玩法的视频 ,这个 JsFiddle 将让你尝试它(或者至少是游戏的有限版本)。

注意:游戏中的说明说使用方向键移动,这是不正确的,应该是 WASD。抱歉。这是一个已修复的 JsFiddle: http://jsfiddle.net/ZpLAu/3/

对于那些不熟悉 Dart 的人来说,它是一种由 Google 开发的语言,目前正在进行 ECMA 标准化。它适用于 Web 和服务器端开发。

我将介绍两个项目,一个非常简单,目标只是在屏幕上显示一些用户可以控制的东西,另一个是更复杂的游戏项目,目标是“完整游戏”。虽然我将努力实现一个游戏,但这次探索的主要目的是研究 Dart,而不是制作有史以来最好的游戏。

因此,本文的目标将是双重的;

  • 1. 展示 Dart Web 应用程序的基本语法和结构。
  • 2. 演示一个简单的游戏。

由于这两个是非常不同的事情,所以本文有时可能会显得有点不集中,为此我提前道歉。

背景

为什么选择 Dart

从个人角度来看,我选择 Dart 有两个原因;

  • 我想学习另一种语言。
  • 我想做一些基于 Web 的事情,因为我很少在工作中做这些。

虽然我懂一点 JavaScript,但我也想看看 Dart 是否兑现了其“结构化 Web 应用程序”的承诺,因为我有着 C++、Java 和 C# 的背景,有时我觉得 JavaScript 用起来有点奇怪,我不介意有一种语言能让我做 JavaScript 能做的事情,但以我更熟悉的方式。

为什么最初创建 Dart?
根据 dartlang.org 网站上的 FAQ,Google 已经编写了他们的 Web 应用程序,并认为他们对 Web 应用程序的开发方式有所改进。 Dart 旨在解决 JavaScript 的一些问题,因此它可以被视为 JavaScript 的替代品,我认为这不太可能发生,但拥有不止一个工具(或在本例中,语言)只会是一件好事。我讨厌不得不将 C# 用于更适合 C++ 的任务,反之亦然,所以我欢迎这个选择。

Seth Ladd(在 Dart 团队工作)在回答“Dart 对 Web 程序员有什么有用的功能吗?”这个问题时,发布了 16 个他喜欢 Dart 的理由,我认为这些观点非常有效,并且同意它们,这是我喜欢 Dart 的一个很好的总结;

  • 可选的静态类型。 当我进行原型设计或只是编写小脚本时,我不会使用大量的静态类型。我只是不需要它们,也不想被繁琐的仪式所困扰。然而,其中一些脚本会演变成更大的程序。随着脚本的扩展,我倾向于想要类和静态类型注解。
  • 在证明有罪之前无罪。 Dart 努力将导致编译时错误的情况降到最低。Dart 中的许多条件都是警告,它们不会阻止您的程序运行。为什么?为了符合 Web 开发的时尚,必须允许开发人员尝试一些代码,点击重新加载,看看会发生什么。开发人员不应该在测试代码的一角之前,首先证明整个程序是正确的。
  • 词法作用域。 如果您不习惯它,这真是太棒了。简而言之,变量的可见性,甚至是 this,都是由程序结构定义的。这消除了传统 Web 编程中一类令人费解的问题。无需重新绑定函数以保持您认为或期望的 this。
  • 语言中内置了真正的类。 显然,大多数开发人员希望使用类,因为大多数 Web 开发框架都提供了解决方案。然而,在传统的 Web 开发中,框架 A 的“类”与框架 B 不兼容。Dart 自然地使用类。
  • 顶层函数。 Java 的一个痛苦之处在于所有内容都必须放入一个类中。这有点人为,尤其是当您想定义一些实用函数时。在 Dart 中,您可以在顶层定义函数,在任何类之外。这使得库组合感觉更自然。
  • 类具有隐式接口。 消除显式接口简化了语言。不再需要在所有地方定义 IDuck,现在您只需要一个类 Duck。因为每个类都有一个隐式接口,所以您可以创建一个 MockDuck implements Duck。
  • 命名构造函数。 您可以给构造函数命名,这确实有助于提高可读性。例如:`var duck = new Duck.fromJson(someJsonString)`
  • 工厂构造函数。 工厂模式很常见,很高兴看到它内置在语言中。工厂构造函数可以返回单例、缓存中的对象或子类型的对象。
  • 隔离区。 共享线程之间可变状态(一种容易出错的技术)的日子一去不复返了。Dart 隔离区是一个独立的内存堆,能够运行在单独的进程或线程中。隔离区通过在端口上发送消息进行通信。隔离区在 Dart VM 中工作,并且可以在 HTML5 应用程序中编译为 Web Workers。
  • Dart 编译为 JavaScript。 这至关重要,因为 JavaScript 是 Web 的通用语。Dart 应用程序应该在现代 Web 上运行。
  • 强大的工具。 Dart 项目还附带一个编辑器。您会发现代码完成、重构、快速修复、代码导航、调试等等。此外,IntelliJ 还有一个 Dart 插件。
  • 库。 您可以将 Dart 代码组织成库,以便于命名空间和重用。您的代码可以导入库,并且库可以重新导出。
  • 字符串插值。 这只是一个很好的功能,可以轻松地组合字符串:`var msg = "Hello $friend!";`
  • noSuchMethod。 Dart 是一种动态语言,您可以使用 noSuchMethod() 处理任意方法调用。
  • 泛型。 能够说“这是一个苹果列表”为您的工具提供了更多信息,可以帮助您并及早发现潜在错误。不过,幸运的是,Dart 的泛型比您可能习惯的要简单。
  • 运算符重载。 Dart 类可以为 + 或 - 等运算符定义行为。例如,您可以编写 `new Point(1,1) + new Point(2,2)` 这样的代码。

注意: 我绝不是说 DartJavaScript 好,我没有足够的时间使用它们中的任何一个来做出判断,这次探索只是为了看看对于像我这样有背景的人来说,使用 Dart 而不是 JavaScript 开发特定的 Web 应用程序是否更容易。

使用代码

本文的项目下载是一个 DartEditor 项目,要打开它只需选择“文件”,然后选择“打开现有文件夹”即可导入。我个人更喜欢在“完整”版本的 Eclipse 中工作,但由于 DartEditor 对于只想看看的人来说更方便一些,所以我将项目保留为 DartEditor 项目。

DartEditor 下载本质上是仅限于 Dart 开发的 Eclipse 版本。

对于那些更喜欢使用 Eclipse 的人,有一个 Eclipse 插件,如果您喜欢,可以从 Dart Eclipse 插件更新站点获取插件。)

Dart 运行在 Dart 虚拟机中,但由于很少有人拥有可以运行它的浏览器,它也可以编译成 JavaScript。

Dart

我将尝试解释和涵盖本文中使用的Dart语法的大部分内容,但如果您想要一个快速的Dart入门,那么Darrrt示例应用程序是一个很好的起点。它涵盖了设置一个骨架应用程序,创建一个HTML页面,Dart代码可以与其交互,以及创建一个简单的文本操作类。

基础知识

最初的“游戏”只是一个黑色的圆圈,用户可以使用键盘上的方向键移动它。这足以作为起点,我将用它来演示一些 Dart 语法,并展示启动和运行一个简单 Web 游戏所需的部件。

页面

与任何 Web 应用程序一样,我们需要一个 HTML 页面来承载我们的内容;

<html>
   <head>
      <meta charset="utf-8"/>
          <title>Basics</title>
   </head>
  <body>
    <canvas id="area" width="800px" height="600px"></canvas>
    <script type="application/dart" src="basics.dart"></script>
  </body>
</html>   
    

很简单。
canvas 是游戏的“宿主”,游戏将使用它的上下文来渲染图形。 script 元素当然以与 JavaScript 相同的方式使用,并指向 basics.dart 文件。

.dart 文件

由于 Dart 旨在提供一种结构化的应用程序开发方式,因此它附带了库(您显然也可以自由开发自己的库),并且 Dart 使用的导入语法与 JavaC# 等语言的导入语法非常相似,在 basics.dart 的顶部可以找到以下导入;

import 'dart:html';
import 'dart:math';
import 'dart:async';
import 'dart:collection';
    

项目带来了什么,一目了然,呈现得也很整洁。

一个 Dart 文件可以有一个主入口点,就像 C# 中的 static void Main(String[] args) 一样,在我的游戏中,我将使用它来获取对 canvas 的引用并启动主游戏循环。

/* This is the main entry point, obviously */
void main() {
  final CanvasElement canvas = querySelector("#area");
  canvas.focus();
    scheduleMicrotask(new GameHost(canvas).run);
}
    

使用 var 还是不使用 var

Dart 和许多其他语言一样,允许在声明变量时使用 var 或实际类型。我通常喜欢在 var 的含义与实际类型相同时使用它,但在 Dart 中我有点矛盾。我喜欢通过将事物声明为 final 来传达意图,虽然 Dart 允许我这样做;

final CanvasElement canvas = querySelector("#area");

它不允许我这样做;

final var canvas = querySelector("#area");

正因为如此,本文的代码中我将主要使用特定的类型。此外,在某些情况下,当我显式指定类型时,智能感知似乎工作得更好,这总是件好事。

返回 ElementquerySelector 方法是 dart:html 的一部分,由于 Dart 不像 C# 那样是严格的面向对象语言,因此不需要类,它只是该库中的一个公共方法。

scheduleMicrotask 方法是 dart:async 的一部分,用于异步运行方法,在本例中是 GameHost 类上的 run 方法。

调用 .focus() 会尝试将焦点从浏览器地址栏移开,这并不总是有效,但有时会有效。这样做的目的是允许方向键控制黑圈,而无需先手动点击 div。

键盘类

在“正常”编程中,通常采用基于事件的系统来读取输入(鼠标悬停、鼠标进入、按键按下等),但在游戏编程中,将特定时间点的所有输入的总和视为一种状态通常更为方便。由于 Dart 支持事件驱动方法,我决定创建一个类来封装键盘事件并将其作为状态提供;

class Keyboard {
  final HashSet<int> _keys = new HashSet<int>();
  Keyboard() {
    window.onKeyDown.listen((final KeyboardEvent e) {
          _keys.add(e.keyCode);
    });
    window.onKeyUp.listen((final KeyboardEvent e) {
      _keys.remove(e.keyCode);
    });
  }
  isPressed(final int keyCode) => _keys.contains(keyCode);
}   
    

这个类展示了 DartJavaC# 的相似之处,泛型以相同的方式工作(或多或少,至少看起来相同),类的定义方式相同,事件通过函数指针(在本例中是匿名函数,但也可以是成员函数)连接,并且支持属性。这就是我一直在寻找的;一种感觉像 JavaC# 的语言,但可以为我做 JavaScript 的事情(暂时忽略我需要将它编译成 JavaScript 才能在大多数浏览器中运行的事实)。

因此,尽管这个类的主要部分对于 JavaC# 开发人员来说是显而易见的,但它也有一些微妙之处;

  • _keys 变量是 private 的,不是默认的,而是因为它的前缀是 _
  • 用于连接事件监听器的 window 变量是 dart:html 中的全局变量。
  • getter isPressed 的主体是一个语句,因此 => 语法就足够了,返回值由表达式的结果定义。
  • 我的代码中充满了 final 修饰符(在成员、参数和变量上),这在 Java 中很常见,但对于 C# 开发人员来说可能看起来很奇怪,因为 readonly 仅在成员上有效。
  • window.onKeyDown.listen 接收一个委托,该委托指向一个以 KeyboardEvent 作为单个参数的方法,在 Java 中,这通常通过一个类型和一个该接口的匿名实现来完成。

这个类提供了在任何时候查询 Keyboard 是否按下特定键并根据该事实执行类似操作的能力;

      if (keyboard.isPressed(KeyCode.LEFT)) 
         x = x + 10;   
   

请注意,此处的 private 不是类级别的私有,而是库级别的私有,这意味着同一库中的任何人都可以访问 _keys 变量。这可能看起来很奇怪,但有人可能会争辩说,一个人应该知道自己的库如何工作,而不是不得不隐藏自己的东西。

GameHost 类

在这个小例子中,GameHost 类将负责游戏当前的状态以及更新和渲染该状态。一个好的设计会将这些职责划分到不同的类中,但这对于这个基本示例试图展示的内容来说过于复杂了。

/*
 * This class holds the state of the game (in this case just the X and Y
 * coordinates of the ball) and continuously schedules an animation frame
 * using the draw method.
 * Each iteration of the game loop goes something like this;
 *    1. Elapsed time since last frame is calculated
 *    2. State is updated based on elapsed time and keyboard state
 *    3. Current state is rendered
 *    4. A new animation frame is requested causing this to loop again
 */
class GameHost {
  final CanvasElement _canvas;
  final Keyboard _keyboard = new Keyboard();

  int _lastTimestamp = 0;
  double _x = 400.0;
  double _y = 300.0;

  GameHost(this._canvas);
  
  ...
}  
    

GameHost 的第一部分展示了它的 私有 成员变量及其构造函数。构造函数看起来很奇怪,那是因为 Dart 允许我们采取一些捷径。对于许多简单类型,构造函数唯一做的事情就是将传递给构造函数的值赋值给成员变量。在 GameHost 的情况下,我们需要传递 canvas,而 Dart 提供了 this.member_name 语法来完成此操作。

GameHost(this._canvas);
    

这是一个带一个参数的构造函数,并将其赋值给 _canvas,类型是成员类型的隐式类型,构造函数甚至不需要定义空主体,尽管如果需要,它也可以有主体。我们可以使用正常的方式定义构造函数;

GameHost(CanvasElement canvas) {
   _canvas = canvas;
}
    

但由于 Dart 允许使用简写版本,我将尝试在构造函数只进行成员赋值的所有情况下坚持使用它。

双精度变量 _x_y 是用户可以用方向键移动的黑圈的位置,它们将在游戏循环的每次迭代中作为更新的一部分而改变。

游戏循环

简单的游戏通常使用一个简单的循环,大致如下:

  • 读取输入
  • 计算自上一帧以来的经过时间
  • 根据输入和经过时间更新状态
  • 渲染当前状态
  • 返回开头,重复所有步骤

在 Web 开发中,我们需要知道何时可以渲染,因为这取决于浏览器,所以我们通过调用 window.requestAnimationFrame 请求一个动画帧,并传入一个指向将处理该帧的方法的指针。在 GameHost 中,这最初由 run 方法触发;

run() {
 window.requestAnimationFrame(_gameLoop);
}

void _gameLoop(final double _) {
 _update(_getElapsed());
 _render();
 window.requestAnimationFrame(_gameLoop);
}   
    

run 方法通过请求一个由 _gameLoop 方法处理的动画帧来充当启动器(_gameLoop 方法接受一个双精度浮点数,我认为它是帧计数或其他类似的东西,但我们不需要它来实现)。然后 _gameLoop 方法执行游戏循环的四个步骤。它通过调用 _getElapsed() 计算经过的时间,使用 _update(double elapsed) 更新状态,渲染它 render,然后通过请求另一个动画帧回到开始。

在该循环中没有明确的输入读取,因为我们已经使用前面展示的 Keyboard 类处理了。

计算时间步(或自上一帧以来的经过时间)非常重要,因为即使浏览器会尝试处理,但间隔并不能保证。这意味着如果事物没有根据经过的时间进行更新,物体将根据机器和/或浏览器的速度以不同的速度移动。做好时间步并非易事,尤其是在使用详细物理时,但有很好的资源可以为您指明方向。对于本文,我采用了相当简单的方法,因为所使用的物理也很简单。

double _getElapsed() {
 final int time = new DateTime.now().millisecondsSinceEpoch;

 double elapsed = 0.0;
 if (_lastTimestamp != 0) {
   elapsed = (time - _lastTimestamp) / 1000.0;
 }

 _lastTimestamp = time;
 return elapsed;
}   
    

在这个基本示例中,_update(final double elapsed) 方法获取当前输入(由 Keyboard 类捕获)并调整用户头像的位置。我说的头像是指一个黑色的圆圈。漂亮的图形不是这个示例的重点。

void _update(final double elapsed) {
 final double velocity = 100.0;

 if (_keyboard.isPressed(KeyCode.LEFT)) _x -= velocity * elapsed;
 if (_keyboard.isPressed(KeyCode.RIGHT)) _x += velocity * elapsed;
 if (_keyboard.isPressed(KeyCode.UP)) _y -= velocity * elapsed;
 if (_keyboard.isPressed(KeyCode.DOWN)) _y += velocity * elapsed;
}   
    

根据按下的方向键,位置会以每秒 100.0 像素的速度水平和垂直调整(因为 elapsed 以秒表示)。收集输入并修改游戏状态后,游戏循环的最后一步就是简单地渲染状态;

void _render() {
 final CanvasRenderingContext2D context = _canvas.context2D;

 context..globalAlpha = 1
        ..fillStyle = "white"
        ..beginPath()
        ..rect(0, 0, 800, 600)
        ..fill();

 context..beginPath()
        ..fillStyle = "black"
        ..arc(_x, _y, 32, 0, PI * 2.0)
        ..fill();
}   
    

CanvasRenderingContext2DHTML5 画布上下文,应用到它的方法对于任何以前使用过画布的人都应该很熟悉。

双点运算符 (..) 是一个级联运算符,它允许级联语句,因为它“返回”源对象,无论调用的方法或属性返回什么。这意味着上面的代码等同于;

context.globalAlpha = 1;
context.fillStyle = "white";
context.beginPath();
context.rect(0, 0, 800, 600);
context.fill();

...
    

由于这种方法会遮蔽任何返回值,因此如果您关心其中一个调用返回的值,则不能使用它。

至此,基本示例所需的代码已经全部涵盖,因为游戏循环的所有步骤都已介绍。该示例允许用户在白色背景上移动一个黑色的圆圈,仅此而已。这并不是特别令人兴奋,但它让我们涵盖了简单的游戏逻辑以及 Dart 语法。接下来将介绍一个名为 Lost Souls 的简单游戏的实现,对于每个有趣的实现细节,我都会解释其原因。

示例游戏

本文实现的示例游戏是一个非常简单的 2D 俯视游戏,基于我之前写的一篇关于游戏中视觉隐藏的文章。一位同事指出,该文章实现中隐藏区域的渲染看起来更像是奇怪的墙壁,而不是其他任何东西,因此我决定根据此制作一个游戏,使用该代码但渲染看起来像高大的块或建筑物。

游戏的目标是在时间用完之前找到迷失的灵魂(红色的家伙)并将他们护送到右下角。有一些尖锐的黑色物体会试图减慢你的速度。实现需要相当多的线性代数,但由于我已经在视觉隐藏文章中解释过,所以本文将完全不涉及该主题。

游戏需要大量的代码,但我不会详细介绍每一个类,相反,我将介绍我认为有趣的地方以及使用了一些 Dart 特定语法或库的地方。

游戏架构

游戏由一系列状态(GameStates)组成,这些状态是控制应用程序总体流程的状态机的一部分。

“驱动”状态机的逻辑位于 lostsouls.dart 中的 GameHost 类中,负责跟踪游戏更新之间的增量时间、更新游戏状态和渲染。它通过与文章第一部分中描述的相同方法完成此操作,并增加了能够从一个独立状态转换到另一个状态的能力。这种能力得益于每个状态在其更新方法中返回一个状态,因此状态负责确定何时转换到新状态,但“驱动器”负责确保新状态成为当前状态。如果游戏状态不想转换到新状态,它只需将自身作为“下一个”状态返回。

  void draw(num _) {
    final num time = new DateTime.now().millisecondsSinceEpoch;
    if (renderTime != null)
      showFps(_, 1000 / (time - renderTime));
    renderTime = time;

    double elapsed = 0.0;
    if (lastTimestamp != 0) {
      elapsed = (time - lastTimestamp) / 1000.0;
    }

    lastTimestamp = time;
    if (currentState != null) {
      var nextState = currentState._update(elapsed);
      currentState._render();
      if (currentState != nextState) {
        currentState = nextState;
        currentState._initialize();
      }
    }

    requestRedraw();
  }

  void requestRedraw() {
    window.requestAnimationFrame(draw);
  }

游戏状态的实现基于一个基类,该基类提供了访问大多数状态可能需要的资源的能力;

  • Keyboard,用于读取键盘状态。
  • Renderer,具有用于渲染的辅助方法。
  • AudioManager,用于播放音频片段。

如果还公开了初始化、更新和渲染方法。

class GameState {
  final Keyboard _keyboard;
  final Renderer _renderer;
  final AudioManager _audioManager;
  double _totalElapsed = 0.0;

  GameState(this._keyboard, this._renderer, this._audioManager);

  void _initialize() {
  }

  GameState _update(final double elapsed) {
    _totalElapsed = _totalElapsed + elapsed;
    return this;
  }

  void _render() {
  }
}

GameHost 对象由主函数实例化

void main() {
  final DivElement mainDiv = querySelector("#areaMain");
  final CanvasElement canvas = querySelector("#gameCanvas");
  canvas.width = mainDiv.clientWidth;
  canvas.height = mainDiv.clientHeight;
  canvas.focus();
  scheduleMicrotask(new GameHost(canvas).start);
}

而 GameHost 的构造函数负责将正确的第一个状态馈入“泵”

GameHost(this.canvas) {  
  keyboard = new Keyboard();
  var renderer = new Renderer(canvas.context2D, canvas.width, canvas.height);
  currentState = new StateLoad(keyboard, renderer);
}

请注意,虽然传入了 KeyboardRenderer,但没有传入 AudioManager。这是因为 StateLoad 状态负责加载游戏资产,也负责创建整个游戏中使用的 AudioManager。游戏可以处于六种不同的状态:

  • StateLoad; 加载游戏资产。
  • StateInit; 显示游戏名称并等待用户按空格键。
  • StateNewLevel; 显示关卡完成消息并等待输入以开始下一关卡。
  • StateGame; 使用 GameController 类运行实际游戏。
  • StateGameOver; 玩家输掉关卡时的状态。
  • StateFade; 一个实用状态,可以从一个状态淡入淡出到另一个状态。

这六个状态组织成一个状态机,看起来像这样:

像这样使用独立的 state 类消除了主游戏循环中需要大量 switch 语句的麻烦。由于 state 之间可以共享信息(例如关卡数据),因此每个 state 都可以是一个相当小、自包含且易于操作的类。

状态

Load (加载)

此状态负责加载游戏使用的音频资产(游戏还使用字体资产,但由于我选择将所有文本渲染为 HTML 元素,因此由 CSS 加载)。

为了及时播放背景音乐和音效,这些声音需要预加载,这就是为什么 init 状态在游戏开始之前就负责处理这些的原因。

虽然加载状态负责加载音频资产,但实际工作由 AudioManager 类(我将在后面介绍)完成,使得 StateInit 类很小。

part of lostsouls;

class StateLoad extends GameState {
  AudioManager _newAudioManager;

  double _loaded = 0.0;
  bool _fullyLoaded = false;
  double _readyTime = double.MAX_FINITE;

  StateLoad(final Keyboard keyboard, final Renderer renderer) : super(keyboard, renderer, null) {
    var clips =
        [
          new SoundClip("wallhit", "audio/73563__stanestane__bunny-push.wav", false),
          new SoundClip("anchormanwallhit", "audio/124382__cubix__8bit-snare.wav", false),
          new SoundClip("lostsouldsaved", "audio/153445__lukechalaudio__8bit-robot-sound.wav", false), //1.346s
          new SoundClip("tractorbeam", "audio/211235__rjonesxlr8__explosion-15.wav", true),
          new SoundClip("levelwon", "audio/211379__rjonesxlr8__coinpickup-06.wav", false),
          new SoundClip("gameover", "audio/213149__radiy__8bit-style-bonus-effect.wav", false),
          new SoundClip("music", "audio/166393__questiion__lost-moons-serious-as-an-attack-button.wav", true),
          new SoundClip("spottedbylostsoul", "audio/211325__rjonesxlr8__powerup-13.wav", false)
        ];

    _newAudioManager = new AudioManager(new AudioContext(),  clips, _onAudioLoaded, _onAllAudioLoaded);
  }

  void _onAudioLoaded(final double loaded) {
    _loaded = loaded;
  }

  void _onAllAudioLoaded(final List<SoundClip> buffers) {
  }

  GameState _update(double elapsed) {
    super._update(elapsed);

    if (_fullyLoaded)
      return new StateInit(_keyboard, _renderer, _newAudioManager);

    return this;
  }

  void _render() {
    _renderer.clip();
    _renderer.clearAll(Colors.backgroundMain);

    final int loadedPercentage = (_loaded * 100.0).toInt();
    final bool wasFullyLoaded = _fullyLoaded;
    _fullyLoaded = loadedPercentage == 100;

    querySelector("#areaGameTextMain").text = "LOADING ${loadedPercentage}%";
    querySelector("#areaGameTextMain").style.visibility = "visible";
  }
}

LoadState 将把一个 SoundClip 列表(这是一个我添加的辅助类,用于封装一段音频)传递给新创建的 AudioManager,后者将异步加载数据。

这意味着 StateLoad 的主要职责是创建 AudioManager 并监听其更新,以确定已加载的资源量,并在所有资源加载完毕后转换到下一个状态。

状态可以通过提供 AudioManager 可以回调的方法来实现,这种回调在 Dart 中定义为 typedef;

typedef void OnAudioLoaded(final double loaded);
typedef void OnAllAudioLoaded(final List<SoundClip> clips);

使用这些回调,StateLoad 可以渲染一个文本,显示已加载资源的百分比,

querySelector("#areaGameTextMain").text = "LOADING ${loadedPercentage}%";

原本可以通过调用画布上的适当方法来渲染文本,但由于通过简单地使用 HTML 元素可以获得很多免费功能,所以我选择了后者。

请注意,为了将百分比显示为整数,我需要将其转换为 int,并且没有(隐式或显式)从 double 到 int 的强制转换,相反,Dart 提供了一个名为 .toInt() 的方法。

游戏

StateGame 状态是驱动实际游戏的状态,但即使如此,它的大小也相当小,因为大部分工作都委托给了 GameController 类。

本质上,StateGame 类的职责是监视 GameController 并在发生一些游戏结束事件/状态(无论是关卡完成还是游戏失败)时启动状态转换。我让设计偏离了这个良好包含的职责,最终导致状态知道游戏何时获胜或失败,而不是 GameController,这是糟糕的设计,如果做得好,不应该那样做。

为了让状态知道关卡已完成,它会计算游戏中仍然活跃的“迷失灵魂”数量。如果这个数字为零,玩家就完成了关卡。 DartIterable 类上提供了接受委托的方法,使得使用它们非常类似于 LINQ(或者对于 Java 8 的爱好者来说是 Jinq);

    if (!_controller.entities.any((e) => e is LostSoul)) {
      _audioManager.play("levelwon");
      return new StateFade(_keyboard, _renderer, _audioManager, this, new StateNewLevel(_keyboard, _renderer, _audioManager, _levelIndex + 1), 1.0, Colors.backgroundMain);
    } 

在这个代码片段中,_controller.entities 是一个 Body 列表,通过调用 .any() 并传入一个检查每个 Body 类型的委托,游戏就知道玩家是否已经全部救出它们(如果一个都没有剩下)。

类型检查使用 is 运算符完成,它的工作方式与 C# 中的 is 或 Java 中的 instanceof 大致相同。

淡入淡出

StateFade 类是一个过渡状态,它会淡出一种状态,然后淡入下一种状态。StateGame 状态使用它在赢得关卡结束时淡入到 StateNewLevel 状态。

    
if (!_controller.entities.any((e) => e is LostSoul)) {
  _audioManager.play("levelwon");
  final GameState nextState = new StateNewLevel(_keyboard, _renderer, _audioManager, _levelIndex + 1);
  return new StateFade(_keyboard, _renderer, _audioManager, this, nextState, 1.0, Colors.backgroundMain);
}

在上面的代码片段(取自 StateGame)中,游戏检查是否不再有 LostSoul 类型的实体(这是关卡的胜利条件),然后异步播放一个声音以表示关卡胜利。之后,应该返回下一个状态,让状态机接收并转换到该状态,但我们没有返回我们想要向用户显示的状态,而是创建该状态并将其与当前状态一起传递给 StateFade 状态。

StateFade 状态将使当前状态淡出为一种颜色,然后从该颜色淡入下一个状态。

它通过首先调用该状态的 render() 方法渲染正在淡出的状态,然后在其顶部渲染一个彩色方块,其 alpha 值逐渐增加。通过将 CanvasRenderingContext2D.globalAlpha 设置为小于 1.0 的值,在该调用之后渲染的任何内容都将与已渲染到上下文中的内容混合。

  void _render() {
    // If we haven't reached _fadeTime yet, fade out current state 
    if (_totalElapsed < _fadeTime) {
      final f = _totalElapsed / _fadeTime;
      _from._render();
      _renderer..pushGlobalAlpha(f)
               ..fillFullRect(_color);
    }
    else  {
      // Otherwise fade in the next.
      final f = (_totalElapsed - _fadeTime) / _fadeTime;
      _to._render();
      _renderer..pushGlobalAlpha(1.0 - f)
               ..fillFullRect(_color);
    }
    
    // Must not forget to reset the globalAlpha or everything else
    // will also be rendered with alpha-blend.
    _renderer.popGlobalAlpha();
  }

音频

加载中

音频剪辑由 AudioManager 类加载和维护,该类接受 SoundClip 类的列表,并将音频缓冲区从剪辑指定的路径加载到剪辑中。

由于在加载资源时查找应用程序是不好的做法,因此使用 dart:web_audio 包异步加载资源。
它的工作方式是,_loadBuffers 从 AudioManager 的构造函数中调用,并同步迭代所有 SoundClips;

  void _loadBuffers() {
    for (var i = 0; i < _clips.length; ++i) {
      _clips[i]._context = _context;
      _loadBuffer(_clips[i], i);
    }
  }

每个 SoundClip 都被赋予 AudioManagerAudioContextAudioContextweb_audio 类),然后为每个剪辑调用 _loadBuffer 方法。通过向剪辑的 URL 发送 HTTP GET 请求来加载音频缓冲区,并且 HttpRequest 类能够异步打开请求。为了在加载完成时收到通知,该方法将监听器连接到 onLoadEndonError,这些监听器将用加载的数据填充剪辑,然后通知正在监听 AudioManager 报告加载进度的事件的类。

  void _loadBuffer(final SoundClip clip, final int index) {
    final HttpRequest request = new HttpRequest();
    request.open("GET", clip._url, async: true);
    request.responseType = "arraybuffer";
    request.onLoadEnd.listen((e) => _onBufferLoaded(request, clip, index));
    request.onError.listen((e) => _handleError(e, clip));

    try {
      request.send();
    }
    catch(ex) {
      clip._broken = true;
      _notify(clip);
    }
  }

我选择了一种容错方法,如果声音加载失败,SoundClip 会被标记为损坏,即使请求也不会播放。这是一种非常懒惰的方法,可能不适合真实的游戏。

Dart 中的异常处理与 C# 或 Java 中的工作方式大致相同,如果 HttpRequest.send 方法失败,SoundClip 将被标记为损坏。

当缓冲区加载完成时,会调用 _onBufferLoaded 方法(因为它已连接到 HttpRequestonLoadEnd 事件);

  void _onBufferLoaded(final HttpRequest request, final SoundClip clip, final int index) {
    _context.decodeAudioData(request.response).then((final AudioBuffer buffer) {
      if (buffer == null) {
        clip._broken = true;
      }
      else {
        clip._buffer = buffer;
      }
      _notify(clip);
    });
  }

使用 _context 中的 AudioContextHttpRequest 的响应被异步解码为 AudioBuffer。对 AudioContext.decodeAudioData 的调用返回一个 Future,一个 Future 允许在将来(显然)或在前一个调用完成后发生某些事情,所以通过这样做;

    _context.decodeAudioData(request.response).then((final AudioBuffer buffer) {
   ... 
   }    

我们实际上是在说“将流解码成一个 AudioBuffer,然后用这个缓冲区做一些事情”。在这种情况下,它是填充 SoundClip_buffer(或者如果缓冲区为空则将其标记为损坏)。

解码完成后,调用 _notify 以汇总到目前为止已加载的内容,并向 AudioManager 的所有者报告。当所有剪辑都加载完毕后,加载状态可以自由地转换到初始化状态。

播放剪辑

由于声音缓冲区以 AudioManager 中的 SoundClip 集合形式保存,因此每个剪辑都通过要求 AudioManager 按名称播放剪辑来播放。例如,当玩家撞到墙壁时,Player 类会请求播放相应的声音;_audioManager.play("wallhit");

由于某些声音是连续的(例如 Anchor Men 拉动玩家时发出的声音),因此音频系统需要以方便的方式支持这一点。我采用的方法是将某些 SoundClip 视为单例,不是以类单例的方式,而是以“如果声音已经在播放则不播放”的方式。AudioManager 不知道声音是否是单例,该信息局部包含在每个 SoundClip 中。这意味着游戏可以在每次更新时有效地请求播放声音,而实际上不会播放大量重叠的相同声音版本。

渲染

在《失落的灵魂》中,文本元素和其他所有元素(例如墙壁或玩家)的渲染方式截然不同。文本是文档中始终存在的 HTML 元素,每个游戏状态的责任是设置文本的上下文并适当地切换元素的可见性。与直接在画布上手动渲染相比,这是一种非常有限的方法,但它具有布局特性(如自动换行)的优势。

所有其他渲染都是通过直接在画布上绘制基本图元(或其集合)来完成的,此游戏中不使用预烘焙的精灵或图像。Renderer 类是用于绘制构成游戏事物的辅助方法的集合。因此,要从一组点绘制一个填充多边形,使用 fillPolygon 方法;

  void fillPolygon(final List<Vector2D> polygon, final String fill) {
    final Vector2D start = polygon[0];
    context..fillStyle = fill
           ..beginPath()
           ..moveTo(start.x, start.y);

    for(var i = 1; i < polygon.length; ++i) {
      final Vector2D point = polygon[i];
      context.lineTo(point.x, point.y);
    }

    context..closePath()
           ..fill();
  }
    

如前所述,.. 运算符是一个级联运算符,当同一个对象被多次访问时,它允许代码更简洁。

利用传递给 Renderer 构造函数的 CanvasRenderingContext2D 上下文,辅助方法结合了 CanvasRenderingContext2D 的功能,提供了渲染所有游戏工件的方法。渲染的职责和渲染方式仍然是每个 Body 的一部分,但它被委托给渲染器,有效地将 BodyCanvasRenderingContext2D 解耦(但显然,将其与 Renderer 耦合)。

从效率上讲,使用原始方法调用来绘制所有游戏图形并不完全明智,但对于这种类型的游戏中绘制的元素数量而言,它在大多数计算机上仍应以 30+ FPS 运行。

关注点

对我来说,使用 Dart 而不是 JavaScript 实现这样的游戏在许多方面都更舒适。我可以使用我习惯的 C#Java 概念,而不会觉得语法很生硬或做作(例如继承),而且 Dart 语法和 C# 之间的短距离让我能够相当高效。这是我第一次使用 Dart 实现,我非常确定我完成代码的速度比使用 JavaScript 更快。

但由于 JavaScript 的成熟度远超年轻的 Dart,使用 JavaScript 具有巨大的优势。即便只是为了方便搜索遇到的问题,Dart 社区虽然在增长,但在线资源与搜索 JavaScript 中同等问题所能获得的资源相比非常有限。

对于对 Dart 感兴趣的人,我建议阅读 Dart 风格指南,它详细列举了需要牢记的注意事项。

历史

  • 2014-04-03; 第一版。
© . All rights reserved.