
想用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可以让你把精力放在游戏逻辑上而不是和按钮样式死磕。我从几个维度做了对比完全是基于这次实际开发的经验维度PyQt6QGraphicsViewPygameUI控件原生控件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_LEFT9×CELL_WIDTH的范围内行列值要在0~8和0~4之间否则直接忽略。这个判断一定要放在所有种植逻辑之前否则会出现植物种到草坪外甚至负坐标的情况。2.2 游戏对象怎么抽象我建了一个基础的GameObject类继承QGraphicsPixmapItem这样每个游戏实体自带坐标、图片、层级和碰撞区域左上角就是锚点。class GameObject(QGraphicsPixmapItem): def __init__(self, name, pixmap, row-1, col-1, hp100): 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移动速度像素/秒啃咬伤害啃咬间隔普通僵尸1203020500ms路障僵尸2802020500ms4.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, bpos) 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》逻辑更紧凑对状态管理的锻炼也更狠。