首页 / 资讯中心 / 文章详情

C++三国游戏实验:面向对象编程核心知识点与实战避坑指南

C++三国游戏实验:面向对象编程核心知识点与实战避坑指南 ★ FEATURED ARTICLE
1. 从一道实验题说起为什么三国游戏是C教学的经典载体第一次看到C程序设计实验之三国游戏这个题目很多人脑子里冒出来的画面大概是一个黑框控制台几行文字玩家输入数字选择武将然后互相砍一刀谁血少谁输。如果你也这么想那说明你还没真正拆过这类实验题的设计意图。我带过几届学生的课程设计也帮不少朋友看过他们的实验报告可以很负责任地说三国游戏这个题材之所以被反复使用不是因为它好玩而是因为它天然覆盖了C面向对象编程的几乎所有核心知识点而且这些知识点之间的耦合关系非常自然不需要生硬地为了用而用。先把这个实验的定位说清楚。它通常出现在大一下学期或大二上学期学生刚学完类与对象、继承、多态、运算符重载、STL容器这些内容需要一个综合性实验把这些零散的知识点串起来。三国题材的好处在于武将有属性攻击、防御、血量、速度有行为攻击、防御、使用技能有分类武将、谋士、君主有交互对战、组队、回合制这些概念和面向对象里的类、继承、多态、封装几乎是一一对应的。你不需要绞尽脑汁去设计一个场景来用多态因为武将和谋士本来就应该有不同的攻击方式多态是自然而然的需求。这个实验最终交付的东西通常是一个控制台程序能实现武将的创建、属性展示、回合制对战、战斗日志输出有些要求高一点的还会加上存档读档、技能系统、装备系统。听起来不复杂但真正动手写的时候你会发现坑一个接一个血量扣减的时机、攻击顺序的判定、多态调用时的对象切片问题、随机数种子的设置、容器遍历时删除元素导致的迭代器失效……这些问题在课本例题里很少同时出现但在一个完整的游戏逻辑里会集中爆发。我见过太多同学类定义写得漂漂亮亮一到主循环就乱套要么是伤害计算顺序错了导致武将死后还能反击要么是用了vector存对象却忘了用指针导致多态失效。所以这篇文章不打算给你一份标准答案而是想从实际开发的角度把这类实验里最容易出问题的地方一个个拆开讲清楚让你不仅能交作业还能真正理解为什么这么写。提示本文所有代码示例基于C11及以上标准使用标准库不依赖任何第三方框架。如果你用的是老版本编译器部分语法需要调整我会在相应位置说明。2. 武将类的设计从能跑就行到经得起扩展2.1 属性字段的取舍与封装边界很多同学拿到题目第一反应是定义一个General类然后把所有能想到的属性都塞进去名字、血量、攻击、防御、速度、暴击率、闪避率、技能列表、装备栏、等级、经验值……结果类定义写了三百行真正用到的没几个。我的建议是先明确这个实验的最低功能需求再决定字段。如果题目只要求回合制对战并输出日志那你至少需要这些字段name武将名称字符串类型hp/maxHp当前血量和最大血量整数attack攻击力整数defense防御力整数speed速度决定出手顺序整数这些字段应该设为private通过public的成员函数访问。为什么因为血量这个字段特别容易出问题。如果你把hp设为public某处代码直接写general.hp - damage万一伤害算出来是负数比如攻击力小于防御力时血量反而增加了。正确的做法是提供一个takeDamage(int damage)函数在里面统一处理边界void takeDamage(int damage) { if (damage 0) damage 0; // 防御力高于攻击力时至少造成0伤害 hp - damage; if (hp 0) hp 0; // 血量不能为负 }这个函数看起来简单但它把血量扣减这个逻辑收口了。以后不管你是普通攻击、技能伤害还是中毒持续掉血都走这个接口就不会出现血量异常的情况。这就是封装的真正价值——不是把字段藏起来就完了而是把对字段的操作规则也一起藏起来。2.2 构造函数与初始化列表的坑构造函数是另一个高频出错点。很多同学写构造函数喜欢在函数体里赋值General(string n, int h, int a, int d, int s) { name n; hp h; maxHp h; attack a; defense d; speed s; }这样写能跑但不是最优。C的构造函数应该优先使用初始化列表General(string n, int h, int a, int d, int s) : name(n), hp(h), maxHp(h), attack(a), defense(d), speed(s) {}区别在于对于基本类型两者差别不大但对于string这类类类型成员初始化列表是直接构造函数体内赋值是先默认构造再赋值多了一次不必要的操作。更重要的是如果成员是const或引用类型那就只能用初始化列表。养成用初始化列表的习惯以后写更复杂的类时不会突然卡住。还有一个细节初始化列表的顺序应该和成员声明顺序一致。C标准规定成员按声明顺序初始化跟你写在初始化列表里的顺序无关。如果你写反了编译器可能给你一个警告但不会报错运行时行为可能和你预期的不一样。比如你先声明maxHp再声明hp但初始化列表里先写hp(h)再写maxHp(h)实际执行时maxHp先初始化虽然这里结果一样但换成有依赖关系的成员就会出问题。2.3 运算符重载让武将比较更自然题目如果要求按速度排序决定出手顺序你可能会写一个排序函数传入两个武将比较它们的speed。但更优雅的做法是重载运算符bool operator(const General other) const { return speed other.speed; // 速度高的排前面 }注意这里我用了speed other.speed因为std::sort默认是升序而我们要速度高的先出手所以反过来定义。这个细节很容易搞混建议在函数上方写一行注释说明。重载之后你就可以直接把武将放进vector调用std::sortstd::vectorGeneral generals {g1, g2, g3}; std::sort(generals.begin(), generals.end());代码立刻清爽了很多。但这里有一个极其重要的坑如果你把武将对象直接存进vector排序时会发生对象拷贝如果类里有指针成员比如动态分配的名字字符串就会出问题。这个实验里通常没有指针成员所以直接存对象没问题。但如果你后面想加装备系统装备用指针管理那就必须存指针或智能指针否则拷贝后会出现双重释放。这个坑我在第4节还会展开讲。3. 继承与多态武将、谋士、君主的差异化实现3.1 为什么一定要用多态而不是if-else假设题目要求三种角色武将物理攻击高谋士会放技能君主能加buff。最直接的做法是定义一个Role类里面加一个type字段然后在攻击函数里写void attack(Role target) { if (type warrior) { // 物理攻击逻辑 } else if (type strategist) { // 技能攻击逻辑 } else if (type lord) { // buff逻辑 } }这样写能跑但扩展性极差。如果题目追加一个刺客角色你就得回来改这个函数加一个else if。而且所有角色的攻击逻辑挤在一个函数里代码会越来越长。正确的做法是用继承和多态class Role { protected: string name; int hp, maxHp, attack, defense, speed; public: Role(string n, int h, int a, int d, int s) : name(n), hp(h), maxHp(h), attack(a), defense(d), speed(s) {} virtual ~Role() {} virtual void attack(Role target) 0; // 纯虚函数 virtual string getType() const 0; // 其他公共接口... }; class Warrior : public Role { public: Warrior(string n, int h, int a, int d, int s) : Role(n, h, a, d, s) {} void attack(Role target) override { int damage attack - target.getDefense(); target.takeDamage(damage); cout name 发动物理攻击造成 damage 点伤害 endl; } string getType() const override { return 武将; } };这样主循环里只需要持有Role*或Role调用attack时会自动根据实际类型调用对应的实现。新增角色时只需要加一个子类主循环代码一行都不用改。这就是多态的核心价值把根据类型做不同事的判断从运行时的手写分支变成了编译时由虚函数表自动完成的分发。3.2 对象切片多态失效的头号杀手多态用起来很爽但有一个坑几乎每个初学者都会踩对象切片object slicing。看这段代码std::vectorRole roles; roles.push_back(Warrior(张飞, 100, 30, 10, 8)); roles.push_back(Strategist(诸葛亮, 80, 15, 5, 9)); for (auto r : roles) { r.attack(target); // 调用的是 Role::attack不是子类的 }问题出在vectorRole上。Role是抽象类按理说不能实例化但push_back时传入的是Warrior对象它会被切片——只保留基类部分子类特有的成员和虚函数表指针全部丢失。结果就是r.attack()调用的是基类的attack如果基类有实现的话或者直接编译报错如果attack是纯虚函数。正确的做法是用指针容器std::vectorstd::unique_ptrRole roles; roles.push_back(std::make_uniqueWarrior(张飞, 100, 30, 10, 8)); roles.push_back(std::make_uniqueStrategist(诸葛亮, 80, 15, 5, 9)); for (auto r : roles) { r-attack(target); // 正确调用子类实现 }用unique_ptr而不是裸指针是为了避免手动delete。如果编译器支持C14make_unique可以直接用如果只支持C11需要自己写一个简单的make_unique或者用shared_ptr代替。这个细节虽然小但能体现你对资源管理的理解实验报告里写清楚会加分。3.3 虚析构函数不写会出事只要一个类有可能被当作基类使用它的析构函数就应该是virtual的。原因很简单如果你用Role*指向一个Warrior对象然后delete这个指针如果析构函数不是虚函数只会调用Role的析构函数Warrior里动态分配的资源就不会被释放。这个实验里如果子类没有动态资源可能看不出问题但养成习惯很重要。而且有些编译器会在你写继承但不写虚析构时给出警告实验报告里被扣分就得不偿失了。class Role { public: virtual ~Role() default; // C11风格简洁明了 // ... };4. 回合制战斗引擎顺序、伤害计算与日志输出4.1 出手顺序的判定逻辑回合制战斗的核心是谁先出手。最简单的做法是按速度排序速度高的先手。但实际写的时候有几个细节要注意第一速度相同怎么办如果两个武将速度都是8谁先出手通常的做法是随机决定或者按队伍顺序。我建议用随机数增加一点不确定性但要在日志里说明速度相同随机决定出手顺序否则玩家会觉得莫名其妙。第二每回合都要重新排序吗如果速度是固定属性那排序一次就够了。但如果某些技能会改变速度比如减速效果那就需要每回合重新计算。这个实验里通常速度不变所以可以在战斗开始时排序一次之后按固定顺序循环。第三死亡角色是否参与排序如果某个武将血量归零它不应该再出手。所以每回合开始时要检查存活状态跳过已死亡的武将。void battle(std::vectorstd::unique_ptrRole team1, std::vectorstd::unique_ptrRole team2) { // 合并所有角色按速度排序 std::vectorRole* allRoles; for (auto r : team1) allRoles.push_back(r.get()); for (auto r : team2) allRoles.push_back(r.get()); std::sort(allRoles.begin(), allRoles.end(), [](Role* a, Role* b) { return a-getSpeed() b-getSpeed(); }); int round 1; while (true) { cout 第 round 回合 endl; for (Role* r : allRoles) { if (!r-isAlive()) continue; // 跳过已死亡角色 // 选择攻击目标对方队伍中存活且血量最低的 Role* target selectTarget(r, team1, team2); if (target nullptr) break; // 对方全灭 r-attack(*target); } if (checkBattleEnd(team1, team2)) break; round; } }这段代码里selectTarget函数负责从对方队伍里选一个存活目标。我通常选择血量最低的策略这样战斗结束得快日志也好看。如果你想更智能可以让谋士优先攻击对方攻击力最高的角色但这会增加代码复杂度实验阶段没必要。4.2 伤害公式的设计与边界处理伤害公式是战斗系统的灵魂。最简单的公式是伤害 攻击力 - 防御力但这个公式有个问题如果防御力大于攻击力伤害就是负数变成给敌人加血了。所以必须加一个下限伤害 max(1, 攻击力 - 防御力)至少造成1点伤害保证战斗一定能结束。如果你想更游戏化一点可以加入随机浮动基础伤害 攻击力 - 防御力 实际伤害 基础伤害 * (0.9 随机浮动 0~0.2)这样每次攻击的伤害略有不同战斗更有悬念。但要注意随机数种子要在程序开始时设置一次不要每次攻击都设置否则随机数会退化成固定值。用std::mt19937配合std::random_device是C11的推荐做法std::random_device rd; std::mt19937 gen(rd()); std::uniform_real_distribution dis(0.9, 1.1); int calculateDamage(int attack, int defense) { int base std::max(1, attack - defense); double factor dis(gen); return static_castint(base * factor); }这里有一个容易被忽略的细节std::random_device在某些平台上可能不稳定比如老版本的MinGW如果发现每次运行随机数一样可以改用时间戳作为种子std::mt19937 gen(static_castunsigned(std::time(nullptr)));虽然时间戳种子的随机性不如random_device但对于课程实验来说完全够用而且兼容性更好。4.3 战斗日志的格式化输出日志输出看起来简单但写得好不好直接影响实验报告的观感。我见过很多同学的日志是这样的张飞攻击诸葛亮造成20点伤害 诸葛亮攻击张飞造成15点伤害能看懂但不够清晰。我建议加上血量变化和回合分隔 第 1 回合 [张飞] 发动物理攻击 - [诸葛亮] 伤害: 20 | 诸葛亮 HP: 80 - 60 [诸葛亮] 发动策略攻击 - [张飞] 伤害: 15 | 张飞 HP: 100 - 85这样一眼就能看出谁打了谁、掉了多少血、还剩多少血。实现上可以用setw控制对齐或者直接用printf风格的格式化。C20有std::format但课程实验通常用C11/14所以还是用iomanip比较稳妥#include iomanip void printAttackLog(const string attacker, const string target, int damage, int oldHp, int newHp) { cout [ attacker ] 发动攻击 - [ target ] endl; cout 伤害: setw(3) damage | target HP: oldHp - newHp endl; }注意setw只对下一个输出有效所以每次都要写。另外中文字符的宽度和英文字符不一样setw按字节数计算可能导致对齐不准。如果实验报告要求不高不用太纠结对齐如果追求完美可以手动计算中文字符数一个中文字符通常占3个字节UTF-8编码下然后补空格。这个细节我在第6节还会提到。5. 容器选型与内存管理vector、unique_ptr与迭代器失效5.1 为什么用vector而不是数组存储武将列表最直接的想法是Role* roles[10]固定大小。但问题是你不知道题目会要求几个武将可能是3个也可能是10个。用固定数组要么浪费空间要么不够用。std::vector是动态数组自动扩容而且提供了丰富的成员函数push_back、erase、size等是这类场景的首选。但vector有一个特性必须注意扩容时会导致所有元素的地址发生变化。如果你在扩容前保存了某个元素的指针或引用扩容后这个指针就失效了。比如std::vectorRole roles; roles.push_back(Warrior(...)); Role ref roles[0]; // 保存引用 roles.push_back(Strategist(...)); // 可能触发扩容 ref.takeDamage(10); // 危险ref可能已经失效这个坑在战斗循环里特别容易踩如果你在遍历vector的同时往里面添加元素比如召唤援军迭代器和引用都可能失效。解决办法是要么提前reserve足够空间要么用索引遍历而不是迭代器要么把添加操作放到遍历结束后。5.2 unique_ptr的所有权转移用unique_ptr管理武将对象核心原则是一个对象同一时间只能被一个unique_ptr拥有。这意味着你不能拷贝unique_ptr只能移动std::unique_ptrRole g1 std::make_uniqueWarrior(张飞, 100, 30, 10, 8); std::unique_ptrRole g2 g1; // 编译错误不能拷贝 std::unique_ptrRole g3 std::move(g1); // 正确所有权转移给g3g1变为nullptr在战斗系统里如果你需要把武将从一个容器转移到另一个容器比如从待选列表移到出战列表必须用std::move。转移后原来的unique_ptr变成空指针不能再使用。这个特性初看很麻烦但它能帮你避免很多悬空指针的问题——编译器会强制你思考这个对象现在归谁管。如果多个地方需要共享同一个武将对象比如战斗日志和战斗引擎都要访问那就不能用unique_ptr应该用shared_ptr。但shared_ptr有引用计数的开销而且容易产生循环引用实验阶段如果不需要共享优先用unique_ptr。5.3 遍历时删除元素的正确姿势战斗结束后你可能需要从容器里移除已死亡的武将。很多同学会这样写for (auto it roles.begin(); it ! roles.end(); it) { if (!(*it)-isAlive()) { roles.erase(it); // 危险erase后it失效it会崩溃 } }erase会返回下一个有效迭代器所以正确的写法是for (auto it roles.begin(); it ! roles.end(); ) { if (!(*it)-isAlive()) { it roles.erase(it); // 用返回值更新it } else { it; } }或者用C20的std::erase_ifstd::erase_if(roles, [](const std::unique_ptrRole r) { return !r-isAlive(); });如果编译器不支持C20可以用移除-删除惯用法roles.erase( std::remove_if(roles.begin(), roles.end(), [](const std::unique_ptrRole r) { return !r-isAlive(); }), roles.end() );这种写法效率更高但可读性稍差。实验报告里用哪种都行关键是要理解为什么不能直接在for循环里erase。6. 从能跑到好用存档、技能与扩展方向6.1 存档读档的简单实现如果题目要求存档功能最简单的方案是把武将数据写入文本文件每行一个武将字段用空格或逗号分隔张飞 100 100 30 10 8 Warrior 诸葛亮 80 80 15 5 9 Strategist读取时按行解析根据最后一个字段决定创建哪个子类。这里有一个设计问题存档里要不要保存运行时状态比如当前血量、buff剩余回合数。如果保存读档后能恢复到存档瞬间的状态如果不保存读档后武将满血复活。通常课程实验只要求保存基础属性但如果你想让程序更完整可以把当前血量也存进去。写文件用std::ofstream读文件用std::ifstream。注意检查文件是否成功打开std::ofstream out(save.txt); if (!out) { cerr 存档失败无法打开文件 endl; return; }读档时要注意处理文件格式错误比如某行字段数量不对、数字解析失败等。可以用std::istringstream逐行解析解析失败就跳过该行并输出警告。6.2 技能系统的设计思路技能系统是区分武将和谋士的关键。一个简单的技能可以这样设计class Skill { string name; int damageMultiplier; // 伤害倍率比如150表示1.5倍 int cooldown; // 冷却回合数 int currentCooldown; public: bool isReady() const { return currentCooldown 0; } void use() { currentCooldown cooldown; } void tick() { if (currentCooldown 0) currentCooldown--; } // ... };每个角色持有一个技能列表回合开始时调用tick()减少冷却攻击时可以选择使用技能如果就绪。这样谋士的火计可以设计成1.5倍伤害、冷却2回合武将的猛击可以设计成2倍伤害、冷却3回合。技能系统让战斗更有策略性但也会增加代码量建议在基础战斗跑通之后再添加。6.3 那些实验报告里不会写但实际很重要的细节最后分享几个我在实际编码中总结的小经验这些内容通常不会出现在实验指导书里但能让你的程序更健壮第一输入校验。如果程序需要玩家输入选择比如选择攻击目标1.张飞 2.诸葛亮一定要检查输入是否合法。用户可能输入0、负数、字母或者超出范围的数字。用cin读取后检查cin.fail()如果失败要清除错误状态并忽略缓冲区int choice; while (!(cin choice) || choice 1 || choice maxChoice) { cin.clear(); cin.ignore(numeric_limitsstreamsize::max(), \n); cout 输入无效请重新选择; }第二中文字符串的处理。在Windows控制台里默认编码可能是GBK而源代码文件可能是UTF-8直接输出中文可能乱码。解决办法有两种一是把源文件保存为GBK编码不推荐跨平台差二是在程序开头设置控制台编码#ifdef _WIN32 system(chcp 65001 nul); // 切换到UTF-8代码页 #endif但system调用不够优雅而且有些环境不允许。更稳妥的做法是尽量用英文输出或者用宽字符wcout。如果实验报告要求中文界面建议在报告里说明编码设置避免演示时乱码。第三随机数的可复现性。调试战斗逻辑时如果每次随机结果都不一样很难定位问题。可以在调试版本里用固定种子#ifdef DEBUG std::mt19937 gen(42); // 固定种子每次运行结果一样 #else std::mt19937 gen(std::random_device{}()); #endif这样调试时战斗过程可复现发布时又是真随机。这个技巧在游戏开发里很常见实验报告里提一句会显得很专业。第四日志输出到文件。如果战斗回合很多控制台输出会刷屏看不清前面的内容。可以把日志同时输出到文件std::ofstream logFile(battle_log.txt); // 封装一个log函数同时输出到cout和logFile这样战斗结束后可以慢慢分析日志也方便截图放进实验报告。第五代码注释要写为什么而不是是什么。比如hp - damage;上面不要写扣除血量而要写伤害已经过takeDamage处理此处直接扣减。注释应该解释代码的意图和背景而不是重复代码的字面意思。这个习惯在实验报告评分里往往是隐性加分项。7. 调试战斗逻辑时我踩过的那些坑7.1 死亡角色仍然出手这是我第一次写回合制战斗时遇到的bug张飞血量归零后下一回合居然还能攻击。排查后发现我在回合开始时检查了存活状态但攻击循环里没有再次检查。因为一个回合内前面的角色可能把后面的角色打死了但后面的角色仍然会执行攻击。解决办法是在每次攻击前都检查for (Role* r : allRoles) { if (!r-isAlive()) continue; // 每次都要检查 // ... }这个bug的教训是状态检查要放在使用状态之前而不是只在循环开始时检查一次。7.2 伤害计算顺序导致的死后反击另一个经典bugA攻击BB血量归零但B的attack函数里先计算伤害再检查自己是否存活结果B在死亡状态下打出了伤害。正确的逻辑应该是攻击者先检查自己是否存活再检查目标是否存活然后才计算伤害。如果目标已经死亡攻击应该无效。void attack(Role target) { if (!isAlive()) return; // 自己死了不能攻击 if (!target.isAlive()) return; // 目标死了不用攻击 // 计算伤害... }这个顺序看起来理所当然但在多态调用时很容易忽略因为基类的attack可能被子类重写子类里忘了检查。7.3 迭代器失效导致的随机崩溃前面提到过vector扩容导致迭代器失效我在实际项目中遇到过更隐蔽的情况在范围for循环里调用了一个可能修改容器的函数。比如for (auto r : roles) { r-attack(target); // attack内部可能触发容器修改 }如果attack函数里不小心往roles里添加了元素比如击杀后召唤援军这个范围for就会崩溃。解决办法是要么确保attack不修改容器要么用索引遍历for (size_t i 0; i roles.size(); i) { if (i roles.size()) break; // 防御性检查 roles[i]-attack(target); }索引遍历虽然不如范围for优雅但在容器可能变化的场景下更安全。7.4 多态调用时的参数类型陷阱最后一个坑比较隐蔽基类指针调用虚函数时如果参数类型不匹配可能不会调用你期望的重载。比如基类有attack(Role)子类想重写为attack(Warrior)这不是重写而是隐藏。因为参数类型不同虚函数机制不生效。正确的做法是保持参数类型一致或者用override关键字让编译器帮你检查class Warrior : public Role { public: void attack(Role target) override; // 参数必须是Role };加上override后如果签名不匹配编译器会直接报错而不是默默隐藏。这个关键字是C11引入的强烈建议在所有重写虚函数的地方都加上。8. 写在最后这个实验真正教会我的事回过头看三国游戏这个实验的价值远不止用C写一个能跑的程序。它逼着你面对一个真实项目才会遇到的问题类之间怎么协作、资源怎么管理、状态怎么同步、边界怎么处理。这些东西在课本的独立例题里是学不到的只有当你把十几个类、几百行代码拼在一起发现它们互相打架的时候才会真正理解封装、继承、多态这些概念为什么存在。我个人的体会是先把最简单的版本跑通再逐步加功能。不要一上来就设计一个包含技能、装备、存档、AI的完整系统那样大概率写到一半就卡住了。先让两个武将能互相攻击、能分出胜负然后再加第三个角色、加技能、加存档。每加一个功能跑一次测试确保之前的逻辑没被破坏。这种增量开发的习惯比任何设计模式都重要。如果你正在做这个实验遇到卡住的地方不妨回到最基础的问题这个类的职责是什么这个函数应该由谁调用这个状态在什么时候改变把这些问题想清楚代码自然就顺了。
阅读完成 · 觉得有帮助?
咨询建站