🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位:承接上篇的控制台地图与蛇身链表,实现方向控制、自动移动、吃食物、撞墙、自撞、计分、调速、暂停、退出和重开。
💡 学习目标:看懂"头插 + 删除尾巴"为何能模拟前进;分清"长一节"和"保持原长度";能解释碰撞检查顺序、尾格特例和资源释放。
📌 代码说明 :附带
code/snake.h、code/snake.c、code/main.c,面向 Windows 控制台,使用 C11 与 Win32 API。PDF 的三阶段设计仍然保留,但部分边界处理做了完善。
文章目录
- 一、一局游戏如何循环
- 二、方向键与禁止原地掉头
- 三、一次移动的完整决策
- 四、没吃食物:头插与删尾
- 五、吃到食物:只增不删
- 六、碰撞判定与蛇尾特例
- 七、暂停、调速与计分
- 八、结束、重开和资源释放
- 九、项目文件与运行方式
- 十、边界测试与常见问题
- 十一、完整源码
- 总结
一、一局游戏如何循环
上篇建立了 SnakeGame,初始化了地图、蛇和食物。现在游戏状态为 GAME_RUNNING,程序需要不断执行三件事:读取输入 → 等待移动间隔 → 前进一步。
text
while (游戏正常)
读取方向 / 暂停 / 调速 / 退出
如果尚未到移动时间,短暂休眠后再检查输入
如果到达移动时间且未暂停,执行一步移动

PDF 示例采用 Sleep(移动间隔) 然后移动。这个做法足以说明原理,但一次休眠可能达到几百毫秒,期间键盘响应也随之变慢。附带工程改成每 10 毫秒轮询一次输入,用 GetTickCount() 计算距离上次移动过去多久:
c
DWORD last_step = GetTickCount();
while (game->status == GAME_RUNNING)
{
/* 处理按键,可能修改方向、速度、暂停状态。 */
if (!paused && GetTickCount() - last_step >= game->step_ms)
{
move_one_step(game);
last_step = GetTickCount();
}
Sleep(10);
}
DWORD 是无符号类型,GetTickCount() 的回绕仍可通过无符号减法计算短时间间隔。这里的 10 毫秒只是输入轮询间隔,不是蛇每一步的移动时间。蛇的移动间隔由 step_ms 决定,初始为 200 毫秒。
如果把输入读取放到 Sleep(200) 之后,玩家在这 200 毫秒内按一下很快松开,轮询时可能已经读不到该键。把短轮询与移动计时分开,可以明显改善操作感;但它仍然是轮询方案,极短的按键若恰好落在两次轮询之间,仍可能被漏掉。需要更可靠的输入记录时,可进一步改用控制台输入事件队列。本教程先保持课程原有的 Win32 API 学习主线。
二、方向键与禁止原地掉头
若蛇正向右走,下一步不能直接向左;正向上走也不能直接向下。否则蛇头会立刻撞到紧跟在后面的身体。四组方向里,互为反方向的只有两对:
c
static int opposite(Direction a, Direction b)
{
return (a == DIR_UP && b == DIR_DOWN) ||
(a == DIR_DOWN && b == DIR_UP) ||
(a == DIR_LEFT && b == DIR_RIGHT) ||
(a == DIR_RIGHT && b == DIR_LEFT);
}
课程代码用当前方向检查输入,这在普通按键节奏下可行;但如果玩家在同一次移动前快速按"上、左",变量方向可能从右改为上,再改为左,实际移动时就掉了头。工程里保存两种方向:
moving_dir:上一次真正移动采用的方向;direction:下一步准备采用的方向。
判断新输入时始终和 moving_dir 比较,直到移动执行后才更新 moving_dir。这样一个移动周期最多只能相对上一步转向 90 度。

方向键适合检测"当前是否按下":
c
if (game->direction == game->moving_dir &&
(GetAsyncKeyState(VK_UP) & 0x8000) &&
!opposite(DIR_UP, game->moving_dir))
{
game->direction = DIR_UP;
}
附带工程在一次移动前只接受第一次有效转向。若两个方向键同时按下,按"上、下、左、右"的检查顺序选第一个有效方向,并等到下一次实际移动后才允许新的转向。更复杂的游戏可以改成按键事件队列,但本项目不需要。
三、一次移动的完整决策
以蛇头 (32,5) 向右走为例,下一格是 (34,5)。真正更新链表之前,先做三项判定:
(34,5)是否在内框?否则是撞墙;- 是否与蛇身重合?否则是自撞;
- 是否与食物坐标相同?决定蛇是否增长。

坐标计算遵守上篇的 CELL_WIDTH = 2:
c
static void next_position(const SnakeGame *game, int *x, int *y)
{
*x = game->head->x;
*y = game->head->y;
switch (game->direction)
{
case DIR_UP: --*y; break;
case DIR_DOWN: ++*y; break;
case DIR_LEFT: *x -= CELL_WIDTH; break;
case DIR_RIGHT: *x += CELL_WIDTH; break;
}
}
这里的 x、y 是"预备中的新蛇头坐标",还没有创建节点。因此发现死亡状态时只改 status 并返回,不需要撤销链表操作。
四、没吃食物:头插与删尾
看一条长度为 5 的蛇:
text
移动前: H → A → B → C → T → NULL
头插后: N → H → A → B → C → T → NULL
删尾后: N → H → A → B → C → NULL
新头 N 是下一格;删除旧尾 T 后,长度仍为 5。屏幕只需把旧尾画成两个空格,把新头画成 ●,旧身体无需整条重画。

关键代码是找到倒数第二个节点,断链后释放旧尾:
c
SnakeNode *new_head = malloc(sizeof *new_head);
if (new_head == NULL)
{
game->status = GAME_ERROR;
return;
}
new_head->x = next_x;
new_head->y = next_y;
new_head->next = game->head;
game->head = new_head;
SnakeNode *prev = game->head;
while (prev->next->next != NULL)
prev = prev->next;
SnakeNode *old_tail = prev->next;
prev->next = NULL;
draw_cell(old_tail->x, old_tail->y, L" ");
free(old_tail);
draw_cell(next_x, next_y, L"●");
在这个项目中初始蛇长为 5,普通移动保持长度,因此 prev->next->next 的访问有长度前提。若把这段逻辑提取为通用链表函数,还应显式处理长度为 0 或 1 的情况。
有一个值得单独记住的图形细节:当新头走进本步即将离开的旧尾格,先画新头再清尾会把新头抹掉。附带工程把"画新头"放在清尾之后再次执行,确保最后画面正确。
五、吃到食物:只增不删
如果下一格正好是食物,更新方式变为:
text
移动前: H → A → B → C → T → NULL 食物位于 N
吃到后: N → H → A → B → C → T → NULL
头插新节点,但不删除尾节点,所以蛇长增加 1。与此同时:
length加 1;score加上当前食物分值;- 标记旧食物已经被吃掉;
- 在剩余空格中生成新食物;
- 如果没有空格,就进入
GAME_WIN。

示意代码如下:
c
if (eating)
{
++game->length;
game->score += game->food_score;
game->has_food = 0;
spawn_food(game);
}
为什么不给食物也分配一个 SnakeNode?它只需要一个坐标,没有 next 的结构意义。课程为了复用节点结构,给食物也申请节点;本工程让 POINT food 只表达坐标,减少分配、释放与空指针状态。
六、碰撞判定与蛇尾特例
撞墙不必真的把蛇头插到墙上,再检查是否撞墙。最简单的判定是下一格必须满足:
c
x > 0 && x < 56 && y > 0 && y < 26 && x % 2 == 0
自撞则要遍历蛇身,比较坐标。但有一个容易误判的边界:普通移动时可以走进当前蛇尾所在格。因为旧尾会在同一步被删掉,新蛇头和旧尾并不会同时留在最终链表中。
c
for (SnakeNode *cur = game->head; cur != NULL; cur = cur->next)
{
if (cur->x == next_x && cur->y == next_y &&
(eating || cur->next != NULL))
{
game->status = GAME_SELF;
return;
}
}
cur->next == NULL 表示它是尾节点。如果本步没吃食物,尾节点会离开,可以放行;若本步吃食物,尾不会离开,则仍应视为碰撞。虽然食物不可能在已有蛇尾上,这个条件写全有利于保持算法语义清楚。
把全部判断和链表更新放在一起,完整的一步移动函数如下。对照"先判定 → 后分配 → 最后绘制",会比逐行背诵更容易理解:
c
static void move_one_step(SnakeGame *game)
{
int x, y, eating;
SnakeNode *cur, *node;
next_position(game, &x, &y);
game->moving_dir = game->direction;
if (!inside(x, y))
{
game->status = GAME_WALL;
return;
}
eating = game->has_food &&
x == game->food.x && y == game->food.y;
for (cur = game->head; cur; cur = cur->next)
{
if (cur->x == x && cur->y == y &&
(eating || cur->next != NULL))
{
game->status = GAME_SELF;
return;
}
}
node = malloc(sizeof *node);
if (!node)
{
game->status = GAME_ERROR;
return;
}
node->x = x;
node->y = y;
node->next = game->head;
game->head = node;
draw_cell(x, y, L"●");
if (eating)
{
++game->length;
game->score += game->food_score;
game->has_food = 0;
spawn_food(game);
}
else
{
SnakeNode *prev = game->head;
while (prev->next->next)
prev = prev->next;
cur = prev->next;
prev->next = NULL;
draw_cell(cur->x, cur->y, L" ");
free(cur);
draw_cell(x, y, L"●");
}
draw_sidebar(game);
}
这一版还有一个好处:若撞墙或自撞,尚未调用 malloc,不会出现"明明已经死亡,却还要处理一个刚分配的新节点"的额外状态。若申请新节点失败,原蛇链表保持完整,结束函数仍能统一释放它。

PDF 采用"先完成移动,再调用撞墙、自撞函数"的流程,教学上便于拆函数。这里调整为"先判定,再创建新头",让死亡路径不会短暂绘制越界或重叠图形,也省去回滚和额外分配。
七、暂停、调速与计分
7.1 空格应按一次切换一次
如果每轮都检测"空格当前按下",按住 0.1 秒就可能触发十次暂停切换。我们记录上一次键状态,只检测上升沿:
c
static int pressed_once(int key, int *was_down)
{
int is_down = (GetAsyncKeyState(key) & 0x8000) != 0;
int edge = is_down && !*was_down;
*was_down = is_down;
return edge;
}
空格按下后切换 paused。暂停期间仍读按键,以便继续或退出,但不调用 move_one_step。解除暂停时重置 last_step,避免因暂停时间很长而立即触发一大串"补偿移动"。
7.2 F3/F4 调整的是时间间隔
step_ms 越小,蛇越快。示例初始值为 200 毫秒,F3 每次减 30 毫秒,最低 80 毫秒;F4 每次加 30 毫秒,最高 320 毫秒。当前食物分值随速度在 2 到 18 之间变化。这里把两个值都明确限幅,保证按键再多也不会出现零毫秒、负分或无边界的加速。
c
if (pressed_once(VK_F3, &faster_down) && game->step_ms > 80)
{
game->step_ms -= 30;
if (game->food_score < 20)
game->food_score += 2;
}

每次吃食物时记下当时 的 food_score,已获得的 score 不因日后降速而改变。
把暂停和调速放回主循环,可以看到"输入"和"移动"确实分成两条路径。下面保留核心分支,完整版本可直接查看随文 code/snake.c:
c
int paused = 0;
int pause_down = 0;
int faster_down = 0;
int slower_down = 0;
DWORD last_step = GetTickCount();
while (game->status == GAME_RUNNING)
{
consider_direction(game, VK_UP, DIR_UP);
consider_direction(game, VK_DOWN, DIR_DOWN);
consider_direction(game, VK_LEFT, DIR_LEFT);
consider_direction(game, VK_RIGHT, DIR_RIGHT);
if (pressed_once(VK_SPACE, &pause_down))
{
paused = !paused;
last_step = GetTickCount();
}
if (GetAsyncKeyState(VK_ESCAPE) & 0x8000)
{
game->status = GAME_QUIT;
break;
}
if (pressed_once(VK_F3, &faster_down) && game->step_ms > 80)
{
game->step_ms -= 30;
if (game->food_score < 18)
game->food_score += 2;
}
if (pressed_once(VK_F4, &slower_down) && game->step_ms < 320)
{
game->step_ms += 30;
if (game->food_score > 2)
game->food_score -= 2;
}
if (!paused && GetTickCount() - last_step >= game->step_ms)
{
move_one_step(game);
last_step = GetTickCount();
}
Sleep(10);
}
pressed_once() 每轮都调用,即便速度已经达到边界,也会照常更新 was_down。这样玩家松开后再按才会被视为一次新操作。paused 为真时,方向键和 Esc 仍能被处理,但蛇不迈步。恢复时把计时起点放到当前时刻,让玩家有完整的一个移动间隔来调整方向。
如果以后要添加"开始菜单""道具""关卡",也应沿着这个思路扩展:把新的输入翻译为状态变化,再由定时移动读取状态。不要在键盘分支里直接跳过碰撞判定、偷偷移动一次蛇头,否则同一套规则会在两个地方复制,后续很难保持一致。
八、结束、重开和资源释放
游戏可能因撞墙、自撞、主动退出、占满地图或内存分配失败而结束。结束函数先根据 status 输出原因,再释放所有仍在链表上的蛇身节点:
c
SnakeNode *cur = game->head;
while (cur != NULL)
{
SnakeNode *next = cur->next;
free(cur);
cur = next;
}
game->head = NULL;
game->length = 0;
为什么先存 next?free(cur) 之后再访问 cur->next 是释放后使用。让指针先越过待释放节点,再释放旧节点,才是安全的销毁顺序。

重开时在 main 中创建新的 SnakeGame game = {0},重新运行初始化、游戏循环与清理。随机数种子 srand 只在程序启动时设置一次,不在每局、每次生成食物时反复设置。控制台光标的旧状态在开始时保存,在结束时恢复。
九、项目文件与运行方式
完整工程随文章一起保存:
text
第30讲-项目实践-贪吃蛇(下)/
├─ 第30讲-项目实践-贪吃蛇(下)-CSDN精修版.md
├─ assets/ # 封面和正文图的 PNG、SVG
└─ code/
├─ snake.h # 数据结构、状态和边界常量
├─ snake.c # 绘图、输入、移动、食物与释放
├─ main.c # 单局与重开入口
└─ README.md # 编译与控制说明
在 Windows 的 Visual Studio 开发者命令提示符中进入 code 文件夹后运行:
bat
cl /nologo /std:c11 /W4 /TC main.c snake.c /Fe:snake.exe /link user32.lib
snake.exe
若使用 MinGW-w64:
bat
gcc -std=c11 -Wall -Wextra -pedantic main.c snake.c -o snake.exe -luser32
snake.exe
按方向键控制,空格暂停/继续,F3 加速,F4 减速,Esc 退出本局。结束后按 Y 再来一局,按 N 或 Esc 结束程序。建议使用支持 Unicode 的 Windows 控制台,并选择可将 □●★ 一致显示为双列的字体。若图形错位,把三个图形统一替换为固定宽度的 ASCII 两字符图块即可。
这一工程在 Windows 控制台上通过 Win32 API 绘制,因此不能直接拿到 Linux 终端编译。算法层的坐标、链表、碰撞与食物逻辑仍可复用;移植时主要替换控制台绘制和键盘输入两层。
十、边界测试与常见问题
项目完成后,不要只验证"能向右走"。至少按下面的顺序手动测试:
| 场景 | 期待结果 |
|---|---|
| 刚开局 | 五节蛇、一个食物,蛇头最右侧,分数 0 |
| 立即按左 | 不会直接掉头 |
| 一个移动间隔内快速按上、左 | 仍不会相对上一步直接掉头 |
| 一直向右 | 到内框外下一步结束,提示撞墙 |
| 走到食物 | 长度加 1,分数增加,生成新食物 |
| 按住空格 | 只切换一次暂停状态 |
| 暂停很久再继续 | 不会瞬间连走多步 |
| 连续按 F3/F4 | 时间间隔和食物分值受上下限约束 |
| Esc 退出后按 Y | 新局从五节蛇、0 分重新开始 |

三个常见故障可以这样定位:
图形错位 :先看终端字体里 □●★ 是否都占两列,再看所有 x 是否为偶数。不要只改"蛇的移动步长",而忘记墙和食物也共用同一坐标系。
蛇尾留痕 :确认普通移动时清除的是旧尾坐标,写入两个空格;确认释放旧尾后把前一个节点的 next 设为 NULL。
重开后行为异常 :确认上局链表已经释放,游戏结构体重新清零,食物状态重新生成,srand 不在每局重复调用。
10.1 再从复杂度看一次设计
这份实现优先追求规则容易理解,性能足够覆盖 675 格的小地图。设当前蛇长为 L、棋盘内部格数为 B=675:
| 操作 | 时间复杂度 | 原因 |
|---|---|---|
| 计算下一格 | O(1) |
只读取蛇头和方向 |
| 判断是否撞墙 | O(1) |
四条边界比较 |
| 判断是否撞身 | O(L) |
遍历蛇链表 |
| 普通移动 | O(L) |
需要找到尾节点的前一个节点 |
| 吃食物 | O(B × L) 的生成上界 |
扫描空格时逐格检查蛇身 |
| 结束时清理 | O(L) |
每个节点释放一次 |
为什么普通移动不是 O(1)?单链表只保存了 head,没有从尾部倒着找到前一个节点的能力。维护一个尾指针也不能直接知道尾的前驱,因此想把这一步稳定做到常数时间,可考虑双向链表、数组环形队列,或者给每个逻辑格维护占用标记并换一种身体序列表示。对于课程中的小棋盘,这些优化不是必须的,但理解瓶颈来自哪里,是从"能运行"走向"会设计"的一步。
10.2 一组可以手推的边界
假设蛇身坐标依次是 (8,5) → (6,5) → (6,6) → (8,6),蛇头在 (8,5)。下一步若向下走到 (8,6),它恰好是旧尾:普通移动允许,因为同一步删尾;若把该格误认为普通身体,就会产生"明明没有咬到自己却死亡"的假阳性。
再看 (10,5) 是食物的情况。蛇向右后链表成为 (10,5) → (8,5) → ... → (8,6),旧尾仍在。此时如果你错误地执行了删尾,画面长度就没有增长;如果忘记更新 length,生成食物时计算出的空格数也会失真。链表、长度、画面三者必须在同一步保持一致。
十一、完整源码
以下代码与同目录 code 文件夹中的三个文件一致。复制时按文件名分别保存,并在 Windows 控制台中按上一节命令编译。
11.1 snake.h
c
#ifndef SNAKE_H
#define SNAKE_H
#include <stddef.h>
#include <windows.h>
enum
{
WALL_LEFT = 0,
WALL_RIGHT = 56,
WALL_TOP = 0,
WALL_BOTTOM = 26,
CELL_WIDTH = 2,
BOARD_CELLS = 27 * 25
};
typedef struct SnakeNode
{
int x;
int y;
struct SnakeNode *next;
} SnakeNode;
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;
int snake_start(SnakeGame *game);
void snake_run(SnakeGame *game);
void snake_end(SnakeGame *game);
#endif
11.2 snake.c
c
#include "snake.h"
#include <stdio.h>
#include <stdlib.h>
#include <wchar.h>
static HANDLE output;
static CONSOLE_CURSOR_INFO previous_cursor;
static int cursor_saved;
static void draw_at(short x, short y, const wchar_t *text)
{
DWORD written;
COORD pos = { x, y };
SetConsoleCursorPosition(output, pos);
WriteConsoleW(output, text, (DWORD)wcslen(text), &written, NULL);
}
static void draw_cell(int x, int y, const wchar_t *glyph)
{
draw_at((short)x, (short)y, glyph);
}
static int inside(int x, int y)
{
return x > WALL_LEFT && x < WALL_RIGHT &&
y > WALL_TOP && y < WALL_BOTTOM &&
x % CELL_WIDTH == 0;
}
static void draw_map(void)
{
int x, y;
for (x = WALL_LEFT; x <= WALL_RIGHT; x += CELL_WIDTH)
{
draw_cell(x, WALL_TOP, L"□");
draw_cell(x, WALL_BOTTOM, L"□");
}
for (y = WALL_TOP + 1; y < WALL_BOTTOM; ++y)
{
draw_cell(WALL_LEFT, y, L"□");
draw_cell(WALL_RIGHT, y, L"□");
}
}
static int occupied(const SnakeGame *game, int x, int y)
{
const SnakeNode *node;
for (node = game->head; node; node = node->next)
if (node->x == x && node->y == y)
return 1;
return 0;
}
static int spawn_food(SnakeGame *game)
{
int free_cells, selected, x, y;
if (game->length >= BOARD_CELLS)
{
game->has_food = 0;
game->status = GAME_WIN;
return 1;
}
free_cells = BOARD_CELLS - (int)game->length;
selected = rand() % free_cells;
for (y = WALL_TOP + 1; y < WALL_BOTTOM; ++y)
{
for (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;
}
static void draw_sidebar(const SnakeGame *game)
{
wchar_t line[64];
draw_at(62, 3, L"C 语言项目实战 · 贪吃蛇");
draw_at(62, 5, L"方向键 移动");
draw_at(62, 6, L"空格 暂停 / 继续");
draw_at(62, 7, L"F3/F4 加速 / 减速");
draw_at(62, 8, L"Esc 退出本局");
swprintf(line, sizeof line / sizeof line[0], L"得分:%-6d", game->score);
draw_at(62, 11, line);
swprintf(line, sizeof line / sizeof line[0], L"每个食物:%-3d", game->food_score);
draw_at(62, 12, line);
swprintf(line, sizeof line / sizeof line[0], L"步长:%-3lu ms", (unsigned long)game->step_ms);
draw_at(62, 13, line);
}
int snake_start(SnakeGame *game)
{
int i;
DWORD console_mode;
SnakeNode *node;
output = GetStdHandle(STD_OUTPUT_HANDLE);
if (output == INVALID_HANDLE_VALUE || output == NULL ||
!GetConsoleMode(output, &console_mode))
{
output = INVALID_HANDLE_VALUE;
game->status = GAME_ERROR;
return 0;
}
system("mode con cols=100 lines=30");
SetConsoleTitleW(L"C 语言项目实战 - 贪吃蛇");
cursor_saved = GetConsoleCursorInfo(output, &previous_cursor);
if (cursor_saved)
{
CONSOLE_CURSOR_INFO hidden = previous_cursor;
hidden.bVisible = FALSE;
SetConsoleCursorInfo(output, &hidden);
}
system("cls");
game->status = GAME_RUNNING;
game->direction = DIR_RIGHT;
game->moving_dir = DIR_RIGHT;
game->score = 0;
game->food_score = 10;
game->step_ms = 200;
draw_map();
for (i = 0; i < 5; ++i)
{
node = malloc(sizeof *node);
if (!node)
{
game->status = GAME_ERROR;
return 0;
}
node->x = 24 + i * CELL_WIDTH;
node->y = 5;
node->next = game->head;
game->head = node;
++game->length;
draw_cell(node->x, node->y, L"●");
}
draw_sidebar(game);
return spawn_food(game);
}
static int opposite(Direction a, Direction b)
{
return (a == DIR_UP && b == DIR_DOWN) ||
(a == DIR_DOWN && b == DIR_UP) ||
(a == DIR_LEFT && b == DIR_RIGHT) ||
(a == DIR_RIGHT && b == DIR_LEFT);
}
static void consider_direction(SnakeGame *game, int key, Direction choice)
{
if (game->direction == game->moving_dir &&
(GetAsyncKeyState(key) & 0x8000) &&
!opposite(choice, game->moving_dir))
game->direction = choice;
}
static int pressed_once(int key, int *was_down)
{
int is_down = (GetAsyncKeyState(key) & 0x8000) != 0;
int edge = is_down && !*was_down;
*was_down = is_down;
return edge;
}
static void next_position(const SnakeGame *game, int *x, int *y)
{
*x = game->head->x;
*y = game->head->y;
switch (game->direction)
{
case DIR_UP: --*y; break;
case DIR_DOWN: ++*y; break;
case DIR_LEFT: *x -= CELL_WIDTH; break;
case DIR_RIGHT: *x += CELL_WIDTH; break;
}
}
static void move_one_step(SnakeGame *game)
{
int x, y, eating;
SnakeNode *cur, *node;
next_position(game, &x, &y);
game->moving_dir = game->direction;
if (!inside(x, y))
{
game->status = GAME_WALL;
return;
}
eating = game->has_food && x == game->food.x && y == game->food.y;
/* 普通移动可以进入当前蛇尾:尾结点会在本步移走。 */
for (cur = game->head; cur; cur = cur->next)
{
if (cur->x == x && cur->y == y &&
(eating || cur->next != NULL))
{
game->status = GAME_SELF;
return;
}
}
node = malloc(sizeof *node);
if (!node)
{
game->status = GAME_ERROR;
return;
}
node->x = x;
node->y = y;
node->next = game->head;
game->head = node;
draw_cell(x, y, L"●");
if (eating)
{
++game->length;
game->score += game->food_score;
game->has_food = 0;
spawn_food(game);
}
else
{
SnakeNode *prev = game->head;
while (prev->next->next)
prev = prev->next;
cur = prev->next;
prev->next = NULL;
/* 若新头走进了旧蛇尾,清尾会抹掉新头,故须再画一次。 */
draw_cell(cur->x, cur->y, L" ");
free(cur);
draw_cell(x, y, L"●");
}
draw_sidebar(game);
}
void snake_run(SnakeGame *game)
{
int pause_down = 0, faster_down = 0, slower_down = 0;
int paused = 0;
DWORD last_step = GetTickCount();
while (game->status == GAME_RUNNING)
{
consider_direction(game, VK_UP, DIR_UP);
consider_direction(game, VK_DOWN, DIR_DOWN);
consider_direction(game, VK_LEFT, DIR_LEFT);
consider_direction(game, VK_RIGHT, DIR_RIGHT);
if (pressed_once(VK_SPACE, &pause_down))
{
paused = !paused;
draw_at(62, 16, paused ? L"已暂停,空格继续" : L" ");
last_step = GetTickCount();
}
if (GetAsyncKeyState(VK_ESCAPE) & 0x8000)
{
game->status = GAME_QUIT;
break;
}
if (pressed_once(VK_F3, &faster_down) && game->step_ms > 80)
{
game->step_ms -= 30;
if (game->food_score < 18)
game->food_score += 2;
}
if (pressed_once(VK_F4, &slower_down) && game->step_ms < 320)
{
game->step_ms += 30;
if (game->food_score > 2)
game->food_score -= 2;
}
if (!paused && GetTickCount() - last_step >= game->step_ms)
{
move_one_step(game);
last_step = GetTickCount();
}
Sleep(10);
}
}
void snake_end(SnakeGame *game)
{
SnakeNode *cur = game->head;
if (output != INVALID_HANDLE_VALUE && output != NULL)
{
const wchar_t *message = L"游戏结束";
switch (game->status)
{
case GAME_WALL: message = L"撞到墙壁,本局结束"; break;
case GAME_SELF: message = L"咬到自己,本局结束"; break;
case GAME_WIN: message = L"填满地图,恭喜通关"; break;
case GAME_QUIT: message = L"已主动退出本局"; break;
case GAME_ERROR: message = L"初始化或内存分配失败"; break;
default: break;
}
draw_at(62, 19, message);
if (game->status != GAME_ERROR)
draw_at(62, 21, L"Y 再来一局 / N 结束");
}
while (cur)
{
SnakeNode *next = cur->next;
free(cur);
cur = next;
}
game->head = NULL;
game->length = 0;
if (cursor_saved && output != INVALID_HANDLE_VALUE && output != NULL)
SetConsoleCursorInfo(output, &previous_cursor);
}
11.3 main.c
c
#include "snake.h"
#include <stdlib.h>
#include <time.h>
int main(void)
{
int again = 1;
srand((unsigned int)time(NULL));
while (again)
{
SnakeGame game = { 0 };
if (!snake_start(&game))
{
snake_end(&game);
return EXIT_FAILURE;
}
snake_run(&game);
snake_end(&game);
/* 每局结束后读取一次当前键状态,等待松开再接受新按键。 */
while ((GetAsyncKeyState('Y') & 0x8000) ||
(GetAsyncKeyState('N') & 0x8000) ||
(GetAsyncKeyState(VK_ESCAPE) & 0x8000))
Sleep(10);
for (;;)
{
if (GetAsyncKeyState('Y') & 0x8000)
{
again = 1;
break;
}
if ((GetAsyncKeyState('N') & 0x8000) ||
(GetAsyncKeyState(VK_ESCAPE) & 0x8000))
{
again = 0;
break;
}
Sleep(20);
}
}
return 0;
}
总结
整个贪吃蛇项目可以凝练为两个链表动作:
没吃食物:头插新节点、删掉旧尾;吃到食物:只头插、不删尾。
其余规则围绕这个动作展开:
- 按键决定方向和速度,定时器决定什么时候移动;
- 新蛇头坐标先通过边界、自撞与食物判定,再写入链表;
- 普通移动允许进入本步会离开的旧蛇尾格;
- 暂停和调速类按键应以"按下边沿"触发;
- 每个动态节点都必须在离开蛇身或本局结束时释放;
- 画面由数据状态驱动,移动时只更新变化的格子。
学完这两篇,获得的不只是一个小游戏:你已经把结构体、枚举、动态内存、链表、函数拆分、控制台 API 和边界测试串成了一个可以运行、可以解释、也可以继续改造的小工程。
如果这篇文章对你有帮助,欢迎点赞、收藏并关注。
下一次项目练习,我们继续把基础语法变成真正可运行的作品。