ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#挖矿模拟器道具昼夜刷怪系统解耦:事件驱动与数据驱动实战

C#挖矿模拟器道具昼夜刷怪系统解耦:事件驱动与数据驱动实战 上一篇文章把挖矿模拟器的地形生成和挖掘判定拆完了这篇接着聊下半场道具、昼夜、刷怪。这三个系统单独拎出来都不算难难的是它们挤在同一个循环里互相拉扯——你只是改了一个道具的掉落权重可能就把夜间的刷怪节奏带崩了。我最早那版把这三块全塞在一个 600 行的 GameManager 里四十多个布尔标记互相判断想加个火把插在地上能抑制刷怪的玩法改了两天都没跑通最后整块推翻重写。现在这套代码拆成了四个模块加一张事件表加新道具、调刷怪曲线、改昼夜长度基本都是改配置。这篇会把三块代码的骨架、为什么这么拆、参数怎么算、我踩过的坑全部摊开讲。不管你是刚学完语言基础想做个完整小项目练手还是已经在写自己的挖矿/生存类游戏卡在系统耦合上都能直接拿走一部分用。代码用的是 C# 和通用游戏引擎的写法换到别的技术栈思路一样主要是结构和判定逻辑。1. 动手之前先把三个系统的依赖关系捋顺1.1 为什么这三个系统不能各写各的新手最自然的做法是新建三个脚本挂到场景里ItemManager 管道具DayNightManager 管时间SpawnManager 管刷怪每个自己跑 Update自己存自己的数据。第一周跑起来完全没问题问题出在玩法开始交叉的时候。举个例子夜里矿洞会刷幽魂幽魂掉夜光石夜光石能做火把火把插下去能阻止刷怪这一句话里三个系统全串起来了。如果每个 Manager 都是单例、都直接拿对方的数据代码里很快就会出现SpawnManager.Instance.nightProgress 0.5f ItemManager.Instance.GetCount(ItemId.Torch) 3这种表达式读的人要同时打开三个文件才知道发生了什么事。我把这套依赖画清楚之后发现它是一个环时间影响刷怪刷怪影响掉落掉落影响道具道具又影响玩家能力玩家能力反过来决定刷怪的强度。有环没关系工程上处理环的办法就是不在环上直接连边而是让中间隔一层。我的做法是三个模块之间绝对不互相持有引用全部通过一个只读的数据快照和一张事件表通信。时间模块只负责发布现在是夜晚这个事实刷怪模块自己订阅刷怪掉落的物品只丢进一个掉落队列道具模块自己去消费。这样一来任何一个模块都可以单独拔掉做单元测试。注意这条模块之间不互相拿引用的规则越早立越好。后期再改工作量是前期的五到十倍。1.2 我现在的模块划分和职责边界拆分的原则不是按系统名字分而是按谁拥有这份数据的写权限分。一个数据只能有一个地方能改其他地方全是只读。模块唯一拥有的写权限对外只读数据对外发布的事件时间模块当前 tick、当前时段时段枚举、归一化进度时段切换、整点跨过道具模块背包内容、地面掉落某道具数量、是否持有道具获得、道具消耗、背包满刷怪模块怪物实例、刷怪计时当前存活数量、危险等级怪物死亡、怪物生成玩家模块位置、血量、装备位置、光照等级、战力受伤、死亡、进入区域这张表贴在我工位上一整个月每写一行新代码都问一句我这个操作改的是不是别人拥有的数据。如果是那就改成发事件。这套规矩立起来之后最明显的变化是我可以在不启动游戏的情况下直接构造一个假的背包数据去测刷怪曲线到底合不合理。1.3 数据驱动到什么程度才合适关于配置表我见过两个极端。一种是所有东西硬编码在代码里加个道具要重新编译另一种是恨不得把 Update 的调用顺序都塞进 JSON结果一个空引用查了半天最后发现是配置少了个字段。我的判断标准很简单改这个值的人需不需要懂代码结构。如果不需要就进表如果需要就留在代码里。按这个标准道具的名字、图标、堆叠上限、稀有度、掉落权重、能不能放进熔炉全部进表道具使用之后干什么这种带逻辑的东西留在代码里表里只存一个效果 ID。昼夜的时长、每个时段的光照颜色、黄昏的持续时间进表时间怎么推进、读档时怎么补算留在代码里。刷怪的权重、深度区间、光照条件进表怎么选点、怎么从池子里取对象留在代码里。这个边界最大的好处是策划或者你自己调数值的时候游戏挂在那里热重载就能看到效果不用每次改完等三分钟编译。2. 道具系统把 switch 换掉之后改需求只要改表2.1 道具的静态数据长什么样第一版我用一个大枚举ItemType加一个 switch 做所有判断写到第二十种道具的时候一个 switch 有三十多个 case改一处漏一处最容易出的 bug 是加了新道具忘了在GetStackLimit里加分支结果所有新道具堆叠上限全是零捡起来直接消失。重构之后改成数据定义加效果注册表静态数据是一个可序列化的结构[Serializable] public class ItemDefinition { public ushort Id; // 用 ushort 而不是 string存档和网络同步都省地方 public string DisplayName; public ItemCategory Category; // Tool / Material / Consumable / Placeable public int MaxStack 64; public float BaseValue; public string IconKey; public string[] EffectIds; // 只存 ID逻辑在代码里注册 public int UnlockDepth 0; }这里有几个决定值得说清楚。ID 用ushort而不是字符串是因为背包里可能有几百个格子怪物掉落和存档都频繁读写这个字段ushort一次比较就是一次整数比较用字符串哈希在每一帧的排序里会白白浪费性能。EffectIds存字符串而不是枚举是为了让效果系统能热加载——注册表里没有这个 ID 就跳过并打一条警告而不是整个游戏崩掉。道具分类用Category而不是给每个道具单独打标签是因为绝大多数逻辑判断只关心大类别。真正需要精细判断的地方比如哪些道具能当燃料我另外维护一个HashSetushort白名单比在每个定义里加布尔字段更灵活熔炉改配方的时候只需要改这一个集合。2.2 运行时数据 ItemStack 该装什么静态定义和运行时数据必须分开这是我在做存档兼容时踩出来的教训。早期版本我把定义和数量塞在同一个类里存档直接序列化整个对象结果改一次道具数值老存档读进来全是旧值玩家的道具强度就跟着旧版本走非常难查。public class ItemStack { public ushort DefId; public int Count; public int Durability -1; // -1 表示无耐久概念 public Dictionarystring, float RuntimeStats; // 词条、附魔这类后期玩法 public bool IsEmpty Count 0; }Count用int而不是byte虽然堆叠上限只有 64但中间计算过程比如从箱子里一次性取出两百个会超出byte范围溢出之后背包会莫名多出一堆道具这个 bug 排查了整整一个下午。Durability用-1表示不适用而不是用零表示因为零在语义上是已损坏两者混在一起会让耐久判定到处都是特判。提示地面掉落的物品也直接用ItemStack不要另外写一套掉落物结构。我在某个版本里给掉落物单独定义了字段结果物品从背包扔到地上再捡回来词条丢失了玩家骂了很久。2.3 使用效果的分发道具被使用的瞬间要决定发生什么这里是最容易写成 switch 的地方。我用一个效果注册表来替代public static class ItemEffectRegistry { static readonly Dictionarystring, ActionItemUseContext Handlers new(); public static void Register(string id, ActionItemUseContext handler) Handlers[id] handler; public static void Apply(ItemStack stack, ItemUseContext ctx) { var def ItemDb.Get(stack.DefId); foreach (var id in def.EffectIds) { if (Handlers.TryGetValue(id, out var h)) h(ctx); else GameLog.Warn($未注册的道具效果: {id}); } } }这套写法的好处是每个效果的实现是独立的小函数比如治疗 20 点血量、在脚下放置火把、对前方 2 格内的怪物造成 15 点伤害各自注册各自的 ID。加新道具只需要在配置里写一个新 ID 组合不需要动任何已有的分支。消耗也就地处理ItemUseContext里携带背包引用和目标格子效果函数执行完之后由外层统一扣数量避免每个效果函数自己扣、扣两次。有一个细节必须注意效果函数里不要再触发使用。我在做药水连锁的玩法时让一个效果去调用Apply去叠加另一个效果结果两个效果互相调用直接栈溢出。后来改成效果函数只往一个待处理队列里塞下一帧要执行的效果用队列消掉递归。2.4 道具系统的实操心得整理了一份我实际踩过的坑基本覆盖了道具系统八成的问题现象根因解决方式捡起道具直接消失堆叠上限是 0合并逻辑判定为空后销毁定义加载后统一做一次校验MaxStack 小于 1 直接报错背包排序后道具错位排序时只排了数量没排定义 ID相同数量的项顺序不稳定比较器加第二关键字 DefId保证排序稳定耐久度归零后还能用判定写在使用之后判定提前到使用之前损坏时执行的是报废分支存档换版本后道具变强存档里存了数值而不是只存 ID只序列化 DefId 和 Count数值一律从当前配置读熔炉配方改了老存档炸掉配方用索引引用物品配方里也用 ushort ID永远不用数组下标还有一个不算坑但很省事的技巧给每个ItemStack加一个Version字段只在词条变化时自增背包 UI 每帧对比这个字段决定要不要重绘格子。我用这个办法把背包界面从每帧刷新三十个格子的文本改成只在变化时刷新手机上帧率直接从 45 提到了稳定 60。3. 昼夜系统别只做一层渐变遮罩3.1 时间推进的三种做法昼夜看起来就是过一会儿天黑了但实现方式直接决定了后面存档、暂停、倍速这些功能好不好写。我试过三种做法优点缺点适用场景每帧累加 deltaTime写法最简单一行代码浮点累加有精度漂移长时间运行会有偏差暂停要额外处理原型阶段固定 tick 累加精度可控暂停/倍速/读档都好处理需要自己做 tick 到秒的换算正式项目读系统时间可以做真实时间同步的玩法玩家改系统时间会出问题离线进度难算特殊需求我最后选的是固定 tick。一天定为 1440 tick一 tick 代表游戏内一分钟每帧按deltaTime * timeScale / secondsPerTick往累加器里加累加器过 1 就推进一个 tick。这样做的核心好处是逻辑和渲染解耦刷怪、作物生长、商店刷新这类逻辑只在 tick 推进的瞬间执行一次而画面上的天光颜色用累加器的余数做插值看起来仍然丝滑。public class DayNightClock { public const int TicksPerDay 1440; long _totalTicks; // 存档只存这个 float _accum; public int TickOfDay (int)(_totalTicks % TicksPerDay); public float DayProgress TickOfDay / (float)TicksPerDay; public int DayCount (int)(_totalTicks / TicksPerDay); public event Actionint OnTick; // 每个 tick 一次 public event ActionTimePhase OnPhase; // 时段切换 public void Advance(float dt, float scale) { _accum dt * scale / SecondsPerTick; while (_accum 1f) { _accum - 1f; _totalTicks; OnTick?.Invoke(TickOfDay); CheckPhase(); } } }CheckPhase里判断当前 tick 落在哪一段和上一段的枚举不一样就发一次OnPhase。这里有个必须注意的点时段切换要用边沿触发不能每帧判断条件。我早期在刷怪代码里直接写if (clock.DayProgress 0.75f) SpawnNightMonsters()结果夜晚的每一帧都在刷怪屏幕直接卡死。改成订阅OnPhase之后一晚只触发一次。3.2 光照颜色线性插值发灰的问题昼夜氛围全靠光照颜色撑很多人包括我第一版就是两个颜色做Lerp白天白、夜晚深蓝。跑起来发现黄昏时段整个画面是灰蒙蒙的一点都不好看。原因很简单RGB 线性插值走的是两个颜色之间的直线而视觉上舒服的过渡是沿着色相环走的。解决办法是把颜色转成 HSL只对亮度和饱和度插值色相单独用关键帧列表控制。// 用关键帧代替两点插值黄昏和黎明可以给不同的色调 [Serializable] public struct LightKey { public float At; public Color Color; public float Intensity; } public LightKey[] Timeline; public (Color, float) Sample(float dayProgress) { for (int i 0; i Timeline.Length - 1; i) { if (dayProgress Timeline[i 1].At) { float t Mathf.InverseLerp(Timeline[i].At, Timeline[i 1].At, dayProgress); t t * t * (3f - 2f * t); // smoothstep去掉生硬的折角 var c Color.Lerp(Timeline[i].Color, Timeline[i 1].Color, t); float intensity Mathf.Lerp(Timeline[i].Intensity, Timeline[i 1].Intensity, t); return (c, intensity); } } return (Timeline[^1].Color, Timeline[^1].Intensity); }smoothstep这一步很关键直接线性插值在关键帧的接缝处会有明显的折角尤其是黎明那一段画面亮度会突然拐一下。加了平滑之后过渡就自然了。另外关键帧至少要给五个深夜、黎明、上午、黄昏、入夜少了之后黄昏会短得几乎看不见。光照本身我分了三层全局环境光一个颜色加一个强度作用于整个场景、玩家随身光半径固定的一圈代表矿灯、局部光源火把、熔炉独立计算衰减。洞穴和地表的区别用遮挡处理——地表在夜里也保留一层很弱的环境光让玩家还能勉强看见轮廓全黑会让新手直接劝退矿洞里则完全跟着时段走白天也是暗的这样下矿才有压迫感。3.3 昼夜钩子让别的系统自己订阅时间和刷怪之间的关系我一开始是写在刷怪模块里的if (isNight)。后来要加凌晨四点商人出现、作物只在白天生长、夜晚矿洞刷新稀有矿脉全塞进刷怪模块就乱了。改成时间模块只发事件其他模块自己订阅clock.OnPhase phase { switch (phase) { case TimePhase.Dusk: spawn.SetNightMode(true); ambient.FadeToNight(); break; case TimePhase.Dawn: spawn.SetNightMode(false); ambient.FadeToDay(); break; } }; clock.OnTick tick { if (tick 4 * 60) merchant.TrySpawn(); if (tick 12 * 60) crops.GrowAll(); if (tick % 30 0) oreVeins.RefreshHiddenNodes(); };这样写还有个额外好处所有和时间相关的玩法都集中在一个地方能看到全貌调平衡的时候不用满项目搜DayProgress。这里我犯过一个错OnTick里做的事情太多一开始每 tick 都要遍历全地图的作物游戏里时间倍速开到十倍之后直接掉帧。后来把每 tick 要做的事按频率分档tick % 30的放一起、tick % 120的再放一起倍速时压力就下来了。3.4 存档里的时间怎么存存long类型的总 tick 数不要存还剩多少秒天黑。前者读档之后一切照旧后者一旦你改了昼夜时长老存档的剩余时间就对不上了。读档的时候用总 tick 反推出当前时段、当前天数直接调到对应的光照关键帧跳过插值动画。这一点在玩家频繁读档的时候体验差别很大如果读档还从白天慢慢过渡到夜晚玩家会以为自己读错了档。4. 刷怪系统从随机撒点到可控的刷怪导演4.1 刷怪的三要素位置、时机、种群乱撒点的做法是每隔几秒在玩家周围随机取一个坐标丢一个怪物。这种做法的问题在于玩家能明显感觉到怪是凭空冒出来的而且难度完全不受控。我把刷怪拆成三个独立问题在哪里刷、什么时候刷、刷什么。位置方面我的规则是要刷在玩家 8 到 16 格之间。8 格以内太近玩家会看到怪物突然出现16 格以外没意义玩家根本感知不到。这个区间还要满足三个额外条件必须是可站立的地面不是空中、不是水里、光照等级低于阈值白天地表不刷、不在玩家的视野锥内。最后一条是我被玩家反馈逼出来的早期版本怪物会正好出现在玩家正前方的黑暗里虽然规则上没问题但观感很像是游戏在故意整人。时机方面不做每帧判断用一个刷怪计时器加一个数量上限。计时器的间隔和上限都受危险等级影响危险等级由深度、夜晚、玩家战力三个因素算出来。4.2 用权重表和约束条件控制刷怪刷什么怪我用一张配置表来控制每一条是一个刷怪条目[Serializable] public class SpawnEntry { public ushort MonsterId; public float Weight 1f; public int MinDepth, MaxDepth; // 深度区间 public float MinLight, MaxLight; // 光照区间1 是全亮 public int StartTick, EndTick; // 允许出现的时段比如夜行怪 1140~300 public int MinDayCount; // 第几天之后才出现 public int PackSizeMin 1, PackSizeMax 1; }选择的时候先把所有条目按当前环境过滤一遍再按Weight做加权随机。这里有个小坑时段跨零点的时候StartTick EndTick直接写tick Start tick End永远为假夜行怪一只都刷不出来。我最后写了个专门的区间判断函数把跨界的情况单独处理这个小函数还救了后面几个类似的配置错误。加权随机的实现要注意别每次都重建列表。我早期每次刷怪都new ListSpawnEntry()然后遍历全表过滤怪物多的时候每帧几十次分配GC 一来画面就一顿。改成复用一个静态列表、只清空不重建之后卡顿直接消失。4.3 对象池和生成开销怪物的生成销毁是这类游戏性能的大头。如果你在刷怪的时候Instantiate怪物死亡的时候Destroy几百只怪的进出会产生大量内存分配和 GC。我的做法是给每种怪物一个池子public class MonsterPool { readonly Dictionaryushort, StackMonster _pools new(); public Monster Get(ushort id, Vector2 pos) { if (!_pools.TryGetValue(id, out var s)) { s new StackMonster(); _pools[id] s; } var m s.Count 0 ? s.Pop() : Create(id); m.gameObject.SetActive(true); m.OnSpawn(pos); return m; } public void Release(Monster m) { m.OnDespawn(); // 清理状态、停止协程、解除事件订阅 m.gameObject.SetActive(false); _pools[m.DefId].Push(m); } }这里面最关键的不是池子本身而是OnDespawn里那几件事。怪物从池子里取出来的时候是上辈子的状态如果血条、状态机、路径缓存、事件订阅没清干净你就能看到一只满血复活的怪顶着残血的血条或者怪物死了还在往旧的目标点走。我因为漏了取消事件订阅出现过怪物死后还在给 UI 发消息的情况池子复用之后那只会显示成幽灵怪。所以OnDespawn里我固定做四件事重置状态机到 Idle、清空目标引用、解除所有事件订阅、把血量和位置复位。池子的大小也要给上限。无限增长的池子等于没回收我按屏幕内可能出现的峰值数量的 1.5 倍设上限超过上限的实例直接销毁不再入池。4.4 难度曲线别用一个线性系数第一版我用difficulty dayCount * 0.1f这种线性公式结果第五天开始怪物强度就跟不上了玩家无聊第十天又突然变成噩梦。后来改成多因子用曲线而不是直线float DangerLevel(Player p, int dayCount, int depth, float lightLevel) { float d Mathf.Pow(dayCount, 0.6f) * 0.4f; // 天数前期涨得快后期放缓 float z depth / (float)MaxDepth; // 深度越深越危险 float night lightLevel 0.3f ? 0.35f : 0f; // 夜晚单独加一档 float gear p.CombatPower / ReferencePower; // 玩家战力防止过早无敌 return Mathf.Clamp01(d * 0.5f z * 0.5f night (gear - 1f) * 0.25f); }Mathf.Pow(dayCount, 0.6f)这个指数是调出来的。用 1.0 就是线性玩家到中期会觉得没挑战用 0.3 又太慢前十小时几乎没有变化。0.6 大概的感觉是前三天变化明显、之后逐渐稳定配合深度因素一起用玩家从地表往深处走的推进节奏刚好。装备战力那一项是防止玩家卡关用的——如果玩家一整套装备已经远超当前深度危险等级会小幅上调让怪物不至于被一路平推。但这一项的权重不能高我试过 0.5玩家换装备之后怪物立刻变强会有很明显的游戏在针对我的体感最后降到 0.25。5. 三个系统联调时会出什么事以及怎么查5.1 常见问题速查表三个系统一旦联调问题就不再是单个系统的 bug而是时序和状态的问题。我把遇到过的典型情况整理成一张表现象大概率原因排查方向夜晚怪物数量暴涨时段切换没做边沿触发每帧都在刷检查OnPhase是否只在切换时发一次读档之后立刻刷一堆怪读档跳过了大量 tick每个 tick 都触发了刷怪读档时用一个静默模式推进 tick不执行钩子火把插下之后刷怪没停光照数据是上一帧缓存的检查光照查询是不是实时计算或有没有脏标记白天在地表也能刷出夜行怪时段跨零点的区间判断写错单独写区间判断函数处理Start End怪物从池子里出来还带着血条OnDespawn没重置 UI 状态统一在池子回收时关闭所有子对象时间倍速之后掉帧OnTick里做了重活把重活按tick % N分档黄昏整片画面发灰RGB 两点线性插值改成多关键帧加平滑这张表里最容易被忽略的是第二条。玩家读档的时候如果存档里的时间是第 50 天深夜而游戏内的 tick 要从 0 推到 50 天中间会经过 50 次日出日落每次切换都触发一遍刷怪、作物生长、商店刷新直接卡死。我的处理是给时间模块加一个AdvanceSilent方法直接跳 tick 总数并只计算最终的时段不发布任何事件跳到目标之后发一次状态重建事件各模块自己去同步。5.2 数据流的方向要守住联调阶段最容易破坏的就是第 1 节里定的那条规矩。加到第四个玩法的时候你会忍不住在刷怪模块里直接读道具模块的背包内容来判断玩家有没有火把。一旦开了这个口子后面就收不住了。我的替代方案是让刷怪模块问一个专门的查询服务public interface IEnvironmentQuery { float LightAt(Vector2 pos); int CountNearbyItems(Vector2 pos, ushort itemId, float radius); bool IsInsidePlayerView(Vector2 pos); }刷怪模块只依赖这个接口具体是查背包、查地面、还是查光照由实现类决定。这样刷怪模块可以拿一个假的实现来跑测试比如让LightAt永远返回 1 来验证全亮环境下确实不会刷怪。做这一层抽象最开始看着有点多此一举等到要写单元测试和做性能优化的时候回报就出来了。5.3 做一个能自己说话的调试面板这套系统我是靠一个调试面板才理清楚的。按一个快捷键呼出上面实时显示当前 tick、时段、危险等级、刷怪计时器剩余、当前存活怪物数、玩家光照值。看着数字变化调参数比在代码里打日志快十倍。面板上我特别加了一个强制推进时间到某个时段的按钮还有一个显示刷怪点的开关——打开之后所有满足刷怪条件的格子会高亮。做刷怪范围调优的时候这个开关帮我发现了两个大问题一是某些地形比如一层薄薄的地面下方会被判定为可站立点怪物在极小的空间里生成然后卡住二是深水区上方一格也被判定为可站立怪物飘在水面上。这两个问题肉眼玩的时候很难发现画出来一眼就看到了。5.4 参数别散落在代码里最后说一个流程上的习惯。这三个系统的数值加起来有一百多个堆叠上限、掉落权重、昼夜时长、光照关键帧、刷怪权重、难度曲线系数如果散在代码里调平衡的时候要开五六个文件。我后来全部集中到一个配置对象里用一处加载改完能热重载[Serializable] public class GameBalanceConfig { public int TicksPerDay 1440; public float SecondsPerTick 1.5f; public LightKey[] LightTimeline; public float SpawnIntervalBase 12f; public float SpawnIntervalMin 3f; public Vector2 SpawnDistanceRange new Vector2(8f, 16f); public int MaxAliveMonsters 40; public ListSpawnEntry SpawnTable; }一百多个参数放在一张表里之后找问题的方式变了以前是我觉得夜里刷怪太多 → 翻代码找那个间隔 → 改成打开表把SpawnIntervalBase从 12 调到 18 → 存档读档看效果。整个过程三十秒。我自己在这三个系统上折腾最久的一个体会是联调阶段的绝大多数 bug都能追溯到两个模块同时写同一份数据。道具捡起来的时候道具模块在改背包数量掉落模块在改地面列表如果你的实现里两边都写了同一个计数器就会随机出现捡了但不加数量或者加了两次的现象。每次遇到这种玄学问题我的第一反应现在都是问自己这块数据谁在写第二次。再补一个实际调优里很管用的小技巧把刷怪距离区间做成和玩家光源半径联动。玩家手里的矿灯半径是 6 格刷怪最小距离就设成 6 加 2也就是刚好在光照边缘之外一点。这样玩家在洞里走的时候怪物总是从视野边缘的黑暗里冒出来观感上很自然也不会出现灯照着的地方突然出现一只怪这种出戏的情况。这个联动参数我调了大概二十次才定下来比单纯给一个固定数字靠谱得多。
返回列表