简介:Unity纪念碑谷.rar 面向希望研究解谜游戏实现与Unity工程组织的初学者和进阶开发者,提供可参考的项目结构、资源组织与源代码案例。压缩包共254个文件,约2.61MB,其中场景与资源配置、导入元信息、C#脚本、依赖库、工程文件构成主体,另含少量运行缓存、日志和说明文档,便于从目录层面观察关卡搭建、素材导入与代码分层。已有957人学习,读者可据此梳理模型、纹理、音频等资源的接入方式,理解角色控制、碰撞检测、谜题交互与界面流程的代码落点,并借鉴脚本挂载、项目调试、性能优化思路及旧版本项目的兼容处理。它适合用作课程设计、个人练习与项目复盘时的对照材料,也能帮助读者快速定位Unity解谜类项目的关键模块,查漏补缺并形成自己的工程组织方法。
1. 从“两个台阶明明对不上”说起:这份 Unity3D 等距解谜工程能跑出什么
第一次打开这类工程,多数人的动作是双击场景、点 Play,然后看着小人自己走过去,却说不清它是怎么判断“这一步能走”的。这份 Unity3D Game Project 就是这样一个等距视错觉解谜工程:正交相机以固定角度俯视,用 45° 网格搭出楼梯、转盘、滑块和传送门,玩家点一下地面,角色沿节点图走过去,扳动把手让整块场景转 90°,原本隔着一段空隙的两段路在屏幕上“接上”了,路就通了。它不大,但把解谜游戏最核心的四件事——相机、图寻路、机关状态、关卡数据——都跑通了。适合三类人:想搞懂等距解谜玩法骨架的、手里有美术资源想套一套完整流程的、以及要给学生做 Unity3D 课堂演示的。下面按“打开能跑、跑明白、改得动”的顺序拆。
2. 工程骨架:Assets 目录、渲染管线和启动链路
拿到压缩包解压后先别急着点 Play,花十分钟把目录和启动链路看一遍,后面改代码会省掉大量猜测。这个工程的结构是典型的“脚本分层 + 预制体驱动关卡”,场景文件很少,绝大部分内容都在 Prefabs 里,这意味着新增关卡基本不用碰 Scene。
2.1 目录结构与各层职责
展开 Assets 大致是这么一层关系,我按二次开发时的改动频率从高到低排:
| 目录 | 作用 | 改动频率 |
|---|---|---|
Scripts/Core | GameManager、LevelController、AudioManager,负责启动与关卡生命周期 | 低 |
Scripts/Camera | CameraController、IsoProjection,正交相机与屏幕投影换算 | 中 |
Scripts/Pathfinding | PathNode、GraphBuilder、Pathfinder,节点图与 A* | 中 |
Scripts/Interaction | IInteractable 接口及各机关实现 | 高 |
Scripts/Actor | PlayerController,点击响应、行走协程、动画桥接 | 中 |
Scripts/Data | LevelData、LevelLibrary 等 ScriptableObject | 高 |
Prefabs/Levels | 每个关卡一个预制体,节点、机关、出生点都在里面 | 高 |
Prefabs/Props | 把手、开关、传送门、地砖等可复用件 | 高 |
Art/Audio | 低多边形模型、贴图、材质、音效 | 高 |
Scenes | Boot.unity常驻管理器,Game.unity载入关卡 | 低 |
关键点是:关卡不是“一个 Scene 一关”,而是“一个 Prefab 一关”。Game.unity里只有一个空壳和几个管理器,真正的几何体在运行时被 Instantiate 到levelRoot下面。好处是切关不用 LoadScene,坏处是新手容易在编辑器里改错对象——你改的是 Prefab 实例还是源 Prefab,差别很大。
提示:在 Hierarchy 里选中关卡根节点,看 Inspector 顶部是 “Prefab” 还是 “Instance”,不确定就先 Open Prefab 再改,避免改完一运行就被覆盖。
2.2 渲染管线与包依赖:决定你打开会不会一片粉
这份工程用的是低多边形 + 纯色材质,视觉上不挑管线,但代码里对管线的隐性依赖是材质引用的 Shader。如果原作者用 Built-in 的 Standard,你在 URP 工程里打开就是一片品红;反过来 URP/Lit 的材质放进 Built-in 工程也一样。判断方式不用猜,写个编辑器小工具把工程里所有材质用的 Shader 盘一遍,比一个个点开快得多:
// 放在 Assets/Editor/ShaderAudit.cs,菜单里点一下就能看到全工程的 Shader 分布 using System.Collections.Generic; using UnityEditor; using UnityEngine; public static class ShaderAudit { [MenuItem("Tools/Audit/List Material Shaders")] public static void ListShaders() { var counter = new Dictionary<string, int>(); // 只扫 Assets,跳过 Packages,避免把内置资源也算进来 foreach (var guid in AssetDatabase.FindAssets("t:Material", new[] { "Assets" })) { var path = AssetDatabase.GUIDToAssetPath(guid); var mat = AssetDatabase.LoadAssetAtPath<Material>(path); if (mat == null || mat.shader == null) continue; counter.TryGetValue(mat.shader.name, out int c); counter[mat.shader.name] = c + 1; } foreach (var kv in counter) Debug.Log($"{kv.Key} -> {kv.Value} 个"); } }跑完看 Console:如果全是Standard,说明是 Built-in 工程;出现Universal Render Pipeline/Lit就是 URP。数量最多的那个就是主 Shader,切管线时按它批量替换即可。除了 Shader,还有两个包要确认:TextMeshPro(不导入 Essentials 会满屏报 “TMP Settings not found”)和 Input System(工程若用新输入系统,Player Settings 里的 Active Input Handling 必须切到 Both 或新系统,否则Input.GetMouseButtonDown直接抛异常)。
2.3 启动链路:从 GameManager 到 LevelController 的三步
整个启动过程只有三步,但顺序不能乱:加载关卡预制体 → 建图并注册机关 → 相机对位。写成代码大概是这样:
public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [SerializeField] private LevelLibrary levelLibrary; // 关卡清单,ScriptableObject [SerializeField] private Transform levelRoot; // 关卡实例的挂载点 public LevelController CurrentLevel { get; private set; } private void Awake() { // 单例常驻,切关时不销毁管理器 if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } private void Start() => LoadLevel(0); public void LoadLevel(int index) { if (CurrentLevel != null) Destroy(CurrentLevel.gameObject); var prefab = levelLibrary.GetLevelPrefab(index); CurrentLevel = Instantiate(prefab, levelRoot); // 顺序很重要:先建图,相机才知道要看向哪个锚点 CurrentLevel.Init(index); CameraController.Instance.FocusOn(CurrentLevel.CameraAnchor); } }levelLibrary是 ScriptableObject,里面是一个LevelData数组,索引即关卡序号;levelRoot一般挂在原点,保证关卡局部坐标和世界坐标一致,省掉一堆换算。Init(index)里做三件事:收集子节点下的所有PathNode建成邻接表、把所有IInteractable注册进列表、把角色放到出生节点上。把这三件事拆开写在LevelController里,是为了让“重建图”这个高频操作可以单独调用——每次机关转动后都要重来一遍。
3. 等距相机与视错觉:正交投影下让两段路“接上”的三个关键
这一章是整个工程的技术核心。视错觉不是靠模型拼出来的,是靠“正交投影 + 固定角度 + 屏幕空间判定”三件事凑出来的,任何一环偏一点,玩家就会发现两条路接不上,谜题直接失效。
3.1 正交相机与等距角:35.264° 和 45° 不是随便填的
标准等距投影的相机旋转是 X = 35.264°(arctan(1/√2))、Y = 45°,投影方式必须切到 Orthographic。透视相机有近大远小,远处的台阶会被压短,屏幕上永远对不齐;正交相机把深度信息彻底丢掉,两个世界坐标相差很远的点只要屏幕坐标重合,看起来就是同一个点——这正是错觉成立的前提。
[RequireComponent(typeof(Camera))] public class CameraController : MonoBehaviour { public static CameraController Instance { get; private set; } [SerializeField] private float orthoSize = 6f; // 视野大小,越小越“贴脸” [SerializeField] private Vector3 baseEuler = new Vector3(35.264f, 45f, 0f); // 等距标准角 [SerializeField] private float rotateStep = 90f; // 每次转动的角度 [SerializeField] private float rotateDuration = 0.35f; public Camera MainCamera { get; private set; } private float targetYaw; private void Awake() { Instance = this; MainCamera = GetComponent<Camera>(); MainCamera.orthographic = true; // 关键:正交,不能是透视 MainCamera.orthographicSize = orthoSize; transform.rotation = Quaternion.Euler(baseEuler); targetYaw = baseEuler.y; } public void FocusOn(Vector3 anchor) { // 只移动位置,不改角度;角度一改,错觉就散了 transform.position = anchor + transform.forward * -20f; } public void RotateBy(float delta) => StartCoroutine(RotateRoutine(targetYaw + delta)); private System.Collections.IEnumerator RotateRoutine(float yaw) { targetYaw = yaw; Quaternion from = transform.rotation; Quaternion to = Quaternion.Euler(baseEuler.x, targetYaw, 0f); float t = 0f; while (t < 1f) { t += Time.deltaTime / rotateDuration; transform.rotation = Quaternion.Slerp(from, to, Mathf.SmoothStep(0f, 1f, t)); yield return null; } transform.rotation = to; } }orthoSize直接决定画面能装下多少格,改小会裁掉边角、改大会让模型显小,通常按关卡最长边的一半再留 20% 余量。baseEuler.x千万别手改成 30° 或 45°,那两个角度是“伪等距”,乍看像,实际投影比例不对,靠对齐做出来的错觉会整体偏移几个像素。
3.2 视错觉的本质:屏幕空间重合,图结构决定能不能走
最反直觉的一点:两条路在屏幕上接上了,不代表它们在物理上连着。判定连通性有两种常见做法,这个工程用的是第一种,我在别的项目里也见过第二种。
第一种是动态判定——每次相机或机关角度变化后,遍历所有节点对,把世界坐标投到屏幕上算像素距离,小于容差就认为“看起来连着”,加一条临时边。
public static class IsoProjection { // 世界坐标 -> 屏幕像素坐标 public static Vector2 ToScreen(Camera cam, Vector3 worldPos) { Vector3 sp = cam.WorldToScreenPoint(worldPos); return new Vector2(sp.x, sp.y); } // 两个节点在屏幕上是否“接上” public static bool LooksConnected(Camera cam, Vector3 a, Vector3 b, float pixelTolerance = 24f) { return Vector2.Distance(ToScreen(cam, a), ToScreen(cam, b)) <= pixelTolerance; } }pixelTolerance是这套方案唯一需要手调的参数,单位是屏幕像素而不是世界单位,所以在不同分辨率下表现会变——这也是它最容易被吐槽的地方。24 像素在 1080p 下差不多是半个台阶宽,太小会出现“看着接上但走不过去”,太大会出现“隔着老远也能跨过去”。保险的做法是按屏幕高度归一化:tolerance = screenHeight * 0.022f。
第二种是预烘焙——每个节点在四个相机朝向(0/90/180/270)下各存一份邻接表,运行时只查表不做判定。稳定、无玄学、性能好,代价是关卡编辑时得手动维护四份数据。工程里RebuildGraph()的接口签名其实已经留好了扩展位,想稳妥可以往这个方向改。
// LevelController 里被机关调用的重建入口 public void RebuildGraph() { var nodes = GetComponentsInChildren<PathNode>(true); foreach (var n in nodes) n.ClearTemporaryLinks(); if (useBakedLinks) { ApplyBakedLinks(nodes); return; } // 预烘焙分支 for (int i = 0; i < nodes.Length; i++) for (int j = i + 1; j < nodes.Length; j++) { // 只有同高度层、且不是同一块几何体上的节点才允许视觉连边 if (Mathf.Abs(nodes[i].WorldPosition.y - nodes[j].WorldPosition.y) > 0.05f) continue; if (IsoProjection.LooksConnected(CameraController.Instance.MainCamera, nodes[i].WorldPosition, nodes[j].WorldPosition)) { nodes[i].AddTemporaryLink(nodes[j]); nodes[j].AddTemporaryLink(nodes[i]); } } }这段两层循环是 O(n²),但一关的节点通常只有几十个,开销可以忽略;关卡上到两三百节点再考虑分格筛选。注意y那一行过滤:不同高度的节点在屏幕上重合是很常见的(上下层叠在一起),如果不加高度限制,角色会直接“穿”到上层去,这也是最常见的翻车点之一。
3.3 相机旋转与点击拾取:屏幕坐标怎么落回网格节点
转动相机之后,节点缓存的屏幕坐标全部失效,所以旋转结束时必须重建一次图;捡取则反过来,从屏幕点发射线打到碰撞体,再往上找PathNode。
// 点击 -> 射线 -> 节点,注意用 GetComponentInParent, // 因为碰撞体通常挂在模型子物体上,节点脚本挂在根上 public PathNode PickNode(Vector2 screenPos) { Ray ray = CameraController.Instance.MainCamera.ScreenPointToRay(screenPos); if (!Physics.Raycast(ray, out RaycastHit hit, 200f, nodeMask)) return null; return hit.collider.GetComponentInParent<PathNode>(); }nodeMask只包含地面与可点击部件所在的 Layer,把装饰物、特效、角色自己排除掉,能省掉一大堆“点到角色身上”的异常。射线距离 200 是随手给的保守值,正交相机下射线起点本来就在相机附近,够用。
4. 交互链路拆解:一次点击如何走到角色落脚
从玩家按下鼠标到角色迈出第一步,中间经过输入层、寻路层、机关层、表现层四道关。这条链路拆清楚,加机关、改手感、查 bug 都有落点。
4.1 输入层:点击先过 UI 这道闸
最省事的写法是在Update里判断鼠标左键,但必须先用EventSystem拦一道,否则点 UI 按钮的同时角色也会走。
private void Update() { if (!Input.GetMouseButtonDown(0)) return; // UI 挡住时直接返回,否则点“重开”按钮角色会同时迈步 if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) return; Ray ray = CameraController.Instance.MainCamera.ScreenPointToRay(Input.mousePosition); if (!Physics.Raycast(ray, out RaycastHit hit, 200f, clickMask)) return; // 机关优先于地面,避免把把手当成落脚点 var interactable = hit.collider.GetComponentInParent<IInteractable>(); if (interactable != null && interactable.CanInteract) { interactable.Interact(); return; } var node = hit.collider.GetComponentInParent<PathNode>(); if (node != null) TryMoveTo(node); }优先级顺序是“机关 > 地面”,因为把手通常就叠在地砖上方,不加判断会点不到。CanInteract这个属性用来挡掉动画播放中的重复点击,机关在旋转过程中不接受新输入,否则连点两下角度就乱了。
4.2 寻路:几十个节点的图,手写 A* 比装插件省事
节点规模小,手写一版 A* 只有几十行,还方便在Neighbors里塞动态逻辑(比如某条边只在机关开启时存在)。
public static List<PathNode> FindPath(PathNode start, PathNode goal) { var open = new List<PathNode> { start }; var cameFrom = new Dictionary<PathNode, PathNode>(); var gScore = new Dictionary<PathNode, float> { [start] = 0f }; var fScore = new Dictionary<PathNode, float> { [start] = start.Heuristic(goal) }; while (open.Count > 0) { // 节点数量级只有几十,线性取最小比维护堆更省事 PathNode current = open[0]; for (int i = 1; i < open.Count; i++) if (fScore.TryGetValue(open[i], out float f) && f < fScore[current]) current = open[i]; if (current == goal) return Rebuild(cameFrom, current); open.Remove(current); foreach (var next in current.Neighbors) // 临时边 + 固定边,机关转完就变 { float tentative = gScore[current] + Vector3.Distance(current.WorldPosition, next.WorldPosition); if (gScore.TryGetValue(next, out float old) && tentative >= old) continue; cameFrom[next] = current; gScore[next] = tentative; fScore[next] = tentative + next.Heuristic(goal); if (!open.Contains(next)) open.Add(next); } } return null; // 不可达,调用方负责弹提示或什么都不做 }Heuristic用世界距离即可,等距投影下地图是立体的,欧氏距离虽然不严格可采纳,但对几十个节点的小图没有影响。Neighbors是一个动态返回的只读列表,内部拼的是“固定边 + 当前相机朝向下成立的临时边”,这样机关一改变状态,寻路结果立刻就变,不用重建整张图。返回null时不要抛异常,直接让点击静默失败,比弹一个报错框体验好。
4.3 机关系统:一个接口加一次重建
机关统一走IInteractable,新增机关只要实现三个成员,然后在LevelController里注册即可。
public interface IInteractable { bool CanInteract { get; } // 动画中返回 false,挡掉连点 Vector3 InteractPoint { get; } // 用于相机聚焦 / 提示气泡定位 void Interact(); }把手是最典型的实现,转动 90° 之后重建图:
public class RotatableHandle : MonoBehaviour, IInteractable { [SerializeField] private Transform pivot; // 旋转轴心,不能是自身 [SerializeField] private float stepAngle = 90f; [SerializeField] private float duration = 0.4f; private bool rotating; public bool CanInteract => !rotating; public Vector3 InteractPoint => pivot != null ? pivot.position : transform.position; public void Interact() { if (!rotating) StartCoroutine(RotateRoutine()); } private System.Collections.IEnumerator RotateRoutine() { rotating = true; Quaternion from = pivot.localRotation; Quaternion to = from * Quaternion.Euler(0f, stepAngle, 0f); float t = 0f; while (t < 1f) { t += Time.deltaTime / duration; pivot.localRotation = Quaternion.Slerp(from, to, Mathf.SmoothStep(0f, 1f, t)); yield return null; } pivot.localRotation = to; rotating = false; // 动画结束再重建图,中途重建会拿到半旋转状态的节点位置 LevelController.Current.RebuildGraph(); } }pivot一定要单独拉一个空物体当轴心,很多人图省事直接用自身,结果整块场景绕模型中心转,接缝全裂。RebuildGraph放在动画结束之后是硬性要求,中途重建会拿到位移一半的节点坐标,产生一条根本不存在的边,角色会“穿墙”走过去——这个坑我踩过不止一次。
4.4 角色行走:协程逐格插值,落点触发事件
走路不要用物理引擎推,直接协程插值,稳且好控。
private System.Collections.IEnumerator WalkRoutine(List<PathNode> path) { isMoving = true; for (int i = 1; i < path.Count; i++) { Vector3 from = transform.position; Vector3 to = path[i].WorldPosition; float dist = Vector3.Distance(from, to); float duration = Mathf.Max(0.08f, dist / moveSpeed); // 短距离给个下限,避免瞬移感 transform.rotation = Quaternion.LookRotation(to - from, Vector3.up); float t = 0f; while (t < 1f) { t += Time.deltaTime / duration; transform.position = Vector3.Lerp(from, to, t); yield return null; } transform.position = to; // 收尾对齐,防止浮点误差累积 path[i].OnStepped?.Invoke(); // 地砖音效、机关触发、路径点标记都挂这里 } isMoving = false; }moveSpeed一般给 3~4 单位/秒,配合 0.08 秒的下限,短格不会快到看不清。OnStepped用事件而不是在每个地砖上挂脚本,好处是换美术时不用重新绑引用。等距视角下LookRotation的朝向看起来会有点偏,这是正常的,模型本身需要按 45° 预处理或者在Art层做一次旋转偏移。
5. 排查清单:这份工程最容易翻车的 5 个地方
下面五条基本覆盖了新接手时 90% 的报错和“看着像 bug 其实是配置”的情况,按“现象 → 原因 → 解决”记,遇到直接对号入座。
5.1 打开与构建阶段:三个最先撞上的问题
现象一:场景能打开,所有模型一片品红,材质球显示 “Hidden/InternalErrorShader”。原因不是资源损坏,而是材质引用的 Shader 在当前渲染管线下找不到,Built-in 的 Standard 材质放进 URP 工程就是这表现。解决办法是先跑 2.2 那个 Shader 盘点脚本确认主 Shader,再决定方向:要么把工程切回 Built-in(Player Settings → Graphics → 清空 Render Pipeline Asset),要么用Edit → Rendering → Materials → Convert Selected Built-in Materials to URP批量转换。转换后记得检查透明材质,纯色低多边形一般没问题,带半透明的传送门特效容易丢混合模式,需要手动把 Surface Type 改回 Transparent。
现象二:Console 一屏红字,提示TMP_Settings找不到或InputSystem命名空间不存在。原因是压缩包只带了 Assets 和 ProjectSettings,Packages/manifest.json里的依赖版本和本机 Unity 对不上,或者 TextMeshPro 的 Essentials 资源没导入。解决顺序是:Package Manager 里确认 TextMeshPro 和 Input System 都已安装(版本差异不大,装最新兼容版即可),然后Window → TextMeshPro → Import TMP Essential Resources;如果工程用的是新输入系统,Player Settings → Other Settings → Active Input Handling 切到Both,重启编辑器。这一步不做,Input.GetMouseButtonDown会直接抛InvalidOperationException。
现象三:编辑器里一切正常,打包出来关卡是空的,只有背景色。原因是关卡预制体只被LevelLibrary这个 ScriptableObject 引用,而 SO 又只被场景里的管理器引用,如果LevelLibrary放在Resources之外且没被任何构建场景直接引用,打包时会被裁掉,运行时拿到 null。解决办法有两个:把LevelLibrary和所有关卡 Prefab 放进Resources目录用Resources.Load加载,或者更规范一点,改用 Addressables 给每个关卡打上level标签。同时检查File → Build Settings里Boot.unity和Game.unity都在列表内,且Boot索引为 0。
5.2 玩法与手感阶段:两个最难查的问题
现象四:走到某个位置,明明看着两段路接上了,角色却停下不动;或者反过来,隔着老远就跨过去了。这两个是同一个根因的两面:屏幕空间容差pixelTolerance不合适,或者相机角度不是标准等距角。先确认相机orthographic = true且 X 旋转严格等于 35.264、Y 是 45 的整数倍;再确认容差是按屏幕高度归一化的而不是写死像素。调试时把IsoProjection.LooksConnected里加一行Debug.DrawLine(a, b, Color.green, 1f),在 Scene 视图里能直接看到哪些节点对被判定为连通,比盲调参数快得多。另外记得检查 3.2 里那个高度过滤,上下层节点重合时如果没过滤,会出现“角色飘到上一层”的诡异现象。
现象五:点地面没反应,或者点一下角色走到完全不相干的位置。三个常见原因:UI 层没拦射线(漏了EventSystem.IsPointerOverGameObject判断,点按钮的同时也触发了行走)、碰撞体挂在子物体上而代码用了GetComponent<PathNode>()拿不到(改成GetComponentInParent)、clickMask把地面 Layer 排除掉了导致射线打空。排查顺序建议反过来做:先在Physics.Raycast后面打一条Debug.Log(hit.collider.name),看射线到底打中了什么;打中了但没走,就去看Pathfinder.FindPath是不是返回 null,多半是图没建起来或者目标节点不在同一连通分量里。
5.3 一套五分钟自查顺序
接手陌生工程时我固定按这个顺序过一遍,基本能在五分钟内判断“能不能跑、能不能改”:
- 看
ProjectSettings/ProjectVersion.txt确认 Unity 版本,用同大版本打开,避免升级弹窗把资源改坏。 - 跑 Shader 盘点脚本,确认渲染管线一致,没有品红材质。
- 打开
Boot.unity点 Play,看 Console 首屏有没有报错,特别留意空引用。 - 在 Hierarchy 里选中关卡根节点,确认
PathNode数量不为 0,机关组件的pivot都已赋值。 - 走一步、转一次把手,看角色是否按预期移动、图的边是否随旋转变化。
注意:第 4 步里如果
pivot是None,运行时不会报错,只会让整块场景绕自身中心转,表现为“接缝裂开”,很容易被误判成美术问题。
6. 二次开发:换皮、加机关和做自己关卡的具体改法
换美术是最常见的需求,也是最容易出问题的地方。三条硬规矩:模型 Pivot 必须放在底部中心,别放在几何中心;1 个 Unity 单位对应 1 格,不要靠 Scale 缩放去凑;同一关卡内所有件的缩放必须一致,否则正交投影下看起来对齐、实际坐标差一截,视错觉立刻失效。材质统一用同一个主 Shader 的实例,靠_BaseColor区分,能省掉大量变体。
加机关比想象中简单,实现IInteractable三个成员,然后在LevelController注册列表里加一项,完事。以一个升降台为例:
public class LiftPlatform : MonoBehaviour, IInteractable { [SerializeField] private Transform platform; [SerializeField] private float raiseHeight = 2f; [SerializeField] private float duration = 0.5f; private bool busy; private bool raised; public bool CanInteract => !busy; public Vector3 InteractPoint => platform.position; public void Interact() => StartCoroutine(MoveRoutine()); private System.Collections.IEnumerator MoveRoutine() { busy = true; Vector3 from = platform.localPosition; Vector3 to = from + Vector3.up * (raised ? -raiseHeight : raiseHeight); // 省略插值循环,写法与 RotatableHandle 一致 // ... raised = !raised; busy = false; LevelController.Current.RebuildGraph(); // 高度变了,连通关系必须重算 } }注意最后那行RebuildGraph()——任何改变节点世界坐标的机关都必须调它,这是整个工程里唯一的“后悔药”。做新关卡的流程是:复制一个现有关卡 Prefab,删掉里面多余的件,摆好节点(每个节点一个空物体 + 一个PathNode),放出生点,然后在LevelLibrary里加一条LevelData,填levelPrefab、cameraAnchor、hint。验证方法别靠肉眼:先关掉角色动画和音效,只跑逻辑,把相机拉远用 Gizmo 把节点和边画出来,确认图连通、没有多余的跨层边,再打开动画调手感。
我现在的习惯是:每加一个新机关,先在空场景里单独测一遍“点击 → 动画结束 → 图重建”这条链路,确认节点坐标真的变了再往关卡里放。因为一旦机关和关卡混在一起,出问题时你分不清是机关没生效还是图没重建,来回猜两小时。希望帮到你。
本文还有配套的精品资源,点击获取