Appearance
第 20 讲:实战——贪吃蛇游戏
⏱️ 本讲 L1 内容建议分 3 次 学习:第 1 次理解设计与数据结构,第 2、3 次动手把三个文件敲完并跑通。贪吃蛇是本课程第一个综合项目,会用到链表、枚举、结构体、动态内存、Win32 API 等多项知识。📘 与末尾 🔬 拓展可按需跳过。
平台限制
贪吃蛇使用 Windows 控制台 API(windows.h)实现光标定位、按键检测、隐藏光标等,只能在 Windows 上编译运行,Linux/macOS 需要改用 ncurses 等库。代码经 MinGW gcc 16(C11)验证零警告通过——原课件"建议用 C++ 编译"的说法已过时,纯 C 即可。
🎯 学完本讲,你将能够
- 为一个真实小游戏设计数据结构:用链表存蛇身、用枚举表示方向和游戏状态
- 说清贪吃蛇的三大模块(开始/运行/结束)各自负责什么
- 解释蛇身移动的本质——"头插新节点 + 视情况删除尾节点"
- 使用
SetPos、GetAsyncKeyState等 Win32 API 在控制台"任意位置打印"和"检测按键" - 独立排查"蛇身错位、内存泄漏、方向反转"等典型问题
🔗 先修知识
结构体与自引用(第 14 讲)、动态内存 malloc/free(第 15 讲)、枚举(第 14 讲)、链表思想(第 22 讲会深入,本讲先用起来)、多文件组织(第 19 讲)。
自检三问:
- 一个结构体里为什么可以包含指向同类型结构体的指针?
malloc出来的内存如果忘了free会怎样?- 单向链表如何在不遍历全表的情况下在头部插入一个节点?
一、游戏要做成什么样
在 Windows 控制台里实现经典贪吃蛇,具备以下功能:
- 绘制地图(围墙),蛇在墙内活动;
- 方向键 ↑↓←→ 控制蛇移动,吃到食物蛇身变长、得分增加;
- 撞墙或咬到自己 → 游戏结束;
- F3 加速、F4 减速(速度越快单个食物分值越高);
- 空格暂停、ESC 退出;
- 结束后可选择再来一局。
技术要点:函数、枚举、结构体、动态内存、预处理、链表、Win32 API。
二、数据结构设计(本讲灵魂)
2.1 为什么用链表存蛇身
蛇每吃一个食物就长一节,长度动态变化。数组要么开得很大、要么频繁扩容,都不合适。而单向链表天然适合"头部加一节、尾部删一节"的操作:
c
typedef struct SnakeNode
{
int x; // 该节蛇身在控制台上的列坐标
int y; // 行坐标
struct SnakeNode* next; // 指向下一节蛇身
} SnakeNode, *pSnakeNode;链表从蛇头到蛇尾依次串联。蛇头就是 _pSnake 指向的第一个节点。
2.2 用结构体管理整局游戏
把散落的"蛇、食物、方向、状态、分数、速度"打包进一个 Snake 结构,函数间只需传一个指针:
c
typedef struct Snake
{
pSnakeNode _pSnake; // 蛇身链表头
pSnakeNode _pFood; // 当前食物节点
enum DIRECTION _Dir; // 蛇头运动方向,默认向右
enum GAME_STATUS _Status; // 游戏状态
int _Score; // 当前得分
int _foodWeight; // 单个食物分值(随速度变化)
int _SleepTime; // 每走一步的休眠毫秒数(越小越快)
} Snake, *pSnake;2.3 两个枚举
c
enum DIRECTION { UP = 1, DOWN, LEFT, RIGHT };
enum GAME_STATUS
{
OK, // 正常运行
KILL_BY_WALL, // 撞墙而死
KILL_BY_SELF, // 咬到自己而死
END_NOMAL // 玩家主动退出
};用枚举而非魔法数字,代码可读性和类型安全都更好(第 14 讲原理)。
2.4 地图与坐标
- 控制台坐标系:横向 X 轴从左向右,纵向 Y 轴从上向下,原点
(0,0)在左上角。 - 地图约 27 行 × 58 列,四周画墙。
- 墙、蛇身、食物用宽字符
□ ● ★,各占 2 字节,需setlocale(LC_ALL, "")开启本地模式、用wprintf输出。 - ⚠️ 关键约定:蛇身和食物的 x 坐标必须是 2 的倍数,否则一个宽字符节点会横跨半格,与墙对不齐。
三、游戏流程设计
整个游戏分三大模块,main 里循环调用:
text
GameStart → 设置窗口/隐藏光标/欢迎界面/画地图/初始化蛇/生成食物
GameRun → 循环:读按键 → 调整方向/速度/暂停 → 移动蛇 → 判定死亡
GameEnd → 打印结束原因 → 释放全部蛇身节点与食物四、代码实现(三个文件)
| 文件 | 作用 |
|---|---|
snake.h | 宏、枚举、结构体、函数声明 |
snake.c | 各函数实现 |
test.c | main 入口与"再来一局"循环 |
4.1 snake.h
🧩 snake.h(多文件源文件片段) · 需与 snake.c、test.c 同目录一起编译
c
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <windows.h>
/* 检测某虚拟键是否被按过:返回值最低位为 1 即按过 */
#define KEY_PRESS(VK) ((GetAsyncKeyState(VK) & 0x1) ? 1 : 0)
enum DIRECTION { UP = 1, DOWN, LEFT, RIGHT };
enum GAME_STATUS { OK, KILL_BY_WALL, KILL_BY_SELF, END_NOMAL };
#define WALL L'□'
#define BODY L'●'
#define FOOD L'★'
#define POS_X 24
#define POS_Y 5
typedef struct SnakeNode { int x; int y; struct SnakeNode* next; } SnakeNode, *pSnakeNode;
typedef struct Snake {
pSnakeNode _pSnake;
pSnakeNode _pFood;
enum DIRECTION _Dir;
enum GAME_STATUS _Status;
int _Score;
int _foodWeight;
int _SleepTime;
} Snake, *pSnake;
void SetPos(short x, short y);
void WelcomeToGame(void);
void PrintHelpInfo(void);
void CreateMap(void);
void InitSnake(pSnake ps);
void CreateFood(pSnake ps);
void pause_game(void);
int NextIsFood(pSnakeNode psn, pSnake ps);
void EatFood(pSnakeNode psn, pSnake ps);
void NoFood(pSnakeNode psn, pSnake ps);
int KillByWall(pSnake ps);
int KillBySelf(pSnake ps);
void SnakeMove(pSnake ps);
void GameStart(pSnake ps);
void GameRun(pSnake ps);
void GameEnd(pSnake ps);4.2 关键函数解析(snake.c 节选)
🧩 光标定位(示意片段) · 贪吃蛇能"在任意位置画"的基础
c
/* 把控制台光标移到 (x,y),下一次打印就出现在那里 */
void SetPos(short x, short y)
{
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
COORD pos = { x, y };
SetConsoleCursorPosition(hOutput, pos);
}🧩 初始化蛇身(示意片段) · 头插法建 5 节蛇
c
void InitSnake(pSnake ps)
{
for (int i = 0; i < 5; i++)
{
pSnakeNode cur = (pSnakeNode)malloc(sizeof(SnakeNode));
if (cur == NULL) { perror("InitSnake malloc"); return; }
cur->next = NULL;
cur->x = POS_X + i * 2; /* x 为 2 的倍数,保证对齐 */
cur->y = POS_Y;
/* 头插法 */
if (ps->_pSnake == NULL)
ps->_pSnake = cur;
else { cur->next = ps->_pSnake; ps->_pSnake = cur; }
}
/* 打印蛇身、初始化状态字段(省略) */
ps->_SleepTime = 200;
ps->_Score = 0;
ps->_Status = OK;
ps->_Dir = RIGHT;
ps->_foodWeight = 10;
}🧩 蛇身移动的核心(示意片段) · 理解"移动 = 头插 + 视情况删尾"
c
void SnakeMove(pSnake ps)
{
pSnakeNode pNextNode = (pSnakeNode)malloc(sizeof(SnakeNode));
if (pNextNode == NULL) { perror("SnakeMove malloc"); return; }
/* 1. 根据方向,用蛇头坐标算出"新蛇头"坐标 */
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;
}
/* 2. 新蛇头正好是食物 → 吃(保留尾巴,蛇变长);否则 → 走一步(删尾巴) */
if (NextIsFood(pNextNode, ps)) EatFood(pNextNode, ps);
else NoFood(pNextNode, ps);
/* 3. 移动后判定是否死亡 */
KillByWall(ps);
KillBySelf(ps);
}🧩 没吃到食物时(示意片段) · 删尾节点,注意置空 next
c
void NoFood(pSnakeNode psn, pSnake ps)
{
psn->next = ps->_pSnake; /* 新头插到链表最前 */
ps->_pSnake = psn;
pSnakeNode cur = ps->_pSnake;
while (cur->next->next) /* 停在倒数第二个节点 */
{
SetPos((short)cur->x, (short)cur->y);
wprintf(L"%c", BODY);
cur = cur->next;
}
SetPos((short)cur->next->x, (short)cur->next->y);
printf(" "); /* 原尾巴位置擦除 */
free(cur->next); /* 释放尾巴 */
cur->next = NULL; /* ⚠️ 关键:必须置空,否则野指针 */
}📘 提高(L2):撞墙与撞自己的判定
c
int KillByWall(pSnake ps)
{
if (ps->_pSnake->x == 0 || ps->_pSnake->x == 56
|| ps->_pSnake->y == 0 || ps->_pSnake->y == 26)
{ ps->_Status = KILL_BY_WALL; return 1; }
return 0;
}
int KillBySelf(pSnake ps)
{
pSnakeNode cur = ps->_pSnake->next; /* 从第二节开始和蛇头比 */
while (cur)
{
if (ps->_pSnake->x == cur->x && ps->_pSnake->y == cur->y)
{ ps->_Status = KILL_BY_SELF; return 1; }
cur = cur->next;
}
return 0;
}蛇头坐标落在墙上(边界值)即撞墙;蛇头与身体任一节坐标重合即咬到自己。这两处逻辑已用脱离 Win32 的纯 C 单元测试验证(撞墙、撞自己、安全区、左移四例全通过)。
4.3 游戏结束与内存释放
🧩 GameEnd(示意片段) · 释放蛇身与食物,杜绝泄漏
c
void GameEnd(pSnake ps)
{
pSnakeNode cur = ps->_pSnake;
SetPos(24, 12);
switch (ps->_Status)
{
case END_NOMAL: printf("您主动退出游戏\n"); break;
case KILL_BY_SELF: printf("您撞上自己了, 游戏结束!\n"); break;
case KILL_BY_WALL: printf("您撞墙了, 游戏结束!\n"); break;
default: break;
}
while (cur) { pSnakeNode del = cur; cur = cur->next; free(del); }
if (ps->_pFood) { free(ps->_pFood); ps->_pFood = NULL; } /* 别忘了食物 */
}📘 提高(L2):原课件两处内存缺陷(本讲已修正)
- 食物泄漏:原版
GameEnd只释放蛇身,最后一个食物节点从未释放。每局结束泄漏一个SnakeNode。上面代码补了free(ps->_pFood)。 - 食物 next 未初始化:原版
CreateFood里malloc出的食物节点没有pFood->next = NULL,虽然当前逻辑没遍历它,但属于隐患。修正版建节点后立即置空 next。
养成习惯:malloc 出来的每个节点,未使用的指针成员一律显式初始化。
五、如何编译运行
MinGW gcc(命令行,纯 C)——三个文件同目录:
bash
gcc -Wall -Wextra -std=c11 test.c snake.c -o snake.exe
snake.exeVS:新建空项目,把 snake.h、snake.c、test.c 添加进项目,Ctrl+F5 运行。
游戏是交互式的,需要真实按键操作,无法像扫雷那样脚本化自动通关;本讲用脱离 Win32 的纯逻辑单元测试验证了移动方向与死亡判定,用完整三文件编译验证了 Win32 调用无误。
⚠️ 常见坑与报错表
| 现象 | 原因 | 对策 |
|---|---|---|
| 蛇身/食物与墙错开半格 | x 坐标不是 2 的倍数 | 生成坐标后强制 x = x / 2 * 2 或 while(x%2) 重取 |
宽字符 □●★ 打印成乱码 | 未调用 setlocale(LC_ALL, "") | main 开头设置本地模式 |
| 蛇"瞬移"或方向诡异 | 同一帧内连续改方向导致反向(如向右时按左) | 每个方向判断都排除其反方向(_Dir != 反向) |
| 游戏结束偶发崩溃 | NoFood 删尾后没把 cur->next = NULL | 释放尾巴后必须置空 |
| 长时间玩内存涨 | 食物/结束节点未释放 | 见 4.3 修正,GameEnd 释放食物 |
windows.h 找不到 / 链接失败 | 在非 Windows 环境编译 | 本讲代码仅限 Windows |
| 按一次键蛇跳好几格 | GetAsyncKeyState 轮询间隔太短 | 每步 Sleep(_SleepTime) 后再检测 |
🛠️ 动手练习
📊 本讲网页练习进度0 / 8(0%)
进度自动保存在本浏览器;编程题不计数,请在编辑器中完成。
第 1 题(知识点:数据结构选择 · 难度:⭐)
蛇身用单向链表而非数组,最主要的原因是?
第 2 题(知识点:移动本质 · 难度:⭐⭐)
蛇"向前走一步但没吃到食物",在链表上的操作是?
第 3 题(知识点:坐标对齐 · 难度:⭐⭐)
蛇身和食物的 x 坐标必须是 2 的倍数,因为?
第 4 题(知识点:方向反转防护 · 难度:⭐⭐)
GameRun 中判断 KEY_PRESS(VK_UP) && ps->_Dir != DOWN 才允许向上,加 _Dir != DOWN 是为了?
第 5 题(知识点:内存管理 · 难度:⭐⭐)
本讲修正了原课件的哪个内存问题?
第 6 题(知识点:删尾置空 · 难度:⭐⭐)
NoFood 中 free(cur->next); 之后紧跟 cur->next = NULL;,若删掉这行赋值会怎样?
第 7 题(知识点:Win32 打印原理 · 难度:⭐)
贪吃蛇能把蛇身、食物"画"在控制台任意位置,靠的是哪个 API 的能力?
空①
第 8 题(知识点:综合扩展 · 难度:⭐⭐⭐)
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
给贪吃蛇增加最高分记录功能:用文件保存历史最高分,重启后仍能看到。
✅ 过关标准:
- 本局得分超过历史最高分时,把新记录写入文件(如
best.txt); - 程序启动时读取该文件并显示历史最高分;文件不存在时按 0 处理且不崩溃;
- 编译零警告,运行不泄漏(文件指针
fclose)。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
💡 思路(卡住再点开)
用第 16 讲的 fopen/fprintf/fscanf。启动时以 "r" 打开 best.txt 读入 best,读失败(文件不存在)则 best=0;每局结束若 score > best,以 "w" 打开写回。注意每次 fopen 判空、用完 fclose。
🧭 步骤
- 增加
int LoadBest(const char* file)与void SaveBest(const char* file, int best); main开头读一次 best,GameEnd 里比较并可能保存;- 用
fscanf("%d", &best)的返回值判断是否真的读到了数字; - 删除 best.txt 验证"文件不存在不崩溃"。
🔑 点击查看参考答案
c
#include <stdio.h>
int LoadBest(const char *file)
{
int best = 0;
FILE *fp = fopen(file, "r");
if (fp == NULL)
return 0; /* 文件不存在,按 0 处理 */
if (fscanf(fp, "%d", &best) != 1)
best = 0; /* 内容非法,回到 0 */
fclose(fp);
return best;
}
void SaveBest(const char *file, int best)
{
FILE *fp = fopen(file, "w");
if (fp == NULL) { perror("SaveBest"); return; }
fprintf(fp, "%d", best);
fclose(fp);
}在 main 里:int best = LoadBest("best.txt");,每局结束 if (score > best) { best = score; SaveBest("best.txt", best); }。实测:首次无文件时 best=0 不崩溃;写入后重开能正确读回。
📝 小结与自测
本讲脉络:
- 蛇身用单向链表(长度动态、头插删尾 O(1)),整局状态用
Snake结构 + 两个枚举管理。 - 移动的本质:造新蛇头节点 → 吃到食物就头插保留尾巴,没吃到就头插并删尾。
- 控制台"任意位置画图"靠
SetConsoleCursorPosition(封装为 SetPos);宽字符需setlocale。 - 死亡判定:蛇头撞边界=撞墙,蛇头与身体重合=咬自己;方向判断要排除 180° 掉头。
- 内存纪律:
NoFood删尾后置空 next;GameEnd要同时释放蛇身和食物。
自测三问:
- 为什么蛇身适合用链表而不是数组?
- "吃到食物"和"没吃到食物"两种情况,链表操作有什么不同?
- 本讲修正了原课件哪两处内存问题?
🔑 自测答案
- 蛇身长度随吃食物不断变化,链表头部插入、尾部删除只需修改指针,O(1) 且无需预分配大空间;数组插入/删除要整体搬移或固定容量。
- 都先头插新蛇头;没吃到食物时额外删除尾节点(长度不变),吃到食物时不删尾(长度 +1)并重新生成食物、加分。
- ①GameEnd 漏释放食物节点 → 补
free(_pFood);②CreateFood 的食物节点 next 未初始化 → 建节点后置next = NULL。
🔬 选学拓展(L3)
WARNING
以下为进阶思考,供学有余力的同学选学。
拓展 1:改用双向循环链表会怎样
当前是无环单向链表,删尾巴时必须从蛇头遍历到倒数第二节才能找到"待删节点的前驱",每步 O(n)。若改成双向循环链表:
- 节点增加
prev指针,尾节点的next指回头、头节点的prev指向尾; - 维护一个
_pTail指针,删尾就是tail = tail->prev; tail->next = head; head->prev = tail;,O(1); - 找前驱不再需要遍历。
代价是每个节点多存一个指针、插入删除要维护更多链接。这正是第 22 讲双向链表的应用场景。
拓展 2:跨平台怎么办
windows.h 的 API 是 Windows 独有的。要在 Linux/macOS 上做同样的控制台游戏,常见替代:
- ncurses:跨平台终端 UI 库,提供光标移动、按键读取、清屏等;
- 自行封装一层"平台适配接口"(SetPos、按键检测等),Windows 用 Win32 实现、类 Unix 用 ncurses 实现,游戏主逻辑不含任何平台代码——这是可移植架构的基本功。
拓展 3:为什么用 GetAsyncKeyState 而不是 getchar
getchar 是"阻塞式、需回车"的输入,不适合实时游戏。GetAsyncKeyState(VK_xxx) 能非阻塞地查询某键此刻是否被按下,游戏主循环每帧轮询它即可实现"按住方向键持续转向"。代价是要配合 Sleep 控制轮询节奏,否则一次按键会被读到多次。
📜 标准卡(L2 选读):本讲依据
- 枚举、自引用结构体、动态内存:C11 §6.7.2.1(结构体)、§6.7.2.2(枚举)、§7.22.3(malloc/free)。
setlocale与宽字符输出:C11 §7.11(本地化)、§7.19.4(wprintf)。windows.h、GetAsyncKeyState、SetConsoleCursorPosition等均为 Win32 API,非 C 标准,仅 Windows 可用。