手撸俄罗斯方块(三)——游戏核心模块设计

news/2024/9/18 12:45:26/ 标签: 前端, 俄罗斯方块, tetris

手撸俄罗斯方块——游戏核心模块设计

开始游戏

按照之前的设计,我们需要游戏的必要元素之后即可开始游戏,下面以控制台上运行俄罗斯方块为例进行展开讲解。

import { ConsoleCanvas, ConsoleController, ConsoleColorTheme, Color } from '@shushanfx/tetris-console';
import { Dimension, ColorFactory, Game } from '@shushanfx/tetris-core';const theme = new ConsoleColorTheme();
const canvas = new ConsoleCanvas(theme);
const controller = new ConsoleController();
const dimension = new Dimension(10, 20);
const factory = new ColorFactory(dimension, [Color.red,Color.green,Color.yellow,Color.blue,Color.magenta,Color.cyan,
]);
const game = new Game({ dimension, canvas, factory, controller });
game.start();

下面我们逐行进行分析:

  • 包的引入,分为tetris-core、tetris-console,这个是包的划分,将核心包的组件放在tetris-core中,具体的实现放在tetris-console中。

  • theme、canvas、controller的初始化;

  • factory、dimension的初始化;

  • game的初始化,使用之前初始化的canvas、factory、canvas、dimension对象;

  • game调用start方法。

接下来,我们看下start做了啥?

Game.start的逻辑


class Game {start() {const { status } = this;if (status === GameStatus.RUNNING) {return ;}if (status === GameStatus.OVER|| status === GameStatus.STOP) {this.stage.reset();this.canvas.render();} else if (status === GameStatus.READY) {this.controller?.bind();this.canvas.render();}this.status = GameStatus.RUNNING;this.tickCount = 0;this.canvas.update();// @ts-ignorethis.tickTimer = setInterval(() => {if (this.tickCount == 0) {// 处理向下this.stage.tick();this.checkIsOver();}this.canvas.update();this.tickCount++;if (this.tickCount >= this.tickMaxCount) {this.tickCount = 0;}}, this.speed);}
}

我们逐行分析一下:

  1. 获取status变量,statusGame游戏状态的内部表示,分别为准备就绪(READY)游戏中(RUNNING)暂停(PAUSE)停止(STOP)游戏结束(OVER)。其中停止和游戏结束的区别是,前者是主动停止游戏,后者为游戏触发结束逻辑导致游戏结束。

  2. 如果游戏正在进行中,则直接返回;

  3. 如果游戏在停止和游戏结束的状态,则对Stage进行重置和对canvas进行整体重绘。

  4. 如果游戏在准备就续中,说明游戏刚完成初始化,从未开始。调用controller进行事件的绑定以及canvas首次绘制;

  5. 设置游戏状态为 游戏中(RUNNING),内部状态tickCount = 0;

  6. 调用canvas立即进行一次局部更新,此处更新主要是status发生了变化,导致游戏状态需要重新渲染;

  7. 开启定时器,定时器的时间通过this.speed,speed后续会考虑跟游戏的level进行搭配(暂时未支持)。

  • 如果tickCount == 0,则触发一次stage的tick动作,触发后立即检查是否结束;
  • 触发canvas的update操作
  • tickCount自增,如果满足 >= tickMaxCount,则重置;

之所以引入tickCount机制,主要是保证canvas的更新频率,一般情况下屏幕刷新率要高于stage.tick速度,如果两者保持一致可能会出现游戏界面不流畅的情况。

Stage tick

从上述代码可以看出,游戏的核心逻辑是stage.tick,其内部实现如下:

class Stage {tick(): void {if (this.isOver || this.clearTimers.length > 0) {return;}// 首次加载,current为空if (!this.current) {this.next = this.factory.randomBlock();this.toTop(this.next);this.current = this.factory.randomBlock();this.toTop(this.current);return ;}const isOver = this.current.points.some((point) => {return !this.points[point.y][point.x].isEmpty;});if (isOver) {this.isOver = true;return;}const canMove = this.current.canMove('down', this.points);if (canMove) {this.current.move('down');} else {this.handleClear();}}
}
  • 首先判断游戏是否结束或者正在执行清除操作。

  • 如果current为空,则表示游戏是首次加载,分别初始化currentnext

  • 判断游戏是否达到结束条件,即currentpoints有重叠。如果有重叠则标记游戏结束。

  • 判断当前current是否可以往下移动,如果能往下移动,则往下移动一格,否则检测是否可以消除。

接下来我们来看如何检测消除,即handleClear的实现。

class Stage {private handleClear() {if (!this.current) {return;}// 1. 复制新的pointsconst pointsClone: Point[][] = this.points.map((row) => row.map((point) => point.clone()));this.current.points.forEach((point) => {pointsClone[point.y][point.x] = point.clone();});// 2. 检查是否有消除的行const cleanRows: number[] = [];for(let i = 0; i < pointsClone.length; i ++) {const row = pointsClone[i];const isFull = row.every((point) => {return !point.isEmpty});if (isFull) {cleanRows.push(i);}}// 3. 对行进行消除if (cleanRows.length > 0) {this.startClear(pointsClone, cleanRows, () => {// 处理计算分数this.score += this.getScore(cleanRows.length);// 处理消除和下落cleanRows.forEach((rowIndex) => {for(let i = rowIndex; i >= 0; i--) {if (i === 0) {pointsClone[0] = Array.from({ length: this.dimension.xSize }, () => new Point(-1, -1));} else {pointsClone[i] = pointsClone[i - 1];}}});// 4. 扫尾工作,变量赋值this.points = pointsClone;this.current = this.next;this.next = this.factory.randomBlock();this.toTop(this.next);});} else {// 4. 扫尾工作,变量赋值this.points = pointsClone;this.current = this.next;this.next = this.factory.randomBlock();this.toTop(this.next);}}
}

从上述代码可以看出,整个流程分为四步:

  1. 复制一个新的pointsClone,包括current和当前的points。

  2. 逐行检测pointsClone,如果整行被填充,则进行标记;

  3. 按照2生成的标记内容,逐行删除。注意删除的操作是从上往下进行,删除一行时从顶部补充一行空行。

  4. 扫尾工作。不管是否进行清除操作均需要进行该步骤,将pointsClone赋值给this.points,同时完成currentnext的切换。

旋转(rotate)

方块旋转是怎么回事呢 ?

所有旋转行为都是通过调用game.rotate方法触发,包括controller定义的事件、外部调用等;

Game中实现逻辑如下:

class Game {rotate() {this.stage.rotate();  this.canvas.update();}
}

接下来看Stage的实现

class Stage {rotate(): boolean {if (!this.current) {return false;}const canChange = this.current.canRotate(this.points);if (canChange) {this.current.rotate();}return false;}
}
  • 首先判断current是否存在,如果不存在则直接返回;

  • 调用currentcanRotate方法,查看当前位置是否可以旋转;如果能选择则调用旋转方法进行旋转。

我们进一步,查看BlockcanRotaterotate方法。

class Block {canRotate(points: Point[][]): boolean {const centerIndex = this.getCenterIndex();if (centerIndex === -1) {return false;}const changes = this.getChanges();if (changes.length === 0) {return false;}const nextChange = changes[(this.currentChangeIndex + 1) % changes.length];const newPoints = this.changePoints(this.points, this.points[centerIndex], nextChange);const isValid = Block.isValid(newPoints, this.dimension);if (isValid) {return newPoints.every((point) => {return points[point.y][point.x].isEmpty;});}return isValid;}
}

我们先看canRotate的实现。

  • 获取centerIndex,centerIndex即旋转的中心点的索引。这个每个图形都不一样,如IBlock,其定义如下:

    class IBlock extends Block {getCenterIndex(): number {return 1;}
    }
    

    即,旋转中心点为第二个节点。如口口口口, 第二个中心点口田口口

    另外在设计该方块时也考虑有些方块是无法旋转的,如OBlock,它无法选择。则getCenterIndex返回-1

  • 获取changes数组,该数组的定义为当前旋转的角度,数组长度表示旋转次数,数组内容表示本次旋转相对上次旋转的角度。如IBlock的定义如下:

    class IBlock extends Block {currentChangeIndex: number = -1;getChanges(): number[] {return [Math.PI / 2,0 - Math.PI / 2];}
    }
    

    即,第一次旋转为初始状态Math.PI / 2(即90度),第二次旋转为第一次旋转的-Math.PI / 2(即-90度)。如下:

    // 初始状态
    // 口田口口// 第一次旋转
    //  口
    //  田
    //  口
    //  口// 第二次旋转
    // 口田口口

    PS: 这里要注意坐标轴是从左到右,从上到下。

  • 进行旋转判断,判断的标准为:

    1. 旋转后的坐标点不能超过整个游戏的边界;
    2. 旋转后的坐标点不能占用已填充方块的点。

    因此,我们看到有isValidnewPoints.every的判断。

我们接下来看Block.rotate,如下:

class Block {rotate() {const centerIndex = this.getCenterIndex();if (centerIndex === -1) {return false;}const changes = this.getChanges();if (changes.length === 0) {return false;}const nextChange = changes[(this.currentChangeIndex + 1) % changes.length];const newPoints = this.changePoints(this.points, this.points[centerIndex], nextChange);const isValid = Block.isValid(newPoints, this.dimension);if (isValid) {this.currentChangeIndex = (this.currentChangeIndex + 1) % changes.length;this.points = newPoints;}return isValid;}
}

通过上面的描述,rotate的逻辑就容易理解了。

  • 获取centerIndexchanges,将currentChangeIndex进行循环自增,并将将Block指向新的坐标。

  • 其中currentChangeIndex初始值为-1,表示当前为旋转,大于等于0则表示选择 index + 1次。(此处请仔细思考,因为数组的索引从0开始)

移动

移动即将Block向四个方向进行移动。我们来看其实现

class Game {move(direction: Direction) {this.stage.move(direction);this.canvas.update();}
}

其中,Direction定义如下:

type Direction = 'up' | 'down' | 'left' | 'right'

进一步看Stage的实现:

class Stage {move(direction: Direction) {if (!this.current) {return false;}const canMove = this.current.canMove(direction, this.points);if (canMove) {this.current.move(direction);}return canMove;}
}

进一步看canMovemove的实现。

class Block {canMove(direction: Direction, points: Point[][]): boolean {return this.points.every((point) => {switch (direction) {case 'up':return point.y > 0 && points[point.y - 1][point.x].isEmpty;case 'down':return point.y < this.dimension.ySize - 1 && points[point.y + 1][point.x].isEmpty;case 'left':return point.x > 0 && points[point.y][point.x - 1].isEmpty;case 'right':return point.x < this.dimension.xSize - 1 && points[point.y][point.x + 1].isEmpty;}});};
}

我们简单翻译一下如下:

  • 上移,所有的y轴点必须大于0(即大于等于1),且移动之后的点必须是空点;

  • 左移,所有的x轴点必须大于0(即大于等于1),且移动之后的点必须是空点;

  • 右移,所有的x轴点必须小于x坐标轴长度-1(即小于等于xSize - 2),且移动之后的点必须是空点;

  • 下移,所有的y轴点必须小于y坐标轴长度-1(即小于等于ySize - 2),且移动之后的点必须是空点。

满足移动条件之后,我们来看move的实现。

class Block {move(direction: Direction): boolean {switch (direction) {case 'up':this.points.forEach((point) => { point.y = point.y - 1})break;case 'down':this.points.forEach((point) => { point.y = point.y + 1})break;case 'left':this.points.forEach((point) => { point.x = point.x - 1})break;case 'right':this.points.forEach((point) => { point.x = point.x + 1})break;}return true;}
}

直接是修改坐标点的值。

小结

本章描述了游戏的三个重要行为:清除、旋转和移动。它们三者之间相互配合,完成游戏。下一章我们将分享游戏的界面渲染和操作控制。


http://www.ppmy.cn/news/1474664.html

相关文章

18.按键消抖模块设计(使用状态机,独热码编码)

&#xff08;1&#xff09;设计意义&#xff1a;按键消抖主要针对的时机械弹性开关&#xff0c;当机械触点断开、闭合时&#xff0c;由于机械触点的弹性作用&#xff0c;一个按键开关在闭合时不会马上稳定地接通&#xff0c;在断开时也不会一下子就断开。因而在闭合以及断开的瞬…

Linux rpm和ssh损坏修复

背景介绍 我遇到的问题可能和你的不一样。但是如果遇到错误一样也可以按此方案尝试修复。 我是想在Linux上安装Oracle&#xff0c;因为必须在离线环境下安装。就在网上搜一篇文章linux离线安装oracle&#xff0c;然后安装教程走&#xff0c;进行到安装oracle依赖包的时候执行了…

React:useState和useEffect

最近因为想要开发一个简单的应用才开始接触React。但是并没有系统学习React&#xff0c;所以这篇博客可能会写的不够专业。 1. Hooks 在程序设计语言中&#xff0c;钩子(hook)是一种机制&#xff0c;它可以允许程序在某些预定的事件或位置执行特定的代码。在React中&#xff0c…

Web 性能入门指南-1.2 分析在线零售 Web 性能及优化方向

让顾客满意是零售业成功的秘诀。事实证明&#xff0c;提供快速、一致的在线体验可以显著提高零售商关心的每项指标——从转化率和收入到留存率和品牌认知度。 本文大纲&#xff1a; 页面速度影响在线零售业务数据 如何将您的网站速度与竞争对手进行比较 性能优化入门&#xf…

超级好用的java http请求工具

kong-http 基于okhttp封装的轻量级http客户端 使用方式 Maven <dependency><groupId>io.github.kongweiguang</groupId><artifactId>kong-http</artifactId><version>0.1</version> </dependency>Gradle implementation …

独特功能的视频号矩阵系统源码,支持多短视频平台自动发布和定时发布

在短视频行业竞争日趋激烈的今天&#xff0c;一个高效的视频发布系统对于内容创作者和营销团队来说至关重要。视频号矩阵系统源码以其独特的功能&#xff0c;为多平台自动发布和定时发布提供了强大的技术支持。 多平台自动化发布&#xff1a;无缝内容分发 视频号矩阵系统源码…

掌握MOJO命令行:参数解析的艺术

在软件开发中&#xff0c;命令行接口&#xff08;CLI&#xff09;是一种与程序交互的强大方式&#xff0c;它允许用户通过终端输入指令和参数来控制程序的行为。对于MOJO语言&#xff0c;即使它是一个假想的编程语言&#xff0c;我们也可以设想它具备解析命令行参数的能力。本文…

Oracle执行一条SQL的内部过程

一、SQL语句根据其功能主要可以分为以下几大类&#xff1a; 1. 数据查询语言&#xff08;DQL, Data Query Language&#xff09; 功能&#xff1a;用于从数据库中检索数据&#xff0c;常用于查询表中的记录。基本结构&#xff1a;主要由SELECT子句、FROM子句、WHERE子句等组成…

使用Docker、Docker-compose部署单机版达梦数据库(DM8)

安装前准备 Linux Centos7安装&#xff1a;https://blog.csdn.net/andyLyysh/article/details/127248551?spm1001.2014.3001.5502 Docker、Docker-compose安装&#xff1a;https://blog.csdn.net/andyLyysh/article/details/126738190?spm1001.2014.3001.5502 下载DM8镜像 …

Bilibili Android一二面凉经(2024)

BiliBili Android一二面凉经(2024) 笔者作为一名双非二本毕业7年老Android, 最近面试了不少公司, 目前已告一段落, 整理一下各家的面试问题, 打算陆续发布出来, 供有缘人参考。今天给大家带来的是《BiliBili Android一二面凉经(2024)》。 面试职位: 高级Android开发工程师&…

Openresty+lua 定时函数 ngx.timer.every

ngx.timer.every 是 OpenResty 中的一个函数&#xff0c;用于创建定时器&#xff0c;以便定期执行某个函数或代码块。它的用法如下&#xff1a; local delay 5 -- 定时器间隔时间&#xff0c;单位为秒ngx.timer.every(delay, function(premature)-- 这里是定时执行的代码块i…

2.5 C#视觉程序开发实例1----CamManager实现模拟相机采集图片(Form_Vision部分代码)

2.5 C#视觉程序开发实例1----CamManager实现模拟相机采集图片(Form_Vision部分代码) 1 目标效果视频 CamManager 2 增加一个class IMG_BUFFER 用来管理采集的图片 // <summary> /// IMG_BUFFER 用来管理内存图片的抓取队列 /// </summary> public class IMG_BUFF…

【代码随想录算法训练营第六十二天|卡码网53.寻宝(prim算法和kruskal算法)】

文章目录 53.寻宝prim算法kruskal算法 53.寻宝 prim算法 prim算法三部曲&#xff1a; 1.选择当前最短入树结点&#xff1b;2.更新入树结点&#xff1b;3.更新结点距离最小生成树的距离。 可以把所有已经使用过的结点看作一个整体&#xff0c;然后把他们相接的结点的结点顶点边…

百日筑基第十八天-一头扎进消息队列1

百日筑基第十八天-一头扎进消息队列1 先对业界消息队列有个宏观的认识 消息队列的现状 当前开源社区用的较多的消息队列主要有 RabbitMQ、RocketMQ、Kafka 和Pulsar 四款。 国内大厂也一直在自研消息队列&#xff0c;比如阿里的 RocketMQ、腾讯的 CMQ 和 TubeMQ、京东的 JM…

玄机——第五章 linux实战-CMS01 wp

文章目录 一、前言二、概览简介 三、参考文章四、步骤&#xff08;解析&#xff09;准备步骤#1.0步骤#1.1通过本地 PC SSH到服务器并且分析黑客的 IP 为多少,将黑客 IP 作为 FLAG 提交; 步骤#1.2通过本地 PC SSH到服务器并且分析黑客修改的管理员密码(明文)为多少,将黑客修改的…

Perl 语言开发(八):子程序和模块

目录 1. 引言 2. 子程序的基本概念与用法 2.1 子程序的定义和调用 2.2 传递参数 2.3 返回值 2.4 上下文和返回值 3. 模块的基本概念与用法 3.1 模块的定义 3.2 使用模块 3.3 导出符号 3.4 模块的文件结构和命名 4. 实际应用中的子程序与模块 4.1 子程序参数验证与…

省市县下拉框的逻辑以及多表联查的实例

2024.7.12 一. 省市县的逻辑开发。1、准备&#xff1a;1.1. 要求&#xff1a;1.2 数据库表&#xff1a; 2. 逻辑&#xff1a;3. 方法3.1 创建实体类3.2 数据访问层3.3 实现递归方法3.4 控制器实现3.5 前端处理 二、多表联查&#xff08;给我干红温了&#xff09;1. 出现了问题2…

代理详解之静态代理、动态代理、SpringAOP实现

1、代理介绍 代理是指一个对象A通过持有另一个对象B&#xff0c;可以具有B同样的行为的模式。为了对外开放协议&#xff0c;B往往实现了一个接口&#xff0c;A也会去实现接口。但是B是“真正”实现类&#xff0c;A则比较“虚”&#xff0c;他借用了B的方法去实现接口的方法。A…

服务网格新篇章:Eureka与分布式服务网格的协同共舞

服务网格新篇章&#xff1a;Eureka与分布式服务网格的协同共舞 引言 在微服务架构的浪潮中&#xff0c;服务网格&#xff08;Service Mesh&#xff09;技术以其微服务间通信的精细化控制而备受瞩目。Eureka作为Netflix开源的服务发现框架&#xff0c;虽然本身不直接提供服务网…

前端面试题47(在动态控制路由时,如何防止未授权用户访问受保护的页面?)

在Vue中&#xff0c;防止未授权用户访问受保护页面通常涉及到使用路由守卫&#xff08;Route Guards&#xff09;。路由守卫允许你在路由发生改变前或后执行一些逻辑&#xff0c;比如检查用户是否已登录或者有访问某个页面的权限。下面是一些常见的路由守卫类型及其使用方式&am…