用 Python 做一个「植物大战僵尸」风格小游戏:从思路到核心代码
公众号:简说 Python
适合读者:学过 Python 基础,想用 Pygame 做一个完整小游戏的朋友

大家好,我是老表,好久不见,继续给大家分享 Python 知识和AI编程。本系列文章将由我和 Codex 合作完成,中途也会和大家分享一些 Codex 使用经验。如果你感兴趣的话,就关注起来吧!
如果想使用 Codex 或者 Workbuddy,但还不会安装使用的可以直接加我微信私聊,我现在每天会帮助 2-3 个读者朋友部署 AI Agent 编程环境。
今天这篇文章,我们不讲语法题,也不刷算法题,而是用 Python 做一个更有成就感的小项目:一个「植物大战僵尸」风格的五路塔防小游戏。
注意:这里做的是原创练习项目,只借鉴横向五路、放置植物、资源掉落、敌人进攻这类经典玩法机制,不使用原作素材。
最终效果包含:
这篇文章重点分享制作思路和核心代码,完整项目可以继续扩展成更完整的塔防游戏。
一、先把游戏拆成几个系统
很多新手写小游戏,最容易犯的错误是:所有代码都写进一个main.py。
一开始没问题,写到 300 行以后就开始痛苦:
所以这次我把项目拆成了几个模块:
main.py game/ app.py # 游戏入口、事件处理、主循环 config.py # 全局配置、植物/敌人数值 entities.py # 植物、敌人、子弹、金币等实体 state.py # 游戏状态、波次、资源、升级 ui.py # HUD、按钮、草坪绘制 text.py # 字体加载和文字绘制
这样拆的好处是:每个文件只负责一件事。
比如以后想新增一个植物,大概率只需要改config.py 和entities.py;想调 UI,就去ui.py;想改刷怪节奏,就去state.py。
二、游戏主循环:Pygame 项目的心脏
Pygame 游戏的核心结构通常很固定:
对应到代码里,就是GameApp.run():
classGameApp:
defrun(self):
whileTrue:
dt = self.clock.tick(FPS) / 1000
self.handle_events()
if self.state.mode == STATE_PLAYING:
self.state.update(dt)
self.ui.draw(self.screen, self.state)
pygame.display.flip()
这里有一个很重要的小细节:dt。
dt 表示上一帧到这一帧经过了多少秒。移动、冷却、倒计时都应该基于dt,而不是直接写「每一帧移动 5 像素」。
这样游戏在不同电脑上运行时,速度会更稳定。
三、用配置表管理植物和敌人
不要把植物的价格、生命值、快捷键写死在逻辑里。
更好的做法是放进配置表:
PLANTS = {
"sunflower": {
"name": "向日葵",
"role": "产阳光",
"cost": 50,
"hp": 80,
"color": (248, 188, 67),
"key": "1",
"upgrade_cost": 75,
},
"peashooter": {
"name": "豌豆射手",
"role": "单行攻击",
"cost": 100,
"hp": 105,
"color": (74, 176, 84),
"key": "2",
"upgrade_cost": 125,
},
}
敌人也一样:
ZOMBIE_KINDS = {
"normal": {"hp": 120, "speed": 18, "reward_chance": 0.22, "reward": 25},
"cone": {"hp": 220, "speed": 16, "reward_chance": 0.32, "reward": 25},
"fast": {"hp": 92, "speed": 38, "reward_chance": 0.28, "reward": 25},
}
这样后面调数值会非常方便。
比如觉得快速敌人太快,就改speed;觉得金币太少,就提高reward_chance。
四、棋盘怎么设计?
这类横向塔防游戏,本质上是一个二维网格。
我这里设置为:
ROWS, COLS = 5, 9
CELL_W, CELL_H = 88, 88
GRID_X, GRID_Y = 190, 160
也就是 5 行、9 列。
当玩家点击屏幕时,需要把鼠标坐标转换成格子坐标:
defcell_at(self, pos):
x, y = pos
ifnot (GRID_X <= x < GRID_X + COLS * CELL_W and GRID_Y <= y < GRID_Y + ROWS * CELL_H):
returnNone
return int((y - GRID_Y) // CELL_H), int((x - GRID_X) // CELL_W)
这个函数非常关键。
它把「鼠标点在屏幕哪个位置」转换成「点中了第几行第几列」。
五、植物放置和升级
游戏状态由GameState 管理。
放置植物的逻辑大致是:
defplace_or_select(self, row, col):
existing = self.plants[row][col]
if existing:
self.selected_cell = (row, col)
self.message = f"已选择 {PLANTS[existing.kind]['name']} Lv.{existing.level}"
return
info = PLANTS[self.selected_plant]
if self.sun_points < info["cost"]:
self.message = f"金币不足,还需要 {info['cost'] - self.sun_points}。"
return
self.sun_points -= info["cost"]
self.plants[row][col] = Plant(self.selected_plant, row, col)
这里做了三件事:
升级逻辑也放在GameState 里:
defupgrade_selected(self):
plant = self.selected_live_plant()
ifnot plant:
self.message = "先点击一株植物。"
return
ifnot plant.can_upgrade():
self.message = "这株植物已满级。"
return
cost = plant.upgrade_cost
if self.sun_points < cost:
self.message = f"升级金币不足,需要 {cost}。"
return
self.sun_points -= cost
plant.upgrade()
植物本身只负责「我能不能升级」「升级后属性怎么变」:
defupgrade(self):
ifnot self.can_upgrade():
returnFalse
self.level += 1
self.max_hp = int(self.max_hp * 1.28)
self.hp = self.max_hp
returnTrue
这个设计有个好处:状态管理和实体行为分开,代码会清楚很多。
六、植物如何自动攻击?
每株植物都有一个timer,表示攻击冷却。
每一帧更新时,冷却减少:
self.timer -= dt
当冷却归零,就检查攻击范围内有没有敌人。
豌豆射手默认只打本行,升级后可以增强:
deftarget_rows(self):
if self.kind == "splitpea":
if self.level >= 3:
return list(range(ROWS))
return [r for r in (self.row - 1, self.row, self.row + 1) if0 <= r < ROWS]
if self.level >= 3and self.kind == "peashooter":
return list(range(ROWS))
return [self.row]
这段代码实现了一个很有意思的效果:
发射子弹的核心代码:
if active_rows:
self.timer = max(0.55, 1.42 - self.level * 0.28)
damage = 22 + self.level * 10
shots = self.make_shot_rows(active_rows)
for target_row in shots:
state.projectiles.append(
Projectile(self.x + 28, target_y, target_row, damage, self.level)
)
升级后,冷却更短,伤害更高,覆盖范围也更强。
这就是一个简单但有效的局内成长系统。
七、子弹碰撞和敌人死亡
子弹每一帧向右移动:
self.x += self.speed * dt
然后判断是否命中同一行的敌人:
for zombie in state.zombies:
if zombie.row == self.target_row and zombie.alive and abs(zombie.x - self.x) < 21:
zombie.take_damage(self.damage, state)
self.alive = False
break
敌人受伤后,如果生命值小于等于 0,立刻死亡结算:
deftake_damage(self, amount, state):
ifnot self.alive:
return
self.hp -= amount
if self.hp <= 0:
self.die(state)
为什么要「立刻」结算死亡?
因为如果等到下一帧再判断,可能出现一个边界问题:敌人已经被打死了,但还没从列表里移除,又继续移动了一帧,甚至可能触发失败。
这种细节,就是小游戏从「能跑」到「比较稳」的区别。
八、金币掉落系统
这个项目里,金币来源有三种:
金币掉落模式放在配置里:
RESOURCE_MODES = {
"normal": {"label": "正常", "interval": (7.0, 9.5), "fall_speed": 52},
"medium": {"label": "中速", "interval": (4.8, 6.4), "fall_speed": 72},
"fast": {"label": "快速", "interval": (2.8, 4.0), "fall_speed": 96},
}
玩家按M 就可以切换模式:
defcycle_resource_mode(self):
order = ["normal", "medium", "fast"]
self.resource_mode = order[(order.index(self.resource_mode) + 1) % len(order)]
自然掉落的生成逻辑:
if self.resource_timer <= 0:
mode = RESOURCE_MODES[self.resource_mode]
self.resource_timer = random.uniform(*mode["interval"])
self.resources.append(
ResourceDrop(random_x, speed=mode["fall_speed"])
)
还有一个小坑:金币从屏幕上方掉下来,如果一开始就倒计时,可能还没落地就消失了。
所以我给金币加了一个landed 状态:
if self.from_sky and self.y < self.target_y:
self.y += self.speed * dt
if self.y >= self.target_y:
self.y = self.target_y
self.landed = True
if self.landed:
self.ttl -= dt
也就是说:金币落地后才开始计算消失时间。
九、敌人波次怎么做?
这个版本没有做复杂关卡编辑器,而是用了一个简单的时间刷怪系统:
spawn_gap = max(0.72, 2.7 - self.wave_level * 0.24)
if self.spawned < self.target_zombies and self.spawn_timer <= 0:
self.spawn_timer = random.uniform(spawn_gap * 0.55, spawn_gap * 1.25)
kind = self.pick_zombie_kind()
self.zombies.append(Zombie(random.randrange(ROWS), self.wave_level, kind))
self.spawned += 1
随着时间推进,wave_level 变高,刷怪间隔逐渐变短。
敌人类型也会逐步变丰富:
defpick_zombie_kind(self):
if self.spawned > 16and random.random() < 0.2:
return"fast"
if self.spawned > 7and random.random() < 0.3:
return"cone"
return"normal"
这样可以做出一个简单的节奏:
十、UI 的两个实用经验
1. 按钮点击优先级
一开始我把金币点击判断放在最前面,结果可能出现一个问题:
金币从 HUD 背后掉下来时,虽然玩家看不到它,但它仍然会拦截按钮点击。
所以事件处理顺序应该是:
核心代码如下:
if self.ui.buttons.get("mode") and self.ui.buttons["mode"].contains(pos):
self.state.cycle_resource_mode()
return
for kind, rect in self.ui.card_rects.items():
if rect.collidepoint(pos):
self.state.selected_plant = kind
return
for drop in list(self.state.resources):
if drop.y > 132and drop.rect.collidepoint(pos):
self.state.sun_points += drop.value
self.state.resources.remove(drop)
return
2. 中文字体要认真处理
Pygame 默认字体不一定支持中文。
我写了一个字体加载函数,优先找系统中文字体:
defmake_font(size, bold=False):
candidates = [
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans CJK SC",
"Arial Unicode MS",
]
for name in candidates:
path = pygame.font.match_font(name, bold=bold)
if path:
return pygame.font.Font(path, size)
return pygame.font.SysFont(None, size, bold=bold)
做中文游戏或中文 UI,这一步很重要。
否则你可能会看到一堆方块,或者文字宽度计算不准确,导致 UI 溢出。
十一、运行项目
安装依赖:
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
运行游戏:
.venv/bin/python main.py
操作方式:
十二、可以继续扩展什么?
这个版本已经有一个完整小游戏闭环,但它仍然只是一个原型。
如果你想继续练习,可以尝试加这些功能:
如果只推荐一个扩展方向,我建议先做「爆炸植物」。
因为它能练到:
这些都是游戏开发里很常见的能力。
总结
这个项目最重要的不是「复刻一个游戏」,而是通过一个熟悉的玩法,练习小游戏开发的核心能力:
项目完整代码已上传 Github https://github.com/XksA-me/python-lane-defense
后面会持续更新,从游戏出发,出一个适合 AI 时代的 Python 编程学习系列教程。
很多 Python 初学者学完语法后,会卡在「不知道做什么项目」。
我的建议是:做小游戏。
因为小游戏天然包含输入、输出、状态、动画、数据结构和工程组织,而且反馈非常直观。你改一行代码,马上就能看到画面变化。
这也是我很喜欢用 Pygame 带大家练项目的原因。
如果你也想练,可以从这个五路塔防开始,把它一点点改成属于你自己的原创小游戏。
我们下篇文章可以继续拆:如何给这个项目加一个「爆炸植物」。