ARTICLE DETAIL

资讯详情

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

C++从零实现泡泡堂:SDL2游戏循环与碰撞检测实战

C++从零实现泡泡堂:SDL2游戏循环与碰撞检测实战 简介这是一份面向C初学者、课程设计学生与游戏开发爱好者的泡泡堂小游戏完整源码工程适合用来练习面向对象编程、图形绘制与局域网联机通信。项目基于C实现涵盖地图与人物绘制、水泡动画效果、鼠标键盘交互、障碍物碰撞、泡泡放置与爆炸以及鞋子、泡泡、药水三种增强道具服务端支持局域网自由对抗模式允许多人在同一地图对战并拓展了房间表、多张地图与临终礼物等玩法。压缩包共41个文件约1.2MB包含11个h头文件与10个cpp源文件构成核心逻辑7张png图片与2个ttf字体提供美术资源另有sln、vcxproj等工程配置及README说明目录按Classes、Resources、proj.win32等模块划分结构清晰。目前已有497人学习下载读者可借此理解游戏主循环、场景切换、网络同步与道具系统的实现思路并直接编译运行、二次开发。1. 从零写一个泡泡堂为什么 C 小游戏是练手的最佳选择很多人第一次想写游戏脑子里冒出来的是 Unity、Godot或者干脆用 Python 的 pygame 糊一个。但如果你搜「C 小游戏」「小游戏 C 代码」会发现泡泡堂这个题材被反复提起——不是因为它简单而是因为它刚好卡在一个甜点位上有实时循环、有网格碰撞、有 AI 敌人、有道具系统但又不涉及 3D 渲染和物理引擎。用 C 把它从零写出来你会被迫直面游戏循环、帧同步、碰撞检测、状态机这些真正的地基问题而不是被引擎的便利性掩盖掉。这篇文章面向两类人一是学过 C 基础语法结构体、指针、STL 容器想找个项目把知识串起来的人二是做过 Python 小游戏想看看同样的逻辑用 C 写出来到底差在哪的人。我会用 SDL2 作为窗口和输入层因为它是 C 小游戏最轻量的选择不需要 vscode 配置 C/C 环境之外的一堆依赖在 Windows 和 Linux 上都能跑。整个方案的核心思路是用二维数组管地图用结构体管实体用固定时间步长管游戏循环用状态机管角色行为。读完你手里会有一个能跑、能玩、能继续扩展的泡泡堂骨架。2. 泡泡堂的核心机制拆解从地图网格到炸弹传播2.1 为什么用二维数组而不是自由坐标泡泡堂的地图本质是一个规则网格。每个格子要么是空地要么是硬墙不可破坏要么是软墙可被炸弹炸掉要么是道具。角色和炸弹都对齐到格子上移动而不是像平台跳跃游戏那样用连续坐标。这个设计选择直接决定了后面所有逻辑的写法。用二维数组的好处是碰撞检测变成 O(1) 的查表操作。角色想往右走只需要检查map[row][col1]是不是空地。炸弹的爆炸范围也是沿着四个方向逐格扫描遇到硬墙就停遇到软墙就炸掉并停止继续传播。如果用自由坐标你就得写 AABB 碰撞、处理浮点误差、做空间划分代码量翻三倍而且容易出玄学 bug。常见做法是定义一个TileType枚举然后用std::vectorstd::vectorTileType存地图。地图尺寸建议用奇数比如 15×13因为泡泡堂的标准布局是外围一圈硬墙内部每隔一格放硬墙软墙随机填充在剩余空位上。这样保证角色出生点周围至少有一个空格可以走。enum class TileType : uint8_t { Empty, // 空地可通行 HardWall, // 硬墙不可破坏 SoftWall, // 软墙可被炸弹摧毁 ItemBomb, // 道具增加炸弹数 ItemPower // 道具增加爆炸范围 }; constexpr int MAP_ROWS 13; constexpr int MAP_COLS 15; class GameMap { public: std::vectorstd::vectorTileType tiles; GameMap() : tiles(MAP_ROWS, std::vectorTileType(MAP_COLS, TileType::Empty)) { generate(); } void generate() { // 外围一圈硬墙 for (int r 0; r MAP_ROWS; r) for (int c 0; c MAP_COLS; c) if (r 0 || r MAP_ROWS-1 || c 0 || c MAP_COLS-1) tiles[r][c] TileType::HardWall; // 内部每隔一格放硬墙形成经典泡泡堂布局 for (int r 2; r MAP_ROWS-2; r 2) for (int c 2; c MAP_COLS-2; c 2) tiles[r][c] TileType::HardWall; // 剩余空位随机放软墙但保留四个出生点周围为空 std::mt19937 rng(std::random_device{}()); std::uniform_int_distributionint dist(0, 99); for (int r 1; r MAP_ROWS-1; r) { for (int c 1; c MAP_COLS-1; c) { if (tiles[r][c] ! TileType::Empty) continue; // 出生点保护区域 if ((r 2 c 2) || (r 2 c MAP_COLS-3) || (r MAP_ROWS-3 c 2) || (r MAP_ROWS-3 c MAP_COLS-3)) continue; if (dist(rng) 70) tiles[r][c] TileType::SoftWall; } } } bool isWalkable(int row, int col) const { if (row 0 || row MAP_ROWS || col 0 || col MAP_COLS) return false; return tiles[row][col] TileType::Empty; } };这段代码里generate()的逻辑是泡泡堂地图生成的标准套路先铺硬墙骨架再随机撒软墙最后保护出生点。isWalkable是后面角色移动和炸弹传播都要调用的核心查询函数。参数上软墙概率 70% 是我试过比较舒服的值太低地图太空旷太高容易把路堵死。如果你想要更紧凑的对局可以调到 75%但记得把出生点保护区域从 2 格扩大到 3 格。2.2 炸弹的放置、计时与爆炸传播炸弹是泡泡堂的灵魂机制。一个炸弹从放置到爆炸经历三个阶段放置瞬间、倒计时中、爆炸传播。放置瞬间要检查当前格子是否已有炸弹倒计时用帧计数器或者std::chrono都行爆炸传播则是从炸弹中心向四个方向逐格扫描。我一般用固定时间步长来驱动倒计时而不是直接用deltaTime累加。因为泡泡堂的节奏偏快炸弹倒计时通常是 2 到 3 秒用固定步长比如 60 FPS 下每帧减 1更容易调试也不会因为帧率波动导致炸弹时间不一致。爆炸传播的规则是从中心格出发向上下左右四个方向各延伸power格遇到硬墙立即停止遇到软墙则摧毁该软墙并停止该方向的传播遇到道具则保留道具但继续传播。struct Bomb { int row, col; int ownerId; // 哪个玩家放的 int power; // 爆炸范围 int timer; // 剩余帧数 bool exploded; // 是否已爆炸 }; class BombSystem { public: std::vectorBomb bombs; static constexpr int FUSE_FRAMES 150; // 60FPS 下约 2.5 秒 void placeBomb(int row, int col, int ownerId, int power) { // 检查该格子是否已有炸弹 for (auto b : bombs) if (b.row row b.col col !b.exploded) return; bombs.push_back({row, col, ownerId, power, FUSE_FRAMES, false}); } void update(GameMap map, std::vectorstd::pairint,int outExplosionCells) { outExplosionCells.clear(); for (auto b : bombs) { if (b.exploded) continue; if (--b.timer 0) { b.exploded true; propagateExplosion(map, b, outExplosionCells); } } // 清理已爆炸的炸弹 bombs.erase(std::remove_if(bombs.begin(), bombs.end(), [](const Bomb b){ return b.exploded; }), bombs.end()); } private: void propagateExplosion(GameMap map, const Bomb b, std::vectorstd::pairint,int cells) { cells.push_back({b.row, b.col}); const int dirs[4][2] {{-1,0},{1,0},{0,-1},{0,1}}; for (auto d : dirs) { for (int step 1; step b.power; step) { int nr b.row d[0]*step; int nc b.col d[1]*step; if (nr 0 || nr MAP_ROWS || nc 0 || nc MAP_COLS) break; auto tile map.tiles[nr][nc]; if (tile TileType::HardWall) break; cells.push_back({nr, nc}); if (tile TileType::SoftWall) { tile TileType::Empty; // 软墙被摧毁 break; // 爆炸不穿透软墙 } } } } };FUSE_FRAMES 150对应 60 FPS 下 2.5 秒这是泡泡堂类游戏的经典手感值。propagateExplosion里的逻辑顺序很关键先把中心格加入爆炸列表然后对四个方向逐格推进遇到硬墙直接 break遇到软墙先摧毁再 break遇到空地或道具则继续推进。注意软墙被摧毁后变成Empty这意味着后续爆炸可以穿过这个格子但当前这次爆炸不会继续延伸——这是泡泡堂的标准规则别写反了。3. 用 SDL2 搭起游戏循环固定时间步长与渲染分层3.1 固定时间步长为什么比 deltaTime 更适合泡泡堂游戏循环有两种主流写法可变时间步长用deltaTime缩放移动距离和固定时间步长每帧逻辑更新量恒定。泡泡堂这类网格对齐的游戏我强烈建议用固定时间步长。原因很简单角色移动是格子到格子的插值动画炸弹倒计时是帧计数AI 决策也是按帧触发。如果用deltaTime你会遇到角色移动速度受帧率影响、炸弹在不同机器上爆炸时间不一致的问题调试起来非常痛苦。固定时间步长的实现方式是设定逻辑帧率为 60 FPS每帧逻辑更新固定执行一次渲染则尽可能快地跑。如果渲染帧率高于逻辑帧率就做插值如果低于就跳帧。SDL2 里可以用SDL_GetTicks()做简单的时间累积器。constexpr double LOGIC_FPS 60.0; constexpr double FRAME_TIME 1.0 / LOGIC_FPS; int main(int argc, char* argv[]) { SDL_Init(SDL_INIT_VIDEO); SDL_Window* window SDL_CreateWindow(Bomberman C, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 960, 832, SDL_WINDOW_SHOWN); SDL_Renderer* renderer SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC); GameMap map; BombSystem bombSys; Player player(1, 1); // 出生在 (1,1) std::vectorstd::pairint,int explosionCells; Uint32 lastTime SDL_GetTicks(); double accumulator 0.0; bool running true; while (running) { Uint32 currentTime SDL_GetTicks(); double elapsed (currentTime - lastTime) / 1000.0; lastTime currentTime; accumulator elapsed; // 处理输入事件 SDL_Event e; while (SDL_PollEvent(e)) { if (e.type SDL_QUIT) running false; if (e.type SDL_KEYDOWN) { switch (e.key.keysym.sym) { case SDLK_UP: player.tryMove(-1, 0, map); break; case SDLK_DOWN: player.tryMove(1, 0, map); break; case SDLK_LEFT: player.tryMove(0, -1, map); break; case SDLK_RIGHT: player.tryMove(0, 1, map); break; case SDLK_SPACE: bombSys.placeBomb(player.row, player.col, player.id, player.bombPower); break; } } } // 固定时间步长逻辑更新 while (accumulator FRAME_TIME) { bombSys.update(map, explosionCells); player.update(map, explosionCells); accumulator - FRAME_TIME; } // 渲染 SDL_SetRenderDrawColor(renderer, 30, 30, 30, 255); SDL_RenderClear(renderer); renderMap(renderer, map); renderBombs(renderer, bombSys); renderExplosions(renderer, explosionCells); renderPlayer(renderer, player); SDL_RenderPresent(renderer); } SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); SDL_Quit(); return 0; }这段循环里accumulator累积真实流逝时间每满FRAME_TIME就执行一次逻辑更新。输入处理放在逻辑更新外面保证按键响应及时。渲染每帧都执行但逻辑更新可能一帧执行多次或零次。SDL_RENDERER_PRESENTVSYNC开启垂直同步避免画面撕裂。参数上LOGIC_FPS 60是甜点值调到 30 会让移动感觉迟钝调到 120 对泡泡堂来说没必要反而增加 CPU 占用。3.2 渲染分层与精灵图对齐SDL2 的渲染是立即模式每帧清屏后重新画所有东西。泡泡堂的渲染顺序很重要先画地面再画软墙和硬墙然后画炸弹再画爆炸效果最后画角色。如果顺序错了角色会被墙挡住或者爆炸效果被炸弹盖住。精灵图对齐是另一个容易翻车的地方。假设每个格子 64×64 像素地图 15×13那么窗口大小应该是 960×832。角色的精灵图通常比格子小一圈比如 48×48绘制时需要偏移(64-48)/2 8像素让角色居中。炸弹和爆炸效果同理。如果你直接用格子坐标乘以 64 作为绘制坐标角色会贴在格子左上角看起来像卡在墙里。constexpr int TILE_SIZE 64; constexpr int SPRITE_OFFSET 8; // (64 - 48) / 2 void renderMap(SDL_Renderer* renderer, const GameMap map) { for (int r 0; r MAP_ROWS; r) { for (int c 0; c MAP_COLS; c) { SDL_Rect dst { c * TILE_SIZE, r * TILE_SIZE, TILE_SIZE, TILE_SIZE }; switch (map.tiles[r][c]) { case TileType::Empty: SDL_SetRenderDrawColor(renderer, 60, 60, 60, 255); break; case TileType::HardWall: SDL_SetRenderDrawColor(renderer, 100, 100, 100, 255); break; case TileType::SoftWall: SDL_SetRenderDrawColor(renderer, 180, 120, 60, 255); break; default: SDL_SetRenderDrawColor(renderer, 60, 60, 60, 255); break; } SDL_RenderFillRect(renderer, dst); } } } void renderPlayer(SDL_Renderer* renderer, const Player p) { SDL_Rect dst { p.col * TILE_SIZE SPRITE_OFFSET, p.row * TILE_SIZE SPRITE_OFFSET, 48, 48 }; SDL_SetRenderDrawColor(renderer, 80, 180, 255, 255); SDL_RenderFillRect(renderer, dst); }这里用纯色矩形代替精灵图方便你先把逻辑跑通。等你确认移动、碰撞、爆炸都正常了再把SDL_RenderFillRect换成SDL_RenderCopy加载真实贴图。SPRITE_OFFSET这个偏移量是血泪经验我第一版没加偏移角色看起来永远在往左上角偏查了半天以为是碰撞检测写错了其实是渲染坐标没对齐。4. 角色移动、碰撞与 AI 敌人的状态机4.1 网格移动的插值动画与输入缓冲泡泡堂的角色移动不是瞬移而是从当前格子平滑滑动到相邻格子。如果直接改row/col然后渲染角色会像棋子一样跳。正确的做法是逻辑上角色仍然属于某个格子但渲染时根据移动进度做插值。移动进度用一个moveProgress变量表示从 0 到 1每帧增加1.0 / MOVE_FRAMES。输入缓冲是另一个提升手感的关键。玩家在移动动画还没结束时按了下一个方向如果直接忽略操作会感觉粘滞。常见做法是缓存最后一个方向输入等当前移动完成后立即执行。这个细节在泡泡堂里尤其重要因为对局节奏快差几帧就可能被炸弹炸到。struct Player { int id; int row, col; int bombCount 1; int bombPower 2; int moveProgress 0; // 0 表示静止0 表示移动中 int targetRow, targetCol; int bufferedDirRow 0, bufferedDirCol 0; bool alive true; static constexpr int MOVE_FRAMES 12; // 60FPS 下 0.2 秒走一格 Player(int id, int r, int c) : id(id), row(r), col(c), targetRow(r), targetCol(c) {} void tryMove(int dr, int dc, const GameMap map) { if (moveProgress 0) { // 移动中缓存输入 bufferedDirRow dr; bufferedDirCol dc; return; } int nr row dr, nc col dc; if (map.isWalkable(nr, nc)) { targetRow nr; targetCol nc; moveProgress 1; } } void update(const GameMap map, const std::vectorstd::pairint,int explosions) { if (moveProgress 0) { moveProgress MOVE_FRAMES / MOVE_FRAMES; // 每帧 1 if (moveProgress MOVE_FRAMES) { row targetRow; col targetCol; moveProgress 0; // 检查缓存输入 if (bufferedDirRow ! 0 || bufferedDirCol ! 0) { int dr bufferedDirRow, dc bufferedDirCol; bufferedDirRow bufferedDirCol 0; tryMove(dr, dc, map); } } } // 检查是否被爆炸波及 for (auto cell : explosions) { if (cell.first row cell.second col) { alive false; } } } // 渲染用的插值坐标 float renderRow() const { if (moveProgress 0) return static_castfloat(row); float t static_castfloat(moveProgress) / MOVE_FRAMES; return row (targetRow - row) * t; } float renderCol() const { if (moveProgress 0) return static_castfloat(col); float t static_castfloat(moveProgress) / MOVE_FRAMES; return col (targetCol - col) * t; } };MOVE_FRAMES 12对应 60 FPS 下 0.2 秒走一格这是泡泡堂的标准移动速度。tryMove里先检查moveProgress 0如果是就缓存输入而不是直接拒绝这个细节能让操作手感提升一个档次。update里每帧增加moveProgress达到MOVE_FRAMES后把逻辑坐标更新到目标格然后检查缓存输入。爆炸检测放在移动更新之后保证角色走到哪就检测到哪。4.2 AI 敌人的行为树与寻路简化泡泡堂的 AI 敌人不需要 A* 寻路那么复杂。经典做法是给每个敌人一个简单的状态机巡逻、追击、逃跑、放炸弹。巡逻状态下随机选一个方向走遇到障碍就换方向追击状态下朝玩家方向移动逃跑状态下远离炸弹放炸弹状态在靠近玩家且自身安全时触发。状态切换的条件用距离和炸弹威胁来判断。比如玩家在 3 格以内且没有炸弹威胁切追击检测到附近有炸弹且自己在爆炸范围内切逃跑追击状态下如果和玩家在同一直线上且距离小于 2 格放炸弹然后切逃跑。enum class AIState { Patrol, Chase, Flee, PlaceBomb }; struct Enemy { int row, col; AIState state AIState::Patrol; int moveCooldown 0; int patrolDir 0; // 0-3 对应上下左右 void update(const Player player, const GameMap map, const BombSystem bombSys) { if (moveCooldown 0) { --moveCooldown; return; } // 检测炸弹威胁 bool inDanger false; for (auto b : bombSys.bombs) { int dist std::abs(b.row - row) std::abs(b.col - col); if (dist b.power) { inDanger true; break; } } if (inDanger) { state AIState::Flee; } else { int dist std::abs(player.row - row) std::abs(player.col - col); if (dist 3) state AIState::Chase; else state AIState::Patrol; } switch (state) { case AIState::Patrol: { const int dirs[4][2] {{-1,0},{1,0},{0,-1},{0,1}}; int nr row dirs[patrolDir][0]; int nc col dirs[patrolDir][1]; if (!map.isWalkable(nr, nc)) { patrolDir (patrolDir 1) % 4; } else { row nr; col nc; } moveCooldown 15; break; } case AIState::Chase: { int dr (player.row row) ? 1 : (player.row row ? -1 : 0); int dc (player.col col) ? 1 : (player.col col ? -1 : 0); if (dr ! 0 map.isWalkable(row dr, col)) row dr; else if (dc ! 0 map.isWalkable(row, col dc)) col dc; moveCooldown 10; break; } case AIState::Flee: { // 简单策略往远离最近炸弹的方向走 int bestDr 0, bestDc 0, bestDist -1; const int dirs[4][2] {{-1,0},{1,0},{0,-1},{0,1}}; for (auto d : dirs) { int nr row d[0], nc col d[1]; if (!map.isWalkable(nr, nc)) continue; int minDist 999; for (auto b : bombSys.bombs) { int dist std::abs(b.row - nr) std::abs(b.col - nc); minDist std::min(minDist, dist); } if (minDist bestDist) { bestDist minDist; bestDr d[0]; bestDc d[1]; } } row bestDr; col bestDc; moveCooldown 8; break; } case AIState::PlaceBomb: // 放炸弹逻辑由外部调用 state AIState::Flee; break; } } };这个 AI 状态机故意写得很简单因为泡泡堂的敌人不需要太聪明太聪明反而不好玩。moveCooldown控制移动频率巡逻 15 帧、追击 10 帧、逃跑 8 帧数值越小敌人越敏捷。Flee状态的策略是遍历四个方向选一个离最近炸弹最远的格子走这是最朴素的贪心但效果够用。如果你想让 AI 更聪明可以把Flee换成 BFS 找安全路径但代码量会翻倍而且对游戏体验提升有限。5. 避坑与排查泡泡堂开发中最容易翻车的 5 个地方5.1 炸弹爆炸后角色卡在墙里现象炸弹炸掉软墙后角色走到那个格子渲染上看起来嵌在墙里或者直接穿过去。原因软墙被摧毁后tiles[r][c]改成了Empty但渲染层没有及时更新或者角色的碰撞检测用的是旧地图缓存。解决确保propagateExplosion里修改地图后下一帧渲染直接读map.tiles不要缓存。另外检查isWalkable是否被内联优化成了常量折叠——如果你把地图传成const引用但内部用了mutable缓存编译器可能不会重新读取。我一般会在爆炸后强制调用一次renderMap确认地图状态已经刷新。5.2 固定时间步长下角色移动速度忽快忽慢现象在低帧率机器上角色移动看起来一顿一顿的或者比高帧率机器上慢。原因accumulator累积逻辑写错了比如用了while (accumulator FRAME_TIME)但没在循环内减FRAME_TIME导致一帧内执行了多次逻辑更新。解决检查accumulator - FRAME_TIME是否在 while 循环内部。另外如果单帧elapsed超过 0.25 秒比如窗口被拖动要限制accumulator的最大值防止一次补太多帧导致角色瞬移。常见做法是accumulator std::min(accumulator, 0.25)。5.3 SDL2 在 Windows 上链接失败现象编译时报undefined reference to SDL_Init或者LNK2019。原因SDL2 的库文件没链接上或者链接顺序不对。在 Windows 上用 MinGW 时-lSDL2必须放在源文件之后用 MSVC 时需要在项目属性里添加SDL2.lib和SDL2main.lib。解决MinGW 的编译命令写成g main.cpp -o game -lSDL2 -lSDL2main注意-lSDL2main要在-lSDL2前面。MSVC 的话把SDL2.lib和SDL2main.lib加到链接器输入里并且确保SDL2.dll和 exe 放在同一目录。如果你用 vscode 配置 C/C 环境检查tasks.json里的args是否包含了正确的-I和-L路径。5.4 炸弹倒计时在不同机器上不一致现象同样的代码在台式机上炸弹 2.5 秒爆炸在笔记本上 3 秒才炸。原因用了SDL_GetTicks()做倒计时但逻辑更新频率受渲染帧率影响导致倒计时不准。解决坚持用固定时间步长驱动炸弹倒计时不要用真实时间。FUSE_FRAMES 150在 60 FPS 逻辑帧率下永远是 2.5 秒不管渲染跑多少帧。如果你发现炸弹时间还是不对检查accumulator循环是否真的每帧执行了 60 次逻辑更新——可以在循环里加个计数器打印出来验证。5.5 角色移动时按反方向键导致位置错乱现象角色正在向右移动玩家快速按左键角色会先走到右边格子然后突然弹回左边或者卡在两个格子中间。原因tryMove里没有处理移动中的输入缓存或者缓存了多个方向导致状态冲突。解决移动中只缓存最后一个方向输入不要用队列。bufferedDirRow和bufferedDirCol每次覆盖而不是追加。另外在update里执行缓存输入前要重新检查目标格是否可走——因为移动过程中地图可能变了比如软墙被炸掉。我一般会在tryMove里加一个if (moveProgress 0) { bufferedDir ...; return; }确保不会同时处理两个移动。6. 进阶技巧用状态快照做回放与调试泡泡堂这类实时对战游戏调试时最痛苦的是「刚才那一帧到底发生了什么」。我习惯在开发阶段加一个状态快照系统每帧把地图、炸弹、角色位置序列化到一个环形缓冲区里出问题时可以回放最近 300 帧。这个技巧在排查「角色莫名其妙死亡」或者「炸弹没炸到人」时特别有用。实现方式很简单定义一个Snapshot结构体包含map.tiles的拷贝、bombs的拷贝、所有角色的row/col/alive。每帧逻辑更新后 push 到std::dequeSnapshot超过 300 个就 pop_front。回放时按帧读取渲染出来就行。struct Snapshot { std::vectorstd::vectorTileType tiles; std::vectorBomb bombs; struct PlayerState { int row, col; bool alive; }; std::vectorPlayerState players; }; class ReplayBuffer { public: std::dequeSnapshot frames; static constexpr size_t MAX_FRAMES 300; void capture(const GameMap map, const BombSystem bombSys, const std::vectorPlayer players) { Snapshot s; s.tiles map.tiles; s.bombs bombSys.bombs; for (auto p : players) s.players.push_back({p.row, p.col, p.alive}); frames.push_back(std::move(s)); if (frames.size() MAX_FRAMES) frames.pop_front(); } // 导出最近 N 帧为文本方便贴到 issue 里 void dump(size_t n) const { size_t start frames.size() n ? frames.size() - n : 0; for (size_t i start; i frames.size(); i) { printf(Frame %zu:\n, i); for (auto p : frames[i].players) printf( Player at (%d,%d) alive%d\n, p.row, p.col, p.alive); } } };MAX_FRAMES 300对应 60 FPS 下 5 秒的回放窗口足够覆盖大多数意外情况。capture每帧调用一次拷贝开销在 15×13 的地图上可以忽略。dump用来把最近的状态打到控制台如果你在写自动化测试可以直接断言某帧的角色位置是否符合预期。另一个进阶方向是把 AI 的决策日志也纳入快照。比如记录每个敌人当前的状态和moveCooldown这样回放时能看到 AI 为什么做了那个选择。我一般在Enemy::update开头加一行if (debugMode) printf(Enemy %d state%d\n, id, state);配合快照一起看基本能定位 90% 的诡异行为。最后说一个我踩过的坑快照系统本身会影响性能因为每帧拷贝vector会触发内存分配。解决办法是用对象池预分配Snapshot或者只在 debug 构建里启用。我现在的习惯是#ifdef DEBUG包住整个ReplayBuffer发布版本直接编译掉零开销。这个方案值不值得做如果你打算长期维护这个泡泡堂项目或者想加网络对战状态快照是后悔药级别的投入——前期花两小时写后期省两天调试。希望帮到你。本文还有配套的精品资源点击获取
返回列表