系列做到第 17 篇,农场的地、作物、角色移动、节气时间这些硬骨头都啃完了,这一篇专门聊下雨。很多朋友第一次在 pygame 里写雨,画出来能看,但一跑起来就露馅:雨丝忽明忽暗像接触不良,同屏雨滴一多帧率从 60 掉到 30,切个场景进屋子雨还在屋里下,第二天一看作物全旱死了。这篇就按我在做的这个星露谷物语风格农场游戏为蓝本,把下雨这件事从天气状态机、粒子池、三层视觉、世界交互一直到踩坑记录,完整拆一遍。内容适合已经能跑起一个 pygame 主循环、手里有张地图和几个角色的朋友,也适合完全没写过天气系统的朋友跟着抄——代码都能直接塞进你的项目里跑。
1. 天气系统先按状态机搭,别用 if-else 堆
1.1 为什么天气不能只用一个字符串变量
我见过不少入门写法是全局挂一个weather = "rain",然后主循环里到处if weather == "rain":判断。单机小 Demo 能活,但只要你想加“雨势渐变”“雨天转晴的那几秒”“雷阵雨和细雨视觉不同”这些需求,代码立刻炸成意大利面。
真正好维护的做法是:天气是一个带强度和过渡的状态机,而不是一个瞬时值。它至少要有四个字段:当前天气类型、下一段天气类型、过渡进度、当前强度。强度用 0.0 到 1.0 的浮点数表示,雨天强度 0.6 就是中雨,1.0 是暴雨。这么设计的好处很直接——所有跟天气相关的表现(雨滴生成速率、环境光变暗程度、雨声音量、作物浇水判定)都只需要读同一个intensity,不用各自再写一套判断。
import random class WeatherSystem: """挂在全局 GameState 上的天气状态机""" CLEAR = "clear" RAIN = "rain" STORM = "storm" # 季节 -> 天气 -> 概率权重,每天清晨掷一次 SEASON_TABLE = { "spring": {CLEAR: 0.50, RAIN: 0.40, STORM: 0.10}, "summer": {CLEAR: 0.72, RAIN: 0.22, STORM: 0.06}, "autumn": {CLEAR: 0.55, RAIN: 0.36, STORM: 0.09}, "winter": {CLEAR: 0.70, RAIN: 0.20, STORM: 0.10}, } def __init__(self, seed=None): self.rng = random.Random(seed) self.type = self.CLEAR self.target_type = self.CLEAR self.intensity = 0.0 # 当前实际强度 self.target_intensity = 0.0 # 目标强度 self.fade_speed = 0.45 # 每秒强度变化量,约 2.2 秒完成转场 self.transition = 0.0 # 0~1,转场进度,给音效淡入用 def roll_daily(self, season): table = self.SEASON_TABLE[season] keys = list(table.keys()) weights = list(table.values()) self.target_type = self.rng.choices(keys, weights=weights, k=1)[0] self.target_intensity = { self.CLEAR: 0.0, self.RAIN: 0.65, self.STORM: 1.0, }[self.target_type]注意target_type和type是两个东西。清晨掷出“今天下雨”,不代表画面上要立刻倾盆大雨,那太突兀了。我让它用两秒多过渡过去,玩家从屋里走出来时雨势正在慢慢变大,这个观感比瞬间切换舒服太多。
1.2 转场和强度更新的写法
update(dt)里只做一件事:把intensity朝target_intensity逼近,逼近到一定程度再改type。这样type永远滞后于视觉表现,逻辑判定用target_type,渲染用intensity,两者不打架。
def update(self, dt): diff = self.target_intensity - self.intensity if abs(diff) < 0.001: self.intensity = self.target_intensity self.type = self.target_type else: step = self.fade_speed * dt self.intensity += step if diff > 0 else -step self.intensity = max(0.0, min(1.0, self.intensity)) # 转场进度归一化,给 BGM / 雨声淡入淡出用 if self.target_intensity > 0: self.transition = self.intensity / max(self.target_intensity, 0.001) else: self.transition = 1.0 - self.intensity self.transition = max(0.0, min(1.0, self.transition))第一遍写的时候我把转场写在了渲染层,结果只要玩家切场景、渲染对象被重建,转场进度就归零,雨势从零开始重新涨。教训是状态和表现一定要分层,状态机只在 GameState 里更新,渲染层每帧去读它。
提示:
fade_speed别设太大。超过 1.5 的时候,转场只有半秒多,玩家几乎注意不到,视觉上就是“啪”一下全暗了,反而廉价。我实测 0.4 到 0.6 之间最自然。
2. 雨滴粒子:对象池和容量计算是性能命门
2.1 每帧 new 一个雨滴,是掉帧的第一元凶
最直觉的写法是在主循环里这样干:
# 反面教材,别这么写 drops = [] while len(drops) < 400: drops.append({"x": random.randint(0, W), "y": 0, "v": random.randint(800, 1200)})每帧创建 400 个字典,一秒 60 帧就是 24000 次对象分配,再加上垃圾回收,帧率曲线会像心电图。Python 的对象创建开销比你想的大得多,尤其在 pygame 这种每帧只有 16.6ms 预算的环境里,光分配就能吃掉几毫秒。
正确做法是对象池:启动时一次性创建固定数量的雨滴对象,之后只改字段、绝不再新建。雨滴掉到屏幕底部就"回收"——把y重置到屏幕上方,重新给个随机 x 和速度,视觉上完全看不出这是同一个对象。
2.2 容量到底该开多少
这个数不能拍脑袋,得算。目标是"同屏视觉上看起来是连续的雨帘",判断依据是屏幕横向的雨丝密度。
假设窗口 1280×720,中等雨势下我想让每 6 到 8 个像素的横向距离上有一条雨丝,那就是 1280÷7≈183 条同屏可见。但雨丝长度只有 18 到 26 像素,从顶部落到底部的时间很短,池子里必须有一部分雨滴正在屏幕上方待命。按下落速度 1000 像素/秒、屏幕高 720 算,单条雨丝在屏时间约 0.72 秒,屏上 183 条意味着每秒要补充 254 条,池子得留出余量。
我的实际配置:
| 项目 | 数值 | 说明 |
|---|---|---|
| 池容量(中雨) | 600 | 覆盖 1280×720 屏幕,视觉够密 |
| 池容量(暴雨) | 1100 | 暴雨额外叠加速率和斜度 |
| 雨丝长度 | 18~26 px | 每帧变化会产生抖动,所以要固定 |
| 下落速度 y | 900~1400 px/s | 速度差制造纵深 |
| 水平速度 x | -60~-140 px/s | 统一风向,斜雨更好看 |
| 重生位置 y | -40 ~ -10 | 保证从屏幕外飘进来 |
| 渲染层 Surface | 全屏一张 | 所有雨滴画在它上面再整体 blit |
池容量不要按最大值给每一档天气都开满,我用一个active_count来控制当前多少滴参与更新和绘制,其余保持休眠。这样小雨天只需要遍历 300 个对象,而不是 1100 个。
2.3 雨滴对象的字段设计
用__slots__的目的是省内存、省属性查找开销,600 个对象不算多,但这是零成本的好习惯。
class RainDrop: __slots__ = ("x", "y", "vy", "vx", "length", "alpha", "alive") def __init__(self): self.x = 0.0 self.y = 0.0 self.vy = 1000.0 self.vx = -80.0 self.length = 20 self.alpha = 160 self.alive = Falselength我是初始化时随机一次就固定下来,不在每帧重新随机的。这一点很多人会踩坑:如果每帧给雨丝随机长度,人眼看到的就是一条条不停伸缩的线,像抽搐一样。深度感靠的是不同雨丝之间的长度差异,不是单条雨丝自身的抖动。
3. 三层视觉:雨丝、地面溅水、环境压暗
3.1 雨丝层:预渲染 Surface + 一次整体 blit
直接对每条雨丝调pygame.draw.line打到主屏上,600 条就是 600 次绘制调用。我实测在 1280×720 下,这个量级还在 60 帧边缘,加个地图重绘就掉了。更稳的做法是:准备一张和窗口等大的SRCALPHA图层,每帧清空、把雨丝画在这张图层上,最后整张 blit 到主屏。绘制调用还是 600 次,但主屏 blit 只有 1 次,而且雨丝可以统一带透明度。
class RainLayer: def __init__(self, width, height): self.w = width self.h = height self.surface = pygame.Surface((width, height), pygame.SRCALPHA) self.drops = [RainDrop() for _ in range(1100)] self.active = 0 # 预渲染几种长度的雨丝贴图,避免每帧 draw.line self.streak_cache = {} for ln in range(16, 29, 2): s = pygame.Surface((2, ln), pygame.SRCALPHA) pygame.draw.line(s, (200, 215, 235, 170), (1, 0), (1, ln), 1) self.streak_cache[ln] = s def resize(self, width, height): self.w, self.h = width, height self.surface = pygame.Surface((width, height), pygame.SRCALPHA) for d in self.drops: d.alive = False self.active = 0注意resize必须清空池子。窗口拉伸之后旧雨滴的坐标全部越界,不清的话要么画到屏幕外,要么在左上角堆成一团。我第一次做全屏切换就踩了这个,窗口拉大之后雨滴全挤在原来那块区域里,特别诡异。
更新逻辑:
def update(self, dt, intensity, wind=-80.0): target = int(300 + intensity * 800) self.active = min(target, len(self.drops)) for i in range(self.active): d = self.drops[i] if not d.alive: self._spawn(d, initial=True) continue d.x += (d.vx + wind) * dt d.y += d.vy * dt if d.y > self.h + 10 or d.x < -30: self._spawn(d, initial=False) def _spawn(self, d, initial=False): d.alive = True # 初始铺满全屏,之后统一从顶上方进入,避免"齐刷刷"的雨幕边 d.y = random.uniform(-40, self.h * 0.9) if initial else random.uniform(-50, -10) d.x = random.uniform(-60, self.w + 60) d.vy = random.uniform(900, 1400) d.vx = random.uniform(-30, -10) d.length = random.choice((16, 18, 20, 22, 24, 26, 28)) d.alpha = random.randint(120, 200)那个initial参数很关键。如果所有雨滴都从y = -40开始,天气刚转雨的那一瞬间,屏幕上会出现一条整齐的水线从顶上压下来,像瀑布闸门打开。所以第一次激活时让它们随机分布在整个屏幕高度上,之后重生才从顶部进入。
绘制:
def draw(self, screen): self.surface.fill((0, 0, 0, 0)) cache = self.streak_cache for i in range(self.active): d = self.drops[i] if not d.alive: continue img = cache.get(d.length) or cache[20] # 斜雨用旋转代价高,这里用水平偏移近似 self.surface.blit(img, (int(d.x), int(d.y))) screen.blit(self.surface, (0, 0))斜雨这里我用了个偷懒方案:雨丝本身是竖直贴图,靠vx让它整体横向漂移,人眼在动态画面里几乎分辨不出雨丝不是真的斜的。真要精细,可以把贴图预渲染几个角度版本(15 度、20 度、25 度),按风速选贴图,成本是一次性的。我试过每帧pygame.transform.rotate,600 次旋转直接掉到 20 帧以下,千万别那么干。
3.2 溅水层:只在下半屏生成,数量要掐死
只有雨丝容易显得"飘在空中没有落地"。加一层地面水花,观感立刻扎实。但溅水粒子千万不能和雨丝同数量级,否则性能翻倍还乱。
我的做法:每帧从激活的雨滴里抽一小部分(大概 3%),在它们"落地"的位置生成水花,水花存活 0.25 秒,用一个小圆环从大到小、从亮到暗的 3 帧动画。
class Splash: __slots__ = ("x", "y", "life", "max_life") def __init__(self): self.x = 0.0 self.y = 0.0 self.life = 0.0 self.max_life = 0.25池子开 120 个就够,因为水花只活 0.25 秒,每秒补充 480 个也远低于这个量——实际我控制在每帧最多生成 4 个,视觉上已经是"地面到处在炸水花"了。这个数量必须掐死,不然玩家走到水边、地图上水面反射一叠加,满屏小白点会很脏。
生成位置也要限制:只在y落在画面下方 40% 区域、且该点在地图上是"室外地面"(不是水面、不是建筑)时才生成。判断地块类型直接查地图的 tile 属性,一次数组访问,很便宜。
3.3 环境压暗:一张半透明黑,胜过一切后期
雨天最容易被忽略的是光。雨丝画得再密,画面明度不变,玩家就是感觉不到"阴天"。
我的做法极简:在所有世界内容画完之后、UI 之前,叠一张覆盖全屏的黑色SRCALPHASurface,alpha 由强度控制,范围 0 到 70。
overlay = pygame.Surface((W, H), pygame.SRCALPHA) overlay.fill((10, 18, 30, int(70 * intensity))) screen.blit(overlay, (0, 0))用(10, 18, 30)这种偏冷的深蓝黑,而不是纯黑。纯黑叠上去画面发灰发闷,冷色调才有"雨天"的湿润感。这个值我调了很多次,最后定在最高 70;超过 90 之后 UI 上的文字和物品图标开始看不清,玩家会骂人。
还有个小细节:UI 层不能压在暗色层下面。背包、生命条、对话框这些必须画在 overlay 之后,否则雨天打开背包,字全是灰的。这个层级顺序我在项目里固定成:世界内容 → 天气视觉 → 环境压暗 → 场景 UI → 全局弹窗,五层,不要乱。
4. 雨落到世界里,得真的改变点什么
4.1 自动浇水:判定放在日切,不要每帧扫
视觉做完了,如果雨只是"看着在下",那就是个屏保。星露谷这类游戏里雨的核心玩法价值是免浇水,这个逻辑必须做对。
我的实现是在每天开始时(游戏时间 6:00)做一次批量判定,而不是每帧遍历农田:
def on_new_day(self, farm_tiles, weather): if weather.target_type in (WeatherSystem.RAIN, WeatherSystem.STORM): for tile in farm_tiles: if tile.tilled and tile.crop is not None: tile.water = tile.max_water为什么不在每帧扫?因为农田 tile 可能有几百块,每帧遍历是纯浪费;而且"下雨自动浇水"在玩家认知里就是"睡一觉起来地是湿的",日切判定完全符合直觉,还省性能。
但有个容易漏的点:雨天不应该触发作物干涸。如果你的干涸逻辑写在日切里,顺序必须是「先判天气浇水 → 再判干涸 → 再判生长」,否则刚浇完又被扣掉,白干。我第一次就把顺序写反了,玩家反馈"下雨天作物还是旱死",查了半天才发现是判定顺序问题。
提示:如果雨是中途开始的(比如上午从晴转雨),那么在转雨的那一刻也要补一次浇水判定。但已经湿的地不要再重复加,
water = max_water是幂等的,直接赋值就行,别写+=。
4.2 湿地面和泥点:低成本高回报的细节
浇水之后,地面贴图要跟着变。我的做法是给每个已耕地的 tile 准备两张图:干土和湿土,water > 0时切湿土。这是纯贴图替换,零额外开销。
再加一层:角色在雨天走过泥地,脚下随机溅起小泥点。这个我做得很克制——只有在泥地上移动、且雨强度大于 0.4 时才生成,每 0.2 秒最多一个,池子 30 个。效果是"走过的地方有点脏",不会糊成一片。
4.3 NPC 和动物要躲雨
这一条是"下雨"这个功能真正的灵魂。雨丝再好看,NPC 还在院子里傻站着晒太阳,整个世界的可信度就崩了。
我给每个 NPC 加了indoor_preference字段,晴天走室外路径,雨天切到室内路径点。切换判定读weather.target_type,在日切时一次性算好当天的路径,别在每帧的 AI 里加天气判断——那会让行为树变得非常难读。
动物同理,鸡舍、畜棚的动物在雨天不出门。这里有个小坑:已经在室外的动物不能瞬间传送回室内,那样玩家能看到"啪"一下消失。我改成让它沿着路径走回去,速度乘 1.5 表示"急着躲雨",观感自然很多。
4.4 雨声:音量跟着强度走,播放只调一次
音频这块坑最多。正确姿势是用pygame.mixer.Sound加载一个循环雨声,用play(loops=-1)起一次,之后只调set_volume,绝不重复play。
self.rain_sound = pygame.mixer.Sound("assets/rain_loop.ogg") self.rain_channel = None def update_audio(self, intensity, transition): volume = min(1.0, intensity * 0.8) if volume > 0.01: if self.rain_channel is None or not self.rain_channel.get_busy(): self.rain_channel = self.rain_sound.play(loops=-1) self.rain_channel.set_volume(volume) elif self.rain_channel is not None: self.rain_channel.fadeout(600) self.rain_channel = Noneget_busy()那层判断很关键。切场景时如果又调一次play,你会听到两轨雨声叠在一起,音量翻倍还带相位差,特别明显。我用fadeout(600)而不是stop(),雨停的时候声音是慢慢退去的,跟视觉上的强度渐变对得上。
BGM 也要交叉淡入。我的做法是 BGM 走独立的 channel,雨天把 BGM 音量压到平时的 55%,雨声负责主体氛围。
5. 性能实测:哪些写法真的会掉帧
这部分是我自己拿这个项目跑出来的对比,环境是 1280×720 窗口、同屏 600 条雨丝,测的是渲染阶段单独耗时。
| 写法 | 单帧渲染耗时 | 现象 |
|---|---|---|
主屏直接draw.line600 次 | 约 6.8 ms | 60 帧勉强,加地图重绘就掉 |
每帧transform.rotate雨丝 | 约 22 ms | 直接 20 帧以下,不可用 |
| 预渲染贴图 + 图层整体 blit | 约 2.4 ms | 稳定 60 帧 |
每帧Surface(...)新建图层 | 约 9 ms + 内存抖动 | 长跑后卡顿加剧 |
复用图层 +fill((0,0,0,0)) | 约 0.3 ms | 可忽略 |
结论很清楚:贴图预渲染 + 图层复用 + 一次 blit是这套方案的性能底线。三件事缺一不可。
还有两个容易忽略的开销点。
第一是fill的清屏范围。如果你的雨层 Surface 是全屏的,每帧fill全屏也是有成本的。可以只清"上一帧雨丝覆盖过的区域",但实现复杂、收益不大,我最后没做,因为全屏 fill 在 1280×720 下只要 0.3ms,不值得。
第二是int()转换。雨滴坐标是浮点,blit 前要转整数,600 次int()是微秒级,无所谓。但如果你的雨滴逻辑里出现了浮点的round()加字符串格式化(有人在调试时留了print),那个才是灾难。发布前一定要把所有循环内的print清干净,我吃过这个亏,一个残留的 print 让帧率掉了 15 帧。
6. 踩过的坑和排查速查表
6.1 雨滴闪烁、拖影、撕裂
闪烁:八成是每帧随机字符串导致的——雨滴的位置每帧被重新随机,而不是按速度推进。检查你的更新里有没有d.x = random...这种写法,随机只能出现在_spawn里。
拖影:图层没有正确清空。用SRCALPHA的 Surface 必须fill((0,0,0,0)),fill((0,0,0))会把 alpha 通道写成不透明,你看到的是上一帧残留的黑色雨丝。
撕裂感:雨丝长度每帧变化。前面说过,长度要初始化时定死。
6.2 切场景之后雨还在屋里下
这是架构问题,不是渲染问题。天气系统必须挂在全局 GameState上,而不是某个 Scene 的成员。如果你的 Scene 在__init__里创建了天气对象,那么进出屋子就会重置一次,进屋时雨强度归零、出门时又从零涨起,看起来就是"雨停了又下"。
同理,雨层 Surface 和粒子池也应该挂在全局渲染器上,切场景只切换"当前是否绘制天气层"。室内场景直接跳过天气层绘制,逻辑上天气还在继续,只是你看不见。
6.3 常见问题速查表
| 现象 | 最可能原因 | 处理方式 |
|---|---|---|
| 雨丝整片抖动 | 每帧重新随机长度/横坐标 | 只在_spawn中随机 |
| 帧率随雨量线性下降 | 每帧新建对象或 Surface | 对象池 + 预渲染贴图 |
| 雨声叠成两轨 | 重复调用play | 判断get_busy() |
| 雨层盖住 UI | 层级顺序错误 | 天气层在场景 UI 之前 |
| 地面不湿但雨在下 | 日切判定顺序写反 | 浇水 → 干涸 → 生长 |
| 读档后雨从零开始涨 | 序列化时只存了类型 | 存intensity并直接赋值 |
| 窗口缩放后雨滴堆在角落 | 未在resize中重置池子 | 清空所有alive标记 |
| 暴雨时画面全黑 | 压暗 alpha 超过 90 | 上限控制在 70 |
存档这块单独说一句:如果你把天气序列化成{"type": "rain", "intensity": 0.65},读档时直接赋值给self.intensity,别让它走过渡。否则玩家读档进来,明明存储时是暴雨,画面却从晴天开始慢慢变暗,那种错乱感非常出戏。至于target_intensity,可以按类型表推导一遍就行。
6.4 关于雷阵雨的额外两个坑
如果你要做暴雨加闪电,闪电的闪白千万不要每帧随机触发,那样会把人闪出癫痫。正确做法是按固定节奏(比如每 6 到 12 秒一次),闪白持续 0.08 秒,用一张全白SRCALPHA图层叠上去,alpha 从 120 迅速衰减到 0。闪电触发的同时把雷声音效延迟 0.3 到 0.8 秒播放,用pygame.time.set_timer发一个自定义事件去做延迟,比在主循环里塞 sleep 靠谱得多。
另一个坑是暴雨时雨声和雷声会争抢混音通道。默认pygame.mixer.init()的通道数是 8,如果你的项目里音效本来就多,雨声可能会被挤掉。启动时把通道数开到 16 或 32,一劳永逸。
7. 我个人在这块的一点体会
下雨这个功能我前后改了三版,第一版是纯视觉、画完就完;第二版加了浇水判定和 NPC 躲雨,但性能垮了;第三版才把状态机、粒子池、图层复用这三块理清。回头看,最值得先做的其实不是雨滴,而是那个天气状态机和小小的intensity变量。有了它,雨滴密度、环境压暗、雨声音量、作物判定、NPC 行为全都挂在同一个数上,改一处参数整体联动,调试起来非常舒服。反过来,如果一开始就扑在雨滴怎么画,后面每加一个交互都要再写一套天气判断,那种维护成本会让人想重开项目。
另外一个小建议:在游戏里加一个调试快捷键,直接切换天气类型并打印当前同屏粒子和帧率。我绑的是 F3,按住能看到实时的active数量、intensity和帧时间。这个两行代码的调试面板,比我反复重启游戏试效果省下来的时间多太多了。要是你也在做类似系统,可以先把这套天气框架跑通,再回头去堆美术细节,路会顺很多。