简介:一个基于C++与EasyX的青蛙过河游戏项目,实现了图形界面与实时交互,适合计算机专业学生作为毕业设计参考,也适合游戏开发爱好者学习。游戏采用WSAD键控制青蛙,结合木板移动和河道速度变化提升可玩性,并保留难度设置、关卡设计、商店系统等扩展空间;完整代码结构包含头文件与源文件分工,便于理清图形绘制、事件处理和游戏逻辑。压缩包内含32个文件,包括10个头文件、7个C++源文件、图片与GIF动画素材、工程配置及可执行程序,整体约1.05MB,可快速体验并调试。目前已有66人学习,项目目录清晰,可帮助读者熟悉从界面搭建到功能扩展的开发流程,同时为毕业设计中的开题报告、论文叙述和答辩PPT提供实例支撑。
1. 从“很好做”到“好交差”:C++ 图形界面青蛙过河这个选题的分量
“青蛙过河”不是一个高门槛的游戏,却是一个特别适合把 C++ 图形界面和实时交互讲到位的毕业设计题。它同时出现多类移动对象、碰撞判定、关卡推进与计分,几乎覆盖了大部分 2D 桌面游戏引擎的常见模块;它又是足够老的教学题材,相关的玩法规则和参考实现都很完整,适合用来验证你写的架构。另一个值得注意的点是:这样的题目往往在“能跑”和“好交差”之间差着一大截——游戏逻辑只是三角形的一角,开题报告、论文和答辩 PPT 还要能互相对上话,数据来自同一份代码、图表来自同一条数据管线。这篇内容面向正在做 C++ 毕设、想一次把代码和文档串起来的人,也适合带新人的工程师用来评估项目拆分和验收清单。
2. 先把环境钉死:VS2022 + Qt6 Widgets 最小工程
2.1 为什么是 Qt 6 Widgets + Graphics View,而不是 SDL 或 Unity3D
做 C++ 图形界面小游戏,常见做法是 Qt 6 的 Widgets 模块加上 Graphics View 框架,而不是直接上 SDL2、SFML 或 Unity3D。原因有三:第一,毕设题目要求“图形界面”,Qt Widgets 本身是完整桌面控件体系,窗口、菜单、对话框、事件循环全都有,不用自己画按钮;第二,Graphics View 是为 2D 对象管理设计的高层抽象,自带场景坐标系、层级 Z 值、碰撞区域和动画机制,对青蛙过河这种“对象不多、逻辑清晰”的游戏刚刚好;第三,Unity3D、Cocos 更适合商业游戏,但会让答辩陷入“你这逻辑是 C# 写的还是 C++ 写的”的追问,反而没机会展示 C++ 语言本身的能力。
| 方案 | 上手成本 | 图形界面完整度 | 想展示 C++ 语法与架构 | 典型场景 |
|---|---|---|---|---|
| Qt 6 Widgets + Graphics View | 低 | 高(原生控件 + 2D 场景) | 高 | 桌面小游戏、工具类软件界面 |
| SDL2 | 中 | 低(需自绘 UI 控件) | 中 | 跨平台引擎、嵌入式 |
| SFML | 中 | 中 | 中 | 教学与小型游戏 |
| Unity3D | 中高 | 高(引擎自带场景编辑器) | 低(脚本为主) | 商业游戏、3D 应用 |
如果你平时习惯了 VS Code 配置 C/C++ 环境,项目本身不冲突,但建议主管交付还是用 Visual Studio 2022 或 Qt Creator,演示时不容易被系统路径问题绊住。以下代码按 Windows 桌面环境写,Linux 上只要把WIN32去掉即可。
2.2 用 CMake 建工程:AUTOMOC 与 Qt6 组件的正确写法
CMakeLists.txt 是整份代码的地基,尤其要打开AUTOMOC,否则 Qt 的信号槽和Q_OBJECT宏不会生成对应的 mocs 文件。
cmake_minimum_required(VERSION 3.21) project(FrogRiver VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) find_package(Qt6 REQUIRED COMPONENTS Widgets) qt_standard_project_setup() add_executable(FrogRiver WIN32 src/main.cpp src/mainwindow.cpp src/mainwindow.h ) target_link_libraries(FrogRiver PRIVATE Qt6::Widgets)两个重点:CMAKE_AUTOMOC告诉 CMake 自动扫描头文件里的Q_OBJECT并生成 moc 源,漏掉它会出现大量“未定义的 vtable”之类编译错误;WIN32告诉链接器这是 GUI 子系统程序,双击运行时不弹出黑色控制台窗口。若想调试时看 qDebug 输出,可以在 Qt Creator 里勾选“在终端中运行”,也可以把这个参数去掉。
2.3 先跑出一个能渲染的空窗口,再谈游戏
mainwindow.cpp 里初始化场景和视图,这段是后续所有游戏对象的容器。
#include <QGraphicsView> #include <QGraphicsScene> MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { QGraphicsScene *scene = new QGraphicsScene(this); scene->setSceneRect(0, 0, 1280, 720); scene->setBackgroundBrush(QColor("#1a1a2e")); QGraphicsView *view = new QGraphicsView(scene, this); view->setRenderHint(QPainter::Antialiasing); view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setCentralWidget(view); }sceneRect定义了游戏世界的范围,坐标系原点在设计稿的左上角,向右为 X 正方向、向下为 Y 正方向;setBackgroundBrush给整片场景铺深色底,避免后面截图写论文时背景花哨;Antialiasing打开能让圆形和斜向对象边缘平滑,不打开的话青蛙看起来会有明显锯齿。到这里窗口已经能显示,先跑一次确认 CMake 配置无误,再继续加图元。
3. 场景-图元架构:把青蛙过河拆成实时交互的可移动对象
3.1 先商量好坐标系、网格步长与车道占位
青蛙过河的玩法核心是“从屏幕底部出发,依次穿过车辆密集的车道、踩着浮木越过水面,到达对岸”。因此场景布局要按照固定行高切分。我一般把 1280×720 的整个场景划分为:底部安全区 80px、车辆区 5 行、每行 64px、水面区 4 行、每行 64px、顶部安全区 80px,这样留存边缘恰好能放下前后格。网格步长直接取 64px,青蛙移动时按 64px 的整数倍跳动,判定和绘制都不会出现“半格”误差。
坐标单位统一用qreal浮点,不要用 integer 保存对象位置。场景缩放时浮点精度更容易对齐,后续做慢动作、减速子弹时间也方便。
3.2 用 QGraphicsObject 派生青蛙、汽车与浮木
青蛙不只是张图片,它需要接收键盘事件、做网格移动、并且被碰撞系统查询。派生的基类选择QGraphicsObject而不是QGraphicsItem,因为它同时继承了QObject,可以把按键、碰撞、死亡等事件通过信号槽发给 GameCore,避免去 QGraphicsScene 里做全局事件分发。
class Frog : public QGraphicsObject { Q_OBJECT public: explicit Frog(const QPointF &start, qreal step = 64.0); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; void tryStep(const QPointF &delta); private: QPointF m_start; qreal m_step; };boundingRect()返回青蛙的视觉边界,比如QRectF(-24, -24, 48, 48),这是场景做碰撞剔除和重绘的最小矩形;paint()负责画圆形身体和眼睛,不要在这里写复杂的图形算法,画一个简单辨识度高的形状即可,答辩 PPT 上反而更容易讲。实现tryStep()时,先计算落地位置,再做边界检查:
void Frog::tryStep(const QPointF &delta) { QPointF target = pos() + delta; if (target.x() < 16 || target.x() > 1264) return; if (target.y() < 16 || target.y() > 704) return; setPos(target); }target.x()的上下界给游戏边界留出青蛙半径的余量,否则青蛙半只身子会画出场景。边界值不要写死在函数里,建议从sceneRect()动态读取,后面改分辨率时不用改这层逻辑。
汽车和浮木则用QGraphicsPixmapItem加一张贴图。汽车有自己的行进速度和初始方向,样式上可以用调色板重画,不需要真实车辆图片避免版权麻烦:
Car::Car(qreal speed, const QColor &color, QGraphicsItem *parent) : QGraphicsPixmapItem(parent), m_speed(speed) { QPixmap pixmap(96, 48); pixmap.fill(color); setPixmap(pixmap); setZValue(0.9); }m_speed的单位是“像素/秒”,而移动发生在每帧 tick 里,因此每次位移量要用速度乘以帧间隔,千万不要在 tick 里写moveBy(m_speed, 0),帧率不同会导致游戏速度完全不同。
浮木和汽车的区别在于它不会被碰撞杀死,反而要能把青蛙“带走”。实现时不用做物理约束,最简单可靠的做法是:tick 阶段判断青蛙坐在哪根浮木上,若坐在上面,就让青蛙跟随浮木位移。这一判断同样每帧做一次。
3.3 动画帧与碰撞:实时交互落在一个 tick 里
Graphics View 自带advance()机制,但自带机制在同一帧内对所有对象回调,顺序不可控。为了把“实时交互”讲清楚,我一般用一个 60FPS 的QTimer驱动 GameCore 的 tick 方法:
void GameCore::tick() { const qreal dt = m_timer.interval() / 1000.0; const auto cars = m_scene->items(m_roadArea, Qt::IntersectsItemShape); for (QGraphicsItem *item : cars) { auto *car = qgraphicsitem_cast<Car *>(item); if (!car) continue; car->moveBy(car->speed() * dt, 0); if (car->pos().x() > m_sceneWidth + 64) { car->setPos(-car->boundingRect().width(), car->y()); } } if (m_frog->currentLog() != nullptr) { m_frog->moveBy(m_frog->currentLog()->speed() * dt, 0); } checkCollisions(); }逻辑说明:dt是上一帧到这一帧的时间秒数;用scene->items()查出车辆区域里的所有对象再逐个转型,是 Qt 官方推荐的对象查询方式,比维护一个手写对象数组更不容易漏;车辆驶出画面右侧后,把它挪到左侧对应的负坐标,完成循环利用。qgraphicsitem_cast要求目标类里有QGraphicsItem宏,所以 Car 类要记得写上Q_DECLARE_METATYPE或其他 Qt 需要的宏,否则转型会返回空指针。
碰撞判定不能依赖视觉上的“接触”,要用场景几何做计算:
bool GameCore::checkRoadCollision(const Frog *frog) const { const QRectF body = frog->sceneBoundingRect().adjusted(4, 4, -4, -4); const auto items = m_scene->items(body, Qt::IntersectsItemShape); for (QGraphicsItem *item : items) { if (qgraphicsitem_cast<Car *>(item)) { return true; } } return false; }adjusted(4, 4, -4, -4)给碰撞盒做了 4px 内缩,消除“擦边被判定死亡”的挫败感;sceneBoundingRect()返回的是对象在场景坐标系中的最终矩形,不会受父级变换干扰。这里用的碰撞检测是遍历场景中的 item,对几十个图元的游戏绰绰有余,完全不需要提前做空间哈希。
需要留意的是,键盘事件进了 Qt 事件循环,而 tick 走的是 QTimer,两者天然在不同时间点触发。不要在 keyPressEvent 里直接修改对象位置的同时让 tick 也修改它,建议键盘事件只记录“方向意图”,tick 里统一消费。这样键盘连击、按住不放都不会出现抖动或丢步。
4. 游戏规则层:状态机、关卡配置与死亡判定
4.1 用有限状态机管理一局游戏的生死
游戏逻辑和界面代码混在一起是最常见的毕设扣分点。把“菜单、游戏中、死亡回放、过关、暂停、结束”这几个界面状态用一个GameState枚举管理,界面层只响应状态切换信号。
enum class GameState { Menu, Running, Dying, LevelUp, Paused, GameOver }; class GameCore : public QObject { Q_OBJECT public: void setState(GameState next); signals: void stateChanged(GameState state); private: GameState m_state = GameState::Menu; };setState()里可以做状态进入时的收尾工作,例如从 Running 切到 Dying 时停止所有车辆移动、播放死亡动画,从 Dying 切回 Running 时重置青蛙位置并启动计时器。UI 层QStackedWidget关联stateChanged信号切换页面,好处是以后加“暂停菜单”时不需要改动 GameCore。
4.2 用 JSON 配置关卡参数:速度、密度与方向
青蛙过河的难度来自车辆速度和密度,以及浮木数量和速度。把这些参数从代码里抽到 JSON 文件,是为了不改代码就能调难度,论文里也能顺手写一节“数据驱动设计”。
{ "level": 1, "scorePerGoal": 100, "lanes": [ { "type": "car", "speed": 90, "density": 0.3, "orientation": "right" }, { "type": "log", "speed": 45, "density": 0.5, "orientation": "left" } ], "allowedMisses": 2 }加载代码用 Qt 自带的 JSON 解析,不引入第三方库:
QJsonObject loadLevel(const QString &path, int level) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) { qWarning() << "cannot open" << path; return {}; } const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); return doc.object() .value(QStringLiteral("levels")) .toArray() .at(level) .toObject(); }file.open失败先告警返回空对象,不要直接崩溃;.at(level)越界时toObject()会返回空对象,调用方需要判空。density 参数决定每根车道在初始化时放置多少辆车:一辆车占 96px 宽,1280px 的车道放 4 辆就是 0.3 的密度,密度真实含义是“车道同时存在车辆数量的期望值”。
4.3 死亡判定与难度曲线的产生式写法
判定逻辑分两块:车辆区被撞到车,或者水面区没站上浮木。实现时按区域分别判断,避免两种判定互相干扰:
bool GameCore::isFrogSafe(const Frog *frog) { const QRectF body = frog->sceneBoundingRect(); if (body.intersects(m_roadArea)) { const auto items = m_scene->items(body, Qt::IntersectsItemShape); for (QGraphicsItem *item : items) { if (qgraphicsitem_cast<Car *>(item)) { return false; } } } if (body.intersects(m_waterArea)) { const auto items = m_scene->items(body, Qt::IntersectsItemShape); for (QGraphicsItem *item : items) { if (qgraphicsitem_cast<Log *>(item)) { return true; } } return false; } return true; }要注意的是浮木被box阻挡时,青蛙不是“踩在整根木头上”,如果items()查出来的浮木与青蛙身体有交集就算安全。这个实现里有极小的边界情况:青蛙同时压在两块浮木间隙,两块浮木都不与 body 相交时会被判定落水,这是合理的。难度曲线设计上,速度绝对值超过 160 像素/秒时普通玩家反应时间接近极限,所以后面关卡的难度更应该靠密度和车道数量而不是疯狂加速度。一个参考区间:
| 关卡 | 车道数 | 车辆速度区间 (px/s) | 车辆密度 | 浮木速度区间 (px/s) |
|---|---|---|---|---|
| 1–2 | 4 | 60–80 | 0.15–0.20 | 30–40 |
| 3–4 | 5 | 80–110 | 0.20–0.30 | 40–55 |
| 5–6 | 6 | 110–140 | 0.30–0.40 | 55–70 |
参数表里的数值是开局配合目标初稿的参考值。答辩时如果现场演示失败,最常被质疑的就是难度曲线设计,所以论文里要放这个表并解释递增依据。
5. 毕设硬通货:调好手感,写清论文,做出能讲 10 分钟的答辩版
5.1 开题报告:先有验收标准,再有文献综述
开题报告最常见的错误是前两页全是“国内外研究现状”摘抄。正确顺序应该是:先写这个项目的验收标准,再写技术风险,最后才补文献。验收标准例如“角色可以在车辆区和浮木区自由往返”“死亡动画可跳过”“全套关卡可一次通关”,这些句子后面会原样变成论文测试章节的条目。
| 阶段 | 产出 | 验证方式 |
|---|---|---|
| 第 1–2 周 | 环境、场景、图元、方向移动 | 手动穿过 2 条车道 |
| 第 3–4 周 | 碰撞、死亡、浮木跟随、过关重置 | 自动化逻辑测试 + 试玩记录 |
| 第 5–6 周 | 关卡数据、计分、菜单/暂停 | 完整通关一次 |
| 第 7–8 周 | 文档、打包与答辩 PPT | 干净 VM 上运行 exe |
把这张表搬进开题报告的“进度安排”一节,格式统一,评委一眼就能看到工作量。文献综述部分只列真实看过的材料,Qt 官方文档、图形学教材、一本游戏编程入门书就够撑起 5–8 条参考文献,不要为了凑数堆来源不明的博客。
5.2 论文结构怎么跟代码同构,答辩提问才能在关键行
论文目录不要按“界面设计、算法设计、测试”这种笼统切分,而是让每一章对应工程里一个真实模块:场景与坐标是图形环境部分,状态机是规则设计部分,碰撞与帧循环是实时交互部分,最后测试章节对应单元测试代码。这样答辩时被问到“这个功能在哪个文件”时,所有的回答都指向同一个路径,不会出现论文和代码对不上号。
一个可靠的做法是给每个关键函数补“最小验证”测试。答辩时我一般会上这样一段给评委看“方法”而非“结果”:
TEST_CASE("frog falls into water without log", "[game]") { Frog frog(QPointF(640, 400)); GameCore core; core.placeWater(QRectF(0, 200, 1280, 200)); REQUIRE_FALSE(core.isFrogSafe(&frog)); }这里用 Catch2 的TEST_CASE把“浮木不在时青蛙落水”固化成断言。为什么这比手动试玩更值钱:它能跑出确定性结果,也能量化边界条件的处理结果,放在论文第四章当测试依据比截图更有说服力。
5.3 答辩 PPT:一页一个观点,演示脚本不超过 10 分钟
PPT 不是代码贴图。建议只放五张:选题与目标、技术选型对比、场景-图元架构图、状态机转移图、测试与演示视频链接。演示视频提前录好放本地,现场即使没网也能播。答辩 10 分钟里,给我讲两个必讲点:一是 Graphics View 的场景-视图分离思路,二是用 QTimer 驱动帧循环为什么比用advance()更可控。这两个点一问倒,基本就会被归结于“没有真正理解框架”。如果评委是后端面试官,很可能顺口问 C++ 的覆盖、隐藏与多态,这部分在下一章用一个容易暴露的破绽专门覆盖。
6. 容易被当场戳破的三个坑:虚析构、高 DPI 与运行库缺失
6.1 给图元类补上虚析构与 override,不在答辩时丢分
很多毕设代码里屏幕上的对象继承自QGraphicsPixmapItem或QGraphicsObject,但头文件里不写析构函数。渲染场景释放这些图元时,如果派生类持有指针成员就会泄漏。建议所有图元类都显式声明虚析构,并在重写advance()、boundingRect()等函数时加override关键字:
class Car : public QGraphicsPixmapItem { public: ~Car() override = default; void advance(int phase) override; };这是 C++ 面试八股里高频的“覆盖与隐藏”场景:没有override时,子类写了一个同名但不同参数的函数会形成隐藏,编译不报错但运行时调不到。答辩现场被问到“你这里为什么加 override”,直接回答“保证虚函数覆盖并且让编译器帮我检查签名”就够了。
6.2 高 DPI 缩放下图形界面发糊的修正
现代答辩演示机基本都是 2K 或 4K 屏,如果不做高 DPI 适配,界面会整体模糊,评委印象分会受影响。在创建 QApplication 对象之前设置缩放策略:
#include <QApplication> int main(int argc, char *argv[]) { QApplication::setHighDpiScaleFactorRoundingPolicy( Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); QApplication app(argc, argv); // ... }注意PassThrough是不做取整,让 Qt 按真实缩放系数渲染;如果调用了这段代码依旧模糊,常见原因是 mainwindow 里用了固定像素尺寸的图片并关闭了平滑缩放,这时给QPixmap设置setDevicePixelRatio并按屏幕 DPI 重新加载即可。
6.3 部署目标机上缺 DLL,演示当场翻车的最后防线
打包时不要手工复制十几个 DLL。Qt 安装目录自带部署工具:
windeployqt --release --no-translations Release/FrogRiver.exe它会自动收集 Qt6Core.dll、Qt6Widgets.dll、平台插件等依赖到 exe 旁。若目标机器仍提示缺少 VCRUNTIME140.dll,补装 Microsoft Visual C++ Redistributable(运行库)即可。每次打压缩包之前,把整个 Release 文件夹放进一台没装过 Qt 的干净沙箱里双击试跑一次,看到窗口弹出并进入游戏才算部署完成。
本文还有配套的精品资源,点击获取