【C语言项目实战】贪吃蛇(下):移动、碰撞与计分,完成一局可玩的游戏

🔥 星光编译者 · 个人主页

📚 学习专栏: 《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)。真正更新链表之前,先做三项判定:

  1. (34,5) 是否在内框?否则是撞墙;
  2. 是否与蛇身重合?否则是自撞;
  3. 是否与食物坐标相同?决定蛇是否增长。

坐标计算遵守上篇的 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。与此同时:

  1. length 加 1;
  2. score 加上当前食物分值;
  3. 标记旧食物已经被吃掉;
  4. 在剩余空格中生成新食物;
  5. 如果没有空格,就进入 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;
}

总结

整个贪吃蛇项目可以凝练为两个链表动作:

没吃食物:头插新节点、删掉旧尾;吃到食物:只头插、不删尾。

其余规则围绕这个动作展开:

  1. 按键决定方向和速度,定时器决定什么时候移动;
  2. 新蛇头坐标先通过边界、自撞与食物判定,再写入链表;
  3. 普通移动允许进入本步会离开的旧蛇尾格;
  4. 暂停和调速类按键应以"按下边沿"触发;
  5. 每个动态节点都必须在离开蛇身或本局结束时释放;
  6. 画面由数据状态驱动,移动时只更新变化的格子。

学完这两篇,获得的不只是一个小游戏:你已经把结构体、枚举、动态内存、链表、函数拆分、控制台 API 和边界测试串成了一个可以运行、可以解释、也可以继续改造的小工程。


如果这篇文章对你有帮助,欢迎点赞、收藏并关注。

下一次项目练习,我们继续把基础语法变成真正可运行的作品。

相关推荐
qq_401700412 小时前
Qt Modbus 源码剖析:从 QModbusClient 到工业级通信框架
开发语言·qt·系统架构
星夜离尘562 小时前
MATLAB 角度解缠遇到丢样:先分段,再说明圈数为什么不可恢复
开发语言·matlab
我命由我123452 小时前
6-2.Python 异常处理 - 异常捕获(捕获指定异常、常见异常类型、异常信息)
java·运维·开发语言·python·http·https·java-ee
ZoeLandia2 小时前
React08:自定义 Hook
开发语言·前端·react.js·typescript
C++ 老炮儿的技术栈2 小时前
机器人的坐标,像 XYZ 的坐标值,是用单精度浮点还是用双精度浮点?
c语言·c++·人工智能·单片机·c
染指11102 小时前
157.Agent-LangGraph-工具(实现ReAct Agent模式)
开发语言·人工智能·python·langchain·agents
Solange__2 小时前
城市更新方案怎么用AI生成3D街区预演?从底图、体块到公众网页展示
人工智能·游戏·3d·ai·自然语言处理·1024程序员节·关卡设计
程序猿乐锅2 小时前
【从0-1学习Python | 第九篇】继承
开发语言·后端·python·学习·fastapi
wdfk_prog2 小时前
lwip教程 38:从 `lwipopts.h` 到 Port Contract——lwIP 裁剪、构建选择、OS Port 与 Network Port
java·开发语言