在C语言的学习中,我们可以通过复刻一些小游戏来加深自己对于知识的理解。
今天给大家带来的是一款经典小游戏,扫雷。
在进行写代码之前,我们要先进入easyx的官网下载easyx图库,基于该图库可以进行可视化操作。
一、建立地图模型
我们今天做的是最简单的9*9类型的地图。对于地图的属性和状态,分别有以下几种:
未知,清除,爆炸,排除。其中,清除状态就是将没有雷的区域点亮,排除就是在已知有雷的区域插上小红旗。为此我们可以创建一个结构体来定义这些信息
struct map {
int type;//0代表没有雷,1代表雷,2代表墙
int number;//表示附近有几个雷
int light;
int showed;//0代表未知,1代表清除,2代表旗子
};
这里的light属性做一下说明,这个是用来制作高亮效果的。按钮高亮效果的加入可以增加反馈感,提升游戏体验。
高亮效果展示
接着定义一个二维数组储存这些结构体变量
map maps[ROW+2][COL+2];
这里加了两行墙体,便于处理数据越界问题。
然后,还要写两个测试函数,用来分别检测每个格子的状态,这是前期开发很重要的工具。虽然最后我们会丢弃这两个函数,但它们还是很有必要的。
void test_type() {
for (int i = 0; i < ROW + 2; i++) {
for (int j = 0; j < ROW + 2; j++) {
printf("%d ", maps[i][j].type);
}
printf("\n");
}
}
void test_number() {
for (int i = 1; i < ROW + 1; i++) {
for (int j = 1; j < ROW + 1; j++) {
printf("%d ", maps[i][j].number);
}
printf("\n");
}
}
二、游戏的初始化
主要操作有创建窗口,设置背景颜色,设置文字样式,布置墙区和雷区,设置每个格子的数字,导入图片等
void gameInit() {
initgraph(432, 592);
setbkcolor(WHITE);
memset(maps, 0, sizeof(maps));
setlinecolor(BLACK);
setbkmode(TRANSPARENT);
settextcolor(BLACK);
LOGFONT f;
gettextstyle(&f);
f.lfHeight = 45;
f.lfWeight = 15;
strcpy(f.lfFaceName, "Segoe UI Black");
f.lfQuality = ANTIALIASED_QUALITY;
settextstyle(&f);
for (int i = 0; i < ROW + 2; i++) {
maps[0][i].type = 2;
maps[ROW + 1][i].type = 2;
maps[i][0].type = 2;
maps[i][ROW + 1].type = 2;
}
int row;
int col;
int mineNumber = 10;
while (mineNumber) {
row = rand() % ROW + 1;
col = rand() % COL + 1;
if (maps[row][col].type == 0);
{
maps[row][col].type = 1;
mineNumber--;
}
}
for (int i = 1; i < ROW + 1; i++) {
for (int j = 1; j < COL + 1; j++) {
if (maps[i][j].type == 0) {
if (maps[i - 1][j - 1].type == 1)maps[i][j].number++;
if (maps[i][j - 1].type == 1)maps[i][j].number++;
if (maps[i][j + 1].type == 1)maps[i][j].number++;
if (maps[i + 1][j + 1].type == 1)maps[i][j].number++;
if (maps[i - 1][j + 1].type == 1)maps[i][j].number++;
if (maps[i + 1][j - 1].type == 1)maps[i][j].number++;
if (maps[i - 1][j].type == 1)maps[i][j].number++;
if (maps[i + 1][j].type == 1)maps[i][j].number++;
}
}
}
for (int i = 0; i < 21; i++) {
sprintf_s(name, sizeof(name), "explode\\%d.png", i + 1);
loadimage(&imgExplode[i], name, 48, 48);
}
loadimage(&imgZhanling, "zhanling.png",48,48);
}
这里有一个开发小技巧。在initgraph函数中,如果多传入一个参数,例如initgraph(WIDTH,HEIGHT,1)可以在打开窗口的同时打开控制台,这样就可以在控制台里调试了。
除此之外,还需要建立游戏状态参量gameState,用来判断输赢
三、建立用户交互
扫雷游戏的用户交互就是鼠标点击。游戏逻辑是判断用户点击的格子有没有雷,如果有就引爆,游戏结束,如果没有,显示当前区域的数字。
void userClick() {
ExMessage msg;
if (peekmessage(&msg)) {
if (msg.message == WM_RBUTTONDOWN &&
msg.x > 0 && msg.x < 432 && msg.y>160 && msg.y < 592) {
curX = msg.x;
curY = msg.y;
row = curX / 48 + 1;
col = (curY - 160) / 48 + 1;
maps[row][col].showed = (maps[row][col].showed+2)%4;
}
if (msg.message == WM_LBUTTONDOWN &&
msg.x > 0 && msg.x < 432 && msg.y>160 && msg.y < 592) {
curX = msg.x;
curY = msg.y;
row = curX / 48 + 1;
col = (curY - 160) / 48 + 1;
maps[row][col].light = 1;
}
if (msg.message == WM_LBUTTONUP) {
if (msg.x > (row - 1) * 48 && msg.y > (col - 1) * 48 + 160
&& msg.x < row * 48 && msg.y < col * 48 + 160) {
if (maps[row][col].type == 0 && maps[row][col].showed == 0)
lianJispector(row, col);
if (maps[row][col].type == 1) {
gameState = FAIL;
}
flag = true;
for (int i = 1; i < ROW + 1&&flag; i++) {
for (int j = 1; j < COL + 1&&flag; j++) {
if (maps[i][j].showed == 0 && maps[i][j].type == 0) {
flag = false;
}
}
}
}
maps[row][col].light = 0;
}
}
}
第一块函数做的是插旗子的判断。我们希望用户右键点击格子出现旗子,再次点击旗子消失,可以妙用%符号。
第二块函数显示高亮效果
第三块函数用于炸弹检测和连击检测
第四块函数用于胜利条件判断
接下来进行逐个讲解
1)判断旗子
当鼠标右键指定区域后,将会产生判断,生成右键点击的坐标位置
其中maps[row][col].showed的值为2时插上小旗子,值为0恢复默认
那么我们就可以通过这样一个语句来实现多次点击在两个状态之间的切换
maps[row][col].showed = (maps[row][col].showed+2)%4;
2)显示高亮
坐标的判断跟第一块函数相同,只是增加了左键单击高亮的设置效果。并且在最后把高亮属性设置为零。这样做的好处是,当用户按住左键移除原来的区域时,并不会判断这个区域的清理。
比如我在最后一秒后悔了,不想点击这个区域,左键不上抬移除鼠标位置,就可以取消操作。
3)连击判定和失败判定
一旦我们点击了炸弹,就把gameState设置为FAIL,这样可以方便之后的处理。在经典扫雷中,有时候会出现玩家点击一个区域亮一篇的情况,这个效果可以用递归函数来实现。
博主代码中的lianJispector就是这样一个判定函数,具体实现如下
void lianJispector(int i, int j) {
if (i != 0 && i != ROW + 1 && j != 0 && j != COL + 1) {
maps[i][j].showed = 1;
if (maps[i][j].number == 0) {
if (maps[i - 1][j].type == 0 && maps[i - 1][j].showed == 0)lianJispector(i - 1, j);
if (maps[i][j - 1].type == 0 && maps[i][j - 1].showed == 0)lianJispector(i, j - 1);
if (maps[i + 1][j].type == 0 && maps[i + 1][j].showed == 0)lianJispector(i + 1, j);
if (maps[i][j + 1].type == 0 && maps[i][j + 1].showed == 0)lianJispector(i, j + 1);
if (maps[i + 1][j + 1].type == 0 && maps[i + 1][j + 1].showed == 0)lianJispector(i + 1, j + 1);
if (maps[i - 1][j - 1].type == 0 && maps[i - 1][j - 1].showed == 0)lianJispector(i - 1, j - 1);
if (maps[i - 1][j + 1].type == 0 && maps[i - 1][j + 1].showed == 0)lianJispector(i - 1, j + 1);
if (maps[i + 1][j - 1].type == 0 && maps[i + 1][j - 1].showed == 0)lianJispector(i + 1, j - 1);
}
}
}
水平有限,不会循环,只能8个方向挨个来一遍了
这个函数的逻辑是,如果相邻格子没有雷并且是未知状态,就点亮它。
写这个函数不当,程序很容易崩溃,原因是如果不设置未知状态的条件,递归会传递无限次。
4)胜利条件
先设置flag=true;遍历每个格子,如果存在未知状态并且没有雷的格子,就退出循环,设置flag=false。直到经过循环后flag仍然为true,flag会用于gameState=VICTORY的判断。
四、渲染格子、动画
目前我们只是将扫雷游戏的逻辑进行了数据化。要真正将其放映出来,需要渲染模块
void updateWindow() {
BeginBatchDraw();
cleardevice();
drawMines();
drawLight();
drawNumbers();
if (flag)gameState = VICTORY;
FlushBatchDraw();
}
这里是渲染模块的基本框架,同时把上节所说的gameState判断加到了这里。
其中BeginBatchDraw和FlushBatchDraw是批量绘图函数,cleardevice是清屏函数。通过清屏来更新屏幕,批量绘图来防止画面闪烁。
1)drawMines绘制格子
void drawMines() {
setfillcolor(RGB(83, 225, 245));
for (int i = 1; i < ROW + 1; i++) {
for (int j = 1; j < COL + 1; j++) {
fillrectangle((i - 1) * 48, (j - 1) * 48 + 160, i * 48, j * 48 + 160);
}
}
}
效果是这样的
2)drawLight绘制高亮
void drawLight() {
setfillcolor(RGB(152, 255, 255));
for (int i = 1; i < ROW + 1; i++) {
for (int j = 1; j < COL + 1; j++) {
if (maps[i][j].light == 1) {
fillrectangle((i - 1) * 48, (j - 1) * 48 + 160, i * 48, j * 48 + 160);
}
}
}
}
高亮显示当前选中区域
3)drawNumbers绘制数字、旗子和已清扫区域
void drawNumbers() {
setfillcolor(RGB(240, 134, 280));
for (int i = 1; i < ROW + 1; i++) {
for (int j = 1; j < COL + 1; j++) {
if (maps[i][j].showed == 1) {
fillrectangle((i - 1) * 48, (j - 1) * 48 + 160, i * 48, j * 48 + 160);
if (maps[i][j].number != 0) {
sprintf(name, "%d", maps[i][j].number);
outtextxy((i - 1) * 48 + 15, (j - 1) * 48 + 160, name);
}
}
if (maps[i][j].showed == 2) {
putimage((i - 1) * 48 , (j - 1) * 48 + 160, &imgZhanling);
}
}
}
}
五、失败动画
原版扫雷在我们点击到炸弹的时候,所有炸弹都会爆炸。我们也来尝试复刻这一效果。
if (gameState == FAIL) {
for (int i = 0; i < 21; i++) {
Sleep(50);
BeginBatchDraw();
row = curX / 48 + 1;
col = (curY - 160) / 48 + 1;
putimage((row - 1) * 48, (col - 1) * 48 + 160, &imgExplode[i]);
FlushBatchDraw();
}
for (int i = 1; i < ROW + 1; i++) {
for (int j = 1; j < COL + 1; j++) {
if (row != i && j != col && maps[i][j].type==1) {
for (int k = 0; k < 21; k++) {
Sleep(10);
BeginBatchDraw();
putimage((i - 1) * 48, (j - 1) * 48 + 160, &imgExplode[k]);
FlushBatchDraw();
}
}
}
}
首先是玩家当前点击的那个炸弹爆炸,帧间隔设置为50毫秒
之后遍历每个格子,将所有其他炸弹都引爆,帧间隔设置为10毫秒
这样就完成了失败动画
六、总结
至此我们的扫雷游戏制作就结束啦!不过还有一些可以优化的地方,比如自选难度,设置计时等,还有一些可以提高用户体验的小细节,比如第一个点击区域一定不是雷等等,等待着你们去探索啦!