Unity DeadBall 3주 프로젝트(2) #007

주환서·2026년 4월 5일
post-thumbnail

1. 각 구역별 퍼즐 구현

1.1. 1구역

  • 튜토리얼 - 서재들이 있고 3개의 서재에는 빛이 나는 책들이 꽂혀 있음. 책들을 주워서 다음으로 가는 문 바로 옆에 있는 서재에 투명한 소캣에 차례로 꽂으면 클리어 보상이 나오고 보상을 획득하면 문이 열리는 연출과 함께 클리어.
  • CineMachine의 우선순위를 조절해서 카메라 이동 후 연출 되도록 하였고, 코루틴을 사용해서 시간 별로 기능을 나눠서 연출하였습니다.
  • 책과 소캣에는 각각 IInteractable을 상속해서 구현했습니다.
using UnityEngine;
using System.Collections;
using Unity.Cinemachine;

public class Area1_Library : MonoBehaviour
{
    [Header("퍼즐 상태")]
    public int collectedBooks = 0; // 수집한 책 수량
    public int placedBooks = 0;    // 설치한 책 수량

    [Header("보상")]
    public GameObject baseball;    // 클리어 보상 야구공 오브젝트

    [Header("연출 설정")]
    public DoorController nextRoomDoor;
    public PlayerController playerController; // 조작 제어용
    public CinemachineCamera doorCam; // 문 비출 카메라 (Door1_Cam)

    // 책 수집 시 호출되는 함수
    public void CollectBook()
    {
        collectedBooks++;
        Debug.Log("책 수집 현재 수량: " + collectedBooks);
    }

    // 책 설치 시도 함수
    public bool TryPlaceBook()
    {
        // 소지 중인 책 확인
            if (collectedBooks > 0)
            {
                collectedBooks--; // 소지 수량 감소
                placedBooks++;    // 설치 수량 증가
                Debug.Log("책 설치 현재 설치량: " + placedBooks);

                // 목표 수량 도달 확인
                if (placedBooks >= 3)
                {
                    ClearPuzzle();
                }
                return true; // 설치 성공 반환
            }
             Debug.Log("소지 중인 책 없음");
             return false; // 설치 실패 반환
    }

    // 퍼즐 완료 처리 로직
    void ClearPuzzle()
    {
        Debug.Log("1구역 퍼즐 완료 보상 생성");

        if (baseball != null) baseball.SetActive(true);
    }

    public void StartDoorCutscene()
    {
        StartCoroutine(DoorOpenCutscene());
    }

    IEnumerator DoorOpenCutscene()
    {
        if (playerController != null) playerController.StopMovementForCutscene();
        if (doorCam != null) doorCam.Priority = 20;

        // 카메라가 문으로 이동할 시간
        yield return new WaitForSeconds(2.0f);

        // 문 개방
        if (nextRoomDoor != null) nextRoomDoor.OpenDoor();

        // 열리는거 잠시 대기
        yield return new WaitForSeconds(2.5f);

        // 시점 복구
        if (doorCam != null) doorCam.Priority = 0;
        yield return new WaitForSeconds(2.0f);

        if (playerController != null) playerController.enabled = true;
    }
}
using UnityEngine;

public class Book : MonoBehaviour, IInteractable
{
    public Area1_Library manager; // 도서관 매니저 참조

    [Header("사운드 설정")]
    public AudioClip takSound; // 책 주울 때 탁 소리

    private Material bookMaterial;
    private Color defaultEmission = new Color(0f, 0.3f, 0.8f) * 1f; // 기본 푸른빛 에미션
    private Color highlightEmission = new Color(1f, 0.9f, 0.2f) * 3f; // 주시 시 노란빛 에미션

    void Start()
    {
        // 렌더러 컴포넌트 추출 및 마테리얼 에미션 활성화
        Renderer rend = GetComponentInChildren<Renderer>();
        if (rend != null)
        {
            bookMaterial = rend.material;
            bookMaterial.EnableKeyword("_EMISSION");

            // 초기 상태 에미션 색상 설정
            bookMaterial.SetColor("_EmissionColor", defaultEmission);
        }
    }

    // 시선이 닿았을 때 호출
    public void OnFocus()
    {
        if (bookMaterial != null)
        {
            bookMaterial.SetColor("_EmissionColor", highlightEmission);
        }
    }

    // 시선이 벗어났을 때 호출
    public void OnLoseFocus()
    {
        if (bookMaterial != null)
        {
            bookMaterial.SetColor("_EmissionColor", defaultEmission);
        }
    }

    // 클릭 상호작용 시 호출
    public void OnInteract()
    {
        if (manager != null)
        {
            manager.CollectBook();
        }

        // 주울 때 탁 소리
        if (takSound != null)
        {
            AudioSource.PlayClipAtPoint(takSound, transform.position);
        }

        // 피드백 초기화 및 오브젝트 비활성화
        OnLoseFocus();
        gameObject.SetActive(false);
    }
}
using UnityEngine;

public class BookSocket : MonoBehaviour, IInteractable
{
    public Area1_Library manager; // 도서관 매니저 참조
    public GameObject visualBook; // 설치 시 활성화할 책 모델
    private bool isFilled = false; // 책 설치 여부 확인

    [Header("사운드 설정")]
    public AudioClip takSound; // 꽂을 때 탁 소리

    private Material socketMaterial;
    private Color defaultColor = Color.black; // 기본 에미션 색상
    private Color focusColor = Color.yellow * 1.5f; // 주시 시 에미션 색상

    void Start()
    {
        // 자식 오브젝트의 렌더러 참조 추출
        Renderer childRenderer = GetComponentInChildren<Renderer>();

        if (childRenderer != null)
        {
            socketMaterial = childRenderer.material;
            socketMaterial.EnableKeyword("_EMISSION");
            socketMaterial.SetColor("_EmissionColor", defaultColor);
        }
        else
        {
            Debug.LogWarning("소켓 자식 오브젝트 내 렌더러 부재");
        }
    }

    public void OnInteract()
    {
        // 이미 설치된 상태거나 매니저 참조 부재 시 종료
        if (isFilled || manager == null) return;

        // 매니저를 통한 설치 로직 실행 및 결과 확인
        bool success = manager.TryPlaceBook();

        if (success)
        {
            isFilled = true;

            // 꽂을 때 탁 소리
            if (takSound != null)
            {
                AudioSource.PlayClipAtPoint(takSound, transform.position);
            }

            // 시각적 모델 활성화
            if (visualBook != null)
            {
                visualBook.SetActive(true);
            }

            // 설치 완료 후 피드백 초기화
            OnLoseFocus();
        }
    }

    public void OnFocus()
    {
        // 미설치 상태에서 주시할 경우 색상 변경
        if (!isFilled && socketMaterial != null)
        {
            socketMaterial.SetColor("_EmissionColor", focusColor);
        }
    }

    public void OnLoseFocus()
    {
        // 에미션 색상 초기화
        if (socketMaterial != null)
        {
            socketMaterial.SetColor("_EmissionColor", defaultColor);
        }
    }
}

1.2. 2구역

  • 벽에 스트라이크 존처럼 생긴 사각형을 만들고 동그란 빛을 세웁니다. 빛이 시작된 포지션에서 정답 위치에 이동하면 클리어 보상이 나오고 보상을 획득하면 문이 열리는 연출과 함께 클리어.
  • 4개의 레버가 있고 IInteractable 상속, 각 레버마다 ID를 할당. 각 레버마다 이동 방향을 설정해놓고 레버와 상호작용 할 때마다 빛의 포지션 업데이트.
  • 스트라이크 존에 Collider를 설치하고 빛이 콜라이더 밖으로 나가지 못하도록 예외 처리.
  • Lerp를 이용해서 빛이 스윽- 자연스럽게 이동되도록 구현
using UnityEngine;
using System.Collections;
using Unity.Cinemachine;

public class Area2_MachineRoom : MonoBehaviour
{
    [Header("조작 대상")]
    public Transform indicatorLight; // 위치 표시용 라이트
    public GameObject rustyBat; // 퍼즐 보상 아이템

    [Header("기준점 및 정답 위치")]
    public Transform startPos; // 라이트 시작 위치
    public Transform centerPos; // 퍼즐 정답 위치

    [Header("제한 구역")]
    public Collider strikeZoneCollider; // 라이트 이동 제한 영역

    [Header("레버별 이동 방향 및 거리")]
    public Vector3 lever1_Offset = new Vector3(0, 0.5f, 0); // 북쪽 이동
    public Vector3 lever2_Offset = new Vector3(0, -0.5f, 0); // 남쪽 이동
    public Vector3 lever3_Offset = new Vector3(0.5f, 0, 0); // 동쪽 이동
    public Vector3 lever4_Offset = new Vector3(-0.5f, 0, 0); // 서쪽 이동

    [Header("연출 설정")]
    public DoorController nextRoomDoor; // 2구역 -> 3구역 문
    public PlayerController playerController;
    public CinemachineCamera doorCam; // Door2_Cam (2구역 문 비추는 카메라)

    private Vector3 currentTargetPos;
    private bool isCleared = false;

    void Start()
    {
        // 라이트 초기 위치 설정
        if (startPos != null)
        {
            currentTargetPos = startPos.position;
            indicatorLight.position = currentTargetPos;
        }
    }

    // 각 레버 클릭 시 호출되어 이동 방향 결정
    public void MoveLightDirection(int leverID)
    {
        if (isCleared) return;

        Vector3 step = Vector3.zero;

        // 레버 ID에 따른 이동 보정치 할당
        switch (leverID)
        {
            case 1: step = lever1_Offset; break;
            case 2: step = lever2_Offset; break;
            case 3: step = lever3_Offset; break;
            case 4: step = lever4_Offset; break;
        }

        currentTargetPos += step;

        // 콜라이더 영역 기반 이동 범위 제한
        if (strikeZoneCollider != null)
        {
            Bounds bounds = strikeZoneCollider.bounds;
            currentTargetPos.x = Mathf.Clamp(currentTargetPos.x, bounds.min.x, bounds.max.x);
            currentTargetPos.y = Mathf.Clamp(currentTargetPos.y, bounds.min.y, bounds.max.y);
        }
    }

    void Update()
    {
        if (isCleared || indicatorLight == null || centerPos == null) return;

        // 목표 위치로 부드러운 이동 처리
        indicatorLight.position = Vector3.Lerp(indicatorLight.position, currentTargetPos, Time.deltaTime * 5f);

        // 정답 위치 도달 여부 판정
        float distanceToCenter = Vector3.Distance(indicatorLight.position, centerPos.position);

        if (distanceToCenter < 0.1f)
        {
            ClearPuzzle();
        }
    }

    // 퍼즐 완료 로직 실행
    void ClearPuzzle()
    {
        if (isCleared) return;
        isCleared = true;
        Debug.Log("2구역 퍼즐 클리어");

        // 보상 활성화 및 다음 문 개방
        if (rustyBat != null)
        {
            rustyBat.SetActive(true);
        }
    }

    public void StartBatCutscene()
    {
        StartCoroutine(BatOpenCutscene());
    }

    IEnumerator BatOpenCutscene()
    {
        // 조작 잠금 및 카메라 전환
        if (playerController != null) playerController.StopMovementForCutscene();
        if (doorCam != null) doorCam.Priority = 20;

        yield return new WaitForSeconds(2.0f);

        // 문 개방
        if (nextRoomDoor != null) nextRoomDoor.OpenDoor();

        yield return new WaitForSeconds(2.5f);

        // 카메라 복귀 및 조작 해제
        if (doorCam != null) doorCam.Priority = 0;
        yield return new WaitForSeconds(2.0f);

        if (playerController != null) playerController.enabled = true;
    }
}
using UnityEngine;

public class Lever : MonoBehaviour, IInteractable
{
    public Area2_MachineRoom manager; // 기계실 매니저 참조
    public int leverID; // 레버 식별 번호 (1:북, 2:남, 3:동, 4:서)

    [Header("사운드 설정")]
    public AudioClip leverSound; // 레버 철컥 소리

    [Header("시각적 피드백")]
    private Material leverMat;
    private Color defaultColor = Color.blue * 2.0f; // 기본 파란색 에미션
    private Color focusColor = Color.yellow * 2.5f; // 주시 시 노란색 에미션

    void Start()
    {
        // 자식 오브젝트의 렌더러 컴포넌트 추출
        Renderer rend = GetComponentInChildren<Renderer>();

        if (rend != null)
        {
            leverMat = rend.material;
            leverMat.EnableKeyword("_EMISSION");

            // 초기 에미션 색상 설정
            leverMat.SetColor("_EmissionColor", defaultColor);
        }
    }

    public void OnInteract()
    {
        // 매니저 참조 확인 후 이동 명령 전달
        if (manager != null)
        {
            manager.MoveLightDirection(leverID);
        }

        // 레버 소리 재생
        if (leverSound != null) AudioSource.PlayClipAtPoint(leverSound, transform.position);
    }

    public void OnFocus()
    {
        // 주시 시 노란색 피드백 적용
        if (leverMat != null)
        {
            leverMat.SetColor("_EmissionColor", focusColor);
        }
    }

    public void OnLoseFocus()
    {
        // 주시 해제 시 원래 색상 복구
        if (leverMat != null)
        {
            leverMat.SetColor("_EmissionColor", defaultColor);
        }
    }
}

1.3. 3구역

  • 3구역 입구에 Collider로 보이지 않는 벽을 설치. Is Trigger를 체크하고 통과 가능하도록 설정하고 OnTrigger 함수를 이용해서 벽에 닿으면 조력자가 플레이어 앞으로 와서 가방을 발광 시키는 연출 재생.
  • 3구역에 배치된 베이스 4개를 야구공으로 맞춰서 전부 부수면 클리어.
  • 3구역 클리어 시 모든 야구공은 3구역에서 회수하고 투척 기능 봉인, 가방 잠금
  • OnCollision 함수를 이용해서 공과 플레이트가 충돌하면 터지는 사운드와 함께 분열 이펙트 발동 되도록 구현
  • AddForce로 공 던지는 기능 구현. 공을 쥐고 클릭 시 공을 던지는 애니메이션을 주면서 공을 던지는 시점에 카메라 중앙에 숨겨놓은 야구공이 휙 사운드와 함께 발사. 공을 쥐고 있지 않으면 쥐고 있는 야구공을 비활성화 시켜 던지지 못하도록 설정.
using System.Collections;
using UnityEngine;
using UnityEngine.AI;
using Unity.Cinemachine;

public class Area3_Manager : MonoBehaviour
{
    [Header("진행 상태")]
    public int totalTargets = 4; // 목표 파괴 개수
    private int destroyedTargets = 0; // 파괴된 타겟 수량

    [Header("4구역 진입문")]
    public DoorController nextRoomDoor; // 다음 구역 연결 문

    [Header("플레이어 제어")]
    public MonoBehaviour playerController; // 플레이어 조작 스크립트 참조

    [Header("연출 설정")]
    public CinemachineCamera doorCam;

    private HelperAI helperAI; // 조력자 AI 참조

    void Start()
    {
        helperAI = FindAnyObjectByType<HelperAI>();

        // 조작 스크립트 자동 할당 시도
        if (playerController == null)
        {
            GameObject player = GameObject.FindGameObjectWithTag("Player");
            if (player != null) playerController = player.GetComponent<PlayerController>();
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        // 플레이어 진입 시 컷씬 실행 및 트리거 비활성화
        if (other.CompareTag("Player"))
        {
            StartCoroutine(Area3EntranceCutscene(other.transform));
            Collider col = GetComponent<Collider>();
            if (col != null) col.enabled = false;
        }
    }

    // 3구역 진입 연출 코루틴
    IEnumerator Area3EntranceCutscene(Transform playerTransform)
    {
        Debug.Log("3구역 컷씬 시작");

        if (playerController != null)
        {
            // MonoBehaviour를 PlayerController로 변환해서 브레이크를 밟음
            PlayerController pc = playerController.GetComponent<PlayerController>();
            if (pc != null) pc.StopMovementForCutscene();
        }
        // 플레이어 조작 및 충돌 일시 정지
        Collider playerCollider = playerTransform.GetComponent<Collider>();
        if (playerCollider != null) playerCollider.enabled = false;

        // 조력자 이동 설정
        if (helperAI != null)
        {
            helperAI.isCutsceneMode = true;
            Vector3 targetPos = playerTransform.position + playerTransform.forward * 2f;
            NavMeshAgent helperAgent = helperAI.GetComponent<NavMeshAgent>();

            if (helperAgent != null)
            {
                helperAgent.SetDestination(targetPos);
                float timeout = 0f;

                // 목표 지점 도착 대기
                while ((helperAgent.pathPending || helperAgent.remainingDistance > 0.5f) && timeout < 3f)
                {
                    timeout += Time.deltaTime;
                    yield return null;
                }
            }

            // 플레이어와 완벽하게 같은 방향을 보게 하여 등 내밈
            helperAI.transform.rotation = playerTransform.rotation;

            helperAI.OfferBox();
        }

        // 가방 아이템 설정
        HelperBox helperBox = FindAnyObjectByType<HelperBox>();
        if (helperBox != null)
        {
            helperBox.allowedItem = "Baseball";
            helperBox.isArea3UnlockBox = true;
            helperBox.ShowItemInBox("Baseball");
        }

        yield return new WaitForSeconds(1.5f);

        // 플레이어 조작 및 충돌 복구
        if (playerCollider != null) playerCollider.enabled = true;
        if (playerController != null) playerController.enabled = true;

        Debug.Log("컷씬 종료 조작 권한 복구");
    }

    // 타겟 파괴 시 호출되는 진행 관리 함수
    public void TargetDestroyed()
    {
        destroyedTargets++;

        // 모든 타겟 제거 확인
        if (destroyedTargets >= totalTargets)
        {
            Debug.Log("3구역 클리어 진행로 개방 및 아이템 회수");

            // 투척 기능 봉인 및 장착 해제
            PlayerThrow pt = FindAnyObjectByType<PlayerThrow>();
            if (pt != null) pt.SealBallForever();

            // 맵에 잔류하는 야구공 오브젝트 제거
            PickupItem[] droppedBalls = FindObjectsByType<PickupItem>(FindObjectsSortMode.None);
            foreach (PickupItem item in droppedBalls)
            {
                if (item.itemName == "Baseball") Destroy(item.gameObject);
            }

            // 가방 상태 리셋 및 잠금
            HelperBox box = FindAnyObjectByType<HelperBox>();
            if (box != null)
            {
                box.AddItem("Baseball");
                box.isLooted = false;
                box.isLocked = true;
                box.isArea3UnlockBox = false;
            }

            // 클리어 연출 코루틴 실행
            StartCoroutine(ClearCutscene());
        }
    }

    // 3구역 클리어 연출 코루틴
    IEnumerator ClearCutscene()
    {
        // 조작 잠금
        if (playerController != null)
        {
            PlayerController pc = playerController.GetComponent<PlayerController>();
            if (pc != null) pc.StopMovementForCutscene();
        }

        // 카메라 문으로 이동
        if (doorCam != null) doorCam.Priority = 20;

        yield return new WaitForSeconds(2.5f);

        // 4구역으로 가는 문 개방
        if (nextRoomDoor != null) nextRoomDoor.OpenDoor();

        yield return new WaitForSeconds(2.5f);

        // 카메라 원상 복구
        if (doorCam != null) doorCam.Priority = 0;

        yield return new WaitForSeconds(2.0f);

        // 조작 권한 복구
        if (playerController != null) playerController.enabled = true;
    }
}
using UnityEngine;

public class TargetPlate : MonoBehaviour
{
    public Area3_Manager manager; // 3구역 매니저 참조
    private bool isDestroyed = false; // 파괴 여부 확인
    private Renderer targetRenderer; // 렌더러 캐싱용

    [Header("사운드 설정")]
    public AudioClip breakSound; // 터지는 소리

    void Start()
    {
        // 렌더러 참조 저장
        targetRenderer = GetComponent<Renderer>();

        // 매니저 미할당 시 자동 검색
        if (manager == null)
        {
            manager = FindAnyObjectByType<Area3_Manager>();
        }
    }

    private void OnCollisionEnter(Collision collision)
    {
        if (isDestroyed) return;

        // 충돌 오브젝트의 아이템 정보 확인
        PickupItem item = collision.gameObject.GetComponent<PickupItem>();
        if (item != null && item.itemName == "Baseball")
        {
            BreakPlate();
        }
    }

    void BreakPlate()
    {
        isDestroyed = true;

        // 매니저에 파괴 신호 전달
        if (manager != null)
        {
            manager.TargetDestroyed();
        }

        // 파괴되는 사운드
        if (breakSound != null) AudioSource.PlayClipAtPoint(breakSound, transform.position);

        // 파편 효과 생성 및 원본 비활성화
        SpawnDebrisEffect();
        gameObject.SetActive(false);
    }

    void SpawnDebrisEffect()
    {
        if (targetRenderer == null) return;

        Material myMaterial = targetRenderer.material;

        // 파편 조각 생성 루프
        for (int i = 0; i < 15; i++)
        {
            // 기본 큐브 생성 및 설정
            GameObject debris = GameObject.CreatePrimitive(PrimitiveType.Cube);
            debris.GetComponent<Renderer>().material = myMaterial;

            // 위치 무작위 분산 및 크기 조절
            debris.transform.position = transform.position + Random.insideUnitSphere * 0.5f;
            debris.transform.localScale = Vector3.one * 0.2f;

            // 물리 컴포넌트 추가 및 폭발력 적용
            Rigidbody rb = debris.AddComponent<Rigidbody>();
            if (rb != null)
            {
                rb.AddExplosionForce(800f, transform.position + Vector3.down * 0.5f, 3f);
            }

            // 일정 시간 후 제거
            Destroy(debris, 3f);
        }
    }
}
using UnityEngine;

public class PlayerThrow : MonoBehaviour
{
    [Header("연결")]
    public Animator anim;
    public GameObject realBallPrefab;
    public Transform throwPoint;
    public GameObject heldBallModel;

    [Header("상태")]
    public bool isHoldingBall = false;  // 공 보유 여부
    public bool canThrowBall = false;   // 3구역 가방 클릭 전까지 투척 제한
    public bool isArea3Cleared = false; // 3구역 클리어 여부

    [Header("설정")]
    public float throwForce = 25f;
    private bool isThrowing = false;

    [Header("사운드 설정")]
    public AudioClip windSound; // 바람 소리

    void Start()
    {
        // 게임 시작 시 모든 권한 초기화
        isHoldingBall = false;
        canThrowBall = false;
        isArea3Cleared = false;
        if (heldBallModel != null) heldBallModel.SetActive(false);
    }

    void Update()
    {
        // 3구역 클리어 시 로직 중단
        if (isArea3Cleared) return;

        // 공 보유 및 가방 허락 상태에서 투척 가능
        if (Input.GetMouseButtonDown(0) && isHoldingBall && canThrowBall && !isThrowing)
        {
            isThrowing = true;
            anim.SetTrigger("Throw");
        }
    }

    // 아이템 획득 시 호출
    public void EquipBall()
    {
        isHoldingBall = true;
        //if (heldBallModel != null) heldBallModel.SetActive(true);
    }

    // 3구역 가방 클릭 시 투척 제한 해제
    public void UnlockThrow()
    {
        canThrowBall = true;
        Debug.Log("공 투척 제한 해제");
    }

    // 애니메이션 이벤트에서 투척 실행
    public void ShootBall()
    {
        // 투척 시 손에서 공 제거하여 무한 투척 방지
        isHoldingBall = false;
        if (heldBallModel != null) heldBallModel.SetActive(false);

        GameObject ball = Instantiate(realBallPrefab, throwPoint.position, throwPoint.rotation);
        Rigidbody rb = ball.GetComponent<Rigidbody>();
        if (rb != null)
        {
            rb.AddForce(Camera.main.transform.forward * throwForce, ForceMode.Impulse);
        }

        // 바람 소리 휙~
        if (windSound != null) AudioSource.PlayClipAtPoint(windSound, throwPoint.position);

        // 동작 종료 처리만 수행
        Invoke("FinishAnimation", 1.0f);
    }

    // 투척 동작 종료 및 상태 초기화
    void FinishAnimation()
    {
        isThrowing = false;
    }

    // 3구역 클리어 시 모든 투척 관련 기능 봉인
    public void SealBallForever()
    {
        isArea3Cleared = true;
        isHoldingBall = false;
        canThrowBall = false;
        if (heldBallModel != null) heldBallModel.SetActive(false);
    }
}

1.4. 4구역

  • 4구역 입장 시 3구역과 동일하게 투명벽에 닿으면 조력자가 플레이어 앞으로 와서 가방을 발광 시킴. 가방 클릭 시 적 스폰 연출 재생. 적 처치 시 상태 관리 함수를 호출 시키고 일반 몹들이 전부 죽으면 보스 스폰 연출 재생. 보스를 해치우고 보스가 드랍하는 클리어 보상 누를 시 다음 구역 문 열림 연출 재생 후 클리어.
  • 클리어 보상에 마찬가지로 IInteractable 상속 후에 발광 기능, 상호 작용 시 기능 구현
  • 적은 NevMesh를 이용해서 플레이어를 추격 공격 범위가 되면 잠시 네비를 끄고 멈춰서 공격 애니메이션 + 데미지 주는 로직 실행
  • 리스폰 위치를 빈 오브젝트로 생성하고 플레이어가 데미지를 받아서 죽으면 그 후의 데미지들은 무시하고 리스폰 장소에서 다시 생성.
  • 죽었을 때 코루틴으로 플레이어 조작을 정지. 부활 후에는 다시 조작 및 배트 조작이 가능하도록 설정.
  • 배트를 쥐면 화면 우측 하단에 배트를 쥐고 있는 것처럼 구현. 클릭하면 애니메이션과 함께 배트를 휘두르고 코루틴으로 만들어 놓은 배트의 각도를 기울여서 휘두르는 연출. 배트의 콜라이더가 적과 닿으면 공격 판정 ON
  • UI 없이 데미지가 들어갔다는 것을 표현하기 위해 배트로 적을 공격 시에 적 추적 네비를 정지하고 AddForce로 넉백 되는 코루틴을 설정. 배트의 콜라이더와 적이 닿으면 데미지 + 넉백 함수 실행
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using Unity.Cinemachine;

public class Area4_Manager : MonoBehaviour
{
    [Header("플레이어 제어")]
    public MonoBehaviour playerController;

    [Header("적군 리스트")]
    public List<EnemyAI> minions = new List<EnemyAI>();
    public EnemyAI boss;

    [Header("보상 및 클리어")]
    public GameObject dosimeterPrefab;
    public DoorController nextRoomDoor;

    [Header("연출 카메라")]
    public CinemachineCamera spawnCam; // 적 스폰 장소 비출 카메라
    public CinemachineCamera doorCam;  // 다음 구역 문 비출 카메라

    private int livingMinions;
    private HelperAI helperAI;
    private PlayerMelee playerMelee;

    void Start()
    {
        helperAI = FindAnyObjectByType<HelperAI>();
        livingMinions = minions.Count;

        // 플레이어 근접 공격 컴포넌트 참조 저장
        if (playerController != null)
        {
            playerMelee = playerController.GetComponent<PlayerMelee>();
        }

        // 적 유닛 초기 비활성화
        if (boss != null) boss.gameObject.SetActive(false);
        foreach (var m in minions)
        {
            if (m != null) m.gameObject.SetActive(false);
        }
    }

    public void StartBattle()
    {
        HelperBox box = FindAnyObjectByType<HelperBox>();
        if (box != null) box.isLocked = true;

        StartCoroutine(SpawnMinionsRoutine());
    }

    // 쫄몹 스폰 연출 코루틴
    IEnumerator SpawnMinionsRoutine()
    {
        Debug.Log("미니언 스폰 연출 시작");

        // 조작 잠금
        if (playerController != null)
        {
            PlayerController pc = playerController.GetComponent<PlayerController>();
            if (pc != null) pc.StopMovementForCutscene();
        }

        // 카메라가 적 스폰 장소로
        if (spawnCam != null) spawnCam.Priority = 20;
        yield return new WaitForSeconds(2.0f); // 카메라 이동 시간
        yield return new WaitForSeconds(0.5f); // 긴장감 정적

        foreach (var m in minions)
        {
            if (m != null)
            {
                m.gameObject.SetActive(true);
                m.isActive = false;
            }
        }

        // 등장하는 모습 1.5초 대기
        yield return new WaitForSeconds(1.5f);

        // 카메라 플레이어에게 복귀
        if (spawnCam != null) spawnCam.Priority = 0;
        yield return new WaitForSeconds(2.0f);

        // 카메라가 돌아왔으니 켜고 전투 시작
        foreach (var m in minions)
        {
            if (m != null) m.isActive = true;
        }

        // 전투 시작, 조작 권한 복구
        if (playerController != null) playerController.enabled = true;
    }

    // 적 처치 시 호출되는 상태 관리 함수
    public void OnEnemyDown(EnemyAI enemy)
    {
        if (minions.Contains(enemy))
        {
            livingMinions--;
            minions.Remove(enemy);
            Debug.Log("미니언 처치 남은 수량: " + livingMinions);

            if (livingMinions <= 0)
            {
                StartCoroutine(BossWakeUpRoutine());
            }
        }
        else if (enemy == boss)
        {
            Debug.Log("보스 처치 보상 생성");
            if (dosimeterPrefab != null)
            {
                Vector3 dropPos = boss.transform.position + Vector3.up * 1f;
                Instantiate(dosimeterPrefab, dropPos, Quaternion.identity);
            }
        }
    }

    // 보스 등장 연출 코루틴
    IEnumerator BossWakeUpRoutine()
    {
        Debug.Log("보스 등장 연출 시작");

        // 플레이어 조작 잠금 (전투 일시 정지)
        if (playerController != null)
        {
            PlayerController pc = playerController.GetComponent<PlayerController>();
            if (pc != null) pc.StopMovementForCutscene();
        }

        // 쫄몹 전멸 후 잠깐 멈춤
        yield return new WaitForSeconds(1.0f);

        // 카메라를 다시 스폰 장소(보스 등장 위치)로 이동
        if (spawnCam != null) spawnCam.Priority = 20;

        // 카메라 이동 시간 2초 + 등장 직전 정적 0.5초
        yield return new WaitForSeconds(2.0f);
        yield return new WaitForSeconds(0.5f);

        // 보스 스폰
        if (boss != null)
        {
            boss.gameObject.SetActive(true);
            boss.isActive = false;
        }

        yield return new WaitForSeconds(2.0f);

        // 카메라 플레이어에게 원상 복구
        if (spawnCam != null) spawnCam.Priority = 0;

        // 시점 돌아오는 시간 대기
        yield return new WaitForSeconds(2.0f);

        // 카메라 복귀 보스 AI 가동
        if (boss != null) boss.isActive = true;

        // 조작 권한 복구 및 보스전
        if (playerController != null) playerController.enabled = true;
    }

    // 방사능 측정기 획득 시 호출
    public void OnDosimeterPickedUp()
    {
        Debug.Log("측정기 획득 진행로 개방");

        // 가방 상태 영구 봉인
        HelperBox box = FindAnyObjectByType<HelperBox>();
        if (box != null)
        {
            box.allowedItem = "";
            box.isLocked = true;
            box.isLooted = true;
        }

        // 플레이어 무기 장착 해제
        if (playerMelee != null)
        {
            playerMelee.isHoldingBat = false;
            if (playerMelee.heldBatModel != null)
            {
                playerMelee.heldBatModel.SetActive(false);
            }
        }

        // 클리어 연출 코루틴 시작
        StartCoroutine(DosimeterClearCutscene());
    }

    // 방사능 측정기 획득 연출 코루틴
    IEnumerator DosimeterClearCutscene()
    {
        // 조작 잠금
        if (playerController != null)
        {
            PlayerController pc = playerController.GetComponent<PlayerController>();
            if (pc != null) pc.StopMovementForCutscene();
        }

        // 카메라 문으로 이동
        if (doorCam != null) doorCam.Priority = 20;
        yield return new WaitForSeconds(2.0f);
        yield return new WaitForSeconds(0.5f);

        // 문 개방
        if (nextRoomDoor != null) nextRoomDoor.OpenDoor();
        yield return new WaitForSeconds(2.5f);

        // 카메라 원상 복구
        if (doorCam != null) doorCam.Priority = 0;
        yield return new WaitForSeconds(2.0f);

        // 조작 권한 복구
        if (playerController != null) playerController.enabled = true;
    }

    private void OnTriggerEnter(Collider other)
    {
        // 구역 진입 시 컷씬 실행
        if (other.CompareTag("Player"))
        {
            StartCoroutine(Area4EntranceCutscene(other.transform));
            Collider col = GetComponent<Collider>();
            if (col != null) col.enabled = false;
        }
    }

    // 4구역 진입 및 배트 배달 연출
    IEnumerator Area4EntranceCutscene(Transform playerTransform)
    {
        Debug.Log("4구역 진입 컷씬 시작");

        if (playerController != null)
        {
            // MonoBehaviour를 PlayerController로 변환해서 브레이크를 밟음
            PlayerController pc = playerController.GetComponent<PlayerController>();
            if (pc != null) pc.StopMovementForCutscene();
        }

        Collider playerCollider = playerTransform.GetComponent<Collider>();
        if (playerCollider != null) playerCollider.enabled = false;

        if (helperAI != null)
        {
            helperAI.isCutsceneMode = true;
            Vector3 targetPos = playerTransform.position + playerTransform.forward * 2f;
            NavMeshAgent helperAgent = helperAI.GetComponent<NavMeshAgent>();

            // 조력자 이동 처리
            if (helperAgent != null && helperAgent.isOnNavMesh)
            {
                helperAgent.isStopped = false;
                helperAgent.SetDestination(targetPos);

                float timeout = 0f;
                while ((helperAgent.pathPending || helperAgent.remainingDistance > 0.5f) && timeout < 3f)
                {
                    timeout += Time.deltaTime;
                    yield return null;
                }
            }
            else
            {
                helperAI.transform.position = targetPos;
            }

            // 플레이어와 완벽하게 같은 방향을 보게 하여 등을 내밈
            helperAI.transform.rotation = playerTransform.rotation;

            helperAI.OfferBox();
        }

        // 가방 아이템 설정
        HelperBox helperBox = FindAnyObjectByType<HelperBox>();
        if (helperBox != null)
        {
            helperBox.allowedItem = "Bat";
            helperBox.isArea4UnlockBox = true;
            helperBox.ShowItemInBox("Bat");
        }

        yield return new WaitForSeconds(1.5f);

        // 조작 복구
        if (playerCollider != null) playerCollider.enabled = true;
        if (playerController != null) playerController.enabled = true;

        Debug.Log("컷씬 종료 조작 권한 복구");
    }
}
using UnityEngine;

public class DosimeterItem : MonoBehaviour, IInteractable
{
    private Material[] itemMaterials;

    [Header("발광 컬러 설정")]
    private Color baseColor = Color.blue * 2.0f; // 기본 에미션
    private Color focusColor = Color.yellow * 2.5f; // 주시 시 에미션

    [Header("사운드 설정")]
    public AudioClip zipSound; // 가방 지퍼 소리

    void Start()
    {
        // 자식 오브젝트의 모든 렌더러 컴포넌트 추출
        Renderer[] renderers = GetComponentsInChildren<Renderer>();

        if (renderers.Length > 0)
        {
            itemMaterials = new Material[renderers.Length];

            for (int i = 0; i < renderers.Length; i++)
            {
                itemMaterials[i] = renderers[i].material;

                // 마테리얼 에미션 기능 활성화
                itemMaterials[i].EnableKeyword("_EMISSION");

                // 초기 기본 색상 적용
                if (itemMaterials[i].HasProperty("_EmissionColor"))
                {
                    itemMaterials[i].SetColor("_EmissionColor", baseColor);
                }
            }
        }
    }

    // 시선이 닿았을 때 호출
    public void OnFocus()
    {
        if (itemMaterials == null) return;

        foreach (Material mat in itemMaterials)
        {
            if (mat != null)
            {
                mat.SetColor("_EmissionColor", focusColor);
            }
        }
    }

    // 시선이 벗어났을 때 호출
    public void OnLoseFocus()
    {
        if (itemMaterials == null) return;

        foreach (Material mat in itemMaterials)
        {
            if (mat != null)
            {
                mat.SetColor("_EmissionColor", baseColor);
            }
        }
    }

    // 클릭 상호작용 시 호출
    public void OnInteract()
    {
        // 4구역 매니저에 획득 신호 전달
        Area4_Manager manager = FindAnyObjectByType<Area4_Manager>();
        if (manager != null)
        {
            manager.OnDosimeterPickedUp();
        }

        // 지퍼 소리 재생
        if (zipSound != null)
        {
            AudioSource.PlayClipAtPoint(zipSound, transform.position);
        }

        // 오브젝트 제거
        Destroy(gameObject);
    }
}
using UnityEngine;
using System.Collections;

public class PlayerHealth : MonoBehaviour
{
    [Header("체력 설정")]
    public float maxHealth = 100f; // 최대 체력임
    private float currentHealth; // 현재 체력임

    [Header("부활 위치")]
    public Transform area4RespawnPoint; // 4구역 입구 부활 위치임

    private PlayerController playerController; // 플레이어 조작 스크립트임
    private CharacterController characterController; // 캐릭터 컨트롤러 컴포넌트임
    private Animator anim;
    private PlayerMelee playerMelee;

    void Start()
    {
        currentHealth = maxHealth;
        playerController = GetComponent<PlayerController>();
        characterController = GetComponent<CharacterController>();
        anim = GetComponentInChildren<Animator>();
        playerMelee = GetComponent<PlayerMelee>();
    }

    // 데미지 처리 함수임
    public void TakeDamage(float damageAmount)
    {
        // 이미 죽은 상태라면 데미지 무시함
        if (currentHealth <= 0) return;

        currentHealth -= damageAmount;
        Debug.Log("데미지 발생함 남은 체력임: " + currentHealth);

        // 체력 상태에 따라 애니메이션을 다르게
        if (currentHealth > 0)
        {
            // 체력이 남았으면 움찔 (Hit)
            if (anim != null) anim.SetTrigger("Hit");
        }
        else
        {
            // 체력이 0 이하가 되면 픽 쓰러짐 (Dead)
            if (anim != null) anim.SetTrigger("Dead");

            // 사망 시 빠따 휘두르기 금지 & 배트 모델 숨기기
            if (playerMelee != null)
            {
                playerMelee.isDead = true;
                if (playerMelee.heldBatModel != null) playerMelee.heldBatModel.SetActive(false);
            }

            StartCoroutine(DieAndRespawnRoutine());
        }
    }

    // 사망 및 부활 처리 코루틴임
    IEnumerator DieAndRespawnRoutine()
    {
        Debug.Log("사망함 조작을 일시 중단함");

        // 플레이어 조작 잠금 처리함
        if (playerController != null) playerController.enabled = false;

        // 2초 동안 정지 상태 유지함
        yield return new WaitForSeconds(2.5f);

        // 부활 위치로 순간이동함
        if (area4RespawnPoint != null)
        {
            // 컨트롤러가 켜져 있으면 위치 이동이 안 되므로 잠시 끔
            if (characterController != null) characterController.enabled = false;

            transform.position = area4RespawnPoint.position;

            if (characterController != null) characterController.enabled = true;
        }

        // 상태 초기화 및 조작 복구함
        currentHealth = maxHealth;
        if (playerController != null) playerController.enabled = true;

        // 부활 시 다시 휘두를 수 있게 해주고, 배트를 들고 있었다면 다시 보여주기
        if (playerMelee != null)
        {
            playerMelee.isDead = false;
            if (playerMelee.isHoldingBat && playerMelee.heldBatModel != null)
                playerMelee.heldBatModel.SetActive(true);
        }

        if (anim != null)
        {
            anim.ResetTrigger("Dead"); // 혹시라도 찌꺼기로 남아있을 사망 신호를 깔끔하게 지움
            anim.Rebind(); // 애니메이터의 기억을 아예 포맷해서 게임 처음 켰을 때(대기 상태)로 강제 리셋
        }

        Debug.Log("부활 완료함 4구역 입구에서 다시 시작함");
    }
}
using UnityEngine;
using System.Collections;

public class PlayerMelee : MonoBehaviour
{
    [Header("연결")]
    public Animator anim;
    public Collider batCollider;      // 진짜 데미지를 줄 빠따의 껍데기
    public GameObject heldBatModel;   // 손에 쥐고 있는 빠따 모델

    [Header("상태")]
    public bool isHoldingBat = false; // 매니저와 상자가 확인할 상태 변수
    private bool isSwinging = false;
    public bool isDead = false;

    void Start()
    {
        if (batCollider != null) batCollider.enabled = false;
    }

    void Update()
    {
        //  좌클릭 했고 빠따를 쥐고 있고 안 휘두르고 있을 때만 공격
        if (Input.GetMouseButtonDown(0) && isHoldingBat && !isSwinging && !isDead)
        {
            if (anim != null) anim.SetTrigger("Swing");

            StartCoroutine(FPSBatSwing());
        }
    }

    // 조력자나 4구역 입구에서 빠따를 얻었을 때 호출되는 함수
    public void EquipBat()
    {
        isHoldingBat = true;
        if (heldBatModel != null) heldBatModel.SetActive(true);

        // 야구공 강제 해제
        PlayerThrow pt = GetComponent<PlayerThrow>();
        if (pt != null)
        {
            pt.isHoldingBall = false; // 야구공 던지기 권한 박탈
            if (pt.heldBallModel != null) pt.heldBallModel.SetActive(false); // 손에 든 야구공 숨기기
        }

        Debug.Log("빠따 장착 완료! 이제 야구공은 안 던집니다!");
    }

    // FPS 배트 궤적 & 판정 연출 코루틴
    IEnumerator FPSBatSwing()
    {
        isSwinging = true;

        // 휘두르기 전 원래 들고 있던 각도 저장
        Quaternion startRot = heldBatModel.transform.localRotation;

        // 대각선 앞으로 내려찍는 타격 각도 계산 (X축으로 숙이고, Y축으로 비틂)
        Quaternion strikeRot = startRot * Quaternion.Euler(60f, -30f, 0f);

        // 공격 판정 즉시 킴
        //if (batCollider != null) batCollider.enabled = true;

        // 엄청 빠른 속도로 내려찍기 (0.1초 컷)
        float t = 0;

        bool isHitColliderActive = false; // 판정이 켜졌는지 체크하는 변수

        while (t < 1f)
        {
            t += Time.deltaTime * 10f; // 속도 조절
            heldBatModel.transform.localRotation = Quaternion.Slerp(startRot, strikeRot, t);

            // 배트가 시각적으로 70%쯤 내려찍혔을 때 공격 판정을 킴
            // 배트가 기울어질 때 적과 부딪히며 타격음 발생 하도록
            if (t >= 0.7f && !isHitColliderActive)
            {
                if (batCollider != null) batCollider.enabled = true;
                isHitColliderActive = true;
            }

            yield return null;
        }

        // 공격 판정 즉시 끔 (다단 히트 방지)
        if (batCollider != null) batCollider.enabled = false;

        // 내려찍은 상태로 아주 잠깐 멈춤 (타격감 극대화)
        yield return new WaitForSeconds(0.1f);

        // 다시 부드럽게 원래 자세로 복귀
        t = 0;
        while (t < 1f)
        {
            t += Time.deltaTime * 4f; // 복귀 속도
            heldBatModel.transform.localRotation = Quaternion.Slerp(strikeRot, startRot, t);
            yield return null;
        }

        // 혹시 모를 오차 방지를 위해 각도 완전 초기화
        heldBatModel.transform.localRotation = startRot;

        // 다음 공격까지 약간의 쿨타임
        yield return new WaitForSeconds(0.2f);
        isSwinging = false;
    }

    public void EnableBatCollider() { }
    public void DisableBatCollider() { }
using UnityEngine;

public class MeleeWeapon : MonoBehaviour
{
    [Header("타격 설정")]
    public float damage = 35f; // 타격 데미지
    public float knockbackForce = 15f; // 물리적 밀쳐내기 강도

    [Header("사운드 설정")]
    public AudioClip hitSound; // 배트 타격음

    private Collider hitCollider;

    void Start()
    {
        // 콜라이더 참조 저장 및 초기 설정
        hitCollider = GetComponent<Collider>();
        if (hitCollider != null)
        {
            hitCollider.isTrigger = true;
            hitCollider.enabled = false;
        }
    }

    // 애니메이션 이벤트에서 공격 판정 활성화
    public void EnableCollider()
    {
        if (hitCollider != null) hitCollider.enabled = true;
    }

    // 공격 판정 비활성화
    public void DisableCollider()
    {
        if (hitCollider != null) hitCollider.enabled = false;
    }

    // 적 유닛과의 충돌 판정 처리
    private void OnTriggerEnter(Collider other)
    {
        // 부모 오브젝트에서 적 AI 컴포넌트 추출
        EnemyAI enemy = other.GetComponentInParent<EnemyAI>();
        if (enemy != null)
        {
            // 타격 방향 계산 및 수평 보정
            Vector3 hitDirection = (enemy.transform.position - transform.position).normalized;
            hitDirection.y = 0;

            // 데미지 및 넉백 전달
            enemy.TakeDamage(damage, hitDirection, knockbackForce);

            // 타격음
            if (hitSound != null) AudioSource.PlayClipAtPoint(hitSound, transform.position);

            // 다단 히트 방지를 위한 판정 즉시 종료
            DisableCollider();
        }
    }
}

1.5. 탈출 통로

  • 에디터에서 긴 복도를 만든 후에 복도 끝에 아주 밝게 빛나는 벽을 설치. box volume을 설치하고 bloom 효과를 넣어서 distance안에 들어온 후 밝게 빛나는 벽으로 향하면 점점 더 밝아지는 연출 표현.
  • 거의 벽에 다다를 때 쯤 설치해둔 투명벽에 닿으면 OnTrigger 함수 호출 -> LoadScene으로 바깥 세상으로 씬 로드(체인지)
  • DontDestroyOnLoad() 함수를 이용해서 플레이어가 그대로 다음 씬에 넘어가도록 구현.
  • 플레이어가 다음 씬으로 넘어갈 때 스폰 구역을 정해놓고 스폰 구역을 string으로 찾아서 이동. 스폰 포인트를 찾았다면 플레이어 위치와 바라보는 방향을 동일하게 맞춤. PlayerController, CineMachine을 다시 연결해줘서 이동과 인게임 시점을 플레이어 기준으로 설정.
  • 바깥은 기획대로 핵 전쟁으로 인해 피폐해진 세상처럼 디자인.
using UnityEngine;
using UnityEngine.SceneManagement;

public class NextScene : MonoBehaviour
{
    [Header("전환 설정")]
    public string nextSceneName; // 이동할 다음 씬의 이름

    private bool hasTriggered = false; // 중복 실행 방지용

    private void OnTriggerEnter(Collider other)
    {
        // 플레이어가 닿았고, 아직 전환된 적이 없다면
        if (other.CompareTag("Player") && !hasTriggered)
        {
            hasTriggered = true; // 두 번 부딪히는 것 방지

            // 혹시 모를 버그를 막기 위해 조작을 잠깐 멈춤
            PlayerController pc = other.GetComponent<PlayerController>();
            if (pc != null) pc.StopMovementForCutscene();

            // 다음 씬으로
            Debug.Log($"{nextSceneName} 씬으로 바로 이동합니다.");
            SceneManager.LoadScene(nextSceneName);
        }
    }
}
using UnityEngine;
using UnityEngine.SceneManagement;
using Unity.Cinemachine;

public class PlayerTransfer : MonoBehaviour
{
    // 씬이 여러 번 바뀌어도 플레이어가 중복 생성되지 않도록 막아주는 장치
    private static PlayerTransfer instance;

    void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject); // 씬이 넘어가도 이 오브젝트(플레이어)를 파괴하지 않음

            // 씬이 로드될 때마다 'OnSceneLoaded' 함수를 실행하도록 예약
            SceneManager.sceneLoaded += OnSceneLoaded;
        }
        else
        {
            // 이미 플레이어가 존재한다면 새로 넘어온 플레이어는 파괴 (중복 방지)
            Destroy(gameObject);
        }
    }

    // 새로운 씬이 화면에 완전히 로드된 직후에 자동으로 실행되는 함수
    void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        // 새로운 씬에서 "SpawnPoint" 스트링을 찾음
        GameObject spawnPoint = GameObject.Find("SpawnPoint");

        // 만약 찾았다면, 플레이어의 위치와 바라보는 방향을 그곳으로 맞춤
        if (spawnPoint != null)
        {
            CharacterController cc = GetComponent<CharacterController>();
            if (cc != null) cc.enabled = false;

            transform.position = spawnPoint.transform.position;
            transform.rotation = spawnPoint.transform.rotation;

            if (cc != null) cc.enabled = true;

            // 묶어뒀던 조작 다시 풀어주기 (스크립트 다시 켜기)
            PlayerController pc = GetComponent<PlayerController>();
            if (pc != null)
            {
                pc.enabled = true;
            }

            // 새로운 씬의 시네머신 카메라가 나(Player)를 찍도록 강제 연결하기
            CinemachineCamera cam = FindAnyObjectByType<CinemachineCamera>();
            if (cam != null)
            {
                cam.Follow = transform;
                cam.LookAt = transform; 
            }

            Debug.Log("플레이어가 새로운 씬의 스폰 지점으로 무사히 이동했습니다.");
        }
    }

    void OnDestroy()
    {
        // 오브젝트가 파괴될 때는 예약해둔 이벤트를 취소 (메모리 누수 방지)
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }
}

2. 문제 발생, 해결 과정

  1. 조력자가 플레이어를 따라오다가 다음 구역으로 넘어가는 문 바닥에 NavMesh가 안깔려서 못들어오는 문제 발생 -> NavMesh의 반지름을 줄이고 문을 설치 하기 전에 Bake를 하고 문을 설치해서 해결했습니다.
  2. 2구역에서 동일한 방향의 레버를 계속 눌렀을 때 빛이 스트라이크 존 밖으로 빠져 나가는 문제가 발생 -> 스트라이크 존의 크기에 맞춰서 Collider를 생성하고 bounds와 Clamp로 범위를 정해서 못빠져 나가도록 수정했습니다.
  3. 3구역에서만 야구공을 던질 수 있고 가방을 열 수 있는데 전 구역에서도 가방을 클릭해서 야구공을 꺼내 던질 수 있는 문제 발생 -> 각종 조건 변수를 만들고 모든 조건에 충족 할 때만 가방을 열고 공을 던질 수 있도록 예외 처리 해서 해결했습니다.
  4. 3구역에서 플레이어가 플레이트를 밟았을 때 그대로 y축이 고정 되어 버리는 문제가 발생 -> rigidbody 기반이 아닌 playercontroller 컴포넌트 기반의 움직임 구현이므로 중력 설정을 해주지 않아서 생긴 문제였습니다. playermovement에 중력을 더해주는 기능을 추가 해줘서 해결했습니다.
  5. 4구역에서 현재는 적들의 수가 적어 풀링이 아닌 리스트로 적들을 관리했습니다. 적이 죽고 Destroy를 해주더라도 매니저 리스트에 Null 값이 남기 때문에 메모리 누수가 있었습니다. -> 리스트에서도 직접 적들을 Remove 해줘서 메모리 누수를 해결했습니다.
  6. 4구역 전투 때 UI를 사용하지 않기에 배트로 상대를 때리더라도 데미지가 들어 가고 있는지 플레이어 입장에서 알지 못하는 문제 발생 -> 배트로 상대를 때릴 때 마다 적들에게 경직을 줘서 히트 확인을 할 수 있도록 해결했습니다.
  7. 4구역에서 전투를 하다가 플레이어가 죽고 리스폰 되었을 때, Dead 애니메이션 자세 그대로 스폰되고 상태가 바뀌지 않는 문제 발생 -> animator.Rebind 함수를 이용해서 아에 플레이어 상태를 idle로 초기화해서 문제를 해결하였습니다.
  8. 마지막 통로에서 씬이 바뀔 때 플레이어가 함께 이동해야 하는데 씬만 바뀌는 문제 발생 -> DontDestroyOnLoad() 함수를 이용해서 플레이어는 절대 파괴 되지 않게 로드 되도록 문제를 해결했습니다.

3. 게임 소개 영상

https://drive.google.com/file/d/1PMCAyOAwok6eTE-U3gZuEAA6ToRAwhE4/view?usp=sharing

4. 3주간 프로젝트를 진행하면서

  1. 게임 개발을 배우기 시작하고 처음으로 본격적인 게임을 만들어 봤습니다. 처음 기획 할 때는 기능들을 완성하는 것에 초점을 두고 기획을 했습니다. 하지만 완성을 하고 보니 퍼즐들을 좀 더 난이도 있고 신박하게 기획을 했었다면 더 성장 할 수 있었을 것 같아서 조금 후회되는 마음이 있습니다. 하지만 기본적으로 게임 기획, 기능 구현, 에셋 적용, 맵 디자인, 유저의 관점에서 디테일 구현, 각종 버그 수정 등등 정말 많은걸 배웠으며 다음 게임 개발을 할 때 많은 도움이 될 것이라고 확신이 들었습니다.
  2. UI 없이 게임을 만들어 보았는데 시작부터 에임은 어떻게 할 것이며 물체와 상호작용은 어떻게 하고 인벤토리 등등 앞길이 막막 했었습니다. 하지만 직접 플레이어의 관점이 되어보고 차근차근 구현을 완성했을 때 UX를 어떻게 구현해야 할지 직접 몸으로 깨닫고 익혔습니다. 모래 주머니를 차고 훈련 하듯이 UI를 사용한 게임 개발에서는 더욱 빠르고 디테일하게 UX를 생각 할 수 있을 것 같습니다.
  3. 개발 도중에 수정 사항이 생기거나 버그가 생겼을 때 '아 이건 그냥 빼야지' 혹은 '이 정도는 괜찮지' 라는 방식이 아니라 타협하지 않고 더욱 디테일하게 개발 하는 것이 올바르고 완성도 높은 게임을 제작할 수 있다는 것을 배웠습니다.

0개의 댓글