摘要:不依赖图形库,用 C 语言在 Linux 终端实现贪吃蛇。本文从墙体绘制、蛇节点打印与移动、非阻塞键盘输入、食物随机生成到碰撞检测,逐步拆解核心逻辑,并附上关键代码与常见问题解决方案。
前言:本文面向具备 C 语言基础、希望了解终端游戏开发思路的读者。阅读前建议先掌握 printf、结构体、链表和指针等基本概念。本项目不依赖任何图形库,仅通过 ANSI 转义序列控制光标位置,在 Linux 终端中实现完整的贪吃蛇游戏,代码结构清晰、逻辑直观,适合作为链表与终端编程的入门实战项目。
目录
- 一、大致逻辑
- 二、具体实现
- 1. 墙体设置
- 2. 光标定位
- 3. 蛇节点的打印
- 4. 蛇节点的移动
- 5. 食物生成
- 6. 墙体的生成
- 7. 碰撞检测
- 7.1 蛇自己的碰撞检测
- 7.2 边缘墙体检测
- 7.3 游戏生成的墙体碰撞检测
- 7.4 蛇吃食物的碰撞检测
- 8. 游戏功能与难度控制
- 三、常见问题与解决方案
本文主要讲解贪吃蛇代码的逻辑实现,如果有源码需求,可通过下面的 GitHub 链接访问我的仓库下载:https://github.com/wwx-taffy/taffy-s-git
本项目不依赖图形库实现,所以代码可能存在冗余或部分 bug,望见谅。
示例图:
一、大致逻辑
我们所谓的蛇,其实本质上就是一个 printf 打印出来的几个特殊字符,和我们平时打印数字或字母没什么本质区别。之所以我们的蛇呈现的是移动的效果,是因为我们通过一个 while(1) 循环不断地 printf 这几个特殊字符(和我们平时 printf 唯一有区别的是,它们指定了位置打印),通过 fflush 不断刷新缓冲区,来实现所谓的移动效果。
我们所谓的蛇,其实本质上就是一个 printf 打印出来的几个特殊字符,和我们平时打印数字或字母没什么本质区别。之所以我们的蛇呈现的是移动的效果,是因为我们通过一个 while(1) 循环不断地 printf 这几个特殊字符(和我们平时 printf 唯一有区别的是,它们指定了位置打印),通过 fflush 不断刷新缓冲区,来实现所谓的移动效果。
// 如果不了解 fflush,建议自行查阅资料,它是这里的核心,并不难理解。
二、具体实现
1. 墙体设置
贪吃蛇的活动范围是清晰的,撞到墙体游戏就会结束,所以墙体的设置必不可少。下面通过讲解墙体的打印来梳理思路。
2. 光标定位
平时使用 printf 时,输出内容通常固定在第一行,或者用几个空格隔开,无法像平面直角坐标系那样指定位置打印。因此,我们需要借助下面的方式来实现。
printf("\033[%d;%dH", x, y) 这样一个东西来实现类似这个功能,这里的 \033 是 8 进制 esc 的转义。
我们通过 "\033[%d;%dH",我们这里面的 %d 一个代表 x 轴,一个代表 y 轴,y 和 x 的值就是打印的位置(有一点需要注意,这个坐标系与正常的坐标系有所不同)。
我这里用一幅图来表示:
(" \033[%d;%dH", x, y) 这里的意思是把光标移动到横坐标为 x,纵坐标为 y 的这个地方,我们所要打印的东西要放在 H 后面,比如 printf("\033[%d;%dH▶", x, y);
所以墙体的设置,估计能猜出来了,设置一个墙体的长宽,通过上述那样移动光标,在边缘处进行打印墙体,代码的实现如下:
void draw_edge(int row, int col) { int x = 1, y = 1; /* 循环只检查行号 x,列的处理交给循环体内 */ while (x <= row && y <= col) { if (x == 1 || x == row) { /* 第一行 或 最后一行:画一整行 */ if (x == 1) printf("\033[%d;%dH▄", x, y); /* 顶边 */ else printf("\033[%d;%dH▀", x, y); /* 底边 */ y++; if (y > col) { /* 这一行画完了,去下一行 */ x++; y = 1; continue; } } else { /* 中间行:只画左右两个点 */ printf("\033[%d;%dH█", x, 1); printf("\033[%d;%dH█", x, col); x++; } } }这里或许和很多人想的不一样,为什么这里面的 printf 里面打印的值怎么不一样呢,这是因为 Linux 终端里面的长宽比是 1:2,就是竖着的字符间隔是横着的两倍,类似 * * 与 ** 的区别,所以为了美观我采用了多种字符,这个比例和字体等等有关系,每个人的也许都不太一样,但大致逻辑理解就可以。
2. 蛇节点的打印
在我们了解了墙体的打印,我们大致猜出来了,蛇也是这样打印出来的,但不同点是我们的蛇是会动的,所以我们用结构体来存储它,里面包含了 x,y 值,和它的移动方向,和指向下一个节点的指针。
typedef struct snakenode { int x,y; int dir; struct snakenode *next; }sn;熟悉链表的人,已经差不多猜出来了,我们的蛇就是一个链表,通过 printf 把所有节点打印在终端上,就形成了一个蛇,这个蛇的实现包括节点的创建赋值等等,这里我只把打印的函数实现放出来。
void draw_snake(sn *head) { sn *p = head; while (p != NULL) { if (p == head) { /* 第一个节点是蛇头,按方向画对应三角 */ if (head->dir == right) printf("\033[%d;%dH▶", p->x, p->y); else if (head->dir == left) printf("\033[%d;%dH◀", p->x, p->y); else if (head->dir == up) printf("\033[%d;%dH▲", p->x, p->y); else if (head->dir == down) printf("\033[%d;%dH▼", p->x, p->y); } else { /* 蛇身画 * */ printf("\033[%d;%dH*", p->x, p->y); } p = p->next; } }具体对蛇节点的初始化和创建,我就不一一讲解了。
3. 蛇节点的移动(通过 while 实现不断的移动)
如果我们蛇节点只是单纯的打印一次的话,我们蛇节点和普通的打印就没什么区别了,还是静态的,我们要实现蛇的动态的话,我们就要对蛇节点里面的 x,y 值进行改变,和对蛇的重新打印,来达到蛇节点的移动。但是这里有一个疑问,我们中途对这个 x,y 值进行改变,最后打印还不是最后一次的 x,y 值,所以这里我们需要 fflush 来对缓冲区进行刷新,达到实时输出的效果。
我们的蛇是移动的,所以我们会输入 wasd 来操控蛇的移动,其实就是更改 x,y 值,但这里有一个疑点,如果我们是通过 getchar 来读取我们的输入,此时的蛇就会卡在这里不动,必须等你输入了才动,这样就是一个遥控蛇了,而不是贪吃蛇,这个行为我们叫做阻塞,要实现非阻塞(也就是我们输入或不输入的时候,这个蛇始终保持移动,而非停下)我们需要借助一个函数 select,这个函数大家自行了解,差不多就是 select 读到缓冲区有东西它就返回值大于 0,并不会阻塞运动。
非阻塞的实现如下:
int look_move(sn *head) { struct timeval tv; tv.tv_sec = 0; tv.tv_usec = 0; /* 超时 0 微秒 = 不等待 */ fd_set rfsd; FD_ZERO(&rfsd); FD_SET(STDIN_FILENO, &rfsd); /* 把标准输入(键盘)加进监视名单 */ if (select(1, &rfsd, NULL, NULL, &tv) > 0) { /* 有输入 */ char ch; read(STDIN_FILENO, &ch, 1); /* 读出一个字符 */ return ch; } return -1; /* 无输入 */ }这里有很多陌生的结构体,这个是系统调用头文件自带的,很简单,大家去了解一下。
这里我们解决了阻塞问题,具体蛇的移动如下:
void node_move(sn *head, int dir) { int pre_x1 = head->x, pre_y1 = head->y, pre_dir1 = head->dir; int pre_x2, pre_y2, pre_dir2; /* 1. 蛇头前进一格(禁止 180° 掉头) */ if (dir == up && head->dir != down) { head->x = head->x - 1; head->dir = up; } else if (dir == down && head->dir != up) { head->x = head->x + 1; head->dir = down; } else if (dir == left && head->dir != right) { head->y = head->y - 1; head->dir = left; } else if (dir == right && head->dir != left) { head->y = head->y + 1; head->dir = right; } else { /* 掉头被禁止,继续沿原方向走 */ if (head->dir == up) head->x = head->x - 1; else if (head->dir == down) head->x = head->x + 1; else if (head->dir == left) head->y = head->y - 1; else if (head->dir == right) head->y = head->y + 1; } /* 2. 身体依次跟随(每个节点取前驱的旧位置) */ sn *cur = head->next; while (cur != NULL) { pre_x2 = cur->x; pre_y2 = cur->y; pre_dir2 = cur->dir; /* 存自己的旧值 */ cur->x = pre_x1; cur->y = pre_y1; cur->dir = pre_dir1; /* 取前驱旧值 */ pre_x1 = pre_x2; pre_y1 = pre_y2; pre_dir1 = pre_dir2; /* 接力给下一个 */ cur = cur->next; } }/* 2. 非阻塞读键,更新方向或触发功能 */ ch = look_move(head); if (ch == 'w') node_move(head, up); else if (ch == 'a') node_move(head, left); else if (ch == 's') node_move(head, down); else if (ch == 'd') node_move(head, right); else if (ch == 'p') time_p(); /* 暂停 */ else if (ch == 'q') break; /* 退出 */ else node_move(head, head->dir); /* 无按键,继续走 */这里就是蛇的移动功能的实现。
4. 食物生成
我们的食物是随机生成的,我们可以采用 srand 随机生成一个种子,配合 rand 达到随机,因为我们的食物并不会马上被吃,会长期存在,所以我推荐用结构体来存储,但要注意的一个点是,食物的生成要避开几个位置,一个是墙体内部,一个是蛇身上,不然就得重新生成,代码如下:
struct food_place *rand_food(int x, int y) { int food_x = rand() % x; if (food_x == 0 || food_x == 1) food_x += 2; /* 避开顶/左边界 */ int food_y = rand() % y; if (food_y == 0 || food_y == 1) food_y += 2; /* 避开底/右边界 */ f_p *f = malloc(sizeof(f_p)); if (f == NULL) { printf("内存分配错误\n"); return NULL; } f->x = food_x; f->y = food_y; return f; }if (food == NULL) { food = rand_food(weight, lenth); /* 检查食物是否落在蛇身上,是就重新生成 */ sn *head_c = head; int flag = 0; while (head_c != NULL) { if (head_c->x == food->x && head_c->y == food->y) { flag = 1; break; } head_c = head_c->next; } if (flag) { free(food); food = NULL; continue; } }5. 墙体的生成
墙体的生成其实也类似于食物,要避开蛇身和边缘墙体,同时墙体生成后是要长期存在的,所以我们采用的是和蛇一样的链表设计(其实可以把墙体看作第二个蛇),我这里由于是随机墙体,并且是随机单个墙体和十字墙体,代码实现比较复杂就不作展示。
6. 碰撞检测
6.1 蛇自己的碰撞检测
我们知道贪吃蛇碰撞到自己的蛇身时会死亡,所以要设计这个碰撞检测,就是头节点每次移动完,进行一个函数的碰撞检测,如果蛇头的 x,y 值等于它的某个节点的 x,y 值,直接结束游戏。
代码如下:
int node_over(sn *head) { sn *n = head->next; while (n != NULL) { if (head->x == n->x && head->y == n->y) { gameover(); return -1; } n = n->next; } return 1; }6.2 边缘墙体检测
这个是比较简单的,只要 y 或 x 大于等于墙体的值,或小于等于,都直接 gameover。
代码如下:
if (head->x <= 1 || head->x >= weight || head->y <= 1 || head->y >= lenth) { gameover(); return; }6.3 游戏生成的墙体碰撞检测
原理类似,就是不能让蛇头 x,y 值等于墙链上任何一点的 x,y 值,否则直接 gameover。
int all_node_hit(sn *head, wd *w_head) { sn *n = head; wd *cur = w_head; while (cur != NULL) { if (cur->x == n->x && cur->y == n->y) { gameover(); return -1; } cur = cur->next; } return 1; }6.4 蛇吃食物的碰撞检测
蛇吃食物也可以看作碰撞,但是不同的是并不 gameover,而是增加节点和释放食物。
代码如下:
if (head->x == food->x && head->y == food->y) { /* 在蛇尾追加一节(方向决定坐标,整体前移会自动修正) */ if ((*tail)->dir == up) create_node(head_copy, tail, (*tail)->x + 1, (*tail)->y); else if ((*tail)->dir == down) create_node(head_copy, tail, (*tail)->x - 1, (*tail)->y); else if ((*tail)->dir == left) create_node(head_copy, tail, (*tail)->x, (*tail)->y + 1); else if ((*tail)->dir == right) create_node(head_copy, tail, (*tail)->x, (*tail)->y - 1); free(food); food = NULL; }我只复制了部分,剩下部分的是增加游戏难度用的。
7. gameover 和暂停功能,积分功能以及难度增加功能
这些属于简单的功能,属于锦上添花的,功能的实现很简单,这里不多赘述。
会出现的问题和解决方案
我们知道我们是通过移动光标来打印的,但这个光标是会一闪一闪的,可能会把我们的节点给遮住,导致观感极差,所以我们需要在游戏开始前关闭这个光标,在结束游戏后打开。
具体实现如下,也可以自己实现:
/* ============ 终端模式切换 ============ */ /* enable_raw_mode:切换到原始模式 效果:按键立刻到达程序(不用等回车)、按键不回显、隐藏光标 / void enable_raw_mode(void) { struct termios raw; tcgetattr(STDIN_FILENO, &g_orig); / 1. 读出当前配置并保存 / raw = g_orig; raw.c_lflag &= ~(ICANON | ECHO); / 2. 关"规范模式"和"回显" / tcsetattr(STDIN_FILENO, TCSANOW, &raw); / 3. 写回新配置 / printf("\033[?25l"); / 隐藏光标,避免光标方块盖住画面 */ fflush(stdout); } /* disable_raw_mode:恢复原始终端配置 必须在游戏结束时调用,否则终端不回显、光标不显示 / void disable_raw_mode(void) { tcsetattr(STDIN_FILENO, TCSANOW, &g_orig); / 恢复之前保存的配置 / printf("\033[?25h"); / 恢复光标显示 */ fflush(stdout); }还有记得游戏结束后记得释放节点,避免出现内存泄漏。