Traffic System use waypoint in Unity-Section1

前言

我們在場景中常常需要有一些NPC,不管是在遊戲或是虛擬場景通常都會有路上的行人走動,而今天我們就利用強大的C#製作出一個Traffic System的系統,讓我們的場景NPC可以依照我們製作的Waypoint路線走動,讓整個場景看起來更加完整,加上Ragdoll功能的話更可以像GTA遊戲一樣可以造成路人被車體碰撞的效果。

Traffic System可以包含車子、行人、騎車的人甚至路上的動物等等,這些都是算在 Traffic System 的系統裡面,本篇的重點先以路上的行人為主,讓我們創建的角色可以依照我們製作的Waypoint行走,是不是很有趣呢。

CharacterNavigationController

一開始最重要的就是我們的人物Controller了,這邊找了很久才找到國外大神分享的人物控制語法,有興趣的朋友可以看一下這篇影片了解人物的Character Navigation Controller之間的控制。

在我們使用套上 Character Navigation Controller 之前我們需要自己創建一個角色或是在Assets Store上面下載免費角色並套上動畫。

依照需求可以先在unity裡面設定自己的角色所需要的動畫,動畫的部分可以上mixamo依照自己的需求製作不同人物動畫使用。

按下play看一下我們的人物走路動畫是否正常運作,這邊注意要讓動畫是原地行走的,因為我們移動的位置需要靠語法控制,若動畫一開始就設定有位置移動的畫反而會讓人物走路變得很奇怪。

如果大家的走動動畫是用 mixamo 的話只要勾選In Place他就不會亂跑了。

設定好動畫以後我們來先給我們角色 Character Navigation Controller 的語法,這套語法主要是要告訴我們的角色該往哪走並在Waypoint裡面指定這套控制角色依照我們的waypoint範圍行走。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CharacterNavigationController : MonoBehaviour
{
    public float movementSpeed = 1;//移動速度
    public float rotationSpeed = 120; //旋轉速度
    public float stopDistance = 2f; //停止距離
    public Vector3 destination;
    public Animator animator;
    public bool reachedDestination;

    private Vector3 lastPosition;
    private Vector3 velocity;

    private void Awake()
    {
        movementSpeed = Random.Range(0.8f, 2f); //移動速度的random及範圍
        animator = GetComponent<Animator>();

    }


    void Update()
    {
        if (transform.position != destination)
        {
            Vector3 destinationDirection = destination - transform.position;
            destinationDirection.y = 0; 
            float destinationDistance = destinationDirection.magnitude; //目的地方向

            if (destinationDistance >= stopDistance)
            {
                reachedDestination = false; //到達目的地
                Quaternion targetRotation = Quaternion.LookRotation(destinationDirection);
                transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
                transform.Translate(Vector3.forward * movementSpeed * Time.deltaTime);
            }
            else
            {
                reachedDestination = true; 

            }

            //指定角色目的地

            velocity = (transform.position - lastPosition) / Time.deltaTime;
            velocity.y = 0;
            var velocityMagnitude = velocity.magnitude;
            velocity = velocity.normalized;
            var fwdDotProduct = Vector3.Dot(transform.forward, velocity);
            var rightDotProduct = Vector3.Dot(transform.right, velocity);

            animator.SetFloat("Horizontal", rightDotProduct);
            animator.SetFloat("Forward", fwdDotProduct);
        }

        lastPosition = transform.position;

    }



    public void SetDestination(Vector3 destination)
    {
        this.destination = destination;
        reachedDestination = false;
    }

}

Waypoint

首先我們在Scripts裡面新建一個叫做Waypoint的C#語法,這個語法主要是指定我們角色所要走的範圍及寬度資訊等等。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Waypoint : MonoBehaviour
{
    public Waypoint previousWaypoint; //開頭的waypoint
    public Waypoint nextWaypoint; // 接著的waypoint

    [Range(0f, 5f)]
    public float width = 1f; //範圍的寬度

    public List<Waypoint> branches = new List<Waypoint>();

    [Range(0f, 1f)]
    public float branchRatio = 0.5f;


    public Vector3 GetPosition()
    {
        //兩者之間的距離
        Vector3 minBound = transform.position + transform.right * width / 2f;
        Vector3 maxBound = transform.position - transform.right * width / 2f;

        return Vector3.Lerp(minBound, maxBound, Random.Range(0f, 1f));

    }
}

WaypointMangerWindow

因為在製作Waypoint的時候如果沒有明顯的GUI會導致很難在場景上直覺的畫出,所以我們這邊需要在Editor裡面製作WaypointManagerWindow的C#語法。

這邊記得這套語法是用在Editor裡面,所以我們必須先using UnityEditor;

using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

public class WaypointMangerWindow : EditorWindow
{
    [MenuItem("Tools/Waypoint Editor")]
    public static void Open()
    {
        GetWindow<WaypointMangerWindow>();

    }

    public Transform waypointRoot; 

    //畫waypoint的GUI
    private void OnGUI()
    {

        SerializedObject obj = new SerializedObject(this);

        EditorGUILayout.PropertyField(obj.FindProperty("waypointRoot"));

        if (waypointRoot == null)
        {
            //報錯視窗
            EditorGUILayout.HelpBox("Root transform must be selected. Please assign a root transform.", MessageType.Warning);
        }

        else
        {
            EditorGUILayout.BeginVertical("box");
            DrawButtons();
            EditorGUILayout.EndVertical();
        }

        obj.ApplyModifiedProperties();

    }

    //GUI畫件按鈕
    void DrawButtons()
    {
        if (GUILayout.Button("Create Waypoint"))
        {
            CreateWaypoint();
        }
        if(Selection.activeGameObject !=null && Selection.activeGameObject.GetComponent<Waypoint>())
        {
            if(GUILayout.Button("Create Waypoint Before"))
            {
                CreateWaypointBefore();
            }
            if(GUILayout.Button("Create Waypoint After"))
            {
                CreateWaypointAfter();
            }
            if(GUILayout.Button("Remove Waypoint"))
            {
                RemoveWaypoint();
            }
        }      
    }

    //接續畫出waypoint
    void CreateWaypoint()
    {

        GameObject waypointObject = new GameObject("Waypoint" + waypointRoot.childCount, typeof(Waypoint));
        waypointObject.transform.SetParent(waypointRoot, false);

        Waypoint waypoint = waypointObject.GetComponent<Waypoint>();
        if (waypointRoot.childCount > 1)
        {
            waypoint.previousWaypoint = waypointRoot.GetChild(waypointRoot.childCount - 2).GetComponent<Waypoint>();
            waypoint.previousWaypoint.nextWaypoint = waypoint;

            // 放Waypoint在最後的位置
            waypoint.transform.position = waypoint.previousWaypoint.transform.position;
            waypoint.transform.forward = waypoint.previousWaypoint.transform.forward;

        }

        Selection.activeGameObject = waypoint.gameObject;


    }

    void CreateWaypointBefore()
    {
        GameObject waypointObject = new GameObject("Waypoint " + waypointRoot. childCount, typeof(Waypoint));
        waypointObject.transform.SetParent(waypointRoot, false);

        Waypoint newWaypoint = waypointObject.GetComponent<Waypoint>();

        Waypoint selectedWaypoint = Selection.activeGameObject.GetComponent<Waypoint>();

        waypointObject.transform.position = selectedWaypoint.transform.position;
        waypointObject.transform.forward = selectedWaypoint.transform.forward;

        if(selectedWaypoint.previousWaypoint != null)
        {
            newWaypoint.previousWaypoint = selectedWaypoint.previousWaypoint;
            selectedWaypoint.previousWaypoint.nextWaypoint = newWaypoint;
        }

        newWaypoint.nextWaypoint = selectedWaypoint;

        selectedWaypoint.previousWaypoint = newWaypoint;

        newWaypoint.transform.SetSiblingIndex(selectedWaypoint.transform.GetSiblingIndex());

        Selection.activeGameObject = newWaypoint.gameObject;


    }
    //Waypoint下一個點
    void CreateWaypointAfter()
    {
        GameObject waypointObject = new GameObject("Waypoint " + waypointRoot.childCount, typeof(Waypoint));
        waypointObject.transform.SetParent(waypointRoot, false);

        Waypoint newWaypoint = waypointObject.GetComponent<Waypoint>();

        Waypoint selectedWaypoint = Selection.activeGameObject.GetComponent<Waypoint>();

        waypointObject.transform.position = selectedWaypoint.transform.position;
        waypointObject.transform.forward = selectedWaypoint.transform.forward;

        newWaypoint.previousWaypoint = selectedWaypoint;

        if(selectedWaypoint.nextWaypoint !=null)
        {
            selectedWaypoint.nextWaypoint.previousWaypoint = newWaypoint;
            newWaypoint.nextWaypoint = selectedWaypoint.nextWaypoint;
        }
    }
    //移除Waypoint
    void RemoveWaypoint()
    {
        Waypoint selectedWaypoint = Selection.activeGameObject.GetComponent<Waypoint>();

        if(selectedWaypoint.nextWaypoint != null)
        {
            selectedWaypoint.nextWaypoint.previousWaypoint = selectedWaypoint.previousWaypoint;

        }
        if(selectedWaypoint.previousWaypoint != null)
        {
            selectedWaypoint.previousWaypoint.nextWaypoint = selectedWaypoint.nextWaypoint;
            Selection.activeGameObject = selectedWaypoint.previousWaypoint.gameObject;
        }

        DestroyImmediate(selectedWaypoint.gameObject);

    }
}

建立好我們的 WaypointManagerWindow 以後如果沒有報錯就可以看到在Unity的Tools裡面多了一個WaypointEditor的插件了。

接著我們新曾一個空白的GameObject並重新命名為Waypoint,並將這個物件拖進剛剛點開的WaypointMangerWindow裡面有一個WaypointRoot當作整個Waypoint的根,這邊的Waypoint記得要擺在我們人物要走的一開頭。

按下Create WayPoint的時候會發現我們少了一些Gizmos視覺的產出,雖然可以畫出Waypoint的各個點了,這邊還是需要另外寫一個語法帶有Gizmos幫助我們更直覺地畫線。

WaypointEditor

因為WayPointEditor也是需要在Unity的Editor裡面製作,如之前所說目前的GUI效果需要Gizmos去顯示,所以我們必須要另外製作一個定義Gizmos的C#語法

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

[InitializeOnLoad()]
public class WaypointEditor
{
    //利用Gizmos視覺化Waypoint,包含選取、沒被選取、挑選
    [DrawGizmo(GizmoType.NonSelected | GizmoType.Selected | GizmoType.Pickable)]
    public static void OnDrawSceneGizmo(Waypoint waypoint, GizmoType gizmoType)
    {
        //如果被選取後顯示的顏色
        if((gizmoType & GizmoType.Selected) !=0 )
            {
            Gizmos.color = Color.yellow;
        }
        //如果沒有選取顯示的顏色
        else
        {
            Gizmos.color = Color.yellow * 0.5f;
        }

        Gizmos.DrawSphere(waypoint.transform.position, .1f);

        //顯示Waypoint寬度的顏色
        Gizmos.color = Color.white;
        Gizmos.DrawLine(waypoint.transform.position + (waypoint.transform.right * waypoint.width / 2f),
            waypoint.transform.position - (waypoint.transform.right * waypoint.width / 2f));

        if (waypoint.previousWaypoint != null)
        {
            //顯示previousWaypoint顏色
            Gizmos.color = Color.red;
            Vector3 offset = waypoint.transform.right * waypoint.width / 2f;
            Vector3 offsetTo = waypoint.previousWaypoint.transform.right * waypoint.previousWaypoint.width / 2f;
            Gizmos.DrawLine(waypoint.transform.position + offset, waypoint.previousWaypoint.transform.position + offsetTo);
        }
        //顯示nextWaypoint顏色
        if (waypoint.nextWaypoint !=null )
            {
            Gizmos.color = Color.green;
            Vector3 offset = waypoint.transform.right * -waypoint.width / 2f;
            Vector3 offsetTo = waypoint.previousWaypoint.transform.right * -waypoint.previousWaypoint.width / 2f;
            Gizmos.DrawLine(waypoint.transform.position + offset, waypoint.previousWaypoint.transform.position + offsetTo);

        }


    }
 
}

可以從下圖標示看到我們在語法上面所標示的顏色

NonSelected – 透明黃色

Selected – 黃色

Width – 白色

PreviousWaypoint-綠色

NextWaypoint – 紅色

新增了Gizoms的視覺以後就很容易拉Waypoint了,這時候我們就依照自己需要NPC走動的路線繪製路線。

連到到一個圈以後大家會發現我們沒辦法剛好全部連接,這邊的方法很簡單。因為我們建立的每一個Waypoint都有 PreviousWaypoint 跟 NextWaypoint 兩個需要對接前後,所以最後一個 Waypoint的NextPoint就是第一個Waypoint,將他拉進去即可。

反之,第一個Previous也要將最後一個Waypoint拉進去,這樣就可以完成了一個完整的圈圈了。

WaypointNavigator

完成了我們的Waypoint的拉線工作以後,接著就是要讓我們的角色跟著路線走了,這邊我們新建一個新的C#語法並命名為WaypointNavigator。

using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using UnityEngine;

public class WaypointNavigator : MonoBehaviour
{
    CharacterNavigationController controller; //把CharacterNavigationController當作控制器
    public Waypoint currentWaypoint;
    float direction;

 
    void Start()
    {
        controller = GetComponent<CharacterNavigationController>(); //使用CharacterNavigationController Coponent
        controller.SetDestination(currentWaypoint.GetPosition());
        direction = Mathf.RoundToInt(UnityEngine.Random.Range(0, 1));

    }


    void Update()
    {
        if(controller.reachedDestination)
        {
            if(direction == 0)
            {
                currentWaypoint = currentWaypoint.nextWaypoint;
            }
            else if(direction == 1)
            {
                currentWaypoint = currentWaypoint.previousWaypoint;
           

        }
            controller.SetDestination(currentWaypoint.GetPosition());
        }
    }
}

然後我們在角色上加裝剛剛寫好的 WaypointNavigator 並將我們所要讓角色跟隨的Waypoint1拉進當前的Waypoint,這樣我們的角色就可以根據我們所建立的Waypoint走動了。

看到成果後是不是覺得很有趣呢?

小記

本篇以單角色的數量製作Traffic System系統,當然如果是正常的世界路上不會只有一個人在走動而已,接著我們必須在多加幾個C#語法讓我們的Wayppoint上充滿人物,這套系統不只能夠製作人物的走動,之後也可以運用在NPC車子或是騎車的人等等,雖然在AssetsStore有人有在賣整套的系統,但是其中我們可以學習到更多舉一反三的用法,且這套系統的C#語法也是很難得可以學習到的。

資料來源:https://www.youtube.com/watch?v=MXCZ-n5VyJc&t=601s

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *