文章目录
🚀 前言
相信大家对游戏都不陌生,而且都曾有过游戏体验。游戏玩多了,就想开发一个属于自己的游戏 (🤔 可能是没事闲的)。本文将通过一个经典的游戏——打砖块和大家分享游戏开发的原理、游戏的开发步骤、游戏的开发思路以及游戏的一些基础知识。
🚀 前期准备
游戏制作可以有许多开发语言可供选择,本文采用 JS+HTML+CSS
示例。大家可能需要掌握一些前端的基础知识,不用太多。
🚀 玩法设计
本游戏的玩法非常简单:
- 游戏共有三次机会
- 移动挡板不要让小球落地
- 击碎所有砖块即可获胜
🚀 游戏场景
🍓 什么是游戏场景
游戏场景指的是所有游戏元素共同构成的特定环境。下图就是一个游戏场景:
本游戏场景中有以下内容:
- 左上角积分
- 右上角生命值
- 砖块
- 小球
- 挡板
本文中的游戏场景就是一张图像,我们需要将这张图画出来。HTML 提供了 canvas
元素可以让我们画出这些图形。例如:
<canvas id="canvas"></canvas>
// 获取 HTML 文档中 id 为 'canvas' 的 <canvas> 元素,并赋值给变量 canvas
const canvas = document.getElementById('canvas');
// 获取 canvas 元素的 2D 渲染上下文(context),并赋值给变量 ctx
const ctx = canvas.getContext('2d');
// 设置填充颜色为粉红色
ctx.fillStyle = 'pink';
// 在 canvas 上绘制一个填充矩形,起点为 (10, 10),宽度为 100 像素,高度为 100 像素
ctx.fillRect(10, 10, 100, 100);
我们可以看到如下效果:
首先,我们需要基于 canvas
元素将游戏场景中需要的元素一个一个的画出来,然后一起放到同一个场景中。
🍓 绘制左上角积分
// 定义一个名为 Score 的类,用于管理和显示游戏中的得分
class Score {
// 构造函数,初始化 Score 类的实例,并接受一个 2D 渲染上下文(ctx)作为参数
constructor(ctx) {
this.ctx = ctx; // 保存传入的绘图上下文,以便在其他方法中使用
}
// 初始化方法,用于设置初始得分及显示文本的样式
init() {
this.score = 0; // 初始化得分为 0
this.text = 'Score: '; // 设置得分前缀文本
this.textColor = '#000000'; // 设置文本颜色为黑色
this.textFont = '16px Arial'; // 设置文本字体和大小
}
// 渲染得分的方法,将得分文本绘制到画布上
render() {
this.ctx.save(); // 保存当前绘图上下文的状态
// 调用 setShadow 方法,设置文本阴影
this.setShadow("rgba(0, 0, 0, 0.5)", 4, 2, 2);
this.ctx.font = this.textFont; // 设置字体
this.ctx.fillStyle = this.textColor; // 设置文本颜色
// 在画布上绘制文本,文本内容为 'Score: ' 加上当前得分
this.ctx.fillText(this.text + this.score, 10, 30);
// 重置阴影设置,以防止影响其他绘制操作
this.setShadow("rgba(0, 0, 0, 0)", 0, 0, 0);
this.ctx.restore(); // 恢复绘图上下文的状态
}
// 封装的阴影设置方法,便于在不同地方复用
setShadow(color, blur, offsetX, offsetY) {
this.ctx.shadowColor = color; // 设置阴影颜色
this.ctx.shadowBlur = blur; // 设置阴影模糊度
this.ctx.shadowOffsetX = offsetX; // 设置阴影的水平偏移量
this.ctx.shadowOffsetY = offsetY; // 设置阴影的垂直偏移量
}
}
// 将 Score 类挂载到全局对象 window 上,以便在全局范围内访问
window.Score = Score;
左上角积分类 Score
实例化之后,我们可以看到如下效果:
🍓 绘制右上角生命值
// 定义一个名为 Lives 的类,用于管理和显示游戏中的剩余生命数
class Lives {
// 构造函数,初始化 Lives 类的实例,并接受一个 2D 渲染上下文(ctx)作为参数
constructor(ctx) {
this.ctx = ctx; // 保存传入的绘图上下文,以便在其他方法中使用
}
// 初始化方法,用于设置初始的画布宽度、生命数及显示文本的样式
init(width) {
this.width = width; // 保存画布的宽度,用于后续的文本对齐
this.lives = 3; // 初始化生命数为 3
this.text = 'Lives: '; // 设置生命数前缀文本
this.textColor = '#000000'; // 设置文本颜色为黑色
this.textFont = '16px Arial'; // 设置文本的字体和大小
}
// 渲染生命数的方法,将生命数文本绘制到画布上
render() {
this.ctx.save(); // 保存当前绘图上下文的状态
// 调用 setShadow 方法,设置文本阴影
this.setShadow("rgba(0, 0, 0, 0.5)", 4, 2, 2);
this.ctx.font = this.textFont; // 设置字体
this.ctx.fillStyle = this.textColor; // 设置文本颜色为黑色
// 构建绘制的完整文本内容
const fillText = this.text + this.lives;
// 测量文本宽度,以便在画布上进行右对齐
const textWidth = this.ctx.measureText(fillText).width;
// 在画布上绘制文本,位置为右对齐,距离画布右边缘10像素,距离顶部30像素
this.ctx.fillText(fillText, this.width - textWidth - 10, 30);
// 重置阴影设置,以防止影响其他绘制操作
this.setShadow("rgba(0, 0, 0, 0)", 0, 0, 0);
this.ctx.restore(); // 恢复绘图上下文的状态
}
// 封装的阴影设置方法,便于在不同地方复用
setShadow(color, blur, offsetX, offsetY) {
this.ctx.shadowColor = color; // 设置阴影颜色
this.ctx.shadowBlur = blur; // 设置阴影模糊度
this.ctx.shadowOffsetX = offsetX; // 设置阴影的水平偏移量
this.ctx.shadowOffsetY = offsetY; // 设置阴影的垂直偏移量
}
}
// 将 Lives 类挂载到全局对象 window 上,以便在全局范围内访问
window.Lives = Lives;
右上角生命值类 Lives
实例化之后,我们可以看到如下效果:
🍓 绘制砖块
// 定义一个名为 Brick 的类,用于管理和渲染游戏中的砖块
class Brick {
// 构造函数,初始化 Brick 类的实例,并接受一个 2D 渲染上下文(ctx)作为参数
constructor(ctx) {
this.ctx = ctx; // 保存传入的绘图上下文,以便在其他方法中使用
}
// 初始化方法,用于设置砖块的初始布局和属性
init(width) {
this.width = width; // 保存画布的宽度,以便用于计算砖块的排列
// 设置砖块的行数和列数
this.brickRowCount = 5;
this.brickColumnCount = 5;
// 设置每个砖块的宽度和高度
this.brickWidth = 55;
this.brickHeight = 10;
// 设置砖块之间的间距
this.brickPadding = 10;
// 计算砖块区域的总宽度
this.totalWidth = (this.brickWidth + this.brickPadding) * this.brickRowCount - this.brickPadding;
// 设置砖块在画布中的偏移量
this.brickOffsetTop = 50; // 距离画布顶部的偏移量
this.brickOffsetLeft = (this.width - this.totalWidth) / 2; // 使砖块区域水平居中
this.bricks = []; // 创建一个数组来存储所有砖块
this.initializeBricks(); // 初始化砖块的位置信息
}
// 初始化砖块函数,设置每个砖块的初始位置和状态
initializeBricks() {
// 遍历每一列
for (let col = 0; col < this.brickColumnCount; col++) {
this.bricks[col] = []; // 为每列创建一个数组
// 遍历每一行
for (let row = 0; row < this.brickRowCount; row++) {
// 计算每个砖块的 x 和 y 坐标,并设置初始状态为 1(表示存在)
this.bricks[col][row] = {
x: (row * (this.brickWidth + this.brickPadding)) + this.brickOffsetLeft,
y: (col * (this.brickHeight + this.brickPadding)) + this.brickOffsetTop,
status: 1 // 每个砖块的状态,1 表示存在,0 表示被打掉
};
}
}
}
// 遍历所有砖块,并对每个砖块执行指定的回调函数
traversalBricks(callback) {
for (let col = 0; col < this.brickColumnCount; col++) {
for (let row = 0; row < this.brickRowCount; row++) {
callback(this.bricks[col][row]); // 对每个砖块执行回调函数
}
}
}
// 封装阴影设置函数
setShadow(color, blur, offsetX, offsetY) {
this.ctx.shadowColor = color; // 设置阴影颜色
this.ctx.shadowBlur = blur; // 设置阴影模糊度
this.ctx.shadowOffsetX = offsetX; // 设置阴影的水平偏移量
this.ctx.shadowOffsetY = offsetY; // 设置阴影的垂直偏移量
}
// 渲染砖块的函数,将存在状态的砖块绘制到画布上
render() {
this.ctx.save(); // 保存当前绘图上下文的状态
// 设置砖块的阴影效果
this.setShadow("rgba(0, 0, 0, 0.5)", 4, 2, 2);
// 遍历所有砖块并绘制
this.traversalBricks((brick) => {
if (brick.status === 1) { // 仅绘制存在状态的砖块
this.ctx.beginPath(); // 开始新的路径
this.ctx.fillStyle = "#FFFFFF"; // 设置砖块的填充颜色为白色
this.ctx.rect(brick.x, brick.y, this.brickWidth, this.brickHeight); // 绘制砖块的矩形路径
this.ctx.fill(); // 填充矩形路径
// 绘制砖块的边框
this.ctx.strokeStyle = "#B22222"; // 设置边框颜色为深红色
this.ctx.lineWidth = 1; // 设置边框宽度
this.ctx.strokeRect(brick.x, brick.y, this.brickWidth, this.brickHeight); // 绘制边框
this.ctx.closePath(); // 关闭路径
}
});
// 清除阴影设置
this.setShadow("rgba(0, 0, 0, 0)", 0, 0, 0);
this.ctx.restore(); // 恢复绘图上下文的状态
}
}
// 将 Brick 类挂载到全局对象 window 上,以便在全局范围内访问
window.Brick = Brick;
砖块类 Brick
实例化之后,我们可以看到如下效果:
🍓 绘制小球
// 定义一个名为 Ball 的类,用于管理和渲染游戏中的小球
class Ball {
// 构造函数,初始化 Ball 类的实例,并接受一个 2D 渲染上下文(ctx)作为参数
constructor(ctx) {
this.ctx = ctx; // 保存传入的绘图上下文,以便在其他方法中使用
}
// 初始化方法,用于设置小球的初始位置、半径、速度和移动角度
init(posX, posY, radius, speed, angle) {
this.ballRadius = radius; // 设置小球的半径
this.x = posX; // 设置小球的初始 x 坐标
this.y = posY; // 设置小球的初始 y 坐标
this.speed = speed; // 设置小球的移动速度
this.angle = angle; // 设置小球移动的初始角度
// 根据角度计算小球在 x 轴和 y 轴上的速度分量
this.dx = speed * Math.cos(angle); // 计算小球的水平速度分量
this.dy = speed * Math.sin(angle); // 计算小球的垂直速度分量
// 设置小球的颜色为亮红色
this.ballColor = '#FF4500';
}
// 绘制小球的函数
render() {
this.ctx.save(); // 保存当前绘图上下文的状态
this.ctx.beginPath(); // 开始绘制路径
// 绘制一个圆形路径,代表小球
this.ctx.arc(this.x, this.y, this.ballRadius, 0, Math.PI * 2);
// 设置小球的填充颜色
this.ctx.fillStyle = this.ballColor; // 设置填充颜色为亮红色
this.ctx.fill(); // 填充路径
this.ctx.closePath(); // 关闭路径
this.ctx.restore(); // 恢复绘图上下文的状态
}
}
// 将 Ball 类挂载到全局对象 window 上,以便在全局范围内访问
window.Ball = Ball;
小球类 Ball
实例化之后,我们可以看到如下效果:
🍓 绘制挡板
// 定义一个名为 Paddle 的类,用于管理和渲染游戏中的挡板
class Paddle {
// 构造函数,初始化 Paddle 类的实例,并接受一个 2D 渲染上下文(ctx)作为参数
constructor(ctx) {
this.ctx = ctx; // 保存传入的绘图上下文,以便在其他方法中使用
}
// 初始化方法,用于设置挡板的初始位置、宽度和高度
init(posX, posY, paddleWidth, paddleHeight) {
this.paddleWidth = paddleWidth; // 设置挡板的宽度
this.paddleHeight = paddleHeight; // 设置挡板的高度
this.x = posX; // 设置挡板的初始 x 坐标
this.y = posY; // 设置挡板的初始 y 坐标
// 设置挡板的颜色为深绿色
this.paddleColor = '#006400';
// 设置挡板的水平速度为 0,初始状态下挡板不移动
this.dx = 0;
}
// 绘制挡板的函数
render() {
this.ctx.save(); // 保存当前绘图上下文的状态
this.ctx.beginPath(); // 开始绘制路径
// 绘制一个矩形路径,代表挡板
this.ctx.rect(this.x, this.y, this.paddleWidth, this.paddleHeight);
// 设置挡板的填充颜色
this.ctx.fillStyle = this.paddleColor; // 设置填充颜色为深绿色
this.ctx.fill(); // 填充路径
this.ctx.closePath(); // 关闭路径
this.ctx.restore(); // 恢复绘图上下文的状态
}
}
// 将 Paddle 类挂载到全局对象 window 上,以便在全局范围内访问
window.Paddle = Paddle;
挡板类 Paddle
实例化之后,我们可以看到如下效果:
🍓 绘制游戏场景
前面我们已经将游戏场景中的元素单独制作好了。但是这些元素还并未真正有组织的放在同一个场景中。现在我们需要对其进行组装。
// 定义一个场景类,负责管理和渲染游戏的所有元素
class Scene {
// 构造函数,接收绘图上下文、画布宽度和高度作为参数
constructor(ctx, width, height) {
this.ctx = ctx; // 保存绘图上下文,用于在画布上绘制元素
this.width = width; // 保存画布的宽度
this.height = height; // 保存画布的高度
// 初始化场景中的各个元素:分数、生命、砖块、球和挡板
this.score = new Score(ctx); // 分数显示
this.lives = new Lives(ctx); // 生命值显示
this.brick = new Brick(ctx); // 砖块集合
this.ball = new Ball(ctx); // 球对象
this.paddle = new Paddle(ctx);// 挡板对象
}
// 初始化场景中的各个元素的位置和状态
init() {
const paddleWidth = 40; // 挡板的宽度
const paddleHeight = 6; // 挡板的高度
const paddleX = (this.width - paddleWidth) / 2; // 挡板的初始水平位置(居中)
const paddleY = this.height - 50; // 挡板的初始垂直位置(靠近画布底部)
const ballRadius = 3; // 球的半径
const ballX = paddleX + paddleWidth / 2; // 球的初始水平位置(在挡板上方居中)
const ballY = paddleY - ballRadius; // 球的初始垂直位置(在挡板上方)
// 初始化各个元素的位置和状态
this.score.init(); // 初始化分数显示
this.lives.init(this.width); // 初始化生命值显示,并传入画布宽度
this.brick.init(this.width); // 初始化砖块布局,并传入画布宽度
this.ball.init(ballX, ballY, ballRadius); // 初始化球的位置和大小
this.paddle.init(paddleX, paddleY, paddleWidth, paddleHeight); // 初始化挡板的位置和大小
}
// 渲染场景中的各个元素
render() {
this.score.render(); // 渲染分数
this.lives.render(); // 渲染生命值
this.brick.render(); // 渲染砖块
this.ball.render(); // 渲染球
this.paddle.render(); // 渲染挡板
}
}
// 将 Scene 类挂载到全局对象 window 上,使其可以在全局范围内访问
window.Scene = Scene;
场景类 Scene
实例化之后,我们可以看到如下效果:
至此,我们已经完成了游戏的第一步。
🚀 让小球动起来
🍓 动画
我们已经制作好了一个游戏场景,这个游戏场景其本质是一张图像,图像是静态的不可能让小球动起来。所以,我们需要将当前静态的场景转换成动画。
动画是一系列静态图像快速连续切换形成的一种视觉效果。这里有两个关键词:一系列静态图像、快速连续切换。一系列静态图像就意味着由许多单个静态图像组成,而这单个静态图像我们有一个专业的词叫做“帧”。快速连续切换不难理解就是字面意思,描述快速连续切换的快慢我们也有一个专业的词叫做“帧率”。
想要将一张图片转换成动画就必须满足动画的两个必要条件:
- 有多张静态图像
- 能够实现自动切换这些静态图像
第一个条件我们已经完成了,前面我们已经讨论过如何采用 canvas
元素制作图像了。现在我们需要了解如何实现多张图像的自动切换。在 JS 中提供了一个 requestAnimationFrame
函数,这个函数可以完成第二个必要条件。
// 获取 HTML 中的 <canvas> 元素
const canvas = document.getElementById('canvas');
// 获取 2D 绘图上下文,用于在 canvas 上绘制
const ctx = canvas.getContext('2d');
// 获取 canvas 元素的 CSS 宽度和高度
const width = canvas.clientWidth;
const height = canvas.clientHeight;
// 定义一个绘制矩形的函数,参数为矩形的左上角坐标 (posX, posY)
function drawRect(posX, posY) {
ctx.clearRect(0, 0, width, height); // 清除整个 canvas,以准备绘制新帧
ctx.save(); // 保存当前的绘图状态
ctx.fillStyle = 'pink'; // 设置填充颜色为粉色
ctx.fillRect(posX, posY, 50, 50); // 在指定位置绘制 50x50 像素的矩形
ctx.restore(); // 恢复绘图状态,防止影响其他绘图操作
}
// 初始化动画帧计数器
let frame = 0;
// 定义动画函数,逐帧调用
function animation() {
// 根据当前帧数计算矩形的 X 坐标位置,Y 坐标固定为 10
const x = frame;
const y = 10;
// 调用绘制矩形的函数
drawRect(x, y);
// 增加帧计数器,使矩形在下一帧中移动
frame++;
// 请求浏览器在下次重绘时调用 animation 函数,实现动画效果
requestAnimationFrame(animation);
}
// 启动动画
animation();
如下图所示,上述代码通过 requestAnimationFrame
函数完成了一个矩形向右移动的动画效果:
我们来看看这是如何实现的。
首先,我们定义了一个 drawRect
函数帮助我们绘制上图的矩形。
// 定义一个绘制矩形的函数,参数为矩形的左上角坐标 (posX, posY)
function drawRect(posX, posY) {
ctx.clearRect(0, 0, width, height); // 清除整个 canvas,以准备绘制新帧
ctx.save(); // 保存当前的绘图状态
ctx.fillStyle = 'pink'; // 设置填充颜色为粉色
ctx.fillRect(posX, posY, 50, 50); // 在指定位置绘制 50x50 像素的矩形
ctx.restore(); // 恢复绘图状态,防止影响其他绘图操作
}
然后,我们定义了一个 animation
函数,调用了 drawRect
函数实现了矩形的绘制。
// 初始化动画帧计数器
let frame = 0;
// 定义动画函数,逐帧调用
function animation() {
// 根据当前帧数计算矩形的 X 坐标位置,Y 坐标固定为 10
const x = frame;
const y = 10;
// 调用绘制矩形的函数
drawRect(x, y);
// 增加帧计数器,使矩形在下一帧中移动
frame++;
// 请求浏览器在下次重绘时调用 animation 函数,实现动画效果
requestAnimationFrame(animation);
}
这里有一个问题,矩形的移动效果是如何实现的呢?🤔
我们仔细看看animation
函数,会发现这个函数有两个功能:
- 根据坐标绘制一张图像
- 调用
requestAnimationFrame
函数
requestAnimationFrame
函数有一个功能:它会继续调用 animation
函数。这就实现了矩形在不断的根据坐标被绘制,而我们发现:每次矩形绘制完成之后,就会通过 frame++
更新下一次的绘制坐标。通过这种方式我们就完成了矩形的不断右移的动画效果。
🍓 游戏循环
为了让小球动起来,我们需要将之前的游戏场景转换成动画。有了动画的前置知识,静态游戏场景向动画的转换就非常简单了。
我们可以创建一个 Game
类,这个类有两个功能:
- 加载之前的游戏场景并初始化游戏场景
- 设置游戏循环(将静态游戏场景转换成动画)
class Game {
// 构造函数,用于初始化 Game 类的实例
constructor(width, height) {
// 设置游戏画布的宽度和高度
this.width = width;
this.height = height;
}
// 设置游戏场景的方法
loadScene(scene) {
// 将传入的场景对象赋值给当前 Game 实例的 scene 属性
this.scene = scene;
// 调用场景的初始化方法,初始化场景中的元素
this.scene.init();
}
// 游戏主循环,用于不断地刷新游戏画面,实现动画效果
gameLoop() {
// 清除画布上的内容,准备绘制新的一帧
ctx.clearRect(0, 0, this.width, this.height);
// 调用当前场景的 render 方法,渲染当前帧的场景
this.scene.render();
// 使用 requestAnimationFrame 循环调用 gameLoop 方法,确保游戏持续运行
// 使用 .bind(this) 绑定当前 Game 实例,确保在 gameLoop 方法中 `this` 始终指向当前 Game 实例
requestAnimationFrame(this.gameLoop.bind(this));
}
}
// 将 Game 类暴露到全局作用域,使其可以在其他脚本中使用
window.Game = Game;
我们主要看看游戏循环的作用。
// 游戏主循环,用于不断地刷新游戏画面,实现动画效果
gameLoop() {
// 清除画布上的内容,准备绘制新的一帧
ctx.clearRect(0, 0, this.width, this.height);
// 调用当前场景的 render 方法,渲染当前帧的场景
this.scene.render();
// 使用 requestAnimationFrame 循环调用 gameLoop 方法,确保游戏持续运行
// 使用 .bind(this) 绑定当前 Game 实例,确保在 gameLoop 方法中 `this` 始终指向当前 Game 实例
requestAnimationFrame(this.gameLoop.bind(this));
}
游戏循环就是将静态游戏场景转换成动画的过程。不过,由于上面每一帧图像都是一样的,所以视觉效果依旧是静止的。但是,我们已经将其转换成动态的动画了。
🍓 移动的小球
根据前面的知识,想要小球移动就很简单了——只要动画的每一帧小球的位置不一样就能达成小球移动的视觉效果。所以,每次开始下一帧场景绘制前,我们都需要重新计算小球的坐标。
class Game {
gameLoop() {
// 清除整个画布,准备绘制新的一帧内容
// ctx.clearRect(x, y, width, height) 方法用于清除指定矩形区域,这里清除的是整个画布
ctx.clearRect(0, 0, this.width, this.height);
// 调用当前场景的 render 方法,渲染当前帧的场景内容
// 这个方法通常用于绘制场景中的所有元素,比如砖块、球、挡板等
this.scene.render();
// 调用当前场景的 update 方法,更新场景中所有元素的状态
// update 方法通常用于处理游戏逻辑,比如检测碰撞、更新对象的位置等
this.scene.update();
// 请求浏览器在下一次重绘之前再次调用 gameLoop 方法,形成循环
// .bind(this) 确保在 gameLoop 方法中 `this` 始终指向当前 Game 实例,从而正确访问实例的属性和方法
requestAnimationFrame(this.gameLoop.bind(this));
}
}
class Scene {
// 该类新增一个 update 方法,用于更新游戏场景
update() {
// 该方法负责更新球的状态,比如位置、速度、方向等
this.updateBall();
}
updateBall() {
// 将小球的水平位置增加水平速度值,更新 x 坐标
this.ball.x += this.ball.dx;
// 将小球的垂直位置增加垂直速度值,更新 y 坐标
this.ball.y += this.ball.dy;
}
}
现在我们的小球可以动起来了。🎉🎉🎉🤗🤗🤗
🚀 控制挡板移动
我们的小球可以移动了,这种移动是由计算机自动按固定的逻辑计算坐标完成的。现在,我们需要控制挡板移动只需要将自动计算移交玩家主动触发。即:玩家发出指令时才计算挡板坐标。
首先,我们需要新增 Input
类,该类的作用是为了监听用户的当前行为
// 定义 Input 类,用于处理键盘输入事件
class Input {
constructor() {
// 初始化键值存储对象,用于保存按键状态
this.keys = {};
// 监听键盘按下事件 (keydown),将按下的键标记为 true
// 使用箭头函数确保 this 指向 Input 实例
document.addEventListener('keydown', (e) => this.keys[e.key] = true);
// 监听键盘抬起事件 (keyup),将松开的键标记为 false
document.addEventListener('keyup', (e) => this.keys[e.key] = false);
}
// 检查指定的键是否被按下
isPressed(key) {
// 返回按键状态,如果未被记录则返回 false
return this.keys[key] || false;
}
}
// 将 Input 类挂载到全局 window 对象,使其在全局范围内可访问
window.Input = Input;
然后,我们在 Scene
中添加挡板的更新逻辑(按照用户的行为更新挡板坐标)
class Scene {
// setInput 方法:用于设置或更新 Scene 的输入管理实例
setInput(input) {
// 将传入的 input 对象赋值给 Scene 实例的 this.input 属性
// 这样,Scene 可以使用这个输入对象来获取当前的输入状态
this.input = input;
}
update() {
// 新增 updatePaddle 方法,更新滑板的位置
this.updatePaddle();
}
// updatePaddle 方法:根据输入状态更新滑板的水平位置
updatePaddle() {
// 检查是否按下了左方向键 'ArrowLeft'
if (this.input.isPressed('ArrowLeft')) {
// 如果按下左方向键,将滑板的 x 坐标减小,使其向左移动
this.paddle.x -= 8;
}
// 检查是否按下了右方向键 'ArrowRight'
else if (this.input.isPressed('ArrowRight')) {
// 如果按下右方向键,将滑板的 x 坐标增加,使其向右移动
this.paddle.x += 8;
}
}
}
现在,我们便可以控制挡板的移动了。🎈🎈🎈
🚀 碰撞检测
目前,游戏的基本问题已经解决了。现在我们需要讨论以下几个问题:
- 小球如何撞墙反弹
- 小球如何击碎砖块
- 挡板触碰到边界墙时的行为
🍓 撞墙反弹
之前,我们虽然让小球动起来了,但是我们却发现:这个小球不能感知到障碍物。如何才能让小球感知到障碍物呢?此时,我们需要对小球进行碰撞检测。
我们知道一共有 4 面墙和一个挡板。想要实现撞墙反弹的效果,我们可分析出:
- 当小球触碰到左右边界时,需要反转小球 x x x 轴方向的速度,即 d x = − d x dx = -dx dx=−dx
- 当小球触碰到上边界时,需要反转小球 y y y 轴方向的速度,即 d y = − d y dy = -dy dy=−dy
- 当小球触碰到下边界时,需要扣减游戏的生命值,小球应该重新回到挡板的上面
- 当小球触碰到挡板时,进行了简化处理, 即:只反转小球 y y y 轴方向的速度,即 d y = − d y dy = -dy dy=−dy
class Scene {
// update 方法:更新场景中的所有游戏对象
update() {
// 更新球的运动状态
this.updateBall();
// 更新滑板的运动状态
this.updatePaddle();
// 检测球的边界碰撞
this.ballBoundaryDetection();
}
// ballBoundaryDetection 方法:检测球与边界的碰撞
ballBoundaryDetection() {
// 检测球与左右边界的碰撞
if (this.ball.x < this.ball.ballRadius) {
// 如果球碰到左边界,将球的位置重置到边界并反转 x 轴方向
this.ball.x = this.ball.ballRadius;
this.ball.dx = -this.ball.dx;
} else if (this.ball.x > this.width - this.ball.ballRadius) {
// 如果球碰到右边界,将球的位置重置到边界并反转 x 轴方向
this.ball.x = this.width - this.ball.ballRadius;
this.ball.dx = -this.ball.dx;
}
// 检测球与上边界的碰撞
if (this.ball.y < this.ball.ballRadius) {
// 如果球碰到上边界,将球的位置重置到边界并反转 y 轴方向
this.ball.y = this.ball.ballRadius;
this.ball.dy = -this.ball.dy;
}
// 检测球是否落到底部
else if (this.ball.y > this.height - this.ball.ballRadius) {
// 如果球掉到底部边界,重置球的位置,并减少生命值
this.ball.y = this.height - this.ball.ballRadius;
this.ball.dy = -this.ball.dy;
this.lives.lives--; // 减少玩家的生命值
if (this.lives.lives) {
// 如果玩家还有生命值,重置球的位置
this.resetBall();
}
}
// 检测球与滑板的碰撞
if (this.ball.x > this.paddle.x - this.ball.ballRadius
&& this.ball.x < this.paddle.x + this.paddle.paddleWidth + this.ball.ballRadius
&& this.ball.y + this.ball.ballRadius >= this.paddle.y) {
// 如果球碰到滑板,反转球的 y 轴方向
this.ball.dy = -this.ball.dy;
// 将球的位置调整到滑板之上,防止球卡在滑板中
this.ball.y = this.paddle.y - this.ball.ballRadius;
}
}
// resetBall 方法:将球重置到滑板的上方
resetBall() {
// 将球的 x 坐标重置到滑板的中间位置
this.ball.x = this.paddle.x + this.paddle.paddleWidth / 2;
// 将球的 y 坐标重置到滑板上方
this.ball.y = this.paddle.y - this.ball.ballRadius;
}
}
现在,我们的小球已经有撞墙反弹的效果了。😀😀😀
🍓 击碎砖块
击碎砖块和撞墙反弹检测非常类似,我们会遍历所有砖块是否与小球产生了碰撞。如果发生了碰撞,那么就将砖块的状态置为 0
表示该砖块已被击碎。当进行下一帧游戏渲染时,这个砖块将不会进行渲染。同时,当砖块被击碎时,分数应该增加。
class Scene {
// update 方法:更新场景中的所有游戏对象
update() {
// 更新球的位置
this.updateBall();
// 更新滑板的位置
this.updatePaddle();
// 检测球与边界的碰撞
this.ballBoundaryDetection();
// 检测球与砖块的碰撞
this.brickCollisionDetection();
}
// brickCollisionDetection 方法:检测球与砖块的碰撞
brickCollisionDetection() {
// 遍历场景中的所有砖块,传入一个回调函数对每个砖块进行检测
this.brick.traversalBricks(brick => {
// 仅检测状态为 1(即未被打掉)的砖块
if (brick.status === 1) {
// 检测球是否与砖块发生碰撞
// 如果球的 x 坐标在砖块的左右边界之间,且 y 坐标在砖块的上下边界之间,则发生碰撞
if (this.ball.x >= brick.x
&& this.ball.x <= brick.x + this.brick.brickWidth
&& this.ball.y >= brick.y
&& this.ball.y <= brick.y + this.brick.brickHeight) {
// 如果碰撞,反转球的 y 轴方向
this.ball.dy = -this.ball.dy;
// 设置砖块状态为 0,表示砖块被打掉
brick.status = 0;
// 增加玩家的分数
this.score.score++;
}
}
});
}
}
通过上面 brickCollisionDetection
的碰撞检测,我们就实现了砖块的击碎效果。🎄🎄🎄
🍓 挡板边界
当前我们的挡板移动时可能超出左右墙的边界,这并不是我们想要的。所以,我们需要给挡板也添加上边界检测。
class Scene {
// update 方法:更新场景中的所有游戏对象
update() {
// 更新球的位置
this.updateBall();
// 更新滑板的位置
this.updatePaddle();
// 检测球与边界的碰撞
this.ballBoundaryDetection();
// 检测球与砖块的碰撞
this.brickCollisionDetection();
// 检测滑板与场景边界的碰撞
this.paddleBoundaryDetection();
}
// paddleBoundaryDetection 方法:检测滑板与场景边界的碰撞
paddleBoundaryDetection() {
// 检测滑板是否超出左边界
if (this.paddle.x < 0) {
// 如果滑板超出左边界,将滑板位置重置到左边界
this.paddle.x = 0;
}
// 检测滑板是否超出右边界
else if (this.paddle.x > this.width - this.paddle.paddleWidth) {
// 如果滑板超出右边界,将滑板位置重置到右边界
this.paddle.x = this.width - this.paddle.paddleWidth;
}
}
}
🚀 游戏胜利
恭喜大家,到这里我们的游戏基本上算开发完成了。但是还有一个小小的问题,就是游戏胜利的触发条件和触发效果还没有实现。现在我们将会实现游戏胜利的触发条件和游戏胜利的效果。
class Scene {
// update 方法:更新场景内的所有元素和检测逻辑,每帧调用一次
update() {
// 检查胜利条件,如果满足则触发胜利处理
this.checkWin();
// 更新球的位置
this.updateBall();
// 更新滑板的位置
this.updatePaddle();
// 检测球与场景边界的碰撞并处理
this.ballBoundaryDetection();
// 检测球与砖块的碰撞并处理
this.brickCollisionDetection();
// 检测滑板是否超出边界并修正
this.paddleBoundaryDetection();
}
// checkWin 方法:检测当前场景是否达成胜利条件
checkWin() {
// 检查当前得分是否等于砖块的总数
// 如果分数达到总砖块数,意味着所有砖块都被打掉
if (this.score.score == this.brick.brickRowCount * this.brick.brickColumnCount) {
// 如果胜利条件满足,调用 handleWin 方法处理胜利
this.handleWin();
}
}
// handleWin 方法:处理胜利的逻辑
handleWin() {
// 弹出一个简单的弹窗,通知玩家已经胜利
alert('You Win!🌹🌹🌹');
}
}
游戏胜利效果如下:
这里我们并没有继续赘述游戏失败的处理,这是因为游戏失败的检测与处理和游戏胜利是类似的。
🚀 结语
本期的分享到这里就结束了,如果大家喜欢的话帮忙点个关注。🚀🚀🚀