想用Python做游戏,很多人第一反应是Pygame。但如果你和我一样,平时主要写GUI工具、对窗口布局和控件交互更熟,那PyQt6其实是一条被低估的路线。这篇文章是我用PyQt6从零手搓《植物大战僵尸》简化版的完整记录,包含场景搭建、游戏循环、碰撞检测、阳光系统、资源打包等关键模块的实现逻辑,以及我实际调试过程中踩过的坑。项目代码量不大,核心逻辑在600到900行之间,适合有一定Python基础、想通过小游戏练手GUI开发的学习者。
先说结论:用PyQt6做这类塔防/即时对战小游戏完全可行。它的QGraphicsView框架本身就是为2D场景交互设计的,信号槽机制天然适合处理鼠标点击、定时刷新这些游戏常用操作,再加上PyQt6的UI能力,做出来的画面观感比Pygame更接近“正经软件”。当然,它也有性能上限,真要做大型商业游戏还是得用专用引擎,但练手和做Demo,绰绰有余。
1. 为什么是PyQt6而不是Pygame:选型背后的实际考量
在做这个项目之前,我纠结过一段时间。网上搜“Python小游戏”,十有八九是Pygame教程,什么pygame.init()、display.set_mode(),看起来也不难。但真用Pygame写过东西的朋友应该都懂,它的UI是一场噩梦——按钮、卡槽、文字提示全部要手绘坐标,鼠标事件要自己判断区域,做出来的界面用两个字形容就是“粗糙”。团队里如果美术资源本来就不行,Pygame做出来的成品会显得更廉价。
PyQt6就不一样了。它本来就擅长桌面软件界面,QLabel、QPushButton、QGraphicsView这些控件都是现成的,用QSS(样式表)几行代码就能给卡槽加上圆角、边框、选中高亮。开发《植物大战僵尸》这种需要大量“面板交互”的玩法时,PyQt6可以让你把精力放在游戏逻辑上,而不是和按钮样式死磕。
我从几个维度做了对比,完全是基于这次实际开发的经验:
| 维度 | PyQt6(QGraphicsView) | Pygame |
|---|---|---|
| UI控件 | 原生控件,QSS美化方便 | 全部要手绘,事件区域手写判断 |
| 场景管理 | QGraphicsScene管理碰撞、层级、坐标系统 | 自己维护sprite列表,手动计算rect相交 |
| 鼠标交互 | 控件自带点击信号,scene也可捕获 | 手动监听MOUSEBUTTONDOWN并判断坐标 |
| 定时循环 | QTimer信号槽驱动,天然线程安全 | 主循环while True,自己控制FPS |
| 输出形式 | 可以打包成桌面GUI程序,专业感强 | 窗口式游戏,适合快速原型 |
| 上手门槛 | 需要懂一点信号槽和事件循环 | API简单但对其他场景帮助有限 |
举个最直观的例子:在Pygame里实现“点击卡槽选择植物、再点击草坪种植”,你需要先记录两个坐标矩形,再在事件循环里分别判断点击落在哪个区域。在PyQt6里,卡槽本身就是一个可以绑定clicked信号的QPushButton,草坪就是QGraphicsView里的一个矩形区域,鼠标点击可以交给scene的鼠标事件去处理,逻辑是清晰分离的。谁好谁坏,用过的自然懂。
另外一个现实因素:学PyQt6这门手艺,对日常开发也有帮助。很多自动化工具、数据分析桌面端、内部管理系统都需要GUI外壳,用PyQt6做游戏练出来的信号槽、多线程、资源管理经验,是可以直接迁移到工作里的。但Pygame写多了,跳回业务开发时几乎用不上。这也是我最终敲定PyQt6的深层原因——不希望花了时间学的东西只在一款小游戏里生效。
2. 动工之前的一小时:场景网格与对象模型设计
这一部分我强烈建议你想清楚再写代码。有朋友拿到需求就急着建工程,结果写了两三百行发现网格坐标算不清楚、对象生命周期管不过来,推倒重来更浪费时间。我自己花了一个小时把下面这些设计画在纸上,后面编码全程顺畅。
2.1 草坪网格与坐标系换算
《植物大战僵尸》的战斗区域本质是一张矩形网格,经典版是9列×5行。在PyQt6里我会把它当成QGraphicsScene上的一块固定区域,拿一个QPixmap背景图垫底。网格大小我设成了每格100×100像素,场景左上角草坪起点坐标是(80, 120),这样左侧还能留出空间放割草机,顶部放卡槽和阳光计数。
换算公式很简单:鼠标点击scenePos()得到的是场景坐标,要换算成网格坐标,就做一次整数除法。
grid_col = int((pos.x() - LAWN_LEFT) / CELL_WIDTH) grid_row = int((pos.y() - LAWN_TOP) / CELL_HEIGHT)注意几个边界条件:点击位置必须落在LAWN_LEFT到LAWN_LEFT+9×CELL_WIDTH的范围内,行列值要在0~8和0~4之间,否则直接忽略。这个判断一定要放在所有种植逻辑之前,否则会出现植物种到草坪外甚至负坐标的情况。
2.2 游戏对象怎么抽象
我建了一个基础的GameObject类,继承QGraphicsPixmapItem,这样每个游戏实体自带坐标、图片、层级和碰撞区域,左上角就是锚点。
class GameObject(QGraphicsPixmapItem): def __init__(self, name, pixmap, row=-1, col=-1, hp=100): super().__init__(pixmap) self.name = name self.row = row self.col = col self.hp = hp self.max_hp = hp self.is_dead = False def update_state(self, delta_ms): if self.hp <= 0: self.is_dead = True植物、僵尸、子弹、阳光都继承这个类。这样场景里的元素可以用一个统一的列表管理,每帧遍历调update_state。好处是新增角色类型时不用改总体框架,只要继承并扩展自己的属性就行。比如僵尸需要加speed和attack_damage,向日葵需要加sun_gen_timer,豌豆射手需要加shoot_interval。
2.3 卡槽与种植状态机
卡槽我用了QPushButton列表,每个按钮绑定一张植物卡片。点击后进入“待种植”状态,鼠标移到草坪上会显示一个半透明植物预览框,点击左键确认种植,右键取消。这个交互流程其实是个简单的状态机:
- 无选中状态:点击卡槽后进入待种植状态,记录selected_plant_id。
- 待种植状态:移动鼠标时更新预览框位置,点击草坪执行种植并回到无选中状态。
- 任何状态:点右键或再次点击同卡片恢复无选中状态。
状态机看似简单,但如果没有统一的处理入口,代码很容易变乱。我把所有鼠标事件都集中到GameScene的mousePressEvent/mouseMoveEvent里,根据当前状态分支处理。前期代码丑一点没关系,关键是不要用散落的if把逻辑藏得到处都是。
3. 游戏循环:用QTimer驱动整个世界的运转
Pygame是经典的while True主循环,PyQt6则是事件循环驱动。作为一个小游戏,我们不能靠用户点击被动推进游戏,所以要用一个QTimer作为心跳,每隔固定毫秒数“滴答”一次,驱动所有对象更新。
3.1 QTimer主循环怎么写
我用的刷新间隔是33毫秒,约合30FPS。对于植物大战僵尸这种节奏的游戏完全够用。你也可以用16毫秒(约60FPS),不过对象多了以后性能压力会明显增加。
class GameWindow(QMainWindow): def __init__(self): super().__init__() self.setup_scene() self.timer = QTimer() self.timer.timeout.connect(self.on_tick) self.timer.start(33) def on_tick(self): delta_ms = 33 self.sun_value += self.calculate_sun_income(delta_ms) self.update_sun_label() self.process_zombie_spawn() for obj in self.scene.objects(): if isinstance(obj, GameObject): obj.update_state(delta_ms) self.handle_collisions() self.remove_dead_objects() self.check_game_over()这里有个重点:不要在QTimer回调里做耗时操作。比如load图片、创建新pixmap、计算复杂路径,这些都会阻塞UI线程,导致窗口卡顿。你可以在初始阶段把需要用到的QPixmap全部提前load好,放到一个resource_dict里,运行时只做引用,不重复加载。这是保证30FPS流畅的关键。
3.2 场景刷新与动画
QGraphicsScene会自动重绘,你不需要手动调update(),只要对象的坐标、图片发生变化,视图层就会自动同步。所以动画的实现方式很简单——每帧修改对象的pos(),比如僵尸的x坐标每帧减0.5,豌豆子弹的x坐标每帧加10。
向日葵摇动、僵尸走路这些“伪动画”,我直接用了多帧图片切换。植物大战僵尸的资源在网上可以找到打包好的帧图,拆开按帧号放到assets文件夹里,逻辑上维护一个frame_index,每200毫秒切换下一帧。
def update_state(self, delta_ms): super().update_state(delta_ms) if self.hp <= 0: return self.anim_timer += delta_ms if self.anim_timer >= 200: self.anim_timer = 0 self.frame_index = (self.frame_index + 1) % len(self.frames) self.setPixmap(self.frames[self.frame_index])这样实现的动画虽然简单,但对节奏类小游戏已经足够自然。真正的高级做法是QPropertyAnimation对每个对象做插值动画,但对象多了之后管理和回收会变得复杂,我建议第一版先不用。
4. 植物、僵尸和子弹:角色系统与碰撞判定细节
这一章是整个项目最核心的部分。植物系统、僵尸AI、子弹碰撞,这些听起来高大上,其实抽丝剥茧后就是几张表格和几个函数。
4.1 植物属性和行为
我第一版做了四种植物:向日葵、豌豆射手、坚果墙、樱桃炸弹。每个植物都有自己的阳光消耗、冷却时间、生命值和行为逻辑。这些参数我用字典集中管理,方便随时调整平衡性。
PLANT_TYPES = { "sunflower": { "name": "向日葵", "sun_cost": 50, "cooldown": 5000, "hp": 80, "sun_gen_interval": 10000, "sun_gen_amount": 25, "image": "assets/sunflower.png" }, "peashooter": { "name": "豌豆射手", "sun_cost": 100, "cooldown": 5000, "hp": 100, "shoot_interval": 1500, "bullet_damage": 20, "image": "assets/peashooter.png" }, "wallnut": { "name": "坚果墙", "sun_cost": 50, "cooldown": 15000, "hp": 800, "image": "assets/wallnut.png" }, "cherrybomb": { "name": "樱桃炸弹", "sun_cost": 150, "cooldown": 30000, "hp": 999, "explode_damage": 1000, "explode_radius": 120, "image": "assets/cherrybomb.png" } }注意表格里的冷却时间单位是毫秒。我把冷却状态也放在一个字典里,卡槽按钮的文字实时显示剩余秒数,冷却结束恢复可点击状态。这样玩家能直观感受到“好了没有”,体验会好很多。
向日葵和豌豆射手的行为都在update_state里实现:
class Sunflower(GameObject): def update_state(self, delta_ms): super().update_state(delta_ms) self.sun_timer += delta_ms if self.sun_timer >= self.sun_gen_interval: self.sun_timer = 0 self.spawn_sun(self.sun_gen_amount)豌豆射手除了计时器,还要判断当前行是否存在僵尸。这个判断我犯过错——一开始是遍历全部僵尸,导致射手对着身后或另一行的僵尸乱射。正确做法是只检查同一行的僵尸,并且位置在当前植物右侧。
class Peashooter(GameObject): def has_zombie_in_lane(self, zombies): for zombie in zombies: if zombie.row == self.row and zombie.x() > self.x(): return True return False4.2 僵尸的AI:走、啃、死
僵尸的逻辑是“一直向左走,碰到植物就咬,咬到体积重叠就一直咬,植物死了继续走”。这里有个关键点:僵尸和植物的碰撞不能只靠坐标判断,因为图片有透明区域,直接用中心点距离判断会显得不真实。QGraphicsPixmapItem提供了collidesWithItem方法,底层会计算两个item的边界是否相交,对于像素级碰撞(默认是用边界矩形)已经足够。
class Zombie(GameObject): def update_state(self, delta_ms): super().update_state(delta_ms) if self.is_dead: return seconds = delta_ms / 1000 if self.is_eating: self.attack_timer += delta_ms if self.attack_timer >= self.attack_interval: self.attack_timer = 0 self.target.take_damage(self.attack_damage) return self.setX(self.x() - self.speed * seconds * 60)这里引入了一个变量is_eating:当僵尸前方有植物时切换为啃食状态,停止移动,每隔固定间隔对植物造成伤害。这个状态切换在碰撞检测函数里完成,下一小节会详细说。
僵尸数据也很简单,我做了两种:普通僵尸和路障僵尸。路障僵尸血量更高、速度略慢,外观上戴了个铁桶图片。
| 僵尸类型 | HP | 移动速度(像素/秒) | 啃咬伤害 | 啃咬间隔 |
|---|---|---|---|---|
| 普通僵尸 | 120 | 30 | 20 | 500ms |
| 路障僵尸 | 280 | 20 | 20 | 500ms |
4.3 子弹与碰撞处理的实现细节
豌豆子弹是独立的GameObject,沿水平方向向右移动。碰撞检测每帧遍历所有子弹和所有僵尸,判断是否相交。直接全部遍历在对象少时没问题,但如果僵尸一多,O(n×m)的性能就扛不住了。我做了一个按行索引的优化:
def handle_collisions(self): zombies_by_row = {} for zombie in self.zombies: zombies_by_row.setdefault(zombie.row, []).append(zombie) for bullet in self.bullets: if bullet.row not in zombies_by_row: continue for zombie in zombies_by_row[bullet.row]: if bullet.collidesWithItem(zombie): zombie.hp -= bullet.damage bullet.is_dead = True break这个优化很实用:子弹只会命中同一行的僵尸,所以把僵尸按行分组后,每颗子弹只需要和当前行的几个僵尸比较,速度提升明显。
僵尸啃食植物的区域判定也很简单,在每轮碰撞检测中检查每个僵尸前方是否存在植物:
def check_eating(self): for zombie in self.zombies: zombie.is_eating = False for plant in self.plants: if plant.row == zombie.row and zombie.collidesWithItem(plant): zombie.is_eating = True zombie.target = plant break顺序很重要:先检查啃食,再更新移动。否则会出现僵尸已经贴住植物了,却还在不断往前挤的鬼畜画面。检查啃食后再调用僵尸的update_state,啃食状态才能正确发挥作用。
4.4 樱桃炸弹的AOE伤害
樱桃炸弹最特殊,它的行为不是持续攻击,而是种植后约1秒爆炸,对周围120像素半径内所有僵尸造成高额伤害,然后自身销毁。我实现时加了个explode_timer,倒计时结束就遍历场景内所有僵尸,计算距离,命中后播放爆炸帧动画并销毁自己。
def explode(self): for zombie in self.scene().zombies: if zombie.is_dead: continue if self.distance_to(zombie) <= self.explode_radius: zombie.hp -= self.explode_damage self.is_dead = True这个AOE伤害的判定用欧几里得距离即可,注意要把场景坐标(QPointF)先转成普通坐标计算,别手滑把QPointF直接拿去比较大小。
5. 阳光、冷却与关卡节奏:让游戏真正“能玩”
有了植物、僵尸、子弹,游戏能跑起来了,但离“好玩”还差得远。植物大战僵尸的核心乐趣在于资源管理和策略决策,也就是阳光的获取、植物的冷却、僵尸的波次压力。这三块没做,游戏就是个射击Demo。
5.1 阳光系统:自然掉落与向日葵产出
阳光是游戏的货币。我做了两个来源:天空每隔8到12秒随机掉落一个阳光,向日葵每10秒产出一个阳光。阳光掉落位置在草坪上方区域随机生成,玩家需要点击阳光才能收集。这个“点击收集”的动作是游戏互动的重要组成部分,千万别做成自动吸附,否则缺少操作感。
阳光对象本身也是一个GameObject。它被点击后触发一个向左侧UI栏飘动的飞行动画,动画结束就销毁。这里用QPropertyAnimation最合适,因为它能自动处理插值和时间线,不需要手动在QTimer里算位置。
class Sun(GameObject): def collect(self): anim = QPropertyAnimation(self, b"pos") anim.setDuration(300) anim.setStartValue(self.pos()) anim.setEndValue(QPointF(70, 30)) anim.finished.connect(self.set_dead) anim.start()注意,QPropertyAnimation会接管对象的位置属性,所以这段时间不要再让主循环里的update_state修改x/y,否则动画会抖动。
5.2 冷却与卡槽联动
卡槽按钮的状态刷新我的做法是:每个植物在种植时记录种植时间,UI每次刷新时计算当前时间与最后种植时间的差,如果超过冷却时间就恢复可点击,否则显示剩余秒数。这个逻辑简单可靠,不需要为每个按钮单独开QTimer。
阳光数值的更新频率也不需要和主循环一样是33ms,我减到每200ms刷新一次阳光Label和卡槽状态,减少UI重绘压力。QTimer套QTimer会觉得乱,其实直接在on_tick里判断时间差就行。
5.3 僵尸波次:用时间轴控制相对简单
第一版不要做复杂的花钱买僵尸系统,直接按时间轴生成就行。我维护了一个float关卡时间,每帧累加delta_ms。同时定义了一批出生时间点,到了时间就在随机列(通常是第5到第9列)生成僵尸。
self.spawn_events = [ (15, "normal_zombie", 0), (30, "normal_zombie", 2), (45, "cone_zombie", 1), (60, "normal_zombie", 4), ]刷怪列表可以存在JSON文件里,这样调整关卡节奏不用改代码。我用纯文本解析方式硬编码在代码里,后续扩展再拆出去。关卡结束的条件也很简单:所有刷怪事件已触发且场上没有存活僵尸,就显示胜利界面。
5.4 游戏结束条件
两句话就能说清楚:如果僵尸到达草坪左边界(越过房子位置),游戏失败;如果所有刷怪事件触发且场上没有僵尸,游戏胜利。
这里我踩过坑:一开始用“僵尸x坐标小于0”作为失败判断,忘了草坪左侧还留着割草机区域,导致僵尸刚走过背景左边就判输,视觉上觉得“明明还没进房子”。正确做法是判断僵尸的x坐标小于LAWN_LEFT减去一个安全值(比如60),也就是僵尸已经穿过草坪进入房子线。割草机逻辑我没实现,但在设计时留了这个位置,后续加很简单。
6. 中途踩过的坑:资源路径、性能与内存回收
这部分是实战中真正浪费时间的地方,也是网上教程最不爱写的地方。我把踩过的几个坑写下来,你能绕一个是一个。
6.1 图片资源路径与Python运行目录
本地调试时一切正常,图片能显示,一打包就黑屏。这基本是资源路径问题。PyQt6加载图片依赖当前工作目录,Python脚本运行时的工作目录不一定在脚本所在目录,尤其用PyInstaller打包后会跑到临时解压目录,路径就全错了。
我的解决办法是写一个resource_path函数,兼容开发环境和打包环境:
import sys from pathlib import Path def resource_path(relative_path): try: base_path = Path(sys._MEIPASS) except AttributeError: base_path = Path(__file__).parent return base_path / relative_path开发时base_path就是脚本目录,打包后就是PyInstaller解压出来的内部目录。所有图片加载都用这个函数,不要直接写相对路径。
6.2 对象删除与QGraphicsScene的坑
在QGraphicsView框架里,删除item不能直接调del,必须要从scene里移除并调用setVisible(False),否则场景还会继续引用已销毁的Python对象,轻则警告,重则崩溃。我写了一个统一的清理函数:
def remove_dead_objects(self): for obj in self.scene.items(): if isinstance(obj, GameObject) and obj.is_dead: self.scene.removeItem(obj) obj.setVisible(False) del obj特别注意,不要遍历列表的同时删除列表元素,Python会跳过下一个元素。我的做法是先标记is_dead,统一在remove_dead_objects里处理,这样最稳妥。每次清理完可以调用一次collectGarbage,但实测不调也没问题,PyQt6的引用计数处理得不错。
6.3 性能优化:为什么30FPS也会卡
第一个版本我每帧都调用scene.addItem创建新豌豆、新阳光,跑了几分钟就有明显的画面卡顿。排查后发现问题出在两方面:
一是对象创建太频繁,QGraphicsPixmapItem实例化本身有开销,我每次还要重新从资源字典拿QPixmap并复制。解决方法是提前把QPixmap全部加载好,addItem时只设置pixmap引用。
二是场景中的item数量会无限增长。如果豌豆子弹没有被碰撞销毁,会一直飞出场景,累积到几百个,碰撞检测效率骤降。我用了一个简单策略:子弹x坐标超过场景宽度后自动标记is_dead。阳光掉落无人点击也会有上限,超过20个后不再掉落新阳光,倒逼玩家积极收集。
6.4 卡槽图片压扁问题
QPushButton的setIcon如果图标尺寸没做好,图片会被拉得很难看。我统一把卡片图片尺寸固定成64×64,再用setIconSize(QSize(48, 48))控制显示大小。不同植物的卡片最好单独做,不要用游戏场景里的物体图片直接拉伸,否则看起来会很“糊”。
7. 还能继续做点什么:从Demo到完整作品的扩展路线
做到这里,一个能玩两三分钟的植物大战僵尸简化版已经完成。但说实话,它距离“完整”还差得很远。如果你想继续往上加东西,我列几个性价比比较高的方向,按难度递增排列。
7.1 用QSS把界面做得更精致
PyQt6的强项就是UI。现在的默认界面很“一眼程序员”,花半小时写一份QSS,把卡槽、阳光计数、开始按钮都加上渐变背景、圆角、阴影效果,观感会提升一个档次。QSS语法类似于CSS,上手无成本。
self.card_button.setStyleSheet(""" QPushButton { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #8B5A2B, stop:1 #5C3A21); border: 2px solid #3A2514; border-radius: 8px; padding: 4px; color: white; } QPushButton:disabled { background: gray; color: #ccc; } """)7.2 关卡配置数据外置
目前刷怪时间点写死在代码里,扩展新关卡很麻烦。把每关的僵尸种类、数量、出生时间、阳光初始值放到JSON文件里,程序运行时读取。这样调整难度只需改数据文件,不需要改代码重新编译,也方便做多关卡流程。
7.3 音效与背景音乐
QSoundEffect支持播放WAV格式短音效,非常适合豌豆发射、僵尸被击中、收集阳光这类反馈音效。背景音乐可以用QMediaPlayer,但要注意它要求安装额外的音视频后端。资源文件可以在免费音效库网站下载,注意确认授权。
7.4 打包成exe
项目收尾后想发给朋友玩,用PyInstaller打包是最直接的方式。一个完整的命令可以这样执行:
pyinstaller --onefile --windowed --add-data "assets;assets" main.py关键就是--add-data,它会把assets目录原样打包进exe,再配合之前写的resource_path函数,就能在对方的机器上正常运行。注意反斜杠在Windows下的写法是分号,不是冒号。打包后exe体积会比较大(因为带了PyQt6库),这是正常的,不要慌。
我这次实际做完之后最大的感触是:不要把“用PyQt6做游戏”当成一个奇怪的偏门方向。它可能不适合做大型游戏,但用来练习事件驱动、对象管理、状态设计,效果远比写增删改查的CRUD工具更有意思。而且做完游戏后,你对于如何在PyQt6里组织信号槽、管理对象生命周期、优化UI刷新,都会有非常感性的认识。下一款小游戏建议你试试做个《俄罗斯方块》或者《2048》,逻辑更紧凑,对状态管理的锻炼也更狠。