尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++游戏开发入门:从零实现控制台贪吃蛇全流程

C++游戏开发入门:从零实现控制台贪吃蛇全流程 很多同学学完 C 语法之后最常问的一句话就是“下一步我该做什么”光是掌握变量、循环、函数和类确实很难直观感受到 C 的能力边界。在我看来写一个完整的游戏项目是性价比非常高的练手方式。因为游戏能在短时间内逼着你把语法、流程控制、数据结构、模块拆分和逻辑抽象全部用起来。本文围绕 C 游戏开发入门展开完整拆解一个控制台贪吃蛇游戏从设计到落地的全流程包含环境搭建、游戏循环、输入处理、渲染方案、碰撞检测与常见报错排查。新手照着做可以写出自己的第一款 C 游戏有基础的开发者也可以把它当作一个干净的项目模板继续扩展。1. 背景与核心概念1.1 C 游戏开发是什么C 游戏开发简单来说就是使用 C 语言编写游戏程序。它既包括使用 Unity、Unreal Engine 这类大型引擎做游戏逻辑和功能模块也包括不依赖引擎直接用 C 配合图形库、声音库甚至控制台接口实现一个完整的可运行游戏。很多初学者会把“游戏开发”理解成一个很遥远、很复杂的事情认为必须掌握图形学、物理引擎和复杂算法之后才能开始。但实际上游戏开发的核心模型非常统一一个不断运行的循环在循环里读取玩家输入、更新游戏状态、绘制当前画面。无论是一个几十行的控制台小程序还是一个 3A 大作都是建立在这样一个循环模型之上。因此对于刚接触 C 的读者与其一开始就扑向庞大的引擎和图形库不如先从一个控制台游戏入手把游戏运行的基本骨架吃透。后面再去学习图形库或者游戏引擎时你会发现很多概念都是相通的。1.2 为什么第一个游戏建议从控制台开始控制台游戏有几个非常明显的优点环境要求低。只需要一个 C 编译器和系统自带的命令行窗口不需要安装任何第三方图形库。代码量适中。一个功能完整的贪吃蛇大约两百行到三百行适合在短时间内读完和改完。暴露的问题全面。游戏循环、输入处理、画面刷新、碰撞检测、随机数生成、数据结构选型这些游戏开发中的基础问题都会遇到。便于调试。出错了直接看输出不需要面对复杂的内存转储和渲染调试工具。有些读者可能会问直接用 SFML 或者 raylib 这样的图形库不是更接近“真正的游戏”吗确实可以但图形库会引入窗口创建、纹理加载、事件系统等额外概念对于刚学完 C 基础的同学来说负担比较重。控制台游戏能够让你把注意力集中在“游戏逻辑如何组织”这个核心问题上而不是被环境配置劝退。1.3 本文的学习路径本文会按照下面几个步骤来展开搭建 C 开发环境在不同操作系统下配置编译器。拆解游戏循环、输入处理、控制台渲染和数据结构这几个核心概念。完整实现一个控制台贪吃蛇游戏逐步解释每一段代码的作用。总结常见编译报错和运行问题给出可操作的排查思路。分享工程化建议帮助你把“能跑的小游戏”整理成“结构清晰的小项目”。最终你能得到的不仅是一个可以运行的贪吃蛇还包括一套可复用的 C 简单游戏设计思路。2. 环境准备与版本说明在进行 C 游戏开发之前首先要有一个可以编译和运行 C 代码的开发环境。不同操作系统下的配置方式略有不同本文给出最常见的几种方案。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。2.1 Windows 下搭建 C 开发环境Windows 下最常见的 C 开发路线有两种。第一种是使用 Visual Studio。Visual Studio 自带 MSVC 编译器和完整的调试工具创建项目非常方便。缺点是安装包体积比较大对于控制台小游戏来说有些“杀鸡用牛刀”。第二种是使用 Visual Studio Code MinGW-w64。这也是网上搜索量很大的组合适合喜欢轻量编辑器的读者。步骤如下从 MinGW-w64 的官方或镜像渠道下载对应版本的压缩包。解压到一个固定目录例如D:\mingw64。将D:\mingw64\bin添加到系统环境变量 Path 中。打开命令行输入g --version能看到版本信息就说明配置成功。在 VS Code 中安装 C/C 扩展。VS Code 中可以通过 tasks.json 配置编译任务一个简单的示例配置如下{ version: 2.0.0, tasks: [ { label: build and run, type: shell, command: g, args: [ -g, main.cpp, -o, snake.exe ], group: { kind: build, isDefault: true } } ] }这个配置的作用是在 VS Code 中执行构建任务时调用 g 编译 main.cpp并生成 snake.exe 可执行文件。后面文章中会用命令行方式演示编译逻辑是一样的。2.2 Linux/macOS 下的最小环境Linux 和 macOS 系统通常自带或可以很方便地安装 g 或 clang。在 Ubuntu/Debian 系列系统中可以执行sudo apt update sudo apt install gmacOS 用户可以在终端执行xcode-select --install安装完成后同样使用g --version验证环境。Linux 和 macOS 下编译命令与 Windows 基本一致只是生成的可执行文件没有.exe后缀g main.cpp -o snake -stdc11 ./snake2.3 项目目录规划对于一个小型控制台游戏建议从一开始就保持干净的项目结构snake-game/ ├── main.cpp ├── game.hpp ├── game.cpp ├── CMakeLists.txt └── README.md如果只是学习阶段把所有代码放在一个 main.cpp 里是可以理解的。但如果想继续扩展建议尽早按照“头文件、实现文件、主入口”的方式拆分。本文先以单文件 main.cpp 为例把核心逻辑讲清楚后面在最佳实践部分再给出拆分建议。3. C 游戏开发核心概念拆解在写具体代码之前先理解几个关键概念。这些概念是所有小游戏开发的基础也是从“写练习题”过渡到“写项目”的关键一步。3.1 游戏循环一切游戏的基础游戏程序本质上是一个持续运行的循环。用伪代码表示大概是这样的while (游戏未结束) { 处理输入(); 更新游戏状态(); 绘制画面(); 等待一小段时间(); }这个循环被称为“游戏循环”。处理输入负责读取玩家的按键等操作更新游戏状态负责根据输入和时间推进游戏逻辑绘制画面负责把当前状态显示到屏幕上。三个步骤循环往复就形成了游戏的效果。控制台贪吃蛇的循环非常清晰输入阶段检测玩家是否按下了方向键。更新阶段根据当前方向移动蛇头判断是否吃到食物是否撞墙或撞到自己。绘制阶段清屏并重新打印地图。理解并亲手实现这个循环是完成第一个游戏项目最重要的一步。3.2 非阻塞输入让游戏实时响应很多初学者在写控制台程序时习惯用cin 或scanf获取用户输入。但这类输入是阻塞式的意思是程序运行到这一行就会停下一直等着用户输入回车。对游戏来说如果每走一步都要按一次回车整个游戏体验会非常糟糕。游戏需要的是“有按键就读取没按键就继续跑游戏循环”。这种读取方式叫做非阻塞输入。Windows 下常用的非阻塞输入函数是_kbhit()和_getch()。_kbhit()用来检测键盘缓冲区中是否有按键_getch()用来无回显地读取一个字符。二者配合就能实现“有按键时处理按键没按键时正常循环”的效果。在 Linux/macOS 下标准 C 库没有直接提供类似的函数需要通过termios和fcntl手动设置终端为非阻塞模式。这个差异是跨平台控制台游戏最需要注意的地方。3.3 控制台渲染画布、光标与刷新控制台可以理解成一张由字符组成的画布。每一帧结束后程序需要根据游戏状态生成一张新的字符画面并把它显示在控制台上。最简单粗暴的刷新方式是system(cls)清屏然后重新打印所有内容。这种方式代码简单但会有轻微闪烁感。更专业一些的做法是使用 ANSI 转义序列把光标移动到屏幕左上角然后重新打印整张画面不再整体清屏。在 Windows 10 及以上版本终端已经支持 ANSI 转义序列也可以在代码中调用 Windows API 来控制光标位置。为了让主代码更容易理解本文示例先使用清屏重绘的方式。3.4 数据结构选型蛇身为什么要用 deque贪吃蛇游戏里最核心的数据结构就是蛇身。蛇身是一个不断移动的序列每次前进时头部增加一个格子尾部去掉一个格子吃到食物时尾部不去掉。这个逻辑非常符合双端队列deque的特性。std::deque支持在头部和尾部高效地插入、删除元素头部插入push_front()尾部删除pop_back()尾部插入push_back()如果用普通vector在头部插入或删除的效率会比较低因为需要移动后续所有元素。用双端队列可以避免这个问题。同时也可以用std::find在蛇身容器中查找某个坐标用来判断是否撞到自己。对于蛇身长度只有几十个格子的场景这个复杂度完全够用。4. 完整实战控制台贪吃蛇从 0 到 1下面进入本文的核心部分。我们将完整实现一个控制台贪吃蛇游戏。本文以 Windows 控制台环境为例后面会给出 Linux/macOS 的适配方式。4.1 需求分析与功能拆分开始写代码前先明确游戏规则蛇在固定大小的矩形地图中移动。玩家使用 W/A/S/D 控制蛇头方向。蛇吃到食物后身体变长分数增加。蛇撞到墙壁或自己的身体游戏结束。按 ESC 可以随时退出游戏。对应的功能模块可以拆成地图常量宽和高。坐标结构体记录蛇身和食物的格子位置。蛇身容器使用双端队列保存蛇的每一节。方向状态当前移动方向。食物生成在空余位置随机生成一个食物。输入处理检测 W/A/S/D 和 ESC。更新逻辑移动蛇头、判定碰撞、处理生长。绘制逻辑清屏并绘制地图、蛇、食物和分数。4.2 编写核心代码下面给出完整的 main.cpp 代码。这段代码可以在 Windows 下使用 g 或 MSVC 编译运行。// 文件路径snake-game/main.cpp #include iostream #include vector #include deque #include cstdlib #include ctime #include conio.h #include windows.h using namespace std; const int WIDTH 20; const int HEIGHT 15; struct Point { int x, y; bool operator(const Point other) const { return x other.x y other.y; } }; enum class Direction { UP, DOWN, LEFT, RIGHT }; class SnakeGame { private: dequePoint snake; Point food; Direction dir; bool isOver; int score; // 将控制台光标移动到指定位置 void setCursor(int x, int y) { COORD coord; coord.X x; coord.Y y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord); } // 生成一个新的食物避免生成到蛇身上 void generateFood() { do { food { rand() % WIDTH, rand() % HEIGHT }; } while (find(snake.begin(), snake.end(), food) ! snake.end()); } public: SnakeGame() : dir(Direction::RIGHT), isOver(false), score(0) { // 初始蛇身长度为3位于地图中间偏左 snake.push_back({ WIDTH / 2, HEIGHT / 2 }); snake.push_back({ WIDTH / 2 - 1, HEIGHT / 2 }); snake.push_back({ WIDTH / 2 - 2, HEIGHT / 2 }); generateFood(); } // 绘制当前游戏画面 void draw() { system(cls); for (int y 0; y HEIGHT; y) { for (int x 0; x WIDTH; x) { Point p { x, y }; if (p snake.front()) { cout O; // 蛇头 } else if (find(snake.begin(), snake.end(), p) ! snake.end()) { cout o; // 蛇身 } else if (p food) { cout *; // 食物 } else { cout .; // 空白 } } cout \n; } cout Score: score \n; cout W/A/S/D Move, ESC Quit \n; } // 非阻塞读取玩家按键 void input() { if (_kbhit()) { char ch _getch(); switch (ch) { case w: case W: if (dir ! Direction::DOWN) dir Direction::UP; break; case s: case S: if (dir ! Direction::UP) dir Direction::DOWN; break; case a: case A: if (dir ! Direction::RIGHT) dir Direction::LEFT; break; case d: case D: if (dir ! Direction::LEFT) dir Direction::RIGHT; break; case 27: // ESC isOver true; break; } } } // 更新游戏状态 void update() { Point head snake.front(); switch (dir) { case Direction::UP: head.y--; break; case Direction::DOWN: head.y; break; case Direction::LEFT: head.x--; break; case Direction::RIGHT: head.x; break; } // 撞墙检测 if (head.x 0 || head.x WIDTH || head.y 0 || head.y HEIGHT) { isOver true; return; } // 撞自己检测 for (size_t i 0; i snake.size(); i) { if (snake[i] head) { isOver true; return; } } // 蛇头前进 snake.push_front(head); // 判断是否吃到食物 if (head food) { score 10; generateFood(); } else { // 没吃到食物尾部收缩 snake.pop_back(); } } bool isGameOver() const { return isOver; } int getScore() const { return score; } void showGameOver() { system(cls); cout Game Over! \n; cout Final Score: score \n; cout Press any key to exit... \n; _getch(); } }; int main() { srand((unsigned int)time(nullptr)); cout Snake Game \n; cout W/A/S/D to move, ESC to quit. \n; cout Press any key to start... \n; _getch(); SnakeGame game; while (!game.isGameOver()) { game.input(); game.update(); game.draw(); Sleep(100); // 控制游戏速度单位毫秒 } game.showGameOver(); return 0; }下面逐段解释代码中的关键点。首先是常量和结构体const int WIDTH 20; const int HEIGHT 15;这两个常量定义了游戏地图的大小。宽度为 20 个字符高度为 15 行。改大数字可以生成更大的地图。struct Point { int x, y; bool operator(const Point other) const { return x other.x y other.y; } };Point 结构体用来表示地图上的一个格子。重载运算符后可以直接用p food比较两个坐标是否相等代码更简洁。方向使用枚举类enum class Direction { UP, DOWN, LEFT, RIGHT };枚举类在 C11 中引入比传统枚举更安全不会意外和其他整型混用。SnakeGame 类封装了整个游戏的状态和方法。构造函数里初始化蛇身初始长度为 3方向朝右snake.push_back({ WIDTH / 2, HEIGHT / 2 }); snake.push_back({ WIDTH / 2 - 1, HEIGHT / 2 }); snake.push_back({ WIDTH / 2 - 2, HEIGHT / 2 });注意push_back的顺序队首是蛇头队尾是蛇尾。初始蛇头在中间靠右的位置后面两节身体依次向左排列。generateFood 函数使用了一个 do-while 循环do { food { rand() % WIDTH, rand() % HEIGHT }; } while (find(snake.begin(), snake.end(), food) ! snake.end());它先生成一个随机坐标然后检查这个坐标是否被蛇身占据。如果被占据就重新生成。这里用find在 deque 中查找坐标如果找到说明食物生成到了蛇身上需要重试。绘制函数比较直观for (int y 0; y HEIGHT; y) { for (int x 0; x WIDTH; x) { Point p { x, y }; ... } }两层循环遍历地图的每一个格子根据当前位置是蛇头、蛇身、食物还是空白输出不同的字符。外层循环是 y内层循环是 x这样打印顺序符合从左到右、从上到下的屏幕扫描顺序。input 函数中的方向判断需要注意if (dir ! Direction::DOWN) dir Direction::UP;为什么要有这层判断因为如果蛇正在向上移动玩家按向下键蛇会立刻掉头撞到自己这在真实游戏里是不允许的。所以方向控制必须禁止 180 度掉头。update 函数中Point head snake.front()取出当前蛇头位置然后根据方向修改坐标switch (dir) { case Direction::UP: head.y--; break; case Direction::DOWN: head.y; break; case Direction::LEFT: head.x--; break; case Direction::RIGHT: head.x; break; }地图坐标中x 表示列y 表示行。向上移动时 y 减一向下移动时 y 加一。更新逻辑的顺序很重要先计算新蛇头再做碰撞检测再移动蛇身最后判断是否吃食物。如果把碰撞检测放在移动之后可能出现“穿墙后又检测到墙”的边界错误。main 函数中的循环是游戏的主循环while (!game.isGameOver()) { game.input(); game.update(); game.draw(); Sleep(100); }每一帧依次执行输入、更新、绘制然后休眠 100 毫秒。休眠的作用是控制游戏速度避免游戏刷新过快导致视觉上根本看不清。如果想让游戏更快就把数值调小想慢一点就调大。4.3 编译运行与验证在 Windows 命令行中进入项目目录后执行g main.cpp -o snake.exe -stdc11 snake.exe如果一切正常会看到控制台先显示欢迎信息按任意键后进入游戏画面。画面中O表示蛇头。o表示蛇身。*表示食物。.表示空白区域。底部显示当前分数和操作提示。使用 W/A/S/D 控制蛇的移动吃到食物后分数增加 10 分蛇身变长。撞墙或撞到自己后游戏结束并显示最终分数。4.4 Linux/macOS 适配方式上面主代码使用了conio.h和windows.h这是 Windows 特有的头文件。Linux 和 macOS 下无法直接编译。适配思路是替换三个部分非阻塞输入、清屏、休眠函数。非阻塞输入可以用 termios 实现。下面是一个简单的替代片段#include termios.h #include unistd.h #include fcntl.h #include cstdio bool kbhit() { struct termios oldt, newt; tcgetattr(STDIN_FILENO, oldt); newt oldt; newt.c_lflag ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, newt); int oldf fcntl(STDIN_FILENO, F_GETFL, 0); fcntl(STDIN_FILENO, F_SETFL, oldf | O_NONBLOCK); int ch getchar(); fcntl(STDIN_FILENO, F_SETFL, oldf); tcsetattr(STDIN_FILENO, TCSANOW, oldt); if (ch ! EOF) { ungetc(ch, stdin); return true; } return false; }这段代码的原理是先将终端设置为非规范模式并关闭回显然后设置非阻塞读取读取一个字符后立即恢复终端原始状态。如果返回值是 EOF说明没有按键输入。清屏可以替换为 ANSI 转义序列cout \033[2J\033[1;1H;这里的\033[2J表示清屏\033[1;1H表示把光标移动到第一行第一列。休眠函数可以替换为usleep(100000);或者使用 C11 的标准库#include thread #include chrono std::this_thread::sleep_for(std::chrono::milliseconds(100));替换后同样使用g main.cpp -o snake -stdc11编译运行./snake即可。5. 常见问题与排查思路初学者在编译和运行控制台游戏时会遇到一些比较典型的报错。下面整理成表格方便快速定位问题。问题现象常见原因解决思路g: command not foundMinGW 或 g 未安装或环境变量未配置重新安装 MinGW-w64 并检查 PATH 配置fatal error: conio.h: No such file or directory在 Linux/macOS 下编译了 Windows 专用头文件使用 termios 替代_kbhit/_getchSleep was not declared in this scopeWindows 的 Sleep 函数不可用换成std::this_thread::sleep_for或usleepsrand或time未声明缺少cstdlib或ctime头文件检查并补充头文件引用运行后立即闪退看不到界面程序结束后自动关闭控制台窗口在结束前添加暂停语句或从已有终端中运行程序游戏速度太快Sleep 时间过短或没有休眠调整休眠时间到合理范围方向键不生效使用了方向键扫描码但代码只处理字符优先使用 W/A/S/D或处理扫描码中文显示乱码控制台编码与源文件编码不一致设置控制台代码页为 UTF-8或界面统一使用英文蛇不移动循环中缺少 update 调用确保游戏循环中依次调用 input、update、draw食物生成到蛇身随机坐标未做蛇身占用检测加 do-while 循环重新生成食物这里重点说两个比较隐蔽的问题。第一个是休眠函数。Sleep在 Windows 中首字母大写单位是毫秒usleep在 POSIX 系统中单位是微秒C11 标准库中的sleep_for还可以直接指定chrono::milliseconds。很多程序从 Windows 移植到 Linux 时就是卡在这个命名差异上。第二个是_getch()读取方向键的问题。方向键在 Windows 控制台中会产生两个字节的扫描码第一次调用_getch()返回 224 或 0第二次才返回具体的方向键码。本文使用 W/A/S/D 而不是方向键就是为了避开这个复杂逻辑。如果你想支持方向键需要额外处理这个扫描码机制。另一个常见的问题是画面闪烁。system(cls)每次清空整个屏幕然后再逐行绘制。当刷新频率较高时会出现肉眼可见的闪烁感。一种改进方法是使用 ANSI 转义序列把光标移动到左上角而不是清屏然后重新输出整张画面。这样终端不会完全清空闪烁会明显减轻。6. 最佳实践与工程建议完成一个能运行的贪吃蛇之后下一步就是思考如何把它写得更像“工程”而不是“练习”。下面这些建议每一条都是实际开发中会遇到的真实问题。6.1 代码模块化单文件 main.cpp 适合教学但游戏逻辑一旦复杂就容易变成几千行的“大杂烩”。推荐的拆分方式是main.cpp负责启动、创建游戏对象、运行主循环。game.hpp声明游戏类的接口和数据结构。game.cpp实现游戏类的核心逻辑。拆分的收益很明显游戏逻辑和主循环解耦修改游戏规则时不需要翻看 main 函数可以单独写测试代码对 game 类做验证后续添加新功能时代码结构也不会快速腐烂。6.2 固定时间步长目前游戏用Sleep(100)控制速度。这种方式有一个问题如果系统卡顿或者线程被其他任务抢占游戏时间会变得不准确。更稳健的做法是记录上一次刷新的时间每次循环计算出真实经过的时间再根据时间步长决定是否更新游戏状态。简单来说double lastTime getCurrentTime(); while (running) { double currentTime getCurrentTime(); double delta currentTime - lastTime; lastTime currentTime; // 根据 delta 更新逻辑 }这种基于时间差的循环模型是图形游戏引擎中最基本的帧率控制方式。在控制台游戏中可以先不做但理解它是必要的。6.3 输入设计当前输入处理是“检测到按键就立即改变方向”。如果玩家在很短的时间内连按多次可能会发生方向跳变甚至出现“本来向上走按了左又立即按右导致蛇头原地掉头”的情况。改进方向有两个使用输入队列只保留最近的合法输入。在 update 阶段统一处理方向变化而不是在 input 阶段直接修改蛇头方向。这样可以让输入处理和状态更新之间的职责更清晰。6.4 工程化建议以下几个方面也值得注意使用 CMake 管理项目。即使只有一个源文件提前熟悉 CMake 也能为后续加入图形库、测试框架做好准备。下面是一个最小 CMakeLists.txtcmake_minimum_required(VERSION 3.10) project(SnakeGame) set(CMAKE_CXX_STANDARD 11) add_executable(snake main.cpp)引入版本管理。在项目目录执行git init每完成一个功能提交一次。这对后续大型项目非常重要。保持游戏逻辑与平台代码分离。把依赖 Windows API 的代码封装成单独的函数移植到其他平台时只改一个文件。控制台类游戏的异常处理需要特别小心。不要滥用system()调用尤其是涉及外部命令的时候要确保参数可信、来源可控。日志比打印大法好用。单文件小游戏可以随意输出调试信息但项目变大后建议写一个简单的日志函数统一输出到文件或调试窗口。7. 总结与后续学习路线你现在已经亲手完成了一个完整的 C 游戏项目。这个项目虽然运行在控制台里但它包含了游戏开发中最核心的部分游戏循环、输入处理、状态更新、画面绘制和碰撞检测。更重要的是你已经体会到了“先设计结构再写代码”的感觉先有 SnakeGame 类、Point 结构体、Direction 枚举然后把它们组装成一个可运行的游戏。完成贪吃蛇之后下一步可以朝几个方向继续深入。第一先给当前项目做增强。可以增加暂停功能、排行榜、不同关卡的地图、游戏难度递增或者用 ANSI 转义序列加颜色。改代码本身就是最好的学习方式。第二学习一个轻量级图形库比如 raylib 或 SFML。你会发现图形版本的游戏循环、输入事件和绘制流程和本文的控制台版本在本质上是相通的。第三系统学习数据结构和设计模式。游戏中用到的deque、状态机、观察者模式在实际开发中会反复出现。第四如果你想走游戏开发职业路线可以在 C 基础巩固之后接触 Unity 或 Unreal Engine。那时你再回来理解引擎提供的 Component、Actor、Game Loop会比直接上手引擎轻松得多。如果你按照上面的步骤已经跑出了自己控制的“O”和“*”恭喜你已经迈出了 C 游戏开发的第一步。接下来最值得做的事情就是亲手把它改成你想要的任何样子。
返回列表