需要用到的知识点
- 条件判断
- 循环
- 函数
- 数组
- 多cpp文件调用
- 指针
- 类
- windows的cmd操作
整个程序的实现流程
我们先简单看一下整个实现流程的目录,这个也差不多是我实现时候的具体流程。我一般在写一个相对有点工程量的东西之前会先确定我要做的东西是什么,然后进行分析,接着就是大概设计一下整个项目的模块都有哪些,当然基本是不可能完全覆盖和正确的,但是会有一个大致的方向和流程去完成整个项目,能有效降低不必要工作和逻辑的清晰程度,也就是完成了熵减,这都是大话,我们还是直接看具体要怎么实现我们今天的主题–俄罗斯方块。
1. 画图
我最喜欢的就是先画图,先将朴实无华的数字打印变成花里胡哨的图案, 能够让整个工作从一开始就显得极其有趣,本次地图沿用上一次写的贪吃蛇地图,不过我们采用光标移动的方式来进行地图元素的局部刷新,这样能有效避免全局刷新带来的闪屏,因为这次我们依旧完全只采用cmd来编程,所以我们不需要任何额外的安装包。
画图模块主要完成的是两件事情: 画地图,画方块。后面还会加入绘制提示板块和得分板块的功能。
画地图
HANDLE hOut=GetStdHandle(STD_OUTPUT_HANDLE);//获取标准输出句柄
/* 设置光标位置 */
void pos(int x,int y){
// 这里有个非常注意的点需要你关注,这里的x和y代表的是cmd中的坐标,
// 第一个参数位横轴,第二个参数是纵轴,另外由于我们打印的字符占位两个位置,所以每次偏移量要乘以二
// 另外,二维数组的第一维代表的是行数,也就是纵轴,第二维代表的是列数,也就是横轴,所以在选定光标位置时需要进行调换
COORD posPoint = {x * 2, y}; //设置坐标
SetConsoleCursorPosition(hOut,posPoint);
}
/* 初始化地图 */
void initMap(int **map){
// 这里有一个需要关注的地方, 那就是边界是墙,那么到时候在判定的时候,
// 我们要将墙体的大小算进到整个地图大小中,也就是height是包含了墙体了的
for(int i = 0; i < height; i++){
for(int j = 0; j < width; j++){
if(i == height - 1){
map[i][j] = 1;
}else if(j == 0 || j == width - 1){
map[i][j] = 1;
}else{
map[i][j] = 0;
}
}
}
return;
}
/* 绘制地图 */
void drawMap(int **map){;
pos(0, 0);
for(int i = 0; i < height; i++){
for(int j = 0; j < width; j++){
if(map[i][j] == 1){
cout << "■"; // 墙体
}else{
cout << "□"; // 可移动区域
}
}
cout << endl;
}
return;
}
上面的注释其实已经介绍了,但我这里还是要强调一下:二维数组的第一维代表的是行数,也就是纵轴,第二维代表的是列数,也就是横轴,所以在选定光标位置时需要进行调换。
地图绘制完成后,我们可以开始绘制方块了, 这里我们假设我们已经完成了方块的设定和初始化了,当然,我比较建议你先跳转到初始化方块的部分先了解方块是怎么定义的,这样有助于你理解一下的实现。
画方块
/* 绘制方块 */
void drawBlock(Block block){
int x = block.x; // 获取方块左上角的坐标
int y = block.y;
for(int i = 0; i < 4; i++){
for(int j = 0; j < 4; j++){
pos(y + j, x + i); // 这里的x和y的位置倒置,上面讲pos这个函数时有讲述原因。
if(block.shape[i][j] == 1 && x + i >= 0 && x + i < height - 1 && y + j < width - 1 && y + j > 0){
std::cout << "■";
}
}
}
pos(0,height); // 绘制完成后,需要将位置定位到最低行,这样有助于减少光标对游戏的视觉影响。
}
/* 清除方块 */
void cleanBlock(Block block){
int x = block.x;
int y = block.y;
for(int i = 0; i < 4; i++){
for(int j = 0; j < 4; j++){
pos(y + j, x + i);
if(block.shape[i][j] == 1 && x + i >= 0 && x + i < height - 1 && y + j < width - 1 && y + j > 0){ // 注意只有原方块存在■的地图需要涂成□,这样就可以避免误删
std::cout << "□";
}
}
}
pos(0,height);
}
/* 添加方块到地图中 */
void addBlock(int **map, Block block){
int x = block.x;
int y = block.y;
for(int i = 0; i < 4; i++){
for(int j = 0; j < 4; j++){
if(block.shape[i][j] == 1 && x + i >= 0 && x + i < height - 1 && y + j < width - 1 && y + j > 0){
map[x+i][y+j] = block.shape[i][j];
}
}
}
}
绘制方块:将方块绘制到地图中去,这个方块目前是独立且存在的,可以移动,也可以旋转。
清除方块:从地图中清除掉当前方块。当方块移动或者是旋转时,我们会采用先将方块清除掉的策略,然后再将移动或旋转后的方块绘制到地图中去(策略简单且好实现与维护)。
添加方块到地图中:当方块不能再往下之后,我们将他的方块存放到当前地图中。由于方块绘制已经完成了,我们只需要将这个值存放到全局地图变量map数组中,保证这个方块值确实存放入地图中就可以了。
注:map是地图变量,0表示可以移动的区域,1表示不可移动区域。我们采取将绘制和地图元素保存同步且独立的方式进行操作,也就是显示的内容确实是与map的值相同的,但是我们可以将显示的内容当前一个特殊的值载体,map看成一个载体,这样子,我们或许能够更加清晰地理清整个过程。
2. 初始化方块
关于方块的初始化, 本来是想使用类的继承和多态的,后来也只是用了类的属性和方法这一特性而已,使用了类初始化对象的方式也确实简化了一些工作(虽然不用也可以,就是我想学习一下类而已)。
方块的类定义如下:
class Block // class declaration
{
public:
int x; // 横坐标,以左上角为标志位
int y; // 纵坐标,以左上角为标志位
int type; // 方块类型
int director; // 旋转方向 0:向左,1向右
int shape[4][4]; // 格子大小
int shapes[8][4][4];
public:
/* 设置方块属性 */
void set(int _x, int _y, int _shape){
x = _x;
y = _y;
if(_shape != -1){
for(int i = 0; i < 4; i++)
for(int j = 0; j < 4; j++)
shape[i][j] = shapes[_shape][i][j];
type = _shape;
director = 0;
}
}
void generate(){
for(int i = 0; i < 8; i++)
for(int j = 0; j < 4; j++)
for(int k = 0; k < 4; k++)
shapes[i][j][k] = 0;
/* 石头 */
shapes[0][1][1] = 1;
/* 棍子 */
shapes[1][1][0] = shapes[1][1][1] = shapes[1][1][2] = shapes[1][1][3] = 1;
/* 七 (左)*/
shapes[2][0][0] = shapes[2][0][1] = shapes[2][1][1] = shapes[2][2][1] = 1;
/* 七 (右) */
shapes[3][0][1] = shapes[3][0][2] = shapes[3][1][1] = shapes[3][2][1] = 1;
/* 凸 */
shapes[4][0][1] = shapes[4][1][0] = shapes[4][1][1] = shapes[4][2][1] = 1;
/* 田 */
shapes[5][1][1] = shapes[5][1][2] = shapes[5][2][1] = shapes[5][2][2] = 1;
/* Z(左) */
shapes[6][0][0] = shapes[6][0][1] = shapes[6][1][1] = shapes[6][1][2] = 1;
/* Z(右)*/
shapes[7][0][2] = shapes[7][0][1] = shapes[7][1][1] = shapes[7][1][0] = 1;
}
};
以上的参数都有对应的注释了,所以我也不再赘述,我核心讲一下这么设定的原因。(其实我是偷师了好几个人才选择了这样的方式,嘘~~)
我们采用一个4*4的网格来存放一个俄罗斯方块,并且我们希望这个方块尽量靠近左上角(统一才是最好的),当然,除了石头和田(这两是乖孩子呀),因为这两个不需要进行旋转(没错,这个紧靠左上角的策略是为了后续的旋转做准备)。使用二维矩阵来表示方块能够很方便地进行shape矩阵和map矩阵的运算,而其中x,y就是shape矩阵再map矩阵中的偏移量。这样我们就可以很清楚地知道这个shape矩阵现在位于map矩阵中的哪个位置,就可以直接进行计算,绘制,以及判断等操作。
其中,4*4的网格(矩阵)中, 取值为1表示的是此处有方块■,其他为0则表示没有方块,这样就完成了方块的初始化了。
然后外部通过传入方块类型type来指定当前初始化的俄罗斯方块属于什么方块,这也为了后续的随机生成方块以及方块提示做了准备。
3. 方块旋转
这一部分我想算是整个俄罗斯方块比较复杂和困难的地方了, 旋转的困难主要在于,1. 不同的方块的旋转策略可能不同,2. 旋转是否合法(旋转后如果撞墙或者会与其他已有块撞在一起,那是不可以进行旋转的)。
关于第一个难点,由于我们已经将俄罗斯方块尽量贴着左上角来绘制了,所以除了棍子方块以外(棍子这个异类,我们批判他),其他方块都是存放在左上角3*3的矩阵中的,所以我们进行旋转时,我们可以只旋转左上角的3*3的矩阵。另外有部分方块其实只有两种形态,所以向左旋转后,我们希望它下次旋转是向右旋转的,这其中有棍子,Z这两种方块,而旋转方向则由当前俄罗斯方块的方向来决定,然后我们筛选出其中不需要旋转的俄罗斯方块:石头,田;那么分类就很清晰了。可以分成一下四类。
- 棍子。
- 棍子,Z。
- 石头,田。
- 7,凸。(7777777777)
关于第二个难点,我们采用一个比较偷懒但是确实有效的方案,我们先完成旋转,然后检查旋转后的俄罗斯方块是否与已有方块重合,如果有,则再让方块逆方向旋转一次。最后绘制旋转后的俄罗斯方块。(这里你可以选择改进我的代码,只有正确旋转才会进行擦除以及重绘,这样应该能让逻辑显得更加智慧一些。)
也许你很想要写那种直接判断是否可以旋转的代码来实现这个功能,而不是像我这样转来转去浪费计算资源,如果你选择这么实现我当然不会阻止,毕竟能实现这种操作的也是大佬了,我也很希望你能留言你的想法的,因为我当时考虑了好久,发现这种方法好实现,而且其实浪费的计算资源也还可以接受,其实最重要的是逻辑简单。(我最喜欢的就是逻辑简单的代码,我从来不难为我自己)。
代码如下:
/* 矩阵旋转90度 */
void rotation(Block *block, int director){
// 我们只旋转左上角3*3的矩阵,并且向director旋转90度
/* 向左旋转 */
if(director == 0){
/* 角转换 */
int value = block->shape[0][0];
block->shape[0][0] = block->shape[0][2];
block->shape[0][2] = block->shape[2][2];
block->shape[2][2] = block->shape[2][0];
block->shape[2][0] = value;
/* 十字转换 */
value = block->shape[0][1];
block->shape[0][1] = block->shape[1][2];
block->shape[1][2] = block->shape[2][1];
block->shape[2][1] = block->shape[1][0];
block->shape[1][0] = value;
}else if(director == 1){
/* 角转换 */
int value = block->shape[0][0];
block->shape[0][0] = block->shape[2][0];
block->shape[2][0] = block->shape[2][2];
block->shape[2][2] = block->shape[0][2];
block->shape[0][2] = value;
/* 十字转换 */
value = block->shape[0][1];
block->shape[0][1] = block->shape[1][0];
block->shape[1][0] = block->shape[2][1];
block->shape[2][1] = block->shape[1][2];
block->shape[1][2] = value;
}
/* 处理棍子的特殊情况 */
if(block->type == 1){
if(block->shape[1][3] == 1){
block->shape[1][3] = 0;
block->shape[3][1] = 1;
}else{
block->shape[1][3] = 1;
block->shape[3][1] = 0;
}
}
}
void _transfer(int **map, Block *block, int sign){
int director = block->director;
cleanBlock(*block); // 擦除旋转前的block
switch(sign){
case 0:
rotation(block, director);
// 旋转后进行碰撞检查
if(checkCrash(map, block) == 1){
rotation(block, director^1);
}
break;
case 1:
rotation(block, director);
block->director = block->director ^ 1;
// 旋转后进行碰撞检查
if(checkCrash(map, block) == 1){
rotation(block, director ^ 1);
block->director = block->director ^ 1;
}
break;
}
drawBlock(*block); // 重新绘制旋转后的block
}
/* 旋转 */
void transfer(int **map, Block *block){
int sign = block->type;
/*
sign: 什么类型的方块, 不同类型的方块的旋转策略不同
*/
if(sign == 2 || sign == 3 || sign == 4){ // 在左上角的三格内旋转
_transfer(map, block, 0);
}else if(sign == 1 || sign == 6 || sign == 7){ // 处理棍子,Z
_transfer(map, block, 1);
}else if(sign == 0 || sign == 5){ // 不需要处理的
// _transfer(block, 2);
return;
}
}
4. 方块移动
哇, 旋转写完了, 终于我们可以开始写移动了。
这里我要讲一下一开始我的误区,我一开始是方块和地图不独立的,所以地图也存放这当前移动方块的值,那么移动的时候,直观上来看,假如我们向左移动一列,那么我们应该删除右边那一列的俄罗斯方块本身,然后将整个俄罗斯方块一列一列地不断地向左替换。新到达的左边一列复制右边一列的俄罗斯方块(注意,这里我们复制的是俄罗斯方块本身的块,也就是说我们还要检查每个点是否是俄罗斯方块本身),这样的操作在这种地图与方块融合的方式实现起来极其复杂(我真为自己感到着急,第一天写的时候差点就因为这个放弃了,然后我就尝试将方块和地图进行分离存放。)。
分离存放后,我们采取用x,y来存储一个俄罗斯方块在地图map中的偏移量,并且方块自己存储自己的方块矩阵shape。这样就可以很方便地实现我们上面的操作,然后我就发现,再使用上面的操作似乎也没有必要,我完全可以直接先将移动前的方块从地图中清除,然后再将移动后的方块绘制出来,这样只需要修改x,y这两个偏移量就可以实现移动的操作,而擦除的是cmd中的打印的方块,所以也不需要操作map,需要注意的是,这里不需要操作map中的值,这也是我上面解释说要讲cmd和map各自看成独立的存值变量的原因。
我们总结一下(怪我太啰嗦了):
- 按下移动建后,将俄罗斯方块从地图中擦除,并更新x,y。
- 检查移动后的方块是否合法,如果不合法,将x,y重新设置为原值。
- 绘制新的俄罗斯方块。
你也可以像上面的方块旋转一样,改进我这里的逻辑,在检查之后再进行擦除操作,让代码显得更加智慧。
int _move(int **map, Block *block, int x, int y, int sign){
// sign: 1表示向下移动, 0表示左右移动
/* 消除所有属于block的块 */
/* 重新定位block的位置,生成新的block */
cleanBlock(*block);
block->set(block->x + x, block->y + y, -1);
if(checkCrash(map, block) == 1){
block->set(block->x - x, block->y - y, -1);
drawBlock(*block);
if(sign == 1)
addBlock(map, *block);
return 1;
}
drawBlock(*block);
return 0;
}
5. 异常检查
异常检查就是为了检查移动和旋转是否合法,以及是否死亡。说白了就是检查俄罗斯方块与地图有重合。
int checkCrash(int **map, Block *block){
int x = block->x;
int y = block->y;
for(int i = 0; i < 4; i++){
for(int j = 0; j < 4; j++){
if(block->shape[i][j] == 1 && map[x+i][y+j] == 1){
return 1;
}
}
}
return 0;
}
6. 成行检查
是否有成行的,如果有,消行加分。
int _checkLine(int **map, int line, int width){
for(int i = 0; i < width; i++){
if(map[line][i] == 0)
return 0;
}
return 1;
}
int checkLine(int **map, int height, int width){
int indexL = -1;
/* 检查哪一行是成行的 */
for(int i = 0; i < height - 1; i++){
int sign = 0;
if(_checkLine(map, i, width) == 1){
indexL = i;
break;
}
}
//如果某一行成行,则将当前行的值替换成上一行的值,并且以上的行也进行相同操作,除了第一行。
if(indexL != -1){
for(int i = indexL; i > 0; i--){
for(int j = 0; j < width; j++){
map[i][j] = map[i-1][j];
}
}
}else{
return 0;
}
return 1;
}
7. 提示
提示功能是比较花里胡哨的功能了,不过,游戏的丰富度是极其重要的,所以我这次就很坚定地加入了这个模块。
代码如下:
提示下一个方块
void drawPrompt(){
/* 加入提示:提示下一个方块的形状 */
/**
1. 为了方便起见,我们这个提示的大小直接固定,如果你有想要修改的意向,这一部分的操作也是个可展开细做的地方。
2. 提示部分放在右上角最顶部
**/
int promptH = 8;
int promptW = 8;
pos(width, 0);
for(int i = 0; i < promptH; i++){
for(int j = 0; j < promptW; j++){
pos(width + j, i);
if(i == promptH - 1 || i == 0){
std::cout << "■";
}else if(j == 0 || j == promptW - 1){
std::cout << "■";
}
}
}
pos(0,height);
}
void _drawPrompt(Block block){
/* 加入提示:提示下一个方块的形状 */
/**
1. 为了方便起见,我们这个提示的大小直接固定,如果你有想要修改的意向,这一部分的操作也是个可展开细做的地方。
2. 提示部分放在右上角最顶部
**/
for(int i = 0; i < 4; i++){
for(int j = 0; j < 4; j++){
pos(width + 2 + j, 3 + i);
if(block.shape[i][j] == 1){
std::cout << "■";
}else{
std::cout << " ";
}
}
}
pos(0,height);
}
提示已获得分数
简单起见,最高分为999分。(因为画图真的好难啊! 呜呜呜~~)。
void _drawNumber(int points[5][3], int x, int y){
for(int i = 0; i < 5; i++){
for(int j = 0; j < 3; j++){
COORD posPoint = {2 * y + j, x + i}; //设置坐标
SetConsoleCursorPosition(hOut,posPoint);
if(points[i][j] == 1){
cout << "+";
}else{
cout << " ";
}
}
}
pos(0, height);
}
void drawNumber(int number, int x, int y){
if(number == 0){
int points[5][3] = {
{1, 1, 1},
{1, 0, 1},
{1, 0, 1},
{1, 0, 1},
{1, 1, 1}
};
_drawNumber(points, x, y);
}else if(number == 1){
int points[5][3] = {
{0, 0, 1},
{0, 0, 1},
{0, 0, 1},
{0, 0, 1},
{0, 0, 1}
};
_drawNumber(points, x, y);
}else if(number == 2){
int points[5][3] = {
{1, 1, 1},
{0, 0, 1},
{1, 1, 1},
{1, 0, 0},
{1, 1, 1}
};
_drawNumber(points, x, y);
}else if(number == 3){
int points[5][3] = {
{1, 1, 1},
{0, 0, 1},
{1, 1, 1},
{0, 0, 1},
{1, 1, 1}
};
_drawNumber(points, x, y);
}else if(number == 4){
int points[5][3] = {
{1, 0, 1},
{1, 0, 1},
{1, 1, 1},
{0, 0, 1},
{0, 0, 1}
};
_drawNumber(points, x, y);
}else if(number == 5){
int points[5][3] = {
{1, 1, 1},
{1, 0, 0},
{1, 1, 1},
{0, 0, 1},
{1, 1, 1}
};
_drawNumber(points, x, y);
}else if(number == 6){
int points[5][3] = {
{1, 1, 1},
{1, 0, 0},
{1, 1, 1},
{1, 0, 1},
{1, 1, 1}
};
_drawNumber(points, x, y);
}else if(number == 7){
int points[5][3] = {
{1, 1, 1},
{0, 0, 1},
{0, 0, 1},
{0, 0, 1},
{0, 0, 1}
};
_drawNumber(points, x, y);
}else if(number == 8){
int points[5][3] = {
{1, 1, 1},
{1, 0, 1},
{1, 1, 1},
{1, 0, 1},
{1, 1, 1}
};
_drawNumber(points, x, y);
}else if(number == 9){
int points[5][3] = {
{1, 1, 1},
{1, 0, 1},
{1, 1, 1},
{0, 0, 1},
{1, 1, 1}
};
_drawNumber(points, x, y);
}
return;
}
void drawScore(int score){
/* 展示已获得的分数 */
drawNumber(score % 10, promptH + 1, width + 6);
score = score / 10;
drawNumber(score % 10, promptH + 1, width + 3);
score = score / 10;
drawNumber(score % 10, promptH + 1, width + 0);
}
代码
欢迎star!!^ . ^
github: https://github.com/iajqs/pratice-c/tree/master/Tetris
参考
- https://zhuanlan.zhihu.com/p/57052168 这个强烈推荐,从大佬那里学来很多东西,而且还有骚操作。