유니티3D - 7
이번 목표
여태 정리하지 못한 캐릭터 구조정리? AI와 Player가 같은 컴포넌트로 이용하게 완벽히 생각해서 나아가보자.
구조 정리
먼저 확실한 차이는 컨트롤러다. 입력 경로가 다르기 때문에, 공통 로직/데이터와 입력 주체를 분리해서 정리한다.
잡다한 생각인 부분 움직임 실행 주체가 달랐다. 같은 속력 값을 쓰려면 NavMeshAgent에서 나온 속도를 CharacterMovement로 전달해 애님컨트롤러에 반영하면 된다.
Player는 CharacterController로 이동하고 있어 CharacterMovement로 “값이 들어오는” 지점을 명확히 해야 한다.
하지만 여기서 중요한점은 어떻게 이동시키냐인데 AI는 NavMeshAgent가 이동을시키고 플레이어는 캐릭터 컨트롤러가한다.
언리얼에서는 AI가 Nav로 움직여도 CharacterMovement의 Velocity가 함께 갱신된다. 유니티에서는 공통 인터페이스(또는 베이스 컨트롤러)로 “현재 속도/방향”만 표준화해 전달하면 된다.
한 번 흐름을 정리하고 다시 생각해보니, 일단 같은 애님컨트롤러를 이용해야한다 하지만 지금 AI의 Velocity값이 전달이안된다.
결론은 간단했다. NavMeshAgent.velocity를 가져오면 된다. 이동을하는 주체 : AI - NavMeshAgent, Player - CharacterController 하지만 여기서 CharacterController는 Velocity값을 얻지 못한다. Move함수로 그 방향벡터만큼 이동만 시켜준다. 속력을 구하는 과정을 PlayerController에서 처리하자. Input도 플레이어 컨트롤러에서 처리하기 때문에 2DVector moveInput을 옮기고 받는다. 카메라의 forward/right 벡터로 이동 방향을 구한다. 일단 근데 미리 생각을 해봤는데 AI도 Target을 바라보고 움직이는 애니메이션을 만들어야하는데 Direction 값을 어떻게 구할까? NavAgent의 Velocity로 방향을 구하고 원래 포워드 벡터를 비교하면 된다. 하지만 그 게임오브젝트에 방향도 회전을 시켜준다.
그럼 Forward 벡터가 계속 바뀌어 애니메이션에 활용할 방향값을 구하기가 힘들다. 이렇게 계속 미래를 생각하도보니 먼가 막힌다 만들어보고 거기서 다시 생각해보자.. BaseCharacter로 만들어서 관리를 하려해도 결국 이 NavMeshAgent로 이동을 하게된다면 내가 하고자한 캐릭터무브먼트를 이용한 Player와 AI 이동은 현재로서는 힘들 것 같다.
최종결론 계속된 삽질로 나는 최종결론을 내리기로했다. 지금 내 구조에서 언리얼처럼 캐릭터무브먼트가 AI와 Player 이동을 같이 처리하는건 까다롭다고 결론을 내리고. 캐릭터무브먼트를 상속받는 Player무브먼트를 만들어 관리하고 애님 컨트롤러에서 캐릭터무브먼트 값을 가져오기로 마무리했다. Ai는 캐릭터무브먼트의 Velocity값을 Agent Velocity값을 전달하여 구성했다.
이런 과정을 겪어보니 언리얼이 확실히 무겁지만 기본적인 구조가 더 편하고 일관되게 잘만들었다고 느껴졌다.
비헤이비어트리
지금은 순찰 기능만 구성했고 적을 감지하고 공격하는 트리까지 만들어보자.
- Player 감지 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[SerializeField] private float viewRadius = 10f;
[SerializeField] private float viewAngle = 90f;
[SerializeField] private LayerMask targetMask;
void DetectPlayer()
{
Collider[] targets = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
foreach (Collider target in targets)
{
if (!target.CompareTag("Player"))
continue;
Vector3 dirToTarget = (target.transform.position - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, dirToTarget);
if (angle < viewAngle / 2f)
{
Debug.Log("플레이어 감지됨!");
// 원하는 행동 수행
break;
}
}
}
// 디버그용 시야각 시각화
void OnDrawGizmosSelected()
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, viewRadius);
Vector3 rightDir = Quaternion.Euler(0, viewAngle / 2, 0) * transform.forward;
Vector3 leftDir = Quaternion.Euler(0, -viewAngle / 2, 0) * transform.forward;
Gizmos.color = Color.red;
Gizmos.DrawRay(transform.position, rightDir * viewRadius);
Gizmos.DrawRay(transform.position, leftDir * viewRadius);
}
플레이어 감지
viewRadius,viewAngle을 이용해 인스펙터창에서 범위와 시야각을 조정해 사용할 수 있게하였다.
그리고 LayerMask를 이용해 Player만 감지하게끔 했다. (언리얼에 콜리전 프리셋과 같은 것)
LauyerMask를 이용했기 때문에 플레이어 태그 검사까지는 할 필요가 없다고 생각이듬. 그리고 이 코드는 엄페여부가 반영이되지 않기 떄문에 시야각에 들어오면 타겟 방향으로 레이케스트를 쏴 엄페여부 확인후 태그롤 확인하는 방법이 좋다고 생각.
- 수정한 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void DetectPlayer()
{
Collider[] targets = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
foreach (Collider target in targets)
{
Vector3 dirToTarget = (target.transform.position - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, dirToTarget);
// 시야각 내에 있을 때만
if (angle < viewAngle / 2f)
{
float distToTarget = Vector3.Distance(transform.position, target.transform.position);
// 시야 방향으로 Ray 쏴서 장애물 있는지 확인
if(Physics.Raycast(transform.position + Vector3.up, dirToTarget, out RaycastHit hit, distToTarget))
{
if (hit.transform.CompareTag("Player"))
{
Debug.Log("플레이어 감지됨! - " + target.name);
break;
}
else
{
Debug.Log(hit.collider.name);
}
}
}
}
}
엄폐
생각한대로 잘 구현됐다. 이제 비헤이비어 트리에 연결해 동작을 분기한다. 블랙보드 Target에 Player를 넣고, 트리에서 Target 유무와 거리/시야 조건으로 추격↔사격을 전이한다.
- 바뀐 코드
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
void DetectPlayer() { Collider[] targets = Physics.OverlapSphere(transform.position, viewRadius, targetMask); foreach (Collider target in targets) { Vector3 dirToTarget = (target.transform.position - transform.position).normalized; float angle = Vector3.Angle(transform.forward, dirToTarget); // 시야각 내에 있을 때만 if (angle < viewAngle / 2f) { float distToTarget = Vector3.Distance(transform.position, target.transform.position); // 시야 방향으로 Ray 쏴서 장애물 있는지 확인 if(Physics.Raycast(transform.position + Vector3.up, dirToTarget, out RaycastHit hit, distToTarget)) { if (hit.transform.CompareTag("Player")) { behaviorGraphAgent.SetVariableValue<GameObject>("Target", hit.transform.gameObject); break; } } } } }
간단하게 behaviorGraphAgent.SetVariableValue<BehaviorState>("State", BehaviorState.Chase); State값을 바꾸고 BT도 디버그 용으로 잘 실행되는지 확인해보자.
똑같이 안된다 그럼 원인은 무엇일까 유니티 내부에서 뭐가 안되는 것일까 아니면 내가 무엇을 잘못한 것일까?
에디터 재시작 후 정상 동작. 캐시/상태 꼬임 가능성을 메모해두고, 값 전달 경로는 유지했다.
이 방식에서 문제가 있는 거 같다 아마 Null을 이용할 수 없는것인가? 그냥 스크립트내에서 Enum값을 변경시키고 동작을하게 해야겠다.
비헤이비어 fire
BT는 정상 동작한다. Fire 액션을 위해 Player/AI의 발사 원점/방향 취득 방식을 인터페이스로 통일해 무기 쪽에서 동일하게 처리한다.
원래코드 ```c# public virtual void fire() { if (bCanFire == false) return; bCanFire = false; // 카메라 기준 Ray 쏘기 Camera cam = Camera.main; Vector3 origin = cam.transform.position; Vector3 direction = cam.transform.forward; Vector3 hitPoint = origin + direction * MaxDistance;
1 2 3 4 5 6 7 8 9 10 11
if (Physics.Raycast(origin, direction, out RaycastHit hit, MaxDistance)) { hitPoint = hit.point; Debug.DrawRay(origin, direction * hit.distance, Color.green, 1f); Instantiate(data.effect, hitPoint, Quaternion.identity); // TODO: hit.collider.SendMessage("TakeDamage", damage) } else { Debug.DrawRay(origin, direction * 100f, Color.red, 1f); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
인터페이스를 활용해 origin 값과 direction 값을 구하게 바꾸고 Owner변수를 만들어 거기서 캐싱해오겠다.
```c#
public void SetOwner(GameObject newOwner)
{
owner = newOwner;
AimInfo = owner.GetComponent<IAimInfo>();
if (AimInfo == null)
{
Debug.LogWarning($"{owner.name}에는 인터페이스를 구현한 컴포넌트가 없습니다.");
}
}
public virtual void fire()
{
if (bCanFire == false) return;
bCanFire = false;
Vector3 origin = AimInfo.GetFireOrigin();
Vector3 direction = AimInfo.GetFireDirection();
Vector3 hitPoint = origin + direction * MaxDistance;
if (Physics.Raycast(origin, direction, out RaycastHit hit, MaxDistance))
{
hitPoint = hit.point;
Debug.DrawRay(origin, direction * hit.distance, Color.green, 1f);
Instantiate(data.effect, hitPoint, Quaternion.identity);
// TODO: hit.collider.SendMessage("TakeDamage", damage)
}
else
{
Debug.DrawRay(origin, direction * 100f, Color.red, 1f);
}
인터페이스 Aiminfo 함수에서 레퍼런스 파라미터를 받는 형식으로 교체를 했다. 이유는 AI 에임정보를 가져오는데 같은 코드를 여러번써야하는 부분들이 있어서 이게 맞는거 같다고 판단.
그리고 C#은 &를 이용하지않고 out이랑 ref 키워드를 이용해서 할 수 있다. out - 반환 전용, ref - 수정 및 반환용
- AI
1 2 3 4 5 6 7 8 9 10
public void GetFireInfo(out Vector3 origin, out Vector3 direction) { Transform muzzle = weapon.GetCurrentWeapon().data.muzzlePoint.transform; origin = muzzle.position; Vector3 TargetPosition = Target.transform.position; TargetPosition.y += 1; direction = (TargetPosition - origin).normalized; }
Player ```c#
public void GetFireInfo(out Vector3 origin, out Vector3 direction) { Camera cam = Camera.main; origin = cam.transform.position; direction = cam.transform.forward; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Fire액션에서는 OnStart에서 Fire코루틴 함수를 호출 Update 부분에서는 타겟을 위한 회전 Fire로 넘어가면서 Agent에서 이동을 멈춰 회전도 못따라가기 때문에 직접 구현
```c#
using System;
using Unity.Behavior;
using UnityEngine;
using Action = Unity.Behavior.Action;
using Unity.Properties;
[Serializable, GeneratePropertyBag]
[NodeDescription(name: "Fire", story: "[Self] Firing", category: "Action", id: "e5228f79ee4bb5328d1ffceb33134cdf")]
public partial class FireAction : Action
{
[SerializeReference] public BlackboardVariable<GameObject> Self;
private AIController Controller;
protected override Status OnStart()
{
GameObject AI = Self.Value;
if (AI == null) return Status.Failure;
Controller = AI.GetComponent<AIController>();
if (Controller == null) return Status.Failure;
Controller.StartFire();
return Status.Running;
}
protected override Status OnUpdate()
{
Controller.RotateToTarget();
return Status.Success;
}
protected override void OnEnd()
{
}
}
시야각에 들어오면 정상적으로 사격한다.
AI사격
이제 거리가 멀어지거나 시야에서 사라지면 다시 추격모드로 바꾸어 보는 코드를 만들어서 마무리해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
[Serializable, GeneratePropertyBag]
[NodeDescription(name: "Fire", story: "[Self] Firing", category: "Action", id: "e5228f79ee4bb5328d1ffceb33134cdf")]
public partial class FireAction : Action
{
[SerializeReference] public BlackboardVariable<GameObject> Self;
private AIController Controller;
protected override Status OnStart()
{
GameObject AI = Self.Value;
if (AI == null) return Status.Failure;
Controller = AI.GetComponent<AIController>();
if (Controller == null) return Status.Failure;
Controller.StartFire();
return Status.Running;
}
protected override Status OnUpdate()
{
Controller.RotateToTarget();
if (Controller.IsCanFire())
return Status.Running;
Controller.SetBehaviorState(BehaviorState.Chase);
return Status.Success;
}
protected override void OnEnd()
{
Controller.OffFire();
}
}
AI추격및사격
잘 적용되고, 다음 단계를 데미지/체력으로 진행한다.


