简介:这是一份基于Unity2017.3制作的《纪念碑谷》解谜游戏完整工程包,面向希望从实际项目学习Unity开发的初学者与进阶者,覆盖场景搭建、资源管理、脚本逻辑与跨平台构建等核心环节。压缩包共254个文件,约2.61MB,包含asset与meta场景资源、cs源代码、dll依赖库、csproj工程配置以及bin、info等缓存与描述文件,可据此还原项目结构并分析关卡组织、模型纹理、音频动画和交互实现。目前已有957人学习,适合用作课程设计、个人练习或技术复盘的参考案例。读者可通过工程文件理解场景配置、对象控制与关卡布局,借助源码梳理角色行为、碰撞检测、谜题触发、玩家输入响应和UI进度管理思路。也能学习资源导入、脚本挂载、依赖配置及性能调试的基本方法,对理解Unity项目组织与功能落地颇具帮助。
1. 一个等距错觉解谜工程,能给你什么
拿到这份 Unity3D Game Project 的第一反应,大概率是双击场景之后发现画面灰扑扑一片,然后怀疑是不是资源没下全。别急,先把 Play 按下去:等距视角、点击移动、可旋转的机关平台、靠投影重合拼出来的“走不通却能走”的路径,再加上一套用配置文件驱动的关卡数据——这是一个能完整跑通的单机解谜闭环,玩一遍十分钟左右,但里面把这类游戏最容易翻车的三个技术点都踩过了:正交相机下的坐标换算、拾取和遮挡的优先级、机关旋转之后的导航重建。
它适合两类人。一类是想做小体量解谜产品、又不想从零搭框架的独立开发者,工程里现成的相机、移动、机关、关卡配置拿去改改就能开工;另一类是想找一个“麻雀虽小五脏俱全”的 Unity 工程来读源码、学状态管理和关卡数据流的同学,代码量不算大,但层级结构是认真分过的。
下面按工程骨架、错觉实现、交互逻辑、常见故障、二次改造的顺序一层层拆,能直接抄的部分我都把代码贴出来了。
2. 工程骨架拆解:从 Assets 目录看一个关卡游戏的模块划分
2.1 目录分层:三个文件夹撑起整个循环
先看目录。这类工程如果做得规整,Assets 下面的分层基本是下面这个样子,你可以打开自己的工程对照着看,对不上的地方就是二次开发要动手改的地方。
Assets/ ├── Scenes/ │ ├── Boot.unity # 引导场景,只放 GameManager 和常驻 UI │ └── Level.unity # 关卡容器场景,实际关卡是运行时实例化的 ├── Scripts/ │ ├── Core/ # GameManager、EventBus、存档 │ ├── Gameplay/ # 角色、机关、锚点、路径 │ └── UI/ # 关卡选择、过关面板 ├── ScriptableObjects/ │ └── Levels/ # 每一关一个 LevelConfig 资产 ├── Prefabs/ │ ├── Player/ │ ├── Mechanisms/ │ └── LevelChunks/ ├── Materials/ ├── Textures/ └── ThirdParty/ # 补间库、音频插件一类真正决定工程能不能扩展的是Scenes只分两个这件事:引导场景常驻,关卡场景复用,所有关卡内容靠预制体挂载。这样做的好处是切关不用SceneManager.LoadScene走一次完整加载,坏处是所有跨关卡的状态都得自己管,DontDestroyOnLoad用不好就会多出一份重复的 GameManager。
Scripts三层分法也值得说一句。Core里不许引用Gameplay的任何类型,Gameplay之间通过事件总线通信,UI只订阅事件、不主动改游戏状态。
提示:如果你的工程里
Gameplay直接FindObjectOfType<UIManager>(),那就是耦合了,改起来会很痛,先把这个引用断掉再谈扩展。
2.2 关卡数据的流转:从 ScriptableObject 到运行时实例
整个数据流的起点是一个 ScriptableObject。它负责描述“这一关长什么样、出生点在哪、过关要几颗星”,运行时只读不写。
using UnityEngine; [CreateAssetMenu(menuName = "Puzzle/LevelConfig", fileName = "Level_01")] public class LevelConfig : ScriptableObject { public string levelId; // 唯一标识,存档和跳关都靠它 public GameObject levelPrefab; // 关卡预制体,运行时实例化 public Vector3 spawnPoint; // 角色出生点,相对关卡根节点 [Range(0, 8)] public int requiredGems; // 通关需要的星星数 public AudioClip bgm; // 每关单独的背景音乐 }字段不多,但每一个都有讲究。levelId用字符串而不是数组下标,是因为后面加关卡的时候如果插一关到中间,下标全乱,存档就对不上了。spawnPoint存的是相对坐标,这样关卡预制体整体挪位置的时候不用回来改配置。
这里有个血泪经验:ScriptableObject 是编辑器资产,运行时改了它的字段,编辑器里也会跟着变,而且关掉 Unity 之前一直保留。所以运行期状态千万别往LevelConfig里写,要写就Instantiate一份出来。
真正的调度在 GameManager 里:
using UnityEngine; public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [SerializeField] private LevelConfig[] levels; // Inspector 里按顺序挂 private int currentIndex = -1; public LevelConfig Current => (currentIndex >= 0 && currentIndex < levels.Length) ? levels[currentIndex] : null; private void Awake() { // 常驻单例:重复实例必须自杀,否则切回 Boot 场景会多一份 if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } public void LoadLevel(int index) { if (index < 0 || index >= levels.Length) return; // 先广播卸载,让订阅者清掉对旧关卡的引用,再实例化新的 EventBus.Publish(new LevelUnloadEvent()); DestroyCurrentLevelRoot(); currentIndex = index; var root = Instantiate(Current.levelPrefab); root.name = $"Level_{Current.levelId}"; EventBus.Publish(new LevelLoadedEvent(Current, root)); } private void DestroyCurrentLevelRoot() { var existing = GameObject.Find("Level_Root"); if (existing != null) Destroy(existing); } }这段代码里三个点值得单独说。第一,Awake里的自杀判断看起来是模板代码,但它解决的是“从关卡场景返回引导场景再来一次”这个非常常见的路径,少了这两行,第二个 GameManager 会把事件订阅翻倍,音效播两遍。第二,先广播LevelUnloadEvent再Destroy,是因为销毁顺序不保证,如果先销毁再广播,订阅者回调里访问到的是已被标记销毁的对象,会抛MissingReferenceException。第三,levels用数组而不是List<T>,是为了让 Inspector 里的顺序稳定,List在某些 Unity 版本里拖拽排序后会丢引用,这个坑我踩过不止一次。
对应的加载事件订阅在 UI 层:
private void OnEnable() => EventBus.Subscribe<LevelLoadedEvent>(OnLevelLoaded); private void OnDisable() => EventBus.Unsubscribe<LevelLoadedEvent>(OnLevelLoaded); private void OnLevelLoaded(LevelLoadedEvent e) { gemTotal.text = e.Config.requiredGems.ToString(); bgmPlayer.clip = e.Config.bgm; bgmPlayer.Play(); }OnEnable/OnDisable成对订阅是必须的,写成Start/OnDestroy也能跑,但对象被禁用再启用时会漏订阅,表现出来就是“过关面板第二次不刷新”——这种问题排查起来极其玄学。
3. 视觉错觉的核心实现:等距相机、投影重排与点击拾取
3.1 正交相机与等距视角的精确参数
这类游戏的画面基础是一个正交相机。透视相机做不出那种“远近一样大”的错觉感,因为透视会让远处的平台缩小,投影就不重合了,魔术当场穿帮。
using UnityEngine; [RequireComponent(typeof(Camera))] public class IsoCameraRig : MonoBehaviour { [SerializeField] private Vector3 focusPoint; // 相机注视的世界坐标 [SerializeField] private float orthoSize = 5f; // 视野半高,单位是世界单位 [SerializeField] private float pullBack = 12f; // 沿视线后撤距离,避免近裁剪面切掉模型 private Camera cam; private void Awake() => cam = GetComponent<Camera>(); private void LateUpdate() { cam.orthographic = true; cam.orthographicSize = orthoSize; // 真等距:绕 X 转 35.264°,绕 Y 转 45°,三个轴的投影长度才相等 transform.rotation = Quaternion.Euler(35.264f, 45f, 0f); transform.position = focusPoint - transform.forward * pullBack; } }35.264°不是随便填的角度,它是arctan(1/√2)的结果,只有这个角度下 X、Y、Z 三个轴在屏幕上的投影长度才一致,正方体看起来才是正六边形轮廓。填 30° 也能看,但方块会略显扁,跟美术给的等距贴图对不齐。
orthographicSize是“屏幕高度的一半对应多少世界单位”,注意它只管高度,宽度是它乘以宽高比算出来的。所以同样一个orthoSize=5,在 16:9 的屏幕上是 17.8 个单位宽,在 21:9 上就是 23 个单位宽。关卡设计的安全区必须按最窄的 16:9 来算,否则超宽屏玩家能看到关卡边界外面的空白。我一般的做法是在编辑器里挂个AspectRatioFitter预览,或者干脆把orthoSize按屏幕宽高比反向缩放:
// 保证横向可视宽度恒定,纵向随比例自适应 float targetWidth = 18f; cam.orthographicSize = targetWidth * 0.5f / cam.aspect;这样不管什么屏幕,横向看到的内容是一样多的,关卡布局就不会错位。
3.2 错觉连接:用锚点对替换真正的几何连接
这是整个工程里最值得看的部分。玩家看到角色从台阶 A 走到了台阶 B,两个台阶在几何上根本不相连,甚至高度差了好几米,但在相机这个固定角度下,它们的屏幕投影是重合的。
实现方式不是去算什么投影矩阵求交点,而是老老实实做锚点对:在两个视觉上重合的位置各放一个IllusionAnchor,互相指向对方,角色走到其中一个的判定范围内就被瞬移到另一个。
using UnityEngine; public class IllusionAnchor : MonoBehaviour { [SerializeField] private Transform target; // 视觉上与之重合的另一端 [SerializeField] private float arriveThreshold = 0.15f; [SerializeField] private bool bidirectional = true; // 是否允许反向通过 /// <summary>角色每帧调用,走到附近就切过去</summary> public bool TryTeleport(Transform player) { if (target == null) return false; if (Vector3.Distance(player.position, transform.position) > arriveThreshold) return false; // 关键:只移动角色,不动相机。 // 两端投影重合,玩家看不出跳变,这一步就是魔术的全部 player.SetPositionAndRotation(target.position, target.rotation); // 防止立刻被反向锚点抓回去 if (bidirectional) target.GetComponent<IllusionAnchor>()?.Cooldown(0.1f); return true; } public void Cooldown(float seconds) { /* 省略:内部计时器 */ } }三个参数的作用得说清楚。target是另一端,空引用是这类工程最常见的报错来源。arriveThreshold是判定半径,太小了角色移动步进会跨过去、太大了会在离得很远的地方就闪走,经验值 0.1 到 0.2 之间,取决于角色移动速度和帧率。bidirectional控制是否允许反向通过,有些关卡故意做成单向,走过去就回不来,这时候要关掉反向并且把target那一端的锚点组件禁掉。
注意:这类工程通常会把相机锁死,不提供旋转功能。原因很直接——错觉依赖固定视角,一旦让玩家转相机,几何上不相连的真相立刻暴露。
还有一个容易忽略的细节:瞬移之后角色的朝向要跟着目标点走。如果只改position不改rotation,角色会面朝错误方向继续走,看起来就是“人倒着滑步”。代码里用SetPositionAndRotation一次性写完,就是为了避免这种中间状态。
3.3 点击拾取的射线与层级遮罩
点击移动靠的是从相机发一条射线打到地面。但等距视角下,前后两层平台在屏幕上可能叠在一起,射线会先打到离相机近的那一层,即使玩家视觉上点是“下面”那一层的路。
using UnityEngine; public class ClickToMove : MonoBehaviour { [SerializeField] private LayerMask walkableMask; // 只包含可行走地面 [SerializeField] private LayerMask illusionMask; // 错觉锚点独立一层 [SerializeField] private Camera isoCamera; private void Update() { if (!Input.GetMouseButtonDown(0)) return; var ray = isoCamera.ScreenPointToRay(Input.mousePosition); // 先检错觉锚点,命中优先,避免被地面挡住 if (Physics.Raycast(ray, out var anchorHit, 100f, illusionMask)) { anchorHit.collider.GetComponent<IllusionAnchor>()?.TryTeleport(transform); return; } if (Physics.Raycast(ray, out var hit, 100f, walkableMask)) { EventBus.Publish(new MoveRequestEvent(hit.point)); } } }这里Physics.Raycast只用了前两个重载参数加LayerMask,没有用RaycastAll排序,原因是场景里可行走物体的层控制得比较干净,用一个walkableMask就能过滤掉机关、装饰、天空盒。如果你的关卡里地面是多层嵌套的,那Physics.RaycastAll之后按hit.distance排序再取第一个是更稳的做法,性能开销在这个体量下可以忽略。
另一个坑是点击 UI 的时候射线会穿透。EventSystem.current.IsPointerOverGameObject()这个判断必须加在最前面,否则玩家点“暂停”按钮会顺带让角色跑一段。
4. 交互与关卡逻辑:角色移动、机关旋转、事件解耦
4.1 角色移动:路径队列加插值,别用 Lerp
角色移动看起来简单,写不好就是“越走越快”或者“最后一步卡顿”。核心是一个路径队列,每次点击往队列里塞一个点,协程逐个消费。
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerMover : MonoBehaviour { [SerializeField] private float speed = 3f; [SerializeField] private float fallResetY = -5f; // 掉出关卡的重置高度 private readonly Queue<Vector3> path = new Queue<Vector3>(); private bool isMoving; public void Enqueue(Vector3 point) => path.Enqueue(point); private void Update() { if (!isMoving && path.Count > 0) StartCoroutine(FollowPath()); // 兜底:角色掉出关卡直接送回出生点,不给玩家卡死的机会 if (transform.position.y < fallResetY) EventBus.Publish(new PlayerFallEvent(transform.position)); } private IEnumerator FollowPath() { isMoving = true; while (path.Count > 0) { var next = path.Dequeue(); while ((transform.position - next).sqrMagnitude > 0.0001f) { // 用 MoveTowards 而不是 Lerp:速度恒定,不受帧率和距离影响 transform.position = Vector3.MoveTowards( transform.position, next, speed * Time.deltaTime); CheckIllusionAnchors(); // 锚点可能就在路径中间 yield return null; } transform.position = next; } isMoving = false; } private void CheckIllusionAnchors() { var anchors = FindObjectsOfType<IllusionAnchor>(); foreach (var a in anchors) if (a.TryTeleport(transform)) return; } }MoveTowards和Lerp的区别是这篇代码里最实用的一个点。Lerp(a, b, t)里如果t是固定的每帧增量,剩余距离越短每帧走的就越少,表现出来就是快到终点时明显减速;MoveTowards是“朝目标走固定距离”,速度恒定。做角色移动基本都该用后者。
sqrMagnitude > 0.0001f是避免开方的性能习惯,等价于距离大于 0.01。
CheckIllusionAnchors里用了FindObjectsOfType,这是为了教学清楚,实际项目里应该缓存成一个列表,在关卡加载时初始化一次,否则每帧全场景搜索,帧率会掉得很难看。这个优化我会放在第六章讲。
4.2 机关旋转:父子层级和状态广播
旋转机关是这类游戏的第二个核心交互。玩家点一下把手,平台转 90 度,上面的路跟着转,角色如果在平台上要跟着一起走。
using System.Collections; using UnityEngine; public class RotatableMechanism : MonoBehaviour { [SerializeField] private float stepAngle = 90f; [SerializeField] private float duration = 0.45f; public bool IsRotating { get; private set; } public IEnumerator Rotate(bool clockwise) { if (IsRotating) yield break; // 旋转中不接受新指令 IsRotating = true; var from = transform.rotation; var to = from * Quaternion.Euler(0f, clockwise ? stepAngle : -stepAngle, 0f); float t = 0f; while (t < 1f) { t += Time.deltaTime / duration; // SmoothStep 让启动和结束都有缓动,硬插值转起来像机械臂,很生硬 transform.rotation = Quaternion.Slerp(from, to, Mathf.SmoothStep(0f, 1f, t)); yield return null; } transform.rotation = to; IsRotating = false; // 旋转结束后必须广播:让角色重新做一次地面吸附 EventBus.Publish(new MechanismRotatedEvent(this)); } }IsRotating这个门闩是必须的。玩家手速快的时候会连点,如果不拦,两个协程同时改transform.rotation,结果就是转到一个奇怪的角度卡住。
旋转结束后广播事件这一条更重要。因为角色在这 0.45 秒里是悬在平台上的,Transform 跟着父物体转了,但角色的“地面高度”缓存还是旧的。旋转完成必须让角色重新做一次 Raycast 吸附,把 Y 坐标对齐到新的地面。少了这一步,角色会悬空一厘米左右,肉眼能看出来但很难定位原因——这种就是典型的玄学 bug。
平台上所有可站立的物体必须是这个机关的子物体。这是父子层级设计的直接好处:父物体转,子物体的世界坐标自动跟着算,不需要写任何同步逻辑。代价是旋转期间子物体上的 Collider 也在动,物理系统会跟着重新计算,如果平台上有刚体,旋转过程中的碰撞会抖。所以角色建议用CharacterController或者干脆不用物理、纯 Transform 移动,把物理参与度降到最低。
4.3 事件总线:把机关和角色解耦
工程里到处出现的EventBus其实就几十行,但它是整个架构的粘合剂。
using System; using System.Collections.Generic; public static class EventBus { private static readonly Dictionary<Type, Delegate> handlers = new(); public static void Subscribe<T>(Action<T> cb) where T : struct { var t = typeof(T); handlers[t] = handlers.TryGetValue(t, out var d) ? Delegate.Combine(d, cb) : cb; } public static void Unsubscribe<T>(Action<T> cb) where T : struct { var t = typeof(T); if (!handlers.TryGetValue(t, out var d)) return; var next = Delegate.Remove(d, cb); if (next == null) handlers.Remove(t); else handlers[t] = next; } public static void Publish<T>(T evt) where T : struct { if (handlers.TryGetValue(typeof(T), out var d)) (d as Action<T>)?.Invoke(evt); } }用struct约束事件类型,是为了避免 GC——每次Publish传一个 class 实例都会产生堆分配,解谜游戏虽然触发频率不高,但这是个好习惯。Delegate.Combine组合多个订阅者,Unsubscribe里判断空了就从字典移除,否则字典会越来越大,长跑之后内存缓慢增长。
角色订阅MechanismRotatedEvent重新吸附,UI 订阅LevelLoadedEvent刷新面板,音效订阅PlayerFallEvent播掉落的音效,各个系统之间零直接引用。这个设计的代价是调试时看不到调用链,出了问题得靠打日志,但换来的可扩展性在这个体量的项目里是划算的。
5. 常见问题排查:从粉屏到穿模的五个坑
5.1 打开工程画面全粉、材质发紫
现象:双击场景按 Play,所有模型显示为亮粉色,UI 正常。
原因:Shader 找不到。这个工程用的是 URP 管线的 Shader,但你的工程当前是 Built-in 管线,或者反过来。粉色是 Unity 的“Shader 缺失”占位色。
解决:先看Project Settings > Graphics里的Scriptable Render Pipeline Settings有没有挂 URP 资产。没挂就去Packages/manifest.json里确认有没有com.unity.render-pipelines.universal,装了的话在 Assets 下找UniversalRenderPipelineAsset,拖进 Graphics 设置。如果工程是 Built-in 的,把材质的 Shader 从Universal Render Pipeline/Lit换成Standard,用Edit > Render Pipeline > Universal Render Pipeline > Convert All Built-in Materials反向转也行。
5.2 点击地面角色纹丝不动
现象:Play 之后点地板,控制台没有报错,但角色不响应。
原因:三种可能。一是主相机上没挂PhysicsRaycaster,ScreenPointToRay打不中任何东西。二是地面的 Layer 不在walkableMask里。三是 UI 层的Image铺了全屏并且Raycast Target打开着,IsPointerOverGameObject()一直返回 true,点击被吞掉。
解决:给主相机加Physics Raycaster组件;检查地面物体的 Layer 并在 Inspector 里确认ClickToMove的Walkable Mask勾上了这一层;用Debug.Log(EventSystem.current.IsPointerOverGameObject())打一行日志,如果是 true,去 Canvas 里找那个全屏透明 Image,把Raycast Target关掉。
5.3 角色走到错觉连接处卡住不动
现象:走到某个位置角色停住,继续点击没反应,也不掉下去。
原因:锚点的 Collider 没勾Is Trigger,或者两个锚点的arriveThreshold设得太小,角色移动的步进跨过了判定范围。速度 3、帧率 60 的情况下每帧走 0.05 个单位,arriveThreshold如果设成 0.03 就会直接跳过去。
解决:锚点物体上的 Collider 勾上Is Trigger,厚度给大一点;arriveThreshold至少设成speed * Time.deltaTime * 3,也就是保证连续三帧的移动距离都能命中。最省事的做法是在PlayerMover里改成检测“上一帧位置到这一帧位置构成的线段”和锚点的距离,而不是只检测当前点,这样高速移动也不会漏。
5.4 机关旋转后角色穿模或掉出地面
现象:转到一半角色开始抖,转完直接掉下去了。
原因:角色用了Rigidbody+CharacterController混合,旋转期间物理引擎每帧重算碰撞,把角色从平台上挤出去了。另一种是旋转完成后没有重新做地面吸附,角色的 Y 坐标还停在旧地面上,视觉上悬空,一旦有重力就开始下落。
解决:角色统一用CharacterController或者纯 Transform 移动,去掉Rigidbody上的重力勾选,把位移完全交给代码。旋转完成必须广播MechanismRotatedEvent,角色收到后从头顶往下打一条短射线,取命中点的 Y 值重新对齐。
private void SnapToGround() { // 从角色上方 1 米往下打,忽略角色自身 if (Physics.Raycast(transform.position + Vector3.up, Vector3.down, out var hit, 3f, groundMask)) transform.position = hit.point; }5.5 打包之后中文全变方块
现象:编辑器里正常,出包运行中文全是方块或者问号。
原因:TextMeshPro 的字体图集只烘焙了 ASCII 字符集,中文根本没进图集。
解决:在Window > TextMeshPro > Font Asset Creator里选一个支持中文的字体,Character Set 选Characters from File或者Unicode Range填4E00-9FFF,生成新的 Font Asset 后替换所有 TMP 组件上的字体引用。注意中文字符集全量烘出来图集会很大,实际用的时候只烘焙项目里出现过的字,从一份 txt 里导入更省显存。
6. 二次开发:把单机关卡包改成能持续出关的流水线
改到第三关的时候你会发现,真正的瓶颈不在于写逻辑,而在于每加一关就要手动拖一堆引用,漏一个就报空引用。所以二次开发的第一件事不是加玩法,是先把关卡生产流程做顺。
| 改造点 | 现状 | 建议做法 | 收益 |
|---|---|---|---|
| 关卡注册 | 手动拖进 Inspector 数组 | 用AssetDatabase.FindAssets("t:LevelConfig")自动收集 | 加关卡零配置 |
| 锚点查找 | 每帧FindObjectsOfType | 关卡加载时缓存成 List | 帧率提升明显 |
| 关卡校验 | 靠运行时崩 | 编辑器一键扫描 | 提前发现问题 |
| 事件类型 | 分散在各处定义 | 统一放一个 Events 文件夹 | 好查好改 |
锚点缓存的改法很直接:在关卡加载事件里搜集一次,存进PlayerMover的字段。
private readonly List<IllusionAnchor> anchors = new(); private void OnLevelLoaded(LevelLoadedEvent e) { anchors.Clear(); anchors.AddRange(e.Root.GetComponentsInChildren<IllusionAnchor>(true)); }GetComponentsInChildren<IllusionAnchor>(true)第二个参数传true表示包含未激活的子物体,因为有些锚点在设计上就是禁用状态,靠机关旋转才激活,不传true会漏掉。
再加一个编辑器脚本来做关卡自检,这个脚本我每开一个新工程都会先写:
using UnityEditor; using UnityEngine; public static class LevelValidator { [MenuItem("Tools/Puzzle/Validate Levels")] public static void Validate() { var guids = AssetDatabase.FindAssets("t:LevelConfig"); int issues = 0; foreach (var guid in guids) { var path = AssetDatabase.GUIDToAssetPath(guid); var cfg = AssetDatabase.LoadAssetAtPath<LevelConfig>(path); if (cfg.levelPrefab == null) { Debug.LogError($"[关卡] 缺少预制体: {path}", cfg); issues++; } if (string.IsNullOrEmpty(cfg.levelId)) { Debug.LogWarning($"[关卡] levelId 为空: {path}", cfg); issues++; } } Debug.Log(issues == 0 ? "全部关卡配置通过" : $"发现 {issues} 处问题"); } }AssetDatabase.FindAssets("t:LevelConfig")这个搜索语法是按类型搜,不用管文件放在哪个目录,新加的关卡自动就会被扫到。Debug.LogError第二个参数传cfg对象,在 Console 里点一下就能直接定位到那个资产,比打字符串路径快得多。
玩法扩展上,最省力的方向是加一种新机关,比如“踩上去会升降的平台”。做法不是改RotatableMechanism,而是抽一个IMechanism接口出来,Rotate、Trigger、IsAnimating三个成员,然后让新机关实现它。角色那边只认接口,不认具体类型,这样加一种机关不用动角色代码。
难度曲线的话,LevelConfig里再加一个hintText字段和parMoves(参考步数),过关面板显示“你用了 N 步,参考 M 步”,成本很低但对体感提升很大。
验证方法建议按这个顺序走:先用编辑器校验脚本扫一遍所有关卡配置,再进游戏从第一关连着玩到最后一关,中间不重启,专门盯着切关时的内存和事件订阅数。EventBus里可以临时加一个CountHandlers()方法打印每种事件的订阅者数量,如果切了三关之后数字一路涨,那就是哪里漏了Unsubscribe。
关于EventBus漏订阅这件事我印象很深。之前有个工程切关十次之后音效叠了十层,排查了半天以为是 AudioSource 没停,最后发现是过关面板的OnDisable里忘了解订阅。从那以后我每次写完Subscribe都强制在同一屏往下翻两行把Unsubscribe补上,然后再继续写别的。希望这个习惯能帮你少走一段弯路。
本文还有配套的精品资源,点击获取