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

资讯详情

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

C++技能管理器项目解析:从类设计到编码实践

C++技能管理器项目解析:从类设计到编码实践 简介这是一份面向C游戏开发初学者与中级程序员的技能系统核心模块实现资源聚焦Windows平台下基于面向对象设计的技能管理功能封装。资源解决了角色技能创建、命中判定与统一调度等常见游戏逻辑难点适用于RPG、ARPG或动作类游戏的技能框架搭建。压缩包共6个文件3个头文件.h定义接口3个源文件.cpp实现逻辑涵盖CSkill单技能实体、CSkillHitBox技能碰撞体及CSkillManager技能生命周期与调用中枢三大核心类结构清晰、职责分明便于理解继承关系与消息分发机制。包体仅22KB轻量易集成代码注释规范含完整类声明与成员函数实现可直接嵌入现有MFC或Win32项目中扩展使用。目前已有75人学习下载适合希望掌握游戏技能系统底层设计思想、快速复用高内聚模块的学习者。1. 项目缘起一个“古老”的C技能管理器最近在整理一个老旧的硬盘时我翻出了一个尘封已久的压缩包名字叫SkillManager.zip。双击解压里面是一个典型的Visual Studio 6.0时代的C项目文件夹结构。看到那些.dsp、.dsw后缀的文件以及用匈牙利命名法写的.cpp和.h文件一股怀旧感扑面而来。这个项目是一个在Windows平台上用原生C没有MFC没有ATL写的“技能管理器”雏形。它的核心就是围绕class类这个概念展开的简单数据管理。在如今动辄微服务、云原生的时代这样一个控制台小工具显得格外“朴素”。但正是这种朴素让它成为了理解面向对象编程OOP基础、C核心特性以及如何在Windows环境下进行简单工程管理的绝佳样本。我决定把它捡起来不是为了让它重焕新生去做一个复杂的系统而是想通过解剖这个“麻雀”来聊聊我们当年是怎么写C的那些关于类设计、内存管理和平台适配的思考对今天还有没有价值。我相信无论是正在学习C基础的学生还是习惯了现代IDE和框架、想回头看看“底层”样子的开发者都能从这个项目中获得一些实在的启发。它涉及了C类的封装与继承、标准模板库STL的早期使用、Windows控制台下的输入输出与简单文件操作。下面我就带你一起打开这个时间胶囊看看里面都有些什么。2. 项目解构SkillManager的核心类设计解压后的项目主体结构非常清晰。主要包含以下几个文件Skill.h/Skill.cpp: 定义基础的Skill类。SkillManager.h/SkillManager.cpp: 定义管理Skill对象的SkillManager类。Main.cpp: 程序入口提供简单的控制台菜单。可能还有一个Data.txt用于持久化存储技能数据。我们先从最核心的Skill类看起。这几乎是所有初学者在学会结构体后第一个尝试用类来重写的例子。2.1 Skill类的封装数据与行为的结合当时的Skill类大概长下面这个样子我根据记忆和常见模式进行了重构和丰富但保留了原始的设计思想// Skill.h #ifndef SKILL_H #define SKILL_H #include string class Skill { private: int m_id; // 技能ID匈牙利命名法m_ 表示成员变量 std::string m_name; std::string m_description; int m_level; // 技能等级 int m_mpCost; // 魔法消耗 double m_cooldown; // 冷却时间秒 public: // 构造函数 Skill(int id 0, const std::string name , const std::string desc , int level 1, int mpCost 0, double cooldown 0.0); // 析构函数当时可能没显式定义但这里为讲解补上 ~Skill(); // Getter 和 Setter 方法 int getId() const { return m_id; } void setId(int id) { m_id id; } std::string getName() const { return m_name; } void setName(const std::string name) { m_name name; } std::string getDescription() const { return m_description; } void setDescription(const std::string desc) { m_description desc; } int getLevel() const { return m_level; } void setLevel(int level) { m_level level; } int getMpCost() const { return m_mpCost; } void setMpCost(int cost) { m_mpCost cost; } double getCooldown() const { return m_cooldown; } void setCooldown(double cd) { m_cooldown cd; } // 成员函数展示技能信息 void display() const; // 成员函数判断技能是否可用基于等级和魔法值 bool isUsable(int playerMp) const; // 静态成员函数获取一个默认技能 static Skill getDefaultSkill(); }; #endif // SKILL_H为什么这么设计私有数据成员private这是封装的核心。将技能的所有属性id, name等设为私有防止外部代码随意修改保证了对象状态的完整性和安全性。比如你可以通过setLevel方法添加等级不能为负数的校验。公有成员函数public提供访问和操作私有数据的接口。Getter通常返回const引用或值Setter可以进行有效性检查。display和isUsable这类函数体现了“行为与数据绑定”的OOP思想。构造函数与初始化列表在Skill.cpp中构造函数应该使用初始化列表来初始化成员这比在构造函数体内赋值更高效对于非POD类型如std::string。// Skill.cpp Skill::Skill(int id, const std::string name, const std::string desc, int level, int mpCost, double cooldown) : m_id(id), m_name(name), m_description(desc), m_level(level), m_mpCost(mpCost), m_cooldown(cooldown) { // 构造函数体可以进行更复杂的初始化或校验 if (m_level 0) m_level 1; }const成员函数像getId(),display(),isUsable()这样的函数被声明为const表示它们不会修改对象的状态。这既是良好的设计习惯也允许在const Skill类型的对象上调用这些函数。静态成员函数getDefaultSkill()是一个静态函数它不属于任何一个对象实例而是属于类本身。常用于创建工厂方法或返回全局常量。踩坑心得关于const的正确性早期写代码很容易忽略const。如果一个成员函数逻辑上不应该修改对象一定要加上const。这不仅是一种承诺还能让你的类在更多场景下被使用例如被const容器引用时。忘记加const会导致编译错误迫使你思考函数的设计意图。2.2 SkillManager类的职责集合管理与持久化SkillManager类负责管理多个Skill对象。在STL普及的今天我们很自然地会用std::vectorSkill。但在更早的时候可能会用原生数组或者自己实现的链表。这个项目大概率使用了std::vector因为它已经是C98标准的一部分了。// SkillManager.h #ifndef SKILLMANAGER_H #define SKILLMANAGER_H #include Skill.h #include vector #include string class SkillManager { private: std::vectorSkill m_skills; // 技能集合 std::string m_dataFile; // 数据文件路径 // 内部辅助函数查找技能索引 int findSkillIndexById(int id) const; public: SkillManager(const std::string filename skills.dat); // 增删改查 bool addSkill(const Skill skill); bool deleteSkillById(int id); bool updateSkill(const Skill skill); Skill* getSkillById(int id); // 返回指针允许修改找到的对象 const std::vectorSkill getAllSkills() const { return m_skills; } // 持久化 bool loadFromFile(); bool saveToFile() const; // 工具函数 void displayAllSkills() const; int getNextAvailableId() const; }; #endif // SKILLMANAGER_H设计解析与潜在陷阱集合存储的选择使用std::vectorSkill而非std::vectorSkill*。这意味着存储的是对象的副本值语义。好处是内存管理简单vector自己负责对象生命周期清晰。缺点是如果Skill对象很大拷贝开销会比较高且对集合内对象的修改需要先找到它然后通过getSkillById返回的指针来修改或者调用updateSkill替换整个对象。如果使用指针则需要手动管理内存new/delete复杂且易出错除非使用智能指针当时C98还没有std::unique_ptr。查找函数的实现findSkillIndexById通常是一个简单的线性搜索 (for循环)。对于小型数据集没问题但如果技能数量上百效率就是问题。这是早期设计时很少考虑性能优化的一个典型例子。在实际项目中如果ID是连续的或需要快速查找可能会引入std::mapint, Skill*作为索引。int SkillManager::findSkillIndexById(int id) const { for (size_t i 0; i m_skills.size(); i) { if (m_skills[i].getId() id) { return static_castint(i); } } return -1; // 未找到 }文件持久化的格式skills.dat很可能用的是二进制格式直接读写vector的内存布局或者是一种简单的文本格式如每行一个技能属性用逗号分隔。二进制格式有严重隐患它直接依赖于Skill类的内存布局。如果Skill类增加了新的成员变量或者std::string的内部实现变了老文件就读不出来了导致数据兼容性问题。更健壮的做法是定义一种稳定的文本格式如JSON、XML或自定义的简单文本格式或使用序列化库。返回const引用getAllSkills返回一个const引用避免了不必要的整个向量拷贝同时防止调用者意外修改内部数据。这是提高效率的常用技巧。实操经验数据持久化的坑我后来在这个项目上吃过大亏。最初为了省事用了二进制保存。当我想给Skill加一个int m_range;成员时所有旧的存档文件全部报废。教训是对于需要长期保存或可能变更结构的数据永远不要使用简单的二进制内存转储。即使是写一个简单的文本格式每行如id|name|desc|level|mp|cd用std::getline和字符串分割来读写其鲁棒性和可维护性也要好得多。这也是为什么后来很多游戏数据都用XML或Lua脚本配置。3. Windows控制台下的交互与“乱码”挑战Main.cpp是这个管理器的用户界面——一个基于文本菜单的控制台程序。代码结构通常是下面这个循环// Main.cpp (简化版) #include SkillManager.h #include iostream #include limits // 用于清除输入缓冲区 void clearInputBuffer() { std::cin.clear(); // 清除错误状态 std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); // 忽略缓冲区剩余字符直到换行 } int main() { SkillManager manager; if (!manager.loadFromFile()) { std::cout 未能加载技能数据将使用空列表开始。 std::endl; } int choice 0; do { std::cout \n 技能管理器 \n; std::cout 1. 显示所有技能\n; std::cout 2. 添加新技能\n; std::cout 3. 查找技能\n; std::cout 4. 更新技能\n; std::cout 5. 删除技能\n; std::cout 6. 保存并退出\n; std::cout 7. 退出不保存\n; std::cout 请选择操作: ; std::cin choice; clearInputBuffer(); // 关键清除数字后的换行符 switch (choice) { case 1: manager.displayAllSkills(); break; case 2: { /* 获取输入创建Skill对象调用manager.addSkill() */ } break; case 3: { /* 输入ID调用manager.getSkillById()并显示 */ } break; // ... 其他case case 6: if (manager.saveToFile()) { std::cout 保存成功\n; } return 0; case 7: std::cout 退出。\n; return 0; default: std::cout 无效选择\n; } } while (choice ! 6 choice ! 7); return 0; }这个简单的交互里藏着两个Windows控制台下的经典问题。3.1 输入缓冲区的清理注意clearInputBuffer()函数。当使用std::cin choice读取一个整数时用户输入“5”然后按回车。cin读取了数字“5”但回车键产生的换行符\n留在了输入缓冲区。如果下一个操作是std::getline(std::cin, name)来读取技能名getline会立刻读到那个等待的\n得到一个空字符串导致程序看起来“跳过”了输入。这就是为什么混合使用和getline时必须小心。clearInputBuffer函数是解决这个问题的标准做法先clear()确保流状态正常然后ignore掉缓冲区中直到换行符的所有字符。3.2 中文乱码的“幽灵”这是Windows C控制台程序的老大难问题。如果你的技能名或描述里包含中文在控制台输出时很可能变成一堆乱码比如“浣犲ソ”代替了“你好”。根因在于编码不匹配。源代码文件编码你的.cpp文件可能以UTF-8无BOM保存这是现代编辑器的默认设置。执行环境编码Windows中文版控制台默认使用GBK代码页936编码来显示文本。C字符串字面量std::string name 火球术;这里的“火球术”在编译时编译器会按照源代码文件的编码将其转换为一系列字节存储在可执行文件中。如果源文件是UTF-8那么“火球术”在内存里就是UTF-8编码的字节序列例如E7 81 AB E7 90 83 E6 9C AF。当程序运行时std::cout将这些字节原样发送到控制台。而控制台期待的是GBK编码它把E7 81 AB解释为GBK字符于是就显示成了乱码。解决方案有几条路径方案一源代码保存为GBK不推荐。将你的.cpp和.h文件用记事本另存为ANSI编码在中文Windows下就是GBK。这样字符串字面量在编译后就是GBK字节cout输出正常。但这种方法与跨平台和现代开发环境如UTF-8作为标准背道而驰。方案二运行时转换编码推荐。这是更通用的方法。使用Windows APIMultiByteToWideChar和WideCharToMultiByte或者C11的codecvt库已弃用但可用或第三方库如iconv将内部存储的UTF-8字符串在输出前转换为控制台使用的编码通常是GBK。#include windows.h std::string utf8ToGbk(const std::string str) { // ... 使用MultiByteToWideChar和WideCharToMultiByte进行转换 // 这是一个简化示例实际代码需要处理错误和缓冲区大小 int wlen MultiByteToWideChar(CP_UTF8, 0, str.c_str(), -1, nullptr, 0); std::wstring wstr(wlen, 0); MultiByteToWideChar(CP_UTF8, 0, str.c_str(), -1, wstr[0], wlen); int len WideCharToMultiByte(CP_ACP, 0, wstr.c_str(), -1, nullptr, 0, nullptr, nullptr); std::string gbkStr(len, 0); WideCharToMultiByte(CP_ACP, 0, wstr.c_str(), -1, gbkStr[0], len, nullptr, nullptr); return gbkStr; } // 使用时 std::cout utf8ToGbk(skill.getName()) std::endl;方案三使用宽字符wchar_t和std::wcout。将字符串定义为宽字符字面量L火球术使用std::wstring和std::wcout。但这也要求控制台支持宽字符输出且文件读写时也需要处理编码转换复杂度不低。方案四设置控制台代码页治标。在程序启动时可以尝试设置控制台代码页为UTF-8#include windows.h SetConsoleOutputCP(CP_UTF8); // 设置控制台输出代码页为UTF-8 SetConsoleCP(CP_UTF8); // 设置控制台输入代码页为UTF-8但这个方法不一定100%有效它依赖于控制台字体是否包含所需的UTF-8字符以及控制台本身对UTF-8的支持程度旧版本Windows控制台支持很差。在Windows 10后期版本和Windows 11中配合支持TrueType字体的新终端如Windows Terminal此方法效果较好。避坑指南编码问题一劳永逸的思考对于新的个人项目我强烈建议方案二运行时转换或直接拥抱方案四并升级到现代终端。同时将源代码统一保存为UTF-8 with BOM对于Visual Studio编译器这有助于它正确识别编码。对于文件存储也明确使用UTF-8。这样你的程序内部逻辑是清晰的UTF-8世界只在需要与特定环境如旧控制台交互的边界进行转换。永远不要在程序内部混用多种编码。4. 从“能用”到“好用”可扩展性与设计模式初探最初的SkillManager只是一个教学演示级别的代码。如果我们想把它变得稍微“工业”一点或者为学习设计模式提供一个切入点可以从以下几个方向思考。4.1 使用智能指针管理资源如果SkillManager需要管理的是Skill*指针而非对象本身例如技能对象可能很大或者需要多态那么内存管理就是个大问题。在C11之前我们得小心翼翼地在析构函数里写for循环delete。现在我们可以用std::unique_ptr或std::shared_ptr。#include memory #include vector class SkillManager { private: std::vectorstd::unique_ptrSkill m_skills; // ... 其他成员 public: bool addSkill(std::unique_ptrSkill skill) { m_skills.push_back(std::move(skill)); return true; } // 删除技能时vector.erase会自动调用unique_ptr的析构函数释放Skill对象内存。 // 无需手动delete避免了内存泄漏。 };std::unique_ptr明确了所有权的独占性SkillManager拥有这些技能对象。当m_skills被清空或SkillManager析构时所有技能对象会自动被删除。这比原始指针安全得多。4.2 引入继承与多态不同类型的技能假设我们不再只有一种Skill而是有AttackSkill攻击技能、BuffSkill增益技能、HealSkill治疗技能。它们有共同的接口如use()但行为不同。这就是多态的用武之地。// Skill基类定义接口 class Skill { public: virtual ~Skill() {} // 虚析构函数确保派生类对象被正确释放 virtual void use(Character caster, Character target) 0; // 纯虚函数 virtual std::string getType() const 0; // ... 其他公共属性和方法 }; class AttackSkill : public Skill { private: int m_damage; public: void use(Character caster, Character target) override { int finalDamage m_damage caster.getAttack() - target.getDefense(); target.takeDamage(finalDamage); std::cout caster.getName() 对 target.getName() 使用了 getName() , 造成 finalDamage 点伤害\n; } std::string getType() const override { return Attack; } }; class HealSkill : public Skill { private: int m_healAmount; public: void use(Character caster, Character target) override { target.heal(m_healAmount); std::cout caster.getName() 对 target.getName() 使用了 getName() , 治疗了 m_healAmount 点生命值。\n; } std::string getType() const override { return Heal; } };此时SkillManager存储的可以是std::vectorstd::unique_ptrSkill。通过基类指针调用use()方法会根据实际对象类型执行不同的代码。这就是运行时多态。文件持久化面临新挑战如何保存和加载不同的派生类一个常见的模式是“类型标签”。在保存时除了基类数据还要保存一个type字段如“Attack”、“Heal”。加载时根据type字段创建对应的派生类对象再恢复其数据。这可能需要一个工厂类SkillFactory::createSkill(const std::string type)。4.3 应用观察者模式实现技能冷却通知这是一个稍微进阶的想法。技能有冷却时间我们希望在冷却结束时通知玩家。可以用观察者模式Observer Pattern来实现一个简单的事件系统。// 观察者接口 class CooldownObserver { public: virtual ~CooldownObserver() default; virtual void onCooldownEnd(int skillId) 0; }; // 被观察的技能对象简化 class SkillWithCooldown : public Skill { private: double m_remainingCd; std::vectorCooldownObserver* m_observers; public: void update(float deltaTime) { // 每帧调用 if (m_remainingCd 0) { m_remainingCd - deltaTime; if (m_remainingCd 0) { notifyCooldownEnd(); } } } void addObserver(CooldownObserver* obs) { m_observers.push_back(obs); } private: void notifyCooldownEnd() { for (auto obs : m_observers) { obs-onCooldownEnd(getId()); } } }; // 具体的观察者控制台通知器 class ConsoleNotifier : public CooldownObserver { public: void onCooldownEnd(int skillId) override { std::cout [系统] 技能ID: skillId 冷却完毕\n; } };这样SkillManager或者游戏主循环可以持有ConsoleNotifier实例并将其注册到各个技能上。当技能冷却结束时通知器会自动收到回调。这解耦了技能逻辑和UI通知逻辑。设计感悟不要过度设计对于SkillManager.zip这样的小项目上述的继承、多态、观察者模式可能都显得“杀鸡用牛刀”。一个重要的原则是在需求明确之前保持简单。最初的扁平化Skill类和基于vector的SkillManager对于管理同质化的技能数据是完全足够的。只有当需求确实出现“我需要有完全不同行为的技能类型”、“我需要冷却结束的视觉反馈”时才引入更复杂的设计模式。过早抽象是复杂性的根源。这个老项目最可贵的地方正是它的简单和直接它完美地完成了它被创造出来的目的教学和演示基础概念。5. 与现代开发环境的接轨从VC6到VSCode/CMake原始的SkillManager大概率是一个Visual Studio 6.0的工程文件.dsp,.dsw。今天我们更可能使用VSCode CMake MinGW/GCC 或 Visual Studio 2022 的现代CMake项目来编译它。5.1 迁移到CMake创建一个CMakeLists.txt文件是让这个老项目在现代编译器中复活的关键。# CMakeLists.txt cmake_minimum_required(VERSION 3.15) project(SkillManager LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) # 使用C11标准以便使用智能指针等 set(CMAKE_CXX_STANDARD_REQUIRED ON) # 如果目标是Windows并且需要处理Windows API如解决乱码 if(WIN32) add_definitions(-D_WIN32_WINNT0x0A00) # 可定义Windows目标版本 # 你可以选择链接特定的库但本例中MultiByteToWideChar在kernel32.lib通常自动链接 endif() # 将源代码文件添加到一个变量中 set(SKILL_SRCS src/Main.cpp src/Skill.cpp src/SkillManager.cpp ) # 创建可执行文件 add_executable(SkillManager ${SKILL_SRCS}) # 包含头文件目录 target_include_directories(SkillManager PRIVATE include) # 在Windows下如果要使用宽字符或控制台API可能需要链接额外的库但通常不需要显式链接 # target_link_libraries(SkillManager PRIVATE kernel32 user32 ...)将.h文件放入include/目录.cpp文件放入src/目录然后在项目根目录执行mkdir build cd build cmake .. -G MinGW Makefiles # 或 Visual Studio 17 2022 等 cmake --build .这样就能生成可执行文件。CMake管理了依赖和编译过程使得项目可以在Linux、macOS上编译也可以在Windows上使用不同的编译器MSVC, MinGW, Clang编译。5.2 在VSCode中配置C环境在VSCode中打开项目文件夹安装C/C扩展ms-vscode.cpptools。CMake Tools扩展ms-vscode.cmake-tools可以让你在VSCode内直接配置、构建和调试CMake项目非常方便。关键的配置文件是.vscode/c_cpp_properties.json它告诉VSCode的IntelliSense在哪里找头文件使用什么编译器标准。{ configurations: [ { name: Win32, includePath: [ ${workspaceFolder}/include, ${workspaceFolder}/** ], defines: [], compilerPath: C:/mingw64/bin/g.exe, // 或你的MSVC cl.exe路径 cStandard: c17, cppStandard: c17, intelliSenseMode: windows-gcc-x64 // 根据编译器选择 } ], version: 4 }5.3 处理跨平台差异原项目是Windows专用的从文件名和热词“windows”看出。如果要考虑跨平台比如在Linux下编译需要注意Windows API调用如SetConsoleOutputCP,MultiByteToWideChar等是Windows独有的。需要用预处理器指令#ifdef _WIN32来隔离这些代码。#ifdef _WIN32 #include windows.h std::string utf8ToGbk(const std::string str) { /* Windows实现 */ } #else // Linux/macOS下控制台通常直接支持UTF-8可能不需要转换或使用iconv std::string utf8ToGbk(const std::string str) { return str; } // 简单返回 #endif文件路径分隔符Windows用\Linux/macOS用/。在代码中尽量使用C17的std::filesystem::path来处理路径它是跨平台的。#include filesystem namespace fs std::filesystem; fs::path dataPath data/skills.dat; std::ifstream file(dataPath); // path对象会自动处理平台差异行尾符文本文件在Windows上是\r\n在Linux上是\n。使用std::getline读取文本行时它会自动处理所以通常不是问题。但如果你自己逐字符解析就要注意。回看SkillManager.zip这个项目它像一颗时间胶囊封存了特定时期C开发的典型样貌对面向对象基础的实践、对Windows平台特性的直接接触、以及相对简单的工程管理。通过拆解它我们不仅复习了C类、封装、文件IO、字符串处理等核心知识更串联起了从问题产生如乱码到解决方案编码转换的完整思考链路以及如何用现代工具CMake, VSCode和理念智能指针、设计模式去审视和改造旧代码。对于学习者而言模仿并扩展这样一个项目比如为其添加图形界面用Qt或简单的Win32 API、网络功能保存到服务器、或者更复杂的技能效果系统是极好的练习。它足够小便于理解也足够有扩展空间可以承载你的各种编程想法。最终你收获的将不仅仅是一个技能管理器而是一套解决实际问题的C编程思维模型。本文还有配套的精品资源点击获取
返回列表