实习开发pvz小游戏遇到的一些问题
记录这个 Pygame PvZ clone 开发里几个很小、但很容易被忽略的问题:开局时序、路障和铁桶的护具改造、豌豆目标与僵尸阻挡判定。
实习开发pvz小游戏遇到的一些问题
这个项目是用 Python 和 Pygame 写的一个 PvZ 小游戏。大部分时间都在加植物、调素材、改动画,真正让我停下来查代码的,反而是几个看上去不太起眼的小问题。
这篇不算教程,主要想把当时是怎么发现、又是怎么处理的记下来。以后项目再出类似问题,至少能知道自己以前在哪儿摔过跟头。
倒计时结束了,第一只僵尸怎么还没来?
开局我做了一个简单的倒计时:3 → 2 → 1 → 准备开始。画面上看着没什么问题,倒计时结束后提示也会消失。
但实际玩的时候,草坪会空很久。第一次我还以为是自己刚开局太紧张,注意力都放在种向日葵上了;多玩几次才觉得不对:都“准备开始”了,怎么还是一点动静都没有?
我没有直接去改波次间隔,而是先把开局过程按秒跑了一遍。结果是:
t=4 倒计时结束
t=17 出现“第 1 波即将到来”
t=20 第 1 波进入进行中状态
t=28 第一只僵尸真正生成
所以问题不是僵尸走得慢,而是它压根还没生成。倒计时结束到第一只僵尸出现,足足隔了 24 秒。
后来顺着状态往下看,原因也不复杂。start_countdown_timer 只负责控制屏幕上的倒计时;第 1 波还是走了正常的波次冷却,波次开始后又要再等一次生成间隔。两个流程各自都没写错,只是拼在一起后,玩家看到的“开始”和游戏内部真正开始不是同一件事。
最后我没有去压短全部波次,而是单独给首波加了启动入口:倒计时结束就启动第 1 波,同时生成第一只僵尸。后面的僵尸还是按原来的间隔出,后续波次的预警也保留,只是预警结束后不再多空等一段。
改完之后,倒计时归零时第一只僵尸就会出现。这个改动很小,但开局的感觉顺了很多。
路障和铁桶僵尸,最后没有做成两种僵尸
路障僵尸和铁桶僵尸是项目里比较早加的内容。一开始的想法很直接:普通僵尸算一种,路障和铁桶各算一种,分别用完整的僵尸贴图。
接进游戏后,问题出在图片尺寸上。程序里僵尸的高度、脚底位置和血条位置都是按普通僵尸定的,完整的路障图和铁桶图却不完全一样。直接缩放会让头上的东西看着不对,调位置后又会出现脚没对齐、身体大小不一致的情况。
当时也试过反复调贴图大小和偏移,但始终很难让三种僵尸在草坪上看起来像同一套东西。后来我干脆换了个思路:不再把路障、铁桶当成完整的僵尸,而是把它们从图片里单独切出来。
现在画面上的结构是“普通僵尸本体 + 路障或铁桶配饰”。普通僵尸的尺寸和动画不用变,配饰单独根据偏移量叠在头上。相关的数据放进 ACCESSORY_REGISTRY,里面除了图片和位置,也有单独的血量。
这样一来,路障和铁桶不只是视觉上的差别。豌豆打到带配饰的僵尸时,会先扣护具血量;护具被打掉后,僵尸还会继续走,只是头上少了东西。和“换一张血更多的僵尸图”相比,这样的状态也更清楚。
更意外的是,配饰单独存在后,后面做玩法就顺手很多了。现在护具僵尸被击败时有机会掉下路障或铁桶,玩家点一下就能收进顶部库存,再把它装到普通植物身上。植物有了额外的护具血量,僵尸会先把护具啃掉,才会伤到植物本体。
最开始只是想把贴图大小调顺,最后却多了一条“打掉僵尸护具,再给植物穿上”的小循环。这个改动花的时间比预想多,但它也让路障和铁桶不再只是两张不同的图片。
豌豆该打谁,不应该靠列表顺序碰运气
豌豆目标的问题比较特别:它不是玩家先遇到、然后跑来报的 Bug,而是我在走读碰撞逻辑时发现的一个隐患。
当时的写法是从僵尸列表里找第一个碰到豌豆的目标。在当前默认规则下,这通常没事:同一波僵尸速度相同,生成顺序和画面上的前后顺序大多是一致的。所以正常玩下来,它未必会出错。
问题是,这个“通常”不是规则。以后只要有不同移速、特殊生成,或者两只僵尸刚好重叠,列表顺序就不一定还能代表它们在草坪上的位置。
为了确认这一点,我手动摆了两只同排、位置重叠的僵尸,再把它们放进列表的顺序反过来。旧逻辑会打列表里的第一只;它可能刚好站在后面。
现在的处理就是把规则写明白:先找出真的和豌豆重叠的僵尸,再选 x 更小的那只。僵尸是从右往左走的,x 更小也就代表它更靠近房子,是豌豆应该先打到的目标。
target = min(colliding_zombies, key=lambda zombie: zombie.x, default=None)
这行代码没什么技巧,只是把原来“依赖列表刚好排对”的前提去掉了。以后真的加上不同速度的僵尸,这个点也不用再回头补。
僵尸为什么会吃到身后的坚果?
这个问题就比较明显了。僵尸站在两株植物中间,还没有碰到右边那株,右边的坚果却开始掉血。看起来像僵尸隔空咬了一口。
我一开始怀疑是遍历方向的问题:代码从右往左找植物,可能直接选中了这一行最右边的那株。后来用一个固定场景测了一下:僵尸的横向范围在 400..446,第 3 列和第 7 列各放一个坚果。它位于两株坚果之间,理论上应该继续往左走,谁都不该攻击。
结果右边、第 7 列的坚果掉血了。
继续看条件才发现,遍历方向不是主因。旧判断只看了僵尸左边缘有没有超过植物右侧,却没有确认僵尸右边缘有没有碰到植物左侧。换句话说,只要植物在僵尸右边,它就有机会被当成“挡路的植物”。
这次改动是把僵尸的实际宽度带进判断里,要求僵尸和植物格真的有横向重叠:
zombie_right = x + width
is_blocking = zombie_right >= plant_left and x <= plant_right - 8
从右往左扫描仍然保留。僵尸向左走,真正接触到植物时,右边那株确实是它先撞上的;需要修的是“有没有接触到”,而不是扫描方向。
这类问题很容易只测一半。修完后我额外确认了三件事:僵尸夹在两株植物中间时会继续走;碰到右边那株后只咬它;前面没有植物时,仍然会正常越过左边界并判负。这样至少不会为了修“隔空咬”又把原来的失败流程弄坏。
后来我把这几个场景留成了测试
这几个改动之后,项目里留下了对应的测试:倒计时结束时第一波已经生成、列表顺序和位置顺序不一致时豌豆仍会打最靠前的僵尸、僵尸只有接触到植物才会进入攻击状态。
它们可以用 dummy SDL 直接跑,不用每次开游戏窗口再手动摆场景。现在项目的测试数量不算多,但这几个场景对我挺有用——后面改波次或碰撞时,先跑一下,就能知道自己有没有把以前的坑踩第二遍。
做这种小项目时,我还是很喜欢先打开游戏看看手感。不过遇到逻辑问题,光靠反复试玩很容易越改越乱。先把位置、时间和状态固定下来,再改对应的一小块代码,最后把这个场景留下来,整体会踏实很多。
作者:T | 汕头大学光电信息科学与工程 | AI Agent 方向 GitHub: github.com/iuyup
