做塔防游戏的人常有一个误区:以为难点在美术和动画。真正动手后会发现,画面反而是最容易糊弄的部分——几个色块加文字就能把玩法跑通;真正难的是逻辑边界:僵尸什么时候该停下啃植物、子弹在什么条件下算命中、暂停时计时器要不要继续走、窗口卡了一秒之后世界会不会瞬移。这些问题跟画得好不好看没有关系,却直接决定这份代码能不能继续迭代下去。
本文围绕一个代号为“植物大战僵尸 2.1”的塔防小游戏工程展开。这里的 2.1 是项目自身的迭代版本号,指的是从“能跑起来”的原型升级到“结构上能改得动”的第二版,不涉及任何官方发行版本,也不使用任何官方美术、音频和关卡数据。示例中的植物和僵尸全部用矩形色块代替,这样既避开了素材版权问题,也让读者能把注意力放在结构上。
选择这个题目,是因为塔防是极好的工程训练场:它同时包含网格坐标系、固定时间步、实体状态管理、资源经济、碰撞判定和游戏状态机六个知识点,每一个都能单独拎出来讲,合在一起又是一个完整可运行的小系统。学会这套骨架,换成消消乐、塔防、自走棋甚至简单的 RTS,思路是通用的。
如果你正在用 Python 写自己的第一个游戏,或者写完了原型却发现“加一个新植物要改五个文件”,下面的内容会比较对味。文章先拆结构,再给完整可运行的多文件代码,最后是排错清单和工程建议,代码可以直接复制到本地跑起来。
1. 从“能跑”到“能改”:2.1 版本真正要解决的问题
一个塔防原型的第一版通常长这样:主循环里写死了所有逻辑,植物位置用像素坐标散落在各处,僵尸移动速度直接写在while循环里,攻击判定靠一堆if嵌套。它能跑,甚至看起来还不错,但只要你想加一个新植物,或者把帧率从 60 调到 120,问题就全冒出来了。
这类代码的债务主要来自三处。第一处是坐标耦合:种植时用鼠标像素坐标判断,绘制时又用另一套偏移量算位置,两边一旦不一致,就会出现“点得到但画歪了”或者“画对了但点不中”的诡异现象。第二处是帧率耦合:把移动写成x -= 5,意味着 60 帧下每秒走 300 像素,30 帧下就只走 150 像素,同一份代码在不同机器上手感完全不同。第三处是实体耦合:植物直接持有僵尸列表并修改僵尸血量,僵尸又反过来删植物,调用链一长,谁都说不清某一行到底该由谁负责。
2.1 版本做的事情,本质上就是把这三处债务各切一刀。坐标层面引入“格子坐标”作为唯一事实来源,像素坐标只作为渲染时的派生结果;时间层面引入固定时间步,逻辑更新只认dt,不认帧;实体层面规定单向数据流——植物只负责“我想开火”,世界对象负责“生成子弹、结算伤害、清理尸体”。切完这三刀,加一个新植物的成本从“改五个文件”降到“新增一个类 + 注册一行字典”。
| 维度 | 2.0 原型的做法 | 2.1 的做法 | 不加区分会怎样 |
|---|---|---|---|
| 位置表示 | 到处用像素坐标 | 格子坐标为准,像素坐标派生 | 缩放窗口后点位全错 |
| 移动速度 | 每帧固定像素 | 每秒像素 × dt | 高刷屏上僵尸跑得飞快 |
| 伤害结算 | 植物直接改僵尸血量 | 统一在世界层结算 | 删除时机混乱,偶发崩溃 |
| 数值配置 | 散落在逻辑里 | 集中到 config 模块 | 调平衡要全局搜索替换 |
| 状态管理 | 用布尔变量拼 | 显式状态字符串 | 失败后还能继续种植物 |
判断自己是否该读下去,可以看三个信号:你写过的游戏代码超过 300 行;你打算在原型基础上继续加内容而不是丢掉重写;你能接受用色块代替贴图先把逻辑跑通。三条中占两条,这篇的投入产出比就不错。
2. 塔防游戏的核心概念与结构拆解
在动手写代码之前,有必要把几个容易混淆的概念说清楚。塔防看起来是“植物打僵尸”,工程上其实是四件事的叠加:一张离散的格子地图、一个稳定的时间推进器、一组有生命周期的实体、一套资源收支规则。下面逐条拆。
2.1 格子坐标与像素坐标为什么要分开
玩家看到的是像素,游戏逻辑关心的却是“第几列第几行”。这两套坐标混用是新手最常见的错误来源。举个具体例子:你想判断鼠标点在哪一格,如果用像素坐标直接做除法,一旦网格有左上角偏移量,算出来的格子就会整体错位;而如果绘制时又忘了加偏移量,植物就会画到网格外面去。
2.1 的做法是把格子坐标(col, row)当作唯一可信的数据,任何实体只记录自己占哪一格。需要画图、需要算距离时,再通过一个转换函数把格子换算成像素。这样即使以后把格子尺寸从 80 像素改成 100 像素,或者把网格整体右移,所有实体的相对位置关系都不用动,改一个函数就够了。
这种“一份数据、多处派生”的思路在工程里很常见,本质是消除冗余状态。冗余状态的问题不在于多占内存,而在于它会不一致,而不一致就会产生那种“只在特定缩放比例下才复现”的 bug。
2.2 固定时间步:为什么不能直接用每帧时间
游戏主循环有两种时间:渲染帧时间和逻辑步长。渲染帧时间由硬件决定,可能是 16.7 毫秒,也可能是 8 毫秒,甚至因为窗口拖动卡成 200 毫秒。如果把逻辑直接绑在渲染帧上,物理表现就会随硬件漂移。
固定时间步的做法是:维护一个累加器,把每帧真实耗时累加进去,只要累加值超过固定步长(比如 1/60 秒),就执行一次逻辑更新并从累加器里扣掉相应时间。这样无论渲染多快多慢,逻辑世界始终以相同的节奏推进。同时要加两个保护:单帧时间做上限截断,防止切回窗口时一次补上几百步导致卡死;单帧内限制最大补步次数,防止低配机器上陷入“越补越慢”的死循环。
这两个保护点在实际项目里非常关键。很多“游戏跑一会儿就卡住”的问题,根因就是切后台后累加器攒了几十秒的时间,回到前台瞬间执行了几千次逻辑更新。
2.3 实体职责边界:谁能改谁
实体系统的核心不是继承,而是职责划分。一个清晰的划分方式是:
- 植物知道自己占哪一格、什么时候该攻击、血量还有多少。
- 僵尸知道自己在哪一行、往左走到什么位置、当前目标是哪个植物。
- 子弹知道自己往哪飞、打到谁、伤害多少。
- 世界对象负责持有所有列表、驱动更新、结算死亡、判定胜负。
关键在于“结算”这一步统一收口。植物不直接删除僵尸,只把伤害交给世界;世界在每帧末尾统一遍历,把血量归零的实体标记为死亡并从列表里移除。这样做的好处是删除时机确定,不会出现“正在遍历列表时被别的对象删掉元素”这种典型的运行时错误。
2.4 状态机:别再堆布尔变量了
原型阶段常见的写法是is_paused、is_game_over、is_win三个布尔量。问题很快出现:暂停时按到了种植键会怎样?失败之后还能不能继续更新僵尸?三个变量有八种组合,其中五种是非法的。
用单一状态字符串(playing、paused、win、lose)代替布尔组合,能让非法状态根本无法表示。更新函数入口先判断状态,不是playing就直接返回,所有“失败后还能操作”的漏洞一次性堵住。这个模式简单到几乎不需要框架,但效果立竿见影。
3. 环境准备与项目结构
示例使用 Python 加 pygame。选它的理由很实际:pygame 的安装成本低,绘图 API 足够表达格子、矩形和圆形,代码量小到可以在一篇文章里贴完整。版本方面,Python 建议 3.8 及以上,pygame 使用当前稳定版即可,具体小版本号以你本地pip拉到的为准,本文不锁定具体版本号,因为示例只用到最基础的绘图、事件和字体接口,跨版本差异很小。
创建虚拟环境并安装依赖:
# 创建并激活虚拟环境(Windows) python -m venv venv venv\Scripts\activate # macOS / Linux python3 -m venv venv source venv/bin/activate # 安装依赖 pip install pygame项目结构按职责拆成五个文件,全部放在同一目录下即可,不需要打包成包:
tower-defense-2.1/ ├── config.py # 全局常量:网格尺寸、数值、成本 ├── grid.py # 坐标转换:格子 <-> 像素 ├── entities.py # 植物、僵尸、子弹 ├── world.py # 世界对象:更新、结算、绘制 └── main.py # 入口:窗口、事件、主循环这个划分对应上一节讲的四个职责。config.py是纯数据,grid.py是纯函数,entities.py里的类不感知窗口,world.py承担所有跨实体逻辑,main.py只处理输入和循环。照着这个边界写,任何一个文件单独拿出来都能读懂,这对后期维护的帮助比任何设计模式都大。
4. 核心流程拆解:一株豌豆射手从种下到开火
把整局游戏拆开看,一次完整的交互链路大概是这样七步。理解这条链路,比记住代码更重要,因为换语言、换引擎时,链路是不变的。
第一步,玩家按键选定植物类型,鼠标点击网格。这一步只做两件事:把选择存进世界对象,把点击位置交给处理函数。不要在事件回调里直接创建对象,事件回调只负责“记录意图”。
第二步,像素转格子。转换函数先判断点击是否落在网格矩形范围内,超出就返回空,再算列号和行号。这里必须有边界判断,否则点到界面顶部也会算出一个负的行号。
第三步,合法性校验与扣费。检查该格是否已被占用、阳光是否足够。两者都通过才扣费并创建植物。顺序很重要:先校验后扣费,避免“阳光扣了但植物没种上”。
第四步,主循环推进。累加器攒够固定步长就调用一次世界更新。世界更新内部按固定顺序执行:生成计时器、植物更新、子弹更新、阳光生命周期、僵尸更新、胜负判定。顺序固定,行为才可复现。
第五步,植物攻击判定。豌豆射手每 1.5 秒尝试一次开火,但前提是本行前方存在存活的僵尸。这个“前方是否存在”的判断要由世界对象提供,因为只有世界同时持有植物和僵尸列表。
第六步,子弹飞行与命中。子弹沿 X 轴正向移动,每帧检查同行僵尸的水平距离是否进入命中范围。命中后扣血并标记自身死亡,世界在帧末统一清理。
第七步,僵尸推进与失败判定。僵尸先找同行最近的阻挡植物,找到就停下啃食,啃死植物后继续前进;找不到就向左移动。当僵尸越过网格左边界,状态切换为失败。
这条链路里最容易出错的是第五步和第七步的距离判定。用“距离小于某阈值”做判定时,必须考虑子弹每帧位移量与阈值的关系。如果子弹速度是每秒 320 像素,60 帧下每帧移动约 5.3 像素,命中窗口只要大于 6 像素就不会穿透;但如果有人把速度调到 2000,就会出现“子弹从僵尸身上穿过去”的现象。此时要么缩小时间步,要么改用线段与矩形的相交检测。
5. 完整示例代码实现
下面五个文件按依赖顺序给出,全部整合后可直接运行。代码里的植物和僵尸都是矩形,文字用英文缩写,原因在注释里说明:不同操作系统可用的中文字体差异较大,先用英文保证跑通,再换字体是更稳妥的顺序。
5.1 全局配置:config.py
# 文件路径:config.py # 全局常量集中管理,调整数值平衡时只改这个文件 GRID_COLS = 9 # 网格列数 GRID_ROWS = 5 # 网格行数 CELL_W = 80 # 单元格宽度(像素) CELL_H = 100 # 单元格高度(像素) GRID_LEFT = 240 # 网格左上角 X 偏移 GRID_TOP = 70 # 网格左上角 Y 偏移 SCREEN_W = 1280 SCREEN_H = 720 FPS = 60 FIXED_DT = 1.0 / 60.0 # 逻辑帧固定步长 START_SUN = 150 # 初始阳光 SUN_VALUE = 25 # 单个阳光价值 SUN_LIFETIME = 8.0 # 阳光在场上停留秒数 PLANT_COST = { "sunflower": 50, "peashooter": 100, "wallnut": 50, } PLANT_HP = { "sunflower": 100, "peashooter": 100, "wallnut": 400, } ZOMBIE_HP = 100 ZOMBIE_SPEED = 18.0 # 像素/秒 ZOMBIE_EAT_SPEED = 20.0 # 每秒啃食伤害 ZOMBIE_SPAWN_INTERVAL = 6.0 # 初始刷怪间隔 WIN_TIME = 120.0 # 存活该时长判定通关所有数值放在一处,是这个文件存在的全部意义。调整刷怪速度、植物血量、子弹伤害时,不需要在逻辑代码里搜索数字。这在多人协作时尤其重要,因为“这个 1.5 到底是什么含义”这类问题会消耗大量沟通成本。
5.2 坐标转换:grid.py
# 文件路径:grid.py from config import ( GRID_COLS, GRID_ROWS, CELL_W, CELL_H, GRID_LEFT, GRID_TOP ) def cell_to_pixel(col, row): """格子坐标 -> 该格左上角的像素坐标""" x = GRID_LEFT + col * CELL_W y = GRID_TOP + row * CELL_H return x, y def cell_center(col, row): """格子坐标 -> 该格中心的像素坐标""" x, y = cell_to_pixel(col, row) return x + CELL_W / 2, y + CELL_H / 2 def pixel_to_cell(x, y): """像素坐标 -> 格子坐标,落在网格外返回 None""" inside_x = GRID_LEFT <= x < GRID_LEFT + GRID_COLS * CELL_W inside_y = GRID_TOP <= y < GRID_TOP + GRID_ROWS * CELL_H if not (inside_x and inside_y): return None col = int((x - GRID_LEFT) // CELL_W) row = int((y - GRID_TOP) // CELL_H) return col, row这三个函数是整个工程的地基。pixel_to_cell里的边界判断不能省,它同时承担了“点击是否在网格内”的校验职责,返回值用None而不是抛异常,是为了让调用方写起来更顺。cell_center返回浮点数,是为了后续做插值和小幅位移时不必再转类型。
5.3 实体定义:entities.py
# 文件路径:entities.py from config import ( GRID_COLS, CELL_W, GRID_LEFT, PLANT_HP, PLANT_COST, ZOMBIE_HP, SUN_VALUE ) from grid import cell_center class Plant: kind = "plant" def __init__(self, col, row): self.col = col self.row = row self.x, self.y = cell_center(col, row) self.max_hp = PLANT_HP[self.kind] self.hp = self.max_hp def update(self, dt, world): """默认什么也不做,子类按需覆盖""" pass class Sunflower(Plant): kind = "sunflower" def __init__(self, col, row): super().__init__(col, row) self.interval = 8.0 self.timer = self.interval def update(self, dt, world): self.timer -= dt if self.timer <= 0: self.timer = self.interval world.add_sun(self.x + 10, self.y, SUN_VALUE) class Peashooter(Plant): kind = "peashooter" def __init__(self, col, row): super().__init__(col, row) self.interval = 1.5 self.timer = 0.0 def update(self, dt, world): self.timer -= dt if self.timer > 0: return if world.row_has_zombie(self.row, self.x): self.timer = self.interval world.spawn_bullet(self.col, self.row) class WallNut(Plant): kind = "wallnut" # 血量由 config.PLANT_HP 提供,这里不需要额外逻辑 class Bullet: def __init__(self, col, row): cx, cy = cell_center(col, row) self.x = cx + 18 self.y = cy - 8 self.row = row self.speed = 320.0 self.damage = 20 self.alive = True def update(self, dt, world): self.x += self.speed * dt for z in world.zombies: if z.row != self.row or z.hp <= 0: continue if 0 < z.x - self.x < 30: z.hp -= self.damage self.alive = False return # 飞出网格右侧后回收 if self.x > GRID_LEFT + GRID_COLS * CELL_W + 100: self.alive = False class Zombie: def __init__(self, row): self.row = row self.hp = ZOMBIE_HP self.max_hp = ZOMBIE_HP self.x = GRID_LEFT + GRID_COLS * CELL_W + 60 self.y = cell_center(GRID_COLS - 1, row)[1] self.alive = True这段代码里有三个设计取舍值得说明。第一,植物基类的update是空实现,而不是抽象方法,因为塔防里大量植物(比如坚果墙)确实什么都不用做,强制实现反而啰嗦。第二,豌豆射手开火前先问世界“本行前方有没有僵尸”,而不是自己去遍历僵尸列表,这是职责边界的体现。第三,子弹超出屏幕后主动标记死亡,避免列表无限增长。
血量上限从config读取而不是写在类里,是为了让数值调整和逻辑彻底分离。同理,阳光产出量也没有硬编码在向日葵里,而是作为参数传给world.add_sun。
5.4 世界对象:world.py
# 文件路径:world.py import random import pygame from config import ( GRID_COLS, GRID_ROWS, CELL_W, CELL_H, GRID_LEFT, GRID_TOP, SCREEN_W, SCREEN_H, START_SUN, SUN_VALUE, SUN_LIFETIME, PLANT_COST, ZOMBIE_SPEED, ZOMBIE_EAT_SPEED, ZOMBIE_SPAWN_INTERVAL, WIN_TIME ) from grid import cell_to_pixel, cell_center, pixel_to_cell from entities import Sunflower, Peashooter, WallNut, Bullet, Zombie PLANT_CLASSES = { "sunflower": Sunflower, "peashooter": Peashooter, "wallnut": WallNut, } class World: def __init__(self): self.state = "playing" # playing / win / lose self.sun = START_SUN self.plants = {} # {(col, row): Plant} self.bullets = [] self.zombies = [] self.suns = [] # [{"x","y","value","life"}] self.time = 0.0 self.spawn_timer = 3.0 self.sun_timer = 5.0 self.selected = "peashooter" self.killed = 0 # ---------- 经济 ---------- def add_sun(self, x, y, value): self.suns.append({"x": x, "y": y, "value": value, "life": SUN_LIFETIME}) def collect_at(self, x, y): for s in list(self.suns): if (s["x"] - x) ** 2 + (s["y"] - y) ** 2 <= 28 ** 2: self.sun += s["value"] self.suns.remove(s) return True return False # ---------- 输入 ---------- def handle_click(self, x, y): if self.state != "playing": return if self.collect_at(x, y): return cell = pixel_to_cell(x, y) if cell is None: return col, row = cell if (col, row) in self.plants: return cost = PLANT_COST[self.selected] if self.sun < cost: return self.sun -= cost self.plants[(col, row)] = PLANT_CLASSES[self.selected](col, row) def spawn_bullet(self, col, row): self.bullets.append(Bullet(col, row)) def row_has_zombie(self, row, x): for z in self.zombies: if z.row == row and z.hp > 0 and z.x > x: return True return False # ---------- 更新 ---------- def update(self, dt): if self.state != "playing": return self.time += dt self.spawn_timer -= dt if self.spawn_timer <= 0: # 随时间推移刷怪变快,但有下限 self.spawn_timer = max( 1.5, ZOMBIE_SPAWN_INTERVAL - self.time * 0.05 ) self.zombies.append(Zombie(random.randrange(GRID_ROWS))) self.sun_timer -= dt if self.sun_timer <= 0: self.sun_timer = 7.0 col = random.randrange(GRID_COLS) row = random.randrange(GRID_ROWS) cx, cy = cell_center(col, row) self.add_sun(cx, cy, SUN_VALUE) for p in list(self.plants.values()): p.update(dt, self) for b in list(self.bullets): b.update(dt, self) self.bullets = [b for b in self.bullets if b.alive] for s in list(self.suns): s["life"] -= dt if s["life"] <= 0: self.suns.remove(s) self.update_zombies(dt) if self.time >= WIN_TIME: self.state = "win" def update_zombies(self, dt): for z in list(self.zombies): if z.hp <= 0: z.alive = False self.killed += 1 continue target = self.find_blocker(z) if target is not None: target.hp -= ZOMBIE_EAT_SPEED * dt if target.hp <= 0: self.plants.pop((target.col, target.row), None) else: z.x -= ZOMBIE_SPEED * dt if z.x < GRID_LEFT - 20: self.state = "lose" self.zombies = [z for z in self.zombies if z.alive] def find_blocker(self, z): """找出僵尸正前方最近的植物,作为啃食目标""" best = None for p in self.plants.values(): if p.row != z.row: continue if p.x <= z.x and z.x - p.x <= 34: if best is None or p.x > best.x: best = p return bestupdate的执行顺序是有意安排的:先生成、再植物、再子弹、再清理阳光、最后僵尸。这样僵尸在被子弹击中的同一帧里仍会先结算一次伤害,手感上更接近“子弹打到了但还是咬了一口”的真实感。如果换成先更新僵尸再更新子弹,僵尸在血量归零的那一帧仍然会完成一次完整的移动或啃食,视觉上会显得“死了还在动”。
find_blocker用“距离最近且不超过阈值”的方式找目标,而不是取整行的第一棵植物,原因是同一行可能存在多棵植物,僵尸必须先啃掉遇到的第一棵。这里返回最靠右(X 最大)的那一棵,符合从左往右推进的直觉。
5.5 绘制与主循环:main.py 及绘制逻辑
绘制部分同样放在world.py里,作为World的方法,因为它需要访问全部实体列表。为了避免和上面的更新逻辑混在一起,这里单独给出。
# 文件路径:world.py(接上面的 World 类,补上绘制方法) def draw(self, screen, font): screen.fill((28, 40, 32)) # 网格底板 for row in range(GRID_ROWS): for col in range(GRID_COLS): x, y = cell_to_pixel(col, row) color = (52, 78, 56) if (col + row) % 2 == 0 else (44, 68, 48) pygame.draw.rect(screen, color, (x, y, CELL_W, CELL_H), 0) # 阳光 for s in self.suns: pygame.draw.circle(screen, (255, 216, 64), (int(s["x"]), int(s["y"])), 14) # 植物 for p in self.plants.values(): self.draw_plant(screen, font, p) # 子弹 for b in self.bullets: pygame.draw.circle(screen, (140, 230, 120), (int(b.x), int(b.y)), 6) # 僵尸 for z in self.zombies: self.draw_zombie(screen, z) # HUD screen.blit(font.render(f"SUN {self.sun}", True, (255, 255, 255)), (20, 20)) tip = f"SELECT 1-SUNFLOWER 2-PEASHOOTER 3-WALLNUT -> {self.selected}" screen.blit(font.render(tip, True, (220, 230, 220)), (20, 46)) screen.blit(font.render( f"TIME {self.time:.1f}s KILLED {self.killed}", True, (200, 220, 200)), (20, 72)) if self.state == "win": text = font.render("YOU SURVIVED", True, (120, 255, 140)) screen.blit(text, text.get_rect( center=(SCREEN_W // 2, SCREEN_H // 2))) elif self.state == "lose": text = font.render("GAME OVER - PRESS R TO RESTART", True, (255, 90, 90)) screen.blit(text, text.get_rect( center=(SCREEN_W // 2, SCREEN_H // 2))) def draw_plant(self, screen, font, p): colors = { "sunflower": (250, 210, 60), "peashooter": (90, 200, 90), "wallnut": (190, 150, 100), } rect = pygame.Rect(0, 0, 56, 72) rect.center = (int(p.x), int(p.y)) pygame.draw.rect(screen, colors[p.kind], rect, border_radius=10) label = font.render(p.kind[0].upper(), True, (20, 20, 20)) screen.blit(label, label.get_rect(center=rect.center)) if p.hp < p.max_hp: ratio = max(0.0, p.hp / p.max_hp) pygame.draw.rect(screen, (60, 60, 60), (rect.left, rect.top - 10, rect.width, 6)) pygame.draw.rect(screen, (220, 80, 80), (rect.left, rect.top - 10, int(rect.width * ratio), 6)) def draw_zombie(self, screen, z): rect = pygame.Rect(0, 0, 46, 80) rect.center = (int(z.x), int(z.y)) pygame.draw.rect(screen, (140, 150, 170), rect, border_radius=6) ratio = max(0.0, z.hp / z.max_hp) pygame.draw.rect(screen, (60, 60, 60), (rect.left, rect.top - 10, rect.width, 6)) pygame.draw.rect(screen, (200, 60, 60), (rect.left, rect.top - 10, int(rect.width * ratio), 6))入口文件负责窗口、字体、事件和固定时间步循环:
# 文件路径:main.py import pygame from config import SCREEN_W, SCREEN_H, FPS, FIXED_DT from world import World def load_font(size): """按优先级尝试系统中文字体,找不到则退回默认字体""" for name in ("simhei", "microsoftyahei", "notosanscjk", "arial"): path = pygame.font.match_font(name) if path: return pygame.font.Font(path, size) return pygame.font.Font(None, size) def main(): pygame.init() screen = pygame.display.set_mode((SCREEN_W, SCREEN_H)) pygame.display.set_caption("Tower Defense 2.1 - Grid Prototype") clock = pygame.time.Clock() font = load_font(20) world = World() accumulator = 0.0 running = True while running: # 单帧时间上限,避免切后台回来时补步过多 frame_dt = min(clock.tick(FPS) / 1000.0, 0.25) for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: world.handle_click(*event.pos) elif event.type == pygame.KEYDOWN: if event.key == pygame.K_1: world.selected = "sunflower" elif event.key == pygame.K_2: world.selected = "peashooter" elif event.key == pygame.K_3: world.selected = "wallnut" elif event.key == pygame.K_r: world = World() accumulator += frame_dt steps = 0 while accumulator >= FIXED_DT and steps < 5: world.update(FIXED_DT) accumulator -= FIXED_DT steps += 1 world.draw(screen, font) pygame.display.flip() pygame.quit() if __name__ == "__main__": main()这里有两个容易被忽略的细节。min(..., 0.25)是防御性截断,防止极端卡顿后一次性补上几百步逻辑;steps < 5是第二层保险,即使累加器里堆了很多时间,单帧也只补五步,剩下的丢弃。丢掉一点逻辑时间带来的手感损失,远小于整帧卡死。
6. 运行结果与效果验证
在项目目录下执行:
python main.py按代码逻辑,窗口打开后应当看到:左边距 240 像素处开始的一块 9×5 网格,深浅交替的格子底板;左上角显示当前阳光数(初始 150)、当前选择的植物和已存活时间;每隔几秒网格内随机位置出现一个黄色圆点,点击即可收集并增加 25 阳光。
操作验证可以按这个顺序走一遍,每一步都能对应到具体代码:
- 按
2选择豌豆射手,点击一个空格子。阳光应从 150 变成 50,格子里出现绿色方块。 - 等待约 3 秒后第一只僵尸从右边缘进入随机一行。当它走进豌豆射手所在行的射击范围时,绿色小圆点应从植物位置向右飞出。
- 子弹命中后,僵尸头顶的红色血条应减少。四发子弹(每发 20 伤害)后僵尸消失,
KILLED计数加一。 - 按
1