3D 공룡 달리기 - 충돌 감지와 사칙연산 시스템

Physics.OverlapSphere로 문 충돌을 감지하고, 사칙연산(+/-/×/÷)으로 공룡 수를 동적으로 조절하는 게임 시스템을 완성합니다

3D 공룡 달리기의 핵심 게임플레이를 완성합니다. DinoController에 구체 범위 충돌 감지(Physics.OverlapSphere)를 추가해 공룡이 문을 통과하는 순간을 포착하고, DinoPositionController의 사칙연산 메서드(method)로 공룡 수를 동적으로 조절합니다. OnDrawGizmos로 충돌 범위를 에디터에서 시각화하는 방법도 학습합니다.


게임 구조

스크립트 메서드(method) 역할
DinoController DoorCheck() 구체 범위로 문 충돌 감지, 연산 위임
DinoController OnDrawGizmos() 에디터에서 충돌 구체 범위 시각화
DinoPositionController SetDoorCalc() 문 타입(type)에 따라 사칙연산 메서드(method) 호출
DinoPositionController PlusRaptor() 공룡 추가 (덧셈)
DinoPositionController MinusRaptor() 공룡 삭제 (뺄셈)
DinoPositionController TimesRaptor() 공룡 곱셈 배증
DinoPositionController DivisionRaptor() 공룡 나눗셈 감소

DinoController - DoorCheck 추가

전체 코드

튜토리얼 49에서 작성한 이동 코드에 DoorCheckOnDrawGizmos를 추가한 완성본입니다.

using UnityEngine;

public class DinoController : MonoBehaviour
{
    public float zMoveSpeed;
    public float xMoveSpeed;

    public DinoPositionController dinoPositionController;
    public Vector3 sphereCenter;
    public float sphereRadius = 0.5f;

    void Update()
    {
        transform.position += Vector3.forward * Time.deltaTime * zMoveSpeed;

        if (Input.GetKey(KeyCode.LeftArrow))
            transform.Translate(-xMoveSpeed * Time.deltaTime, 0, 0);
        if (Input.GetKey(KeyCode.RightArrow))
            transform.Translate(xMoveSpeed * Time.deltaTime, 0, 0);

        DoorCheck();
    }

    void DoorCheck()
    {
        Collider[] hitColliders = Physics.OverlapSphere(transform.position + sphereCenter, sphereRadius);
        foreach (Collider doors in hitColliders)
        {
            int doorNum = doors.gameObject.GetComponent<SelectDoors>().GetDoorNumber(transform.position.x);
            DoorType doorType = doors.gameObject.GetComponent<SelectDoors>().GetDoorType(transform.position.x);
            doors.gameObject.GetComponent<BoxCollider>().enabled = false;
            dinoPositionController.SetDoorCalc(doorType, doorNum);
        }
    }

    void OnDrawGizmos()
    {
        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(transform.position + sphereCenter, sphereRadius);
    }
}

핵심 개념 설명 - DinoController

1. Physics.OverlapSphere - 구체 범위 충돌 감지

Collider[] hitColliders = Physics.OverlapSphere(transform.position + sphereCenter, sphereRadius);

Physics.OverlapSphere: 지정한 위치를 중심으로 반지름 내에 있는 모든 Collider를 배열(array)로 반환(return)합니다. 총알이 범위 내 적을 감지하거나, 폭발 범위 내 오브젝트를 찾을 때 자주 사용합니다.

Physics.OverlapSphere(중심 위치, 반지름)
                         ↑            ↑
              transform.position   sphereRadius
              + sphereCenter

매개변수(variable)(parameter) 설명:

매개변수(variable)(parameter) 타입(type) 설명
1번째 (center) Vector3 구체의 중심 위치. transform.position + sphereCenter로 오브젝트 기준 오프셋 적용
2번째 (radius) float 구체의 반지름. 이 범위 안의 Collider를 모두 반환(return)
3번째 (layerMask) int (선택) 특정 레이어만 검사할 때 사용. 생략 시 모든 레이어 검사

반환(return)(return value): Collider[] 배열(array). 범위 안에 아무것도 없으면 빈 배열(길이 0)을 반환(return)합니다.

sphereCenter를 쓰는 이유: 공룡의 pivot(원점)이 발 아래에 있을 경우, 충돌 구체를 몸통 중앙이나 앞쪽으로 옮기고 싶을 때 오프셋으로 조정합니다. Inspector에서 sphereCenter(0, 0.5, 1) 등으로 설정하면 앞쪽 위에 구체가 생깁니다.


2. foreach로 충돌체 순회

foreach (Collider doors in hitColliders)
{
    // ...
}

Physics.OverlapSphere는 범위 안의 모든 Collider를 배열(array)로 반환(return)합니다. foreach로 각 Collider를 순서대로 처리합니다.

// for 루프로도 동일하게 작성 가능
for (int i = 0; i < hitColliders.Length; i++)
{
    Collider doors = hitColliders[i];
    // ...
}

게임에서는 보통 한 번에 하나의 문만 감지되지만, foreach를 사용하면 여러 개가 감지되는 경우에도 안전하게 처리할 수 있습니다.


3. GetComponent 체이닝 - 컴포넌트 연속 접근

int doorNum = doors.gameObject.GetComponent<SelectDoors>().GetDoorNumber(transform.position.x);
DoorType doorType = doors.gameObject.GetComponent<SelectDoors>().GetDoorType(transform.position.x);
doors.gameObject.GetComponent<BoxCollider>().enabled = false;

충돌한 Collider로부터 같은 GameObject의 다른 컴포넌트에 접근하는 패턴입니다.

Collider (doors)
    └── .gameObject           → Collider가 붙어 있는 GameObject
         └── .GetComponent<SelectDoors>()  → 같은 오브젝트의 SelectDoors 컴포넌트
              └── .GetDoorNumber(...)       → 메서드 호출

doors.gameObject: Collider 컴포넌트가 부착된 GameObject를 가져옵니다. ColliderSelectDoors가 같은 오브젝트에 있으므로 이 방식으로 접근합니다.

성능 최적화를 위해 같은 오브젝트에서 GetComponent를 여러 번 호출한다면 캐싱할 수 있습니다:

// 최적화 예시: GetComponent 결과를 변수에 저장
SelectDoors selectDoors = doors.gameObject.GetComponent<SelectDoors>();
int doorNum = selectDoors.GetDoorNumber(transform.position.x);
DoorType doorType = selectDoors.GetDoorType(transform.position.x);

4. BoxCollider.enabled = false - 중복 충돌 방지

doors.gameObject.GetComponent<BoxCollider>().enabled = false;

문을 통과하는 순간 해당 문의 BoxCollider를 비활성화합니다. 이렇게 하지 않으면 공룡이 문을 천천히 통과하는 동안 DoorCheck가 여러 프레임에 걸쳐 반복 실행되어 덧셈이 수십 번 적용될 수 있습니다.

상황 결과
BoxCollider 비활성화 안 함 문 통과 중 매 프레임 충돌 감지 → 연산 중복 적용
BoxCollider.enabled = false 첫 충돌 시 즉시 비활성화 → 연산 1회만 적용
// 비활성화 vs 삭제 비교
doors.gameObject.GetComponent<BoxCollider>().enabled = false;  // Collider만 끔 (오브젝트는 유지)
Destroy(doors.gameObject.GetComponent<BoxCollider>());         // Collider 컴포넌트 삭제
Destroy(doors.gameObject);                                     // 오브젝트 전체 삭제

enabled = false를 사용하면 오브젝트(문 비주얼)는 그대로 보이면서 충돌만 비활성화할 수 있습니다.


5. OnDrawGizmos - 에디터 충돌 범위 시각화

void OnDrawGizmos()
{
    Gizmos.color = Color.red;
    Gizmos.DrawWireSphere(transform.position + sphereCenter, sphereRadius);
}

OnDrawGizmos: Unity 에디터의 Scene 뷰에서 시각적 디버깅 도구(기즈모)를 그리는 특수 메서드(method)입니다. 게임 빌드에는 포함되지 않으며 Scene 뷰에서만 표시됩니다.

  • Gizmos.color: 이후 그리는 기즈모의 색상을 설정합니다.
  • Gizmos.DrawWireSphere(center, radius): 지정한 위치에 와이어프레임 구체를 그립니다.

이 코드 덕분에 Unity 에디터의 Scene 뷰에서 공룡 오브젝트를 선택하면 빨간 구체가 표시되어, 충돌 감지 범위를 직관적으로 확인하고 sphereCentersphereRadius를 조정할 수 있습니다.

// 자주 사용하는 Gizmos 메서드
Gizmos.DrawWireSphere(pos, radius);  // 와이어프레임 구체
Gizmos.DrawSphere(pos, radius);      // 속이 찬 구체
Gizmos.DrawWireCube(pos, size);      // 와이어프레임 박스
Gizmos.DrawLine(from, to);          // 선
Gizmos.DrawRay(origin, direction);   // 광선

OnDrawGizmosSelected: OnDrawGizmos와 달리, 오브젝트가 선택된 상태일 때만 기즈모를 그립니다. 씬에 오브젝트가 많을 때 선택된 것만 표시하고 싶을 때 유용합니다.


DinoPositionController - SetDoorCalc 추가

전체 코드

튜토리얼 50에서 작성한 SetDinoPosition 코드에 SetDoorCalc와 4개의 사칙연산 메서드(method)를 추가한 완성본입니다.

using UnityEngine;

public class DinoPositionController : MonoBehaviour
{
    public Transform raptors;
    public float radius = 1f;
    public float ratio = 0.1f;

    public GameObject raptorPrefab;

    void Start()
    {
        SetDinoPosition();
    }

    public void SetDoorCalc(DoorType doorType, int doorNumber)
    {
        if (doorType.Equals(DoorType.Plus)) PlusRaptor(doorNumber);
        else if (doorType.Equals(DoorType.Minus)) MinusRaptor(doorNumber);
        else if (doorType.Equals(DoorType.Times)) TimesRaptor(doorNumber);
        else if (doorType.Equals(DoorType.Division)) DivisionRaptor(doorNumber);
        SetDinoPosition();
    }

    private void SetDinoPosition()
    {
        for (int i = 0; i < raptors.childCount; i++)
        {
            if (i > 8)
            {
                raptors.GetChild(i).gameObject.SetActive(false);
                continue;
            }
            if (raptors.childCount < 10)
            {
                float angleStep = 360f / (raptors.childCount * ratio);
                float angle = i * angleStep;
                float angleRad = angle * Mathf.Deg2Rad;
                float x = Mathf.Cos(angleRad) * radius;
                float z = Mathf.Sin(angleRad) * radius;
                raptors.GetChild(i).localPosition = new Vector3(x, 0f, z);
            }
        }
    }

    private void PlusRaptor(int number)
    {
        for (int i = 0; i < number; i++)
            Instantiate(raptorPrefab, raptors);
    }

    private void MinusRaptor(int number)
    {
        if (number > raptors.childCount) number = raptors.childCount;
        int raptorNum = raptors.childCount;
        for (int i = raptorNum - 1; i >= (raptorNum - number); i--)
            Destroy(raptors.GetChild(i).gameObject);
    }

    private void TimesRaptor(int number)
    {
        int currentCount = raptors.childCount;
        int toAdd = currentCount * number - currentCount;
        for (int i = 0; i < toAdd; i++)
            Instantiate(raptorPrefab, raptors);
    }

    private void DivisionRaptor(int number)
    {
        int currentCount = raptors.childCount;
        int toRemain = currentCount / number;
        if (toRemain < 0) toRemain = 0;
        int toRemove = currentCount - toRemain;
        for (int i = currentCount - 1; i >= (currentCount - toRemove); i--)
            Destroy(raptors.GetChild(i).gameObject);
    }
}

핵심 개념 설명 - DinoPositionController

6. SetDoorCalc - 연산 분기

public void SetDoorCalc(DoorType doorType, int doorNumber)
{
    if (doorType.Equals(DoorType.Plus)) PlusRaptor(doorNumber);
    else if (doorType.Equals(DoorType.Minus)) MinusRaptor(doorNumber);
    else if (doorType.Equals(DoorType.Times)) TimesRaptor(doorNumber);
    else if (doorType.Equals(DoorType.Division)) DivisionRaptor(doorNumber);
    SetDinoPosition();
}

DinoController로부터 doorTypedoorNumber를 전달받아 적절한 연산 메서드(method)를 호출한 뒤, 공룡 위치를 재배치합니다. 연산 후 SetDinoPosition()을 다시 호출해 변경된 공룡 수에 맞게 원형 배치가 갱신됩니다.


7. PlusRaptor - Instantiate로 공룡 추가

private void PlusRaptor(int number)
{
    for (int i = 0; i < number; i++)
        Instantiate(raptorPrefab, raptors);
}

Instantiate(prefab, parent): 프리팹의 복사본을 생성하고 지정한 Transform을 부모로 설정합니다.

// Instantiate 오버로드 비교
Instantiate(prefab);                                   // 월드 원점에 생성, 부모 없음
Instantiate(prefab, position, rotation);               // 위치·회전 지정, 부모 없음
Instantiate(prefab, parent);                           // 부모 지정, 부모의 로컬 원점에 생성
Instantiate(prefab, position, rotation, parent);       // 위치·회전·부모 모두 지정

raptors를 부모로 지정하면 생성된 공룡이 raptors의 자식이 되어 raptors.childCount가 즉시 증가합니다. 이후 SetDinoPosition()이 증가된 childCount를 기준으로 원형 배치를 다시 계산합니다.

예: 현재 3마리 + +2 문 통과 → PlusRaptor(2) → 2번 Instantiate → 5마리


8. MinusRaptor - 뒤에서부터 삭제하는 이유

private void MinusRaptor(int number)
{
    if (number > raptors.childCount) number = raptors.childCount;
    int raptorNum = raptors.childCount;
    for (int i = raptorNum - 1; i >= (raptorNum - number); i--)
        Destroy(raptors.GetChild(i).gameObject);
}

예외(exception) 처리: number > raptors.childCount이면 numberchildCount로 제한합니다. 예를 들어 공룡이 2마리인데 -5 문을 통과해도 0마리로 줄 뿐 음수가 되지 않습니다.

왜 뒤에서부터(raptorNum - 1에서 감소) 삭제하는가?

자식 인덱스: [0] [1] [2] [3] [4]   (5마리)
앞에서 삭제: [0] 삭제 → [0][1][2][3] 로 인덱스 재조정
            [0] 삭제 → [0][1][2]   로 인덱스 재조정
            → 인덱스 변화로 예상치 못한 오브젝트가 삭제될 수 있음

뒤에서 삭제: [4] 삭제 → [0][1][2][3] (앞쪽 인덱스 변화 없음)
            [3] 삭제 → [0][1][2]    (앞쪽 인덱스 변화 없음)
            → 안전하게 원하는 수만큼 삭제
// 5마리 중 2마리 삭제 예시
int raptorNum = 5;  // childCount
// i = 4 → GetChild(4) 삭제
// i = 3 → GetChild(3) 삭제
// i = 3 >= (5 - 2) = 3 이므로 루프 종료

Destroy는 현재 프레임 말에 실행되므로, 루프가 끝날 때까지 childCount가 즉시 줄지 않습니다. 따라서 뒤에서부터 삭제하는 방식이 안전합니다.


9. TimesRaptor - 곱셈 공룡 증가

private void TimesRaptor(int number)
{
    int currentCount = raptors.childCount;
    int toAdd = currentCount * number - currentCount;
    for (int i = 0; i < toAdd; i++)
        Instantiate(raptorPrefab, raptors);
}

계산식: 추가할 수 = 현재 수 × 배수 - 현재 수

현재 3마리, x2 문 통과:
목표 = 3 × 2 = 6마리
추가할 수 = 6 - 3 = 3마리 Instantiate
현재 수 배수 목표 수 추가할 수
3 2 6 3
5 3 15 10
4 1 4 0 (변화 없음)

currentCount를 루프 전에 저장하는 이유: Instantiate로 자식이 추가되면 raptors.childCount가 늘어나므로, 루프 조건에서 raptors.childCount를 직접 사용하면 계속 증가해 무한 루프가 발생할 수 있습니다. 초기값을 변수(variable)에 고정해 놓는 것이 안전합니다.


10. DivisionRaptor - 정수(integer) 나눗셈과 예외(exception) 처리

private void DivisionRaptor(int number)
{
    int currentCount = raptors.childCount;
    int toRemain = currentCount / number;
    if (toRemain < 0) toRemain = 0;
    int toRemove = currentCount - toRemain;
    for (int i = currentCount - 1; i >= (currentCount - toRemove); i--)
        Destroy(raptors.GetChild(i).gameObject);
}

정수(integer) 나눗셈: C#에서 int / int는 소수점을 버리고 정수(integer)만 반환(return)합니다.

int result = 5 / 2;  // 2 (2.5가 아님)
int result = 7 / 3;  // 2 (2.33...이 아님)
현재 5마리, ÷2 문 통과:
남길 수 = 5 / 2 = 2 (정수 나눗셈)
삭제할 수 = 5 - 2 = 3마리
현재 수 나눗수 남길 수 (정수(integer) 나눗셈) 삭제할 수
6 2 3 3
5 2 2 3
7 3 2 5
4 4 1 3

if (toRemain < 0) toRemain = 0: 이론적으로 int / int에서 toRemain이 음수가 될 수 없지만(childCount와 number 모두 양수), 방어적 코딩으로 0 미만이 되지 않도록 보장합니다.

삭제 시에도 MinusRaptor와 동일하게 뒤에서부터 삭제해 인덱스 안정성을 유지합니다.


완성된 게임 흐름

[게임 시작]
    ↓
DinoController.Update() - 매 프레임
    ├── Z축 전진 (Vector3.forward)
    ├── 방향키 X축 이동 (Transform.Translate)
    └── DoorCheck() 호출
          ↓
    Physics.OverlapSphere로 주변 Collider 검색
          ↓
    문 Collider 발견 시:
    ├── SelectDoors.GetDoorType(xPos)  → DoorType 획득
    ├── SelectDoors.GetDoorNumber(xPos) → 숫자 획득
    ├── BoxCollider.enabled = false    → 중복 방지
    └── DinoPositionController.SetDoorCalc(type, num) 호출
              ↓
        DoorType에 따라 분기:
        ├── Plus     → PlusRaptor(n)     → n마리 Instantiate
        ├── Minus    → MinusRaptor(n)    → n마리 Destroy (뒤에서)
        ├── Times    → TimesRaptor(n)    → (현재×n - 현재)마리 Instantiate
        └── Division → DivisionRaptor(n) → (현재 - 현재/n)마리 Destroy
              ↓
        SetDinoPosition() - 변경된 공룡 수로 원형 재배치

Unity 씬 설정 요약

항목 권장 설정
공룡 오브젝트 DinoController 부착, dinoPositionControllerDinoPositionController 할당
sphereCenter Inspector에서 충돌 구체 중심 오프셋 설정 (예: (0, 0.5, 0.5))
sphereRadius 문 크기에 맞게 설정 (기본값 0.5)
공룡 부모 오브젝트 DinoPositionController 부착, raptors에 공룡 부모 Transform 할당
raptorPrefab Inspector에서 공룡 프리팹 할당
문 오브젝트 SelectDoors + BoxCollider 부착 (Layer 설정 권장)

요약

개념 코드 용도
구체 충돌 감지 Physics.OverlapSphere(pos, radius) 반경 내 모든 Collider 반환(return)
충돌 반복 방지 BoxCollider.enabled = false 문 통과 중 중복 연산 차단
기즈모 시각화 OnDrawGizmos + Gizmos.DrawWireSphere 에디터에서 충돌 범위 확인
공룡 생성 Instantiate(prefab, parent) 프리팹 복사본을 부모 아래 생성
공룡 삭제 뒤에서부터 Destroy(GetChild(i).gameObject) 인덱스 안정성 유지
정수(integer) 나눗셈 int / int 소수점 버림으로 공룡 수 계산