云计算百科
云计算领域专业知识百科平台

C语言控制台贪吃蛇游戏(上)

文章目录

    • 最终效果展示
    • 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. 上篇小结:已经搭好可运行的“场景”


到这里,已经可以解决四个问题:

  • 为什么横向坐标按 2 移动?因为宽字符通常占两个控制台列。
  • 为什么要保存输出句柄?光标定位和光标信息 API 都需要它。
  • 为什么蛇用链表?移动时只需头插新节点、必要时删除尾节点。
  • 为什么先地图、后蛇和食物?后者需要在已经确定的坐标系统中绘制。
  • 下篇将从 GameRun 开始,逐行跟踪“读取按键 → 计算下一格 → 吃食物或删除尾巴 → 判断碰撞”的完整闭环。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » C语言控制台贪吃蛇游戏(上)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!