前情提要

在上一节中,我们实现了重力系统和跳跃功能,角色已经可以在地面上跳来跳去了。但是画面还是太单调了,只有一个紫色的背景和一条黑线。

这节课我们来添加一张背景图片,让游戏画面更加丰富。

准备背景图片

首先,你需要准备一张背景图片。可以从 Mari0 游戏中提取,或者自己找一张合适的图片。

图片的尺寸最好和画布的尺寸一致(600 x 500),这样绘制起来最简单。如果尺寸不一致也没关系,Canvas 的 drawImage 方法可以缩放图片。

把图片放到 images 文件夹中,命名为 bg.jpg(或者其他你喜欢的名字)。

加载背景图片

和加载角色图片一样,我们需要创建一个 Image 对象来加载背景图片:

const backgroundImg = new Image();
backgroundImg.src = './images/bg.jpg';

这段代码可以放在 player 对象定义的附近,方便管理。

绘制背景图片

在 drawImg 函数中,我们需要先绘制背景图片,然后再绘制其他内容。

重要:绘制顺序很重要!

Canvas 的绘制是有顺序的,后绘制的内容会覆盖先绘制的内容。所以背景图片应该最先绘制,然后是地面线,最后是角色。

function drawImg() {
  ctx.clearRect(0, 0, cvWidth, cvHeight); // 清除画面

  // 先绘制背景图片
  ctx.drawImage(backgroundImg, 0, 0, cvWidth, cvHeight);

  // 绘制文字信息
  ctx.font = "20px 微软雅黑";
  ctx.fillStyle = "#fff"; // 改成白色,在深色背景上更清晰
  ctx.fillText("当前时间戳为: " + gameData.currentTimeStamp, 10, 30);
  ctx.fillText("当前循环耗时(秒): " + gameData.updateDelta, 10, 60);
  ctx.fillText("当前帧数: " + gameData.fps, 10, 90);
  ctx.fillText("按空格跳跃,A/D 左右移动", 10, 120);

  // 最后绘制角色
  ctx.drawImage(
    player.image,
    player.x - player.image.width / 2,
    player.y - player.image.height / 2
  );
}

drawImage 的不同用法

你可能注意到了,绘制背景图片时我们使用了 4 个参数:

ctx.drawImage(backgroundImg, 0, 0, cvWidth, cvHeight);

而绘制角色时只用了 3 个参数:

ctx.drawImage(player.image, x, y);

这是因为 drawImage 有多种用法:

三参数用法

ctx.drawImage(image, x, y);
  • image - 要绘制的图片
  • x - 图片左上角的 X 坐标
  • y - 图片左上角的 Y 坐标

这种用法会按照图片的原始尺寸绘制。

五参数用法

ctx.drawImage(image, x, y, width, height);
  • image - 要绘制的图片
  • x - 图片左上角的 X 坐标
  • y - 图片左上角的 Y 坐标
  • width - 绘制出的图片宽度
  • height - 绘制出的图片高度

这种用法可以缩放图片。我们用这种方法来确保背景图片填满整个画布。

九参数用法

ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

这种用法可以裁剪图片的一部分来绘制,我们会在后面讲动画时用到。

优化:添加半透明遮罩

如果背景图片太亮,文字可能看不清楚。我们可以在背景图片上添加一个半透明的黑色遮罩:

function drawImg() {
  ctx.clearRect(0, 0, cvWidth, cvHeight);

  // 绘制背景图片
  ctx.drawImage(backgroundImg, 0, 0, cvWidth, cvHeight);

  // 绘制文字信息
  ctx.font = "20px 微软雅黑";
  ctx.fillStyle = "#fff";
  ctx.fillText("当前时间戳为: " + gameData.currentTimeStamp, 10, 30);
  ctx.fillText("当前循环耗时(秒): " + gameData.updateDelta, 10, 60);
  ctx.fillText("当前帧数: " + gameData.fps, 10, 90);
  ctx.fillText("按空格跳跃,A/D 左右移动", 10, 120);

  // 绘制角色
  ctx.drawImage(
    player.image,
    player.x - player.image.width / 2,
    player.y - player.image.height / 2
  );
}

rgba(0, 0, 0, 0.3) 表示黑色,透明度为 30%。你可以调整最后一个参数来改变遮罩的深浅。

关于图片加载

和角色图片一样,背景图片也是异步加载的。但是因为我们有一个无限循环在不断尝试绘制,所以不需要特别处理图片加载完成的事件。

如果你想确保图片加载完成后再开始游戏,可以使用 onload 事件:

const backgroundImg = new Image();
backgroundImg.onload = function() {
  console.log('背景图片加载完成');
};
backgroundImg.src = './images/bg.jpg';

但在我们的游戏中,这不是必需的。

最终效果

现在,游戏画面应该有了一个漂亮的背景图片。角色在背景前跳跃,看起来就像一个真正的游戏了。

完整的代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title></title>
  </head>
  <body>
    <div id="screen"></div>
  </body>
  <script>
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d");

    let then;

    const cvWidth = 538;
    const cvHeight = 430;
    const floorY = 344;
    const g = 980;

    const gameData = {
      currentTimeStamp: new Date().getTime(),
    };

    const keysDown = {};
    addEventListener("keydown", function (e) {
      keysDown[e.code] = true;
    });
    addEventListener("keyup", function (e) {
      delete keysDown[e.code];
    });

    // 加载背景图片
    const backgroundImg = new Image();
    backgroundImg.src = "./images/bg.jpg";

    const player = {
      image: new Image(),
      x: cvWidth / 2,
      y: floorY,
      speed: 200,
      ySpeed: 0,
      airCount: 0,
      maxAirCount: 28,
    };
    player.image.src = "./images/mario.png";

    function init() {
      then = Date.now();
      createCanvas(cvWidth, cvHeight);
      setInterval(main, 1);
    }

    function createCanvas(width, height) {
      canvas.width = width;
      canvas.height = height;
      canvas.style.margin = "0 auto";
      let screen = document.getElementById("screen");
      screen.appendChild(canvas);
    }

    function main() {
      const now = Date.now();
      const delta = now - then;
      update(delta / 1000);
      drawImg();
      then = now;
    }

    function gravityController(modifier) {
      player.y -= player.ySpeed * modifier;

      if (player.y < floorY) {
        player.ySpeed -= g * modifier;
      } else {
        player.ySpeed = 0;
        player.y = floorY;
        player.airCount = 0;
      }
    }

    function update(modifier) {
      gameData.currentTimeStamp = new Date().getTime();
      gameData.updateDelta = modifier;
      gameData.fps = Math.floor(1 / modifier);

      if ("Space" in keysDown) {
        if (player.airCount < player.maxAirCount) {
          player.ySpeed += 20;
          player.airCount++;
        }
      }

      if ("KeyA" in keysDown) {
        player.x += -player.speed * modifier;
      }
      if ("KeyD" in keysDown) {
        player.x += player.speed * modifier;
      }

      if (player.x < 0) {
        player.x = 0;
      }
      if (player.x > cvWidth) {
        player.x = cvWidth;
      }

      gravityController(modifier);
    }

    function drawImg() {
      ctx.clearRect(0, 0, cvWidth, cvHeight);

      // 绘制背景图片
      ctx.drawImage(backgroundImg, 0, 0, cvWidth, cvHeight);

      // 绘制文字信息
      ctx.font = "20px 微软雅黑";
      ctx.fillStyle = "#fff";
      ctx.fillText("当前时间戳为: " + gameData.currentTimeStamp, 10, 30);
      ctx.fillText("当前循环耗时(秒): " + gameData.updateDelta, 10, 60);
      ctx.fillText("当前帧数: " + gameData.fps, 10, 90);
      ctx.fillText("按空格跳跃,A/D 左右移动", 10, 120);

      // 绘制角色
      ctx.drawImage(
        player.image,
        player.x - player.image.width / 2,
        player.y - player.image.height / 2
      );
    }

    window.onload = init();
  </script>
</html>

下载完整代码

Leason5.zip