三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

C++控制台游戏开发实战:从零实现猫抓老鼠游戏

C++控制台游戏开发实战:从零实现猫抓老鼠游戏

1. 项目概述:从零构建一个C++控制台游戏

“猫抓老鼠”这个游戏,相信是很多人童年记忆的一部分。它规则简单,但策略性十足,非常适合作为编程入门的实战项目。今天,我想分享的,就是如何用最纯粹的C++,在控制台环境下,亲手实现这个经典游戏。这不仅仅是一个“Hello World”式的练习,它涵盖了游戏循环、状态管理、用户输入、随机逻辑、碰撞检测等游戏开发的核心概念,是检验和巩固C++基础知识的绝佳试金石。

这个项目适合所有正在学习C++,并且已经掌握了基础语法(如变量、循环、条件判断、函数、数组)的朋友。即使你还没接触过面向对象,我们也可以先用过程化的方式来实现,之后再探讨如何用类来重构,让它更优雅。我们将从最简陋的文本界面开始,一步步添加功能,最终实现一个可玩性不错的版本。过程中,我会穿插讲解为什么选择某种数据结构,某个算法背后的考量,以及我踩过的那些坑。我们的目标是:写出来的代码不仅要能跑,还要清晰、易维护,让你真正理解每一行代码的作用。

2. 核心设计思路与架构拆解

在动手写代码之前,花点时间想清楚整体架构,能省去后期大量的重构时间。一个简单的“猫抓老鼠”游戏,核心元素无非是:一个棋盘(地图)、一只猫、一只或多只老鼠、以及一些可能的障碍物。游戏的核心循环是:显示当前状态 -> 获取玩家输入(控制猫) -> 更新游戏逻辑(猫移动,老鼠按某种规则移动) -> 判断胜负条件。

2.1 游戏状态的数据表示

首先,我们需要一种方式来“记住”棋盘上每个格子里有什么。最简单直接的方式就是使用一个二维字符数组(或者vector<vector<char>>)。例如,我们可以约定:

  • 'C'代表猫
  • 'M'代表老鼠
  • '#'代表墙或障碍物
  • '.'代表空地
  • '*'代表奶酪(目标)

为什么用char而不用int或枚举?在控制台显示时,char可以直接打印,非常方便。当然,用枚举(enum CellType)来定义类型,再用一个二维数组存储这些枚举值,是更工程化的做法,可读性更强。但对于第一个快速原型,我们用char足以。

猫和老鼠的位置,我们可以用两个简单的结构体Point来记录,包含xy坐标。这样在移动逻辑中,直接修改这两个点的坐标,比遍历整个二维数组查找效率要高。

struct Point { int x; // 行坐标 int y; // 列坐标 }; // 游戏地图,例如 10x10 const int ROWS = 10; const int COLS = 10; char gameMap[ROWS][COLS]; Point catPos; Point mousePos;

2.2 游戏主循环的结构

游戏主循环是游戏的心脏,它必须稳定且高效。一个经典的游戏循环结构如下:

bool gameRunning = true; bool catWon = false; while (gameRunning) { // 1. 渲染:清屏并打印当前地图 system("cls"); // Windows, Linux/Mac 用 "clear" printMap(gameMap, ROWS, COLS); // 2. 处理输入:获取玩家控制猫的方向 char input = getPlayerInput(); // 3. 更新:根据输入移动猫,然后让老鼠自动移动 updateCatPosition(input, catPos, gameMap); updateMousePosition(mousePos, catPos, gameMap); // 4. 检测碰撞/胜负条件 if (catPos.x == mousePos.x && catPos.y == mousePos.y) { gameRunning = false; catWon = true; } // 可以添加其他条件,如老鼠到达奶酪,游戏时间结束等 // 5. 控制游戏节奏,避免循环过快 Sleep(100); // Windows, 单位毫秒 } // 循环结束,显示游戏结果 displayGameResult(catWon);

这里有几个关键点:

  1. 清屏system(“cls”)是Windows特有的,它依赖于操作系统。更可移植的做法是输出足够多的换行符,或者使用像ncurses这样的库。但为了最初的原型简单,我们可以先用它。
  2. 输入获取getPlayerInput()需要实现非阻塞或半阻塞的输入。控制台默认是行缓冲的,我们需要用_kbhit()_getch()(Windows)或termios库(Linux)来获取单个即时按键。
  3. 更新顺序:先处理玩家输入(猫),再处理AI(老鼠),这样逻辑更符合直觉。
  4. 延时Sleep(100)让每次循环间隔约0.1秒,既保证了游戏有可玩的速度,又不会过度消耗CPU。这个值可以根据需要调整。

注意:频繁调用system()函数存在安全风险和性能开销,在产品级项目中应避免。这里仅用于教学演示。一个更好的做法是直接操作控制台API或使用跨平台库。

2.3 老鼠的AI:让游戏活起来

老鼠不能傻站着,需要一点简单的“智能”。这里就是体现设计思路的地方。我们可以设计几种不同复杂度的AI:

  1. 随机移动:最简单。老鼠在每个回合随机选择上、下、左、右中的一个方向(如果该方向可走)。实现简单,但行为完全不可预测,缺乏挑战性。

    // 伪代码 int dir = rand() % 4; // 0上,1下,2左,3右 根据 dir 计算新位置 newPos; if (位置有效且不是墙) { mousePos = newPos; }
  2. 远离猫的移动:增加一点策略。计算猫和老鼠的相对位置,让老鼠倾向于向远离猫的方向移动。这需要计算距离。

    // 伪代码:计算四个可能方向移动后,与猫的欧几里得距离或曼哈顿距离 // 选择移动后距离最大的那个方向(前提是可走)。
  3. A*寻路算法:最复杂但最智能。如果地图上有奶酪,老鼠可以使用A*算法寻找通往奶酪的最短路径,同时避开猫。这涉及到开放列表、封闭列表和启发函数,是一个不小的挑战,但实现后游戏性会大大提升。

对于初版,我强烈建议从随机移动开始。先让整个游戏流程跑通,之后再迭代AI部分。这是项目管理的常见技巧:先做出一个最小可行产品(MVP)。

3. 核心模块实现与代码精讲

有了设计蓝图,我们来逐一实现各个模块。我会重点讲解容易出错的细节和性能考量。

3.1 地图初始化与渲染

地图初始化不只是填满字符。我们需要考虑可玩性:墙要构成一定的迷宫格局,猫和老鼠的初始位置不能重叠,最好也不要太近。

void initializeMap(char map[ROWS][COLS], Point& cat, Point& mouse) { // 1. 全部初始化为空地 for (int i = 0; i < ROWS; ++i) { for (int j = 0; j < COLS; ++j) { map[i][j] = '.'; } } // 2. 放置围墙 for (int j = 0; j < COLS; ++j) { map[0][j] = '#'; map[ROWS-1][j] = '#'; } for (int i = 0; i < ROWS; ++i) { map[i][0] = '#'; map[i][COLS-1] = '#'; } // 3. 随机放置一些内部障碍物(确保有通路) srand(time(nullptr)); int obstacleCount = ROWS * COLS / 10; // 障碍物约占10%格子 for (int k = 0; k < obstacleCount; ++k) { int i = rand() % ROWS; int j = rand() % COLS; // 确保不放在边界和可能的主角初始位置上 if (map[i][j] == '.' && !(i < 2 && j < 2)) { map[i][j] = '#'; } } // 4. 放置猫和老鼠(确保位置为空且不重叠) do { cat.x = 1 + rand() % (ROWS - 2); cat.y = 1 + rand() % (COLS - 2); } while (map[cat.x][cat.y] != '.'); map[cat.x][cat.y] = 'C'; do { mouse.x = 1 + rand() % (ROWS - 2); mouse.y = 1 + rand() % (COLS - 2); } while (map[mouse.x][mouse.y] != '.' || (abs(cat.x - mouse.x) + abs(cat.y - mouse.y) < 5)); // 确保初始距离不太近 map[mouse.x][mouse.y] = 'M'; // 5. 可以放置一个奶酪作为目标 // ... 类似逻辑 }

渲染函数相对简单,但要注意细节:

void printMap(const char map[ROWS][COLS], int rows, int cols) { for (int i = 0; i < rows; ++i) { for (int j = 0; j < cols; ++j) { std::cout << map[i][j] << ' '; // 加空格让显示更清晰 } std::cout << std::endl; } std::cout << "\n控制: W(上) A(左) S(下) D(右) | Q(退出)\n"; }

实操心得:在初始化随机位置时,使用do...while循环比while循环更合适,因为它至少会执行一次,逻辑更清晰。条件abs(cat.x - mouse.x) + abs(cat.y - mouse.y) < 5使用了曼哈顿距离来确保两者初始有一定距离,避免游戏一开始就结束,这比简单的“坐标不相等”要求更高,提升了游戏体验。

3.2 非阻塞键盘输入处理

这是控制台游戏的一个小难点。标准cin会等待回车,不适合实时游戏。我们需要即时读取按键。

Windows平台实现:

#include <conio.h> // 用于 _kbhit 和 _getch char getPlayerInput() { if (_kbhit()) { // 检查是否有按键按下 char ch = _getch(); // 处理方向键(扩展键码) if (ch == 0 || ch == -32) { // 方向键的第一个字符 ch = _getch(); // 获取真正的方向码 switch (ch) { case 72: return 'W'; // 上 case 80: return 'S'; // 下 case 75: return 'A'; // 左 case 77: return 'D'; // 右 } } // 处理 WASD 和退出键 ch = toupper(ch); if (ch == 'W' || ch == 'A' || ch == 'S' || ch == 'D' || ch == 'Q') { return ch; } } return '\0'; // 没有有效输入 }

Linux/macOS平台实现(使用termios):

#include <termios.h> #include <unistd.h> #include <fcntl.h> char getPlayerInput() { struct termios oldt, newt; char ch; int oldf; // 获取当前终端设置并修改为“非规范模式”和“无回显” tcgetattr(STDIN_FILENO, &oldt); newt = oldt; newt.c_lflag &= ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, &newt); // 设置文件描述符为非阻塞 oldf = fcntl(STDIN_FILENO, F_GETFL, 0); fcntl(STDIN_FILENO, F_SETFL, oldf | O_NONBLOCK); ch = getchar(); // 恢复原有终端设置 tcsetattr(STDIN_FILENO, TCSANOW, &oldt); fcntl(STDIN_FILENO, F_SETFL, oldf); if (ch != EOF) { ch = toupper(ch); if (ch == 'W' || ch == 'A' || ch == 'S' || ch == 'D' || ch == 'Q') { return ch; } // Linux终端方向键处理更复杂,通常先收到转义序列‘\033’,这里为简化,建议用WASD } return '\0'; }

踩坑记录:跨平台输入处理是控制台游戏的一大痛点。上面的Linux代码只是一个简化示例,实际处理方向键需要解析\033[A这样的转义序列。对于学习项目,我强烈建议统一使用WASD字母键,这样可以极大简化代码,保证在Windows和Linux/macOS上都能一致工作。这也是很多成熟控制台游戏的做法。

3.3 移动逻辑与碰撞检测

移动逻辑需要检查目标位置是否合法(不超出边界且不是墙)。

bool isValidPosition(int x, int y, const char map[ROWS][COLS]) { return (x >= 0 && x < ROWS && y >= 0 && y < COLS && map[x][y] != '#'); } void updateCatPosition(char input, Point& cat, char map[ROWS][COLS]) { int newX = cat.x, newY = cat.y; switch (input) { case 'W': newX--; break; case 'S': newX++; break; case 'A': newY--; break; case 'D': newY++; break; default: return; // 非移动指令,直接返回 } if (isValidPosition(newX, newY, map)) { // 更新地图:旧位置变空地,新位置放猫 map[cat.x][cat.y] = '.'; cat.x = newX; cat.y = newY; map[cat.x][cat.y] = 'C'; } // 如果位置无效(撞墙),则猫不动 }

老鼠的随机移动实现:

void updateMousePosition(Point& mouse, const Point& cat, char map[ROWS][COLS]) { // 简单随机移动AI int directions[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; // 上,下,左,右 int randomIndex = rand() % 4; int newX = mouse.x + directions[randomIndex][0]; int newY = mouse.y + directions[randomIndex][1]; if (isValidPosition(newX, newY, map)) { map[mouse.x][mouse.y] = '.'; mouse.x = newX; mouse.y = newY; map[mouse.x][mouse.y] = 'M'; } // 如果随机方向不可走,则本轮老鼠不动(也可以尝试其他方向) }

碰撞检测在游戏主循环中已经做了,就是简单的坐标比较。但这里有一个关键细节:我们是在猫和老鼠都移动完之后才检测的。如果猫移动后抓住了老鼠,那么老鼠在地图上的‘M’会被猫的‘C’覆盖,这没问题。但如果我们想实现“老鼠被抓住的瞬间有特殊显示”,就需要在更新地图前进行检测。

4. 功能增强与代码重构

一个基础版本完成后,我们可以考虑添加更多功能,让游戏更好玩,同时重构代码,让其更健壮。

4.1 添加游戏元素与状态

  1. 奶酪与胜负条件:在地图上随机放置一块奶酪‘*’。老鼠的目标是吃到奶酪,猫的目标是抓住老鼠。游戏胜负条件变为:

    • 猫鼠同格 -> 猫赢。
    • 鼠与奶酪同格 -> 鼠赢。
    • 可以加入回合限制,超时则为平局或猫赢。
  2. 分数与回合计数:记录猫移动的步数,步数越少得分越高。或者记录游戏进行的回合数。

  3. 多个老鼠:将mousePos改为vector<Point>,每只老鼠独立移动。猫需要抓住所有老鼠才能赢,或者只要抓住一只就赢。

  4. 更复杂的地形:引入多种地形,例如:

    • ‘~’水域:猫不能进入,老鼠可以。
    • ‘@’传送点:进入后随机传送到另一个传送点。
    • 这些地形需要在isValidPosition和移动逻辑中做特殊判断。

4.2 使用面向对象重构

当功能越来越多,过程式编程会显得混乱。用类来组织代码是必然的选择。

class GameEntity { public: Point position; char symbol; GameEntity(int x, int y, char s) : position{x, y}, symbol(s) {} virtual void move(const GameMap& map) = 0; // 纯虚函数,不同实体移动逻辑不同 // ... 其他公共属性和方法 }; class Cat : public GameEntity { public: Cat(int x, int y) : GameEntity(x, y, 'C') {} void move(const GameMap& map) override { // 依赖外部输入,这里可能只是更新位置,输入由Game类传入 } }; class Mouse : public GameEntity { public: Mouse(int x, int y) : GameEntity(x, y, 'M') {} void move(const GameMap& map) override { // 实现AI移动逻辑 } }; class GameMap { private: std::vector<std::vector<Cell>> grid; // Cell是一个包含类型和属性的结构体/类 public: void render() const; bool isCellWalkable(const Point& p) const; void setCell(const Point& p, CellType type); // ... }; class Game { private: GameMap map; std::unique_ptr<Cat> cat; std::vector<std::unique_ptr<Mouse>> mice; bool isRunning; // ... public: void run(); // 主游戏循环 void processInput(); void update(); void render(); };

重构后,职责清晰:Game类管理主循环和全局状态,GameMap管理地图数据与查询,GameEntity及其子类负责各自的行为。添加新生物(比如狗)只需要继承GameEntity并实现move方法即可,符合开闭原则。

4.3 引入简单的状态机

游戏可能有多个状态:开始菜单、游戏中、暂停、胜利/失败画面。用一个简单的枚举和状态机来管理,会使逻辑更清晰。

enum class GameState { MAIN_MENU, PLAYING, PAUSED, CAT_WON, MOUSE_WON }; class Game { GameState currentState; // ... void run() { while (true) { switch (currentState) { case GameState::MAIN_MENU: renderMainMenu(); processMainMenuInput(); break; case GameState::PLAYING: renderGame(); processGameInput(); updateGameLogic(); checkWinCondition(); // 可能切换状态到 CAT_WON 等 break; case GameState::CAT_WON: renderWinScreen("Cat Wins!"); if (pressAnyKeyToRestart()) resetGame(); break; // ... 其他状态 } } } };

5. 调试技巧、常见问题与优化建议

即使是一个小游戏,调试起来也可能让人头疼。下面分享一些我实践中总结的经验。

5.1 调试技巧:让问题无处遁形

  1. 打印调试信息:在关键函数入口、变量改变处,打印日志。例如,在updateMousePosition里打印老鼠的新旧坐标。

    std::cout << "[DEBUG] Mouse moving from (" << mouse.x << "," << mouse.y << ") to (" << newX << "," << newY << ")\n";
  2. 使用调试器:如果你使用Visual Studio、CLion或VSCode(配合GDB/LLDB),学会设置断点、单步执行、查看变量值。这是定位逻辑错误最强大的武器。特别是对于随机性的问题,断点比cout更有效。

  3. 控制随机性:调试AI时,随机行为难以复现问题。可以暂时将srand(time(nullptr))改为srand(0),这样每次运行随机序列都相同,便于定位。

  4. 地图快照:当游戏行为异常时,写一个函数把当前地图状态完整输出到一个文本文件,方便离线分析。

5.2 常见问题排查表

问题现象可能原因排查方法
猫或老鼠“穿墙”isValidPosition函数边界检查错误,或地图数组越界。1. 检查ROWSCOLS定义。2. 在isValidPosition中打印传入的x, y值。3. 确保移动逻辑 (newX = cat.x - 1) 正确。
游戏画面闪烁严重清屏 (system(“cls”)) 和打印之间没有延时,或打印内容太多。1. 在主循环中增加Sleep时间。2. 尝试只重绘变化的部分(更复杂)。3. 使用双缓冲技术(先准备完整帧再一次性输出)。
键盘输入无反应或反应迟滞输入函数getPlayerInput实现有误,可能是阻塞的。1. 确认使用的是_kbhit(Win) 或非阻塞getchar(Linux)。2. 检查是否正确处理了按键释放和特殊键。回归基础:先用WASD测试。
老鼠有时会“卡住”不动随机移动的方向恰好是墙,而代码没有“尝试其他方向”的逻辑。updateMousePosition中,如果随机方向不行,可以尝试遍历所有方向,直到找到一个可行的。或者增加一个计数器,连续多次失败后强制移动。
游戏运行一段时间后崩溃内存访问越界、无限递归或未初始化的变量。1. 使用valgrind(Linux) 或AddressSanitizer检查内存错误。2. 检查所有数组索引。3. 检查递归函数是否有终止条件。

5.3 性能与代码优化建议

  1. 减少不必要的重绘:我们的简单实现是每帧清屏并重绘整个地图。对于10x10的网格没问题。但如果网格很大(比如50x50),频繁的cout操作会成为瓶颈。优化思路是“脏矩形”渲染,只重绘那些发生变化的格子。

  2. 避免频繁的系统调用system(“cls”)Sleep()都是系统调用。对于游戏循环来说,可以自己实现一个更精确的帧率控制,例如使用<chrono>库来计算帧时间。

  3. 使用更高效的数据结构:当有几十上百只老鼠时,用vector<Point>存储并遍历更新是O(n)。如果需要进行空间查询(如“找到3格内的所有老鼠”),可以考虑使用空间划分数据结构,如网格分区(Spatial Grid),将地图划分为大格子,每个格子维护其中的实体列表,查询时只需检查相邻的几个大格子。

  4. 预计算可行走区域:如果地图是固定的,可以在游戏初始化时,就计算好每个格子是否是“可行走”的,并存储在一个bool二维数组中。这样在isValidPosition中只需要做一次数组查找,比判断map[x][y] != ‘#’可能更快(虽然对于字符比较来说差异不大,但思路可以借鉴)。

  5. 代码可读性:给魔法数字(如72,80方向键码)和字符常量(如‘#’,‘C’)起有意义的名字,使用枚举或常量定义。这不会提升运行性能,但会极大提升你和其他人阅读、维护代码的效率,减少bug。

写完基础版本后,不要停下。尝试去实现我提到的“功能增强”里的任意一点,或者尝试用不同的AI算法。这个过程里遇到的每一个问题,和解决它的过程,才是这个项目带给你的最大价值。编程就像这个“猫抓老鼠”的游戏,你需要不断地探索、试错、优化,最终抓住那个名为“解决方案”的老鼠。

← 返回列表