macOS下用C语言在75行实现简易版贪吃蛇(写的天旋地转)

之前在知乎看见的链接在这里➡️用C语言,能在100行之内实现贪吃蛇吗?%%%,大一c++课程作业也是贪吃蛇来着,不过那个代码可长可长hhhhhh而且是在windows平台下的,主要是考察链表结构?

知乎里面好些都用了ncurses库,这个库用来编写独立于终端的基于文本的用户界面,不过我还是想偷懒不想安装别的库,所以写一个只使用标准库的,参考了知乎Milo Yip的超清思路,只在macOS上试过,写交互的的时候真的感觉没学过C一样,,一无所知水平受限可能有哪里理解的不对,欢迎指出QWQ


0 代码

总共有75行,其实有些可以再压缩一下,但是没有必要啦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include<iostream>
#include<cstdio>
#include<time.h>
#include<unistd.h>
#include<sys/select.h>
#include<termios.h>
#define W 64
#define H 20
int mapp[W*H],snake[W*H],pos,npos,change=1,food,head=0,len=1,ch=67;
struct termios orig_termios;
void reset_terminal_mode(){
tcsetattr(0, TCSANOW, &orig_termios);
}
void set_conio_terminal_mode(){
struct termios new_termios;
tcgetattr(0, &orig_termios);
memcpy(&new_termios, &orig_termios, sizeof(new_termios));
new_termios.c_lflag&=~(ICANON|ECHO);
atexit(reset_terminal_mode);
tcsetattr(0, TCSANOW, &new_termios);
}
int kbhit(){
struct timeval tv = { 0L, 0L };
fd_set fds;
FD_ZERO(&fds);
FD_SET(0, &fds);
return select(1, &fds, NULL, NULL, &tv);
}
int main(){
set_conio_terminal_mode();
srand(time(NULL));
for(int i=0;i<W*H;i++)mapp[i]=((i/W%(H-1)==0)||(i%W%(W-1)==0))?1:0;//board
snake[0]=pos=64*10+32;//init the position of snake
mapp[pos]=1;
npos=pos+1;//define the first step
do{food=rand()%(W*H);}while(mapp[food]==1);//create food
while(1){
while(!kbhit()){//not press
if(mapp[npos]==1)return 0;//gg
head=(head+1)%(W*H);//snake move
snake[head]=npos;
mapp[npos]=1;
if(npos==food){
do{food=rand()%(W*H);}while(mapp[food]==1);//eat then create food
++len;
}
else{//clear the tail of the snake
int tail=(head+W*H-len)%(W*H);
mapp[snake[tail]]=0;
}
pos=npos;//this step is ok
for(int i=0;i<W*H;i++){//show
if(i==food){
printf("*");
continue;
}
printf("%c",mapp[i]==0?' ':'#');
if(i%W==(W-1))printf("\n");
}
printf("score: %d\n",len-1);
printf("\033[1;1H\033[2J");
usleep(abs(change)>1?250000-len*200:125000-len*100);
npos=pos+change;
}
if((ch=getchar())!=27)continue;
if((ch=getchar())!=91)return 0;
ch=getchar();// get direction
if(ch==65)change=-W;
else if(ch==66)change=W;
else if(ch==67)change=1;
else if(ch==68)change=-1;
else change=1;
}
return 0;
}

1 运行效果

能吃能跑能撞墙_(:з」∠)_

2 思路

2.1 建图及建蛇

2.1.1 建图

定义地图的宽W/高H,实际可移动的范围是(0,64)(0,20),而第0行/19行/0列/63列是边界

mapp数组用来存图,mapp[i]==0表示无障碍,mapp[i]==1表示有障碍,有障碍的地方就是蛇身和边界

1
2
3
#define W 64
#define H 20
int mapp[W*H];

抛弃二维的思路,直接用一维,坐标(x,y)表示成pos,pos=W*x+y,那么就可以获得地图的边界条件

  • 左右边界:pos/W%(H-1)==0,即第0列和(W-1)列
  • 上下边界:pos%W%(W-1)==0,即第0行和(H-1)行

蛇身就是蛇的初始位置,那个位置也设置为1

2.1.2 建蛇

开一个数组表示蛇,蛇最长就是(W-1)*(H-1)那么长,如果用链表结构的话很方便的可以进行插入的操作,但是代码量也会大大增加,所以直接用数组记录蛇的每一节的坐标

snake[i]表示蛇的某一节(不一定是第i节,这个数组是循环利用的,见蛇的移动部分)

2.2 食物的生成

随机数生成食物,food_pos=rand()%(W*H)就可以获得一个随机的位置,判断食物位置不合法的条件就是m[food_pos]==1,那么写一个do while循环就可以在一行实现

生成食物只需要在两个地方调用,一次是初始化部分调用一次,还有是在食物被吃了的时候调用

2.3 蛇的移动

2.3.1 上下左右

因为已经把问题投影到一维地图上了,所以上下左右带来的坐标变化量change也投影到一维上:

  • 上:(x,y)->(x-1,y) change=-W
  • 下:(x,y)->(x+1,y) change=W
  • 右:(x,y)->(x,y+1) change=1
  • 左:(x,y)->(x,y-1) change=-1

新的蛇头坐标就是npos=pos+change,然后需要判断这一步npos是否可行

2.3.2 吃或没吃或死

移动一步后npos位置总共面临三种情况:碰到蛇身或边界死亡/吃食物/没吃食物可以继续移动

碰到蛇身或边界死亡

直接判断新位置的m[npos]是否为0即可,死亡肯定是最先判断的

没吃食物可以继续移动

继续循环,这就要涉及到那个保存蛇位置的snake数组了,我觉得这里有点用到滚动数组的思想

我设置了一个head记录蛇的头的索引号,头的位置就保存在snake[head]

len记录蛇的长度,同时这个量也可以表示蛇头索引到蛇尾索引的偏移,也可以输出得分

每次有了新的蛇头后,就把新蛇头的坐标记录在snake[(head+1)%(W*H)]中,那么如果没有吃食物的话,除了蛇尾的位置需要改变,其他记录的蛇身是不变的

利用这个“滚动数组”,计算出蛇尾的索引就是(head+W*H-len)%(W*H),那么把对应位置的mapp值设置成0即实现了抹去旧蛇尾

吃食物

吃了那么蛇的长度加一,这样就不用抹去旧蛇尾

至此游戏部分的逻辑基本清楚了,然后就是万恶的可视化和交互ORZ

2.4 可视化

基本想法是图的可视化就print输出,每次输出完到一下一个时间间隔的时候清屏

2.4.1 清屏

然后考虑如何清屏

方法一:

当时大一课程作业贪吃蛇的交互部分老师给出代码了,,太久远了我也不记得咋实现的,但是没想到搜了一下我们学校+贪吃蛇,竟然搜到了我们那届的贪吃蛇代码!(而且经过大胆假设小心求证后那个大佬我认识hhhhh!神奇!!还发现当时写代码的助教是我们现在一个课的老师,我真的一点印象都没有了ORZ)

大佬存档的大一课程作业的贪吃蛇代码的思路在这里,可惜我当时的代码不知道被我丢哪里去了:c++贪吃蛇补充

然后清屏似乎就变的顺利起来,Windows下清屏是system("cls");(stdlib.h),那么macOS下是system("clear");(unistd.h)

但是问题又来了,只清屏不行,还需要把光标定格在最上面的位置再开始输出图啊,这要怎么实现?

方法二:

查啊查终于找到了这个东西:VT100 escape codes,就是发送一个转义码给屏幕,就可以实现对终端的一些特殊效果,然后这个码是以^[开头的(具体的也不太会翻译就不翻了QAQ)

那么printf("\033[2J");就可以实现清屏(奇怪的代码增加了.jpg)

再找找cursor相关的,(v,h)参数就是移动到屏幕的(v,h)位置,所以^[[1;1H就可以移动到(1,1)的位置,然后重新打印地图,nice!

结合在一起就是:printf("\033[1;1H\033[2J");

2.4.2 速度

时间间隔通过usleep实现,查询可得单位是微秒

macOS终端默认的字体是SF Mono Regular,截图计算了下单个字符宽高比是1:2的样子,也就是说如果要使左右移动的速度和上下移动速度看起来相等的话,在左右移动路程是上下移动路程的1/2的情况下,左右移动的时间也需要是上下移动时间的1/2

那么判断左右后设置不同的等待时间即可

1
usleep(abs(change) > 1 ? 250000 : 125000);

此处还可以增加蛇的变速功能,吃的食物越多蛇的速度越快,增加游戏体验,反正并不需要增加代码的行数hhhhh

1
usleep(abs(change)>1?250000-len*200:125000-len*100);

2.5 交互

再然后最没有头绪的就是交互部分了,,

2.5.1 按键

windows下是GetAsyncKeyState,“是一个用来判断函数调用时指定虚拟键的状态,确定用户当前是否按下了键盘上的一个键的函数。”

又或者用_kbhit(),_getch()这样的组合

macOS下或者linux下都有方法来实现kbhit()函数:(我一开始看的其实是linux那个,但是倒腾了半天最后发现macOS标准库里都没有#include<stropts.h>这个库,,,)

最后的结构大概是这样:

1
2
3
4
5
6
7
set_conio_terminal_mode();//一些初始工作
while(1){
while(!kbhit()){//没按键,按键了就退出最里层while
//do something
}
//获取按键
}

set_conio_terminal_mode()初始工作

参考以下这三个文档:

首先linux命令行(macOS也一样)是行缓冲的,也就是直到按下了回车之前输入的内容才会到stdin里,这样显然不符合上下左右的实时交互,所以_kbhit()函数里的第一步就是关闭行缓冲,需要用到termios.h这个库(是标准库里的),这个库主要是一些终端的传输设置?

总之接下来要干的事情就是设置一堆参数,用termios结构体,一开始我唯一认识的就是atexit了,,,还是PA里讲的

1
2
3
4
5
6
7
8
9
10
11
12
13
void reset_terminal_mode(){
tcsetattr(0, TCSANOW, &orig_termios);
}
void set_conio_terminal_mode(){
struct termios new_termios;
tcgetattr(0, &orig_termios);//获取原始与终端相关的参数
memcpy(&new_termios, &orig_termios, sizeof(new_termios));//复制
new_termios.c_lflag&=~(ICANON|ECHO);//设置控制模式,后面两个是宏定义
// ICANON: Enable canonical mode (described below)
// ECHO: Echo input characters.
atexit(reset_terminal_mode);//在程序结束return 0后调用atexit,把终端恢复成原来的设置
tcsetattr(0, TCSANOW, &new_termios);//TCSANOW表示设置的对终端的更改立即发生
}

kbhit()

timeout设置为0即非阻塞编程,select函数用来检查可读性

如果readset有fd被set了表明有新数据可读(在贪吃蛇中也就是按键了,这样函数返回值就是1,会退出while循环开始getchar)

1
2
3
4
5
6
7
8
int kbhit(){
struct timeval tv = { 0L, 0L };//timeval: 高精度结构体,秒/微秒
fd_set fds;
FD_ZERO(&fds);//fds清零
FD_SET(0, &fds);//0加入fds集合
return select(1, &fds, NULL, NULL, &tv);
// 函数原型: int select(int nfds,fd_set* readset,fd_set* writeset,fe_set* exceptset,struct timeval* timeout);
}

getchar()

编码是之前搜到的那个VT100 escape codes,唉这部分我最后都没怎么搞懂TAT

VT100 User Guide可以在这里获得:https://vt100.net/docs/vt100-ug/,可以找到3.6节是需要的控制编码

esc对应的ascii码是27,[的ascii是91,A/B/C/D对应的ascii分别是65/66/67/68

所以如果要识别一个方向键,实际上是要getchar()三次的,比如按up实际上接收的是27/91/65,就参考@Aqua的代码那么写了:

1
2
3
4
5
6
7
8
if((ch=getchar())!=27)continue;
if((ch=getchar())!=91)return 0;
ch=getchar();// get direction
if(ch==65)change=-W;
else if(ch==66)change=W;
else if(ch==67)change=1;
else if(ch==68)change=-1;
else change=1;

我最后会有一点bug,就是按ESC退出的话,并没有直接结束再按一下才结束?按照代码的逻辑,接收到27之后,确实是需要再getchar才有机会return 0,不知道要怎么改,真的不会了一点也不会了ORZ先这样吧


本来以为半天就能搞完这个贪吃蛇的结果最后终端交互部分拖了好几好几天,真的后悔折腾这个了是我太菜了得去赶作业了_(´ཀ`」 ∠)_ddl你慢点

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2018-2020 LeFlacon

奶茶一杯 快乐起飞

支付宝
微信