ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt+C++精灵对战游戏开发:从建模到AI决策实战

Qt+C++精灵对战游戏开发:从建模到AI决策实战 简介一份基于QTC开发的宠物小精灵人机对战游戏完整源码面向毕业设计、课程设计及项目开发场景。项目实现了精灵基类与多态攻击方法包含力量型、肉盾型、防御型、敏捷型四种精灵每种拥有独立的种族特性与属性成长机制等级从1级至15级升级时主属性获得额外加成玩家可与AI进行实时对战。整套源码经过严格测试可直接参考运行并留有清晰的目录结构与模块划分便于在此基础上扩展新精灵或调整战斗逻辑。资源共含36个文件以C源文件cpp/h为主搭配Qt界面文件ui、资源文件qrc、工程配置pro以及PNG图片与说明文档压缩包整体约1.88MB。已有281人学习下载适合作为Qt编程、面向对象设计及游戏开发入门的学习素材。1. 为什么用 QtC 做精灵对战游戏课程设计和毕设里最稳的一条路答辩现场最容易出现的一种局面同组五六个项目全是“注册登录 增删改查”唯独你能现场打开一个窗口让两只精灵打起来把属性克制、血条变化、技能特效和胜负判定完整演示一遍评委的注意力自然就落过来了。用 QtC 开发的宠物小精灵人机对战游戏本质就是一套面向对象设计与 GUI 框架结合的实践项目核心逻辑是精灵数据建模、伤害计算和 AI 决策表现层是窗口、按钮、进度条和定时器动画。它比单纯的管理系统更有记忆点又比网络通信类项目更容易在本地跑通所以一直是毕业设计和课程设计里的常见选题。这篇笔记面向准备做同类型项目的人讲的是如何把数据层、界面层、AI 决策和踩坑经验串在一起让答辩时问什么都能接得上。2. 核心数据建模精灵、技能和属性克制表的代码落地2.1 用 struct 还是 class精灵对象的设计思路宠物小精灵类游戏的第一步不是画界面而是把“精灵”这个对象定义好。常见做法是用一个 Monster 类保存名字、属性、六维数值和技能列表玩家的精灵和 AI 的精灵共用同一个类这样战斗逻辑只需要传入两个 Monster 实例。// model.h #pragma once #include QString #include QList enum class Element { Normal, Fire, Water, Grass, Electric, None }; struct Move { QString name; Element elem Element::Normal; int power 40; // 技能基础威力 int maxPp 20; int pp 20; // 当前剩余使用次数 }; class Monster { public: QString name; Element elem Element::Normal; int maxHp 100; int hp 100; int attack 20; int defense 20; int speed 20; QListMove moves; void reset() { hp maxHp; for (auto m : moves) m.pp m.maxPp; } };这里把 Move 设计成 struct是因为它没有复杂行为Monster 用 class 是给后续扩展留下空间比如后面要加异常状态、升级经验值直接在类里加成员函数就行。参数上需要注意hp、attack、defense、speed 全部用 int不用 float因为伤害公式最终要取整浮点在界面显示血条时还要再做一次转换徒增麻烦。2.2 属性克制表二维数组比 switch 更省心属性克制是这类游戏的灵魂。如果只在代码里写 if 判断“火打草翻倍、水打火翻倍”后期加新属性时改动会非常分散。业界更常见的做法是用一张二维表行代表攻击方属性列代表防御方属性查询时按下标直接取值。// battle_calc.cpp #include model.h #include QDebug // 行 攻击方属性列 防御方属性 static const float kTypeChart[6][6] { // vs Normal Fire Water Grass Electric None {1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f}, // Normal {1.0f, 0.5f, 0.5f, 2.0f, 1.0f, 1.0f}, // Fire {1.0f, 2.0f, 0.5f, 0.5f, 1.0f, 1.0f}, // Water {1.0f, 0.5f, 2.0f, 0.5f, 1.0f, 1.0f}, // Grass {1.0f, 1.0f, 1.0f, 1.0f, 0.5f, 1.0f}, // Electric {1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f}, // None }; float typeEffect(Element atk, Element def) { return kTypeChart[static_castint(atk)][static_castint(def)]; }这段代码的逻辑说明kTypeChart 的下标直接使用枚举的整数映射值所以 Element 枚举的顺序必须是 Normal0、Fire1、Water2 这种固定排列中途插一个新属性要把表全改一遍。参数上克制倍率只做 0.5、1.0、2.0 三档不加 1.5 这种中间值因为对新手项目来说三档最容易被评委看懂也最容易验证。2.3 伤害公式STAB 加成和随机浮动怎么配比伤害公式我采用的是“攻击力乘威力、除以防御力、再叠加属性一致加成和克制倍率”的经典结构。属性一致加成也就是 STABSame-Type Attack Bonus指的是精灵使用与自己属性相同的技能时伤害提高 1.5 倍这是让玩家组建同属性队伍的核心动力。// battle_calc.cpp #include QtGlobal #include QTime float stabBonus(const Monster* atk, const Move mv) { return atk-elem mv.elem ? 1.5f : 1.0f; } int calcDamage(const Monster* atk, const Move mv, const Monster* def) { double base (atk-attack * mv.power * 0.4) / def-defense 2.0; double stab stabBonus(atk, mv); double eff typeEffect(mv.elem, def-elem); // 随机浮动 0.85 ~ 1.0模拟实际对战的不确定性 double rnd (qrand() % 16 85) / 100.0; int dmg static_castint(base * stab * eff * rnd); return dmg 1 ? 1 : dmg; // 伤害保底 1 点 }参数说明0.4 是威力缩放系数目的是让 base 落到 10 到 100 这个区间配合 60 到 120 的 HP 上限一场战斗能控制在 3 到 6 个回合结束。qrand 在使用前需要写一句 qsrand(QTime::currentTime().msec()) 初始化种子否则每次启动游戏随机数序列都相同这是一个容易被忽略的细节。保底 1 点伤害是必须的否则会出现“属性被完全克制时打不动对方”的挫败感。3. Qt 界面框架用 QStackedWidget 串起菜单、精灵选择和对战场景3.1 从 QMainWindow 到页面管理界面结构先规划再做界面层不用一上来就堆代码。我先规划页面索引主菜单页、精灵选择页、战斗页。Qt 里做多页面切换最稳定也最好讲的方式是 QStackedWidget它会把所有页面叠在一起通过 setCurrentIndex 切换。相比 QTabWidget它没有标签条更适合游戏场景。// mainwindow.h #pragma once #include QMainWindow #include QStackedWidget class PageMenu; class PageChoose; class PageBattle; class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget* parent nullptr); private: QStackedWidget* m_stack nullptr; PageMenu* m_pageMenu nullptr; PageChoose* m_pageChoose nullptr; PageBattle* m_pageBattle nullptr; };// mainwindow.cpp #include mainwindow.h #include pagemenu.h #include pagechoose.h #include pagebattle.h MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { m_stack new QStackedWidget(this); m_pageMenu new PageMenu(this); m_pageChoose new PageChoose(this); m_pageBattle new PageBattle(this); m_stack-addWidget(m_pageMenu); m_stack-addWidget(m_pageChoose); m_stack-addWidget(m_pageBattle); setCentralWidget(m_stack); setWindowTitle(宠物小精灵人机对战); resize(960, 640); }逻辑说明三个页面都是独立类继承自 QWidget每个页面内部管理自己的按钮和事件。信号与槽的跨页面通信在原来写在本文件里统一在 MainWindow 构造函数中 connect这样页面类之间不互相引用以后要加“背包页”“存档页”只需要新增类并加入 QStackedWidget 即可。参数上 960x640 是比较稳妥的窗口尺寸既能在常见的 13 寸笔记本上完整显示又不至于在 1080p 屏幕下显得太小。3.2 战斗页的手绘过程血条、技能按钮和战斗日志战斗页是所有页面的核心。底部放四个技能按钮左边放玩家精灵信息右边放 AI 精灵信息中间一块 QTextEdit 做战斗日志。血条用 QProgressBar 实现但默认样式太“桌面软件”了需要设置样式表让它更像游戏里的血条。// pagebattle.cpp 关键片段 #include pagebattle.h #include QProgressBar #include QPushButton #include QTextEdit #include QLabel #include QVBoxLayout #include QHBoxLayout PageBattle::PageBattle(QWidget* parent) : QWidget(parent) { m_playerHpBar new QProgressBar(this); m_playerHpBar-setRange(0, 100); m_playerHpBar-setValue(100); m_playerHpBar-setTextVisible(true); m_playerHpBar-setStyleSheet( QProgressBar { border: 2px solid #444; border-radius: 6px; background: #333; text-align: center; } QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #00cc66, stop:1 #00aa55); border-radius: 6px; } ); m_logView new QTextEdit(this); m_logView-setReadOnly(true); m_skillButtons.resize(4); for (int i 0; i 4; i) { m_skillButtons[i] new QPushButton(this); m_skillButtons[i]-setEnabled(false); m_skillButtons[i]-setStyleSheet( QPushButton { background: #2d2d2d; color: white; border: 1px solid #555; border-radius: 8px; padding: 8px; } QPushButton:hover { background: #3d3d3d; } QPushButton:disabled { color: #888; background: #222; } ); } }这段代码的逻辑说明血条范围设置成 0 到 100但精灵的 HP 不一定是 100所以在每回合更新时要把实际 HP 换算成百分比再 setValue。skillButtons 用的是 QList 加指针数组方便后续把技能名字和点击事件绑定到循环里不需要手写四个 connect。qlineargradient 渐变样式是为了让血条在低血量时视觉上更明显实际判断逻辑还是依赖数值比较。3.3 信号与槽的串联开始按钮到战斗初始化页面之间的跳转依赖信号。PageMenu 里有一个“开始冒险”按钮点击后发出 startGame 信号MainWindow 收到信号后把当前页切成精灵选择页。玩家选好精灵后PageChoose 发出 gameStartWithMonster 信号携带一个 Monster 实例MainWindow 再把它传给 PageBattle 并切页。// mainwindow.cpp 中新增 connect(m_pageMenu, PageMenu::startGame, this, [this]() { m_stack-setCurrentWidget(m_pageChoose); }); connect(m_pageChoose, PageChoose::gameReady, this, [this](Monster playerMon, Monster aiMon) { m_pageBattle-startBattle(playerMon, aiMon); m_stack-setCurrentWidget(m_pageBattle); });parameter说明gameReady 信号携带两个 Monster 实例按值传递而非指针传递因为在页面切换过程中原对象可能销毁按值传递可以避免悬空指针问题。startBattle 是 PageBattle 里一个关键方法它接收精灵数据后做三件事把精灵名字和数值显示到界面、初始化四个技能按钮上的文字、把血条进度刷新到满值。4. 人机对战 AI让对手学会判断属性克制和血量血线4.1 决策模型从随机出招到带权重的评分排序人机对战的“智商”是整个项目最值得拿出来讲设计思路的部分。如果 AI 只是随机放技能玩家打两局就会发现规律答辩时也容易被追问如果 AI 每一步都选伤害最高的技能又显得机械。最稳妥的方案是评分制给每一个可用技能算出一个分数分数越高越优先选择。// ai_actor.cpp #include ai_actor.h #include QtGlobal int AiActor::decideMove(const Monster* ai, const Monster* player, const QListMove moves) { QVectordouble scores; for (int i 0; i moves.size(); i) { const Move mv moves[i]; if (mv.pp 0) { scores.append(-1.0); continue; } double score mv.power * 1.0; float eff typeEffect(mv.elem, player-elem); score * eff; // 本系技能加成同属性 12% if (mv.elem ai-elem) score * 1.12; // 克制对方时额外加权重减少 AI 对非克制技能的犹豫 if (eff 1.0f) score * 1.25; if (eff 1.0f) score * 0.55; // 残血时更倾向打高威力技能收尾 if (player-hp player-maxHp * 0.2 mv.power 50) score 18; // PP 盈余惩罚只剩 1 次时适当降权防止 AI 把技能全打空 if (mv.pp 1) score - 6; scores.append(score); } // 寻找最高分若最高分与第二高分差距 5%随机选一个 int best 0; for (int i 1; i scores.size(); i) { if (scores[i] scores[best]) best i; } int second -1; for (int i 0; i scores.size(); i) { if (i ! best (second -1 || scores[i] scores[second])) second i; } if (second 0 (scores[best] - scores[second]) scores[best] * 0.05) { if (qrand() % 100 40) best second; } return best; }逻辑说明这段 AI 的决策链依次考虑克制关系、本系加成、残血斩杀和 PP 余量。每个权重后面我都标注了来源1.25 是因为“AI 使用克制技能”是对局中最核心的优势必须放大0.55 则是压制 AI 使用被抵抗技能的倾向否则 AI 会在没有好选择时浪费回合。随机判定用 5% 差值阈值最终生效概率 40%目的是让 AI 偶尔换个技能增加对局变数又不至于打乱整体决策逻辑。你调整难度时只需要改这几处权重不需要动整个算法结构。4.2 回合执行顺序速度判定和战斗流程的状态机整个回合流程必须具备清晰的状态流转。很多新手直接把顺序写在“按钮点击事件”里结果玩家先出手还是 AI 先出手的逻辑完全绑定在按钮响应上一旦要新增功能就乱了。常见做法是维护一个回合状态枚举状态机控制每一步。// battle_state.h enum class BattlePhase { PlayerSelect, // 等待玩家选择技能 SpeedCheck, // 判定谁先手 PlayerTurn, // 玩家执行攻击 AiTurn, // AI 执行攻击 CheckEnd, // 检查胜负 TurnEnd // 清理状态回到 PlayerSelect };执行回合时先比较双方速度值速度快的一方先手先手攻击完若对方还未倒下则后手攻击如果后手攻击时先手方已经倒下则不再执行后续动作。这里的边界要注意第一回合不存在“后手被打死后不能反击”之外的场景但后续回合存在“双方同时倒下”的可能。我建议先判定玩家是否击败 AI再判定 AI 是否击败玩家两个判定独立最终结果按先后手次序输出到日志。处理完所有动作后把技能 PP、血量刷新到界面再切换回 PlayerSelect 状态。4.3 让 AI 的难度可控三个调节旋钮把难度变成参数而不是硬编码是我从实际开发里总结出来的经验。第一个旋钮是克制权重倍率范围 0.8 到 1.6决定 AI 有多爱用克制技能第二个旋钮是随机选中概率范围 0 到 40%决定 AI 的失误率第三个旋钮是 AI 能力值倍率范围 0.9 到 1.2直接放大 AI 精灵的攻击和速度。答辩演示时默认用一个 1.0 倍率如果一个同学上手太快就把随机率从 40% 调到 20%AI 的胜率立刻上升。这三个旋钮在代码里以全局配置结构体存放或者在 PageBattle 构造函数中作为参数传入后续做成设置界面也很方便。5. 避坑排查Qt 5.15 环境、编译器和运行时的高频“翻车”记录5.1 MSVC 与 MinGW 混用错乱构建套件对不上引发的编译失败现象Qt Creator 里新建项目代码照着教程写一编译就报错错误信息类似:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets... does not exist.或者更常见的“cannot find -lQt5Widgets”。 原因最常见的是安装 Qt 时一次性装了 MinGW 和 MSVC 两套构建库Creator 当前选中的构建套件用的是 MinGW 编译器但 .pro 或 CMakeLists 里的 Qt 路径却指向 msvc2019_64 目录两边 ABI 不兼容。 解决在 Qt Creator 左侧“项目”选项卡中确认构建套件与 Qt 版本路径必须一一配对。如果要用 MSVC 套件需要在“工具-选项-Kit”里把编译器选为 Microsoft Visual C 2019 或 2022Qt Versions 指向 5.15.2 的 msvc2019_64 前缀路径。还有一种常见补救做法用 VS2022 打开 CMakeLists 时先安装“ VS2022 Qt Solutions”扩展扩展会自动匹配 Qt 版本但前提是 Qt 路径已经配置到系统环境变量或 VS 的 Qt Versions 选项中。5.2 中文乱码的三次方问题编码格式不统一现象代码里写了中文提示语“战斗开始”运行时显示成一串乱码日志里输出了中文字符串刷新界面后部分文字变成问号。 原因Qt 源码文件保存为 UTF-8 无 BOM但 MSVC 在没有 BOM 时会默认按本地代码页中文系统上是 GBK解析导致字符串字面量里的中文变成错误的字节序列。 解决在 Qt Creator 中点击“工具-选项-文本编辑器-行为”把 UTF-8 BOM 设置为“总是写入”。如果你用 VS2022 编辑项目则需在文件菜单中“高级保存选项”里选择“UTF-8 带签名”。另外少用 QString::fromLocal8Bit 来转换中文字符串因为它的行为依赖当前系统代码页代码换到别的机器上就变了。正确做法是让所有源码文件统一带 BOM字符串直接赋值给 QStringQt 内部会按 UTF-8 处理。5.3 界面卡死sleep 不是动画是主线程的毒药现象点击技能按钮后窗口立刻变成“未响应”状态几秒后才恢复血条和日志一次性跳到最终结果完全没有动画过程。 原因在槽函数里直接调用了 QThread::msleep(500) 或 for 循环空转做延时目的是模拟技能施法动画。但 Qt 的界面事件循环被阻塞窗口无法刷新于是表现为假死。 解决用定时器替代阻塞延时。常见做法是把一条技能动画拆成多个步骤用一个 QTimer 按先后顺序执行调整血条和写日志的操作。最简单的替换方式是使用 QTimer::singleShot// 用单发定时器模拟出招延迟不卡界面 void PageBattle::animateAttack(int dmg, QProgressBar* bar) { QTimer::singleShot(300, this, [this, dmg, bar]() { int v bar-value() - dmg; if (v 0) v 0; bar-setValue(v); appendLog(QString(造成了 %1 点伤害).arg(dmg)); }); }参数说明300 毫秒是技能动画的“最小可感知间隔”低于 200 毫秒玩家几乎感受不到动作高于 600 毫秒会让战斗拖沓。singleShot 的 this 作为上下文对象保证了 lambda 在对象销毁后不会再被执行避免点击按钮后切换页面导致槽函数访问已释放界面控件的崩溃这类崩溃在 Qt 项目里被叫“悬空指针玄学”本质就是 lambda 捕获了裸指针。5.4 qrc 资源文件路径写错图片全变空白现象启动后界面上原来的技能图标、精灵贴图全部显示为空白但程序不报错。 原因qrc 文件里资源路径看着没问题但代码中加载图片时写的是相对路径比如QPixmap(images/pikachu.png)而 Qt 在运行时的工作目录不一定是项目源码目录相对路径解析失败。 解决所有通过资源系统加载的图片路径必须写成/images/pikachu.png这种以冒号开头的 qrc 前缀形式。qrc 文件中冒号后面的路径是资源系统的根路径实际读取时不依赖当前工作目录所以无论从 Qt Creator 启动还是从 exe 所在目录启动都能找到资源。我的习惯是把图片放在项目根目录下的 res/images 文件夹中然后在 qrc 文件中添加前缀 /images文件指向 res/images 下的真实文件代码中使用“:/images/pikachu.png”访问。如果你在运行 exe 后发现图片丢失先在 Qt Creator 中 F5 调试跑一遍确认代码路径没错再检查是否忘记把 qrc 文件加入 .pro 或 CMakeLists 的资源列表。5.5 属性克制表越界枚举顺序一改战斗数值全乱现象加了一个“幽灵”属性后战斗突然出现 0 伤害或者 4 倍伤害怎么找都查不出原因。 原因kTypeChart 二维数组的大小是 6x6但 Element 枚举中加入了新属性int 映射值超出数组范围读取了后续内存中的随机数据伤害计算自然离谱。 解决不要用裸二维数组改成 QMapstd::pairElement,Element, float 或者用一个带边界检查的查询函数。更简单的做法是在 typeEffect 函数开头加一行校验if (static_castint(atk) 6 || static_castint(def) 6) return 1.0f;此外Element 枚举中新增属性必须放在数组定义之后并同步扩展 kTypeChart 的行列数这是一组强关联的修改。最理想的做法是给 typeEffect 写单元测试把“所有属性两两组合的倍率”逐个验证一遍。不过受限于课程设计周期能写一个临时测试函数遍历打印倍率表就够了。6. 战斗数值验证用日志和参数微调校准对局体验项目做到界面能跑、AI 会思考还差最后一公里数值平衡是不是合理玩家能不能玩得爽。我的做法是给战斗过程加一套完整日志输出每回合把伤害、克制倍率、AI 决策评分、剩余血量全部打到控制台然后分别测试三场战斗观察整体节奏和最终胜负。// pagebattle.cpp 回合日志片段 void PageBattle::logTurnInfo(const Monster atk, const Monster def, const Move mv, int dmg, float eff) { qInfo().noquote() QString(回合日志%1 使用 %2克制倍率 %3伤害 %4目标 HP %5/%6) .arg(atk.name) .arg(mv.name) .arg(eff, 0, f, 1) .arg(dmg) .arg(def.hp) .arg(def.maxHp); }逻辑说明qInfo 配合 noquote 可以让 QString 内容原样输出避免引号干扰阅读。每回合日志至少包含四个信息使用了什么技能、预估的克制倍率是多少、实际伤害数值、当前剩余血量。有了这些数据你可以做两组对比实验第一组把 AI 的克制权重从 1.25 调到 1.0连打五局看 AI 胜率变化第二组把随机选中概率从 40% 调到 10%观察战斗是否趋于单调。我踩过的实际教训是伤害公式的随机浮动区间不能设成 0.5 到 1.0否则一场战斗会出现 15 点伤害和 45 点伤害交替出现的极端情况玩家会觉得胜负全靠运气。最终拍板的区间是 0.85 到 1.0既有随机性又不影响策略的主导地位。关于“这款游戏怎么做更好玩”我的经验是再加两个轻量功能一个是闪避概率把精灵的速度值换算成 2% 到 10% 的躲避率放入伤害结算前判定另一个是属性强化技能例如给自己的攻击提升一档玩家需要有意识地预判 AI 会不会强化决策深度立刻上了一个台阶。这些功能都能在现有 Monster 类上加几个字段实现不会破坏原有结构。最后说一个比较实际的观察课程设计和毕业设计里代码能跑只是及格线能让答辩老师看到“这个学生真的懂这个项目”才是加分项。我习惯的做法是在 AI 决策函数每一处加权后面写一行注释说明这个权重的设计意图——比如“克制技能吗”对比“这是为了防止 AI 在残血局面下放弃斩杀”。这些注释在项目文档里可以直接复用。战斗日志也是你的护城河当老师问“你这个 AI 是怎么思考的”你把日志调出来一行行讲解当时的评分和决策依据比背概念有力得多。希望这篇笔记里的建模习惯、界面框架、AI 权重和排错清单帮你能少走几个弯路把精力真正用在让精灵对战更精彩这件事上。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进