1. 浇水系统在农场游戏里到底承担什么角色
做到第 16 篇,轮到浇水了。前面几篇把地图、角色移动、工具切换、背包和时间系统都铺完了,很多人做到这一步会下意识觉得浇水就是个小事:按下鼠标、把格子状态改成"湿的"、播个水花动画,收工。真上手写才知道,这个模块是整条农场循环的咽喉。
在农场模拟类游戏里,浇水承担的不只是"让作物长"这一个功能,它同时是每日节奏的锚点、时间资源的消耗器、工具升级的驱动力,以及视觉反馈最密集的交互点。玩家一早出门,第一件事就是绕着自己的田走一圈把水浇了,这个行为每天重复几十次,任何一点手感上的别扭都会在第 30 次重复时被放大成烦躁。所以这篇我按"能让人连玩几十天不烦"的标准来拆,而不是按"功能能跑通"的标准来写。
先说清楚这篇适合谁:已经用pygame把角色、地图、瓦片渲染、工具切换跑通了,现在卡在浇水这一环的开发者;或者准备做一个星露谷风格的农场模拟项目,想先把每日循环的核心数据流想明白的人。新手可以照着代码抄,老手可以重点看第 4 节和第 6 节,那里是我踩坑最多的地方。
我自己的判断是,浇水系统的设计难点有三个,按难度排:
| 难度 | 问题 | 为什么难 |
|---|---|---|
| 中 | 目标格判定 | 朝向、鼠标、多格工具、地图边界,四种情况交叉 |
| 高 | 状态结算顺序 | 顺序错了会出现"浇了水但作物没长"这种极难查的 bug |
| 高 | 手感与反馈 | 没有即时反馈,玩家会怀疑自己没操作成功 |
1.1 从玩家体验倒推系统设计
我最开始写的第一版浇水是"点一下,格子里长出一滩水,完事"。跑起来功能是对的,但玩起来特别累,原因很简单:一块 10×10 的田,意味着一早上要点击 100 次。人在重复点击 100 次之后,注意力会完全涣散,这时候游戏就从"经营"变成了"体力活"。
所以改版时我给自己立了三条规则,后面所有实现都是围绕这三条展开的:
- 减少操作次数:水壶必须能蓄力,按住不放能一次浇 3×3 甚至 5×5,把 100 次点击压到 10 次左右。
- 保留资源约束:但又不能让人无脑按住浇全场,所以水壶有容量,浇完要跑一趟水源补水,补水这段路程本身就是"规划"的一部分。
- 每次操作都要有明确反馈:水花、音效、土地颜色变化、作物的"咯噔"一下,四者至少要有两个,让玩家在余光里也能确认操作成功。
这三条其实是互相拉扯的:减少操作次数会削弱节奏感,增加资源约束又会增加操作次数。我最后的平衡点是,水壶初始容量 40 次,蓄力范围 1 格,升级后容量 60、范围 3 格,再升一级容量 100、范围 5 格。也就是说,前期玩家确实要每天跑两趟水井,后期才逐步解放出来。这种"前期累一点、后期爽一点"的曲线,比一开始就很爽要耐玩得多。
另外一个很容易被忽略的体验点:浇水要允许"后悔"。玩家手滑浇了一块空地,不应该有任何惩罚,只是浪费一点水。真正有惩罚的应该是"忘记浇水",那是玩家的规划失误,不是操作失误。所以我的设计里,浇空地不做任何提示,也不扣额外的东西,静悄悄地扣掉一次水量就行。
1.2 一块田到底要记哪些字段
写代码之前,先把数据想清楚能省掉后面一半的返工。我给每个瓦片 (Tile) 定的字段是这样,写在tile.py里的 dataclass 中:
| 字段 | 类型 | 取值范围 | 说明 |
|---|---|---|---|
tilled | bool | - | 是否已翻土,没翻土的地浇不了水 |
moisture | int | 0 / 1 / 2 | 0 干、1 湿、2 干裂(连续未浇) |
watered_today | bool | - | 今天是否已经被浇过,防止重复扣水 |
crop | object / None | - | 挂在上面的作物实例 |
fertilized | bool | - | 是否施过肥,和浇水互不影响 |
这五个字段里,watered_today和moisture的分离是最容易搞错的地方。我见过不少人直接用一个is_watered布尔值,结果遇到"雨天自动浇水"和"连续两天没浇变干裂"这类需求时,就得不停地加补丁。正确的做法是:
watered_today是当天的瞬时状态,每天晚上睡觉时统一清零。moisture是跨天的累积状态,它决定了土地的视觉效果和干旱判定,清零逻辑完全不同。
这两个东西看起来像,但生命周期不一样。把它们混成一个,后面一定会写出"昨天浇过的地今天还是湿的,但作物没长"这种诡异 bug。我在第 6 节会详细讲这个坑。
1.3 把"湿润"拆成多级状态而不是一个布尔值
为什么moisture是 0/1/2 而不是真假?因为视觉上,一块刚浇过的深色土地和一块干到发白开裂的土地,给玩家的信息量完全不同。玩家扫一眼田地,就应该能看出哪块快干了,这是降低认知负担的手段,不需要点开任何面板。
具体映射是这样:
moisture = 1:深褐色,带一点点水光,是刚浇过的样子。moisture = 0:普通泥色,是翻过土但没浇的样子。moisture = 2:发白、带裂纹,是连续两天以上没浇的样子,同时触发作物的干旱计时。
视觉上的三级变化,比在头顶飘一个"缺水"的图标要温和得多,也更符合这类游戏的调性。做 UI 的时候我一直记着一条:能用环境本身传达的信息,就不要用弹窗和图标。
2. 核心数据结构与状态机设计
数据结构和状态机这块,我的建议是宁可多写几个类,也不要全塞进一个 Game 类里。我第一版是把所有东西都写在主循环里,变量名从tile_watered一路加到tile_watered_flag_temp,改到第 2000 行的时候已经不敢动了。后面重构成三层:Game管循环和渲染、TileMap管所有瓦片、WateringCan管工具状态,代码量反而少了。
2.1 Tile 与 Crop 的分层
瓦片和作物必须是两个独立的对象,瓦片持有作物的引用,但不要把作物的生长数据塞进瓦片。原因是瓦片是固定的、海量的(一张地图几千个),而作物是少量的、有状态的。分开之后,存档会变得非常清爽:瓦片只存tilled和moisture,作物单独存一张列表。
from dataclasses import dataclass, field from typing import Optional @dataclass class CropInstance: crop_id: str # 作物种类,例如 "parsnip" stage: int = 0 # 当前生长阶段 days_in_stage: int = 0 # 当前阶段已经过的天数 dry_days: int = 0 # 连续未浇水天数 dead: bool = False # 是否已经枯死 @dataclass class Tile: tilled: bool = False moisture: int = 0 # 0 干 / 1 湿 / 2 干裂 watered_today: bool = False crop: Optional[CropInstance] = None fertilized: bool = False这里有个细节值得说:dry_days放在CropInstance里而不是Tile里。因为干旱是作物的属性,不是土地属性——空地不浇水完全没关系,只有上面长了作物才有所谓干旱。如果放在 Tile 上,你后面就得写一堆判空逻辑。
另外dead字段一定要有,不要用stage == -1之类的魔法值表示枯死。这种小聪明在一个月后回来看代码时,会让你想抽自己。
2.2 水壶状态机的三个状态
水壶我用了三个状态,不多不少:
IDLE:待机,可以移动、可以切换工具。CHARGING:按住鼠标蓄力,此时玩家不能移动(或者移动会取消蓄力,看你的设计取向)。USING:播放浇水动画,动画结束时才真正扣水、改状态。
为什么要有CHARGING这个独立状态?因为蓄力需要一个"进度条"式的反馈。我最初的实现是靠pygame.time.get_ticks()记录按下时间,在渲染时算差值显示进度条——能跑,但后面一旦要做"蓄力中移动会中断"或者"蓄力中切换工具",逻辑就会散落在各个地方。做成显式状态机之后,所有进入/退出时的清理逻辑都能塞进enter_state()和exit_state()两个函数里,干净很多。
class CanState: IDLE = "idle" CHARGING = "charging" USING = "using" class WateringCan: def __init__(self, capacity=40): self.capacity = capacity self.water = capacity # 剩余水量 self.state = CanState.IDLE self.charge_start = 0 self.charge_level = 1 # 1 / 3 / 5 格 def charge_seconds(self, now_ms): return (now_ms - self.charge_start) / 1000.0 def resolve_range(self, now_ms): t = self.charge_seconds(now_ms) if t >= 1.2: return 5 if t >= 0.5: return 3 return 1阈值的选择是手感问题,不是技术问题。我实测下来 0.5 秒和 1.2 秒这两个节点比较舒服:0.5 秒之内松手就是单格,稍微按久一点到 3 格,再久到 5 格。低于 0.3 秒会让人误触,高于 1.5 秒会让人觉得"按了半天还没到位"。
2.3 一天结束时的结算顺序
这是整个浇水系统里我认为最容易翻车的地方,单独拎出来讲。晚上睡觉时,必须按固定顺序执行:
- 对每个有作物的瓦片,检查
watered_today。如果为真,days_in_stage += 1,dry_days = 0;如果为假,dry_days += 1。 - 检查
days_in_stage是否达到当前阶段的所需天数,是则stage += 1、days_in_stage = 0。 - 检查
dry_days,超过作物的耐旱阈值就标记dead = True。 - 清零所有瓦片的
watered_today。 - 根据新的
watered_today(此时已全为假)更新moisture:昨天浇过的降到 0,连续没浇的升到 2。
注意第 1 步和第 4 步的顺序,生长结算必须在清零之前。我第一次写反了,结果是"浇了水,第二天早上起来作物一点动静都没有",查了两个小时才反应过来。这个 bug 阴险在于它不报错,只是数值不对,而且因为生长本身有随机延迟,你会怀疑是随机数的问题。
还有一个衍生问题:雨天怎么办?我的做法是在第 1 步之前,先把所有露天瓦片的watered_today置为True,这样雨天就等价于"全自动浇水",玩家可以放心去钓鱼或者下矿。这个处理放在结算流程的最前面,逻辑上就很自然,不需要在生长判定里再写一次if raining。
提示:结算流程一定要写成一个独立的函数,并且在这个函数里加日志。我现在的做法是每次结算打印一行
DAY 12 | watered=37 | grew=35 | died=0,出了问题时直接看日志就知道是哪一环断的,比断点调试快得多。
3. 从按键到湿润土地:交互流程实现
这一节讲从玩家按下鼠标到格子变湿之间的全部环节。流程不长,但每一步都有坑,尤其是目标格判定,写错了会出现"明明指着这块地,水浇到了旁边"的情况,玩家会立刻觉得游戏很廉价。
3.1 目标格判定的两种方案
判定玩家想浇哪一格,主流有两种做法:
- 角色朝向法:以角色所在格为基准,按朝向偏移一格。
- 鼠标位置法:直接取鼠标世界坐标,反推格子坐标。
我的建议是两个都实现,但默认用朝向法。原因很实际:这类游戏里玩家用键盘移动角色、用鼠标点交互,如果完全依赖鼠标,手柄和纯键盘玩家就没法玩了;如果完全依赖朝向,鼠标操作又会觉得不够精准。
朝向法的核心代码很短:
DIR_VECTORS = { "down": (0, 1), "up": (0, -1), "left": (-1, 0), "right": (1, 0), } def get_target_tile(player, tile_size): gx = player.rect.centerx // tile_size gy = player.rect.centery // tile_size dx, dy = DIR_VECTORS[player.facing] return gx + dx, gy + dy看着简单,但有两个边界情况必须处理:一是角色站在地图边缘时,目标格可能越界,要直接返回None;二是角色跨格移动时,rect.center可能落在两个格子中间,导致朝向判定抖动。我的解决办法是用角色"脚部"的矩形中心点(身高下方三分之一处)来计算格子坐标,这样即使角色上半身跨过格子边界,判定依然稳定在脚下那一格。
地图边界检查一定要写在最前面,而且要返回None而不是返回一个无效坐标。很多人习惯返回(0, 0)然后指望后面不越界,结果就是每次在地图左上角浇水都会莫名其妙浇到那块地上。
3.2 蓄力范围的实际取样逻辑
3 格和 5 格的范围,不能简单地取"以目标格为中心的矩形",因为那会把墙、水、建筑全部卷进来。正确做法是逐个格子检查是否可浇,可浇就浇,不可浇就跳过。
def get_affected_tiles(cx, cy, size, tilemap): half = size // 2 result = [] for dy in range(-half, half + 1): for dx in range(-half, half + 1): tx, ty = cx + dx, cy + dy tile = tilemap.get_tile(tx, ty) if tile is None: continue if not tile.tilled: continue # 没翻土的地不浇 result.append((tx, ty)) return result这里有个取舍:是"只浇范围内的可浇格子",还是"只要有不可浇格子就整片失败"?我选了前者,因为后者在田边会非常恼人——你站在田角蓄力 5 格,结果因为有三个格子是草地而整片不生效,玩家会觉得工具坏了。宽松处理更符合这类游戏的调性。
还有水量扣减的问题:是按"范围内实际浇到的格子数"扣,还是按"蓄力等级固定扣"?我选了按实际浇到的格子数扣,这样做的好处是不会出现"范围 5 格但只浇到 2 格却扣 5 点水"的荒谬情况。代码上就是在get_affected_tiles之后,先检查water >= len(tiles),不够就降级或者干脆不执行。
3.3 动画和手持工具的反馈
动画这块我给水壶做了 5 帧:举起、倾斜、水流、收回、复位。对应角色的朝向有四套,总共 20 帧图。这个数量听起来多,但用同一套图做水平翻转就能省掉一半工作量。
关键点是动画时长要和逻辑判定解耦。我一开始的做法是等动画播完再执行浇水逻辑,结果发现动画 0.4 秒,玩家在连续浇水时会明显感觉到延迟,手感非常黏。改成"按下即判定,动画只是视觉表现"之后,手感立刻提升。玩家不会去核对动画和第 0.05 秒的状态变化,但他们会感受到点击是否"跟手"。
音效同理,要在判定的那一帧触发,不要等动画结束。这类细节听起来是吹毛求疵,但对于一个每天要重复几十次的操作来说,几十毫秒的延迟累积起来就是"这游戏手感一般"的评价。
4. 水量、耐久与生长联动的数值设计
数值设计这块,我想强调一个观点:浇水系统的数值不是拍脑袋定的,而是由"一个游戏日里玩家能干多少事"反推出来的。一个游戏日从早上 6 点到凌晨 2 点,大约 20 小时游戏时间,扣掉睡觉、吃饭、跑路,真正能干活的时间大约是 12 小时。如果玩家希望每天保留一半时间去做别的事情(下矿、钓鱼、社交),那么浇水就必须在 6 小时游戏时间内完成。
4.1 水壶容量与水井补水的路线规划
假设玩家的田是 12×8,共 96 格,全部种满。水壶初始容量 40,单格浇水,那么一轮浇不完,需要补一次水。水井放在田的一侧,跑一趟来回大约 1 小时游戏时间。所以前期的每日浇水耗时 = 96 次点击 + 1 趟补水 + 移动,大约 4~5 小时,刚好占据玩家一半的可用时间。
这个比例是我想要的:前期浇水确实占大头,让玩家有动力去升级工具。升级到范围 3 格之后,96 格只需要大约 12 次蓄力操作,耗时降到 1.5 小时左右,腾出来的时间玩家会自然转向其他玩法。
| 等级 | 容量 | 单次范围 | 每日浇水耗时(96 格) | 升级材料量级 |
|---|---|---|---|---|
| 初级 | 40 | 1×1 | 约 4.5 小时 | - |
| 中级 | 60 | 3×3 | 约 2 小时 | 中等 |
| 高级 | 100 | 5×5 | 约 1 小时 | 较高 |
| 顶级 | 无限 | 5×5 | 约 0.5 小时 | 很高 |
注意高级水壶容量 100 已经超过 96 格,意味着一次补水可以浇一整天。这不是数值失衡,而是设计上的"毕业感"——玩家花大量资源升级到最后,就应该获得"再也不用为水发愁"的解放。后面真正限制玩家的应该是别的资源(种子、体力、时间),而不是还在纠结水壶。
补水逻辑很简单:玩家站在水源瓦片旁边使用工具时,如果当前是水壶且water < capacity,则一次性补满,同时消耗一点游戏时间。这里我建议补满而不是按次补,减少玩家的操作次数。
4.2 干旱计时与枯萎判定
dry_days的阈值必须按作物区分,这是让不同作物有性格的重要手段。我定的规则是:
- 普通作物(萝卜、土豆之类):连续 2 天无水开始枯萎,第 4 天死亡。
- 耐旱作物(某些香草、仙人掌类):连续 4 天无水才枯萎,第 7 天死亡。
- 水生作物:完全不怕旱,但也不能种在普通土地上。
这里有个设计上的细节:枯萎不等于死亡。枯萎之后作物仍然可以救回来——只要重新浇水,dry_days归零,作物继续生长,只是浪费了几天时间。这种"可挽回的惩罚"比"一次失误直接归零"要友好得多,也更符合慢节奏经营的调性。
死亡判定我放在结算流程的第 3 步,判断依据是dry_days > 阈值。死亡之后作物不消失,而是换成一个枯萎的贴图,玩家需要手动清理。这个设计是故意的:让玩家每天看到自己失误的后果,比默默消失更有教育意义。
4.3 生长推进与浇水的优先级关系
最后说生长推进。我的规则是:浇水不是生长条件,而是生长加速器。也就是说不浇水作物也会长,只是慢。
具体来说,每种作物有一个days_per_stage的基准值。当天浇过水,进度 +1;没浇水,进度 +0(不增长);雨天算作浇过水。这样设计的好处是,玩家即使忙不过来荒废两天,作物也不会瞬间崩盘,只是收获时间推后。这比"不浇水就不长且会死"要宽容得多。
为什么不干脆做成"不浇水就不长"?因为那样惩罚太重了,玩家一旦某天忘记,整片田的收获时间全部推后,那种挫败感会直接劝退。而"慢一点 + 有枯萎风险"的组合,惩罚是渐进的,玩家有反应时间。
def advance_day(tile, raining): crop = tile.crop if crop is None or crop.dead: return watered = tile.watered_today or raining if watered: crop.days_in_stage += 1 crop.dry_days = 0 else: crop.dry_days += 1 need = CROP_DB[crop.crop_id]["days_per_stage"] if crop.days_in_stage >= need: crop.stage += 1 crop.days_in_stage = 0 limit = CROP_DB[crop.crop_id]["dry_limit"] if crop.dry_days > limit: crop.dead = True这段代码是整个系统的核心,建议单独抽成一个函数,并且配上单元测试。我后面加新作物的时候,就是靠测试保证老作物的行为没被改坏的。
5. 视觉呈现:贴图切换、水花与 HUD
前面四节讲的是"逻辑对",这一节讲"看起来对"。浇水是视觉反馈最密集的交互,如果做得干巴巴,玩家会觉得这个系统是拼上去的,而不是长在游戏里的。
5.1 土地贴图的三级切换
土地贴图我用的是一个图集,每个状态对应图集里的一行。渲染时根据(tilled, moisture)组合去查索引:
def tile_sprite_index(tile): if not tile.tilled: return 0 # 草地 if tile.moisture == 2: return 3 # 干裂 if tile.moisture == 1: return 2 # 湿润 return 1 # 干燥耕地实际做的时候,单纯换贴图会显得很"跳",因为相邻两帧的颜色差异太大。我的处理是在湿润贴图上再叠一层半透明的深色矩形(alpha 大约 40),并且给湿润地块的边缘加一点水光。叠层的好处是过渡自然,而且可以随moisture从 1 到 0 的变化做淡出动画——浇水当天是深色,第二天醒来慢慢变浅,这个渐变本身就是提醒玩家"该浇水了"。
还有一个容易被忽略的点:湿润地块的边缘要和其他地块区分。一块田里如果湿的干的混在一起,玩家需要一眼看出哪块浇过哪块没浇。我给湿润地块加了一圈 2 像素的深色描边,效果立竿见影。
5.2 水花粒子与溅射效果
水花不需要复杂的粒子系统,二十行代码就够了。核心是一个粒子列表,每个粒子有位置、速度、生命值,每帧更新位置并递减生命值,归零就删除。
class Droplet: def __init__(self, x, y): self.x = x self.y = y self.vx = random.uniform(-1.5, 1.5) self.vy = random.uniform(-3.0, -1.0) self.life = random.randint(12, 20) def spawn_splash(particles, grid_x, grid_y, tile_size): cx = grid_x * tile_size + tile_size // 2 cy = grid_y * tile_size + tile_size // 2 for _ in range(6): particles.append(Droplet(cx, cy)) def update_particles(particles, gravity=0.35): for p in particles[:]: p.x += p.vx p.y += p.vy p.vy += gravity p.life -= 1 if p.life <= 0: particles.remove(p)每个格子溅 6 个粒子,5×5 范围就是 150 个粒子,对现代机器毫无压力,但要注意上限。我给粒子列表加了一个 300 的硬上限,超过就不再生成。这个上限看着多余,但玩家如果疯狂蓄力连浇,不设限的话帧率会掉得很难看。
粒子的颜色建议用两到三种蓝色随机,单色的水花看起来像灰色的灰尘。
5.3 HUD 水量条与目标格高亮
HUD 上我只放了一个水量条,放在工具栏旁边,不搞花哨的图标。原因是玩家的注意力在田里,不在角落,所以水量信息必须"余光可见":条状比数字更快被感知。
关键设计:水量低于 20% 时变成黄色,低于 10% 变成红色并轻微闪烁。这不是为了好看,而是为了让玩家在浇到一半时就知道"待会儿得回去补水了",从而提前规划路线,而不是浇到一半突然发现没水,那种挫败感很强。
目标格高亮我用的是一个半透明白色方框,跟随get_target_tile的结果移动。蓄力时方框会扩散到对应的 3×3 或 5×5 范围,颜色从白变浅蓝。这个高亮的实现成本很低,但对可玩性的提升非常明显——玩家能清楚看到自己的操作会影响哪几格,蓄力范围这种"看不见的机制"就变成了看得见的东西。
注意:高亮框的绘制顺序一定要在土地和作物之上、角色之下。画在角色之上会挡住人物,画在土地之下会看不见。我在这个渲染顺序上改过三次才发现问题,建议一开始就定好图层顺序,写进常量里。
6. 常见问题与排查实录
这一节是我这两个月里真实踩过的坑,按出现频率排序。如果你正准备写浇水,建议先看一遍,能省掉一晚上的调试。
6.1 浇了水但格子没变化
最常见的原因有三个,按概率排:
- 工具状态没切换:玩家手里拿的还是锄头,按下鼠标执行的是翻土逻辑。排查方法是在鼠标按下时打印当前工具 ID,一眼就能看出来。
tilled为假:没翻过的地不能浇,这是我自己定的规则。但玩家不知道,他只会觉得"浇不上"。解决方式是在目标格高亮上区分颜色:可浇的画白色,不可浇的画灰色并微微闪烁。- 水量为 0:
water已经耗尽,但 HUD 上没提示或者提示太弱。这个就是上一节提到的水量条变色要够明显的原因。
排查顺序建议从工具开始,因为它是纯状态检查,不需要断点。
6.2 存档读档之后土地全干了
这个坑我踩得最惨。原因是存档时只存了moisture,没存watered_today,读档之后watered_today全部默认为False,于是当天结算时作物全部没长,土地也全变干。
正确的存档策略是两个都存,并且在读档之后立刻根据存档里的watered_today重新计算moisture,而不是信任存档里的moisture。因为moisture是派生状态,派生状态永远不该被信任,应该由源状态重新推导出来。
这条经验可以推广到整个项目:凡是能由其他字段算出来的字段,一律不存档,读档后重算。这样能避免绝大部分存档不一致的问题。
6.3 蓄力浇水扣水数量不对
扣水数量按"实际浇到的格子数"算,这个前面说过。但有一个边界:如果范围内有 25 格,而水壶只剩 10 点水,怎么办?我最初的实现是直接不执行,玩家会看到"蓄力了但什么都没发生"。这个体验非常糟。
改进方案有两种,我最后选了第二种:
- 方案一:水不够就降级,自动退回到能浇的范围。
- 方案二:水不够就浇能浇的那部分,把水浇完为止,并给一个"水壶空了"的提示。
方案二更直观,玩家的理解成本更低——"我有多少水就浇多少"。方案一的降级逻辑会让玩家困惑,为什么我蓄力 5 格结果只浇了 3 格。
6.4 常见问题速查表
| 现象 | 最可能的原因 | 排查方法 | 修复方式 |
|---|---|---|---|
| 浇水无反应 | 当前工具不是水壶 | 打印工具 ID | 加工具 ID 判断 |
| 浇到旁边的格子 | 目标格判定用错了锚点 | 可视化绘制目标格 | 改用脚部矩形中心 |
| 地图边缘报错 | 越界坐标未拦截 | 打印目标坐标 | 返回 None 并跳过 |
| 作物浇了不长 | 结算顺序错误 | 在结算函数里打日志 | 先结算生长再清零 |
| 读档后全干 | watered_today未存 | 检查存档字段 | 存源状态,读档重算 |
| 蓄力范围不对 | 阈值取值不合理 | 手测松手时间 | 调到 0.5s / 1.2s |
| 连续浇水卡顿 | 粒子无上限 | 统计粒子数量 | 设 300 上限 |
| 雨天作物还是干了 | 雨天逻辑位置错误 | 检查结算开头 | 结算前统一置为已浇 |
6.5 两个不写在文档里的心得
第一个心得:给浇水加一个极短的冷却。完全不加冷却的话,玩家按住鼠标连点会造成一帧内多次触发,扣水扣得莫名其妙。我加的是 150 毫秒冷却,肉眼几乎察觉不到,但异常触发全部消失了。
第二个心得:调试时把整块田的状态可视化。我写了一个隐藏按键,按下之后在每块地上画出一个小字:湿的显示W,干的显示.,有作物的显示作物的stage数字。这个调试视图帮我抓到了至少三次状态错乱,而且写起来只要十几行。做完这个之后我才明白,很多所谓的"玄学 bug",本质上就是状态不可见。
再补一个关于数值的心态问题。我一开始总想把浇水做得特别真实,搞过土壤湿度衰减曲线、搞过不同类型的土壤吸水率,做了两天全部推翻了。原因是玩家根本感知不到这些细节,他们只在乎三件事:浇没浇上、什么时候该浇、水够不够。超出这三件事的复杂度,全是自嗨。后来我把所有精力放在"即时反馈"和"目标格高亮"上,同样的时间里,手感的提升比搞真实物理大十倍。
这个模块做到现在,我自己最满意的是蓄力加高亮的组合,它把一个每天要重复几十次的操作,变成了一秒之内就能完成的动作,同时保留了范围控制的策略性。后面如果要继续扩展,我打算加的是洒水器——毕竟前面所有的数值设计,都是在为一个"自动化"的终点做铺垫。