[Web翻译]使用Dart和HTML5 Canvas的网页游戏:贪吃蛇

733 阅读29分钟

原文地址:dart.academy/web-games-w…

原文作者:dart.academy/author/mont…

发布时间:2018年5月1日 - 24分钟阅读

Dart拥有许多像C++和Java这样的高级组织结构,但却拥有更简单的语义和轻量级的语法,是构建网页游戏的绝佳语言。你准备好学习如何使用Dart和HTML5 <canvas>元素编写游戏了吗?如果你需要先刷一下Dart本身的知识,我只留下几个链接供你进一步阅读:

本教程的完整游戏代码可以在GitHub上找到,想玩的朋友可以参考一下。

有许多免费的Dart框架和库可用于高级游戏开发,如StageXLRanger-Dartgame_loop,但由于本教程是为初学者准备的,所以你将在这里只进行基础工作。

设置

好消息!在本教程中,如果你还没有一个编辑器或IDE在你的电脑上创建Dart项目,你不需要做太多准备。对于本教程,如果你还没有编辑器或IDE在电脑上创建Dart项目,你不需要做太多准备。所有你写的游戏代码都可以在你的浏览器中通过在线飞镖游戏平台DartPad来完成。不需要任何设置

在这篇文章中,将在假设你使用DartPad的情况下给出说明。如果你使用的是其他的环境,那么我们将假设你知道如何在其中组织和运行你的代码。

欢迎来到 DartPad

当你第一次导航到DartPad页面时,你会看到一个随机命名的 "Hello, World "应用程序。在左侧窗格中,你可以编辑你的Dart、HTML和CSS代码。右上角窗格显示控制台或HTML输出。右下角的窗格是代码错误和警告。所有这些窗格都可以调整大小以适应你的工作需要。

游戏

在本教程中,您将创建一个基于网络的经典贪吃蛇游戏版本。它简单而熟悉,使其成为演示代码概念的最佳选择,而不会让你陷入游戏细节的泥潭。

如果还不够熟悉的话,这里有一个简单的简介:用户控制着一条分段的蛇,在棋盘上不停地爬行,咀嚼着食物。每一次蛇吃食物,都会变长。如果蛇的头部与棋盘的边界或自己身体的任何部分发生碰撞,它就会死亡。就像俄罗斯方块和其他许多经典游戏一样,目标是在你不可避免地被打败之前尽可能地持续下去。

第1步:CSS

这款游戏只需要一点点CSS,由于它可以帮助快速在屏幕上得到一些东西,所以你先把这个问题解决了。

点击DartPad左边窗格中的CSS链接,在下面的窗格中输入以下CSS代码。

html, body {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
}

#wrapper {
  width: 450px;
  margin: auto;
  border: solid thin black;
}

你把页面的大小最大化,这样就很容易把画布居中。由于canvas元素不与某些CSS规则合作,所以会有一个名为wrapper的包装<div>,它将成为所有格式化的目标。

这就是你需要的所有CSS。恭喜你完成了你游戏项目的第一个文件。

第2步:HTML

这个项目只有一点点的HTML。点击左侧窗格中的DartPad的HTML链接,并在那里输入以下代码。

<div id="wrapper">
  <canvas id="canvas" width="450" height="450"></canvas></div>

一旦进入,你应该会看到一个黑色的方形轮廓出现在右侧窗格中。如果你没有看到它,确保_HTML OUTPUT_被选中,然后点击代码窗格右上角的Run。<canvas>元素有一个id属性,以便于你从Dart代码中瞄准它。

注意:如果你的HTML输出窗格有滚动条,测试和玩这个游戏会很困难。如果你在一个低分辨率的屏幕上编码,并且无法通过最大化浏览器窗口或调整DartPad窗格的大小来消除滚动条,请随意修改canvas元素的高度属性,使其成为任何可除以10的数字。这不会影响游戏代码的行为。

什么是画布?

HTML5 <canvas> 元素与所有 HTML 标签一样,代表了文档中的一个矩形区域,可以通过代码谨慎地进行操作。它用于绘制光栅图形。需要注意的是,<canvas>元素本身没有绘制能力,它只是作为图形的容器。脚本是用来绘制图形的,这些脚本通常是用JavaScript构建的,但是Dart有一个几乎相同的API来与画布进行交互。

画布的左上角是原点,坐标为(0,0)。水平轴(x)向右上升,而垂直轴(y)向下上升。您在这个项目的HTML中定义的画布的宽和高都是450像素,这意味着右下角的像素坐标是(449,449)(因为您从0开始计算)。

本教程中的绘图代码将通过一个二维上下文对象作用于画布,在Dart中由·CanvasRenderingContext2D·类表示。通过它,你可以绘制文本、线条、方框、圆圈、图像、填充颜色、渐变或图案等。

为什么要用Canvas而不是DOM?

你可能想知道为什么你不应该使用熟悉的老DOM来构建游戏。当然,你可以指定一个<div>作为游戏板,并在其中移动一堆彩色的<span>元素。事实上,对于一个如此简单的游戏,而且硬件也和现在一样好,你也许可以这样做,但这种方法不会扩展。如果有很多移动的对象,要维持60fps的DOM更新并不容易。

画布的速度很,在最好的情况下(即使用现代浏览器),甚至可以利用当今先进的视频适配器提供的硬件加速。有了画布,您就拥有了像素级的控制权,它经过优化,可以在主机硬件允许的情况下快速绘制。即使是大型、复杂的游戏,也可以通过画布获得令人印象深刻的性能。

第3步:让我们来绘制

为了绘制到画布上,我们首先需要的是对画布元素的引用和它的2D上下文对象。

点击代码窗格左上角的_DART_ 。从现在开始你将在Dart代码视图中工作。用下面的代码替换那里的示例代码。

import 'dart:html';

CanvasElement canvas.CanvasRenderingContext2D    
CanvasRenderingContext2D ctx;

void main() {
  canvas = querySelector('#canvas')。
  ctx = canvas.getContext('2D');
}

首先导入核心的Dart HTML库,它让你可以访问查询和操作DOM的函数和类。每个Dart程序都是以main()函数开始执行的。这个main()函数使用Dart的可选类型注释来声明一个void返回类型,表示函数在结束时将返回null

main()上面声明了两个顶层变量,这些变量在main()函数里面被赋值。

第一个叫做canvas,它被分配给HTML文件中的<canvas>元素的引用。你再次利用Dart的可选类型注释,将变量显式化为CanvasElement,这是dart:html库为你提供的类之一。querySelector()函数,也是dart:html的一部分,允许你获取一个DOM节点的引用,在本例中,idcanvas的节点。

你需要一个上下文对象来在canvas上绘图,你通过canvas引用调用getContext()成员函数来获取它,并传递特殊的字符串 "2d "来表示你将进行二维绘图。这个上下文引用会被保存到ctx中,ctx被声明为CanvasRenderingContext2D类型。

有了这两个工具,你现在可以在画布上绘图了。

绘制单元格

为了简单起见,蛇游戏中的所有东西都会被画成一个小矩形,我们称之为细胞。蛇的每一段都将是一个绿色的单元格,而食物,当它出现时,将被画成一个蓝色的单元格。绘制彩色单元格将是游戏的一个基本构件,因此,接下来你将创建处理这个任务的顶层函数。

首先,你需要访问Dart的Point类,它的实例有成员变量xy,用于存储二维坐标。这意味着在Dart代码的顶部添加另一个导入语句。

import 'dart:math'

你还将使用一个常量定义每个单元格的大小(宽度和高度),单位是像素。如果这个值以后发生变化,你可以在一个方便的地方进行更改。

const int CELL_SIZE = 10

在这一点上,你完整的Dart代码应该是这样的。

import 'dart:html';
import 'dart:math';

const int CELL_SIZE = 10;

CanvasElement canvas.CanvasRenderingContext2D
CanvasRenderingContext2D ctx;

void main() {
  canvas = querySelector('#canvas')。
  ctx = canvas.getContext('2D');
}

现在你已经拥有了构建drawCell()函数所需要的一切。把这个函数放在Dart代码的main()函数下面。

void drawCell(Point coords, String color) { {
  ctx..fillStyle = color
    ...strokeStyle = "white".最后的 int x = coords.x * CELL_SIZE; ;

  final int x = coords.x * CELL_SIZE;
  final int y = coords.y * CELL_SIZE;

  ctx..fillRect(x, y, CELL_SIZE, CELL_SIZE)
    .strokeRect(x, y, CELL_SIZE, CELL_SIZE)。
}

drawCell()函数使用顶层的、全局可访问的画布渲染上下文,通过它可以进行绘制。它的参数是一个包含要绘制的单元格坐标的Point对象和一个字符串形式的颜色。对于color,您可以提供任何有效的 HTML 颜色名称

首先,您可以设置上下文的填充样式和描边样式。color参数最终作为单元格填充的颜色,但是笔触(本质上是单元格的边框)将始终是白色的,与游戏板的背景色相匹配。使用Dart的级联操作符(..),你可以用一条语句设置ctx的两个属性。

接下来,你使用CELL_SIZE的值和coords参数来确定单元格左上角的像素坐标。我们在这里使用 "单元格 "这个词,因为把画布想象成有一个虚构的网格叠加在上面是很有帮助的,网格中的每个单元格都有CELL_SIZE的宽度和高度(设置为10像素以上)。画布需要像素坐标来绘制,但对于游戏代码来说,用单元格坐标作为抽象处理是很方便的。所以左上角虚单元格的位置是(0,0),右下角单元格的位置是(44,44)。画布的宽度和高度为450像素,除以10像素的CELL_SIZE,则每个轴上有45个单元格。

变量xy被声明为final变量,因为它们在初始化后不应该被改变。如果任何代码试图在初始计算后改变变量的值,Dart分析器会警告你。

最后,你调用canvas上下文的两个成员函数,fillRect()strokeRect(),分别绘制一个填充的矩形和一个未填充的矩形(边框)。

要测试新的绘制函数,可以在main()函数的结尾添加这样的调用。

drawCell(new Point(10, 10), "salmon");

如果你正在查看_HTML OUTPUT_ ,然后点击_Run_ ,你应该会看到一个粉红色的小方块出现在你的游戏板上。由于该测试调用不会成为最终游戏的一部分,因此您需要在继续之前将其删除。

清除棋盘

这个游戏还需要一个图形辅助函数,这个函数将用于在帧之间清除游戏板。在drawCell()函数下面添加以下函数。

void clear() {
  ctx..fillStyle="white"
    .fillRect(0, 0, canvas.width, canvas.height);
}

clear()函数将2D上下文的填充样式设置为白色,然后从左上角开始绘制一个覆盖整个画布的填充矩形,并使用画布的宽度和高度来确定矩形的尺寸。

第5步:处理键盘输入

对于动作游戏来说,有时候单纯通过事件来处理键盘输入可能会很迟钝,让玩家感觉控制不灵敏。正因为如此,最好再加入一段辅助代码来平滑这个问题。

首先,在你的Dart代码的顶部添加一个导入语句。这将使你能够访问HashMap类,它将被下面的键盘类使用。

import 'dart:collection';

然后将这个类添加到Dart代码的最后。

class Keyboard {
  HashMap<int, num> _keys = new HashMap<int, num>();

  Keyboard() {
    window.onKeyDown.listen((KeyboardEvent event) {
      _keys.putIfAbsent(event.keyCode, () => event.timeStamp);
    });

    window.onKeyUp.listen((KeyboardEvent event) {
      _keys.remove(event.keyCode);
    });
  }

  bool isPressed(int keyCode) => _keys.containsKey(keyCode);
}

感谢Fredrik Bornander在他的使用Dart的游戏开发教程中介绍了这个按键处理程序代码。

由于篇幅和时间的限制,我们无法对这段代码进行太深入的研究,但本质上,它将监听来自浏览器的KeyDown和KeyUp事件,并在HashMap(像一个关联数组或字典)中跟踪在任何给定时间内哪些键被按下。一旦你创建了这个类的实例,你就可以用这样的行来检查比如左箭头键的状态。

if (keyboard.isPressed(KeyCode.LEFT)) { }

Dart的KeyCode类提供了一个完整的键码常量,这意味着你的代码不会充满深奥的整数键码(例如,左方向键的键码为37)。自我文档化代码的胜利

重要的是:为了启动这个处理程序,你需要在Dart代码的顶部创建一个顶层实例,以及Canvas和CanvasRenderingContext2D声明。在Dart代码的顶部添加这一行(在main()上面,但在imports下面)。

Keyboard keyboard = new Keyboard();

其他输入类型呢?

敏锐的读者或有经验的游戏开发者会注意到,一般来说,这样专门处理输入并不是最好的做法。如果你的游戏对象直接使用这个类来检查用户的输入,那么游戏将只能在有键盘的设备上运行。那用鼠标控制蛇呢?在移动设备上,你可能想提供屏幕控制或使用加速计

要做到这一点,你需要另一层抽象,也许是命令模式,但对于这个项目,只支持键盘输入。

第6步:蛇

使用Dart的一大优势是它是一种纯面向对象的编程语言,就像Smalltalk一样。这意味着Dart没有原始类型,一切都是对象。为了保持这个传统,你会把你的Snake做成一个类。

Snake!

将Snake类添加到Dart代码的底部。你将在接下来的几个章节中填充它。

class Snake {

}

掌握一些方向

在Snake类中添加以下常量定义。

// directions
static const Point LEFT = const Point(-1, 0);
static const Point RIGHT = const Point(1, 0);
static const Point UP = const Point(0, -1);
static const Point DOWN = const Point(0, 1);

首先,你定义了一组常量来代表蛇能够行走的四个方向。请注意,Snake类中的所有坐标都是单元格坐标。只有drawCell()函数才会处理像素坐标。减少蛇头的x坐标将使蛇向左移动,而增加蛇头的y坐标将使蛇向下移动。Dart的Point类覆盖了+操作符,所以Point操作非常方便,就像这个例子。

Point snakeHead = const Point(5, 5);
Point moveRight = const Point(1, 0);

Point newSnakeHead = snakeHead + moveRight; // Point(6, 5)

由于Point类是不可改变的(它的所有成员变量都被声明为final),你可以用关键字const代替new来创建Point的新实例。这些特殊的Point实例被称为编译时常量,Dart的编译器会像对待其他文字值一样对待它们,比如5(一个整数)或 "Hello"(一个字符串)。换句话说,编译时常量是一个直接嵌入到代码中的值,没有对象创建的开销。

要阅读更多关于Dart类和使它们成为编译时常量的信息,请查看Dart语言之旅的类部分

出生时的尺寸

蛇类又多了一个常量定义。

static const int START_LENGTH = 6;

START_LENGTH是一条新的小蛇开始其生命的身体部分的数量。

蛇的身体

将这个变量声明(以及可选的注释)添加到你的Snake类中。

// coordinates of the body segments
List<Point> _body;

_body成员变量被声明为Point元素的List。列表基本上就是一个数组。在这个数组中,你将存储蛇的每一个体段的单元格坐标,每一个体段都将在游戏板上被画成一个方形单元格。变量的名称以下划线开头,以标记它是一个私有字段,不打算被外部代码访问。因为Dart的隐私边界是,而是类,所以在这个单Dart文件的设置中,隐私不会被正确执行,但养成使用封装的习惯是很好的。

朝右开始

将此变量定义添加到你的Snake类中。

// current travel direction
Point _dir = RIGHT;

你将蛇的当前行进方向存储在一个名为_dir的点变量中,用编译时常量RIGHT初始化。这个值会在用户玩游戏时根据键盘输入而改变。

构造一条蛇

更多Snake类的代码。

Snake() {
  int i = START_LENGTH - 1;
  _body = new List<Point>.generate(START_LENGTH,
    (int index) => new Point(i--, 0));
}

Snake类的构造函数Snake()只有一项工作:为新蛇的身体的每一段生成坐标。如果你要在一个原始的蛇类数组上执行一行类似print(_body);的代码,你会发现它的长度为6,其元素如下。

[
  Point(5, 0),   // head
  Point(4, 0),   // segment
  Point(3, 0),   // segment
  Point(2, 0),   // segment
  Point(1, 0),   // segment
  Point(0, 0)    // tail
]

你可以使用一个带有递减迭代器的for循环来创建这样的List,或者甚至将它声明为一个文字,但是这个任务给了我们一个机会来锻炼Dart的一个更有趣的特性:命名构造函数。

你仍然需要一个降序迭代器,所以你将整数i设置为你需要生成的第一个x值:比蛇的起始体长少一个,在本例中就是5。将引用存储在 _body 中,你创建了新的 List of Point 元素,但是你没有调用正常的、无聊的 List 构造函数,而是调用了 List.generate() 命名的构造函数,它需要一个长度和一个生成函数作为参数。让我们来看看用于构造新蛇的生成函数。

// generator function from Snake's constructor
(int index) => new Point(i--, 0)

对于一个新蛇的六个元素中的每一个,生成函数将以当前的索引作为参数被调用。你使用Dart的函数速记语法(=>,或胖箭头)来保持这个匿名函数的主体简洁。每次调用生成函数,它都会返回一个新的点对象,x设置为当前值iy设置为0。每次创建新的点后,i都会被递减,为下一次迭代做准备。

获得一个头

在Snake类中添加以下getter函数定义。

Point get head => _body.first;

通过这段代码,你使用Dart的getter语法来创建一个方便的属性。蛇的头部总是_body 列表的第一个元素(即_body[0])。多亏了这一行,每当游戏代码需要快速访问蛇头的位置时,它可以用snake.head来获取,或者直接从Snake类中获取head

更新蛇类

对于游戏中的每一帧,蛇需要做几件事,包括检查用户输入以改变方向,根据当前方向移动自己,以及绘制自己。现在就逐一将这些方法添加到Snake类中。

改变方向

void _checkInput() {
  if (keyboard.isPressed(KeyCode.LEFT) && _dir != RIGHT) {
    _dir = LEFT;
  }
  else if (keyboard.isPressed(KeyCode.RIGHT) && _dir != LEFT) {
    _dir = RIGHT;
  }
  else if (keyboard.isPressed(KeyCode.UP) && _dir != DOWN) {
    _dir = UP;
  }
  else if (keyboard.isPressed(KeyCode.Down) && _dir != UP) {
    _dir = DOWN;
  }
}

_checkInput()私有类方法(还记得下划线吗?)检查键盘上的每一个方向键,以确定它当前是否被按下。你不会允许蛇倒档,所以在每次处理改变方向之前,你都要验证蛇是不是已经在与请求改变的方向相反的方向上行驶。这个方法使用顶层的keyboard变量来完成按键检查。

蛇的成长

void grow() {
  // add new head based on current direction
  _body.insert(0, head + _dir);
}

grow()方法处理根据蛇的当前行进方向添加一个新的头。请注意,它不是私有的,因为每当蛇吃到食物的时候,游戏类(待定)都需要调用这个方法。

移动一条蛇

void _move() {
  // add a new head segment
  grow();
  
  // remove the tail segment
  _body.removeLast();
}

要在游戏板上移动蛇,你不会费力地更新每一组身体段的坐标。取而代之的是,你会在现有的头部前面添加一个新的头段,然后弹掉尾巴。从视觉上看,这也能达到同样的效果。_move()方法只能在Snake类中被调用,所以这个方法会用下划线前缀命名。

绘制一条蛇

void _draw() {
  // starting with the head, draw each body segment
  for (Point p in _body) {
    drawCell(p, "green");
  }
}

当需要把蛇放在屏幕上的时候,你会调用_draw()方法。使用 Dart 的 for...in 循环来遍历蛇的身体部分,它对每个部分都调用 drawCell() 方法,并指定填充颜色为绿色。

不要再打自己了

bool checkForBodyCollision() {
  for (Point p in _body.skip(1)) {
    if (p == head) {
      return true;
    }
  }

  return false;
}

游戏类将在其更新周期内调用这个公开可用的方法。checkForBodyCollision()函数将返回一个布尔值:如果蛇的头部与任何身体段相撞,则为true,否则为false。为了检查这一点,我们遍历除头部以外的每一个蛇身段,并将其位置与头部的位置进行比较。你用 _body.skip(1) 创建一个临时的 List 身体段,不包括头部。传递 List 的 skip() 方法一个 1,返回一个新的 List,其中包括除了第一个元素之外的所有元素。

最后的更新

void update() {
  _checkInput()。
  _move();
  _draw();
}

snake的公开update()方法将在每一帧被Game类的update()方法调用。

测试Snake

为了确保Snake能正常工作,暂时在main()函数的结尾添加以下代码。

Snake snake = new Snake();
clear().snake.update();。
snake.update();

如果你现在运行这段代码,你应该看到在白色游戏板的左上角HTML OUTPUT区域出现了一条有六个单元格段的 "蛇"。新的蛇在开始时尾巴位于单元格坐标(0,0),但调用snake.update()会在画蛇之前将蛇向右移动一个单元格。

在进入下一步之前,不要忘记删除测试代码,在下一步中,你将编写一个类来管理和运行实际的游戏。

步骤7:游戏

游戏类是游戏的真正驱动力。它包含主循环,准备和渲染每一帧动画。

一个新的游戏

在Dart代码的结尾添加以下内容来开始Game类。

class Game {

}

控制您的速度

在Game类中添加以下static const

// smaller numbers make the game run faster
static const num GAME_SPEED = 50;

如果你的计算机有自己的方式,它会以硬件和环境允许的最快速度运行游戏循环,除了最简陋的设备,这将使游戏无法运行。为了使事情得到控制,你将使用GAME_SPEED来限制游戏循环运行的频率。分配的值是在你允许下一次更新之前,你需要从上一次更新发生后经过的毫秒数。

Dart的num类型是intdouble的祖先,所以GAME_SPEED可以被分配一个整数或小数的值。

你还需要一个变量来帮助控制速度。在 Game 类中添加这一行。

num _lastTimeStamp = 0;

有了这行,你就可以计算帧更新之间已经过去了多少时间。

注意那些边缘

将这些成员变量添加到Game类中。

// a few convenience variables to simplify calculations
int _rightEdgeX;
int _bottomEdgeY;

在代码中,有几个地方你需要知道游戏板上最大的xy单元格坐标,这些将在Game类的构造函数中动态填充,以适应画布大小的任何变化。

游戏实体

将这些成员变量添加到Game类中。

Snake _snake;
Point _food;

游戏类需要跟踪的唯一两个实体是蛇和蛇的食物。当然,蛇是你的Snake类的一个实例,而食物只用一个Point来表示。

构造游戏类

Game() {
  _rightEdgeX = canvas.width ~/ CELL_SIZE;
  _bottomEdgeY = canvas.height ~/ CELL_SIZE;

  init();
}

按照约定,_rightEdgeX_bottomEdgeY在这里被计算出来。数学计算是用Dart的整数除法运算符(~/)完成的。如果除法的结果有小数部分,那么它将被截断,只有整数部分会被返回。

init()成员函数还不存在,但是每当创建一个新的游戏时,你需要调用它。

初始化

void init() {
  _snake = new Snake();
  _food = _randomPoint();
}

这个函数创建一个新的Snake实例,并使用另一种方法随机生成第一个蛇食的位置。

Point _randomPoint() {
  Random random = new Random();
  return new Point(random.nextInt(_rightEdgeX),
    random.nextInt(_bottomEdgeY));
}

在创建一个Dart的Random类的实例后,你用它来创建一个随机的xy坐标。这有许多潜在的用途,但它的主要目的是在游戏板周围放置食物。Random的nextInt()函数返回一个介于0和传递给它的值之间的伪随机整数,在这种情况下,每个轴的最大坐标。

当世界发生碰撞时

void _checkForCollisions() {
  // check for collision with food
  if (_snake.head == _food) {
    _snake.grow();
    _food = _randomPoint();
  }
  
  // check death conditions
  if (_snake.head.x <= -1 ||
    _snake.head.x >= _rightEdgeX ||
    _snake.head.y <= -1 ||
    _snake.head.y >= _bottomEdgeY ||
    _snake.checkForBodyCollision()) {
    init();
  }
}

如果蛇的头段位置曾经等于食物的位置,蛇就会 "吃掉 "食物,并长出一段。食物会被一个新的随机位置的小食取代。

"但是,等等!"你惊呼。"在许多面向对象的语言中,用==比较两个Point实例,只有当它们是同一个实例时,才会解析为true。什么原因?" 这也是Dart的默认行为,但就像Dart的Point类为了让你的生活更轻松而覆盖了+操作符一样,它也覆盖了==操作符。在后者的情况下,如果x字段和y字段都是等价的,那么比较就会返回true

对于蛇来说,在这个游戏中,有五种致命的碰撞类型:头部与游戏板的四个边缘中的任何一个或自己的身体部分的碰撞。_checkForCollisions()中的第二个条件是检查蛇的头段是否已经逃出了游戏板的边界,然后它运行Snake类的checkForBodyCollision()函数,如果发生了身体碰撞,则返回true。如果这些比较中的任何一个解析为真,你再次运行init()函数,将蛇重置为它的默认大小和位置。读者将把蛇的内脏爆炸和死亡之痛作为一个练习留给它。

请求动画帧

你还需要一个导入语句来使游戏的这一部分正常工作。将这行添加到你的DART代码窗格的顶部,以及其他import语句。

import 'dart:async';

这是个启动球的函数。在Game类中添加run()方法。

Future run() async {
  update(await window.animationFrame);
}

历史小课堂

在过去的悲惨日子里,JavaScripter创建动画或游戏循环是很常见的,看起来像这样。

function drawStuff() {
  // draw all the stuff
}

setInterval(drawStuff, 100);

这告诉浏览器尝试每100毫秒调用drawStuff()。这个方法在很多方面都有问题。首先,setInterval()是一个普通的定时器函数,它与动画或绘制到屏幕上没有直接关系,所以它没有考虑到你正在绘制的东西是否对用户可见。它可能在另一个窗口后面,或者从视口中滚动出来,但无论如何,drawStuff()都会被忠实地调用,浪费周期。

另一个问题是setInterval()试图每100ms调用一次drawStuff(),无论浏览器是否准备好。浏览器必须兼顾你的和它自己的重绘周期,而且在这种情况下,你的游戏的帧更新和浏览器的帧更新不可能是同步的,所以CPU的使用率会上升,风扇会旋转,设备电池也会耗尽。

Mozilla火狐浏览器的制造商)来救场了,提出了开发一种更礼貌地让大家保持屏幕重绘同步的方法。所以现在,在现代浏览器中,windows对象暴露了一个新的JavaScript方法,叫做requestAnimationFrame()。调用它,浏览器就会通过回调函数让你知道什么时候可以画画了。

用Dart请求帧

由于这是一个新的功能,一些旧的浏览器只用供应商前缀来暴露这个功能,迫使你写代码来检查哪个是真正可用的: requestAnimationFrame(), webkitRequestAnimationFrame(), mozRequestAnimationFrame(), 或 msRequestAnimationFrame(), 这取决于浏览器供应商。Dart为你提供了保护。

await window.animationFrame

Dart使用Futures(在其他语言中有时也被称为承诺)来帮助管理和减轻使用回调函数时的一些痛苦。因为你导入了dart:html,所以你可以访问浏览器的window对象,但是你没有调用requestAnimationFrame()并传递一个回调,而是访问animationFrame getter,它返回一个Future实例。

Dart 的 await 语法用于等待 animationFrame Future 的完成,一旦浏览器的下一个重绘周期开始,就会发生。完成的Future将被解析为一个数字delta值,代表你的应用程序启动后的毫秒数。这个数字会被传递给Game类即将编写的update()函数。一旦系统准备好重新绘制屏幕,Game的update()就会被系统调用。当update()完成它的业务后,它将再次调用run()来请求另一帧,从而创建你的游戏的主循环。

更新游戏

在你的Game类中添加update()方法。

void update(num delta) {
  final num diff = delta - _lastTimeStamp;

  if (diff > GAME_SPEED) {
    _lastTimeStamp = delta;
    clear();
    drawCell(_food, "blue");
    _snake.update();
    _checkForCollisions();
  }

  // keep looping
  run();
}

游戏类的最后一段代码将把所有的代码整合在一起(并消除你最后关于试图调用未定义函数的警告)。

当系统通过调用update()来响应你的请求时,它会传递自应用程序开始加载以来的delta时间,单位为毫秒。从这个数字中减去_lastTimeStamp的值,就得到了两者之间的差异。此时,diff的值就是上次调用update()后的毫秒数。这里的条件只有在距离上一次调用后超过GAME_SPEED毫秒的情况下才会构造并渲染一个新的帧。

由于移动算法的工作方式,蛇会在每一帧中移动CELL_SIZE像素。默认情况下,CELL_SIZE是10个像素,GAME_SPEED是50毫秒。你问我是如何决定这些数字的?一点点有学问的猜测让我能够大致确定,然后我调整了这些数字,直到我觉得速度合适为止。如果你想让游戏更快,你可以降低GAME_SPEED的值,如果你的目标受众是小孩子,你可以增加它来减缓蛇的速度。

如果从上一帧开始已经过了足够的时间,你就把delta的值保存为这一帧的时间戳。接下来,调用clear()给画布涂上一层漂亮的白色。使用drawCell(),你将蛇类食物画到游戏板上。在Snake实例上调用update(),检查键盘输入,移动蛇,并在屏幕上渲染蛇。最后,你确定蛇是否与食物或棋盘的边缘相撞。

请注意,每次调用Game的update()时都会调用run(),无论是否有新的帧被渲染。这一点很重要,因为这个调用会让循环持续下去。

第8步:运行游戏

这是你通过本教程的漫长旅程的结束。恭喜你来到这里! 为了给你创建的这个怪物注入生命,你需要一个启动按钮(形象地讲)。

用下面的代码替换你的main()函数。

void main() {
  canvas = querySelector('#canvas').focus();
  ctx = canvas.getContext('2D');

  new Game().run();
}

这就是你已经使用了一段时间的main()函数,但有两个小的不同。首先,你使用级联操作符来调用CanvasElement上的focus()函数,因为你获得了它的引用,其目的是给你的canvas元素键盘焦点。在一些运行时的场景中,这可以防止需要点击画布来使键盘开始响应。不幸的是,在DartPad中,你通常需要点击画布使其聚焦,然后你的蛇才会对按键做出反应。

最后,你通过创建一个新的Game类实例来运行游戏,然后执行它的run()方法来启动游戏循环。

一切就绪,所有的错别字都消除了,确保你正在输出窗格中查看HTML OUTPUT,然后点击Run。确保你已经和你的家人和朋友吻别了,因为这个蛇游戏很容易让人上瘾。

下一步是什么?

现在你已经得到了可以工作的代码,挑战一下自己,添加一些改进。这里有一些建议。

  • 向用户显示某种分数。最简单的数值就是蛇的长度。或者你可以设计更复杂的评分,也许是基于蛇吃掉多少食物。
  • 你可以改变游戏速度,作为游戏本身的一个功能,作为一个可配置的选项,或者两者兼而有之。
  • 改变一次显示多少块食物的数量。
  • 发明新的食物类型。也许有些可食用的部分是有毒的,会减少蛇的长度并损害玩家的分数。也许有些食物具有放射性,会使蛇的体型立即增加一倍。
  • 玩画例程来改变图形。让蛇的头或尾巴变成不同的颜色或形状,或者使用位图艺术来代替动态绘制的矢量图形。改变游戏板的背景。
  • 让游戏成为多人游戏。一次一个玩家,热座式;两条蛇同时出现在屏幕上,可以在一个设备上,也可以在多个联网设备上。
  • 增加更多的障碍物。墙壁,可以打开和关闭的门,AI控制的敌人,发射的弹丸可以摧毁身体部分,并将蛇切成两半。

我可以一直说下去,但为了你们,我不会。我确实希望我已经证明了即使是最简单的想法也可以成为精心设计的,(希望)有趣的,有趣的游戏。

结论

Dart是一种 "功能齐全" 的现代网络语言,专门设计为可扩展性,并从第一行代码开始提高开发人员的生产力。这些特点使它成为网络游戏开发的最佳选择。

为了让你能用Dart去做令人敬畏的事情,你将需要离开DartPad。它是一个很好的演示概念或快速概念验证的环境,但当你的想法变得更大,你将需要转移到更强大的东西。

关于如何使用Jetbrains WebStorm(Dart官方认可的IDE)的逐步介绍,请看 "用Dart编写命令行实用程序 "的开头部分。


通过( www.DeepL.com/Translator )(免费版)翻译