
1. 项目概述从零构建一个C控制台游戏“猫抓老鼠”这个游戏相信是很多人童年记忆的一部分。它规则简单但策略性十足非常适合作为编程入门的实战项目。今天我想分享的就是如何用最纯粹的C在控制台环境下亲手实现这个经典游戏。这不仅仅是一个“Hello World”式的练习它涵盖了游戏循环、状态管理、用户输入、随机逻辑、碰撞检测等游戏开发的核心概念是检验和巩固C基础知识的绝佳试金石。这个项目适合所有正在学习C并且已经掌握了基础语法如变量、循环、条件判断、函数、数组的朋友。即使你还没接触过面向对象我们也可以先用过程化的方式来实现之后再探讨如何用类来重构让它更优雅。我们将从最简陋的文本界面开始一步步添加功能最终实现一个可玩性不错的版本。过程中我会穿插讲解为什么选择某种数据结构某个算法背后的考量以及我踩过的那些坑。我们的目标是写出来的代码不仅要能跑还要清晰、易维护让你真正理解每一行代码的作用。2. 核心设计思路与架构拆解在动手写代码之前花点时间想清楚整体架构能省去后期大量的重构时间。一个简单的“猫抓老鼠”游戏核心元素无非是一个棋盘地图、一只猫、一只或多只老鼠、以及一些可能的障碍物。游戏的核心循环是显示当前状态 - 获取玩家输入控制猫 - 更新游戏逻辑猫移动老鼠按某种规则移动 - 判断胜负条件。2.1 游戏状态的数据表示首先我们需要一种方式来“记住”棋盘上每个格子里有什么。最简单直接的方式就是使用一个二维字符数组或者vectorvectorchar。例如我们可以约定C代表猫M代表老鼠#代表墙或障碍物.代表空地*代表奶酪目标为什么用char而不用int或枚举在控制台显示时char可以直接打印非常方便。当然用枚举enum CellType来定义类型再用一个二维数组存储这些枚举值是更工程化的做法可读性更强。但对于第一个快速原型我们用char足以。猫和老鼠的位置我们可以用两个简单的结构体Point来记录包含x和y坐标。这样在移动逻辑中直接修改这两个点的坐标比遍历整个二维数组查找效率要高。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);这里有几个关键点清屏system(“cls”)是Windows特有的它依赖于操作系统。更可移植的做法是输出足够多的换行符或者使用像ncurses这样的库。但为了最初的原型简单我们可以先用它。输入获取getPlayerInput()需要实现非阻塞或半阻塞的输入。控制台默认是行缓冲的我们需要用_kbhit()和_getch()Windows或termios库Linux来获取单个即时按键。更新顺序先处理玩家输入猫再处理AI老鼠这样逻辑更符合直觉。延时Sleep(100)让每次循环间隔约0.1秒既保证了游戏有可玩的速度又不会过度消耗CPU。这个值可以根据需要调整。注意频繁调用system()函数存在安全风险和性能开销在产品级项目中应避免。这里仅用于教学演示。一个更好的做法是直接操作控制台API或使用跨平台库。2.3 老鼠的AI让游戏活起来老鼠不能傻站着需要一点简单的“智能”。这里就是体现设计思路的地方。我们可以设计几种不同复杂度的AI随机移动最简单。老鼠在每个回合随机选择上、下、左、右中的一个方向如果该方向可走。实现简单但行为完全不可预测缺乏挑战性。// 伪代码 int dir rand() % 4; // 0上1下2左3右 根据 dir 计算新位置 newPos; if (位置有效且不是墙) { mousePos newPos; }远离猫的移动增加一点策略。计算猫和老鼠的相对位置让老鼠倾向于向远离猫的方向移动。这需要计算距离。// 伪代码计算四个可能方向移动后与猫的欧几里得距离或曼哈顿距离 // 选择移动后距离最大的那个方向前提是可走。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 添加游戏元素与状态奶酪与胜负条件在地图上随机放置一块奶酪‘*’。老鼠的目标是吃到奶酪猫的目标是抓住老鼠。游戏胜负条件变为猫鼠同格 - 猫赢。鼠与奶酪同格 - 鼠赢。可以加入回合限制超时则为平局或猫赢。分数与回合计数记录猫移动的步数步数越少得分越高。或者记录游戏进行的回合数。多个老鼠将mousePos改为vectorPoint每只老鼠独立移动。猫需要抓住所有老鼠才能赢或者只要抓住一只就赢。更复杂的地形引入多种地形例如‘~’水域猫不能进入老鼠可以。‘’传送点进入后随机传送到另一个传送点。这些地形需要在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::vectorstd::vectorCell 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_ptrCat cat; std::vectorstd::unique_ptrMouse 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 调试技巧让问题无处遁形打印调试信息在关键函数入口、变量改变处打印日志。例如在updateMousePosition里打印老鼠的新旧坐标。std::cout [DEBUG] Mouse moving from ( mouse.x , mouse.y ) to ( newX , newY )\n;使用调试器如果你使用Visual Studio、CLion或VSCode配合GDB/LLDB学会设置断点、单步执行、查看变量值。这是定位逻辑错误最强大的武器。特别是对于随机性的问题断点比cout更有效。控制随机性调试AI时随机行为难以复现问题。可以暂时将srand(time(nullptr))改为srand(0)这样每次运行随机序列都相同便于定位。地图快照当游戏行为异常时写一个函数把当前地图状态完整输出到一个文本文件方便离线分析。5.2 常见问题排查表问题现象可能原因排查方法猫或老鼠“穿墙”isValidPosition函数边界检查错误或地图数组越界。1. 检查ROWS和COLS定义。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 性能与代码优化建议减少不必要的重绘我们的简单实现是每帧清屏并重绘整个地图。对于10x10的网格没问题。但如果网格很大比如50x50频繁的cout操作会成为瓶颈。优化思路是“脏矩形”渲染只重绘那些发生变化的格子。避免频繁的系统调用system(“cls”)和Sleep()都是系统调用。对于游戏循环来说可以自己实现一个更精确的帧率控制例如使用chrono库来计算帧时间。使用更高效的数据结构当有几十上百只老鼠时用vectorPoint存储并遍历更新是O(n)。如果需要进行空间查询如“找到3格内的所有老鼠”可以考虑使用空间划分数据结构如网格分区Spatial Grid将地图划分为大格子每个格子维护其中的实体列表查询时只需检查相邻的几个大格子。预计算可行走区域如果地图是固定的可以在游戏初始化时就计算好每个格子是否是“可行走”的并存储在一个bool二维数组中。这样在isValidPosition中只需要做一次数组查找比判断map[x][y] ! ‘#’可能更快虽然对于字符比较来说差异不大但思路可以借鉴。代码可读性给魔法数字如72,80方向键码和字符常量如‘#’,‘C’起有意义的名字使用枚举或常量定义。这不会提升运行性能但会极大提升你和其他人阅读、维护代码的效率减少bug。写完基础版本后不要停下。尝试去实现我提到的“功能增强”里的任意一点或者尝试用不同的AI算法。这个过程里遇到的每一个问题和解决它的过程才是这个项目带给你的最大价值。编程就像这个“猫抓老鼠”的游戏你需要不断地探索、试错、优化最终抓住那个名为“解决方案”的老鼠。