大戰(zhàn)完整指南:循環(huán)、碰撞與打包全解析)
簡介這是一份基于蘋果移動(dòng)操作系統(tǒng)平臺(tái)的飛機(jī)大戰(zhàn)完整項(xiàng)目代碼面向想入門游戲開發(fā)或?qū)W習(xí)SpriteKit與Objective-C的開發(fā)者也適合作為課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)的參考原型。項(xiàng)目以經(jīng)典打飛機(jī)玩法為藍(lán)本完整涵蓋游戲引擎調(diào)度、飛機(jī)/子彈/敵機(jī)對象管理、碰撞檢測、分?jǐn)?shù)系統(tǒng)與游戲狀態(tài)管理——從開始界面、游戲進(jìn)行到暫停與結(jié)束都提供了清晰實(shí)現(xiàn)同時(shí)加入音效與素材加載處理并配有較詳細(xì)的注釋便于對照理解每個(gè)模塊的實(shí)現(xiàn)細(xì)節(jié)。資源共39個(gè)文件包含Objective-C源碼、SpriteKit場景類、配置文件、圖片素材、音頻音效、界面布局文件等壓縮包僅1.28MB目錄結(jié)構(gòu)規(guī)整適合快速導(dǎo)入Xcode開發(fā)工具運(yùn)行。目前已有515人學(xué)習(xí)下載對希望通過實(shí)際案例掌握蘋果移動(dòng)端游戲開發(fā)流程、學(xué)習(xí)對象設(shè)計(jì)與事件處理的讀者來說是一份輕量而完整的實(shí)踐參考。1. 飛機(jī)大戰(zhàn)完整版代碼一個(gè)窗口背后藏著的三件事“飛機(jī)大戰(zhàn)完整版代碼”這個(gè)檢索詞在搜索引擎里能翻出一大堆結(jié)果但真正把代碼下下來照著跑的人一半卡在圖片路徑不對另一半卡在碰撞檢測一次扣兩次血。這說明大多數(shù)版本只是“能跑”不是“能完整跑”。一個(gè)完整的飛機(jī)大戰(zhàn)至少要同時(shí)解決主循環(huán)、主角控制、多機(jī)種敵機(jī)、子彈、道具、計(jì)分、音效、背景滾動(dòng)和勝負(fù)判定缺任意一塊游戲體驗(yàn)都會(huì)在某一分鐘突然垮掉。適合拿這個(gè)題目練手的人是想搞懂游戲循環(huán)怎么寫、碰撞怎么判、資源怎么管的從業(yè)者。讀完這一篇你不僅能照抄而且能知道每個(gè)參數(shù)為什么是這么設(shè)的踩過的坑我也一并標(biāo)出來。2. 用 Pygame 把飛機(jī)大戰(zhàn)跑起來窗口、循環(huán)與主角的 30 行骨架2.1 為什么是 Pygame一個(gè)窗口背后的完整體系做飛機(jī)大戰(zhàn)這種 2D 俯沖射擊游戲框架選型基本沒有懸念Pygame。它不是引擎不替你管理場景和組件但它把窗口創(chuàng)建、事件分發(fā)、圖片加載、聲音播放、矩形碰撞這些底層操作全部封裝成了足夠簡單的 API開發(fā)者只需要關(guān)注“循環(huán)里每一步干什么”。這對理解游戲本質(zhì)反而是好事因?yàn)闊o論用什么引擎游戲核心都繞不開那幾件事讀輸入、更新狀態(tài)、畫畫面、控制節(jié)奏。Pygame 的坐標(biāo)體系也需要先立起來。屏幕左上角是 (0, 0)x 軸向右y 軸向下。飛機(jī)往屏幕下方飛y 值就在變大。這個(gè)坐標(biāo)系和數(shù)學(xué)課上習(xí)慣的直角坐標(biāo)不一樣很多新手在這里翻車想讓敵機(jī)從上方出現(xiàn)結(jié)果設(shè)置成 y 0 的負(fù)值圖片直接跑到屏幕外面去了。我一般會(huì)先在紙上畫一個(gè) 480x700 的矩形框把主角出發(fā)位置、敵機(jī)出生位置全部標(biāo)上去再寫代碼這樣視覺上不會(huì)亂。另一個(gè)必須一開始就理解的機(jī)制是事件循環(huán)。Pygame 程序不是從上往下執(zhí)行完就退出的而是靠一個(gè) while 循環(huán)不斷重復(fù)“處理事件、更新邏輯、繪制畫面”三個(gè)步驟。每執(zhí)行一輪就是一幀pygame.time.Clock().tick(60)把幀率限制在每秒 60 幀。你按一下方向鍵程序在下一幀讀鍵盤狀態(tài)再下一幀把飛機(jī)位置挪動(dòng)幾像素連續(xù)看起來就是平滑移動(dòng)。2.2 游戲循環(huán)與主角讓飛機(jī)動(dòng)起來的兩件事先把最小可運(yùn)行版本搭出來。這段代碼只有窗口、主角、方向鍵移動(dòng)和邊界限制不涉及敵人和子彈但它是一切的基礎(chǔ)。import pygame import sys pygame.init() screen pygame.display.set_mode((480, 700)) pygame.display.set_caption(飛機(jī)大戰(zhàn)) # 加載圖片保證圖片和腳本在同一目錄 plane_img pygame.image.load(plane.png) plane_rect plane_img.get_rect() plane_rect.midbottom (240, 650) clock pygame.time.Clock() while True: # 1. 處理事件 for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() # 2. 讀取鍵盤狀態(tài)并更新主角位置 keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: plane_rect.x - 5 if keys[pygame.K_RIGHT]: plane_rect.x 5 # 限制主角不出左右邊界 if plane_rect.left 0: plane_rect.left 0 if plane_rect.right 480: plane_rect.right 480 # 3. 繪制畫面 screen.fill((0, 0, 0)) screen.blit(plane_img, plane_rect) pygame.display.flip() clock.tick(60)代碼里有一個(gè)容易忽視的設(shè)計(jì)pygame.key.get_pressed()讀的是鍵盤的實(shí)時(shí)狀態(tài)而不是單個(gè)按鍵事件。這意味著按住左鍵不放飛機(jī)就會(huì)持續(xù)向左移動(dòng)每一次循環(huán)里plane_rect.x - 5都會(huì)執(zhí)行。如果用KEYDOWN事件你只能得到按下那一瞬間的反饋松手也不會(huì)自動(dòng)停住體驗(yàn)會(huì)差很多。screen.fill((0, 0, 0))的作用是清空上一幀畫面不填的話上一幀的飛機(jī)圖像會(huì)殘留在屏幕上形成拖影。blit是“畫一塊圖像到窗口上”的操作參數(shù)是圖片和位置矩形。最后pygame.display.flip()才真正把畫好的內(nèi)容推送到顯示設(shè)備這一步漏掉窗口會(huì)一直黑屏。clock.tick(60)放在循環(huán)末尾作用是每幀睡眠到固定時(shí)長保證所有機(jī)器上運(yùn)行速度大致一致。不同機(jī)器如果沒這行飛機(jī)的移動(dòng)速度會(huì)跟著 CPU 性能走。2.3 圖片加載與碰撞檢測先給游戲立好邊界主角的plane_rect來自plane_img.get_rect()這個(gè)矩形對象是整個(gè)系統(tǒng)里最重要的東西。它不僅有 x、y、寬、高等位置屬性還承擔(dān)碰撞檢測的職責(zé)。Pygame 內(nèi)置了rect.colliderect(other_rect)、rect.collidepoint((x, y))等系列方法后面子彈打敵機(jī)、敵機(jī)撞主角全都是靠矩形相交判斷的。圖片本身的尺寸也要注意。我習(xí)慣把主角圖片控制在 80x80 以內(nèi)敵機(jī) 60x60 或 60x80這樣在小窗口上視覺不會(huì)失衡。plane_rect.midbottom (240, 650)是讓飛機(jī)的底部中心點(diǎn)定位在窗口底部偏上的位置這樣飛機(jī)看起來是“站在”場景里而不是貼在屏幕頂端。這個(gè)階段還要養(yǎng)成一個(gè)習(xí)慣圖片文件路徑不要寫死成plane.png后面會(huì)講到路徑問題但至少一開始你要讓圖片和腳本放在同一個(gè)目錄。運(yùn)行時(shí)如果報(bào)pygame.error: Unable to open file基本就是路徑對不上或文件名打錯(cuò)。先用相對路徑跑通后面再統(tǒng)一處理。3. 敵機(jī)、子彈與道具讓“完整版”工作量翻倍的三件事3.1 敵機(jī)不同機(jī)種、不同移動(dòng)方式怎么在一套代碼里兼容敵機(jī)系統(tǒng)是“完整版”和“demo”的分水嶺。demo 只會(huì)生成一種小飛機(jī)勻速向下飛完整版至少要有兩到三種機(jī)型重量、速度、血量、分值都不一樣。我一般用類繼承來解決這個(gè)問題寫一個(gè)Enemy基類把坐標(biāo)生成、向下移動(dòng)、出屏回收這些公共邏輯放在里面具體機(jī)型通過子類或配置參數(shù)區(qū)分。import random import pygame class Enemy(pygame.sprite.Sprite): def __init__(self, image_path, speed, hp, score_value): super().__init__() self.image pygame.image.load(image_path) self.rect self.image.get_rect() self.speed speed self.hp hp self.max_hp hp self.score_value score_value self.rect.x random.randint(0, 480 - self.rect.width) self.rect.y -self.rect.height def update(self): self.rect.y self.speed if self.rect.top 700: self.kill()hp用于記錄血量max_hp用于后面畫血條或判斷血量比例。self.rect.y -self.rect.height是讓敵機(jī)出現(xiàn)在屏幕正上方之外隨著 update 里 y 不斷增加它從頂部“飛”進(jìn)畫面。如果敵機(jī)在窗口內(nèi)出生然后往下飛玩家會(huì)感覺畫面很突兀像是在天上憑空刷怪。random.randint控制出生橫坐標(biāo)保證每架敵機(jī)的橫向位置隨機(jī)。self.kill()是 Pygame 精靈組的特性。當(dāng)敵機(jī)飛出屏幕底部時(shí)調(diào)用它把自身從精靈組中移除否則列表越積越長內(nèi)存開銷越來越大幀率會(huì)逐漸下降。這個(gè)回收機(jī)制是精靈組比普通 list 管理對象方便的核心原因。一個(gè)常見的配置參數(shù)表可以這么設(shè)供你參考機(jī)種圖片尺寸速度血量分值小型機(jī)50x504~61100中型機(jī)70x702~33300大型機(jī)100x1001~28800你還可以讓大型機(jī)在血量低于一半時(shí)分裂成兩架小型機(jī)但那是進(jìn)階玩法先不展開。這里給兩個(gè)建議其一速度不要用固定整數(shù)用random.randint(4, 6)敵人速度略微波動(dòng)會(huì)讓玩家覺得“每局手感不一樣”其二敵機(jī)刷新間隔不要寫死后面測難度曲線時(shí)用得分去動(dòng)態(tài)調(diào)整。3.2 子彈發(fā)射節(jié)奏、彈速與屏幕邊界回收子彈系統(tǒng)的核心參數(shù)有三個(gè)發(fā)射間隔、子彈速度、傷害值。發(fā)射間隔決定了玩家按一下空格能射出多少發(fā)速度決定了彈道看起來是“射出去”還是“飄出去”。我常用的值是間隔 150~200 毫秒速度 12~15 像素每幀60 幀下約 720~900 像素每秒傷害 1。這個(gè)配置在小窗口上彈道清晰也不會(huì)快到無法躲避。class Bullet(pygame.sprite.Sprite): def __init__(self, x, y, speed15, damage1): super().__init__() self.image pygame.image.load(bullet.png) self.rect self.image.get_rect() self.rect.centerx x self.rect.bottom y self.speed speed self.damage damage def update(self): self.rect.y - self.speed if self.rect.bottom 0: self.kill()子彈的出生位置self.rect.centerx x、self.rect.bottom y是兩個(gè)容易被忽略的細(xì)節(jié)。如果直接用rect.x和rect.y子彈會(huì)從飛機(jī)的左上角冒出來看起來像是從飛機(jī)屁股后發(fā)射。而用centerx和bottom子彈正好從飛機(jī)頭部中央射出視覺上才正確。這里y是傳進(jìn)來的飛機(jī)頭部 y 坐標(biāo)所以bottom y表示子彈底部貼著飛機(jī)頭部位置。子彈回收條件和敵機(jī)相反敵機(jī)飛出底部要清除子彈飛出頂部要清除。不回收的子彈會(huì)無限向上飄幾輪射擊后整個(gè)列表有上千個(gè)物體每幀都要做碰撞檢測幀率崩盤是必然的。發(fā)射節(jié)奏由誰控制我一般是記錄一個(gè)last_shot_time每幀判斷當(dāng)前時(shí)間減上次發(fā)射時(shí)間是否大于間隔current_time pygame.time.get_ticks() if current_time - last_shot_time 150 and keys[pygame.K_SPACE]: bullet Bullet(plane_rect.centerx, plane_rect.top) bullets.add(bullet) last_shot_time current_timepygame.time.get_ticks()返回程序啟動(dòng)后的毫秒數(shù)用它來做時(shí)間間隔判斷比用幀數(shù)可靠。幀數(shù)受幀率波動(dòng)影響而 get_ticks 是真實(shí)時(shí)間不會(huì)因機(jī)器性能不同而改變。這個(gè)思路在無敵幀、連擊判定、道具持續(xù)時(shí)間里都會(huì)反復(fù)用到。3.3 道具增強(qiáng)火力與恢復(fù)生命如何不破壞難度曲線道具系統(tǒng)是完整版里最需要克制設(shè)計(jì)的部分。常見道具三類火力增強(qiáng)雙發(fā)或三發(fā)、回血、護(hù)盾。道具掉落方式有兩種敵機(jī)被擊毀后概率掉落或每隔固定時(shí)間在屏幕頂部隨機(jī)生成。我推薦前一種因?yàn)榈谰叩袈渑c玩家擊殺行為掛鉤玩家會(huì)覺得“是我自己打出來的”而不是白撿的。掉落概率控制在 15%~20%太高了玩家每局都滿火力敵機(jī)難度形同虛設(shè)太低則道具系統(tǒng)形同虛設(shè)。class PowerUp(pygame.sprite.Sprite): def __init__(self, x, y, power_type): super().__init__() self.power_type power_type # double, heal, shield self.image pygame.image.load(fpower_{power_type}.png) self.rect self.image.get_rect() self.rect.center (x, y) self.speed 3 def update(self): self.rect.y self.speed if self.rect.top 700: self.kill()火力增強(qiáng)的效果不是直接把子彈速度翻倍而是改變主角的發(fā)射方式。最簡單的實(shí)現(xiàn)是給主角加一個(gè)fire_level屬性發(fā)射時(shí)根據(jù)等級(jí)拆成多組子彈坐標(biāo)def fire(plane_rect, fire_level, bullets): if fire_level 1: bullets.add(Bullet(plane_rect.centerx, plane_rect.top)) elif fire_level 2: bullets.add(Bullet(plane_rect.centerx - 15, plane_rect.top)) bullets.add(Bullet(plane_rect.centerx 15, plane_rect.top)) elif fire_level 3: bullets.add(Bullet(plane_rect.centerx, plane_rect.top)) bullets.add(Bullet(plane_rect.centerx - 25, plane_rect.top, speed12)) bullets.add(Bullet(plane_rect.centerx 25, plane_rect.top, speed12))注意speed12是關(guān)鍵字參數(shù)這里中間子彈速度 15、兩側(cè)子彈速度 12形成一束彈道的層次感。這個(gè)細(xì)節(jié)不調(diào)也沒什么但調(diào)了之后彈幕的視覺密度會(huì)有明顯提升?;鹆Φ燃?jí)要有上限一般三級(jí)封頂且被撞一次后降一級(jí)。這樣玩家既享受成長感又不會(huì)無敵到失去挑戰(zhàn)性。道具的持續(xù)時(shí)間和拾取碰撞也要單獨(dú)處理?;匮妥o(hù)盾是一次性生效直接改主角屬性火力增強(qiáng)如果是定時(shí)持續(xù)則需要記錄power_end_time每幀檢查當(dāng)前時(shí)間是否超過power_end_time超過了就退回一級(jí)。不要用“開啟后就這么強(qiáng)下去”的永久機(jī)制那是 demo 的寫法會(huì)毀掉難度曲線。4. 計(jì)分、音效與背景滾動(dòng)三件讓游戲“活過來”的事4.1 計(jì)分與連擊分?jǐn)?shù)不是加法是難度曲線的開關(guān)很多代碼里的計(jì)分就是一句話score 100。但完整版里分?jǐn)?shù)至少承擔(dān)兩個(gè)責(zé)任給玩家即時(shí)反饋以及驅(qū)動(dòng)難度變化。擊毀不同機(jī)種給不同分值、連續(xù)擊毀不加倍這個(gè)基礎(chǔ)邏輯要先寫對。具體分?jǐn)?shù)的回傳方式常見做法是讓敵機(jī)在死亡時(shí)返回自己的score_value主循環(huán)里統(tǒng)一累加不要在每個(gè)子類里直接改全局變量。比較進(jìn)階的計(jì)分設(shè)計(jì)是連擊系統(tǒng)。2 秒內(nèi)連續(xù)擊毀敵機(jī)擊毀數(shù)遞增每擊毀一架將連擊數(shù)乘以對應(yīng)倍率累加到分?jǐn)?shù)里。這個(gè)系統(tǒng)在飛機(jī)大戰(zhàn)里不算必需但它能把玩家的操作節(jié)奏從“亂射”逼成“精準(zhǔn)射擊”游戲可玩性立刻上一個(gè)臺(tái)階。實(shí)現(xiàn)連擊只需要一個(gè)時(shí)間戳變量每次擊毀敵機(jī)時(shí)用當(dāng)前時(shí)間減去上次擊毀時(shí)間如果間隔小于 1500 毫秒連擊數(shù)加 1否則連擊數(shù)重置為 1。這個(gè)時(shí)間戳判斷本身不復(fù)雜但很值得做因?yàn)樗巧贁?shù)用幾行代碼就能提升“游戲感”的設(shè)計(jì)。同時(shí)你要明確分?jǐn)?shù)在游戲平衡里的地位分?jǐn)?shù)是難度曲線的開關(guān)。后面的敵機(jī)刷新間隔、敵機(jī)速度提升都應(yīng)該從分?jǐn)?shù)讀取而不是從關(guān)卡數(shù)讀取。關(guān)卡數(shù)需要額外維護(hù)一個(gè)變量而且玩家死亡后重開會(huì)比較尷尬分?jǐn)?shù)本來就是遞增的拿來當(dāng)難度計(jì)最自然。4.2 背景滾動(dòng)偽 3D 效果的引擎飛機(jī)大戰(zhàn)的主體是“飛機(jī)在空中飛”但如果不做背景玩家看到的是一片黑色飛行的感覺會(huì)大打折扣。最常見的做法是讓一張長條形的星空或云層圖片從屏幕底部勻速向下滾動(dòng)循環(huán)播放制造出“飛機(jī)在向上飛行”的錯(cuò)覺。這個(gè)效果只需要一張圖片和兩個(gè) y 坐標(biāo)變量bg_img pygame.image.load(background.png) # 尺寸 480x700 bg_y1 0 bg_y2 -700 bg_speed 2 def update_bg(screen): global bg_y1, bg_y2 bg_y1 bg_speed bg_y2 bg_speed if bg_y1 700: bg_y1 -700 if bg_y2 700: bg_y2 -700 screen.blit(bg_img, (0, bg_y1)) screen.blit(bg_img, (0, bg_y2))原理很簡單兩張完全相同的背景圖首尾相接第二張放在第一張正上方一個(gè)屏幕高度的地方。兩張圖同時(shí)向下移動(dòng)第一張完全滑出屏幕時(shí)第二張正好占據(jù)整個(gè)屏幕此時(shí)把第一張的坐標(biāo)重置到屏幕上方循環(huán)繼續(xù)。把bg_speed從 2 逐步提高到 5、8速度越快給玩家的心理暗示是“飛機(jī)在加速推進(jìn)”這個(gè)變量也可以加入分?jǐn)?shù)驅(qū)動(dòng)的難度曲線里。背景速度的邊界要控制好。超過 10 之后星空場景會(huì)變得讓人眼花而且玩家視覺容易疲勞。我一般把背景速度上限設(shè)為 8比主角移動(dòng)速度慢一截保證前景運(yùn)動(dòng)更突出。背景圖片分辨率最好和窗口尺寸一致這樣省去縮放處理也不會(huì)出現(xiàn)邊緣間隙。4.3 音效與資源加載讓游戲有手感的關(guān)鍵習(xí)慣音效是“游戲手感”的重要組成部分。子彈發(fā)射的聲音、敵機(jī)爆炸的聲音、被撞時(shí)的預(yù)警音都直接影響玩家對“命中”的判斷。Pygame 的音效模塊叫pygame.mixer用之前要先初始化然后加載聲音文件播放時(shí)機(jī)放在對應(yīng)事件里。這里有一個(gè)常見錯(cuò)誤在初始化 mixer 前加載聲音會(huì)直接拋pygame.error。另外有些機(jī)器上pygame.mixer.init()會(huì)失敗比如音頻設(shè)備被占用所以初始化后要檢查返回值import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) ASSET_DIR os.path.join(BASE_DIR, assets) def load_sound(name): path os.path.join(ASSET_DIR, name) return pygame.mixer.Sound(path) pygame.mixer.init() try: shoot_sound load_sound(shoot.wav) explosion_sound load_sound(explosion.wav) except pygame.error as e: print(f音效加載失敗: {e})這里我用了絕對路徑而不是相對路徑因?yàn)橄鄬β窂揭蕾嚒爱?dāng)前工作目錄”在命令行里直接執(zhí)行腳本和在 IDE 里運(yùn)行腳本工作目錄經(jīng)常不一樣。os.path.dirname(os.path.abspath(__file__))得到的是腳本所在目錄再拼接assets子目錄這樣不管從哪個(gè)位置啟動(dòng)程序都能找到音效文件。這個(gè)習(xí)慣在打包發(fā)布時(shí)尤其重要。音效文件建議統(tǒng)一用 WAV 格式因?yàn)?Pygame 對 WAV 的處理最穩(wěn)定延遲最低。MP3 在某些版本上有初始化問題。音量也要控制Pygame 的Sound默認(rèn)音量是 1.0也就是最大音量多個(gè)音效疊加時(shí)容易刺耳。我一般把射擊音設(shè)為 0.2爆炸音 0.4背景音樂 0.5具體用set_volume()設(shè)置。這個(gè)參數(shù)可以根據(jù)個(gè)人聽感微調(diào)但先把這個(gè)習(xí)慣建立起來避免上線后玩家被突然巨響嚇一跳。5. 飛機(jī)大戰(zhàn)最容易翻車的 5 個(gè)坑從幀率到列表遍歷5.1 幀率忽高忽低飛機(jī)“飄”【現(xiàn)象】飛機(jī)和敵機(jī)移動(dòng)不勻速畫面一頓一頓的像在動(dòng)畫里抽幀。 【原因】最常見的是clock.tick(60)被隨意移動(dòng)了位置或者循環(huán)里做了大量圖片加載操作。另一個(gè)可能是在while循環(huán)里頻繁調(diào)用pygame.image.load()每幀都重新從磁盤讀圖片拖垮循環(huán)速度。 【解決】clock.tick(60)固定在循環(huán)末尾執(zhí)行。所有圖片和音效在循環(huán)開始前一次性加載完畢循環(huán)里只做 blit 和坐標(biāo)運(yùn)算。如果畫面還是卡檢查是否有大尺寸圖片的縮放操作放在循環(huán)內(nèi)把pygame.transform.scale的結(jié)果提前存起來。5.2 一次碰撞反復(fù)扣血【現(xiàn)象】敵機(jī)撞上主角一次血量瞬間扣了兩三次甚至直接游戲結(jié)束。 【原因】碰撞檢測寫在“敵機(jī)與主角矩形相交”這個(gè)條件里但沒有移除碰撞對象或設(shè)置冷卻時(shí)間。如果同一幀內(nèi)多個(gè)敵機(jī)同時(shí)與主角矩形相交每個(gè)敵機(jī)都會(huì)觸發(fā)扣血邏輯而如果主循環(huán)里敵機(jī)在下一次碰撞檢測前沒有被清除同一架敵機(jī)會(huì)連續(xù)觸發(fā)多次。 【解決】給主角加一個(gè)無敵幀機(jī)制被撞后設(shè)置invincible_until pygame.time.get_ticks() 1500每幀做碰撞檢測前先判斷當(dāng)前時(shí)間是否大于invincible_until。另外被撞的敵機(jī)立即調(diào)用kill()把這些對象從精靈組移除從根源上避免同一幀重復(fù)判定。5.3 遍歷 list 時(shí)刪除元素導(dǎo)致漏檢【現(xiàn)象】子彈明明打中了敵機(jī)但敵機(jī)沒有扣血或者某些子彈憑空消失。 【原因】常見寫法是for e in enemies:遍歷列表然后在循環(huán)里判斷碰撞后調(diào)用enemies.remove(e)。但 Python 的 for 循環(huán)在遍歷過程中刪除元素會(huì)改變列表索引導(dǎo)致下一個(gè)元素被跳過。 【解決】不要直接遍歷原始列表。用for e in enemies[:]:先復(fù)制一份副本或者使用 Pygame 的pygame.sprite.Group它內(nèi)部實(shí)現(xiàn)了安全的移除機(jī)制。我推薦直接用 Group它既支持add/remove/kill內(nèi)部也處理了遍歷刪除的問題比自己維護(hù) list 省心得多。5.4 音效初始化失敗不報(bào)錯(cuò)【現(xiàn)象】游戲跑起來沒有聲音程序也不報(bào)錯(cuò)看起來一切正常。 【原因】pygame.mixer.init()靜默失敗。某些設(shè)備上音頻驅(qū)動(dòng)不可用或已被占用時(shí)mixer 的初始化會(huì)返回一個(gè)未就緒的狀態(tài)但不會(huì)拋異常。后續(xù)播放Sound.play()時(shí)資源不可用聲音就無聲地丟掉了。 【解決】初始化后檢查pygame.mixer.get_init()如果返回None說明初始化失敗。這種情況我是直接打印日志提示“音頻不可用”然后讓游戲繼續(xù)以靜音模式運(yùn)行不要卡死。至少玩家知道是設(shè)備問題而不是代碼 bug。5.5 復(fù)制項(xiàng)目后圖片路徑失效【現(xiàn)象】換了一臺(tái)電腦把源碼和圖片目錄原樣拷過去運(yùn)行時(shí)報(bào)找不到圖片文件。 【原因】代碼里寫的是plane.png這樣的相對路徑。相對路徑是相對于“當(dāng)前工作目錄”解析的而工作目錄取決于你從哪里啟動(dòng)程序。有時(shí)候在 IDE 里運(yùn)行沒問題直接在命令行運(yùn)行就報(bào)錯(cuò)就是因?yàn)楣ぷ髂夸涀兞恕?【解決】把所有資源的加載統(tǒng)一走os.path.join(BASE_DIR, ...)這種基于腳本位置的絕對路徑方式BASE_DIR在文件頂部定義一次全項(xiàng)目復(fù)用。這樣不管從哪個(gè)目錄啟動(dòng)程序資源路徑都不會(huì)變。這個(gè)改動(dòng)雖然簡單但它決定了你的項(xiàng)目能不能被別人一鍵跑起來。6. 從能跑到能發(fā)布勝負(fù)邏輯、難度曲線與打包的一個(gè)小技巧6.1 勝負(fù)判定用狀態(tài)機(jī)把游戲流程串起來飛機(jī)大戰(zhàn)的勝負(fù)邏輯不能只寫“血量為零就退出”否則開始界面、暫停、游戲結(jié)束后的重新開始都會(huì)變得很亂。我一般用三個(gè)狀態(tài)常量管理整個(gè)游戲流程READY等待開始、PLAYING游戲進(jìn)行中、OVER游戲結(jié)束。主循環(huán)里按狀態(tài)分發(fā)不同的邏輯比如OVER狀態(tài)下按鍵 R 重置主角位置、血量、分?jǐn)?shù)和所有精靈組回到PLAYING。狀態(tài)機(jī)不只是工程習(xí)慣它也是玩家體驗(yàn)的一部分死亡后不能立刻重新開局需要留出 1~2 秒動(dòng)畫時(shí)間讓死亡反饋被看見否則玩家會(huì)覺得自己“莫名死了”挫敗感會(huì)很強(qiáng)。6.2 難度曲線分?jǐn)?shù)驅(qū)動(dòng)的敵機(jī)刷新參數(shù)敵機(jī)刷新間隔不要從頭到尾都是一個(gè)固定值。我推薦的曲線是以 80 幀為初始間隔分越高間隔越短但設(shè)一個(gè)下限 20避免出現(xiàn)幾幀內(nèi)刷出大量敵機(jī)的極端情況。具體公式可以是enemy_interval max(20, 80 - score // 2000 * 5)也就是說每獲得 2000 分刷新間隔縮短 5 幀。配合敵機(jī)速度整體增加玩家每一段時(shí)間都會(huì)感覺到壓力提升但又不會(huì)立刻被淹沒。難度曲線必須做封頂否則最后幾幀刷一堆敵機(jī)任何操作水平都躲不過去那就是設(shè)計(jì)事故了。6.3 打包發(fā)布資源路徑統(tǒng)一處理的小函數(shù)打包飛機(jī)大戰(zhàn)發(fā)布給同事或朋友試玩最常見的工具是 PyInstaller。打包出的 exe 在運(yùn)行時(shí)工作目錄可能是臨時(shí)解壓目錄此時(shí)基于相對路徑的資源全部失效。我的解決方案是在代碼最頂部寫一個(gè)資源路徑函數(shù)def resource_path(relative): base getattr(sys, _MEIPASS, os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, relative)這個(gè)函數(shù)用到 PyInstaller 的一個(gè)特性打包后運(yùn)行時(shí)會(huì)把資源解壓到sys._MEIPASS未打包時(shí)用腳本所在目錄。所有pygame.image.load(resource_path(plane.png))都走它既能本地直接跑也能打包后穩(wěn)定運(yùn)行。我自己的血淚經(jīng)驗(yàn)是第一版打包后發(fā)給別人玩對方報(bào)找不到圖片排查半天發(fā)現(xiàn)就是路徑問題。有了這個(gè)函數(shù)之后再?zèng)]翻過車。做游戲和寫業(yè)務(wù)代碼最大的不同是業(yè)務(wù)代碼跑通就是勝利游戲跑通只是起點(diǎn)。你的每一次參數(shù)調(diào)整——刷新頻率、子彈速度、無敵時(shí)間、音效音量——都是在跟玩家的耐心掰手腕。希望這篇筆記能讓你少走幾圈我走過的彎路把精力留給真正有意思的部分。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取