Unity3D等距解谜Demo:正交相机与机关连通性重算

发布时间:2026/10/27 4:28:49
Unity3D等距解谜Demo:正交相机与机关连通性重算 简介这是一份围绕《纪念碑谷》解谜玩法整理的Unity3D工程源码包面向游戏开发学习者与独立开发者可用于拆解解谜关卡、空间视觉与交互逻辑的实现方式也可作为Unity 2017.3环境下的项目结构参考。压缩包共254个文件约2.61MB内容以asset场景与资源资产、cs脚本、meta元数据为主并配有dll库、xml/txt配置说明、csproj工程文件及log日志等便于按工程模块检索与还原。目前已有957人学习具备一定的参考热度。读者可从中观察场景对象配置、模型纹理与音频资源导入、C#脚本对角色行为、碰撞检测、谜题触发、UI响应和进度管理的组织方式同时了解Unity项目在资源管理、代码分层与调试信息上的常见做法。对希望提升引擎实操能力、积累关卡设计思路的初中级开发者而言这份案例适合逐文件对照阅读并做二次实践。1. 引子第一次解压这个包我以为是半小时能看完的东西——一个等距视角的解谜 Demo能有多少含量。结果第二天早上还在调某关那个旋转机关角色走到拼接点会卡住半帧视觉上过去了逻辑上没过去。这类 Unity3D Game Project 最容易被低估的从来不是画面而是「错觉」背后那套坐标与连通性计算——相机是正交的路径是离散的机关一转整张图的拓扑就变了而画面上的「看起来连上了」和代码里的「确实连上了」是两件完全不同的事。这份资源适合三类人想搞懂等距关卡怎么组织的美术向程序、要给自己解谜玩法找参考的独立开发者、以及接了个「做个类似风格小游戏」的活但不知道从哪下手的人。它不教你写渲染管线但它把「一块地砖怎么严丝合缝对上另一块」拆得足够细细到你能照着改。2. 把等距错觉拆成三件事正交相机、路径图与机关状态2.1 正交投影的三个数rotation、size、pivot等距视角在 Unity 里从来不是一个开关而是三个互相牵制的数。第一个是相机旋转常见做法是(30, 45, 0)或者更「标准」的(35.264, 45, 0)——后者是真正的等距投影三个轴的缩短比例一致地砖看起来才是正菱形而不是被拉长的菱形。很多工程随手写 30 度单看没问题一旦把地块摆成斜向长条接缝就会开始露馅。第二个数是orthographicSize。正交相机下它等于「屏幕高度的一半对应多少世界单位」所以画幅的横向可见宽度 size * aspect。这意味着同一份场景在 16:9 和 20:9 的手机上纵向看到的地砖数量一样横向不一样。想保证「一屏至少看到 N 块砖」就得按短边定 size 而不是按高度定具体写法是把基准尺寸乘一个min(1, aspect / 基准aspect)的系数。第三个数是 pivot 与像素对齐。正交相机没有透视所有物体在屏幕上的投影大小与距离无关好处是不会出现「远处的砖更小」坏处是任何亚像素偏移都会被放大成可见的缝。我一般把相机挂在场景根节点下位置取整数格Transform.position的 x/y 强制对齐到1/PixelsPerUnit的倍数。参数常用值作用改错的后果Camera.rotation(35.264, 45, 0)标准等距方向砖块菱形被拉伸orthographicSize57视地块尺寸半屏高对应世界单位手机端横向裁切near/far0.1 / 200剔除范围远景机关被裁掉PixelsPerUnit64 或 100像素对齐基准地砖边缘出现 1px 缝// 按短边锁定的正交相机尺寸避免窄屏裁掉关键地块 using UnityEngine; [RequireComponent(typeof(Camera))] public class IsoCameraFit : MonoBehaviour { public float baseSize 6f; // 设计基准竖屏下想看到的高度一半 public float designAspect 16f / 9f; Camera cam; float lastAspect; void Awake() { cam GetComponentCamera(); cam.orthographic true; } void LateUpdate() { float aspect (float)Screen.width / Screen.height; if (Mathf.Approximately(aspect, lastAspect)) return; // 只在画幅变化时重算 lastAspect aspect; // 比设计基准更窄的屏幕抬高 size 保住纵向内容更宽则维持基准 cam.orthographicSize aspect designAspect ? baseSize * (designAspect / aspect) : baseSize; } }这段逻辑的关键在最后那个三元判断。很多人反过来写窄屏时减小 size结果手机上横向能看到的地块更多、纵向被切掉玩家看不到顶部的机关。baseSize取多少取决于你一块地砖的世界尺寸地砖 1 单位、想一屏看 12 行baseSize就是 6。改这个参数唯一要盯的是「关键机关是否完整落在画幅内」不要凭感觉调。2.2 为什么用路径图而不是 NavMesh拿到工程第一件事就是看角色怎么走路。如果是 NavMesh基本可以判定这份 Demo 的机关不会太复杂——因为 NavMesh 烘焙出来的是一张静态网格一旦某个平台旋转 90 度你得重新烘焙或者在运行时动态生成 NavMesh代价高且容易出现「角色贴边抖动」。等距解谜恰恰是「地图会变形」的玩法烘焙思路天然别扭。更常见的做法是一张手写的路径图Waypoint Graph每个可站立格点是一个节点相邻节点之间连一条边角色在边上做插值移动。旋转机关时只需要重算「哪些节点之间的边还成立」不需要碰任何几何数据。这样做的好处是状态完全可控出问题能打日志、能可视化、能回放坏处是关卡设计师得手动铺点工作量比刷地形大。我的经验是格子数在 200 以内手铺点完全划算超过这个量级再考虑用编辑器脚本批量生成。// 路径节点只描述拓扑不描述几何 using System.Collections.Generic; using UnityEngine; public class PathNode : MonoBehaviour { public int id; public ListPathEdge edges new ListPathEdge(); // 出边 public Vector3 StandPoint transform.position Vector3.up * 0.05f; // 站立点略抬高避免穿模 } [System.Serializable] public class PathEdge { public PathNode from; public PathNode to; public float cost 1f; // 用于最短路径默认按下段距离填 public bool walkable true; // 机关旋转后由重算逻辑改写 }StandPoint加那 0.05 的抬升是个小细节角色胶囊体的底部原点如果正好落在砖面阴影面片会 z-fighting。参数改成 0.02 或 0.1 都行取决于你的砖厚。walkable这个字段是整套系统的开关——连通性重算本质上就是把一批边的walkable翻成 false 或 true寻路算法只认它不认任何视觉表现。2.3 机关不是动画是带索引的状态机新手最容易犯的错是把旋转机关当成一个「播放一段旋转动画」的对象。动画播完转到位了看着对但逻辑层完全不知道发生了什么于是角色要么走不过去要么能走过去但走的是空气。正确的抽象是机关持有一个离散的rotationIndex03对应 0/90/180/270 度逻辑层只读这个索引视觉层负责把索引映射成目标角度并做补间。// 机关逻辑先行表现为后 using UnityEngine; public class RotateMechanism : MonoBehaviour { public int rotationIndex; // 逻辑状态0..3 public float degreesPerStep 90f; public float tweenDuration 0.35f; Quaternion targetRot; public void Step(int delta) { rotationIndex (rotationIndex delta 4) % 4; // 防负数取模 targetRot Quaternion.Euler(0f, rotationIndex * degreesPerStep, 0f); // 先派发逻辑事件再做视觉补间顺序反了会导致角色提前走上未连通的边 PathGraph.Instance.RebuildConnectivity(); } void Update() { transform.rotation Quaternion.RotateTowards( transform.rotation, targetRot, 360f / tweenDuration * Time.deltaTime); } }(rotationIndex delta 4) % 4这个写法是为了处理逆时针旋转传 -1 的情况C# 的%对负数返回负值不加 4 会得到 -1 这种非法索引然后Quaternion.Euler直接给你一个意想不到的角度。tweenDuration一般取 0.30.4 秒太短玩家来不及看清自己转了什么太长会打断解谜节奏。这里用RotateTowards是为了让动画速度与帧率无关别直接乘Time.deltaTime给一个固定角度增量。3. 跑起来工程导入、输入闭环与第一段寻路3.1 目录结构与版本对齐先做别急着点 Play解压之后先别双击场景。第一步看ProjectSettings/ProjectVersion.txt把编辑器版本对齐到工程声明的版本附近——跨大版本打开粒子系统、URP 配置、Input System 包最容易炸。第二步看Packages/manifest.json确认依赖是用内置管线还是 URP这一步判断错打开就是一片粉红。常见的目录组织是这样的Scenes放关卡Scripts/Core放路径图与状态机Scripts/Gameplay放角色与机关Scripts/UI放界面Art下分Models、Materials、ShadersAudio放音效Prefabs放可复用件。看到这种分法说明作者是有工程习惯的可以放心按它的结构改如果所有脚本堆在根目录那就得先花半小时归类否则后面加功能会互相踩脚。提示导入前先确认工程里是否包含Library目录。如果压缩包里带了这个目录删掉再导入它跟本机版本绑定留着只会拖慢首次加载甚至报错。场景打开后先只启用一个最小的关卡场景把其他场景从 Build Settings 里临时移除。同时打开 Console 的Clear on Play和Error Pause让第一个报错就停在现场。这个习惯能省掉大量「跑起来了但某个东西没生效」的排查时间。3.2 点击到格点射线检测的三个修正等距视角的点击交互本质是「屏幕点 → 世界射线 → 命中的地块 → 最近的可达路径节点」。Camera.ScreenPointToRay在正交相机下依然有效射线的 origin 会落在近裁剪面上方向固定为相机 forward。这里第一个坑是层级UI 元素会挡住点击必须先用EventSystem.current.IsPointerOverGameObject()判断否则玩家点按钮的同时角色也动了。第二个修正是在移动端做触点去抖。触屏的Input.GetMouseButtonDown(0)在某些设备上会连续触发两次表现是角色走了一步又立刻折返。常见做法是记录上一次点击时间间隔小于 0.15 秒直接丢弃。第三个修正是命中后的节点选择不要直接用射线命中的那个格子而是取「距命中点最近的、当前可达的节点」否则玩家点到砖块边缘时会选到相邻格走位看起来像抽风。// 点击 - 目标节点含 UI 遮挡与去抖 using UnityEngine; using UnityEngine.EventSystems; public class ClickToMove : MonoBehaviour { public Camera isoCamera; public LayerMask tileMask; // 只勾地块层别把角色自己算进去 public float debounce 0.15f; float lastClickTime -1f; void Update() { if (!Input.GetMouseButtonDown(0)) return; if (EventSystem.current ! null EventSystem.current.IsPointerOverGameObject()) return; if (Time.unscaledTime - lastClickTime debounce) return; lastClickTime Time.unscaledTime; Ray ray isoCamera.ScreenPointToRay(Input.mousePosition); if (!Physics.Raycast(ray, out RaycastHit hit, 200f, tileMask)) return; PathNode node PathGraph.Instance.NearestReachable(hit.point); if (node ! null) GetComponentPlayerMover().MoveTo(node); } }tileMask一定要单独建层把角色、特效、粒子全部排除在外——角色站在砖上射线第一个打到的往往是角色自己的碰撞体然后你会得到「点哪都不动」的玄学现象。200f是射线最大距离正交相机下这个值只要大于场景纵深就行给太小会导致远处地块点不中。NearestReachable需要内部做一次距离排序节点数不多的话直接线性扫不要过早引入空间划分。3.3 沿边走协程插值比 Update 状态机更不容易翻车角色移动最稳的实现是「一段一段走」拿到目标节点后用 A* 或 BFS 在当前walkable的边上求一条节点序列然后逐段插值。用协程写的好处是每段移动的起止状态天然隔离中途被机关打断时可以直接StopCoroutine收尾不会留下「速度残留」这种脏状态。// 逐段移动每段独立插值 using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerMover : MonoBehaviour { public float moveSpeed 3.2f; public float turnSpeed 720f; public Transform visualRoot; // 只转模型不转逻辑根避免影响射线 Coroutine routine; public void MoveTo(PathNode goal) { ListPathNode path PathGraph.Instance.FindPath(CurrentNode(), goal); if (path null || path.Count 2) return; if (routine ! null) StopCoroutine(routine); routine StartCoroutine(Walk(path)); } IEnumerator Walk(ListPathNode path) { for (int i 1; i path.Count; i) { Vector3 from transform.position; Vector3 to path[i].StandPoint; // 朝向只影响视觉层逻辑位置立刻开始移动 Vector3 dir (to - from); dir.y 0f; if (dir.sqrMagnitude 0.001f) visualRoot.rotation Quaternion.RotateTowards( visualRoot.rotation, Quaternion.LookRotation(dir), turnSpeed * Time.deltaTime); float t 0f; float duration Vector3.Distance(from, to) / moveSpeed; while (t 1f) { t Time.deltaTime / duration; transform.position Vector3.Lerp(from, to, Mathf.SmoothStep(0f, 1f, t)); yield return null; } transform.position to; // 收尾强制吸附避免浮点累积误差 } routine null; } }Mathf.SmoothStep让起步和收尾有一点缓动等距视角下比线性Lerp自然得多但别用太强的曲线否则每段之间会有明显的顿挫感。duration按距离除以速度算保证长边和短边速度一致——如果按固定时长会出现「一格走 0.3 秒、三格还是 0.3 秒」的怪事。最后那句强制吸附是血泪经验Vector3.Lerp在t超过 1 之后不会再精确落在终点走上几十步就会偏离半格然后在某个拐角直接穿墙。visualRoot与逻辑根分离这一点值得单独说。逻辑根负责位置和碰撞朝向交给子物体这样旋转时不会带着碰撞体一起转射线检测和物理查询的结果都可预测。4. 错觉机关的运行时逻辑旋转、重算与解耦4.1 父子层级决定了旋转会不会歪旋转机关在场景里的挂法只有两种是对的要么整块机关是一个空物体下的子集绕空物体的本地 Y 轴转要么每个可动砖块各自持有自己的转轴点。错误做法是把砖块直接挂在机关根节点下然后给根节点一个带缩放的父级——Unity 的非均匀缩放会在旋转时产生剪切变形砖块会从菱形慢慢变成平行四边形而且这种变形在编辑器里静态看不出来只有转起来才露馅。我一般的层级是LevelRoot无缩放 →MechanismPivot位置在旋转中心缩放 1,1,1 →Tiles每块砖的本地坐标相对 pivot 摆放。旋转只改MechanismPivot.localRotation砖块的本地坐标一动不动重算连通性时直接把本地坐标转世界坐标即可。这里有个容易忽略的点旋转中心必须落在格点上而不是几何中心。如果 pivot 在 0.5 偏移的位置转 90 度后整块机关的砖会落在半格位置视觉上「差一点点」逻辑上所有节点全部错位。判定方法很简单转完之后打印任意一块砖的世界坐标x 和 z 应该是 0.5 的整数倍假设格距为 1。4.2 连通性重算先清标记再判距离最后补边重算这件事必须做成幂等的也就是「跑一次」和「跑十次」结果一样。流程分三步第一步把所有边的walkable重置为 false第二步遍历所有节点对距离小于阈值通常是 1.05 倍格距留一点容差的建立候选第三步检查这对节点之间是否有遮挡体——比如两块砖之间有墙就不能连。// 幂等的连通性重算 using System.Collections.Generic; using UnityEngine; public class PathGraph : MonoBehaviour { public static PathGraph Instance; public ListPathNode nodes new ListPathNode(); public float linkTolerance 1.05f; // 相对格距的容差 public LayerMask blockerMask; // 墙、栏杆等阻断层 void Awake() { Instance this; } public void RebuildConnectivity() { // 1. 全清 foreach (var n in nodes) foreach (var e in n.edges) e.walkable false; // 2. 按距离重建 for (int i 0; i nodes.Count; i) for (int j 0; j nodes.Count; j) { if (i j) continue; Vector3 a nodes[i].StandPoint; Vector3 b nodes[j].StandPoint; if (Vector3.Distance(a, b) linkTolerance) continue; if (Physics.Linecast(a, b, blockerMask)) continue; // 3. 遮挡剔除 var edge nodes[i].edges.Find(e e.to nodes[j]); if (edge null) { edge new PathEdge { from nodes[i], to nodes[j] }; nodes[i].edges.Add(edge); } edge.walkable true; } } }linkTolerance这个值设成 1.05 而不是 1.0是因为浮点误差。砖块转 90 度之后两个本该重合的节点世界坐标可能差 1e-4严格等于判断会全部失效。设太大又会让斜向相邻的节点被误连——斜向距离是 1.4141.05 够不到安全。blockerMask里放墙和栏杆Linecast从站立点出发注意站立点已经抬高了 0.05不会跟地面自己打自己。这个算法的复杂度是 O(n²)节点数 200 时是 4 万次距离计算加线检测跑一次大概几毫秒。如果在转动时感觉有卡顿把RebuildConnectivity挪到协程里分帧执行或者只对「受影响的节点集合」重算。别一上来就上四叉树这个量级不值得。4.3 用事件把机关和路径解耦机关转完要通知路径重算路径重算完要通知角色重新判断当前所在节点是否还合法角色如果掉在断开的节点上还要播放一个下落。这些跨模块的连锁反应如果用直接引用写会变成一团意大利面机关引用路径图路径图引用角色角色又引用机关。简单做法是静态事件总线只放三四个事件不搞泛型框架。// 极简事件总线够用就停 using System; public static class GameEvents { public static event Action OnMechanismRotated; // 机关状态已变 public static event Action OnConnectivityRebuilt; // 路径已重算 public static event Actionint OnPlayerNodeChanged; // 角色进入新节点 public static void RaiseMechanismRotated() OnMechanismRotated?.Invoke(); public static void RaiseConnectivityRebuilt() OnConnectivityRebuilt?.Invoke(); public static void RaisePlayerNodeChanged(int id) OnPlayerNodeChanged?.Invoke(id); }订阅的顺序有讲究GameEvents.OnMechanismRotated RebuildConnectivity必须在角色移动逻辑之前注册否则会出现「角色先按旧路径走了一步路径才更新」的一帧错位。实际排查时这种错位极难复现所以我在Awake里手动控制注册顺序并在OnDestroy里全部反注册。静态事件不反注册切场景之后旧的委托还在第二次进关会收到两份通知表现是机关转一次、逻辑转两次。5. 避坑与排查让解谜瞬间失效的几个细节5.1 点不中远处的地砖或者点到角色脚下现象很统一点画面中央没问题点右上角的砖块毫无反应点角色站的位置反而触发了相邻格。原因有两层一是射线最大距离给得不够正交相机的射线 origin 在近裁剪面远处物体对应的射线长度超过设定值直接被截断二是tileMask里混进了角色或特效的碰撞体射线先命中了角色脚下的碰撞盒。解决办法是把射线距离改成按场景包围盒动态计算或者在Start里取一个足够大的常数比如 500。层遮罩要单独建一层把所有地块统一挂上去角色和特效全部排除。另外如果地块用的是 MeshCollider 而且网格很薄射线有可能从背面穿过去改成 BoxCollider 更稳。5.2 机关转完了角色还是走不过去现象是视觉上两块砖已经严丝合缝点击目标格角色却不动或者只走一半停下。原因九成在重算时机RebuildConnectivity在旋转动画开始时就跑了此时transform.rotation还是旧值重算出来的节点世界坐标全是错的等动画播完逻辑还停留在上一帧的拓扑。解决方式是先改逻辑索引、再派发重算、最后才做视觉补间三者顺序不能换。如果坚持要等动画播完再重算就得在补间结束时补一次调用但这中间有一段时间玩家可以点击会产生「点了没反应」的体验。我倾向逻辑先行的写法视觉上那 0.35 秒的延迟玩家几乎感知不到。5.3 走完一段路角色偏离了半格单次移动没问题走十几步之后角色逐渐偏离砖面中心最后卡在两块砖的缝里。原因是Vector3.Lerp配合SmoothStept超过 1 之后插值结果不再等于终点而循环里的while (t 1f)在最后一帧t可能是 1.03位置就停在 103% 处。每一步多出百分之一格二十步就是五分之一格。修法是在循环结束后强制赋transform.position to不要依赖插值收尾。另外duration不要用Mathf.Max(0.01f, ...)之外的方式兜底距离为 0 时会除零t变成无穷大协程直接卡死。这种死循环在编辑器里表现为「点了不动但不报错」最难查。5.4 移动端帧率一掉点击就双击在低端机上触摸事件偶尔会在一帧内派发两次GetMouseButtonDown角色走出去一步又折返看起来像在原地抽搐。Unity 的 Input 类在触屏上的模拟行为在不同设备上并不完全一致不能假设它是干净的。稳妥做法是自己做一层去抖记录上一次接受点击的Time.unscaledTime间隔小于 0.15 秒直接丢弃。用unscaledTime而不是time是因为有些工程会在过场时把Time.timeScale调成 0用缩放时间会导致过场结束后第一次点击被误判为重复。去抖窗口别设太大0.3 秒以上会让快速连点解谜的操作变得粘手。注意协程里如果有yield return new WaitForSeconds它受timeScale影响。过场暂停时用它会让移动卡在半路改用WaitForSecondsRealtime或者纯yield return null手动累加。6. 把解谜流程做成可回放的验证脚本改完机关逻辑之后最痛苦的事情是「手动把每一关重打一遍确认没坏」。我的做法是给工程加一个轻量录制器每次有效点击记录一条(节点 id, 时间戳)通关时把序列存成 JSON验证时按序列自动重放跑完对比最终状态是否一致。这东西不到两百行但能挡住 80% 的回归问题。// 最小回放器记录点击序列按时间戳重放 using System.Collections.Generic; using UnityEngine; [System.Serializable] public struct StepRecord { public int nodeId; public float time; } public class ReplayRunner : MonoBehaviour { public ListStepRecord records new ListStepRecord(); public PlayerMover player; public void Record(int nodeId) { records.Add(new StepRecord { nodeId nodeId, time Time.time }); } public System.Collections.IEnumerator Playback() { float start Time.time; int i 0; while (i records.Count) { // 按录制时的相对时间推进保证节奏一致 if (Time.time - start records[i].time - records[0].time) { var node PathGraph.Instance.FindById(records[i].nodeId); if (node ! null) player.MoveTo(node); i; } yield return null; } } }time记录的是绝对时间重放时减去序列起点这样不依赖录制时刻的Time.timeScale。FindById需要路径图维护一个 id 到节点的字典别每次线性查找重放时一秒能调几十次。回放期间把输入关掉否则手动点击会污染序列。配套的自检表我一般这么列每次改完机关逻辑照着过一遍检查项判定方式常见异常节点世界坐标落在格点打印 x/z应为 0.5 的整数倍旋转后偏移半格边数与视觉连通一致打开 Gizmos 画边多连了斜向边重算幂等连续调用两次边集合不变边被重复添加关卡可通回放序列跑通中途卡死帧率无关时间缩放到 0.5 重放位置漂移回放这套东西一开始我觉得是过度设计直到有一次改了个「重算只对受影响节点生效」的优化表面上看逻辑没变实际漏掉了一类跨机关的边导致第七关从中间开始不可通。手动测了四十分钟没发现回放脚本三十秒就报出来了。从那以后我每次动连通性相关的代码都强制走一遍录制、重放、比对最终节点 id 的流程不管改动多小。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询