☰
Qt+C++精灵对战游戏开发:从建模到AI决策实战
2026/10/7 13:15:27 网站建设 项目流程

简介:一份基于QT+C++开发的宠物小精灵人机对战游戏完整源码,面向毕业设计、课程设计及项目开发场景。项目实现了精灵基类与多态攻击方法,包含力量型、肉盾型、防御型、敏捷型四种精灵,每种拥有独立的种族特性与属性成长机制,等级从1级至15级,升级时主属性获得额外加成,玩家可与AI进行实时对战。整套源码经过严格测试,可直接参考运行,并留有清晰的目录结构与模块划分,便于在此基础上扩展新精灵或调整战斗逻辑。资源共含36个文件,以C++源文件(cpp/h)为主,搭配Qt界面文件(ui)、资源文件(qrc)、工程配置(pro)以及PNG图片与说明文档,压缩包整体约1.88MB。已有281人学习下载,适合作为Qt编程、面向对象设计及游戏开发入门的学习素材。

1. 为什么用 Qt+C++ 做精灵对战游戏:课程设计和毕设里最稳的一条路

答辩现场最容易出现的一种局面:同组五六个项目全是“注册登录 + 增删改查”,唯独你能现场打开一个窗口,让两只精灵打起来,把属性克制、血条变化、技能特效和胜负判定完整演示一遍,评委的注意力自然就落过来了。用 Qt+C++ 开发的宠物小精灵人机对战游戏,本质就是一套面向对象设计与 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; QList<Move> 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_cast<int>(atk)][static_cast<int>(def)]; }

这段代码的逻辑说明:kTypeChart 的下标直接使用枚举的整数映射值,所以 Element 枚举的顺序必须是 Normal=0、Fire=1、Water=2 这种固定排列,中途插一个新属性要把表全改一遍。参数上,克制倍率只做 0.5、1.0、2.0 三档,不加 1.5 这种中间值,因为对新手项目来说,三档最容易被评委看懂,也最容易验证。

2.3 伤害公式:STAB 加成和随机浮动怎么配比

伤害公式我采用的是“攻击力乘威力、除以防御力、再叠加属性一致加成和克制倍率”的经典结构。属性一致加成,也就是 STAB(Same-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_cast<int>(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 QList<Move>& moves) { QVector<double> 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 或 2022,Qt 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,字符串直接赋值给 QString,Qt 内部会按 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 映射值超出数组范围,读取了后续内存中的随机数据,伤害计算自然离谱。 解决:不要用裸二维数组,改成 QMap<std::pair<Element,Element>, float> 或者用一个带边界检查的查询函数。更简单的做法是在 typeEffect 函数开头加一行校验:

if (static_cast<int>(atk) >= 6 || static_cast<int>(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 权重和排错清单帮你能少走几个弯路,把精力真正用在让精灵对战更精彩这件事上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询