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

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

开始游戏

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

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.mzph.cn/pingmian/44567.shtml

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

相关文章

CSS技巧专栏:一日一例 3.纯CSS实现炫酷多彩按钮特效

大家好,今天是 CSS技巧专栏:一日一例 第三篇《纯CSS实现炫酷多彩按钮特效》 先看图: 开工前的准备工作 正如昨日所讲,为了案例的表现,也处于书写的习惯,在今天的案例开工前,先把昨天的准备工作重做一遍。 清除浏览器的默认样式定义页面基本颜色设定body的样式清除butt…

云视频监控中的高效视频转码策略:视频汇聚EasyCVR平台H.265自动转码H.264能力解析

随着科技的快速发展&#xff0c;视频监控技术已经广泛应用于各个领域&#xff0c;如公共安全、商业管理、教育医疗等。与此同时&#xff0c;视频转码技术作为视频处理的关键环节&#xff0c;也在不断提高视频的质量和传输效率。 一、视频监控技术的演进 视频监控技术的发展历…

SEO之网站结构优化(一)

初创企业搭建网站的朋友看1号文章&#xff1b;想学习云计算&#xff0c;怎么入门看2号文章谢谢支持&#xff1a; 1、我给不会敲代码又想搭建网站的人建议 2、新手上云 网站内的优化大致可以分为两部分&#xff0c;一是网站结构调整&#xff0c;二是页面上针对关键词的相关性优化…

前端八股文 闭包的理解

什么是闭包 闭包是指有权访问另一个函数作用域中的变量的函数 ——《JavaScript高级程序设计》 &#xff08;闭包 内层函数 引用的外层函数的变量&#xff09; 下面就是一个简单的闭包 闭包不一定必须有 return 闭包不一定有内存泄漏 闭包 什么时候用到 return 就是 外部…

真实工作项目Java使用apache.poi生成word

加油&#xff0c;新时代打工人&#xff01; 将实体类利用poi转成Word文件 demo示例 package com.fqpais.util;import com.fqpais.business.domain.TestReportTemplate; import com.fqpais.common.utils.StringUtils; import org.apache.poi.xwpf.usermodel.*; import org.slf4…

apache python使用

修改httpd.conf文件。 AddHandler cgi-script .cgi .py 代码 #!自己的python.exe #-*- coding:UTF-8 -*- print ("Content-type:text/html") print () print (<html>) print (<head>) print (<meta charset"gb2312">) print (<tit…

【排序 - 直接选择排序】

选择排序&#xff08;Selection Sort&#xff09;是一种简单直观的排序算法&#xff0c;虽然不如快速排序或归并排序高效&#xff0c;但它易于理解和实现&#xff0c;适用于小型数据集合。 选择排序原理 选择排序的基本思想是每次从未排序的数据中选出最小&#xff08;或最大…

mvcc 速读

MVCC&#xff08;Multi-Version Concurrency Control&#xff0c;多版本并发控制&#xff09;是MySQL中一种用于实现数据库并发控制的方法&#xff0c;尤其在InnoDB存储引擎中得到了广泛应用。它的主要作用是提高数据库在高并发场景下的性能&#xff0c;并确保数据的一致性。 …

QGroundControl连接运行在Docker中的PX4固件模拟器

1.通过docker启动PX4无人机 docker run --rm -it jonasvautherin/px4-gazebo-headless:1.14.3 启动成功&#xff1a; PX4完全启动OK如下&#xff1a; 会看到pxh> INFO [commander] Ready for takeoff! 执行takeoff指令 查看运行docker容器与QGC客户端的主机IP&#xff1a; …

【HarmonyOS NEXT】鸿蒙 代码混淆

代码混淆简介 针对工程源码的混淆可以降低工程被破解攻击的风险&#xff0c;缩短代码的类与成员的名称&#xff0c;减小应用的大小。 DevEco Studio提供代码混淆的能力并默认开启&#xff0c;API 10及以上版本的Stage模型、编译模式为release时自动进行代码混淆。 使用约束 …

【企业级监控】Docker部署Zabbix与监控主机

基于Docker部署Zabbix6.2 文章目录 基于Docker部署Zabbix6.2资源列表基础环境一、首先安装Docker1.1、部署Docker1.2、配置Docker加速器 二、部署Zabbix组件介绍三、部署Zabbix3.1、安装数据库容器3.2、安装zabbix-java-gateway3.3、安装zabbix-server3.4、安装zabbix-web界面3…

从零开始实现大语言模型(四):简单自注意力机制

1. 前言 理解大语言模型结构的关键在于理解自注意力机制(self-attention)。自注意力机制可以判断输入文本序列中各个token与序列中所有token之间的相关性&#xff0c;并生成包含这种相关性信息的context向量。 本文介绍一种不包含训练参数的简化版自注意力机制——简单自注意…

java synchronized关键字介绍

Java 中的 synchronized 关键字用于实现线程安全&#xff0c;防止多个线程同时访问共享资源时出现数据不一致的问题。它可以用来修饰方法或者代码块&#xff0c;确保在同一时刻最多只有一个线程执行被 synchronized 修饰的代码。 使用场景 同步实例方法&#xff1a;确保同一实…

JMH324-免费【最后一战LOL】MOBA竞技版本+单机一键端+视频教程+文本教程

资源介绍&#xff1a; 修改前打开【D:\ZHServer】文件夹里的【[1]一键启动.bat】&#xff0c;游戏不要打开&#xff0c;否则修改失败。 修改完以后重启架设程序才会生效。 fball_gamedb1数据库——gameuser数据表 obj_name 角色名 obj_lv 等级 obj_diamond 钻石 obj_gold 8…

嵌入式驱动源代码(9):Linux内核移植

目录 环境平台介绍 修改编译内核 1. 解压linux-3.0.8.tar.bz2源码包 2. 修改内核根目录下Makefile文件 3. 修改链接地址和运行地址 4. 修改机器类型ID号(mach-type) 5. 修改默认配置文件 6. 修改支持NAND FLASH及MTD分区 7. 支持yaffs文件系统 8. make zImage 9. 内…

ZFT9-7VE8043-Z同期脉冲发送装置100V JOSEF约瑟 柜内安装

ZFT9(PIG)同期脉冲发送装置 系列型号 ZFT9(PIG) 7VE8033同期脉冲发送装置; ZFT9(PIG) 7VE8043同期脉冲发送装置; ZFT9 7VE8033同期脉冲发送装置; ZFT9 7VE8043同期脉冲发送装置; 用途&#xff1a; ZFT9同期脉冲发送装置用于船舶的三相系统&#xff0c;根据发电机和电力系…

为什么要考国际人力资源证书?HR不能不知道!

在人力资源领域中&#xff0c;持有专业的人力资源证书并非铁律般的必需。但不容忽视的是&#xff0c;随着时代的进步和行业的不断演进&#xff0c;越来越多的人力资源专业人员开始重视并追求人力资源资格认证。 一张高含金量的证书让HR在求职市场上更具竞争力&#xff0c;更能…

JavaScript中的执行上下文和原型链

目录 一、执行上下文 1.执行上下文 2.执行上下文栈 3.闭包 1&#xff09;定义 2&#xff09;形成条件 3&#xff09;例子 &#xff08;1&#xff09;例子1&#xff1a;简单闭包 &#xff08;2&#xff09;例子2&#xff1a;闭包与循环 &#xff08;3&#xff09;例子…

mes系统在新材料行业中的应用价值

万界星空科技新材料MES系统是针对新材料制造行业的特定需求而设计的制造执行系统&#xff0c;它集成了生产计划、过程监控、质量管理、设备管理、库存管理等多个功能模块&#xff0c;以支持新材料生产的高效、稳定和可控。以下是新材料MES系统的具体功能介绍&#xff1a; 一、生…

LVS集群(二)

DR模式 LVS三种模式 nat地址转换 DR直接路由模式 tun隧道模式 DR模式的特点&#xff1a; 调度器在整个lvs集群中是最重要的&#xff0c;在nat模式中&#xff0c;负责接收请求&#xff0c;同时根据负载均衡算法转发流量&#xff0c;响应发送给客户端 DR模式&#xff1a;调度…