文章目录
- 🚀 前言
- 🚀 前期准备
- 🚀 玩法设计
- 🚀 游戏场景
- 🍓 什么是游戏场景
- 🍓 绘制左上角积分
- 🍓 绘制右上角生命值
- 🍓 绘制砖块
- 🍓 绘制小球
- 🍓 绘制挡板
- 🍓 绘制游戏场景
- 🚀 让小球动起来
- 🍓 动画
- 🍓 游戏循环
- 🍓 移动的小球
- 🚀 控制挡板移动
- 🚀 碰撞检测
- 🍓 撞墙反弹
- 🍓 击碎砖块
- 🍓 挡板边界
- 🚀 游戏胜利
- 🚀 结语
🚀 前言
相信大家对游戏都不陌生,而且都曾有过游戏体验。游戏玩多了,就想开发一个属于自己的游戏 (🤔 可能是没事闲的)。本文将通过一个经典的游戏——打砖块和大家分享游戏开发的原理、游戏的开发步骤、游戏的开发思路以及游戏的一些基础知识。
🚀 前期准备
游戏制作可以有许多开发语言可供选择,本文采用 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; // 初始化得分为 0this.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; // 初始化生命数为 3this.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 坐标固定为 10const 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 坐标固定为 10const 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),将松开的键标记为 falsedocument.addEventListener('keyup', (e) => this.keys[e.key] = false);}// 检查指定的键是否被按下isPressed(key) {// 返回按键状态,如果未被记录则返回 falsereturn 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!🌹🌹🌹');}
}
游戏胜利效果如下:
这里我们并没有继续赘述游戏失败的处理,这是因为游戏失败的检测与处理和游戏胜利是类似的。
🚀 结语
本期的分享到这里就结束了,如果大家喜欢的话帮忙点个关注。🚀🚀🚀