
🏠 个人主页: EXtreme35
📚 个人专栏:
| 专栏名称 | 专栏主题简述 |
|---|---|
| 《C语言》 | C语言基础、语法解析与实战应用 |
| 《数据结构》 | 线性表、树、图等核心数据结构详解 |
| 《题解思维》 | 算法思路、解题技巧与高效编程实践 |

目录
- [一、Win 32 API介绍](#一、Win 32 API介绍)
-
- [1.1 控制台设置](#1.1 控制台设置)
- 1.2程序中设置控制台
-
- [1.2.1 COORD控制台屏幕坐标](#1.2.1 COORD控制台屏幕坐标)
- [1.2.2 GetStdHandle 函数](#1.2.2 GetStdHandle 函数)
- [1.2.3 [GetConsoleCursorInfo 函数](https://learn.microsoft.com/zh-cn/windows/console/getconsolecursorinfo)](#1.2.3 GetConsoleCursorInfo 函数)
-
- [CONSOLE_CURSOR_INFO 结构](#CONSOLE_CURSOR_INFO 结构)
- [1.2.4 SetConsoleCursorPosition 函数](#1.2.4 SetConsoleCursorPosition 函数)
- [1.2.5 GetAsyncKeyState 函数](#1.2.5 GetAsyncKeyState 函数)
- 二、游戏演示与界面设计
- 三、核心逻辑实现分析
-
- [3.1 创建地图](#3.1 创建地图)
- [3.2 初始化蛇身](#3.2 初始化蛇身)
- [3.3 创建食物](#3.3 创建食物)
- [3.4 游戏状态](#3.4 游戏状态)
- [3.5 蛇的移动](#3.5 蛇的移动)
-
- [3.5.1 `EatFood` 函数详解](#3.5.1
EatFood函数详解) - [3.5.2 `NoFood` 函数详解(未吃到食物)](#3.5.2
NoFood函数详解(未吃到食物)) - [3.6 碰撞检测函数](#3.6 碰撞检测函数)
- [3.5.1 `EatFood` 函数详解](#3.5.1
- [3.6 游戏结束](#3.6 游戏结束)
- 四、总结
学习完C语言和链表,最好的方式就是通过项目实战来巩固知识。贪吃蛇 是一个经典的控制台游戏项目,它不仅能让你熟练掌握链表 这种重要的数据结构,还能让你体验Win32 API在控制台程序中的强大应用。
一、Win 32 API介绍
Windows 这个多作业系统除了协调应⽤程序的执⾏、分配内存、管理资源之外, 它同时也是⼀个很⼤的服务中⼼,调⽤这个服务中⼼的各种服务(每⼀种服务就是⼀个函数 ),可以帮应⽤程序达到开启视窗、描绘图形、使⽤周边设备等⽬的,由于这些函数服务的对象是应⽤程序(Application), 所以便称之为 Application Programming Interface,简称 API 函数。WIN32 API也就是Microsoft Windows32位平台的应⽤程序编程接⼝。
1.1 控制台设置

这里呢,如果代码运行后是这个界面,那么就代表代码运行是在终端上,这时候需要进行设置一下:

修改成让Windows决定。

也可以直接选控制台主机。

吧背景颜色修改一下,便于区分。
1.2程序中设置控制台
system函数用来执行系统命令。
c
system("mode con cols=30 lines=30");

c
int main()
{
//设置控制台窗口大小
system("mode con cols=100 lines=30");
//设置控制台名称
system("title 贪吃蛇");
//设置控制台暂停 获取一个字符后继续
system("pause");
return 0;
}

1.2.1 COORD控制台屏幕坐标
COORD 是Windows API中定义的⼀个结构体,表⽰⼀个字符在控制台屏幕上的坐标。
c
typedef struct _COORD {
SHORT X;
SHORT Y;
} COORD, *PCOORD;


屏幕上任何一个位置就是一个坐标。那怎么定义一个坐标呢?
c
COORD pos = { 0,0 };
COORD pos = { 10,10 };
1.2.2 GetStdHandle 函数
要控制控制台窗口,首先要获得这个窗口。
GetStdHandle是⼀个Windows API函数 。它⽤于从⼀个特定的标准设备(标准输⼊、标准输出或标准错误)中取得⼀个句柄(⽤来标识不同设备的数值),使⽤这个句柄可以操作设备(也就是屏幕)。
句柄 其实是一个数字,只是叫句柄而已,简单理解为抓手。
函数定义(飞机票)
c
HANDLE WINAPI GetStdHandle(
_In_ DWORD nStdHandle
);

HANDLE是一种空指针变量。

1.2.3 GetConsoleCursorInfo 函数
这里有光标一直在闪,如果贪吃蛇一直在跑的话,光标一直闪非常不方便,我们可以把光标隐藏起来。

检索有关指定控制台屏幕缓冲区的光标⼤⼩和可⻅性的信息。
c
BOOL WINAPI GetConsoleCursorInfo(
_In_ HANDLE hConsoleOutput,
_Out_ PCONSOLE_CURSOR_INFO lpConsoleCursorInfo
);
它的第一个参数就是刚才获取到的句柄。
第二个参数是指向 CONSOLE_CURSOR_INFO 结构的指针,该结构接收有关控制台游标的信息。
CONSOLE_CURSOR_INFO 结构
c
typedef struct _CONSOLE_CURSOR_INFO {
DWORD dwSize;
BOOL bVisible;
} CONSOLE_CURSOR_INFO, *PCONSOLE_CURSOR_INFO;
dwSize,由光标填充的字符单元格的百分⽐。 此值介于1到100之间。 光标外观会变化,范围从完全填充单元格到单元底部的⽔平线条。bVisible,游标的可⻅性。 如果光标可⻅,则此成员为TRUE。
示例代码:
c
int main()
{
//获取标准输出设备
HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO cursor_info = { 0 };
GetConsoleCursorInfo(houtput, &cursor_info);
system("pause");
return 0;
}

这里的25表示看见的光标,只占到一个字符的25%。也可以直接隐藏,那就需要设置结构体另一个成员。
这里需要注意的是,不能只修改,不然将会不成功,而是设置 ---> 修改 ---> 再设置。
不是我没截,是真的没了。
1.2.4 SetConsoleCursorPosition 函数
c
BOOL WINAPI SetConsoleCursorPosition(
_In_ HANDLE hConsoleOutput,
_In_ COORD dwCursorPosition
);
设置指定控制台屏幕缓冲区中的光标位置 ,将想要设置的坐标信息放在COORD类型的pos中,⽤SetConsoleCursorPosition函数将光标位置设置到指定的位置。
指定新光标位置(以字符为单位)的 COORD 结构。 坐标是屏幕缓冲区字符单元的列和行。 坐标必须位于控制台屏幕缓冲区的边界以内。

c
COORD pos = { 5, 5};
HANDLE hOutput = NULL;
//获取标准输出的句柄(⽤来标识不同设备的数值)
hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
//设置标准输出上光标的位置为pos
SetConsoleCursorPosition(hOutput, pos);
光标被定位到(5,5)。
设置光标位置函数set_pos
c
void set_pos(short x, short y)
{
//拿到标准输出设备句柄
HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
//定义光标位置
COORD pos = { x,y };
//设置光标位置
SetConsoleCursorPosition(houtput, pos);
}
1.2.5 GetAsyncKeyState 函数
获取按键情况,GetAsyncKeyState的函数原型如下:
c
SHORT GetAsyncKeyState( int vKey );
将键盘上每个键的虚拟键值传递给函数,函数通过返回值来分辨按键的状态。(虚拟键码很多,我只是截取了部分。)

GetAsyncKeyState 的返回值是short类型,在上⼀次调⽤ GetAsyncKeyState 函数后,如果返回的16位的short数据中,最⾼位是1,说明按键的状态是按下,如果最⾼是0,说明按键的状态是抬起;如果最低位被置为1则说明,该按键被按过,否则为0。
如果我们要判断⼀个键是否被按过,可以检测GetAsyncKeyState返回值的最低值是否为1.
c
#define KEY_PRESS(VK) ( (GetAsyncKeyState(VK) & 0x1) ? 1 : 0 )
c
int main()
{
while (1)
{
if (KEY_PRESS(0x30))
printf("0\n");
else if(KEY_PRESS(0x31))
printf("1\n");
else if (KEY_PRESS(0x32))
printf("2\n");
else if (KEY_PRESS(0x27))
printf("右\n");
else if (KEY_PRESS(0x28))
printf("下\n");
}
return 0;
}
二、游戏演示与界面设计
2.1 成果演示

从这个成果预览我们可以看出,这个小游戏不仅有刚开始的欢迎页面 ,还有给用户交互的键盘提示页面 ,还包括游戏的边界框 ,游戏得分,以及蛇、食物、加速减速等。
那这几个页面我们需要全部实现,接下来就对每个页面实现进行拆解。
2.2 游戏界面分解
我们的贪吃蛇游戏界面主要由以下几个部分构成:
- 欢迎界面和操作指南:
在游戏开始前,WelcomeToGame函数会打印出欢迎信息和详细的按键指南(↑, ↓, ←, → 移动;W 加速;S 减速;ESC 退出)。 - 主游戏界面布局:
CreateMap函数绘制了游戏的边框(WALL字符L'□')。蛇身(BODY字符L'●')和食物(FOOD字符L'★')在边界内部移动。 - 信息面板展示:
在游戏区域的右侧,实时显示游戏的当前总分数 和当前食物分数 ,以及操作提示。
2.2.1 欢迎界面和操作指南

在这个页面我们发现就是一些信息打印,那么现在最重要的也就是设置光标,然后输出就可以了。(细心的可以发现光标也已经隐藏了哦!)
这里为了便于后续设置光标位置和游戏边界,那就统一将我们的操作界面设置为一个大小,那么后续也就不回出现乱码的情况了。
c
//0.先设置窗口大小,后光标隐藏
system("mode con cols=100 lines=33");
system("title 贪吃蛇");
HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO cursor_info = { 0 };
GetConsoleCursorInfo(houtput, &cursor_info); //获取控制台光标信息
cursor_info.bVisible = false; //隐藏控制台光标
SetConsoleCursorInfo(houtput, &cursor_info); //设置控制台光标状态
这里我们将大小确定好之后,光标也已经进行隐藏了,那只需要大概找到屏幕正中间位置就可以开始打印信息 了。这里可以一点一点去试试,不难,一会就好了,至于这里为啥长和宽比例如此离谱,请耐心往下读!!!
这里有个小细节,那就是切换界面时,需要让用户自己操作,那就进行一个system("pause");暂停操作。前一个界面会影响第二个界面的观感,那就进行一次system("cls");清屏操作。
这里给出伪码,部分可能看不懂,别着急,往下接着看:
c
void WelcomeToGame()
{
SetPos(38, 14);//注意 第一个坐标是列,不是行
wprintf(L"欢迎来到贪吃蛇小游戏\n");
SetPos(70, 26);//按任意键继续信息的位置
system("pause");//切换屏幕
system("cls");//清理屏幕
SetPos(43, 11);
wprintf(L"按键指南\n");
......
SetPos(70, 26);//按任意键继续信息的位置
system("pause");//切换屏幕
system("cls");//清理屏幕
}
2.2.2 主游戏界面布局

很明显这个界面信息多的就不是一星半点了,别着急,一步一步来,首先最明显就是这一圈的框框,那这到底是啥,这是咋打印的?
在游戏地图上,我们打印墙体使⽤宽字符:□ ,打印蛇使⽤宽字符● ,打印⻝物使⽤宽字符★ 普通的字符是占⼀个字节的,这类宽字符是占⽤2个字节。
这⾥再简单的讲⼀下C语⾔的国际化特性相关的知识,过去C语⾔并不适合⾮英语国家(地区)使⽤。C语⾔最初假定字符都是但⾃⼰的。但是这些假定并不是在世界的任何地⽅都适⽤。C语⾔字符默认是采⽤ASCII编码的,ASCII字符集采⽤的是单字节编码,且只使⽤了单字节中的低7位,最⾼位是没有使⽤的,可表⽰为0xxxxxxxx;可以看到,ASCII字符集共包含128个字符,在英语国家中,128个字符是基本够⽤的,但是,在其他国家语⾔中,⽐如,在法语中,字⺟上⽅有注⾳符号,它就⽆法⽤ ASCII 码表⽰。于是,⼀些欧洲国家就决定,利⽤字节中闲置的最⾼位编⼊新的符号。⽐如,法语中的é的编码为130(⼆进制10000010)。这样⼀来,这些欧洲国家使⽤的编码体系,可以表⽰最多256个符号。但是,这⾥⼜出现了新的问题。不同的国家有不同的字⺟,因此,哪怕它们都使⽤256个符号的编码⽅式,代表的字⺟却不⼀样。⽐如,130在法语编码中代表了é,在希伯来语编码中却代表了字⺟Gimel ,在俄语编码中⼜会代表另⼀个符号。但是不管怎样,所有这些编码⽅式中,0--127表⽰的符号是⼀样的,不⼀样的只是128--255的这⼀段。⾄于亚洲国家的⽂字,使⽤的符号就更多了,汉字就多达10万左右。⼀个字节只能表⽰256种符号,肯定是不够的,就必须使⽤多个字节表达⼀个符号。⽐如,简体中⽂常⻅的编码⽅式是 GB2312,使⽤两个字节表⽰⼀个汉字,所以理论上最多可以表⽰ 256 x 256 = 65536 个符号。
后来为了使C语⾔适应国际化,C语⾔的标准中不断加⼊了国际化的⽀持。⽐如:加⼊和宽字符的类型wchar_t 和宽字符的输⼊和输出函数,加⼊和<locale.h>头⽂件,其中提供了允许程序员针对特定地区(通常是国家或者说某种特定语⾔的地理区域)调整程序⾏为的函数。
1、<local.h>本地化
<locale.h>提供的函数⽤于控制C标准库中对于不同的地区会产⽣不⼀样⾏为的部分。在标准可以中,依赖地区的部分有以下⼏项:
- 数字量的格式
- 货币量的格式
- 字符集
- ⽇期和时间的表⽰形式
2、类项
通过修改地区,程序可以改变它的⾏为来适应世界的不同区域。但地区的改变可能会影响库的许多部分,其中⼀部分可能是我们不希望修改的。所以C语⾔⽀持针对不同的类项进⾏修改,下⾯的⼀个宏,指定⼀个类项:
LC_COLLATE:影响字符串比较函数strcoll()和strxfrm()。LC_CTYPE:影响字符处理函数的行为。LC_MONETARY:影响货币格式。LC_NUMERIC:影响printf()的数字格式。LC_TIME:影响时间格式strftime()和wcsftime()。LC_ALL- 针对所有类项修改,将以上所有类别设置为给定的语言环境。
3、setlocale函数
c
char* setlocale (int category, const char* locale);
setlocale 函数⽤于修改当前地区,可以针对⼀个类项修改,也可以针对所有类项。
setlocale 的第⼀个参数可以是前⾯说明的类项中的⼀个,那么每次只会影响⼀个类项,如果第⼀个参数是LC_ALL,就会影响所有的类项 。
C标准给第⼆个参数仅定义了2种可能取值 :C(正常模式 )和 (本地环境 )。
在任意程序执⾏开始,都会隐藏式执⾏调⽤:
c
setlocale(LC_ALL, "C");
当地区设置为"C"时,库函数按正常⽅式执⾏,⼩数点是⼀个点。
当程序运⾏起来后想改变地区 ,就只能显⽰调⽤setlocale函数 。⽤" "作为第2个参数,调⽤setlocale函数 就可以切换到本地模式,这种模式下程序会适应本地环境。⽐如:切换到我们的本地模式后就⽀持宽字符(汉字)的输出等。
c
setlocale(LC_ALL, " ");//切换到本地环境
setlocale的返回值是一个字符串指针,表示已经设置好的格式。如果调用失败,则返回空指针NULL。
setlocale()可以用来查询当前地区,此时第二个参数设置为NULL就好了。

可以得到当调用 setlocale(LC_ALL, NULL) 查询当前设置,或在程序启动后未做任何设置时,生效的就是这个默认环境。它的核心特征是:
- 字符集 :基于 ASCII(或非常简单的字符集)。
- 格式化规则 :
- 数字 :小数点使用点号
.。 - 时间/日期格式 :遵循 C/POSIX 标准格式 (例如
%a %b %d %H:%M:%S %Y)。 - 货币符号 :通常只有基本的美元符号
$,没有本地化格式。 - 字符串比较(排序) :严格基于 ASCII 码值 (例如
'Z'排在'a'前面)。
- 数字 :小数点使用点号
当调用 "" 空字符串 :是程序主动"入乡随俗",去适应操作系统用户所在的语言和文化环境。也就是CHINA。
宽字符的打印
那如果想在屏幕上打印宽字符,怎么打印呢?
宽字符的字面量必须加上前缀L,否则C语言会把字面量当作窄字符类型处理。宽字符的打印不使用printf(),而是wprintf()。
- 前缀
L在单引号前面,表示宽字符,对应wprintf()的占位符为%lc; - 前缀
L在双引号前面,表示宽字符串,对应wprintf()的占位符为%ls; - 格式串前面也要加上前缀
L。

我的名字我就码住了,现在根据结果可以看到一个宽字符占到两个字节。
游戏边界的打印
那实际上在我们的终端 上也就是这个页面 (假设我标出边框的是终端界面):

那么我们现在想在终端上实现一个30行,58列 的棋盘(行和列可以根据自己喜好修改),再围绕地图画出墙,就是如下效果:

这时候最难的也就是算这个坐标,细心设置好光标,然后打印就好了。
蛇和食物的打印
现在就开始创建初始蛇的长度和食物的位置,这里我们还是固定一下蛇的初始位置好一点,免得随机在墙边,来不及反应就撞上去了,体验感不好,这里唯一注意的也就是蛇、食物、边界的这些宽字符,别设置一样的就好了。

到这里,这些界面就已经全部设置好了,剩下的靠右那些提示和打印分数,只要定位光标,直接打印就好了,没什么需要特别注意的。
2.3 数据结构设计
贪吃蛇最核心的就是对蛇身 的表示,我们选择使用单向链表,因为蛇的移动是典型的头部插入、尾部删除操作。
-
链表节点的设计:
每个节点代表蛇身的一个部分,存储其在屏幕上的坐标以及指向下一个节点的指针。ctypedef struct SnakeNode { int x; // 坐标X(列) int y; // 坐标Y(行) struct SnakeNode* next; // 下一个节点指针 }SnakeNode, *pSnakeNode; -
Snake结构体详解:
Snake结构体是整个游戏的上下文 ,它包含了游戏的所有状态信息。ctypedef struct Snake { pSnakeNode _pSnake; //指向蛇头的指针 pSnakeNode _pFood; //指食物节点的指针 enum DIRECTION _dir; //蛇的方向 enum GAME_STATUS _status; //游戏状态 int _food_weight; //一个食物的分数 int _score; //游戏总分数 int _sleep_time; //休息时间,时间越短速度越快,时间越长速度越快 }Snake,* pSnake; -
枚举类型定义:
我们使用枚举 (enum) 来清晰地定义蛇的方向 (DIRECTION) 和游戏的状态 (GAME_STATUS)。c//蛇行进的方向 enum DIRECTION { UP = 1, DOWN, LEFT, RIGHT }; //蛇的状态:正常、撞墙、撞自己、esc退出 enum GAME_STATUS { OK, //正常 KILL_BY_WALL, //撞墙 KILL_BY_SELF, //撞到自己 END_NORMAL //正常退出 };
2.4 操作说明
- 蛇的移动过程:
蛇每走一步,就相当于在头部方向 多了一个新节点,并在尾部少了一个旧节点,从而实现"移动"的视觉效果。 - 食物生成和得分机制:
食物在非墙壁、非蛇身的位置随机生成。当蛇头移动到食物位置时,它会吃掉食物,然后长度会变长,总分数增加,并在地图上立即重新生成一个新的食物。 - 加速减速效果:
通过按 W 和 S 键,可以动态调整游戏的速度,这通过改变_sleep_time参数来实现。_sleep_time越短,蛇移动越快,同时食物分数 (_food_weight) 也会相应变化,实现了加速得分更高的Tips。
2.5 游戏流程设计

三、核心逻辑实现分析
这里就是一些核心函数的实现了,就不一一展开了,只展示一些核心的内容,完了给出我的源码链接(文末),有兴趣的可以自己去拿一下。
3.1 创建地图
创建地图就是将墙打印出来,因为是宽字符打印,所有使⽤wprintf函数,打印格式串前使⽤L
打印地图的关键是要算好坐标,才能在想要的位置打印墙体。
墙体打印的宽字符:
c
#define WALL L'□'
创建地图函数CreateMap
c
void CreateMap()
{
//上 58列 宽字符占2字节
for (int i = 0; i < 29; i++)
wprintf(L"%lc", WALL);
//下 58列 宽字符占2字节
SetPos(0, 29);
for (int i = 0; i < 29; i++)
wprintf(L"%lc", WALL);
//左
for (int i = 1; i < 29; i++)
{
SetPos(0, i);
wprintf(L"%lc", WALL);
}
//右
for (int i = 1; i < 29; i++)
{
SetPos(56, i);
wprintf(L"%lc", WALL);
}
}
这里就需要根据个人定义的边界条件具体判断了,我定义的是30行。
3.2 初始化蛇身
蛇最开始长度为4节,每节对应链表的⼀个节点,蛇⾝的每⼀个节点都有⾃⼰的坐标。创建4个节点,然后将每个节点存放在链表中进⾏管理。创建完蛇⾝后,将蛇的每⼀节打印在屏幕上。(这里我定义的是四节,你可以自己改)
再设置当前游戏的状态,蛇移动的速度,默认的⽅向,初始成绩,蛇的状态,每个⻝物的分数。
蛇⾝打印的宽字符:#define BODY L'●'
初始化蛇⾝函数:InitSnake
c
void InitSnake(pSnake ps)
{
pSnakeNode cur = NULL;
for (int i = 0; i <= 3; i++)
{
cur = (pSnakeNode)malloc(sizeof(SnakeNode));
//创建失败
if (cur == NULL)
{
perror("malloc");
exit(-1);
}
//node串起来
cur->next = NULL;
cur->x = POS_X + 2 * i;
cur->y = POS_Y;
//头插法
if (ps->_pSnake == NULL)
ps->_pSnake = cur;
else
{
//始终往蛇头插入
cur->next = ps->_pSnake;
ps->_pSnake = cur;
}
}
//打印蛇
cur = ps->_pSnake;
while (cur)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc", BODY);
cur = cur->next;
}
ps->_dir = RIGHT;//默认向右
ps->_score = 0;
ps->_food_weight = 10;
ps->_sleep_time = 200;//毫秒
ps->_status = OK;
}
我们使用头插法创建初始蛇身(例如4个节点)。初始时,蛇头位于链表的第一个节点。
3.3 创建食物
先随机⽣成⻝物的坐标
- x坐标必须是2的倍数。
- ⻝物的坐标不能 和蛇⾝每个节点的坐标重复。
这里说详细一些,为什么必须是2的倍数呢?
因为每一个蛇头都占据1个宽字符,两个字节,后续需要用食物坐标和蛇头坐标进行判断,判断是否吃到食物,那如果不是2的倍数,蛇头永远到不了这个食物的坐标,这游戏也就没法继续下去了。
为什么不能重复呢? 重复了你连食物都找不到,那还玩啥?
c
void CreateFood(pSnake ps)
{
int x = 0;
int y = 0;
//食物坐标
//x 2的倍数 2-54
//y 2的倍数 1-28
again:
do
{
//食物坐标不能出内场范围
x = rand() % 53 + 2;
y = rand() % 28 + 1;
} while (x%2!=0);
//x和y不能和蛇身蛇头坐标一样
pSnakeNode cur = ps->_pSnake;
while (cur)
{
if (x == cur->x && y == cur->y)
{
goto again;
}
cur = cur->next;
}
//创建食物节点
pSnakeNode pFood = (pSnakeNode)malloc(sizeof(SnakeNode));
if (pFood == NULL)
{
perror("malloc");
exit(-1);
}
pFood->x = x;
pFood->y = y;
pFood->next = NULL;
SetPos(pFood->x, pFood->y);
wprintf(L"%lc", FOOD);
ps->_pFood = pFood;
}
3.4 游戏状态
游戏运行期间,右侧打印帮助信息提示玩家,根据游戏状态检查游戏是否继续,如果是状态是OK,游戏继续 ,否则游戏结束 。如果游戏继续,就是检测按键情况,确定蛇下⼀步的⽅向,或者是否加速减速,是否暂停或者退出游戏。确定了蛇的⽅向和速度,蛇就可以移动了。
这里最重要的就是把交互做好,也就是键盘响应,很多代码都是一样的,可以定义宏进行简化代码,完整代码就只给个伪码了。
c
void GameRun(pSnake ps)
{
do
{
//打印总分数和食物分值
SetPos(70, 7);
printf("当前总分数:%2d\n",ps->_score);
SetPos(70, 8);
printf("当前食物分数:%2d\n", ps->_food_weight);
if (KEY_PRESS(VK_UP) && ps->_dir != DOWN)
ps->_dir = UP;
//这里省略了自定义的某些键盘代码
else if (KEY_PRESS(0x57))
{
//加速
if (ps->_sleep_time > 80)
{
ps->_food_weight += 2;
ps->_sleep_time -= 30;
}
}
else if (KEY_PRESS(0x53))
{
//减速
if (ps->_food_weight > 2)
{
ps->_food_weight -= 2;
ps->_sleep_time += 30;
}
}
//蛇走一步
SnakeMove(ps);
Sleep(ps->_sleep_time);
} while (ps->_status==OK);
}
这里加速减速,也实现的没有那么复杂,就是将沉睡时间加长或者减少,但也不能无限制,在范围内就可以。
3.5 蛇的移动
蛇的移动算法 (SnakeMove): 这是游戏的核心逻辑。
- 根据当前方向 (
_dir),计算出下一个位置的坐标,并创建一个新节点pNextNode。 - 判断下一个位置是否为食物 (
NextIsFood)。- 如果是食物 (
EatFood): 将_pFood节点直接连接到蛇头(头插 ),让它成为新的蛇头。不进行尾部删除,从而实现蛇身变长。然后重新创建食物。 - 如果不是食物 (
NoFood): 将pNextNode连接到蛇头(头插 )。找到链表的倒数第二个节点 ,释放它的next节点(即删除旧尾巴 ),并将其next置为NULL。
- 如果是食物 (
c
void SnakeMove(pSnake ps)
{
//创建一个结点,表示蛇即将到的下一个节点
pSnakeNode pNextNode = (pSnakeNode)malloc(sizeof(SnakeNode));
if (pNextNode == NULL)
{
perror("SnakeMove()::malloc()");
return;
}
//这里需要修改 保持头结点不变 ,然后更新位置 接着打印
switch (ps->_dir)
{
case UP:
pNextNode->x = ps->_pSnake->x;
pNextNode->y = ps->_pSnake->y - 1;
break;
case DOWN:
pNextNode->x = ps->_pSnake->x;
pNextNode->y = ps->_pSnake->y + 1;
break;
case LEFT:
pNextNode->x = ps->_pSnake->x - 2;
pNextNode->y = ps->_pSnake->y;
break;
case RIGHT:
pNextNode->x = ps->_pSnake->x + 2;
pNextNode->y = ps->_pSnake->y;
break;
}
//下一位置是不是食物
if (NextIsFood(pNextNode, ps))
//是食物
EatFood(pNextNode, ps);
else
//不是食物
NoFood(pNextNode, ps);
//检测蛇是否撞墙
KillByWall(ps);
//检测蛇是否撞到自己
KillBySelf(ps);
}
3.5.1 EatFood 函数详解
EatFood 函数处理蛇吃到食物的逻辑,其核心操作是让蛇身增长并更新游戏状态。
首先,它通过头插法 将食物节点 (ps->_pFood) 插入到蛇头 (ps->_pSnake) 的前面,使食物节点成为新的蛇头,从而实现蛇身的增长,因为这次移动没有移除尾部节点。接着,它释放了原本为下一个位置预分配的临时节点 pn,避免内存泄漏。
随后,函数从新的蛇头开始遍历整个蛇身链表,并在控制台上重新打印蛇身的每个节点,确保蛇身的新状态在屏幕上正确显示 (wprintf(L"%lc", BODY))。
最后,它根据当前食物的分值 (ps->_food_weight) 更新总得分 (ps->_score),并调用 CreateFood(ps) 在地图上生成一个新的食物.
c
void EatFood(pSnakeNode pn, pSnake ps)
{
//头插法
ps->_pFood->next = ps->_pSnake;
ps->_pSnake = ps->_pFood;
//释放下一个位置的节点
free(pn);
pn = NULL;
pSnakeNode cur = ps->_pSnake;
//打印蛇
while (cur)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc", BODY);
cur = cur->next;
}
ps->_score += ps->_food_weight;
//重新创建食物
CreateFood(ps);
}
3.5.2 NoFood 函数详解(未吃到食物)
当蛇移动到的下一个位置不是食物时,NoFood 函数执行蛇身平移逻辑:
- 头插法 将代表新位置的节点
pn连接到蛇头 (ps->_pSnake) 的前面,使其成为新的蛇头; - 遍历链表,找到倒数第二个节点 (
cur),并在遍历过程中重新打印除尾部外的所有蛇身节点; - 函数定位到最后一个节点(旧尾巴),先在屏幕上将其位置打印成两个空格 (
" ") 来清除旧尾巴的痕迹,然后在内存中释放该节点 (free(cur->next)); - 将倒数第二个节点的
next指针设置为NULL(cur->next = NULL),正式截断链表,使蛇的长度保持不变,完成一步移动。
c
void NoFood(pSnakeNode pn, pSnake ps)
{
//头插法
pn->next = ps->_pSnake;
ps->_pSnake = pn;
pSnakeNode cur = ps->_pSnake;
while (cur->next->next != NULL)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc", BODY);
cur = cur->next;
}
//把最后一个结点打印成空格
SetPos(cur->next->x, cur->next->y);
printf(" ");
//释放最后一个结点
free(cur->next);
//把倒数第二个节点的地址置为NULL
cur->next = NULL;
}
这里打印空格的目的是为了在控制台屏幕上清除蛇身旧尾巴的痕迹 ,实现蛇的平滑移动效果。
3.6 碰撞检测函数
KillByWall、KillBySelf 这两个函数共同实现了贪吃蛇游戏的碰撞检测机制,用于判断游戏是否结束。
KillByWall 函数检查蛇头 (ps->_pSnake) 的坐标是否触及地图边界(X坐标为 0 0 0 或 56 56 56,Y坐标为 0 0 0 或 28 28 28)(这里就是判断边界,如果你的边界不是我的这个大小,自己修改一下就好了),如果触墙,则立即将游戏状态 ps->_status 设置为 KILL_BY_WALL。
KillBySelf 函数则通过遍历蛇头后面的所有蛇身节点 (pSnakeNode cur = ps->_pSnake->next;),检查是否有任何一个节点 (cur) 的坐标与当前蛇头坐标完全重合;如果发生重合(即蛇头咬到自己),则将游戏状态 ps->_status 设置为 KILL_BY_SELF。
一旦任一函数检测到碰撞,就会设置相应的状态,导致 GameRun 函数中的循环终止,游戏结束。
c
void KillByWall(pSnake ps)
{
if (ps->_pSnake->x == 0 || ps->_pSnake->x == 56 || ps->_pSnake->y == 0 || ps->_pSnake->y == 28)
{
ps->_status = KILL_BY_WALL;
}
}
c
void KillBySelf(pSnake ps)
{
pSnakeNode cur = ps->_pSnake->next;
while (cur)
{
if (cur->x == ps->_pSnake->x && cur->y == ps->_pSnake->y)
{
ps->_status = KILL_BY_SELF;
break;
}
cur = cur->next;
}
}
每次蛇移动都要检测是否结束哦!
3.6 游戏结束
游戏状态不再是OK(游戏继续)的时候,要告知游戏结束的原因,然后直接退出?
不不不!!!
你的蛇是链表啊!链表节点是动态申请的啊,它在堆 上,直接退出只把栈区 的内存空间归还给操作系统,你还得干啥呢?对喽,把节点释放了,把内存还给人家,有借有还,再借不难啊!
c
void GameEnd(pSnake ps)
{
SetPos(30, 14);
switch (ps->_status)
{
case END_NORMAL:
wprintf(L"主动结束游戏\n");
break;
case KILL_BY_WALL:
wprintf(L"撞到墙上,游戏结束\n");
break;
case KILL_BY_SELF:
wprintf(L"撞到了自己,游戏结束\n");
break;
}
//释放蛇身的链表
pSnakeNode cur = ps->_pSnake;
while (cur)
{
pSnakeNode del = cur;
cur = cur->next;
free(del);
}
}
四、总结
这里没写判断获胜的条件,因为我们当前这个空间有限,所以从概率上来讲,是由获胜的可能的,类似于这样:

这里呢,就当作一个小优化思路吧,还可以把蛇头用不一样的宽字符,每次都在蛇尾新增节点,这样视觉效果会更好!
通过实现C语言贪吃蛇小游戏,不仅将理论知识转化为实际代码,更深入理解了几个关键点:
- 链表是处理动态、顺序数据的理想结构,其头部插入和尾部删除的特性完美契合了蛇的移动需求。
- Win32 API是控制台程序交互的关键,它让我们能够突破标准C库的限制,实现对光标、窗口和键盘的精细控制。
- 正确的内存管理至关重要,特别是对链表的动态操作。
- 链表的动态内存分配: 无论是初始化蛇身 (
InitSnake)、创建食物 (CreateFood)、还是蛇移动时创建新的头节点 (SnakeMove),都需要使用malloc进行动态内存分配,确保每次操作都有足够的空间。 - 节点添加和删除: 在
NoFood中,我们通过free(cur->next)释放了尾部节点,防止内存泄漏。 - 游戏结束时的资源释放 (
GameEnd): 无论游戏是正常退出还是失败,都必须调用GameEnd函数。该函数会遍历整个蛇身链表,逐个释放所有动态分配的节点,彻底清理占用的内存。
- 链表的动态内存分配: 无论是初始化蛇身 (
如果你对这个感兴趣,可以尝试添加更多功能,例如墙体穿透、不同颜色区分蛇头与蛇身,或实现多种不同的食物类型!

