C++肉鸽游戏开发:随机地图生成与回合制AI实战解析

发布时间:2026/10/12 5:46:23
C++肉鸽游戏开发:随机地图生成与回合制AI实战解析
简介由C与EasyX图形库实现的肉鸽游戏Slime-Hunter是作者22级技科专业课程设计作品。游戏内含角色控制、敌人攻击动画与基础关卡机制虽为中期版本但核心玩法已具备完整雏形适合正在学习C游戏开发的初学者参考。资源共531个文件压缩包约95MB以gif、jpg素材居多另有cpp/h源代码、exe可执行程序、pdf说明及工程配置等可对照图片与代码理解动画播放、交互响应与关卡渲染。当前已有263人浏览学习。读者通过观察EasyX窗口管理、精灵动画播放、攻击判定及交互逻辑的写法能掌握小型游戏核心循环的搭建思路结合作者分阶段留存的项目结构与音频素材还可梳理从基础框架到功能迭代的演进路径。相比纯文本教程这套包含完整工程与运行演示的资源可支持编译体验、修改练习与二次扩展。1. 资源拆解C 肉鸽游戏 Slime-Hunter 到底能给你什么C 版肉鸽游戏 Slime-Hunter最让我想拆开看的不是史莱姆长什么样而是那套随机地图生成器。你控制角色在地牢里猎杀史莱姆捡素材喂装备成长下一层地图完全推倒重排角色一死整局归零。这套资源解决的是从零写过传统 Roguelike 的人最容易撞上的三件事回合怎么推进、地图怎么保证连通、史莱姆 AI 怎么在有限计算量下跑起来。适合已经会 C 基础语法和 STL但还没把一套可玩循环串起来的人。把它当骨架抄比自己闷头从空白的 main.cpp 开始要快得多。2. 先把循环搭对回合制主循环与 GameContext 的职责边界2.1 三个循环叠加探索、战斗、成长怎么安排回合肉鸽游戏看起来是动作刷怪底层其实是三个不同节奏的循环叠加。第一层是快循环玩家一次输入等于一个回合玩家动完所有史莱姆动渲染刷新然后回到等待输入。第二层是中层循环玩家从第 1 层进到第 2 层地图重新生成但角色带走的经验、材料和装备成长保留下来。第三层是长循环玩家死亡后整个 Run 结束死亡时的数据或解锁项影响下一次开局。很多新手代码翻车是因为把三层循环全塞进一个 update 函数里。每帧都检查敌人、每帧都消耗玩家输入、每帧都尝试推进回合结果就是按键一次角色跑两次、菜单打开时怪物还在动。我的做法是先把快循环用显式的回合状态机锁住再往里面填地图和 AI。状态机不复杂但边界必须清楚。2.2 工程骨架GameContext、Phase 与回合主循环GameContext 是整局游戏的“全局状态对象”它应该同时保存跨层数据和当前层数据。跨层数据包括角色属性、材料背包、楼层索引和随机数种子当前层数据包括地板、玩家位置、当前楼层所有史莱姆实体的列表。enum class Phase { PLAYER_TURN, // 等待玩家输入 ENEMY_TURN, // 史莱姆批量行动 FLOOR_TRANSITION, // 切层清理旧层生成新层 GAME_OVER // 永久死亡 }; struct GameContext { Phase phase Phase::PLAYER_TURN; Dungeon* floor nullptr; // 当前楼层的地图 Player* player nullptr; // 玩家跨层数据 std::vectorSlimeAI slimes; // 当前层史莱姆列表 int floor_index 1; // 楼层层数 int turn_count 0; // 全局回合数用于日志 std::mt19937 rng; // 随机数引擎用于复现 };逻辑说明phase 决定当前谁来行动渲染层只读 GameContext不直接修改任何逻辑字段。floor 是当前楼层的全部 Tile 数据切换楼层时先释放旧 floor再生成新的避免楼层对象堆积。参数说明floor_index 用于难度曲线和名字生成rng 是肉鸽游戏最容易忽略的点保存种子才能复现同样的地图和掉落。后期排查 AI 问题时种子复用比日志定位快得多。主循环的逻辑要足够短我只做三件事取输入、推回合、渲染。while (running) { SDL_Event ev; while (SDL_PollEvent(ev)) { if (ev.type SDL_QUIT) running false; else handle_input(ctx, ev); } if (ctx.phase Phase::PLAYER_TURN !ctx.command_queue.empty()) { Command cmd ctx.command_queue.front(); ctx.command_queue.pop(); apply_player_command(ctx, cmd); if (ctx.phase Phase::PLAYER_TURN) { start_enemy_turn(ctx); // 玩家动完AI 批量行动 } } render(ctx); SDL_Delay(16); }逻辑说明SDL_Delay(16) 只是控制渲染帧率和回合推进无关。玩家输入不会直接移动角色而是先写进 command_queue每回合只消费一个命令。这样即使一帧里收到多个按键事件角色也只动一次从根源避免“一步走两格”。参数说明command_queue 用 std::queue就够容量不需要做大因为每帧最多消费一条。start_enemy_turn 会遍历当前层所有史莱姆并更新状态全部更新完再把 phase 切回 PLAYER_TURN。2.3 输入事件与回合推进为什么回合内不准掏计时器回合制游戏最容易出的一个玄学 bug 是“按键偶尔没反应”。原因往往是开发者想用真实时间控制玩家移动间隔于是一边监听键盘一边记 last_move_time结果按键在间隔外被丢掉。肉鸽游戏不需要真实时间参与回合推进玩家按下方向键角色就应在这个回合内移动一步。我一般会在输入处理里加一层“防连发”保护if (ev.type SDL_KEYDOWN ev.key.repeat 0) { switch (ev.key.keysym.sym) { case SDLK_UP: queue_command(ctx, Command::MOVE_UP); break; case SDLK_DOWN: queue_command(ctx, Command::MOVE_DOWN); break; case SDLK_LEFT: queue_command(ctx, Command::MOVE_LEFT); break; case SDLK_RIGHT: queue_command(ctx, Command::MOVE_RIGHT); break; case SDLK_SPACE: queue_command(ctx, Command::WAIT); break; } }逻辑说明ev.key.repeat 0 是排除键盘系统自身的自动连发。按住方向键不放时SDL 会产生多个 KEYDOWN 事件repeat 非 0 的事件全部忽略只有第一次按下才入队。参数说明SPACE 键做原地等待这在回合制里很有用玩家可以站着不动让史莱姆自己靠过来省去反复横跳的操作。WAIT 命令和移动命令一样消耗一个回合所以 AI 也会跟着动。把循环搭对之后后续塞地图生成和 AI 才有稳定的运行环境。几乎所有的“怪不动”“怪乱动”“玩家瞬移”问题最后都能追回到输入层和回合状态机这里。3. 随机地图生成房间-走廊算法与连通性检查3.1 为什么选房间-走廊而不是 BSP 分割Slime-Hunter 的地图生成用房间-走廊算法而不是 BSP 二叉分割。原因很直接房间-走廊生成的地图视觉上就是“一堆矩形房间 连接线”玩家一看就懂调节参数也直观BSP 分割虽然能生成更复杂的树形结构但递归划分、节点记录和后续刷怪点分配都得额外设计对一个小型可玩 Demo 来说性价比太低。另一个容易被忽略的点是肉鸽游戏的战斗场景不大通常一层地图控制在 30x30 到 48x48 之间房间-走廊的连通性可以用简单的 BFS 验证。BSP 生成的房间数量如果偏多走廊会密集交叉后期刷怪时反而很难判断哪个房间适合作为出生区域。3.2 房间生成与走廊连接核心实现代码地图初始化为全墙然后按顺序尝试放置矩形房间。每个房间随机位置、随机宽高但要求不与已有房间重叠并且与已有房间至少保留 1 格墙间距。放完房间后从上一个房间中心向新房间中心画一条 L 型走廊。bool try_place_room(Dungeon d, std::mt19937 rng, Room out) { int w std::uniform_int_distribution(MIN_W, MAX_W)(rng); int h std::uniform_int_distribution(MIN_H, MAX_H)(rng); int x std::uniform_int_distribution(1, d.width - w - 1)(rng); int y std::uniform_int_distribution(1, d.height - h - 1)(rng); Room r{x, y, w, h}; if (room_overlaps(d, r)) return false; carve_room(d, r); out r; return true; }逻辑说明uniform_int_distribution 每次生成一个随机值x 和 y 的范围限制在地图边缘内避免房间贴墙导致边界越界。carve_room 把房间内部的 Tile 标记为 FLOOR。room_overlaps 是地图生成里最容易写错的地方必须做外扩检测bool room_overlaps(const Dungeon d, const Room r) { for (int ty r.y - 1; ty r.y r.h; ty) { for (int tx r.x - 1; tx r.x r.w; tx) { if (d.tiles[tx ty * d.width].glyph FLOOR) return true; } } return false; }逻辑说明检测范围比房间实际范围外扩一格保证两间房间之间至少间隔一堵墙。如果没有外扩两个房间紧贴在一起时视觉上会连成一个畸形大房间史莱姆刷怪点也会变得不可控。参数说明MIN_W、MIN_H、MAX_W、MAX_H 是房间宽高的上下限外面宏定义或常量配置均可。房间太小只能放下一只史莱姆房间太大空旷无遮挡我一般把最小值设为 4最大值设为 12。走廊连接用 L 型路径先水平再垂直void connect_rooms(Dungeon d, const Room a, const Room b) { int ax a.cx(), ay a.cy(); int bx b.cx(), by b.cy(); carve_h_line(d, std::min(ax, bx), std::max(ax, bx), ay); carve_v_line(d, std::min(ay, by), std::max(ay, by), bx); }逻辑说明L 型走廊固定先从房间 A 中心水平走到房间 B 的 x 坐标再垂直走到底。这样生成的走廊数量少、交叉少玩家走图时方向感清晰。如果改成双走廊随机走法地图更复杂但出死路的概率也会上升。3.3 连通性验证与参数表生成完所有房间和走廊必须验证整层地图是连通的否则会出现“宝箱放在孤岛房间”的尴尬局面。验证方式是从玩家出生房间出发做一次 BFS统计能到达的房间数量。int count_reachable_rooms(const Dungeon d, int start_room) { std::queueint q; std::vectorbool visited(d.tiles.size(), false); int reachable 0; q.push(start_room); visited[start_room] true; while (!q.empty()) { int idx q.front(); q.pop(); reachable; // 遍历上下左右四个邻居能走且未访问就入队 } return reachable; }逻辑说明这个 BFS 只统计“房间中心所属的可达房间数”不统计走廊上的 Tile。因为走廊是连通路径只要房间中心连通走廊通常也能走通。判断邻居时用 glyph FLOOR 即可不区分走廊和房间。连通率低于 70% 就整层重生成重试上限三次。这个“重生成保险”和“重试上限”必须同时存在避免极端卡死。下面是这套地图生成器最有调整价值的几个参数参数建议值作用房间数量6~10过少地图空过多走廊密集房间宽高4~12控制战斗场地大小放置重试次数120超过则放弃本层重来走廊宽度1保留狭窄地形的策略性连通率阈值70%低于则重生成地图初次跑通时先把房间数量锁定为 6连通阈值 70%跑 30 层确认没有死路再逐步提高房间数量。改参数时记住一点随机地图生成没有绝对最优解但连通率必须守死。4. 史莱姆AI与战斗状态机、仇恨范围与伤害公式4.1 贪心移动替代全局寻路够用就好Slime-Hunter 里的史莱姆不是高智商敌人不应该每回合做 A* 寻路。A* 在小型地图上也能跑但每个敌方单位每回合都做一次全图搜索层数一多、怪一多性能就会明显下降。对这颗“史莱姆”来说贪心移动已经足够每回合向玩家方向移动一步被墙挡住就尝试垂直方向看起来笨拙但符合史莱姆的设定。这个选择背后有一个更实际的理由资源项目里 AI 代码越简单越容易让新开发者理解状态切换。先跑通 IDLE、CHASE、ATTACK 三个状态再回来替换移动逻辑复杂度控制更稳。4.2 回合制AI更新从IDLE到ATTACK每个史莱姆实体维护自己的状态和属性行动由主循环中的 start_enemy_turn 批量驱动enum class SlimeState { IDLE, CHASE, ATTACK, DEAD }; struct SlimeAI { SlimeState state SlimeState::IDLE; int hp 12; int ap 3; // 攻击力 int sight_range 5; // 仇恨范围 int attack_range 1; // 贴身攻击范围 std::pairint, int pos; // 当前坐标 int room_id 0; // 出生房间用于掉落实例 };逻辑说明sight_range 表示史莱姆能发现玩家的距离attack_range 表示能打出伤害的距离。slime 不在仇恨范围内就保持 IDLE玩家靠近后进入 CHASE贴身则进入 ATTACK。更新一个史莱姆的逻辑浓缩成一个函数void update_slime(SlimeAI s, const Player p, const Dungeon d) { if (s.state SlimeState::DEAD) return; int dist manhattan(s.pos, p.pos); if (dist s.sight_range) { s.state SlimeState::IDLE; return; } if (dist s.attack_range) { s.state SlimeState::ATTACK; return; } s.state SlimeState::CHASE; try_step_toward(s.pos, p.pos, d); }逻辑说明manhattan 距离是 |dx| |dy|适合矩形网格地图比欧几里得距离更贴近实际步数。先判断距离再决定状态顺序不能反如果把 ATTACK 判断放前面史莱姆会在玩家走远后还保持攻击姿态。try_step_toward 是贪心移动的核心void try_step_toward(std::pairint,int pos, const std::pairint,int target, const Dungeon d) { int dx (target.first pos.first) - (target.first pos.first); int dy (target.second pos.second) - (target.second pos.second); if (can_step(pos.first dx, pos.second, d)) pos.first dx; else if (can_step(pos.first, pos.second dy, d)) pos.second dy; }逻辑说明优先向水平方向靠近水平被挡再尝试垂直。这样做的好处是史莱姆在走廊里不会反复横跳缺点是斜向追击时会绕一点路但视觉上反而更符合低智商怪物设定。参数说明sight_range 设置为 5 时玩家在 5 格内就会被发现。如果调到 4史莱姆的压迫感会明显下降调到 7地图小房间内几乎全程战斗。4.3 伤害公式与掉落让数值有成长空间战斗系统最怕两类问题伤害波动太大或防御堆满后根本打不动。我习惯用一个保守的伤害公式int calc_damage(const SlimeAI s, const Player p) { int base s.ap; float roll std::uniform_real_distribution(0.9f, 1.1f)(rng); int dmg static_castint(base * roll); dmg - p.defense / 3; return std::max(1, dmg); }逻辑说明随机倍率控制在 0.9 到 1.1波动只有 20%不会出现一刀砍 1 点下一刀砍 10 点的割裂感。防御除以 3 是为了防止低阶玩家堆两三件护甲就完全免伤保证每次攻击至少有 1 点伤害兜底。参数说明base 是史莱姆的攻击力player.defense 是玩家的防御总值。如果未来要加入“破甲”效果可以在 roll 之后、减伤之前乘一个穿透系数比直接改防御公式更好维护。掉落系统围绕“猎杀史莱姆”这个核心玩法做击杀后掉落“凝胶块”材料用于合成防具和消耗品。掉率我固定为 80%避免刷怪体验过于稀疏bool drop_loot(const SlimeAI s, Player p) { if (std::uniform_int_distribution(1, 100)(rng) 80) return false; p.inventory.gel 1 s.room_id % 3; return true; }逻辑说明掉落数量与房间编号挂钩深层地图的史莱姆掉落略多形成简单的层数正反馈。room_id 可以换成 floor_index效果等价。经验曲线也值得提前设计int exp_needed(int level) { return 10 (level - 1) * 5 level * level * 2; }逻辑说明用二次项让后期升级成本显著高于前期。前 5 级玩家能明显感到变强10 级以后升级速度放缓正好配合肉鸽游戏的层数推进节奏。5. 避坑与常见问题输入、地图、存档的五条翻车记录5.1 输入重复与回合错位第一条坑按一次方向键角色跑两步。现象是按住方向键后角色连续移动松开时已经多走了好几步。原因很简单SDL 会对长按的按键持续产生 KEYDOWN 事件而代码只判断了按键码没过滤系统自动重复。解决方法是所有按键事件必须检查 ev.key.repeat 0只有首次按下才入队。从那以后我每次写回合制输入都会把 repeat 检查放在事件处理的第一行。第二条坑菜单打开后按空格玩家和怪物同时行动。现象是玩家只是想打开背包结果怪物也动了一轮。原因是输入分发没有分层同一个按键既控制菜单又控制主世界按下空格时菜单打开WAIT 命令也投进了队列。解决方法是把输入处理改成两层先判断当前 UI 层是否拦截输入普通菜单打开时所有主世界命令直接丢弃关闭菜单再交还给主世界。5.2 地图生成与存档恢复第三条坑某些楼层出现完全无法到达的孤立房间宝箱散落在孤岛上。现象是玩家绕遍整层地图却有一条走廊通向一堵墙墙后就是宝箱房间。原因是房间-走廊算法只保证“画了走廊”不保证走廊真的把房间连起来当两个房间的中心点连线穿过已有房间时走廊可能被已有房间的墙体打断。解决方法是生成结束后必须做连通性 BFS 校验从玩家出生房间出发统计可达房间数占总房间数的比例低于 70% 就重新生成本层。第四条坑史莱姆刷在墙里玩家能看到怪物轮廓但打不到。现象是敌人列表里某个史莱姆的坐标对应的 Tile 是 WALL每回合卡在原地。原因是刷怪逻辑用“全图扫描可走格子”的方式但只判断了 FLOOR 标记没有排除房间边缘和走廊端点上的“临界格子”。解决方法是刷怪时只从每个房间的中心点及周围 3x3 范围内挑候选点保证生成位置一定是房间内部。这个限制还能顺便让怪物出现在玩家容易预期的位置减少“转角遇到怪”的阴间体验。第五条坑存档读档后敌人坐标乱套甚至直接崩溃。现象是玩家保存后重启游戏读档时史莱姆出现在上一层的位置或者指针指向未初始化的内存。原因是把指向内存对象的指针直接写进了存档文件加载时指针值无效再访问就翻车。解决方法是存档文件只写整数坐标、楼层索引、实体 ID 和当前种子加载时先用 seed 重新生成楼层再按坐标重新创建史莱姆实体。这样做存档文件体积也小每只史莱姆记录两三个 int 就够。这一章的坑基本覆盖了从输入到存档的完整链路。遇到奇怪表现时我会先把“随机性”排除用固定种子跑同一局如果问题必现说明是逻辑 bug如果问题随机出现再怀疑地图生成和刷怪流程。6. 把调试做成功能种子回放、地图验证与退出清理Slime-Hunter 这套资源里最值得带走的习惯是把调试工具做成可随时调用的功能而不是写一堆 cout 打完就删。我强烈建议给主程序加几个命令行参数./slime_hunter --seed 20241107 --verify-map--seed 固定随机种子--verify-map 表示只生成地图做连通性检查不启动完整游戏。这样每次改完地图参数只需要跑 30 层生成确认所有楼层连通率都达标再进游戏手动试玩。种子回放的价值在 AI 调试时最明显。假如史莱姆在某个楼层卡墙用普通模式复现可能要看运气带 --seed 跑同一局同样的楼层布局会再出现一次直接断点打上去就能定位。坐标日志也是被低估的调试工具。我习惯每回合向日志文件写一行turn12 slime3 actionCHASE from(4,6) to(5,6)不用全量打印只记录行动变化。这样战斗表现异常时翻日志能看到史莱姆在哪个回合、从哪个坐标移动到了哪个坐标比肉眼盯屏幕靠谱得多。退出清理同样重要。Dungeon 和 Player 这种生命周期较长的对象我用 std::unique_ptr 管理GameContext 析构时自动释放当前楼层切层时先 reset 旧楼层再生成新楼层避免内存泄漏。如果项目里仍然用裸指针必须保证 delete 和 new 成对出现否则反复进出 20 层后内存会肉眼可见地涨。从那以后我每次改地图生成参数都会强制走一遍 --seed --verify-map确认连通率达标再把角色放到第 1 层手动试玩十分钟。哪怕只是把史莱姆的 sight_range 从 5 改成 7也会跑一次完整生成验证。这个习惯帮我挡掉了至少七八成的地图死路和刷怪事故希望帮到你。本文还有配套的精品资源点击获取