球板和操控
这是使用纯 JavaScript 创建打砖块游戏教程 11 个步骤中的第 4 步。我们已经让球移动并从墙壁上反弹,但这很快就变得无聊了——没有任何交互!我们需要引入游戏玩法,所以在本文中,我们将创建一个可以移动并用来击球的球板。
渲染球板
球和球板都需要图像、位置、尺寸、碰撞箱以及绘制逻辑。我们可以通过 GameObject 基类来共享这些内容。将现有的 Ball 类替换为以下两个类:
class GameObject {
asset;
ctx;
size = { w: undefined, h: undefined };
pos = { x: 0, y: 0 };
origin = { x: 0.5, y: 0.5 };
constructor(url, ctx) {
this.asset = new Image();
this.asset.src = url;
this.ctx = ctx;
}
async preload() {
await this.asset.decode();
if (this.size.w === undefined) {
this.size.w = this.asset.width;
this.size.h = this.asset.height;
}
}
get hitbox() {
const left = this.pos.x - this.size.w * this.origin.x;
const top = this.pos.y - this.size.h * this.origin.y;
return {
left,
right: left + this.size.w,
top,
bottom: top + this.size.h,
};
}
draw() {
const { left, top } = this.hitbox;
this.ctx.drawImage(this.asset, left, top);
}
onCollide() {}
}
class Ball extends GameObject {
pos = { x: 50, y: 50 };
vel = { x: 150, y: 150 };
move(dt) {
this.pos.x += this.vel.x * dt;
this.pos.y += this.vel.y * dt;
}
onCollide({ x, y }) {
if (x) {
this.vel.x = -this.vel.x;
}
if (y) {
this.vel.y = -this.vel.y;
}
}
}
origin 定义了图像上哪个点放在 pos 位置,其值是相对于图像宽度和高度的比例。默认值 (0.5, 0.5) 会将图像的中心放在那里。基类还定义了一个空的 onCollide() 方法,以确保所有对象都具有这个方法。如果某个对象不对碰撞做出响应——例如球板——它就可以继承这个默认方法。
Ball 从 GameObject 继承了构造函数、preload()、hitbox 和 draw()。它还添加了初始位置、速度、move(),以及上一节中的 onCollide() 响应逻辑。
现在,在 Ball 下方添加一个 Paddle 类:
class Paddle extends GameObject {
origin = { x: 0.5, y: 1 };
constructor(url, ctx) {
super(url, ctx);
this.pos = { x: ctx.canvas.width / 2, y: ctx.canvas.height - 5 };
}
}
super(url, ctx) 调用会运行共享的构造函数,设置球板的图像和绘图上下文。然后,球板会设置自己的初始位置。我们可以使用 canvas.width 和 canvas.height 的值,将球板放在我们想要的位置:canvas.width / 2 正好位于屏幕中央。在本例中,游戏世界与画布的大小相同,但在横向卷轴游戏等其他类型的游戏中,游戏世界会更大,你可以调整它来创造有趣的效果。
备注:将 origin.y 设置为 1 后,在绘制球板和计算其顶部边缘时,我们减去的是 this.size.h,而不是 this.size.h / 2。这意味着 paddle.pos.y 表示的是球板底部边缘的 y 坐标,而不是其中心的 y 坐标。这样,我们就能更方便地控制球板相对于画布底部边缘的位置。
下载球板图像,并将其保存在你的 /img 文件夹中。紧接着创建球的代码创建球板:
const paddle = new Paddle("img/paddle.png", ctx);
还要将 paddle 添加到 Promise.all() 中的数组里:
Promise.all([ball, paddle].map((obj) => obj.preload())).then(() =>
requestAnimationFrame(update),
);
并在现有的 ball.draw() 调用之后添加以下代码:
paddle.draw();
现在,球板已经位于我们想要的位置。接下来,为了让球从球板上反弹,我们必须实现它们之间的碰撞物理逻辑。
添加物理效果
备注:这里的讲解比教程的其他部分推进得更快,因为这部分工作正是 Phaser 这样的框架会替我们完成的。通常,你不需要自己实现任何物理逻辑,只需将球板注册为碰撞体即可。
与墙壁不同,球板是一个有限大小的矩形,因此球可能撞到它的任意一条边(甚至是角),而在极少数情况下,如果球的速度足够快,它甚至可能穿过球板。我们不会在移动球后检查是否发生重叠,而是实现连续碰撞检测,计算球在两帧之间首次接触表面的时刻,这样就不会丢失关于首先接触了哪个表面的信息。
我们将把碰撞计算放在一个可复用的函数中,以便将它用于球板,以及后续的砖块。这个函数接收一个 moving 碰撞箱和一个 obstacle 碰撞箱,判断 moving 对象是否会在 dt 时间内与 obstacle 发生碰撞;如果会,还会告诉我们碰撞发生的时间和位置。
我们不必像在 handleWallCollisions 中那样逐一计算移动对象四条边的位置,而是可以将障碍物的区域扩展一个移动对象的宽度和高度,从而把移动对象简化为一个点——它的左上角。
function getCollision(moving, velocity, obstacle, dt) {
const width = moving.right - moving.left;
const height = moving.bottom - moving.top;
const movingPos = { x: moving.left, y: moving.top };
const left = obstacle.left - width;
const right = obstacle.right;
const top = obstacle.top - height;
const bottom = obstacle.bottom;
const hit = { time: dt, x: null, y: null };
function checkFace(axis, direction, coordinate, min, max) {
if (velocity[axis] * direction <= 0) {
return;
}
const time = (coordinate - movingPos[axis]) / velocity[axis];
if (time < 0 || time > hit.time) {
return;
}
const otherAxis = axis === "x" ? "y" : "x";
const otherPosition = movingPos[otherAxis] + velocity[otherAxis] * time;
if (otherPosition < min || otherPosition > max) {
return;
}
if (time < hit.time) {
hit.x = null;
hit.y = null;
}
hit.time = time;
hit[axis] = coordinate;
}
checkFace("x", 1, left, top, bottom);
checkFace("x", -1, right, top, bottom);
checkFace("y", 1, top, left, right);
checkFace("y", -1, bottom, left, right);
return hit.x === null && hit.y === null ? null : hit;
}
大部分逻辑都在嵌套的 checkFace() 函数中。它接收 axis、direction 和 coordinate 参数,用内法向量的形式描述障碍物四个面中的一个。例如,"x", 1, left 表示左侧边缘,因为沿 x 轴正方向的向量会垂直于该表面,并指向对象内部。
对于垂直表面,接触所需的时间等于水平距离除以水平速度。然后,我们计算球在该时刻的垂直位置,以检查它是撞到了表面,还是从表面上方或下方经过(因此并没有实际发生碰撞)。水平表面的处理方式相同,只需交换坐标轴。checkFace() 函数会保留 dt 时间内最早的接触。
如果没有接触,getCollision() 的结果为 null;否则,结果包含接触时间,以及在每个发生碰撞的轴上应使用的左上角坐标。例如,撞到垂直表面时会得到一个 x 坐标,而 y 仍为 null。如果恰好撞到角点,则两个坐标都会被设置。
现在,我们只需要对球与所有它可能碰撞的对象调用 getCollision()。我们甚至可以将墙壁实现为普通障碍物,这样就不需要单独的一套逻辑了。colliders 列表包含具有 hitbox 属性的对象,因此每次调用 getCollision() 时,都可以通过触发 GameObject 上的 getter 来获取最新的 hitbox 值,而不是存储对象刚加入列表时的快照。
const baseWallHitbox = {
left: -Infinity,
right: Infinity,
top: -Infinity,
bottom: Infinity,
};
const colliders = [
{ hitbox: { ...baseWallHitbox, right: 0 } },
{ hitbox: { ...baseWallHitbox, left: canvas.width } },
{ hitbox: { ...baseWallHitbox, bottom: 0 } },
{ hitbox: { ...baseWallHitbox, top: canvas.height } },
];
在创建球板之后,立即将 paddle 也注册进去:
colliders.push(paddle);
接下来,将旧的 handleWallCollisions() 函数替换为 moveBall() 函数。它负责协调碰撞处理,反复调用 getCollision() 并移动球,直到指定的 dt 时间耗尽。每次循环中,它都会找到球接下来将撞到的障碍物(hit.time 最小),将球移动到接触点,调用碰撞响应逻辑,然后继续处理剩余时间。
function moveBall(dt) {
while (dt > 0) {
// 避免反复触发 getter
const ballHitbox = ball.hitbox;
let hitTime = dt;
let hitX = null;
let hitY = null;
let contacts = [];
for (const collider of colliders) {
const hit = getCollision(ballHitbox, ball.vel, collider.hitbox, hitTime);
if (hit === null) {
continue;
}
if (hit.time < hitTime) {
hitX = null;
hitY = null;
contacts = [];
}
hitTime = hit.time;
hitX = hit.x ?? hitX;
hitY = hit.y ?? hitY;
contacts.push({ collider, hit });
}
ball.move(hitTime);
dt -= hitTime;
if (contacts.length === 0) {
break;
}
// 将位置校正到接触点,以避免浮点误差
if (hitX !== null) {
ball.pos.x = hitX + ball.size.w / 2;
}
if (hitY !== null) {
ball.pos.y = hitY + ball.size.h / 2;
}
ball.onCollide({ x: hitX !== null, y: hitY !== null });
for (const { collider, hit } of contacts) {
collider.onCollide?.({ x: hit.x !== null, y: hit.y !== null });
}
}
}
备注:这个循环可能会在一帧内处理大量碰撞,从而导致卡顿。有时,人们会限制一帧内允许处理的碰撞次数,并在达到上限后丢弃剩余的 dt。这样可以让游戏更快地重绘,但会导致对象移动得更慢。
在 update() 中,将对 ball.move() 和 handleWallCollisions() 的调用替换为对 moveBall() 的调用:
const dt = lastTimestamp === null ? 0 : (timestamp - lastTimestamp) / 1000;
lastTimestamp = timestamp;
moveBall(dt);
这套计算假定球一开始位于画布内部,且不与任何碰撞体重叠,并且碰撞体在每次调用 moveBall() 期间保持静止。
控制球板
接下来的问题是我们还无法移动球板。为了解决这个问题,我们可以使用系统的指针输入(鼠标或触摸,具体取决于平台),并将球板的位置设置为指针所在的位置。
canvas.addEventListener("pointermove", (event) => {
if (paddle.size.w === undefined) {
return;
}
const bounds = canvas.getBoundingClientRect();
const x = ((event.clientX - bounds.left) * canvas.width) / bounds.width;
paddle.pos.x = Math.max(
paddle.size.w / 2,
Math.min(canvas.width - paddle.size.w / 2, x),
);
});
指针的 clientX 是相对于视口测量的。我们减去画布左侧边缘的位置,再将结果缩放到画布的绘图坐标系中,因为 CSS 可能会让画布以不同的尺寸显示。然后,我们更新 paddle.pos.x,并确保整个球板都位于画布内部。在指针移动之前,球板会停留在构造函数设置的中心位置。尺寸检查会忽略球板图像加载完成之前的输入。
为了允许触摸拖动而不滚动页面,请在画布的 CSS 规则中添加以下声明:
canvas {
/* … */
touch-action: none;
}
如果你还没有这样做,请重新加载你的 index.html 并试一试!
定位球
球板已经按预期工作了,接下来让我们把球放在它上面。在两个资源都加载完成后,我们可以利用它们的碰撞箱和尺寸,将球的底部边缘放在球板的顶部边缘。更新 Ball 类:
class Ball extends GameObject {
pos = { x: undefined, y: undefined };
vel = { x: 150, y: -150 };
// …
}
水平速度保持不变。我们将垂直速度从 150 改为 -150,让球一开始向上移动,而不是向下。我们无法提前知道球的位置,因为要把它放在球板上方,就必须先加载球板。将现有的 Promise.all() 代码块替换为以下内容:
Promise.all([ball, paddle].map((obj) => obj.preload())).then(() => {
ball.pos.x = paddle.pos.x;
ball.pos.y = paddle.hitbox.top - ball.size.h / 2;
requestAnimationFrame(update);
});
现在,球会从球板的正中间出发。
比较你的代码
下面是你到目前为止应该得到的代码,示例可以实时运行。要查看源代码,请点击“播放”(Play)按钮。
下一步
我们可以移动球板,并让球从上面反弹,但如果球无论如何都会从屏幕底部边缘弹回来,这又有什么意义呢?让我们引入输掉游戏的可能性——也就是游戏结束逻辑。