前情提要

在前面的课程中,我们已经实现了角色的移动、跳跃,还添加了背景图片。但是游戏中的物体之间还没有任何交互——它们可以互相穿过对方。

这节课我们来实现碰撞检测,让游戏中的物体能够"感知"到彼此的存在。

什么是碰撞检测

碰撞检测就是判断两个物体是否重叠或接触。在 2D 游戏中,最常用的碰撞检测方法是矩形碰撞检测(AABB - Axis-Aligned Bounding Box)。

简单来说,就是把每个物体看作一个矩形,然后判断这两个矩形是否重叠。

矩形碰撞检测的原理

两个矩形重叠的条件是:

  1. 它们在 X 轴上有重叠
  2. 它们在 Y 轴上有重叠

用代码表示就是:

// 假设有两个矩形 A 和 B
// A 的中心坐标是 (ax, ay),宽度是 aw,高度是 ah
// B 的中心坐标是 (bx, by),宽度是 bw,高度是 bh

// X 轴上的距离
const xDistance = Math.abs(ax - bx);
// Y 轴上的距离
const yDistance = Math.abs(ay - by);

// 如果 X 轴距离小于两个矩形宽度之和的一半,说明 X 轴上有重叠
const xOverlap = xDistance <= (aw + bw) / 2;
// 如果 Y 轴距离小于两个矩形高度之和的一半,说明 Y 轴上有重叠
const yOverlap = yDistance <= (ah + bh) / 2;

// 如果 X 轴和 Y 轴都有重叠,说明两个矩形碰撞了
const isCollision = xOverlap && yOverlap;

实现碰撞检测函数

让我们来写一个通用的碰撞检测函数:

function isCollision(entity1, entity2) {
  // 计算两个物体中心点之间的距离
  const xDistance = Math.abs(entity1.x - entity2.x);
  const yDistance = Math.abs(entity1.y - entity2.y);

  // 获取两个物体的图片(用于获取宽高)
  const img1 = entity1.image;
  const img2 = entity2.image;

  // 判断是否碰撞
  const xOverlap = xDistance <= (img1.width + img2.width) / 2;
  const yOverlap = yDistance <= (img1.height + img2.height) / 2;

  return xOverlap && yOverlap;
}

添加一个测试物体

为了测试碰撞检测,我们先添加一个简单的测试物体:

const testBox = {
  image: new Image(),
  x: 400,
  y: floorY,
};
testBox.image.src = "./images/box.png"; // 你需要准备一张箱子图片

如果没有箱子图片,我们可以用 Canvas 画一个简单的矩形来代替:

const testBox = {
  x: 400,
  y: floorY,
  width: 40,
  height: 40,
};

然后在 drawImg 函数中绘制它:

function drawImg() {
  // ... 其他绘制代码 ...

  // 绘制测试箱子
  ctx.fillStyle = "#8B4513"; // 棕色
  ctx.fillRect(
    testBox.x - testBox.width / 2,
    testBox.y - testBox.height / 2,
    testBox.width,
    testBox.height
  );

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

检测碰撞并做出反应

现在我们在 update 函数中检测碰撞:

function update(modifier) {
  // ... 其他更新逻辑 ...

  gravityController(modifier);

  // 检测碰撞
  if (isCollisionWithBox(player, testBox)) {
    console.log("碰撞了!");
    // 可以在这里添加碰撞后的处理逻辑
    // 比如:阻止角色移动、播放音效、减少生命值等
  }
}

// 专门用于检测角色和箱子碰撞的函数
function isCollisionWithBox(player, box) {
  const xDistance = Math.abs(player.x - box.x);
  const yDistance = Math.abs(player.y - box.y);

  // 如果 player 有 image 属性,使用 image 的宽高
  // 否则使用 player 自己的 width 和 height
  const playerWidth = player.image ? player.image.width : player.width;
  const playerHeight = player.image ? player.image.height : player.height;

  const xOverlap = xDistance <= (playerWidth + box.width) / 2;
  const yOverlap = yDistance <= (playerHeight + box.height) / 2;

  return xOverlap && yOverlap;
}

碰撞后的处理

检测到碰撞后,我们可以做很多事情。最简单的是阻止角色继续移动:

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

  // 保存角色的旧位置
  const oldX = player.x;
  const oldY = player.y;

  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);

  // 检测碰撞,如果碰撞了就恢复到旧位置
  if (isCollisionWithBox(player, testBox)) {
    player.x = oldX;
    player.y = oldY;
    player.ySpeed = 0; // 停止垂直移动
  }
}

可视化碰撞区域

为了更好地理解碰撞检测,我们可以把碰撞区域画出来:

function drawImg() {
  // ... 其他绘制代码 ...

  // 绘制碰撞区域(调试用)
  ctx.strokeStyle = "red";
  ctx.lineWidth = 2;

  // 角色的碰撞区域
  ctx.strokeRect(
    player.x - player.image.width / 2,
    player.y - player.image.height / 2,
    player.image.width,
    player.image.height
  );

  // 箱子的碰撞区域
  ctx.strokeRect(
    testBox.x - testBox.width / 2,
    testBox.y - testBox.height / 2,
    testBox.width,
    testBox.height
  );
}

优化:更精确的碰撞检测

有时候,图片的实际内容可能比图片尺寸小(比如图片周围有透明区域)。这时候可以给每个物体添加一个碰撞区域的偏移量:

const player = {
  image: new Image(),
  x: cvWidth / 2,
  y: floorY,
  speed: 200,
  ySpeed: 0,
  airCount: 0,
  maxAirCount: 28,
  // 碰撞区域的宽高(可以比图片小一些)
  collisionWidth: 30,
  collisionHeight: 40,
};

然后在碰撞检测函数中使用这些值:

function isCollisionWithBox(player, box) {
  const xDistance = Math.abs(player.x - box.x);
  const yDistance = Math.abs(player.y - box.y);

  const playerWidth = player.collisionWidth || player.image.width;
  const playerHeight = player.collisionHeight || player.image.height;

  const xOverlap = xDistance <= (playerWidth + box.width) / 2;
  const yOverlap = yDistance <= (playerHeight + box.height) / 2;

  return xOverlap && yOverlap;
}

小结

这节课我们学习了:

  1. 矩形碰撞检测的原理
  2. 如何实现一个通用的碰撞检测函数
  3. 如何在检测到碰撞后做出反应
  4. 如何可视化碰撞区域来调试

在下一节课中,我们会添加敌人角色,并使用碰撞检测来实现"踩敌人"和"被敌人撞到"的游戏逻辑。

完整的代码

<!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";

    // 测试箱子
    const testBox = {
      x: 400,
      y: floorY,
      width: 40,
      height: 40,
    };

    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 isCollisionWithBox(player, box) {
      const xDistance = Math.abs(player.x - box.x);
      const yDistance = Math.abs(player.y - box.y);

      const playerWidth = player.image.width || 40;
      const playerHeight = player.image.height || 40;

      const xOverlap = xDistance <= (playerWidth + box.width) / 2;
      const yOverlap = yDistance <= (playerHeight + box.height) / 2;

      return xOverlap && yOverlap;
    }

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

      const oldX = player.x;
      const oldY = player.y;

      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);

      // 检测碰撞
      if (isCollisionWithBox(player, testBox)) {
        player.x = oldX;
        player.y = oldY;
        player.ySpeed = 0;
      }
    }

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

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

      // 绘制测试箱子
      ctx.fillStyle = "#8B4513";
      ctx.fillRect(
        testBox.x - testBox.width / 2,
        testBox.y - testBox.height / 2,
        testBox.width,
        testBox.height
      );

      ctx.font = "20px 微软雅黑";
      ctx.fillStyle = "#fff";
      ctx.fillText("按空格跳跃,A/D 左右移动", 10, 30);
      ctx.fillText("尝试碰撞箱子", 10, 60);

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

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

下载完整代码

Leason6.zip