c + easyx 实现怀旧掌机界面风格俄罗斯方块
前言
好久没写东西了,用一个星期的下班休息时间写了一个俄罗斯方块小游戏,算是圆了儿时的一个梦。界面写成了怀旧的掌机风格,之所以写成这样是因为当初在查找资料的时候谷歌出来的第一个条目是github上的一个俄罗斯方块项目(项目地址),一下子就喜欢上这个风格,于是就打算模仿这个写了基本一样效果的。不同于github上的这个项目,我是用C加上easyx库写的,写的时候也并没有参考他的代码(因为没学过,看不懂(︶︿︶)),因为太忙了,写完之后测试暂时没发现什么bug,也就没继续优化代码,可能不少地方写的很啰嗦,没办法,业余爱好,不是专业程序员,每天劳心劳力的上班,精力实在有限,要不是真的喜欢可能早就放弃了。这次很大一部分精力用在了处理图形效果上,真正涉及到俄罗斯方块本身运行逻辑的代码并不算多。而且有点吃瘪的是当初为了省事直接把github上的截了图,以这个截图当做游戏的背景,再在上面进行绘制,本以为这样能省很多事,偷点懒,结果一点懒没偷着,为了精确的绘制,得找准坐标,而截图是人家的,不是自己设计的,坐标什么的自己并不知道,就得测量各个分区的坐标,这样就限制了程序本身的可移植性和适应性,以后再不这么做了,费力不讨好。
最终效果如下:
一、俄罗斯方块的构型
俄罗斯方块一共有七种构型,全部都由四个方块组成:
各种四格方块由左至右,上至下的英文字母代号:I、J、L、O、S、T、Z。为了表示七种构型,创建了枚举变量:
enum TetrisM //俄罗斯方块的七种类型
{
I,
J,
L,
O,
S,
T,
Z,
};
二、数据结构
一个标准的俄罗斯方块操作界面,其大小为:行宽为10,列高为20。我们可以将游戏的操作界面当做一个20 * 10的二维数组,
主要数据结构如下(示例):
/*将整个操作区设为20*10的二维数组,方块的移动可看成在数组中的移动,这样移动方块只
要将相应的数组元素改变颜色即可,方块的堆叠、消除等操作也是对数组的操作*/
typedef struct Block
{
int x, y; //用来记录每一格在屏幕上的坐标,后面绘制的时候用
COLORREF color;//该格当前颜色,背景色或方块颜色
BOOL isoccupy;//方块碰撞到边界或上一轮已停止的方块后状态修改为TRUE,为TRUE的方块在显示的时候用c_used 颜色绘制。
}BLOCK;
//
typedef struct Tetris //每个方块的坐标:x,y为方块在
{
//二维数组block[20][10]中的索引
int x;
int y;
}TETRIS;
COLORREF c_notused = RGB(138, 152, 116); //游戏背景没被方块占用时的颜色
COLORREF c_used = BLACK; //游戏背景被方块占用时的颜色
BLOCK block[20][10]; //整个游戏区是10X20的方块大小
TETRIS Tetrix_now[4]; //当前正在操作的方块
三、游戏的实现逻辑
1.界面绘制
游戏窗口如下:
(一)黄色背景是掌机的外壳,当初为了省事直接用github上的截图当背景
loadimage(&img, _T("rs\\bk.jpg"), 0, 0, false);//加载游戏背景图片
putimage(0, 0, W_WIDTH, W_LENGTH, &img, 0, 0, SRCCOPY);
(二)资源加载:
需要的图形资源都在一个文件中,icon.gif。因为小恐龙动画有左右不同方向的效果,而图片只有一个方向,所以需要将图片翻转,这里用的方法是直接操作图形缓冲区,然后逆拷贝,形成方向不同的两组图片。
//加载、初始化各种资源
void init_rs()
{
DWORD* dst;
DWORD* src;
int src_width;
int src_height;
loadimage(&img_icon, _T("rs\\icon.gif"), 0, 0, false);//加载小恐龙动画图片资源
SetWorkingImage(&img_icon);
for (int i = 0; i < 4; i++)
{
getimage(&dinosaur[i], i * 100, 98, 100, 88);//加载分割前四个画面
getimage(&dinosaur[i + 4], i * 100, 98, 100, 88);//初始化后四个画面,如果不初始化后面翻转操作会报错,因为指针为空
}
for (int i = 0; i < 4; i++)//小恐龙画面数组后四个画面是前四个的翻转
{
src = GetImageBuffer(&dinosaur[i]);
dst = GetImageBuffer(&dinosaur[i + 4]);
src_width = dinosaur[i].getwidth();
src_height = dinosaur[i].getheight();
for (int iy = 0; iy < src_height; iy++)
{
int dx = src_width - 1;
for (int ix = 0; ix < src_width; ix++)
{
dst[dx--] = src[ix];
}
dst += src_width;
src += src_width;
}
}
}
(三)
1. 中间的液晶屏幕是游戏显示的主要区域,分为左边的游戏区和右边的信息区。 游戏区的背景绘制完成后要保存,后面每次移动方块前都要刷新该区域。
2. 在二维数组区域内绘制20*10个方块,无填充正方形内绘制有填充正方形达到液晶效果。正方形的绘制过程也是二维数组每个元素坐标的计算过程,一并进行了初始化。这样在后面继续绘制背景和方块的时候直接从数组元素中取得坐标即可。
setlinecolor(c_notused);
setfillcolor(c_notused);
for (int x = 0; x < 20; x++)//绘制液晶区背景
{
for (int y = 0; y < 10; y++)
{
rectangle(ix + y * B_WIDTH + y * (Thickness + Interval), iy, ix + y * B_WIDTH + y * (Thickness + Interval) + B_WIDTH, iy + B_WIDTH);
solidrectangle(ix + y * B_WIDTH + y * (Thickness + Interval) + 3, iy + 3, ix + y * B_WIDTH + y * (Thickness + Interval) + B_WIDTH - 4, iy + B_WIDTH - 4);
block[x][y].x = ix + y * B_WIDTH + y * (Thickness + Interval);
block[x][y].y = iy;
block[x][y].color = c_notused;
block[x][y].isoccupy = FALSE;
}
iy += B_WIDTH + Thickness + Thickness;
}
getimage(&imgbk, 0, 0, W_WIDTH, W_LENGTH);//将界面备份以备后面刷新
信息区的显示比较复杂,而且要不停的更新,单独放在一个函数中,根据不同条件进行显示。其中时间的显示因为要每秒钟都闪烁一次时间分隔号“:”,我没有想到别的办法,只能把时间显示放在了单独的线程里,线程调用精确计时函数,每隔一秒钟循环显示一次时间和分隔号。
//线程,用于显示时间
DWORD WINAPI show_Time(LPVOID lpParam)
{
SYSTEMTIME t1;
int x, i;
while (1)
{
HpSleep(1000);//精确计时,每隔一秒显示一次时间,
GetLocalTime(&t1);
x = t1.wHour;//小时
if (x >= 10)
{
i = x / 10;
transparentimage(NULL, R_bx + 60, R_by - 31, &img_digit[i], 0xA1B187);
x %= 10;
transparentimage(NULL, R_bx + 76, R_by - 31, &img_digit[x], 0xA1B187);
}
else
{
transparentimage(NULL, R_bx + 60, R_by - 31, &img_digit[0], 0xA1B187);
x %= 10;
transparentimage(NULL, R_bx + 76, R_by - 31, &img_digit[x], 0xA1B187);
}
if ((timeinterval++ % 2) == 1)//小时与分钟相隔的冒号,明暗间隔显示
transparentimage(NULL, R_bx + 92, R_by - 31, &img_digit[11], 0xA1B187);
else
transparentimage(NULL, R_bx + 92, R_by - 31, &img_digit[12], 0xA1B187);
x = t1.wMinute;//分钟
if (x >= 10)
{
i = x / 10;
transparentimage(NULL, R_bx + 108, R_by - 31, &img_digit[i], 0xA1B187);
x %= 10;
transparentimage(NULL, R_bx + 124, R_by - 31, &img_digit[x], 0xA1B187);
}
else
{
transparentimage(NULL, R_bx + 108, R_by - 31, &img_digit[0], 0xA1B187);
x %= 10;
transparentimage(NULL, R_bx + 124, R_by - 31, &img_digit[x], 0xA1B187);
}
}
}
2.方块的操作
主循环:
while (!isend) //方块没有触顶上死亡就一直循环
{
Tetrix_next = rand() % 7; //随机生成下一个方块类型
next_block(Tetrix_next); //初始化下一个方块
show_info(!isend); //在信息区显示消息
while (!istouch) //方块没有触底或者碰撞其他已存在方块一直循环掉落
{
if (ln> 20) //消除行数大于10行
{
level = 2;
sleeptime = 500;
}
else if (ln> 50)
{
level = 3;
sleeptime = 400;
}
else if (ln> 80)
{
level = 4;
sleeptime = 300;
}
else if (ln> 100)
{
level = 5;
sleeptime = 200;
}
key = normal;
if (_kbhit()) //检测是否有键盘消息
KeyMsg(); //处理按键消息
while (MouseHit()) //检测是否有鼠标消息,不用while循环的话会错过鼠标消息,因为鼠标消息太多了,容易在鼠标消息缓冲区溢出去
MouseMsg(); //处理鼠标消息
moveblock(); //根据按键和鼠标点按移动方块
showblock(); //显示移动后的方块
rowcomplete(); //判断是否需要消除行,如满足条件消除相应的行
touchtop(); //判断方块是否触顶,是游戏死亡,退出循环
show_info(!isend); //在信息区显示消息
Sleep(sleeptime);
}
规则:按照从上到下,从左到右的顺序将4个方块进行编号:0、1、2、3,然后以此编号规则对7种类型方块进行初始化和后续的翻转等操作。在初始化的时候只要设定编号0方块的坐标,其他方块的坐标可以由彼此间位置关系得到,在进行翻转操作时也是同样的道理。例如当前正在操作的方块初始函数中:
void Init_Block()
{
int x, y;
t_type = Tetrix_next;//当前方块类型
t_dt = 0;//初始为水平方向
switch (t_type)
{
case I:
for (int x = 0; x < 4; x++)
{
Tetrix_now[x].x = 0;
Tetrix_now[x].y = x + 3;
}
break;
case J:
Tetrix_now[0].x = -1;
Tetrix_now[0].y = 4;
for (int x = 1; x < 4; x++)
{
Tetrix_now[x].x = 0;
Tetrix_now[x].y = x + 3;
}
break;
方块的移动:需要注意的是每次移动方块的时候需要清除移动前的方块,主要是清除颜色。
//清除当前方块,以备下一步移动方块
void cleartetris()
{
int x, y;
for (int i = 0; i < 4; i++)
{
x = Tetrix_now[i].x;
y = Tetrix_now[i].y;
if (x >= 0)
block[x][y].color = c_notused;
}
}
每次移动方块要提前判断是否越界和是否与其他落地的方块碰撞,否才移动,越界则不动,碰撞则停止,并修改占用状态,这里举向左移动为例:
//向左移动方块
void goleft()
{
int x, y;
int can_move = 1;
cleartetris();
for (int i = 0; i < 4; i++)
{
x = Tetrix_now[i].x;
y = Tetrix_now[i].y;
if ((y >= 1) && (block[x][y - 1].isoccupy == TRUE))//左侧有已占用方块不能继续向左移动
can_move = 0;
if (y == 0) //已经在边界,不能继续向左移动
can_move = 0;
}
if (can_move)
{
for (int i = 0; i < 4; i++)
{
Tetrix_now[i].y -= 1;//修改坐标,每个方块列减1
x = Tetrix_now[i].x;
y = Tetrix_now[i].y;
block[x][y].color = c_used;//修改颜色
}
}
else //不可移动,因为在一开始已经清除了方块,所以这里要恢复
{
for (int i = 0; i < 4; i++)
{
x = Tetrix_now[i].x;
y = Tetrix_now[i].y;
if (x >= 0)
block[x][y].color = c_used;
}
}
}
在加速下降过程中判断是否碰撞的函数,因为涉及到space键的加速下降,所以要监测不同的可能状态并返回优先级最高的检测,检测状态从0到2权值逐级降低。
//加速下降过程中判断是否碰撞
int touchdown()
{
int x, y;
int r[4];
int t;
for (int i = 0; i < 4; i++)
{
x = Tetrix_now[i].x;
y = Tetrix_now[i].y;
if (x < 18)
{
if (block[x + 1][y].isoccupy == TRUE)
r[i] = 0;
else if (block[x + 2][y].isoccupy == TRUE)
r[i] = 1;
else
r[i] = 2;
}
else if (x == 18)
r[i] = 1;
else
r[i] = 0;
}
t = r[0];
for (int i = 1; i < 4; i++)
if (r[i] < t)
t = r[i];
return t;
}
方块的翻转:根据当前方块状态来计算方块翻转后的坐标,设置一个变量
int t_dt; //当前方块方向,最多有三种方向
不同的方块构型,当前方向不一样翻转后也不一样,但因为彼此之间的位置关系很容易计算翻转后的坐标。struct Tetris里的x,y是当前四个方块中每一个方块在二维数组中的索引,翻转的计算只要找好一个翻转的轴心,修改轴心的索引,其他可由相对位置关系算得。例如:
case J:
if (t_dt == horz)//当前为水平方向,修改为垂直方向
{
t_dt = vert;
int x, y;
x = Tetrix_now[2].x; //保持第3个方块坐标保持
y = Tetrix_now[2].y; //保持第3个方块坐标保持
Tetrix_now[2].x = Tetrix_now[0].x; //旋转后第3个方块来到第1个方块的位置
Tetrix_now[2].y = Tetrix_now[0].y; //旋转后第3个方块来到第1个方块的位置
Tetrix_now[3].x = Tetrix_now[1].x; //旋转后第4个方块来到第2个方块的位置
Tetrix_now[3].y = Tetrix_now[1].y; //旋转后第4个方块来到第2个方块的位置
Tetrix_now[1].x = x - 2; //旋转后第2个方块,用与原第3个方块的相对位置计算
Tetrix_now[1].y = y - 1; //旋转后第2个方块,用与原第3个方块的相对位置计算
Tetrix_now[0].x = x - 2; //旋转后第1个方块,用与原第3个方块的相对位置计算
Tetrix_now[0].y = y; //旋转后第1个方块,用与原第3个方块的相对位置计算
}
3.行的消除
玩俄罗斯方块最重要的是要能消除填满的行并计算得分,由于最多从1可以到4行进行消除,就需要记录被消除行的起始和终止。判断每一行是否填满非常简单,只有测试每行10个的方块是否被占用就可以,10个都占用了该行消除掉。记录消除行的起止还可以用来实现消除效果和消除后上面的行掉落到下面,具体实现看代码://消除行,测试每一行是否全部被方块占满,并记录占满的起始行、终止行行号,并消除相应的行
void rowcomplete()
{
int i = 4; //动画效果循环计数
int rb = -1; //被占满的起始行行号
int re = 0; //被占满的终止行行号
int rn, l; //rn每一行被占据的方块个数
for (int x = 0; x < 20; x++)//对全部20行逐行进行从上到下的测试
{
rn = 0; //每次一行前将被占据的方块数计数清零
for (int y = 0; y < 10; y++)
{
if (block[x][y].isoccupy)
rn++; //当前行当前方块被占据,计数加1
}
if (rn == 10) //计数为10代表当前行被占满
{
re = x; //终止行被赋予当前行号
if (rb == -1)
rb = x; //第一次计数时,当前行被赋予起始行
}
}
if (rb >= 0) //如果起始行号大于等于0,代表有行被方块占满
{
playsound(s_row); //播放消除行效果音乐
while (i--) //按计数进行循环实现动画效果
{
for (int x = rb; x <= re; x++)
{
for (int y = 0; y < 10; y++)
{
setlinecolor(c_notused);
setfillcolor(c_notused);
rectangle(block[x][y].x, block[x][y].y, block[x][y].x + B_WIDTH, block[x][y].y + B_WIDTH);
solidrectangle(block[x][y].x + 3, block[x][y].y + 3, block[x][y].x + B_WIDTH - 4, block[x][y].y + B_WIDTH - 4);
}
}
Sleep(100);
for (int x = rb; x <= re; x++)
{
for (int y = 0; y < 10; y++)
{
setlinecolor(RGB(0, 255, 0));
setfillcolor(RGB(0, 255, 0));
rectangle(block[x][y].x, block[x][y].y, block[x][y].x + B_WIDTH, block[x][y].y + B_WIDTH);
solidrectangle(block[x][y].x + 3, block[x][y].y + 3, block[x][y].x + B_WIDTH - 4, block[x][y].y + B_WIDTH - 4);
}
}
}
//清除行的动画效果结束,清空被占据的行
for (int x = rb; x <= re; x++)
{
for (int y = 0; y < 10; y++)
{
setlinecolor(c_notused);
setfillcolor(c_notused);
rectangle(block[x][y].x, block[x][y].y, block[x][y].x + B_WIDTH, block[x][y].y + B_WIDTH);
solidrectangle(block[x][y].x + 3, block[x][y].y + 3, block[x][y].x + B_WIDTH - 4, block[x][y].y + B_WIDTH - 4);
block[x][y].color = c_notused;
block[x][y].isoccupy = FALSE;
}
}
l = re - rb + 1; //'l'被占据的行数
ln += l; //ln已经被消除的行数,被消除的行数增加计数
score += 100 * l;
//消除掉被占据的行后,对被占据的其实行以上的行进行重新绘制整理,实现上面的行整体位移到消除后空余空间的效果
for (int x = rb - 1; x >= 0; x--) //从被占据的行上一行开始循环
{
for (int y = 0; y < 10; y++) //对每行的每一个方块进行整理
{
if (block[x][y].isoccupy) //如果当前方块是被占据的方块
{
block[x][y].color = c_notused; //清除当前方块
block[x][y].isoccupy = FALSE;
block[x + l][y].color = c_used; //整体位移L距离
block[x + l][y].isoccupy = TRUE;
}
}
}
showblock();
}
}
后记
游戏音效:主要用MCI函数实现:
在资源加载的时候先把音效文件加载,并重命名为mysong方便后续操作。
mciSendString(L"open rs\\music.wav alias mysong", NULL, 0, NULL);//打开wav音乐文件,别名“mysong”
播放用mciSendString 函数实现,该函数中关键的是播放的起止时间。
void playsound(int type)
{
if (!ismusic)//是否关闭音效
{
switch (type)
{
case s_move:
mciSendString(L"play mysong from 2500 to 3000", NULL, 0, NULL);
break;
case s_rotate:
mciSendString(L"play mysong from 500 to 1000", NULL, 0, NULL);
break;
case s_space:
mciSendString(L"play mysong from 1000 to 1500", NULL, 0, NULL);
break;
case s_end:
mciSendString(L"play mysong from 8000 to 9000", NULL, 0, NULL);
break;
case s_start:
mciSendString(L"play mysong from 3000 to 7000", NULL, 0, NULL)