身为一个大一学生,我将吃豆人小游戏作为我的答辩项目,代码写的比较复杂,只是记录一下Easyx的使用心得,记录一下我做的第一个游戏。素材全是我自己手绘的,然后代码由自己研究,目前还存在一些问题(不是玩法上的)。
在此之前我没写过博客,而且这是我在完成这个项目之后写的,很多东西都是以一种完成了的角度去看的,所以并不能给一种从无到有的那种感觉,其实跟拿着完整版代码念差不多。
开始这个项目的时候首先考虑了以下几个问题:菜单、地图、主循环、角色移动、怪物搜索、胜负判断以及游戏设置。着手制作的时候按照这个顺序来,一步一步实现就不会有太大的问题。
Tips1:做这种很长的项目,建议分成几个部分来做
Tips2:关于新建图形化窗口的问题,不再赘述
菜单(导入图片操作)
导入素材
我用的VS2022,使用Easyx首先要把需要的素材导入这个项目的文件夹
如果和我一样是用VS的话,直接点这里就好了,或者点开你的项目存放的地方,进入项目名称直接就是资源文件夹了。
然后就要把图片导入图形窗口,代码如下:
int main()
{
initgraph(1280, 720);
IMAGE menu;
loadimage(&menu, "./Menu.png", 525, 675, true);
putimage(377, 23, &menu);
while (1);
closegraph();
return 0;
}
创建IMAGE类型变量
IMAGE是变量名称。Easyx库里的,用来存储图片文件,就把你要用的图片当作变量一样调用就OK了。
loadimage()
loadimage()是导入图片的函数。
第一个参数是目标变量的地址。建议把变量名称和要导入的图片名称搞成一样的,写的时候比较好对应,我这个是因为封装了Menu()函数占用了名称才这么干的;
第二个参数是导入图片的地址。可以选用绝对地址和相对地址,绝对地址比较麻烦我这里选用相对地址,相当于是直接键入图片名称,很多人第一次使用会发现这里报错,这个要去设置里面改一个东西。如下图所示:
把这里改成“使用多节字符集”就好了。
后面三个参数分别是导入图片选取的大小,bool类型的意思是是否允许改变原图的某些格式吧,我没用过,因为我是按照像素来调整画布大小,刚好放进去的。
putimage()
前两个参数是图片左上角要对准的坐标,第三个参数是要放置的图片的地址,很好理解。
最终的效果
鼠标交互操作
有了菜单画面,然后就要对点击事件进行处理了。之前有一点我忘了说,就是我这个菜单是在用线条画了框框之后再向上面添加按钮“字”的,也就是说我的“开始游戏”,“退出”按钮的位置,是事先用线条画过一遍,包括我上面代码块里的数据,也是计算得来的。
要对按钮的像素位置有个大概的数据才能做鼠标点击,这一点我就不讲了。
上模板
ExMessage msg;
while (1)
{
if (peekmessage(&msg, EX_MOUSE))
{
if (msg.message == WM_LBUTTONDOWN)
{
if (msg.x >= balebala && mag,y >= balabala)
Game();
}
}
}
ExMessage
封装的类,谁封装的我不记得了。
peekmessage()
第一时间读取目标类型,也就是第二个参数,如果是EX_MOUSE就是等待读取鼠标信息,还有其他类型,具体参见:PeekMessageA 函数 (winuser.h) - Win32 apps | Microsoft Learn
WM_LBUTTONDOWN
L —— 左
BUTTON —— 按钮
DOWN —— 下
EX_MOUSE + LBUTTONDOWN —— 鼠标左键按下
msg.x 和 msg.y
表示收到信息的时候,鼠标点击位置在坐标系上的坐标,一般把坐标限制在某个矩形范围内,不就是表示一个按钮吗,所以之前就说要先算好距离和位置,要不然到这一步根本做不了。
Game()
写一个函数接手这个判断就可以了
到这里为止,菜单就搭建完毕了。
地图
我的地图是用的一种最笨的方法,人力复制而成,虽然麻烦,但是优点是碰撞逻辑很好写,可以直观的转化为数组上元素的相邻关系,我的Peas游戏的像素和数组元素比为25:1,但是强烈建议使用偶数比,奇数比几乎无法调整速度差!下手写了就知道。
HIGH和LONG是我定义的宏。
int mp[HIGH][LONG] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,3,3,3,3,3,3,3,3,3,0,3,3,3,3,3,3,3,3,3,0,
0,3,0,0,0,3,0,0,0,3,0,3,0,0,0,3,0,0,0,3,0,
0,3,0,0,0,3,0,0,0,3,0,3,0,0,0,3,0,0,0,3,0,
0,3,0,0,0,3,0,0,0,3,0,3,0,0,0,3,0,0,0,3,0,
0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
0,3,0,0,0,3,0,3,0,0,0,0,0,3,0,3,0,0,0,3,0,
0,3,0,0,0,3,0,3,0,0,0,0,0,3,0,3,0,0,0,3,0,
0,3,3,3,3,3,0,3,3,3,0,3,3,3,0,3,3,3,3,3,0,
0,0,0,0,0,3,0,0,0,3,0,3,0,0,0,3,0,0,0,0,0,
0,0,0,0,0,3,0,3,3,3,3,3,3,3,0,3,0,0,0,0,0,
0,0,0,0,0,3,0,3,0,3,4,3,0,3,0,3,0,0,0,0,0,
0,0,0,0,0,3,0,3,0,3,5,3,0,3,0,3,0,0,0,0,0,
0,3,3,3,3,3,3,3,0,3,2,3,0,3,3,3,3,3,3,3,0,
0,0,0,0,0,3,0,3,0,0,0,0,0,3,0,3,0,0,0,0,0,
0,0,0,0,0,3,0,3,3,3,3,3,3,3,0,3,0,0,0,0,0,
0,0,0,0,0,3,0,3,0,0,0,0,0,3,0,3,0,0,0,0,0,
0,0,0,0,0,3,0,3,0,0,0,0,0,3,0,3,0,0,0,0,0,
0,3,3,3,3,3,3,3,3,3,0,3,3,3,3,3,3,3,3,3,0,
0,3,0,0,0,3,0,0,0,3,0,3,0,0,0,3,0,0,0,3,0,
0,3,3,3,0,3,3,3,3,3,1,3,3,3,3,3,0,3,3,3,0,
0,0,0,3,0,3,0,3,0,0,0,0,0,3,0,3,0,3,0,0,0,
0,0,0,3,0,3,0,3,0,0,0,0,0,3,0,3,0,3,0,0,0,
0,3,3,3,3,3,0,3,3,3,0,3,3,3,0,3,3,3,3,3,0,
0,3,0,0,0,0,0,0,0,3,0,3,0,0,0,0,0,0,0,3,0,
0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
};
打印地图
void UpdataMap()
{
//地图加载
int k = 5;
while (k--)
{
cleardevice();
for (int i = 0, y = 23; i < 27; i++, y += 25)
{
for (int j = 0, x = 377; j < 21; j++, x += 25)
{
switch (mp[i][j])
{
case Edge:
putimage(x, y, &Wall);
break;
case Ghost:
putimage(x, y, &Ghost1);
break;
case Yuan:
putimage(x, y, &Original);
break;
case Bottle:
putimage(x, y, &Medicinal);
break;
default:
break;
}
}
}
//逐渐修正吃豆人位置
Playerx += DirX[dirt];
Playery += DirY[dirt];
//吃豆人
switch (dirt)
{
case 0:
Sleep(10);
i++;
putimage(Playerx, Playery, &Peas[3][i % 3]);
break;
case 1:
Sleep(10);
i++;
putimage(Playerx, Playery, &Peas[2][i % 3]);
break;
case 2:
Sleep(10);
i++;
putimage(Playerx, Playery, &Peas[1][i % 3]);
break;
case 3:
Sleep(10);
i++;
putimage(Playerx, Playery, &Peas[0][i % 3]);
break;
case 4:
putimage(Playerx, Playery, &Peas[3][0]);
break;
}
FlushBatchDraw();
}
}
Playerx和Playery是玩家在像素点上的坐标,我将每一次Player和Ghost位置的变换打印五次,是为了呈现玩家慢慢移动的效果,但是对于Ghost就没有这种效果,因为真的太麻烦了,每次移动都要拖着四个变量,并且Ghost还有特殊的传送功能,后文会提到。
switch(dirt)是调整吃豆人的状态,原本我是想让吃豆人一边嘴巴张合一边随着移动方向转动面向,后来写代码的时候把定义的i给忘了,但这个张合雀食没有必要,但是删掉的话也很麻烦,要对Peas的数组进行改动,删一行就要爆红。
主循环
主循环是游戏的运行思路,非常重要。因为我是第一次写没有经验,中间因为思路没有理清楚,不同的函数之间相互调用十分混乱,后来用主循环才把代码整理清楚。
ReCreatActor();//重新创建数据
while (1)
{
PlayerMove();//玩家移动
GhostSearch();//怪物搜索
UpdataMap();//加载地图
}
这是我的主循环,封装在Game()函数里面,仅供参考。
角色移动(键盘操作)
头文件:<conic.h>
键盘操作接受的是一个char类型的字符。比较简单。
char key = _getch();
if (key >= 'A' && key <= 'Z')key += 32;
switch (key)
{
case 'w'://上
break;
case 's'://下
break;
case 'a'://左
break;
case 'd'://右
break;
这里我设置了两个方向数组
int DirX[5] = { 5,0,-5,0 ,0 };
int DirY[5] = { 0,5,0,-5 ,0 };//传入0为右,传入1为下,传入2为左,传入3为上
方向数组建议数值为+-1和0,我设置为5最开始是为像素考虑,后来发现数组地图用这玩意用的更多,导致我后面很多代码都加了/5,因为忘记了/5也折腾了很久,不要模仿啊。
然后键盘方向就可以改成这个样子。注意,getch()是控制台函数,不知道为什么我这个游戏即使在打包了之后仍然需要在控制台输入才能让玩家移动,使用ExMessage类型的peekmessage()函数应该可以不调用控制台实现输入,但是我试了一下发现我不会,如果不是图简单不要像我这样写。
char key = _getch();
if (key >= 'A' && key <= 'Z')key += 32;
switch (key)
{
case 'w'://上
dirt = 3;
break;
case 's'://下
dirt = 1;
break;
case 'a'://左
dirt = 2;
break;
case 'd'://右
dirt = 0;
break;
碰撞逻辑
就是对当前玩家坐标加上方向数组后的位置进行判断,也是一个switch的事情,我展示一下我写的。这一块每个人都有各自的写法,包括游戏玩法也有很大影响。
#define Player 1
#define Ghost 2
#define Yuan 3
#define Bottle 4
#define Empty 5
#define Edge 0
//以下是函数的一个片段
switch (mp[PlayerMapY + DirY[dirt] / 5][PlayerMapX + DirX[dirt] / 5])//表示玩家即将前往的格子
{
case Ghost:
Lose();
break;
case Yuan:
ChangePlace();
Score++;
if (Score == 237 - BottleNum)
{
Win();
}
break;
case Bottle:
ChangePlace();
mp[GhostY][GhostX] = PassSomething;
GhostY = rand() % 27;
GhostX = rand() % 21;
while (mp[GhostY][GhostX] == Edge || abs(GhostX - PlayerMapX) + abs(GhostY - PlayerMapY) < 8)
{
GhostY = rand() % 27;
GhostX = rand() % 21;
}
PassSomething = mp[GhostY][GhostX];
mp[GhostY][GhostX] = Ghost;
break;
case Empty:
ChangePlace();
break;
case Edge:
dirt = 4;
break;
}
我觉得有必要对上述代码进行一些解释,不然根本看不懂。
碰见Ghost就判负。
碰见Yuan就增加分数并且删除当前位置的Yuan图标,再判断一下分数是否达标,然后判断胜利。
碰见Edge和Empty就比较容易看懂,这俩分别是墙体和空的宏。
至于这个碰见Bottle,这是我设置的一个道具,也是魔改的一个点,吃掉之后可以将Ghost随机传送到地图上的其他点位。PassSomething是Ghost所在的位置被Ghost覆盖着的东西,在Ghost移动过走之后必须要将其放回原位;Ghost落点的判断第一个是不能落到墙上,第二个是与玩家的曼哈顿距离得大于8,不然可能会跳脸直接寄,至于PassSomething是怎么交换到Ghost离开的位置的,我觉得还比较易懂。
怪物搜索
众所周知,经典的吃豆人小游戏是四个怪围绕着玩家,玩家可以吃大豆子然后反过去吃怪物。由于最初的01搜索模式因为莫名其妙的bug一直死循环,我选择了A*算法,让怪物全图锁定玩家,但是怪物的数量减少,并且移除特定情况下怪物远离玩家的特性,改成全图随机传送,这样可以让游戏变得相对容易被写出来。
A*算法
定义一个类,在类里面定义几个量:当前位置的xy坐标,上一个位置的xy坐标,当前点是否已被探索以及当前点位距离玩家的曼哈顿距离(别问我为啥这么喜欢这个,问就是简单好用),然后在类里面写一个友元函数,如果是A*的话照着写就可以,优先队列用的。写出了这个友元函数之后就可以把A*当BFS来写了,只是多一个回溯路径而已。
class Bath
{
public:
int X = 0;
int Y = 0;//指向当前位置
int x = 0;
int y = 0;//指向前一个格子
int Mhd = 0;//曼哈顿距离
bool Pass = false;
friend bool operator <(Bath a, Bath b)
{
return a.Mhd > b.Mhd;
}
};
void GhostSearch()
{
//A*搜索
{
bool out = false;
Bath Point[HIGH][LONG];
Point[GhostY][GhostX].Pass = true;
Point[GhostY][GhostX].x = GhostX;
Point[GhostY][GhostX].X = GhostX;
Point[GhostY][GhostX].y = GhostY;
Point[GhostY][GhostX].Y = GhostY;
Point[GhostY][GhostX].Mhd = abs(GhostX - PlayerMapX) + abs(GhostY - PlayerMapY);
priority_queue<Bath> AStar;
AStar.push(Point[GhostY][GhostX]);
Bath cnt;
while (!AStar.empty())
{
auto temp = AStar.top();
AStar.pop();
for (int k = 0; k < 4; k++)
{
if ((mp[temp.Y + DirY[k] / 5][temp.X + DirX[k] / 5] != Edge || (temp.Y + DirY[k] / 5 == 13 && temp.X + DirX[k] / 5 == 0) || (temp.Y + DirY[k] / 5 == 13 && temp.X + DirX[k] / 5 == 20)) && Point[temp.Y + DirY[k] / 5][temp.X + DirX[k] / 5].Pass == false)
{
if (temp.X == PlayerMapX && temp.Y == PlayerMapY)//已经搜索到,开始回溯
{
out = true;
break;
}
Point[temp.Y + DirY[k] / 5][temp.X + DirX[k] / 5].x = temp.X;
Point[temp.Y + DirY[k] / 5][temp.X + DirX[k] / 5].X = temp.X + DirX[k] / 5;
Point[temp.Y + DirY[k] / 5][temp.X + DirX[k] / 5].y = temp.Y;
Point[temp.Y + DirY[k] / 5][temp.X + DirX[k] / 5].Y = temp.Y + DirY[k] / 5;
Point[temp.Y + DirY[k] / 5][temp.X + DirX[k] / 5].Pass = true;
Point[temp.Y + DirY[k] / 5][temp.X + DirX[k] / 5].Mhd = abs(temp.X + DirX[k] / 5 - PlayerMapX) + abs(temp.Y + DirY[k] / 5 - PlayerMapY);;
AStar.push(Point[temp.Y + DirY[k] / 5][temp.X + DirX[k] / 5]);
}
}
if (out == true)
{
cnt.x = temp.x;
cout << "?";
cnt.y = temp.y;
break;
}
}
cout << "!";
//回溯搜索路径
auto temp = Point[cnt.y][cnt.x];
if (temp.x == GhostX && temp.y == GhostY)
{
MessageBox(0, "怪物追上了你,你寄了", " ", MB_OK);
Lose();
}
else
{
while (!(temp.x == GhostX && temp.y == GhostY))
{
temp = Point[temp.y][temp.x];
}
//Ghost移动
if (mp[temp.Y][temp.X] == Player)
{
MessageBox(0, "怪物追上了你,你寄了", " ", MB_OK);
Lose();
}
else
{
mp[GhostY][GhostX] = PassSomething;
PassSomething = mp[temp.Y][temp.X];
mp[temp.Y][temp.X] = Ghost;
GhostY = temp.Y;
GhostX = temp.X;
}
}
}
}
这段代码很难看懂,因为我为了省事,甚至不愿意多设几个变量来替换那些很常用的数值,背一个BFS的板子就很好写出来了,但是在实际采用的过程中会有几个bug:比如说玩家进死胡同里面,怪物紧跟着,这个时候A*的优先队列会空然后造成运行时错误,原因是因为玩家在怪物脸上得单独考虑这种情况。
MessageBox()
这是召唤弹窗的函数,好像不需要特别引头文件,我只说下使用方法。
MessageBox(0,"弹窗中的内容","弹窗名称",弹窗提示文本);
值得注意的是弹窗提示文本,这是我自己起的名,意思就是说是给你显示什么可以点选的选项,比如“确定”啊,“是”和“否”啊之类的。“确定”就是MB_OK,“是否”就是MB_YESNO,其他的参见
MessageBox 函数 (winuser.h) - Win32 apps | Microsoft Learn
总结
其他的其实没啥好说的了,胜负判断都已经写在PlayerMove()和GhostSearch()里面了。我就分享一下这个应用程序吧,欢迎游玩(虽然只有一关)
链接:https://pan.baidu.com/s/1xgZc9mq2i56FuAi29A7W9w?pwd=vn5v
提取码:vn5v