制作自己的游戏:打砖块

文章目录

    • 🚀 前言
    • 🚀 前期准备
    • 🚀 玩法设计
    • 🚀 游戏场景
      • 🍓 什么是游戏场景
      • 🍓 绘制左上角积分
      • 🍓 绘制右上角生命值
      • 🍓 绘制砖块
      • 🍓 绘制小球
      • 🍓 绘制挡板
      • 🍓 绘制游戏场景
    • 🚀 让小球动起来
      • 🍓 动画
      • 🍓 游戏循环
      • 🍓 移动的小球
    • 🚀 控制挡板移动
    • 🚀 碰撞检测
      • 🍓 撞墙反弹
      • 🍓 击碎砖块
      • 🍓 挡板边界
    • 🚀 游戏胜利
    • 🚀 结语

🚀 前言

相信大家对游戏都不陌生,而且都曾有过游戏体验。游戏玩多了,就想开发一个属于自己的游戏 (🤔 可能是没事闲的)。本文将通过一个经典的游戏——打砖块和大家分享游戏开发的原理、游戏的开发步骤、游戏的开发思路以及游戏的一些基础知识。

🚀 前期准备

游戏制作可以有许多开发语言可供选择,本文采用 JS+HTML+CSS 示例。大家可能需要掌握一些前端的基础知识,不用太多。

🚀 玩法设计

本游戏的玩法非常简单:

  1. 游戏共有三次机会
  2. 移动挡板不要让小球落地
  3. 击碎所有砖块即可获胜

🚀 游戏场景

🍓 什么是游戏场景

游戏场景指的是所有游戏元素共同构成的特定环境。下图就是一个游戏场景:

本游戏场景中有以下内容:

  1. 左上角积分
  2. 右上角生命值
  3. 砖块
  4. 小球
  5. 挡板

本文中的游戏场景就是一张图像,我们需要将这张图画出来。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 实例化之后,我们可以看到如下效果:

至此,我们已经完成了游戏的第一步。

🚀 让小球动起来

🍓 动画

我们已经制作好了一个游戏场景,这个游戏场景其本质是一张图像,图像是静态的不可能让小球动起来。所以,我们需要将当前静态的场景转换成动画。

动画是一系列静态图像快速连续切换形成的一种视觉效果。这里有两个关键词:一系列静态图像、快速连续切换。一系列静态图像就意味着由许多单个静态图像组成,而这单个静态图像我们有一个专业的词叫做“帧”。快速连续切换不难理解就是字面意思,描述快速连续切换的快慢我们也有一个专业的词叫做“帧率”。

想要将一张图片转换成动画就必须满足动画的两个必要条件:

  1. 有多张静态图像
  2. 能够实现自动切换这些静态图像

第一个条件我们已经完成了,前面我们已经讨论过如何采用 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 函数,会发现这个函数有两个功能:

  1. 根据坐标绘制一张图像
  2. 调用 requestAnimationFrame 函数

requestAnimationFrame 函数有一个功能:它会继续调用 animation 函数。这就实现了矩形在不断的根据坐标被绘制,而我们发现:每次矩形绘制完成之后,就会通过 frame++ 更新下一次的绘制坐标。通过这种方式我们就完成了矩形的不断右移的动画效果。

🍓 游戏循环

为了让小球动起来,我们需要将之前的游戏场景转换成动画。有了动画的前置知识,静态游戏场景向动画的转换就非常简单了。

我们可以创建一个 Game 类,这个类有两个功能:

  1. 加载之前的游戏场景并初始化游戏场景
  2. 设置游戏循环(将静态游戏场景转换成动画)
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;}}
}

现在,我们便可以控制挡板的移动了。🎈🎈🎈

🚀 碰撞检测

目前,游戏的基本问题已经解决了。现在我们需要讨论以下几个问题:

  1. 小球如何撞墙反弹
  2. 小球如何击碎砖块
  3. 挡板触碰到边界墙时的行为

🍓 撞墙反弹

之前,我们虽然让小球动起来了,但是我们却发现:这个小球不能感知到障碍物。如何才能让小球感知到障碍物呢?此时,我们需要对小球进行碰撞检测。

我们知道一共有 4 面墙和一个挡板。想要实现撞墙反弹的效果,我们可分析出:

  1. 当小球触碰到左右边界时,需要反转小球 x x x 轴方向的速度,即 d x = − d x dx = -dx dx=dx
  2. 当小球触碰到上边界时,需要反转小球 y y y 轴方向的速度,即 d y = − d y dy = -dy dy=dy
  3. 当小球触碰到下边界时,需要扣减游戏的生命值,小球应该重新回到挡板的上面
  4. 当小球触碰到挡板时,进行了简化处理, 即:只反转小球 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!🌹🌹🌹');}
}

游戏胜利效果如下:

这里我们并没有继续赘述游戏失败的处理,这是因为游戏失败的检测与处理和游戏胜利是类似的。

🚀 结语

本期的分享到这里就结束了,如果大家喜欢的话帮忙点个关注。🚀🚀🚀

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/417706.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Java实用类——StringBuffer类和StringBuilder类

StringBuffer类和StringBuilder类位于java.util包中&#xff0c;是String类的增强型&#xff0c;提供了很多方法可供使用 StringBuffer和StringBuilder出现的原因是&#xff1a;使用拼接字符串会浪费大量内存空间 String a "Hello"; a a "chmy"; 新的…

分类任务实现模型集成代码模版

分类任务实现模型&#xff08;投票式&#xff09;集成代码模版 简介 本实验使用上一博客的深度学习分类模型训练代码模板-CSDN博客&#xff0c;自定义投票式集成&#xff0c;手动实现模型集成&#xff08;投票法&#xff09;的代码。最后通过tensorboard进行可视化&#xff0…

Datawhale x李宏毅苹果书AI夏令营深度学习详解进阶Task03

在深度学习中&#xff0c;批量归一化&#xff08;Batch Normalization&#xff0c;BN&#xff09;技术是一种重要的优化方法&#xff0c;它可以有效地改善模型的训练效果。本文将详细讨论批量归一化的原理、实现方式、在神经网络中的应用&#xff0c;以及如何选择合适的损失函数…

Python-面向对象编程(超详细易懂)

面向对象编程&#xff08;oop&#xff09; 面向对象是Python最重要的特性&#xff0c;在Python中一切数据类型都是面向对象的。 面向对象的编程思想&#xff1a;按照真实世界客观事物的自然规律进行分析&#xff0c;客观世界中存在什么样的实体&#xff0c;构建的软件系统就存在…

视频监控管理平台LntonAIServer视频智能分析噪声检测应用场景

在视频监控系统中&#xff0c;噪声问题常常影响到视频画面的清晰度和可用性。噪声可能由多种因素引起&#xff0c;包括但不限于低光环境、摄像机传感器灵敏度过高、编码压缩失真等。LntonAIServer通过引入噪声检测功能&#xff0c;旨在帮助用户及时发现并解决视频流中的噪声问题…

linux 内核代码学习(八)

总体目标&#xff1a;由于fedora10 linux发行版中自带的linux2.6.xx内核源码规模太庞大了&#xff0c;对于想通读内核源码的爱好者来说太困难了&#xff0c;因此选择了linux2.4.20内核来进行测试&#xff08;最终是希望能够实现linux1.0内核的源码完全编译和测试&#xff09;。…

了解一下HTTP 与 HTTPS 的区别

介绍&#xff1a; HTTP是超文本传输协议。规定了客户端&#xff08;通常是浏览器&#xff09;和服务器之间如何传输超文本&#xff0c;也就是包含链接的文本。通常使用TCP【1】/IP协议来传输数据&#xff0c;默认端口为80。 HTTPS是超文本传输安全协议&#xff0c;具有CA证书。…

【RLHF】浅谈ChatGPT 等大模型中的RLHF算法

本文收录于《深入浅出讲解自然语言处理》专栏&#xff0c;此专栏聚焦于自然语言处理领域的各大经典算法&#xff0c;将持续更新&#xff0c;欢迎大家订阅&#xff01;​个人主页&#xff1a;有梦想的程序星空​个人介绍&#xff1a;小编是人工智能领域硕士&#xff0c;全栈工程…

TCP的流量控制深入理解

在理解流量控制之前我们先需要理解TCP的发送缓冲区和接收缓冲区&#xff0c;也称为套接字缓冲区。首先我们先知道缓冲区存在于哪个位置&#xff1f; 其中缓冲区存在于Socket Library层。 而我们的发送窗口和接收窗口就存在于缓冲区当中。在实现滑动窗口时则将两个指针指向缓冲区…

STM32F103调试DMA+PWM 实现占空比逐渐增加的软启效果

实现效果&#xff1a;DMAPWM 实现PWM输出时&#xff0c;从低电平到输出占空比逐渐增加再到保持高电平的效果&#xff0c;达到控制 MOS 功率开关软启的效果。 1.配置时钟 2.TIM 的 PWM 功能配置 选择、配置 TIM 注意&#xff1a;选择 TIM 支持 DMA 控制输出 PWM 功能的通道&a…

使用Unity的准备

下载Unity 下载Unity Hub Unity - 实时内容开发平台 | 3D、2D、VR & AR可视化https://unity.cn/ 创建账号或者登入账号 Unity安装 路径尽量为英文路径 登入账号 点击头像登入账号 这里已经登入 打开偏好 设置中文 添加许可证 获取免费版的即可 安装编辑器 新建项目…

mysql-PXC实现高可用

mysql8.0使用PXC实现高可用 什么是 PXC PXC 是一套 MySQL 高可用集群解决方案&#xff0c;与传统的基于主从复制模式的集群架构相比 PXC 最突出特点就是解决了诟病已久的数据复制延迟问题&#xff0c;基本上可以达到实时同步。而且节点与节点之间&#xff0c;他们相互的关系是…

PHP一站式解决方案高级房产系统小程序源码

一站式解决方案&#xff0c;高级房产系统让房产管理更轻松 &#x1f3e0;【开篇&#xff1a;告别繁琐&#xff0c;迎接高效房产管理新时代】&#x1f3e0; 你是否还在为房产管理的繁琐流程而头疼&#xff1f;从房源录入、客户咨询到合同签订、售后服务&#xff0c;每一个环节…

【CSS】如何写渐变色文字并且有打光效果

效果如上&#xff0c;其实核心除了渐变色文字的设置 background: linear-gradient(270deg, #d2a742 94%, #f6e2a7 25%, #d5ab4a 48%, #f6e2a7 82%, #d1a641 4%);color: #e8bb2c;background-clip: text;color: transparent;还有就是打光效果&#xff0c;原理其实就是两块遮罩&am…

7、关于LoFTR

7、关于LoFTR LoFTR论文链接&#xff1a;LoFTR LoFTR的提出&#xff0c;是将Transformer模型的注意力机制在特征匹配方向的应用&#xff0c;Transformer的提取特征的机制&#xff0c;在自身进行&#xff0c;本文提出可以的两张图像之间进行特征计算&#xff0c;非常适合进行特…

“弹性盒子”一维布局系统(补充)——WEB开发系列31

弹性盒子是一种一维布局方法&#xff0c;用于根据行或列排列元素。元素可以扩展以填补多余的空间&#xff0c;或者缩小以适应较小的空间&#xff0c;为容器中的子元素提供灵活的且一致的布局方式。 一、什么是弹性盒子&#xff1f; CSS 弹性盒子&#xff08;Flexible Box Layo…

提高开发效率的实用工具库VueUse

VueUse中文网&#xff1a;https://vueuse.nodejs.cn/ 使用方法 安装依赖包 npm i vueuse/core单页面使用&#xff08;useThrottleFn举例&#xff09; import { useThrottleFn } from "vueuse/core"; // 表单提交 const handleSubmit useThrottleFn(() > {// 具…

策略模式的小记

策略模式 策略模式支付系统【场景再现】硬编码完成不同的支付策略使用策略模式&#xff0c;对比不同&#xff08;1&#xff09;支付策略接口&#xff08;2&#xff09;具体的支付策略类&#xff08;3&#xff09;上下文&#xff08;4&#xff09;客户端&#xff08;5&#xff0…

python 交互模式怎么切换目录

假如要用交互界面调用一个.py文件&#xff1a; &#xff08;1&#xff09;用cmd界面定位到文件位置&#xff0c;如cd Desktop/data/ #进入desktop下data目录。 &#xff08;2&#xff09;接着打开python&#xff08;输入python&#xff09; 调用os &#xff08;1&#xff0…

Linux df命令详解,Linux查看磁盘使用情况

《网络安全自学教程》 df 一、字段解释二、显示单位三、汇总显示四、指定目录五、指定显示字段六、du和df结果不一样 df&#xff08;disk free&#xff09;命令用来查看系统磁盘空间使用情况。 参数&#xff1a; -h&#xff1a;&#xff08;可读性&#xff09;显示单位&#…