ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unity挖矿模拟器:道具、昼夜、刷怪联动系统设计

Unity挖矿模拟器:道具、昼夜、刷怪联动系统设计 上一期把挖掘判定和地形分层拆完之后后台收到最多的追问不是挖掘本身而是道具、昼夜、刷怪这三块。很多人做挖矿模拟器时挖掘和地形能勉强写出来一到这三个系统就卡住背包越写越乱、天数一换光照就翻车、怪物刷出来内存蹭蹭涨。问题往往不在某一行代码写错而在于一开始没想清楚这三块其实是互相咬合的。这篇就顺着代码往下讲把道具、昼夜、刷怪从数据结构到联动逻辑完整走一遍能直接拿去改自己的工程。看完你至少能得到三样东西一套数据驱动的物品与背包写法、一个稳定不抽风的昼夜时间轴、一个用对象池撑住的刷怪框架以及它们之间怎么通信才不会互相拖累。适合已经能写出基础挖掘循环、想把项目往完整玩法推进的人。1. 三个系统为什么必须放在一起设计1.1 它们共享同一条时间与状态主线先讲一个很多人踩过的坑。刚上手时容易把道具、昼夜、刷怪当成三个独立模块各写各的更新函数各自在Update里读时间、读玩家状态。刚开始能跑加到二三十个实体后就开始出现诡异现象天明明亮了怪物还挂在夜晚的强化状态里背包里道具消耗了工具耐久却没同步。根子在于三个系统都在偷偷维护自己的一份“当前状态”而没有人负责统一裁决。正确的做法是让它们共享一条主线。这条主线只有两个东西一个是全局时间游戏内的小时、天一个是游戏事件总线。道具系统关心“现在是几点”来决定某些物品能不能用昼夜系统负责推进时间和广播变化刷怪系统订阅这些变化来决定刷不刷、刷多强。三者谁都不要直接去改对方的数据全部通过事件走。用生活类比这就像一个小区。昼夜系统是物业负责报时和开关公共照明道具系统是住户听到“天黑了”就自己把窗帘拉上刷怪系统是安保听到“天黑了”就提高巡逻频率。住户和安保都不去动物业的钟只是听通知行事。这样的结构加再多功能也不会乱。1.2 数据与逻辑分离是底层原则第二个共性原则是数据与逻辑分离。道具的属性、怪物的数值、昼夜的时长这些都应该躺在配置里而不是硬编码进逻辑代码。为什么强调这一点因为挖矿模拟器这类游戏后期一定会频繁调数值平衡——某种矿石多值钱、夜晚怪物强多少、一天多长。如果数值写死在if里调一次就要重新编译、重新测试成本极高。把数据抽出来之后逻辑代码只负责“怎么用这些数据”配置负责“数据是多少”。这样你把物品表交给一个完全不懂代码的人他也能改价格和描述。实践中最常见的两种载体是引擎自带的资源资产Unity 的 ScriptableObject、外部表格文件JSON、CSV。小项目直接上 JSON 也够用热更新友好中大型项目用引擎资产能省心不少编辑器里就能可视化编辑。选哪种取决于你的团队规模和迭代节奏没有绝对对错。提示不要在逻辑代码里写gold 10这种魔法数字哪怕只有一处。把它改成读配置你会在三个月后感谢现在的自己。2. 道具系统数据驱动的物品与背包写2.1 物品定义一份配置说清楚所有东西物品定义我倾向于用一个独立的类或结构体承载只放“静态属性”。所谓静态属性就是这东西的固有特征创建后基本不变唯一 ID、显示名、图标、类型、最大堆叠数、基础售价、描述。运行时会变的状态——比如当前数量、当前耐久——绝对不能塞到这里否则两个同类型的矿石会因为共享引用互相覆盖。下面是 C# 里一个典型的物品定义其他语言照搬思路即可public enum ItemType { Ore, Tool, Consumable, Material } [CreateAssetMenu(fileName NewItem, menuName Mining/Item)] public class ItemData : ScriptableObject { public string id; // 全局唯一存档靠它 public string displayName; public Sprite icon; public ItemType type; public int maxStack 99; public float basePrice; [TextArea] public string description; }这里id是重中之重。存档、任务、合成配方全部通过id关联而不是通过资源引用。你可能会觉得用引用更方便但一旦项目大起来资源的重命名和移动会让引用悄悄断掉而字符串id只要不改就永远稳。用id的代价是要自己保证唯一性写个编辑器校验脚本就能解决批量遍历所有物品发现重复id直接报错。至于为什么把maxStack放在物品定义里而不是背包里是因为堆叠上限本质上是物品的属性矿石能堆 99工具只能占 1 格。放在物品侧背包只需要读它逻辑自然就干净了。2.2 背包的堆叠、增删与合并背包的核心难点是堆叠。堆叠真正难的地方在“合并”和“拆分”的边界处理。一个合格的背包至少要支持四个操作添加可能分散到多个格子、移除可能跨格子、交换两格、拆分一堆。为了不把这些逻辑写成一团乱麻先定义一个纯粹的格子结构[System.Serializable] public struct ItemStack { public ItemData data; public int count; public bool IsEmpty data null || count 0; public bool IsFull data ! null count data.maxStack; }用结构体而不是类是为了避免“我以为我改了背包其实改的是副本”这种隐蔽 bug。每个格子就是一个ItemStack背包就是ItemStack[]容量固定格子为空就是count 0。添加物品的逻辑顺序很关键先填已有的同类格子再开新格子。很多人写反了导致同样是铁矿石前几格有空位却偏偏占了新格背包很快就满。核心伪代码public int AddItem(ItemData item, int amount) { // 第一轮填已有同类格子 for (int i 0; i slots.Length amount 0; i) { if (slots[i].data ! item || slots[i].IsFull) continue; int space item.maxStack - slots[i].count; int putIn Mathf.Min(space, amount); slots[i].count putIn; amount - putIn; } // 第二轮开新格子 for (int i 0; i slots.Length amount 0; i) { if (!slots[i].IsEmpty) continue; int putIn Mathf.Min(item.maxStack, amount); slots[i] new ItemStack { data item, count putIn }; amount - putIn; } return amount; // 返回没放进去的调用方负责掉地上 }第二个参数amount和返回值的语义要统一返回值代表“溢出数量”。这样上层逻辑才知道该不该在地上生成一个掉落物。如果返回值永远被忽略就会出现背包满了物品凭空消失的问题玩家会认为这是 bug。注意删除物品时同样要“从后往前”或“从前往后”约定清楚最好只提供RemoveItem(id, amount)一个入口别在多处手改count。手改的地方越多负数和幻影道具的 bug 就越多。2.3 道具使用效果的分发别写巨型 switch道具用起来之后马上会遇到“不同道具用起来不一样”的问题。工具增加挖掘等级、消耗品回血、食物回饱食度。新手最容易写一个巨大的switch (item.type)每加一种效果就往上再加一个case。这种写法在十种以内还能忍超过二十种就是灾难改一处要回归全部。更稳的做法是策略模式让每种效果实现同一个接口物品配置里挂对应的效果对象使用时统一分发。public interface IItemEffect { void Apply(PlayerContext ctx, ItemData item); } public class HealEffect : IItemEffect { public int amount; public void Apply(PlayerContext ctx, ItemData item) { ctx.Health Mathf.Min(ctx.Health amount, ctx.MaxHealth); } }这样新增效果只需要新建一个类、在配置里挂上完全不碰原有代码。这就是代码解耦的价值——不是追求时髦而是让“加需求”这件事不引发连锁风险。在你动手写第一个道具效果之前就把这个接口定下来后面会省下大量重构时间。工具类物品还要额外处理耐久。耐久属于运行时状态得跟背包格子绑定。我的做法是给ItemStack加一个durability字段只有工具有意义。挖掘一次扣一点扣到零销毁并播放损坏提示。注意耐久不要放进ItemData那个是共享资源改了会污染所有同类物品。3. 昼夜循环一根时间轴撬动整个玩法节奏3.1 用归一化时间推进而不是记时分秒昼夜系统最容易写乱的地方是时间表示。有人用hour、minute分开记有人直接存float time结果加减和取模到处出错。我强烈建议用归一化时间一个[0, 1)的浮点数0代表午夜0.5代表正午。优点非常直接——一天的长度就是1转换成分和小时只是一次乘法跨天只需要取小数部分。public class DayNightSystem : MonoBehaviour { [Range(1f, 60f)] public float minutesPerDay 20f; // 现实里20分钟过一天 [Range(0f, 1f)] public float timeOfDay 0.3f; // 当前归一化时间 public int currentDay 1; public float timeScale 1f; public event System.Actionfloat OnTimeChanged; public event System.Actionint OnDayChanged; void Update() { float prev timeOfDay; float delta (Time.deltaTime * timeScale) / (minutesPerDay * 60f); timeOfDay delta; if (timeOfDay 1f) { timeOfDay - 1f; currentDay; OnDayChanged?.Invoke(currentDay); } OnTimeChanged?.Invoke(timeOfDay); } }minutesPerDay用现实分钟表示玩家更容易理解“20 分钟一天”。把它反算出每秒推进多少归一化时间逻辑就全在一条线上不会出现“分钟加到 60 忘记进位”这种低级错误。为什么timeScale要单独留出来因为睡觉、跳过夜晚、剧情暂停这些需求都要靠它。想跳过夜晚把timeScale拉大等timeOfDay跨到早上再恢复。想暂停时间做动画把timeScale设成 0 即可不需要在Update里到处判断“现在该不该走时间”。3.2 光照表现用曲线插值而不是硬切画面上最容易翻车的是光照。新手常见做法是白天开一盏强光、夜晚切到另一盏弱光用if硬切。结果黄昏那一瞬间画面“啪”地跳变非常出戏。解决办法是用曲线插值先定义关键时间点的光照颜色和强度中间用插值过渡。public Gradient skyColor; // 用渐变描述全天色温 public AnimationCurve lightIntensity; // 0~1 全天强度曲线 void ApplyVisual(float t) { skyLight.color skyColor.Evaluate(t); skyLight.intensity lightIntensity.Evaluate(t); }用Gradient和AnimationCurve的好处是美术可以纯在编辑器里拖曲线不用改一行代码就能调出漂亮的黄昏和黎明星空。0.0设深蓝0.25设橙红0.5设明亮白0.75再回到橙红1.0回到深蓝一条 V 形曲线就把日出日落说明白了。实操心得Gradient的采样是有插值模式的默认是线性看起来会有点“灰”。想让黄昏更暖可以把关键帧的插值模式调成平滑过渡会自然很多。这个细节很少有人提但画面质感差距不小。如果项目用 2D 光照还要注意光源数量对性能的影响。每个参与光照计算的灯都是一个 draw call几十个火把能把帧率压下去。能烘焙的静态光尽量烘焙动态的只留玩家周围的几个。3.3 用事件把昼夜变化广播出去昼夜系统本身只干两件事推进时间、广播状态。它绝不能反过来去调用背包或怪物。所有关心时间的系统都是订阅方。void OnEnable() dayNight.OnTimeChanged HandleTime; void OnDisable() dayNight.OnTimeChanged - HandleTime; void HandleTime(float t) { bool isNight t 0.25f || t 0.75f; foreach (var spawner in spawners) spawner.nightBoost isNight ? 1.5f : 1f; }这里订阅和退订一定要成对出现。OnEnable订阅、OnDisable退订是 C# 里最稳的写法。只订阅不退订对象销毁后事件还持有引用会引发空引用和内存泄漏。很多人项目跑久了越来越卡根源就是这么几个没退订的事件。夜晚除了影响刷怪还能影响玩法节奏某些矿石在夜晚发光可采集、夜晚视野变小逼迫玩家点灯、某些怪物白天睡觉晚上出来。这些规则统统通过OnTimeChanged派生昼夜系统本身保持“无知”扩展性最好。4. 刷怪系统刷怪点、对象池与行为状态机4.1 刷怪点设计条件、上限与冷却刷怪不是简单地每隔几秒Instantiate一个怪物那样怪物会无脑堆满地图。一个可控的刷怪点至少要管三件事触发条件、数量上限、刷新冷却。触发条件包括玩家是否在附近、当前是不是夜晚、玩家是否在安全区。数量上限是防止刷怪点无限累积。冷却则让刷怪有节奏而不是瞬间铺满。public class Spawner : MonoBehaviour { public GameObject monsterPrefab; public int maxAlive 3; public float spawnInterval 5f; public float activeRadius 15f; public float nightBoost 1f; // 昼夜系统写入 float timer; readonly System.Collections.Generic.ListGameObject alive new(); void Update() { alive.RemoveAll(m m null); bool playerNear Vector3.Distance( Player.Instance.transform.position, transform.position) activeRadius; if (!playerNear || alive.Count maxAlive) return; timer - Time.deltaTime * nightBoost; if (timer 0f) { timer spawnInterval / nightBoost; SpawnOne(); } } }注意maxAlive只在玩家靠近时生效玩家走远后怪物可以延迟销毁。玩家不在附近还持续刷怪是纯浪费也不会有玩家察觉。nightBoost是一个很干净的联动点昼夜系统只管往这里写一个倍率刷怪点自己决定怎么用谁也不依赖谁。alive.RemoveAll(m m null)这一句很关键。怪物被玩家打死或被销毁后列表里的引用会变成“幽灵引用”不清掉的话maxAlive永远算满新怪就刷不出来——这是刷怪系统最常见的“昨晚还好的今天不刷了”的原因。4.2 对象池别让 Instantiate 拖垮帧率怪物数量一多频繁Instantiate和Destroy就是性能杀手。因为每次创建都涉及内存分配销毁又留给 GC 回收短时间内大量分配会引发卡顿。解决办法是对象池预先生成一批对象、循环复用用“取”和“还”代替“创建”和“销毁”。public class ObjectPoolT where T : Component { readonly System.Collections.Generic.QueueT pool new(); readonly T prefab; readonly Transform parent; public ObjectPool(T prefab, int preload, Transform parent) { this.prefab prefab; this.parent parent; for (int i 0; i preload; i) { var obj Object.Instantiate(prefab, parent); obj.gameObject.SetActive(false); pool.Enqueue(obj); } } public T Get() { T obj pool.Count 0 ? pool.Dequeue() : Object.Instantiate(prefab, parent); obj.gameObject.SetActive(true); return obj; } public void Release(T obj) { obj.gameObject.SetActive(false); pool.Enqueue(obj); } }池子里没有空闲对象时的兜底也不该省。直接Dequeue空队列会抛异常所以Get里要判断pool.Count 0没有就临时补一个。这样即使预加载数量估少了游戏也不会崩。注意对象从池里取出来后一定要重置它的状态——血量、速度、冷却、动画、事件订阅一个都不能漏。我见过最迷惑的 bug 就是“复活的怪物一出来就残血”原因就是没重置血量。给怪物写一个OnSpawn()方法把重置逻辑集中进去比在Get里逐条写要清晰得多。4.3 怪物行为一个够用的状态机挖矿模拟器里的怪物不需要复杂的 AI一个简单的状态机就能覆盖绝大多数需求巡逻、追击、攻击、返回。用枚举加switch实现就够不用急着上行为树。enum State { Patrol, Chase, Attack, Return } State state State.Patrol; void Update() { switch (state) { case State.Patrol: TickPatrol(); break; case State.Chase: TickChase(); break; case State.Attack: TickAttack(); break; case State.Return: TickReturn(); break; } }每个Tick方法只干自己那点事并且负责判断何时切换状态。比如TickPatrol里检测到玩家进入视野就切ChaseTickChase里距离足够近就切AttackTickAttack里玩家跑远就切Return。这样每个状态逻辑独立改一个不影响其他。状态切换时记得处理“进出”动作进入Chase时要播放警觉音效进入Return时要清除对玩家的仇恨。如果只在状态内部逐帧处理而没有进出钩子你会写出一堆重复的初始化和清理代码。给状态机加一对OnEnter/OnExit是值得的。怪物和昼夜的联动也在这里发生。夜晚怪物视野更大、移速更快本质上是读取timeOfDay或订阅的一个“夜晚倍率”。注意不要在每一帧都去查全局时间而是在事件触发时缓存下来减少跨系统调用。5. 三系统联动的常见坑与排查技巧5.1 典型问题速查表把前面几个系统接起来之后问题多半出在“接口处”。下面这张表是我自己踩出来和帮别人排查出来的一些高频问题按现象、原因、解决整理方便对照。现象常见原因解决思路天黑后怪物没变强刷怪系统没订阅时间事件或订阅了但退订导致失效检查事件订阅是否成对确认事件确实被触发背包满了物品消失添加物品返回值被忽略上层必须处理溢出生成掉落或给出提示怪物越来越多、帧率下降没有对象池或离开区域的怪没回收上对象池给怪物加离开销毁逻辑天数切换后光照跳变用if硬切光照而非插值用曲线或渐变对所有时刻插值怪物一复活就残血对象池取用时没重置状态集中写OnSpawn重置所有运行态时间越走越快timeScale被叠加设置、没复位所有临时加速都记录并在结束复位背包道具耐久互相污染耐久写进了共享的物品定义耐久放进格子运行时状态切场景后事件报空引用事件订阅未随对象生命周期管理OnEnable订阅、OnDisable退订这张表里最值得单独说的是“时间越走越快”。跳过夜晚时放大timeScale如果忘记在早晨恢复下一天的推进速度就会莫名变快越往后越离谱。凡是会临时修改全局状态的操作都用“修改-使用-恢复”三段式写或者干脆封装成一个带回调的协程中间怎么变都行结束时一定复位。5.2 性能与调试的几点心得第一别迷信“优化一次到位”。先把系统跑通、功能齐全再用 Profiler 找真正的瓶颈。我见过不少人一开始就纠结对象池的容量、光照的批处理结果拖了进度最后发现真正的卡顿来自一堆没退订的事件。工具在哪里就先测哪里。第二给每个系统留一个调试开关。昼夜系统可以用快捷键直接把timeOfDay拽到任意时刻刷怪点可以在场景里画出有效半径的 gizmo。这些小工具会在你通宵排查问题时救你一命。特别是昼夜如果每次都要等现实二十分钟看一次效果调试效率会低到让人崩溃。第三存档和这三块关系密切。物品靠id存、时间存timeOfDay和currentDay、怪物一般不入存档只存刷怪点状态。存之前务必做一次版本字段比如saveVersion将来改结构时能优雅地做兼容。很多人第一次改存档格式就直接让老玩家的进度全废这种坑一次就够记一辈子。第四代码规范在这个阶段开始体现价值。命名不统一、同一个功能两份实现、魔法数字满天飞到三个系统联动时就会集中爆发。给物品、怪物、事件各定一套命名前缀定期用静态检查工具扫一遍能提前发现不少潜在问题。我自己用过几种代码诊断插件最大的价值不是风格而是把那些“可能为空”和“未使用变量”在提交前拦下来。最后再分享一个我在实现里反复用的小技巧让三个系统都只依赖抽象事件不依赖具体对象。道具系统不知道昼夜的具体实现刷怪系统不知道是谁在广播时间它们只认事件。这样的好处是将来你想加一个“天气系统”只要它也广播事件另外三个系统几乎零改动就能接上。扩展新玩法时你要动的是新增的模块而不是去翻旧代码。这套结构我用了很久从最初的小 demo 一直到后来稍大一点的项目都没需要推翻重写最多是在事件列表里加几项。写到这里道具、昼夜、刷怪这条线就算走完了接下来想把天气、任务或者存档接进来思路都是同一套先定事件再定数据最后才是逻辑。
返回列表