前情提要

在上一节中,我们修正了角色的移动速度,让它变得稳定可控。现在角色可以在画布上自由移动了,但是看起来还是有点奇怪——它可以在空中飞来飞去。

一个真正的游戏角色应该受到重力的影响,能够跳跃,也会下落。这节课我们就来实现这个功能。

什么是重力系统

在现实世界中,所有物体都会受到重力的影响。在游戏中,我们也需要模拟这个效果。

简单来说,重力系统就是:

  • 角色会不断向下加速
  • 当角色碰到地面时,停止下落
  • 当角色跳跃时,给它一个向上的速度
  • 向上的速度会逐渐被重力抵消,最终角色会开始下落

定义地面和重力

首先,我们需要定义一些新的变量:

// 游戏基本变量
const floorY = 344; // 地面的 Y 坐标
const g = 980; // 重力加速度,单位是像素/秒²

为什么重力加速度是 980 呢?因为现实世界中的重力加速度是 9.8 米/秒²,我们这里把 1 米对应 100 像素,所以就是 980 像素/秒²。当然,你也可以根据游戏的感觉来调整这个值。

给角色添加垂直速度

角色需要一个垂直方向的速度属性:

const player = {
  image: new Image(),
  x: cvWidth / 2,
  y: floorY, // 初始位置改为地面上
  speed: 200,
  ySpeed: 0, // 新增:垂直方向的速度,单位是像素/秒
};
player.image.src = './images/mario.png';

实现重力控制器

现在我们来写一个函数,专门处理重力相关的逻辑:

function gravityController(modifier) {
  // 更新角色的 Y 坐标
  player.y -= player.ySpeed * modifier;

  // 如果角色在空中,就施加重力
  if (player.y < floorY) {
    player.ySpeed -= g * modifier; // 重力让垂直速度不断减小(向下加速)
  } else {
    // 如果角色在地面上或者穿过了地面
    player.ySpeed = 0; // 垂直速度归零
    player.y = floorY; // 把角色固定在地面上
  }
}

让我们理解一下这段代码:

  1. player.y -= player.ySpeed * modifier - 根据垂直速度更新 Y 坐标。注意这里是减号,因为 Canvas 的 Y 轴是向下的,而我们希望正的 ySpeed 表示向上的速度。

  2. if (player.y < floorY) - 判断角色是否在空中(Y 坐标小于地面坐标)

  3. player.ySpeed -= g * modifier - 如果在空中,重力会让垂直速度不断减小。正的 ySpeed 会逐渐变小,变成 0,然后变成负数(开始下落)。

  4. 如果角色到达或穿过地面,就把它固定在地面上,并且把垂直速度归零。

实现跳跃

现在我们来添加跳跃功能。当玩家按下空格键时,给角色一个向上的速度:

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

  // 跳跃控制
  if ('Space' in keysDown) {
    if (player.y >= floorY) { // 只有在地面上才能跳跃
      player.ySpeed = 400; // 给一个向上的初速度
    }
  }

  // 左右移动
  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);
}

优化:二段跳

现在的跳跃已经可以工作了,但是只能在地面上跳。我们可以添加一个"二段跳"的功能,让游戏更有趣。

首先,给角色添加一个"滞空次数"的属性:

const player = {
  image: new Image(),
  x: cvWidth / 2,
  y: floorY,
  speed: 200,
  ySpeed: 0,
  airCount: 0, // 新增:当前滞空次数
  maxAirCount: 28, // 新增:最大滞空次数(可以理解为可以在空中按多少次跳跃键)
};
player.image.src = './images/mario.png';

然后修改跳跃逻辑:

// 跳跃控制
if ('Space' in keysDown) {
  if (player.airCount < player.maxAirCount) {
    player.ySpeed += 20; // 每次按空格增加一点向上的速度
    player.airCount++; // 滞空次数加 1
  }
}

最后,在重力控制器中,当角色落地时重置滞空次数:

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; // 落地时重置滞空次数
  }
}

这样,玩家可以在空中连续按空格键来延长滞空时间,甚至实现二段跳的效果。

移除上下移动

现在我们有了重力系统,就不需要 W 和 S 键来控制上下移动了。可以把这部分代码删除:

// 删除这些代码
if ('KeyW' in keysDown) {
  player.y += -player.speed * modifier;
}
if ('KeyS' in keysDown) {
  player.y += player.speed * modifier;
}
if (player.y < 0) {
  player.y = 0;
}
if (player.y > cvHeight) {
  player.y = cvHeight;
}

最终效果

现在,角色会受到重力的影响,可以跳跃,也会下落。按住空格键可以跳得更高,松开后角色会自然下落。

你可以尝试调整这些参数来改变游戏的手感:

  • g - 重力加速度,越大角色下落越快
  • player.ySpeed = 400 - 跳跃初速度,越大跳得越高
  • player.maxAirCount - 最大滞空次数,越大可以在空中停留越久

完整的代码

<!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; // 地面的 Y 坐标
    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 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.fillStyle = "#E6E6FA";
      ctx.fillRect(0, 0, cvWidth, cvHeight);

      ctx.font = "20px 微软雅黑";
      ctx.fillStyle = "#000";
      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>

下载完整代码

Leason4.zip