纪念碑谷复刻教程 - 基于Unity引擎

前言

之前一直很好奇纪念碑谷的实现方式,最近偶然间刷到了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);
        }
    }
}

工程链接

https://github.com/1520386112/Monument-Valley

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值