简介:这是一份面向C++初学者与编程爱好者的免费小游戏实践资源包,聚焦语法巩固、逻辑训练与项目拆解,帮助学习者通过可运行的完整案例掌握类与对象、循环控制、随机数生成、状态管理等核心概念。资源共117个文件,主体为115个独立.cpp源码文件(如《龙的传人》《狼人杀》《国粹麻将》《技能大乱斗》等),辅以1个可直接运行的exe程序和1个编译输出out文件,便于对比理解编译流程与执行结果;整体压缩包仅1.13MB,轻量易下载。已有10617人学习下载,热度高、实操性强。每个游戏均结构清晰、注释充分,涵盖迷宫寻路、回合对战、规则判定等典型场景,既可逐个调试加深理解,也支持二次开发——例如扩展图形界面、添加存档功能或重构为多线程版本,是夯实C++基础、培养工程思维的优质入门素材。
1. 这不是玩具代码合集:一个能编译、能调试、能改出新玩法的 C++ 小游戏实战包
上周帮某高校实验室带毕设的学生调代码,他甩来一个“C++小游戏集合”压缩包,说“老师让跑通再加个存档功能”。结果打开发现——全是.cpp文件,没CMakeLists.txt,main()分散在七八个文件里,#include "game.h"却找不到game.h在哪。最后花了三小时才理清依赖链。而你手头这份「一个免费的c++小游戏集合」,恰恰反其道而行:它用现代 CMake 统一管理 5 个完整可运行游戏(贪吃蛇、俄罗斯方块、推箱子、扫雷、井字棋),每个游戏独立构建、独立运行,源码里带清晰注释、关键算法有 inline 解释、甚至预留了扩展接口。它不教语法基础,但专治“写完编译不过”“改完逻辑崩了”“想加个音效却卡死在 SDL2 链接阶段”这类真实翻车现场。适合刚学完类和 STL 的 C++ 新手练手,也适合嵌入式/图形方向的开发者快速验证算法或移植到裸机平台。
2. 构建与运行:从解压到双击可执行文件的完整链路
2.1 目录结构即设计意图:为什么这样组织?
项目解压后目录如下(已剔除.git和构建产物):
games-collection/ ├── CMakeLists.txt # 顶层:定义 CMake 最低版本、项目名、启用C++17 ├── build/ # 构建目录(建议始终外置,避免污染源码) ├── assets/ # 所有游戏共用资源:字体、音效WAV、图标ICO ├── common/ # 公共模块:输入抽象层、定时器封装、日志工具 │ ├── input_manager.hpp/cpp # 封装 SDL2 键盘/鼠标事件为统一事件队列 │ ├── timer.hpp/cpp # 基于 std::chrono 的高精度帧计时器 │ └── logger.hpp # 简单宏封装,DEBUG 模式输出带文件行号的日志 ├── games/ # 每个子目录 = 一个完整游戏工程 │ ├── snake/ # 贪吃蛇:核心是双向链表+方向向量控制 │ │ ├── CMakeLists.txt # 子项目:声明依赖 common,链接 SDL2 │ │ ├── main.cpp # 游戏入口:初始化→主循环→清理 │ │ ├── game_state.hpp # 状态机:MENU / PLAYING / PAUSED / GAME_OVER │ │ └── renderer.hpp # 基于 SDL_Renderer 的绘制封装(含网格对齐逻辑) │ ├── tetris/ # 俄罗斯方块:核心是 4x4 旋转矩阵 + 消行检测 │ │ ├── CMakeLists.txt │ │ ├── main.cpp │ │ ├── board.hpp # 二维数组表示游戏区,含碰撞检测方法 │ │ └── piece.hpp # 方块类型枚举 + 旋转数据表(硬编码 4 种旋转) │ └── ... # 其他游戏同构 └── README.md # 写明编译命令、依赖库版本、各游戏快捷键提示:这种“顶层 CMake + 子项目 CMake”结构,是大型 C++ 工程的标准做法。它让每个游戏像微服务一样独立编译,又共享公共工具——改
common/logger.hpp后所有游戏自动获得新日志能力,无需复制粘贴。
2.2 三步编译:Windows/macOS/Linux 通用命令
前提:已安装 CMake 3.16+ 和对应平台的编译器(Windows: MSVC 或 MinGW;macOS: Xcode Command Line Tools;Linux: g++/clang++)
# 1. 进入项目根目录,创建并进入构建目录(关键!禁止在源码目录内 cmake) cd games-collection mkdir build && cd build # 2. 配置项目(自动探测编译器、生成 Makefile/Ninja/Visual Studio Solution) cmake .. -DCMAKE_BUILD_TYPE=Release # 3. 编译全部游戏(-j 参数利用多核,Linux/macOS 用 -j$(nproc),Windows 用 -j%NUMBER_OF_PROCESSORS%) cmake --build . -j编译成功后,build/games/下会生成 5 个可执行文件(如snake.exe,tetris)。直接运行即可:
# Linux/macOS ./games/snake # Windows(PowerShell) .\games\snake.exe2.3 运行时行为与调试入口
每个游戏启动后默认进入主菜单(ASCII 艺术字 + 选项高亮),支持键盘导航:
↑/↓:选择菜单项Enter:确认Esc:返回上一级或退出
调试友好设计:
- 所有游戏在
DEBUG模式下(编译时用-DCMAKE_BUILD_TYPE=Debug)会输出详细日志到控制台,例如:DEBUG [snake/main.cpp:87] Snake head moved to (12, 5), new direction: RIGHT - 主循环中插入
#ifdef DEBUG ... #endif区域,方便临时打印变量状态 common/timer.hpp提供Timer::frameTimeMs()方法,可实时监控帧率(按F1切换显示/隐藏 FPS)
3. 核心模块拆解:贪吃蛇的双向链表实现与俄罗斯方块的旋转矩阵
3.1 贪吃蛇:用 std::list 实现蛇身,避免数组越界玄学
games/snake/snake.hpp中定义蛇身结构:
#include <list> #include <utility> // std::pair struct Position { int x, y; Position(int x = 0, int y = 0) : x(x), y(y) {} bool operator==(const Position& other) const { return x == other.x && y == other.y; } }; class Snake { private: std::list<Position> body; // 双向链表:头部=蛇头,尾部=蛇尾 Position direction; // 当前移动方向向量:{0,-1}=UP, {1,0}=RIGHT 等 static constexpr int GRID_SIZE = 20; // 游戏区划分为 20x20 网格 public: void move() { // 1. 获取当前蛇头位置 auto head = body.front(); // 2. 计算新蛇头位置(向量加法) Position newHead{head.x + direction.x, head.y + direction.y}; // 3. 将新头插入链表前端 body.push_front(newHead); // 4. 若未吃到食物,删除蛇尾(保持长度不变) if (!isFoodEaten(newHead)) { body.pop_back(); // 关键!pop_back() 安全,无需检查空链表(游戏开始时body.size()>=1) } } // 检测是否撞墙或自撞(遍历链表,跳过蛇头自身) bool isColliding() const { auto head = body.front(); // 撞墙:超出游戏区边界(假设游戏区宽400px,高300px,GRID_SIZE=20 → 20x15格) if (head.x < 0 || head.x >= 20 || head.y < 0 || head.y >= 15) return true; // 自撞:新蛇头位置是否与其他身体节点重合 auto it = body.begin(); ++it; // 跳过蛇头 for (; it != body.end(); ++it) { if (*it == head) return true; } return false; } };参数说明与设计权衡:
- 使用
std::list而非std::vector:push_front()和pop_back()均为 O(1),且无需预分配内存;vector的insert(begin(), ...)是 O(n)。 GRID_SIZE硬编码为 20:简化坐标映射逻辑(屏幕坐标 ÷ GRID_SIZE = 网格坐标),实际项目中应抽为配置项。isColliding()中++it跳过蛇头:避免误判“蛇头撞自己”,这是新手最常写的 bug。
3.2 俄罗斯方块:用旋转矩阵查表法,绕过三角函数计算
games/tetris/piece.hpp定义方块数据结构:
#include <array> #include <vector> enum class TetrominoType { I, O, T, S, Z, J, L }; // 每种方块的 4 种旋转形态(0°, 90°, 180°, 270°),用相对坐标表示 // 以方块中心为原点,坐标范围 [-1,1],例如 'O' 方块永远是 2x2 正方形,无旋转变化 static const std::array<std::array<std::array<int, 2>, 4>, 7> ROTATION_DATA = {{ // I型:长条,4种形态本质是同一组点的旋转 {{{-1,0},{0,0},{1,0},{2,0}}}, // 0° {{{0,-1},{0,0},{0,1},{0,2}}}, // 90° {{{-1,0},{0,0},{1,0},{2,0}}}, // 180°(同0°,因对称) {{{0,-1},{0,0},{0,1},{0,2}}}, // 270°(同90°) // O型:固定2x2,所有旋转形态相同 {{{-1,-1},{-1,0},{0,-1},{0,0}}, {{-1,-1},{-1,0},{0,-1},{0,0}}, {{-1,-1},{-1,0},{0,-1},{0,0}}, {{-1,-1},{-1,0},{0,-1},{0,0}}}, // T型:中心点+上下左右,旋转时中心点固定,其他点绕中心转 {{{-1,0},{0,0},{1,0},{0,-1}}}, // 0°:T字顶朝上 {{{0,-1},{0,0},{0,1},{-1,0}}}, // 90°:T字顶朝左 {{{-1,0},{0,0},{1,0},{0,1}}}, // 180°:T字顶朝下 {{{0,-1},{0,0},{0,1},{1,0}}}, // 270°:T字顶朝右 // ... 其他类型省略,结构同上 }}; class Piece { private: TetrominoType type; int rotation; // 0~3,对应 ROTATION_DATA[type][rotation] Position position; // 当前方块在游戏板上的左上角坐标(以格为单位) public: // 获取当前旋转形态的所有方块坐标(绝对坐标 = position + 相对坐标) std::vector<Position> getAbsolutePositions() const { std::vector<Position> result; const auto& shape = ROTATION_DATA[static_cast<int>(type)][rotation]; for (const auto& offset : shape) { result.emplace_back(position.x + offset[0], position.y + offset[1]); } return result; } // 顺时针旋转:rotation = (rotation + 1) % 4 void rotateClockwise() { rotation = (rotation + 1) % 4; } };关键实现细节:
- 查表法替代数学计算:不使用
sin/cos或矩阵乘法,直接硬编码所有旋转形态。性能零损耗,且完全规避浮点误差导致的坐标偏移问题。 - 中心点隐式处理:
ROTATION_DATA中所有坐标均以方块“视觉中心”为原点,getAbsolutePositions()中叠加position即完成平移,无需额外计算中心偏移。 - O型特殊处理:其
ROTATION_DATA四个形态完全相同,避免无效旋转操作。
4. 常见问题排查:编译失败、黑屏、逻辑错位的血泪经验
4.1 现象:CMake 配置时报错 “Could NOT find SDL2 (missing: SDL2_LIBRARY SDL2_INCLUDE_DIR)”
原因:CMake 无法定位 SDL2 库文件。常见于:
- Windows 未安装 SDL2 开发包(仅下载了 runtime DLL)
- macOS 用 Homebrew 安装但未
brew link sdl2 - Linux 未安装
libsdl2-dev(Ubuntu/Debian)或sdl2-devel(CentOS/RHEL)
解决:
- Windows:去 libsdl.org 下载
SDL2-devel-2.28.5-VC.zip,解压后将SDL2-2.28.5\lib\x64路径传给 CMake:cmake .. -DSDL2_ROOT_DIR="D:/path/to/SDL2-2.28.5" -DCMAKE_BUILD_TYPE=Release - macOS:确保
brew install sdl2后执行brew link sdl2,或手动指定:cmake .. -DSDL2_ROOT_DIR="/opt/homebrew/opt/sdl2" ... - Linux:
sudo apt install libsdl2-dev(Ubuntu),然后cmake ..即可自动找到。
4.2 现象:程序编译通过,但运行后窗口一闪而逝或黑屏
原因:SDL2 初始化失败后未正确处理错误,或主循环未启动。典型代码缺陷:
// 错误示范:忽略 SDL_Init 返回值 SDL_Init(SDL_INIT_VIDEO); // 若失败,后续 SDL_CreateWindow 必然崩溃 SDL_Window* window = SDL_CreateWindow(...); // 此处可能为 nullptr解决:
- 检查
games/*/main.cpp中 SDL 初始化部分,必须添加错误检查:if (SDL_Init(SDL_INIT_VIDEO) < 0) { Logger::error("SDL could not initialize! SDL_Error: %s", SDL_GetError()); return -1; } SDL_Window* window = SDL_CreateWindow("Snake", ..., SDL_WINDOW_SHOWN); if (!window) { Logger::error("Window could not be created! SDL_Error: %s", SDL_GetError()); SDL_Quit(); return -1; } - 确保主循环中调用
SDL_PollEvent()处理窗口关闭事件,否则窗口会无响应:SDL_Event e; while (SDL_PollEvent(&e) != 0) { if (e.type == SDL_QUIT) { running = false; // 退出主循环标志 } }
4.3 现象:贪吃蛇移动时“瞬移”或“方向错乱”,比如按右键却向上走
原因:方向更新逻辑写在move()内部,导致每帧都覆盖用户输入。正确做法是:输入只更新方向变量,move()只根据当前方向计算新位置。
解决:
- 检查
snake.hpp的move()方法,确认其中没有类似direction = {1,0};的硬编码赋值。 - 方向应在输入处理模块(如
input_manager.hpp)中捕获,并存入Snake对象的成员变量:// 在主循环的输入处理段 if (key == SDLK_RIGHT && currentDir != LEFT) { // 防止180°掉头 snake.setDirection({1, 0}); } // 然后在 move() 中只做:head.x += direction.x; ... setDirection()需校验反向(如当前向左,禁止设为向右),否则蛇会立即自撞。
4.4 现象:俄罗斯方块旋转后位置偏移,方块“飞出”游戏区
原因:旋转时未重新计算方块在游戏板上的锚点(anchor point)。ROTATION_DATA中的坐标是相对中心的,但position成员变量被误认为是左上角,导致旋转后整体偏移。
解决:
Piece类中position应明确为方块中心在游戏板上的坐标(整数格),而非左上角。getAbsolutePositions()中的计算改为:result.emplace_back(position.x + offset[0], position.y + offset[1]);- 初始化
position时,需设为游戏板中心附近(如Position{10, 0}),而非(0,0)。 - 在
board.hpp的碰撞检测中,需确保position.x + offset[0]不越界(0 <= x < BOARD_WIDTH)。
5. 二次开发实战:给扫雷添加“标记旗帜”功能与难度选择菜单
5.1 扫雷游戏结构速览:games/minesweeper/的关键文件
minesweeper/ ├── CMakeLists.txt ├── main.cpp # 主循环:处理菜单、游戏状态切换 ├── game_board.hpp # 核心:二维数组存储雷区,含布雷、开格、递归展开逻辑 ├── cell.hpp # 单格状态:HIDDEN / REVEALED / FLAGGED / MINE ├── menu.hpp # ASCII 菜单渲染与输入处理 └── renderer.hpp # SDL2 绘制:格子、数字、旗帜图标(assets/flag.png)当前版本仅支持固定 10x10 雷区、15 颗雷,且无标记功能。我们要实现:
- 按右键在隐藏格上放置/取消旗帜(
FLAGGED状态) - 新增难度菜单:初级(9x9, 10雷)、中级(16x16, 40雷)、高级(16x30, 99雷)
5.2 添加右键标记:修改game_board.hpp与输入处理
步骤1:扩展Cell状态枚举
// games/minesweeper/cell.hpp enum class CellState { HIDDEN, // 未翻开 REVEALED, // 已翻开(显示数字或空白) FLAGGED, // 已插旗(仅对 HIDDEN 格有效) MINE // 地雷(翻开后游戏结束) };步骤2:在GameBoard类中添加标记方法
// games/minesweeper/game_board.hpp class GameBoard { private: std::vector<std::vector<Cell>> board; int width, height, mineCount; public: // 在 (x,y) 格切换旗帜状态(仅当该格为 HIDDEN 时) void toggleFlag(int x, int y) { if (x < 0 || x >= width || y < 0 || y >= height) return; auto& cell = board[y][x]; // 注意:y 是行(垂直),x 是列(水平) if (cell.state == CellState::HIDDEN) { cell.state = CellState::FLAGGED; } else if (cell.state == CellState::FLAGGED) { cell.state = CellState::HIDDEN; } // 不对 REVEALED/MINE 格做任何操作 } // 检查是否所有雷都被正确标记(胜利条件之一) bool allMinesFlagged() const { int flaggedCount = 0; for (int y = 0; y < height; ++y) { for (int x = 0; x < width; ++x) { if (board[y][x].state == CellState::FLAGGED && board[y][x].isMine) { // isMine 是 Cell 的布尔成员 flaggedCount++; } } } return flaggedCount == mineCount; } };步骤3:在主循环中捕获右键事件
// games/minesweeper/main.cpp 的主循环内 SDL_Event e; while (SDL_PollEvent(&e) != 0) { if (e.type == SDL_QUIT) { running = false; } else if (e.type == SDL_MOUSEBUTTONDOWN) { if (e.button.button == SDL_BUTTON_LEFT) { // 左键:翻开格子(原有逻辑) int x, y; SDL_GetMouseState(&x, &y); gameBoard.revealCell(screenToGrid(x, y)); // 屏幕坐标转网格坐标 } else if (e.button.button == SDL_BUTTON_RIGHT) { // 右键:标记旗帜(新增逻辑) int x, y; SDL_GetMouseState(&x, &y); gameBoard.toggleFlag(screenToGrid(x, y)); } } }5.3 实现难度选择菜单:重构menu.hpp与main.cpp
步骤1:定义难度枚举与配置表
// games/minesweeper/menu.hpp enum class Difficulty { BEGINNER, INTERMEDIATE, EXPERT }; struct DifficultyConfig { int width, height, mineCount; }; static const std::map<Difficulty, DifficultyConfig> DIFFICULTY_TABLE = { {Difficulty::BEGINNER, {9, 9, 10}}, {Difficulty::INTERMEDIATE, {16, 16, 40}}, {Difficulty::EXPERT, {16, 30, 99}} };步骤2:修改主菜单逻辑,支持难度选择
// games/minesweeper/main.cpp int main(int argc, char* argv[]) { // ... SDL 初始化 ... Difficulty selectedDifficulty = Difficulty::BEGINNER; GameState gameState = GameState::MENU; // 新增状态:MENU / PLAYING / GAME_OVER while (running) { switch (gameState) { case GameState::MENU: { renderMainMenu(); // 显示 "Beginner / Intermediate / Expert / Quit" if (handleMenuInput(selectedDifficulty, gameState)) { // 用户选择了难度,初始化新游戏板 gameBoard = GameBoard( DIFFICULTY_TABLE.at(selectedDifficulty).width, DIFFICULTY_TABLE.at(selectedDifficulty).height, DIFFICULTY_TABLE.at(selectedDifficulty).mineCount ); gameState = GameState::PLAYING; } break; } case GameState::PLAYING: { // 主游戏循环(含左右键处理) break; } } } return 0; }步骤3:在renderer.hpp中绘制旗帜图标
// games/minesweeper/renderer.hpp void renderCell(const Cell& cell, int gridX, int gridY) { SDL_Rect dest = {gridX * CELL_SIZE, gridY * CELL_SIZE, CELL_SIZE, CELL_SIZE}; if (cell.state == CellState::FLAGGED) { SDL_Texture* flagTex = loadTexture("assets/flag.png"); // 预加载一次,缓存指针 SDL_RenderCopy(renderer, flagTex, nullptr, &dest); } else if (cell.state == CellState::REVEALED) { // 渲染数字或空白... } else if (cell.state == CellState::HIDDEN) { // 渲染未翻开格子... } }提示:
assets/flag.png需自行准备(16x16 像素 PNG),放入assets/目录。若不想处理图片,可用 ASCII 字符F替代,在renderCell()中调用SDL_RenderDrawText()(需集成 SDL2_ttf)。
6. 从那以后我每次改 C++ 游戏,都强制走一遍这三步验证
改完代码,尤其是动了核心数据结构(如Snake::body的容器类型、Piece的坐标系统),我绝不会直接点运行看效果。而是机械地执行以下三步,十年没翻过车:
第一步:编译时开启所有警告,把-Wall -Wextra -Werror加进 CMake
在CMakeLists.txt顶层添加:
if(CMAKE_BUILD_TYPE STREQUAL "Debug") target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Werror) endif()理由:std::list::pop_back()对空容器是未定义行为,但编译器不会报错;而std::list::back()对空容器会触发-Wnull-dereference警告。加-Werror后,这类隐患在编译期就被掐死,比运行时崩溃好 debug 一万倍。
第二步:对每个游戏写一个最小化测试用例(test_*.cpp),只验证核心逻辑
例如test_snake.cpp:
#include "snake.hpp" #include <cassert> int main() { Snake snake; // 初始化:蛇长3格,头在(5,5),向右 snake.init({5,5}, {1,0}); // 假设添加了 init() 方法 assert(snake.getBody().size() == 3); snake.move(); // 向右移动一格 auto head = snake.getBody().front(); assert(head.x == 6 && head.y == 5); // 新蛇头应在(6,5) // 测试撞墙 snake.setPosition({19,5}); // 移到最右列 snake.setDirection({1,0}); snake.move(); assert(snake.isColliding()); // 应撞墙 }编译命令:g++ -std=c++17 test_snake.cpp -o test_snake && ./test_snake。每次改snake.hpp,先跑这个,绿了再跑完整游戏。
第三步:用valgrind(Linux/macOS)或Application Verifier(Windows)扫内存泄漏
特别是加了新资源(如 SDL_Texture、SDL_AudioSpec),必须确保cleanup()函数被调用:
// games/snake/main.cpp void cleanup() { SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); SDL_Quit(); } // 在 main() 结尾、所有 return 前调用 cleanup()漏掉SDL_DestroyRenderer(),valgrind --leak-check=full ./snake会精准报出:“definitely lost: 128 bytes in 1 blocks”。
这三步看着慢,实则省下 90% 的调试时间。上次给某跨平台系统移植扫雷时,光是valgrind就揪出 3 个 texture 未释放的 bug,避免了客户设备内存耗尽重启的事故。希望帮到你。
本文还有配套的精品资源,点击获取