星光编译者头像
【C语言项目实战】贪吃蛇(上):从控制台坐标到链表建模,把游戏真正搭起来封面图

【C语言项目实战】贪吃蛇(上):从控制台坐标到链表建模,把游戏真正搭起来

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把“为什么会这样”和“应该怎样解决”说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

星光编译者博客开场动画

🔥 本文定位:项目实践-贪吃蛇完成需求拆解、Windows 控制台基础、地图坐标、蛇身链表、初始化和食物生成。下一篇实现游戏循环与完整规则。

💡 学习目标:理解“屏幕字符格”和“游戏逻辑格”的换算,能解释为什么蛇身适合用链表,能写出不会在蛇身上生成食物的初始化流程。

📌 运行环境:Windows 控制台;源码采用 C11 和 Win32 API。文章的完整工程放在下篇的 code 文件夹。●、★、□ 在常见中文等宽终端通常显示为两列;若终端字形宽度不同,应改用 ASCII 方块或统一调整坐标步长。


文章目录


一、先把需求变成可执行的规则

“写一个贪吃蛇”听起来只是让圆点在屏幕上移动,真正落到 C 语言代码里,至少要回答七个问题:

  1. 蛇的每一节保存在哪里?
  2. 每隔多久移动一步?按键与自动移动如何同时进行?
  3. 新蛇头落在哪里?
  4. 吃到食物后怎样增长、加分并刷新食物?
  5. 没吃到食物时怎样保持长度不变?
  6. 撞墙和咬到自己发生在更新链表之前,还是之后?
  7. 游戏退出或重开时,动态申请的节点由谁释放?

这门课的目标是实现 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输入结果典型用途
GetStdHandleSTD_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,不需要给每个节点另存方向。整条蛇的下一次移动方向由游戏对象管理;具体形状由节点坐标与链接顺序表达。无论游戏暂停、加速还是重开,蛇身节点本身都不负责这些全局信息。

维护链表时要始终守住三个不变量:

  1. head 指向当前蛇头,若本局正常运行则不能为空;
  2. 从 head 沿 next 最终能到达 NULL,不能意外成环;
  3. 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玩家按 EscGAME_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 使用随机抽取坐标、若与蛇重合就重抽的做法。蛇较短时很直观,但当蛇接近填满棋盘时,重复抽取可能耗时很久;棋盘已满时更会无限循环。

本项目采用“按空格序号随机抽取”的方式:

  1. 已知内部总容量 675,减去蛇长得到空格数;
  2. 若空格数为 0,标记获胜;
  3. 从 0 到 空格数 - 1 选择一个随机序号;
  4. 按行扫描全部逻辑格,跳过被蛇占据的格子,找到对应空格;
  5. 保存食物坐标并绘制 ★。

在这里插入图片描述

核心判断是遍历蛇链表:

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 都是偶数;
  • 食物不落在墙上或蛇身上;
  • 退出前可以释放初始化过程中已经成功创建的节点。

如果只画出地图就急于写碰撞逻辑,后续会很难判断问题究竟来自坐标、绘制还是规则。把这五项单独验证,能让下篇的游戏循环建立在稳定的数据上。


总结

贪吃蛇上篇最重要的收获,是把“控制台图形”还原为一组可验证的坐标与数据结构:

  1. 用 GameStart、GameRun、GameEnd 管理整局生命周期;
  2. 区分控制台字符列与游戏逻辑格,横向移动必须遵守两列一步;
  3. 用 COORD 和 SetConsoleCursorPosition 定位,用 Win32 宽字符输出绘图;
  4. 用单链表保存蛇身,蛇头是链表头,蛇尾是最后一个节点;
  5. 初始化食物时排除蛇身,棋盘满时明确宣布获胜;
  6. 在开始移动前,先检查句柄、坐标、内存与状态是否完整。

下一篇,我们将进入最关键的一步:把新蛇头接到链表上,决定何时删除蛇尾,并处理吃食物、自撞、撞墙、暂停、调速和重开。


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

下一篇:贪吃蛇(下)——游戏循环与完整可运行工程。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_64304261/article/details/169149790

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--