上个版本的挖矿模拟器,地形生成、挖掘判定、背包槽位都跑通了,我一度以为"能玩"这件事已经解决。丢给朋友试了十分钟,他抬头说了一句:能挖,但没意思。那一刻我就明白,真正决定一个挖矿游戏能不能留住人的,不是那些看起来最硬核的算法,而是道具、昼夜、刷怪这三样东西有没有被当成一个整体来做。这一篇就是接上一期,把这三块从零到能跑通的全过程拆开讲,顺带回答几个私信里被问得最多的问题:道具控制放置那套鼠标玩法到底怎么实现的、AI生成的人物资产为什么排版总是错位、以及每个场景图为什么我坚持要存两种版本。内容偏实战,代码是C#的,思路换成任何引擎都能照搬。
1. 道具系统的骨架:一个道具为什么要拆成三层数据
最早我的道具就是一个类搞定的:图标、名字、数量、能不能放地上,全塞一个Item里。写到第五个道具就开始乱,写到一个带耐久的镐子要跟一个能堆叠的矿石共用一个类的时候,直接崩盘。后来我把道具拆成了三层,这套结构到现在都没再大改过。
1.1 静态定义、运行时实例、行为逻辑,三者必须分开
第一层是静态定义(Definition),描述"这个道具是什么"。它是不变的、可以被策划表驱动的数据,最适合挂在 ScriptableObject 或者配置文件上。
[CreateAssetMenu(menuName = "Mining/Item Definition")] public class ItemDefinition : ScriptableObject { public string id; // 全局唯一,存档只存这个 public string displayName; public Sprite icon; public ItemCategory category; // Tool / Material / Placeable / Consumable public int maxStack = 99; public ToolStats toolStats; // 挖掘速度、等级需求 public PlaceableStats placeableStats; // 占地格数、朝向 public float baseValue; }第二层是运行时实例(Instance),描述"背包里的这一个具体道具"。它必须是可序列化的,因为要进存档。
[Serializable] public struct ItemStack { public string defId; public int count; public int durability; // 满耐久存 -1 表示"不适用" public int[] affixIds; // 词条,没有就是空数组 }第三层是行为逻辑(Behavior),描述"这个道具被使用时发生什么"。挖掘、放置、食用、投掷,全部走一个接口,通过category分发。
为什么要费劲拆开?三个理由,都不是洁癖。第一,存档体积。十万个方块如果每个都存精灵引用和字符串名字,存档能直接起飞;只存defId和count,同样的内容体积能差出一个数量级。第二,热更新与平衡性调整。挖矿速度太慢想调快点?改ItemDefinition一个字段,所有已有的镐子实例自动生效,不用重开档。第三,工具与材料的差异。工具需要耐久、有词条、不可堆叠;材料可堆叠、无耐久、消耗即消失。如果共用一个类,你会得到一堆永远为 null 的字段和一堆if (category == Tool)的判断,改起来是灾难。
这里有个我踩过的坑:早期我把实例直接存了ItemDefinition的引用,本地跑起来一点问题没有,打包后偶尔读档报空引用。原因是 ScriptableObject 在编辑器里的引用和运行时构建出来的引用不是一回事,跨版本更新还可能丢。后来所有实例只认defId,运行时通过一个字典Dictionary<string, ItemDefinition>反查,这个问题再没出现过。
1.2 堆叠、耐久、词条这三个字段会互相打架
堆叠看着简单,count++就完事,但真正写起来有三个边界要处理。
堆叠键不能只用 defId。两把镐子,一把耐久 50 带"效率+2",一把耐久 100 带"幸运+1",它们绝不能叠在一起。我的做法是算一个堆叠哈希:defId + affixIds排序后拼接 + (durability是否满)。满耐久的工具允许互相堆叠,用过的就单独占格。这一步在入包逻辑里做,别在 UI 里做,否则排序一次就得重算一遍。
**耐久归零的时机。**很多新手写成"挖掘一次减一耐久",结果是挖软土和挖黑曜石一个消耗,玩家立刻感觉不对。正确做法是耐久消耗跟方块硬度挂钩,用Mathf.CeilToInt(hardness * factor),同时设一个最低消耗为 1,防止挖空气刷耐久。
public void ConsumeDurability(ref ItemStack stack, float hardness) { if (stack.durability < 0) return; // 无耐久道具 int cost = Mathf.Max(1, Mathf.CeilToInt(hardness * 0.5f)); stack.durability -= cost; if (stack.durability <= 0) stack = default; // 工具碎裂,直接清空槽位 }**词条的计算顺序。**词条分加法和乘法两类。我的经验是:先加后乘,且同类词条先合并再参与乘法,否则词条一多数字会失控。举例,两个"挖掘速度+20%"到底是 1.2×1.2=1.44 还是 1+0.2+0.2=1.4?我统一按后者(加法叠加),只有稀有词条用乘法,这样数值可控,也不会出现后期速度爆炸。
1.3 道具控制放置(msplay)背后的状态机
热词里提到的"道具控制放置 msplay",说的就是鼠标驱动的那套放置玩法:手里拿着一个可放置道具,鼠标移动时地面出现半透明的预览轮廓,点一下才真正落地。这东西看着是个视觉效果,实际上是个严格的状态机,状态没管好就会出现"隔墙放方块""一次点击放两个""卡在预览态退不出来"。
我把它拆成四个状态:Idle(空手或拿着非放置物)、Previewing(拿着放置物,显示幽灵)、Rejected(当前位置不合法,幽灵变红)、Placing(已经触发放置,进入短暂的冷却)。
核心是合法性检测,判断"这个位置能不能放"要同时满足三个条件:
public bool CanPlaceAt(Vector3 worldPos, ItemDefinition def) { Vector3 snapped = SnapToGrid(worldPos, def.placeableStats.size); // 条件一:目标格子必须为空(不是已有方块,也不是被实体占据) if (!GridManager.Instance.IsEmpty(snapped, def.placeableStats.size)) return false; // 条件二:不能跟玩家或其他实体穿模 Vector3 half = (Vector3)def.placeableStats.size * 0.5f - Vector3.one * 0.05f; if (Physics.CheckBox(snapped + Vector3.up * half.y, half, Quaternion.identity, blockingMask)) return false; // 条件三:必须在世界边界内,且下方有支撑(防止悬空) return GridManager.Instance.InBounds(snapped) && GridManager.Instance.HasSupportBelow(snapped); }注意那个0.05f的收缩。这是容差,不是随便写的。物理检测盒如果跟方块尺寸一模一样,方块与方块紧密相邻时会出现"明明贴着却判定为碰撞"的边界抖动,玩家会看到幽灵方块疯狂闪烁。收缩 5% 之后既不会误判穿模,也不会闪。
还有一个很隐蔽的问题:快速点击时会放两个。原因是你把放置逻辑写在了Update里读Input.GetMouseButtonDown(0),而某些帧率下同一帧被处理了两次朝向的射线,触发了两次。解决办法是放置成功后立刻进Placing状态,加一个 0.15 秒的冷却窗口,期间忽略输入。别小看这 150 毫秒,它同时还解决了"放完立刻想挖"的误触。
2. 昼夜循环:它根本不是光照效果,是全场的总时钟
我一开始把昼夜理解成"让太阳转起来,颜色变暖变冷",做出来之后发现游戏没有节奏感——白天挖矿、晚上也挖矿,怪物不刷,商店不开,作物不长。真正的昼夜系统本质是一个全局的时间源,光照只是它众多订阅者之一。
2.1 时间刻度:为什么我不用真实秒数
如果直接用Time.time除以 86400 来算一天,会立刻遇到问题:游戏里一天只有 16 分钟,你却要跟现实时钟绑定,暂停、加速、跳过夜晚全都没法做。正确做法是维护一个归一化的进度值。
public class TimeOfDay : MonoBehaviour { [Range(1f, 60f)] public float minutesPerDay = 16f; public float DayProgress { get; private set; } // [0, 1) public int DayCount { get; private set; } = 1; public event Action<int> OnNewDay; // 天变了 public event Action<int> OnHourTick; // 每个游戏内小时 float _lastHour = -1f; void Update() { DayProgress += Time.deltaTime / (minutesPerDay * 60f); if (DayProgress >= 1f) { DayProgress -= 1f; DayCount++; OnNewDay?.Invoke(DayCount); } float hour = DayProgress * 24f; if (Mathf.Floor(hour) != _lastHour) { _lastHour = Mathf.Floor(hour); OnHourTick?.Invoke((int)_lastHour); } Shader.SetGlobalFloat("_DayProgress", DayProgress); } }这里的关键设计决策有三个。
**第一,用DayProgress而不是"当前几点几分"。**归一化到 [0,1) 之后,所有跟时间相关的东西都变成了一次线性映射:光照强度是curve.Evaluate(DayProgress),雾的颜色是Gradient.Evaluate(DayProgress),刷怪权重是spawnCurve.Evaluate(DayProgress)。没有时区、没有跨天归零、没有 24 点奇数,代码干净非常多。
**第二,一天的时长必须是可配的。**16 分钟是我反复调出来的。8 分钟太赶,玩家刚下矿天就黑了;30 分钟又太长,等着天黑刷怪要等到烦躁。这个值最后我放在了设置里让玩家自己调,默认 16。
**第三,跨天用-= 1f而不是= 0f。**因为在一帧里如果时间倍率很高(比如玩家用了加速),可能直接跨过 1.0 好几次,用减法的写法能保留剩余的进度,不会吃掉时间。
2.2 事件总线:把刷怪、商店、作物、光照全挂上去
进度值有了,接下来是"谁在什么时候做什么"。这里最容易写错的就是在 Update 里到处轮询时间:刷怪脚本每帧判断"现在是不是晚上",脚本一多,每帧几十次判断,而且时序容易乱。
我改用事件订阅,每个系统只关心自己那一两个时间点:
| 订阅者 | 监听事件 | 触发行为 |
|---|---|---|
| 光照控制器 | 每帧读 DayProgress | 更新方向光角度、强度、环境色 |
| 刷怪管理 | OnHourTick | 按当前时段重算刷怪配额 |
| 商人 NPC | OnNewDay | 刷新当日商品列表 |
| 作物系统 | OnNewDay | 推进一格生长阶段 |
| 存档系统 | OnNewDay | 写一次自动存档 |
| 音频控制 | OnHourTick | 切换白天/夜晚的环境音轨 |
用表格把这张"时间表"列出来之后,你会发现整个游戏的节奏一目了然。这也是我建议的做法:先把时间表画出来,再动手写昼夜,不然做到一半一定会漏掉某个系统的联动,回头补的时候到处改。
有个必须注意的点:OnNewDay这种"一天一次"的事件,订阅者要在注册时自己判断当前是第几天,因为可能存在"读档时刚好是第 3 天,但刷怪系统还没订阅"的情况。我的做法是让TimeOfDay暴露一个CurrentDay属性,所有系统初始化时先主动同步一次状态,再开始订阅。
2.3 光照与性能:为什么我不改实时方向光
最直觉的做法是拿一个 Directional Light,根据时间改它的intensity和color。我试过,效果能看,但有两个坑。
**坑一:实时方向光 + 大量方块 = 阴影爆炸。**地下矿洞动辄几万个面,实时阴影贴图根本扛不住,帧率会掉到你怀疑人生。所以我的场景光照方案是:地表用一张烘焙好的底图 + 全局色调着色,地下用顶点色 + 点光源。方向光只负责极少量的、跟随玩家的"主光"效果,且不投影。
**坑二:Shader.SetGlobalFloat比material.SetFloat快得多。**如果你有一百个材质球都用了同一个昼夜参数,逐个设置会产生一百次材质实例化,内存和 GC 都会爆。用全局变量,所有 Shader 里统一声明_DayProgress,一次设置全局生效。这是我在一次 30fps 掉到 12fps 之后才改过来的,改完直接回到 60。
另外,光照曲线别用代码里的if-else硬编码,用AnimationCurve在 Inspector 里拖。策划想调"黄昏再长一点",拖两下就行,不用改代码重新编译。
public class DayNightLighting : MonoBehaviour { public AnimationCurve sunIntensity; // 0-1 映射到亮度 public Gradient ambientTint; // 环境色调 public float sunAngleRange = 180f; TimeOfDay _time; void Start() => _time = FindObjectOfType<TimeOfDay>(); void Update() { float p = _time.DayProgress; Shader.SetGlobalColor("_AmbientTint", ambientTint.Evaluate(p)); Shader.SetGlobalFloat("_SunIntensity", sunIntensity.Evaluate(p)); // 太阳绕场景旋转,用旋转角替代位移,避免阴影跟随问题 float angle = Mathf.Lerp(-90f, 270f, p); transform.rotation = Quaternion.Euler(angle, 30f, 0f); } }3. 刷怪系统:刷怪点、权重表、玩家距离,缺一不可
刷怪是这三块里最容易做崩的。做少了没事干,做多了电脑卡;做得"随机"吧,玩家会觉得刷得莫名其妙。我最后收敛到三个核心机制:刷怪点分区、权重表抽签、玩家距离剔除。
3.1 刷怪点:从"全图随机"到"分区配额"
第一版我写的是每隔几秒在玩家周围一个环形区域里随机点位置刷怪。结果非常糟糕:怪会刷在墙里、刷在岩浆里、刷在玩家脚边,而且数量完全失控。
第二版改成显式刷怪点:在地图编辑器里手动放置SpawnNode,每个节点挂一个区域标签(地表、浅层矿洞、深层矿洞)和一个容量上限。
public class SpawnNode : MonoBehaviour { public SpawnZone zone; // 区域标签 public int capacity = 3; // 这个点最多同时存在几只 public float respawnDelay = 8f; [HideInInspector] public float cooldown; [HideInInspector] public List<GameObject> spawned = new(); }这一改,问题解决了大半。怪只会从"看得见摸得着"的洞口、裂隙、阴影角落里冒出来,玩家有代入感,也不会被莫名其妙围殴。放刷怪点的时候有个小技巧:节点朝向面朝开阔方向,别贴着墙放,否则怪一出生就卡在墙里。
配额则是分区统计的。每个区域维护一个"当前存活数量",达到上限就不再从该区域抽签。这样即使矿洞很大,玩家也不会一次面对二十只怪。
3.2 权重表与保底机制:让"随机"不至于恶心人
每个刷怪点有一张权重表:
[Serializable] public class SpawnEntry { public GameObject prefab; public float baseWeight = 10f; public float nightWeightMult = 2f; // 夜间权重倍率 public int minDepth = 0; // 只在这些深度出现 public int pityCount = 6; // 连续 N 次没抽中后强制出现 }抽签逻辑里我加了两个保护。一个是深度过滤:浅层不会刷出深层精英怪,否则新手挖两下就被劝退。另一个是保底计数:某些稀有怪如果纯随机,玩家可能三十次都遇不到一次。我给它们加了pityCount,连续若干次没抽中就提高权重直到必出。这套做法在抽卡里很常见,用在刷怪上同样有效,而且玩家感知不到,只会觉得"偶尔会遇到个大家伙"。
public SpawnEntry WeightedPick(List<SpawnEntry> pool, int depth, float dayProgress) { float nightFactor = (dayProgress < 0.25f || dayProgress > 0.75f) ? 1f : 0f; float total = 0f; var weights = new float[pool.Count]; for (int i = 0; i < pool.Count; i++) { var e = pool[i]; if (depth < e.minDepth) { weights[i] = 0f; continue; } float w = e.baseWeight * Mathf.Lerp(1f, e.nightWeightMult, nightFactor); if (e.missStreak >= e.pityCount) w *= 5f; // 保底放大 weights[i] = w; total += w; } if (total <= 0f) return null; float roll = Random.value * total; for (int i = 0; i < pool.Count; i++) { roll -= weights[i]; if (roll <= 0f) { pool[i].missStreak = 0; return pool[i]; } } return pool[^1]; }注意Random.value,这里用的是普通伪随机。不要在刷怪里用System.Random且每次 new 一个实例,在快速连续调用时会有时间种子重复的问题,刷出来的怪会一模一样。要么用引擎自带的随机,要么用全局单例的随机数生成器。
3.3 刷怪与昼夜、地层的联动
这是让刷怪"活起来"的关键。白天怪少、温和,集中在浅层;夜晚怪多、凶,会主动靠近营地;深层则是另一种生态,跟昼夜关系不大,跟挖掘进度强相关。
我把这套逻辑全压进了一张SpawnProfile配置:每个区域一个 profile,里面定义白天权重、夜间权重、深度门槛、最大同时存在数量。运行时的逻辑就退化成"读当前时间 + 读玩家深度 → 查表 → 抽签",非常好调。
| 区域 | 白天最大数量 | 夜间最大数量 | 夜间权重倍率 | 深度门槛 |
|---|---|---|---|---|
| 地表草地 | 4 | 10 | 2.5 | 0 |
| 浅层矿洞 | 6 | 12 | 1.8 | 10 |
| 深层矿洞 | 8 | 8 | 1.0 | 40 |
| 熔岩层 | 5 | 5 | 1.0 | 70 |
顺便说一个刷怪的性能细节:别在怪物死亡时立刻销毁对象,用对象池。我最初是Destroy,一波怪打完会看到明显的卡顿尖峰,那是 GC 在收拾残局。改成对象池之后,刷怪瞬间的开销几乎可以忽略。池子的初始容量按区域上限 × 2 给就够了,多了浪费内存。
4. 每个场景图为什么有两种:AI人物资产排版与分镜图的真相
这个问题私信里问得最多,我统一说清楚。不是因为我闲,是因为一张图根本承载不了这些信息。
4.1 两张图各自承担什么职责
我项目里的每张场景图都对应两张资源,用途完全不同:
第一张是"底图 / 远景层":不参与碰撞、不参与挖掘,只负责视觉。它包含了天空、远山、树线、背景建筑。它可以是低分辨率的、可以被拉伸的,因为它永远在最底下,玩家不会凑近看。有它,视差滚动才有层次;没它,角色一动背景就是死板的一张画。
第二张是"交互层 / 前景层":这一张才是真正跟玩法绑定的。它包含所有可挖掘的方块、可放置的格子、可碰撞的墙体,通常是一张 tilemap 或者一套图集,每个格子有明确坐标。这张图必须是像素对齐的、坐标可推导的。
为什么必须分开?因为它们的更新频率完全不同。场景重绘时,底图我可以整张替换、重新调色;交互层一旦动了,所有已放置的方块坐标、存档里的地形数据就全废了。把两者焊死在一张图里,改一次美术就要重开一次档,没人受得了。
还有第三个隐藏原因:昼夜切换。底图可以用全局色调着色快速切换白天/黄昏/夜晚,一张图能顶三张用;如果把交互层也染了色,方块的颜色就失真了,玩家分不清铁矿和石头。所以底图染色,交互层不染,这个分工必须靠两张图来实现。
4.2 AI人物资产的排版规范
用AI生成人物立绘很方便,但直接塞进项目里通常一片混乱:尺寸不一、脚底线对不齐、有的半透明边缘带白边。我总结了一套排版流程,做完之后所有角色都能自动对齐。
第一步,统一画布。不管生成出来是 1024×1024 还是 512×768,全部放进同一个尺寸的透明画布(我用 512×512),并且脚底对齐画布底边往上留 16 像素。这个 16 像素是给地面阴影和轻微下沉动画留的空间。统一画布之后,所有角色的锚点坐标都是同一个,代码里不用为每个角色单独写偏移。
**第二步,锚点设置在脚底中心。**Unity 里精灵的 pivot 一定要设成Bottom Center,不要用默认的Center。用默认值的话,角色站到地面上时模型会往下陷半个身位,你会以为是坐标算错了,其实是锚点的问题。这个坑我调了整整一个下午。
**第三步,像素密度必须统一。**如果 A 角色是 100 像素高、B 角色是 200 像素高,放在同一个场景里 B 会大得像巨人。解决办法是全部导出前按"目标身高像素"重采样,比如所有角色统一 128 像素高,体型差异用缩放系数在代码里调,而不是在美术资源里调。
**第四步,切分与命名。**如果一张图里有多个角色,按角色名_动作_序号命名,比如miner_idle_01、miner_walk_01。命名乱是后期最大的时间黑洞,我见过一个项目因为精灵名字全是sprite_1到sprite_300,改一个动画要翻半小时。
4.3 分镜图:画它不是为了好看,是为了省返工
分镜图这东西听起来是动画和影视的活儿,但在游戏里它解决的是一个很实际的问题:在动手搭场景之前,先把"这一幕玩家看到什么、做什么、拿到什么"写清楚。
我的分镜图一格包含四样信息:镜头构图(玩家视角看到什么)、角色位置(NPC 站哪、玩家从哪进)、道具出现(这一格要展示或产出哪些道具)、关键交互(点哪里、挖哪里、触发什么)。画得潦草没关系,信息齐就行。
举个例子,我要做一个"夜晚矿洞遭遇"的桥段,分镜是这样的:一格是玩家在矿洞口,天已经暗下来,手上提着灯;第二格是进入矿洞,光照变窄,远处有怪影;第三格是挖掘时触发了稀有矿脉;第四格是怪群靠近,玩家用刚挖到的道具放置了一堵墙挡住。这四格画完,我立刻就知道需要哪些资产:灯笼道具、矿洞底图、稀有矿脉贴图、怪物资产、可放置墙体道具。资产清单是从分镜图里"长"出来的,而不是想一个做一个,这样做完的场景才是连贯的,不会出现"这个道具做出来没用过"的情况。
5. 资产补充与落地:一套能反复用的生产管线
道具系统、昼夜、刷怪三块功能跑通之后,最大的瓶颈就变成了"内容不够"。这时候如果没有一套固定的生产流程,每加一个道具、每加一个场景都要重新想一遍,效率会低到绝望。
5.1 目录结构与命名约定
我按"职责"而不是"类型"来分目录,这一点跟很多教程不一样。
Assets/ Content/ Items/ Definitions/ # 每个道具一个 .asset Icons/ Actors/ Characters/ # AI 人物资产,按角色名建子目录 Miner/ idle/ walk/ hurt/ Monsters/ Scenes/ Backgrounds/ # 场景底图(远景层) Tilesets/ # 场景交互层(前景层) Storyboards/ # 分镜图,按章节建子目录为什么按职责分?因为"一个道具"实际上散落在四个地方:定义在Definitions、图标在Icons、可放置的道具还要在Tilesets里有一张格子图、如果出现在剧情里还会在Storyboards里被引用。把它们放一起(比如Items/Miner/Pickaxe/)看似合理,但会导致图集打包时同类资源分散,Draw Call 反而变多。按职责分,图集好打,加载好控。
命名上我强制两条:道具 id 全小写下划线,跟资源文件名一致;场景资源带层级后缀,比如cave_shallow_bg、cave_shallow_tiles。后者非常重要,看到名字就知道哪个是底图哪个是交互层,不会再搞混。
5.2 从分镜图到场景搭建的转换
有了分镜图,搭场景就是填空。我的顺序是固定的:先放底图定比例,再铺交互层定格子,然后按分镜摆角色,最后放刷怪点和道具产出点。
这里有个很实用的技巧:在场景里加一个只有编辑器可见的GridDebugger,把可挖掘格、刷怪点、道具刷新点分别用不同颜色画出来。搭场景的时候一眼能看出哪里格子没铺满、哪里刷怪点重叠了。这东西在正式打包时必须关掉,因为它每帧画 gizmo 有开销。我最初忘了关,打包后帧率低得莫名其妙,找了半天才想起来是调试器还在跑。
另外,从分镜到场景的转换过程中,一定会遇到"分镜里想得到但资产做不出来"的情况。我的处理原则是:优先用现有资产变体凑,不要为了一个镜头做一套新资产。比如分镜里想要一个"发光的矿脉",我直接用现有矿石贴图加个自发光材质,而不是重新画一张。内容量是靠这种"复用+微调"堆起来的,全靠新建根本做不完。
5.3 排查表:这几类问题我几乎每周都会遇到一次
最后把常见问题整理成一张表,都是我在实际调试中反复撞到的,遇到问题先按这张表过一遍,比瞎找快得多。
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 放置道具时幽灵方块闪烁 | 物理检测盒没有收缩容差 | 检测盒各轴收缩 5%,加合法性防抖 |
| 快速点击一次放了两个 | 放置逻辑写在了 Update 且无冷却 | 放置后进 Placing 状态,加 0.15s 冷却 |
| 读档后道具图标全丢 | 实例直接引用了 ScriptableObject | 只存 defId,运行时字典反查 |
| 夜晚光照没变化 | 用了材质实例而非全局 Shader 变量 | 改 Shader.SetGlobalFloat |
| 怪物刷在墙里 | 刷怪点朝向贴墙 | 刷怪点朝向开阔方向,出生点做一次落地检测 |
| 刷怪瞬间卡顿 | 死亡时 Destroy 触发 GC | 改用对象池,容量按区域上限 ×2 |
| 角色站位陷进地面 | 精灵 pivot 用了 Center | 改成 Bottom Center |
| 场景颜色错乱 | 交互层被昼夜着色污染 | 只对远景层着色,tileset 不参与 |
这张表里我最想强调的是第一条和最后一条,因为它们最容易被当成"玄学 bug"。幽灵方块闪烁看着像渲染问题,其实是物理检测的边界问题;场景颜色错乱看着像材质问题,其实是着色范围搞错了。这类问题的共同点是:现象在表层,原因在数据层,所以遇到诡异问题时,先回头看看数据是怎么组织的,往往比盯着渲染管线查更快。
我自己做这块最大的体会是,挖矿模拟器这类游戏的功能复杂度其实不高,难的是所有系统都互相咬合——道具影响放置,放置影响地形,地形影响刷怪,刷怪受昼夜控制,昼夜又驱动存档和商店。任何一块单独拿出来都不难,难的是让它们在同一个时钟下协调运转。所以每加一个新功能,我都会先问一句:它挂在哪个事件上?想清楚这一句,代码基本就顺了。