C++小游戏集合工程化拆解:CMake多目标与终端公共层

发布时间:2026/10/28 3:32:38
C++小游戏集合工程化拆解:CMake多目标与终端公共层

简介:这份C++小游戏集合面向已掌握基础语法、希望以项目驱动方式巩固编程能力的初学者与爱好者,也适合有经验的开发者用作二次改编的练手素材。包内收录117个文件,以115个cpp源码为主体,另有1个exe可执行文件与1个out输出文件,压缩包约1.13MB,体量轻巧,便于本地编译调试与逐项拆解。内容覆盖猜数字、井字棋、迷宫等基础逻辑练习,也延伸到棋牌、冒险、生存等题材的完整小项目,涉及随机数生成、条件与循环控制、函数封装、类与对象设计、状态机判断等核心知识点,可帮助读者从读懂单文件代码过渡到理解模块划分与游戏流程组织。目前已有10627人学习,说明其作为教学案例具备一定参考价值。读者可对照源码与可执行结果验证思路,在修改规则、补充功能或优化结构中训练问题拆解与调试能力,也可作为课程设计、兴趣练手的起步材料。

1. 解压之后先别急着 g++:这个 C++ 小游戏集合到底是个什么东西

解压完一个 C++ 小游戏集合,多数人的第一反应是找 main 函数,然后g++ main.cpp -o game一把梭。真跑起来才知道,这类仓库里常常躺着七八个各自带 main 的源文件,链接器直接甩你一句 multiple definition of main,新手往往就在这一步卡住。

这份免费的 C++ 小游戏集合真正的价值,不在于某个游戏写得多花哨,而在于它把贪吃蛇、俄罗斯方块、打砖块这一类经典玩法,收进了同一套构建脚本和同一个终端公共层里:每个游戏是独立可执行目标,清屏、输入、计时这些没有技术含量的杂活全部抽到 common 目录。适合刚学完语法、想看看别人怎么把类和循环拼成能玩的东西的人,也适合手上有个点子、想拿现成骨架往里塞一个新玩法的开发者。下面按仓库骨架、核心链路、编译运行、踩坑排查、二次开发的顺序,一层层拆开看。

2. 仓库骨架与 CMake 编排:把多个游戏装进一个工程

一个游戏一个 .cpp、一个 main,是最省事的写法,也是最容易翻车的写法。这个集合采用的做法是「一个游戏一个可执行目标 + 一份公共静态库」,目录结构决定了后面所有编译命令和改造动作。我一般会先看目录,再看构建脚本,因为构建脚本基本等于这个项目的骨架说明书。

2.1 目录分层与公共层抽取

解压后典型的结构长这样,不同分支里游戏名字会变,但分层逻辑一致:

gamebox/ ├── CMakeLists.txt # 顶层:定义标准、编译选项、公共库 ├── common/ │ ├── include/ │ │ ├── terminal.h # 清屏 / 光标 / 整帧输出 │ │ ├── input.h # 非阻塞按键读取 + 按键队列 │ │ └── clock.h # 固定步长计时 │ └── src/ │ ├── terminal.cpp │ ├── input.cpp │ └── clock.cpp ├── games/ │ ├── snake/ │ │ ├── CMakeLists.txt # 只声明 snake 这一个可执行目标 │ │ └── src/main.cpp │ ├── tetris/ │ ├── breakout/ │ └── minesweeper/ └── cmake/ └── warnings.cmake # 编译告警选项集中管理

common 编译成静态库,每个游戏链接它。这么做的好处很直接:改一次输入层,所有游戏同时生效;坏处是公共层的接口一旦定歪,四个游戏一起歪。所以公共层只放三样东西——终端控制、输入、计时,不放任何游戏规则相关的代码,规则永远留在各自的 main.cpp 里。

注意:把「游戏逻辑」和「终端输出」分开放,是这套目录结构里最重要的约定。逻辑层不该出现 printf,渲染层不该出现蛇的移动规则,后面接图形库时才不会推倒重来。

2.2 顶层 CMakeLists 与多目标注册

顶层脚本负责三件事:定 C++ 标准、定告警级别、把 common 编成库。逐个游戏的编译目标交给子目录自己声明,这是 CMake 里最不容易互相干扰的写法:

cmake_minimum_required(VERSION 3.16) project(gamebox LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # MSVC 与 GCC/Clang 的告警开关名字不同,分开处理 if (MSVC) add_compile_options(/W4 /utf-8) else() add_compile_options(-Wall -Wextra -Wpedantic) endif() # 公共层编译成静态库,游戏只链接不重复编译 add_library(game_common STATIC common/src/terminal.cpp common/src/input.cpp common/src/clock.cpp ) # PUBLIC 让链接方自动继承头文件搜索路径 target_include_directories(game_common PUBLIC common/include) # 每个游戏目录自己注册目标 add_subdirectory(games/snake) add_subdirectory(games/tetris) add_subdirectory(games/breakout)

add_library(... STATIC)生成的是libgame_common.a,链接进每个可执行文件,体积小、没有运行时依赖。target_include_directories用了PUBLIC而不是PRIVATE,是因为游戏源码要#include "input.h",头文件路径必须传递给链接方;如果写成PRIVATE,就会出现「库编过了、游戏编不过、报找不到头文件」这种让人摸不着头脑的错。/utf-8是给 Windows 加的,源文件里有中文注释时,不加这个选项 MSVC 会按本地代码页解析,注释里的字符会把下一行代码吞掉。

每个子目录里的 CMakeLists 只有一行有效内容:

add_executable(snake src/main.cpp) target_link_libraries(snake PRIVATE game_common)

add_executable只收当前游戏自己的源文件,绝不出现../跨目录引用,这是多目标工程不打架的根本。PRIVATE表示这个依赖不会继续往外传递,符合「可执行文件不需要被别人链接」的事实。

2.3 公共层三件套:终端、输入、计时

先看终端封装。核心思路是整帧输出,而不是边算边打印,这样画面不会撕裂:

// common/include/terminal.h #pragma once #include <string> class Terminal { public: void hideCursor(); // 隐藏光标,避免画面里有个方块乱闪 void clear(); // 清屏并把光标归位到左上角 void draw(const std::string& frame); // 一帧内容拼好后一次性输出 void restore(); // 退出前恢复光标与终端状态 private: std::string backBuffer_; // 双缓冲:先在内存拼好整帧 };

draw内部通常先std::cout << "\x1b[H" << frame << std::flush;:\x1b[H把光标挪回左上角复用同一块屏幕,省掉全屏清空带来的闪烁;std::flush不能省,终端 stdout 默认是行缓冲,不加 flush 会出现「程序在跑但屏幕没动」的假死现象。restore必须成对出现,否则程序被 Ctrl+C 打断后,终端光标就隐身了,只能reset救回来。

计时层负责把「每秒多少帧」翻译成「这一帧要推进几步」:

// common/include/clock.h #pragma once #include <chrono> class FrameClock { public: explicit FrameClock(double fps) : step_(std::chrono::duration<double>(1.0 / fps)), last_(std::chrono::steady_clock::now()) {} // 返回本帧应推进的逻辑步数,0 表示这一帧不需要更新逻辑 int tick() { auto now = std::chrono::steady_clock::now(); acc_ += now - last_; last_ = now; int steps = 0; while (acc_ >= step_ && steps < 5) { // 上限 5 步,防止卡顿后疯狂追帧 acc_ -= step_; ++steps; } return steps; } private: std::chrono::duration<double> step_; std::chrono::steady_clock::time_point last_; std::chrono::duration<double> acc_{0}; };

fps是逻辑帧率,贪吃蛇这类网格游戏给 8~12 就够,太高手感反而失控。上限 5 步是血泪经验:机器一卡顿、acc_累积成好几秒,不封顶的话恢复瞬间蛇会连走十几格直接撞墙,玩家会以为游戏坏了。steady_clock不能用system_clock替代,后者会被系统对时改动,改完时间游戏速度当场玄学。

输入层最难的是跨平台,接口本身很小:

// common/include/input.h #pragma once #include <deque> enum class Key { None, Up, Down, Left, Right, Enter, Space, Esc, Quit }; class Input { public: void poll(); // 每帧调用一次,非阻塞地把按键塞进队列 Key pop(); // 取出一个待处理按键 bool empty() const; private: std::deque<Key> queue_; // 用队列而不是单变量,快速连按不会丢键 };

实现里靠条件编译分叉:Windows 侧用_kbhit()+_getch(),POSIX 侧用termios关掉行缓冲和回显再read。方向键在 Windows 上是两字节扩展码(前缀 224 或 0,再跟 72/80/75/77),在 POSIX 终端上是\x1b[A这样的转义序列,两者都必须「读到前缀后再补读一次」,否则上方向键会被识别成 Esc,菜单直接退出——这个坑我见过不止一次。

3. 核心运行时:主循环、状态机与网格碰撞怎么串起来

公共层只是地基,真正决定「能不能玩」的是每个游戏里的那套主循环。所有游戏共用同一种骨架:读输入 → 推进逻辑 → 渲染一帧,顺序不能乱,乱了就会出现「按下方向键后画面延迟一帧」的别扭手感。

3.1 固定步长主循环

以贪吃蛇为例,main 函数短得不像一个游戏:

int main() { Terminal term; term.hideCursor(); FrameClock clock(10.0); // 逻辑帧率 10,即每秒走 10 格 Input input; Game game(20, 15); // 20 列 x 15 行的网格 bool running = true; while (running) { input.poll(); while (!input.empty()) { Key k = input.pop(); if (k == Key::Quit) running = false; else game.handleKey(k); // 输入只改状态,不直接改画面 } int steps = clock.tick(); for (int i = 0; i < steps; ++i) game.update(); // 可能一帧走多步 term.draw(game.render()); // 无论逻辑是否推进,都要重绘 } term.restore(); return 0; }

三个顺序要点:先处理输入,再推进逻辑,最后渲染,保证「按下即生效」;steps为 0 时跳过 update 但仍渲染,屏幕不会冻住;渲染放在循环末尾而不是 update 内部,逻辑层永远不知道终端存在,这也是后面能无头测试的前提。FrameClock(10.0)这个参数直接决定蛇的行进速度,觉得慢就调到 15,觉得快就降到 8,改一个数字就够。

3.2 场景状态机:菜单、游戏中、暂停、结束

四个状态用枚举加 switch 就够了,不需要引入任何状态机框架:

enum class Scene { Menu, Playing, Paused, GameOver }; void Game::handleKey(Key k) { switch (scene_) { case Scene::Menu: if (k == Key::Enter) { reset(); scene_ = Scene::Playing; } break; case Scene::Playing: if (k == Key::Space) { scene_ = Scene::Paused; break; } if (k == Key::Esc) { scene_ = Scene::Menu; break; } dir_ = toDir(k); // 只更新方向,是否合法在 update 里判断 break; case Scene::Paused: if (k == Key::Space) scene_ = Scene::Playing; break; case Scene::GameOver: if (k == Key::Enter) scene_ = Scene::Menu; break; } } void Game::update() { if (scene_ != Scene::Playing) return; // 非游戏态一律不推进逻辑 if (++tickCount_ % moveInterval_ == 0) step(); }

用状态机处理暂停,比「暂停时停掉计时器」干净得多:时钟照常走,只是update直接返回,恢复时不会因为累积时间突然连走好几步。moveInterval_是另一个调速旋钮,它和FrameClock的 fps 是乘法关系,一般只调其中一个,两个一起动会很难找手感。

3.3 网格类游戏的碰撞判定与坐标换算

贪吃蛇的碰撞只有两件事:撞墙和撞自己。第一件好写,第二件有个容易忽略的细节:

struct Point { int x, y; }; bool Game::hitWall(Point p) const { return p.x < 0 || p.x >= width_ || p.y < 0 || p.y >= height_; } bool Game::hitSelf(Point p) const { // 尾巴这一帧会让位,所以不参与判定,否则「追着尾巴走」会被误判死亡 for (size_t i = 0; i + 1 < snake_.size(); ++i) if (snake_[i].x == p.x && snake_[i].y == p.y) return true; return false; } void Game::step() { Point head = snake_.front(); switch (dir_) { case Dir::Up: head.y -= 1; break; case Dir::Down: head.y += 1; break; case Dir::Left: head.x -= 1; break; case Dir::Right: head.x += 1; break; } if (hitWall(head) || hitSelf(head)) { scene_ = Scene::GameOver; return; } snake_.push_front(head); // 新头入队 if (head.x == food_.x && head.y == food_.y) { spawnFood(); // 吃到食物:不弹尾,长度 +1 } else { snake_.pop_back(); // 没吃到:弹尾,长度不变 } }

hitSelf里i + 1 < snake_.size()这个上界是重点。蛇每帧整体前移一格,如果头正好走到当前尾巴所在的位置,尾巴会在同一帧离开,这一格其实是空的。把尾巴也算进碰撞,玩家会莫名其妙在「贴着尾巴拐弯」时死掉,属于典型的判定过严。另外方向反转要单独拦:从 Up 直接改成 Down 时,头会撞上第二节身体,表现出来就是「按一下反方向就死」,正确做法是在toDir里忽略与当前方向相反的输入。

坐标从网格映射到终端行列时,还得处理字符不是正方形的问题:

// 终端里一个字符大约「高是宽的两倍」,所以横向乘 2 拉开,画面才不变形 int Game::toRow(int y) const { return y + kTopMargin; } int Game::toCol(int x) const { return x * 2 + kLeftMargin; }

不加这个横向拉伸,蛇在屏幕上看起来又扁又胖,玩家第一眼就会觉得画面糊了。kTopMargin和kLeftMargin用来留出边框和分数行的位置,改这两个常量比改渲染逻辑方便。打砖块那类连续坐标的游戏不走网格,用的是浮点位置加每帧速度累加,但「先更新位置、再判定越界、最后绘制」的顺序完全一致。

4. 编译与运行:Windows、Linux、macOS 三条落地路径

构建脚本写好后,落地动作其实是固定几条命令。不同平台差异集中在生成器选择和终端能力上,把命令记住基本就能跑通,剩下的问题都属于第 5 章那类。

4.1 Windows:MinGW 与 MSVC 两种生成器

走 MinGW 最省事,一条命令配好、一条命令编译:

# 在仓库根目录执行,-G 指定生成器,-B 指定构建目录 cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release cmake --build build -j # 产物按子目录分开,直接运行 ./build/games/snake/snake.exe

-S .是源码目录,-B build是构建产物目录,两者分开可以随时删掉 build 重新来,不影响源码。-DCMAKE_BUILD_TYPE=Release单配置生成器必须显式写,不写默认无优化,贪吃蛇感觉不出来,打砖块的球速计算会明显拖沓。

用 Visual Studio 生成器时有个坑:

cmake -S . -B build -G "Visual Studio 17 2022" -A x64 cmake --build build --config Release # 产物路径多一层配置目录,不要照着上一条命令的路径去找 ./build/games/snake/Release/snake.exe

--config Release和多出来的Release/目录,是多配置生成器的固定行为。很多人编译成功却「找不到 exe」,就是因为还按单配置的路径在找。

4.2 Linux 与 macOS:终端能力和宽字符

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j"$(nproc)" ./build/games/tetris/tetris

Linux 下编译一般没问题,跑起来才见真章。终端窗口至少要 80x24,贪吃蛇 20x15 的网格加上边框和分数行刚好占满,窗口太小会出现内容折行、整个画面错位。macOS 自带终端对 ANSI 转义序列支持良好,但要注意TERM环境变量被设成dumb时清屏和光标控制全部失效,表现就是「一行行往下滚」。跑之前echo $TERM看一眼,正常应该是xterm-256color之类。

注意:Windows 自带的旧版控制台对 ANSI 序列支持有限,如果画面出现←[H这类字符原样输出,需要改用较新的终端程序,或者把清屏实现换成 Windows 控制台 API。

4.3 想接图形库时,替换点在哪

玩久了总会嫌终端太素,想换成图形库。这套结构最大的好处是替换点很明确:main里的 Terminal、FrameClock、Input 三个对象是外壳,Game::handleKey / update / render是内核,换外壳不碰内核。

// 图形后端适配骨架:逻辑类完全不动,只重写宿主循环 class WindowHost { public: void run(Game& g) { while (!quit_) { pollEvents(g); // 把窗口事件翻译成 Key,喂给 g.handleKey if (clock_.tick() > 0) g.update(); renderer_.draw(g.render()); // render 返回的帧数据交给图形库画 } } private: FrameClock clock_{10.0}; Renderer renderer_; bool quit_ = false; };

改造的关键是把render()的返回值从「拼好的字符串」换成「一组带坐标的绘制指令」,图形层按指令画方块和文字。这一步不做,就会变成在图形库里继续拼字符串,白白多一层转义。计时和输入的逻辑可以原样保留,FrameClock完全不关心外壳是什么。

5. 避坑与排查:编译过了却玩不了的几种情况

这个项目最大的特点是「编译成功不等于能玩」。两类问题分得很清楚:一类在编译链接期就被拦住,另一类编译全绿、运行时才暴露。

5.1 编译期与链接期

现象:满屏multiple definition of main。原因很典型——用 IDE 新建工程时把所有 .cpp 一股脑拖进去,每个游戏的 main 都被编进了同一个可执行文件。解决是把工程拆成多目标,每个游戏目录单独add_executable,公共代码只编成静态库。判断方法也简单:报错里出现几个不同的源文件路径,就说明它们被塞进了同一个链接单元。

现象:fatal error: conio.h: No such file or directory。原因是在 Linux 或 macOS 上编了 Windows 专有的_kbhit、_getch,头文件根本不存在。解决是用#ifdef _WIN32把两套输入实现分开,POSIX 侧改用termios:tcgetattr保存原状态,tcsetattr关掉ICANON和ECHO,退出时再恢复。恢复这一步别偷懒,否则程序异常结束后终端不再回显你敲的字符,只能重开窗口。

现象:undefined reference to 'Terminal::draw(...)'。链接期找不到实现,八成是target_link_libraries写漏了,或者顺序不对。CMake 里被依赖的库必须写在依赖方后面,target_link_libraries(snake PRIVATE game_common)这样写才对。另一种可能是 common 的 .cpp 没被加进add_library,头文件能找到、实现没编进去,症状完全一样。

现象:中文注释那一行后面的代码报一堆语法错。MSVC 在源码含非 ASCII 字符、又没加/utf-8时,会按本地代码页解析,注释里的字节把换行吃掉,下一行代码就被并进注释里。解决方案就是编译选项加/utf-8,并把源文件统一存成 UTF-8 无 BOM。这个坑特别隐蔽,因为报错位置和真正的原因隔了一行。

5.2 运行期

现象:程序在跑,屏幕一动不动,按 Ctrl+C 才刷出一堆画面。原因是 stdout 缓冲没刷。终端下 stdout 连的是行缓冲,没有换行就不输出。解决是把整帧拼好后一次性std::cout << frame << std::flush,或者用std::ios::sync_with_stdio(false)配合手动 flush。这里也解释了为什么公共层要做双缓冲:边算边输出不仅会撕裂画面,还更容易踩到缓冲问题。

现象:方向键一按,游戏直接退出。前面提过,方向键在终端里是\x1b[A这种多字节序列,只读第一个字节就会被当成 Esc。解决是在读到\x1b后再补读两个字节,按最后一位区分上下左右。Windows 侧同理,读到前缀 224 后必须再_getch()一次拿真正的键码。这个问题在「菜单按 Esc 返回」和「方向键移动」同时存在时最容易翻车,因为两者共用了一个前缀。

现象:退出后终端光标消失、命令行错位。hideCursor之后如果走了提前 return 或者异常路径,restore就没机会执行。稳妥做法是把 Terminal 的生命周期绑到 RAII 上,析构函数里调restore,或者注册std::atexit兜底。我一般还会在restore里额外输出一次「重置全部属性」的转义序列,防止某个游戏改了前景色没改回来,下次开终端还是花的。

现象:单位时间内的游戏速度随机器变,笔记本上慢、台式机上快。用「每帧走一格」当时间基准就会这样。解决是改用steady_clock累计真实时间,由FrameClock::tick()决定这一帧推进几步。改完之后同一份代码在不同机器上的手感才一致,这也是引入公共计时层的直接原因。

6. 二次开发:加一个新玩法、调手感、跑一遍回归

骨架看明白之后,往里加自己的游戏是最有价值的用法。整个接入成本集中在三件事:目录、CMake 目标、三个接口函数。接口约定很轻——handleKey(Key)收输入,update()推进一次逻辑,render()返回一帧内容,其余随便写。

6.1 新增游戏的接入步骤

复制一个现有游戏目录改名,然后在它的CMakeLists.txt里只改目标名,再在顶层加一行add_subdirectory。游戏类按约定实现三个方法即可,下面是打砖块的骨架:

class Breakout { public: explicit Breakout(int w, int h) : width_(w), height_(h) {} void handleKey(Key k) { // 只改状态,不做任何绘制 if (k == Key::Left) paddleX_ = std::max(0, paddleX_ - 2); if (k == Key::Right) paddleX_ = std::min(width_ - 6, paddleX_ + 2); } void update() { // 一次逻辑步:球走一步 + 判碰撞 ballX_ += vx_; ballY_ += vy_; if (ballX_ <= 0 || ballX_ >= width_ - 1) vx_ = -vx_; // 左右墙反弹 if (ballY_ <= 0) vy_ = -vy_; // 顶墙反弹 if (ballY_ >= height_ - 1) { over_ = true; return; } // 落底判负 if (ballY_ == paddleRow_ && hitPaddle()) vy_ = -vy_; } std::string render() const; // 拼一帧字符串,由终端层输出 private: int width_, height_; int paddleX_ = 0, paddleRow_ = 0; double ballX_ = 0, ballY_ = 0, vx_ = 0.4, vy_ = -0.6; bool over_ = false; bool hitPaddle() const; };

关键点在于update里所有判定都基于逻辑坐标,跟终端完全解耦;vx_、vy_用浮点,屏幕上是整数格,这样球速可以小于一格、看起来更连续。render声明成const,是为了让它在任何状态下都能被安全调用,包括暂停和结束画面。

6.2 常用参数与手感调节表

手感全在这几个数字上,改之前先只动一个,动完跑两分钟再决定下一步:

参数所在位置影响建议范围
FrameClock的 fpsmain 函数整个游戏的节奏基准网格类 8~12,动作类 30~60
moveInterval_游戏类的update每隔几步真正走一格1~3,和 fps 二选一调
网格宽高Game构造函数场地大小、难度宽 16~30,高 12~20
蛇初始长度reset()起步难度3~5
球速vx_/vy_游戏类成员初值打砖块难度曲线绝对值 0.3~0.8
输入队列上限Input内部连按是否排队3~8,超过就丢最旧的

调 fps 和调moveInterval_是等效的,但前者影响所有游戏,后者只影响当前一个。想只让俄罗斯方块下落变快,就改moveInterval_;想整体让所有游戏更跟手,再动 fps。两个一起改会让手感变得不可复现,调参调到最后自己都不知道是哪个起的作用。

6.3 不开终端也能验证:逻辑层的无头回归

改完逻辑后每次都要手动开终端试玩,效率太低。因为逻辑和渲染是分开的,完全可以写一个不开终端的小程序,把update空跑上万次:

// tests/snake_smoke.cpp —— 只跑逻辑,不碰终端 int main() { Game g(20, 15); g.reset(); for (int i = 0; i < 10000 && !g.over(); ++i) { g.update(); // 无输入下持续推进 assert(g.snakeLength() <= 20 * 15); // 长度不能超过格子总数 assert(g.snakeLength() >= 1); // 也不能缩成负数 } return 0; }

这个断言看着简陋,但真能挡住两类改动事故:一是spawnFood写到已被占用的格子上导致食物永远吃不到、长度不涨;二是pop_back和push_front顺序写反导致蛇越跑越长最后越界。这两个 bug 在终端里表现都是「玩着玩着不对劲」,很难当场定位,放到无头测试里几秒钟就暴露了。把它挂进 CMake 的add_test,每次改完ctest一下,比手玩十局都靠谱。

从那以后我每次加新游戏,都强制先把三个接口跑通、再写一行无头测试,最后才去调手感——顺序反了,后面所有调试都会在「到底是逻辑错了还是画错了」之间反复怀疑自己。这份 C++ 小游戏集合的价值也在这儿:它给的是一套能拆、能换、能单独验证的骨架,而不是几个只能照着玩的黑盒子。希望帮到你。

本文还有配套的精品资源,点击获取