当前位置:首页>python>用 Python 做一个「植物大战僵尸」风格小游戏:从思路到核心代码

用 Python 做一个「植物大战僵尸」风格小游戏:从思路到核心代码

  • 2026-09-11 00:09:43
用 Python 做一个「植物大战僵尸」风格小游戏:从思路到核心代码

用 Python 做一个「植物大战僵尸」风格小游戏:从思路到核心代码

公众号:简说 Python
适合读者:学过 Python 基础,想用 Pygame 做一个完整小游戏的朋友

大家好,我是老表,好久不见,继续给大家分享 Python 知识和AI编程。本系列文章将由我和 Codex 合作完成,中途也会和大家分享一些 Codex 使用经验。如果你感兴趣的话,就关注起来吧!

如果想使用 Codex 或者 Workbuddy,但还不会安装使用的可以直接加我微信私聊,我现在每天会帮助 2-3 个读者朋友部署 AI Agent 编程环境。

今天这篇文章,我们不讲语法题,也不刷算法题,而是用 Python 做一个更有成就感的小项目:一个「植物大战僵尸」风格的五路塔防小游戏。

注意:这里做的是原创练习项目,只借鉴横向五路、放置植物、资源掉落、敌人进攻这类经典玩法机制,不使用原作素材。

最终效果包含:

  • 5 行 9 列的草坪战场
  • 点击卡片选择植物,再点击格子种植
  • 金币自然掉落,点击收集
  • 向日葵生产金币
  • 豌豆射手发射子弹
  • 双线射手可以跨行支援
  • 坚果墙阻挡敌人
  • 植物可以升级到 3 级
  • 敌人死亡后有概率掉落金币
  • 支持金币掉落模式:正常 / 中速 / 快速

这篇文章重点分享制作思路和核心代码,完整项目可以继续扩展成更完整的塔防游戏。


一、先把游戏拆成几个系统

很多新手写小游戏,最容易犯的错误是:所有代码都写进一个main.py。

一开始没问题,写到 300 行以后就开始痛苦:

  • 植物逻辑在哪里?
  • 敌人移动在哪里?
  • UI 按钮在哪里?
  • 金币掉落在哪里?
  • 游戏状态在哪里?

所以这次我把项目拆成了几个模块:

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 游戏的核心结构通常很固定:

  1. 处理输入事件
  2. 更新游戏状态
  3. 绘制画面
  4. 刷新屏幕

对应到代码里,就是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]

这段代码实现了一个很有意思的效果:

  • 普通豌豆射手:只打本行
  • 满级豌豆射手:可以跨行支援
  • 双线射手:可以攻击上下相邻行
  • 满级双线射手:覆盖全部 5 行

发射子弹的核心代码:

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)

为什么要「立刻」结算死亡?

因为如果等到下一帧再判断,可能出现一个边界问题:敌人已经被打死了,但还没从列表里移除,又继续移动了一帧,甚至可能触发失败。

这种细节,就是小游戏从「能跑」到「比较稳」的区别。


八、金币掉落系统

这个项目里,金币来源有三种:

  1. 天空自然掉落
  2. 向日葵生产
  3. 击败敌人概率掉落

金币掉落模式放在配置里:

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 背后掉下来时,虽然玩家看不到它,但它仍然会拦截按钮点击。

所以事件处理顺序应该是:

  1. 先判断 HUD 按钮
  2. 再判断植物卡片
  3. 再判断金币
  4. 最后判断草坪格子

核心代码如下:

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

操作方式:

  • 鼠标点击植物卡片选择植物
  • 点击草坪格子种植
  • 点击已有植物可以选中
  • 点击掉落金币收集资源
  • 1 选择向日葵
  • 2 选择豌豆射手
  • 3 选择双线射手
  • 4 选择坚果墙
  • U 升级选中的植物
  • M 切换金币掉落模式
  • Space 暂停 / 继续
  • R 重新开始

十二、可以继续扩展什么?

这个版本已经有一个完整小游戏闭环,但它仍然只是一个原型。

如果你想继续练习,可以尝试加这些功能:

  • 新增一个爆炸植物
  • 新增一个冰冻植物,让敌人减速
  • 给敌人加护盾机制
  • 做一个开始菜单
  • 做多个关卡
  • 加音效
  • 加游戏结束统计
  • 把植物和敌人配置改成 JSON
  • 做一个简单关卡编辑器

如果只推荐一个扩展方向,我建议先做「爆炸植物」。

因为它能练到:

  • 延迟触发
  • 范围判定
  • 一次性实体销毁
  • 范围伤害特效

这些都是游戏开发里很常见的能力。


总结

这个项目最重要的不是「复刻一个游戏」,而是通过一个熟悉的玩法,练习小游戏开发的核心能力:

  • 游戏主循环
  • 事件处理
  • 状态管理
  • 实体更新
  • 碰撞检测
  • UI 绘制
  • 配置化设计
  • 模块化拆分

项目完整代码已上传 Github https://github.com/XksA-me/python-lane-defense

后面会持续更新,从游戏出发,出一个适合 AI 时代的 Python 编程学习系列教程。

很多 Python 初学者学完语法后,会卡在「不知道做什么项目」。

我的建议是:做小游戏。

因为小游戏天然包含输入、输出、状态、动画、数据结构和工程组织,而且反馈非常直观。你改一行代码,马上就能看到画面变化。

这也是我很喜欢用 Pygame 带大家练项目的原因。

如果你也想练,可以从这个五路塔防开始,把它一点点改成属于你自己的原创小游戏。

我们下篇文章可以继续拆:如何给这个项目加一个「爆炸植物」。

最新文章

随机文章