回到游戏

我们现在已经了解了足够多的画布绘图知识,我们已经可以使用基于画布的显示系统来改造前面几章中开发的游戏了。新的界面不会再是一个个色块,而使用drawImage来绘制游戏中元素对应的图片。

我们定义了一种对象类型,叫做CanvasDisplay,支持第 14 章中的DOMDisplay的相同接口,也就是setState方法与clear方法。

这个对象需要比DOMDisplay多保存一些信息。该对象不仅需要使用 DOM 元素的滚动位置,还需要追踪自己的视口(viewport)。视口会告诉我们目前处于哪个关卡。最后,该对象会保存一个filpPlayer属性,确保即便玩家站立不动时,它面朝的方向也会与上次移动所面向的方向一致。

  1. class CanvasDisplay {
  2. constructor(parent, level) {
  3. this.canvas = document.createElement("canvas");
  4. this.canvas.width = Math.min(600, level.width * scale);
  5. this.canvas.height = Math.min(450, level.height * scale);
  6. parent.appendChild(this.canvas);
  7. this.cx = this.canvas.getContext("2d");
  8. this.flipPlayer = false;
  9. this.viewport = {
  10. left: 0,
  11. top: 0,
  12. width: this.canvas.width / scale,
  13. height: this.canvas.height / scale
  14. };
  15. }
  16. clear() {
  17. this.canvas.remove();
  18. }
  19. }

setState方法首先计算一个新的视口,然后在适当的位置绘制游戏场景。

  1. CanvasDisplay.prototype.setState = function(state) {
  2. this.updateViewport(state);
  3. this.clearDisplay(state.status);
  4. this.drawBackground(state.level);
  5. this.drawActors(state.actors);
  6. };

DOMDisplay相反,这种显示风格确实必须在每次更新时重新绘制背景。 因为画布上的形状只是像素,所以在我们绘制它们之后,没有什么好方法来移动它们(或将它们移除)。 更新画布显示的唯一方法,是清除它并重新绘制场景。 我们也可能发生了滚动,这要求背景处于不同的位置。

updateViewport方法与DOMDisplayscrollPlayerintoView方法相似。它检查玩家是否过于接近屏幕的边缘,并且当这种情况发生时移动视口。

  1. CanvasDisplay.prototype.updateViewport = function(state) {
  2. let view = this.viewport, margin = view.width / 3;
  3. let player = state.player;
  4. let center = player.pos.plus(player.size.times(0.5));
  5. if (center.x < view.left + margin) {
  6. view.left = Math.max(center.x - margin, 0);
  7. } else if (center.x > view.left + view.width - margin) {
  8. view.left = Math.min(center.x + margin - view.width,
  9. state.level.width - view.width);
  10. }
  11. if (center.y < view.top + margin) {
  12. view.top = Math.max(center.y - margin, 0);
  13. } else if (center.y > view.top + view.height - margin) {
  14. view.top = Math.min(center.y + margin - view.height,
  15. state.level.height - view.height);
  16. }
  17. };

Math.maxMath.min的调用保证了视口不会显示当前这层之外的物体。Math.max(x,0)保证了结果数值不会小于 0。同样地,Math.min`保证了数值保持在给定范围内。

在清空图像时,我们依据游戏是获胜(明亮的颜色)还是失败(灰暗的颜色)来使用不同的颜色。

  1. CanvasDisplay.prototype.clearDisplay = function(status) {
  2. if (status == "won") {
  3. this.cx.fillStyle = "rgb(68, 191, 255)";
  4. } else if (status == "lost") {
  5. this.cx.fillStyle = "rgb(44, 136, 214)";
  6. } else {
  7. this.cx.fillStyle = "rgb(52, 166, 251)";
  8. }
  9. this.cx.fillRect(0, 0,
  10. this.canvas.width, this.canvas.height);
  11. };

要画出一个背景,我们使用来自上一节的touches方法中的相同技巧,遍历在当前视口中可见的所有瓦片。

  1. let otherSprites = document.createElement("img");
  2. otherSprites.src = "img/sprites.png";
  3. CanvasDisplay.prototype.drawBackground = function(level) {
  4. let {left, top, width, height} = this.viewport;
  5. let xStart = Math.floor(left);
  6. let xEnd = Math.ceil(left + width);
  7. let yStart = Math.floor(top);
  8. let yEnd = Math.ceil(top + height);
  9. for (let y = yStart; y < yEnd; y++) {
  10. for (let x = xStart; x < xEnd; x++) {
  11. let tile = level.rows[y][x];
  12. if (tile == "empty") continue;
  13. let screenX = (x - left) * scale;
  14. let screenY = (y - top) * scale;
  15. let tileX = tile == "lava" ? scale : 0;
  16. this.cx.drawImage(otherSprites,
  17. tileX, 0, scale, scale,
  18. screenX, screenY, scale, scale);
  19. }
  20. }
  21. };

非空的瓦片是使用drawImage绘制的。otherSprites包含了描述除了玩家之外需要用到的图片。它包含了从左到右的墙上的瓦片,火山岩瓦片以及精灵硬币。

回到游戏 - 图1

背景瓦片是20×20像素的,因为我们将要用到DOMDisplay中的相同比例。因此,火山岩瓦片的偏移是 20,墙面的偏移是 0。

我们不需要等待精灵图片加载完成。调用drawImage时使用一幅并未加载完毕的图片不会有任何效果。因为图片仍然在加载当中,我们可能无法正确地画出游戏的前几帧。但是这不是一个严重的问题,因为我们持续更新荧幕,正确的场景会在加载完毕之后立即出现。

前面展示过的走路的特征将会被用来代替玩家。绘制它的代码需要根据玩家的当前动作选择正确的动作和方向。前 8 个子画面包含一个走路的动画。当玩家沿着地板移动时,我们根据当前时间把他围起来。我们希望每 60 毫秒切换一次帧,所以时间先除以 60。当玩家站立不动时,我们画出第九张子画面。当竖直方向的速度不为 0,从而被判断为跳跃时,我们使用第 10 张,也是最右边的子画面。

因为子画面宽度为 24 像素而不是 16 像素,会稍微比玩家的对象宽,这时为了腾出脚和手的空间,该方法需要根据某个给定的值(playerXOverlap)调整x坐标的值以及宽度值。

  1. let playerSprites = document.createElement("img");
  2. playerSprites.src = "img/player.png";
  3. const playerXOverlap = 4;
  4. CanvasDisplay.prototype.drawPlayer = function(player, x, y,
  5. width, height){
  6. width += playerXOverlap * 2;
  7. x -= playerXOverlap;
  8. if (player.speed.x != 0) {
  9. this.flipPlayer = player.speed.x < 0;
  10. }
  11. let tile = 8;
  12. if (player.speed.y != 0) {
  13. tile = 9;
  14. } else if (player.speed.x != 0) {
  15. tile = Math.floor(Date.now() / 60) % 8;
  16. }
  17. this.cx.save();
  18. if (this.flipPlayer) {
  19. flipHorizontally(this.cx, x + width / 2);
  20. }
  21. let tileX = tile * width;
  22. this.cx.drawImage(playerSprites, tileX, 0, width, height,
  23. x, y, width, height);
  24. this.cx.restore();
  25. };

drawPlayer方法由drawActors方法调用,该方法负责画出游戏中的所有角色。

  1. CanvasDisplay.prototype.drawActors = function(actors) {
  2. for (let actor of actors) {
  3. let width = actor.size.x * scale;
  4. let height = actor.size.y * scale;
  5. let x = (actor.pos.x - this.viewport.left) * scale;
  6. let y = (actor.pos.y - this.viewport.top) * scale;
  7. if (actor.type == "player") {
  8. this.drawPlayer(actor, x, y, width, height);
  9. } else {
  10. let tileX = (actor.type == "coin" ? 2 : 1) * scale;
  11. this.cx.drawImage(otherSprites,
  12. tileX, 0, width, height,
  13. x, y, width, height);
  14. }
  15. }
  16. };

当需要绘制一些非玩家元素时,我们首先检查它的类型,来找到与正确的子画面的偏移值。熔岩瓷砖出现在偏移为 20 的子画面,金币的子画面出现在偏移值为 40 的地方(放大了两倍)。

当计算角色的位置时,我们需要减掉视口的位置,因为(0,0)在我们的画布坐标系中代表着视口层面的左上角,而不是该关卡的左上角。我们也可以使用translate方法,这样可以作用于所有元素。

这个文档将新的显示屏插入runGame中:

  1. <body>
  2. <script>
  3. runGame(GAME_LEVELS, CanvasDisplay);
  4. </script>
  5. </body>