制作自己的游戏:打砖块

news2024/11/23 22:00:56

文章目录

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

🚀 前言

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

🚀 前期准备

游戏制作可以有许多开发语言可供选择,本文采用 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;  // 初始化得分为 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 实例化之后,我们可以看到如下效果:

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

🚀 让小球动起来

🍓 动画

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

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

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

  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 坐标固定为 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 函数,会发现这个函数有两个功能:

  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),将松开的键标记为 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;
    }
  }
}

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

🚀 碰撞检测

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

  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.coloradmin.cn/o/2109479.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

场景是人工智能第四要素,是垂直领域人工智能的第一要素。

"场景是人工智能的第四要素&#xff0c;与数据、算力、算法同等重要。"拿着技术找场景&#xff0c;还是拿着场景找技术&#xff1f;这个锤子和钉子的问题&#xff0c;一直困扰着各家AI大厂。从近5年的实践来看&#xff0c;拿着场景找技术是更为稳健的&#xff0c;否则…

文章解读与仿真程序复现思路——电力自动化设备EI\CSCD\北大核心《面向电网调峰的聚合温控负荷多目标优化控制方法 》

本专栏栏目提供文章与程序复现思路&#xff0c;具体已有的论文与论文源程序可翻阅本博主免费的专栏栏目《论文与完整程序》 论文与完整源程序_电网论文源程序的博客-CSDN博客https://blog.csdn.net/liang674027206/category_12531414.html 电网论文源程序-CSDN博客电网论文源…

R901085689比例流量控制阀配置HE-SP1比例放大器

R901085689比例流量控制阀配置HE-SP1比例放大器的功能是将电信号转换成对应的流量变化&#xff0c;通过调整阀门开度来控制介质的流量。这种转换是通过比例电磁铁实现的&#xff0c;它将输入的电流信号转换成力或位移&#xff0c;从而驱动阀芯移动&#xff0c;实现流量的连续调…

html+css+js网页设计 珠宝首饰模版13个页面

htmlcssjs网页设计 珠宝首饰模版13个页面 网页作品代码简单&#xff0c;可使用任意HTML编辑软件&#xff08;如&#xff1a;Dreamweaver、HBuilder、Vscode 、Sublime 、Webstorm、Text 、Notepad 等任意html编辑软件进行运行及修改编辑等操作&#xff09;。 获取源码 1&…

手机同时传输USB功能与充电的实现及LDR6500的作用

在智能设备日益普及的今天&#xff0c;用户对于手机的功能需求愈发多样化&#xff0c;其中同时实现USB数据传输与充电功能成为了许多用户的迫切需求。这一功能的实现离不开先进的硬件技术和创新的芯片解决方案&#xff0c;而LDR6500正是这样一款能够满足这一需求的USB PD&#…

uni-app 扫码优化:谈谈我是如何提升安卓 App 扫码准确率的

一. 前言 之前的一个项目遭到用户吐槽&#xff1a;“你们这个 App 扫码的正确率太低了&#xff0c;尤其是安卓的设备。经常性的扫码扫不出来&#xff0c;就算是扫出来了&#xff0c;也是错误的结果&#xff01;” 由于之前是扫描二维码的需求&#xff0c;所以没有对扫描条形码…

yolov8-obb旋转目标检测onnxruntime和tensorrt推理

onnxruntime推理 导出onnx模型&#xff1a; from ultralytics import YOLO model YOLO("yolov8n-obb.pt") model.export(format"onnx") onnx模型结构如下&#xff1a; python推理代码&#xff1a; import cv2 import math import numpy as np impo…

全面提升管理效率的智慧园区可视化系统

通过图扑 HT 搭建智慧园区可视化&#xff0c;实时监测和展示园区内各设施的状态与能耗&#xff0c;优化资源配置&#xff0c;提升园区管理效率。

科普神文,一次性讲透AI大模型的核心概念

令牌&#xff0c;向量&#xff0c;嵌入&#xff0c;注意力&#xff0c;这些AI大模型名词是否一直让你感觉熟悉又陌生&#xff0c;如果答案肯定的话&#xff0c;那么朋友&#xff0c;今天这篇科普神文不容错过。我将结合大量示例及可视化的图形手段&#xff0c;为你由浅入深一次…

电脑怎么禁止软件联网?电脑怎么限制软件上网?方法很多,这三种最常用!

在日常使用电脑时&#xff0c;某些软件可能会自动联网&#xff0c;这不仅会消耗网络资源&#xff0c;还可能带来安全风险。此外企业老板考虑到公司员工可能会在工作期间访问无关软件&#xff0c;影响工作效率&#xff0c;因此&#xff0c;很多用户希望能够禁止某些软件联网&…

springboot学生社团管理系统—计算机毕业设计源码26281

目录 摘要 Abstract 1 绪论 1.1 研究背景 1.2 研究意义 1.3论文结构与章节安排 2 学生社团管理系统系统分析 2.1 可行性分析 2.2 系统流程分析 2.2.1 数据增加流程 2.2.2 数据修改流程 2.2.3 数据删除流程 2.3 系统功能分析 2.3.1 功能性分析 2.3.2 非功能性分析…

C语言 10 数组

简单来说&#xff0c;数组就是存放数据的一个组&#xff0c;所有的数据都统一存放在这一个组中&#xff0c;一个数组可以同时存放多个数据。 一维数组 比如现在想保存 12 个月的天数&#xff0c;那么只需要创建一个 int 类型的数组就可以了&#xff0c;它可以保存很多个 int …

Linux网络编程IO管理

网络 IO 涉及到两个系统对象&#xff0c;一个是用户空间调用 IO 的进程或者线程&#xff0c;一个是内核空间的内核系统&#xff0c;比如发生 IO 操作 read 时&#xff0c;它会经历两个阶段&#xff1a; 等待内核协议栈的数据准备就绪&#xff1b;将内核中的数据拷贝到用户态的…

vue3 json格式化显示数据(vue3-json-viewer) 对比修改前后数据

需求&#xff1a;对比变更前后数据 npm包下载 npm install vue3-json-viewer --savemain.ts中全局引用 // json可视化 import JsonViewer from "vue3-json-viewer" import "vue3-json-viewer/dist/index.css";app.use(JsonViewer).mount("#app&quo…

鸿蒙界面开发——组件(6):属性字符串(StyledString)文本输入

属性字符串StyledString/MutableStyledString MutableStyledString继承于StyledString&#xff0c;以下统一简称StyledString。 是功能强大的标记对象&#xff0c;可用于字符或段落级别设置文本样式。 通过将StyledString附加到文本组件&#xff0c; 可以通过多种方式更改文本…

深度学习-用神经网络NN实现足球大小球数据分析软件

文章目录 前言一、 数据收集1.1特征数据收集代码实例 二、数据预处理清洗数据特征工程&#xff1a; 三、特征提取四、模型构建五、模型训练与评估总结 前言 预测足球比赛走地大小球&#xff08;即比赛过程中进球总数是否超过某个预设值&#xff09;的深度学习模型是一个复杂但有…

霍尼韦尔、书客、米家护眼大路灯怎么样?终极测评对比和护眼灯王者机型

霍尼韦尔、书客、米家护眼大路灯怎么样&#xff1f;护眼大路灯的重要性不容忽视&#xff0c;它是我们日常生活中用眼的必备工具&#xff0c;也是眼睛能够得到保护重要一环。近年来&#xff0c;护眼大路灯市场呈现出国际大牌的垄断局面&#xff0c;但这也带来了一些问题。为了争…

油猴插件录制请求,封装接口自动化参数

参考&#xff1a;如何使用油猴插件提高测试工作效率 一、背景 在酷家乐设计工具测试中&#xff0c;总会有许多高频且较繁琐的工作&#xff0c;比如&#xff1a; 查询插件版本&#xff1a;需要打开Chrome控制台&#xff0c;输入好几个命令然后过滤出版本信息。 查询模型商品&…

java设计模式day03--(结构型模式:代理模式、适配器模式、装饰者模式、桥接模式、外观模式、组合模式、享元模式)

5&#xff0c;结构型模式 结构型模式描述如何将类或对象按某种布局组成更大的结构。它分为类结构型模式和对象结构型模式&#xff0c;前者采用继承机制来组织接口和类&#xff0c;后者釆用组合或聚合来组合对象。 由于组合关系或聚合关系比继承关系耦合度低&#xff0c;满足“…

SpingBoot中使用Swagger快速生成接口文档

目录 一.Swagger快速上手 二.Swagger中的基本注解 三.使用Swagger进行测试 一.Swagger快速上手 Swagger是⼀个接⼝⽂档⽣成⼯具&#xff0c;它可以帮助开发者⾃动⽣成接⼝⽂档。当项⽬的接⼝发⽣变更时&#xff0c;Swagger可以实时更新⽂档&#xff0c;确保⽂档的准确性和时…