Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(115일차/Final Project) - 마감 전 최종 수정 및 발표 준비

by 독기품은토끼 2025. 11. 11.
✅ 오늘의 백로그
1. 버그 수정
2. 발표 준비

1. 버그 수정

1. 스폰 버그

똑같은 스폰 좌표에 여러 이상현상이 스폰되는 부분은

기존에 위치 좌표를 계산하여 점유하는지 여부를 체크하고 없으면 스폰되게 하였으나

바닥 스폰용 / 벽 스폰용 좌표가 달라져 계산이 계속 실패하는 문제가 있었다.

 

그래서 결국 딕셔너리를 활용해서

스폰 포인트를 점유하면 해당 스폰 포인트는 점유할 수 있는 대상에서 제외시켜 스폰 자체를 할 수 없도록 해주었다.

// 스폰 포인트 슬롯 점유 관리 (Ground / Wall 공용)
readonly HashSet<Transform> _occupiedSpawnPoints = new();

bool AbnormalSetPosition(AbnormalData data, out Vector3 pos, out Quaternion rot)
{
    pos = default;
    rot = Quaternion.identity;

    if (spawnPoints == null || spawnPoints.Length == 0)
        return false; // 스폰포인트가 하나도 없으면 실패

    // 유효한 위치 나올 때까지 재시도
    for (int t = 0; t < Mathf.Max(1, navSampleMaxTries); t++)
    {
        var sp = spawnPoints[Random.Range(0, spawnPoints.Length)];

        if (!sp)
            continue;

        if (_occupiedSpawnPoints.Contains(sp))
            continue;

        if (!data.spawnWall)
        {
            // Ground 스폰
            // 스폰포인트 위치를 NavMesh에 스냅
            if (!NavMesh.SamplePosition(sp.position, out var hit, 3f, NavMesh.AllAreas))
                continue;

            var candidate = hit.position;

            // 자리 점유 검사: 비어있을 때만 통과
            var hits = Physics.OverlapSphere(candidate, safeRadius, occupyMask, QueryTriggerInteraction.Collide);
            if (hits != null && hits.Length > 0)
            {
                // 스폰 실패 테스트용 로그
                //for (int i = 0; i < hits.Length; i++)
                //{
                //    var c = hits[i];
                //    Debug.Log($"[AbnormalSpawner] {c.name}로 인해 스폰 실패");
                //}
                continue;
            }

            // 스폰 성공 테스트용 로그
            // Debug.Log($"[AbnormalSpawner] 스폰 완료");
            pos = candidate;
            rot = Quaternion.identity;

            _occupiedSpawnPoints.Add(sp);
            return true;
        }
        else
        {
            // 여러 방향으로 레이 쏘기
            Vector3[] dirs =
            {
                sp.forward, -sp.forward, sp.right, -sp.right,
                (sp.forward + sp.right).normalized, (sp.forward - sp.right).normalized
            };

            // Wall 스폰
            foreach (var dir in dirs)
            {
                // 스폰포인트보다 y가 낮아지는(아래로 향하는) 레이는 사용하지 않음
                if (dir.normalized.y < 0f)
                    continue;

                if (!Physics.Raycast(sp.position, dir, out var hit, wallRayDistance, wallMask, QueryTriggerInteraction.Collide))
                    continue;

                var candidate = hit.point + hit.normal * surfaceOffset; // 벽 표면에서 살짝 띄워 붙이기
                candidate.y += 1.3f; // 벽 중앙에 위치하도록 y값 수정

                // 벽 앞 액자/거울/플레이어 등 간섭물 확인
                var hits = Physics.OverlapSphere(candidate, safeRadius, occupyMask, QueryTriggerInteraction.Collide);
                if (hits != null && hits.Length > 0)
                    continue;

                pos = candidate;
                rot = Quaternion.LookRotation(-hit.normal, Vector3.up);

                _occupiedSpawnPoints.Add(sp);
                return true;
            }
        }
    }

    return false; // 유효 좌표 못 찾음
}

 

코드가 이렇게 길어질 줄 모르고 한 메서드 안에서 바닥 스폰 / 벽 스폰 두 가지 역할을 같이 하게 했는데

리팩토링할 때에는 이 함수도 좀 분리시켜주어야 할 것 같다.

 

 

2. 천우인 버그

천우인을 벽에 스폰하기 위해서 벽에 레이를 쏘고 닿으면 그 부위에 1.3정도 y값 위치를 바꿔 스폰하고 있다.

그런데 3층이나 2층에서 스폰될 때에는 천우인이 왜인지 자꾸 아랫층 벽에 레이를 쏘고 그 위에 스폰되어 공중에 스폰된 것 처럼 처리가 되고 있다.

 

이부분을 해결해주기 위해서

어제 정말 이짓 저짓 많이 해봤는데 결국 ray의 변수, Layer의 변수 등

아직 내가 해결할 수 없는 부분에 계속 직면하여서

그냥 로직 자체를 바꾸기로 결심했다.

 

어차피 앞으로 벽 전용 이상현상이 계속 생길 수 있기 때문에

바닥용 이상현상과 계속 같은 스폰 포인트를 사용하게 되면 그것도 그거대로 문제가 생길 거라 생각한다.. 고 합리화 하며 과감히 지금 로직을 버리겠다.. 😭

 

public class AbnormalSpawner : NetworkBehaviour
{
    // 벽 스폰 전용
    [SerializeField] Transform[] wallSpawnPoints; // 스폰 포인트

    // 스폰 포인트 슬롯 점유 관리 (Ground / Wall 공용)
    readonly HashSet<Transform> _occupiedSpawnPoints = new();

    bool AbnormalSetPosition(AbnormalData data, out Vector3 pos, out Quaternion rot)
    {
        pos = default;
        rot = Quaternion.identity;

        var points = data.spawnWall ? wallSpawnPoints : spawnPoints;

        if (points == null || points.Length == 0)
            return false; // 스폰포인트가 하나도 없으면 실패

        // 유효한 위치 나올 때까지 재시도
        for (int t = 0; t < Mathf.Max(1, navSampleMaxTries); t++)
        {
            var sp = points[Random.Range(0, points.Length)];

            if (!sp)
                continue;

            if (_occupiedSpawnPoints.Contains(sp))
                continue;

            if (!data.spawnWall)
            {
                // Ground 스폰
                // 스폰포인트 위치를 NavMesh에 스냅
                if (!NavMesh.SamplePosition(sp.position, out var hit, 3f, NavMesh.AllAreas))
                    continue;

                var candidate = hit.position;

                // 자리 점유 검사: 비어있을 때만 통과
                var hits = Physics.OverlapSphere(candidate, safeRadius, occupyMask, QueryTriggerInteraction.Collide);
                if (hits != null && hits.Length > 0)
                {
                    // 스폰 실패 테스트용 로그
                    //for (int i = 0; i < hits.Length; i++)
                    //{
                    //    var c = hits[i];
                    //    Debug.Log($"[AbnormalSpawner] {c.name}로 인해 스폰 실패");
                    //}
                    continue;
                }

                // 스폰 성공 테스트용 로그
                // Debug.Log($"[AbnormalSpawner] 스폰 완료");
                pos = candidate;
                rot = Quaternion.identity;
            }
            else
            {
                var candidate = sp.position;

                var overlaps = Physics.OverlapSphere(candidate, safeRadius, occupyMask, QueryTriggerInteraction.Collide);

                if (overlaps != null && overlaps.Length > 0)
                    continue;

                pos = candidate;
                rot = sp.rotation;
            }

            _occupiedSpawnPoints.Add(sp);
            return true;
        }

        return false; // 유효 좌표 못 찾음
    }
}

 

 

계속 Ray에 묶여서 오랜 시간 동안 해결하지 못한 부분을

이렇게 과감히 욕심을 버리니까 바로 해결할 수 있어서.. 나의 고집을 좀 되돌아보는 시간이 되었다.... 🙇‍♀️

그냥 나는 나중에 유지보수 안 힘들게 하기 위해서 계속 고민했던건데 ㅠ-ㅠ.. 오히려 이게 더 시간 잡아먹을 수도 있겠구나 싶었다..!

 

솔직히 이렇게 처음부터 스폰 포인트를 기준으로 잡았더라면..

하하하하 되돌아보는 시간은 일단 내일 발표끝나고 하기로 지금 넘 바쁘다

 

3. 꽃신 버그

 

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(112일차/Final Project) - 탐지 아이템 멀티 연동

 

toxicbunny.tistory.com

 

112일차 때 해결하지 못했던 버그를 이제 수정해줄 건데

실은 앞에 버그들 해결하면서 중간 중간 잘 안 될 때에는 꽃신 로직을 계속 바꿔주고 있었는데

이 부분 버그 해결도 쉽지 않을 것으로 판단된다.

 

우선 지금 NavMesh Surface는 그냥 바닥인 곳에 모두 다 bake 되어 있기 때문에 (장애물이나 벽 등을 제외하고 bake된 게 아님)

nav Mesh 위인 것만을 토대로 좌표를 지정해버려서 생긴 문제인 것 같다.

 

NavMeshAgent.CalculatePath를 활용해서 경로가 있는 목적지만 채택 & 경로 상실 시 중단하고 다시 목적지를 찾도록 로직을 수정해주려고 한다.

또 5초 이상 목표 좌표에 도착하지 않으면 다시 정지하고 새로운 목적지를 찾도록 로직을 추가해주었다.

 

[RequireComponent(typeof(NavMeshAgent))] // 꽃신은 돌아다녀야 해서 NavMesh 필요
public class FlowerShoes : NetworkBehaviour
{
    private int maxDestinationTries = 10; // 유효 목적지 탐색 재시도 횟수
    private float sampleMaxDistance = 1.0f; // NavMesh.SamplePosition 반경

    private float moveTimeoutSeconds = 5f; // 목적지 향해 걷는 최대 시간
    private float moveElapsed = 0f;   // 현재 목적지를 향해 이동한 누적 시간

    // 생략 ...
    
    public override void FixedUpdateNetwork()
    {
        if (!Object || !Object.HasStateAuthority)
            return;

        if (ghostSpawner == null)
            ghostSpawner = GhostSpawner.Instance;

        if (ghostSpawner == null || itemData == null || itemData.canDetect == null)
        {
            AgentStop();
            return;
        }

        bool canDetectThisGhost = itemData.canDetect.Contains(ghostSpawner.mapGhostType);
        
        // 테스트용
        //var ghostName = ghostSpawner.mapGhostType.ToString();
        //Debug.Log($"[FlowerShoes] {ghostName} 탐지 완료 / 탐지 대상 : {canDetectThisGhost}");

        if (!canDetectThisGhost)
        {
            phase = Phase.Idle;
            moveElapsed = 0f;
            AgentStop();
            return;
        }

        bool isMoving =
            !agent.isStopped &&
            !agent.pathPending &&
            agent.remainingDistance > agent.stoppingDistance &&
            agent.velocity.sqrMagnitude > 0.01f;

        FlowerShoesAnim = isMoving ? 1f : 0f;
        FlowerShoesMove(Runner.DeltaTime);
    }

    void FlowerShoesMove(float dt)
    {
        switch (phase)
        {
            case Phase.Idle:
                // 첫 진입 시 대기부터 시작
                waitRemain = RandomWait();
                phase = Phase.Waiting;
                moveElapsed = 0f;
                break;

            case Phase.Waiting:
                if (waitRemain > 0f)
                {
                    waitRemain -= dt;
                    break;
                }
                // 목적지 선정
                if (!TargetDestination(transform.position, out dest))
                {
                    // 샘플 실패 시 짧게 재시도 대기
                    waitRemain = 2f;
                    break;
                }
                // 이동 시작
                agent.isStopped = false;
                agent.SetDestination(dest);

                moveElapsed = 0f;
                phase = Phase.Moving;
                break;

            case Phase.Moving:
                // 아직 경로 계산 중이면 대기
                if (agent.pathPending)
                    break;

                // 경로가 유효하지 않으면 다시 후보를 찾도록 대기 상태로 복귀
                if (!agent.hasPath ||
                    agent.pathStatus == NavMeshPathStatus.PathInvalid ||
                    agent.pathStatus == NavMeshPathStatus.PathPartial)
                {
                    AgentStop();
                    waitRemain = 2f; // 바로 다시 굴리기보단 살짝 쉬고 재탐색
                    moveElapsed = 0f;
                    phase = Phase.Waiting;
                    break;
                }

                moveElapsed += dt;

                // 목적지까지 일정 시간 이상 걸리면 이 목적지는 포기하고 새 목적지 탐색
                if (moveElapsed >= moveTimeoutSeconds)
                {
                    AgentStop();
                    waitRemain = 2f;
                    moveElapsed = 0f;
                    phase = Phase.Waiting;
                    break;
                }

                // 정상 경로일 때 도착 판정
                if (Arrived(dest))
                {
                    AgentStop();
                    moveElapsed = 0f;
                    waitRemain = RandomWait();
                    phase = Phase.Waiting;
                }
                break;
        }
    }

    // 생략..

    /// <summary>
    /// 현재 에이전트 위치에서 destination까지 PathComplete인지 확인
    /// </summary>
    private bool HasCompletePath(Vector3 destination)
    {
        if (!agent || !agent.isOnNavMesh)
            return false;

        var path = new NavMeshPath();
        if (!agent.CalculatePath(destination, path))
            return false;

        return path.status == NavMeshPathStatus.PathComplete;
    }
}

 

그런데 이렇게 해주니까 발생한 문제점이 하나 더 있었다.

 

목표 지점으로 이동하는 과정에서 갑자기 순간이동 하듯이 이리갔다 저리갔다 진짜 귀신들린 것 처럼.. 무언가 이상했다..

 

원인을 찾아보니

Fusion 환경과 NavMeshAgent 환경에서 충돌이 발생한 것 같다.

 

NavMesh는 자기 update 루프를 갖고 있어서

내가 작성한 스크립트를 거치지 않고도 transform 위치를 바꿔버릴 수 있다고 한다.

동시에 Fusion이 네트워크 위치를 보정하고 있어서 튀는 현상이 생긴 것 같다.

 

public override void FixedUpdateNetwork()
{
    if (!Object)
        return;

    // NavMeshAgent 비활성
    if (!Object.HasStateAuthority)
    {
        if (agent)
        {
            if (agent.enabled)
            {
                agent.isStopped = true;
                if (agent.hasPath)
                    agent.ResetPath();
                agent.velocity = Vector3.zero;
                agent.updatePosition = false;
                agent.updateRotation = false;
                agent.enabled = false; // 완전히 꺼버리기
            }
        }
        return;
    }

    if (agent && !agent.enabled)
        agent.enabled = true;

    if (ghostSpawner == null)
        ghostSpawner = GhostSpawner.Instance;

    if (ghostSpawner == null || itemData == null || itemData.canDetect == null)
    {
        AgentStop();
        return;
    }

    bool canDetectThisGhost = itemData.canDetect.Contains(ghostSpawner.mapGhostType);

    if (!canDetectThisGhost)
    {
        phase = Phase.Idle;
        moveElapsed = 0f;
        AgentStop();
        return;
    }

    bool isMoving =
        agent &&
        !agent.isStopped &&
        !agent.pathPending &&
        agent.remainingDistance > agent.stoppingDistance &&
        agent.velocity.sqrMagnitude > 0.01f;

    FlowerShoesAnim = isMoving ? 1f : 0f;
    FlowerShoesMove(Runner.DeltaTime);
}

 

그래서 agent.updatePosition과 agent.updateRotation, enable을 모두 false로 바꾼 다음

이동이 시작될 때만 true로 바꿔주어서 navMesh의 간섭을 최소화 시켜주었다.

 


 

추가로 꽃신은 NavMesh로 움직여지고 있기 때문에

맵의 2층이나 3층에 설치하는 경우 현재 층에 깔려있는 NavMesh에 고정하는 작업이 없어서

1층에 설치되는 문제가 있었다.

 

public override void Spawned()
{
    // Awake 전에 호출되는 상황 대비용
    if (!agent)
        agent = GetComponent<NavMeshAgent>();
    if (!animator)
        animator = GetComponent<Animator>();
    if (!itemData)
        itemData = GetComponent<ItemObject>()?.itemData;

    // 호스트만
    if (!Object || !Object.HasStateAuthority || !agent)
        return;

    agent.enabled = false; // 에이전트가 임의로 움직이기 전에 잠깐 꺼두기

    Vector3 desired = transform.position; // 층간 스폰용

    if (NavMesh.SamplePosition(desired, out var hit, 0.5f, NavMesh.AllAreas))
    {
        desired = hit.position;
    }

    transform.position = desired; // 트랜스폼 먼저 맞추기

    // 에이전트 활성화 + NavMesh 상의 위치 강제로 확정
    agent.enabled = true;
    agent.Warp(desired);
}

 

이 부분은 NavMesh 위치를 강제로 맞춰주어서 해결하였다.