본문 바로가기
Unity

Unity(9) - Random Spawn Monster & A*

by Srff5123 2026. 4. 27.
728x90

이제 저번 글의 마무리에 적었던 맵의 끝에서 몬스터가 생성되도록 만들것이다.

그렇기에 오늘은 몬스터를 일단 만들어주는 세팅부터 할 것이다.

using System;
using UnityEngine;

public class CharacterStats : MonoBehaviour
{
    [Header("Health")]
    [SerializeField] private float maxHealth = 100f; // 최대 체력
    [SerializeField] private float currentHealth = 100f; // 현재 체력

    [Header("Combat")]
    [SerializeField] private float attackPower = 10f; // 파워
    [SerializeField] private float attackRange = 1.5f; // 거리
    [SerializeField] private float attackCooldown = 1f; // 공격 쿨다운

    public float MaxHealth => maxHealth;
    public float CurrentHealth => currentHealth;
    public float AttackPower => attackPower;
    public float AttackRange => attackRange;
    public float AttackCooldown => attackCooldown;

    public bool IsDead => currentHealth <= 0f;
    // 죽었을 경우 동작 안되게

    public event Action<CharacterStats> OnDied; // 액션 이벤트 
    public event Action<CharacterStats> OnHealthChanged; // 체력 갱신

    private void Awake()
    {
        maxHealth = Mathf.Max(1f, maxHealth);
        currentHealth = Mathf.Clamp(currentHealth, 0f, maxHealth);
        // 맥스값 제한

        if (currentHealth <= 0f)
            currentHealth = maxHealth;
        // 시작 체력
    }

    private void OnValidate()
    {
        maxHealth = Mathf.Max(1f, maxHealth);
        currentHealth = Mathf.Clamp(currentHealth, 0f, maxHealth);
        attackPower = Mathf.Max(0f, attackPower);
        attackRange = Mathf.Max(0f, attackRange);
        attackCooldown = Mathf.Max(0f, attackCooldown);
        // 보정 잘못된 값 넣을 수 경우 대비
    }

    public void TakeDamage(float amount)
    {
        // 데미지 받았을 경우
        if (IsDead) return;
        if (amount <= 0f) return;

        currentHealth -= amount;
        currentHealth = Mathf.Max(0f, currentHealth); // 맥스값 0 밑으로 안떨어지게

        OnHealthChanged?.Invoke(this);

        if (currentHealth <= 0f)
        {
            Die(); // 0이하면 죽음
        }
    }

    public void Heal(float amount)
    {
        // 나중 소비템 또는 시간이 지났을 경우 재생을 위한 힐
        if (IsDead) return;
        if (amount <= 0f) return;

        currentHealth += amount;
        currentHealth = Mathf.Min(currentHealth, maxHealth);

        OnHealthChanged?.Invoke(this);
    }

    public void SetHealthToFull()
    {
        // 새 게임 시작했거나 리스폰 했을 경우 최대체력으로 만들어줌
        if (IsDead) return;

        currentHealth = maxHealth;
        OnHealthChanged?.Invoke(this);
    }

    private void Die()
    {
        // 죽었을 경우
        Debug.Log($"{gameObject.name} died.");
        OnDied?.Invoke(this);
    }
}

프로젝트의 Player 폴더에 CharacterStat스크립트를 만들고 Gameplay오브젝트에 컴포넌트로 넣어주어 몬스터가 캐릭터를 공격하고 있는지를 확인한다 

 

이제 몬스터의 스폰을 담당할 MonsterSpawn Script를 만들어준다

맵의 양끝 1~5줄 사이의 공간에 랜덤적으로 스폰이 될것이고 스폰이 되면 플레이어를 쫓도록 만들어 줄것이다.

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

public class MonsterSpawner : MonoBehaviour
{
    [Header("Refs")]
    [SerializeField] private VoxelWorld world;
    [SerializeField] private GameObject monsterPrefab;
    [SerializeField] private Transform player; // 플레이어 위치 참조

    [Header("Spawn Count")]
    [SerializeField] private int initialSpawnCount = 3; // 게임 시작 시 생성될 몬스터 수
    [SerializeField] private int maxAliveMonsters = 5; // 몬스터의 최대 수

    [Header("Respawn")]
    [SerializeField] private bool enableRespawn = true; // 리스폰
    [SerializeField] private float spawnInterval = 8f; // 몬스터 생성 주기

    [Header("Edge Spawn Rule")] // 맵의 가장 자리 스폰 1 ~ 5 
    [SerializeField] private int minInsetFromEdge = 1;
    [SerializeField] private int maxInsetFromEdge = 5;

    [Header("Spawn Safety")]
    [SerializeField] private float minDistanceFromPlayer = 10f; // 몬스터의 스폰이 캐릭터와 너무 가깝지않게
    [SerializeField] private int maxSpawnAttemptsPerMonster = 50;
    [SerializeField] private float spawnYOffset = 0.05f; // 몬스터가 블록에 끼는것을 방지 오프셋 조금

    [Header("World Ready Wait")]
    [SerializeField] private float maxWaitSeconds = 5f; // 월드 생성 대기

    [Header("Debug")]
    [SerializeField] private bool logSpawnResult = true; // 몬스터 생성 결과 호출(확인용)

    private readonly List<GameObject> aliveMonsters = new List<GameObject>();
    // 현재 월드맵에 있는 몬스터 저장 리스트
    private float nextSpawnTime = 0f;
    // 다음 몬스터 리스폰 시간

    // IEnumerator 
    // 코루틴 이란? 
    // 시간의 경과에 따른 절차적 단계를 수행하는 로직을 구현하는데 사용하는 함수
    // 함수는 한 프레임에 호출되어 완료가 된다, 이에 IEnumerator 형식을 반환값으로 가지는 함수를 사용
    // IEnumerator는 함수 내부에 실행을 중지하고, 다음 프레임에서 실행을 재개할 수 있는 yield return을 사용

    private IEnumerator Start() // 코루틴 사용(시작 시 월드가 생성 대기를 위해 사용)
    {
        AutoFindRefs(); // VoxelWorld, Player 참조 찾기 - 참조가 되지 않았을 경우의 오류 방지

        if (world == null || monsterPrefab == null) // world와 monster 가 없다면 종료
        {
            Debug.LogWarning("MonsterSpawner: world or monsterPrefab is missing.");
            yield break;
        }

        yield return StartCoroutine(WaitUntilWorldReady()); // 월드 생성 기다림
        // 월드 생성 전에 몬스터가 스폰되면 순서가 안맞음 오류 생길 수 있음

        // 시작 시 초기 몬스터 생성
        for (int i = 0; i < initialSpawnCount; i++)
        {
            if (GetAliveCount() >= maxAliveMonsters) // 최대치 이상이면 종료
                break;

            TrySpawnOneMonster(); // 몬스터 생성
        }

        nextSpawnTime = Time.time + spawnInterval; // 일정 시간 이후 리스폰
    }

    private void Update()
    {
        if (!enableRespawn) // 리스폰이 꺼져 있으면 종료
            return;

        CleanupDeadReferences(); // 몬스터가 죽으면 해당 몬스터 리스트에서 제거

        if (Time.time < nextSpawnTime) // 리스폰 시간 기다리기
            return;

        nextSpawnTime = Time.time + spawnInterval; // 다음 리스폰 시간 설정

        if (GetAliveCount() >= maxAliveMonsters) // 최대치 이상이면 생성 x
            return;

        TrySpawnOneMonster();
    }

    private void AutoFindRefs() // 필요한 참조 자동 연결
    {
        if (world == null)
            world = FindObjectOfType<VoxelWorld>();

        if (player == null)
        {
            GameObject playerObj = GameObject.FindGameObjectWithTag("Player");
            if (playerObj != null)
                player = playerObj.transform;
        }
    }

    private IEnumerator WaitUntilWorldReady() // 월드 준비 코루틴
    {
        float timer = 0f; // 대기 시간 확인

        while (timer < maxWaitSeconds)
        {
            if (world != null) // 월드 참조 확인
            {
                bool hasChunks = world.transform.childCount > 0;
                bool hasAnySurface = false; // 맵 표면 셀을 찾았는지 확인

                int testX = Mathf.Max(0, world.WorldSizeX / 2);
                int testZ = Mathf.Max(0, world.WorldSizeZ / 2);
                // 월드 중앙에 표면 셀이 있으면 월드가 어느정도 준비가 되었다고 판단

                if (world.TryGetSurfaceCell(testX, testZ, out _))
                    hasAnySurface = true;

                if (hasChunks && hasAnySurface)
                    yield break;
                // 청크. 표면 셀 검사 후 전부 있다면 월드가 준비되었다고 보고 코루틴을 종료
            }

            timer += Time.deltaTime; // 기다린 시간 누적
            yield return null; // 다음 프레임까지 기다림
        }

        Debug.LogWarning("MonsterSpawner: world ready wait timeout. Trying spawn anyway.");
        // 최대 대기 시간이 지났음에도 월드가 아직 준비되있지 않다면 경고 로그 출력
    }

    private bool TrySpawnOneMonster() // 몬스터 스폰
    {
        if (world == null || monsterPrefab == null) // 월드와 몬스터 프리팹 확인
            return false;

        int worldSizeX = world.WorldSizeX;
        int worldSizeZ = world.WorldSizeZ;
        // 몬스터 스폰 위치를 위한 월드 크기 가져오기

        if (worldSizeX <= 0 || worldSizeZ <= 0) // 월드의 크기 확인
        {
            Debug.LogWarning("MonsterSpawner: invalid world size.");
            return false;
        }

        for (int attempt = 0; attempt < maxSpawnAttemptsPerMonster; attempt++) 
            // 반복문을 통해 위치 여러번 뽑기
        {
            Vector2Int candidateXZ = GetRandomEdgeCell(worldSizeX, worldSizeZ); 
            // 월드 가장자리 근처의 랜덤 x,z 좌표를 가져오기

            if (!world.TryGetSurfaceCell(candidateXZ.x, candidateXZ.y, out VoxelWorld.SurfaceCell cell))
                continue;
            // 해당 좌표에서의 가장 위쪽의 땅 좌표를 찾아줌, 실패하면 continue로 다음 시도로 넘어감

            Vector3 spawnPos = cell.FootWorldPosition; // 표면 셀 좌표 가져오기 ( 몬스터의 발이 위치할 곳)
            spawnPos.y += spawnYOffset; // 블록에 끼임을 방지하기 위해 오프셋 조금 줌

            if (player != null) // 플레이어와의 거리 확인
            {
                Vector3 flatPlayer = player.position; // 플레이어의 위치
                Vector3 flatSpawn = spawnPos; // 몬스터 스폰될 위치
                flatPlayer.y = 0f; // y값 0으로 초기화
                flatSpawn.y = 0f;

                if (Vector3.Distance(flatPlayer, flatSpawn) < minDistanceFromPlayer)
                    continue;
                // 몬스터와 캐릭터의 수평 거리상 가까운지 확인
                // 가깝다면 다른 랜덤 위치 찾기
            }


            GameObject monster = Instantiate(monsterPrefab, spawnPos, Quaternion.identity);
            // 위의 과정을 통해 스폰 위치가 결정 되었으면 몬스터 프리팹 생성

            // 생성된 몬스터에게 씬 참조를 직접 주입한다.
            MonsterController monsterController = monster.GetComponent<MonsterController>();
            // 생성된 몬스터에서 MonsterContoller 컴포넌트 가져옴,
            // 스폰된 몬스터가 플레이어를 추적 , 공격하게 하기 위해

            if (monsterController != null)
                // 몬스터 컨트롤러가 있는지 확인
            {
                CharacterStats playerStats = null;

                if (player != null)
                    // 플레이어 참조 확인 후 플레이어 정보 넣어줌
                {
                    playerStats = player.GetComponent<CharacterStats>();

                    if (playerStats == null)
                        playerStats = player.GetComponentInParent<CharacterStats>();

                    if (playerStats == null)
                        playerStats = player.GetComponentInChildren<CharacterStats>();
                }

                monsterController.Initialize(world, player, playerStats);
                // 생성된 몬스터 초기화
            }
            else
            {
                Debug.LogWarning("MonsterSpawner: spawned monster has no MonsterController.");
            }

            aliveMonsters.Add(monster); // 생성된 몬스터 리스트에 추가

            if (logSpawnResult)
            {
                Debug.Log($"MonsterSpawner: spawned monster at ({cell.x}, {cell.surfaceY}, {cell.z})");
                // 어디에 생성되었는지 좌표 로그 확인
            }

            return true;
        }

        if (logSpawnResult)
        {
            Debug.LogWarning("MonsterSpawner: failed to find valid spawn position.");
            // 유효한 위치를 찾지 못한 경우 실패 로그 출력
        }

        return false;
        // 몬스터 생성 실패
    }

    private Vector2Int GetRandomEdgeCell(int worldSizeX, int worldSizeZ)
        // 월드 가장자리 근처의 랜덤 x,z 좌표를 반환하는 함수
    {
        int edge = Random.Range(0, 4); // 0 ~ 3 랜덤 뽑기 (면 뽑기)

        int insetX = Random.Range(minInsetFromEdge, maxInsetFromEdge + 1);
        int insetZ = Random.Range(minInsetFromEdge, maxInsetFromEdge + 1);
        // 가장 자리에서 몇 칸 안쪽으로 들어올지 랜덤 숫자 뽑기

        switch (edge)
        {
            // 왼쪽
            case 0:
                {
                    int x = insetX;
                    int z = Random.Range(0, worldSizeZ);
                    return new Vector2Int(x, z);
                }

            // 오른쪽
            case 1:
                {
                    int x = (worldSizeX - 1) - insetX;
                    int z = Random.Range(0, worldSizeZ);
                    return new Vector2Int(x, z);
                }

            // 아래쪽
            case 2:
                {
                    int x = Random.Range(0, worldSizeX);
                    int z = insetZ;
                    return new Vector2Int(x, z);
                }

            // 위쪽
            default:
                {
                    int x = Random.Range(0, worldSizeX);
                    int z = (worldSizeZ - 1) - insetZ;
                    return new Vector2Int(x, z);
                }
        }
    }

    private void CleanupDeadReferences()
        // 월드에서 없어진 몬스터 참조를 리스트에서 제거
    {
        for (int i = aliveMonsters.Count - 1; i >= 0; i--)
        {
            // 리스트 순회하며 제거
            if (aliveMonsters[i] == null)
                aliveMonsters.RemoveAt(i);
        }
    }

    private int GetAliveCount()
    {
        CleanupDeadReferences();
        return aliveMonsters.Count; // 삭제 후 리스트에 남아 있는 몬스터 수 반환
    }
}

현재는 많은 몬스터를 스폰하지 않을 거라 매번 생성하는 쪽으로 했으나 나중 몬스터의 수가 많아지면 풀링할 예정

 

복셀 월드 기반 환경에서 몬스터의 초기 생성과 리스폰을 관리, 몬스터가 생성되는 문제를 방지하기 위해 코루틴을 이용해 월드의 준비 상태를 확인하고, 맵의 표면을 찾아 해당 표면보다 조금 오프셋을 주어 위쪽에 스폰되도록 하였음

 

몬스터는 계획대로 가장자리 근처에서 생성되도록 하였고 만약 몬스터와 플레이어의 수평 거리 계산을 통해 플레이어와 너무 가까운 위치라면 생성하지 않도록 하였음

 

using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterController))]
[RequireComponent(typeof(CharacterStats))]

public class MonsterController : MonoBehaviour
{
    private enum NavMode
    {
        None, // 경로 찾지 못함 or 공격 중
        ExactPath, // 목표로 가는중
        PartialPath, // 플레이어까지 가는 길이 없는 경우 - 최대한 가까운 위치로
        Roam // 길이 끊겼을 경우 플레이어 주변 맴돌기
    }
    // 몬스터의 현재 길찾기 상태 Enum

    [Header("Refs")]
    [SerializeField] private Transform target; // 플레이어 위치
    [SerializeField] private CharacterStats targetStats; // 플레이어 스탯
    [SerializeField] private VoxelWorld world;

    [Header("Move")]
    [SerializeField] private float moveSpeed = 3.5f;
    [SerializeField] private float gravity = 20f;
    [SerializeField] private float rotateSpeed = 10f;
    [SerializeField] private float jumpSpeed = 7f;
    [SerializeField] private float waypointReachDistance = 0.2f; // 목표 거리 도착 판단 거리
    [SerializeField] private float jumpCooldown = 0.25f; // 점프 쿨타임

    [Header("Pathfinding")]
    [SerializeField] private float repathInterval = 0.5f; // 경로 탐색 간격
    [SerializeField] private MonsterPathSettings pathSettings = default;

    [Header("Roam")]
    [SerializeField] private float roamMinRadius = 2f;
    [SerializeField] private float roamMaxRadius = 4f;
    [SerializeField] private float roamRepathInterval = 1.0f;
    // 주변 맴돌기 범위 및 간격

    [Header("Break Fallback")]
    [SerializeField] private bool allowBreakPlayerPlacedBlocks = true; // 블록 파괴 가능 여부
    [SerializeField] private float breakAfterUnreachableTime = 3f; // 접근하지 못한 시간
    [SerializeField] private float breakCooldown = 0.8f; // 블록 부수기 쿨타임
    [SerializeField] private float breakReachDistance = 1.8f; // 블록 부술 수 있는 거리

    [Header("Debug")]
    [SerializeField] private bool drawPathGizmos = true; // 디버그 그리기

    private CharacterController cc;
    private CharacterStats myStats; 

    private float verticalVelocity;
    private float lastAttackTime = -999f; // 게임 시작 후 바로 행동 할 수 있게 -999로 설정
    private float lastJumpTime = -999f;
    private float lastBreakTime = -999f;

    private float nextRepathTime = 0f; // 다음 경로 재계산 시간
    private float nextRoamTime = 0f; // 돌아다니기 재계산
    private float unreachableSince = -999f; // 접근 불가 판단 시간

    private NavMode navMode = NavMode.None; // 현재 경로 상태
    private MonsterPathResult currentPathResult; // 경로 탐색 결과 
    private List<VoxelWorld.SurfaceCell> currentPath = new List<VoxelWorld.SurfaceCell>();
    // 따라가야 할 표면 경로
    private int currentPathIndex = 0; // 현재 경로 위치
    private Vector2Int lastRoamGoalKey = new Vector2Int(int.MinValue, int.MinValue);

    private float movementLockedUntil = -999f;

    private static readonly Vector2Int[] RoamOffsets =
        // 플레이어 주변에서 맴돌 위치
    {
        new Vector2Int( 2,  0),
        new Vector2Int(-2,  0),
        new Vector2Int( 0,  2),
        new Vector2Int( 0, -2),

        new Vector2Int( 3,  0),
        new Vector2Int(-3,  0),
        new Vector2Int( 0,  3),
        new Vector2Int( 0, -3),

        new Vector2Int( 2,  2),
        new Vector2Int(-2,  2),
        new Vector2Int( 2, -2),
        new Vector2Int(-2, -2),

        new Vector2Int( 3,  1),
        new Vector2Int(-3,  1),
        new Vector2Int( 3, -1),
        new Vector2Int(-3, -1),

        new Vector2Int( 1,  3),
        new Vector2Int(-1,  3),
        new Vector2Int( 1, -3),
        new Vector2Int(-1, -3),
    };

    private void Reset()
    {
        pathSettings = MonsterPathSettings.Default();
    }

    private void Awake()
    {
        cc = GetComponent<CharacterController>();
        myStats = GetComponent<CharacterStats>();

        if (pathSettings.maxSearchNodes <= 0)
            pathSettings = MonsterPathSettings.Default();
    }

    private void Start()
    {
        TryAutoFindRefs(); // 참조 찾기
    }

    public void Initialize(VoxelWorld world, Transform target, CharacterStats targetStats)
    {
        
        this.world = world;
        this.target = target;
        this.targetStats = targetStats;
        // 참조를 몬스터 내부 변수에 저장

        // 캐릭터 스탯이 없다면 다시 찾아본다.
        if (this.targetStats == null && this.target != null)
        {
            this.targetStats = this.target.GetComponent<CharacterStats>();

            if (this.targetStats == null)
                this.targetStats = this.target.GetComponentInParent<CharacterStats>();

            if (this.targetStats == null)
                this.targetStats = this.target.GetComponentInChildren<CharacterStats>();
        }

        // 스폰 직후 경로를 다시 계산하기 위해 초기화
        nextRepathTime = 0f;
        nextRoamTime = 0f;
        unreachableSince = -999f;

        ClearNavigation(); // 이전 경로 상태 초기화
    }

    private void Update()
    {
        if (myStats == null || myStats.IsDead)
            // 몬스터 죽음 처리
            return;

        TryAutoFindRefs();

        if (target == null || targetStats == null || world == null)
            // 모든 참조가 있는 경우에만 행동
            return;

        if (Time.time < movementLockedUntil)
        {
            ApplyVerticalOnly();
            return;
        }


        float distance = GetHorizontalDistanceToTarget();
        // 플레이어와의 수평 거리 계산

        // 공격 가능 거리 안이면 길찾기보다 공격 우선
        if (distance <= myStats.AttackRange)
        {
            ClearNavigation();
            unreachableSince = -999f;

            FaceTarget();
            TryAttack();
            return;
        }

        // 일정 주기마다 경로 재계산
        if (Time.time >= nextRepathTime)
        {
            RebuildMainPath();
        }

        // 현재 경로가 있으면 따라간다.
        if (HasActivePath())
        {
            // 현재 따라가는 경로가 있으면 따라감
            FollowCurrentPath();

            if (!HasActivePath())
            {
                // 따라가야 하는 경로가 없는 경우( 플레이어 주변 맴돌기, 플레이어가 설치한 블록 부수기)
                HandleUnreachableBehavior();
                
            }

            return;
        }

        // 경로가 없으면 멈추지 말고 대체 행동
        HandleUnreachableBehavior();
    }

    private void TryAutoFindRefs()
    {
        if (world == null)
            world = FindObjectOfType<VoxelWorld>();

        if (target != null && targetStats != null)
            return;

        GameObject playerObj = GameObject.FindGameObjectWithTag("Player");
        // 플레이어 태그인 오브젝트 찾기 (플레이어 찾기)
        if (playerObj == null)
            // 플레이어 없으면 종료
            return;

        target = playerObj.transform;
        targetStats = playerObj.GetComponent<CharacterStats>();
    }

    private float GetHorizontalDistanceToTarget()
        // 몬스터와 플레이어 사이의 수평 거리를 반환
    {
        Vector3 myPos = transform.position;
        Vector3 targetPos = target.position;

        myPos.y = 0f;
        targetPos.y = 0f;

        return Vector3.Distance(myPos, targetPos);
    }

    private void RebuildMainPath()
    {
        // 플레이어를 향한 메인 경로 재계산
        nextRepathTime = Time.time + repathInterval;
        // 다음 경로 재계산 주기 설정

        currentPathResult = MonsterPathfinder.FindPathOrBestEffort(
            // 몬스터 위치에서 플레이어 위치까지의 경로 찾기
            world,
            transform.position,
            target.position,
            pathSettings
        );

        switch (currentPathResult.type)
            // 경로 탐색 결과에 따른 상태 
        {
            case MonsterPathResultType.Exact:
                SetPath(currentPathResult.path, NavMode.ExactPath);
                unreachableSince = -999f;
                // 플레이어로 향하는 경로 찾은 경우
                break;

            case MonsterPathResultType.PartialBest:
                SetPath(currentPathResult.path, NavMode.PartialPath);

                if (unreachableSince < 0f)
                    unreachableSince = Time.time;
                // 플레이어 근처로 이동하는 경로
                break;

            default:
                ClearNavigation();

                if (unreachableSince < 0f)
                    unreachableSince = Time.time;
                // 경로 찾기 실패 
                break;
        }
    }

    private void HandleUnreachableBehavior()
        // 플레이어로 접근할 수 없을 때의 행동 함수
    {
        bool hasRoam = false; // roam 경로 찾았는지에 대한 여부

        if (Time.time >= nextRoamTime)
            hasRoam = TryBuildRoamPath();
        // roam 재계산 시간이 되었으면 플레이어 주변을 맴도는 경로 찾기

        if (hasRoam && HasActivePath())
        {
            FollowCurrentPath();
            // roam 경로를 찾았다면 해당 경로 따라감
            return;
        }

        // 일정 시간 이상 접근하지 못했다면 플레이어가 설치한 블록 파괴(플레이어가 의도적으로 길을 막은 경우)
        if (ShouldAttemptBreak())
        {
            // 플레이어 아래 지지 블록부터 우선, 블록을 쌓고 올라간 경우를 대응
            if (TryBreakSupportColumnUnderTarget())
            {
                ApplyVerticalOnly();
                return;
            }

            // 안 되면 앞을 막는 블록
            if (TryBreakTowardTarget())
            {
                ApplyVerticalOnly();
                return;
            }
        }

        // 아무것도 할 수 없는 경우 플레이어 바라보기
        FaceTarget();
    }

    private bool HasActivePath()
    {
        // 아래의 조건 만족하는 경우에만 경로로 봄
        return currentPath != null &&
               currentPath.Count > 0 &&
               currentPathIndex >= 0 &&
               currentPathIndex < currentPath.Count;
    }

    private void SetPath(List<VoxelWorld.SurfaceCell> newPath, NavMode mode)
    {
        // 새로운 경로 설정
        currentPath = newPath ?? new List<VoxelWorld.SurfaceCell>();
        navMode = mode; // 현재 경로 상태 저장

        // 현재 위치 다음 좌표부터 따라감
        currentPathIndex = (currentPath.Count > 1) ? 1 : 0;
    }

    private void ClearNavigation()
    {
        // 현재 경로 초기화
        currentPath.Clear();
        currentPathIndex = 0;
        navMode = NavMode.None;
    }

    private void FollowCurrentPath()
    {
        // 현재 경로 따라가기
        if (!HasActivePath())
        {
            ApplyVerticalOnly();
            return;
        }

        VoxelWorld.SurfaceCell nextCell = currentPath[currentPathIndex];
        // 현재 따라갈 다음 표면 좌표와 해당 위치 가져오기
        Vector3 waypoint = nextCell.FootWorldPosition;
        // 몬스터의 발판 기준 좌표

        Vector3 flat = waypoint - transform.position;
        // 방향 벡터만 구하고 y축은 무시. 수평 방향 이동만 계산
        flat.y = 0f;

        // waypoint 도착 판정
        if (flat.magnitude <= waypointReachDistance)
            // 현재 도달점에 얼마나 가까워 졌는지 확인
        {
            currentPathIndex++;

            if (!HasActivePath())
            {
                // 도달점이 없는 경우 경로 끝
                ApplyVerticalOnly();
                return;
            }

            nextCell = currentPath[currentPathIndex];
            waypoint = nextCell.FootWorldPosition;
            flat = waypoint - transform.position;
            flat.y = 0f;
            // 다음 도달점 기준 방향으로 재계산
        }

        if (flat.sqrMagnitude <= 0.0001f)
        {
            ApplyVerticalOnly();
            return;
            // 이동 방향이 거의 0인 경우 
        }

        Vector3 moveDir = flat.normalized;
        // 이동 방향을 단위 벡터로 만들어줌
        RotateToward(moveDir);
        // 이동 방향 바라보기

        bool grounded = cc.isGrounded; // 현재 땅에 닿아 있는지 확인

        if (grounded && verticalVelocity < 0f)
            verticalVelocity = -2f;

        TryJumpToHigherNextCell(nextCell, grounded, flat.magnitude);
        // 다음 좌표가 현재보다 높다면 점프 시도

        if (!cc.isGrounded)
            verticalVelocity -= gravity * Time.deltaTime;
        else if (verticalVelocity < 0f)
            verticalVelocity = -2f;
        // 공중이면 중력 적용 

        Vector3 velocity = moveDir * moveSpeed;
        velocity.y = verticalVelocity;

        cc.Move(velocity * Time.deltaTime); // 몬스터 이동
    }

    /// <summary>
    /// 다음 칸이 현재 칸보다 높으면 점프
    /// </summary>
    private void TryJumpToHigherNextCell(VoxelWorld.SurfaceCell nextCell, bool grounded, float horizontalDistance)
    {
        // 다음 좌표가 현재 위치보다 높은 경우 점프하기
        if (!grounded)
            return;
        // 공중에 떠있는 경우는 점프 못함

        if (Time.time < lastJumpTime + jumpCooldown)
            return;
        // 점프 쿨타임 체크

        int currentX = Mathf.FloorToInt(transform.position.x);
        int currentZ = Mathf.FloorToInt(transform.position.z);
        // 몬스터의 현재 월드 좌표

        if (!world.TryGetSurfaceCell(currentX, currentZ, out VoxelWorld.SurfaceCell currentCell))
            return;
        // 현재 위치의 좌표 찾기 (높이 비교)

        int deltaY = nextCell.surfaceY - currentCell.surfaceY;
        // 다음 좌표와 현재 좌표의 높이 차이 구하기

        // 한 칸 차이 이면서 충분히 가까워졌을 경우에만 점프
        if (deltaY > 0 && horizontalDistance <= 0.85f)
        {
            verticalVelocity = jumpSpeed;
            lastJumpTime = Time.time;
        }
    }

    private bool TryBuildRoamPath()
    {
        // 플레이어 주변 맴돌기 함수
        nextRoamTime = Time.time + roamRepathInterval;
        // 다음 roam 재계산 시간

        if (target == null || world == null)
            return false;
        // 타겟이나 월드가 없으면 반환

        int targetX = Mathf.FloorToInt(target.position.x);
        int targetZ = Mathf.FloorToInt(target.position.z);
        // 플레이어의 현재 좌표

        List<VoxelWorld.SurfaceCell> bestPath = null; // 가장 좋은 경로 저장
        Vector2Int bestKey = default; // 해당 경로의 목표 키
        int bestScore = int.MaxValue; // 경로 비용

        for (int i = 0; i < RoamOffsets.Length; i++)
        {
            // 맴돌기 후보 위치 검사
            Vector2Int offset = RoamOffsets[i];

            float r = offset.magnitude;
            if (r < roamMinRadius || r > roamMaxRadius)
                continue;
            // 후보 위치가 roam 반경 안에 있는지 확인, 너무 가깝거나 멀면 제외

            int candidateX = targetX + offset.x;
            int candidateZ = targetZ + offset.y;
            // 플레이어 좌표 기준으로 계산

            if (!world.TryGetSurfaceCell(candidateX, candidateZ, out VoxelWorld.SurfaceCell candidateCell))
                continue;
            // 해당 위치가 갈 수 있는 곳인지

            Vector3 candidateGoal = candidateCell.FootWorldPosition;
            // 해당 위치의 월드 좌표 가져오기

            MonsterPathResult roamResult = MonsterPathfinder.FindPathOrBestEffort(
                // 몬스터의 현재 위치에서 해당 위치까지의 경로 찾기
                world,
                transform.position,
                candidateGoal,
                pathSettings
            );

            // roam은 도달 가능한 경로인 경우에만 이동
            if (roamResult.type != MonsterPathResultType.Exact || !roamResult.HasPath || roamResult.path.Count <= 1)
                continue;

            int score = roamResult.totalCost; // 경로 비용 

            // 맨해튼 거리 기반으로 비용 계산
            int distToTarget = Mathf.Abs(candidateX - targetX) + Mathf.Abs(candidateZ - targetZ);
            score += distToTarget * 4;

            // 방금 갔던 위치와 같은 칸이면 비용을 추가로 줘서
            // 맴돌 때 한 곳에 고정되는 걸 줄인다
            if (candidateCell.Key == lastRoamGoalKey)
                score += 25;

            if (score < bestScore)
            {
                // 현재 위치가 지금까지 찾은 위치보다 좋다면 저장
                bestScore = score;
                bestPath = roamResult.path;
                bestKey = candidateCell.Key;
            }
        }

        if (bestPath == null || bestPath.Count == 0)
            // 적절한 위치를 찾지 못했다면 실패 반환ㄴ
            return false;

        SetPath(bestPath, NavMode.Roam);
        lastRoamGoalKey = bestKey;
        return true;
        // 가장 좋은 경로를 현재 경로로 설정하고 True 반환
    }

    private bool ShouldAttemptBreak()
    {
        // 블록을 파괴할 수 있는지 확인
        if (!allowBreakPlayerPlacedBlocks)
            return false;

        if (unreachableSince < 0f)
            return false;

        return (Time.time - unreachableSince) >= breakAfterUnreachableTime;
    }

    private bool TryBreakSupportColumnUnderTarget()
    {
        // 플레이어가 블록을 설치에 위로 올라가 있는 경우
        // 아래의 지지 블록을 부수기, 버티는 경우를 막기 위함

        if (target == null || world == null)
            return false;

        if (Time.time < lastBreakTime + breakCooldown)
            return false;
        // 블록 파괴 쿨타임

        int tx = Mathf.FloorToInt(target.position.x);
        int tz = Mathf.FloorToInt(target.position.z);
        // 현재 플레이어 위치

        int startY = Mathf.Clamp(Mathf.FloorToInt(target.position.y) - 1, 0, VoxelChunk.SizeY - 1);
        // 플레이어 발판 검사를 위한 y좌표 구하기,
        // clamp로 월드 높이 범위 벗어나지 않게 제한

        bool foundPlacedColumn = false; // 플레이어가 설치한 기둥을 찾았는지
        int lowestPlacedY = -1; // 가장 아래쪽 설치 블록 y좌표 저장
        

        // 플레이어 발판 아래로 내려가면서 검사
        for (int y = startY; y >= 0; y--)
        {
            BlockType t = world.GetBlockWorld(tx, y, tz);
            // 해당 위치의 블록 타입 가져오기

            if (t == BlockType.Air)
            {
                // 비어있는 공간이면 넘어가기
                if (foundPlacedColumn)
                    break;

                continue;
            }

            bool placed = world.GetBlockPlacedByPlayerWorld(tx, y, tz);
            // 해당 블록이 플레이어가 설치한 블록인지 확인

            if (!placed)
            {
                // 자연 지형이라면 부수지 않음
                break;
            }

            foundPlacedColumn = true;
            // 플레이어가 설치한 발판 블록 발견
            lowestPlacedY = y;
            // 마지막 지정된 값이 가장 아래쪽 설치 블록
        }

        if (!foundPlacedColumn || lowestPlacedY < 0)
            return false;
        // 설치한 블록을 찾지 못했다면 실패 반환

        Vector3 blockCenter = new Vector3(tx + 0.5f, lowestPlacedY + 0.5f, tz + 0.5f);
        float dist = Vector3.Distance(transform.position, blockCenter);
        // 부술 블록의 중심 좌표를 구하고 몬스터와의 거리 계산

        // 너무 멀면 못 부숨
        // (나중 고민해서 블록 부수는 거리만 늘리든 할 예정)
        if (dist > breakReachDistance)
            return false;

        bool ok = world.TrySetBlockWorld(tx, lowestPlacedY, tz, BlockType.Air, false);
        if (!ok)
            return false;
        // 해당 블록을 비어있는 공간 air로 바꿔서 블록 제거
        // 블록 부수기에 실패하면 false 반환

        lastBreakTime = Time.time;
        nextRepathTime = 0f;
        nextRoamTime = 0f;

        ClearNavigation();

        // 블록을 부섰을 경우 상태 갱신
        // 부순 시간, 경로 재계산 시간, 기존 경로 제거
        // 블록이 사라지며 경로가 달라졌을 수도 있기 때문

        Debug.Log($"{name} broke support block under player at ({tx}, {lowestPlacedY}, {tz})");
        return true;
    }


    private bool TryBreakTowardTarget()
    {
        // 플레이어 방향으로 가는 길에 플레이어가 설치한 블록이 있는 경우 부수기
        if (target == null || world == null)
            return false;

        if (Time.time < lastBreakTime + breakCooldown)
            return false;

        Vector3 toTarget = target.position - transform.position;
        toTarget.y = 0f;
        // 플레이어를 향한 수평 방향 벡터 구하기

        if (toTarget.sqrMagnitude <= 0.0001f)
            return false;

        Vector3Int step = ToCardinalStep(toTarget.normalized);
        // 플레이어 방향을 가장 가까운 상하좌우 방향으로 바꿔줌

        int currentX = Mathf.FloorToInt(transform.position.x);
        int currentZ = Mathf.FloorToInt(transform.position.z);
        // 현재 몬스터의 x,z 좌표 구하기

        if (!world.TryGetSurfaceCell(currentX, currentZ, out VoxelWorld.SurfaceCell currentCell))
            return false;
        // 현재 위치의 좌표 찾기

        int frontX = currentCell.x + step.x;
        int frontZ = currentCell.z + step.z;
        // 몬스터 앞쪽 좌표 계산

        for (int y = currentCell.surfaceY + 1; y <= currentCell.surfaceY + 2; y++)
        {
            // 몬스터 앞쪽의 몸 높이 정도에 해당하는 블록 검사, 
            // 공격이 가능하면 되기 때문 몸 앞을 막는 블록 찾기
            BlockType t = world.GetBlockWorld(frontX, y, frontZ);
            if (t == BlockType.Air)
                continue;
            // 앞쪽 블록이 비어있다면 넘어가기

            bool placedByPlayer = world.GetBlockPlacedByPlayerWorld(frontX, y, frontZ);
            if (!placedByPlayer)
                return false;
            // 플레이어가 설치한 블록이 아니면 부수지 않음

            Vector3 blockCenter = new Vector3(frontX + 0.5f, y + 0.5f, frontZ + 0.5f);
            float dist = Vector3.Distance(transform.position, blockCenter);
            // 블록 중심까지의 거리 계산

            if (dist > breakReachDistance)
                return false;
            // 너무 멀면 부수지 않음

            bool ok = world.TrySetBlockWorld(frontX, y, frontZ, BlockType.Air, false);
            if (!ok)
                return false;
            // 블록을 비어있는 공간인 air로 만들어 제거

            lastBreakTime = Time.time;
            nextRepathTime = 0f;
            nextRoamTime = 0f;

            ClearNavigation();
            // 블록 부섰으니 아까처럼 초기화 및 재계산

            Debug.Log($"{name} broke front player block at ({frontX}, {y}, {frontZ})");
            return true;
        }

        return false;
    }

    private Vector3Int ToCardinalStep(Vector3 dir)
    {
        // 임의의 방향 벡터를 복셀 격자 기준 상하좌우 한 칸 방향으로 변환
        float ax = Mathf.Abs(dir.x);
        float az = Mathf.Abs(dir.z);
        // x,z 축 방향 크기 절대값 구하기

        if (ax >= az)
            return new Vector3Int(dir.x >= 0f ? 1 : -1, 0, 0);
        // x 축의 영향이 더 크면 x 축 방향으로 한칸 이동

        return new Vector3Int(0, 0, dir.z >= 0f ? 1 : -1);
        // z 면 z축으로, 플레이어 방향 정면의 블록 판단
    }

    private void ApplyVerticalOnly()
    {
        if (cc.isGrounded && verticalVelocity < 0f)
            verticalVelocity = -2f;
        else if (!cc.isGrounded)
            verticalVelocity -= gravity * Time.deltaTime;

        Vector3 gravityOnly = new Vector3(0f, verticalVelocity, 0f);
        cc.Move(gravityOnly * Time.deltaTime);
    }

    private void FaceTarget()
    {
        // 플레이어 바라보게 만듬
        Vector3 direction = target.position - transform.position;
        direction.y = 0f;
        // 플레이어 방향을 구하되 y 축 무시

        if (direction.sqrMagnitude <= 0.0001f)
        {
            ApplyVerticalOnly();
            return;
        }
        // 방향이 거의 없으면 회전 x 

        RotateToward(direction.normalized);
        ApplyVerticalOnly();
        // 플레이어 방향으로 회전
    }

    private void RotateToward(Vector3 dir)
    {
        // 주어진 방향으로 부드럽게 회전

        Quaternion targetRot = Quaternion.LookRotation(dir);
        // 해당 방향을 바라보는 회전값
        transform.rotation = Quaternion.Slerp(
            transform.rotation,
            targetRot,
            rotateSpeed * Time.deltaTime
            // 목표 회전으로 부드럽게 보간해줌
            // Slerp를 사용해 즉시 돌아보는것이 아닌 스르륵 회전
        );
    }

    private void TryAttack()
    {
        // 공격 가능하면 플레이어에게 데미지 주기
        if (targetStats == null || targetStats.IsDead)
            return;
        // 공격 대상이 없거나 죽었다면 공격 x

        if (Time.time < lastAttackTime + myStats.AttackCooldown)
            return;
        // 공격 쿨타임이 아직 남아 있다면 공격 x

        lastAttackTime = Time.time; // 마지막 공격 시간 갱신
        targetStats.TakeDamage(myStats.AttackPower);
        // 플레이어에게 공격 -> 데미지 주기

        Debug.Log($"{name} attacked {target.name} for {myStats.AttackPower} damage.");
    }

    private void OnDrawGizmosSelected()
    {
        // 디버그 옵션 공격 범위, 경로 표현
        if (!drawPathGizmos)
            return;

        CharacterStats stats = GetComponent<CharacterStats>();
        if (stats != null)
        {
            Gizmos.color = Color.red;
            Gizmos.DrawWireSphere(transform.position, stats.AttackRange);
        }

        if (currentPath == null || currentPath.Count == 0)
            return;

        Gizmos.color = navMode == NavMode.ExactPath ? Color.green :
                       navMode == NavMode.PartialPath ? Color.yellow :
                       Color.cyan;

        for (int i = 0; i < currentPath.Count; i++)
        {
            Vector3 p = currentPath[i].FootWorldPosition;
            Gizmos.DrawSphere(p + Vector3.up * 0.05f, 0.08f);

            if (i < currentPath.Count - 1)
            {
                Vector3 next = currentPath[i + 1].FootWorldPosition;
                Gizmos.DrawLine(p + Vector3.up * 0.05f, next + Vector3.up * 0.05f);
            }
        }
    }

    private void HandleDied(CharacterStats deadStats)
    {
        // 몬스터가 죽었을 경우 , 이벤트 기반 사망 처리
        Debug.Log($"{name} died and will be destroyed.");

        Destroy(gameObject); // 몬스터 객체 삭제
    }
    private void OnEnable()
    {
        // 오브젝트 활성화 사망 이벤트 구독
        if (myStats == null)
            myStats = GetComponent<CharacterStats>();

        if (myStats != null)
            myStats.OnDied += HandleDied;
    }

    private void OnDisable()
    {
        // 비활성화될 때 구독 해제
        if (myStats != null)
            myStats.OnDied -= HandleDied;
    }

    public void LockMovement(float duration)
    {
        // 몬스터 이동 경직
        // 맞았을 경우
        movementLockedUntil = Mathf.Max(movementLockedUntil, Time.time + duration);
        ClearNavigation();
    }

}

 

MonsterController 몬스터의 플레이어 추적, 이동, 공격, 사망 처리를 담당하는 컨트롤러인데 짜다보니 좀 과하게 몰려있긴한 감이 없잖아 있긴하다 나중에 좀 분리해서 다시 짜야할듯함

 

몬스터가 일정 주기마다 플레이어로의 경로 계산을 통해 현재 상태를 판단하여 플레이어를 쫓아 공격할 수 있도록 하였다

플레이어가 만약 블록을 쌓아 올려 몬스터의 접근을 막아 꼼수를 쓴다면 몬스터가 블록을 부술 수 있도록 하여 방지했다

 

using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public struct MonsterPathSettings
    // 몬스터 길찾기 설정값
{
    public int maxStepUp;            // 위로 몇 칸까지 올라갈 수 있는가
    public int maxDropDown;          // 아래로 몇 칸까지 내려갈 수 있는가
    public int straightCost;         // 평지 한 칸 비용
    public int jumpPenalty;          // 위로 올라갈 때 추가 비용
    public int waterCostMultiplier;  // 물이 있다면 칸이면 비용 배율로 증가
    public int maxSearchNodes;       // 너무 멀리 탐색하지 않도록 제한

    public static MonsterPathSettings Default()
    {
        // 초기 설정값
        return new MonsterPathSettings
        {
            maxStepUp = 1,
            maxDropDown = 2,
            straightCost = 10,
            jumpPenalty = 8,
            waterCostMultiplier = 2,
            maxSearchNodes = 2500
        };
    }
}

public enum MonsterPathResultType
{
    // 길찾기 결과 enum
    Failed, // 찾지 못함
    Exact, // 도달 가능
    PartialBest // 일부 가능
}

public struct MonsterPathResult
{
    // 결과값 구조체
    // A* 활용, 플레이어로 향하는 표면 경로 계산, 도달 불가능한 경우 가장 가까운 도달 지점 경로 반환
    public MonsterPathResultType type; // 탐색 결과
    public List<VoxelWorld.SurfaceCell> path; // 실제 몬스터가 따라갈 경로
    public int totalCost; // 해당 경로의 총 이동 비용
    public VoxelWorld.SurfaceCell bestCell; // 가장 좋은 경로

    public bool HasPath => path != null && path.Count > 0;
    // 경로가 존재하는지 확인하는 프로퍼티

    public static MonsterPathResult FailedResult()
    {
        // 실패 했을 경우의 결과값
        return new MonsterPathResult
        {
            type = MonsterPathResultType.Failed,
            path = new List<VoxelWorld.SurfaceCell>(),
            totalCost = 0,
            bestCell = default
        };
    }
}

public static class MonsterPathfinder
{
    // 길찾기
    private static readonly Vector2Int[] NeighborDirs =
    {
        // 탐색할 상하좌우
        new Vector2Int( 1,  0),
        new Vector2Int(-1,  0),
        new Vector2Int( 0,  1),
        new Vector2Int( 0, -1),
    };

    public static MonsterPathResult FindPathOrBestEffort(
        VoxelWorld world,
        Vector3 startWorldPos, // 몬스터 현재 위치
        Vector3 targetWorldPos, // 플레이어 위치
        MonsterPathSettings settings) // 길찾기 설정값
    {
        if (world == null)
            return MonsterPathResult.FailedResult();

        int startX = Mathf.FloorToInt(startWorldPos.x);
        int startZ = Mathf.FloorToInt(startWorldPos.z);
        int goalX = Mathf.FloorToInt(targetWorldPos.x);
        int goalZ = Mathf.FloorToInt(targetWorldPos.z);
        // 월드 좌표를 복셀 셀 좌표로 변환해줌

        if (!world.TryGetSurfaceCell(startX, startZ, out VoxelWorld.SurfaceCell startCell))
            return MonsterPathResult.FailedResult();

        if (!world.TryGetSurfaceCell(goalX, goalZ, out VoxelWorld.SurfaceCell goalCell))
            return MonsterPathResult.FailedResult();

        Vector2Int startKey = startCell.Key; // 시작 셀키
        Vector2Int goalKey = goalCell.Key; // 목표 셀 키

        // 오픈 리스트 : 런타임에 크기가 동적으로 변할 수 있는 데이터 리스트
        List<Vector2Int> openList = new List<Vector2Int> { startKey }; // 탐색할 후보 노드
        HashSet<Vector2Int> openSet = new HashSet<Vector2Int> { startKey };
        // 특정 노드가 open에 있는지 빠르게 확인하기 위한 보조 셋
        // List는 가장 낮은 최단 경로 비용을 찾기 위해 순회
        // HashSet 해당 경로가 이미 후보에 들어 있는지 빠른 확인 위해 사용

        HashSet<Vector2Int> closedSet = new HashSet<Vector2Int>();
        // 탐색이 이미 끝난 노드를 저장하는 집합,
        // A*에서 한 번 처리한 노드를 다시 처리하는 것이 비효율적이기 때문에 closed set으로 관리

        // A* 점수 기록
        Dictionary<Vector2Int, int> gScore = new Dictionary<Vector2Int, int>();
        // 시작점에서 해당 노드까지 오는 데 실제로 든 비용
        Dictionary<Vector2Int, int> hScore = new Dictionary<Vector2Int, int>();
        // 해당 노드에서 목표까지 얼마나 남았는지 추정 비용, (맨해튼 기반의 휴리스틱 사용)
        Dictionary<Vector2Int, Vector2Int> cameFrom = new Dictionary<Vector2Int, Vector2Int>();
        // 경로 복원을 위한 부모 노드 기록
        Dictionary<Vector2Int, VoxelWorld.SurfaceCell> cellCache = new Dictionary<Vector2Int, VoxelWorld.SurfaceCell>();
        // 2D좌표 키에 해당되는 표면 셀 정보를 저장, 같은 셀 정보를 여러번 찾지 않게 하기 위해 

        gScore[startKey] = 0; // 시작점 까지 오는 비용
        hScore[startKey] = Heuristic(startKey, goalKey, settings.straightCost);
        // 시작점에서 목표까지의 추정 비용
        cellCache[startKey] = startCell; 
        cellCache[goalKey] = goalCell;
        // 시작 셀과 목표 셀을 캐시에 저장

        // 목표까지는 못 가더라도, 가장 가까이 간 노드를 기억해 둔다.
        Vector2Int bestApproachKey = startKey; // 지금까지 발견한 노드 중 목표에 가장 가까운 노드의 키
        int bestApproachHeuristic = hScore[startKey]; // 해당 노드에서 목표까지의 추정 거리
        int bestApproachG = 0; // 해당 노드까지 이동한 비용
        // 부분 경로 이동만들기 위해 필요

        int searched = 0; // 현재까지 탐색한 노드의 수

        while (openList.Count > 0 && searched < settings.maxSearchNodes)
        {
            // 탐색할 후보가 남아있고, 최대 탐색 노드 수를 넘지 않은 경우 반복
            searched++; // 탐색 횟수 증가

            Vector2Int currentKey = GetLowestFCostKey(openList, gScore, hScore);
            // 오픈리스트에서 F(최단 경로 비용)이 가장 낮은 노드 선택
            // F = G(현재 비용) + H(추정 비용)
            openList.Remove(currentKey);
            openSet.Remove(currentKey);
            // 선택한 노드는 이제 탐색할 것이므로 open에서 제거

            if (currentKey == goalKey)
                // 목표 도달 검사
            {
                return new MonsterPathResult
                {
                    type = MonsterPathResultType.Exact,
                    path = ReconstructPath(currentKey, cameFrom, cellCache),
                    totalCost = gScore[currentKey],
                    bestCell = cellCache[currentKey]
                };
                // 정확한 경로 결과 반환
            }

            closedSet.Add(currentKey); // 현재 노드 탐색 완료 처리, 같은 노드 나오면 건너뜀

            if (!cellCache.TryGetValue(currentKey, out VoxelWorld.SurfaceCell currentCell))
            {
                // 현재 노드의 표면 셀 정보가 캐시에 있는지 확인
                if (!world.TryGetSurfaceCell(currentKey.x, currentKey.y, out currentCell))
                    continue;
                // 없다면 월드에서 다시 표면 셀 찾기
                // 찾지 못하면 더 처리할 수 없기에 넘어감
                cellCache[currentKey] = currentCell;
                // 찾은 셀을 캐시에 저장
            }

            for (int i = 0; i < NeighborDirs.Length; i++)
            {
                // 이웃 노드 상하좌우 탐색
                Vector2Int dir = NeighborDirs[i];

                if (!world.TryGetNeighborSurfaceCell(
                        currentCell,
                        dir.x,
                        dir.y,
                        settings.maxStepUp,
                        settings.maxDropDown,
                        out VoxelWorld.SurfaceCell nextCell))
                {
                    continue;
                }
                // 현재 셀에서 해당 방향으로 이동 가능한 이웃 표면 셀 찾기
                // 이웃 셀이 너무 높으면 이동 불가, 너무 낮아도 이동 불가능

                Vector2Int nextKey = nextCell.Key;
                // 다음 셀의 키 가져오기

                if (closedSet.Contains(nextKey))
                    continue;
                // 이미 탐색 완료한 노드라면 건너뜀

                cellCache[nextKey] = nextCell;
                // 다음 셀 정보를 캐시에 저장

                int tentativeG = gScore[currentKey] + GetMoveCost(currentCell, nextCell, settings);
                // 현재 노드를 거쳐 다음 노드로 이동했을 경우 새로운 G(현재까지의 비용) 계산

                if (!gScore.TryGetValue(nextKey, out int oldG) || tentativeG < oldG)
                {
                    // 다음 노드에 대해 아직 비용 기록이 없거나, 이번에 찾은 경로가 기존보다 더 낮다면 갱신
                    cameFrom[nextKey] = currentKey;
                    // 다음 노드는 현재 노드에서 왔음을 기록
                    // 나중 경로 복원 시 사용
                    gScore[nextKey] = tentativeG;
                    // 다음 노드까지의 실제 비용 갱신
                    
                    int nextH = Heuristic(nextKey, goalKey, settings.straightCost);
                    hScore[nextKey] = nextH;
                    // 다음 노드에서 목표까지의 예상 비용을 계산하고 저장

                    if (nextH < bestApproachHeuristic ||
                        (nextH == bestApproachHeuristic && tentativeG < bestApproachG))
                        // 목표에 더 가까운 노드를 찾았는지 확인

                    {
                        bestApproachKey = nextKey;
                        bestApproachHeuristic = nextH;
                        bestApproachG = tentativeG;
                        // 가장 가까이 접근한 노드 정보 갱신
                    }

                    if (!openSet.Contains(nextKey))
                    {
                        openList.Add(nextKey);
                        openSet.Add(nextKey);
                        // 다음 노드가 open set에 없다면 탐색 후보에 추가
                    }
                }
            }
        }

        // while문이 끝나면, 더 이상 탐색할 노드가 없거나, 최대 탐색 수 제한에 걸린 경우이기에
        // 목표까지의 정확한 경로를 찾지 못한 상태임

        if (!cellCache.TryGetValue(bestApproachKey, out VoxelWorld.SurfaceCell bestCell))
            return MonsterPathResult.FailedResult();
        // 가장 가까이 접근한 셀 정보 찾기

        List<VoxelWorld.SurfaceCell> partialPath = ReconstructPath(bestApproachKey, cameFrom, cellCache);
        // 가장 가까운 접근 셀까지의 경로 복원

        if (partialPath.Count == 0)
            return MonsterPathResult.FailedResult();
        // 복원된 경로가 없다면 실패 처리

        return new MonsterPathResult
        {
            type = MonsterPathResultType.PartialBest,
            path = partialPath,
            totalCost = gScore.TryGetValue(bestApproachKey, out int g) ? g : 0,
            bestCell = bestCell
        };
        // 부분 경로 결과를 반환
    }

    private static int GetMoveCost(
        VoxelWorld.SurfaceCell from,
        VoxelWorld.SurfaceCell to,
        MonsterPathSettings settings)
        // 현재 셀에서 다음 셀로 이동할 때의 비용을 계산
    {
        int cost = settings.straightCost; // 기본 이동 비용 시작

        int heightDelta = to.surfaceY - from.surfaceY;
        // 다음 셀이 현재 셀보다 얼마나 높이 있는지 또는 낮은지를 계산

        if (heightDelta > 0)
            cost += settings.jumpPenalty * heightDelta;
        // 다음 셀이 더 높다면 점프 비용 추가

        if (to.IsWater)
            cost *= settings.waterCostMultiplier;
        // 해당 경로에 물이 있다면 비용 곱으로 증가

        return cost; // 계산된 비용 반환
    }

    private static int Heuristic(Vector2Int a, Vector2Int b, int straightCost)
    {
        // A* 휴리스틱 비용 계산

        int dx = Mathf.Abs(a.x - b.x);
        int dz = Mathf.Abs(a.y - b.y);
        // x,z 방향 거리 구하기

        return (dx + dz) * straightCost;
        // 맨해튼 거리 기반 비용 반환
    }

    private static Vector2Int GetLowestFCostKey(
        List<Vector2Int> openList,
        Dictionary<Vector2Int, int> gScore,
        Dictionary<Vector2Int, int> hScore)
        // 오픈 리스트 안에서 최단 비용 노드 찾기
    {
        Vector2Int best = openList[0];
        int bestF = GetF(best, gScore, hScore);
        // 첫 번째 후보를 일단 가장 좋은 노드로 설정

        for (int i = 1; i < openList.Count; i++)
        {
            // 두 번째 후보부터 마지막 후보까지 순회

            Vector2Int key = openList[i];
            int f = GetF(key, gScore, hScore);
            // 현재 후보의 최단거리비용 계산

            if (f < bestF)
            {
                best = key;
                bestF = f;
            }
            // 현재 후보의 최단거리비용이 더 낮으면 가장 좋은 경로의 노드 키를 갱신
        }

        return best; // 가장 좋은 경로의 노드 키 반환
    }

    private static int GetF(
        Vector2Int key,
        Dictionary<Vector2Int, int> gScore,
        Dictionary<Vector2Int, int> hScore)
        // 최단 거리 비용 F 계산
    {
        int g = gScore.TryGetValue(key, out int gv) ? gv : int.MaxValue / 4;
        // gScore에 값이 있으면 그 값 사용,

        int h = hScore.TryGetValue(key, out int hv) ? hv : 0;
        // hScore에 값이 있으면 사용하고 ,없으면 0 사용

        return g + h; // F비용 계산하여 반환
    }

    private static List<VoxelWorld.SurfaceCell> ReconstructPath(
        Vector2Int currentKey,
        Dictionary<Vector2Int, Vector2Int> cameFrom,
        Dictionary<Vector2Int, VoxelWorld.SurfaceCell> cellCache)
        // A* 탐색이 끝난 뒤 실제 경로 리스트 복원
        // 탐색 중에 전체 경로를 매번 저장하지 않고 어느 노드에서 왔는지 cameFrom에 기록
        // 부모 기록을 거꾸로 따라가 최종 경로로 만듬
    {
        List<VoxelWorld.SurfaceCell> result = new List<VoxelWorld.SurfaceCell>();
        // 결과 경로 리스트 만들어줌

        if (!cellCache.TryGetValue(currentKey, out VoxelWorld.SurfaceCell cell))
            return result;
        // 현재 키에 해당하는 셀 정보 찾기, 없으면 빈 경로 반환

        result.Add(cell); // 현재 셀 결과에 추가, 목표 -> 시작점으로 추가됌

        while (cameFrom.TryGetValue(currentKey, out Vector2Int parent))
        {
            // 현재 노드의 부모가 있다면 계속 따라감
            currentKey = parent;
            // 현재 키를 부모 키로 바꿔줌

            if (!cellCache.TryGetValue(currentKey, out VoxelWorld.SurfaceCell parentCell))
                break;
            // 부모 셀 정보를 찾지 못하면 종료

            result.Add(parentCell); // 부모 셀을 결과에 추가
        }

        result.Reverse(); // 타고올라간 경로를 Reverse로 뒤집어 올바른 경로로 바꿔줌
        return result; // 바꿔준 경로를 반환
    }
}

 

MonsterPathfinder 복셀 월드 구조에 맞춰서 구현한 A* 길찾기를 만들었다

몬스터가 설 수 있는 표면을 기준으로 경로를 탐색하여 상하좌우 올라갈수있는지 내려갈 수 있는 높이를 제한하고

평지 이동 비용과 점프 비용 물 지형 비용을 추가해서 계산했다

 

목표까지 도달이 가능한지에 따라 Exact, PartialBest, Fail 세가지의 상태를 만들었고

해당 상태에 따라 roam 블록파괴, 공격 등 여러 행동을 할 수 있게 하였다

728x90

'Unity' 카테고리의 다른 글

Unity(11) - Stage Select & Lobby  (0) 2026.05.31
Unity(10) - Take Damage Event & UI  (0) 2026.04.28
Unity(8) - Map Error Resolution  (0) 2026.04.13
Unity(7) - 3x3 Crafting Table  (0) 2026.03.13
Unity(6) - Atlas Block Map & Crafting Table (2)  (0) 2026.03.10