🧠 오늘의 핵심 정리

  • 아이템 상호작용(IInteractable) + 줍기(ItemObject) 구현
  • Interaction: 화면 중앙 Raycast로 상호작용 대상 탐지 + 안내 텍스트 출력
  • UIInventory: 스택/사용/드롭/장착 시스템 구현 + 인벤 열면 TimeScale 정지
  • EquipTool: 공격 시 스태미나 소모 + 화면 중앙 Ray로 타격 판정
  • 적 NPC: NavMesh 기반 AI + 피격/드롭 + 스포너 리스폰
  • 점프 패드 구현(점프 부스트) + 맵 이탈 방지 수정

 

🎒  아이템 상호작용 & 줍기 (IInteractable / ItemObject)

아이템을 “바라보고 E로 줍기” 흐름을 만들기 위해, 상호작용 공통 규격을 인터페이스로 먼저 정의했다.
아이템 오브젝트는 GetInteractPrompt()로 안내 문구를 주고, OnInteract()에서 플레이어에게 아이템 데이터를 넘기고 삭제하도록 구성했다.

public interface IInteractable
{
    public string GetInteractPrompt();
    public void OnInteract();
}

public class ItemObject : MonoBehaviour, IInteractable
{
    public ItemData data;

    public string GetInteractPrompt()
    {
        string str = $"{data.displayName}\n{data.description}";
        return str;
    }

    public void OnInteract()
    {
        CharacterManager.Instance.Player.itemData = data;
        CharacterManager.Instance.Player.addItem?.Invoke();
        Destroy(gameObject);
    }
}

여기서 포인트는 addItem 이벤트를 쓴 것!
플레이어는 “아이템 데이터를 들고 있음”만 책임지고, 인벤토리가 그 이벤트를 받아 실제 추가 처리를 하게 만들어서 흐름이 깔끔해졌다.

 

🖥️  Interaction: 화면 중앙 Raycast + 안내 텍스트 출력

2D 때 RaycastHit2D로 상호작용을 했던 것처럼, 3D에서는 카메라 기준 Raycast로 변경했다.
화면 중앙(=조준점) 기준으로 Ray를 쏴서 대상이 있으면 promptText를 켜고, 사라지면 끄는 방식이다.

Ray ray = _camera.ScreenPointToRay(new Vector3(Screen.width / 2, Screen.height / 2, 0));
RaycastHit hit;

if (Physics.Raycast(ray, out hit, maxCheckDistance, layerMask))
{
    if (hit.collider.gameObject != curInteractGameObject)
    {
        curInteractGameObject = hit.collider.gameObject;
        curInteractable = hit.collider.GetComponent();
        SetPromptText();
    }
}
else
{
    curInteractGameObject = null;
    curInteractable = null;
    promptText.gameObject.SetActive(false);
}

입력 처리도 간단하게 “대상이 있으면 실행”으로 했고, 실행 후에는 상태를 리셋해서 UI가 깔끔하게 사라지도록 했다.

public void OnInteractInput(InputAction.CallbackContext context)
{
    if (context.started && curInteractable != null)
    {
        curInteractable.OnInteract();
        curInteractGameObject = null;
        curInteractable = null;
        promptText.gameObject.SetActive(false);
    }
}

 

⏱️  UIInventory: 스택/사용/드롭/장착 + 인벤 열면 시간 정지

인벤토리는 오늘 작업의 핵심이었다.
슬롯에 아이템이 들어가면 아이콘/개수 표시를 하고, 선택한 아이템의 타입에 따라 버튼이 다르게 노출되게 만들었다.

스택 처리는 “같은 아이템이고 maxStackAmount 미만인 슬롯 먼저 채우기”로 구성했다.

ItemSlot GetItemStack(ItemData data)
{
    for(int i = 0; i < slots.Length; i++)
    {
        if (slots[i].item == data && slots[i].quantity < data.maxStackAmount)
        {
            return slots[i];
        }
    }
    return null;
}

그리고 인벤 열 때 “게임 멈춤”을 확실히 해주려고 Toggle에서 Time.timeScale을 같이 제어했다.

public void Toggle()
{
    if (IsOpen())
    {
        Time.timeScale = 1f;
        inventoryWindow.SetActive(false);
    }
    else
    {
        Time.timeScale = 0f;
        inventoryWindow.SetActive(true);
    }
}

 

🛡️  장비/공격: 스태미나 소모 + 중앙 Ray 판정 (IDamageable 통합)

무기 사용에 “자원”이 들어가야 생존게임 느낌이 난다… 해서 공격 시 스태미나가 빠지게 만들었다.
PlayerCondition에 UseStamina()를 만들어서, 스태미나가 부족하면 공격 자체를 못 하게 막았다.

public bool UseStamina(float amount)
{
    if(stamina.curValue - amount < 0f)
    {
        return false;
    }
    stamina.Subtract(amount);
    return true;
}

EquipTool에서는 공격 입력 시 먼저 스태미나 체크를 하고, 가능할 때만 공격 애니메이션과 트리거가 실행되도록 했다.

public override void OnAttackInput()
{
    if (!attacking)
    {
        if (CharacterManager.Instance.Player.condition.UseStamina(useStamina))
        {
            attacking = true;
            _animation.Play("Attack");
            Invoke(nameof(StopAttackAnimation), ANIMATION_DURATION);
            _animator.SetTrigger("Attack");
            Invoke("OnCanAttack", attackRate);
        }
    }
}

타격 판정은 카메라 중앙 Raycast로 처리했고, 기존에는 Resource 전용이었는데 오늘은 IDamageable로 바꿔서 적/자원 모두 통합 처리할 수 있게 했다.

public void OnHit()
{
    Ray ray = _camera.ScreenPointToRay(new Vector3(Screen.width/2, Screen.height/2, 0));
    RaycastHit hit;

    if(Physics.Raycast(ray, out hit, attackDistance))
    {
        if(doesDealDamage && hit.collider.TryGetComponent(out IDamageable damageable))
        {
            damageable.TakePhysicalDamage(damage);
        }
    }
}

 

👾  적 NPC + 스포너(리스폰)까지

적도 오늘 제대로 붙였다.
NavMesh로 배회/추적/공격 상태를 돌리고, 피격 시 색이 잠깐 변하는 플래시 연출도 넣었다.
그리고 “죽으면 끝”이 아니라, 스포너가 다시 뽑아주는 구조로 연결했다.

NPC가 죽을 때 스포너에게 “죽었음”을 알리고, 스포너가 일정 시간 후 리스폰:

// NPC.cs (Die)
if(spawn != null)
{
    spawn.OnNPCDied();
}
Destroy(gameObject);
// NpCSpawn.cs
public void SpawnNPC()
{
    currentNPC = Instantiate(npcPrefab, transform.position, transform.rotation);
    currentNPC.GetComponent<NPC>().spawn = this;

    Animator anim = currentNPC.GetComponentInChildren<Animator>();
    if(anim != null)
    {
        anim.SetTrigger("Spawn");
    }
}

public void OnNPCDied()
{
    StartCoroutine(RespawnNPC());
}

IEnumerator RespawnNPC()
{
    yield return new WaitForSeconds(respawnDelay);
    SpawnNPC();
}

이렇게 해두니까 필드가 너무 비지 않고, “생존” 느낌이 확 살아났다.

 

🪂  점프 패드 구현

JumpPad 태그 구역에 들어가면 onJumpad 플래그를 켜고, 점프 시 jumpBoost를 더해 슈퍼 점프가 되도록 구현했다.

private void OnTriggerEnter(Collider other)
{
    if (other.CompareTag("JumpPad"))
    {
        onJumpad = true;
    }
}
private void OnTriggerExit(Collider other)
{
    if (other.CompareTag("JumpPad"))
    {
        onJumpad = false;
    }
}

public void OnJump(InputAction.CallbackContext context)
{
    if (context.started && IsGround())
    {
        float finalJumpPower = jumpPower;
        if (onJumpad)
        {
            Debug.Log("슈퍼점프");
            finalJumpPower += jumpBoost;
        }
        _rigidbody.AddForce(Vector2.up * finalJumpPower, ForceMode.Impulse);
        _animation.Play("Jump");
    }
}

점프 패드는 재밌는데… 항상 맵 이탈 문제가 따라와서 그 부분은 추가로 수정했다.

 

📌 오늘의 회고

오늘은 기능이 한 번에 너무 많이 붙어서 정신이 없었지만, 그래도 “게임의 뼈대”가 확실히 세워진 날이었다.
인벤토리/장비/스태미나/적/드롭/리스폰까지 있으니까 진짜 생존게임 같은 느낌이 난다.
이제 남은 건… 제출 가능한 상태로 버그 줄이기 + 안정화… 제발!!