简介:这是一份基于苹果移动操作系统平台的飞机大战完整项目代码,面向想入门游戏开发或学习SpriteKit与Objective-C的开发者,也适合作为课程设计、毕业设计的参考原型。项目以经典打飞机玩法为蓝本,完整涵盖游戏引擎调度、飞机/子弹/敌机对象管理、碰撞检测、分数系统与游戏状态管理——从开始界面、游戏进行到暂停与结束都提供了清晰实现,同时加入音效与素材加载处理,并配有较详细的注释,便于对照理解每个模块的实现细节。资源共39个文件,包含Objective-C源码、SpriteKit场景类、配置文件、图片素材、音频音效、界面布局文件等,压缩包仅1.28MB,目录结构规整,适合快速导入Xcode开发工具运行。目前已有515人学习下载,对希望通过实际案例掌握苹果移动端游戏开发流程、学习对象设计与事件处理的读者来说,是一份轻量而完整的实践参考。
1. 飞机大战完整版代码:一个窗口背后藏着的三件事
“飞机大战完整版代码”这个检索词,在搜索引擎里能翻出一大堆结果,但真正把代码下下来照着跑的人,一半卡在图片路径不对,另一半卡在碰撞检测一次扣两次血。这说明大多数版本只是“能跑”,不是“能完整跑”。一个完整的飞机大战,至少要同时解决主循环、主角控制、多机种敌机、子弹、道具、计分、音效、背景滚动和胜负判定,缺任意一块,游戏体验都会在某一分钟突然垮掉。适合拿这个题目练手的人,是想搞懂游戏循环怎么写、碰撞怎么判、资源怎么管的从业者。读完这一篇,你不仅能照抄,而且能知道每个参数为什么是这么设的,踩过的坑我也一并标出来。
2. 用 Pygame 把飞机大战跑起来:窗口、循环与主角的 30 行骨架
2.1 为什么是 Pygame:一个窗口背后的完整体系
做飞机大战这种 2D 俯冲射击游戏,框架选型基本没有悬念:Pygame。它不是引擎,不替你管理场景和组件,但它把窗口创建、事件分发、图片加载、声音播放、矩形碰撞这些底层操作全部封装成了足够简单的 API,开发者只需要关注“循环里每一步干什么”。这对理解游戏本质反而是好事,因为无论用什么引擎,游戏核心都绕不开那几件事:读输入、更新状态、画画面、控制节奏。
Pygame 的坐标体系也需要先立起来。屏幕左上角是 (0, 0),x 轴向右,y 轴向下。飞机往屏幕下方飞,y 值就在变大。这个坐标系和数学课上习惯的直角坐标不一样,很多新手在这里翻车:想让敌机从上方出现,结果设置成 y = 0 的负值,图片直接跑到屏幕外面去了。我一般会先在纸上画一个 480x700 的矩形框,把主角出发位置、敌机出生位置全部标上去,再写代码,这样视觉上不会乱。
另一个必须一开始就理解的机制是事件循环。Pygame 程序不是从上往下执行完就退出的,而是靠一个 while 循环不断重复“处理事件、更新逻辑、绘制画面”三个步骤。每执行一轮就是一帧,pygame.time.Clock().tick(60)把帧率限制在每秒 60 帧。你按一下方向键,程序在下一帧读键盘状态,再下一帧把飞机位置挪动几像素,连续看起来就是平滑移动。
2.2 游戏循环与主角:让飞机动起来的两件事
先把最小可运行版本搭出来。这段代码只有窗口、主角、方向键移动和边界限制,不涉及敌人和子弹,但它是一切的基础。
import pygame import sys pygame.init() screen = pygame.display.set_mode((480, 700)) pygame.display.set_caption("飞机大战") # 加载图片,保证图片和脚本在同一目录 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. 读取键盘状态并更新主角位置 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)代码里有一个容易忽视的设计:pygame.key.get_pressed()读的是键盘的实时状态,而不是单个按键事件。这意味着按住左键不放,飞机就会持续向左移动,每一次循环里plane_rect.x -= 5都会执行。如果用KEYDOWN事件,你只能得到按下那一瞬间的反馈,松手也不会自动停住,体验会差很多。
screen.fill((0, 0, 0))的作用是清空上一帧画面,不填的话,上一帧的飞机图像会残留在屏幕上,形成拖影。blit是“画一块图像到窗口上”的操作,参数是图片和位置矩形。最后pygame.display.flip()才真正把画好的内容推送到显示设备,这一步漏掉,窗口会一直黑屏。clock.tick(60)放在循环末尾,作用是每帧睡眠到固定时长,保证所有机器上运行速度大致一致。不同机器如果没这行,飞机的移动速度会跟着 CPU 性能走。
2.3 图片加载与碰撞检测:先给游戏立好边界
主角的plane_rect来自plane_img.get_rect(),这个矩形对象是整个系统里最重要的东西。它不仅有 x、y、宽、高等位置属性,还承担碰撞检测的职责。Pygame 内置了rect.colliderect(other_rect)、rect.collidepoint((x, y))等系列方法,后面子弹打敌机、敌机撞主角,全都是靠矩形相交判断的。
图片本身的尺寸也要注意。我习惯把主角图片控制在 80x80 以内,敌机 60x60 或 60x80,这样在小窗口上视觉不会失衡。plane_rect.midbottom = (240, 650)是让飞机的底部中心点定位在窗口底部偏上的位置,这样飞机看起来是“站在”场景里,而不是贴在屏幕顶端。
这个阶段还要养成一个习惯:图片文件路径不要写死成"plane.png",后面会讲到路径问题,但至少一开始你要让图片和脚本放在同一个目录。运行时如果报pygame.error: Unable to open file,基本就是路径对不上或文件名打错。先用相对路径跑通,后面再统一处理。
3. 敌机、子弹与道具:让“完整版”工作量翻倍的三件事
3.1 敌机:不同机种、不同移动方式,怎么在一套代码里兼容
敌机系统是“完整版”和“demo”的分水岭。demo 只会生成一种小飞机,匀速向下飞;完整版至少要有两到三种机型,重量、速度、血量、分值都不一样。我一般用类继承来解决这个问题:写一个Enemy基类,把坐标生成、向下移动、出屏回收这些公共逻辑放在里面,具体机型通过子类或配置参数区分。
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是让敌机出现在屏幕正上方之外,随着 update 里 y 不断增加,它从顶部“飞”进画面。如果敌机在窗口内出生然后往下飞,玩家会感觉画面很突兀,像是在天上凭空刷怪。random.randint控制出生横坐标,保证每架敌机的横向位置随机。
self.kill()是 Pygame 精灵组的特性。当敌机飞出屏幕底部时调用它,把自身从精灵组中移除,否则列表越积越长,内存开销越来越大,帧率会逐渐下降。这个回收机制是精灵组比普通 list 管理对象方便的核心原因。
一个常见的配置参数表可以这么设,供你参考:
| 机种 | 图片尺寸 | 速度 | 血量 | 分值 |
|---|---|---|---|---|
| 小型机 | 50x50 | 4~6 | 1 | 100 |
| 中型机 | 70x70 | 2~3 | 3 | 300 |
| 大型机 | 100x100 | 1~2 | 8 | 800 |
你还可以让大型机在血量低于一半时分裂成两架小型机,但那是进阶玩法,先不展开。这里给两个建议:其一,速度不要用固定整数,用random.randint(4, 6),敌人速度略微波动会让玩家觉得“每局手感不一样”;其二,敌机刷新间隔不要写死,后面测难度曲线时用得分去动态调整。
3.2 子弹:发射节奏、弹速与屏幕边界回收
子弹系统的核心参数有三个:发射间隔、子弹速度、伤害值。发射间隔决定了玩家按一下空格能射出多少发,速度决定了弹道看起来是“射出去”还是“飘出去”。我常用的值是间隔 150~200 毫秒,速度 12~15 像素每帧(60 帧下约 720~900 像素每秒),伤害 1。这个配置在小窗口上弹道清晰,也不会快到无法躲避。
class Bullet(pygame.sprite.Sprite): def __init__(self, x, y, speed=15, damage=1): 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是两个容易被忽略的细节。如果直接用rect.x和rect.y,子弹会从飞机的左上角冒出来,看起来像是从飞机屁股后发射。而用centerx和bottom,子弹正好从飞机头部中央射出,视觉上才正确。这里y是传进来的飞机头部 y 坐标,所以bottom = y表示子弹底部贴着飞机头部位置。
子弹回收条件和敌机相反:敌机飞出底部要清除,子弹飞出顶部要清除。不回收的子弹会无限向上飘,几轮射击后整个列表有上千个物体,每帧都要做碰撞检测,帧率崩盘是必然的。发射节奏由谁控制?我一般是记录一个last_shot_time,每帧判断当前时间减上次发射时间是否大于间隔:
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()返回程序启动后的毫秒数,用它来做时间间隔判断,比用帧数可靠。帧数受帧率波动影响,而 get_ticks 是真实时间,不会因机器性能不同而改变。这个思路在无敌帧、连击判定、道具持续时间里都会反复用到。
3.3 道具:增强火力与恢复生命,如何不破坏难度曲线
道具系统是完整版里最需要克制设计的部分。常见道具三类:火力增强(双发或三发)、回血、护盾。道具掉落方式有两种:敌机被击毁后概率掉落,或每隔固定时间在屏幕顶部随机生成。我推荐前一种,因为道具掉落与玩家击杀行为挂钩,玩家会觉得“是我自己打出来的”,而不是白捡的。掉落概率控制在 15%~20%,太高了玩家每局都满火力,敌机难度形同虚设;太低则道具系统形同虚设。
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(f"power_{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()火力增强的效果不是直接把子弹速度翻倍,而是改变主角的发射方式。最简单的实现是给主角加一个fire_level属性,发射时根据等级拆成多组子弹坐标:
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, speed=12)) bullets.add(Bullet(plane_rect.centerx + 25, plane_rect.top, speed=12))注意speed=12是关键字参数,这里中间子弹速度 15、两侧子弹速度 12,形成一束弹道的层次感。这个细节不调也没什么,但调了之后弹幕的视觉密度会有明显提升。火力等级要有上限,一般三级封顶,且被撞一次后降一级。这样玩家既享受成长感,又不会无敌到失去挑战性。
道具的持续时间和拾取碰撞也要单独处理。回血和护盾是一次性生效,直接改主角属性;火力增强如果是定时持续,则需要记录power_end_time,每帧检查当前时间是否超过power_end_time,超过了就退回一级。不要用“开启后就这么强下去”的永久机制,那是 demo 的写法,会毁掉难度曲线。
4. 计分、音效与背景滚动:三件让游戏“活过来”的事
4.1 计分与连击:分数不是加法,是难度曲线的开关
很多代码里的计分就是一句话:score += 100。但完整版里,分数至少承担两个责任:给玩家即时反馈,以及驱动难度变化。击毁不同机种给不同分值、连续击毁不加倍,这个基础逻辑要先写对。具体分数的回传方式,常见做法是让敌机在死亡时返回自己的score_value,主循环里统一累加,不要在每个子类里直接改全局变量。
比较进阶的计分设计是连击系统。2 秒内连续击毁敌机,击毁数递增,每击毁一架将连击数乘以对应倍率累加到分数里。这个系统在飞机大战里不算必需,但它能把玩家的操作节奏从“乱射”逼成“精准射击”,游戏可玩性立刻上一个台阶。实现连击只需要一个时间戳变量:每次击毁敌机时,用当前时间减去上次击毁时间,如果间隔小于 1500 毫秒,连击数加 1;否则连击数重置为 1。这个时间戳判断本身不复杂,但很值得做,因为它是少数用几行代码就能提升“游戏感”的设计。
同时你要明确分数在游戏平衡里的地位:分数是难度曲线的开关。后面的敌机刷新间隔、敌机速度提升,都应该从分数读取,而不是从关卡数读取。关卡数需要额外维护一个变量,而且玩家死亡后重开会比较尴尬;分数本来就是递增的,拿来当难度计最自然。
4.2 背景滚动:伪 3D 效果的引擎
飞机大战的主体是“飞机在空中飞”,但如果不做背景,玩家看到的是一片黑色,飞行的感觉会大打折扣。最常见的做法是让一张长条形的星空或云层图片从屏幕底部匀速向下滚动,循环播放,制造出“飞机在向上飞行”的错觉。这个效果只需要一张图片和两个 y 坐标变量:
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))原理很简单:两张完全相同的背景图首尾相接,第二张放在第一张正上方一个屏幕高度的地方。两张图同时向下移动,第一张完全滑出屏幕时,第二张正好占据整个屏幕;此时把第一张的坐标重置到屏幕上方,循环继续。把bg_speed从 2 逐步提高到 5、8,速度越快给玩家的心理暗示是“飞机在加速推进”,这个变量也可以加入分数驱动的难度曲线里。
背景速度的边界要控制好。超过 10 之后,星空场景会变得让人眼花,而且玩家视觉容易疲劳。我一般把背景速度上限设为 8,比主角移动速度慢一截,保证前景运动更突出。背景图片分辨率最好和窗口尺寸一致,这样省去缩放处理,也不会出现边缘间隙。
4.3 音效与资源加载:让游戏有手感的关键习惯
音效是“游戏手感”的重要组成部分。子弹发射的声音、敌机爆炸的声音、被撞时的预警音,都直接影响玩家对“命中”的判断。Pygame 的音效模块叫pygame.mixer,用之前要先初始化,然后加载声音文件,播放时机放在对应事件里。这里有一个常见错误:在初始化 mixer 前加载声音,会直接抛pygame.error。另外有些机器上pygame.mixer.init()会失败(比如音频设备被占用),所以初始化后要检查返回值:
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}")这里我用了绝对路径而不是相对路径,因为相对路径依赖“当前工作目录”,在命令行里直接执行脚本和在 IDE 里运行脚本,工作目录经常不一样。os.path.dirname(os.path.abspath(__file__))得到的是脚本所在目录,再拼接assets子目录,这样不管从哪个位置启动程序,都能找到音效文件。这个习惯在打包发布时尤其重要。
音效文件建议统一用 WAV 格式,因为 Pygame 对 WAV 的处理最稳定,延迟最低。MP3 在某些版本上有初始化问题。音量也要控制:Pygame 的Sound默认音量是 1.0,也就是最大音量,多个音效叠加时容易刺耳。我一般把射击音设为 0.2,爆炸音 0.4,背景音乐 0.5,具体用set_volume()设置。这个参数可以根据个人听感微调,但先把这个习惯建立起来,避免上线后玩家被突然巨响吓一跳。
5. 飞机大战最容易翻车的 5 个坑:从帧率到列表遍历
5.1 帧率忽高忽低,飞机“飘”
【现象】飞机和敌机移动不匀速,画面一顿一顿的,像在动画里抽帧。 【原因】最常见的是clock.tick(60)被随意移动了位置,或者循环里做了大量图片加载操作。另一个可能是在while循环里频繁调用pygame.image.load(),每帧都重新从磁盘读图片,拖垮循环速度。 【解决】clock.tick(60)固定在循环末尾执行。所有图片和音效在循环开始前一次性加载完毕,循环里只做 blit 和坐标运算。如果画面还是卡,检查是否有大尺寸图片的缩放操作放在循环内,把pygame.transform.scale的结果提前存起来。
5.2 一次碰撞反复扣血
【现象】敌机撞上主角一次,血量瞬间扣了两三次,甚至直接游戏结束。 【原因】碰撞检测写在“敌机与主角矩形相交”这个条件里,但没有移除碰撞对象或设置冷却时间。如果同一帧内多个敌机同时与主角矩形相交,每个敌机都会触发扣血逻辑;而如果主循环里敌机在下一次碰撞检测前没有被清除,同一架敌机会连续触发多次。 【解决】给主角加一个无敌帧机制:被撞后设置invincible_until = pygame.time.get_ticks() + 1500,每帧做碰撞检测前先判断当前时间是否大于invincible_until。另外被撞的敌机立即调用kill(),把这些对象从精灵组移除,从根源上避免同一帧重复判定。
5.3 遍历 list 时删除元素导致漏检
【现象】子弹明明打中了敌机,但敌机没有扣血,或者某些子弹凭空消失。 【原因】常见写法是for e in enemies:遍历列表,然后在循环里判断碰撞后调用enemies.remove(e)。但 Python 的 for 循环在遍历过程中删除元素,会改变列表索引,导致下一个元素被跳过。 【解决】不要直接遍历原始列表。用for e in enemies[:]:先复制一份副本,或者使用 Pygame 的pygame.sprite.Group,它内部实现了安全的移除机制。我推荐直接用 Group,它既支持add/remove/kill,内部也处理了遍历删除的问题,比自己维护 list 省心得多。
5.4 音效初始化失败不报错
【现象】游戏跑起来没有声音,程序也不报错,看起来一切正常。 【原因】pygame.mixer.init()静默失败。某些设备上,音频驱动不可用或已被占用时,mixer 的初始化会返回一个未就绪的状态,但不会抛异常。后续播放Sound.play()时资源不可用,声音就无声地丢掉了。 【解决】初始化后检查pygame.mixer.get_init(),如果返回None,说明初始化失败。这种情况我是直接打印日志提示“音频不可用”,然后让游戏继续以静音模式运行,不要卡死。至少玩家知道是设备问题,而不是代码 bug。
5.5 复制项目后图片路径失效
【现象】换了一台电脑,把源码和图片目录原样拷过去,运行时报找不到图片文件。 【原因】代码里写的是"plane.png"这样的相对路径。相对路径是相对于“当前工作目录”解析的,而工作目录取决于你从哪里启动程序。有时候在 IDE 里运行没问题,直接在命令行运行就报错,就是因为工作目录变了。 【解决】把所有资源的加载统一走os.path.join(BASE_DIR, ...)这种基于脚本位置的绝对路径方式,BASE_DIR在文件顶部定义一次,全项目复用。这样不管从哪个目录启动程序,资源路径都不会变。这个改动虽然简单,但它决定了你的项目能不能被别人一键跑起来。
6. 从能跑到能发布:胜负逻辑、难度曲线与打包的一个小技巧
6.1 胜负判定:用状态机把游戏流程串起来
飞机大战的胜负逻辑不能只写“血量为零就退出”,否则开始界面、暂停、游戏结束后的重新开始都会变得很乱。我一般用三个状态常量管理整个游戏流程:READY(等待开始)、PLAYING(游戏进行中)、OVER(游戏结束)。主循环里按状态分发不同的逻辑,比如OVER状态下按键 R 重置主角位置、血量、分数和所有精灵组,回到PLAYING。状态机不只是工程习惯,它也是玩家体验的一部分:死亡后不能立刻重新开局,需要留出 1~2 秒动画时间让死亡反馈被看见,否则玩家会觉得自己“莫名死了”,挫败感会很强。
6.2 难度曲线:分数驱动的敌机刷新参数
敌机刷新间隔不要从头到尾都是一个固定值。我推荐的曲线是:以 80 帧为初始间隔,分越高间隔越短,但设一个下限 20,避免出现几帧内刷出大量敌机的极端情况。具体公式可以是enemy_interval = max(20, 80 - score // 2000 * 5),也就是说每获得 2000 分,刷新间隔缩短 5 帧。配合敌机速度整体增加,玩家每一段时间都会感觉到压力提升,但又不会立刻被淹没。难度曲线必须做封顶,否则最后几帧刷一堆敌机,任何操作水平都躲不过去,那就是设计事故了。
6.3 打包发布:资源路径统一处理的小函数
打包飞机大战发布给同事或朋友试玩,最常见的工具是 PyInstaller。打包出的 exe 在运行时,工作目录可能是临时解压目录,此时基于相对路径的资源全部失效。我的解决方案是在代码最顶部写一个资源路径函数:
def resource_path(relative): base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, relative)这个函数用到 PyInstaller 的一个特性:打包后运行时会把资源解压到sys._MEIPASS,未打包时用脚本所在目录。所有pygame.image.load(resource_path("plane.png"))都走它,既能本地直接跑,也能打包后稳定运行。我自己的血泪经验是,第一版打包后发给别人玩,对方报找不到图片,排查半天发现就是路径问题。有了这个函数之后,再没翻过车。
做游戏和写业务代码最大的不同是:业务代码跑通就是胜利,游戏跑通只是起点。你的每一次参数调整——刷新频率、子弹速度、无敌时间、音效音量——都是在跟玩家的耐心掰手腕。希望这篇笔记能让你少走几圈我走过的弯路,把精力留给真正有意思的部分。希望帮到你。
本文还有配套的精品资源,点击获取