---
# System prepended metadata

title: Unity_Learn_3D_Kit

---

# Unity_Learn_3D_Kit
## Last Week
## 鏡頭
![{59DB7D30-447A-48A6-9C8A-110445B1BB75}](https://hackmd.io/_uploads/HygHAAS-ke.png)

### 旋轉
![{430362E7-E4B8-4BE5-BC27-6A6DBE0150C7}](https://hackmd.io/_uploads/BkWcqkU-ye.png)

![{0DCBF796-8023-4AD4-8B74-B500B71087E7}](https://hackmd.io/_uploads/SyuPpkLWkx.png)

[Cinemachine Free Look Camera 官方文檔](https://docs.unity3d.com/Packages/com.unity.cinemachine@2.3/manual/CinemachineFreeLook.html)


## 玩家
### 移動
![{27D5B5C7-5542-47C9-80F0-5C6727933DA7}](https://hackmd.io/_uploads/S1G4nRr-ye.png)
### 跳躍
![{7B40349A-FCAC-41EA-BFE1-0AB50D8A9F1D}](https://hackmd.io/_uploads/Hk-N6CHZke.png)
### 攻擊
![{DEC4FD08-8C67-4AC4-9D5A-895FCE0E1DAD}](https://hackmd.io/_uploads/Bk7t60rZyx.png)

## 取得鍵盤輸入
```csharp=
void Update()
{
    m_Movement.Set(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
    m_Camera.Set(Input.GetAxis("Mouse X"), Input.GetAxis("Mouse Y"));
    m_Jump = Input.GetButton("Jump");

    if (Input.GetButtonDown("Fire1"))
    {
        if (m_AttackWaitCoroutine != null)
            StopCoroutine(m_AttackWaitCoroutine);

        m_AttackWaitCoroutine = StartCoroutine(AttackWait());
    }

    m_Pause = Input.GetButtonDown ("Pause");
}
```

### 為什麼Input.GetAxis("Horizontal")就抓得到鍵盤?
![{3E504221-74E1-4EBD-9056-1E20335D36AB}](https://hackmd.io/_uploads/ryUTHbLWkl.png)

[Input Manager官方文檔](https://docs.unity3d.com/Manual/class-InputManager.html)

### 怎麼看程式碼?
- 查看定義：按F12 / 右鍵Go to definition，來查看變數、class在哪裡宣告的
- 查看引用：按Shft+F12 / 右鍵Find Usage，來看哪些地方有用過這個變數

![{EDCA675C-2778-41D6-AD45-8282B9C05ABD}](https://hackmd.io/_uploads/ByCm4lI-1g.png)
▲ 對 m_Movement 按F12找到他是Vector2

![{D07EEEA1-6D01-42C0-A83F-5C597A516D96}](https://hackmd.io/_uploads/BJwJqg8-1l.png)
▲ 對 m_Movement 按Shft+F12找到哪幾行有用到這個變數

![{2A6BCCE4-AA85-477F-9727-FB5AF9D41E21}](https://hackmd.io/_uploads/BJ8a9xUWyx.png)
▲ 找到 MoveInput 會返回 m_Movement

![{4D9536C0-B977-4A09-994E-C65D564EA1F2}](https://hackmd.io/_uploads/ryjGse8Wkx.png)
▲ 對 MoveInput 按Shft+F12找到哪幾行有用到這個變數
→ PlayerController使用了MoveInput來計算移動方向



---

## 操作人物
找到從PlayerInput取得了鍵盤輸入，PlayerController再拿來計算方向。
先全部摺疊起來，從標題看看大致有哪些功能

```csharp=
public class PlayerController : MonoBehaviour, IMessageReceiver
{
    ......

    public void SetCanAttack(bool canAttack)

    // 醒來
    void Awake()

    // 啟用
    void OnEnable()

    // 停用
    void OnDisable()

    // 更新
    void FixedUpdate()

    // Called at the start of FixedUpdate to record the current state of the base layer of the animator.
    void CacheAnimatorState()

    // Called after the animator state has been cached to determine whether this script should block user input.
    void UpdateInputBlocking()

    // Called after the animator state has been cached to determine whether or not the staff should be active or not.
    bool IsWeaponEquiped()

    // Called each physics step with a parameter based on the return value of IsWeaponEquiped.
    void EquipMeleeWeapon(bool equip)

    // Called each physics step.
    void CalculateForwardMovement()

    // Called each physics step.
    void CalculateVerticalMovement()

    // Called each physics step to set the rotation Ellen is aiming to have.
    void SetTargetRotation()

    // Called each physics step to help determine whether Ellen can turn under player input.
    bool IsOrientationUpdated()

    // Called each physics step after SetTargetRotation if there is move input and Ellen is in the correct animator state according to IsOrientationUpdated.
    void UpdateOrientation()

    // Called each physics step to check if audio should be played and if so instruct the relevant random audio player to do so.
    void PlayAudio()

    // Called each physics step to count up to the point where Ellen considers a random idle.
    void TimeoutToIdle()

    // Called each physics step (so long as the Animator component is set to Animate Physics) after FixedUpdate to override root motion.
    void OnAnimatorMove()

    // This is called by an animation event when Ellen swings her staff.
    public void MeleeAttackStart(int throwing = 0)

    // This is called by an animation event when Ellen finishes swinging her staff.
    public void MeleeAttackEnd()

    // This is called by Checkpoints to make sure Ellen respawns correctly.
    public void SetCheckpoint(Checkpoint checkpoint)

    // This is usually called by a state machine behaviour on the animator controller but can be called from anywhere.
    public void Respawn()

    protected IEnumerator RespawnRoutine()

    // Called by a state machine behaviour on Ellen's animator controller.
    public void RespawnFinished()

    // Called by Ellen's Damageable when she is hurt.
    public void OnReceiveMessage(MessageType type, object sender, object data)

    // Called by OnReceiveMessage.
    void Damaged(Damageable.DamageMessage damageMessage)

    // Called by OnReceiveMessage and by DeathVolumes in the scene.
    public void Die(Damageable.DamageMessage damageMessage)
}
```
眼睛很花對吧? 表示塞太多東西了。
最好盡量細分功能，一個程式只負責一件事情
腦袋也會比較分的清楚👍

*總歸一句話，不需要給別人知道的東西，就不要公開出去*

## 生命週期
![image](https://hackmd.io/_uploads/HkFwlzI-1x.png)
### 常用的事件函数(依執行順序)
1. Awake：醒來（一定會做一次，不會重複執行）
2. OnEnable：啟用
3. Start：開始（"啟用後"做一次，不會重複執行）
4. Update：更新（每一幀呼叫一次，啟用後會一直重複）
5. OnDisable：停用
6. OnDestroy：刪除

比喻人來說：起床→有沒有班?→腦袋開機→開始工作->關機->下班



[事件函数的执行顺序](https://docs.unity3d.com/cn/2018.4/Manual/ExecutionOrder.html)

所以主要工作的地方都是在□□□Update
打開FixedUpdate區塊：

```csharp=180
// Called automatically by Unity once every Physics step.
void FixedUpdate()
{
   CacheAnimatorState();//儲存動畫狀態

   UpdateInputBlocking();//更新輸入偵測狀態

   EquipMeleeWeapon(IsWeaponEquiped());//裝備近戰武器

   m_Animator.SetFloat(m_HashStateTime, Mathf.Repeat(m_Animator.GetCurrentAnimatorStateInfo(0).normalizedTime, 1f));//設置動畫時間
   m_Animator.ResetTrigger(m_HashMeleeAttack);//重置攻擊觸發

   if (m_Input.Attack && canAttack)//如果可以攻擊
       m_Animator.SetTrigger(m_HashMeleeAttack);//觸發攻擊動畫

   CalculateForwardMovement();//計算跑步速度
   CalculateVerticalMovement();//計算降落速度

   SetTargetRotation();//計算轉向

   if (IsOrientationUpdated() && IsMoveInput)//如果動了
       UpdateOrientation();//設定轉向

   PlayAudio();//播放音效

   TimeoutToIdle();//是否播放閒置動畫

   m_PreviouslyGrounded = m_IsGrounded;
}
```
看命名找到計算跑步速度函式 → CalculateForwardMovement()

```csharp=253
void CalculateForwardMovement()
{
    // Cache the move input and cap it's magnitude at 1.
    Vector2 moveInput = m_Input.MoveInput;
    if (moveInput.sqrMagnitude > 1f)
        moveInput.Normalize();

    // Calculate the speed intended by input.
    m_DesiredForwardSpeed = moveInput.magnitude * maxForwardSpeed;

    // Determine change to speed based on whether there is currently any move input.
    float acceleration = IsMoveInput ? k_GroundAcceleration : k_GroundDeceleration;

    // Adjust the forward speed towards the desired speed.
    m_ForwardSpeed = Mathf.MoveTowards(m_ForwardSpeed, m_DesiredForwardSpeed, acceleration * Time.deltaTime);

    // Set the animator parameter to control what animation is being played.
    m_Animator.SetFloat(m_HashForwardSpeed, m_ForwardSpeed);
}
```
重要的是輸入從哪裡來？算完被送到哪裡去？
```csharp=253
void CalculateForwardMovement()
{
    //從PlayInput取得移動輸入
    Vector2 moveInput = m_Input.MoveInput;

   （經過一些處理......）

    //設定到Animator的"ForwardSpeed"變數
    m_Animator.SetFloat(m_HashForwardSpeed, m_ForwardSpeed);
}
```
只有設定動畫人物就會動？！

## BlendTree
BlendTree是一個可以用數值去控制混合動畫的功能
![image](https://hackmd.io/_uploads/H1RXhIDWke.png)
▲ AnimatorController中使用ForwardSpeed的地方
![image](https://hackmd.io/_uploads/ByNYhUwZJg.png)
▲ 依ForwardSpeed的大小決定要用走路還是跑步動畫

![image](https://hackmd.io/_uploads/Skj40IPbkl.png)
打開動畫發現有把Root往前(Z軸)移動了5.32 → 這動畫含有Root Motion
[Animator component](https://docs.unity3d.com/2022.3/Documentation/Manual/class-Animator.html)
![image](https://hackmd.io/_uploads/rJ6KgPwbyx.png)

:::warning
如果遇到EX：怎麼跑起來怪怪的？
:::
## 有效率的Debug方式：直接看到數值，不用自己辛苦推算
1. 顯示到Inspector上
```csharp=
protected float m_ForwardSpeed;// How fast Ellen is currently going along the ground.
public float m_ForwardSpeed;//暫時公開成public
[SerializeField] protected float m_ForwardSpeed;//或是加上[SerializeField] 
```

## 11/21
## 取得道具
![{AC9F122C-E236-4C6A-9649-0B7ACB10FDD7}](https://hackmd.io/_uploads/BkEgaJ3Gkx.png)
![{EC43CD6F-A60D-4EB5-BA79-785EB92541BE}](https://hackmd.io/_uploads/B1M70JhfJg.png)
![{A31C2FDC-F8EB-425B-B254-2A221A292DD9}](https://hackmd.io/_uploads/SJaNRk2fye.png)

▲碰到道具之後Can Attack打開了

![453453453_proc](https://hackmd.io/_uploads/rJChJlhf1e.jpg)
▲法杖有擺一個碰撞框，碰到Player之後把Can Attack打開

```csharp=8
[RequireComponent(typeof(Collider))] //預先標記依賴的元件(Component)
public class InteractOnTrigger : MonoBehaviour
{
    public LayerMask layers; //要偵測的圖層
    public UnityEvent OnEnter, OnExit; //自定義事件
    new Collider collider; //碰撞框
    public InventoryController.InventoryChecker[] inventoryChecks;

    void Reset()
    {
        layers = LayerMask.NameToLayer("Everything");
        collider = GetComponent<Collider>();
        collider.isTrigger = true;
    }
    //進入碰撞框
    void OnTriggerEnter(Collider other)
    {
        if (0 != (layers.value & 1 << other.gameObject.layer))//判斷圖層
        {
            ExecuteOnEnter(other);
        }
    }

    protected virtual void ExecuteOnEnter(Collider other)
    {
        OnEnter.Invoke();//執行自定義事件
        for (var i = 0; i < inventoryChecks.Length; i++)
        {
            inventoryChecks[i].CheckInventory(other.GetComponentInChildren<InventoryController>());
        }
    }
    //出去碰撞框
    void OnTriggerExit(Collider other)
    {
        if (0 != (layers.value & 1 << other.gameObject.layer))
        {
            ExecuteOnExit(other);
        }
    }

    protected virtual void ExecuteOnExit(Collider other)
    {
        OnExit.Invoke();//執行自定義事件
    }

    void OnDrawGizmos()
    {
        Gizmos.DrawIcon(transform.position, "InteractionTrigger", false);
    }

    void OnDrawGizmosSelected()
    {
        //need to inspect events and draw arrows to relevant gameObjects.
    }

} 
```
:::info
## 練習：自定義事件
```csharp=
public UnityEvent OnDebug;

public void DebugMessage(string msg)
{
    print(msg);
}
```
![{9A6FD118-E107-40B9-8BEC-D58E2126D194}](https://hackmd.io/_uploads/HkgpPghGke.png)
![{D8DB91CA-1323-46ED-9159-599E05DF4973}](https://hackmd.io/_uploads/H1jaPlnzke.png)

:::

## 攻擊
```csharp=104
public class PlayerController : MonoBehaviour, IMessageReceiver
{
    ......
    
    public void SetCanAttack(bool canAttack)
    {
        this.canAttack = canAttack;
    }
}
```
![{1403AB9E-670C-4458-9ADA-48350CA8884D}](https://hackmd.io/_uploads/ryhI7x2f1l.png)

```csharp=177
public class PlayerController : MonoBehaviour, IMessageReceiver
{
    ......
    
    void FixedUpdate()
    {
        CacheAnimatorState();

        UpdateInputBlocking();

        EquipMeleeWeapon(IsWeaponEquiped());//是否要顯示武器

        m_Animator.SetFloat(m_HashStateTime, Mathf.Repeat(m_Animator.GetCurrentAnimatorStateInfo(0).normalizedTime, 1f));
        m_Animator.ResetTrigger(m_HashMeleeAttack);

        if (m_Input.Attack && canAttack)//如果按下攻擊
            m_Animator.SetTrigger(m_HashMeleeAttack);//觸發攻擊動畫

        CalculateForwardMovement();
        CalculateVerticalMovement();

        SetTargetRotation();
        ......
    }
}
```

![image](https://hackmd.io/_uploads/rkSvSenzkx.png)
▲MeleeAttack的Trigger被打開

### 攻擊時才顯示武器
![{A0358F8B-8BAD-4A94-9C6F-EE01B51B016F}](https://hackmd.io/_uploads/SydsBgnG1x.png)

```csharp=226
public class PlayerController : MonoBehaviour, IMessageReceiver
{
    ......
    
    // Called after the animator state has been cached to determine whether or not the staff should be active or not.
    bool IsWeaponEquiped() //是不是正在打Combo ?
    {
        bool equipped = m_NextStateInfo.shortNameHash == m_HashEllenCombo1 || m_CurrentStateInfo.shortNameHash == m_HashEllenCombo1;
        equipped |= m_NextStateInfo.shortNameHash == m_HashEllenCombo2 || m_CurrentStateInfo.shortNameHash == m_HashEllenCombo2;
        equipped |= m_NextStateInfo.shortNameHash == m_HashEllenCombo3 || m_CurrentStateInfo.shortNameHash == m_HashEllenCombo3;
        equipped |= m_NextStateInfo.shortNameHash == m_HashEllenCombo4 || m_CurrentStateInfo.shortNameHash == m_HashEllenCombo4;

        return equipped;
    }

    // Called each physics step with a parameter based on the return value of IsWeaponEquiped.
    void EquipMeleeWeapon(bool equip)
    {
        meleeWeapon.gameObject.SetActive(equip);
        m_InAttack = false;
        m_InCombo = equip;

        if (!equip)
            m_Animator.ResetTrigger(m_HashMeleeAttack);
    }
}
```

### 偵測打擊
```csharp=76
public class MeleeWeapon : MonoBehaviour
{
    ......
    
    public void BeginAttack(bool thowingAttack)
    {
        if (attackAudio != null)
            attackAudio.PlayRandomClip();
        throwingHit = thowingAttack;

        m_InAttack = true;

        m_PreviousPos = new Vector3[attackPoints.Length];

        for (int i = 0; i < attackPoints.Length; ++i)
        {
            Vector3 worldPos = attackPoints[i].attackRoot.position +
                               attackPoints[i].attackRoot.TransformVector(attackPoints[i].offset);
            m_PreviousPos[i] = worldPos;

#if UNITY_EDITOR
            attackPoints[i].previousPositions.Clear();
            attackPoints[i].previousPositions.Add(m_PreviousPos[i]);
#endif
        }
    }

    public void EndAttack()
    {
        m_InAttack = false;


#if UNITY_EDITOR
        for (int i = 0; i < attackPoints.Length; ++i)
        {
            attackPoints[i].previousPositions.Clear();
        }
#endif
    }

    private void FixedUpdate()
    {
        if (m_InAttack)
        {
            for (int i = 0; i < attackPoints.Length; ++i)
            {
                AttackPoint pts = attackPoints[i];

                Vector3 worldPos = pts.attackRoot.position + pts.attackRoot.TransformVector(pts.offset);
                Vector3 attackVector = worldPos - m_PreviousPos[i];

                if (attackVector.magnitude < 0.001f)
                {
                    // A zero vector for the sphere cast don't yield any result, even if a collider overlap the "sphere" created by radius. 
                    // so we set a very tiny microscopic forward cast to be sure it will catch anything overlaping that "stationary" sphere cast
                    attackVector = Vector3.forward * 0.0001f;
                }


                Ray r = new Ray(worldPos, attackVector.normalized);

                int contacts = Physics.SphereCastNonAlloc(r, pts.radius, s_RaycastHitCache, attackVector.magnitude,
                    ~0,
                    QueryTriggerInteraction.Ignore);

                for (int k = 0; k < contacts; ++k)
                {
                    Collider col = s_RaycastHitCache[k].collider;

                    if (col != null)
                        CheckDamage(col, pts);
                }

                m_PreviousPos[i] = worldPos;

#if UNITY_EDITOR
                pts.previousPositions.Add(m_PreviousPos[i]);
#endif
            }
        }
    }
}
```

![{E4EC069D-8946-4F0F-BCEE-131BE62C3BF0}](https://hackmd.io/_uploads/HkKLXM3fyl.png)

▲動畫裡也是可以呼叫程式碼的
![453453453_proc](https://hackmd.io/_uploads/ry8sZ7hGyg.jpg)
▲攻擊範圍
## Debug攻擊範圍
```csharp
public class MeleeWeapon : MonoBehaviour
{
    private void FixedUpdate()
    {
        if (m_InAttack)
        {
            debugPoints.Clear();
            ......
            Debug.DrawLine(worldPos, worldPos + attackVector, Color.red, 1);

            for (int j = 0; j <= 4; j++)
            {
                debugPoints.Add(worldPos + attackVector * (j / 4f));
            }
        }
    }

    List<Vector3> debugPoints = new();

    private void OnDrawGizmosSelected()
    {
        ......

        Gizmos.color = Color.yellow;
        foreach (var item in debugPoints)
        {
            Gizmos.DrawWireSphere(item, pts.radius);
        }
    }
}
```
:::info
## 練習：作一把新武器
![{0A43557C-DEEE-451D-989F-4C3B2488DFA0}](https://hackmd.io/_uploads/rJELKmnGkg.png)

![{350F1338-5C5A-4326-8224-8C48CCB4719C}](https://hackmd.io/_uploads/S1vKtX2Gke.png)

▲設定偵測點(刀刃)

![image](https://hackmd.io/_uploads/H1i0D5H7Jl.png)

![image](https://hackmd.io/_uploads/HkkgO9Smyx.png)

▲記得要擺音效
:::
## 造成傷害
```csharp
public class MeleeWeapon : MonoBehaviour
{
    private bool CheckDamage(Collider other, AttackPoint pts)
    {
        Damageable d = other.GetComponent<Damageable>();
        if (d == null)
        {
            return false;
        }

        if (d.gameObject == m_Owner)
            return true; //ignore self harm, but do not end the attack (we don't "bounce" off ourselves)

        if ((targetLayers.value & (1 << other.gameObject.layer)) == 0)
        {
            //hit an object that is not in our layer, this end the attack. we "bounce" off it
            return false;
        }

        播放音效......

        Damageable.DamageMessage data;

        data.amount = damage;
        data.damager = this;
        data.direction = m_Direction.normalized;
        data.damageSource = m_Owner.transform.position;
        data.throwing = m_IsThrowingHit;
        data.stopCamera = false;

        d.ApplyDamage(data);

        播放粒子......

        return true;
    }
}
```

```csharp
public partial class Damageable : MonoBehaviour
{
    ......
    
    public void ApplyDamage(DamageMessage data)
    {
        if (currentHitPoints <= 0)
        {//ignore damage if already dead. TODO : may have to change that if we want to detect hit on death...
            return;
        }

        if (isInvulnerable)
        {
            OnHitWhileInvulnerable.Invoke();
            return;
        }

        Vector3 forward = transform.forward;
        forward = Quaternion.AngleAxis(hitForwardRotation, transform.up) * forward;

        //we project the direction to damager to the plane formed by the direction of damage
        Vector3 positionToDamager = data.damageSource - transform.position;
        positionToDamager -= transform.up * Vector3.Dot(transform.up, positionToDamager);

        if (Vector3.Angle(forward, positionToDamager) > hitAngle * 0.5f)
            return; //不在攻擊角度内不接受傷害

        isInvulnerable = true;
        currentHitPoints -= data.amount;

        if (currentHitPoints <= 0)
            schedule += OnDeath.Invoke; //This avoid race condition when objects kill each other.
        else
            OnReceiveDamage.Invoke();

        var messageType = currentHitPoints <= 0 ? MessageType.DEAD : MessageType.DAMAGED;

        for (var i = 0; i < onDamageMessageReceivers.Count; ++i)
        {
            var receiver = onDamageMessageReceivers[i] as IMessageReceiver;
            receiver.OnReceiveMessage(messageType, this, data);
        }
    }

    void LateUpdate()
    {
        if (schedule != null)
        {
            schedule();
            schedule = null;
        }
    }
}
```
![{B7262028-60D5-4707-BB80-960806A9D8EF}](https://hackmd.io/_uploads/SkVDUm2Gyx.png)
▲怪物是360度都會受傷的
:::info
## 練習：調整傷害接收器
調整血量(Max Hit Points)、偵測範圍(Hit Angle、Hit Forward Rotation)
![螢幕擷取畫面 2024-11-21 114112](https://hackmd.io/_uploads/BJHl27hGke.jpg)

```csharp
public void Explode()
{
    //在事件中加一些奇怪的東西，例如越打越大隻↓
    transform.localScale += Vector3.one * 0.5f;
}
```
:::

## 敵人偵測玩家
![985618951](https://hackmd.io/_uploads/SyECtN3zke.jpg)

```csharp=7
//use this class to simply scan & spot the player based on the parameters.
//Used by enemies behaviours.
[System.Serializable]
public class TargetScanner
{
    public float heightOffset = 0.0f;
    public float detectionRadius = 10;
    [Range(0.0f, 360.0f)]
    public float detectionAngle = 270;
    public float maxHeightDifference = 1.0f;
    public LayerMask viewBlockerLayerMask;

    
    /// <summary>
    /// 偵測玩家
    /// </summary>
    public PlayerController Detect(Transform detector, bool useHeightDifference = true)
    {
        //玩家目前狀態不是正在重生
        if (PlayerController.instance == null || PlayerController.instance.respawning)
            return null;

        Vector3 eyePos = detector.position + Vector3.up * heightOffset; //眼睛位置
        Vector3 toPlayer = PlayerController.instance.transform.position - eyePos;//玩家的腳
        Vector3 toPlayerTop = PlayerController.instance.transform.position + Vector3.up * 1.5f - eyePos;//玩家的頭

        if (useHeightDifference && Mathf.Abs(toPlayer.y + heightOffset) > maxHeightDifference)
        {
            return null;// 跟玩家高度差太多當作找不到
        }

        Vector3 toPlayerFlat = toPlayer;
        toPlayerFlat.y = 0;// 只取平面旋轉角度
        
        //在偵測距離内
        if (toPlayerFlat.sqrMagnitude <= detectionRadius * detectionRadius)
        {
            //點積：兩個方向完全一樣=1 完全相反=-1 垂直=0
            if (Vector3.Dot(toPlayerFlat.normalized, detector.forward) >
                Mathf.Cos(detectionAngle * 0.5f * Mathf.Deg2Rad))
            {

                bool canSee = false;

                Debug.DrawRay(eyePos, toPlayer, Color.blue);
                Debug.DrawRay(eyePos, toPlayerTop, Color.blue);

                canSee |= !Physics.Raycast(eyePos, toPlayer.normalized, detectionRadius,
                    viewBlockerLayerMask, QueryTriggerInteraction.Ignore);

                canSee |= !Physics.Raycast(eyePos, toPlayerTop.normalized, toPlayerTop.magnitude,
                    viewBlockerLayerMask, QueryTriggerInteraction.Ignore);

                if (canSee)
                    return PlayerController.instance;
            }
        }

        return null;
    }
    ......
}
```
https://www.desmos.com/calculator/vpxgi2xxfh?lang=zh-TW

![{7A840D60-512E-4028-A066-D0546B703099}](https://hackmd.io/_uploads/rJBFbS2fyl.png)
▲搜索範圍是270度，得出來點積要在-0.707以上

![{4095D6BB-83D8-4FBB-9428-F3D55085DC61}](https://hackmd.io/_uploads/Bkojbr3zyl.png)
▲黑線是怪物正前方，紅色是玩家位置，兩個方向的點積是-0.207，在偵測範圍內