刻飛機(jī)大戰(zhàn):掌握QGraphicsView游戲架構(gòu)與碰撞檢測)
簡介這份Qt飛機(jī)大戰(zhàn)游戲壓縮包是基于Qt框架實(shí)現(xiàn)的完整飛機(jī)大戰(zhàn)項(xiàng)目工程面向Qt初學(xué)者和游戲開發(fā)愛好者幫助理解2D游戲從界面搭建到邏輯控制的完整流程。包內(nèi)共223個(gè)文件壓縮包大小6.21MB主要包含105個(gè).h頭文件、32個(gè).cpp源文件、60個(gè)PNG圖片資源、5個(gè)WAV與2個(gè)OGG音頻以及UI界面、GLSL著色器、pro工程文件和qrc資源文件等覆蓋了游戲圖形、音效、界面和場景配置等各個(gè)開發(fā)環(huán)節(jié)。資源已有280人學(xué)習(xí)下載。源碼按功能拆分出玩家、飛機(jī)、核心邏輯及人機(jī)/雙人AI相關(guān)模塊可清晰看到玩家操控、敵機(jī)生成、碰撞檢測、AI對(duì)戰(zhàn)等核心邏輯的實(shí)現(xiàn)方式結(jié)合QPainter繪圖、信號(hào)槽通信和Qt動(dòng)畫框架展示了如何在Qt中渲染飛機(jī)、子彈和爆炸效果并處理實(shí)時(shí)交互。整體代碼結(jié)構(gòu)清晰既適合配合Qt教程逐段研讀也適合作為課程設(shè)計(jì)或畢業(yè)設(shè)計(jì)的參考工程對(duì)提升Qt桌面應(yīng)用與游戲開發(fā)能力很有幫助。1. 先用 Qt 復(fù)刻一個(gè)飛機(jī)大戰(zhàn)值得嗎很多人看到「飛機(jī)大戰(zhàn)」四個(gè)字第一反應(yīng)是「這不就是小游戲嘛」。但這個(gè)用 Qt 寫的飛機(jī)大戰(zhàn)其實(shí)把一款游戲引擎最核心的幾個(gè)模塊都占了事件循環(huán)、場景與精靈、碰撞檢測、音效播放、狀態(tài)切換。跑一遍會(huì)發(fā)現(xiàn)整個(gè)游戲就建立在一個(gè)QGraphicsScene上你的戰(zhàn)機(jī)、敵機(jī)、子彈都是場景里的QGraphicsItem一百多行核心代碼能從按下按鍵到敵人爆炸完整跑通。適合有 C 基礎(chǔ)但沒接觸過 Qt 游戲開發(fā)的人正好用它來弄懂游戲循環(huán)在桌面框架里怎么落地也適合熟手快速復(fù)習(xí) Qt 的場景管理模型因?yàn)檎麄€(gè)項(xiàng)目拆開看就是一個(gè)縮水版的引擎架構(gòu)。2. 工程解剖QGraphicsView 框架下的游戲架構(gòu)2.1 目錄結(jié)構(gòu)先看文件怎么排拿到源碼第一步不是直接編譯而是先看目錄結(jié)構(gòu)。這個(gè)項(xiàng)目常見的排法是這樣qt-aircraft-game/ ├── CMakeLists.txt # 構(gòu)建腳本qmake 版則對(duì)應(yīng) .pro 文件 ├── resources/ │ ├── images/ # 戰(zhàn)機(jī)、敵機(jī)、子彈、背景 PNG │ └── sounds/ # 開火、爆炸、背景音樂等音頻 ├── include/ │ ├── mainwindow.h # 主窗口菜單、游戲重新開始入口 │ ├── gamescene.h # 游戲場景敵機(jī)生成、碰撞檢測、計(jì)分 │ ├── player.h # 玩家戰(zhàn)機(jī)鍵盤移動(dòng)與開火 │ ├── enemy.h # 敵機(jī)速度、生命值、分?jǐn)?shù) │ ├── bullet.h # 子彈方向、速度、越界邏輯 │ └── config.h # 全局參數(shù)場景尺寸、幀率、速度系數(shù) └── src/ ├── main.cpp ├── mainwindow.cpp ├── gamescene.cpp └── ...這里最值得看的是gamescene.h它承擔(dān)了「導(dǎo)演」的職責(zé)。整個(gè)游戲沒有傳統(tǒng)意義上的while循環(huán)而是由一個(gè)QTimer每隔約 16 毫秒觸發(fā)一次gameLoop()場景里的所有對(duì)象都用事件驅(qū)動(dòng)的方式推進(jìn)。如果你之前寫過多線程游戲循環(huán)會(huì)發(fā)現(xiàn) Qt 這套把「每幀更新」和「界面重繪」都塞進(jìn)了事件循環(huán)反而少了很多同步問題。資源目錄里的 PNG 通常要帶 Alpha 通道否則戰(zhàn)機(jī)背景會(huì)顯示成一個(gè)黑方塊。音效文件用 WAV 或 OGG 都可以但要留意 Qt 在不同平臺(tái)解碼器的支持情況這一塊我在第 4 章的避坑記錄里專門說。2.2 技術(shù)選型為什么是 QGraphicsView 而不是 QPainter在這個(gè)項(xiàng)目里用 QGraphicsView 是比直接用 QPainter 更合理的選型。直接QPainter::drawPixmap畫圖性能上限高但碰撞檢測、點(diǎn)擊命中、層級(jí)管理都要自己寫而 QGraphicsView 提供了場景Scene和條目Item的體系項(xiàng)目里的飛機(jī)、子彈天然就是一個(gè)個(gè) ItemcollidingItems()是現(xiàn)成的 API。整個(gè)架構(gòu)的軸線是QGraphicsView負(fù)責(zé)把場景繪制到窗口QGraphicsScene存對(duì)象QGraphicsItem代表個(gè)體。玩家按方向鍵時(shí)keyPressEvent把按鍵狀態(tài)記錄在 player 對(duì)象里下一幀在gameLoop()中根據(jù)狀態(tài)更新位置而不是「按下立刻移動(dòng)」。這樣能保證移動(dòng)速度與幀率掛鉤不會(huì)因?yàn)榘戳艘幌骆I盤就瞬移。參數(shù)上場景尺寸我一般固定為 480×700對(duì)應(yīng)config.h里的三個(gè)常量// config.h static const int SCENE_WIDTH 480; static const int SCENE_HEIGHT 700; static const int GAME_FPS 60; static const int BULLET_SPEED 8; // 子彈每幀移動(dòng) 8 像素 static const int ENEMY_SPEED_MIN 2; static const int ENEMY_SPEED_MAX 5;把速度跟幀率解耦的寫法是每個(gè)對(duì)象記錄pixPerFrame一幀移動(dòng) 8 像素60 FPS 下就是每秒 480 像素。如果你想調(diào)「游戲難度」改這里比去改循環(huán)邏輯要快得多。2.3 游戲循環(huán)誰在驅(qū)動(dòng)每一幀很多人第一次看 Qt 項(xiàng)目會(huì)找while (true)找不到就開始翻車。其實(shí)這個(gè)項(xiàng)目的循環(huán)藏在QTimer里// gamescene.cpp playTimer new QTimer(this); playTimer-setInterval(1000 / GAME_FPS); // 約 16ms 一幀 connect(playTimer, QTimer::timeout, this, GameScene::gameLoop); playTimer-start(); void GameScene::gameLoop() { updateBullets(); // 所有子彈按速度前移 updateEnemies(); // 敵機(jī)向下移動(dòng) checkCollisions(); // 碰撞檢測與爆炸音效 cleanupItems(); // 移除越界對(duì)象 updateScore(); // 刷新計(jì)分 }updateBullets()遍歷場景里的Bullet對(duì)象每個(gè)對(duì)象調(diào)用一次自身的advance()updateEnemies()類似。這種驅(qū)動(dòng)方式的優(yōu)點(diǎn)是代碼路徑非常清晰調(diào)試時(shí)打斷點(diǎn)也直觀缺點(diǎn)是如果某個(gè)對(duì)象提前被 delete下一幀再遍歷就會(huì)懸空指針?biāo)詂leanupItems()一定要晚于碰撞檢測執(zhí)行。關(guān)于碰撞我看到的常見實(shí)現(xiàn)里有一個(gè)取舍用shape()做像素級(jí)判定是好看的但性能較差多數(shù)小項(xiàng)目直接用QRectF的相交判定或者保留一張縮小后的碰撞圖hitbox把戰(zhàn)機(jī)框成一個(gè)小矩形。我一般會(huì)先暴力相交試跑發(fā)現(xiàn)誤判再換QPainterPath不要一上來就上像素級(jí)碰撞。3. 核心模塊拆解一個(gè)子彈從按鍵到碰撞的完整鏈路3.1 玩家戰(zhàn)機(jī)鍵盤狀態(tài)與開火玩家類繼承自QGraphicsPixmapItem鍵盤事件的響應(yīng)方式有兩種項(xiàng)目里常見的是在Player::keyPressEvent里標(biāo)記狀態(tài)然后在gameLoop的統(tǒng)一更新里消費(fèi)。這樣不會(huì)出現(xiàn)「按鍵被重復(fù)觸發(fā)導(dǎo)致速度加倍」的問題// player.cpp void Player::keyPressEvent(QKeyEvent *event) { if (event-key() Qt::Key_Left) isLeftPressed true; if (event-key() Qt::Key_Right) isRightPressed true; if (event-key() Qt::Key_Space) tryFire(); } void Player::advance() { int step 5; // 每幀移動(dòng) 5 像素 if (isLeftPressed x() 0) setPos(x() - step, y()); if (isRightPressed x() boundingRect().width() SCENE_WIDTH) setPos(x() step, y()); }advance()里的x()是QGraphicsItem自帶的方法返回當(dāng)前坐標(biāo)boundingRect()控制移動(dòng)邊界。tryFire()里加一個(gè)fireCooldown計(jì)數(shù)比如每 10 幀允許開一槍否則按住空格會(huì)變成機(jī)槍掃射破壞平衡。我一般還會(huì)在Player里留一個(gè)setInvincible(int frames)的接口被擊中后進(jìn)入 60 幀的無敵時(shí)間玩家狀態(tài)會(huì)在gameLoop里統(tǒng)一檢查。注意keyPressEvent只處理「按下」keyReleaseEvent把對(duì)應(yīng)狀態(tài)位清零否則會(huì)出現(xiàn)「松開鍵盤角色還往左飄」的黑匣子現(xiàn)象。3.2 敵機(jī)生成定時(shí)器驅(qū)動(dòng)的波次敵機(jī)不是在場景里寫死的而是由一個(gè)QTimer周期性調(diào)用spawnEnemy()生成。這個(gè)生成邏輯里我見過的經(jīng)典寫法是隨機(jī)取 X 坐標(biāo)隨機(jī)選敵機(jī)類型再用一個(gè)隨機(jī)的幀計(jì)數(shù)做「下一次生成間隔」// gamescene.cpp void GameScene::spawnEnemy() { Enemy *enemy new Enemy( QRandomGenerator::global()-bounded(SCENE_WIDTH - 50), -60.0 ); enemy-setSpeed(QRandomGenerator::global()-bounded(ENEMY_SPEED_MIN, ENEMY_SPEED_MAX)); scene()-addItem(enemy); connect(enemy, Enemy::shotDown, this, GameScene::addScore); }敵機(jī)初始 Y 設(shè)為負(fù)值是讓它從畫面上方看不見的地方開進(jìn)場景避免「憑空出現(xiàn)」的違和感。setSpeed決定這架敵機(jī)每幀向下移動(dòng)多少像素shotDown是敵機(jī)自己發(fā)出的事件通知場景加分——用信號(hào)槽而不是場景直接輪詢敵機(jī)狀態(tài)能讓后續(xù)新增多種敵機(jī)時(shí)不用改GameScene。進(jìn)一步的難度曲線會(huì)放在一個(gè)spawnInterval變量上每消滅一個(gè)波次間隔縮短 20 毫秒最低不低于 300 毫秒。這個(gè)數(shù)值可以在config.h里做成DIFFICULTY_STEP和DIFFICULTY_MIN_INTERVAL我給讀者留的修改點(diǎn)就在這。注意刪除敵機(jī)時(shí)要deleteLater()不能直接delete item因?yàn)?Qt 場景還在遍歷 Item 列表直接刪會(huì)觸發(fā)崩潰。3.3 碰撞檢測相交判定與音效反饋碰撞檢測是這個(gè)項(xiàng)目里最能體現(xiàn)「代碼順序決定體驗(yàn)」的地方// gamescene.cpp void GameScene::checkCollisions() { const QListQGraphicsItem * bullets scene()-items(); for (QGraphicsItem *bullet : bullets) { if (bullet-data(0) ! ROLE_BULLET) continue; QListQGraphicsItem * hits bullet-collidingItems(); for (QGraphicsItem *hit : hits) { if (hit-data(0) ROLE_ENEMY) { // 子彈命中敵機(jī) Enemy *enemy static_castEnemy *(hit); enemy-decreaseHp(bulletDamage); bullet-markForDeletion(); if (enemy-isDead()) { emit addScore(enemy-scoreValue()); playExplosionSound(); } break; } } } }這里用data(0)存角色標(biāo)記ROLE_BULLET、ROLE_ENEMY、ROLE_PLAYER用來過濾對(duì)象類型。collidingItems()默認(rèn)用shape()或boundingRect()做判定所以前面提到的碰撞框設(shè)計(jì)會(huì)直接影響手感。我把bulletDamage設(shè)為 1小型敵機(jī) 1 發(fā)擊毀中型敵機(jī) 3 發(fā)既有操作感又不至于讓玩家覺得刮痧。音效這邊開火和爆炸用QSoundEffect最合適因?yàn)樗虞d的是壓縮后的小文件延遲低背景音樂才用QMediaPlayer QMediaPlaylist循環(huán)播放。很多初學(xué)同學(xué)把背景音樂也拿QSoundEffect放會(huì)出現(xiàn)播放卡頓和句柄泄漏。3.4 計(jì)分與結(jié)算QSettings 存最高分游戲的結(jié)束邏輯發(fā)生在玩家戰(zhàn)機(jī)hp 0時(shí)場景彈出一個(gè)結(jié)算面板顯示當(dāng)前得分和最高分。最高分存法有幾種用QSettings寫配置文件是跨平臺(tái)里最省事的// gamescene.cpp QSettings settings(SomeCompany, AircraftGame); int lastHigh settings.value(highScore, 0).toInt(); if (currentScore lastHigh) { settings.setValue(highScore, currentScore); }我一般會(huì)在「游戲結(jié)束」和「重新開始」之間加一個(gè)resetGame()接口把所有對(duì)象的指針置空、計(jì)時(shí)器重啟、分?jǐn)?shù)清零。這里有個(gè)細(xì)節(jié)新的一局開始前要把舊場景里所有Item清空否則上一局的敵機(jī)還掛在場景里新玩家一出生就被包圍。清理用scene()-clear()但清空前要disconnect所有信號(hào)槽連接防止懸空信號(hào)。這就是最典型的一個(gè)「沒寫清理就內(nèi)存泄漏還連帶著崩潰」的坑。4. 編譯運(yùn)行與避坑指南從下載到首飛的完整鏈路4.1 環(huán)境準(zhǔn)備用哪個(gè) Qt 版本最能復(fù)現(xiàn)這個(gè)項(xiàng)目最常見的構(gòu)建環(huán)境是 Qt 5.15 或 Qt 6.x搭配 CMake。如果你只想快速跑通用 Qt Creator 打開CMakeLists.txt選擇對(duì)應(yīng)的 Kit比如 MinGW 64 位直接構(gòu)建即可。前提是Qt 的編譯器位數(shù)要和 CMake 指定的 Kit 一致否則鏈接階段會(huì)報(bào)一堆“未定義的引用”。如果是 qmake 的.pro工程打開.pro文件后 Qt Creator 會(huì)自動(dòng)識(shí)別也是直接構(gòu)建。二者差異不大唯一需要確認(rèn)的是QT core gui widgets multimedia這行在.pro里有沒有寫全漏了multimedia會(huì)導(dǎo)致音效相關(guān)類編譯直接失敗。4.2 構(gòu)建與運(yùn)行三種啟動(dòng)方式驗(yàn)證跑通一個(gè) Qt 游戲項(xiàng)目驗(yàn)證方式有幾種第一種是在 Qt Creator 里按Ctrl R走默認(rèn)的運(yùn)行時(shí)路徑日志窗口能看到 qDebug 輸出的分?jǐn)?shù)變化。第二種是在命令行單獨(dú)執(zhí)行構(gòu)建產(chǎn)物比如build/Release/qt-aircraft-game.exe這時(shí)如果程序報(bào)缺少Q(mào)t6Core.dll就把 Qt 的bin目錄加入PATH或者用windeployqt把運(yùn)行時(shí)文件復(fù)制到 exe 同目錄。第三種是調(diào)試模式F5打斷點(diǎn)看gameLoop是否每 16ms 觸發(fā)一次這是判斷計(jì)時(shí)器邏輯是否正常的最快方法。經(jīng)歷過這一步基本就能區(qū)分“環(huán)境問題”和“代碼問題”程序閃退先看臨時(shí)目錄里有沒有crash.log游戲能啟動(dòng)但沒有畫面八成是資源路徑寫的是絕對(duì)路徑換個(gè)機(jī)器就跑不起來了。這個(gè)項(xiàng)目里推薦用QDir::currentPath()拼接相對(duì)路徑或者用 Qt 的資源系統(tǒng):/images/player.png后者最省心。4.3 避坑記錄我在這個(gè)項(xiàng)目上踩過的 5 個(gè)坑下面按「現(xiàn)象 → 原因 → 解決」的格式記錄幾個(gè)高頻問題如果有同學(xué)在這個(gè)項(xiàng)目上卡住基本都逃不出這幾類。第一個(gè)坑中文亂碼?,F(xiàn)象是游戲里中文標(biāo)題或提示文字顯示成亂碼。原因是 MSVC 編譯器默認(rèn)按本地代碼頁讀取源碼而源碼文件是 UTF-8 存儲(chǔ)。解決方法是把源碼文件轉(zhuǎn)成 UTF-8 with BOM或者在CMakeLists.txt里加add_compile_options($$CXX_COMPILER_ID:MSVC:/utf-8)。這個(gè)是老項(xiàng)目里最高頻的問題沒有之一。第二個(gè)坑PNG 圖片帶黑底飛機(jī)顯示成黑方塊。原因是圖片本身不帶 Alpha 通道或者用了 JPEG 轉(zhuǎn)存的偽 PNG。解決方法是查看圖片屬性里的位深度用支持透明的重新導(dǎo)出并在代碼里用QPixmap的setMask兜底雖然效果一般但至少不是黑底。第三個(gè)坑跑起來發(fā)現(xiàn)子彈有時(shí)會(huì)穿過敵機(jī)尤其是全屏一堆子彈時(shí)。原因是碰撞檢測只在gameLoop的固定時(shí)刻判定一次子彈速度過快時(shí)一幀的位移超過敵機(jī)寬度就會(huì)跳過。解決方法是限制子彈速度上限或者把碰撞范圍從單點(diǎn)擴(kuò)展成「上一幀位置到當(dāng)前幀位置」的線段相交檢測。在我的經(jīng)驗(yàn)里先把速度限制在 10 像素/幀以內(nèi)體驗(yàn)已經(jīng)足夠線段檢測是性能兜底的方案。第四個(gè)坑音效播放導(dǎo)致界面卡頓?,F(xiàn)象是每次爆炸都卡一下。原因是用了QMediaPlayer播放短音效而這類對(duì)象創(chuàng)建和銷毀的開銷比較大。解決方法是改用QSoundEffect它在播放延遲和資源占用上都更適合短音效。背景音樂才用QMediaPlayer并且只創(chuàng)建一個(gè)實(shí)例常駐。第五個(gè)坑退出游戲時(shí)崩潰?,F(xiàn)象是點(diǎn)擊關(guān)閉窗口后進(jìn)程退出緩慢甚至直接段錯(cuò)誤。原因是析構(gòu)時(shí)QTimer還在運(yùn)行觸發(fā)了gameLoop而此時(shí)場景里的 Item 已被銷毀。解決方法是把定時(shí)器停止放在closeEvent或析構(gòu)函數(shù)第一行然后先clear()場景再銷毀播放器。5. 二次開發(fā)技巧三道改造題讓戰(zhàn)機(jī)長出新玩法拿到了源碼第一件事不是加新功能而是把config.h里的參數(shù)全部改成可配置項(xiàng)。我的習(xí)慣是建一個(gè)GameSettings單例類把SCENE_WIDTH、BULLET_SPEED、ENEMY_SPEED_MAX、spawnInterval這些常量收攏成int、double成員變量讓關(guān)卡腳本可以動(dòng)態(tài)改。這樣后面做任何玩法都不用翻源碼改常量了。三道適合上手的改造題是第一給玩家加護(hù)盾道具。做一個(gè)PowerUp類繼承QGraphicsPixmapItem用定時(shí)器每隔一段隨機(jī)時(shí)間把它刷到場景里當(dāng)玩家戰(zhàn)機(jī)和它碰撞時(shí)設(shè)置invincibleFrames 300。這個(gè)改造能訓(xùn)練你理解碰撞檢測和 Item 生命周期。第二做敵機(jī)彈幕。給Enemy加一個(gè)fireInterval用獨(dú)立的QTimer發(fā)射EnemyBullet子彈方向和玩家一樣向下移動(dòng)檢測它是否命中玩家。注意這里要復(fù)用ROLE_PLAYER的過濾邏輯否則玩家的子彈也會(huì)被敵人的子彈觸發(fā)碰撞。第三做關(guān)卡系統(tǒng)根據(jù)當(dāng)前分?jǐn)?shù)切換背景圖片、敵機(jī)速度和生成間隔。一個(gè)具體的實(shí)現(xiàn)技巧是新增任何 Item 類都遵循「創(chuàng)建時(shí)加入場景、銷毀時(shí)從場景移除」的原則并統(tǒng)一在gameLoop里通過data(0)過濾類型。只要維護(hù)好這個(gè)約定加新玩法就只需要寫新類不用動(dòng)原核心循環(huán)。驗(yàn)證方法是給gameLoop加一個(gè)qDebug() FPS: fps的計(jì)時(shí)輸出如果幀數(shù)穩(wěn)定在 5560說明你的改造沒有拖慢主循環(huán)。這個(gè)項(xiàng)目的代碼量不大正因如此它是練手 Qt 游戲架構(gòu)最好的素材。我拿到這類小項(xiàng)目后會(huì)強(qiáng)制自己先按避坑清單走一遍檢查資源路徑、檢查定時(shí)器生命周期、檢查碰撞框大小再開始動(dòng)代碼。這套檢查流程幫我少踩了至少一半的坑。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取