Compare commits
1 Commits
d94d9175b1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d69cbebd5 |
@@ -0,0 +1,659 @@
|
||||
# 实训项目集深度解析(按学生关注度:04 / 11 / 02 / 05 / 06 / 08)
|
||||
|
||||
> 题目来源:wiki「02. Qt方向 → 04. 项目集」(pageId 183664900,16 个项目)。
|
||||
> 本文对其中学生关注度较高的 6 个项目做深入拆解:题目重点是什么、技术难点在哪、
|
||||
> 哪些地方容易卡住、卡住了往哪个方向查。目标是让学生顺利启动、按里程碑推进,
|
||||
> 不在环境和 API 细节上消耗整周时间。
|
||||
>
|
||||
> 每个项目按固定结构展开:**定位 → 题目重点 → 技术难点拆解 →
|
||||
> 高频坑与排查 → 建议启动路径(里程碑)**。里程碑设计原则与
|
||||
> [`ASSIGNMENTS.md`](ASSIGNMENTS.md) 相同:先保最小可运行闭环,再逐层加功能。
|
||||
|
||||
## 六个项目总览
|
||||
|
||||
| 项目 | 难度 | 核心考点 | 最容易卡住的点 |
|
||||
|---|---|---|---|
|
||||
| 04 简易本地音乐播放器 | ⭐⭐⭐⭐⭐ | QtMultimedia、Model/View、异步元数据 | 元数据是异步到达的,setMedia 后立即读为空 |
|
||||
| 11 OpenCV 面部识别系统 | ⭐⭐⭐⭐⭐⭐ | OpenCV+Qt 集成、图像格式转换、多线程 | 环境搭建:MinGW 的 Qt 链不上 MSVC 编译的 OpenCV |
|
||||
| 02 基于QT的自动售卖机 | ⭐⭐⭐⭐⭐ | QStateMachine、Model/View、互斥锁 | 带条件的状态跳转(守卫)Qt 没有现成 API |
|
||||
| 05 个人相册管理与智能标签 | ⭐⭐⭐⭐⭐ | 缩略图性能、多线程加载、SQLite 索引 | 主线程同步加载大图,几十张照片就把界面卡死 |
|
||||
| 06 多功能抽奖与随机点名 | ⭐⭐⭐⭐ | 随机算法正确性、定时器动画 | CSV 导入中文乱码(Excel 导出的是 GBK) |
|
||||
| 08 双人飞机大战 | ⭐⭐⭐⭐⭐ | QGraphicsView、游戏循环、碰撞检测 | 键盘多键同时按:直接在 keyPressEvent 里移动会卡顿、双人抢键 |
|
||||
|
||||
## 开工前的三条通用提醒(六个项目都适用)
|
||||
|
||||
1. **先确认 Qt 版本再写第一行代码。** 课程环境是 Qt 5.14.2。自己电脑装了 Qt 6 的
|
||||
同学注意:这批题目里 04(`QMediaPlaylist` 在 Qt6 被删除)和 02(`QStateMachine`
|
||||
在 Qt6 挪进了独立的 StateMachine 模块)受 Qt5/Qt6 差异影响最大,各项目小节末尾
|
||||
有版本说明。网上的 Qt5 和 Qt6 示例代码混着抄,是编译报错查半天的常见原因。
|
||||
2. **资源路径不要用裸相对路径。** 双击 exe 和从 IDE 里运行,"当前目录"不一样。
|
||||
图片、音效等只读资源进 `.qrc`(`:/imgs/xxx.png`);存档、配置等可写数据用
|
||||
`QCoreApplication::applicationDirPath()` 或 `QStandardPaths` 拼绝对路径。
|
||||
"IDE 里正常、双击就白屏/没声音"多数是这个原因。
|
||||
3. **每完成一个里程碑就 git commit。** 这批题目都要在一份代码上连续叠功能,
|
||||
没有提交点,一次重构失败就退不回去。验收时 git log 也能说明你是按里程碑推进的。
|
||||
|
||||
---
|
||||
|
||||
## 04. 简易本地音乐播放器 ⭐⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
用 QtMultimedia 做一个能实际使用的播放器:播放控制链路(QMediaPlayer)、
|
||||
播放列表(Model/View)、元数据展示三块拼起来。没有算法难度,考的是
|
||||
把 Qt 各模块正确地连在一起,六个项目里属于投入产出比较高的一个。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- 这道题的骨架是两条方向相反的状态同步链:
|
||||
1. UI → 播放器:按钮/滑块操作调用 `play()/pause()/setPosition()/setVolume()`;
|
||||
2. 播放器 → UI:`positionChanged`/`durationChanged`/`stateChanged`/
|
||||
`metaDataChanged` 等信号回写界面。
|
||||
两条链各自单向、靠信号槽解耦,这正是题面"各UI组件间通过信号与槽高效解耦通信"
|
||||
要考的。写成"按钮槽里既改播放器又顺手改十个控件",这部分分就没有了。
|
||||
- 播放列表用 Model/View(`QAbstractTableModel` 或至少 `QStandardItemModel` +
|
||||
`QTableView`),不要用 `QTableWidget` 逐格塞字符串,题面明确点了 Model/View。
|
||||
- 播放模式(列表循环/单曲循环/随机)在 Qt 5.14 里有现成 API:
|
||||
`QMediaPlaylist::setPlaybackMode(Loop / CurrentItemInLoop / Random)`,一行完成。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 1:元数据(ID3 标签)是异步到达的。** `setMedia()` 之后媒体要经历加载
|
||||
过程,立即调 `metaData()` 拿到的是空值。正确做法:连接
|
||||
`QMediaPlayer::metaDataAvailableChanged` 或 `mediaStatusChanged`
|
||||
(等 `LoadedMedia`)信号,在槽里再读
|
||||
`metaData(QMediaMetaData::Title / ContributingArtist / AlbumTitle)`。
|
||||
标题一直显示空白的,基本都是在 `setMedia` 后下一行就去读了。
|
||||
|
||||
**难点 2:进度条的拖动回环。** 直观的写法是把 `positionChanged` 连到
|
||||
`slider->setValue()`、把 `slider->valueChanged` 连到 `player->setPosition()`,
|
||||
结果两个信号互相触发,拖动时来回跳动甚至卡顿。标准解法:
|
||||
|
||||
- 播放器 → 滑块用 `setValue`(程序触发的 `valueChanged` 不做 seek);
|
||||
- 用户 → 播放器只用 `sliderMoved`(仅用户拖动才发出),或
|
||||
`sliderPressed` 时暂停回写、`sliderReleased` 时一次性 `setPosition()`。
|
||||
|
||||
**难点 3:专辑封面在很多平台后端拿不到。** `QMediaMetaData::CoverArtImage`
|
||||
依赖系统解码后端,Windows/Linux 上对 MP3 内嵌封面经常返回空。题面已经给了
|
||||
降级路径,按题面做即可:拿不到就在歌曲同目录找 `cover.jpg`/`folder.jpg`,
|
||||
再拿不到显示默认占位图。想真正解析 ID3 内嵌封面(APIC 帧)可引入 TagLib,
|
||||
算加分项,不是及格线。
|
||||
|
||||
**难点 4:解码后端决定能不能出声。** QMediaPlayer 自己不解码,依赖系统后端:
|
||||
Windows(DirectShow/WMF)放 MP3/WAV 没问题,FLAC 未必;Linux 走 GStreamer,
|
||||
缺 `gstreamer1.0-plugins-good/bad/ugly、gst-libav` 就没有声音。
|
||||
建议先用 WAV 文件打通全链路,再测 MP3/FLAC,把"代码错"和"后端缺解码器"
|
||||
两类问题分开。
|
||||
|
||||
**难点 5(可选算法项):LRC 歌词同步。** 题面点了二分查找:把 LRC 解析成
|
||||
按时间戳排序的 `QVector<QPair<qint64, QString>>`,`positionChanged` 里用
|
||||
`std::upper_bound` 定位当前行。这是题目"算法与数据结构优化"一节里
|
||||
最容易落地的一项(Fisher-Yates 随机也是,见项目 06 的展开)。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| 编译报 `QMediaPlayer: No such file` | 没链 multimedia 模块 | qmake: `QT += multimedia`;CMake: `find_package(... Multimedia)` + `target_link_libraries(... Qt5::Multimedia)` |
|
||||
| 不报错但没声音 | ① 后端缺解码器 ② 文件路径错(相对路径) ③ 音量为 0 | 先播 WAV;`QMediaPlayer::error` 信号连上打印;路径用 `QUrl::fromLocalFile(绝对路径)`,不要手拼 `file://` |
|
||||
| 歌曲标题/歌手一直空白 | 元数据异步,读早了 | 挪进 `metaDataAvailableChanged` 槽里读 |
|
||||
| 拖进度条时来回跳动 | 双向信号回环 | 用 `sliderMoved`/`sliderReleased`,见难点 2 |
|
||||
| 显示时间是一串大数字 | `position()` 返回毫秒(qint64) | `QTime(0,0).addMSecs(pos).toString("mm:ss")` |
|
||||
| 关闭再打开,列表没了 | 只存了内存 | 关闭时(`closeEvent`)把路径列表写 `QSettings` 或 JSON,启动时读回;注意存绝对路径 |
|
||||
| 随机模式来回就那两首 | 用 `rand()%n` 且每次独立随机 | 用 `QMediaPlaylist::Random`,或自己实现 Fisher-Yates 洗牌序列 |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(半天)**:一个按钮 + 一个 `QMediaPlayer`,点击播放一个写死路径的 WAV。
|
||||
打通"模块链接 + 能出声",这一步没过不要写任何 UI。
|
||||
2. **M2(1 天)**:`QFileDialog` 多选添加 → `QMediaPlaylist` + Model/View 列表 →
|
||||
双击列表任意行播放。
|
||||
3. **M3(1 天)**:播放/暂停/上下曲按钮、进度条(含拖动 seek)、音量条、
|
||||
时间标签。到这里已经可以日常使用。
|
||||
4. **M4(1 天)**:三种播放模式切换 + 元数据/封面展示(含降级链)+ 列表持久化。
|
||||
5. **M5(加分,量力)**:LRC 歌词二分同步、系统托盘、QSS 明暗双主题、LRU 最近播放。
|
||||
|
||||
### Qt5 / Qt6 版本说明(本项目受影响最大)
|
||||
|
||||
- `QMediaPlaylist` 在 Qt6 被删除,没有替代类。用 Qt6 就得自己维护
|
||||
`QList<QUrl>` + 当前索引 + 播放模式逻辑(`mediaStatusChanged` 收到
|
||||
`EndOfMedia` 时按模式算下一首),工作量不小,选 Qt6 前想清楚。
|
||||
- Qt6 里 `setMedia()` 改为 `setSource()`,且必须 `setAudioOutput(new QAudioOutput)`
|
||||
才有声音——Qt6 下没有声音,先查这一条。
|
||||
- Qt6 的 `metaData()` 返回 `QMediaMetaData` 对象,取值用
|
||||
`.value(QMediaMetaData::Title)`,与 Qt5 的 API 不同。
|
||||
- 结论:此题用课程环境 Qt 5.14.2 做,问题最少。
|
||||
|
||||
---
|
||||
|
||||
## 11. (OpenCV)基于 OpenCV 的面部识别系统 ⭐⭐⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
六个里唯一的六星题。难度不在识别算法(模型是现成的),而在工程集成:
|
||||
OpenCV 环境搭建、`cv::Mat` 与 `QImage` 互转、视频流不阻塞 UI、
|
||||
"检测→特征→比对"流水线的正确串联。选这题前要有准备:第一周必须把环境跑通,
|
||||
跑不通后面无从谈起。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- 先分清两个概念,题目是两级流水线,不要混为一谈:
|
||||
1. **人脸检测(detection)**:找出画面里哪里有脸(矩形框)。传统方案
|
||||
`cv::CascadeClassifier`(Haar),现代方案 DNN(如 YuNet / ResNet-SSD)。
|
||||
2. **人脸识别(recognition)**:判断这张脸是谁。用预训练模型
|
||||
(SFace/FaceNet/ArcFace 的 ONNX)把人脸编码成 128/512 维特征向量,
|
||||
与注册库中向量算余弦相似度,超过阈值即判定为该人。
|
||||
- 识别不是训练神经网络。模型是下载来的 `.onnx`/`.xml` 文件,
|
||||
用 `cv::dnn::readNet` 或 `cv::FaceRecognizerSF` 加载。在想"要不要自己训练模型"
|
||||
的同学可以打住:那不是本题要求,实训周期内也做不完。
|
||||
- 题面明说"可选只通过图片识别",这是官方给的降级出口。摄像头/实时流
|
||||
做不下来时,"导入图片→检测→识别→标注展示"的静态版本仍是一个完整、
|
||||
可以及格的系统。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 0(第一关):环境。** OpenCV 官网给 Windows 的预编译包是 MSVC 编译的,
|
||||
MinGW 版 Qt 链不上(C++ ABI 不兼容,症状是链接期大量 `undefined reference`)。
|
||||
三条出路,按推荐排序:
|
||||
|
||||
1. Qt 用 MSVC kit(装 Visual Studio Community / Build Tools,Qt 安装时勾选
|
||||
MSVC 组件),直接用官方 OpenCV 包,最省事;
|
||||
2. 坚持 MinGW,就要用 CMake + 同版本 MinGW 自己编译 OpenCV(预留一个晚上);
|
||||
3. Linux/WSL:`apt install libopencv-dev`,基本没有这个问题。
|
||||
|
||||
第一周内跑不通"读一张图并显示",就应该向老师求助或换项目。环境是本题
|
||||
最大的淘汰因素,及时求助不丢人。
|
||||
|
||||
**难点 1:`cv::Mat` → `QImage` 的三个常见错误。**
|
||||
|
||||
- 通道序:OpenCV 是 BGR,Qt 是 RGB。直接
|
||||
`QImage(mat.data, w, h, QImage::Format_RGB888)` 得到的画面偏蓝。
|
||||
用 `QImage::Format_BGR888`(Qt 5.14 起可用),或先 `cv::cvtColor(BGR2RGB)`。
|
||||
- 行对齐:`QImage` 构造必须显式传 `mat.step` 作 bytesPerLine,
|
||||
否则某些分辨率下图像出现斜纹/错位(Mat 行末可能有 padding)。
|
||||
- 生命周期:这种构造是浅拷贝,`QImage` 借用 Mat 的内存。Mat 先析构
|
||||
(比如函数局部变量)就会花屏或崩溃。跨函数传递前 `.copy()` 一次。
|
||||
|
||||
三个问题合起来,建议直接采用这个写法:
|
||||
|
||||
```cpp
|
||||
QImage matToQImage(const cv::Mat &bgr) {
|
||||
return QImage(bgr.data, bgr.cols, bgr.rows,
|
||||
static_cast<int>(bgr.step),
|
||||
QImage::Format_BGR888).copy(); // copy() 断开内存借用
|
||||
}
|
||||
```
|
||||
|
||||
**难点 2:视频流驱动方式。** 入门版:`QTimer` 每 33ms 触发
|
||||
"`cap.read()` → 检测 → 转 QImage → `label->setPixmap()`"。能跑,但检测一慢
|
||||
(DNN 单帧可能超过 100ms)整个 UI 就冻住。正式版:把 `VideoCapture` + 检测识别
|
||||
放进 `QThread` 工作线程,处理完一帧发 `frameReady(QImage)` 信号回主线程
|
||||
(QImage 隐式共享,跨线程信号传值安全)。注意子线程里不能直接操作任何 QWidget。
|
||||
建议先用 QTimer 版打通流程,再整体搬进线程,一步到位容易两头出错。
|
||||
|
||||
**难点 3:识别流水线的数据设计。** 注册(录入)与识别共用前半段:
|
||||
|
||||
```
|
||||
帧/图片 → 检测得人脸框 → 裁剪+对齐 → 特征提取模型 → 128/512维向量
|
||||
注册:向量 + 姓名 存库(多张照片可求平均向量,题面提示了)
|
||||
识别:向量与库中所有向量算余弦相似度 → 最高分且超阈值 → 判定身份
|
||||
```
|
||||
|
||||
数据库不必上 SQL:人数少时 `QVector<Person{name, QVector<float> feat}>` +
|
||||
JSON 持久化完全够用(题面也允许)。余弦相似度手写只有几行(点积/模长),
|
||||
阈值从 0.36(SFace 官方推荐值附近)起调。建议做一个滑块让阈值可调,
|
||||
演示时展示"调阈值看误识率变化",本身就是加分点。
|
||||
|
||||
**难点 4:级联检测器文件路径。** `CascadeClassifier::load("haarcascade_frontalface_default.xml")`
|
||||
返回 false 不抛异常,不检查返回值的话,程序照常运行但永远检测不到脸。
|
||||
xml 在 OpenCV 安装目录 `etc/haarcascades/` 下,复制到程序目录并用绝对路径加载,
|
||||
load 后一定要检查返回值。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| 链接期大量 `undefined reference to cv::...` | MinGW Qt 配 MSVC OpenCV | 见难点 0,换 MSVC kit 或自编译 |
|
||||
| 程序一启动就闪退 | 运行时找不到 opencv_worldXXX.dll | 把 dll 拷到 exe 旁,或把 OpenCV bin 目录加进 PATH |
|
||||
| 画面人脸偏蓝 | BGR/RGB 通道序 | `Format_BGR888` 或 cvtColor |
|
||||
| 图像斜纹/花屏/随机崩溃 | 没传 `mat.step` / QImage 借用已析构的 Mat | 用上面 matToQImage 的写法 |
|
||||
| 摄像头打不开 | 索引不对 / 被其它程序占用 / 权限 | `VideoCapture cap(0)` 换 1、2 试;检查 `cap.isOpened()` |
|
||||
| 检测一直没结果 | 级联 xml 没加载成功 | 检查 `load()` 返回值 |
|
||||
| 检测有框但识别乱认 | 没做人脸对齐/裁剪就送识别模型;阈值太松 | 用检测框裁剪(DNN 方案用 `FaceRecognizerSF::alignCrop`);调高阈值 |
|
||||
| 一开检测 UI 就明显卡顿 | 检测在主线程 | 降低检测频率(每 3 帧检测 1 次,框沿用上一次)或移到工作线程 |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(第一周内必须完成)**:环境验证——Qt 程序里用 `cv::imread` 读一张图、
|
||||
转 QImage 显示在 QLabel。这是本题第一个必须通过的节点。
|
||||
2. **M2**:QTimer + VideoCapture 实时显示摄像头画面(不检测)。
|
||||
3. **M3**:加 Haar 级联检测,画面上实时画人脸框(`cv::rectangle` 或转 QPixmap 后
|
||||
QPainter 画)。
|
||||
4. **M4**:接入特征提取模型(推荐 OpenCV 4.5.4+ 自带的 YuNet 检测 + SFace 识别,
|
||||
API 最顺);做注册界面:拍照/导入 → 提特征 → 存 JSON。
|
||||
5. **M5**:实时比对 + 画面标注姓名/相似度 + 识别日志列表。到此即完整达标。
|
||||
6. **M6(加分,量力)**:多张照片平均特征、阈值滑块、识别事件抓拍存盘。
|
||||
跟踪/活体检测等题面"提升"项投入大、产出小,谨慎选择。
|
||||
|
||||
### 版本说明
|
||||
|
||||
- OpenCV 用 4.x(推荐 ≥4.5.4,才有 `FaceDetectorYN`/`FaceRecognizerSF`)。
|
||||
网上大量教程是 3.x 的 `cv::face::LBPHFaceRecognizer`(在 contrib 模块里,
|
||||
官方预编译包不含),照抄编译不过,而且 LBPH 效果也远不如 DNN 方案。
|
||||
- `QImage::Format_BGR888` 需要 Qt ≥ 5.14,课程环境刚好够用;更老的 Qt 只能 cvtColor。
|
||||
|
||||
---
|
||||
|
||||
## 02. 基于 QT 的自动售卖机 ⭐⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
题面是售卖机,考的其实是软件设计。没有硬技术门槛(不需要多媒体、不需要
|
||||
第三方库),看的是状态机建模、Model/View 数据流、并发保护、JSON 持久化
|
||||
四个工程概念用得对不对。它是六个项目里最不容易被环境卡住的选择;
|
||||
全用 if-else 也能跑起来,但设计分就没有了。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- 核心是用状态机管交易流程:`待机 → 已投币 → 已选商品 → 出货中 → 找零中 → 完成`。
|
||||
题面点名 `QStateMachine`。关键在于:"当前允许做什么"由状态决定,而不是由
|
||||
散落各处的 if 判断决定——待机态投币按钮可用、选货按钮禁用;已投币态反之。
|
||||
评分时首先看的就是这一点。
|
||||
- 第二个重心是数据与界面分离:商品/库存/零钱集中在一个数据管理类
|
||||
(单例或注入均可),前台购买界面和后台管理界面共享同一个模型——
|
||||
前台买走一瓶,后台表格里的库存立刻变。这是 Model/View 真正的考点:
|
||||
一份 `QAbstractTableModel`,两处视图。
|
||||
- `QMutex` 那条要求,本质是要求把"扣库存+扣零钱"做成一个原子操作:
|
||||
`deductStock()` 里 `QMutexLocker locker(&mutex);` 检查+扣减一起完成。
|
||||
加分做法:开一个 `QThread` 模拟并发购买,演示"不加锁会超卖、加锁不会"。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 1:QStateMachine 是事件驱动异步的,和直觉不同。**
|
||||
|
||||
- `machine.start()` 之后状态并不立即进入,要等事件循环跑起来。
|
||||
start 后下一行断言当前状态,必然失败。
|
||||
- 状态迁移用 `state->addTransition(sender, &Sender::signal, targetState)` 声明,
|
||||
UI 更新挂在 `QState::entered`/`exited` 信号上,或用
|
||||
`state->assignProperty(widget, "enabled", true)` 让状态机自动改属性。
|
||||
这套接对了,代码里基本不需要手写"if 当前状态是 X"。
|
||||
|
||||
**难点 2:带条件的迁移(守卫条件)没有现成 API。** "投币额 ≥ 商品价才允许出货"
|
||||
这种条件迁移,Qt 的标准做法是自定义 Transition:继承 `QSignalTransition`
|
||||
重写 `eventTest()`,不满足条件返回 false(迁移不发生)。嫌重的话有个务实变通:
|
||||
在槽里判断条件、满足才 `emit sufficientPayment()`,让状态机只监听这个信号。
|
||||
两种都算对,后者更容易讲清楚。这是本题最隐蔽的设计问题,动手前先想好,
|
||||
不要写到一半才发现 addTransition 不支持带条件,回头改架构。
|
||||
|
||||
**难点 3:找零算法的正确性边界。** 贪心(从大面额往小配)对常规币制
|
||||
{50,20,10,5,1} 是最优的,但零钱库存有限时贪心会失败:库存没有 5 元、有 3 张
|
||||
2 元时找 6 元,贪心先拿 5 就走进死路。及格线:贪心 + 失败时整单退币(题面明确
|
||||
允许"无法找零则退币");加分线:库存受限的找零本质是完全背包可行性问题,
|
||||
用 DP 或回溯保证"能找开就一定找得开"。答辩时能说清贪心什么时候会错,
|
||||
比闷头写 DP 更有价值。
|
||||
|
||||
**难点 4:持久化的时机与原子性。** "数据变更时自动保存"不要真的每次变更都
|
||||
全量写盘(快速连点可能写出半截 JSON)。简单可靠的做法:变更只置脏标记 +
|
||||
`QTimer` 500ms 防抖落盘;写盘用 `QSaveFile` 替代 `QFile`(写完才原子替换,
|
||||
崩溃不损坏存档)。`QSaveFile` 知道的学生不多,用上可以作为一个细节亮点。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| `machine.start()` 后状态没进入 | 状态机异步启动 | 初始化逻辑挂到 `initialState` 的 `entered` 信号,不要 start 后同步读状态 |
|
||||
| 迁移不触发 | 信号签名不匹配 / 当前不在源状态 | 迁移只在源状态激活时监听;连 `QState::entered` 打日志看状态流 |
|
||||
| 忘了 `setInitialState` | 状态机启动即静默停止 | `machine.setErrorState`/日志;new 完状态第一件事设初始态 |
|
||||
| 后台改库存前台不动 | 两界面各持一份数据副本 | 共享同一个 model 实例;改数据走 `setData` 或发 `dataChanged` |
|
||||
| 表格改了数字界面不刷 | 自定义 model 改数据没 emit `dataChanged` | 所有修改路径统一收口到一个函数,里面负责 emit |
|
||||
| 偶发库存变负数 | 检查与扣减分离,中间被插队 | "检查+扣减"放进同一个 `QMutexLocker` 作用域 |
|
||||
| JSON 读回来全是 0 | `QJsonObject` 取值类型不对(如 `toInt()` 用在 double 上)/ 键名拼写 | 先 `qDebug() << doc.toJson()` 核对;封装 load/save 对称函数 |
|
||||
| 程序异常退出后存档损坏 | 直接 QFile 覆盖写 | 换 `QSaveFile` |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(1 天)**:不带界面,先写 `VendingData` 数据类(商品表、零钱表、
|
||||
加锁的 deduct/refund 接口)+ JSON load/save,用 qDebug 自测。数据层立住,
|
||||
后面基本是接线工作。
|
||||
2. **M2(1 天)**:前台购买界面(商品按钮阵列 + 投币按钮 + 信息提示区),
|
||||
先用普通槽函数把"投币→选货→出货→找零"跑通(暂时用 if-else 没关系)。
|
||||
3. **M3(1–2 天)**:引入 QStateMachine 重构 M2 的流程控制——按钮使能交给
|
||||
`assignProperty`,流程迁移交给 transition。先能跑再重构,比一步到位稳。
|
||||
4. **M4(1 天)**:管理员后台(密码 QDialog + QTableView 库存表,共享模型)+
|
||||
售罄置灰、无法找零拦截两条异常流。
|
||||
5. **M5(加分)**:交易流水 + 报表(QSortFilterProxyModel 排序即可,QtCharts 可选)、
|
||||
多线程购买压测演示、折扣规则引擎。
|
||||
|
||||
### 版本说明
|
||||
|
||||
- Qt6 中 `QStateMachine` 被移出 QtCore,进了独立的 statemachine 模块
|
||||
(`QT += statemachine` / `find_package(Qt6 COMPONENTS StateMachine)`)。
|
||||
Qt 5.14 下在 QtCore 里直接用,无需额外配置。
|
||||
- QtCharts 在 Qt 5.14 是独立模块(`QT += charts`,且要在安装器里勾选过)。
|
||||
没装的话报表用 QTableView 呈现即可,不必为一张柱状图重装 Qt。
|
||||
|
||||
---
|
||||
|
||||
## 05. 个人相册管理与智能标签应用 ⭐⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
一道性能敏感的 Model/View 大题:核心挑战不是显示图片(一行 setPixmap 的事),
|
||||
而是几百上千张照片下界面依然流畅——缩略图异步加载、元数据索引、组合筛选。
|
||||
"智能标签"名字唬人,实际是可裁剪的加分层:题目允许从"手动标签 + 搜索"
|
||||
起步,ONNX 智能识别放在最后做。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- 本题真正的关键是索引:把图片路径、拍摄日期、评分、标签、收藏这些
|
||||
元数据放进一个独立于文件系统的库(SQLite 或 JSON 起步),所有浏览、搜索、
|
||||
筛选都查库,不实时扫磁盘。想通这一点,"虚拟相册"(同一张图归入多个相册
|
||||
而不复制文件)、组合筛选、倒排索引都是顺理成章的查询而已。
|
||||
- 第二个关键是缩略图网格的性能。评分演示时老师多半会导入一大批照片,
|
||||
滚动一卡,前面做得再多也减分。这是本题技术含量最高的部分(见难点 1)。
|
||||
- 智能标签的合理做法:先把手动标签的完整链路做穿(加标签→存库→按标签搜索),
|
||||
智能标签只是标签的另一个生产者——接 ONNX 后生成的标签走完全相同的
|
||||
存储/搜索链路。架构对了,"智能"是插件;架构不对,"智能"意味着重写。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 1:缩略图异步加载。** 直观的写法:遍历文件夹,每张
|
||||
`QPixmap(path).scaled(...)` 塞进 `QListWidget`。一张 12MP 的 JPEG 解码要
|
||||
几十上百毫秒,100 张照片主线程就冻结十几秒。正确做法分三层:
|
||||
|
||||
1. 解码离开主线程:用 `QThreadPool` + `QRunnable`(或 `QtConcurrent::run`)
|
||||
在后台解码并缩放,完成后发信号把缩略图(QImage,跨线程传值安全)交回主线程。
|
||||
注意 QPixmap 不能在非 GUI 线程创建,后台一律用 QImage,回主线程再转。
|
||||
2. 读小不读大:`QImageReader reader(path); reader.setScaledSize(QSize(200,200));`
|
||||
——JPEG 可以按比例降采样解码,比"解全图再 scaled"快一个数量级、省数倍内存。
|
||||
这是本题最值得记住的一个 API。
|
||||
3. 缩略图磁盘缓存:首次生成后存到缓存目录(文件名用路径哈希),
|
||||
第二次打开应用直接读缓存。加分项。
|
||||
|
||||
视图层用 `QListView::IconMode` + 自定义 model(配合按需供数),
|
||||
不要用 `QListWidget` 一次性塞几千个 item。
|
||||
|
||||
**难点 2:SQLite 表设计(标签是多对多)。** 三张表就够:
|
||||
`photos(id, path, date, rating, favorite)`、`tags(id, name)`、
|
||||
`photo_tags(photo_id, tag_id)`。"按标签找图"就是一个 JOIN,这本身就是
|
||||
题面"倒排索引"的数据库实现,答辩时可以直接这样讲。组合筛选(日期范围 +
|
||||
评分 + 标签)就是动态拼 WHERE 子句。不想上 QtSql 的同学用 JSON + 内存
|
||||
`QHash<QString标签, QSet<int>图片id>` 也能达标,但数据量大后启动变慢。
|
||||
|
||||
**难点 3:ONNX Runtime 集成(可选层,放最后做)。** 流程:下载 squeezenet /
|
||||
mobilenet 分类 ONNX 模型 → C++ 里 `Ort::Session` 加载 → 图片缩放到 224×224、
|
||||
归一化成 float tensor → 推理 → top-5 类别按 ImageNet 标签表映射成中文标签存库。
|
||||
坑主要在预处理:通道序(NCHW vs NHWC)、归一化均值方差要和模型训练时一致,
|
||||
不一致不报错但输出全错。人脸分组(聚类)工作量再上一个台阶,
|
||||
建议只有已完成全部基础功能、确有余力的同学做。
|
||||
|
||||
**难点 4:非破坏性编辑 + 撤销。** 题面点了命令模式:每个编辑操作
|
||||
(旋转/裁剪/调色)封装成命令对象压栈,undo 出栈反做。Qt 有现成的
|
||||
`QUndoStack`/`QUndoCommand`,不必自己实现栈管理。"非破坏性"最省事的实现:
|
||||
不覆盖原图,编辑结果另存副本,或只存操作参数列表、显示时重放。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| 导入文件夹时界面冻死 | 主线程同步解码大图 | QImageReader::setScaledSize + 线程池,见难点 1 |
|
||||
| 后台线程崩溃:`QPixmap: It is not safe...` | 子线程创建了 QPixmap | 子线程只用 QImage |
|
||||
| 内存涨到几个 GB | 缓存了全尺寸 QImage | 只留缩略图;看大图时才解码全图,且用 `QPixmapCache` 限额 |
|
||||
| 部分照片没有拍摄日期 | 截图/微信图片本无 EXIF | 降级链:EXIF DateTimeOriginal → 文件修改时间;Qt 自带 EXIF 读取很弱,完整 EXIF 建议引入 TinyEXIF/easyexif(单头文件) |
|
||||
| 手机竖拍照片横过来了 | 没处理 EXIF Orientation | `QImageReader::setAutoTransform(true)`,一行解决 |
|
||||
| 搜索一次卡一秒 | 每次搜索全量扫描 | 元数据进 SQLite 建索引,或内存哈希 |
|
||||
| 删除照片后列表错乱 | 直接操作文件没同步库 | 所有增删走数据层统一入口,UI 只发请求 |
|
||||
| 中文路径图片读不出 | 用了 `std::string`/`fopen` 链路 | 全程用 QString/QFile;给 OpenCV/stb 传路径时注意 UTF-8 转换 |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(1 天)**:选文件夹 → 列出所有图片文件 → 同步加载 20 张的缩略图网格 +
|
||||
双击全屏看图(先不管性能,打通视觉闭环)。
|
||||
2. **M2(1–2 天)**:把 M1 的加载改成 QImageReader 降采样 + 线程池异步。
|
||||
验收标准:导入 500 张照片,滚动不卡。这是本题技术难度最高的一步,值得花足时间。
|
||||
3. **M3(1 天)**:元数据层——SQLite/JSON 三表结构,手动标签、星级、收藏的
|
||||
增删改查 + 持久化。
|
||||
4. **M4(1 天)**:搜索框(文件名/标签实时过滤)+ 组合筛选面板(日期范围/评分/
|
||||
收藏)。到此为完整达标的相册管理器。
|
||||
5. **M5(加分)**:虚拟相册、旋转/裁剪/亮度对比度编辑 + QUndoStack 撤销、
|
||||
缩略图磁盘缓存。
|
||||
6. **M6(大加分,量力)**:ONNX 自动标签;人脸检测分组(可参考项目 11 的
|
||||
OpenCV 部分,环境成本也相同)。
|
||||
|
||||
### 版本说明
|
||||
|
||||
- `QImageReader::setAutoTransform` 在 Qt5 默认 false(Qt6 默认 true)。
|
||||
课程环境下不手动打开,竖拍照片就会横着显示。
|
||||
- QtSql 的 SQLite 驱动随 Qt 发行版自带,无需另装 SQLite;MySQL 等驱动不自带,
|
||||
不要把时间花在配数据库服务上,本题 SQLite 文件库就够了。
|
||||
|
||||
---
|
||||
|
||||
## 06. 多功能抽奖与随机点名程序 ⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
六个里唯一的四星题,功能面窄,但考正确性:随机算法是否真的公平、
|
||||
不重复抽取是否真的不重复、权重抽奖概率是否真的和权重成正比。
|
||||
适合想稳妥完成、把精力花在细节上的同学。注意题面的算法项
|
||||
(Fisher-Yates、轮盘赌)是答辩必问点,只写一个 `rand()%n` 过不了关。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- UI 上的"滚动"只是演出,抽取结果在点"开始"那一刻就该定了。合理的架构:
|
||||
按下开始 → 先用算法抽出结果 → QTimer 驱动名字快速轮播制造悬念 →
|
||||
按下停止 → 动画减速定格到预定结果。让"停止那一刻的随机数"决定中奖者,
|
||||
公平性就依赖用户手速,答辩说不清。这一条想通了,逻辑层(抽取)和
|
||||
表现层(动画)自然就分开了。
|
||||
- 三个算法是题目的核心,各 20 行以内,务必自己写、写到能白板复现:
|
||||
1. Fisher-Yates 洗牌(从 N 人不重复抽 M 人:洗牌取前 M);
|
||||
2. 轮盘赌选择(权重抽奖:随机数落在累计权重区间上);
|
||||
3. 随机数源:`QRandomGenerator::global()->bounded(n)`,自动用系统熵播种。
|
||||
`rand()` 不 srand 的话每次运行结果相同,演示时会当场暴露。
|
||||
- 名单管理(增删改、txt/CSV 导入、持久化)是体力活,但中文编码有一个
|
||||
几乎必踩的坑,见下。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 1:CSV/文本导入的编码。** Windows Excel 导出的 CSV 默认 GBK/ANSI
|
||||
编码,`QTextStream` 按默认设置读(Qt5 是 Locale,而记事本另存的 UTF-8 又带
|
||||
BOM),中文名很容易乱码。稳妥做法:读入字节后探测——有 UTF-8 BOM 按 UTF-8,
|
||||
否则先按 UTF-8 严格解码、失败再退 GBK(`QTextCodec::codecForName("GBK")`)。
|
||||
另外 CSV 字段可能带引号包裹和内嵌逗号,课程场景用 `line.split(',')` 可以接受,
|
||||
但至少要 `trimmed()` 并跳过空行。(xlsx 是压缩包格式,Qt 原生读不了,
|
||||
题面说"CSV 或 xlsx",选 CSV 即可,不必为 xlsx 引第三方库。)
|
||||
|
||||
**难点 2:滚动动画的两种实现,选简单的那种。** 题面提示了 QPropertyAnimation,
|
||||
但对"名字快速切换"这个效果,QTimer + 每 tick 换 QLabel 文本才是对口工具
|
||||
(30–50ms 一换足够快)。QPropertyAnimation 适合做结果揭晓的放大/滑入动画
|
||||
(配合 `QGraphicsOpacityEffect` 或对 geometry 做动画)。"减速定格"用变周期
|
||||
QTimer:每次 tick 后 `interval *= 1.15`,超过 300ms 时停在预定结果,
|
||||
二十行代码就有不错的现场效果。
|
||||
|
||||
**难点 3:不重复抽取的状态一致性。** "抽过的人从池子里移除"说起来简单,
|
||||
坑在多处状态要同步:候选池、已中奖列表、UI 上两个列表控件。要先定好
|
||||
数据以谁为准:数据层一个 `QList<Person>` + 一个 `QSet<int> drawnIds`,
|
||||
两个视图都从它渲染。再叠加"中途重置""切换可重复模式""删除已中奖的人"
|
||||
这些操作时才不会乱。先想清数据结构,UI 只是它的投影。
|
||||
|
||||
**难点 4:权重抽奖 + 不重复的组合。** 各自单独都好写,组合时注意:抽走一人后
|
||||
总权重要重算(或轮盘赌遍历时跳过已中者)。验证方法值得写进报告:
|
||||
循环抽 10 万次统计频率,和权重比例对照,这个小实验就是公平性的答辩证据。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| 导入名单中文乱码 | Excel CSV 是 GBK | 编码探测降级链,见难点 1 |
|
||||
| 每次运行抽中的人一样 | `rand()` 没播种 | 换 `QRandomGenerator::global()` |
|
||||
| "不重复"偶尔重复 | 从 UI 控件读名单,删除项后索引错位 | 状态收口到数据层,UI 只渲染 |
|
||||
| 滚动动画卡顿 | tick 里做了重活(如重排全列表) | tick 只改一个 QLabel 文本 |
|
||||
| 多人抽取结果有重复 | 循环 M 次独立随机 | Fisher-Yates 洗牌取前 M |
|
||||
| 全屏后布局错乱 | 固定坐标写死 | 全程用布局管理器;全屏用 `showFullScreen()`,记得处理 Esc 退出 |
|
||||
| 音效不响 | 路径/模块问题 | `QSoundEffect` 只支持 WAV,不解码 MP3;`QT += multimedia`;资源走 qrc |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(半天)**:控制台级验证——名单硬编码,Fisher-Yates 抽 M 人、轮盘赌
|
||||
带权抽 1 人,qDebug 打印 + 万次频率统计自检。算法先于界面。
|
||||
2. **M2(1 天)**:主界面:名单列表(增删改)+ txt/CSV 导入(含编码处理)+
|
||||
JSON 持久化。
|
||||
3. **M3(1 天)**:单人抽取全流程:开始滚动 → 停止减速定格 → 结果进
|
||||
"已中奖"列表;可重复/不可重复开关。
|
||||
4. **M4(半天)**:多人抽取模式 + 中奖记录导出 txt + 抽奖/点名模式切换文案。
|
||||
5. **M5(加分)**:权重列 + 轮盘赌抽奖、分组抽奖、结果揭晓 QPropertyAnimation
|
||||
动画、背景音乐音效、全屏模式、自定义背景图。
|
||||
|
||||
### 版本说明
|
||||
|
||||
- `QTextCodec` 在 Qt6 移入 core5compat 兼容模块(Qt6 正统是
|
||||
`QStringConverter`/`QStringDecoder`)。课程环境 Qt 5.14 直接用 QTextCodec 即可。
|
||||
- `QRandomGenerator` 需要 Qt ≥ 5.10,课程环境没问题;教程里出现
|
||||
`qsrand/qrand`(已废弃)的可以跳过不看。
|
||||
|
||||
---
|
||||
|
||||
## 08. 双人飞机大战 ⭐⭐⭐⭐⭐
|
||||
|
||||
### 定位
|
||||
|
||||
一道实时程序架构题:游戏循环、大量对象的生命周期管理、键盘多键并发、
|
||||
碰撞检测,和前五个事件驱动的表单程序是完全不同的思维方式。
|
||||
它是六个里代码量最大、最容易烂尾的:功能清单(双人、三模式、道具、
|
||||
BOSS、弹幕)是开放的,没有里程碑约束很容易把时间耗在素材和关卡设计上。
|
||||
另一面是演示效果最好,适合愿意投入的同学。
|
||||
|
||||
### 题目重点
|
||||
|
||||
- 先选定框架路线,两条路二选一,不要混:
|
||||
- **路线 A:QGraphicsView 框架**(题面推荐)——`QGraphicsScene` 管对象、
|
||||
`QGraphicsItem` 派生飞机/子弹、场景自带 `collidingItems()` 碰撞检测。
|
||||
省很多底层代码,但要理解 item 坐标系和场景坐标系的区别。
|
||||
- **路线 B:QWidget + QPainter 手绘**——一个大 widget,`paintEvent` 里画所有
|
||||
对象,自己维护对象列表和矩形碰撞。原始一些,但每一步都在自己掌控里,
|
||||
配套教材 ch10 的绘图知识直接够用。
|
||||
- 建议:跟着题面走 A;如果 QGraphicsView 两天内没找到感觉,及时退到 B。
|
||||
B 路线做完,好过 A 路线做一半。
|
||||
- 游戏循环是全部逻辑的驱动源:一个 `QTimer`(16ms ≈ 60FPS)驱动每帧
|
||||
`处理输入 → 移动所有对象 → 生成敌机/子弹 → 碰撞检测 → 清理死亡对象 → 重绘`。
|
||||
所有会动的东西都由这一个 timer 统一驱动。给每个子弹/敌机各开一个 QTimer
|
||||
是常见错误,几百个 timer 会把事件循环拖垮,而且相位不齐。
|
||||
- 对象生命周期是本题隐含的 C++ 考点:子弹飞出屏幕、敌机被击落,都要从
|
||||
场景移除并 delete。忘删,内存与遍历量持续膨胀,越玩越卡;
|
||||
删早了(还在容器遍历中),程序崩溃。见难点 3。
|
||||
|
||||
### 技术难点拆解
|
||||
|
||||
**难点 1:键盘多键同时按(双人模式的关键)。** 直接在 `keyPressEvent` 里
|
||||
`player.move()` 有两个问题:一是依赖系统键盘重复,第一下和连发之间有停顿,
|
||||
手感卡;二是玩家1 按住 W 时玩家2 再按方向键,系统键盘重复只服务最后按下的键,
|
||||
另一个玩家会直接失控。标准解法是按键状态表 + 游戏循环采样:
|
||||
|
||||
```cpp
|
||||
QSet<int> pressedKeys;
|
||||
void keyPressEvent(QKeyEvent *e) override {
|
||||
if (e->isAutoRepeat()) return; // 屏蔽系统自动重复
|
||||
pressedKeys.insert(e->key());
|
||||
}
|
||||
void keyReleaseEvent(QKeyEvent *e) override {
|
||||
if (e->isAutoRepeat()) return;
|
||||
pressedKeys.remove(e->key());
|
||||
}
|
||||
// 游戏循环每帧:
|
||||
if (pressedKeys.contains(Qt::Key_W)) p1.moveUp();
|
||||
if (pressedKeys.contains(Qt::Key_Up)) p2.moveUp(); // 互不干扰,天然支持斜向
|
||||
```
|
||||
|
||||
双人模式能不能玩取决于这一段,建议一开始就按这个写法来。另外游戏主
|
||||
widget 要 `setFocusPolicy(Qt::StrongFocus)`,否则按键事件不会分发到它。
|
||||
|
||||
**难点 2:碰撞检测的量级与精度。**
|
||||
|
||||
- 起步:矩形相交(`QRect::intersects` 或 QGraphicsView 的 `collidingItems`)
|
||||
完全够用。QGraphicsItem 默认按 `boundingRect`+`shape()` 判定;飞机贴图
|
||||
四角是透明的,会出现"看着没碰到就爆了"——重写 `shape()` 返回缩小的
|
||||
`QPainterPath`,或直接把判定框缩成机身核心(弹幕游戏的通行做法,
|
||||
玩家只会觉得手感变好)。
|
||||
- 量级:每帧"所有子弹 × 所有敌机"是 O(N²),课程规模(500 个对象以内)
|
||||
暴力检测没有问题——先暴力,卡了再优化。题面的四叉树是加分项,报告里
|
||||
给出"对象数-帧耗时"对比数据,比只谈复杂度有说服力。
|
||||
- 帧率稳定性:`QTimer` 精度默认约 15ms 且会漂移,位移量写死"每帧 5 像素"
|
||||
在卡顿时会变慢。进阶写法:用 `QElapsedTimer` 测真实帧间隔 dt,
|
||||
位移 = 速度 × dt。及格线不强求,答辩能说出这个问题在哪就是加分。
|
||||
|
||||
**难点 3:容器遍历中删除对象。** 每帧要清理出界子弹/死亡敌机,
|
||||
`for (auto *b : bullets) { if (出界) { bullets.removeOne(b); delete b; } }`
|
||||
是遍历中修改容器,未定义行为,迟早崩溃。两个安全做法:
|
||||
一,标记-清扫:遍历只打 `dead` 标记,遍历结束后统一
|
||||
`removeIf + delete`(或 `QMutableListIterator`);
|
||||
二,碰撞回调里不直接 delete 对方,只标记。另外 QGraphicsScene 删 item
|
||||
要 `scene->removeItem(item); delete item;`,只 delete 的话场景还留着悬空指针。
|
||||
这是本题最常见的崩溃原因,写清理代码前先把这段看明白。
|
||||
|
||||
**难点 4:对象设计与生成节奏。** 建一个基类(如 `Entity`:位置/速度/血量/
|
||||
`advance()`/`rect()`),玩家机、敌机、子弹、道具全部派生。多态在这里不是
|
||||
为了展示技巧,是实际需要:游戏循环只面对 `QList<Entity*>`。敌机生成用
|
||||
"生成计时器 + 关卡脚本"(第几秒出什么阵型),数据驱动(写在表/JSON 里)
|
||||
比硬编码 if 好调平衡。关于素材:飞机/子弹贴图用开源素材包(如 Kenney.nl
|
||||
免费素材),自己画像素图非常耗时;实在没素材,纯色几何图形(QPainter
|
||||
画三角形飞机)完全不影响技术评分。
|
||||
|
||||
**难点 5:双人对战模式的裁剪。** 题面三模式(单人/合作/对战)全做工作量很大。
|
||||
架构上留好口子即可:碰撞规则表里"玩家子弹是否伤害另一玩家"是一个 bool,
|
||||
合作为 false、对战为 true——对战模式本质是碰撞规则的一次配置变更。
|
||||
想通这层,第三模式的边际成本很低;想不通就先做单人+合作。
|
||||
|
||||
### 高频坑与排查
|
||||
|
||||
| 症状 | 真实原因 | 排查/解法 |
|
||||
|---|---|---|
|
||||
| 按方向键飞机不动 | 焦点不在游戏 widget 上 | `setFocusPolicy(Qt::StrongFocus)`;场景/视图结构下注意谁拿焦点 |
|
||||
| 移动一顿一顿 / 双人互相抢键 | 直接在 keyPressEvent 移动 | 按键状态表 + 循环采样,见难点 1 |
|
||||
| 玩一会儿越来越卡 | 出界子弹没清理,对象只增不减 | 每帧清理;调试时在窗口标题实时显示对象数,方便发现泄漏 |
|
||||
| 随机崩溃在碰撞/清理附近 | 遍历中删除 / 双重 delete | 标记-清扫,见难点 3 |
|
||||
| 子弹出生位置不对 | item 坐标系与场景坐标系混淆 | 生成子弹用 `mapToScene()`/`item->scenePos()` 换算 |
|
||||
| 看着没碰到就判死 | 判定框是含透明边的整图矩形 | 重写 `shape()` 或缩小判定矩形 |
|
||||
| 背景滚动有裂缝 | 两张背景图衔接坐标差 1 像素 / 非整数位移 | 两张图首尾相接循环复位;位移取整 |
|
||||
| 全屏炸弹一放帧率骤降 | 一帧内 delete 几百对象 + 特效 | 分帧清理,或炸弹只做标记、由正常清理流程消化 |
|
||||
| CPU 占一核 100% | timer 间隔设 0 或忙循环 | 16ms 间隔足够;`QGraphicsView` 的 `setViewportUpdateMode` 用默认值即可 |
|
||||
|
||||
### 建议启动路径(里程碑)
|
||||
|
||||
1. **M1(1 天)**:窗口 + 游戏循环 timer + 一架能被 WASD 流畅操控的飞机
|
||||
(按键状态表一步到位)。这一步的手感决定整个项目的手感。
|
||||
2. **M2(1 天)**:玩家子弹(按住 J 连发、出界清理)+ 敌机从顶部随机生成
|
||||
直线下落 + 子弹打敌机的碰撞与爆炸消失。到这里游戏已经成立。
|
||||
3. **M3(1 天)**:敌机撞玩家/敌弹打玩家 → 生命值与无敌闪烁 → 得分显示 →
|
||||
Game Over 判定。加第二名玩家(方向键 + 小键盘0),合作模式成立。
|
||||
4. **M4(1 天)**:滚动背景、开始菜单/暂停/结束三场景切换(简单 FSM:
|
||||
一个 enum + QStackedWidget 或场景切换)、最高分持久化(QSettings)。
|
||||
5. **M5(加分)**:道具掉落、BOSS(多阶段血条 + 正弦/极坐标弹幕)、
|
||||
对战模式(碰撞规则开关)、音效(QSoundEffect + WAV)。
|
||||
6. 提醒一条执行纪律:M2 没完成前不做素材、不写 BOSS。本题烂尾的原因
|
||||
几乎都是顺序反了。
|
||||
|
||||
### 版本说明
|
||||
|
||||
- 本题用到的 QGraphicsView/QPainter/事件系统在 Qt5/Qt6 间差异很小,
|
||||
是六题中版本风险最低的。注意音效走 `QSoundEffect`(只支持 WAV),
|
||||
背景音乐才用 QMediaPlayer——后者在 Qt6 的差异见项目 04 的版本说明。
|
||||
- 高分屏(Windows 缩放 150%)下坐标可能被缩放干扰,演示机若有此问题,
|
||||
入口处 `QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)`(Qt 5.14)
|
||||
或环境变量 `QT_SCALE_FACTOR=1` 兜底。
|
||||
|
||||
---
|
||||
|
||||
## 怎么选、怎么起步
|
||||
|
||||
- 想稳妥拿分、不被环境问题拖住:选 **02 售卖机** 或 **06 抽奖点名**。
|
||||
纯 Qt 标准模块,问题集中在设计和细节,本文的排查表能覆盖大部分。
|
||||
- 想做完自己还能用:选 **04 音乐播放器** 或 **05 相册管理**。
|
||||
多媒体和性能两个方向各有一个硬难点(异步元数据 / 异步缩略图),
|
||||
啃下来收获扎实。
|
||||
- 接受环境风险、想上技术高度:选 **11 面部识别**。第一周先过环境验证(M1),
|
||||
过不了及时降级到图片识别版或换题。
|
||||
- 看重答辩演示效果:选 **08 飞机大战**。用里程碑约束进度,M2 前不碰素材。
|
||||
|
||||
无论选哪个:第一周结束时必须有一个能运行的 M1。这批项目的失败方式高度一致:
|
||||
前两周画界面调样式,最后一周才发现核心链路(能出声/能链上库/状态机能转/
|
||||
键盘不卡)有硬问题。先做最难的部分。
|
||||
Reference in New Issue
Block a user