☰
Slime-Hunter C++肉鸽源码:主循环、场景栈与词条系统
2026/11/2 17:29:35 网站建设 项目流程

简介:《Slime-Hunter》是一份用 C++ 配合 easyX 图形库独立实现的肉鸽(Roguelike)小游戏工程,源自 22 级技科专业课程设计作业,面向想通过完整项目练习 C++ 面向对象编程、图形界面与游戏循环控制的初学者与在校学生。压缩包共 531 个文件、约 95.16MB,含 277 个 gif、171 个 jpg 与 33 个 png 角色、敌人与升级动画素材,8 个 mp3 音效,以及 cpp/h 源码、sln 与 vcxproj 工程配置和编译产物,可直接用 Visual Studio 打开继续开发。目前已有 267 人学习下载。源码由 2 个 cpp 与 3 个头文件构成,分工相对清晰,便于按模块阅读与二次扩展;读者可从中获得可运行的工程骨架,包括角色移动与碰撞、史莱姆敌人行为、升级与得分机制、关卡切换等基础玩法的实现思路,也能参考大量序列帧动画的加载与播放方式、素材命名规范与目录组织习惯,适合作为课程设计、C++ 大作业或图形库入门的对照案例。

1. 从一局二十分钟的死亡循环说起:Slime-Hunter 是个什么项目

第一次把 Slime-Hunter 跑起来,我在第三层被一群会分裂的史莱姆围住,前后不到二十分钟,却已经把“捡词条、叠属性、赌下一扇门开什么”这套C++ 肉鸽循环走了三遍。它是个俯视角的单机动作小游戏:方向键移动、武器自动索敌开火、每清完一层从三个强化里选一个、死亡直接重开,所有成长都压在一局之内完成,没有存档、没有养成、没有养成系统那套长线数据。项目不挂商业引擎,窗口、渲染、音频全靠轻量图形库自己接,逻辑层和表现层切得相对干净。

这类源码的价值不在画面,而在“一个完整的游戏循环到底该怎么组织”。很多人写 C++ 小游戏,写到两千行就变成一坨 main 函数,改一个数值要翻十处。Slime-Hunter 把主循环、场景栈、随机数、词条结算各自拆成了独立模块,正好可以当脚手架——想学工程结构的人看骨架,想做肉鸽的人抄随机与词条,想练手的人拿它改成自己的题材。

2. 工程骨架与构建链:从 CMake 目录到第一个可执行文件

拿到源码先别急着看 main.cpp,先看目录。目录结构基本决定了这个项目能不能被改,一个所有 .cpp 都堆在 src 根下的工程,改到第三个功能就会开始互相污染。Slime-Hunter 的分法属于比较克制的三层:核心层管循环和场景,世界层管房间、刷怪、碰撞,数据层管词条和数值,表现层只剩渲染和资源加载。

2.1 目录分层:核心层、世界层、数据层各管什么

SlimeHunter/ ├── assets/ # 贴图、字体、音效、关卡模板 │ ├── fonts/ │ ├── sprites/ │ └── rooms/rooms.json # 房间模板表,纯数据 ├── src/ │ ├── main.cpp # 只做三件事:建窗口、建 Game、run() │ ├── core/ # 与玩法无关的骨架 │ │ ├── Game.cpp/.hpp # 主循环、时间累加器 │ │ ├── SceneStack.cpp/.hpp # 场景入栈出栈 │ │ ├── Input.cpp/.hpp # 输入状态快照 │ │ └── Rng.cpp/.hpp # 可复现随机数 │ ├── world/ # 玩法规则 │ │ ├── RoomGenerator.cpp │ │ ├── WaveSpawner.cpp │ │ └── BattleWorld.cpp # 实体容器 + 碰撞结算 │ ├── entity/ │ │ ├── Player.cpp Enemy.cpp Bullet.cpp │ ├── skill/ │ │ ├── ModifierPool.cpp # 词条池与三选一抽取 │ │ └── StatBlock.cpp # 属性叠加计算 │ └── scene/ │ ├── MenuScene.cpp BattleScene.cpp RewardScene.cpp └── CMakeLists.txt

这个分法有一条隐形规则:core/不允许 includeworld/和entity/。一旦核心层反向依赖玩法层,主循环就会被迫知道“玩家”“史莱姆”是什么,后面想加第二个游戏模式就得动刀子。我一般会在 CI 里加一条 grep 检查,core/里出现#include "entity/就直接失败,比靠自觉靠谱得多。

assets/rooms/rooms.json单独拎出来是个好习惯。关卡模板是数据不是代码,改一个刷怪点不该触发重新编译,策划(哪怕就是你自己)改 JSON 就能调难度。

2.2 CMakeLists 与三方依赖:把窗口、音频、字体接进来

构建脚本不长,但每行都有用:

cmake_minimum_required(VERSION 3.16) project(SlimeHunter LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 运行时按相对路径找资源容易翻车,编译期先把源码树的 assets 绝对路径注入进去兜底 add_compile_definitions(SH_ASSET_ROOT="${CMAKE_CURRENT_SOURCE_DIR}/assets") find_package(SFML 2.5 COMPONENTS graphics window audio system REQUIRED) add_executable(slime_hunter src/main.cpp src/core/Game.cpp src/core/SceneStack.cpp src/core/Rng.cpp src/world/RoomGenerator.cpp src/world/WaveSpawner.cpp src/skill/StatBlock.cpp src/skill/ModifierPool.cpp src/scene/BattleScene.cpp src/scene/RewardScene.cpp ) target_include_directories(slime_hunter PRIVATE src) target_link_libraries(slime_hunter PRIVATE sfml-graphics sfml-window sfml-audio sfml-system)

add_compile_definitions那行是血泪经验。CMake 生成的程序,工作目录取决于你从哪儿启动:命令行./build/slime_hunter的工作目录是项目根,从 IDE 点运行可能是 build 目录,双击 exe 又是 exe 所在目录。三种情况三套相对路径,资源必然找不到。注入一个SH_ASSET_ROOT宏,加载器里先试相对路径、失败再回落到宏里的绝对路径,本地开发阶段就不用反复拷 assets 了。

target_include_directories(... PRIVATE src)让所有头文件都用#include "world/RoomGenerator.hpp"这种从 src 起算的写法,比../../world/稳。注意PRIVATE——这个可执行文件不需要把 include 路径导出给别人。

2.3 编译、运行与资源路径的三个开关

Linux 和 macOS 的流程基本一致,Windows 上把生成器换成对应版本即可:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j8 ./build/slime_hunter --seed 20240517

第一条生成构建目录,-DCMAKE_BUILD_TYPE一定要显式给。默认空构建类型等价于不加任何优化,肉鸽后期屏幕上几十个实体加子弹,Debug 版掉到 20 帧是常事,很多人第一反应是“代码写得烂”,其实只是没开-O2。

第二条-j8并行编译,按自己核数改。

第三条是启动参数,这个项目支持--seed指定随机种子,方便复现某一局。调试数值崩溃时非常有用——不用靠运气重开二十次去撞那个 bug。

提示:第一次编译如果卡在find_package(SFML)报找不到,先确认装了对应开发包而不是只有运行时库;CMake 找的是SFMLConfig.cmake,不是那几个动态库文件。

3. 主循环与状态机:一帧里到底发生了什么

游戏卡不卡、手感飘不飘、逻辑会不会因为帧率不同而跑出不同结果,八成取决于主循环这四十行。Slime-Hunter 用的是“固定步长逻辑 + 可变步长渲染插值”的标准写法,不算新鲜,但把边界情况处理得比较完整。

3.1 固定步长加插值的主循环

// src/core/Game.cpp void Game::run() { sf::Clock frameClock; const sf::Time kStep = sf::seconds(1.f / 60.f); // 逻辑恒定 60Hz sf::Time accumulator = sf::Time::Zero; while (m_window.isOpen() && !m_stack.empty()) { sf::Time elapsed = frameClock.restart(); // 断点、拖窗口、系统休眠回来,elapsed 可能是好几秒, // 不钳制就会一次性补上千次 update,俗称“死亡螺旋” if (elapsed > sf::seconds(0.25f)) elapsed = sf::seconds(0.25f); accumulator += elapsed; processEvents(); // 输入只在这层收,全局一份 while (accumulator >= kStep) { m_stack.update(kStep.asSeconds()); // 逻辑步长永远相同 accumulator -= kStep; } // alpha ∈ [0,1),交给渲染层做位置插值,消除高刷屏上的顿挫 m_stack.render(m_window, accumulator.asSeconds() / kStep.asSeconds()); m_window.display(); } }

kStep是逻辑步长,固定 1/60 秒。它的意义是:不管你屏幕是 60Hz、144Hz 还是 240Hz,同一秒内update()被调用的次数和传入的dt完全一样。肉鸽这种有弹道、有位移、有击退的游戏,这一点尤其重要——否则 144Hz 机器上子弹飞得比 60Hz 快,平衡性直接崩。

elapsed的钳制是本段最容易被删掉的一行。删掉的后果不是“偶尔卡一下”,而是:你在断点里停了五秒,继续运行的瞬间accumulator累积了 300 个 step,游戏会用一帧时间把五秒的逻辑全跑完,如果这期间有任何“每帧扣血”逻辑,角色当场暴毙。

alpha是从上一逻辑帧到下一逻辑帧之间的插值系数。渲染时把实体位置算成prevPos + (currPos - prevPos) * alpha,画面就平滑了。如果项目里暂时没做插值,直接传 0 也不会崩,只是高刷屏上能看到轻微抖动。

3.2 场景栈:菜单、战斗、结算怎么切

// src/core/Scene.hpp class Scene { public: virtual ~Scene() = default; virtual void onEnter() {} virtual void onExit() {} virtual void handleEvent(const sf::Event& e) = 0; virtual void update(float dt) = 0; virtual void render(sf::RenderTarget& rt, float alpha) = 0; virtual bool isOpaque() const { return true; } // 决定下层要不要继续画 }; // src/core/SceneStack.cpp void SceneStack::push(std::unique_ptr<Scene> s, bool pauseBelow) { m_pending.push_back({Op::Push, std::move(s), pauseBelow}); } void SceneStack::pop() { m_pending.push_back({Op::Pop, nullptr, false}); } void SceneStack::applyPending() { for (auto& cmd : m_pending) { if (cmd.op == Op::Push) { if (!m_stack.empty() && cmd.pauseBelow) m_stack.back()->onPause(); m_stack.push_back(std::move(cmd.scene)); m_stack.back()->onEnter(); } else if (!m_stack.empty()) { m_stack.back()->onExit(); m_stack.pop_back(); if (!m_stack.empty()) m_stack.back()->onResume(); } } m_pending.clear(); }

关键在于“栈操作排队,不即时生效”。如果BattleScene::update()里在遍历实体的循环中途直接push(RewardScene),栈容器一旦扩容,正在用的引用就悬空了,轻则画面错乱,重则随机崩溃。加一个m_pending命令队列,在每帧的逻辑更新结束后统一applyPending(),问题就消失了。这套写法跟编译器里“遍历时不能改容器”是同一个道理。

isOpaque()是个小优化:奖励界面是三选一面板,半透明盖在战斗画面上,下层场景还需要继续渲染但不更新。渲染时从栈顶往下画,遇到isOpaque() == true就停手,省掉整层无用的绘制。

3.3 输入采集与事件分发

// src/core/Input.cpp void Input::beginFrame() { m_pressedThisFrame.clear(); } void Input::onEvent(const sf::Event& e) { if (e.type == sf::Event::KeyPressed) { m_held[e.key.code] = true; m_pressedThisFrame.insert(e.key.code); // 只在按下那一帧为真 } else if (e.type == sf::Event::KeyReleased) { m_held[e.key.code] = false; } } bool Input::held(sf::Keyboard::Key k) const { return m_held[k]; } bool Input::pressed(sf::Keyboard::Key k) const { return m_pressedThisFrame.count(k) > 0; }

区分held和pressed是手感的基础。移动用held,每帧读一次;闪避、开箱、确认选择用pressed,只在按下的那一逻辑帧为真。如果全用held,玩家按住空格会触发无数次闪避;如果全用事件回调,输入响应又会被事件队列的到达时机带偏,和逻辑帧对不上。

还要注意:beginFrame()必须在每个逻辑帧开始时调用,而不是每渲染帧。放在渲染帧里调,高刷屏下同一逻辑帧内会清空多次,pressed可能被吃掉,表现就是“偶尔按了没反应”,这种偶发问题最难查。

4. 肉鸽玩法内核:种子、房间生成与词条叠加

玩法层是这类项目最值钱的部分。战斗手感和数值曲线很难靠读代码学会,但“随机怎么做得可复现”“属性怎么叠不爆炸”这两件事,是有明确工程解法的。

4.1 种子管理:让每一局都能被复现

// src/core/Rng.hpp class Rng { public: explicit Rng(uint64_t seed) : m_state(seed ? seed : 0x9E3779B97F4A7C15ull) {} uint32_t next() { // xorshift64* m_state ^= m_state >> 12; m_state ^= m_state << 25; m_state ^= m_state >> 27; return static_cast<uint32_t>((m_state * 0x2545F4914F6CDD1Dull) >> 32); } int range(int lo, int hi) { return lo + static_cast<int>(next() % (hi - lo + 1)); } float unit() { return next() / 4294967296.0f; } // [0,1) private: uint64_t m_state; };

这里刻意没用std::rand(),也没用std::uniform_int_distribution。前者的实现依赖标准库,换平台结果就变;后者虽然算法规范,但各家标准库的分布实现细节仍有差异,为了跨平台严格复现,自己写一个 xorshift 更省心,几十行、速度还快。

更重要的是分层派生子种子:

// 同一局内,房间生成和词条抽取用两条独立的随机流 Rng roomRng(runSeed ^ 0xA5A5A5A5ull); Rng lootRng(runSeed ^ 0x5A5A5A5Aull); // 每一层再按深度派生,保证“第 3 层”的门永远是同一批候选, // 不会因为你在第 2 层多杀了两个怪而改变 Rng layerRng(roomRng.next() ^ (static_cast<uint64_t>(depth) * 0x9E3779B1ull));

单流随机会有个隐蔽问题:玩家在第 1 层多捡了一件道具,抽取次数变了,后面所有层的房间布局全跟着变,想复现“上次那个特别难的第三层”就没戏。按用途拆流、按深度派生,才能做到“同种子 + 同深度 → 同结果”。

4.2 房间与波次生成:从网格模板到刷怪点

房间不做程序化拼接,而是用预置模板表 + 加权抽取。对独立体量来说这比全自动生成稳得多,美术成本可控,也不会出现“生成出一个没法走的房间”。

struct RoomTemplate { std::string id; // "cross_01" int gridW, gridH; std::vector<sf::Vector2i> spawnPoints; // 刷怪点,单位是格子 std::vector<sf::Vector2i> doorSlots; // 门的位置 int minDepth = 0; // 最早出现的层数 int weight = 10; // 权重,越大越常见 }; const RoomTemplate& RoomGenerator::pick(int depth, Rng& rng, const std::string& lastId) { std::vector<const RoomTemplate*> pool; int total = 0; for (const auto& t : m_templates) { if (t.minDepth > depth) continue; // 深度没到,不允许出现 if (t.id == lastId) continue; // 连续两层同一张图,太偷懒 pool.push_back(&t); total += t.weight; } int roll = rng.range(0, total - 1); // 加权轮盘 for (auto* t : pool) { roll -= t->weight; if (roll < 0) return *t; } return *pool.back(); // 兜底,防止权重算错时 UB }

lastId的排除是个体验细节,不加的话玩家会遇到“连续两层一模一样”的情况,虽然概率不高,但撞上那一次就会觉得这游戏很敷衍。minDepth则控制难度曲线,复杂的十字房放到第 4 层以后。

波次生成在WaveSpawner里,按房间的spawnPoints分批出怪,每批的数量随深度线性增长并加一个随机浮动:

int WaveSpawner::rollCount(int depth, Rng& rng) const { int base = 3 + depth * 2; // 每深一层多 2 只 int jitter = rng.range(-1, 2); // 抖动,避免每层节奏完全一样 return std::max(1, base + jitter); // 下限保护,别算出 0 只 }

std::max(1, ...)这个下限保护看着多余,但深度回退(某些道具会把你送回上一层)时depth可能算出负数,没有保护就一只怪都不刷,卡关。

4.3 词条系统:加法区和乘法区必须分开

这是整个项目里我最想让人看的一段。很多自制肉鸽到中期数值崩掉,根子都在“所有加成直接乘到一起”。

// src/skill/StatBlock.hpp enum class Stat { MaxHp, MoveSpeed, FireRate, Damage, CritChance, Projectiles, PickupRadius, Count }; struct Modifier { Stat stat; float add = 0.f; // 加法区 float mul = 1.f; // 乘法区 std::string tag; // "fire"、"crit",用于互斥或联动判断 }; class StatBlock { public: float get(Stat s) const { int i = static_cast<int>(s); float v = (m_base[i] + m_add[i]) * m_mul[i]; if (s == Stat::CritChance) v = std::clamp(v, 0.f, 1.f); // 暴击率封顶 100% return std::max(0.f, v); // 任何属性不许为负 } void apply(const Modifier& m) { int i = static_cast<int>(m.stat); m_add[i] += m.add; m_mul[i] *= m.mul; } private: std::array<float, static_cast<size_t>(Stat::Count)> m_base{}; std::array<float, static_cast<size_t>(Stat::Count)> m_add{}; std::array<float, static_cast<size_t>(Stat::Count)> m_mul{1.f, 1.f, 1.f, 1.f, 1.f, 1.f, 1.f}; };

分成两个区之后,“+5 伤害”和“伤害 ×1.2”的叠加顺序就被固定成(基础 + 全部加法) × 全部乘法。玩家拿十个 +5 是线性的,拿三个 ×1.2 是乘算的,前者负责稳定成长、后者负责爆发爽感,两者互不干扰。如果全塞进乘法区,十个 +10% 就是 2.59 倍,再叠十个直接失控。

clamp也不能少。暴击率超过 1 在有些实现里会“溢出”成负值或者触发未定义行为,玩家看到的是“暴击了反而不掉血”。属性下限归零同理,移速被减成负数会让角色往反方向跑。

4.4 战斗结算:一个逻辑帧内的固定顺序

// src/scene/BattleScene.cpp void BattleScene::update(float dt) { m_input.beginFrame(); m_player.update(dt, m_input); // 1. 玩家先动,拿到最新位置 m_spawner.update(dt, m_roomRng); // 2. 刷怪 for (auto& e : m_enemies) e->update(dt, m_player.pos()); m_bullets.update(dt); // 3. 弹道推进 resolveCollisions(); // 4. 统一算伤害,不在这里删对象 reapDead(); // 5. 标记为 dead 的才真正移除 if (m_spawner.finished() && m_enemies.empty()) { requestPushReward(); // 6. 状态切换走队列,不当场改栈 } }

顺序不是随便定的。玩家先更新,子弹才能用最新位置做命中判定,否则会看到“贴着怪打不中”的诡异现象。碰撞结算统一放在最后,是为了让同一帧内的多颗子弹打同一个敌人时伤害可叠加,而不是谁先谁后看运气。

第 4 步只打标记、第 5 步才真删,这条“延迟删除”规则是整个项目里最不能省的一条,章节 5 会细说。

5. 踩坑排查:编译、链接、运行期的五个翻车现场

下面这五条基本是我第一次跑这类项目时按顺序踩过去的,每一条都卡了大半天以上。

5.1 链接期 undefined reference to sfml 符号

现象:编译全过,链接阶段刷出一屏undefined reference to 'sf::RenderWindow::RenderWindow()',头文件明明能找到。

原因:CMake 里target_link_libraries顺序写错,或者只链了sfml-graphics没链sfml-system。图形模块依赖窗口和系统模块,静态链接时顺序必须从上层到下层。另一个常见原因是装了静态库版本(libsfml-graphics-s.a),但没定义SFML_STATIC宏,编译器按动态库的导入约定去解析符号,自然对不上。

解决:先把find_package的COMPONENTS补全成graphics window audio system,链接时按graphics window audio system的顺序排;如果确认用的是静态库,在 CMake 里加target_compile_definitions(slime_hunter PRIVATE SFML_STATIC)。查证时用ldd ./build/slime_hunter | grep sfml,能直接看出到底链的是哪一份。

5.2 一进游戏就黑屏,控制台干净得像没事

现象:窗口出来了,尺寸也对,但里面全黑,控制台一条日志都没有,鼠标点关闭还能正常退出。

原因:只有两种可能。一是主循环里漏了display(),或者把clear()放在了display()后面,画完就被下一帧擦掉。二是资源加载抛了异常被try/catch吞掉,场景对象没构造完就进了渲染分支,画了个空。第二种更阴,因为catch里往往只写了return;,连日志都没打。

解决:先在run()的循环末尾打断点确认display()被执行;再去资源加载器里把所有catch块改成至少打一行std::cerr,带上文件路径。顺手检查render()里的绘制坐标——窗口默认视口是左上角原点,用错坐标系画到可视区外面去了,也是全黑。我现在的习惯是任何 catch 块里都必须有日志,这条规矩能省下无数次瞎猜。

5.3 IDE 里跑得好好的,双击 exe 就崩

现象:在 IDE 里点运行一切正常,把 build 目录里的 exe 单独拷到桌面双击,程序闪退。

原因:工作目录变了。IDE 启动时把工作目录设成项目根,相对路径assets/能命中;双击时工作目录是 exe 所在目录,assets/根本不存在,加载器读文件失败后没做判空,直接对空指针操作。

解决:两头一起改。加载器里先试相对路径,失败回落SH_ASSET_ROOT,再失败打一条带std::filesystem::current_path()的错误日志——把当前工作目录打出来,一眼就能看出问题。发布时把assets/拷到 exe 同级目录,或者打包成单文件资源。这个坑不分项目大小,我后来所有 C++ 工程的资源加载器第一行都是打印 cwd。

5.4 帧率越高角色跑得越远,甚至能穿墙

现象:在自己机器上跑得好好的,同事的高刷屏笔记本上角色明显更快,冲刺技能能直接穿过墙体。

原因:位移写成了pos += speed;,没乘dt,于是移动距离和帧率成正比。即使乘了dt,如果用的是可变步长渲染帧直接驱动逻辑(没有章节 3 里那个固定步长累加器),高帧率下每步位移变小但步数变多,碰撞检测的采样点变密,边界处理稍有不严就会漏检穿墙。

解决:逻辑一律走固定步长,位移统一写pos += velocity * dt。碰撞不能只做“终点是否在墙内”的判定,要做线段与墙体的扫掠检测,或者干脆把单步位移钳制在半个格子以内。最省事的验证方法是在主循环里临时把kStep改成1.f / 240.f跑一遍,如果手感明显变了,说明还有地方依赖帧率。

5.5 打死怪物的瞬间崩溃,报错信息指向容器的奇怪位置

现象:单只怪随便打,一群怪围上来时偶尔崩在std::vector的迭代器上,gdb栈回溯指向Enemy::update之后的一行。

原因:子弹命中后直接在遍历m_enemies的循环里erase了元素。erase会让当前迭代器以及之后的所有迭代器失效,循环继续++it就是对已析构对象操作。更麻烦的是这种崩溃位置随机、难以复现,看着像“玄学”。

解决:改成标记删除。命中时不删对象,只把e->dead = true;循环结束后统一std::erase_if(m_enemies, [](auto& e){ return e->dead; })。同理,任何“在遍历中增删”的地方都要走这套。要是非要在循环里删,至少用it = v.erase(it)的写法并跳过自增;但实体数量一多,延迟删除几乎总是更清晰的选择。

6. 二次开发:加一把武器,只需要动三个地方

想改这个项目,最合适的第一个练习是加一把散射武器,因为这条路正好串起数据、词条、表现三层。

第一步改skill/ModifierPool.cpp里的词条表,加一条scatter词条,stat = Projectiles、add = 2,同时把tag设成"fire";第二步在武器的开火函数里按Projectiles的返回值决定发射数量,把原来单发的方向向量按扇形角度均分出去,角度步长取12 度;第三步在assets/sprites里补一张弹丸贴图,在渲染层的贴图映射表里加一行。三处改完,词条抽取池会自动带上它,不需要动任何抽取逻辑。

改动位置文件验证方式
词条定义skill/ModifierPool.cpp连续重开,确认三选一里能刷出scatter
发射逻辑entity/Player.cpp的fire()控制台打印Projectiles的实际值,应为 1→3
资源与贴图键assets/sprites+ 渲染映射表观察多出来的弹丸是否共用同一张图

验证闭环我一般按这个顺序走:先固定种子--seed 12345跑三局,确认词条池里出现了新条目;再用调试键把玩家属性直接拉到Projectiles = 5,看扇形角度是否会因为角度步长写死而出现弹丸重叠;最后把帧率限制解除,跑满 240 帧,确认散射的弹道在高速下不会瞬移穿过敌人。第二次能过,基本就稳了。

我踩过最贵的一次坑是改完词条忘了同步Stat::Count这个枚举末尾的哨兵值,std::array的大小跟着变小,get()里按枚举下标访问直接越界,表现是“随机某个属性读到垃圾值”,查了两天才定位。从那以后我每次往枚举里加成员,都会在编译期加一行static_assert(static_cast<int>(Stat::Count) == 7, "记得同步 Count"),改漏了直接编译不过,比运行时随机崩强太多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询