前情提要

在上一节中,我们实现了一个可以通过键盘控制移动的角色。但是细心的你可能已经发现了一个问题:角色的移动速度似乎不太稳定。

问题在哪里

还记得第一节课中提到的吗?我们的游戏循环间隔时间其实是不固定的。虽然我们设置了 setInterval(main, 1),但实际上每次循环的间隔可能是 1 毫秒,也可能是 5 毫秒,甚至更长。

这是因为游戏逻辑的处理和画面的绘制都需要时间,而且这个时间是不固定的。

在上一节的代码中,我们是这样移动角色的:

if ('KeyA' in keysDown) {
  player.x += -1; // 每次循环移动 1 像素
}

这意味着什么呢?如果循环间隔是 1 毫秒,角色 1 秒钟会移动 1000 像素。但如果循环间隔变成了 2 毫秒,角色 1 秒钟就只能移动 500 像素了。

这就是为什么角色的移动速度会不稳定的原因。

如何解决

解决方法其实很简单,还记得我们在第一节课中计算的 delta 吗?它记录的就是每次循环的实际间隔时间。

我们只需要把移动的距离和这个时间关联起来就可以了。

定义角色的速度

首先,我们需要给角色定义一个速度属性。这个速度的单位是"像素/秒"。

const player = {
  image: new Image(),
  x: cvWidth / 2,
  y: cvHeight / 2,
  speed: 200, // 新增:角色的移动速度,单位是像素/秒
};
player.image.src = './images/mario.png';

使用 delta 来计算移动距离

然后,在 update 函数中,我们就可以使用 delta 来计算角色应该移动的距离了。

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

  // modifier 就是 delta,单位是秒
  // player.speed 的单位是像素/秒
  // player.speed * modifier 的结果就是这一帧应该移动的像素数

  if ('KeyA' in keysDown) {
    player.x += -player.speed * modifier; // 向左移动
  }
  if ('KeyD' in keysDown) {
    player.x += player.speed * modifier; // 向右移动
  }
  if ('KeyW' in keysDown) {
    player.y += -player.speed * modifier; // 向上移动
  }
  if ('KeyS' in keysDown) {
    player.y += player.speed * modifier; // 向下移动
  }
}

原理解释

让我们来理解一下这个计算:

  • player.speed 是 200,表示角色每秒移动 200 像素
  • modifier 是每次循环的间隔时间,单位是秒(比如 0.001 秒)
  • player.speed * modifier 就是这一帧应该移动的距离

举个例子:

  • 如果循环间隔是 0.001 秒(1 毫秒),那么这一帧移动 200 * 0.001 = 0.2 像素
  • 如果循环间隔是 0.002 秒(2 毫秒),那么这一帧移动 200 * 0.002 = 0.4 像素

虽然每帧移动的距离不同,但是 1 秒钟累计移动的距离都是 200 像素左右,这样就保证了移动速度的稳定性。

添加边界限制

现在角色可以稳定移动了,但是它可以移动到画布外面去。让我们添加一个边界限制,防止角色跑出画布。

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

  if ('KeyA' in keysDown) {
    player.x += -player.speed * modifier;
  }
  if ('KeyD' in keysDown) {
    player.x += player.speed * modifier;
  }
  if ('KeyW' in keysDown) {
    player.y += -player.speed * modifier;
  }
  if ('KeyS' in keysDown) {
    player.y += player.speed * modifier;
  }

  // 边界限制
  if (player.x < 0) {
    player.x = 0;
  }
  if (player.x > cvWidth) {
    player.x = cvWidth;
  }
  if (player.y < 0) {
    player.y = 0;
  }
  if (player.y > cvHeight) {
    player.y = cvHeight;
  }
}

最终效果

现在,无论游戏循环的间隔时间如何变化,角色的移动速度都会保持稳定。你可以尝试修改 player.speed 的值,来调整角色的移动速度。

比如:

  • player.speed = 100 - 角色移动得很慢
  • player.speed = 200 - 角色移动速度适中
  • player.speed = 400 - 角色移动得很快

完整的代码

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

    // 该变量用于存储上一帧开始的时间
    let then;

    // Canvas 宽高
    const cvWidth = 538;
    const cvHeight = 430;

    // 存储游戏数据的变量
    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 player = {
      image: new Image(),
      x: cvWidth / 2,
      y: cvHeight / 2,
      speed: 200, // 角色的移动速度,单位是像素/秒
    };
    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 update(modifier) {
      gameData.currentTimeStamp = new Date().getTime();
      gameData.updateDelta = modifier;
      gameData.fps = Math.floor(1 / modifier);

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

      // 边界限制
      if (player.x < 0) {
        player.x = 0;
      }
      if (player.x > cvWidth) {
        player.x = cvWidth;
      }
      if (player.y < 0) {
        player.y = 0;
      }
      if (player.y > cvHeight) {
        player.y = cvHeight;
      }
    }

    function drawImg() {
      ctx.clearRect(0, 0, cvWidth, cvHeight);
      ctx.fillStyle = "#E6E6FA";
      ctx.fillRect(0, 0, cvWidth, cvHeight);
      ctx.fillStyle = "#000";
      ctx.moveTo(cvWidth / 2, 0);
      ctx.lineTo(cvWidth / 2, cvHeight);
      ctx.stroke();
      ctx.moveTo(0, cvHeight / 2);
      ctx.lineTo(cvWidth, cvHeight / 2);
      ctx.stroke();
      ctx.font = "20px 微软雅黑";
      ctx.fillStyle = "#000";
      ctx.fillText("当前时间戳为: " + gameData.currentTimeStamp, 260, 50);
      ctx.fillText("当前循环耗时(秒): " + gameData.updateDelta, 260, 120);
      ctx.fillText("当前帧数: " + gameData.fps, 260, 180);
      ctx.drawImage(
        player.image,
        player.x - player.image.width / 2,
        player.y - player.image.height / 2
      );
    }

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

下载完整代码

Leason3.zip