前情提要
在前面的课程中,我们已经实现了角色的移动、跳跃,还添加了背景图片。但是游戏中的物体之间还没有任何交互——它们可以互相穿过对方。
这节课我们来实现碰撞检测,让游戏中的物体能够"感知"到彼此的存在。
什么是碰撞检测
碰撞检测就是判断两个物体是否重叠或接触。在 2D 游戏中,最常用的碰撞检测方法是矩形碰撞检测(AABB - Axis-Aligned Bounding Box)。
简单来说,就是把每个物体看作一个矩形,然后判断这两个矩形是否重叠。
矩形碰撞检测的原理
两个矩形重叠的条件是:
- 它们在 X 轴上有重叠
- 它们在 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;
}
小结
这节课我们学习了:
- 矩形碰撞检测的原理
- 如何实现一个通用的碰撞检测函数
- 如何在检测到碰撞后做出反应
- 如何可视化碰撞区域来调试
在下一节课中,我们会添加敌人角色,并使用碰撞检测来实现"踩敌人"和"被敌人撞到"的游戏逻辑。
完整的代码
<!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>