尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++装备系统开发实战:从类设计到序列化一条龙

C++装备系统开发实战:从类设计到序列化一条龙 1. 0.4.5版本到底做了什么拖了两个多月终于把《神明之剑》的装备机制从底层到表现整个捋完了。这个版本其实没有加新地图、没有加新怪物所有工作都压在装备系统上——从最初的“能穿能脱”到今天“词条随机、品质判定、Buff联动、存档封装”全跑通算是把这个项目最核心的养成坑填上了。先说清楚这个版本的实际内容方便想参考的朋友对号入座这是一款用C控制台/轻量图形库做的RPG小游戏0.4.5的重点就是把装备模块从“数据结构草图”推进到“完整可玩”的程度具体包括装备类体系设计、背包与槽位管理、随机词条生成、装备Buff与战斗事件联动以及装备存档的持久化读写。整体代码量不算大大概新增和重写了2000多行C但涉及的知识点非常典型——继承与多态、STL容器选型、智能指针、右值语义、序列化、随机数权重……几乎是C中阶项目的标准样本。适合看这篇内容的人想做文字冒险或RPG小游戏的C练习者想搞懂“装备系统”在工程上到底怎么落地的朋友以及需要一份“从类设计到存档一条龙”参考代码的开发者。如果你只是想找一个能跑的成品游戏那这篇帮不了你但如果你想知道这些东西背后的取舍逻辑我踩过的坑你基本可以避开。1.1 装备机制的范围界定装备系统这个词其实很大不同项目里含义差得很远。有些游戏是暗黑类的“掉落-鉴定-对比-替换”循环有些是MMO里的“套装加成-强化-镶嵌”还有些是自走棋那种“临时拿装备合成”的玩法。在动手之前你必须想清楚自己要哪一种否则很容易变成一个万能但无用的半成品。《神明之剑》本身是单机向的回合制RPG战斗系统比较简单所以装备需求我框定成四块装备有品质差异白装、蓝装、紫装、橙装品质影响基础属性和词条数量。每件装备有随机词条词条从预设池里按权重抽取同名词条有数值浮动。装备分部位武器、护甲、饰品三类角色只能带一个武器、一件护甲、两件饰品。装备和战斗逻辑解耦装备只是提供一组“属性修正”和“状态效果”战斗系统统一消费这些数据。这么框定的好处是每个模块都可以独立测试词条系统不需要关心战斗回合怎么跑战斗系统也不需要知道词条是哪里来的。0.4.5版本所谓“彻底完成”就是指这个闭环已经跑通打怪掉落、随机生成、背包存取、穿戴生效、存档恢复全链路无断点。1.2 为什么选这个版本节点做装备游戏开发里最容易翻车的节点就是“在核心玩法还没稳定时先做养成”。装备系统天生依赖战斗和数值框架战斗没定下来装备就是空中楼阁。0.4.5之前《神明之剑》已经有三套职业技能、五种敌人AI和完整的回合战斗流程数值公式也调过两轮这时候再动装备才不会出现“装备加的攻击力根本不影响战斗结果”这种尴尬局面。另一个原因是架构上到了该重构的时候。之前角色属性直接写在Player类里想加个“捡起武器攻击力X”的效果就得硬编码一个分支。随着内容变多这种散装逻辑会越堆越臭所以这个版本顺势把属性获取改成“基础值装备修正”的聚合模式为后续版本留出扩展空间。2. 装备属性的底层类设计装备系统最忌讳一上来就写一堆getter/setter然后到处用if判断类型。C写装备第一件事是把“装备”这个抽象概念用类体系表达清楚让后续所有逻辑都能面向基类操作。2.1 装备基类与派生体系设计我先定义了一个Equipment抽象基类所有装备都继承它。基类里放通用字段物品ID、名称、描述、图标ID如果是图形界面、品质、部位、价格、词条列表。关键点是基类只描述“所有装备都有什么”具体某件装备怎么特殊、怎么行为不同都通过虚函数和子类实现。enum class EquipSlot { Weapon, Armor, Accessory }; enum class Quality { Common, // 白 Uncommon, // 蓝 Rare, // 紫 Legendary // 橙 }; class Equipment { public: Equipment(int id, std::string name, EquipSlot slot, Quality quality) : m_id(id), m_name(std::move(name)), m_slot(slot), m_quality(quality) {} virtual ~Equipment() default; int id() const { return m_id; } const std::string name() const { return m_name; } EquipSlot slot() const { return m_slot; } Quality quality() const { return m_quality; } // 子类可以提供额外的基础属性修正 virtual int baseAttackBonus() const { return 0; } virtual int baseDefenseBonus() const { return 0; } virtual int baseMaxHealthBonus() const { return 0; } // 词条集合外部逻辑只通过这个接口拿属性 const std::vectorAffix affixes() const { return m_affixes; } void addAffix(Affix affix) { m_affixes.push_back(std::move(affix)); } private: int m_id; std::string m_name; EquipSlot m_slot; Quality m_quality; std::vectorAffix m_affixes; };这里有个细节值得说我没有把攻击力、防御力这些属性直接做成基类的成员变量而是用了“基础修正词条”的组合模式。基础修正让子类可以表达武器和护甲的天然差异词条则承载随机性。如果所有属性都堆在基类里词条系统就没法跟基础属性区分后面做“词条数值浮动”会很别扭。2.2 武器、护甲、饰品的多态实现三个子类各自只关心自己那一亩三分地。武器加攻击护甲加防御饰品可以灵活一点加血加暴击都行。class Weapon : public Equipment { public: Weapon(int id, std::string name, Quality quality, int baseAttack) : Equipment(id, std::move(name), EquipSlot::Weapon, quality) , m_baseAttack(baseAttack) {} int baseAttackBonus() const override { return m_baseAttack; } private: int m_baseAttack; }; class Armor : public Equipment { public: Armor(int id, std::string name, Quality quality, int baseDefense) : Equipment(id, std::move(name), EquipSlot::Armor, quality) , m_baseDefense(baseDefense) {} int baseDefenseBonus() const override { return m_baseDefense; } private: int m_baseDefense; };这样设计之后战斗系统里取角色总攻击力就变成一行代码的事情int totalAttack player.baseAttack() player.equipmentBonus(AttributeType::Attack);不需要在战斗系统里判断具体某件装备是武器还是护甲。多态的价值就在这里——让调用方不需要知道对象的真实类型只需要相信接口的一致性。如果你在一个小游戏里写满了if (type Weapon)这种代码说明你的抽象层级还没到位。饰品稍微特殊一点它可能出现“每秒回复魔力”“攻击附带火焰伤害”这类专属效果但底层处理方式不变基础修正返回0效果全部通过词条和Buff系统表达。3. 背包与装备栏内存管理才是重头戏很多人一提到装备系统就觉得是类设计麻烦其实类设计反而是最容易的部分真正折磨人的是对象生命周期。装备是在背包里还是穿在身上卸下来之后内存归谁管存档读档之后指针还指不指向原来那件装备这些问题不搞明白程序会在你最不设防的时候崩溃。3.1 容器选型不背vector锅的Inventory背包本质上是“一组装备对象”的集合。但我强烈建议不要直接用std::vectorEquipment主要原因有两个装备可能为空槽位vector没法优雅表达“这个格子里没东西”。玩家可能整理背包、堆叠、比较这些是业务逻辑不该直接暴露给容器。我用了std::vectorstd::shared_ptrEquipment配合“空指针表示空格子”的做法class Inventory { public: static constexpr size_t kBagSize 40; bool add(const std::shared_ptrEquipment item); std::shared_ptrEquipment removeAt(size_t index); const std::shared_ptrEquipment at(size_t index) const; private: std::vectorstd::shared_ptrEquipment m_slots; };为什么用shared_ptr而不是unique_ptr因为同一件装备在穿戴、卸下、存档过程中会有多个“观察者”同时指向它。比如玩家穿上一把武器战斗系统要读它的词条UI要显示它的图标存档系统要序列化它的数据。如果用裸指针任何一个环节忘记清空就容易悬挂用unique_ptr又很难安全地把同一对象同时交给背包和装备栏管理。shared_ptr在这里不是性能问题而是安全兜底。3.2 穿戴与卸下操作顺序的坑穿戴的核心动作是“把装备从背包放到对应槽位”。听起来很简单吧但有一个隐藏问题如果槽位已经有装备怎么办。处理办法是把旧装备放回背包同时新装备从背包移除。这两步必须作为一个原子操作不能在中间插入其他逻辑。class EquipSystem { public: bool equip(size_t bagIndex, EquipSlot slot) { auto item m_inventory-at(bagIndex); if (!item) return false; if (item-slot() ! slot) return false; // 先卸下旧装备腾出背包空间 auto old m_slots[(int)slot]; if (old) { if (!m_inventory-add(old)) { // 背包满了装备失败 return false; } } // 从背包取出新装备穿到身上 auto taken m_inventory-removeAt(bagIndex); m_slots[(int)slot] taken; return true; } void unequip(EquipSlot slot) { auto old m_slots[(int)slot]; if (!old) return; if (!m_inventory-add(old)) return; // 背包满了就穿不回去保持原样 m_slots[(int)slot] nullptr; } private: std::arraystd::shared_ptrEquipment, 3 m_slots; std::shared_ptrInventory m_inventory; };这个顺序一旦写反就会出bug。最典型的错误是先把新装备从背包移除、再尝试把旧装备放回背包结果背包空间不足时新装备已经丢了旧装备无处安放——玩家就莫名其妙损失了一件装备。我踩过这个坑结论是所有涉及多位置元素的交换操作先解决“是否成功”再执行“移动”。3.3 shared_ptr循环引用的预防既然是shared_ptr那就绕不开循环引用。装备和背包之间还好最危险的是装备挂BuffBuff又引回装备不做处理就泄漏。我的教训是装备和Buff的相互引用一律用裸指针或weak_ptr处理“观察”关系只有容器才持有shared_ptr所有权。背包容器 - shared_ptr - Equipment 装备槽位 - shared_ptr - Equipment Equipment - weak_ptr - Character这样整个引用链是单向的析构时不会出现互相等待释放的僵局。如果项目里已经出现循环引用的结构问题别急着到处改成weak_ptr先想想是不是容器的“持有权”设计得不清晰。4. 品质、词条与随机数值怎么算才靠谱一个装备系统好玩不好玩一半看随机。随机不是rand() % 100这么简单——词条池、权重、品质阈值、数值浮动任何一步失衡玩家要么5分钟毕业要么刷到天荒地老也等不到一件能用的。4.1 品质决定词条数我给的规则很朴素品质基础属性倍率词条数量词条数值浮动范围白装(Common)1.0x00蓝装(Uncommon)1.2x180%~120%紫装(Rare)1.5x270%~130%橙装(Legendary)2.0x360%~140%注意这个“基础属性倍率”。前面类设计里基础攻击力是整数的随机生成的时候要用浮点数倍率算完再取整不能直接在整数上乘。auto weapon std::make_sharedWeapon( nextId(), 水银长剑, Quality::Rare, static_castint(baseAtk * qualityMultiplier(q) 0.5f) );浮点转整数向上取整还是向下取整看起来是小事但对玩家感知影响很大。装备面板里显示“攻击117”比“攻击116”更有面子一点我倾向于四舍五入跟主流游戏保持一致玩家算账的时候心理预期不会乱。4.2 词条池与权重随机词条不是平均出现。攻击力词条和防御力词条不能一样常见暴击率词条更是稀有货。我用的是经典的“权重表”方案struct AffixEntry { AffixType type; int weight; int minValue; int maxValue; }; static const std::vectorAffixEntry affixPool(EquipSlot slot) { static std::unordered_mapEquipSlot, std::vectorAffixEntry pool { { EquipSlot::Weapon, { { AffixType::Attack, 100, 5, 18 }, { AffixType::CriticalRate, 8, 1, 5 }, { AffixType::AttackSpeed, 12, 1, 4 }, }}, { EquipSlot::Armor, { { AffixType::Defense, 100, 4, 15 }, { AffixType::MaxHealth, 60, 10, 30 }, }}, { EquipSlot::Accessory, { { AffixType::MaxHealth, 100, 10, 25 }, { AffixType::Attack, 30, 3, 10 }, { AffixType::CriticalRate, 10, 1, 4 }, }}, }; return pool.at(slot); }抽取词条时先算权重总和然后随机命中区间。这个算法很简单但有一点要注意词条不能重复同一个池子抽两次可能抽到同一个类型的词条游戏里就会出现“暴击率3%”“暴击率4%”这种毫无设计感的装备。处理方式是从池子里剔除已抽中的类型或者允许重复但统一归并为一个词条再按最大值覆盖。我选了“剔除已抽类型”的做法逻辑清晰词条多样性也更有保证。std::vectorAffix rollAffixes(Quality quality, EquipSlot slot) { auto pool affixPool(slot); std::vectorAffix result; int targetCount affixCountByQuality(quality); for (int i 0; i targetCount !pool.empty(); i) { int totalWeight 0; for (auto entry : pool) totalWeight entry.weight; int roll rand() % totalWeight; int cumulative 0; size_t chosenIndex 0; for (size_t j 0; j pool.size(); j) { cumulative pool[j].weight; if (roll cumulative) { chosenIndex j; break; } } auto chosen pool[chosenIndex]; int value chosen.minValue rand() % (chosen.maxValue - chosen.minValue 1); // 按品质做二次浮动 float scale qualityValueScale(quality); value static_castint(value * scale); result.emplace_back(chosen.type, value); // 从池子里移除已用的词条类型 pool.erase(pool.begin() chosenIndex); } return result; }这里有一个容易忽略的性能细节每次抽取都动态删除vector中间的元素词条池那么小每种部位最多五六个词条性能完全无所谓。但如果你把词条池扩展到上百条这种算法就得换——可以改成“已选类型放进set遍历池子跳过已选”否则反复erase的O(n²)复杂度会拖慢生成流程。5. Buff系统与战斗联动解耦才是王道装备凑齐了词条也随机出来了如果不跟战斗挂钩那一切都是白搭。0.4.5版本的一个重要改动就是把“装备效果”从装备类里抽出来变成一套独立的Buff系统战斗逻辑只需要消费Buff效果不需要关心Buff来自装备还是来自技能。5.1 从“装备带属性”到“装备触发效果”一开始我的设计是给装备加一堆虚函数比如onTurnStart()、onHit()装备子类自己override。这看起来很美但很快就失控了一件套装要触发“每回合回复5点魔力”同时还要“暴击时附加额外伤害”如果都靠继承覆盖你得为每个特效写一个子类类爆炸。更好的做法是装备生成时把自身效果注册成一系列Buff结构战斗系统在合适的时机触发这些Buffstruct Buff { enum class Trigger { OnTurnStart, OnHit, OnAttacked, Persistent }; Trigger trigger; // 属性修正直接作用于数值 std::optionalint additiveAttack; std::optionalint additiveDefense; std::optionalint additiveMaxHealth; // 特殊效果 std::vectorstd::functionvoid(CombatContext) callbacks; };比如“每回合回复5点魔力”这种Buff在装备穿戴时注册一个带回调的Buff回调会在onTurnStart阶段被战斗系统调用而“攻击12”这种纯数值修正直接存成additiveAttack字段战斗时汇总即可。这样装备设计只负责“产出Buff”战斗系统只负责“消费Buff”彼此保持距离。5.2 穿戴变更的合理性校验装备卸载时Buff必须同步移除否则会出现“脱了装备还能享受加成”的严重bug。这块我吃过一次亏——当时只在穿戴时计算属性总和卸下时忘了扣结果玩家的攻击力只涨不跌刷了个橙装就永久毕业了。0.4.5改成“每次穿戴状态变化全量重算属性”的方案int Character::totalValue(AttributeType attr) const { int base m_baseAttributes.value(attr); int equipmentBonus 0; for (auto equip : m_equipped) { if (!equip) continue; equipmentBonus equip-attributeBonus(attr); // 加上update状态下所有additive Buff的值 for (auto buff : equip-buffs()) { if (buff.additiveAttack attr AttributeType::Attack) { equipmentBonus *buff.additiveAttack; } } } return base equipmentBonus; }全量重算比“局部加加减减”多花一点时间但角色属性计算本来就高频低耗换来的是状态一致性这种取舍非常值。不要过早优化那几微秒逻辑正确永远优先。5.3 属性修正的顺序约定属性汇总时加法和百分比乘法的顺序很讲究。我的约定是最终属性 (基础值 固定加成) × (1 百分比加成)同一个AttributeType下加成按“先加固定值后乘百分比”执行。这个顺序必须在所有数据接口里保持一致否则玩家看到“10%攻击力”实际效果跟预想不一样体验会很混乱。我建议在早期就定死这个规则写进数值策划文档别到后期才补。6. 存档系统装备数据如何持久化装备机制做的再花哨关了游戏全丢就等于零。0.4.5的存档模块专门为装备数据做了序列化封装确保穿在身上的、放在背包里的装备都能正确还原。6.1 序列化方案选定项目是C游戏最初我考虑过用第三方库如jsoncpp、protobuf或者boost.serialization。但是一个小型RPG不想引入太多外部依赖最终决定手写简单的二进制序列化。原因是游戏数据量不大、结构稳定、跨版本兼容可以通过设计文件头来解决没必要为一个背包的装备数据引入重量级依赖。class EquipmentSerializer { public: void serialize(const Equipment eq, std::ostream out) { writeInt(eq.id(), out); writeString(eq.name(), out); writeInt(static_castint(eq.slot()), out); writeInt(static_castint(eq.quality()), out); // 派生类额外数据 if (auto* weapon dynamic_castconst Weapon*(eq)) { writeInt(weapon-baseAttackBonus(), out); } else if (auto* armor dynamic_castconst Armor*(eq)) { writeInt(armor-baseDefenseBonus(), out); } // 词条列表 writeInt(eq.affixes().size(), out); for (auto affix : eq.affixes()) { writeInt(static_castint(affix.type), out); writeInt(affix.value, out); } } std::shared_ptrEquipment deserialize(std::istream in) { int id readInt(in); std::string name readString(in); auto slot static_castEquipSlot(readInt(in)); auto quality static_castQuality(readInt(in)); std::shared_ptrEquipment ret; if (slot EquipSlot::Weapon) { int baseAtk readInt(in); ret std::make_sharedWeapon(id, name, quality, baseAtk); } else if (slot EquipSlot::Armor) { int baseDef readInt(in); ret std::make_sharedArmor(id, name, quality, baseDef); } else { ret std::make_sharedAccessory(id, name, quality); } int affixCount readInt(in); for (int i 0; i affixCount; i) { auto type static_castAffixType(readInt(in)); int value readInt(in); ret-addAffix(Affix(type, value)); } return ret; } };序列化和反序列化必须成对设计。最容易出的bug是字段顺序不一致——写入先写id再写name读取时先读name再读id直接错位数据全崩。我每次改完序列化代码都会做一次完整的手动导出验证不偷懒。6.2 版本兼容性设计存档还得考虑“旧版本存档在新版本里还能不能用”。我加了简单但必要的存档头struct SaveHeader { char magic[4] {P,S,J,1}; // 魔数防错文件 uint32_t version 1; // 版本号 uint32_t equipCount 0; };每次读取存档先校验魔数然后检查版本号。未来如果装备结构变化可以走迁移逻辑——从旧版本读取时缺失的新字段填默认值而不是直接拒绝读取。很多小游戏开发者在存读档上偷懒出bug时又怪“运气不好”其实都是规范问题。存档这种跨程序的生命周期数据必须从一开始就放进版本控制体系里。7. 典型Bug与调试心得这几条坑你大概率也会踩这个版本调试过程中我撞了一堆看似不起眼、排查起来却让人抓狂的问题。列几个典型的给后来人打个预防针。7.1 深拷贝导致的词条错乱C里绑定装备时我曾经直接在容器里按值传递Equipment对象结果整个装备被隐式拷贝词条vector也跟着拷贝。表面上看没问题但很多逻辑持有的是拷贝后的“临时对象”战斗系统读到的属性跟玩家看到的不一致。查找这类问题的效率方法是在所有关键位置打印对象地址你会发现“同一件装备”的地址居然不一样。使用智能指针之后装备对象全程只有一份地址稳定问题就消失了。这也是为什么我建议“穿越核心业务边界背包-战斗-存档时优先用指针而不是值”的原因。7.2 随机数种子惹的祸词条生成用了rand()但rand()是全局的战斗里也有一堆随机数调用。导致的结果是词条抽取的结果取决于之前打过多少只怪甚至取决于某个暴击有没有触发。这种耦合让玩家觉得“掉落像假的一样”还会让权重配置难以验证。0.4.5里我改成了独立的随机数引擎static std::mt19937 g_rng(std::random_device{}()); int rollValue(int min, int max) { std::uniform_int_distributionint dist(min, max); return dist(g_rng); }如果要在存档里做掉落记录或同一种子复现可以把种子存进存档头以后调试同一个随机序列就能复现。也许你觉得这是小事但对一款以“刷装备”为养成的游戏来说随机数的可复现性和可测试性直接影响数值调整的效率。7.3 装备栏索引与背包索引混乱装备栏只有三个槽位本来没什么好混乱的。但我一开始把武器槽索引定义成0、护甲1、饰品2背包索引又是另一个体系结果某个版本里饰品槽位的卸载函数错误地引用到了背包空格玩家卸饰品不但卸不下来还会莫名其妙消耗掉背包里的一件物品。这个bug的排查很花时间因为它不崩溃只是数值悄悄变成“背包少了东西”。最终我把槽位类型和索引统一用enum class封装避免裸整数传参并启用编译器的强警告防止类型不匹配的隐式转换。这类问题在后期越来越难发现最好在初版就定好严格的口径。7.4 常见问题速查症状可能原因排查方向卸下装备后属性没变全量重算逻辑没跑或Buff未移除检查穿戴状态变更事件是否触发属性刷新装备从背包消失穿戴/卸下顺序写反或背包满没回滚加日志输出每个移动步骤的返回值和当前容量读档后装备词条错乱序列化字段顺序不一致对比读写代码逐字段检查顺序随机词条总是一模一样rand()全局耦合或种子固定改用独立随机引擎种子用random_device初始化同一件装备出现两个副本按值拷贝容器导致用shared_ptr替换裸对象检查所有传参方式shared_ptr对象无法释放出现循环引用使用weak_ptr断开环或重新设计所有权归属8. 个人收拾旧江山的一点体会0.4.5走到今天技术上谈不上高深大部分代码都是很常规的C写法。但我对这个版本的定位是“把欠的技术债还掉把该有的骨架立起来”。装备系统最大的价值不是那几件装备本身而是它倒逼我把属性计算、随机生成、事件设计、存档规范全部统一化了。最后分享一个版本迭代中积累的心得装备机制的复杂度会随词条数量和交互玩法膨胀但架构层面不要跟着膨胀。保持“装备生成-背包存储-穿戴切换-属性汇总-战斗消费-存档持久化”这条链路清晰稳定新加任何装备类型都只是在链路上增加一个节点而不是在各个环节打补丁。下一版本我打算加套装效果和强化系统到时候依然沿用这套底层逻辑希望能少踩几个坑。
返回列表