목차
팀 프로젝트
25.05.01
AI가 문이 닫혀있을 땐 통과하지 못하고 문이 열려있을 땐 통과하도록
: NavMeshObstacle을 활용
※ NavMeshObstacle
: NavMesh Agent가 World를 탐색하는 동안 피해야 하는 움직이는 장애물
--> 이미 Agent가 지나갈 수 있게 Bake한 상태에서도 Obstacle을 활성화하면 Agent가 그곳을 지나가지 못하게 막을 수 있다.
- Carve
- 비활성화 : Agent는 Obstacle과 충돌을 피하려고 하고, Obstacle과 가까우면 Collider와 같이 충돌한다. --> Agent가 Obstacle이 많은 환경에서 Obstacle을 피해갈 길을 찾지 못할 수 있다.
- 활성화 : Obstacle이 정지 중일 때, NavMesh에 구멍을 낸다. 따라서 Obstacle은 이동할 때 방해물이 된다. --> Agent는 Obstacle이 많은 환경에서 그 장소를 피해가도록 하거나 현재 경로가 Obstacle에 막힐 경우 다른 길을 찾을 수 있다.
https://docs.unity3d.com/kr/560/Manual/class-NavMeshObstacle.html
내비메시 장애물 - Unity 매뉴얼
Nav Mesh Obstacle 컴포넌트로 내비메시 에이전트가 월드를 탐색하는 동안 피해야 하는 움직이는 장애물(예: 물리 시스템에 의해 제어되는 배럴 또는 크레이트)을 설명할 수 있습니다. 장애물이 움직
docs.unity3d.com
>> NavMeshObstacle 세팅
- 문 Object에 'NavMeshObstacle' Component를 붙인다.
- NavMesh를 Bake한다. --> 이때, NavMeshObstacle은 활성화든 비활성화든 상관없다.
--> 이때, 문을 통해 NavMesh가 이어져야 한다. 그런 다음 문이 닫혀있을 때는 Obstacle을 통해 NavMesh를 끊어주고 문이 열리면 Obstacle을 비활성화하여 NavMesh가 다시 이어지도록 하면 된다.
>> Script에서 Obstacle 제어
: 문이 열리면 Obstacle을 SetActive(false)로, 문이 닫히면 Obstacle을 SetActive(true)로 바꿔준다.
--> 만든 'Door_Blocker'를 바인딩
- 예시 코드
using UnityEngine;
using UnityEngine.AI;
public class DoorController : MonoBehaviour
{
[SerializeField] private GameObject navObstacleBlocker;
bool isOpen = false;
public void ToggleDoor()
{
if (isOpen)
CloseDoor();
else
OpenDoor();
}
void OpenDoor()
{
navObstacleBlocker.SetActive(false);
isOpen = true;
}
void CloseDoor()
{
navObstacleBlocker.SetActive(true);
isOpen = false;
}
}
└ 문제 발생 및 해결 과정
>> 문의 유무와 상관없이 Bake했을 때 통로가 막히는 문제
- 아래 사진과 같이 전체를 다 비활성화하면 당연히 통로가 무난히 Bake된다.
: 하지만 그러면 문과 벽을 다 뚫고 통과하게 된다.
- 아래 사진과 같이 문만 비활성화하고 Bake하면 문을 통해 이어져야 하는데, 이어지지 않는 문제가 발생했다.
- 이것저것 다 테스트하는데도 안 돼서 도대체 뭐가 문제인가 싶어서 하나씩 Active를 켜고 끄면서 Bake해보며 테스트 해보았다.
: 근데 아무 상관 없어야 할 부모 오브젝트인 'Door_Corrider (9)'를 켜고 끄는 것에 따라 Bake 결과가 달랐다.
--> 알고보니 처음에 NavMeshObstacle을 테스트를 한다고 넣어놓은 이게 문제의 원인이었다.
- 그렇게 NavMeshObstacle을 삭제한 다음 다시 Bake해봤지만, 그대로 NavMesh가 이어지지 않았다.
: 문과 함께있는 벽의 Collider가 다음 사진과 같은데, 문의 너비가 좁아서 생기는 문제인 듯 하다.
- 새롭게 빈 게임 오브젝트를 만들어서, 기존의 Collider보다는 더 넓게 벽에 따로 Obstacle을 추가 및 배치했다.
- 그리고 Script로 활성화/비활성화할 Obstacle도 추가 및 배치했다.
- 'WallBlocker'를 제외한 다른 자식 오브젝트들을 비활성화한 다음 Bake, 이후 다시 전부 활성화
: 드디어 NavMesh가 이어진 모습
- 드디어 테스트 성공!
: 문이 열릴 때 문을 뚫고 들어오는 건 어쩔 수 없지만 그래도 원하는 결과를 이끌어 냈다.
--> 다만 이 방식으로 맵에 있는 모든 문에 적용하기가 굉장히 까다롭다는 단점이 있다.
※ 갑자기 생각난 또 다른 방법
: Bake할 때, Agent의 Radius를 줄이면 무난히 통과하지 않을까?
--> 열심히 찾아본 게 무색할 정도로 간단히 통과한다. 심지어 다른 문에 적용할 때에도 비교적 간단하다.
--> 점프가 없는 게임이기 때문에 Step Height를 0.16으로 설정하여 낮은 구조물도 올라가지 못하도록 설정 (0.16인 이유는 기본적으로 Step Height는 0.15를 초과하도록 설정하라고 권하기 때문)
>> 모든 문 Prefab에 Door_Blocker를 만들고 LockedDoor.cs에 바인딩하기
: 다른 분들과 소통한 다음 작업해야 하기 때문에 우선 미뤄둠.
--> 다시 팀장님의 branch에 merge 함
※ 팀장님과 소통 내용
: 만든 기능이 멀티로도 문제없을지 다른 팀원 분들이랑 소통해보자, 그리고 서브 빌런이 Attack Animation을 사용할건지 얘기해보자.
25.05.02
: 드디어 Asset을 지원 받았다. 그래서 Animation 작업을 먼저 하려고 한다.
AI Animation
>> Asset의 ZombieNurse Prefab을 복사하여 사용
: 이름은 'ZombieNurse_SubAI'
>> ZombieNurse의 Animator Controller 생성
: 이름은 'ZombieNurse_SubAI' --> 생성한 후, ZombieNurse_SubAI에 Animator Controller 바인딩
>> Animation 추가
- 'Idle2' : Idle로 이름 변경
- 'Walk' : 'Patrol'로 이름 변경
- Blend Tree를 만들어서 'Trace'로 이름 변경
--> Animation들도 Asset에 있는 Animation들을 복사하여 사용 (사진은 복사하기 전)
>> Make Transition
: Parameter 추가하고 Transition 조건 설정
--> Bool 타입으로 'Idle', 'Patrol', 'Trace' 추가
└ 문제 발생 및 해결 과정
>> Animation이 부자연스러운 문제
: 회전할 때, 'Walk <-> Run' 전환될 때, Player와 가까이 있을 때 제자리 걸음을 하는 등
>> Blend Type을 수정하고 Position(X, Y)를 활용하여 자연스러운 회전 구현
- Blend Type : 2D Freeform Cartesian
- X : 회전 각도 (TurnAngle)
- Y : 속도 (Speed)
>> SubAIStateTrace.cs에서 Parameter 세팅
- TurnAngle : SignedAngle(forward, desiredDir) --> 회전 방향
- Speed : velocity.magnitude --> 걷기 / 뛰기 전환용
- 코드
// 속도 및 회전값 변수
private float speed;
private float angle;
private Vector3 desiredDir;
public void Update()
{
// 회전 Animation
desiredDir = _subAIController.Agent.desiredVelocity.normalized;
if (desiredDir.sqrMagnitude > 0.1f)
{
angle = Vector3.SignedAngle(_subAIController.transform.forward, desiredDir, Vector3.up);
speed = _subAIController.Agent.velocity.magnitude;
// Animation 결정
_subAIController.SubAIAnimator.SetFloat("TurnAngle", angle);
_subAIController.SubAIAnimator.SetFloat(Speed, speed);
if (Mathf.Abs(angle) > 140f)
{
_subAIController.SubAIAnimator.SetTrigger("Turn180");
}
}
}
>> 'Run -> Walk'의 모션이 부자연스러운 문제
: 회전 Animation를 자연스럽게 만들면서 Player와 가까워질 때 Run에서 Walk로 전환되면서 부자연스러웠던 부분까지 함께 해결되었다. (원래는 Player와 멀 때만 속도가 증가하도록 코드가 짜여있어서 이 부분을 수정하려 했다.)
--> 다만 서브 빌런의 시야에 있던 Player가 서브 빌런의 옆이나 반대 등으로 도망칠 때, 서브 빌런이 Run 상태로 쫓아오다가 회전하려고 할 때 다시 Walk 상태가 되면서 여전히 'Run -> Walk'의 모션이 어색한 문제가 남아있다.
정기 회의
>> 서브 빌런이 Player를 공격했을 때, 어떻게 처리할 것인가?
- Player에 대한 처리 : Player를 1초 정도 아예 움직이지 못하도록 하고 그 뒤에 일정 시간 동안 슬로우 디버프를 걸기로 결정 --> 추가로 달리기를 위한 스테미나를 못 쓰게 할수도 있다.
- 서브 빌런에 대한 처리 : Attack Animation을 추가하기로 결정. 그리고 공격한 뒤 랜덤 위치로 순간이동해서 공격한 위치에서 사라지고 새로 스폰된 것처럼 하기로 결정 --> 이왕이면 공격한 지점에서 먼 곳에서 나오도록 하는 게 좋지 않을까
- 서브 빌런 인원수에 대한 얘기 : Player의 로컬에 1명씩 배치하기로 했으나 난이도가 너무 쉽지 않을까 하는 생각을 대부분 가짐 --> 이 부분은 언제든 추가할 수 있는 것이니, 추후 QA를 할 때 난이도를 보고 결정하기로 함.
>> 팀원 분들의 Animation Tip
- 'Walk'와 'Run'을 Blend Tree말고 그냥 State로 구분한 다음 Settings에서 Animation을 살짝 겹쳐놓으면 자연스럽게 이어진다.
- 서브 빌런이 회전할 때, Animation 속도를 조정해보면 'Run -> Walk'의 모션이 좀 자연스러워지지 않을까? 하는 의견
25.05.03
AI Animation
>> 지난 시간에 이어 'Run->Walk'의 모션이 어색한 문제
: 서브 빌런의 시야에 있던 Player가 서브 빌런의 옆이나 반대 등으로 도망칠 때, 서브 빌런이 Run 상태로 쫓아오다가 회전하려고 할 때 다시 Walk 상태가 되면서 여전히 'Run -> Walk'의 모션이 어색한 문제
- Blend Tree의 Threshold 간격을 조정
: Walk와 StrafeLeft, StrafeRight의 PosY를 0으로, Run의 PosY를 2로 Threshold 간격을 넓힘
- Update 코드에서 SetFloat()를 DampTime과 함께 사용하여 선형적으로 조절
: Speed가 갑자기 변경되지 않고 서서히 증가 or 감소되어 자연스러워진다.
// 속도 및 회전값 변수
private float speed;
private float angle;
private Vector3 desiredDir;
public void Update()
{
// 회전 Animation
desiredDir = _subAIController.Agent.desiredVelocity.normalized;
if (desiredDir.sqrMagnitude > 0.1f)
{
angle = Vector3.SignedAngle(_subAIController.transform.forward, desiredDir, Vector3.up);
speed = _subAIController.Agent.velocity.magnitude;
// Animation 결정
_subAIController.SubAIAnimator.SetFloat("TurnAngle", angle, 0.2f, Time.deltaTime);
_subAIController.SubAIAnimator.SetFloat(Speed, speed, 0.2f, Time.deltaTime);
if (Mathf.Abs(angle) > 140f)
{
_subAIController.SubAIAnimator.SetTrigger("Turn180");
}
}
}
▶ 결과가 썩 마음에 들진 않지만, 일단 Animation보다 아직 구현하지 못한 기능을 만드는 것이 더 중요하기 때문에 우선 여기서 킵
: 추후 Blend Tree가 아닌 State 방식으로 새롭게 만들어서 테스트 해볼 예정
일정 주기마다 서브 빌런이 복도의 랜덤한 위치로 텔레포트하는 기능 구현
: 텔레포트할 복도의 위치는 따로 지정한 다음, 배열로 받아서 랜덤으로 이동하도록 --> 주기는 2분
>> 텔레포트할 위치를 지정
: NavMesh 위에 있어야 Agent가 오류 없이 작동한다.
--> 사진과 같이 NavMesh 위에, AI가 텔레포트 했을 때 바라볼 방향까지 신경써서 맵의 여러 곳에 배치
>> 코드 작성
: SubAIController.cs
using UnityEngine;
public class SubAIController : MonoBehaviour
{
// 텔레포트 관련 변수 (위치, 주기)
[SerializeField] private Transform[] teleportPositions;
[SerializeField] private float teleportCooldown = 120f; // 2분 주기
private float _teleportTimer = 0f;
private void Update()
{
// 일정 주기마다 텔레포트
_teleportTimer += Time.deltaTime;
if (_teleportTimer >= teleportCooldown)
{
TeleportToRandomPosition();
_teleportTimer = 0f;
}
}
// 지정한 장소 중 랜덤한 위치로 텔레포트
private void TeleportToRandomPosition()
{
if (teleportPositions == null || teleportPositions.Length == 0) return;
int index = Random.Range(0, teleportPositions.Length);
Transform target = teleportPositions[index];
// NavMeshAgent를 일시적으로 끄고 텔레포트
Agent.enabled = false;
transform.position = target.position;
transform.rotation = target.rotation;
Agent.enabled = true;
// 목적지 재설정
Agent.SetDestination(target.position);
// 상태 초기화
SetState(SubAIState.Idle);
}
}
>> ZombieNurse에 TeleportPositions 바인딩
25.05.04
Attack 만들기
: 공격한 뒤 랜덤 위치로 텔레포트 하도록 구현할 예정이고, Attack Animation도 필요하다.
>> 공격한 뒤 랜덤 위치로 텔레포트 하도록 구현
: 공격 범위에 들어오려면 AI는 무조건 Trace 상태이므로 SubAIStateTrace.cs에서 AI의 전방으로 Raycast를 쏴서 'Player' Layer를 가진 Object가 닿으면 SubAIController.cs에 만들어 둔 TeleportToRandomPosition()을 활용하여 랜덤한 위치로 텔레포트 하도록 구현
└ 문제 발생 및 해결 과정
: 기존의 코드에서 Raycast를 쏴서 'Player' Layer를 가진 Object가 닿으면 TeleportToRandomPosition() 함수를 호출했더니 오류 발생
--> 기존 코드 및 오류 사진
public void Update()
{
// 전방에 Player가 있고, 공격 거리 안에 있으면 Player에게 방해공작
RaycastHit hit;
Vector3 transformPosition = _subAIController.transform.position;
transformPosition.y = 1f;
if (Physics.Raycast(transformPosition,
_subAIController.transform.forward,
out hit,
_subAIController.MaxAttackDistance,
_subAIController.TargetLayerMask))
{
// TODO: Player 방해 처리
// 랜덤 포인트 중 한 곳으로 텔레포트
_subAIController.TeleportToRandomPosition();
}
}
>> 오류가 난 라인
: TeleportToRandomPosition() 함수가 호출된 다음, 뜬금없이 회전 Animation과 관련된 코드에서 에러가 났다.
public void Update()
{
// 전방에 Player가 있고, 공격 거리 안에 있으면 Player에게 방해공작
RaycastHit hit;
Vector3 transformPosition = _subAIController.transform.position;
transformPosition.y = 1f;
if (Physics.Raycast(transformPosition,
_subAIController.transform.forward,
out hit,
_subAIController.MaxAttackDistance,
_subAIController.TargetLayerMask))
{
// TODO: Player 방해 처리
// 랜덤 포인트 중 한 곳으로 텔레포트
_subAIController.TeleportToRandomPosition();
}
// 회전 Animation
desiredDir = _subAIController.Agent.desiredVelocity.normalized; // 93번 줄
if (desiredDir.sqrMagnitude > 0.1f)
{
angle = Vector3.SignedAngle(_subAIController.transform.forward, desiredDir, Vector3.up);
speed = _subAIController.Agent.velocity.magnitude;
// Animation 결정
_subAIController.SubAIAnimator.SetFloat("TurnAngle", angle, 0.2f, Time.deltaTime);
_subAIController.SubAIAnimator.SetFloat(Speed, speed, 0.2f, Time.deltaTime);
}
}
- 문제 원인 및 해결
: TeleportToRandomPosition() 함수를 보면 마지막에 상태를 Idle로 바꿔주는데, 이때 Trace 상태의 Exit이 실행되면서 '_subAIController'가 null이 되어 문제가 발생
--> TeleportToRandomPosition() 함수를 호출한 뒤, return을 해서 그 이후 코드가 실행되지 않도록 수정하여 해결
public void Update()
{
// 전방에 Player가 있고, 공격 거리 안에 있으면 Player에게 방해공작
RaycastHit hit;
Vector3 transformPosition = _subAIController.transform.position;
transformPosition.y = 1f;
if (Physics.Raycast(transformPosition,
_subAIController.transform.forward,
out hit,
_subAIController.MaxAttackDistance,
_subAIController.TargetLayerMask))
{
// TODO: Player 방해 처리
// 랜덤 포인트 중 한 곳으로 텔레포트
_subAIController.TeleportToRandomPosition();
return; // 추가하여 문제 해결
}
}
>> 처음 1번은 작동하지만 그 이후 작동하지 않는 문제
: 맨 처음엔 정상적으로 작동하지만, Teleport된 이후로는 제대로 작동하지 않는 문제 발생
- 문제 원인 및 해결
: Map의 바닥이 기본적으로 1f의 높이를 가지고 있고, 2층까지 있어서 2층의 바닥은 기본적으로 5f의 높이를 가지고 있다. 근데 Raycast를 쏠 때, Raycast를 쏘는 y의 Position을 1f로 고정해버리는 바람에 Teleport를 한 뒤에는 Raycast가 바닥의 높이에서 발사되어 정상적으로 인식하지 못하면서 문제가 발생
▶ 처음에 테스트할 때 바닥의 높이가 0이었기에 Raycast를 쏘는 높이를 1f로 높여서 테스트 했었던 것이 문제를 일으킴
--> Raycast를 쏘는 높이를 AI의 Position.y를 기준으로 1f 높여서 문제를 해결, AI가 1층에 있든 2층에 있든 AI의 Position.y가 기준이기 때문에 상관없을 것이다.
public void Update()
{
// 전방에 Player가 있고, 공격 거리 안에 있으면 Player에게 방해공작
RaycastHit hit;
Vector3 transformPosition = _subAIController.transform.position + Vector3.up * 1f; // Ray를 쏘는 높이를 올려줌
if (Physics.Raycast(transformPosition,
_subAIController.transform.forward,
out hit,
_subAIController.MaxAttackDistance,
_subAIController.TargetLayerMask))
{
// TODO: Player 방해 처리
// 랜덤 포인트 중 한 곳으로 텔레포트
_subAIController.TeleportToRandomPosition();
return;
}
}
25.05.07
Attack Animation 만들기
: Attack Animation이 재생될 때는 AI가 움직이지 않도록 하고, Attack Animation이 끝난 후 텔레포트 하도록
--> Animation이 다 재생한 다음 Teleport 하도록 Animation의 마지막 프레임에 Event를 추가하여 구현
>> Animation 추가
: Parameter를 Trigger 타입으로 'Attack' 추가, Attack Animation을 추가한 다음 Make Transition
--> 'Attack -> Idle'은 다른 설정 없이 Make Transition만 해두기
>> Animation에 Event 추가
: Animation이 끝나는 프레임에 'Attack End' Event를 추가하여 Animation이 끝난 다음 Teleport가 작동하도록 구현
--> SubAIController.cs에 코드 작성
// Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
TeleportToRandomPosition();
}
└ 문제 발생 및 해결 과정
: AI의 공격 범위에 들어가지 않고 심지어 정면에 있지 않아도 즉, Raycast를 맞지 않아도 탐지 범위에만 들어가면 곧바로 Attack Animation이 실행되고 Animation이 끝나면 Teleport하는 문제가 발생, 근데 이 문제가 한번은 Attack Animation 없이 공격 범위에 들어오면 Teleport하는 문제와 번갈아가면서 발생
--> 분명 Attack Animation을 실행하는 Trigger가 Raycast에 맞았을 때 작동하도록 코드를 작성했는데 그 외의 상황에서 Animation이 작동하는 것도 의문이고 아예 Animation이 실행되지 않고 나머지 기능은 정상작동하는 현상과 번갈아가며 나타나는 것도 의문이다.
1. Distance 테스트
: 처음에는 Raycast의 MaxAttackDistance가 제대로 작동하는 지 확인하기 위해 Player와 AI간의 Distance를 구해 Distance가 MaxAttackDistance 보다 작을 때만 Raycast를 쏘도록 코드를 작성해서 테스트 해보았다.
▶ Attack Animation 중에 AI가 움직이는 것도 방지하기 위해서 Agent.isStopped와 Agent.velocity도 설정해줬다.
--> 작성한 코드
public void Update()
{
float distance = Vector3.Distance(_subAIController.transform.position, _detectPlayerTransform.position);
if (distance <= _subAIController.MaxAttackDistance)
{
RaycastHit hit;
if (Physics.Raycast(_subAIController.transform.position + Vector3.up * 1f, // Ray를 쏘는 높이를 올려줌,
_subAIController.transform.forward,
out hit,
_subAIController.MaxAttackDistance,
_subAIController.TargetLayerMask))
{
// Attack Animation 실행
_subAIController.Agent.isStopped = true; // 공격 중 이동 방지
_subAIController.Agent.velocity = Vector3.zero; // 움직이던 관성도 제거
_subAIController.SubAIAnimator.SetTrigger(Attack);
// TODO: Player 방해 처리
// Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
_subAIController.AttackEnd();
return;
}
}
}
// Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
_subAIController.Agent.isStopped = false; // 공격 중 이동 방지 해제
TeleportToRandomPosition();
}
- 테스트 결과
: Distance와 상관없이 문제가 지속됐다.
2. Transform 테스트
: Raycast에 맞은 Transform과 감지된 Player의 Transform과 같을 때만 Attack을 실행하도록 코드를 작성하여 테스트 해보았다.
--> 작성한 코드
public void Update()
{
RaycastHit hit;
if (Physics.Raycast(_subAIController.transform.position + Vector3.up * 1f, // Ray를 쏘는 높이를 올려줌,
_subAIController.transform.forward,
out hit,
_subAIController.MaxAttackDistance,
_subAIController.TargetLayerMask))
{
if (hit.transform == _detectPlayerTransform)
{
// Attack Animation 실행
_subAIController.Agent.isStopped = true; // 공격 중 이동 방지
_subAIController.Agent.velocity = Vector3.zero; // 움직이던 관성도 제거
_subAIController.SubAIAnimator.SetTrigger(Attack);
// TODO: Player 방해 처리
// Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
_subAIController.AttackEnd();
return;
}
}
}
- 테스트 결과
: 오히려 Attack이 실행되지 않았다.
--> _detectPlayerTransform이 1초마다 새롭게 갱신되므로 AI가 Attack하러 다가오는 동안 Player가 움직이면 위치가 바뀌어서 그런 것 같다.
정기 회의
>> Player가 Hide 상태일 때, AI가 감지하지 못하게 하기
: LayerMask로 'HidePoint'를 확인해서 처리하도록 만들자 --> 다른 팀원이 만드신 메인 빌런을 참고하자
>> 서브 빌런이 카메라에 찍혔을 때
: 길게 스턴되도록 구현할건데, 'Idle3' Animation을 적용하자 --> 스턴은 따로 상태로 만들어서 구현하자
>> 남은 기간 동안 방향성
: 최대한 Animation을 자연스럽게 만드는 방향
25.05.08
Attack Animation 만들기
: Attack Animation이 재생될 때는 AI가 움직이지 않도록 하고, Attack Animation이 끝난 후 텔레포트 하도록
--> 어제에 이어 버그 해결
└ 문제 발생 및 해결 과정
: AI의 공격 범위에 들어가지 않고 심지어 정면에 있지 않아도 즉, Raycast를 맞지 않아도 탐지 범위에만 들어가면 곧바로 Attack Animation이 실행되고 Animation이 끝나면 Teleport하는 문제가 발생, 근데 이 문제가 한번은 Attack Animation 없이 공격 범위에 들어오면 Teleport하는 문제와 번갈아가면서 발생
1. AttackEnd()를 Update에서 실행하지 않도록
: Update문에서 AttackEnd()가 Animation의 재생 시간과 상관없이 즉시 실행되는 것이 문제였다.
--> Animation Event는 따로 실행시킬 필요없이 코드를 작성만 해두면 Animation Event를 추가해놓은 프레임에 알아서 실행된다.
▶ Agent.isStopped와 Agent.velocity를 다시 삭제
- 문제가 된 실행 순서
- Raycast에 맞으면 SetTrigger(Attack)으로 Attack Animation 실행
- Animation 실행과 동시에 AttackEnd()도 실행되어 TeleportToRandomPosition()이 실행됨
- TeleportToRandomPosition()이 실행되면서 다시 Idle로 전환됨
--> 결과적으로 Attack Animation은 나오지 않고 랜덤한 위치로 Teleport하는 문제가 발생한 것
- 작성한 코드
public void Update()
{
RaycastHit hit;
if (Physics.Raycast(_subAIController.transform.position + Vector3.up * 1f, // Ray를 쏘는 높이를 올려줌,
_subAIController.transform.forward,
out hit,
_subAIController.MaxAttackDistance,
_subAIController.TargetLayerMask))
{
// TODO: Player 방해 처리
// Attack Animation 실행
_subAIController.SubAIAnimator.SetTrigger(Attack);
return;
}
}
// Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
TeleportToRandomPosition();
}
- 테스트 결과
: 그래도 아직 Raycast를 맞지 않아도 탐지 범위에만 들어가면 곧바로 Attack Animation이 실행된 다음 Teleport하는 문제가 남아있음. --> 문제가 간헐적으로 발생함 (정상적으로 작동할 때도 있음)
2. 'Attack' State를 추가
: Attack State를 새로 만들어서 코드를 작성하고 실행하니 모든 문제가 해결됐다. 솔직히 아직은 왜 해결됐는지 잘 모르겠다. 정확한 원인은 나중에 다시 알아보자.
- 작성한 코드
: SubAIController.cs에서 enum에 Attack을 추가하고 상태 변수 및 객체를 생성해주고 Dictionary에도 추가해주기
>> SubAIStateTrace.cs
public void Update()
{
RaycastHit hit;
if (Physics.Raycast(_subAIController.transform.position + Vector3.up * 1f, // Ray를 쏘는 높이를 올려줌,
_subAIController.transform.forward,
out hit,
_subAIController.MaxAttackDistance,
_subAIController.TargetLayerMask))
{
// TODO: Player 방해 처리
// Attack Animation 실행
_subAIController.SetState(SubAIState.Attack);
return;
}
}
>> SubAIStateAttack.cs
using UnityEngine;
public class SubAIStateAttack : ISubAIState
{
private static readonly int Attack = Animator.StringToHash("Attack");
private SubAIController _subAIController;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.SubAIAnimator.SetTrigger(Attack);
}
public void Update()
{
}
public void Exit()
{
_subAIController = null;
}
}
25.05.09
Stun State 만들기
: 카메라로 AI를 찍었을 때, Animation과 함께 길게 스턴에 걸리도록
>> Stun Animation Loop 설정
: Stun의 시간이 Animation보다 길더라도 Stun Animation을 반복하도록 Animation을 Loop
--> Idle3 Animation을 복사, 붙여넣기하여 이름을 'Stun_SubAI'로 바꾸고 Loop 설정
>> Animator 세팅
: Parameter를 Bool 타입으로 'Stun' 추가, Stun Animation을 추가한 다음 Make Transition
--> 'Trace -> Stun'도 마찬가지로 Make Transition
>> SubAIStateStun.cs 생성 및 코드 작성
- SubAIController.cs에서 enum에 Stun을 추가하고 상태 변수 및 객체를 생성해주고 Dictionary에도 추가해주기
- Hierarchy에서 Stun 지속 시간을 수정하기 쉽도록 SubAIController.cs에 SerializeField로 'maxStunTime'을 추가
// 스턴 관련 변수
public float MaxStunTime => maxStunTime;
[SerializeField] private float maxStunTime = 5f;
using UnityEngine;
public class SubAIStateStun : ISubAIState
{
private static readonly int Stun = Animator.StringToHash("Stun");
private SubAIController _subAIController;
private float _stunTimer = 0f;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_stunTimer = 0f;
_subAIController.Agent.isStopped = true;
_subAIController.SubAIAnimator.SetBool(Stun, true);
}
public void Update()
{
if (_stunTimer >= _subAIController.MaxStunTime)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
_stunTimer += Time.deltaTime;
}
public void Exit()
{
_subAIController.Agent.isStopped = false;
_subAIController.SubAIAnimator.SetBool(Stun, false);
_subAIController = null;
}
}
>> 추후, 카메라에 맞았을 때 Stun State로 바꿔주는 코드 작성하기
_subAIController.SetState(SubAIState.Stun);
AI가 Patrol Point를 더 잘 찾도록 보완
: AI가 벽에 가까이 붙어있거나 맵의 가장자리에 있어서 주변에 NavMesh가 적으면 RandomPoint를 못 찾아서 제자리에 서있는 시간이 늘어나기 때문에 이를 보완
--> SubAIStatePatrol.cs의 FindRandomPatrolPoint() 함수에서 RandomPoint를 찾는 과정을 10번 반복하고 그 중에 발견한 곳을 반환하도록 보완
>> 수정한 코드
private int _tryToFindRandomPoint = 10;
// 정찰 위치 랜덤 결정
private Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * _subAIController.PatrolCircleRadius;
randomDirection += _subAIController.transform.position;
NavMeshHit hit;
if (NavMesh.SamplePosition(randomDirection, out hit,
_subAIController.DetectCircleRadius, NavMesh.AllAreas))
{
return hit.position;
}
}
// 실패하면 현재 위치 반환
return _subAIController.transform.position;
}
Turn 구현
: 이동해야 할 경로가 AI가 바라보고 있는 방향의 반대라면 Turn하도록
--> 우선 Idle → Patrol 할 때만 Turn하도록 먼저 구현해보고 성공하면 Idle → Trace, Patrol → Trace 등 확장 적용할 예정
>> 구현 방식
: Patrol Point와 현재 AI가 바라보고 있는 방향의 각도를 비교하여 150도 이상 차이가 나면 Turn Animation을 실행, Animation이 끝난 후 Patrol 상태로 변경
--> Bool 타입으로 변수를 만들어서 Turn하는 중에는 다른 작업을 하지 않도록 조치
>> 수정한 코드
- SubAIStateIdle.cs
using System.Collections;
using UnityEngine;
public class SubAIStateIdle : ISubAIState
{
private static readonly int Idle = Animator.StringToHash("Idle");
private static readonly int Turn180 = Animator.StringToHash("Turn180");
private SubAIController _subAIController;
// Idle에서 Patrol로 상태를 바꾸기 전, 현재 대기 시간 변수
private float _patrolWaitTime = 0f;
// Turn하는 중인지 판단하기 위한 변수
private bool _isTurning = false;
// 다음 Patrol 지점을 저장해두는 변수
private Vector3 _nextPatrolPoint;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.SubAIAnimator.SetBool(Idle, true);
_subAIController.Agent.isStopped = true;
}
public void Update()
{
Debug.Log($"_isTurning: {_isTurning}");
// Turn하는 중이라면 다른 작업을 하지 않도록
if (_isTurning) return;
// 탐지 범위 안에 Player가 있는 지 확인
var detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (detectPlayerTransform)
{
_subAIController.SetState(SubAIState.Trace);
return;
}
// 정찰 여부 판단
if (_patrolWaitTime > _subAIController.MaxPatrolWaitTime && Random.Range(0, 100) < 30)
{
_nextPatrolPoint = _subAIController.FindRandomPatrolPoint();
// Patrol Point를 못 찾았을 때, 다시 Patrol Point를 찾도록 대기 시간 초기화
if (_nextPatrolPoint == _subAIController.transform.position)
{
_patrolWaitTime = 0f;
return;
}
Vector3 dirToPatrolPoint = (_nextPatrolPoint - _subAIController.transform.position).normalized;
float angle = Vector3.Angle(_subAIController.transform.forward, dirToPatrolPoint);
if (angle > 120f)
{
// Turn180 Animation 실행
_isTurning = true;
_subAIController.SubAIAnimator.SetTrigger(Turn180);
_subAIController.StartTurnCoroutine(WaitForTurning());
return;
}
// Turn하지 않으면 바로 Patrol로 전환
_subAIController.SetState(SubAIState.Patrol);
return;
}
_patrolWaitTime += Time.deltaTime;
}
public void Exit()
{
_patrolWaitTime = 0f;
_isTurning = false;
_subAIController.SubAIAnimator.SetBool(Idle, false);
_subAIController = null;
}
private IEnumerator WaitForTurning()
{
yield return new WaitForSeconds(3.5f); // Turn180 Animation 길이
_subAIController.SetState(SubAIState.Patrol);
}
}
--> 원활한 테스트를 위해 Turn하는 조건의 각도를 120도 이상으로 수정했다.
- SubAIController.cs
// Turn Coroutine
public void StartTurnCoroutine(IEnumerator coroutine)
{
StartCoroutine(coroutine);
}
>> 문제 발생
: Turn Animation을 재생하면 AI의 Rotation 수치는 변경하지 않으면서 눈에 보이기에만 뒤돌게 된다.
--> 그래서 결국 Turn Animation이 끝나고 Patrol 할 때 AI가 다시 걸어가며 뒤도는 Animation이 나오며 부자연스러워진다.
>> 해결하기 위한 작업
1. Root Motion
: Animation 자체에 Root Motion이 없다.
--> 적용되도록 하고 싶었는데, Animation Type이 Generic이라 Root Motion 관련 설정이 없다.
2. Generic Type을 Humanoid Type으로 변경
: Humanoid Type으로 변환은 가능했지만, 변환하고 나서 다시 ZombieNurse에 Animation을 적용해보면 T포즈를 취하며 적용되지 않는다.
3. Animation이 끝나는 타이밍에 맞춰서 AI의 Rotation을 돌리기
: Animation이 끝나는 프레임에 Animation Event를 추가해, AI를 Rotate해봤지만 여전히 Animation은 부자연스러웠다.
- 작성한 코드
: SubAIController.cs
// Animation Event --> 회전 후 AI Rotate
public void RotateAI()
{
// 현재 forward의 반대 방향을 기준
Vector3 backDirection = -transform.forward;
// 새로운 회전값 계산
Quaternion newRotation = Quaternion.LookRotation(backDirection, Vector3.up);
// 회전 적용
transform.rotation = newRotation;
}
▶ 우선 Turn은 나중으로 미루고 기능부터 완성하기로 결정
4. Turn Animation이 끝난 다음 Patrol 하도록 수정
: Turn Animation이 재생 중인데도 AI가 Patrol 상태로 전환되자마자 NavMeshAgent.SetDestination()이 즉시 회출되기 때문
--> Turn Animation이 끝난 다음 목적지를 향해 이동하도록 Idle 상태와 Patrol 상태 모두 목적지가 공유되어야 한다.
25.05.10
AI의 이동 속도 조정 및 속도에 따른 Animation 조정
: 이동 속도에 따라 Animation 속도를 조절하도록 코딩
--> 이동과 상관없는 상태일 땐 Animation 속도를 다시 초기화 해줘야 한다.
>> 이동 속도를 높이니 AI가 찾은 Player의 위치를 갱신하는 주기보다 AI가 찾은 Player의 위치로 이동하는 시간이 더 짧아서 Player의 위치로 이동하고 다음 위치가 갱신될 때까지 시간이 남아 주춤거리는 문제 발생
--> 갱신 주기를 0.3f로 수정
25.05.12
정기 회의
>> 게임 흐름 조정
: 기존의 게임 흐름은 <명패 기믹 → 소방벨 등 기믹 4가지 중 2가지 입력 → 컴퓨터에 유령 기믹 4가지 중 2가지 입력 → 석상 가지고 소환진에 넣어서 문 열리고 탈출> 이지만, 지금은 프로젝트 제출까지 시간이 너무 촉박해서 컴퓨터에 유령 기믹을 입력하는 것은 취소하고 스토리적 단서로 대체
--> 소방벨 등 기본 기믹도 원래 4가지 중 2가지를 하려 했으나, 유령 기믹을 대신하여 4가지 전부 하도록
AI가 Teleport할 때, 근처로 Teleport 되지 않도록 구현
: AI가 1층에 있다면 2층으로, 2층에 있다면 1층으로 Teleport하도록 구현
--> 1층의 높이는 1f, 2층의 높이는 5f이므로 AI의 transform.position.y가 3f를 기준으로 작으면 2층으로, 크면 1층으로 Teleport하도록 구현
>> 수정한 코드
: SubAIController.cs
// 지정한 장소 중 랜덤한 위치로 텔레포트
public void TeleportToRandomPosition()
{
if (teleportPositions == null || teleportPositions.Length == 0) return;
int index = -1;
int count = teleportPositions.Length / 2;
// AI가 1층에 있다면 2층으로, 2층에 있다면 1층으로 Teleport
// 1층의 높이 : 1f, 2층의 높이 : 5f / 1층의 [index] : 짝수, 2층의 [index] : 홀수
if (transform.position.y < 3f) // AI가 1층에 존재 --> 홀수 index만 뽑기
{
index = Random.Range(0, count) * 2 + 1;
}
else // AI가 2층에 존재 --> 짝수 index만 뽑기
{
index = Random.Range(0, count) * 2;
}
Transform target = teleportPositions[index];
// NavMeshAgent를 일시적으로 끄고 텔레포트
Agent.enabled = false;
transform.position = target.position;
transform.rotation = target.rotation;
Agent.enabled = true;
// 목적지 재설정
Agent.SetDestination(target.position);
// 상태 초기화
SetState(SubAIState.Idle);
}
PatrolAnimation 버그 수정
: AI가 Idle 상태에서 Patrol 상태로 바뀔 때 Walk Animation이 먼저 재생되고 뒤이어 이동하는 문제
>> 문제 원인
: AI가 Patrol Point까지의 Path를 계산 중(Agent.pathPending = true)이기 때문에 Patrol 상태가 됐을 때 곧바로 이동하지 않는데, 그 동안 Animator는 SetBool(Patrol, true)로 즉시 Animation을 실행하기 때문에 발생한 문제.
>> 문제 해결
: Coroutine을 사용하여 pathPending 이후로 Animator에서 Animation을 실행하도록 수정
>> 수정한 코드
: SubAIStatePatrol.cs
using System.Collections;
using UnityEngine;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.SetNormalSpeed(); // 이동 속도 조절
_subAIController.ResetAnimationSpeed(); // 이동 속도에 따른 Animation 재생 속도 조절
// 랜덤으로 정찰 위치를 구하고, 있으면 해당 위치로 이동, 없으면 다시 Idle 상태로 전환
var patrolPoint = FindRandomPatrolPoint();
if (patrolPoint == _subAIController.transform.position)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(patrolPoint);
_subAIController.StartCoroutine(WaitPatrolAnimation());
}
// 경로 계산이 끝날 때까지 Animation을 지연시키는 Coroutine
private IEnumerator WaitPatrolAnimation()
{
// 경로 계산 중이면 대기
while (_subAIController.Agent.pathPending)
yield return null;
// 이동이 시작되면 Animation 실행
_subAIController.SubAIAnimator.SetBool(Patrol, true);
}
이전에 이어서 Turn 구현
: 이동해야 할 경로가 AI가 바라보고 있는 방향의 반대라면 Turn하도록
--> 우선 Idle → Patrol 할 때만 Turn하도록 먼저 구현해보고 성공하면 Idle → Trace, Patrol → Trace 등 확장 적용할 예정
>> 이전에 해결 못한 문제
: Turn Animation이 끝나고 Patrol 할 때 AI가 다시 걸어가며 뒤도는 Animation이 나오며 부자연스러워진다.
>> 해결하기 위한 작업
└ Turn Animation이 끝난 다음 Patrol 하도록
: Turn Animation이 재생 중인데도 AI가 Patrol 상태로 전환되자마자 NavMeshAgent.SetDestination()이 즉시 호출되기 때문에 생긴 문제 --> Turn Animation이 끝난 다음 목적지를 향해 이동하도록 Idle 상태와 Patrol 상태 모두 목적지가 공유되어야 한다.
- SubAIStatePatrol에 목적지 전달 기능 추가
private Vector3? _customPatrolPoint = null;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.SetNormalSpeed();
_subAIController.ResetAnimationSpeed();
// 랜덤으로 정찰 위치를 구하고, 있으면 해당 위치로 이동, 없으면 다시 Idle 상태로 전환
Vector3 patrolPoint = _customPatrolPoint ?? _subAIController.FindRandomPatrolPoint();
_customPatrolPoint = null; // 초기화
if (patrolPoint == _subAIController.transform.position)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(patrolPoint);
_subAIController.StartCoroutine(WaitPatrolAnimation());
}
public void SetCustomPatrolPoint(Vector3 point)
{
_customPatrolPoint = point;
}
- SubAIStateIdle의 WaitForTurning에서 SetState 전에 Patrol 위치 전달
private IEnumerator WaitForTurning()
{
yield return new WaitForSeconds(3.25f); // Turn180 Animation 길이
_subAIController.SetNextPatrolPoint(_nextPatrolPoint); // 목적지 미리 설정
_subAIController.SetState(SubAIState.Patrol); // 이제 이동 시작
}
- SubAIController.cs에 중계 함수 추가
public void SetNextPatrolPoint(Vector3 point)
{
if (_aiStates[SubAIState.Patrol] is SubAIStatePatrol patrolState)
{
patrolState.SetCustomPatrolPoint(point);
}
}
▶ 결과 : 코드를 수정해봤지만 여전히 같은 문제 발생, 해결할 시간이 부족하여 결국 Turn Animaion은 포기하기로 결정하고 우선 Merge함
25.05.13
AI가 한 쪽 문이 열려있어도 그 문으로 지나가지 않고 버벅거리는 문제
: 양 쪽 문 중에 한 쪽 문만 열려있을 때, Player가 닫힌 문 쪽에 서있거나 Patrol Point가 닫힌 문 쪽에 있으면 AI가 열린 문을 통해 지나가지 않고 닫힌 문 쪽으로 지나가려고 하면서 지나가지 못 하는 문제
>> 문제 원인
: NavMesh로는 닫힌 문도 Bake 되어 있는데, NavMesh Obstacle로 AI가 가지 못하게 막혀있어도 NavMesh는 여전히 Bake 되어 있기 때문에 닫힌 문을 통해 가려고 하는 것이 문제 --> 양쪽 문을 다 열 수 있는 문에만 해당되는 문제
>> 문제 해결
: NavMesh Obstacle의 'Carve' 설정을 체크하여 Obstacle이 NavMesh를 아예 끊도록 만들어서 열린 문을 통해 지나가도록 유도하여 해결
멀티 플레이 환경에서 AI가 문이 열려있음에도 지나가지 못 하는 문제
: 멀티 플레이 환경에서 AI가 문이 한 쪽이든 양 쪽이든 열려있어도, 심지어 탐지 범위 안에 있어도 계속 지나가지 못 하는 문제
--> 혼자서 테스트할 때는 이러한 문제가 발생하지 않았다.
>> 문제 원인
: 내가 문을 닫아놓았는데, 다른 플레이어가 문을 열면 AI가 문을 지나가지 못하도록 막아주는 'GhostBlock'이 SetActive(false)되지 않고 true로 남아있어서 AI가 지나가지 못 하게 된 것이었다.
--> GhostBlock에 대한 처리가 로컬 환경에서 이루어져서 발생한 문제
>> 문제 해결
: 멀티 플레이 환경에서도 GhostBlock에 대한 처리가 일어나도록 코드를 수정하여 해결
25.05.14
Player가 AI를 피해 방에 숨으면 갇히는 문제
: 현재 Player가 AI를 피해서 방의 입구가 하나 뿐인 곳으로 숨으면 그 방도 여전히 AI의 탐지 범위 안에 있기 때문에 AI가 계속 문 앞을 지키고 서있게 돼서 Player가 방에 갇히는 문제
--> 물론 주기적으로 2분마다 무작위 장소로 Teleport하지만 너무 길다.
>> 문제 해결
: AI가 Trace 상태를 일정 시간 이상 지속하면 무작위 장소로 Teleport 하도록 구현하여 해결
>> 수정한 코드
: SubAIStateTrace.cs
// Trace를 유지하는 시간 변수
private float _maxTraceTime = 30f;
// Trace 시간 변수
private float _traceTime = 0f;
public void Update()
{
if (_traceTime > _maxTraceTime)
{
_traceTime = 0f;
_subAIController.TeleportToRandomPosition();
return;
}
_traceTime += Time.deltaTime;
}
Player가 좁은 공간에 들어갔을 때 쫓아가지 못하는 문제
: Player가 구석과 같은 좁은 공간에 들어갔을 때 AI가 쫓아가지 못하고 제자리에서 걸어다니는 문제
>> 문제 원인
: 좁은 공간에 NavMesh가 Bake되어 있지 않아서, Player가 좁은 공간에 들어가면 NavMesh 위에 있지 않기 때문에 AI가 위치를 찾지 못하고 마지막으로 찾은 위치에서 멈춰있던 것
>> 문제 해결
: Player가 NavMesh 위에 있지 않더라고 AI가 최대한 Player 근처의 NavMesh로 이동하도록 로직을 수정하여 해결
--> NavMesh.SamplePosition()을 활용
- SubAIStateTrace.cs
using UnityEngine.AI;
public void Update()
{
// 일정 주기로 찾은 Player의 위치를 갱신해서 AI를 갱신된 위치의 최대한 근처로 이동
if (_detectPlayerTime > _maxDetectPlayerUpdateTime)
{
Vector3 targetPos = _detectPlayerTransform.position;
NavMeshHit navMeshHit;
if (NavMesh.SamplePosition(targetPos, out navMeshHit, 2f, NavMesh.AllAreas))
{
_subAIController.Agent.SetDestination(navMeshHit.position);
}
else
{
_subAIController.SetState(SubAIState.Idle);
return;
}
_detectPlayerTime = 0f;
}
_detectPlayerTime += Time.deltaTime;
}
※ AI가 Player를 Trace하는 로직에 대해 갑자기 든 의문
: Trace 상태일 때, Player의 위치는 Enter에서 한번만 갱신해주는데, 어떻게 AI는 Update에서 계속 Player의 위치를 새롭게 갱신해서 SetDestination 할 수 있는가?
--> 모든 GameObject의 Transform.Position은 참조 기반으로 작동하기 때문에 한 번 Player의 Transform을 저장해두면 Update에서 읽을 때마다 Player의 “현재 위치”가 나오게 된다.
AI의 공격 로직 수정
: AI가 플레이어 근처까지는 가게 만들었지만 결국 Player를 바라보지 않으면 공격하지 않는 문제
>> 문제 원인
: 현재 로직으로는 AI의 정면 방향으로 Raycast를 쏴서 공격할지 말지를 결정하기 때문에 AI가 직접 바라보지 않으면 근처에 있어도 공격하지 못하는 것
>> 문제 해결
: AI와 Player의 거리를 계산해서 AttackDistance 안에 들어오면 무조건 Attack 하도록 로직을 수정
--> 추가로 자연스러운 Animation을 위해서 AI가 공격할 때에 Player 방향으로 서서히 회전하도록 구현
- SubAIStateTrace.cs
public void Update()
{
// Player가 공격 거리 안에 있으면 Player를 공격하여 방해
float distance = Vector3.Distance(_subAIController.transform.position, _detectPlayerTransform.position);
if (distance <= _subAIController.MaxAttackDistance)
{
// TODO: Player 방해 처리
// Attack 상태로 전환
_subAIController.SetState(SubAIState.Attack);
return;
}
}
- SubAIStateAttack.cs
using UnityEngine;
public class SubAIStateAttack : ISubAIState
{
private static readonly int Attack = Animator.StringToHash("Attack");
private SubAIController _subAIController;
private Transform _targetTransform;
private Quaternion _targetRotation;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.ResetAnimationSpeed(); // Animation 속도 정상화
// 공격할 때 Player를 바라보도록 목표 방향 설정
_targetTransform = _subAIController.DetectPlayerInCircle();
if (_targetTransform != null)
{
Vector3 dirToTarget = (_targetTransform.position - _subAIController.transform.position).normalized;
dirToTarget.y = 0f;
_targetRotation = Quaternion.LookRotation(dirToTarget);
}
// Attack Animation 실행 후 Teleport --> _subAIController.AttackEnd() 실행됨
_subAIController.SubAIAnimator.SetTrigger(Attack);
}
public void Update()
{
// 공격할 때 Player 방향으로 서서히 회전하도록
if (_targetTransform == null) return;
Transform ai = _subAIController.transform;
ai.rotation = Quaternion.Slerp(ai.rotation, _targetRotation, Time.deltaTime * 5f);
}
public void Exit()
{
_subAIController = null;
}
}
Player가 Hide 상태일 때, AI가 감지하지 못하도록
: Player의 Hide 상태를 Bool 타입으로 넘겨 받아서 Hide 상태라면 AI를 Idle 상태로 바꾸거나 AI가 Player를 감지하지 못하도록
- SubAIController.cs의 DetectPlayerInCircle()에서 아예 감지하지 못하도록
- SubAIStateTrace.cs에서 Player가 Hide 상태라면 AI를 Idle 상태로 바꾸도록
--> 사실 SubAIController.cs의 DetectPlayerInCircle()에서 아예 감지하지 못하면 Patrol이나 Trace 상태로 전환 자체가 안 되기 때문에 Trace에서 코드는 없어도 될 것 같지만 Trace 도중에 Player가 Hide 상태가 됐을 때, 예외적 상황을 안전하게 처리하기 위해서 추가
>> 문제 발생 및 해결 과정
1. AI가 상태는 Trace 상태인데, Animation은 Idle 상태로 유지되어 쫓아오지도 않고 가만히 서있는 문제가 발생
: Player Component를 가져오는 타이밍이 DetectPlayerInCircle() 보다 앞에 있어서 _player가 null이 되어 발생한 문제
--> Player Component를 DetectPlayerInCircle() 보다 뒤에 배치하여 해결
- 기존 코드
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
// Player Component 가져오기
_player = _detectPlayerTransform.GetComponent<Player>();
// Player가 탐지 범위 밖으로 나가면 Idle로 전환
_detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (_detectPlayerTransform == null)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
// 탐지 범위 안에 Player가 진입하면 Player를 향해 이동
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(_detectPlayerTransform.position);
_subAIController.SubAIAnimator.SetBool(Trace, true);
}
- 수정한 코드
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
// Player가 탐지 범위 밖으로 나가면 Idle로 전환
_detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (_detectPlayerTransform == null)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
// Player Component 가져오기
_player = _detectPlayerTransform.GetComponent<Player>();
// 탐지 범위 안에 Player가 진입하면 Player를 향해 이동
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(_detectPlayerTransform.position);
_subAIController.SubAIAnimator.SetBool(Trace, true);
}
2. AI가 Player를 쫓아오지만 공격하지 않는 문제와 Player가 Hide 상태가 되어도 계속 쫓아오는 문제
: 가져온 Player의 Damaged()를 불러올 때, NullReferenceException 오류가 뜨면서 Attack 상태로 넘어가지 못하게 되는데, 디버깅 해보니 Player가 가진 Collider를 제대로 가져오지 못해서 null을 가져오고 결국 Hide 상태를 체크할 때 _player가 null이 되면서 모든 문제가 발생
--> Player의 Collider가 SimpleKCC(Kinematic Character Controller) 형태인데, 다른 팀원 분께서 알려주시길 이를 가져오려면 GetComponent로 가져오는 것이 아니라 그 부모인 GetComponentInParent로 가져와야 제대로 가져와진다고 한다. 따라서 GetComponent를 GetComponentInParent로 수정하여 해결
- 기존 코드
- SubAIController.cs
// 일정 반경에 Player가 진입하면 Player를 감지
public Transform DetectPlayerInCircle() // Enemy와 Player가 층이 다르면 탐지 범위 안에 있어도 인식하지 못하도록
{
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayerMask);
foreach (var hitCollider in hitColliders)
{
Debug.Log(hitCollider.gameObject.name);
NetworkObject targetNetworkObj = hitCollider.GetComponentInParent<NetworkObject>();
Debug.Log($"NetworkObject {targetNetworkObj}");
if (!targetNetworkObj) continue;
Debug.Log($"Detect Player in Circle: {hitCollider.name}");
if (!targetNetworkObj.HasInputAuthority) continue;
Vector3 playerPos = hitCollider.transform.position;
Vector3 myPos = transform.position;
float horizontalDistance =
Vector3.Distance(new Vector2(playerPos.x, playerPos.z), new Vector2(myPos.x, myPos.z));
float heightDifference = Mathf.Abs(playerPos.y - myPos.y);
if (horizontalDistance <= detectCircleRadius && heightDifference <= 1.5f) // 1.5미터 이내만 같은 층으로 인정
{
// Player의 Hide 상태를 체크하여 처리
Player player = hitCollider.GetComponent<Player>(); // Player Component 가져오기
if (player != null && player.isHide)
continue;
return hitCollider.transform;
}
}
return null;
}
- SubAIStateTrace.cs
// Player Component 가져오기
private Player _player;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
// Player가 탐지 범위 밖으로 나가면 Idle로 전환
_detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (_detectPlayerTransform == null)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
// Player Component 가져오기
_player = _detectPlayerTransform.GetComponent<Player>();
// 탐지 범위 안에 Player가 진입하면 Player를 향해 이동
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(_detectPlayerTransform.position);
_subAIController.SubAIAnimator.SetBool(Trace, true);
}
public void Update()
{
// Player의 Hide 상태를 체크하여 처리
if (_player != null && _player.isHide)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
}
- 수정된 코드
- SubAIController.cs
// 일정 반경에 Player가 진입하면 Player를 감지
public Transform DetectPlayerInCircle() // Enemy와 Player가 층이 다르면 탐지 범위 안에 있어도 인식하지 못하도록
{
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayerMask);
foreach (var hitCollider in hitColliders)
{
Debug.Log(hitCollider.gameObject.name);
NetworkObject targetNetworkObj = hitCollider.GetComponentInParent<NetworkObject>();
Debug.Log($"NetworkObject {targetNetworkObj}");
if (!targetNetworkObj) continue;
Debug.Log($"Detect Player in Circle: {hitCollider.name}");
if (!targetNetworkObj.HasInputAuthority) continue;
Vector3 playerPos = hitCollider.transform.position;
Vector3 myPos = transform.position;
float horizontalDistance =
Vector3.Distance(new Vector2(playerPos.x, playerPos.z), new Vector2(myPos.x, myPos.z));
float heightDifference = Mathf.Abs(playerPos.y - myPos.y);
if (horizontalDistance <= detectCircleRadius && heightDifference <= 1.5f) // 1.5미터 이내만 같은 층으로 인정
{
// Player의 Hide 상태를 체크하여 처리
Player player = hitCollider.GetComponentInParent<Player>(); // Player Component 가져오기
if (player != null && player.isHide)
continue;
return hitCollider.transform;
}
}
return null;
}
- SubAIStateTrace.cs
// Player Component 가져오기
private Player _player;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
// Player가 탐지 범위 밖으로 나가면 Idle로 전환
_detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (_detectPlayerTransform == null)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
// Player Component 가져오기
_player = _detectPlayerTransform.GetComponentInParent<Player>();
// 탐지 범위 안에 Player가 진입하면 Player를 향해 이동
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(_detectPlayerTransform.position);
_subAIController.SubAIAnimator.SetBool(Trace, true);
}
public void Update()
{
// Player의 Hide 상태를 체크하여 처리
if (_player != null && _player.isHide)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
}
AI가 Player를 Attack 했을 때, Player가 디버프를 받도록
: 가져온 Player Component를 통해 Player에 있는 디버프 관련 코드를 호출하여 구현 완료
- 작성한 코드
public void Update()
{
// Player가 공격 거리 안에 있으면 Player를 공격하여 방해
float distance = Vector3.Distance(_subAIController.transform.position, _detectPlayerTransform.position);
if (distance <= _subAIController.MaxAttackDistance)
{
// Player 방해 처리
_player.Damaged();
// Attack 상태로 전환
_subAIController.SetState(SubAIState.Attack);
return;
}
}
25.05.23
지난 주, 최종 프로젝트를 제출 완료했고 '멋쟁이사자처럼 Unity 게임 개발 3기'를 무사히 수료했다.
하지만 게임이 완전히 완성된 것이 아니라서 팀원들과 상의해본 결과 게임을 출시하기까지 함께 더 개발하기로 결정했다.
Turn Animation 구현
: 지난 프로젝트 기간동안 시간이 부족해서 구현하지 못했던 Turn Animation을 다시 구현해보자.
--> Idle 상태에서 찾은 Patrol Point가 AI가 바라보는 방향과 각도가 120° 이상 차이나면 Turn Animation 실행
>> FindRandomPatrolPoint() 조정
: 원래는 FindRandomPatrolPoint() 함수를 SubAIStatePatrol.cs에서만 사용했었지만, AI가 Idle 상태에서 PatrolPoint를 받아야 하기 때문에 함수를 SubAIController.cs로 옮기고 public으로 접근제한자를 변경
--> Idle상태와 Patrol상태에서 각각 FindRandomPatrolPoint()를 호출하면 찾은 Patrol Point가 서로 달라지는 문제가 발생하기 때문에 SubAIController.cs에서 찾은 Patrol Point를 담을 변수를 선언하고 거기에 찾은 Point를 담아서 전달할 수 있도록 SetNextPatrolPoint() 함수를 구현
>> Animation Behaviour를 활용
: Animation Event를 사용했었는데, 정확히 Animation이 끝날 때 AI를 Patrol 상태로 바꾸고 직접 회전시키기 위해 Animation Behaviour를 생성하여 사용
>> Turn 관련
- 회전 중인 지를 구분하도록 Bool 타입의 변수를 선언하여 회전 중이라면 다른 작업을 하지 않도록 구현
- Animation에 Root Motion이 없기 때문에 Animation이 끝날 때 AI가 180° 회전하도록 구현
>> 남은 문제
: 인게임으로 테스트해보니 여전히 Animation이 부자연스러운 문제가 있다.
--> Patrol 상태로 전환되는 것과 Patrol Point가 갱신되는 것의 시간차가 약간 있어서 Turn Animation이 끝난 뒤에 바로 이동하지 못해서..?
>> 수정한 코드
- SubAIController.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using UnityEngine;
using UnityEngine.AI;
using Random = UnityEngine.Random;
public enum SubAIState { None, Idle, Patrol, Trace, Attack, Stun }
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(Animator))]
public class SubAIController : MonoBehaviour
{
[Header("AI")]
// 탐지 범위 관련 변수
[SerializeField] private float detectCircleRadius = 15f;
public float DetectCircleRadius => detectCircleRadius;
// 정찰 범위 관련 변수
public float PatrolCircleRadius => patrolCircleRadius;
[SerializeField] private float patrolCircleRadius = 30f;
// AI가 탐지할 Layer 변수
public LayerMask TargetLayerMask => targetLayerMask;
[SerializeField] private LayerMask targetLayerMask;
// 시야각 관련 변수
public float MaxDetectSightAngle => maxDetectSightAngle;
[SerializeField] private float maxDetectSightAngle = 30f;
// Idle에서 Patrol로 상태를 바꾸기 전, 대기 시간을 결정하는 변수
public float MaxPatrolWaitTime => maxPatrolWaitTime;
[SerializeField] private float maxPatrolWaitTime = 3f; // 3초마다 Patrol
// Patrol Point를 찾는 횟수
private int _tryToFindRandomPoint = 10;
// 찾은 Patrol Point 변수
public Vector3 NextPatrolPoint { get; private set; }
// 공격 거리 변수
public float MaxAttackDistance => maxAttackDistance;
[SerializeField] private float maxAttackDistance = 2f;
// 속도 변수 (일반, 추격)
[SerializeField] private float normalSpeed = 2.5f;
[SerializeField] private float traceSpeed = 4f;
// 텔레포트 관련 변수 (위치, 주기)
[SerializeField] public Transform[] teleportPositions;
[SerializeField] private float teleportCooldown = 120f; // 2분 주기
private float _teleportTimer = 0f;
// 스턴 관련 변수
public float MaxStunTime => maxStunTime;
[SerializeField] private float maxStunTime = 5f;
// ㅡㅡㅡ 상태 변수 ㅡㅡㅡ
private SubAIStateIdle _stateIdle;
private SubAIStatePatrol _statePatrol;
private SubAIStateTrace _stateTrace;
private SubAIStateAttack _stateAttack;
private SubAIStateStun _stateStun;
public SubAIState CurrentState { get; private set; }
private Dictionary<SubAIState, ISubAIState> _aiStates;
// ㅡㅡㅡ Component ㅡㅡㅡ
public NavMeshAgent Agent { get; private set; }
public Animator SubAIAnimator { get; private set; }
private void Awake()
{
SubAIAnimator = GetComponent<Animator>();
Agent = GetComponent<NavMeshAgent>();
Agent.updatePosition = true;
Agent.updateRotation = true;
}
private void Start()
{
// 상태 객체 생성
_stateIdle = new SubAIStateIdle();
_statePatrol = new SubAIStatePatrol();
_stateTrace = new SubAIStateTrace();
_stateAttack = new SubAIStateAttack();
_stateStun = new SubAIStateStun();
_aiStates = new Dictionary<SubAIState, ISubAIState>
{
{ SubAIState.Idle, _stateIdle },
{ SubAIState.Patrol, _statePatrol },
{ SubAIState.Trace, _stateTrace },
{ SubAIState.Attack, _stateAttack },
{ SubAIState.Stun, _stateStun }
};
// 상태 초기화
SetState(SubAIState.Idle);
}
private void Update()
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Update();
}
// 일정 주기마다 텔레포트
_teleportTimer += Time.deltaTime;
if (_teleportTimer >= teleportCooldown)
{
TeleportToRandomPosition();
_teleportTimer = 0f;
}
}
// 상태 변경
public void SetState(SubAIState state)
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Exit();
}
CurrentState = state;
_aiStates[CurrentState].Enter(this);
}
// 일정 반경에 Player가 진입하면 Player를 감지
public Transform DetectPlayerInCircle() // Enemy와 Player가 층이 다르면 탐지 범위 안에 있어도 인식하지 못하도록
{
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayerMask);
foreach (var hitCollider in hitColliders)
{
Debug.Log(hitCollider.gameObject.name);
NetworkObject targetNetworkObj = hitCollider.GetComponentInParent<NetworkObject>();
Debug.Log($"NetworkObject {targetNetworkObj}");
if (!targetNetworkObj) continue;
Debug.Log($"Detect Player in Circle: {hitCollider.name}");
if (!targetNetworkObj.HasInputAuthority) continue;
Vector3 playerPos = hitCollider.transform.position;
Vector3 myPos = transform.position;
float horizontalDistance =
Vector3.Distance(new Vector2(playerPos.x, playerPos.z), new Vector2(myPos.x, myPos.z));
float heightDifference = Mathf.Abs(playerPos.y - myPos.y);
if (horizontalDistance <= detectCircleRadius && heightDifference <= 1.5f) // 1.5미터 이내만 같은 층으로 인정
{
// Player의 Hide 상태를 체크하여 처리
Player player = hitCollider.GetComponentInParent<Player>(); // Player Component 가져오기
if (player != null && player.isHide)
continue;
return hitCollider.transform;
}
}
return null;
}
// 서브 빌런의 기본 이동속도
public void SetNormalSpeed()
{
Agent.speed = normalSpeed;
}
// 서브 빌런의 추격 이동속도
public void SetTraceSpeed()
{
Agent.speed = traceSpeed;
}
// 지정한 장소 중 랜덤한 위치로 텔레포트
public void TeleportToRandomPosition()
{
if (teleportPositions == null || teleportPositions.Length == 0) return;
int index = -1;
int count = teleportPositions.Length / 2;
// AI가 1층에 있다면 2층으로, 2층에 있다면 1층으로 Teleport
// 1층의 높이 : 1f, 2층의 높이 : 5f / 1층의 [index] : 짝수, 2층의 [index] : 홀수
if (transform.position.y < 3f) // AI가 1층에 존재 --> 홀수 index만 뽑기
{
index = Random.Range(0, count) * 2 + 1;
}
else // AI가 2층에 존재 --> 짝수 index만 뽑기
{
index = Random.Range(0, count) * 2;
}
Transform target = teleportPositions[index];
// NavMeshAgent를 일시적으로 끄고 텔레포트
Agent.enabled = false;
transform.position = target.position;
transform.rotation = target.rotation;
Agent.enabled = true;
// 목적지 재설정
Agent.SetDestination(target.position);
// 상태 초기화
SetState(SubAIState.Idle);
}
// Animation Event --> Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
TeleportToRandomPosition();
}
// 정찰 위치 랜덤 결정
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
return hit.position;
}
}
// 실패하면 현재 위치 반환
return transform.position;
}
// 찾은 Patrol Point를 설정
public void SetNextPatrolPoint(Vector3 point)
{
NextPatrolPoint = point;
}
// Animation 속도 변경
public void SetAnimationSpeed()
{
float speedRatio = Agent.velocity.magnitude / Agent.speed;
SubAIAnimator.speed = Mathf.Max(0.5f, speedRatio); // 최소 속도 보장
}
// Animation 속도 정상화
public void ResetAnimationSpeed()
{
SubAIAnimator.speed = 1f;
}
#region 디버깅
private void OnDrawGizmos()
{
// Circle 감지 범위
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectCircleRadius);
// 시야각
Gizmos.color = Color.red;
Vector3 rightDirection = Quaternion.Euler(0, maxDetectSightAngle, 0) * transform.forward;
Vector3 leftDirection = Quaternion.Euler(0, -maxDetectSightAngle, 0) * transform.forward;
Gizmos.DrawRay(transform.position, rightDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, leftDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, transform.forward * detectCircleRadius);
// Agent 목적지 시각화
if (Agent != null && Agent.hasPath)
{
Gizmos.color = Color.green;
Gizmos.DrawSphere(Agent.destination, 0.5f);
Gizmos.DrawLine(Agent.destination, Agent.destination);
}
}
#endregion
}
- SubAIStateIdle.cs
using System.Collections;
using UnityEngine;
public class SubAIStateIdle : ISubAIState
{
private static readonly int Idle = Animator.StringToHash("Idle");
private static readonly int Turn = Animator.StringToHash("Turn");
private SubAIController _subAIController;
// Idle에서 Patrol로 상태를 바꾸기 전, 현재 대기 시간 변수
private float _patrolWaitTime = 0f;
// 회전 중인지 구분하는 변수
private bool _isTurning = false;
// 찾은 Patrol Point를 담는 변수
private Vector3 _nextPatrolPoint;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.ResetAnimationSpeed(); // Animation 속도 정상화
_subAIController.SubAIAnimator.SetBool(Idle, true);
_subAIController.Agent.isStopped = true;
}
public void Update()
{
if (_isTurning) return; // Turn 중이면 이후 과정을 전부 스킵
// 탐지 범위 안에 Player가 있는 지 확인
var detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (detectPlayerTransform)
{
_subAIController.SetState(SubAIState.Trace);
return;
}
// 정찰 여부 판단
// if (_patrolWaitTime > _subAIController.MaxPatrolWaitTime && Random.Range(0, 100) < 30)
// {
// _subAIController.SetState(SubAIState.Patrol);
// return;
// }
if (_patrolWaitTime > _subAIController.MaxPatrolWaitTime && Random.Range(0, 100) < 30)
{
_nextPatrolPoint = _subAIController.FindRandomPatrolPoint();
_subAIController.SetNextPatrolPoint(_nextPatrolPoint);
Vector3 dir = (_nextPatrolPoint - _subAIController.transform.position).normalized;
float angle = Vector3.Angle(_subAIController.transform.forward, dir);
if (angle > 120f) // 회전 Animation 실행
{
_isTurning = true;
_subAIController.SubAIAnimator.SetTrigger(Turn);
return;
}
_subAIController.SetState(SubAIState.Patrol);
return;
}
_patrolWaitTime += Time.deltaTime;
}
public void Exit()
{
_isTurning = false;
_patrolWaitTime = 0f;
_subAIController.SubAIAnimator.SetBool(Idle, false);
_subAIController = null;
}
}
- SubAIStatePatrol.cs
using System.Collections;
using UnityEngine;
using UnityEngine.AI;
public class SubAIStatePatrol : ISubAIState
{
private static readonly int Patrol = Animator.StringToHash("Patrol");
private SubAIController _subAIController;
private int _tryToFindRandomPoint = 10;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.SetNormalSpeed(); // 이동 속도 조절
_subAIController.ResetAnimationSpeed(); // 이동 속도에 따른 Animation 재생 속도 조절
// 랜덤으로 정찰 위치를 구하고, 있으면 해당 위치로 이동, 없으면 다시 Idle 상태로 전환
Vector3 patrolPoint = _subAIController.NextPatrolPoint;
if (patrolPoint == _subAIController.transform.position)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(patrolPoint);
_subAIController.StartCoroutine(WaitPatrolAnimation());
}
public void Update()
{
// 감지 영역에 Player가 있는 지 확인 후, 있으면 Trace로 전환
var detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (detectPlayerTransform)
{
_subAIController.SetState(SubAIState.Trace);
return;
}
// 정찰 목적지에 도착하면 Idle로 전환
if (!_subAIController.Agent.pathPending &&
_subAIController.Agent.remainingDistance <= _subAIController.Agent.stoppingDistance &&
_subAIController.Agent.hasPath)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
}
public void Exit()
{
_subAIController.SubAIAnimator.SetBool(Patrol, false);
_subAIController = null;
}
// 경로 계산이 끝날 때까지 Animation을 지연시키는 Coroutine
private IEnumerator WaitPatrolAnimation()
{
// 경로 계산 중이면 대기
while (_subAIController.Agent.pathPending)
yield return null;
// 이동이 시작되면 Animation 실행
_subAIController.SubAIAnimator.SetBool(Patrol, true);
}
}
- TurnAnimationBehaviour.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TurnAnimationBehaviour : StateMachineBehaviour
{
private SubAIController _subAIController;
// OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
_subAIController = animator.GetComponent<SubAIController>();
}
// OnStateExit is called when a transition ends and the state machine finishes evaluating this state
override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
_subAIController.transform.Rotate(0f, 180f, 0f);
_subAIController.SetState(SubAIState.Patrol);
_subAIController = null;
}
}
25.05.28
외조부상으로 며칠간 개발을 진행하지 못했다..
Turn Animation 구현
: 지난 시간에 이어 Turn Animation을 구현해보자.
>> 남은 문제
: 여전히 Turn Animation이 어색하게 실행되는 문제
>> 해결 방법
: Patrol 상태로 전환되는 것과 Patrol Point가 갱신되는 것의 시간차가 약간 있어서 Turn Animation이 끝난 뒤에 바로 이동하지 못해서 생긴 문제인 듯 싶었으나, Animator가 'Turn'에서 'Patrol'로 전환될 때 자연스럽게 Animation이 전환되도록 Animation이 섞여있어서 Turn이 끝나기 직전에 Patrol Point를 향해 Patrol Animation이 섞여서 실행되는 것 때문에 Animation이 부자연스럽게 재생됐던 것
--> Animation이 안 섞이도록 설정하여 해결
: 오른쪽 사진과 같이 설정
- 해결된 모습
>> 아쉬운 점
- Turn Animation을 끝내고 걸어갈 때, 순간적으로 바뀌기 때문에 Animation이 어색하게 연결되는 것이 조금 아쉽다.
- Turn Animation의 실행 조건 --> 만약 AI가 바라보는 방향과 찾은 Patrol Point 사이의 각도가 120° 보다 작지만 구조상 길이 막혀있어서 AI가 뒤돌아서 걸어가야한다면 그때는 Turn Animation이 작동하지 않고 그냥 걸어가는 것이 아쉽다.
25.05.30
Turn 발생 조건 수정
: AI가 바라보는 방향과 설정된 목적지와의 각도를 계산하는 것이 아니라, AI가 나아가야할 방향과의 각도를 계산하여 120° 이상이면 Turn하도록 로직 수정
>> 해결 시도
- Agent.desiredVelocity.normalized를 활용
: desiredVelocity는 AI가 이동 중일 때 실제 이동 방향을 얻어오는 것이기 때문에 이동이 시작되지 않은 현재 상황에서는 방향 계산이 되지 않는다.
--> Patrol 중에 Player를 감지했을 때는 활용 가능할 듯하다.
25.06.02
Turn 발생 조건 수정
: 지난 시간에 이어 Turn 발생 조건 수정
>> 해결 과정
- NavMeshAgent.path.corners를 활용
: NavMeshAgent.path.corners는 Agent가 목적지까지 가기 위해 계산한 경로의 점들이기 때문에 기존의 'desiredVelocity'나 '(destination - transform.position)' 보다 정확하다.
※ 추후 Player를 감지했을 때도 Turn하도록 확장하기 위해 회전의 여부를 결정하는 코드를 함수로 빼서 SubAIController.cs로 옮긴 후, Patrol과 Trace에 맞춰서 각각 작동하도록 구현
--> TurnAnimationBehaviour.cs도 맞춰서 수정
>> 작성한 코드
- SubAIController.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using UnityEngine;
using UnityEngine.AI;
using Random = UnityEngine.Random;
public enum SubAIState { None, Idle, Patrol, Trace, Attack, Stun }
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(Animator))]
public class SubAIController : MonoBehaviour
{
private static readonly int Turn = Animator.StringToHash("Turn");
[Header("AI")]
// 탐지 범위 관련 변수
[SerializeField] private float detectCircleRadius = 15f;
public float DetectCircleRadius => detectCircleRadius;
// 정찰 범위 관련 변수
public float PatrolCircleRadius => patrolCircleRadius;
[SerializeField] private float patrolCircleRadius = 30f;
// AI가 탐지할 Layer 변수
public LayerMask TargetLayerMask => targetLayerMask;
[SerializeField] private LayerMask targetLayerMask;
// 시야각 관련 변수
public float MaxDetectSightAngle => maxDetectSightAngle;
[SerializeField] private float maxDetectSightAngle = 30f;
// Idle에서 Patrol로 상태를 바꾸기 전, 대기 시간을 결정하는 변수
public float MaxPatrolWaitTime => maxPatrolWaitTime;
[SerializeField] private float maxPatrolWaitTime = 3f; // 3초마다 Patrol
// Patrol Point를 찾는 횟수
private int _tryToFindRandomPoint = 10;
// 찾은 Patrol Point 변수
public Vector3 NextPatrolPoint { get; private set; }
// 공격 거리 변수
public float MaxAttackDistance => maxAttackDistance;
[SerializeField] private float maxAttackDistance = 2f;
// 속도 변수 (일반, 추격)
[SerializeField] private float normalSpeed = 2.5f;
[SerializeField] private float traceSpeed = 4f;
// 텔레포트 관련 변수 (위치, 주기)
[SerializeField] public Transform[] teleportPositions;
[SerializeField] private float teleportCooldown = 120f; // 2분 주기
private float _teleportTimer = 0f;
// 스턴 관련 변수
public float MaxStunTime => maxStunTime;
[SerializeField] private float maxStunTime = 5f;
// Turn 이후 상태를 담을 변수
public SubAIState NextStateAfterTurn { get; set; } = SubAIState.None;
// ㅡㅡㅡ 상태 변수 ㅡㅡㅡ
private SubAIStateIdle _stateIdle;
private SubAIStatePatrol _statePatrol;
private SubAIStateTrace _stateTrace;
private SubAIStateAttack _stateAttack;
private SubAIStateStun _stateStun;
public SubAIState CurrentState { get; private set; }
private Dictionary<SubAIState, ISubAIState> _aiStates;
// ㅡㅡㅡ Component ㅡㅡㅡ
public NavMeshAgent Agent { get; private set; }
public Animator SubAIAnimator { get; private set; }
private void Awake()
{
SubAIAnimator = GetComponent<Animator>();
Agent = GetComponent<NavMeshAgent>();
Agent.updatePosition = true;
Agent.updateRotation = true;
}
private void Start()
{
// 상태 객체 생성
_stateIdle = new SubAIStateIdle();
_statePatrol = new SubAIStatePatrol();
_stateTrace = new SubAIStateTrace();
_stateAttack = new SubAIStateAttack();
_stateStun = new SubAIStateStun();
_aiStates = new Dictionary<SubAIState, ISubAIState>
{
{ SubAIState.Idle, _stateIdle },
{ SubAIState.Patrol, _statePatrol },
{ SubAIState.Trace, _stateTrace },
{ SubAIState.Attack, _stateAttack },
{ SubAIState.Stun, _stateStun }
};
// 상태 초기화
SetState(SubAIState.Idle);
}
private void Update()
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Update();
}
// 일정 주기마다 텔레포트
_teleportTimer += Time.deltaTime;
if (_teleportTimer >= teleportCooldown)
{
TeleportToRandomPosition();
_teleportTimer = 0f;
}
}
// 상태 변경
public void SetState(SubAIState state)
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Exit();
}
CurrentState = state;
_aiStates[CurrentState].Enter(this);
}
// 일정 반경에 Player가 진입하면 Player를 감지
public Transform DetectPlayerInCircle() // Enemy와 Player가 층이 다르면 탐지 범위 안에 있어도 인식하지 못하도록
{
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayerMask);
foreach (var hitCollider in hitColliders)
{
Debug.Log(hitCollider.gameObject.name);
NetworkObject targetNetworkObj = hitCollider.GetComponentInParent<NetworkObject>();
Debug.Log($"NetworkObject {targetNetworkObj}");
if (!targetNetworkObj) continue;
Debug.Log($"Detect Player in Circle: {hitCollider.name}");
if (!targetNetworkObj.HasInputAuthority) continue;
Vector3 playerPos = hitCollider.transform.position;
Vector3 myPos = transform.position;
float horizontalDistance =
Vector3.Distance(new Vector2(playerPos.x, playerPos.z), new Vector2(myPos.x, myPos.z));
float heightDifference = Mathf.Abs(playerPos.y - myPos.y);
if (horizontalDistance <= detectCircleRadius && heightDifference <= 1.5f) // 1.5미터 이내만 같은 층으로 인정
{
// Player의 Hide 상태를 체크하여 처리
Player player = hitCollider.GetComponentInParent<Player>(); // Player Component 가져오기
if (player != null && player.isHide)
continue;
return hitCollider.transform;
}
}
return null;
}
// 서브 빌런의 기본 이동속도
public void SetNormalSpeed()
{
Agent.speed = normalSpeed;
}
// 서브 빌런의 추격 이동속도
public void SetTraceSpeed()
{
Agent.speed = traceSpeed;
}
// 지정한 장소 중 랜덤한 위치로 텔레포트
public void TeleportToRandomPosition()
{
if (teleportPositions == null || teleportPositions.Length == 0) return;
int index = -1;
int count = teleportPositions.Length / 2;
// AI가 1층에 있다면 2층으로, 2층에 있다면 1층으로 Teleport
// 1층의 높이 : 1f, 2층의 높이 : 5f / 1층의 [index] : 짝수, 2층의 [index] : 홀수
if (transform.position.y < 3f) // AI가 1층에 존재 --> 홀수 index만 뽑기
{
index = Random.Range(0, count) * 2 + 1;
}
else // AI가 2층에 존재 --> 짝수 index만 뽑기
{
index = Random.Range(0, count) * 2;
}
Transform target = teleportPositions[index];
// NavMeshAgent를 일시적으로 끄고 텔레포트
Agent.enabled = false;
transform.position = target.position;
transform.rotation = target.rotation;
Agent.enabled = true;
// 목적지 재설정
Agent.SetDestination(target.position);
// 상태 초기화
SetState(SubAIState.Idle);
}
// Animation Event --> Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
TeleportToRandomPosition();
}
// 정찰 위치 랜덤 결정
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
return hit.position;
}
}
// 실패하면 현재 위치 반환
return transform.position;
}
// 찾은 Patrol Point를 설정
public void SetNextPatrolPoint(Vector3 point)
{
NextPatrolPoint = point;
}
/// <summary>
/// 목적지까지 경로를 계산하고, 현재 바라보는 방향과 첫 이동 방향의 각도가 지정 각도보다 크면 true 반환
/// </summary>
/// <param name="destination">이동할 목적지</param>
/// <returns>회전 필요 여부</returns>
public bool NeedsToTurn(Vector3 destination)
{
NavMeshPath path = new NavMeshPath();
if (!Agent.CalculatePath(destination, path)) return false;
if (path.corners.Length >= 2)
{
Vector3 dir = (path.corners[1] - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, dir);
return angle > 120f;
}
return false;
}
// Animation 속도 변경
public void SetAnimationSpeed()
{
float speedRatio = Agent.velocity.magnitude / Agent.speed;
SubAIAnimator.speed = Mathf.Max(0.5f, speedRatio); // 최소 속도 보장
}
// Animation 속도 정상화
public void ResetAnimationSpeed()
{
SubAIAnimator.speed = 1f;
}
#region 디버깅
private void OnDrawGizmos()
{
// Circle 감지 범위
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectCircleRadius);
// 시야각
Gizmos.color = Color.red;
Vector3 rightDirection = Quaternion.Euler(0, maxDetectSightAngle, 0) * transform.forward;
Vector3 leftDirection = Quaternion.Euler(0, -maxDetectSightAngle, 0) * transform.forward;
Gizmos.DrawRay(transform.position, rightDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, leftDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, transform.forward * detectCircleRadius);
// Agent 목적지 시각화
if (Agent != null && Agent.hasPath)
{
Gizmos.color = Color.green;
Gizmos.DrawSphere(Agent.destination, 0.5f);
Gizmos.DrawLine(Agent.destination, Agent.destination);
}
}
#endregion
}
- SubAIStateIdle.cs
using System.Collections;
using UnityEngine;
public class SubAIStateIdle : ISubAIState
{
private static readonly int Idle = Animator.StringToHash("Idle");
private static readonly int Turn = Animator.StringToHash("Turn");
private SubAIController _subAIController;
// Idle에서 Patrol로 상태를 바꾸기 전, 현재 대기 시간 변수
private float _patrolWaitTime = 0f;
// 회전 중인지 구분하는 변수
private bool _isTurning = false;
// 찾은 Patrol Point를 담는 변수
private Vector3 _nextPatrolPoint;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.ResetAnimationSpeed(); // Animation 속도 정상화
_subAIController.SubAIAnimator.SetBool(Idle, true);
_subAIController.Agent.isStopped = true;
}
public void Update()
{
if (_isTurning) return; // Turn 중이면 이후 과정을 전부 스킵
// 탐지 범위 안에 Player가 있는 지 확인
Transform detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (detectPlayerTransform)
{
_subAIController.SetState(SubAIState.Trace);
return;
}
// 정찰 여부 판단
if (_patrolWaitTime > _subAIController.MaxPatrolWaitTime && Random.Range(0, 100) < 30)
{
_nextPatrolPoint = _subAIController.FindRandomPatrolPoint();
_subAIController.SetNextPatrolPoint(_nextPatrolPoint);
if (_subAIController.NeedsToTurn(_nextPatrolPoint))
{
_isTurning = true;
_subAIController.NextStateAfterTurn = SubAIState.Patrol;
_subAIController.SubAIAnimator.SetTrigger(Turn);
return;
}
_subAIController.SetState(SubAIState.Patrol);
return;
}
_patrolWaitTime += Time.deltaTime;
}
public void Exit()
{
_isTurning = false;
_patrolWaitTime = 0f;
_subAIController.SubAIAnimator.SetBool(Idle, false);
_subAIController = null;
}
}
- TurnAnimationBehaviour.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TurnAnimationBehaviour : StateMachineBehaviour
{
private SubAIController _subAIController;
// OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
_subAIController = animator.GetComponent<SubAIController>();
}
// OnStateExit is called when a transition ends and the state machine finishes evaluating this state
override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
_subAIController.transform.Rotate(0f, 180f, 0f);
if (_subAIController.NextStateAfterTurn != SubAIState.None)
{
_subAIController.SetState(_subAIController.NextStateAfterTurn);
_subAIController.NextStateAfterTurn = SubAIState.None;
}
_subAIController = null;
}
}
25.06.03
Turn 확장
>> AI가 Player를 감지했을 때도 Turn 하도록
: AI가 Player를 감지했을 때, Player의 위치가 AI가 바라보는 방향으로부터 각도가 120° 이상이면 Turn하도록
--> Idle 상태 뿐만 아니라 Patrol 상태에서도 작동하고 Turn 후, Trace 상태가 되도록
--> DetectPlayerInCircle()로 감지한 Player의 Transform을 Vector3로 받아서 만들어둔 NeedsToTurn() 함수에 넣어서 Turn하도록 구현
>> 문제 발생
: Idle -> Patrol에서는 문제 없이 Turn이 작동하지만, Player를 감지했을 때는 Turn이 잘 작동하지 않았다.
--> Agent의 상태가 Idle일 때는 Agent.isStopped = true로 되어 있어서 경로를 계산하지 못할 수도 있고 Agent.desiredVelocity가 0이라서 첫 걸음 방향을 추론하기 위한 path.corners가 비정상적일 가능성이 높기 때문에 Agent.CalculatePath()가 실패할 수도 있다.
>> 해결 과정
- NavMesh.CalculatePath()를 직접 사용
: NavMesh.CalculatePath()는 Agent 없이도 경로 계산이 가능하므로 계산이 더욱 확실해진다.
※ Animator 수정
: 기존에는 Idle -> Patrol 에서만 Turn할 예정이었기 때문에 Animator에서 Turn이 끝나면 바로 Patrol로 연결했지만, 이제는 Player를 감지했을 때도 Turn하고 뒤이어 Trace로도 연결되어야하기 때문에 Turn이 끝나면 Idle로 연결하여 상황에 맞게 Patrol과 Trace로 전환되도록 수정
>> 작성한 코드
- SubAIController.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using UnityEngine;
using UnityEngine.AI;
using Random = UnityEngine.Random;
public enum SubAIState { None, Idle, Patrol, Trace, Attack, Stun }
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(Animator))]
public class SubAIController : MonoBehaviour
{
private static readonly int Turn = Animator.StringToHash("Turn");
[Header("AI")]
// 탐지 범위 관련 변수
[SerializeField] private float detectCircleRadius = 15f;
public float DetectCircleRadius => detectCircleRadius;
// 정찰 범위 관련 변수
public float PatrolCircleRadius => patrolCircleRadius;
[SerializeField] private float patrolCircleRadius = 30f;
// AI가 탐지할 Layer 변수
public LayerMask TargetLayerMask => targetLayerMask;
[SerializeField] private LayerMask targetLayerMask;
// 시야각 관련 변수
public float MaxDetectSightAngle => maxDetectSightAngle;
[SerializeField] private float maxDetectSightAngle = 30f;
// Idle에서 Patrol로 상태를 바꾸기 전, 대기 시간을 결정하는 변수
public float MaxPatrolWaitTime => maxPatrolWaitTime;
[SerializeField] private float maxPatrolWaitTime = 3f; // 3초마다 Patrol
// Patrol Point를 찾는 횟수
private int _tryToFindRandomPoint = 10;
// 찾은 Patrol Point 변수
public Vector3 NextPatrolPoint { get; private set; }
// 공격 거리 변수
public float MaxAttackDistance => maxAttackDistance;
[SerializeField] private float maxAttackDistance = 2f;
// 속도 변수 (일반, 추격)
[SerializeField] private float normalSpeed = 2.5f;
[SerializeField] private float traceSpeed = 4f;
// 텔레포트 관련 변수 (위치, 주기)
[SerializeField] public Transform[] teleportPositions;
[SerializeField] private float teleportCooldown = 120f; // 2분 주기
private float _teleportTimer = 0f;
// 스턴 관련 변수
public float MaxStunTime => maxStunTime;
[SerializeField] private float maxStunTime = 5f;
// Turn 이후 상태를 담을 변수
public SubAIState NextStateAfterTurn { get; set; } = SubAIState.None;
// ㅡㅡㅡ 상태 변수 ㅡㅡㅡ
private SubAIStateIdle _stateIdle;
private SubAIStatePatrol _statePatrol;
private SubAIStateTrace _stateTrace;
private SubAIStateAttack _stateAttack;
private SubAIStateStun _stateStun;
public SubAIState CurrentState { get; private set; }
private Dictionary<SubAIState, ISubAIState> _aiStates;
// ㅡㅡㅡ Component ㅡㅡㅡ
public NavMeshAgent Agent { get; private set; }
public Animator SubAIAnimator { get; private set; }
private void Awake()
{
SubAIAnimator = GetComponent<Animator>();
Agent = GetComponent<NavMeshAgent>();
Agent.updatePosition = true;
Agent.updateRotation = true;
}
private void Start()
{
// 상태 객체 생성
_stateIdle = new SubAIStateIdle();
_statePatrol = new SubAIStatePatrol();
_stateTrace = new SubAIStateTrace();
_stateAttack = new SubAIStateAttack();
_stateStun = new SubAIStateStun();
_aiStates = new Dictionary<SubAIState, ISubAIState>
{
{ SubAIState.Idle, _stateIdle },
{ SubAIState.Patrol, _statePatrol },
{ SubAIState.Trace, _stateTrace },
{ SubAIState.Attack, _stateAttack },
{ SubAIState.Stun, _stateStun }
};
// 상태 초기화
SetState(SubAIState.Idle);
}
private void Update()
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Update();
}
// 일정 주기마다 텔레포트
_teleportTimer += Time.deltaTime;
if (_teleportTimer >= teleportCooldown)
{
TeleportToRandomPosition();
_teleportTimer = 0f;
}
}
// 상태 변경
public void SetState(SubAIState state)
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Exit();
}
CurrentState = state;
_aiStates[CurrentState].Enter(this);
}
// 일정 반경에 Player가 진입하면 Player를 감지
public Transform DetectPlayerInCircle() // Enemy와 Player가 층이 다르면 탐지 범위 안에 있어도 인식하지 못하도록
{
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayerMask);
foreach (var hitCollider in hitColliders)
{
Debug.Log(hitCollider.gameObject.name);
NetworkObject targetNetworkObj = hitCollider.GetComponentInParent<NetworkObject>();
Debug.Log($"NetworkObject {targetNetworkObj}");
if (!targetNetworkObj) continue;
Debug.Log($"Detect Player in Circle: {hitCollider.name}");
if (!targetNetworkObj.HasInputAuthority) continue;
Vector3 playerPos = hitCollider.transform.position;
Vector3 myPos = transform.position;
float horizontalDistance =
Vector3.Distance(new Vector2(playerPos.x, playerPos.z), new Vector2(myPos.x, myPos.z));
float heightDifference = Mathf.Abs(playerPos.y - myPos.y);
if (horizontalDistance <= detectCircleRadius && heightDifference <= 1.5f) // 1.5미터 이내만 같은 층으로 인정
{
// Player의 Hide 상태를 체크하여 처리
Player player = hitCollider.GetComponentInParent<Player>(); // Player Component 가져오기
if (player != null && player.isHide)
continue;
return hitCollider.transform;
}
}
return null;
}
// 서브 빌런의 기본 이동속도
public void SetNormalSpeed()
{
Agent.speed = normalSpeed;
}
// 서브 빌런의 추격 이동속도
public void SetTraceSpeed()
{
Agent.speed = traceSpeed;
}
// 지정한 장소 중 랜덤한 위치로 텔레포트
public void TeleportToRandomPosition()
{
if (teleportPositions == null || teleportPositions.Length == 0) return;
int index = -1;
int count = teleportPositions.Length / 2;
// AI가 1층에 있다면 2층으로, 2층에 있다면 1층으로 Teleport
// 1층의 높이 : 1f, 2층의 높이 : 5f / 1층의 [index] : 짝수, 2층의 [index] : 홀수
if (transform.position.y < 3f) // AI가 1층에 존재 --> 홀수 index만 뽑기
{
index = Random.Range(0, count) * 2 + 1;
}
else // AI가 2층에 존재 --> 짝수 index만 뽑기
{
index = Random.Range(0, count) * 2;
}
Transform target = teleportPositions[index];
// NavMeshAgent를 일시적으로 끄고 텔레포트
Agent.enabled = false;
transform.position = target.position;
transform.rotation = target.rotation;
Agent.enabled = true;
// 목적지 재설정
Agent.SetDestination(target.position);
// 상태 초기화
SetState(SubAIState.Idle);
}
// Animation Event --> Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
TeleportToRandomPosition();
}
// 정찰 위치 랜덤 결정
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
return hit.position;
}
}
// 실패하면 현재 위치 반환
return transform.position;
}
// 찾은 Patrol Point를 설정
public void SetNextPatrolPoint(Vector3 point)
{
NextPatrolPoint = point;
}
/// <summary>
/// 목적지까지 경로를 계산하고, 현재 바라보는 방향과 첫 이동 방향의 각도가 지정 각도보다 크면 true 반환
/// </summary>
/// <param name="destination">이동할 목적지</param>
/// <returns>회전 필요 여부</returns>
public bool NeedsToTurn(Vector3 destination)
{
NavMeshPath path = new NavMeshPath();
if (!NavMesh.CalculatePath(transform.position, destination, NavMesh.AllAreas, path))
return false;
if (path.corners.Length >= 2)
{
Vector3 dir = (path.corners[1] - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, dir);
return angle > 120f;
}
return false;
}
// Animation 속도 변경
public void SetAnimationSpeed()
{
float speedRatio = Agent.velocity.magnitude / Agent.speed;
SubAIAnimator.speed = Mathf.Max(0.5f, speedRatio); // 최소 속도 보장
}
// Animation 속도 정상화
public void ResetAnimationSpeed()
{
SubAIAnimator.speed = 1f;
}
#region 디버깅
private void OnDrawGizmos()
{
// Circle 감지 범위
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectCircleRadius);
// 시야각
Gizmos.color = Color.red;
Vector3 rightDirection = Quaternion.Euler(0, maxDetectSightAngle, 0) * transform.forward;
Vector3 leftDirection = Quaternion.Euler(0, -maxDetectSightAngle, 0) * transform.forward;
Gizmos.DrawRay(transform.position, rightDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, leftDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, transform.forward * detectCircleRadius);
// Agent 목적지 시각화
if (Agent != null && Agent.hasPath)
{
Gizmos.color = Color.green;
Gizmos.DrawSphere(Agent.destination, 0.5f);
Gizmos.DrawLine(Agent.destination, Agent.destination);
}
}
#endregion
}
- SubAIStateIdle.cs
using System.Collections;
using UnityEngine;
public class SubAIStateIdle : ISubAIState
{
private static readonly int Idle = Animator.StringToHash("Idle");
private static readonly int Turn = Animator.StringToHash("Turn");
private SubAIController _subAIController;
// Idle에서 Patrol로 상태를 바꾸기 전, 현재 대기 시간 변수
private float _patrolWaitTime = 0f;
// 회전 중인지 구분하는 변수
private bool _isTurning = false;
// 찾은 Patrol Point를 담는 변수
private Vector3 _nextPatrolPoint;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.ResetAnimationSpeed(); // Animation 속도 정상화
_subAIController.SubAIAnimator.SetBool(Idle, true);
_subAIController.Agent.isStopped = true;
}
public void Update()
{
if (_isTurning) return; // Turn 중이면 이후 과정을 전부 스킵
// 탐지 범위 안에 Player가 있는 지 확인
Transform detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (detectPlayerTransform)
{
Vector3 playerPosition = detectPlayerTransform.position;
if (_subAIController.NeedsToTurn(playerPosition))
{
_isTurning = true;
_subAIController.NextStateAfterTurn = SubAIState.Trace;
_subAIController.SubAIAnimator.SetTrigger(Turn);
return;
}
_subAIController.SetState(SubAIState.Trace);
return;
}
// 정찰 여부 판단
if (_patrolWaitTime > _subAIController.MaxPatrolWaitTime && Random.Range(0, 100) < 30)
{
_nextPatrolPoint = _subAIController.FindRandomPatrolPoint();
_subAIController.SetNextPatrolPoint(_nextPatrolPoint);
if (_subAIController.NeedsToTurn(_nextPatrolPoint))
{
_isTurning = true;
_subAIController.NextStateAfterTurn = SubAIState.Patrol;
_subAIController.SubAIAnimator.SetTrigger(Turn);
return;
}
_subAIController.SetState(SubAIState.Patrol);
return;
}
_patrolWaitTime += Time.deltaTime;
}
public void Exit()
{
_isTurning = false;
_patrolWaitTime = 0f;
_subAIController.SubAIAnimator.SetBool(Idle, false);
_subAIController = null;
}
}
25.06.04
Turn 확장
: Patrol 중에도 뒤에서 Player가 감지되면 Turn하도록 구현
>> 구현 방법
: Idle 상태에서 Player를 감지했을 때 조건에 따라 Turn 하듯이 Patrol 상태에서도 동일하게 코드 작성
--> Animator에서 Patrol 상태에서도 Turn으로 전환될 수 있도록 Make Transition
>> 문제 발생
: Patrol 중에 Player가 감지되어 Turn이 실행되어도 계속 목적지를 향해 걸어가면서 Turn Animation을 실행한 다음 Player를 향해 쫓아온다.
>> 해결 과정
: Patrol 중에 Player가 감지되고 Turn의 조건이 충족되어 Turn을 한다면 Agent.isStopped = true로 설정하여 AI가 멈추도록 하고 Turn Animation이 끝날 때 Agent.isStopped = false로 설정하여 다시 움직이도록 만들어서 해결
>> 작성한 코드
- SubAIStatePatrol.cs
using System.Collections;
using UnityEngine;
public class SubAIStatePatrol : ISubAIState
{
private static readonly int Patrol = Animator.StringToHash("Patrol");
private static readonly int Turn = Animator.StringToHash("Turn");
private SubAIController _subAIController;
// 회전 중인지 구분하는 변수
private bool _isTurning = false;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.SetNormalSpeed(); // 이동 속도 조절
_subAIController.ResetAnimationSpeed(); // 이동 속도에 따른 Animation 재생 속도 조절
// 랜덤으로 정찰 위치를 구하고, 있으면 해당 위치로 이동, 없으면 다시 Idle 상태로 전환
Vector3 patrolPoint = _subAIController.NextPatrolPoint;
if (patrolPoint == _subAIController.transform.position)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(patrolPoint);
_subAIController.StartCoroutine(WaitPatrolAnimation());
}
public void Update()
{
if (_isTurning) return; // Turn 중이면 이후 과정을 전부 스킵
// 감지 영역에 Player가 있는 지 확인 후, 있으면 Trace로 전환
Transform detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (detectPlayerTransform)
{
Vector3 playerPosition = detectPlayerTransform.position;
if (_subAIController.NeedsToTurn(playerPosition))
{
_isTurning = true;
_subAIController.Agent.isStopped = true;
_subAIController.NextStateAfterTurn = SubAIState.Trace;
_subAIController.SubAIAnimator.SetTrigger(Turn);
return;
}
_subAIController.SetState(SubAIState.Trace);
return;
}
// 정찰 목적지에 도착하면 Idle로 전환
if (!_subAIController.Agent.pathPending &&
_subAIController.Agent.remainingDistance <= _subAIController.Agent.stoppingDistance &&
_subAIController.Agent.hasPath)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
}
public void Exit()
{
_isTurning = false;
_subAIController.SubAIAnimator.SetBool(Patrol, false);
_subAIController = null;
}
// 경로 계산이 끝날 때까지 Animation을 지연시키는 Coroutine
private IEnumerator WaitPatrolAnimation()
{
// 경로 계산 중이면 대기
while (_subAIController.Agent.pathPending)
yield return null;
// 이동이 시작되면 Animation 실행
_subAIController.SubAIAnimator.SetBool(Patrol, true);
}
}
- TurnAnimationBehaviour.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TurnAnimationBehaviour : StateMachineBehaviour
{
private SubAIController _subAIController;
// OnStateEnter is called when a transition starts and the state machine starts to evaluate this state
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
_subAIController = animator.GetComponent<SubAIController>();
}
// OnStateExit is called when a transition ends and the state machine finishes evaluating this state
override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
_subAIController.transform.Rotate(0f, 180f, 0f);
_subAIController.Agent.isStopped = false;
if (_subAIController.NextStateAfterTurn != SubAIState.None)
{
_subAIController.SetState(_subAIController.NextStateAfterTurn);
_subAIController.NextStateAfterTurn = SubAIState.None;
}
_subAIController = null;
}
}
Player를 감지하면 탐지 범위를 벗어나더라도 일정 시간동안 Player를 쫓아가도록
: AI가 Player를 향해 Turn하는 시간이 3.25초 가량 소요되기 때문에, 그동안 Player가 도망간다면 AI가 Player를 잡기가 어려워지기 때문에 기능을 추가
--> Timer를 하나 추가해서, Timer가 정해둔 시간만큼 지나지 않았다면 Update()에서 Player가 탐지 범위를 벗어났을 때 Idle로 전환하는 코드를 실행하지 않도록 구현
>> 문제 발생
: 현재는 Idle 상태든 Patrol 상태든 Player를 감지하여 Turn에 진입하면 그 상태가 그대로 유지되고, Turn이 끝난 다음 Trace 상대로 진입하는데, 이때 Trace의 Enter에서 Player가 탐지 범위 밖으로 나가면 Idle 상태로 전환하기 때문에 일정 시간동안 Player를 쫓지 않는다.
--> Player를 감지해서 Turn에 진입하고, Turn이 끝난 다음에도 Player가 탐지 범위 안에 있으면 문제없이 작동한다.
>> 해결 과정
: SubAIController.cs에서 감지한 Player의 Transform을 담을 변수를 새로 선언해서 Idle 상태든 Patrol 상태든 Player를 감지했을 때 그 Transform을 저장해두고 Trace 상태에서 넘겨받아 사용하여 해결
>> 수정한 코드
- SubAIController.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using UnityEngine;
using UnityEngine.AI;
using Random = UnityEngine.Random;
public enum SubAIState { None, Idle, Patrol, Trace, Attack, Stun }
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(Animator))]
public class SubAIController : MonoBehaviour
{
private static readonly int Turn = Animator.StringToHash("Turn");
[Header("AI")]
// 탐지 범위 관련 변수
[SerializeField] private float detectCircleRadius = 15f;
public float DetectCircleRadius => detectCircleRadius;
// 정찰 범위 관련 변수
public float PatrolCircleRadius => patrolCircleRadius;
[SerializeField] private float patrolCircleRadius = 30f;
// AI가 탐지할 Layer 변수
public LayerMask TargetLayerMask => targetLayerMask;
[SerializeField] private LayerMask targetLayerMask;
// 시야각 관련 변수
public float MaxDetectSightAngle => maxDetectSightAngle;
[SerializeField] private float maxDetectSightAngle = 30f;
// Idle에서 Patrol로 상태를 바꾸기 전, 대기 시간을 결정하는 변수
public float MaxPatrolWaitTime => maxPatrolWaitTime;
[SerializeField] private float maxPatrolWaitTime = 3f; // 3초마다 Patrol
// Patrol Point를 찾는 횟수
private int _tryToFindRandomPoint = 10;
// 찾은 Patrol Point 변수
public Vector3 NextPatrolPoint { get; private set; }
// 공격 거리 변수
public float MaxAttackDistance => maxAttackDistance;
[SerializeField] private float maxAttackDistance = 2f;
// 속도 변수 (일반, 추격)
[SerializeField] private float normalSpeed = 2.5f;
[SerializeField] private float traceSpeed = 4f;
// 텔레포트 관련 변수 (위치, 주기)
[SerializeField] public Transform[] teleportPositions;
[SerializeField] private float teleportCooldown = 120f; // 2분 주기
private float _teleportTimer = 0f;
// 스턴 관련 변수
public float MaxStunTime => maxStunTime;
[SerializeField] private float maxStunTime = 5f;
// Turn 이후 상태를 담을 변수
public SubAIState NextStateAfterTurn { get; set; } = SubAIState.None;
// 감지한 Player의 Transform을 담을 변수
public Transform DetectedPlayerTransform { get; set; }
// ㅡㅡㅡ 상태 변수 ㅡㅡㅡ
private SubAIStateIdle _stateIdle;
private SubAIStatePatrol _statePatrol;
private SubAIStateTrace _stateTrace;
private SubAIStateAttack _stateAttack;
private SubAIStateStun _stateStun;
public SubAIState CurrentState { get; private set; }
private Dictionary<SubAIState, ISubAIState> _aiStates;
// ㅡㅡㅡ Component ㅡㅡㅡ
public NavMeshAgent Agent { get; private set; }
public Animator SubAIAnimator { get; private set; }
private void Awake()
{
SubAIAnimator = GetComponent<Animator>();
Agent = GetComponent<NavMeshAgent>();
Agent.updatePosition = true;
Agent.updateRotation = true;
}
private void Start()
{
// 상태 객체 생성
_stateIdle = new SubAIStateIdle();
_statePatrol = new SubAIStatePatrol();
_stateTrace = new SubAIStateTrace();
_stateAttack = new SubAIStateAttack();
_stateStun = new SubAIStateStun();
_aiStates = new Dictionary<SubAIState, ISubAIState>
{
{ SubAIState.Idle, _stateIdle },
{ SubAIState.Patrol, _statePatrol },
{ SubAIState.Trace, _stateTrace },
{ SubAIState.Attack, _stateAttack },
{ SubAIState.Stun, _stateStun }
};
// 상태 초기화
SetState(SubAIState.Idle);
}
private void Update()
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Update();
}
// 일정 주기마다 텔레포트
_teleportTimer += Time.deltaTime;
if (_teleportTimer >= teleportCooldown)
{
TeleportToRandomPosition();
_teleportTimer = 0f;
}
}
// 상태 변경
public void SetState(SubAIState state)
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Exit();
}
CurrentState = state;
_aiStates[CurrentState].Enter(this);
}
// 일정 반경에 Player가 진입하면 Player를 감지
public Transform DetectPlayerInCircle() // Enemy와 Player가 층이 다르면 탐지 범위 안에 있어도 인식하지 못하도록
{
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayerMask);
foreach (var hitCollider in hitColliders)
{
Debug.Log(hitCollider.gameObject.name);
NetworkObject targetNetworkObj = hitCollider.GetComponentInParent<NetworkObject>();
Debug.Log($"NetworkObject {targetNetworkObj}");
if (!targetNetworkObj) continue;
Debug.Log($"Detect Player in Circle: {hitCollider.name}");
if (!targetNetworkObj.HasInputAuthority) continue;
Vector3 playerPos = hitCollider.transform.position;
Vector3 myPos = transform.position;
float horizontalDistance =
Vector3.Distance(new Vector2(playerPos.x, playerPos.z), new Vector2(myPos.x, myPos.z));
float heightDifference = Mathf.Abs(playerPos.y - myPos.y);
if (horizontalDistance <= detectCircleRadius && heightDifference <= 1.5f) // 1.5미터 이내만 같은 층으로 인정
{
// Player의 Hide 상태를 체크하여 처리
Player player = hitCollider.GetComponentInParent<Player>(); // Player Component 가져오기
if (player != null && player.isHide)
continue;
return hitCollider.transform;
}
}
return null;
}
// 서브 빌런의 기본 이동속도
public void SetNormalSpeed()
{
Agent.speed = normalSpeed;
}
// 서브 빌런의 추격 이동속도
public void SetTraceSpeed()
{
Agent.speed = traceSpeed;
}
// 지정한 장소 중 랜덤한 위치로 텔레포트
public void TeleportToRandomPosition()
{
if (teleportPositions == null || teleportPositions.Length == 0) return;
int index = -1;
int count = teleportPositions.Length / 2;
// AI가 1층에 있다면 2층으로, 2층에 있다면 1층으로 Teleport
// 1층의 높이 : 1f, 2층의 높이 : 5f / 1층의 [index] : 짝수, 2층의 [index] : 홀수
if (transform.position.y < 3f) // AI가 1층에 존재 --> 홀수 index만 뽑기
{
index = Random.Range(0, count) * 2 + 1;
}
else // AI가 2층에 존재 --> 짝수 index만 뽑기
{
index = Random.Range(0, count) * 2;
}
Transform target = teleportPositions[index];
// NavMeshAgent를 일시적으로 끄고 텔레포트
Agent.enabled = false;
transform.position = target.position;
transform.rotation = target.rotation;
Agent.enabled = true;
// 목적지 재설정
Agent.SetDestination(target.position);
// 상태 초기화
SetState(SubAIState.Idle);
}
// Animation Event --> Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
TeleportToRandomPosition();
}
// 정찰 위치 랜덤 결정
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
return hit.position;
}
}
// 실패하면 현재 위치 반환
return transform.position;
}
// 찾은 Patrol Point를 설정
public void SetNextPatrolPoint(Vector3 point)
{
NextPatrolPoint = point;
}
/// <summary>
/// 목적지까지 경로를 계산하고, 현재 바라보는 방향과 첫 이동 방향의 각도가 지정 각도보다 크면 true 반환
/// </summary>
/// <param name="destination">이동할 목적지</param>
/// <returns>회전 필요 여부</returns>
public bool NeedsToTurn(Vector3 destination)
{
NavMeshPath path = new NavMeshPath();
if (!NavMesh.CalculatePath(transform.position, destination, NavMesh.AllAreas, path))
return false;
if (path.corners.Length >= 2)
{
Vector3 dir = (path.corners[1] - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, dir);
return angle > 120f;
}
return false;
}
// Animation 속도 변경
public void SetAnimationSpeed()
{
float speedRatio = Agent.velocity.magnitude / Agent.speed;
SubAIAnimator.speed = Mathf.Max(0.5f, speedRatio); // 최소 속도 보장
}
// Animation 속도 정상화
public void ResetAnimationSpeed()
{
SubAIAnimator.speed = 1f;
}
#region 디버깅
private void OnDrawGizmos()
{
// Circle 감지 범위
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectCircleRadius);
// 시야각
Gizmos.color = Color.red;
Vector3 rightDirection = Quaternion.Euler(0, maxDetectSightAngle, 0) * transform.forward;
Vector3 leftDirection = Quaternion.Euler(0, -maxDetectSightAngle, 0) * transform.forward;
Gizmos.DrawRay(transform.position, rightDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, leftDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, transform.forward * detectCircleRadius);
// Agent 목적지 시각화
if (Agent != null && Agent.hasPath)
{
Gizmos.color = Color.green;
Gizmos.DrawSphere(Agent.destination, 0.5f);
Gizmos.DrawLine(Agent.destination, Agent.destination);
}
}
#endregion
}
- SubAIStateIdle.cs
using System.Collections;
using UnityEngine;
public class SubAIStateIdle : ISubAIState
{
private static readonly int Idle = Animator.StringToHash("Idle");
private static readonly int Turn = Animator.StringToHash("Turn");
private SubAIController _subAIController;
// Idle에서 Patrol로 상태를 바꾸기 전, 현재 대기 시간 변수
private float _patrolWaitTime = 0f;
// 회전 중인지 구분하는 변수
private bool _isTurning = false;
// 찾은 Patrol Point를 담는 변수
private Vector3 _nextPatrolPoint;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.ResetAnimationSpeed(); // Animation 속도 정상화
_subAIController.SubAIAnimator.SetBool(Idle, true);
_subAIController.Agent.isStopped = true;
}
public void Update()
{
if (_isTurning) return; // Turn 중이면 이후 과정을 전부 스킵
// 탐지 범위 안에 Player가 있는 지 확인
Transform detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (detectPlayerTransform)
{
_subAIController.DetectedPlayerTransform = detectPlayerTransform;
if (_subAIController.NeedsToTurn(detectPlayerTransform.position))
{
_isTurning = true;
_subAIController.NextStateAfterTurn = SubAIState.Trace;
_subAIController.SubAIAnimator.SetTrigger(Turn);
return;
}
_subAIController.SetState(SubAIState.Trace);
return;
}
// 정찰 여부 판단
if (_patrolWaitTime > _subAIController.MaxPatrolWaitTime && Random.Range(0, 100) < 30)
{
_nextPatrolPoint = _subAIController.FindRandomPatrolPoint();
_subAIController.SetNextPatrolPoint(_nextPatrolPoint);
if (_subAIController.NeedsToTurn(_nextPatrolPoint))
{
_isTurning = true;
_subAIController.NextStateAfterTurn = SubAIState.Patrol;
_subAIController.SubAIAnimator.SetTrigger(Turn);
return;
}
_subAIController.SetState(SubAIState.Patrol);
return;
}
_patrolWaitTime += Time.deltaTime;
}
public void Exit()
{
_isTurning = false;
_patrolWaitTime = 0f;
_subAIController.SubAIAnimator.SetBool(Idle, false);
_subAIController = null;
}
}
- SubAIStatePatrol.cs
using System.Collections;
using UnityEngine;
public class SubAIStatePatrol : ISubAIState
{
private static readonly int Patrol = Animator.StringToHash("Patrol");
private static readonly int Turn = Animator.StringToHash("Turn");
private SubAIController _subAIController;
// 회전 중인지 구분하는 변수
private bool _isTurning = false;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.SetNormalSpeed(); // 이동 속도 조절
_subAIController.ResetAnimationSpeed(); // 이동 속도에 따른 Animation 재생 속도 조절
// 랜덤으로 정찰 위치를 구하고, 있으면 해당 위치로 이동, 없으면 다시 Idle 상태로 전환
Vector3 patrolPoint = _subAIController.NextPatrolPoint;
if (patrolPoint == _subAIController.transform.position)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(patrolPoint);
_subAIController.StartCoroutine(WaitPatrolAnimation());
}
public void Update()
{
if (_isTurning) return; // Turn 중이면 이후 과정을 전부 스킵
// 감지 영역에 Player가 있는 지 확인 후, 있으면 Trace로 전환
Transform detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (detectPlayerTransform)
{
_subAIController.DetectedPlayerTransform = detectPlayerTransform;
if (_subAIController.NeedsToTurn(detectPlayerTransform.position))
{
_isTurning = true;
_subAIController.Agent.isStopped = true;
_subAIController.NextStateAfterTurn = SubAIState.Trace;
_subAIController.SubAIAnimator.SetTrigger(Turn);
return;
}
_subAIController.SetState(SubAIState.Trace);
return;
}
// 정찰 목적지에 도착하면 Idle로 전환
if (!_subAIController.Agent.pathPending &&
_subAIController.Agent.remainingDistance <= _subAIController.Agent.stoppingDistance &&
_subAIController.Agent.hasPath)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
}
public void Exit()
{
_isTurning = false;
_subAIController.SubAIAnimator.SetBool(Patrol, false);
_subAIController = null;
}
// 경로 계산이 끝날 때까지 Animation을 지연시키는 Coroutine
private IEnumerator WaitPatrolAnimation()
{
// 경로 계산 중이면 대기
while (_subAIController.Agent.pathPending)
yield return null;
// 이동이 시작되면 Animation 실행
_subAIController.SubAIAnimator.SetBool(Patrol, true);
}
}
- SubAIStateTrace.cs
using UnityEngine;
using UnityEngine.AI;
public class SubAIStateTrace : ISubAIState
{
private static readonly int Trace = Animator.StringToHash("Trace");
private static readonly int Speed = Animator.StringToHash("Speed");
private static readonly int TurnAngle = Animator.StringToHash("TurnAngle");
private SubAIController _subAIController;
// 탐지한 Player의 Transform을 담는 변수
private Transform _detectPlayerTransform;
// 찾은 Player의 위치(AI의 목적지)를 갱신할 주기 및 시간 변수
private float _maxDetectPlayerUpdateTime = 0.3f;
private float _detectPlayerTime = 0f;
// Trace를 유지하는 시간 변수, 시간이 지나면 Teleport
private float _maxTraceTime = 25f;
private float _traceTime = 0f;
// Player를 감지했을 때, 무조건 Trace를 유지하기 위한 시간 변수
private float _maxForceTraceTime = 3f;
private float _forceTraceTime = 0f;
// Player Component 가져오기
private Player _player;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
// 우선 저장된 PlayerTransform을 사용
_detectPlayerTransform = _subAIController.DetectedPlayerTransform;
if (_detectPlayerTransform == null)
{
// 그래도 없으면 직접 다시 감지
_detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (_detectPlayerTransform == null)
{
// Player가 탐지 범위 밖으로 나가면 Idle로 전환
_subAIController.SetState(SubAIState.Idle);
return;
}
}
// Player Component 가져오기
_player = _detectPlayerTransform.GetComponentInParent<Player>();
// 탐지 범위 안에 Player가 진입하면 Player를 향해 이동
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(_detectPlayerTransform.position);
_subAIController.SubAIAnimator.SetBool(Trace, true);
}
public void Update()
{
// Player의 Hide 상태를 체크하여 처리
if (_player != null && _player.isHide)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
// 일정 주기로 찾은 Player의 위치를 갱신해서 AI를 갱신된 위치의 최대한 근처로 이동
if (_detectPlayerTime > _maxDetectPlayerUpdateTime)
{
Vector3 targetPos = _detectPlayerTransform.position;
NavMeshHit navMeshHit;
if (NavMesh.SamplePosition(targetPos, out navMeshHit, 2f, NavMesh.AllAreas))
{
_subAIController.Agent.SetDestination(navMeshHit.position);
}
else
{
_subAIController.SetState(SubAIState.Idle);
return;
}
_detectPlayerTime = 0f;
}
_detectPlayerTime += Time.deltaTime;
// AI가 Trace를 일정 시간 이상 유지하면 Teleport
if (_traceTime > _maxTraceTime)
{
_traceTime = 0f;
_subAIController.TeleportToRandomPosition();
return;
}
_traceTime += Time.deltaTime;
var playerDistanceSqr = (_detectPlayerTransform.position - _subAIController.transform.position).sqrMagnitude;
// Trace 중, Player가 멀리서 시야에 들어오면 속도 증가
if (DetectPlayerInSight(_detectPlayerTransform) && playerDistanceSqr > 5f)
{
// 속도 증가
_subAIController.SetTraceSpeed();
}
else
{
// 기본 속도
_subAIController.SetNormalSpeed();
}
// Trace로 전환되고 일정 시간동안은 탐지 범위 밖으로 나가도 Trace 유지
if (_forceTraceTime >= _maxForceTraceTime)
{
// Player를 감지할 수 있는 범위를 넘어서면 다시 Idle로 전환
if (playerDistanceSqr > (_subAIController.DetectCircleRadius * _subAIController.DetectCircleRadius))
{
_forceTraceTime = 0f;
_subAIController.SetState(SubAIState.Idle);
return;
}
}
_forceTraceTime += Time.deltaTime;
// Player가 공격 거리 안에 있으면 Player를 공격하여 방해
float distance = Vector3.Distance(_subAIController.transform.position, _detectPlayerTransform.position);
if (distance <= _subAIController.MaxAttackDistance)
{
// Player 방해 처리
_player.Damaged();
// Attack 상태로 전환
_subAIController.SetState(SubAIState.Attack);
return;
}
// 달리기 및 회전 Animation
Vector3 desiredDir = _subAIController.Agent.desiredVelocity.normalized;
if (desiredDir.sqrMagnitude > 0.1f)
{
float angle = Vector3.SignedAngle(_subAIController.transform.forward, desiredDir, Vector3.up);
float speed = _subAIController.Agent.velocity.magnitude;
// Animation 결정
_subAIController.SubAIAnimator.SetFloat(TurnAngle, angle, 0.2f, Time.deltaTime);
_subAIController.SubAIAnimator.SetFloat(Speed, speed, 0.2f, Time.deltaTime);
// 이동 속도에 따른 Animation 재생 속도 조절
_subAIController.SetAnimationSpeed();
}
}
public void Exit()
{
_subAIController.SubAIAnimator.SetBool(Trace, false);
_subAIController = null;
}
// 일정 반경에 Player가 진입하면 시야에 들어왔다고 판단
private bool DetectPlayerInSight(Transform playerTransform)
{
if (playerTransform == null)
{
return false;
}
var cosTheta = Vector3.Dot(_subAIController.transform.forward,
(playerTransform.position - _subAIController.transform.position).normalized);
var angle = Mathf.Acos(cosTheta) * Mathf.Rad2Deg;
if (angle < _subAIController.MaxDetectSightAngle)
{
return true;
}
else
{
return false;
}
}
}
25.06.09
AI가 방 문이 닫혀있으면 방 안을 Patrol Point로 지정하지 않도록
: 현재 방 문이 닫혀있을 때 방 안을 Patrol Point로 지정하게 되면 AI가 방 안으로 들어가려고 계속 방 문에 비비면서 걷기 때문에 플레이하는 입장에서 어색해보이는 문제를 해결하기 위해
--> NavMeshObstacle의 Carve 기능을 활용하여 NavMesh의 길을 끊음으로써 구현 가능
>> 문제 발생
: 다른 MainAI한테 문이 닫혀있으면 문 근처의 Collider를 감지하여 문을 열고 가는 기능이 있다. 근데 내가 NavMeshObstacle의 Carve 기능을 활용하여 구현하면 Player와 MainAI의 거리가 먼 상태에서 MainAI가 Player를 쫓아가는 상황에 Player가 열려있던 문을 닫았을 때, NavMesh의 길이 아예 끊기게 된다. 그렇게 되면 MainAI가 Collider에 닿지 못해 문을 열지 못하고 결국 돌아가게 되어 Player를 쫓지 못하는 문제가 발생한다.
--> MainAI가 Player를 쫓아가는 기능이 더 중요하기 때문에 Carve를 활용하여 만들 수 없다.
>> 해결 과정
: NavMeshModifierVolume을 활용하여 방 문이 닫혀있다면 방 안의 Area를 Cost가 높은 Area("DoorBlocked")로 설정하여 AI가 방 안을 Patrol Point로 지정하는 일이 적도록 하고 방 문이 열리면 방 안의 Area를 기존의 "Walkable"로 설정하여 기존과 동일하게 Patrol Point로 지정하도록 구현
--> 방 문이 닫혀있을 때 절대 방 안을 Patrol Point로 지정하지 않도록 막는 것이 아니라 단순히 Cost를 증가시킨 것이기 때문에 빈도가 적을 뿐, 방 안을 Patrol Point로 지정하는 일이 발생할 수 있다는 단점이 존재.
--> 방마다 일일히 NavMeshModifierVolume을 설정해줘야하기에 번거롭다는 단점이 존재.
- Navigation Areas에 "DoorBlocked" 추가
- 방 마다 바닥에 NavMeshModifierVolume을 추가 및 설정
: 방 크기에 맞게 Size 및 Center를 조정하고, Area Type을 추가해준 "DoorBlocked"로 설정
>> 수정한 코드
- LockedDoor.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using JetBrains.Annotations;
using Unity.AI.Navigation;
using UnityEditor;
using UnityEngine;
using UnityEngine.Audio;
using UnityEngine.Serialization;
[RequireComponent(typeof(Rigidbody))]
public class LockedDoor : InteractiveObject
{
[SerializeField] private ItemType requiredItemType;
[SerializeField] public bool isLocked = false;
[SerializeField] public bool isOpen = false;
public float DoorOpenAngle = 90.0f; // Y축 회전 각도
private Vector3 defaulRot;
private Vector3 openRot;
public float OpenTime = 1f;
public float CloseTime = 1f;
public float smooth = 1f;
private Rigidbody rb;
bool isOpening = false;
[Header("사운드 관련")] [SerializeField] private AudioMixer mixer;
[SerializeField] private AudioClip doorOpen;
[SerializeField] private AudioClip doorClose;
[SerializeField] private AudioSource doorSoundSource;
[Header("닿았을 때 자동 문 열림")]
[SerializeField] private LayerMask targetLayerMask; // Inspector에서 설정할 LayerMask
[Header("메인도어키 상호작용시 시작될 것")]
[SerializeField] [CanBeNull] private Morgue morgue;
[Header("NavMesh 관련")]
[SerializeField] [CanBeNull] private GameObject navObstacleBlocker;
[SerializeField] [CanBeNull] private NavMeshModifierVolume roomVolume;
[SerializeField] private int areaClosed = 3;
[SerializeField] private int areaOpen = 1;
public override void Spawned()
{
rb = GetComponent<Rigidbody>();
defaulRot = transform.eulerAngles;
openRot = new Vector3(defaulRot.x, defaulRot.y + DoorOpenAngle, defaulRot.z);
// rotatesOnYAxis가 true이므로 초기 위치/회전 설정은 회전을 기준으로 합니다.
_isActive = true;
rb.isKinematic = true;
}
public void OnTriggerStay(Collider other)
{
if (isOpen || isOpening) return;
// 충돌한 오브젝트의 Layer가 targetLayerMask에 포함되어 있는지 확인합니다.
if ((targetLayerMask.value & (1 << other.gameObject.layer)) != 0)
{
RpcOpenDoor();
}
}
public override bool Interact(InteractionContext interactionContext)
{
if (isLocked)
{
if (interactionContext.ItemType.HasValue && interactionContext.ItemType.Value == requiredItemType)
{
isLocked = false;
interactionContext.inventory.ClearInventorySlot(interactionContext.inventory.currentItemIndex);
if (requiredItemType == ItemType.MainDoorKey)
{
Debug.Log("메인도어키 사용");
morgue?.StartMorgueDoorLoop();
}
}
else
{
UIManager.Instance.SetTestText("문이 잠겨 있다");
return false;
}
}
if (_isActive && !isOpen && !isOpening)
{
isOpen = true;
RpcOpenDoor();
}
else if (_isActive && isOpen && !isOpening)
{
isOpen = false;
RpcCloseDoor();
}
return false;
}
[Rpc(RpcSources.All, RpcTargets.All)]
public void RpcOpenDoor()
{
StartCoroutine(OpenDoor());
if (navObstacleBlocker != null) navObstacleBlocker.SetActive(false);
if (roomVolume != null) //test
roomVolume.area = areaOpen; //test
isOpen = true;
}
[Rpc(RpcSources.All, RpcTargets.All)]
public void RpcCloseDoor()
{
StartCoroutine(CloseDoor());
if (navObstacleBlocker != null) navObstacleBlocker.SetActive(true);
if (roomVolume != null) //test
roomVolume.area = areaClosed; //test
isOpen = false;
}
public IEnumerator OpenDoor()
{
isOpening = true;
float temp = 0;
Quaternion startRotation = Quaternion.Euler(defaulRot);
Quaternion endRotation = Quaternion.Euler(openRot);
doorSoundSource.PlayOneShot(doorOpen);
while (temp < OpenTime)
{
temp += Time.deltaTime;
float t = Mathf.Clamp01(temp / OpenTime);
float easedT = Mathf.SmoothStep(0, 1, t);
Quaternion newRot = Quaternion.Slerp(startRotation, endRotation, easedT);
rb.MoveRotation(newRot);
//rb.transform.rotation = newRot;
yield return null;
}
isOpening = false;
rb.MoveRotation(endRotation);
// rb.transform.rotation = endRotation;
}
public IEnumerator CloseDoor()
{
isOpening = true;
float temp = 0;
Quaternion startRotation = Quaternion.Euler(openRot);
Quaternion endRotation = Quaternion.Euler(defaulRot);
doorSoundSource.PlayOneShot(doorClose);
while (temp < CloseTime)
{
temp += Time.deltaTime;
float t = Mathf.Clamp01(temp / CloseTime);
float easedT = Mathf.SmoothStep(0, 1, t);
Quaternion newRot = Quaternion.Slerp(startRotation, endRotation, easedT);
rb.MoveRotation(newRot);
//rb.transform.rotation = newRot;
yield return null;
}
isOpening = false;
rb.MoveRotation(endRotation);
//rb.transform.rotation = endRotation;
}
}
- SubAIController.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using UnityEngine;
using UnityEngine.AI;
using Random = UnityEngine.Random;
public enum SubAIState { None, Idle, Patrol, Trace, Attack, Stun }
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(Animator))]
public class SubAIController : MonoBehaviour
{
private static readonly int Turn = Animator.StringToHash("Turn");
[Header("AI")]
// 탐지 범위 관련 변수
[SerializeField] private float detectCircleRadius = 15f;
public float DetectCircleRadius => detectCircleRadius;
// 정찰 범위 관련 변수
public float PatrolCircleRadius => patrolCircleRadius;
[SerializeField] private float patrolCircleRadius = 30f;
// AI가 탐지할 Layer 변수
public LayerMask TargetLayerMask => targetLayerMask;
[SerializeField] private LayerMask targetLayerMask;
// 시야각 관련 변수
public float MaxDetectSightAngle => maxDetectSightAngle;
[SerializeField] private float maxDetectSightAngle = 30f;
// Idle에서 Patrol로 상태를 바꾸기 전, 대기 시간을 결정하는 변수
public float MaxPatrolWaitTime => maxPatrolWaitTime;
[SerializeField] private float maxPatrolWaitTime = 3f; // 3초마다 Patrol
// Patrol Point를 찾는 횟수
private int _tryToFindRandomPoint = 10;
// 찾은 Patrol Point 변수
public Vector3 NextPatrolPoint { get; private set; }
// 공격 거리 변수
public float MaxAttackDistance => maxAttackDistance;
[SerializeField] private float maxAttackDistance = 2f;
// 속도 변수 (일반, 추격)
[SerializeField] private float normalSpeed = 2.5f;
[SerializeField] private float traceSpeed = 4f;
// 텔레포트 관련 변수 (위치, 주기)
[SerializeField] public Transform[] teleportPositions;
[SerializeField] private float teleportCooldown = 120f; // 2분 주기
private float _teleportTimer = 0f;
// 스턴 관련 변수
public float MaxStunTime => maxStunTime;
[SerializeField] private float maxStunTime = 5f;
// Turn 이후 상태를 담을 변수
public SubAIState NextStateAfterTurn { get; set; } = SubAIState.None;
// 감지한 Player의 Transform을 담을 변수
public Transform DetectedPlayerTransform { get; set; }
// ㅡㅡㅡ 상태 변수 ㅡㅡㅡ
private SubAIStateIdle _stateIdle;
private SubAIStatePatrol _statePatrol;
private SubAIStateTrace _stateTrace;
private SubAIStateAttack _stateAttack;
private SubAIStateStun _stateStun;
public SubAIState CurrentState { get; private set; }
private Dictionary<SubAIState, ISubAIState> _aiStates;
// ㅡㅡㅡ Component ㅡㅡㅡ
public NavMeshAgent Agent { get; private set; }
public Animator SubAIAnimator { get; private set; }
private void Awake()
{
SubAIAnimator = GetComponent<Animator>();
Agent = GetComponent<NavMeshAgent>();
Agent.updatePosition = true;
Agent.updateRotation = true;
// 문이 닫힌 Area의 비용을 높게 설정
int doorBlockedArea = NavMesh.GetAreaFromName("DoorBlocked");
if (doorBlockedArea >= 0) // doorBlockedArea가 -1을 반환하는 경우는 해당 이름의 Area가 정의되지 않은 경우
{
NavMesh.SetAreaCost(doorBlockedArea, 1000f);
}
}
private void Start()
{
// 상태 객체 생성
_stateIdle = new SubAIStateIdle();
_statePatrol = new SubAIStatePatrol();
_stateTrace = new SubAIStateTrace();
_stateAttack = new SubAIStateAttack();
_stateStun = new SubAIStateStun();
_aiStates = new Dictionary<SubAIState, ISubAIState>
{
{ SubAIState.Idle, _stateIdle },
{ SubAIState.Patrol, _statePatrol },
{ SubAIState.Trace, _stateTrace },
{ SubAIState.Attack, _stateAttack },
{ SubAIState.Stun, _stateStun }
};
// 상태 초기화
SetState(SubAIState.Idle);
}
private void Update()
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Update();
}
// 일정 주기마다 텔레포트
_teleportTimer += Time.deltaTime;
if (_teleportTimer >= teleportCooldown)
{
TeleportToRandomPosition();
_teleportTimer = 0f;
}
}
// 상태 변경
public void SetState(SubAIState state)
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Exit();
}
CurrentState = state;
_aiStates[CurrentState].Enter(this);
}
// 일정 반경에 Player가 진입하면 Player를 감지
public Transform DetectPlayerInCircle() // Enemy와 Player가 층이 다르면 탐지 범위 안에 있어도 인식하지 못하도록
{
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayerMask);
foreach (var hitCollider in hitColliders)
{
Debug.Log(hitCollider.gameObject.name);
NetworkObject targetNetworkObj = hitCollider.GetComponentInParent<NetworkObject>();
Debug.Log($"NetworkObject {targetNetworkObj}");
if (!targetNetworkObj) continue;
Debug.Log($"Detect Player in Circle: {hitCollider.name}");
if (!targetNetworkObj.HasInputAuthority) continue;
Vector3 playerPos = hitCollider.transform.position;
Vector3 myPos = transform.position;
float horizontalDistance =
Vector3.Distance(new Vector2(playerPos.x, playerPos.z), new Vector2(myPos.x, myPos.z));
float heightDifference = Mathf.Abs(playerPos.y - myPos.y);
if (horizontalDistance <= detectCircleRadius && heightDifference <= 1.5f) // 1.5미터 이내만 같은 층으로 인정
{
// Player의 Hide 상태를 체크하여 처리
Player player = hitCollider.GetComponentInParent<Player>(); // Player Component 가져오기
if (player != null && player.isHide)
continue;
return hitCollider.transform;
}
}
return null;
}
// 서브 빌런의 기본 이동속도
public void SetNormalSpeed()
{
Agent.speed = normalSpeed;
}
// 서브 빌런의 추격 이동속도
public void SetTraceSpeed()
{
Agent.speed = traceSpeed;
}
// 지정한 장소 중 랜덤한 위치로 텔레포트
public void TeleportToRandomPosition()
{
if (teleportPositions == null || teleportPositions.Length == 0) return;
int index = -1;
int count = teleportPositions.Length / 2;
// AI가 1층에 있다면 2층으로, 2층에 있다면 1층으로 Teleport
// 1층의 높이 : 1f, 2층의 높이 : 5f / 1층의 [index] : 짝수, 2층의 [index] : 홀수
if (transform.position.y < 3f) // AI가 1층에 존재 --> 홀수 index만 뽑기
{
index = Random.Range(0, count) * 2 + 1;
}
else // AI가 2층에 존재 --> 짝수 index만 뽑기
{
index = Random.Range(0, count) * 2;
}
Transform target = teleportPositions[index];
// NavMeshAgent를 일시적으로 끄고 텔레포트
Agent.enabled = false;
transform.position = target.position;
transform.rotation = target.rotation;
Agent.enabled = true;
// 목적지 재설정
Agent.SetDestination(target.position);
// 상태 초기화
SetState(SubAIState.Idle);
}
// Animation Event --> Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
TeleportToRandomPosition();
}
// 정찰 위치 랜덤 결정
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
return hit.position;
}
}
// 실패하면 현재 위치 반환
return transform.position;
}
// 찾은 Patrol Point를 설정
public void SetNextPatrolPoint(Vector3 point)
{
NextPatrolPoint = point;
}
/// <summary>
/// 목적지까지 경로를 계산하고, 현재 바라보는 방향과 첫 이동 방향의 각도가 지정 각도보다 크면 true 반환
/// </summary>
/// <param name="destination">이동할 목적지</param>
/// <returns>회전 필요 여부</returns>
public bool NeedsToTurn(Vector3 destination)
{
NavMeshPath path = new NavMeshPath();
if (!NavMesh.CalculatePath(transform.position, destination, NavMesh.AllAreas, path))
return false;
if (path.corners.Length >= 2)
{
Vector3 dir = (path.corners[1] - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, dir);
return angle > 120f;
}
return false;
}
// Animation 속도 변경
public void SetAnimationSpeed()
{
float speedRatio = Agent.velocity.magnitude / Agent.speed;
SubAIAnimator.speed = Mathf.Max(0.5f, speedRatio); // 최소 속도 보장
}
// Animation 속도 정상화
public void ResetAnimationSpeed()
{
SubAIAnimator.speed = 1f;
}
#region 디버깅
private void OnDrawGizmos()
{
// Circle 감지 범위
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectCircleRadius);
// 시야각
Gizmos.color = Color.red;
Vector3 rightDirection = Quaternion.Euler(0, maxDetectSightAngle, 0) * transform.forward;
Vector3 leftDirection = Quaternion.Euler(0, -maxDetectSightAngle, 0) * transform.forward;
Gizmos.DrawRay(transform.position, rightDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, leftDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, transform.forward * detectCircleRadius);
// Agent 목적지 시각화
if (Agent != null && Agent.hasPath)
{
Gizmos.color = Color.green;
Gizmos.DrawSphere(Agent.destination, 0.5f);
Gizmos.DrawLine(Agent.destination, Agent.destination);
}
}
#endregion
}
25.06.11
AI가 방 문이 닫혀있으면 방 안을 Patrol Point로 지정하지 않도록
: 현재 방 문이 닫혀있을 때 방 안을 Patrol Point로 지정하게 되면 AI가 방 안으로 들어가려고 계속 방 문에 비비면서 걷기 때문에 플레이하는 입장에서 어색해보이는 문제를 해결하기 위해
--> NavMeshModifierVolume을 활용하여 방 문이 닫혀있다면 방 안의 Area를 Cost가 높은 Area("DoorBlocked")로 설정하여 AI가 방 안을 Patrol Point로 지정하는 일이 적도록 하고 방 문이 열리면 방 안의 Area를 기존의 "Walkable"로 설정하여 기존과 동일하게 Patrol Point로 지정하도록 구현
>> 문제 발생
- NavMeshModifierVolume은 처음에 Bake하는 시점에만 NavMesh에 영향을 주기 때문에 코드로 Area를 바꿔도 이미 Bake한 NavMesh에는 영향을 줄 수 없다. --> 문이 열릴 때 NavMeshModifierVolume.enabled =false 로 비활성화해도 이미 해당 Volume이 Bake된 NavMesh에는 영향을 줄 수 없다.
- Area를 바꾸는 것이 아니라 Area의 Cost만 코드로 변경하는 것은 Bake된 NavMesh와 상관없이 동작하지만, 현재 모든 방들이 "DoorBlocked" Area를 공유하고 있기에 하나의 문만 열어도 모든 방의 Cost가 줄어드는 문제가 발생한다. --> 각 방마다 독립된 Area를 설정하는 방법이 있지만, 사용자 정의로 설정 가능한 Area의 최대 개수는 28개 밖에 없고 현재 방 개수가 28개를 넘기 때문에 불가능하다.
>> 해결 과정
: AI가 Patrol Point를 선택할 때 문이 닫혀있는 방 안의 Point를 선택했다면 제외하는 방식으로 구현
- 구현 방식
- NavMesh.SamplePosition() 으로 Patrol Point를 구함
- 구한 Patrol Point의 위치가 포함된 방의 문이 열려 있는지를 판별 --> 얻은 Patrol Point 주변에 NavMeshModifierVolume Component가 존재하는지 감지하여, NavMeshModifierVolume가 있고 그 Area가 "DoorBlocked"인지 체크 (문이 열리면 NavMeshModifierVolume.enabled = false, 문이 닫히면 NavMeshModifierVolume.enabled = true로 설정)
- 문이 열려있으면 그 위치를 채택, 아니면 반복하여 다른 지점을 찾도록 시도
--> 문마다 [SerializeField]로 NavMeshModifierVolume을 받아서 바인딩해줌
- 테스트 결과
: Patrol Point 주변에 존재하는 NavMeshModifierVolume Component를 가져오질 못해서 아예 작동하지 않았다.
- 작성한 코드
1. SubAIController.cs
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
Vector3 findPoint = hit.position;
// 주변에 NavMeshModifierVolume이 있는지 확인
Collider[] colliders = Physics.OverlapSphere(findPoint, 0.1f);
bool isBlocked = false;
foreach (var col in colliders)
{
var volume = col.GetComponent<NavMeshModifierVolume>();
if (volume != null)
{
Debug.Log("GetComponent<NavMeshModifierVolume> 성공");
if (volume.area == NavMesh.GetAreaFromName("DoorBlocked"))
{
Debug.Log("Volume.area가 DoorBlocked 성공");
isBlocked = true;
break;
}
}
}
if (!isBlocked)
return findPoint;
}
}
// 실패하면 현재 위치 반환
return transform.position;
}
2. LockedDoor.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using JetBrains.Annotations;
using Unity.AI.Navigation;
using UnityEditor;
using UnityEngine;
using UnityEngine.Audio;
using UnityEngine.Serialization;
[RequireComponent(typeof(Rigidbody))]
public class LockedDoor : InteractiveObject
{
[SerializeField] private ItemType requiredItemType;
[SerializeField] public bool isLocked = false;
[SerializeField] public bool isOpen = false;
public float DoorOpenAngle = 90.0f; // Y축 회전 각도
private Vector3 defaulRot;
private Vector3 openRot;
public float OpenTime = 1f;
public float CloseTime = 1f;
public float smooth = 1f;
private Rigidbody rb;
bool isOpening = false;
[Header("사운드 관련")] [SerializeField] private AudioMixer mixer;
[SerializeField] private AudioClip doorOpen;
[SerializeField] private AudioClip doorClose;
[SerializeField] private AudioSource doorSoundSource;
[Header("닿았을 때 자동 문 열림")]
[SerializeField] private LayerMask targetLayerMask; // Inspector에서 설정할 LayerMask
[Header("메인도어키 상호작용시 시작될 것")]
[SerializeField] [CanBeNull] private Morgue morgue;
[Header("NavMesh 관련")]
[SerializeField] [CanBeNull] private GameObject navObstacleBlocker;
[SerializeField] [CanBeNull] private NavMeshModifierVolume roomVolume; // 추가
public override void Spawned()
{
rb = GetComponent<Rigidbody>();
defaulRot = transform.eulerAngles;
openRot = new Vector3(defaulRot.x, defaulRot.y + DoorOpenAngle, defaulRot.z);
// rotatesOnYAxis가 true이므로 초기 위치/회전 설정은 회전을 기준으로 합니다.
_isActive = true;
rb.isKinematic = true;
}
public void OnTriggerStay(Collider other)
{
if (isOpen || isOpening) return;
// 충돌한 오브젝트의 Layer가 targetLayerMask에 포함되어 있는지 확인합니다.
if ((targetLayerMask.value & (1 << other.gameObject.layer)) != 0)
{
RpcOpenDoor();
}
}
public override bool Interact(InteractionContext interactionContext)
{
if (isLocked)
{
if (interactionContext.ItemType.HasValue && interactionContext.ItemType.Value == requiredItemType)
{
isLocked = false;
interactionContext.inventory.ClearInventorySlot(interactionContext.inventory.currentItemIndex);
if (requiredItemType == ItemType.MainDoorKey)
{
Debug.Log("메인도어키 사용");
morgue?.StartMorgueDoorLoop();
}
}
else
{
UIManager.Instance.SetTestText("문이 잠겨 있다");
return false;
}
}
if (_isActive && !isOpen && !isOpening)
{
isOpen = true;
RpcOpenDoor();
}
else if (_isActive && isOpen && !isOpening)
{
isOpen = false;
RpcCloseDoor();
}
return false;
}
[Rpc(RpcSources.All, RpcTargets.All)]
public void RpcOpenDoor()
{
StartCoroutine(OpenDoor());
if (navObstacleBlocker != null) navObstacleBlocker.SetActive(false);
if (roomVolume != null) //test
roomVolume.enabled = false; //test
isOpen = true;
}
[Rpc(RpcSources.All, RpcTargets.All)]
public void RpcCloseDoor()
{
StartCoroutine(CloseDoor());
if (navObstacleBlocker != null) navObstacleBlocker.SetActive(true);
if (roomVolume != null) //test
roomVolume.enabled = true; //test
isOpen = false;
}
public IEnumerator OpenDoor()
{
isOpening = true;
float temp = 0;
Quaternion startRotation = Quaternion.Euler(defaulRot);
Quaternion endRotation = Quaternion.Euler(openRot);
doorSoundSource.PlayOneShot(doorOpen);
while (temp < OpenTime)
{
temp += Time.deltaTime;
float t = Mathf.Clamp01(temp / OpenTime);
float easedT = Mathf.SmoothStep(0, 1, t);
Quaternion newRot = Quaternion.Slerp(startRotation, endRotation, easedT);
rb.MoveRotation(newRot);
//rb.transform.rotation = newRot;
yield return null;
}
isOpening = false;
rb.MoveRotation(endRotation);
// rb.transform.rotation = endRotation;
}
public IEnumerator CloseDoor()
{
isOpening = true;
float temp = 0;
Quaternion startRotation = Quaternion.Euler(openRot);
Quaternion endRotation = Quaternion.Euler(defaulRot);
doorSoundSource.PlayOneShot(doorClose);
while (temp < CloseTime)
{
temp += Time.deltaTime;
float t = Mathf.Clamp01(temp / CloseTime);
float easedT = Mathf.SmoothStep(0, 1, t);
Quaternion newRot = Quaternion.Slerp(startRotation, endRotation, easedT);
rb.MoveRotation(newRot);
//rb.transform.rotation = newRot;
yield return null;
}
isOpening = false;
rb.MoveRotation(endRotation);
//rb.transform.rotation = endRotation;
}
}
25.06.12
AI가 방 문이 닫혀있으면 방 안을 Patrol Point로 지정하지 않도록
: 현재 방 문이 닫혀있을 때 방 안을 Patrol Point로 지정하게 되면 AI가 방 안으로 들어가려고 계속 방 문에 비비면서 걷기 때문에 플레이하는 입장에서 어색해보이는 문제를 해결하기 위해
>> 해결 과정
: NavMeshModifierVolume이 아니라 Collider와 Tag를 활용하여 이전과 동일한 방식으로 구현
- 구현 방식
- NavMesh.SamplePosition() 으로 Patrol Point를 구함
- 구한 Patrol Point의 위치가 포함된 방의 문이 열려 있는지를 판별 --> 얻은 Patrol Point 주변에 Collider가 존재하는지 감지하여, Collider가 있고 그 Collider를 가진 Object가 "ClosedRoom" Tag로 설정되어 있는지 확인 (문이 열리면 roomCollider.enabled = false, 문이 닫히면 roomCollider.enabled = true로 설정)
- 문이 열려있으면 그 위치를 채택, 아니면 반복하여 다른 지점을 찾도록 시도
--> 문마다 [SerializeField]로 Collider를 받아서 바인딩해줌
- 테스트 결과
: 랜덤으로 Patrol Point를 설정하다보니 테스트가 까다로웠지만 Debug.Log()로도 찍히는 것으로 보아 정상적으로 작동하는 듯하다.
- 작성한 코드
1. SubAIController.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using Unity.AI.Navigation;
using UnityEngine;
using UnityEngine.AI;
using Random = UnityEngine.Random;
public enum SubAIState { None, Idle, Patrol, Trace, Attack, Stun }
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(Animator))]
public class SubAIController : MonoBehaviour
{
private static readonly int Turn = Animator.StringToHash("Turn");
[Header("AI")]
// 탐지 범위 관련 변수
[SerializeField] private float detectCircleRadius = 15f;
public float DetectCircleRadius => detectCircleRadius;
// 정찰 범위 관련 변수
public float PatrolCircleRadius => patrolCircleRadius;
[SerializeField] private float patrolCircleRadius = 30f;
// AI가 탐지할 Layer 변수
public LayerMask TargetLayerMask => targetLayerMask;
[SerializeField] private LayerMask targetLayerMask;
// 시야각 관련 변수
public float MaxDetectSightAngle => maxDetectSightAngle;
[SerializeField] private float maxDetectSightAngle = 30f;
// Idle에서 Patrol로 상태를 바꾸기 전, 대기 시간을 결정하는 변수
public float MaxPatrolWaitTime => maxPatrolWaitTime;
[SerializeField] private float maxPatrolWaitTime = 3f; // 3초마다 Patrol
// Patrol Point를 찾는 횟수
private int _tryToFindRandomPoint = 10;
// 찾은 Patrol Point 변수
public Vector3 NextPatrolPoint { get; private set; }
// 공격 거리 변수
public float MaxAttackDistance => maxAttackDistance;
[SerializeField] private float maxAttackDistance = 2f;
// 속도 변수 (일반, 추격)
[SerializeField] private float normalSpeed = 2.5f;
[SerializeField] private float traceSpeed = 4f;
// 텔레포트 관련 변수 (위치, 주기)
[SerializeField] public Transform[] teleportPositions;
[SerializeField] private float teleportCooldown = 120f; // 2분 주기
private float _teleportTimer = 0f;
// 스턴 관련 변수
public float MaxStunTime => maxStunTime;
[SerializeField] private float maxStunTime = 5f;
// Turn 이후 상태를 담을 변수
public SubAIState NextStateAfterTurn { get; set; } = SubAIState.None;
// 감지한 Player의 Transform을 담을 변수
public Transform DetectedPlayerTransform { get; set; }
// ㅡㅡㅡ 상태 변수 ㅡㅡㅡ
private SubAIStateIdle _stateIdle;
private SubAIStatePatrol _statePatrol;
private SubAIStateTrace _stateTrace;
private SubAIStateAttack _stateAttack;
private SubAIStateStun _stateStun;
public SubAIState CurrentState { get; private set; }
private Dictionary<SubAIState, ISubAIState> _aiStates;
// ㅡㅡㅡ Component ㅡㅡㅡ
public NavMeshAgent Agent { get; private set; }
public Animator SubAIAnimator { get; private set; }
private void Awake()
{
SubAIAnimator = GetComponent<Animator>();
Agent = GetComponent<NavMeshAgent>();
Agent.updatePosition = true;
Agent.updateRotation = true;
// 문이 닫힌 Area의 비용을 높게 설정
int doorBlockedArea = NavMesh.GetAreaFromName("DoorBlocked");
if (doorBlockedArea >= 0) // doorBlockedArea가 -1을 반환하는 경우는 해당 이름의 Area가 정의되지 않은 경우
{
NavMesh.SetAreaCost(doorBlockedArea, 1000f);
}
}
private void Start()
{
// 상태 객체 생성
_stateIdle = new SubAIStateIdle();
_statePatrol = new SubAIStatePatrol();
_stateTrace = new SubAIStateTrace();
_stateAttack = new SubAIStateAttack();
_stateStun = new SubAIStateStun();
_aiStates = new Dictionary<SubAIState, ISubAIState>
{
{ SubAIState.Idle, _stateIdle },
{ SubAIState.Patrol, _statePatrol },
{ SubAIState.Trace, _stateTrace },
{ SubAIState.Attack, _stateAttack },
{ SubAIState.Stun, _stateStun }
};
// 상태 초기화
SetState(SubAIState.Idle);
}
private void Update()
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Update();
}
// 일정 주기마다 텔레포트
_teleportTimer += Time.deltaTime;
if (_teleportTimer >= teleportCooldown)
{
TeleportToRandomPosition();
_teleportTimer = 0f;
}
}
// 상태 변경
public void SetState(SubAIState state)
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Exit();
}
CurrentState = state;
_aiStates[CurrentState].Enter(this);
}
// 일정 반경에 Player가 진입하면 Player를 감지
public Transform DetectPlayerInCircle() // Enemy와 Player가 층이 다르면 탐지 범위 안에 있어도 인식하지 못하도록
{
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayerMask);
foreach (var hitCollider in hitColliders)
{
Debug.Log(hitCollider.gameObject.name);
NetworkObject targetNetworkObj = hitCollider.GetComponentInParent<NetworkObject>();
Debug.Log($"NetworkObject {targetNetworkObj}");
if (!targetNetworkObj) continue;
Debug.Log($"Detect Player in Circle: {hitCollider.name}");
if (!targetNetworkObj.HasInputAuthority) continue;
Vector3 playerPos = hitCollider.transform.position;
Vector3 myPos = transform.position;
float horizontalDistance =
Vector3.Distance(new Vector2(playerPos.x, playerPos.z), new Vector2(myPos.x, myPos.z));
float heightDifference = Mathf.Abs(playerPos.y - myPos.y);
if (horizontalDistance <= detectCircleRadius && heightDifference <= 1.5f) // 1.5미터 이내만 같은 층으로 인정
{
// Player의 Hide 상태를 체크하여 처리
Player player = hitCollider.GetComponentInParent<Player>(); // Player Component 가져오기
if (player != null && player.isHide)
continue;
return hitCollider.transform;
}
}
return null;
}
// 서브 빌런의 기본 이동속도
public void SetNormalSpeed()
{
Agent.speed = normalSpeed;
}
// 서브 빌런의 추격 이동속도
public void SetTraceSpeed()
{
Agent.speed = traceSpeed;
}
// 지정한 장소 중 랜덤한 위치로 텔레포트
public void TeleportToRandomPosition()
{
if (teleportPositions == null || teleportPositions.Length == 0) return;
int index = -1;
int count = teleportPositions.Length / 2;
// AI가 1층에 있다면 2층으로, 2층에 있다면 1층으로 Teleport
// 1층의 높이 : 1f, 2층의 높이 : 5f / 1층의 [index] : 짝수, 2층의 [index] : 홀수
if (transform.position.y < 3f) // AI가 1층에 존재 --> 홀수 index만 뽑기
{
index = Random.Range(0, count) * 2 + 1;
}
else // AI가 2층에 존재 --> 짝수 index만 뽑기
{
index = Random.Range(0, count) * 2;
}
Transform target = teleportPositions[index];
// NavMeshAgent를 일시적으로 끄고 텔레포트
Agent.enabled = false;
transform.position = target.position;
transform.rotation = target.rotation;
Agent.enabled = true;
// 목적지 재설정
Agent.SetDestination(target.position);
// 상태 초기화
SetState(SubAIState.Idle);
}
// Animation Event --> Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
TeleportToRandomPosition();
}
// 정찰 위치 랜덤 결정
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
Vector3 findPoint = hit.position;
// 주변에 "ClosedRoom" 태그를 가진 Collider가 있는지 확인
Collider[] colliders = Physics.OverlapSphere(findPoint, 0.3f);
bool isClosed = false;
foreach (var col in colliders)
{
if (col.enabled && col.CompareTag("ClosedRoom"))
{
Debug.Log("Collider 존재하고 ClosedRoom Tag 확인");
isClosed = true;
break;
}
}
if (!isClosed)
{
return findPoint;
}
}
}
// 실패하면 현재 위치 반환
return transform.position;
}
// 찾은 Patrol Point를 설정
public void SetNextPatrolPoint(Vector3 point)
{
NextPatrolPoint = point;
}
/// <summary>
/// 목적지까지 경로를 계산하고, 현재 바라보는 방향과 첫 이동 방향의 각도가 지정 각도보다 크면 true 반환
/// </summary>
/// <param name="destination">이동할 목적지</param>
/// <returns>회전 필요 여부</returns>
public bool NeedsToTurn(Vector3 destination)
{
NavMeshPath path = new NavMeshPath();
if (!NavMesh.CalculatePath(transform.position, destination, NavMesh.AllAreas, path))
return false;
if (path.corners.Length >= 2)
{
Vector3 dir = (path.corners[1] - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, dir);
return angle > 120f;
}
return false;
}
// Animation 속도 변경
public void SetAnimationSpeed()
{
float speedRatio = Agent.velocity.magnitude / Agent.speed;
SubAIAnimator.speed = Mathf.Max(0.5f, speedRatio); // 최소 속도 보장
}
// Animation 속도 정상화
public void ResetAnimationSpeed()
{
SubAIAnimator.speed = 1f;
}
#region 디버깅
private void OnDrawGizmos()
{
// Circle 감지 범위
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectCircleRadius);
// 시야각
Gizmos.color = Color.red;
Vector3 rightDirection = Quaternion.Euler(0, maxDetectSightAngle, 0) * transform.forward;
Vector3 leftDirection = Quaternion.Euler(0, -maxDetectSightAngle, 0) * transform.forward;
Gizmos.DrawRay(transform.position, rightDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, leftDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, transform.forward * detectCircleRadius);
// Agent 목적지 시각화
if (Agent != null && Agent.hasPath)
{
Gizmos.color = Color.green;
Gizmos.DrawSphere(Agent.destination, 0.5f);
Gizmos.DrawLine(Agent.destination, Agent.destination);
}
}
#endregion
}
2. LockedDoor.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using JetBrains.Annotations;
using Unity.AI.Navigation;
using UnityEditor;
using UnityEngine;
using UnityEngine.Audio;
using UnityEngine.Serialization;
[RequireComponent(typeof(Rigidbody))]
public class LockedDoor : InteractiveObject
{
[SerializeField] private ItemType requiredItemType;
[SerializeField] public bool isLocked = false;
[SerializeField] public bool isOpen = false;
public float DoorOpenAngle = 90.0f; // Y축 회전 각도
private Vector3 defaulRot;
private Vector3 openRot;
public float OpenTime = 1f;
public float CloseTime = 1f;
public float smooth = 1f;
private Rigidbody rb;
bool isOpening = false;
[Header("사운드 관련")] [SerializeField] private AudioMixer mixer;
[SerializeField] private AudioClip doorOpen;
[SerializeField] private AudioClip doorClose;
[SerializeField] private AudioSource doorSoundSource;
[Header("닿았을 때 자동 문 열림")]
[SerializeField] private LayerMask targetLayerMask; // Inspector에서 설정할 LayerMask
[Header("메인도어키 상호작용시 시작될 것")]
[SerializeField] [CanBeNull] private Morgue morgue;
[Header("NavMesh 관련")]
[SerializeField] [CanBeNull] private GameObject navObstacleBlocker;
[SerializeField] [CanBeNull] private Collider roomCollider;
public override void Spawned()
{
rb = GetComponent<Rigidbody>();
defaulRot = transform.eulerAngles;
openRot = new Vector3(defaulRot.x, defaulRot.y + DoorOpenAngle, defaulRot.z);
// rotatesOnYAxis가 true이므로 초기 위치/회전 설정은 회전을 기준으로 합니다.
_isActive = true;
rb.isKinematic = true;
}
public void OnTriggerStay(Collider other)
{
if (isOpen || isOpening) return;
// 충돌한 오브젝트의 Layer가 targetLayerMask에 포함되어 있는지 확인합니다.
if ((targetLayerMask.value & (1 << other.gameObject.layer)) != 0)
{
RpcOpenDoor();
}
}
public override bool Interact(InteractionContext interactionContext)
{
if (isLocked)
{
if (interactionContext.ItemType.HasValue && interactionContext.ItemType.Value == requiredItemType)
{
isLocked = false;
interactionContext.inventory.ClearInventorySlot(interactionContext.inventory.currentItemIndex);
if (requiredItemType == ItemType.MainDoorKey)
{
Debug.Log("메인도어키 사용");
morgue?.StartMorgueDoorLoop();
}
}
else
{
UIManager.Instance.SetTestText("문이 잠겨 있다");
return false;
}
}
if (_isActive && !isOpen && !isOpening)
{
isOpen = true;
RpcOpenDoor();
}
else if (_isActive && isOpen && !isOpening)
{
isOpen = false;
RpcCloseDoor();
}
return false;
}
[Rpc(RpcSources.All, RpcTargets.All)]
public void RpcOpenDoor()
{
StartCoroutine(OpenDoor());
if (navObstacleBlocker != null) navObstacleBlocker.SetActive(false);
if (roomCollider != null) //test
roomCollider.enabled = false; //test
isOpen = true;
}
[Rpc(RpcSources.All, RpcTargets.All)]
public void RpcCloseDoor()
{
StartCoroutine(CloseDoor());
if (navObstacleBlocker != null) navObstacleBlocker.SetActive(true);
if (roomCollider != null) //test
roomCollider.enabled = true; //test
isOpen = false;
}
public IEnumerator OpenDoor()
{
isOpening = true;
float temp = 0;
Quaternion startRotation = Quaternion.Euler(defaulRot);
Quaternion endRotation = Quaternion.Euler(openRot);
doorSoundSource.PlayOneShot(doorOpen);
while (temp < OpenTime)
{
temp += Time.deltaTime;
float t = Mathf.Clamp01(temp / OpenTime);
float easedT = Mathf.SmoothStep(0, 1, t);
Quaternion newRot = Quaternion.Slerp(startRotation, endRotation, easedT);
rb.MoveRotation(newRot);
//rb.transform.rotation = newRot;
yield return null;
}
isOpening = false;
rb.MoveRotation(endRotation);
// rb.transform.rotation = endRotation;
}
public IEnumerator CloseDoor()
{
isOpening = true;
float temp = 0;
Quaternion startRotation = Quaternion.Euler(openRot);
Quaternion endRotation = Quaternion.Euler(defaulRot);
doorSoundSource.PlayOneShot(doorClose);
while (temp < CloseTime)
{
temp += Time.deltaTime;
float t = Mathf.Clamp01(temp / CloseTime);
float easedT = Mathf.SmoothStep(0, 1, t);
Quaternion newRot = Quaternion.Slerp(startRotation, endRotation, easedT);
rb.MoveRotation(newRot);
//rb.transform.rotation = newRot;
yield return null;
}
isOpening = false;
rb.MoveRotation(endRotation);
//rb.transform.rotation = endRotation;
}
}
25.06.16
AI가 방 문이 닫혀있으면 방 안을 Patrol Point로 지정하지 않도록
: 현재 방 문이 닫혀있을 때 방 안을 Patrol Point로 지정하게 되면 AI가 방 안으로 들어가려고 계속 방 문에 비비면서 걷기 때문에 플레이하는 입장에서 어색해보이는 문제를 해결하기 위해
>> 문제 발생
: Merge한 다음 다시 테스트 해보니까 여전히 방 문이 닫혀있어도 방 안을 Patrol Point로 지정하는 문제 발생 --> 그 빈도가 잦음.
>> 해결 과정
1. Collider를 더 정확하게 감지하기 위해 Physics.OverlapSphere() 대신 Physics.OverlapBox()를 사용
※ OverlapBox
https://docs.unity3d.com/ScriptReference/Physics.OverlapBox.html
--> 사용한 코드
Collider[] overlaps = Physics.OverlapBox(
patrolPoint,
Vector3.one * 0.5f, // Box 크기
Quaternion.identity,
LayerMask.GetMask("Default"));
- 디버깅
: Physics.OverlapBox()를 사용해도 같은 문제가 여전히 발생하여 Debug.Log 를 통해 원인을 파악함
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
Vector3 findPoint = hit.position;
// OverlapBox 실행 및 디버깅 출력
Collider[] colliders = Physics.OverlapBox(
findPoint,
Vector3.one * 0.5f,
Quaternion.identity,
LayerMask.GetMask("Default")
);
Debug.Log($"[FindRandomPatrolPoint] Try {i + 1}: Found {colliders.Length} colliders at point {findPoint}");
foreach (var col in colliders)
{
Debug.Log($"[FindRandomPatrolPoint] Collider: {col.name}, Tag: {col.tag}, Enabled: {col.enabled}");
}
bool isClosed = false;
foreach (var col in colliders)
{
if (col.enabled && col.CompareTag("ClosedRoom"))
{
Debug.Log($"[FindRandomPatrolPoint] -> CLOSED ROOM DETECTED at {findPoint} by {col.name}");
isClosed = true;
break;
}
}
if (!isClosed)
{
Debug.Log($"[FindRandomPatrolPoint] ✅ Valid Patrol Point: {findPoint}");
return findPoint;
}
else
{
Debug.Log($"[FindRandomPatrolPoint] ❌ Rejected due to ClosedRoom Collider");
}
}
}
Debug.Log("[FindRandomPatrolPoint] ⚠️ No valid point found — returning current position");
return transform.position;
}
▶ 디버깅 결과
: 방의 Collider를 감지하는 것이 아니라 Terrain을 감지하고 있던 것이 문제
2. Collider를 구분하는 데에 Tag 대신 Layer를 활용
: Physics.OverlapBox()는 해당 위치의 모든 Collider를 찾기 때문에 정확히 방의 Collider를 감지하기 위해서 방에 Layer를 설정하고 그 Layer만 감지하도록
--> 새로 "ClosedRoom" Layer를 추가하고 방마다 Layer 설정하기
- 작성한 코드
: Layer를 설정했기 때문에 Collider가 감지되면 무조건 방 안의 Collider이다. 따라서 foreach로 일일이 하나씩 확인할 필요가 없어진다.
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
Vector3 findPoint = hit.position;
// 오직 ClosedRoom Layer만 감지
Collider[] colliders = Physics.OverlapBox(
findPoint,
Vector3.one * 0.5f,
Quaternion.identity,
LayerMask.GetMask("ClosedRoom") // 감지할 Layer
);
Debug.Log($"[FindRandomPatrolPoint] Try {i + 1}: Found {colliders.Length} colliders at point {findPoint}");
foreach (var col in colliders)
{
Debug.Log($"[FindRandomPatrolPoint] Collider: {col.name}, Layer: {LayerMask.LayerToName(col.gameObject.layer)}, Enabled: {col.enabled}");
}
bool isClosed = colliders.Length > 0;
if (!isClosed)
{
Debug.Log($"[FindRandomPatrolPoint] ✅ Valid Patrol Point: {findPoint}");
return findPoint;
}
}
}
Debug.Log("[FindRandomPatrolPoint] ⚠️ No valid point found — returning current position");
return transform.position;
}
▶ 디버깅 결과
: 아예 foreach문이 작동하지 않아서 foreach문에 있는 Debug.Log가 Console에 찍히지 않는다.
--> Physics.OverlapBox()가 LayerMask.GetMask("ClosedRoom")로 Layer를 필터링했는데, 결과가 0개라서 foreach가 아예 돌지 않은 것
3. 문제 원인 파악
- LayerMask가 제대로 설정됐는가?
- Collider를 감지하지 못한 것인가?
- 문제 원인 파악을 위한 코드
: LayerMask 없이 감지했을 때 감지가 된다면 LayerMask 문제, 감지가 되지 않는다면 OverlapBox의 범위/위치 문제
Collider[] colliders = Physics.OverlapBox(
findPoint,
Vector3.one * 1.0f,
Quaternion.identity
);
Debug.Log($"[DEBUG] Found {colliders.Length} colliders at point {findPoint}");
foreach (var col in colliders)
{
Debug.Log($"[DEBUG] Collider: {col.name}, Layer: {LayerMask.LayerToName(col.gameObject.layer)}");
}
▶ 디버깅 결과
: Collider 감지가 성공적으로 된 것으로 보아 LayerMask의 문제
--> 하지만 모든 LayerMask가 정상적으로 설정되어 있었으며, 다시 "ClosedRoom" Layer만 감지하도록 설정하여 테스트해보면 여전히 하나도 감지하지 못해서 foreach문으로 진입을 못함. 혹시 몰라 OverlapBox()의 사이즈도 크게 해봤지만 마찬가지.
4. 디버깅 중 발견한 의심되는 문제점
: 여러 번의 테스트 중 한번은 정상적으로 Collider가 감지되어 기능이 실행됐다. 이때 찾은 Patrol Point 지점과 실패했을 때의 Patrol Point 지점을 비교해본 결과, 성공했을 때 Patrol Point의 Y축이 1f에 가까웠지만 실패했을 때 Patrol Point의 Y축은 9f나 0f 등 높이가 아주 높거나 아주 낮았다.
--> 현재 Map에서 1층 바닥의 높이는 1f, 2층 바닥의 높이는 5f인데, 찾은 Patrol Point의 높이가 바닥보다 너무 낮거나 높아서 Collider의 범위를 벗어나버려 감지하지 못했던 것
※ 그럼 왜 AI는 Patrol Point의 높이가 맞지 않아도 길을 잘 찾았는가?
: NavMesh.SamplePosition()은 반경 내에서 가장 가까운 NavMesh 위의 점을 찾아 정확한 바닥 높이로 snap 해주기 때문에 Random.insideUnitSphere로 찾은 randomDirection의 높이가 엉망이어도 상관없었던 것
--> 하지만 OverlapBox()는 찾은 Point의 Y좌표를 기준으로 3D 박스를 만들기 때문에 찾은 지점의 Y값이 방 바닥보다 너무 낮거나 높으면 박스가 방 Collider를 안 건드려서 감지하지 못함.
5. 해결 방안
: 찾은 Point의 y값을 통해 층을 자동으로 구분해서 높이를 고정한 다음 OverlapBox()를 호출하여 해결
- 작성한 코드
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit, DetectCircleRadius, NavMesh.AllAreas))
{
Vector3 findPoint = hit.position;
// 층 높이에 맞게 Y 고정
float floorY = (transform.position.y < 3f) ? 1f : 5f;
findPoint.y = floorY;
Collider[] colliders = Physics.OverlapBox(
findPoint,
Vector3.one * 1.5f,
Quaternion.identity,
LayerMask.GetMask("ClosedRoom")
);
Debug.Log($"[FindRandomPatrolPoint] Try {i + 1}: Found {colliders.Length} colliders at {findPoint}");
foreach (var col in colliders)
{
Debug.Log($"[FindRandomPatrolPoint] Collider: {col.name}, Layer: {LayerMask.LayerToName(col.gameObject.layer)}, Enabled: {col.enabled}");
}
bool isClosed = colliders.Length > 0;
if (!isClosed)
{
Debug.Log($"[FindRandomPatrolPoint] ✅ Valid Patrol Point: {findPoint}");
return findPoint;
}
}
}
Debug.Log("[FindRandomPatrolPoint] ⚠ No valid point found — returning current position");
return transform.position;
}
▶ 디버깅 결과
: 드디어 Collider가 정상적으로 감지되기 시작했다.
>> 아직 남은 문제점
: Random으로 뽑은 지점이 아예 건물의 밖이거나 아래 사진과 같이 방과 방 사이에 존재하는 빈 공간이라면 거기엔 Collider가 없기 때문에 찾은 지점을 반환하게 되는데, 그때 NavMesh.SamplePosition()가 반경 내에서 가장 가까운 NavMesh 위의 점을 찾아 snap한 지점이 방 안이라면 그대로 방 안을 Patrol Point로 지정하게 된다.
>> 작성한 코드
- SubAIController.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using UnityEngine;
using UnityEngine.AI;
using Random = UnityEngine.Random;
public enum SubAIState { None, Idle, Patrol, Trace, Attack, Stun }
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(Animator))]
public class SubAIController : MonoBehaviour
{
private static readonly int Turn = Animator.StringToHash("Turn");
[Header("AI")]
// 탐지 범위 관련 변수
[SerializeField] private float detectCircleRadius = 15f;
public float DetectCircleRadius => detectCircleRadius;
// 정찰 범위 관련 변수
public float PatrolCircleRadius => patrolCircleRadius;
[SerializeField] private float patrolCircleRadius = 30f;
// AI가 탐지할 Layer 변수
public LayerMask TargetLayerMask => targetLayerMask;
[SerializeField] private LayerMask targetLayerMask;
// 시야각 관련 변수
public float MaxDetectSightAngle => maxDetectSightAngle;
[SerializeField] private float maxDetectSightAngle = 30f;
// Idle에서 Patrol로 상태를 바꾸기 전, 대기 시간을 결정하는 변수
public float MaxPatrolWaitTime => maxPatrolWaitTime;
[SerializeField] private float maxPatrolWaitTime = 3f; // 3초마다 Patrol
// Patrol Point를 찾는 횟수
private int _tryToFindRandomPoint = 10;
// 찾은 Patrol Point 변수
public Vector3 NextPatrolPoint { get; private set; }
// 공격 거리 변수
public float MaxAttackDistance => maxAttackDistance;
[SerializeField] private float maxAttackDistance = 2f;
// 속도 변수 (일반, 추격)
[SerializeField] private float normalSpeed = 2.5f;
[SerializeField] private float traceSpeed = 4f;
// 텔레포트 관련 변수 (위치, 주기)
[SerializeField] public Transform[] teleportPositions;
[SerializeField] private float teleportCooldown = 120f; // 2분 주기
private float _teleportTimer = 0f;
// 스턴 관련 변수
public float MaxStunTime => maxStunTime;
[SerializeField] private float maxStunTime = 5f;
// Turn 이후 상태를 담을 변수
public SubAIState NextStateAfterTurn { get; set; } = SubAIState.None;
// 감지한 Player의 Transform을 담을 변수
public Transform DetectedPlayerTransform { get; set; }
// ㅡㅡㅡ 상태 변수 ㅡㅡㅡ
private SubAIStateIdle _stateIdle;
private SubAIStatePatrol _statePatrol;
private SubAIStateTrace _stateTrace;
private SubAIStateAttack _stateAttack;
private SubAIStateStun _stateStun;
public SubAIState CurrentState { get; private set; }
private Dictionary<SubAIState, ISubAIState> _aiStates;
// ㅡㅡㅡ Component ㅡㅡㅡ
public NavMeshAgent Agent { get; private set; }
public Animator SubAIAnimator { get; private set; }
private void Awake()
{
SubAIAnimator = GetComponent<Animator>();
Agent = GetComponent<NavMeshAgent>();
Agent.updatePosition = true;
Agent.updateRotation = true;
}
private void Start()
{
// 상태 객체 생성
_stateIdle = new SubAIStateIdle();
_statePatrol = new SubAIStatePatrol();
_stateTrace = new SubAIStateTrace();
_stateAttack = new SubAIStateAttack();
_stateStun = new SubAIStateStun();
_aiStates = new Dictionary<SubAIState, ISubAIState>
{
{ SubAIState.Idle, _stateIdle },
{ SubAIState.Patrol, _statePatrol },
{ SubAIState.Trace, _stateTrace },
{ SubAIState.Attack, _stateAttack },
{ SubAIState.Stun, _stateStun }
};
// 상태 초기화
SetState(SubAIState.Idle);
}
private void Update()
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Update();
}
// 일정 주기마다 텔레포트
_teleportTimer += Time.deltaTime;
if (_teleportTimer >= teleportCooldown)
{
TeleportToRandomPosition();
_teleportTimer = 0f;
}
}
// 상태 변경
public void SetState(SubAIState state)
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Exit();
}
CurrentState = state;
_aiStates[CurrentState].Enter(this);
}
// 일정 반경에 Player가 진입하면 Player를 감지
public Transform DetectPlayerInCircle() // Enemy와 Player가 층이 다르면 탐지 범위 안에 있어도 인식하지 못하도록
{
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayerMask);
foreach (var hitCollider in hitColliders)
{
Debug.Log(hitCollider.gameObject.name);
NetworkObject targetNetworkObj = hitCollider.GetComponentInParent<NetworkObject>();
Debug.Log($"NetworkObject {targetNetworkObj}");
if (!targetNetworkObj) continue;
Debug.Log($"Detect Player in Circle: {hitCollider.name}");
if (!targetNetworkObj.HasInputAuthority) continue;
Vector3 playerPos = hitCollider.transform.position;
Vector3 myPos = transform.position;
float horizontalDistance =
Vector3.Distance(new Vector2(playerPos.x, playerPos.z), new Vector2(myPos.x, myPos.z));
float heightDifference = Mathf.Abs(playerPos.y - myPos.y);
if (horizontalDistance <= detectCircleRadius && heightDifference <= 1.5f) // 1.5미터 이내만 같은 층으로 인정
{
// Player의 Hide 상태를 체크하여 처리
Player player = hitCollider.GetComponentInParent<Player>(); // Player Component 가져오기
if (player != null && player.isHide)
continue;
return hitCollider.transform;
}
}
return null;
}
// 서브 빌런의 기본 이동속도
public void SetNormalSpeed()
{
Agent.speed = normalSpeed;
}
// 서브 빌런의 추격 이동속도
public void SetTraceSpeed()
{
Agent.speed = traceSpeed;
}
// 지정한 장소 중 랜덤한 위치로 텔레포트
public void TeleportToRandomPosition()
{
if (teleportPositions == null || teleportPositions.Length == 0) return;
int index = -1;
int count = teleportPositions.Length / 2;
// AI가 1층에 있다면 2층으로, 2층에 있다면 1층으로 Teleport
// 1층의 높이 : 1f, 2층의 높이 : 5f / 1층의 [index] : 짝수, 2층의 [index] : 홀수
if (transform.position.y < 3f) // AI가 1층에 존재 --> 홀수 index만 뽑기
{
index = Random.Range(0, count) * 2 + 1;
}
else // AI가 2층에 존재 --> 짝수 index만 뽑기
{
index = Random.Range(0, count) * 2;
}
Transform target = teleportPositions[index];
// NavMeshAgent를 일시적으로 끄고 텔레포트
Agent.enabled = false;
transform.position = target.position;
transform.rotation = target.rotation;
Agent.enabled = true;
// 목적지 재설정
Agent.SetDestination(target.position);
// 상태 초기화
SetState(SubAIState.Idle);
}
// Animation Event --> Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
TeleportToRandomPosition();
}
// 정찰 위치 랜덤 결정
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
Vector3 findPoint = hit.position;
// 층 높이에 맞게 y값 고정
float floorY = (transform.position.y < 3f) ? 1f : 5f;
findPoint.y = floorY;
// 주변에 "ClosedRoom" Layer를 가진 Collider가 있는지 확인
Collider[] colliders = Physics.OverlapBox(
findPoint,
Vector3.one * 1.5f,
Quaternion.identity,
LayerMask.GetMask("ClosedRoom"));
Debug.Log($"[DEBUG] Found {colliders.Length} colliders at point {findPoint}");
foreach (var col in colliders)
{
Debug.Log($"[DEBUG] Collider: {col.name}, Layer: {LayerMask.LayerToName(col.gameObject.layer)}, Enabled: {col.enabled}");
}
bool isClosed = colliders.Length > 0;
if (!isClosed)
{
Debug.Log($"[FindRandomPatrolPoint] Success / Valid Patrol Point: {findPoint}");
return findPoint;
}
}
}
// 실패하면 현재 위치 반환
Debug.Log("[FindRandomPatrolPoint] ⚠ No valid point found — returning current position");
return transform.position;
}
// 찾은 Patrol Point를 설정
public void SetNextPatrolPoint(Vector3 point)
{
NextPatrolPoint = point;
}
/// <summary>
/// 목적지까지 경로를 계산하고, 현재 바라보는 방향과 첫 이동 방향의 각도가 지정 각도보다 크면 true 반환
/// </summary>
/// <param name="destination">이동할 목적지</param>
/// <returns>회전 필요 여부</returns>
public bool NeedsToTurn(Vector3 destination)
{
NavMeshPath path = new NavMeshPath();
if (!NavMesh.CalculatePath(transform.position, destination, NavMesh.AllAreas, path))
return false;
if (path.corners.Length >= 2)
{
Vector3 dir = (path.corners[1] - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, dir);
return angle > 120f;
}
return false;
}
// Animation 속도 변경
public void SetAnimationSpeed()
{
float speedRatio = Agent.velocity.magnitude / Agent.speed;
SubAIAnimator.speed = Mathf.Max(0.5f, speedRatio); // 최소 속도 보장
}
// Animation 속도 정상화
public void ResetAnimationSpeed()
{
SubAIAnimator.speed = 1f;
}
#region 디버깅
private void OnDrawGizmos()
{
// Circle 감지 범위
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectCircleRadius);
// 시야각
Gizmos.color = Color.red;
Vector3 rightDirection = Quaternion.Euler(0, maxDetectSightAngle, 0) * transform.forward;
Vector3 leftDirection = Quaternion.Euler(0, -maxDetectSightAngle, 0) * transform.forward;
Gizmos.DrawRay(transform.position, rightDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, leftDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, transform.forward * detectCircleRadius);
// Agent 목적지 시각화
if (Agent != null && Agent.hasPath)
{
Gizmos.color = Color.green;
Gizmos.DrawSphere(Agent.destination, 0.5f);
Gizmos.DrawLine(Agent.destination, Agent.destination);
}
}
#endregion
}
25.06.17
AI가 방 문이 닫혀있으면 방 안을 Patrol Point로 지정하지 않도록
: 현재 방 문이 닫혀있을 때 방 안을 Patrol Point로 지정하게 되면 AI가 방 안으로 들어가려고 계속 방 문에 비비면서 걷기 때문에 플레이하는 입장에서 어색해보이는 문제를 해결하기 위해
--> 지난 시간에 찾은 문제점을 마저 해결해보자.
>> 아직 남은 문제점
: Random으로 뽑은 지점이 아예 건물의 밖이거나 아래 사진과 같이 방과 방 사이에 존재하는 빈 공간이라면 거기엔 Collider가 없기 때문에 찾은 지점을 반환하게 되는데, 그때 NavMesh.SamplePosition()가 반경 내에서 가장 가까운 NavMesh 위의 점을 찾아 snap한 지점이 방 안이라면 그대로 방 안을 Patrol Point로 지정하게 된다.
>> 해결 과정
: NavMesh.CalculatePath() 를 호출해서 AI의 현재 위치에서 찾은 Patrol Point까지 경로를 직접 계산 --> 경로가 벽이나 닫힌 문으로 막혀 있으면 path.status가 PathPartial 이나 PathInvalid로 반환되고 성공하면 PathComplete로 반환되는 것을 기존의 Collider 감지와 같이 활용하여 구현하여 드디어 완전하게 기능이 작동하는 것을 확인
- 기능 동작 순서
- 방이 닫혀있는지 OverlapBox로 먼저 필터링
- NavMeshPath로 경로가 유효한지 추가로 필터링
- 둘 다 통과한 Point만 사용
>> 아쉬운 부분
: 현재 Main AI의 기능을 위해 복도 문의 NavMeshObstacle의 Carve는 무조건 체크 해제해야 하는데, 그럼 복도 문이 닫혀있어도 NavMesh는 문이 열린 것처럼 이어져있다. 이때, 현재 구조로는 닫힌 문 너머의 복도로 Patrol Point를 잡는 것은 막을 수 없어서 복도 문에 비비면서 걷는 문제는 여전히 남아있다.
>> 작성한 코드
// 정찰 위치 랜덤 결정
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
Vector3 findPoint = hit.position;
// 층 높이에 맞게 y값 고정
findPoint.y = (transform.position.y < 3f) ? 1f : 5f;
// 주변에 "ClosedRoom" Layer를 가진 Collider가 있는지 확인
Collider[] colliders = Physics.OverlapBox(
findPoint,
Vector3.one * 1.0f,
Quaternion.identity,
LayerMask.GetMask("ClosedRoom"));
if (colliders.Length > 0)
{
Debug.Log("XXX / ClosedRoom detected ㅡ Skip");
continue;
}
// Path 체크
NavMeshPath path = new NavMeshPath();
Agent.CalculatePath(findPoint, path);
if (path.status != NavMeshPathStatus.PathComplete)
{
Debug.Log("XXX / Path blocked ㅡ skip");
continue;
}
Debug.Log($"OOO / Valid Patrol Point : {findPoint}");
return findPoint;
}
}
// 실패하면 현재 위치 반환
Debug.Log("⚠ No valid point found — returning current position");
return transform.position;
}
- 테스트 결과
: 두 번의 필터링 모두 동작하고, 무사히 Patrol Point를 찾아서 이동하는 것까지 확인 완료
>> 최종 코드
- SubAIController.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using UnityEngine;
using UnityEngine.AI;
using Random = UnityEngine.Random;
public enum SubAIState { None, Idle, Patrol, Trace, Attack, Stun }
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(Animator))]
public class SubAIController : MonoBehaviour
{
private static readonly int Turn = Animator.StringToHash("Turn");
[Header("AI")]
// 탐지 범위 관련 변수
[SerializeField] private float detectCircleRadius = 15f;
public float DetectCircleRadius => detectCircleRadius;
// 정찰 범위 관련 변수
public float PatrolCircleRadius => patrolCircleRadius;
[SerializeField] private float patrolCircleRadius = 30f;
// AI가 탐지할 Layer 변수
public LayerMask TargetLayerMask => targetLayerMask;
[SerializeField] private LayerMask targetLayerMask;
// 시야각 관련 변수
public float MaxDetectSightAngle => maxDetectSightAngle;
[SerializeField] private float maxDetectSightAngle = 30f;
// Idle에서 Patrol로 상태를 바꾸기 전, 대기 시간을 결정하는 변수
public float MaxPatrolWaitTime => maxPatrolWaitTime;
[SerializeField] private float maxPatrolWaitTime = 3f; // 3초마다 Patrol
// Patrol Point를 찾는 횟수
private int _tryToFindRandomPoint = 10;
// 찾은 Patrol Point 변수
public Vector3 NextPatrolPoint { get; private set; }
// 공격 거리 변수
public float MaxAttackDistance => maxAttackDistance;
[SerializeField] private float maxAttackDistance = 2f;
// 속도 변수 (일반, 추격)
[SerializeField] private float normalSpeed = 2.5f;
[SerializeField] private float traceSpeed = 4f;
// 텔레포트 관련 변수 (위치, 주기)
[SerializeField] public Transform[] teleportPositions;
[SerializeField] private float teleportCooldown = 120f; // 2분 주기
private float _teleportTimer = 0f;
// 스턴 관련 변수
public float MaxStunTime => maxStunTime;
[SerializeField] private float maxStunTime = 5f;
// Turn 이후 상태를 담을 변수
public SubAIState NextStateAfterTurn { get; set; } = SubAIState.None;
// 감지한 Player의 Transform을 담을 변수
public Transform DetectedPlayerTransform { get; set; }
// ㅡㅡㅡ 상태 변수 ㅡㅡㅡ
private SubAIStateIdle _stateIdle;
private SubAIStatePatrol _statePatrol;
private SubAIStateTrace _stateTrace;
private SubAIStateAttack _stateAttack;
private SubAIStateStun _stateStun;
public SubAIState CurrentState { get; private set; }
private Dictionary<SubAIState, ISubAIState> _aiStates;
// ㅡㅡㅡ Component ㅡㅡㅡ
public NavMeshAgent Agent { get; private set; }
public Animator SubAIAnimator { get; private set; }
private void Awake()
{
SubAIAnimator = GetComponent<Animator>();
Agent = GetComponent<NavMeshAgent>();
Agent.updatePosition = true;
Agent.updateRotation = true;
}
private void Start()
{
// 상태 객체 생성
_stateIdle = new SubAIStateIdle();
_statePatrol = new SubAIStatePatrol();
_stateTrace = new SubAIStateTrace();
_stateAttack = new SubAIStateAttack();
_stateStun = new SubAIStateStun();
_aiStates = new Dictionary<SubAIState, ISubAIState>
{
{ SubAIState.Idle, _stateIdle },
{ SubAIState.Patrol, _statePatrol },
{ SubAIState.Trace, _stateTrace },
{ SubAIState.Attack, _stateAttack },
{ SubAIState.Stun, _stateStun }
};
// 상태 초기화
SetState(SubAIState.Idle);
}
private void Update()
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Update();
}
// 일정 주기마다 텔레포트
_teleportTimer += Time.deltaTime;
if (_teleportTimer >= teleportCooldown)
{
TeleportToRandomPosition();
_teleportTimer = 0f;
}
}
// 상태 변경
public void SetState(SubAIState state)
{
if (CurrentState != SubAIState.None)
{
_aiStates[CurrentState].Exit();
}
CurrentState = state;
_aiStates[CurrentState].Enter(this);
}
// 일정 반경에 Player가 진입하면 Player를 감지
public Transform DetectPlayerInCircle() // Enemy와 Player가 층이 다르면 탐지 범위 안에 있어도 인식하지 못하도록
{
var hitColliders = Physics.OverlapSphere(transform.position, detectCircleRadius, targetLayerMask);
foreach (var hitCollider in hitColliders)
{
Debug.Log(hitCollider.gameObject.name);
NetworkObject targetNetworkObj = hitCollider.GetComponentInParent<NetworkObject>();
Debug.Log($"NetworkObject {targetNetworkObj}");
if (!targetNetworkObj) continue;
Debug.Log($"Detect Player in Circle: {hitCollider.name}");
if (!targetNetworkObj.HasInputAuthority) continue;
Vector3 playerPos = hitCollider.transform.position;
Vector3 myPos = transform.position;
float horizontalDistance =
Vector3.Distance(new Vector2(playerPos.x, playerPos.z), new Vector2(myPos.x, myPos.z));
float heightDifference = Mathf.Abs(playerPos.y - myPos.y);
if (horizontalDistance <= detectCircleRadius && heightDifference <= 1.5f) // 1.5미터 이내만 같은 층으로 인정
{
// Player의 Hide 상태를 체크하여 처리
Player player = hitCollider.GetComponentInParent<Player>(); // Player Component 가져오기
if (player != null && player.isHide)
continue;
return hitCollider.transform;
}
}
return null;
}
// 서브 빌런의 기본 이동속도
public void SetNormalSpeed()
{
Agent.speed = normalSpeed;
}
// 서브 빌런의 추격 이동속도
public void SetTraceSpeed()
{
Agent.speed = traceSpeed;
}
// 지정한 장소 중 랜덤한 위치로 텔레포트
public void TeleportToRandomPosition()
{
if (teleportPositions == null || teleportPositions.Length == 0) return;
int index = -1;
int count = teleportPositions.Length / 2;
// AI가 1층에 있다면 2층으로, 2층에 있다면 1층으로 Teleport
// 1층의 높이 : 1f, 2층의 높이 : 5f / 1층의 [index] : 짝수, 2층의 [index] : 홀수
if (transform.position.y < 3f) // AI가 1층에 존재 --> 홀수 index만 뽑기
{
index = Random.Range(0, count) * 2 + 1;
}
else // AI가 2층에 존재 --> 짝수 index만 뽑기
{
index = Random.Range(0, count) * 2;
}
Transform target = teleportPositions[index];
// NavMeshAgent를 일시적으로 끄고 텔레포트
Agent.enabled = false;
transform.position = target.position;
transform.rotation = target.rotation;
Agent.enabled = true;
// 목적지 재설정
Agent.SetDestination(target.position);
// 상태 초기화
SetState(SubAIState.Idle);
}
// Animation Event --> Attack Animation이 끝난 후 랜덤 포인트 중 한 곳으로 텔레포트
public void AttackEnd()
{
TeleportToRandomPosition();
}
// 정찰 위치 랜덤 결정
public Vector3 FindRandomPatrolPoint()
{
for (int i = 0; i < _tryToFindRandomPoint; i++)
{
Vector3 randomDirection = Random.insideUnitSphere * PatrolCircleRadius;
randomDirection += transform.position;
if (NavMesh.SamplePosition(randomDirection, out NavMeshHit hit,
DetectCircleRadius, NavMesh.AllAreas))
{
Vector3 findPoint = hit.position;
// 층 높이에 맞게 y값 고정
findPoint.y = (transform.position.y < 3f) ? 1f : 5f;
// 주변에 "ClosedRoom" Layer를 가진 Collider가 있는지 확인
Collider[] colliders = Physics.OverlapBox(
findPoint,
Vector3.one * 1.0f,
Quaternion.identity,
LayerMask.GetMask("ClosedRoom"));
if (colliders.Length > 0)
{
continue;
}
// Path 체크
NavMeshPath path = new NavMeshPath();
Agent.CalculatePath(findPoint, path);
if (path.status != NavMeshPathStatus.PathComplete)
{
continue;
}
return findPoint;
}
}
// 실패하면 현재 위치 반환
return transform.position;
}
// 찾은 Patrol Point를 설정
public void SetNextPatrolPoint(Vector3 point)
{
NextPatrolPoint = point;
}
/// <summary>
/// 목적지까지 경로를 계산하고, 현재 바라보는 방향과 첫 이동 방향의 각도가 지정 각도보다 크면 true 반환
/// </summary>
/// <param name="destination">이동할 목적지</param>
/// <returns>회전 필요 여부</returns>
public bool NeedsToTurn(Vector3 destination)
{
NavMeshPath path = new NavMeshPath();
if (!NavMesh.CalculatePath(transform.position, destination, NavMesh.AllAreas, path))
return false;
if (path.corners.Length >= 2)
{
Vector3 dir = (path.corners[1] - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, dir);
return angle > 120f;
}
return false;
}
// Animation 속도 변경
public void SetAnimationSpeed()
{
float speedRatio = Agent.velocity.magnitude / Agent.speed;
SubAIAnimator.speed = Mathf.Max(0.5f, speedRatio); // 최소 속도 보장
}
// Animation 속도 정상화
public void ResetAnimationSpeed()
{
SubAIAnimator.speed = 1f;
}
#region 디버깅
private void OnDrawGizmos()
{
// Circle 감지 범위
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectCircleRadius);
// 시야각
Gizmos.color = Color.red;
Vector3 rightDirection = Quaternion.Euler(0, maxDetectSightAngle, 0) * transform.forward;
Vector3 leftDirection = Quaternion.Euler(0, -maxDetectSightAngle, 0) * transform.forward;
Gizmos.DrawRay(transform.position, rightDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, leftDirection * detectCircleRadius);
Gizmos.DrawRay(transform.position, transform.forward * detectCircleRadius);
// Agent 목적지 시각화
if (Agent != null && Agent.hasPath)
{
Gizmos.color = Color.green;
Gizmos.DrawSphere(Agent.destination, 0.5f);
Gizmos.DrawLine(Agent.destination, Agent.destination);
}
}
#endregion
}
25.06.18
AI가 복도 문이 닫혀있을 때 반대편에 Patrol Point가 있으면 돌아서 가도록
: 현재 AI가 복도 문이 닫힌 너머로 Patrol Point를 잡으면 닫혀있는 문을 통해 가려고 계속 복도 문에 비비면서 걷기 때문에 플레이하는 입장에서 어색해보이는 문제를 해결하기 위해
--> 방과 달리 복도는 양 방향이라 방에서 구현한 것처럼 구현할 수 없다.
>> 해결 과정
1. 아이디어
- NavMeshModifierVolume을 복도마다 설치하고 Area를 각각 독립적으로 설정한 다음 Cost를 높여서 세팅한다.
- 복도 문을 열면 그 문에 인접한 두 복도의 Cost를 "Walkable"과 같이 1f로 낮춘다.
- 복도 문을 닫으면 반대로 인접한 두 복도의 Cost를 다시 높여준다.
- 이렇게 구현했을 때 존재하는 허점
- 모든 복도 문이 닫혀있으면 모든 복도 Area의 Cost가 높아서 AI가 존재하는 복도까지도 Cost가 높아짐
- 어느 한 복도의 양쪽 문을 열어서 그 복도의 Cost를 낮춘 상태에서 한 쪽 문만 닫으면 그 복도의 Cost가 다시 높아지고 그럼 한쪽 문이 열려있음에도 Cost가 높아서 AI가 그 복도로 가지 않음
- 복도의 모양이 직사각형이 아니기 때문에 NavMeshModifierVolume을 복도에 세팅할 때 다른 방들도 겹치게 됨
- Baking된 NavMesh는 MainAI에게도 영향을 끼친다.
2. 개선한 방법
: 복도 전체를 NavMeshModifierVolume으로 덮어서 Area의 Cost를 조절하는 것이 아니라 복도 문마다 독립적으로 문을 통과하는 경로에만 얇은 NavMeshModifierVolume를 만들어서 그 문이 열리고 닫힘에 따라 Area의 Cost를 조절하는 방법
--> 복도 문에만 NavMeshModifierVolume을 세팅했기 때문에 허점 1, 2, 3번 모두 해결 가능하다.
- 발생한 문제
: 방 문과 달리 복도는 문이 2개씩 있어서 2개 모두 열려있는 문을 한쪽만 닫으면 한쪽 문은 열려있지만 설정한 Area의 Cost는 높아져서 AI가 지나가지 않게 된다.
3. 최종 해결 방법
: 문이 2개씩 있으므로 각 문마다 다르게 NavMeshModifierVolume 및 Area를 세팅하여 해결
- 기능 동작 순서
- 우선 각 복도 문마다 NavMeshModifierVolume 및 Area를 세팅한 다음 NavMesh Bake하기
- 새롭게 "AreaCostSetting" Script와 빈 게임 Object를 만들어서 Awake()에서 초기 Cost 세팅 --> 초기 Cost는 1,000,000으로 아주 높게 책정
- AI가 목적지를 찾고 목적지까지 가기 위한 경로를 찾을 때 중간에 복도 문이 닫혀있다면 비용이 엄청 높기 때문에 우회하는 길을 스스로 선택하게 만듦
※ SetDestination()을 주기적으로 실행해줘야 목적지를 향해 나아가던 중 문이 열리거나 닫혔을 때 새롭게 경로를 업데이트 한다.
--> SubAIStatePatrol.cs의 Update()에서 일정 주기로 실행
- NavMeshModifierVolume 및 Area 세팅
: 각 문마다 NavMeshModifierVolume에 맞는 Area Type을 설정하고 Inspector 창에서 "LockedDoor.cs"에다가 문에 맞는 Area 이름을 적어줘야 한다.
4. MainAI에게도 영향을 끼치는 문제 해결
: MainAI의 NavMeshAgent에서 "Area Mask"를 기존의 Area에만 영향을 받도록 설정하여 해결
>> 작성한 코드
- SubAIStatePatrol.cs
using System.Collections;
using UnityEngine;
public class SubAIStatePatrol : ISubAIState
{
private static readonly int Patrol = Animator.StringToHash("Patrol");
private static readonly int Turn = Animator.StringToHash("Turn");
private SubAIController _subAIController;
// 회전 중인지 구분하는 변수
private bool _isTurning = false;
// SetDestination() 갱신 주기
private float _maxSetDestinationTime = 0.5f;
private float _setDestinationTime = 0f;
public void Enter(SubAIController subAIController)
{
_subAIController = subAIController;
_subAIController.SetNormalSpeed(); // 이동 속도 조절
_subAIController.ResetAnimationSpeed(); // 이동 속도에 따른 Animation 재생 속도 조절
// 랜덤으로 정찰 위치를 구하고, 있으면 해당 위치로 이동, 없으면 다시 Idle 상태로 전환
Vector3 patrolPoint = _subAIController.NextPatrolPoint;
if (patrolPoint == _subAIController.transform.position)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
_subAIController.Agent.isStopped = false;
_subAIController.Agent.SetDestination(patrolPoint);
_subAIController.StartCoroutine(WaitPatrolAnimation());
}
public void Update()
{
if (_isTurning) return; // Turn 중이면 이후 과정을 전부 스킵
// 감지 영역에 Player가 있는 지 확인 후, 있으면 Trace로 전환
Transform detectPlayerTransform = _subAIController.DetectPlayerInCircle();
if (detectPlayerTransform)
{
_subAIController.DetectedPlayerTransform = detectPlayerTransform;
if (_subAIController.NeedsToTurn(detectPlayerTransform.position))
{
_isTurning = true;
_subAIController.Agent.isStopped = true;
_subAIController.NextStateAfterTurn = SubAIState.Trace;
_subAIController.SubAIAnimator.SetTrigger(Turn);
return;
}
_subAIController.SetState(SubAIState.Trace);
return;
}
// 일정 주기마다 SetDestination 갱신
if (_setDestinationTime > _maxSetDestinationTime)
{
_subAIController.Agent.SetDestination(_subAIController.NextPatrolPoint);
}
_setDestinationTime += Time.deltaTime;
// 정찰 목적지에 도착하면 Idle로 전환
if (!_subAIController.Agent.pathPending &&
_subAIController.Agent.remainingDistance <= _subAIController.Agent.stoppingDistance &&
_subAIController.Agent.hasPath)
{
_subAIController.SetState(SubAIState.Idle);
return;
}
}
public void Exit()
{
_isTurning = false;
_subAIController.SubAIAnimator.SetBool(Patrol, false);
_subAIController = null;
}
// 경로 계산이 끝날 때까지 Animation을 지연시키는 Coroutine
private IEnumerator WaitPatrolAnimation()
{
// 경로 계산 중이면 대기
while (_subAIController.Agent.pathPending)
yield return null;
// 이동이 시작되면 Animation 실행
_subAIController.SubAIAnimator.SetBool(Patrol, true);
}
}
- LockedDoor.cs
using System;
using System.Collections;
using System.Collections.Generic;
using Fusion;
using JetBrains.Annotations;
using UnityEditor;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.Audio;
using UnityEngine.Serialization;
[RequireComponent(typeof(Rigidbody))]
public class LockedDoor : InteractiveObject
{
[SerializeField] private ItemType requiredItemType;
[SerializeField] public bool isLocked = false;
[SerializeField] public bool isOpen = false;
public float DoorOpenAngle = 90.0f; // Y축 회전 각도
private Vector3 defaulRot;
private Vector3 openRot;
public float OpenTime = 1f;
public float CloseTime = 1f;
public float smooth = 1f;
private Rigidbody rb;
bool isOpening = false;
[Header("사운드 관련")] [SerializeField] private AudioMixer mixer;
[SerializeField] private AudioClip doorOpen;
[SerializeField] private AudioClip doorClose;
[SerializeField] private AudioClip unlockedDoorSoundSource;
[SerializeField] private AudioSource doorSoundSource;
[Header("닿았을 때 자동 문 열림")]
[SerializeField] private LayerMask targetLayerMask; // Inspector에서 설정할 LayerMask
[Header("메인도어키 상호작용시 시작될 것")]
[SerializeField] [CanBeNull] private Morgue morgue;
[Header("NavMesh 관련")]
[SerializeField] [CanBeNull] private GameObject navObstacleBlocker;
[SerializeField] [CanBeNull] private Collider roomCollider;
[SerializeField] private string areaName;
[SerializeField] private float costOpen = 1f;
[SerializeField] private float costClose = 1000000f;
public override void Spawned()
{
rb = GetComponent<Rigidbody>();
defaulRot = transform.eulerAngles;
openRot = new Vector3(defaulRot.x, defaulRot.y + DoorOpenAngle, defaulRot.z);
// rotatesOnYAxis가 true이므로 초기 위치/회전 설정은 회전을 기준으로 합니다.
_isActive = true;
rb.isKinematic = true;
}
public void OnTriggerStay(Collider other)
{
if (isOpen || isOpening) return;
// 충돌한 오브젝트의 Layer가 targetLayerMask에 포함되어 있는지 확인합니다.
if ((targetLayerMask.value & (1 << other.gameObject.layer)) != 0)
{
RpcOpenDoor();
}
}
public override bool Interact(InteractionContext interactionContext)
{
if (isLocked)
{
if (interactionContext.ItemType.HasValue && interactionContext.ItemType.Value == requiredItemType)
{
isLocked = false;
SoundManager.Instance.RPC_SoundPlay(false, SoundType.Door_Unlocked, this.transform.position);
interactionContext.inventory.ClearInventorySlot(interactionContext.inventory.currentItemIndex);
if (requiredItemType == ItemType.MainDoorKey)
{
Debug.Log("메인도어키 사용");
morgue?.StartMorgueDoorLoop();
return false;
}
}
else
{
UIManager.Instance.SetTestText("문이 잠겨 있다");
return false;
}
}
if (_isActive && !isOpen && !isOpening)
{
isOpen = true;
RpcOpenDoor();
}
else if (_isActive && isOpen && !isOpening)
{
isOpen = false;
RpcCloseDoor();
}
return false;
}
[Rpc(RpcSources.All, RpcTargets.All)]
public void RpcOpenDoor()
{
StartCoroutine(OpenDoor());
if (navObstacleBlocker != null) navObstacleBlocker.SetActive(false);
if (roomCollider != null)
roomCollider.enabled = false;
UpdateDoorAreaCost(costOpen);
isOpen = true;
}
[Rpc(RpcSources.All, RpcTargets.All)]
public void RpcCloseDoor()
{
StartCoroutine(CloseDoor());
if (navObstacleBlocker != null) navObstacleBlocker.SetActive(true);
if (roomCollider != null)
roomCollider.enabled = true;
UpdateDoorAreaCost(costClose);
isOpen = false;
}
public IEnumerator OpenDoor()
{
isOpening = true;
float temp = 0;
Quaternion startRotation = Quaternion.Euler(defaulRot);
Quaternion endRotation = Quaternion.Euler(openRot);
doorSoundSource.PlayOneShot(doorOpen);
while (temp < OpenTime)
{
temp += Time.deltaTime;
float t = Mathf.Clamp01(temp / OpenTime);
float easedT = Mathf.SmoothStep(0, 1, t);
Quaternion newRot = Quaternion.Slerp(startRotation, endRotation, easedT);
rb.MoveRotation(newRot);
//rb.transform.rotation = newRot;
yield return null;
}
isOpening = false;
rb.MoveRotation(endRotation);
// rb.transform.rotation = endRotation;
}
public IEnumerator CloseDoor()
{
isOpening = true;
float temp = 0;
Quaternion startRotation = Quaternion.Euler(openRot);
Quaternion endRotation = Quaternion.Euler(defaulRot);
doorSoundSource.PlayOneShot(doorClose);
while (temp < CloseTime)
{
temp += Time.deltaTime;
float t = Mathf.Clamp01(temp / CloseTime);
float easedT = Mathf.SmoothStep(0, 1, t);
Quaternion newRot = Quaternion.Slerp(startRotation, endRotation, easedT);
rb.MoveRotation(newRot);
//rb.transform.rotation = newRot;
yield return null;
}
isOpening = false;
rb.MoveRotation(endRotation);
//rb.transform.rotation = endRotation;
}
private void UpdateDoorAreaCost(float cost)
{
int doorBlockedArea = NavMesh.GetAreaFromName(areaName);
if (doorBlockedArea < 0) return;
NavMesh.SetAreaCost(doorBlockedArea, cost);
}
}
- AreaCostSetting.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class AreaCostSetting : MonoBehaviour
{
private void Awake()
{
int FrontToLeft_1F_L = NavMesh.GetAreaFromName("FrontToLeft_1F_L");
if (FrontToLeft_1F_L >= 0) NavMesh.SetAreaCost(FrontToLeft_1F_L, 1000000f);
int FrontToLeft_1F_R = NavMesh.GetAreaFromName("FrontToLeft_1F_R");
if (FrontToLeft_1F_R >= 0) NavMesh.SetAreaCost(FrontToLeft_1F_R, 1000000f);
int FrontToRight_1F_L = NavMesh.GetAreaFromName("FrontToRight_1F_L");
if (FrontToRight_1F_L >= 0) NavMesh.SetAreaCost(FrontToRight_1F_L, 1000000f);
int FrontToRight_1F_R = NavMesh.GetAreaFromName("FrontToRight_1F_R");
if (FrontToRight_1F_R >= 0) NavMesh.SetAreaCost(FrontToRight_1F_R, 1000000f);
int RightToRight_1F_L = NavMesh.GetAreaFromName("RightToRight_1F_L");
if (RightToRight_1F_L >= 0) NavMesh.SetAreaCost(RightToRight_1F_L, 1000000f);
int RightToRight_1F_R = NavMesh.GetAreaFromName("RightToRight_1F_R");
if (RightToRight_1F_R >= 0) NavMesh.SetAreaCost(RightToRight_1F_R, 1000000f);
int RightToBack_1F_L = NavMesh.GetAreaFromName("RightToBack_1F_L");
if (RightToBack_1F_L >= 0) NavMesh.SetAreaCost(RightToBack_1F_L, 1000000f);
int RightToBack_1F_R = NavMesh.GetAreaFromName("RightToBack_1F_R");
if (RightToBack_1F_R >= 0) NavMesh.SetAreaCost(RightToBack_1F_R, 1000000f);
int BackToLeft_1F_L = NavMesh.GetAreaFromName("BackToLeft_1F_L");
if (BackToLeft_1F_L >= 0) NavMesh.SetAreaCost(BackToLeft_1F_L, 1000000f);
int BackToLeft_1F_R = NavMesh.GetAreaFromName("BackToLeft_1F_R");
if (BackToLeft_1F_R >= 0) NavMesh.SetAreaCost(BackToLeft_1F_R, 1000000f);
int FrontToLeft_2F_L = NavMesh.GetAreaFromName("FrontToLeft_2F_L");
if (FrontToLeft_2F_L >= 0) NavMesh.SetAreaCost(FrontToLeft_2F_L, 1000000f);
int FrontToLeft_2F_R = NavMesh.GetAreaFromName("FrontToLeft_2F_R");
if (FrontToLeft_2F_R >= 0) NavMesh.SetAreaCost(FrontToLeft_2F_R, 1000000f);
int LeftToBack_2F_L = NavMesh.GetAreaFromName("LeftToBack_2F_L");
if (LeftToBack_2F_L >= 0) NavMesh.SetAreaCost(LeftToBack_2F_L, 1000000f);
int LeftToBack_2F_R = NavMesh.GetAreaFromName("LeftToBack_2F_R");
if (LeftToBack_2F_R >= 0) NavMesh.SetAreaCost(LeftToBack_2F_R, 1000000f);
int BackToRight_2F_L = NavMesh.GetAreaFromName("BackToRight_2F_L");
if (BackToRight_2F_L >= 0) NavMesh.SetAreaCost(BackToRight_2F_L, 1000000f);
int BackToRight_2F_R = NavMesh.GetAreaFromName("BackToRight_2F_R");
if (BackToRight_2F_R >= 0) NavMesh.SetAreaCost(BackToRight_2F_R, 1000000f);
int RightToFront_2F_L = NavMesh.GetAreaFromName("RightToFront_2F_L");
if (RightToFront_2F_L >= 0) NavMesh.SetAreaCost(RightToFront_2F_L, 1000000f);
int RightToFront_2F_R = NavMesh.GetAreaFromName("RightToFront_2F_R");
if (RightToFront_2F_R >= 0) NavMesh.SetAreaCost(RightToFront_2F_R, 1000000f);
}
}
25.06.19
Player가 AI한테 공격 받았을 때 시각적인 효과 추가
: 현재 Player가 AI한테 공격 받았을 때 일정 시간동안 슬로우 효과와 함께 달리지 못하도록 디버프를 받는데, 시각적으로도 공격 받았음을 알 수 있도록 효과 추가
--> 공격 받았을 때 Player의 화면에 Sprite Image를 UI로 띄워서 구현
>> 구현 과정
: 처음에는 피격 이미지 하나만 Fade In/Fade Out 되도록 구현하려 했으나, 더욱 자연스럽게 보이기 위해 피격 이미지를 구성하는 각각의 Layer마다 따로 Image를 받아서 순차적으로 Fade In/Fade Out 되도록 구현
1. Canvas에 "HitEffectUI" Panel 추가
: Anchor를 Stretch로 설정하고 Color의 Alpha값을 0으로 설정한 다음 클릭 차단을 방지하기 위해 Raycast Target 체크 해제
2. HitEffectUI.cs 생성 및 코드 작성
- HitEffectUI.cs에서 피격 이미지를 받아서 피격 연출까지 모두 관리
- UIManager를 통해 세팅 및 관리되도록 UIManager.cs에도 코드 작성 --> UI가 화면에 뜰 때 기본 UI는 끄지 않도록 주의!
- Player가 공격 받았을 때 호출되도록 Player.cs에 코드 작성
3. 작성한 코드에 각각 바인딩
- HitEffectUI
: "HitEffectUI"의 자식으로 각 Layer별로 나눈 Image의 개수만큼 UI - Image를 추가하여 Image를 바인딩하고 Color의 Alpha값을 0으로 조정 후, Raycast Target을 체크 해제 --> 이후 HitEffectUI.cs에 바인딩
- UIManager
: UIManager에 만든 HitEffectUI 바인딩
※ 잠시 있었던 문제
: 처음에는 Player.cs에 HitEffectUI를 바인딩해서 Component를 참조하여 사용하려고 했었는데, HitEffectUI의 Prefab을 바인딩 했더니, 인 게임에서 Player가 Manager에 의해 Spawn되었을 때 Hierarchy에 있는 HitEffectUI를 가져오는 것이 아니기 때문에 정상적으로 작동하지 않는 문제가 있었음!
--> Hierarchy에서 바인딩하여 해결했었는데, 이미 GetComponen<>를 통해 Component를 참조하고 있기 때문에 바인딩하는 코드를 삭제함
>> 개선 사항
: 팀원 분께서 피격 Image를 만드실 때는 Image들의 Alpha 값을 다 다르게 조정하여 만드셨는데, Image를 Unity에 삽입하여 Texture Type을 Default에서 Sprite로 변경하는 과정에서 설정해둔 Alpha 값이 전부 사라지는 듯하다.
--> 현재 코드는 List로 받은 Image들을 foreach로 순회하면서 동일하게 Alpha값을 1f로 설정해주고 있는데, 각 Image마다 팀원 분께서 조정하셨던 Alpha값대로 코드를 통해 직접 적용해보자.
>> 작성한 코드
- HitEffectUI.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class HitEffectUI : MonoBehaviour
{
[Header("피격 이미지")]
[SerializeField] private List<Image> hitEffectImages;
[Header("스프라이트 전환 딜레이")]
[SerializeField] private float frameDelay = 0.01f;
[Header("피격 연출 설정")]
[SerializeField] private float fadeOutTime = 0.5f;
private Coroutine hitEffectCoroutine;
public void PlayHitEffect()
{
if (hitEffectCoroutine != null)
StopCoroutine(hitEffectCoroutine);
hitEffectCoroutine = StartCoroutine(HitEffectStart());
}
private IEnumerator HitEffectStart()
{
// 모든 Image 초기화
foreach (var img in hitEffectImages)
{
img.color = new Color(1, 1, 1, 0);
img.gameObject.SetActive(true);
}
// Fade In
foreach (var img in hitEffectImages)
{
img.color = new Color(1, 1, 1, 0.3f);
yield return new WaitForSeconds(frameDelay);
}
yield return new WaitForSeconds(2f);
// Fade Out
for (int i = hitEffectImages.Count - 1; i >= 0; i--)
{
Image img = hitEffectImages[i];
float elapsed = 0f;
while (elapsed < fadeOutTime)
{
elapsed += Time.deltaTime;
float alpha = Mathf.Lerp(0.3f, 0f, elapsed / fadeOutTime);
img.color = new Color(1, 1, 1, alpha);
yield return null;
}
img.color = new Color(1, 1, 1, 0);
img.gameObject.SetActive(false);
}
hitEffectCoroutine = null;
}
}
- UIManager.cs
- PanelState에 "HitEffect" 상태 추가
- SetUIFade()에 "HitEffect" 상태 추가
- SetHitEffectPanel() 함수 추가 --> defaultPanel도 같이 SetActive(true)로 설정하여 기본 UI도 보이도록
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Serialization;
using UnityEngine.UI;
using UnityEngine.Video;
using Random = UnityEngine.Random;
public enum PanelState
{
Default,
Camera,
Telephone,
Casting,
Gimmick,
Paper,
HitEffect,
None,
}
public class UIManager : Singleton<UIManager>
{
[Header("패널 관리")] public GameObject defaultPanel;
[SerializeField] public GameObject cameraPanel;
[SerializeField] public GameObject telephonePanel;
[SerializeField] public GameObject CastingPanel;
[SerializeField] private GameObject gimmickPanel;
[SerializeField] public GameObject paperPanel;
[SerializeField] public FlashLightUI flashLightUI;
[SerializeField] public GameObject hitEffectPanel;
[Header("죽었을 때 비디오 연출")]
public GameObject gameOverPanel;
public VideoPlayer gameOverVideoPlayer;
#region CameraRegion
public Camera mainCamera;
[Header("글리치(화면 흔들리는 효과) 관련 변수")] public float glitchDuration = 0.05f;
public float intensity = 0.1f;
public float triggerInterval = 2f;
[Header("화면 깜빡이는 효과 관련 변수")] public float blackoutDuration = 0.2f;
public Image blackoutImage;
private Rect originalRect;
#endregion
[Header("테스트용 글자 나타내기 위함/삭제예정")] public TextMeshProUGUI testText;
[SerializeField] private Image backgroundImage;
[SerializeField] private float fadeTime = 1.0f; // 페이드 시간 설정
[Header("엔딩 크레딧")]
[SerializeField] private CreditScroller creditScroller;
public void Start()
{
// if (mainCamera == null)
// {
//
// mainCamera = mainCamera.GetComponent<Camera>();
// if (mainCamera == null)
// {
// Debug.LogWarning("Main Camera is null!");
// }
// }
InitGames();
// 픽셀rect랑 무슨 차이 ?
// originalRect = mainCamera.rect;
}
public IEnumerator GlitchEffect()
{
yield return new WaitForSeconds(triggerInterval);
}
IEnumerator SingleGlitch()
{
StartCoroutine(FadeToBlack());
SetCameraPanel();
float time = 0f;
while (time < glitchDuration)
{
float xOffset = Random.Range(-intensity, intensity);
float yOffset = Random.Range(-intensity, intensity);
mainCamera.rect = new Rect(originalRect.x + xOffset, originalRect.y + yOffset,
originalRect.width - Mathf.Abs(xOffset) * 2, originalRect.height - Mathf.Abs(yOffset) * 2);
time += Time.deltaTime;
yield return null;
}
mainCamera.rect = originalRect;
}
IEnumerator FadeToBlack()
{
if (blackoutImage != null)
{
Color startColor = blackoutImage.color;
Color endColor = new Color(0f, 0f, 0f, 1f);
float elapsed = 0f;
while (elapsed < blackoutDuration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / blackoutDuration);
blackoutImage.color = Color.Lerp(startColor, endColor, t);
yield return null;
}
// 필요하다면 여기서 카메라 패널을 끄거나 다른 동작을 수행할 수 있습니다.
// cameraPanel.SetActive(false);
StartCoroutine(FadeToClear()); // 암흑 후 다시 밝아지게 하는 코루틴 호출 (선택 사항)
}
}
IEnumerator FadeToClear()
{
if (blackoutImage != null)
{
Color startColor = blackoutImage.color;
Color endColor = new Color(0f, 0f, 0f, 0f);
float elapsed = 0f;
float fadeOutDuration = blackoutDuration; // 암흑에서 다시 밝아지는 시간 (동일하게 설정)
while (elapsed < fadeOutDuration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / fadeOutDuration);
blackoutImage.color = Color.Lerp(startColor, endColor, t);
yield return null;
}
}
}
public void SetCameraPanelWithGlitch()
{
StartCoroutine(SingleGlitch());
}
public void SetUIFade(PanelState state)
{
switch (state)
{
case PanelState.Default:
SetDefaultPanel();
break;
case PanelState.Camera:
SetCameraPanel();
break;
case PanelState.Telephone:
SetTelephonePanel();
break;
case PanelState.Casting:
SetCastingPanel();
break;
case PanelState.Gimmick:
SetGimmickPanel();
break;
case PanelState.Paper:
SetPaperPanel();
break;
case PanelState.HitEffect:
SetHitEffectPanel();
break;
}
StartCoroutine(FadeToBlack());
}
//TODO : 카메라 분리
public void InitGames()
{
defaultPanel.SetActive(true);
cameraPanel.SetActive(false);
telephonePanel.SetActive(false);
CastingPanel.SetActive(false);
gimmickPanel.SetActive(false);
paperPanel.SetActive(false);
hitEffectPanel.SetActive(false);
}
public void SetDefaultPanel()
{
defaultPanel.SetActive(true);
cameraPanel.SetActive(false);
telephonePanel.SetActive(false);
gimmickPanel.SetActive(false);
CastingPanel.SetActive(false);
paperPanel.SetActive(false);
hitEffectPanel.SetActive(false);
}
public void SetCameraPanel()
{
cameraPanel.SetActive(true);
defaultPanel.SetActive(false);
telephonePanel.SetActive(false);
gimmickPanel.SetActive(false);
CastingPanel.SetActive(false);
paperPanel.SetActive(false);
hitEffectPanel.SetActive(false);
}
public void SetTelephonePanel()
{
telephonePanel.SetActive(true);
defaultPanel.SetActive(false);
cameraPanel.SetActive(false);
gimmickPanel.SetActive(false);
CastingPanel.SetActive(false);
paperPanel.SetActive(false);
hitEffectPanel.SetActive(false);
}
public void SetCastingPanel()
{
CastingPanel.SetActive(true);
defaultPanel.SetActive(false);
cameraPanel.SetActive(false);
telephonePanel.SetActive(false);
gimmickPanel.SetActive(false);
paperPanel.SetActive(false);
hitEffectPanel.SetActive(false);
}
public void SetGimmickPanel()
{
gimmickPanel.SetActive(true);
defaultPanel.SetActive(false);
cameraPanel.SetActive(false);
telephonePanel.SetActive(false);
CastingPanel.SetActive(false);
paperPanel.SetActive(false);
hitEffectPanel.SetActive(false);
}
public void SetPaperPanel()
{
paperPanel.SetActive(true);
gimmickPanel.SetActive(false);
defaultPanel.SetActive(false);
cameraPanel.SetActive(false);
telephonePanel.SetActive(false);
CastingPanel.SetActive(false);
hitEffectPanel.SetActive(false);
}
public void SetHitEffectPanel()
{
paperPanel.SetActive(false);
gimmickPanel.SetActive(false);
defaultPanel.SetActive(true);
cameraPanel.SetActive(false);
telephonePanel.SetActive(false);
CastingPanel.SetActive(false);
hitEffectPanel.SetActive(true);
}
public void SetTestText(string text)
{
StopAllCoroutines(); // 이전 코루틴 중지
SetAlpha(150f/255f, 100f/255f); // 먼저 투명도 초기화
testText.text = text;
StartCoroutine(FadeOut());
}
private IEnumerator FadeOut()
{
yield return new WaitForSeconds(3f); // 3초 대기
float elapsed = 0f;
Color textColor = testText.color;
Color imageColor = backgroundImage.color;
while (elapsed < fadeTime)
{
float alpha = Mathf.Lerp(150f/255f, 0f, elapsed / fadeTime);
float alpha2 = Mathf.Lerp(100f/255f, 0f, elapsed / fadeTime);
testText.color = new Color(textColor.r, textColor.g, textColor.b, alpha);
backgroundImage.color = new Color(imageColor.r, imageColor.g, imageColor.b, alpha2);
elapsed += Time.deltaTime;
yield return null;
}
// 완전히 사라지게 처리
SetAlpha(0f,0f);
testText.text = "";
}
private void SetAlpha(float alpha, float alpha2)
{
Color textColor = testText.color;
Color imageColor = backgroundImage.color;
testText.color = new Color(textColor.r, textColor.g, textColor.b, alpha);
backgroundImage.color = new Color(imageColor.r, imageColor.g, imageColor.b, alpha2);
}
public void PlayEndingCredit()
{
creditScroller.gameObject.SetActive(true);
}
protected override void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
}
}
- Player.cs
- Damaged() 함수에서 UIManager의 SetHitEffectPanel()을 호출하여 HitEffectUI를 세팅
- Damaged() 함수에서 세팅한 HitEffectUI()의 Component를 참조하여 PlayHitEffect() 함수를 호출, 피격 효과 재생
using System.Collections;
using UnityEngine;
using Fusion;
using Fusion.Addons.SimpleKCC;
using System.Collections.Generic;
[DefaultExecutionOrder(-5)]
public class Player : NetworkBehaviour
{
// 로컬 플레이어 전역 참조
public static Player LocalPlayer { get; private set; }
// -------------------- 기본 이동 및 상태 관련 --------------------
[SerializeField] private float _moveSpeed = 5f;
[SerializeField] private float _runSpeed = 10f;
[SerializeField] private Transform _cameraPivot;
public Camera _playerCamera;
[SerializeField] private MeshRenderer[] _thirdPersonRenderers;
[SerializeField] private SkinnedMeshRenderer[] _thirdPersonRenderers2;
private float mouseSensitivity;
private bool isRunning = false;
private float currentSpeed = 1f; // 기본 속도
// -------------------- 앉기/서기 관련 설정 --------------------
[SerializeField] private float _standingColliderHeight = 2.0f; // 일어선 키
[SerializeField] private float _crouchingColliderHeight = 1.5f; // 앉은 키
[SerializeField] private Vector3 _standingCameraOffset = new Vector3(0f, 1.6f, 0f);
[SerializeField] private Vector3 _crouchingCameraOffset = new Vector3(0f, 0.6f, 0f);
[SerializeField] private float _crouchSpeedMultiplier = 0.5f; // 앉아서 걸을 때 감속 비율
// -------------------- 부활 티켓 프리팹 --------------------
public NetworkPrefabRef reviveTicketPrefab;
// -------------------- 네트워크 변수 --------------------
[Networked] private float NetHorizontal { get; set; }
[Networked] private float NetVertical { get; set; }
[Networked] private bool _isCrouching { get; set; }
[Networked] private NetworkButtons _lastButtonsInput { get; set; }
[Networked] public bool isHide { get; set; } // 모든 유저와 동기화됨
[Networked] public bool IsDead { get; private set; } // 플레이어 사망 상태
// -------------------- 상호작용 상태 --------------------
public bool isDoingInteraction = false;
public bool isDoingCutScene = false;
private bool _isDamagedSlow = false;
// -------------------- 오디오 관련 --------------------
[SerializeField] AudioListener _audioListener;
// -------------------- 컴포넌트 참조 --------------------
public PlayerStamina _stamina;
private SimpleKCC _kcc;
private Animator _animator;
public Transform CameraPivot => _cameraPivot; // 관전 카메라
private InteractionManager _interactionManager;
// -------------------- 위치 차분 계산용 --------------------
private Vector3 _lastPosition;
// -------------------- 발소리 관련 --------------------
[Header("Footstep (2D) Settings")]
[SerializeField] private float stepInterval = 0.5f;
private float _stepTimer;
// -------------------- SubAI에게 피격 관련 --------------------
private HitEffectUI _hitEffectUI;
protected void Awake()
{
_kcc = GetComponent<SimpleKCC>();
_playerCamera = GetComponentInChildren<Camera>();
UIManager.Instance.mainCamera = _playerCamera;
_animator = GetComponentInChildren<Animator>();
_stamina = GetComponent<PlayerStamina>();
_interactionManager = GetComponent<InteractionManager>();
// ▶▶ 발소리용 타이머 초기화
_stepTimer = stepInterval; // ▶▶ 변경
// ▶▶ 위치 차분용 마지막 위치 초기화
_lastPosition = transform.position; // ▶▶ 변경
}
public override void Spawned()
{
Debug.Log($"[Spawned] 플레이어 {Object.InputAuthority} 스폰됨");
//mouseSensitivity = GameManager.Instance.GetMouseSensitivity();
SpectatorController.Instance.StopSpectating();
IsDead = false;
bool isLocalPlayer = HasInputAuthority;
// 로컬 권한 플레이어라면 LocalPlayer에 등록
if (HasInputAuthority)
{
LocalPlayer = this;
_playerCamera.enabled = true;
Debug.Log("동기화 완료");
}
else
{
_playerCamera.enabled = false;
}
SetThirdPersonRenderersVisibility(!isLocalPlayer && !IsDead);
_audioListener = GetComponentInChildren<AudioListener>();
if (HasInputAuthority)
{
if (_audioListener != null)
{
_audioListener.enabled = true;
}
else
{
Debug.Log("오디오 리스너 없음");
}
}
else
{
if (_audioListener != null)
{
_audioListener.enabled = false;
}
}
PlayerManager.Instance.RegisterPlayer(this);
}
private void SetThirdPersonRenderersVisibility(bool isVisible)
{
foreach (var render in _thirdPersonRenderers2)
if (render != null) render.enabled = isVisible;
}
public override void FixedUpdateNetwork()
{
if (IsDead && isDoingInteraction) return;
if (isDoingCutScene) return;
var input = GetInput<GameplayInput>();
ProcessInput(input.GetValueOrDefault());
ProcessCrouching(input.GetValueOrDefault());
}
public override void Render()
{
if (IsDead) return;
if (!HasInputAuthority)
SetThirdPersonRenderersVisibility(true);
if (HasInputAuthority && !isDoingInteraction)
{
Vector2 pitchRotation = _kcc.GetLookRotation(true, false);
_cameraPivot.localRotation = Quaternion.Euler(pitchRotation);
}
if (_animator != null)
{
_animator.SetFloat("Horizontal", NetHorizontal);
_animator.SetFloat("Vertical", NetVertical);
_animator.SetBool("IsRun", _stamina.CanRun && !_isCrouching);
_animator.SetBool("IsCrouch", _isCrouching);
}
}
protected void LateUpdate()
{
if (IsDead && isDoingInteraction) return;
mouseSensitivity = GameManager.Instance.GetMouseSensitivity();
if (_playerCamera != null && HasInputAuthority)
{
_playerCamera.transform.position = _cameraPivot.position;
_playerCamera.transform.rotation = _cameraPivot.rotation;
}
// ▶▶ 로컬 플레이어 전용 발소리 로직 (위치 차분)
if (HasInputAuthority && !IsDead && !isDoingInteraction)
{
// ① 현재 프레임 이동량 계산
Vector3 delta = transform.position - _lastPosition; // ▶▶ 변경
Vector3 horiz = new Vector3(delta.x, 0, delta.z) / Time.deltaTime; // ▶▶ 변경
// ② 땅에 붙어 있고 충분히 움직였을 때만
if (horiz.magnitude > 0.1f && _kcc.IsGrounded)
{
_stepTimer -= Time.deltaTime;
if (_stepTimer <= 0f)
{
_stepTimer = stepInterval;
PlayFootstep2D(); // ▶▶ 변경
}
}
else
{
_stepTimer = stepInterval;
}
}
// 위치 차분용 마지막 위치 업데이트
_lastPosition = transform.position; // ▶▶ 변경
//if (Input.GetKeyDown(KeyCode.Space)) Die();
if (Input.GetKeyDown(KeyCode.H))
{
Die(); // 테스트용 키 입력
}
bool shouldRender = !HasInputAuthority && !IsDead;
foreach (var renderer in _thirdPersonRenderers2)
if (renderer != null) renderer.enabled = shouldRender;
}
private void PlayFootstep2D()
{
// 2D UI/스테레오 오디오로 간단 재생
SoundManager.Instance.PlayRandomPlayerWalk2D(); // ▶▶ 변경
}
private void SetCrouching(bool isCrouching)
{
_isCrouching = isCrouching;
_stamina.isCrouching = isCrouching;
if (_playerCamera != null)
{
Vector3 targetOffset = _isCrouching ? _crouchingCameraOffset : _standingCameraOffset;
StartCoroutine(SmoothCameraOffsetChange(targetOffset));
}
if (_kcc != null)
_kcc.SetHeight(_isCrouching ? _crouchingColliderHeight : _standingColliderHeight);
}
private IEnumerator SmoothCameraOffsetChange(Vector3 targetOffset)
{
float duration = 0.1f;
float time = 0f;
Vector3 start = _cameraPivot.localPosition;
while (time < duration)
{
_cameraPivot.localPosition = Vector3.Lerp(start, targetOffset, time / duration);
time += Time.deltaTime;
yield return null;
}
_cameraPivot.localPosition = targetOffset;
}
#region input을 통한 네트워크 프레임 관리 및 이동
private void ProcessCrouching(GameplayInput input)
{
if (input.IsCrouching && !_isCrouching)
{
SetCrouching(true);
}
else if (!input.IsCrouching && _isCrouching)
{
if (CanStandUp())
{
SetCrouching(false);
}
}
}
private void ProcessInput(GameplayInput input)
{
bool canRun = input.IsRunning
&& _stamina != null
&& _stamina.CanRun
&& !_isCrouching
&& !_isDamagedSlow
&& input.MoveDirection != Vector2.zero;
currentSpeed = canRun ? _runSpeed : _moveSpeed;
if (_isCrouching) currentSpeed *= _crouchSpeedMultiplier;
// 마우스 감도를 적용
//float currentSensitivity = PlayerPrefs.GetFloat("MouseSensitivity", 0.5f);
//Vector2 adjustedLookRotation = input.LookRotationDelta * mouseSensitivity;
Vector2 adjustedLookRotation = input.LookRotationDelta * input.MouseSensitivity;
_kcc.AddLookRotation(adjustedLookRotation);
Vector3 dir = _kcc.TransformRotation * new Vector3(input.MoveDirection.x, 0f, input.MoveDirection.y);
_kcc.Move(dir * currentSpeed);
Vector2 pitchRotation = _kcc.GetLookRotation(true, false);
_cameraPivot.localRotation = Quaternion.Euler(pitchRotation);
_lastButtonsInput = input.Buttons;
NetHorizontal = input.MoveDirection.x;
NetVertical = input.MoveDirection.y;
isRunning = input.IsRunning;
_stamina?.SetRunningIntent(input.IsRunning && input.MoveDirection != Vector2.zero);
}
#endregion
#region 데미지 <-> 슬로우 및 달리기 비활성화
public void Damaged()
{
if (_isDamagedSlow) return; // 중복 호출 방지
UIManager.Instance.SetHitEffectPanel();
_hitEffectUI = UIManager.Instance.hitEffectPanel.GetComponent<HitEffectUI>();
_hitEffectUI.PlayHitEffect();
// 서버에 데미지 받았다는 요청을 보내기
if (Runner.IsServer)
{
StartCoroutine(ApplySlowEffect());
}
else
{
RPC_RequestDamaged();
}
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
private void RPC_RequestDamaged()
{
// 서버에서 슬로우 효과를 적용
StartCoroutine(ApplySlowEffect());
}
private IEnumerator ApplySlowEffect()
{
_isDamagedSlow = true;
_stamina.isDamaged = true;
float originalMoveSpeed = _moveSpeed;
float originalRunSpeed = _runSpeed;
_moveSpeed = originalMoveSpeed * 0.5f;
_runSpeed = 0f; // 달리기 비활성화
yield return new WaitForSeconds(3f);
_moveSpeed = originalMoveSpeed;
_runSpeed = originalRunSpeed;
_isDamagedSlow = false;
_stamina.isDamaged = false;
}
#endregion
#region 사망 처리 <-> Rpc요청
public void Die(float delay = 0)
{
Debug.Log("Die 함수");
if (IsDead) return;
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
// 이곳에서 아이템 떨구기 요청
_interactionManager.DieDropItem(transform.position, Vector3.zero);
if (HasInputAuthority)
{
StartCoroutine(RequestDie(delay));
}
}
public IEnumerator RequestDie(float delay = 0)
{
yield return new WaitForSeconds(delay);
PlayerManager.Instance.RPC_RemoveAlivePlayer(Object.InputAuthority, Object.GetComponent<Player>());
PlayerManager.Instance.RPC_RequestDie(Object.InputAuthority);
}
// [Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
// private void RPC_RequestDie(PlayerRef diePlayerRef)
// {
// // 서버에 죽을 요청만 보낸다. 나머지는 서버에서 처리
// StartCoroutine(ServerHandleDeath(diePlayerRef));
// }
//
// private IEnumerator ServerHandleDeath(PlayerRef deadPlayerRef)
// {
// Vector3 deathPosition = transform.position; // 위치 미리 저장
// NetworkObject playerObj = Runner.GetPlayerObject(deadPlayerRef);
// IsDead = true;
// //PlayerManager.Instance.RemoveAlivePlayer(this);
//
//
// // 부활티켓 임시 보류
// // NetworkObject reviveTicket = Runner.Spawn(
// // reviveTicketPrefab,
// // deathPosition + Vector3.up,
// // Quaternion.identity
// // );
// //
// // ReviveTicket ticket = reviveTicket.GetComponent<ReviveTicket>();
// // if (ticket != null)
// // {
// // ticket.SetPlayerId(Object.Id);
// // ticket.SetPlayerRef(deathRef);
// // }
//
// //Debug.Log($"플레이어 {deathRef} 사망 및 티켓 생성");
//
// yield return new WaitForSeconds(0.1f);
// Runner.Despawn(playerObj); // 플레이어 제거
// }
#endregion
// 앉기 시 머리 위 체크
private bool CanStandUp()
{
// 현재 플레이어 위치 기준 머리 위 공간 검사
float checkHeight = _standingColliderHeight - _crouchingColliderHeight;
float radius = 0.3f; // 충돌 반경. 상황에 맞게 조절
Vector3 start = transform.position + Vector3.up * _crouchingColliderHeight;
Vector3 end = start + Vector3.up * checkHeight;
// 플레이어 자신은 제외
int layerMask = ~LayerMask.GetMask("Player");
// 머리 위에 충돌하는 물체가 있으면 false
return !Physics.CheckCapsule(start, end, radius, layerMask, QueryTriggerInteraction.Ignore);
}
#region 플레이어 숨기 <-> Rpc 요청으로 처리 , Tag = HidePoint 사용
private void OnTriggerEnter(Collider other)
{
// 입력 권한이 있는 유저만 상태를 바꿀 수 있음
if (!HasInputAuthority) return;
if (other.CompareTag("HidePoint"))
{
RPC_SetHideState(true);
}
}
private void OnTriggerExit(Collider other)
{
if (!HasInputAuthority) return;
if (other.CompareTag("HidePoint"))
{
RPC_SetHideState(false);
}
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
private void RPC_SetHideState(bool state)
{
Debug.Log("숨기"+state);
isHide = state;
}
#endregion
public override void Despawned(NetworkRunner runner, bool hasState)
{
if (HasInputAuthority)
{
// 서버에 죽을 요청만 보낸다. 나머지는 서버에서 처리
SpectatorController.Instance.meDead = true;
RPC_SetIsDead(true);
SpectatorController.Instance?.StartSpectating();
}
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
private void RPC_SetIsDead(bool isDead)
{
IsDead = isDead;
}
}
25.06.20
메모 텍스트 가독성 높이기
: 맵에 배치되는 메모들의 가독성을 높이도록 수정 --> Text가 적혀있는 json 파일을 수정
>> 참고한 사이트들
https://docs.unity3d.com/kr/2022.1/Manual/UIE-supported-tags.html
지원되는 리치 텍스트 태그 - Unity 매뉴얼
다음 표에는 지원되는 모든 리치 텍스트 태그가 나열되어 있습니다.
docs.unity3d.com
https://mentum.tistory.com/411
TextMeshPro Sprite Asset
# 어떨 때 사용할까? 텍스트와 스프라이트를 같이쓰는 경우가 종종있다. 유아이 정렬기능등을 통해서 직접 Image로 넣는 경우도있지만 정렬오류가 많은편. 때문에 TextMeshPro에서는 Sprite를 글자처
mentum.tistory.com
※ Text에 Sprite 추가하는 방법
: 팀원들과 얘기하다가 Text에 Sprite를 추가하는 방법에 대해 알아냈다.
1. Sprite Esset 생성
: Project 탭에서 Text에 추가하고자 하는 Sprite를 우클릭하여 Sprite Asset을 생성
--> 하나의 Sprite 안에 여러 개의 Sprite가 있다면, Sprite Mode를 Multiple로 설정한 다음 Asset을 생성해야 한다.
2. 경로 확인
: Edit - Project Settings - TextMesh Pro - Settings - Default Sprite Esset 에서 경로를 확인하여 해당 경로에 만든 Sprite Esset을 이동하거나 만든 Sprite Esset을 Default Sprite Esset 으로 설정
3. Text Tag를 통해 Text에서 Sprite 추가
: 만든 Sprite Asset 정보를 토대로 추가
- 추가 방법
- Index로 호출 : <sprite="GameIcon" index=0>
- 이름으로 호출 : <sprite="GameIcon" sprite name="GameIcon"> (sprite는 Asset명, sprite name은 Asset 정보 안에 있는 Name)
--> Default Sprite Asset으로 생성한 Sprite Asset을 등록하면 <sprite=0>, <sprite name="GameIcon"> 과 같이 Index로 호출하든 이름으로 호출하든 간단히 작성하여 사용할 수 있다.
※ 기본적으로 Default Sprite Asset으로 이모지가 등록되어 있어서 이모지도 Text에 추가하여 사용 가능하다.
'Development > Unity BootCamp' 카테고리의 다른 글
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 91일차 (0) | 2025.04.22 |
---|---|
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 90일차 (0) | 2025.04.17 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 89일차 (0) | 2025.04.04 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 88일차 (1) | 2025.04.03 |
멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 87일차 (1) | 2025.04.02 |