ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用C++开发摸金题材回合制探险游戏:从地图生成到存档系统

用C++开发摸金题材回合制探险游戏:从地图生成到存档系统 1. 为什么用C写一款摸金题材的探险小游戏前阵子把大学时期写的几个C练习项目翻出来重构正好那阵子把《鬼吹灯》系列又刷了一遍脑子里突然冒出一个念头与其继续做毫无新意的贪吃蛇、俄罗斯方块不如用C做一个摸金题材的回合制探险小游戏。这里要先说明一下摸金在我的设定里纯粹是字面意义上的探索古墓、寻找宝藏灵感来自各类悬疑探险题材的文学作品和游戏完成的是一款虚构的、娱乐性质的控制台小游戏不涉及任何现实行为玩的就是一个探险解谜收集的乐趣。之所以选C而不是Python或者JavaScript原因很简单我手头正好有一堆积累下来的C代码和经验而且这类带道具、地图、战斗、存档的回合制游戏恰恰是练习数据结构、算法、内存管理、文件读写的最佳载体。用Python写当然更快但用C写出来的每一点难度都是实打实的能力增长。这个游戏的定位也很明确模伤的是早年那种字符界面下的Roguelike游戏——黑色终端窗口里用WASD移动角色地图用字符渲染每个格子可能是墙壁、通道、陷阱、密室也可能藏着机关和宝物。没有图形界面却刚好能把所有精力放在核心玩法和逻辑细节上。如果你是一个C初学者或者已经学了语法但不知道怎么把指针、引用、vector、算法真正用起来那这个项目的拆解过程值得你完整读一遍。整个项目的源码思路、踩坑过程、算法选择我都会在这篇里讲清楚。2. 环境准备VS Code C环境的配置细节开发环境这件事看起来简单但一开始还是折腾了不少时间。我用的是 VS Code MinGW-w64 的这套组合轻量、免费、跨平台也方便后面把项目丢到别的机器上编译。2.1 编译器与VS Code的配置流程MinGW-w64 是目前Windows上比较推荐的GCC移植版本安装完成后需要把bin目录加入系统PATH。验证是否安装成功在终端里执行g --version能输出版本信息就说明编译器就绪。VS Code这边需要安装 C/C 扩展插件然后在项目根目录创建.vscode/tasks.json配置编译任务{ version: 2.0.0, tasks: [ { label: build game, type: shell, command: g, args: [ -g, *.cpp, -o, majin_game.exe, -stdc17, -Wall ], group: { kind: build, isDefault: true } } ] }这里几个参数我重点说一下-stdc17指定标准因为我要用std::optional、std::variant这些新特性-Wall开启所有警告开发阶段能多一层防护-g生成调试信息配合VS Code的断点调试功能使用。2.2 运行时库和常见启动问题很多朋友在别的机器上运行编译出来的exe时遇到过缺少VCRUNTIME140.dll或者无法启动此程序因为计算机中丢失MSVCP140.dll这样的错误。这通常不是因为代码写得有问题而是目标机器缺少Visual C Redistributable运行库。两种解决办法一是把编译方式切换为静态链接在tasks.json的args中加入-static-libgcc -static-libstdc编译出来的exe体积更大但免安装运行库二是让玩家去官网下载对应版本的运行库装上。我个人推荐静态链接方案省事尤其适合给朋友分享小游戏的场景。2.3 工程骨架设计这个游戏的源码目录结构是这样的majin_game/ ├── main.cpp // 入口主循环 ├── GameManager.h/.cpp // 游戏状态管理 ├── Map.h/.cpp // 地图生成与渲染 ├── Player.h/.cpp // 玩家角色 ├── Item.h/.cpp // 物品系统 ├── Monster.h/.cpp // 怪物 ├── SaveSystem.h/.cpp // 存档 └── common.h // 公共头文件模块划分的原则是每个类只干一件事。Map管地图生成Player管角色状态GameManager负责把这些模块串起来。一开始我没分这么细所有逻辑都堆在main.cpp里写到500行以后就彻底乱了后来才拆成现在这个样子。教训很直接C项目一旦超过几百行就别硬往一个文件里塞了。3. 摸金游戏的核心数据结构从角色属性到地图容器数据结构是C项目最见功夫的部分。这一章我会把游戏里几个关键的数据结构完整讲一遍每一步都说明为什么这么设计。3.1 地图的抽象与容器选择游戏地图我用了最经典的二维字符数组格子每格用单个字符表示#墙壁.通道M怪物$宝物E入口X出口底层用一个std::vectorstd::vectorchar来存储而不是原生的char[][]。原因很简单vector可以动态改变大小方便后面生成随机地图时调整尺寸同时vector自带边界检查at()方法调试阶段真的能帮我抓住越界问题。class Map { private: std::vectorstd::vectorchar grid; int width; int height; public: Map(int w, int h) : width(w), height(h) { grid.assign(height, std::vectorchar(width, )); } char getTile(int x, int y) const { return grid.at(y).at(x); } void setTile(int x, int y, char c) { grid.at(y).at(x) c; } bool isWall(int x, int y) const { return getTile(x, y) #; } };at()会做边界检查性能略低于operator[]但在游戏里这一层性能损耗可以忽略。真遇到性能瓶颈后期再去优化成operator[]也不迟。3.2 玩家角色与物品的数据组织玩家属性用Player类保存包括血量、攻击力、防御、所持宝物数量以及当前坐标。struct Position { int x; int y; }; class Player { private: std::string name; int hp; int maxHp; int attack; int defense; int treasureCount; Position pos; public: Player(const std::string n) : name(n), hp(100), maxHp(100), attack(15), defense(5), treasureCount(0), pos{1, 1} {} };物品这块我用了一个std::vectorItem来管理背包Item是简单结构体struct Item { enum class Type { WEAPON, ARMOR, PILL, TREASURE }; Type type; std::string name; int effect; };为什么用vector而不是数组因为背包中的物品种类和数量是运行期动态变化的数组在编译期就要确定大小非常不灵活。而vector可以随时push_back或erase完美契合背包场景。3.3 STL容器在实际游戏里的应用场景写这个项目最大的收获之一就是把STL从背过的概念变成了顺手就能用的工具容器游戏场景为什么选它std::vector地图格子、背包物品、怪物列表随机访问快动态扩容方便std::queueBFS最短路寻路FIFO天然适合广度优先搜索std::stack回溯法生成迷宫LIFO天然适合深度优先搜索std::map按键对应物品名称键值查找方便std::unordered_map物品ID直接索引到物品数据哈希查找O(1)比map更快以前背vector底层是动态数组、list底层是双向链表只是纯记忆真正写完一个游戏你才会感受到用容器之前先想清楚访问模式这件事有多重要。4. 核心玩法链路从地图生成到摸金判定这一章是整个项目的精华也是花时间最长的地方。4.1 迷宫地图生成用栈做深度优先搜索游戏需要随机生成一个带分叉的迷宫我最开始用随机挖墙法结果生成的地图非常凌乱没有走廊感玩起来很怪。后来换成了经典的递归回溯法初始状态下地图全是墙壁。从起点出发随机挑选一个方向挖通道。如果能走前方两格仍是墙壁且不越界就向前挖两格把当前格子入栈。走到死胡同后从栈顶回退继续尝试其他方向。直到栈为空迷宫就生成完了。核心代码片段void MapGenerator::generateMaze() { std::stackPosition pathStack; pathStack.push(startPos); setTile(startPos.x, startPos.y, .); while (!pathStack.empty()) { Position current pathStack.top(); auto dirs getRandomDirections(); // 随机打乱4个方向 bool moved false; for (auto dir : dirs) { int nx current.x dir.dx * 2; int ny current.y dir.dy * 2; if (isInside(nx, ny) getTile(nx, ny) ) { setTile(current.x dir.dx, current.y dir.dy, .); setTile(nx, ny, .); pathStack.push({nx, ny}); moved true; break; } } if (!moved) { pathStack.pop(); // 死胡同回退 } } }这里用栈的原因非常直观栈天然保存了回溯路径你不需要额外记录是怎么走到当前位置的栈顶就是你要回退的地方。这就是数据结构换个场景就变成算法的典型案例。4.2 视野系统与角色移动移动逻辑很简单按W/A/S/D更新坐标但每次尝试移动前必须做两步判断一是目标格是否越界二是目标格是否是墙壁。bool GameManager::movePlayer(int dx, int dy) { int nx player.getPos().x dx; int ny player.getPos().y dy; if (map.isWall(nx, ny)) { return false; } if (map.getTile(nx, ny) M) { enterBattle(nx, ny); return false; } player.setPos({nx, ny}); discovered[nx][ny] true; return true; }迷雾系统我没有用复杂的视锥算法只需要一个跟地图同尺寸的discovered二维布尔数组记录哪些格子是解锁过的。渲染的时候没被探索的格子一律显示为黑色块已经走过的地方才显示真实地形。这个方案对回合制游戏来说效果足够而且实现成本极低。4.3 摸金事件与随机数引擎摸金的核心就是抵达密室后触发探测事件这里需要真正的随机数。很多教材案例还在推荐rand() % 100但rand()是线性同余生成器低位的周期很短而且如果不设种子每次运行结果一样。C11 之后就有了random头文件提供更强的引擎和分布std::mt19937 rng(std::random_device{}()); int rollTreasure(int difficulty) { std::uniform_int_distributionint dist(1, 100); int roll dist(rng); if (roll difficulty * 5) { return rollTreasureType(); // 概率判定宝物品级 } return -1; // 什么都没有 }std::random_device用于产生真随机种子std::mt19937负责高质量伪随机序列uniform_int_distribution负责把随机数均匀映射到指定范围。三者结合摸金开宝的体验在实测中非常稳定不会说连续几次都摇到相同结果。4.4 战斗系统与快速幂的一个巧妙应用战斗是回合制玩家和怪物轮流攻击。攻击公式是伤害 攻击方攻击力 - 防御方防御力下限保底为1。int calculateDamage(int attack, int defense) { int dmg attack - defense; return dmg 0 ? dmg : 1; }这里有个小技巧我设计了一个弱点倍数机制——当角色身上携带特殊道具时伤害倍数按指数计算。比如每层龙符提升2倍基础伤害三层就是原先的8倍。如果按循环连乘三层要乘三次层级多了效率低。用快速幂可以做到O(log n)int quickPow(int base, int exp) { int result 1; while (exp 0) { if (exp 1) { result * base; } base * base; exp 1; } return result; }快速幂的核心思想是折半计算把指数看作二进制每次平方底数只有当前位为1时才乘入结果。这个知识点笔试常见真正放到游戏里做数值计算瞬间就没那么抽象了。4.5 寻找最短路径通向出口迷宫里的怪物巡逻路径以及玩家寻宝的最短路线我用了BFS。BFS用队列实现从起点出发每次把相邻未被访问的格子入队直到找到目标。std::vectorPosition bfsShortestPath( const Map map, Position start, Position target) { std::queuePosition q; std::mapPosition, Position parent; q.push(start); parent[start] start; while (!q.empty()) { Position cur q.front(); q.pop(); if (cur.x target.x cur.y target.y) { break; } for (auto dir : getDirections()) { Position nxt{cur.x dir.dx, cur.y dir.dy}; if (!map.isWall(nxt.x, nxt.y) parent.find(nxt) parent.end()) { parent[nxt] cur; q.push(nxt); } } } // 回溯路径 std::vectorPosition path; Position step target; while (!(step.x start.x step.y start.y)) { path.push_back(step); step parent[step]; } path.push_back(start); std::reverse(path.begin(), path.end()); return path; }这里的每一步其实都在讲一个C知识点std::queue的FIFO特性、std::map的键值记录、std::reverse对 vector 的反转。数据结构和算法从来不是孤立的知识点它们最终都服务到具体玩法上。4.6 存档系统把游戏进度序列化到文件存档的实现用的是文件流 自定义文本格式。每个存档写入玩家坐标、血量、攻击、背包物品ID序列。bool SaveSystem::saveGame(const Player player, const Map map) { std::ofstream fout(save.dat); if (!fout.is_open()) { return false; } fout player.getName() \n; fout player.getPos().x player.getPos().y \n; fout player.getHp() player.getAttack() player.getDefense() \n; // 背包 for (const auto item : player.getInventory()) { fout static_castint(item.type) item.effect item.name \n; } fout.close(); return true; }读档时逐行解析这些数据重新赋值给Player对象。这里的坑也不少比如读取顺序写错、格式不匹配导致读取失败所以我在每一段写入之前都加了分隔标记读的时候按标记拆解哪怕是老手这种自定义格式也建议留一点容错空间。5. 实测过程中踩过的C大坑与完整排查链路这一章分享一下开发过程中遇到的内存和逻辑问题都是真实经历。我会保留完整的排查链路而不是只给结论。5.1 字符串数组初始化引发的崩溃第一次跑通完整流程后我按任意键触发一个随机事件程序莫名崩溃。当时报错信息指向了一个std::string的操作。我先在代码里加入了逐行打印来定位崩溃点最后发现崩在了事件系统里的一句拼接std::string eventDesc 你发现了 itemName 品质 quality;这看起来很正常的代码为什么会崩排查发现itemName是一个C风格字符数组char itemName[10];而你发现了是const char*类型在你发现了 itemName这一步两个C风格字符串相加编译器尝试把指针相加属于未定义行为轻则乱码重则程序崩溃。修复方式是直接改用std::stringstd::string itemName; std::string eventDesc std::string(你发现了) itemName 品质 quality;这就用上了C字符串的根本优势重载了加法运算符具备自动扩容和类型安全。这个坑想提醒大家的是C里只要涉及字符串拼接第一选择永远是std::string除非有明确的性能理由否则别碰裸字符数组。5.2 指针、引用还是值传递一个修改不了的状态我在升级玩家属性时写了一个函数void upgradePlayer(Player p) { p.setAttack(p.getAttack() 5); }然后调用upgradePlayer(player)发现攻击力根本没变。原因很典型upgradePlayer接收的是按值传递的副本在函数里改动的是副本原对象不受影响。修复方案有两种传引用或传指针。// 方案1引用 void upgradePlayer(Player p) { p.setAttack(p.getAttack() 5); } // 方案2指针 void upgradePlayer(Player* p) { p-setAttack(p-getAttack() 5); }推荐方案1引用因为引用没有空指针问题语法上也更自然。如果你读别人代码时看到void doSomething(const Player p)那表示函数只读不写这也是实实在在的编码规范不是套话。5.3 控制台中文乱码问题游戏里一开始大量使用了中文提示但控制台输出总是乱码。原因是现代Windows控制台默认使用GBK代码页而MinGW编译的源码如果保存成UTF-8运行时会因为编码不一致而显示乱码。我的解决方法是分两步一是在源码文件顶部加#pragma execution_character_set(utf-8)MSVC专有但GCC下无效二是干脆把源码编码统一保存为GBK。折腾一圈后我发现最稳的做法是在程序启动时主动切换到UTF-8代码页#ifdef _WIN32 system(chcp 65001 nul); #endif这段代码在Windows下执行将控制台代码页切换为UTF-8然后源码以UTF-8编码保存双方一致后乱码问题就消失了。这也是写中文C控制台程序绕不开的国服特供问题。5.4 自动怪物移动的数组越界噩梦怪物巡逻AI写完后画面经常出现幽灵格子——某个字符跑到了地图外。排查后发现是怪物做随机方向移动时没有先判断下一步是不是越界void Monster::aiMove(Map map) { // 随机取一个方向 int dx rand() % 3 - 1; // -1, 0, 1 int dy rand() % 3 - 1; int nx x dx; int ny y dy; // 缺少边界检查就直接写入地图 if (map.getTile(nx, ny) .) { map.setTile(nx, ny, M); map.setTile(x, y, .); } }当x在地图边缘时nx很容易变为-1或者width此时map.getTile(nx, ny)就会越界。虽然我在Map::getTile里用了.at()越界会抛异常而不是默默写坏内存但测试时仍会崩溃。修复就一句话移动前加边界判断bool isInside(int nx, int ny) { return nx 0 nx width ny 0 ny height; }这个经历给我最大的教训是调试阶段一定要打开调试信息并用.at()这类带检查的接口。越界问题平时可能不声不响真正上线了才会变成最诡异的状态错乱。6. 1.0版本实测效果、核心参数与可复用思路目前1.0版本已经稳定跑通没有出现崩溃和乱码问题。游戏的核心体验是进入随机生成的迷宫逐步探索地图躲避或击败怪物抵达密室完成摸金判定收集宝物后从出口离开。一局完整的游戏大约需要15到20分钟难度适中。6.1 1.0版本核心参数一览参数数值迷宫默认尺寸21 x 21奇数保证迷宫通道对称怪物数量6个随机分布在地图中密室数量3个每个密室对应一个宝物判定点玩家初始血量100玩家基础攻击15宝物品质概率传说5%史诗15%精良30%普通50%存档位置当前目录 save.dat6.2 代码结构层面的复盘如果让我给这个1.0版本做个评价只能说能玩但不够好玩。当前版本的地图生成机制是核心亮点但怪物AI、道具数量、随机事件的丰富程度都比较简陋。控制台版的交互也限制了表现力后续如果要升级首要方向是引入图形界面SDL或SFML让视野和战斗有更直观的呈现。另外虽然用了多文件组织但游戏状态管理的耦合度还是偏高——GameManager里既处理输入、又管战斗、又管事件。如果继续迭代我会把战斗系统、事件系统全部拆成独立模块。实际上这也是大型游戏引擎的架构思路ECS或者组件模式每个系统只负责自己的事。6.3 想自己动手复刻的话建议这样起步如果你看完也想练一个类似的项目我建议按下面这个顺序逐步推进每个节点都确保能编译运行再进入下一步先写一个能渲染10x10字符地图并移动的角色练基础IO和循环。加入墙壁碰撞检测练边界判断和逻辑分支。加随机地图生成和寻路练栈、队列、递归。加怪物和战斗练类与对象、数据封装。加入摸金事件、物品、存档练文件流、随机数、容器。重构优化代码结构拆分多文件练模块化能力。按照这个顺序哪怕每天只写50行一个半月到两个月可以完成自己的1.0版本。期间遇到的内存问题、乱码问题、越界问题都是C学习路上必经的坎我的建议是你一定要亲手踩一遍不要因为我写了解决办法就直接跳过。踩坑的记忆远比看代码深刻。最后再分享一个小技巧开发这种控制台小游戏时每一步改动后都重新编译一次不要写到一千行再汇总调试。出现Bug时用二分注释法——把一半功能注释掉看看问题是否消失能比你逐行阅读快不少。我把这个项目从成立到稳定运行花了两周其中一天半在解决Windows控制台乱码和静态链接的问题所以遇到环境问题时别灰心这些才是实际项目中占比最高的不写代码的工作。
返回列表