文章目录
-
- 引言
- 项目速览
- 架构设计:状态机驱动的游戏循环
- 模块职责划分
- 技术亮点
-
- [1. 程序化图形:用几何图元画出一个世界](#1. 程序化图形:用几何图元画出一个世界)
- [2. 程序化音效:用数学公式合成声音](#2. 程序化音效:用数学公式合成声音)
- [3. 严格的时间管理:统一使用 `pygame.time.get_ticks()`](#3. 严格的时间管理:统一使用
pygame.time.get_ticks()) - [4. 坐标系统:网格与像素的清晰转换](#4. 坐标系统:网格与像素的清晰转换)
- [5. 实体交互模式:通过 `game` 引用解耦](#5. 实体交互模式:通过
game引用解耦) - [6. 特殊僵尸的差异化行为](#6. 特殊僵尸的差异化行为)
- [7. 中文显示与字体回退](#7. 中文显示与字体回退)
- 波次与关卡系统
- 扩展指南
- 总结
- 运行效果
人工备注说明
- 开发工具Claude Code + (火山方舟)glm-5.1;
- 这个项目使用Claude Code开发,2小时直接生成80%代码,2小时让AI微调代码;
- 这篇文章也是使用Claude Code编写;
引言
植物大战僵尸(Plants vs. Zombies)是无数玩家的童年回忆。你是否想过,不依赖任何图片、音频等外部资源,仅用纯代码能否复刻这款经典塔防游戏?本文将带你走进一个完全基于 Pygame 的植物大战僵尸克隆项目------所有图形通过几何图元程序化绘制,所有音效通过数学公式程序化生成,界面全中文支持,整个项目仅约 2000 行 Python 代码。
项目速览
- 代码量:约 2055 行,11 个 Python 模块
- 外部依赖 :仅需
pygame,无图片、无音频文件、无其他第三方库 - 运行方式 :
pip install pygame && python main.py - 特性:5 种植物、5 种僵尸、10 关关卡、波次系统、割草机、阳光收集、暂停/设置持久化
架构设计:状态机驱动的游戏循环
游戏的核心是经典的游戏循环模式:处理事件 → 更新逻辑 → 渲染画面 → 刷新屏幕。在此基础上,通过状态机管理不同的游戏阶段:
menu → level_select → playing ⇄ paused ⇄ settings
↓
level_complete → playing (下一关)
↓
win / lose → menu
game.py 中的 Game 类是整个项目的中枢,拥有主循环、状态机、所有实体列表和波次系统。每个状态都有独立的 draw_* 方法和事件处理分支:
python
def run(self):
while running:
for event in pygame.event.get():
self.handle_event(event)
self.update()
# 根据状态选择渲染方法
if self.state == "menu":
self.draw_menu()
elif self.state == "playing":
self.draw()
# ...
pygame.display.flip()
self.clock.tick(FPS)
这种设计将不同阶段的逻辑完全隔离,避免了状态间的耦合。例如暂停状态下不会触发游戏更新,设置界面的事件处理也不会影响战斗逻辑。
模块职责划分
项目遵循单一职责原则,每个模块都有清晰的边界:
| 模块 | 行数 | 职责 |
|---|---|---|
game.py |
816 | 游戏主循环、状态机、实体管理、波次系统 |
plants.py |
299 | 植物基类及 5 个子类(向日葵、豌豆射手、双发射手、食人花、坚果墙) |
zombies.py |
202 | 僵尸基类及 5 个子类(普通、路障、铁桶、跨栏、报纸僵尸) |
ui.py |
207 | 植物卡片栏、铲子、暂停按钮、波次进度条 |
sound.py |
152 | 程序化音效生成,SoundManager 管理 13 种音效 |
map_grid.py |
96 | 网格数据、坐标转换、草坪/街道渲染、割草机 |
sun.py |
81 | 阳光下落、收集动画、生命周期 |
settings.py |
111 | 所有常量定义、中文字体辅助函数 |
projectile.py |
40 | 豌豆弹丸移动与碰撞检测 |
button.py |
34 | 通用按钮组件 |
main.py |
17 | 入口文件 |
settings.py 是游戏平衡的唯一数据源------血量、费用、速度、时间间隔等所有常量集中管理,调整难度只需修改一个文件。
技术亮点
1. 程序化图形:用几何图元画出一个世界
这是项目最有趣的特性。没有加载任何 PNG/SVG,所有角色都通过 pygame.draw 的 circle、rect、ellipse、polygon、line、arc 组合绘制。
以豌豆射手为例,它由茎、叶子、头部、嘴巴和眼睛组成:
python
def draw(self, surface, x, y):
# 茎
pygame.draw.rect(surface, (0, 80, 0), (x - 4, y + 5, 8, 30))
# 叶子
pygame.draw.ellipse(surface, DARK_GREEN, (x - 18, y + 10, 16, 8))
# 头部(渐变效果用两个同心圆模拟)
pygame.draw.circle(surface, (0, 120, 0), (x, y - 5), 20)
pygame.draw.circle(surface, (0, 160, 0), (x, y - 5), 17)
# 嘴巴/炮管
pygame.draw.rect(surface, (0, 80, 0), (x + 5, y - 10, 22, 14))
# 眼睛
pygame.draw.circle(surface, WHITE, (x - 5, y - 11), 6)
pygame.draw.circle(surface, BLACK, (x - 4, y - 11), 3)
这种绘制方式不仅避免了版权问题,还带来了独特的手绘风格。每个角色都有动画细节------向日葵的花瓣会微微摇摆,僵尸走路时身体会上下晃动,坚果墙受伤后会出现裂纹:
python
# 坚果墙的损伤可视化
damage_ratio = 1 - self.hp / self.max_hp
if damage_ratio > 0.33:
pygame.draw.line(surface, DARK_BROWN, ...) # 第一道裂纹
if damage_ratio > 0.66:
pygame.draw.line(surface, BLACK, ...) # 更深的裂纹
2. 程序化音效:用数学公式合成声音
sound.py 实现了一个完整的程序化音效系统。核心思路是用正弦波合成乐音,用随机噪声合成打击音效:
python
def _tone(freq, duration, volume=0.5, sample_rate=22050):
"""生成指定频率的正弦波音调"""
n = int(sample_rate * duration)
samples = []
for i in range(n):
t = i / sample_rate
env = min(1.0, min(i, n - i) / (sample_rate * 0.01)) # 包络线
samples.append(volume * env * math.sin(2 * math.pi * freq * t))
return samples
通过组合不同频率和时长的音调,可以模拟出丰富的游戏音效。例如阳光收集音效使用上行音阶(880Hz → 1100Hz → 1320Hz),给出愉悦的反馈感;僵尸死亡音效使用下行音阶(300Hz → 200Hz → 100Hz),营造低沉的氛围;游戏胜利音效则使用 C 大调上行琶音(C5 → E5 → G5 → C6),带来通关的成就感。
SoundManager 在初始化时一次性生成 13 种音效并缓存,运行时通过 play(name) 按名称播放:
python
self.sounds = {
'place': create_place_sound(),
'pea': create_pea_sound(),
'sun_collect': create_sun_collect_sound(),
'zombie_die': create_zombie_die_sound(),
# ...
}
3. 严格的时间管理:统一使用 pygame.time.get_ticks()
游戏开发中计时器的管理是容易出现 bug 的地方。本项目坚持一个原则:所有计时器统一使用 pygame.time.get_ticks() ,禁止使用 time.time() 或帧计数。
这个原则在暂停/恢复机制中尤为重要。当游戏暂停时,记录暂停时间戳;恢复时,计算暂停时长 delta_ms,然后将所有计时器偏移该时长:
python
def _resume(self):
delta = pygame.time.get_ticks() - self.paused_from
self._offset_timers(delta)
self.state = "playing"
def _offset_timers(self, delta_ms):
self.spawn_timer += delta_ms
self.sky_sun_timer += delta_ms
self.wave_timer += delta_ms
for plant in self.plants_list:
if hasattr(plant, 'sun_timer'):
plant.sun_timer += delta_ms
if hasattr(plant, 'fire_timer'):
plant.fire_timer += delta_ms
for card in self.ui.cards:
card.last_placed += delta_ms
# ...
这确保了暂停不会导致"时间跳跃"------恢复后植物不会突然发射大量豌豆,阳光不会突然过期。每一个有计时器的实体都被精心偏移,包括植物的射击/产阳光/咀嚼计时器、卡片的冷却计时器、阳光的生命周期计时器等。
4. 坐标系统:网格与像素的清晰转换
游戏场地是一个 5 行 9 列的网格,但实际渲染和交互都在像素空间。MapGrid 封装了两个关键的转换方法:
python
def pixel_to_grid(self, x, y):
col = (x - GRID_X_OFFSET) // CELL_WIDTH
row = (y - GRID_Y_OFFSET) // CELL_HEIGHT
if 0 <= row < GRID_ROWS and 0 <= col < GRID_COLS:
return row, col
return None, None
def grid_to_pixel(self, row, col):
x = GRID_X_OFFSET + col * CELL_WIDTH + CELL_WIDTH // 2
y = GRID_Y_OFFSET + row * CELL_HEIGHT + CELL_HEIGHT // 2
return x, y
所有涉及坐标的操作------放置植物、碰撞检测、幽灵预览------都通过这两个方法进行转换,绝不硬编码像素坐标。这使得调整网格偏移或单元格尺寸时,整个游戏自动适配。
5. 实体交互模式:通过 game 引用解耦
植物和僵尸的 update() 方法接收 game 对象引用,通过它与其他系统交互:
python
class Peashooter(Plant):
def update(self, game):
now = pygame.time.get_ticks()
if now >= self.fire_timer:
if game.has_zombie_in_row(self.row): # 查询同行僵尸
self.fire_timer = now + PEASHOOTER_FIRE_INTERVAL
game.spawn_pea(self.row, self.col) # 生成豌豆
这种设计让实体逻辑保持内聚------豌豆射手自己决定何时射击,但不需要知道豌豆是如何被创建和管理的。碰撞检测同样遵循这个模式,在 projectile.py 中豌豆自行检测与僵尸的碰撞并造成伤害。
6. 特殊僵尸的差异化行为
5 种僵尸共享基类的移动和啃食逻辑,但通过方法重写实现差异化的行为:
- 路障僵尸/铁桶僵尸 :单纯增加血量,通过
_draw_accessory绘制不同的头部装饰 - 跨栏僵尸 :重写
_check_plant_collision,首次遇到植物时跳过而非啃食 - 报纸僵尸 :重写
take_damage,报纸被打掉后进入暴怒状态,速度提升 3 倍
python
class NewspaperZombie(Zombie):
def take_damage(self, amount):
if self.newspaper_alive:
self.newspaper_hp -= amount
self.hp -= amount
if self.newspaper_hp <= 0:
self.newspaper_alive = False
self.angry = True
self.base_speed = ZOMBIE_SPEED * NEWSPAPER_ZOMBIE_ANGRY_SPEED_MULT
self.speed = self.base_speed
7. 中文显示与字体回退
所有界面文字均为中文,settings.py 中的 get_chinese_font() 实现了多级字体回退:
python
def get_chinese_font(size):
font_paths = [
"C:/Windows/Fonts/msyh.ttc", # 微软雅黑
"C:/Windows/Fonts/simhei.ttf", # 黑体
"C:/Windows/Fonts/simsun.ttc", # 宋体
]
for path in font_paths:
if os.path.exists(path):
try:
return pygame.font.Font(path, size)
except Exception:
continue
return pygame.font.Font(None, size) # 最终回退
所有 render() 调用都必须使用此函数,而非 Pygame 默认字体,确保中文字符正确显示。
波次与关卡系统
游戏包含 10 个关卡,每关 5 波僵尸。僵尸数量随关卡和波次递增:
python
def _zombie_count_for_wave(self):
base = BASE_ZOMBIE_COUNT * self.level
return base + self.wave * self.level
不同关卡的僵尸种类逐步解锁:第 2 关出现路障僵尸,第 3 关出现跨栏僵尸,第 4 关出现报纸僵尸,第 6 关出现铁桶僵尸。每波开始前有 20 秒准备时间,带有倒计时提示和音效。
关卡进度通过 JSON 文件持久化,解锁的最高关卡会被保存:
python
def _save_settings(self):
data = {
'volume': self.volume,
'speed': SPEED_OPTIONS[self.speed_index],
'level_unlocked': self.level_unlocked,
}
with open(SETTINGS_FILE, 'w') as f:
json.dump(data, f)
扩展指南
添加新植物
- 在
settings.py中添加常量(费用、血量、计时器间隔) - 在
plants.py中创建子类,实现update(game)和draw(surface, x, y) - 将新类追加到
PLANT_CLASSES列表 - 在
ui.py的PlantCard._draw_plant_icon()中添加卡片图标 - 如果植物有计时器,在
game.py的_offset_timers()中添加偏移逻辑
添加新僵尸
- 在
settings.py中添加常量(血量、速度倍率) - 在
zombies.py中创建子类,重写_draw_accessory()绘制装饰,必要时重写_check_plant_collision()或take_damage() - 在
game.py的_spawn_zombie()中添加生成逻辑
总结
这个项目展示了如何用最简洁的技术栈实现一个完整的塔防游戏。程序化图形和音效消除了对外部资源的依赖,状态机管理了复杂的游戏流程,统一的时间系统避免了暂停后的时间跳跃,清晰的模块划分让代码易于理解和扩展。
对于想入门游戏开发的 Python 程序员来说,这是一个很好的学习案例------你不需要美术素材,不需要音频工程知识,只需要理解游戏循环、状态管理和碰撞检测这几个核心概念,就能创造出有趣的游戏体验。


