前言
之前一直很好奇纪念碑谷的实现方式,最近偶然间刷到了Mix and Jam的视频,根据其提供的视觉错位技术的实现思路,还原了纪念碑谷的第一关。完整项目链接附在文章末尾。
最终效果如下
视觉错位效果
摄像机采用正交投影的方式,可以使得远处和近处的方块大小相同,并且可以让远处和近处的物体错位在一起。
3D场景中物体的实际摆放:
将Rotator顺时针旋转90度后:
然后使用多个摄像机对建筑进行分层渲染,使得在后面的建筑能够渲染到前面建筑的上方。
我们对几个区域进行编号:
1 是需要渲染到 3 的上方的,所以整个Rotator,连带着 4 区域,需要一个摄像机将其渲染到更高层 Topper 层。
这样做之后, Rotator顺时针旋转90度后,1 会渲染到 2 的上方,这不是我们想要的结果,所以我们需要把区域 2 也渲染到 Topper 层,为了让柱子正确地渲染到 2 上面,柱子也需要调至 Topper 层。
这时的效果如下:
依然有两处不正确的地方,我们在这两处加上几个平面,把渲染错误的地方遮挡掉。
右下角的遮挡平面放在 Topper 层。
因为左上角的方块本身在柱子后面,所以左上角的遮挡平面不能与柱子同在 Topper 层,需要新建更高的一层 Topest 层。
最开始摆放物体的时候,如果是采用区域 1 和 3 相连,顺时针旋转90度后与 2 错位的方式,可以省掉一个相机,也并不需要担心 Player 空间中错位移动的问题,因为后面寻路系统中,会根据边的长度动态调整移动速度。缺点是 Rotator 的激活、禁用触发器需要细心摆放。
寻路系统
PathNode
整个路点网络是一张图。Rotator 旋转的时候会动态禁用、激活一些边,所以边需要一个 bool 类型的变量记录边的启用状态。
路点和边的定义如下:
using System;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class Edge
{
public bool isActive;
public PathNode toNode;
}
public class PathNode : MonoBehaviour
{
//该结点邻接的边
public List<Edge> edgeList;
//PathManager 计算路径需要记录前驱结点与边
[HideInInspector]
public PathNode fromNode;
[HideInInspector]
public Edge fromEdge;
private void OnDrawGizmosSelected()
{
if (edgeList != null) {
int length = edgeList.Count;
for (int i = 0; i < length; ++i) {
if (edgeList[i].toNode != null && edgeList[i].isActive) {
Debug.DrawLine(transform.position, edgeList[i].toNode.transform.position, Color.red);
}
}
}
Gizmos.DrawCube(transform.position, new Vector3(0.2f, 0.2f, 0.2f));
}
}
Agent
对于 Agent 来说,需要记录当前结点和目标结点,点击鼠标发射射线,射线打到路点后,生成点击特效,并通过 PathManager 进行寻路。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Agent : MonoBehaviour
{
public float moveSpeed = 2f;
public PathNode currentNode;
public PathNode targetNode;
private Camera _camera;
private ClickEffect _effect;
private void Start()
{
_camera = Camera.main;
_effect = GetComponent<ClickEffect>();
RaycastHit hit;
if(Physics.Raycast(transform.GetChild(0).position, -transform.up, out hit, 1, LayerMask.GetMask("PathNode"))) {
currentNode = hit.transform.GetComponent<PathNode>();
}
if(currentNode == null) {
Debug.LogError("agent is not on any path node");
}
}
private void Update()
{
if (Input.GetMouseButtonDown(0)) {
Ray ray = _camera.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit, 1000, LayerMask.GetMask("PathNode"))) {
targetNode = hit.transform.GetComponent<PathNode>();
if(targetNode != null) {
PathManager.Instance.FindPath(this, targetNode);
_effect.GenerateEffect(targetNode.transform);
}
}
}
}
}
PathManager
由于路径较少,可以采用广度优先搜索算法来获取最短路径。
在寻路过程中,如果因为旋转机关被旋转等因素导致当前边被禁用,则停止寻路。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PathManager : MonoSingleton<PathManager>
{
public void FindPath(Agent agent, PathNode targetNode)
{
//获取最短路径
List<Edge> path = GetPath(agent.currentNode, targetNode);
if (path == null) return;
//停止上一次寻路的移动
StopAllCoroutines();
//进行移动
StartCoroutine(Move(agent, path));
}
private List<Edge> GetPath(PathNode currentNode, PathNode targetNode)
{
Dictionary<PathNode, bool> visitedDic = new Dictionary<PathNode, bool>();
Queue<PathNode> q = new Queue<PathNode>();
currentNode.fromEdge = null;
currentNode.fromNode = null;
targetNode.fromNode = null;
targetNode.fromEdge = null;
q.Enqueue(currentNode);
visitedDic.Add(currentNode, true);
while(q.Count > 0) {
PathNode last = q.Dequeue();
foreach(Edge edge in last.edgeList) {
PathNode node = edge.toNode;
if (!visitedDic.ContainsKey(node) && edge.isActive) {
node.fromNode = last;
node.fromEdge = edge;
q.Enqueue(node);
visitedDic.Add(node, true);
}
}
}
if(targetNode.fromNode == null) {
return null;
}
List<Edge> path = new List<Edge>();
BackTrace(path, targetNode);
return path;
}
private void BackTrace(List<Edge> path, PathNode curNode)
{
if(curNode.fromNode != null) {
BackTrace(path, curNode.fromNode);
path.Add(curNode.fromEdge);
}
}
private IEnumerator Move(Agent agent, List<Edge> path)
{
foreach(Edge edge in path){
PathNode node = edge.toNode;
//根据边长动态调整移动速度
float speed = Vector3.Distance(node.transform.position, agent.currentNode.transform.position) * agent.moveSpeed;
while (Vector3.Distance(agent.transform.position, node.transform.position) > 0.05f) {
//寻路途中路径被禁用
if (!edge.isActive) yield break;
agent.transform.position = Vector3.MoveTowards(agent.transform.position, node.transform.position, speed * Time.deltaTime);
yield return null;
}
agent.currentNode = node;
}
}
}
ClickEffect
玩家可能频繁地点击路点,故点击特效使用对象池进行管理。
圆环特效需要总是渲染到最上层,所以单独用了一个摄像机进行渲染。也可以将其做成 Overlay Canvas 下的 UI。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ClickEffect : MonoBehaviour
{
public GameObject pointEffectPF;
public GameObject annulusEffectPF;
private GameObject lastPoint;
public float annulusAnimLength = 0.67f;
//减少GC
private WaitForSeconds ws;
private Camera _uiCamera;
private void Start()
{
foreach(Camera item in FindObjectsOfType<Camera>()) {
if(item.name == "Annulus Camera") {
_uiCamera = item;
}
}
if(_uiCamera == null) {
Debug.LogError("Annulus Camera not found");
}
ws = new WaitForSeconds(annulusAnimLength);
}
public void GenerateEffect(Transform nodeTF)
{
if(lastPoint != null) {
GameObjectPool.Instance.ObjectCollect("PointEffect", lastPoint);
}
lastPoint = GameObjectPool.Instance.Obtain("PointEffect", pointEffectPF, nodeTF.position + nodeTF.up * 0.01f, nodeTF.rotation * Quaternion.Euler(90, 0, 0));
lastPoint.transform.parent = nodeTF;
GameObject go = GameObjectPool.Instance.Obtain("AnnulusEffect", annulusEffectPF, nodeTF.position, _uiCamera.transform.rotation);
StartCoroutine(CollectAnnulusEffect(go));
}
public IEnumerator CollectAnnulusEffect(GameObject obj)
{
yield return ws;
GameObjectPool.Instance.ObjectCollect("AnnulusEffect", obj);
}
}
项目工程中路点没有处理好,应该规范每个路点的命名,在编辑器中拖拽连边的时候才会比较清晰。另外,可以写一个路点编辑器,进行快速连边。
旋转机关
Rotator
Rotator 中需要记录四个旋转角度对应应该激活的边,玩家每次开始拖拽旋转的时候,禁用四个列表中所有的边,当松开手指的时候,插值旋转到最近的角度,当旋转机关稳定不动后,激活对应角度的边列表。
这里的 SetDragable() 方法用到了Dotween插件。
using DG.Tweening;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class ActiveEdge
{
public PathNode node;
public int index;
}
public class Rotator : MonoBehaviour
{
public bool dragable = true;
[Header("激活边列表")]
public List<ActiveEdge> activeEdgeList0;
public List<ActiveEdge> activeEdgeList90;
public List<ActiveEdge> activeEdgeList180;
public List<ActiveEdge> activeEdgeList270;
/// <summary>
/// 轴心点的屏幕坐标
/// </summary>
private Vector2 originPos;
private bool isDraging;
private bool isSteady = true;
private float targetY;
private void Start()
{
originPos = Camera.main.WorldToScreenPoint(this.transform.position);
cylinder1TF = transform.FindChildByName("Cylinder1");
cylinder2TF = transform.FindChildByName("Cylinder2");
}
private bool lastSteadyState = true;
private void Update()
{
if (!isDraging) {
float curY = Mathf.Lerp(transform.localEulerAngles.y, targetY, 0.08f);
this.transform.localEulerAngles = new Vector3(0, curY, 0);
if(Mathf.Abs(curY - targetY) < 1) {
this.transform.localEulerAngles = new Vector3(0, targetY, 0);
isSteady = true;
}
//稳定的第一帧触发,激活当前对应的边
if(isSteady && lastSteadyState == false) {
//UnActiveAllEdge();
float curLocalEuler = this.transform.localEulerAngles.y;
if (curLocalEuler >= 315 || curLocalEuler < 45) {
SetEdgeListState(activeEdgeList0, true);
}
else if (curLocalEuler >= 45 && curLocalEuler < 135) {
SetEdgeListState(activeEdgeList90, true);
}
else if (curLocalEuler >= 135 && curLocalEuler < 225) {
SetEdgeListState(activeEdgeList180, true);
}
else {
SetEdgeListState(activeEdgeList270, true);
}
}
}
else if(dragable) {
this.transform.localRotation = Quaternion.Lerp(transform.localRotation, aimRotation, 0.3f);
}
lastSteadyState = isSteady;
}
private Vector2 startDirection;
private Quaternion startRotation;
private Quaternion aimRotation;
private void OnMouseDown()
{
if (!dragable) return;
isDraging = true;
isSteady = false;
startDirection = (Vector2)Input.mousePosition - originPos;
startRotation = transform.localRotation;
//禁用四个列表中所有的边
UnActiveAllEdge();
}
private void OnMouseDrag()
{
if (!dragable) return;
Vector2 currentDirection = (Vector2)Input.mousePosition - originPos;
float angle = Vector2.Angle(startDirection, currentDirection);
//this.transform.Rotate(Vector3.Cross(startDirection, currentDirection).normalized.z * Vector3.up * angle, Space.Self);
aimRotation = startRotation * Quaternion.Euler(Vector3.Cross(startDirection, currentDirection).normalized.z * Vector3.up * angle);
}
private void OnMouseUp()
{
isDraging = false;
float rotationY = this.transform.localEulerAngles.y;
if(rotationY >= 315 || rotationY < 45) {
targetY = 0;
if (rotationY >= 315) targetY = 359.9f;
}
else if(rotationY >= 45 && rotationY < 135) {
targetY = 90;
}
else if(rotationY >= 135 && rotationY < 225) {
targetY = 180;
}
else {
targetY = 270f;
}
}
private void SetEdgeListState(List<ActiveEdge> list, bool state)
{
foreach(ActiveEdge ae in list) {
ae.node.edgeList[ae.index].isActive = state;
}
}
private void UnActiveAllEdge()
{
SetEdgeListState(activeEdgeList0, false);
SetEdgeListState(activeEdgeList90, false);
SetEdgeListState(activeEdgeList180, false);
SetEdgeListState(activeEdgeList270, false);
}
private Transform cylinder1TF;
private Transform cylinder2TF;
//设置 Rotator 是否可被拖拽
public void SetDragable(bool state)
{
if(state == true) {
cylinder1TF.DOScaleY(1, 0.5f);
cylinder2TF.DOScaleY(1, 0.5f);
}
else {
cylinder1TF.DOScaleY(0.6f, 0.5f);
cylinder2TF.DOScaleY(0.6f, 0.5f);
}
dragable = state;
}
}
RotatorActivator
玩家走到 Rotator 上面时,需要将 isdragable 设置为 false,禁止拖拽旋转;反之从 Rotator 中走出时,设置为 true。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class RotatorActivator : MonoBehaviour
{
public Rotator rotator;
public bool triggerState;
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player")) {
rotator.SetDragable(triggerState);
Debug.Log("trigger " + triggerState);
}
}
}