🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把“为什么会这样”和“应该怎样解决”说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。
🔥 本文定位:项目实践-贪吃蛇完成需求拆解、Windows 控制台基础、地图坐标、蛇身链表、初始化和食物生成。下一篇实现游戏循环与完整规则。
💡 学习目标:理解“屏幕字符格”和“游戏逻辑格”的换算,能解释为什么蛇身适合用链表,能写出不会在蛇身上生成食物的初始化流程。
📌 运行环境:Windows 控制台;源码采用 C11 和 Win32 API。文章的完整工程放在下篇的
code文件夹。●、★、□在常见中文等宽终端通常显示为两列;若终端字形宽度不同,应改用 ASCII 方块或统一调整坐标步长。
文章目录
- 一、先把需求变成可执行的规则
- 二、游戏由哪些模块组成
- 三、控制台坐标和 Win32 API
- 四、宽字符为什么影响坐标
- 五、地图的边界与逻辑格
- 六、用链表表示蛇身
- 七、游戏状态设计
- 八、初始化蛇身与地图
- 九、生成食物:随机也要有边界
- 十、初始化顺序与验证清单
- 总结
一、先把需求变成可执行的规则
“写一个贪吃蛇”听起来只是让圆点在屏幕上移动,真正落到 C 语言代码里,至少要回答七个问题:
- 蛇的每一节保存在哪里?
- 每隔多久移动一步?按键与自动移动如何同时进行?
- 新蛇头落在哪里?
- 吃到食物后怎样增长、加分并刷新食物?
- 没吃到食物时怎样保持长度不变?
- 撞墙和咬到自己发生在更新链表之前,还是之后?
- 游戏退出或重开时,动态申请的节点由谁释放?
这门课的目标是实现 Windows 控制台版本:方向键转向、吃食物、撞墙和自撞结束、计分、F3/F4 调速、空格暂停、Esc 退出。先把规则写清楚,再决定函数与数据结构。否则代码会逐渐变成一个塞满键盘判断、打印语句和链表操作的超长 main。

本文把坐标视作唯一的游戏事实。屏幕上的图形只是这些坐标的显示结果。不要根据屏幕上打印了什么反推蛇在哪里;更新规则应始终读取游戏数据。
二、游戏由哪些模块组成
课程将生命周期分为 GameStart、GameRun、GameEnd 三段。这个划分非常适合小项目:
| 阶段 | 主要工作 | 结束后的保证 |
|---|---|---|
GameStart | 设置控制台、画墙、创建初始蛇和食物 | 游戏状态完整,可以进入循环 |
GameRun | 读输入、计时、移动、判定、刷新状态 | 状态变为死亡、胜利或退出 |
GameEnd | 报告原因、释放蛇身、恢复光标 | 不留下本局申请的内存 |
进一步拆开,一次移动就是一条清晰的数据链:
当前蛇头 + 当前方向
↓
计算下一格坐标
↓
判断墙体、蛇身、食物
↓
更新链表与分数
↓
只重画发生变化的格子

这张流程图也指出一个关键顺序:先判断“下一格能不能走”,再修改链表。如果先插入新头,再发现它越界或撞身,清理和判定都会更绕。
三、控制台坐标和 Win32 API
3.1 COORD:屏幕上的位置
Windows 控制台以字符单元格为坐标:左上角是 (0, 0),向右 X 增大,向下 Y 增大。COORD 包含两个 SHORT 字段:
COORD pos = { 10, 5 }; /* 第 10 列、第 5 行 */
这里的 X 是控制台字符列,不是像素坐标,也不一定等于我们心中的“第几个游戏方格”。打印宽图形后,两个概念尤其容易混淆。
3.2 获取输出句柄并定位光标
课程使用 GetStdHandle(STD_OUTPUT_HANDLE) 获取控制台输出句柄,再用 SetConsoleCursorPosition 把打印位置移到指定坐标:
#include <windows.h>
static HANDLE g_output;
static void set_pos(short x, short y)
{
COORD pos = { x, y };
SetConsoleCursorPosition(g_output, pos);
}
int main(void)
{
g_output = GetStdHandle(STD_OUTPUT_HANDLE);
if (g_output == INVALID_HANDLE_VALUE)
return 1;
set_pos(10, 5);
return 0;
}
GetStdHandle 返回的是句柄,不能当作普通整数坐标使用。真实工程里也要检查调用是否成功;如果程序被重定向输出,窗口定位就不是合适的显示方式。
3.3 隐藏光标
每一帧都移动可见光标会让画面不断闪动。课程使用 GetConsoleCursorInfo 读取当前信息、修改 bVisible、再调用 SetConsoleCursorInfo:
CONSOLE_CURSOR_INFO old_info;
if (GetConsoleCursorInfo(g_output, &old_info))
{
CONSOLE_CURSOR_INFO hidden = old_info;
hidden.bVisible = FALSE;
SetConsoleCursorInfo(g_output, &hidden);
/* 游戏结束时,再用 old_info 恢复原设置。 */
}
记住“先保存旧值”这个习惯:程序不应让用户的终端在退出后仍然看不到光标。
3.4 读取键盘
课程用 GetAsyncKeyState 轮询虚拟键码,例如 VK_UP、VK_F3 和 VK_SPACE。它返回值的高位可表示当前是否按下:
if (GetAsyncKeyState(VK_UP) & 0x8000)
{
/* 上方向键当前处于按下状态 */
}
连续按住方向键可以持续生效;但空格、F3、F4 这类“一按只执行一次”的动作,需要保存上一次按键状态,只在“未按下 → 按下”的边沿触发。否则一次按键可能在几十次轮询中反复暂停或调速。课程示例用返回值最低位判断“按过”,这在现代输入处理里不宜作为唯一依据;下篇的工程使用显式的边沿检测。
把课程中用到的几个 API 放在一张表里,调试时会更容易定位问题:
| API | 输入 | 结果 | 典型用途 |
|---|---|---|---|
GetStdHandle | STD_OUTPUT_HANDLE | 输出句柄 | 找到控制台屏幕缓冲区 |
GetConsoleCursorInfo | 输出句柄、接收结构体 | 成功或失败 | 保存光标原状态 |
SetConsoleCursorInfo | 输出句柄、目标结构体 | 成功或失败 | 隐藏并最终恢复光标 |
SetConsoleCursorPosition | 输出句柄、COORD | 成功或失败 | 指定下一次绘制位置 |
WriteConsoleW | 输出句柄、宽字符串 | 写入字符数 | 输出地图、蛇和文字 |
GetAsyncKeyState | 虚拟键码 | 当前按键状态 | 方向与功能键轮询 |
这些函数属于显示与输入层。它们解决的是“让玩家看见、让程序知道按了什么”;蛇是否还能继续移动,仍由地图和状态规则决定。将两层分开,遇到“数据显示正常却画歪了”时,就知道先检查终端字形宽度和定位代码。

四、宽字符为什么影响坐标
课程用 □ 画墙、● 画蛇、★ 画食物。不要把三个概念混在一起:
sizeof(wchar_t)是 C 对象的字节数,在 Windows 上通常为 2;- 一个字符的编码长度取决于字符集与编码方式;
- 一个字形在终端中占几列,取决于终端与字体的显示规则。
“宽字符占两个字节,所以一定占两列”并不是普遍成立的推理。对本项目,真正需要验证的是:所用终端里 □、●、★ 是否都显示为两列。若是,我们把每一个逻辑格映射到两个字符列;若不是,换成固定宽度的 ASCII 绘制方式更稳妥。
课程先调用 setlocale(LC_ALL, "") 再用 wprintf。这里还要注意 C 标准流的方向:对同一个流混用 printf 和 wprintf 容易出问题。下篇完整工程改用 Win32 的 WriteConsoleW 输出游戏图形和文字,避免在同一个 stdout 上混用窄、宽标准 I/O。
static void draw_at(short x, short y, const wchar_t *text)
{
DWORD written = 0;
COORD pos = { x, y };
SetConsoleCursorPosition(g_output, pos);
WriteConsoleW(g_output, text, (DWORD)wcslen(text), &written, NULL);
}
这个函数仍然依赖 Windows 控制台。项目若要移植到 Linux 或支持重定向输出,就要另写终端显示层。
五、地图的边界与逻辑格
PDF 中地图外框坐标为:左 x=0、右 x=56、上 y=0、下 y=26。因此内部合法蛇头坐标是:
x ∈ {2, 4, 6, ..., 54}
y ∈ {1, 2, 3, ..., 25}
横向共有 (54 - 2) / 2 + 1 = 27 个逻辑格,纵向共有 25 格,地图总容量是 27 × 25 = 675 格。课程提到的“58 列”是打印外框所占的字符列范围;实际可供蛇与食物占据的是 27 × 25 个逻辑格。

我们可以用统一常量表达边界,避免在不同函数中散落 56、26、54 这样的数字:
enum
{
WALL_LEFT = 0,
WALL_RIGHT = 56,
WALL_TOP = 0,
WALL_BOTTOM = 26,
CELL_WIDTH = 2
};
static int is_inside(int x, int y)
{
return x > WALL_LEFT && x < WALL_RIGHT &&
y > WALL_TOP && y < WALL_BOTTOM &&
x % CELL_WIDTH == 0;
}
向上、向下各改 y 一行;向左、向右各改 x 两列:
| 方向 | 下一格坐标 |
|---|---|
| 上 | (x, y - 1) |
| 下 | (x, y + 1) |
| 左 | (x - 2, y) |
| 右 | (x + 2, y) |
如果把“向右”错写成 x + 1,蛇头会落在一个图形的半格处。地图、蛇、食物必须遵守同一坐标约定。
再用一个具体例子核对:初始蛇头是 (32,5),向右一步是 (34,5),向下一步是 (32,6)。这两个位置都必须是内部格。若不断向右,最后一个可站的蛇头位置是 (54,5);再走一步得到 (56,5),恰好触碰右墙。因此判定条件应使用 x < 56,不能误写成 x <= 56。纵向同理:y=25 是最后一行内部格,y=26 已经是下墙。
| 边界附近的坐标 | 含义 | 能否成为蛇头 |
|---|---|---|
(2,1) | 左上角的第一个内部格 | 可以 |
(54,25) | 右下角的最后一个内部格 | 可以 |
(0,5) | 左墙 | 不可以 |
(56,5) | 右墙 | 不可以 |
(4,0) | 上墙 | 不可以 |
(4,26) | 下墙 | 不可以 |
(3,5) | 两个逻辑格之间的半格 | 不可以 |
把这些点代入 is_inside() 手算一遍,是检查坐标常量的最快方式。特别是 (3,5):它虽然没有触碰墙,但并不是合法游戏格;只判断四面墙还不够,还需要 x % CELL_WIDTH == 0。
六、用链表表示蛇身
蛇身长度会在吃到食物时增长,因此课程用单链表保存每一节:
typedef struct SnakeNode
{
int x;
int y;
struct SnakeNode *next;
} SnakeNode;
头指针始终指向蛇头,沿 next 向后可以走到蛇尾:
head → (32,5) → (30,5) → (28,5) → (26,5) → (24,5) → NULL

为什么合适?正常移动可以“头插一个新结点,再删除尾结点”;吃食物时只头插,不删尾。链表节点的增删不要求搬移整段元素。不过,这并不代表链表是唯一选择。若使用数组或环形缓冲区,同样能实现贪吃蛇,而且更容易连续访问;本课程选择链表,是为了把此前学到的动态内存和指针真正用到项目里。
链表的所有权要明确:每个 SnakeNode 由游戏创建,离开蛇身时由游戏释放;整局结束时必须遍历释放剩余节点。食物只需要一个坐标和“是否存在”标志,不一定要动态分配一个节点。下篇工程据此把食物表示为 POINT food。
可以把蛇身链表看成一个有顺序的坐标序列:
| 结点位置 | 游戏含义 | 更新责任 |
|---|---|---|
head | 当前蛇头 | 每一步都会变化 |
| 中间结点 | 当前蛇身 | 普通移动不必逐个改坐标 |
next == NULL | 当前蛇尾 | 普通移动删除,吃食物时保留 |
这解释了为什么链表节点里只需 x、y 和 next,不需要给每个节点另存方向。整条蛇的下一次移动方向由游戏对象管理;具体形状由节点坐标与链接顺序表达。无论游戏暂停、加速还是重开,蛇身节点本身都不负责这些全局信息。
维护链表时要始终守住三个不变量:
head指向当前蛇头,若本局正常运行则不能为空;- 从
head沿next最终能到达NULL,不能意外成环; length等于实际可遍历的节点数,吃食物才增加,普通移动不变。
当某一步出现“画面多出一节”时,先遍历链表数节点,再看 length 与画面是否一致;这比只盯着绘制函数更快找出根因。
七、游戏状态设计
方向、游戏状态都适合用枚举。相比散落的魔法数字,代码意图更清楚:
typedef enum Direction
{
DIR_UP,
DIR_DOWN,
DIR_LEFT,
DIR_RIGHT
} Direction;
typedef enum GameStatus
{
GAME_RUNNING,
GAME_WALL,
GAME_SELF,
GAME_WIN,
GAME_QUIT,
GAME_ERROR
} GameStatus;
typedef struct SnakeGame
{
SnakeNode *head;
POINT food;
int has_food;
Direction direction; /* 下一次移动准备采用的方向 */
Direction moving_dir; /* 上一次真正移动的方向 */
GameStatus status;
int score;
int food_score;
DWORD step_ms;
size_t length;
} SnakeGame;
PDF 原稿中的状态为正常、撞墙、自撞、主动退出四类。这里补充“占满棋盘获胜”和“分配失败”两种状态,保证边界情况也能明确结束。direction 与 moving_dir 分开,是为了防止玩家在同一移动周期内快速按两个方向键,绕开“不能直接掉头”的限制。
把状态变化写成表格,外层循环的退出条件就非常直观:
| 当前状态 | 触发事件 | 下一状态 | 后续动作 |
|---|---|---|---|
GAME_RUNNING | 下一格是普通空格 | GAME_RUNNING | 头插、删尾 |
GAME_RUNNING | 下一格是食物 | GAME_RUNNING | 头插、加分、换食物 |
GAME_RUNNING | 下一格越过内框 | GAME_WALL | 停止循环并清理 |
GAME_RUNNING | 下一格与身体重合 | GAME_SELF | 停止循环并清理 |
GAME_RUNNING | 蛇占满 675 格 | GAME_WIN | 报告通关并清理 |
GAME_RUNNING | 玩家按 Esc | GAME_QUIT | 主动退出并清理 |
| 任意初始化阶段 | 资源申请失败 | GAME_ERROR | 释放已经创建的节点 |
其中“暂停”并不一定需要独立的死亡或结束状态。它只是本局仍在运行,但暂时不调用移动函数。保持 GAME_RUNNING,用局部 paused 标志控制计时即可;Esc 仍可在暂停期间生效。

八、初始化蛇身与地图
初始化过程按“环境 → 地图 → 蛇 → 食物”的顺序进行。地图的四条边分别位于上面给出的固定坐标。画边框时,每一个横向方块都从偶数列开始:
static void create_map(void)
{
for (int x = WALL_LEFT; x <= WALL_RIGHT; x += CELL_WIDTH)
{
draw_at((short)x, WALL_TOP, L"□");
draw_at((short)x, WALL_BOTTOM, L"□");
}
for (int y = WALL_TOP + 1; y < WALL_BOTTOM; ++y)
{
draw_at(WALL_LEFT, (short)y, L"□");
draw_at(WALL_RIGHT, (short)y, L"□");
}
}
初始蛇可以放在 y=5:头 (32,5),之后依次是 (30,5)、(28,5)、(26,5)、(24,5),默认向右。若用头插法,按 x=24,26,28,30,32 的顺序创建节点,最后插入的 (32,5) 自然成为蛇头。
游戏启动时还有三件和链表无关,却会直接影响体验的准备工作:
output = GetStdHandle(STD_OUTPUT_HANDLE);
if (output == INVALID_HANDLE_VALUE || output == NULL)
{
game->status = GAME_ERROR;
return 0;
}
DWORD console_mode;
if (!GetConsoleMode(output, &console_mode))
{
game->status = GAME_ERROR;
return 0; /* 输出被重定向时,无法使用当前绘制方式 */
}
system("mode con cols=100 lines=30");
SetConsoleTitleW(L"C 语言项目实战 - 贪吃蛇");
mode con 调整传统 Windows 控制台窗口大小;SetConsoleTitleW 设置标题。示例将游戏棋盘画在左侧,右侧留出提示和分数区。控制台尺寸与字体应在运行时检查:若窗口没有达到预期宽度,右侧文字可能换行,虽然蛇身数据仍然正确,但显示会很混乱。GetConsoleMode 能确认输出句柄确实连着控制台,避免把 WriteConsoleW 当作普通文件输出使用。
for (int i = 0; i < 5; ++i)
{
SnakeNode *node = malloc(sizeof *node);
if (node == NULL)
{
game->status = GAME_ERROR;
return; /* 调用方负责清理已经创建的节点 */
}
node->x = 24 + i * CELL_WIDTH;
node->y = 5;
node->next = game->head;
game->head = node;
++game->length;
}
“申请失败”不应只打印一条错误后继续运行。否则后续代码可能在半条蛇或空头指针上解引用。应设置错误状态,让外层统一收尾。
九、生成食物:随机也要有边界
食物的两个硬条件:落在地图内部,并且不与任一蛇身节点重合。PDF 使用随机抽取坐标、若与蛇重合就重抽的做法。蛇较短时很直观,但当蛇接近填满棋盘时,重复抽取可能耗时很久;棋盘已满时更会无限循环。
本项目采用“按空格序号随机抽取”的方式:
- 已知内部总容量
675,减去蛇长得到空格数; - 若空格数为 0,标记获胜;
- 从
0到空格数 - 1选择一个随机序号; - 按行扫描全部逻辑格,跳过被蛇占据的格子,找到对应空格;
- 保存食物坐标并绘制
★。

核心判断是遍历蛇链表:
static int occupied(const SnakeGame *game, int x, int y)
{
for (const SnakeNode *cur = game->head; cur != NULL; cur = cur->next)
{
if (cur->x == x && cur->y == y)
return 1;
}
return 0;
}
扫描生成一次食物的最坏复杂度是 O(棋盘格数 × 蛇长);675 格的小地图完全可接受。若以后地图明显扩大,可以维护空格集合或占用表,优化为更快的查找。这里优先让规则明确、结束条件完备。
下面是随文工程使用的完整食物生成逻辑。它不申请食物节点,只更新 POINT food:
static int spawn_food(SnakeGame *game)
{
if (game->length >= BOARD_CELLS)
{
game->has_food = 0;
game->status = GAME_WIN;
return 1;
}
int free_cells = BOARD_CELLS - (int)game->length;
int selected = rand() % free_cells;
for (int y = WALL_TOP + 1; y < WALL_BOTTOM; ++y)
{
for (int x = WALL_LEFT + CELL_WIDTH;
x < WALL_RIGHT;
x += CELL_WIDTH)
{
if (!occupied(game, x, y))
{
if (selected-- == 0)
{
game->food.x = x;
game->food.y = y;
game->has_food = 1;
draw_cell(x, y, L"★");
return 1;
}
}
}
}
game->status = GAME_ERROR;
return 0;
}
selected 代表“选中第几个空格”,而不是原始数组中的第几个坐标。每遇到一个未被蛇占据的格子就递减一次,到零时停下。这样无论蛇弯成什么形状,食物都不会落到已有节点上。这里使用 rand() % free_cells 是教学示例中的简单随机抽取;它可能存在轻微取模偏差,但不影响游戏规则。若做竞技或统计用途的随机分布,应换成质量更高、范围映射更严格的随机数方案。
还要注意播种位置:程序启动时调用一次 srand((unsigned)time(NULL)) 即可。若每次创建食物都重新以当前秒数播种,短时间内连续吃食物时反而容易反复得到相似的伪随机序列。
十、初始化顺序与验证清单
最终初始化应满足下面的前置条件链:
有效的控制台句柄
→ 窗口与光标设置完成
→ 地图边框已绘制
→ 初始蛇链表非空且坐标合法
→ 食物已在空白逻辑格生成
→ GAME_RUNNING,可以进入游戏循环

建议启动后先验证以下五件事,再写移动代码:
- 上下墙的端点分别是
x=0与x=56,左右墙不缺角; - 蛇头位于最右侧,默认方向也是向右;
- 所有蛇节点与食物的
x都是偶数; - 食物不落在墙上或蛇身上;
- 退出前可以释放初始化过程中已经成功创建的节点。
如果只画出地图就急于写碰撞逻辑,后续会很难判断问题究竟来自坐标、绘制还是规则。把这五项单独验证,能让下篇的游戏循环建立在稳定的数据上。
总结
贪吃蛇上篇最重要的收获,是把“控制台图形”还原为一组可验证的坐标与数据结构:
- 用
GameStart、GameRun、GameEnd管理整局生命周期; - 区分控制台字符列与游戏逻辑格,横向移动必须遵守两列一步;
- 用
COORD和SetConsoleCursorPosition定位,用 Win32 宽字符输出绘图; - 用单链表保存蛇身,蛇头是链表头,蛇尾是最后一个节点;
- 初始化食物时排除蛇身,棋盘满时明确宣布获胜;
- 在开始移动前,先检查句柄、坐标、内存与状态是否完整。
下一篇,我们将进入最关键的一步:把新蛇头接到链表上,决定何时删除蛇尾,并处理吃食物、自撞、撞墙、暂停、调速和重开。
如果这篇文章对你有帮助,欢迎点赞、收藏并关注。
下一篇:贪吃蛇(下)——游戏循环与完整可运行工程。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_64304261/article/details/169149790




