ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python pygame塔防游戏源码解析:主循环、碰撞与二次开发

Python pygame塔防游戏源码解析:主循环、碰撞与二次开发

简介:这是一份基于 Python 编写的植物大战僵尸复刻源码包,面向有一定 Python 基础、希望借助经典游戏案例练习图形界面与游戏循环逻辑的开发者与学习者。压缩包共 10 个文件、约 42KB,以 8 个 png 图片素材(草坪地图、豌豆射手、向日葵、僵尸、子弹等角色贴图)、1 个 py 主程序脚本和 1 个 md 说明文档组成,素材与代码一并提供,下载后无需另行搜集美术资源即可运行调试。已有 1791 人学习下载,说明它在同类练手项目中具备一定参考价值。读者可从主脚本中了解角色与子弹的生成、移动与碰撞判定,草坪网格坐标与图片贴图的对应方式,以及关卡地图的拼接思路;README 文档则给出运行环境与使用说明,便于快速上手改造,比如替换贴图、调整植物属性或新增僵尸波次,适合作为课程设计、期末项目与个人练习的起点。

1. 从一份 Python 塔防源码说起:它到底能干什么

用 Python 写植物大战僵尸风格的塔防玩法,几乎是每个学完 pygame 基础的人都会动一次的念头:画格子、种植物、僵尸排队走过来、豌豆飞出去把僵尸打倒。这份源码就是把这件事完整做完的版本,不是画个草坪就收工的练习,阳光经济、卡槽冷却、波次生成、子弹命中、僵尸啃食、胜负判定都在里面。它适合两类人:一类是刚摸完 pygame 教程、想找个完整项目把事件循环和精灵系统串起来;另一类是想拿它当课程设计底稿、往里塞自己的美术和关卡数据。纯 Python + pygame,不依赖引擎,读懂一行就能改一行,这是它最值钱的地方。

2. 工程结构与环境搭建:pygame 塔防的目录骨架与启动链路

拿到一份没读过的 Python 游戏源码,我的习惯是先看目录,再看入口,最后才去啃玩法逻辑。目录决定了作者有没有分层意识,入口决定了依赖能不能装上,玩法逻辑反而是最容易读的部分。这一章先把这份源码的骨架摊开,再把它跑起来,最后讲一层最容易被忽略、也最容易出问题的东西——资源路径和坐标系约定。

2.1 目录结构:谁管状态、谁管实体、谁管资源

源码的目录大致长这样,不同版本细节会有出入,但分层思路是一致的:

td-game/ ├── main.py # 唯一入口,负责初始化和退出 ├── config.py # 全局常量:分辨率、帧率、网格尺寸、路径 ├── core/ │ ├── game.py # 场景栈 + 主循环 │ ├── scene.py # 场景基类,定义 handle_event/update/draw 三件套 │ └── resource.py # 图片、音效、字体的加载与缓存 ├── entities/ │ ├── plant.py # 植物基类 + 向日葵、豌豆射手、坚果 │ ├── zombie.py # 僵尸基类与状态机 │ ├── bullet.py # 子弹,含减速等附加效果 │ └── sunlight.py # 阳光掉落物 ├── scenes/ │ ├── menu.py # 开始菜单 │ └── battle.py # 战斗主场景,玩法都在这 ├── assets/ │ ├── images/ │ ├── sounds/ │ └── fonts/ └── data/ └── level_1.json # 波次配置

core这一层是引擎性质的东西,跟具体玩法无关,你换一套美术和规则它照样用。entities是游戏对象,每个类都继承pygame.sprite.Sprite,自带image和rect两个属性,这样才能塞进pygame.sprite.Group里批量更新和碰撞。scenes负责把对象组装起来,菜单和战斗是两个独立场景。data放数值配置,这一层做得好不好,直接决定你后面改平衡是改表格还是满代码库找数字。

我一般会额外在config.py里放一套全局常量,所有模块从这里取,不允许硬编码数字:

# config.py import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) ASSETS_DIR = os.path.join(BASE_DIR, "assets") SCREEN_W, SCREEN_H = 1000, 600 FPS = 60 GRID_COLS, GRID_ROWS = 9, 5 CELL_W, CELL_H = 80, 100 GRID_ORIGIN = (250, 80) # 草坪左上角在屏幕中的坐标 START_SUN = 50 SUN_INTERVAL = 6.0 # 自然掉阳光的间隔(秒) SUN_VALUE = 25

BASE_DIR用abspath(__file__)推出来,而不是os.getcwd(),这是血泪经验——用当前工作目录拼路径,换个地方启动就报FileNotFoundError。后面 2.3 节还会细说。

2.2 依赖安装与第一次启动

这份源码只依赖 pygame,没有其他第三方库。建议用虚拟环境,避免和你机器上其他项目的 pygame 版本打架:

# 创建并激活虚拟环境 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 安装依赖,版本号以源码 requirements.txt 为准 pip install pygame==2.5.2 # 启动 python main.py

如果没有requirements.txt,装最新版通常也能跑,但 pygame 2.x 和 1.9.x 之间 API 差异不小,尤其是Surface.blit的special_flags和 mixer 的初始化参数,跑到一半报TypeError多半是版本不对,先pip show pygame确认一下。

入口文件通常很薄,只做三件事:初始化、进主循环、保证退出时资源释放:

# main.py import sys import pygame from core.game import Game def main(): pygame.init() pygame.display.set_caption("Tower Defense Demo") game = Game() try: game.run() finally: # 无论正常退出还是异常,都保证 pygame 被正确关闭 pygame.quit() return 0 if __name__ == "__main__": sys.exit(main())

这里try/finally不是多余的。pygame 在部分平台上如果没走pygame.quit(),窗口关闭后进程会残留,调试的时候反复启动会积累一堆僵尸进程,任务管理器里看着很乱。sys.exit(main())把返回值交给操作系统,方便以后接 CI 时判断退出码。

2.3 资源路径与坐标系:最容易翻车的一层

塔防游戏的坐标换算比一般小游戏复杂,因为存在两套坐标:屏幕像素坐标和网格坐标。源码里一般会提供两个互转函数,位置放在scenes/battle.py或单独的core/grid.py:

# core/grid.py import pygame from config import GRID_COLS, GRID_ROWS, CELL_W, CELL_H, GRID_ORIGIN def cell_to_rect(col: int, row: int) -> pygame.Rect: """网格坐标 -> 屏幕矩形,用于种植和绘制""" x = GRID_ORIGIN[0] + col * CELL_W y = GRID_ORIGIN[1] + row * CELL_H return pygame.Rect(x, y, CELL_W, CELL_H) def pos_to_cell(pos): """鼠标屏幕坐标 -> 网格坐标,越界返回 None""" x, y = pos col = (x - GRID_ORIGIN[0]) // CELL_W row = (y - GRID_ORIGIN[1]) // CELL_H if 0 <= col < GRID_COLS and 0 <= row < GRID_ROWS: return int(col), int(row) return None

cell_to_rect负责正向计算,种植时用它的返回值判断格子是否被占。pos_to_cell负责反查,鼠标点击时调用。这里有两个细节:一是//对负数向下取整,鼠标移到草坪左侧时会算出-1,必须用范围判断兜住;二是返回值里的int()不能省,-0.5 // 80在某些值上会得到-1.0这种浮点,拿去当字典 key 会出现「明明点的是这一格却种不上」的玄学现象。

资源加载建议统一收口到core/resource.py,做一层缓存,避免同一张图被反复load:

# core/resource.py import os import pygame from config import ASSETS_DIR _cache = {} def load_image(name: str) -> pygame.Surface: if name not in _cache: path = os.path.join(ASSETS_DIR, "images", name) _cache[name] = pygame.image.load(path).convert_alpha() return _cache[name]

convert_alpha()必须放在display.set_mode()之后调用,否则会抛pygame.error: cannot convert without video mode。这一条在 5.1 节还会作为坑点单独讲。

3. 主循环拆解:从 tick 到 flip 的一帧完整链路

游戏卡不卡、手感对不对、僵尸会不会穿模,八成问题都出在主循环这一百来行代码里。很多人读源码喜欢从entities开始,结果读了一堆类的属性却不知道它们什么时候被调用。我的建议是反过来:先看一帧里发生了什么,再回头去看每个类的update在什么时候被谁触发。

3.1 主循环骨架与 dt 的意义

# core/game.py import pygame from config import SCREEN_W, SCREEN_H, FPS from scenes.menu import MenuScene class Game: def __init__(self): self.screen = pygame.display.set_mode((SCREEN_W, SCREEN_H)) self.clock = pygame.time.Clock() self.scene_stack = [MenuScene(self)] self.running = True @property def scene(self): return self.scene_stack[-1] def run(self): while self.running: # tick 返回距上一帧的毫秒数,除 1000 转成秒 dt = self.clock.tick(FPS) / 1000.0 # 关键:切窗口回来 dt 可能高达几秒,必须钳制 dt = min(dt, 0.05) for event in pygame.event.get(): if event.type == pygame.QUIT: self.running = False else: self.scene.handle_event(event) self.scene.update(dt) self.scene.draw(self.screen) pygame.display.flip()

clock.tick(FPS)一行做两件事:把循环速度限制在 60 帧,同时返回上一帧耗时。用它算dt的好处是,所有运动都按「像素每秒」写,不受机器性能影响。僵尸速度写成20 * dt,那么在 30 帧的机器和 144 帧的机器上,走过一格草坪花的时间是一样的。

min(dt, 0.05)这行是后悔药。拖动窗口、系统弹通知、切到后台再切回来,tick可能返回 2000 毫秒,此时dt = 2.0,僵尸在一帧之内瞬移两格,直接越过你的坚果墙。钳到 50 毫秒,最坏情况也就多走一小步,玩家看不出来。

3.2 场景栈:菜单、战斗、暂停怎么组织

用栈管场景,比用if state == "menu"这种字符串判断要干净得多。核心就三个方法:

def push(self, scene): """进入新场景,比如菜单 -> 战斗""" self.scene_stack.append(scene) def pop(self): """返回上一场景,栈底永远保留主菜单""" if len(self.scene_stack) > 1: self.scene_stack.pop() def replace(self, scene): """替换当前场景,用于重开一局""" self.scene_stack[-1] = scene

栈的好处是暂停特别自然:按 ESC 时push(PauseScene),暂停界面盖在战斗场景上面,update只跑暂停场景的,战斗场景的计时器全部冻住,不需要在每个类里加if paused: return。按继续时pop()一下,战斗从冻结处接着跑。

事件分发也跟着栈顶走。Game.run里只把事件交给self.scene,也就是栈顶那个,底下的场景收不到键盘事件。如果你需要暂停界面半透明显示后面的战斗画面,就在PauseScene.draw里先让下一层场景画一遍,再叠一层半透明遮罩,这部分源码里一般写在scenes/pause.py。

3.3 更新顺序与碰撞结算:为什么不能边更新边打

BattleScene.update里的调用顺序,是整份源码最值得抄的部分:

def update(self, dt): # 1. 先更新各自状态,不做任何跨对象判定 self.sun_mgr.update(dt) self.plants.update(dt) self.bullets.update(dt) self.zombies.update(dt) self.wave_mgr.update(dt) # 2. 所有对象都移动到位之后,再统一结算碰撞 self.resolve_bullet_hits() self.resolve_zombie_eat() # 3. 最后做清场和胜负判断 self.cleanup_dead() self.check_game_over() def resolve_bullet_hits(self): # dokill1=True 表示子弹命中即销毁,dokill2=False 表示僵尸由血量逻辑处理 hits = pygame.sprite.groupcollide(self.bullets, self.zombies, True, False) for bullet, zombies in hits.items(): for z in zombies: z.take_damage(bullet.damage) if bullet.slow: z.apply_slow(bullet.slow, duration=3.0)

groupcollide(group1, group2, dokill1, dokill2)返回一个字典,key 是第一个组里的精灵,value 是与之碰撞的第二个组精灵列表。这里子弹设成命中即删,僵尸不删——因为僵尸有血量,可能挨好几发,删不删由take_damage里的血量判定决定。

顺序为什么重要?如果把碰撞结算插在zombies.update前面,会出现这样的情况:子弹在这一帧移动到僵尸身上,但僵尸还没移动,判定用的是旧位置;等僵尸移动完可能已经跑到子弹后面去了,视觉上就是「子弹从僵尸身体里穿过去没打中」。把所有位移做完再统一判定,这类问题一次性消掉。

cleanup_dead()里遍历各组,把hp <= 0或者已经出屏的精灵kill()掉。kill()只是把它从所有组里移除,对象本身还在,如果这个僵尸持有对其他对象的引用(比如正在啃的植物),要在kill之前把引用置空,否则植物那边会一直以为有人在啃它。

4. 玩法内核实战:阳光经济、格子种植与僵尸波次

前面两章讲的是骨架,这一章才是肉。塔防游戏的乐趣全在数值和节奏上,而数值和节奏在代码里体现为三个东西:阳光的产出速率、种植的冷却时间、僵尸波次的密度。这三个数字调好了,游戏就有味道;调不好,要么前三十秒无事可做,要么第三波直接被推平。

4.1 阳光:定时掉落、点击拾取与上限

# entities/sunlight.py import random import pygame from config import GRID_COLS, CELL_W, GRID_ORIGIN, SUN_INTERVAL, SUN_VALUE class SunManager: def __init__(self): self.timer = 0.0 self.sun = 50 # 开局阳光 self.drops = pygame.sprite.Group() def update(self, dt, sun_value=SUN_VALUE, interval=SUN_INTERVAL): self.timer += dt # 用 while 而不是 if,防止某一帧 dt 较大时漏掉一次产出 while self.timer >= interval: self.timer -= interval self.spawn_sky_sun(sun_value) for s in list(self.drops): s.update(dt) if s.expired(): s.kill() def spawn_sky_sun(self, value): col = random.randint(0, GRID_COLS - 1) x = GRID_ORIGIN[0] + col * CELL_W + CELL_W // 2 self.drops.add(Sun(x, y=-40, fall_speed=60, value=value, life=8.0)) def collect(self, pos): """鼠标点击时调用,命中就加阳光""" for s in list(self.drops): if s.rect.collidepoint(pos): self.sun += s.value s.kill() return True return False

while那一段是有意为之。如果写成if self.timer >= interval,当某一帧dt稍微大一点(比如 0.1 秒),计时器每次只减一个interval,剩下的零头在下一帧继续累积,长期下来阳光产出会比设计值偏慢。用while把累积的时间全部消化掉,产出曲线才是平的。

Sun类本身很简单,fall_speed控制下落速度,life控制存活时间,超时自动消失,防止玩家不点导致屏幕上堆满阳光。落地后一般会停止下落,源码里用self.y >= self.target_y判断,target_y取格子中心高度,这样阳光落点看起来是「掉在草坪上」而不是穿到屏幕底部。

阳光的数值设计上,自然掉落 25 一个、6 秒一次,向日葵 50 阳光换 25 一次、约 8 秒产一次,豌豆射手 100 阳光。也就是说开局 50 阳光,最快也要等到第一个自然阳光落地才能种下第一株向日葵,这个节奏感是刻意留的。你如果觉得开局太慢,把SUN_INTERVAL改成 4.0 就能明显感觉到区别,改完跑三波僵尸再决定要不要保留。

4.2 网格种植:卡槽冷却与占位判定

卡槽数据用字典驱动,一处配置、四处使用:

# data/cards.py from entities.plant import Sunflower, Peashooter, WallNut CARD_DEFS = { "sunflower": {"cost": 50, "cd": 7.5, "cls": Sunflower}, "peashooter": {"cost": 100, "cd": 7.5, "cls": Peashooter}, "wallnut": {"cost": 50, "cd": 30.0, "cls": WallNut}, }

种植逻辑放在BattleScene里,每次点击先过三道判断:

def try_plant(self, key, cell): if cell is None: return False card = CARD_DEFS[key] # 1. 冷却 if self.cooldowns.get(key, 0.0) > 0: return False # 2. 阳光够不够 if self.sun_mgr.sun < card["cost"]: return False # 3. 这一格有没有东西 if cell in self.occupied: return False rect = cell_to_rect(*cell) plant = card["cls"](rect.centerx, rect.centery) self.plants.add(plant) self.occupied[cell] = plant self.sun_mgr.sun -= card["cost"] self.cooldowns[key] = card["cd"] return True

cooldowns用一个字典存每张卡的剩余冷却秒数,update里统一递减:

for k in self.cooldowns: self.cooldowns[k] = max(0.0, self.cooldowns[k] - dt)

注意max(0.0, ...),冷却减到负数不影响判断,但显示在卡槽上的进度条会算错,保险起见钳住。

occupied用(col, row)元组当 key,指向植物实例。这个字典要双向维护:植物被僵尸啃死时要del self.occupied[cell],否则会出现「格子空了但显示已占用、种不进去」的情况。这是我自己改这份代码时踩过的坑,后面 5.2 节会展开。

4.3 波次生成与僵尸状态机

波次配置放在 JSON 里,读出来是一个列表,每项包含触发时间和这一波刷什么:

# data/level_1.json 的结构示例(读取后是 Python 列表) WAVES = [ {"at": 8.0, "spawn": [("normal", 4)]}, {"at": 25.0, "spawn": [("normal", 4), ("cone", 3)]}, {"at": 50.0, "spawn": [("normal", 5), ("cone", 4), ("bucket", 2)]}, ]

("normal", 4)表示在这一行(0 到 4 行)刷一只普通僵尸,行号随机也行,源码里通常写成row由配置指定、-1表示随机。

波次管理器的核心是一个elapsed计时器和已消费索引:

class WaveManager: def __init__(self, waves, zombie_factory): self.waves = sorted(waves, key=lambda w: w["at"]) self.factory = zombie_factory self.elapsed = 0.0 self.index = 0 def update(self, dt, group): self.elapsed += dt while self.index < len(self.waves) and self.elapsed >= self.waves[self.index]["at"]: wave = self.waves[self.index] for kind, row in wave["spawn"]: group.add(self.factory(kind, row)) self.index += 1

僵尸本身是个状态机,只有三个状态:走、啃、死。

# entities/zombie.py class Zombie(pygame.sprite.Sprite): def __init__(self, x, y, speed=20, hp=200, bite=25): super().__init__() self.x = float(x) self.speed = speed self.hp = hp self.bite_damage = bite self.state = "walk" self.target = None # 正在啃的植物 def update(self, dt): if self.state == "walk": self.x -= self.speed * dt self.rect.x = int(self.x) if self.x < -60: # 走出屏幕左侧,判负 self.state = "dead" elif self.state == "eat": self.eat_timer += dt if self.eat_timer >= 0.5: self.eat_timer -= 0.5 if self.target and self.target.alive(): self.target.take_damage(self.bite_damage) else: self.state = "walk" self.target = None

self.x用浮点单独存,rect.x每帧取整同步。如果直接用rect.x -= speed * dt,在dt很小的时候会反复取整成 0,僵尸看起来就是原地不动。这个细节几乎所有 pygame 教程都会踩。

state从walk切到eat的判断通常放在场景层:僵尸的rect和某一株植物的rect发生碰撞,就设置target并把状态改成eat。切回去的条件是目标已经死亡或被移除,代码里用self.target.alive()判断,alive()是pygame.sprite.Sprite自带的方法,精灵被kill()之后返回False。

5. 常见问题排查:五类跑不起来和跑起来不对的坑

这份源码在别人机器上跑通不代表在你机器上跑通,pygame 的坑集中在启动那几十秒和运行时的表现异常上。下面五条是我在不同机器上实际遇到过的,按「现象 → 原因 → 解决」写,遇到了直接对号入座。

5.1 启动阶段的三类报错

坑一:满屏方块,中文全是豆腐块。

现象:游戏能跑,但所有中文文字显示成一个个方框或者问号。

原因:pygame.font.SysFont("simhei", 24)里的字体名是系统相关的,macOS 和 Linux 上根本没有这个字体,pygame 找不到就回退到一个不含中文字形的默认字体。

解决:把字体文件放进assets/fonts/,用绝对路径加载,别依赖系统字体:

FONT_PATH = os.path.join(ASSETS_DIR, "fonts", "SourceHanSansCN-Regular.otf") _font_cache = {} def get_font(size): if size not in _font_cache: _font_cache[size] = pygame.font.Font(FONT_PATH, size) return _font_cache[size]

字体文件建议用开源可商用的中文字体,随项目一起打包,换机器也不会出问题。

坑二:cannot convert without video mode。

现象:一启动就抛pygame.error: cannot convert without video mode。

原因:某个模块在pygame.display.set_mode()之前调用了convert()或convert_alpha()。常见于resource.py在模块顶层就加载图片,而main.py里import发生在set_mode之前。

解决:图片加载写成惰性函数,第一次调用时才真正读文件,不要在模块导入时执行。

坑三:窗口一闪就退出,控制台没有任何消息。

现象:python main.py回车后窗口闪一下消失。

原因:主循环里for event in pygame.event.get()正常,但self.running被某个条件提前置为了False,或者run()方法根本没被调用——入口if __name__ == "__main__":写成了if __name__ = "__main__":(少一个等号)这种低级错误。

解决:在run()的第一行和while循环里各打一个print,确认代码真的进去了。如果没有输出,问题在入口;如果有输出但很快退出,在self.running = False的位置打日志。

5.2 运行时的两类表现异常

坑四:僵尸穿过植物继续走, фун图看不出问题。

现象:僵尸明明和豌豆射手重叠了,却不啃,直接穿过去。

原因:僵尸的rect和植物的rect位置对了,但碰撞判断用的是pygame.sprite.spritecollide且传了dokill=True,第一个碰到的植物被删了却没触发啃食逻辑;或者僵尸的rect.x是浮点同步的、植物在格子中心,两边的判定区域没对齐。

解决:把「谁和谁碰」的判断从spritecollide换成手动遍历,判定范围用rect.inflate(-20, -10)收窄,避免视觉上没碰到就触发:

def resolve_zombie_eat(self): for z in self.zombies: if z.state == "eat": continue for p in self.plants: if z.rect.inflate(-20, -10).colliderect(p.rect): z.target = p z.state = "eat" z.eat_timer = 0.0 break

坑五:植物被啃死之后,那一格再也种不上东西。

现象:向日葵被吃掉,格子空了,但鼠标点上去提示已占用,或者没反应。

原因:occupied字典只在种植时写入,植物死亡时没有同步删除。

解决:给植物加一个cell属性,存它所在的网格坐标,在cleanup_dead()里统一清理:

def cleanup_dead(self): for p in list(self.plants): if p.hp <= 0: if p.cell in self.occupied: del self.occupied[p.cell] p.kill()

同时把僵尸的target置空,不然僵尸会抱着一个已经kill()的对象继续啃空气。

6. 二次开发:加一株新植物并验证它的数值

给这份源码加新内容,最省事的路径是「配置优先,代码兜底」:先看新东西能不能通过改字典加进去,实在不行再动类。我拿加一株冰豆射手举例,它和豌豆射手 90% 的逻辑一样,只是子弹带减速。

第一步改配置,CARD_DEFS里加一行:

# data/cards.py from entities.plant import Sunflower, Peashooter, WallNut, IcePeaShooter CARD_DEFS = { "sunflower": {"cost": 50, "cd": 7.5, "cls": Sunflower}, "peashooter": {"cost": 100, "cd": 7.5, "cls": Peashooter}, "wallnut": {"cost": 50, "cd": 30.0, "cls": WallNut}, "icepea": {"cost": 175, "cd": 7.5, "cls": IcePeaShooter}, }

第二步让子弹支持减速参数,不新写类,复用现有的:

# entities/bullet.py class Bullet(pygame.sprite.Sprite): def __init__(self, x, y, damage=20, speed=320, slow=None): super().__init__() self.image = resource.load_image("pea.png") self.rect = self.image.get_rect(center=(x, y)) self.pos_x = float(x) self.damage = damage self.speed = speed self.slow = slow # 形如 (0.5, 3.0),表示速度减半持续 3 秒 def update(self, dt): self.pos_x += self.speed * dt self.rect.x = int(self.pos_x) if self.rect.left > SCREEN_W: self.kill()

slow参数是元组,第一个值是速度倍率,第二个是持续秒数。传入None就是普通豌豆,传入(0.5, 3.0)就是冰豆。

第三步写IcePeaShooter,继承Peashooter只覆盖开火方法:

# entities/plant.py class IcePeaShooter(Peashooter): COST = 175 FIRE_INTERVAL = 1.4 def shoot(self): return Bullet(self.rect.centerx + 20, self.rect.centery, damage=20, slow=(0.5, 3.0))

第四步给僵尸的apply_slow补实现,之前只有调用没有定义的话这里一并补上:

# entities/zombie.py def apply_slow(self, factor, duration): # 取更强的减速效果,不叠加,避免多株冰豆把僵尸定死 if factor < self.slow_factor: self.slow_factor = factor self.slow_timer = duration def tick_slow(self, dt): if self.slow_timer > 0: self.slow_timer -= dt if self.slow_timer <= 0: self.slow_factor = 1.0

改完之后别急着开一局看效果,数值类改动最好先做一轮无窗口验证。pygame 支持SDL_VIDEODRIVER=dummy,可以不开窗口跑逻辑:

# tools/verify_icepea.py import os os.environ["SDL_VIDEODRIVER"] = "dummy" # 必须在 import pygame 之前设置 import pygame pygame.init() pygame.display.set_mode((1, 1)) from entities.bullet import Bullet from entities.zombie import Zombie z = Zombie(x=600, y=200, speed=40, hp=200) b = Bullet(x=100, y=200, damage=20, slow=(0.5, 3.0)) z.take_damage(b.damage) z.apply_slow(*b.slow) for _ in range(60): # 模拟 60 帧,每帧 1/60 秒 z.tick_slow(1 / 60) z.update(1 / 60) assert z.hp == 180, f"伤害结算异常: {z.hp}" assert abs(z.slow_factor - 0.5) < 1e-6, "减速未生效" print("icepea ok, x =", round(z.x, 2))

跑通这个脚本再去开窗口,能省掉大量「改了代码、开游戏、发现没效果、回去加 print」的来回。我后来养成一个习惯:任何涉及数值的改动,都先在 dummy 驱动下写一个断言脚本跑一遍,尤其是碰撞和计时相关的逻辑,肉眼在 60 帧里根本分不清是减速了还是没减速、是伤害 20 还是 25。

改造过程中还有个容易忽略的点:新植物要进 UI 卡槽。卡槽一般是从CARD_DEFS遍历生成的,加了字典就自动多一格,但如果卡槽位置是硬编码的range(3),新卡就不会显示。检查scenes/battle.py里画卡槽的循环,确认它读的是CARD_DEFS的长度而不是写死的数字。这种「数据加了、界面没加」的问题,比逻辑错误更隐蔽,因为游戏不报错,只是新东西永远不出现在你面前。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进