Bootstrap

制作自己的游戏:打砖块

🚀 前言

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

🚀 前期准备

游戏制作可以有许多开发语言可供选择,本文采用 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!🌹🌹🌹');
  }
}

游戏胜利效果如下:

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

🚀 结语

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

;