挖矿模拟器核心系统实战:道具控制、昼夜循环与刷怪机制
上个版本的挖矿模拟器地形生成、挖掘判定、背包槽位都跑通了我一度以为能玩这件事已经解决。丢给朋友试了十分钟他抬头说了一句能挖但没意思。那一刻我就明白真正决定一个挖矿游戏能不能留住人的不是那些看起来最硬核的算法而是道具、昼夜、刷怪这三样东西有没有被当成一个整体来做。这一篇就是接上一期把这三块从零到能跑通的全过程拆开讲顺带回答几个私信里被问得最多的问题道具控制放置那套鼠标玩法到底怎么实现的、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运行时通过一个字典Dictionarystring, 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.21.44 还是 10.20.21.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 Actionint OnNewDay; // 天变了 public event Actionint 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按当前时段重算刷怪配额商人 NPCOnNewDay刷新当日商品列表作物系统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 FindObjectOfTypeTimeOfDay(); 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 ListGameObject 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(ListSpawnEntry 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里面定义白天权重、夜间权重、深度门槛、最大同时存在数量。运行时的逻辑就退化成读当前时间 读玩家深度 → 查表 → 抽签非常好调。区域白天最大数量夜间最大数量夜间权重倍率深度门槛地表草地4102.50浅层矿洞6121.810深层矿洞881.040熔岩层551.070顺便说一个刷怪的性能细节别在怪物死亡时立刻销毁对象用对象池。我最初是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。幽灵方块闪烁看着像渲染问题其实是物理检测的边界问题场景颜色错乱看着像材质问题其实是着色范围搞错了。这类问题的共同点是现象在表层原因在数据层所以遇到诡异问题时先回头看看数据是怎么组织的往往比盯着渲染管线查更快。我自己做这块最大的体会是挖矿模拟器这类游戏的功能复杂度其实不高难的是所有系统都互相咬合——道具影响放置放置影响地形地形影响刷怪刷怪受昼夜控制昼夜又驱动存档和商店。任何一块单独拿出来都不难难的是让它们在同一个时钟下协调运转。所以每加一个新功能我都会先问一句它挂在哪个事件上想清楚这一句代码基本就顺了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →