文章目录
-
- 最终效果展示
- 1. 控制台不是画布,但也有坐标
- 2. 项目用到的 Win32 控制台 API
-
- 2.1 `windows.h` 与 Win32 API
- 2.2 `mode` 与 `title` 命令
- 2.3 `COORD`:表示一个坐标
- 2.4 `HANDLE` 与 `GetStdHandle`
- 2.5 `CONSOLE_CURSOR_INFO`与`GetConsoleCursorInfo`
- 2.6 `SetConsoleCursorPosition` 与封装函数
- 2.7 隐藏光标:`CONSOLE_CURSOR_INFO`
- 2.8 `Sleep`:让循环慢下来
- 2.9 `GetAsyncKeyState` 与按键宏
- 3. 宽字符与本地化设置
- 4. 用结构体和单链表保存游戏状态
-
- 4.1 一个蛇身节点
- 4.2 整体状态结构体
- 5. `GameStart`:按顺序完成开局
-
- 5.1 欢迎页的定位输出
- 5.2 `CreateMap`:四条边框
- 6. `InitSnake`:用头插法建立五节蛇
- 7. `CreateFood`:随机、对齐、避开蛇身
- 8. 上篇小结:已经搭好可运行的“场景”
最终效果展示
代码库链接:贪吃蛇源码
在下篇最后附有源码,也可直接在代码仓库查看
这款游戏启动后先显示欢迎界面和操作提示,再在控制台左侧绘制58列宽、27行高的地图,右侧显示分数与游戏相关帮助信息。蛇用 ● 表示,食物用 ★ 表示。
此篇作为上篇,先解决“场景怎样画出来”的问题:控制台坐标怎样工作、宽字符配合本地化、链表如何保存蛇身,以及开局函数如何把这些部件组装起来。
1. 控制台不是画布,但也有坐标
在图形界面里,我们会习惯用像素坐标定位按钮;控制台则把窗口划成“字符单元格”。(0, 0) 位于左上角,横坐标 x 向右增加,纵坐标 y 向下增加。Snake.c 中地图边界是 x=0、x=56、y=0、y=26,因此内部可活动区域落在它们之间。 
图中每个方格代表一次光标定位。中文或 □、●、★ 这样的宽字符通常占两个显示列,所以源码让横向坐标每次增加 2:蛇向右移动时 x + 2,而上下移动时 y ± 1。如果把横坐标也只加 1,字符会挤在一起,地图看起来就会倾斜。
2. 项目用到的 Win32 控制台 API
2.1 windows.h 与 Win32 API
Win32 API 是 Windows 提供的一组系统函数。它们不是 C 语言关键字,而是由 Windows SDK 提供的声明和实现。包含头文件后,编译器才知道 HANDLE、COORD、Sleep 等名字的类型和函数原型:
#include <windows.h>
这些接口决定了项目的运行平台:源码可以在 Visual Studio 的 Windows 控制台中运行,但不能直接在 Linux 终端上按原样编译。
2.2 mode 与 title 命令
GameStart() 一开始执行:
system("mode con cols=100 lines=30");
system("title 贪吃蛇");
mode con cols=100 lines=30 把控制台设置为 100 列、30 行;title 贪吃蛇 修改窗口标题。 system 会让操作系统启动命令解释器执行字符串,因此写法简单。
官方链接参考(可能需要魔法上网打开):
- mode命令
- title命令
2.3 COORD:表示一个坐标
Windows 定义的 COORD 是一个结构体:
typedef struct _COORD {
SHORT X;
SHORT Y;
} COORD;
使用时可以直接初始化:
COORD pos = { 10, 4 }; // 第 10 列、第 4 行
官方链接参考:COORD
2.4 HANDLE 与 GetStdHandle
句柄(handle)可以解释为 Windows 用来代表某个系统对象的“编号”,也可理解为是Windows用来控制项目所用到的控制台的把手,有了具体的把手才能够精准的控制。标准输出句柄由下面的函数取得:
HANDLE GetStdHandle(DWORD nStdHandle);
项目传入 STD_OUTPUT_HANDLE:
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
得到的 hOutput 指向当前控制台输出设备。句柄不是普通的字符串或文件指针,不要自行解引用;把它交给对应的 Win32 API 使用即可。
官方链接参考: GetStdHandle
2.5 CONSOLE_CURSOR_INFO与GetConsoleCursorInfo
CONSOLE_CURSOR_INFO结构体中包含有关控制台光标的信息:
typedef struct _CONSOLE_CURSOR_INFO {
DWORD dwSize;
BOOL bVisible;
} CONSOLE_CURSOR_INFO, *PCONSOLE_CURSOR_INFO;
- dwSize:由光标填充的字符单元格的百分比。此值介于 1 到 100 之间。光标外观会变化,范围从完全填充单元格到单元底部的水平线条。
- bVisible:游标的可见性。如果光标可见,则此成员为 TRUE。
CursorInfo.bVisible = false; //隐藏控制台光标
GetConsoleCursorInfo则用于检索有关指定控制台屏幕缓冲区的光标大小和可见性的信息:
BOOL WINAPI GetConsoleCursorInfo(
HANDLE hConsoleOutput,
PCONSOLE_CURSOR_INFO lpConsoleCursorInfo
);
PCONSOLE_CURSOR_INFO 是指向 CONSOLE_CURSOR_INFO 结构的指针。
HANDLE hOutput = NULL;
//获取标准输出的句柄(用来标识不同设备的数值)
hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(hOutput, &CursorInfo);//获取控制台光标信息
官方链接参考: GetConsoleCursorInfo
2.6 SetConsoleCursorPosition 与封装函数
函数原型是:
BOOL SetConsoleCursorPosition(HANDLE hConsoleOutput, COORD dwCursorPosition);
它把光标移动到指定坐标。项目把重复操作封装为:
void SetPos(short x, short y)
{
COORD pos = { x, y };
HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE);
SetConsoleCursorPosition(hOutput, pos);
}
以后只需写 SetPos(62, 10); printf("得分:%d", score);,就能在右侧信息栏更新分数,而不用清屏重画整个地图。
官方参考链接:SetConsoleCursorPosition

2.7 隐藏光标:CONSOLE_CURSOR_INFO
控制台光标信息结构体包含两个成员:光标大小 dwSize 和是否可见 bVisible。相关函数原型为:
BOOL GetConsoleCursorInfo(
HANDLE hConsoleOutput,
PCONSOLE_CURSOR_INFO lpConsoleCursorInfo
);
BOOL SetConsoleCursorInfo(
HANDLE hConsoleOutput,
const CONSOLE_CURSOR_INFO *lpConsoleCursorInfo
);
项目先读取原值,再只修改可见性:
CONSOLE_CURSOR_INFO cursor_info;
GetConsoleCursorInfo(houtput, &cursor_info);
cursor_info.bVisible = FALSE;
SetConsoleCursorInfo(houtput, &cursor_info);
传入 &cursor_info 是因为函数需要通过指针写回结构体。初学者常见错误是把结构体变量写成 cursor_info 传入,类型就不匹配了。
2.8 Sleep:让循环慢下来
void Sleep(DWORD dwMilliseconds);
参数单位是毫秒。Sleep(200) 会暂停约 0.2 秒,项目把它作为蛇每走一步的间隔。睡眠时间越小,速度越快;但过小会让输入和刷新压力增大。这里的 Sleep 是 Windows API,不是标准 C 的 sleep。
2.9 GetAsyncKeyState 与按键宏
函数原型:
SHORT GetAsyncKeyState(int vKey);
vKey 是虚拟键码,例如:
- 上:VK_UP
- 下:VK_DOWN
- 左:VK_LEFT
- 右:VK_RIGHT
- 空格:VK_SPACE
- ESC:VK_ESCAPE
- F3:VK_F3
- F4:VK_F4
项目定义:
#define KEY_PRESS(VK) ((GetAsyncKeyState(VK) & 0x1) ? 1 : 0)
这样 KEY_PRESS(VK_UP) 就能在循环中判断按键。低位表示“自上次查询后是否按过”,适合事件式轮询。 代码示例:
//测试键盘按键信号
void test1()
{
while (1)
{
if (KEY_PRESS(0x30))
{
printf("0\\n");
}
else if (KEY_PRESS(0x31))
{
printf("1\\n");
}
else if (KEY_PRESS(0x32))
{
printf("2\\n");
}
else if (KEY_PRESS(0x33))
{
printf("3\\n");
}
else if (KEY_PRESS(0x34))
{
printf("4\\n");
}
else if (KEY_PRESS(0x35))
{
printf("5\\n");
}
else if (KEY_PRESS(0x36))
{
printf("6\\n");
}
else if (KEY_PRESS(0x37))
{
printf("7\\n");
}
else if (KEY_PRESS(0x38))
{
printf("8\\n");
}
else if (KEY_PRESS(0x39))
{
printf("9\\n");
}
}
}
运行示例: 
官方参考链接:
- GetAsyncKeyState
- 虚拟键码(Winuser.h)- Win32 apps
3. 宽字符与本地化设置
Snake.h 中的图形宏是:
#define WALL L'□'
#define BODY L'●'
#define FOOD L'★'
L'●' 的 L 表示宽字符常量,对应 wchar_t。输出时使用:
wprintf(L"%lc", BODY);
test.c 在进入游戏前调用:
setlocale(LC_ALL, "");
它让 C 运行库根据系统区域设置处理中文和宽字符。若省略这一步,中文可能乱码,宽字符输出也可能不符合预期。
4. 用结构体和单链表保存游戏状态
4.1 一个蛇身节点
typedef struct SnakeNode
{
int x;
int y;
struct SnakeNode *next;
} SnakeNode, *pSnakeNode;
每个节点只记录一个身体方块的坐标,并用 next 指向下一个节点。五节蛇身就是五个节点串成的链表:蛇头在链表头部,蛇尾在最后。
4.2 整体状态结构体
typedef struct Snake
{
pSnakeNode _pSnake;
pSnakeNode _pFood;
enum DIRECTION _Dir;
enum GAME_STATUS _Status;
int _Score;
int _foodWeight;
int _SleepTime;
} Snake, *pSnake;
它把“指针、方向、状态、分数、速度”放到一起。函数只要接收一个 pSnake,就能读取并更新整局游戏,而不必传十几个独立参数。
方向枚举从 UP=1 开始递增,状态枚举没有设置初始值则默认从0开始;状态枚举中的 OK 表示继续运行,KILL_BY_WALL、KILL_BY_SELF 和 END_NOMAL 表示三种结束原因。枚举值本质上是整数,命名却比直接写数字更容易读懂。
//方向枚举
enum DIRECTION
{
UP = 1, //上
DOWN, //下
LEFT, //左
RIGHT //右
};
//游戏状态枚举
enum GAME_STATUS
{
OK, //正常运行
KILL_BY_WALL, //撞墙结束
KILL_BY_SELF, //咬到自己结束
END_NOMAL //正常结束
};

5. GameStart:按顺序完成开局
//游戏开始
void GameStart(pSnake ps)
{
//设置控制台窗口大小,30行,100列
system("mode con cols=100 lines=30");
//设置控制台窗口名称
system("title 贪吃蛇");
//获取标准输出的句柄(用于标识不同设备的数值)
HANDLE houtput = GetStdHandle(STD_OUTPUT_HANDLE);
//创建光标结构体
CONSOLE_CURSOR_INFO CursorInfo;
GetConsoleCursorInfo(houtput, &CursorInfo);//获取控制台光标信息
//隐藏光标
CursorInfo.bVisible = false;
SetConsoleCursorInfo(houtput, &CursorInfo);//设置控制台光标信息
//打印欢迎界面
WelcomeToGame();
//打印地图
CreateMap();
//初始化蛇
InitSnake(ps);
//初始化食物
CreateFood(ps);
}
这里的顺序很重要:先准备窗口,再显示欢迎页;欢迎页结束并清屏后画地图;地图存在后再把蛇和食物放进去。test 函数每一局都先写 Snake snake = { 0 };,因此 _pSnake、_pFood 等指针从空指针开始,初始化函数才能安全判断“链表是否为空”。
5.1 欢迎页的定位输出
WelcomeToGame 通过 SetPos 把文字放到中央:
//打印欢迎界面
void WelcomeToGame()
{
SetPos(36, 12);
printf("欢迎来到贪吃蛇小游戏!");
SetPos(39, 22);
system("pause");
system("cls");
SetPos(34, 11);
printf("用↑.↓.←.→分别控制蛇的移动\\n");
SetPos(36, 12);
printf("按下F3为加速,按下F4为减速\\n");
SetPos(38, 13);
printf("加速将得到更高的分数。");
SetPos(40, 22);
system("pause");
system("cls");
}
system("pause") 等待用户按键,system("cls") 清屏。清屏会把已有内容全部擦掉,所以它只适合页面切换,不适合游戏主循环中的每一帧。
文字坐标的位置可以自行测试修改,也可以自行加减文字叙述。
5.2 CreateMap:四条边框
//打印地图
void CreateMap()
{
//打印上边框
for (int i = 0; i < 58; i += 2)
{
wprintf(L"%lc", WALL);
}
SetPos(0, 26);
//打印下边框
for (int i = 0; i < 58; i += 2)
{
wprintf(L"%lc", WALL);
}
//打印左边框
for (int i = 1; i < 26; i++)
{
SetPos(0, i);
wprintf(L"%lc", WALL);
}
//打印右边框
for (int i = 1; i < 26; i++)
{
SetPos(56, i);
wprintf(L"%lc", WALL);
}
}
循环变量的“逻辑次数”和“屏幕列数”不是一回事:横向输出 29 个宽字符,每个占两列,刚好覆盖 0…56;纵向每次增加 1。理解这个换算,后面才不会把蛇的 x、y 写反。

6. InitSnake:用头插法建立五节蛇
核心代码如下:
//初始化蛇身5个节点
pSnakeNode cur = NULL;
for (int i = 0; i < 5; i++)
{
cur = (pSnakeNode)malloc(sizeof(SnakeNode));
if (cur == NULL)
{
perror("InitSnake()::malloc()");
return;
}
//设置坐标
cur->x = POS_X + i * 2;
cur->y = POS_Y;
cur->next = NULL;
//头插法插入蛇指针
if (ps->_pSnake == NULL)
{
ps->_pSnake = cur;
}
else
{
cur->next = ps->_pSnake;
ps->_pSnake = cur;
}
}
//打印初始蛇身
cur = ps->_pSnake;
while (cur)
{
SetPos(cur->x, cur->y);
wprintf(L"%lc", BODY);
cur = cur->next;
}
POS_X 是 24,POS_Y 是 5(宏定义)。第一次插入 (24,5),后续节点坐标依次是 (26,5)、(28,5)……但每次都头插,所以最后插入的 (32,5) 成为蛇头,初始方向 RIGHT 时下一步会走到 (34,5),运动方向与链表头保持一致。
头插法的好处是 O(1) 插入,不需要遍历到尾部;代价是节点在内存中的申请顺序与从头到尾的逻辑顺序相反。读链表时永远以 ps->_pSnake 为头,不要根据地址大小规律猜顺序。
初始化完成后,函数遍历链表,把每个节点打印成 BODY,并同步设置贪吃蛇的相关数据:
//初始化贪吃蛇相关数据
ps->_Status = OK;
ps->_SleepTime = 200;
ps->_Dir = RIGHT;
ps->_Score = 0;
ps->_foodWeight = 10;
7. CreateFood:随机、对齐、避开蛇身
食物坐标由两步约束得到:
again:
do
{
x = rand() % 53 + 2; //2..54
y = rand() % 25 + 1; //1..25
} while (x % 2 != 0);
用 x % 2 != 0 丢弃奇数横坐标,保证食物与宽字符网格对齐。接着遍历蛇链表,如果发现坐标重合,就跳回 again 重新抽取:
pSnakeNode cur = ps->_pSnake;
while (cur)
{
if (x == cur->x && y == cur->y)
goto again;
cur = cur->next;
}
test.c 在程序开始时调用 srand((unsigned int)time(NULL));,让每次启动的随机序列不同。若忘记播种,很多实现会在每次运行时生成相同的食物序列。 
最后申请一个 SnakeNode 保存食物,并把它的地址写入 ps->_pFood。食物也使用节点结构体,是因为它同样需要 x、y;但它不是蛇链表的一部分,next 保持为 NULL。
//初始化食物节点并打印
pSnakeNode pFood = (pSnakeNode)malloc(sizeof(SnakeNode));
if (pFood == NULL)
{
perror("CreateFood()::malloc()");
return;
}
else
{
pFood->x = x;
pFood->y = y;
pFood->next = NULL;
SetPos(pFood->x, pFood->y);
wprintf(L"%lc", FOOD);
ps->_pFood = pFood;
}
8. 上篇小结:已经搭好可运行的“场景”
到这里,已经可以解决四个问题:
下篇将从 GameRun 开始,逐行跟踪“读取按键 → 计算下一格 → 吃食物或删除尾巴 → 判断碰撞”的完整闭环。
网硕互联帮助中心




评论前必须登录!
注册