1. 从一道实验题说起:为什么"三国游戏"是C++教学的经典载体
第一次看到"C++程序设计实验之三国游戏"这个题目,很多人脑子里冒出来的画面大概是:一个黑框控制台,几行文字,玩家输入数字选择武将,然后互相砍一刀,谁血少谁输。如果你也这么想,那说明你还没真正拆过这类实验题的设计意图。我带过几届学生的课程设计,也帮不少朋友看过他们的实验报告,可以很负责任地说:三国游戏这个题材之所以被反复使用,不是因为它好玩,而是因为它天然覆盖了C++面向对象编程的几乎所有核心知识点,而且这些知识点之间的耦合关系非常自然,不需要生硬地"为了用而用"。
先把这个实验的定位说清楚。它通常出现在大一下学期或大二上学期,学生刚学完类与对象、继承、多态、运算符重载、STL容器这些内容,需要一个综合性实验把这些零散的知识点串起来。三国题材的好处在于:武将有属性(攻击、防御、血量、速度),有行为(攻击、防御、使用技能),有分类(武将、谋士、君主),有交互(对战、组队、回合制),这些概念和面向对象里的类、继承、多态、封装几乎是一一对应的。你不需要绞尽脑汁去"设计一个场景来用多态",因为武将和谋士本来就应该有不同的攻击方式,多态是自然而然的需求。
这个实验最终交付的东西,通常是一个控制台程序,能实现武将的创建、属性展示、回合制对战、战斗日志输出,有些要求高一点的还会加上存档读档、技能系统、装备系统。听起来不复杂,但真正动手写的时候,你会发现坑一个接一个:血量扣减的时机、攻击顺序的判定、多态调用时的对象切片问题、随机数种子的设置、容器遍历时删除元素导致的迭代器失效……这些问题在课本例题里很少同时出现,但在一个完整的游戏逻辑里会集中爆发。
我见过太多同学,类定义写得漂漂亮亮,一到主循环就乱套,要么是伤害计算顺序错了导致武将"死后还能反击",要么是用了vector存对象却忘了用指针导致多态失效。所以这篇文章不打算给你一份"标准答案",而是想从实际开发的角度,把这类实验里最容易出问题的地方一个个拆开讲清楚,让你不仅能交作业,还能真正理解为什么这么写。
提示:本文所有代码示例基于C++11及以上标准,使用标准库,不依赖任何第三方框架。如果你用的是老版本编译器,部分语法需要调整,我会在相应位置说明。
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::sort:
std::vector<General> 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::vector<Role> 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,不是子类的! }问题出在vector<Role>上。Role是抽象类,按理说不能实例化,但push_back时传入的是Warrior对象,它会被"切片"——只保留基类部分,子类特有的成员和虚函数表指针全部丢失。结果就是r.attack()调用的是基类的attack(如果基类有实现的话),或者直接编译报错(如果attack是纯虚函数)。
正确的做法是用指针容器:
std::vector<std::unique_ptr<Role>> roles; roles.push_back(std::make_unique<Warrior>("张飞", 100, 30, 10, 8)); roles.push_back(std::make_unique<Strategist>("诸葛亮", 80, 15, 5, 9)); for (auto& r : roles) { r->attack(target); // 正确调用子类实现 }用unique_ptr而不是裸指针,是为了避免手动delete。如果编译器支持C++14,make_unique可以直接用;如果只支持C++11,需要自己写一个简单的make_unique或者用shared_ptr代替。这个细节虽然小,但能体现你对资源管理的理解,实验报告里写清楚会加分。
3.3 虚析构函数:不写会出事
只要一个类有可能被当作基类使用,它的析构函数就应该是virtual的。原因很简单:如果你用Role*指向一个Warrior对象,然后delete这个指针,如果析构函数不是虚函数,只会调用Role的析构函数,Warrior里动态分配的资源就不会被释放。这个实验里如果子类没有动态资源,可能看不出问题,但养成习惯很重要。而且有些编译器会在你写继承但不写虚析构时给出警告,实验报告里被扣分就得不偿失了。
class Role { public: virtual ~Role() = default; // C++11风格,简洁明了 // ... };4. 回合制战斗引擎:顺序、伤害计算与日志输出
4.1 出手顺序的判定逻辑
回合制战斗的核心是"谁先出手"。最简单的做法是按速度排序,速度高的先手。但实际写的时候有几个细节要注意:
第一,速度相同怎么办?如果两个武将速度都是8,谁先出手?通常的做法是随机决定,或者按队伍顺序。我建议用随机数,增加一点不确定性,但要在日志里说明"速度相同,随机决定出手顺序",否则玩家会觉得莫名其妙。
第二,每回合都要重新排序吗?如果速度是固定属性,那排序一次就够了。但如果某些技能会改变速度(比如"减速"效果),那就需要每回合重新计算。这个实验里通常速度不变,所以可以在战斗开始时排序一次,之后按固定顺序循环。
第三,死亡角色是否参与排序?如果某个武将血量归零,它不应该再出手。所以每回合开始时要检查存活状态,跳过已死亡的武将。
void battle(std::vector<std::unique_ptr<Role>>& team1, std::vector<std::unique_ptr<Role>>& team2) { // 合并所有角色,按速度排序 std::vector<Role*> 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是C++11的推荐做法:
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_cast<int>(base * factor); }这里有一个容易被忽略的细节:std::random_device在某些平台上可能不稳定(比如老版本的MinGW),如果发现每次运行随机数一样,可以改用时间戳作为种子:
std::mt19937 gen(static_cast<unsigned>(std::time(nullptr)));虽然时间戳种子的随机性不如random_device,但对于课程实验来说完全够用,而且兼容性更好。
4.3 战斗日志的格式化输出
日志输出看起来简单,但写得好不好直接影响实验报告的观感。我见过很多同学的日志是这样的:
张飞攻击诸葛亮,造成20点伤害 诸葛亮攻击张飞,造成15点伤害能看懂,但不够清晰。我建议加上血量变化和回合分隔:
===== 第 1 回合 ===== [张飞] 发动物理攻击 -> [诸葛亮] 伤害: 20 | 诸葛亮 HP: 80 -> 60 [诸葛亮] 发动策略攻击 -> [张飞] 伤害: 15 | 张飞 HP: 100 -> 85这样一眼就能看出谁打了谁、掉了多少血、还剩多少血。实现上可以用setw控制对齐,或者直接用printf风格的格式化。C++20有std::format,但课程实验通常用C++11/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::vector<Role> 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_ptr<Role> g1 = std::make_unique<Warrior>("张飞", 100, 30, 10, 8); std::unique_ptr<Role> g2 = g1; // 编译错误!不能拷贝 std::unique_ptr<Role> g3 = std::move(g1); // 正确,所有权转移给g3,g1变为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; } }或者用C++20的std::erase_if:
std::erase_if(roles, [](const std::unique_ptr<Role>& r) { return !r->isAlive(); });如果编译器不支持C++20,可以用"移除-删除"惯用法:
roles.erase( std::remove_if(roles.begin(), roles.end(), [](const std::unique_ptr<Role>& 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_limits<streamsize>::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 伤害计算顺序导致的"死后反击"
另一个经典bug:A攻击B,B血量归零,但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后,如果签名不匹配,编译器会直接报错,而不是默默隐藏。这个关键字是C++11引入的,强烈建议在所有重写虚函数的地方都加上。
8. 写在最后:这个实验真正教会我的事
回过头看,三国游戏这个实验的价值远不止"用C++写一个能跑的程序"。它逼着你面对一个真实项目才会遇到的问题:类之间怎么协作、资源怎么管理、状态怎么同步、边界怎么处理。这些东西在课本的独立例题里是学不到的,只有当你把十几个类、几百行代码拼在一起,发现它们互相打架的时候,才会真正理解封装、继承、多态这些概念为什么存在。
我个人的体会是:先把最简单的版本跑通,再逐步加功能。不要一上来就设计一个包含技能、装备、存档、AI的完整系统,那样大概率写到一半就卡住了。先让两个武将能互相攻击、能分出胜负,然后再加第三个角色、加技能、加存档。每加一个功能,跑一次测试,确保之前的逻辑没被破坏。这种增量开发的习惯,比任何设计模式都重要。
如果你正在做这个实验,遇到卡住的地方,不妨回到最基础的问题:这个类的职责是什么?这个函数应该由谁调用?这个状态在什么时候改变?把这些问题想清楚,代码自然就顺了。