본문 바로가기

UNITY

UNITY_20231012[개인과제 완료]

오늘 부로 개인과제 작업을 제출까지 완료했다. 요 근래 공휴일이 연속으로 걸쳐서 덩달아 신나게 놀고 나서, 막상 다시 유니티를 잡으려니 의욕이 바닥을 쳤다. 강의도 산더미처럼 밀렸는데 사실 큰일이다.

이번 과제에서 건졌다고 할 만한 내용은 InputSystem의 InvokeUnityEvents, 미로 만들기 알고리즘이다.


InputSystem-InvokeUnityEvents

InputSystem의 Behavior 기본값은 SendMessages다. 이것을 InvokeUnityEvents로 변경하고, 지정된 InputAction의 입력 키를 누르면 그에 맞는 값을 InputAction.CallbackContext 클래스의 인자로 받아 바로 사용할 수 있다.

아래는 InputSystem의 상세와 이를 사용한 코드다. 이 코드로 플레이어의 이동, 방향 전환이 모두 이루어진다.

(Delegate를 이용한 옵저버 패턴이라던가, 그런건 없다. 따라서 코드의 재활용성은 보장하지 못한다.)

이동, 시점 회전, 상호작용, 행동 등을 InvokeUnityEvents로 설정

더보기
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerController : MonoBehaviour
{
    [Header("Movement")]
    public float moveSpeed;
    private Vector2 curMovementInput;
    [Range(1f, 10f)]
    public float accelRateInput;

    [Header("Look")]
    public Transform cameraContainer;
    public float minXLook;
    public float maxXLook;
    private float camCurXRot;
    private float camCurYRot;
    public float lookSensitivity;

    private Vector2 mouseDelta;

    [HideInInspector]
    public bool canLook = true;
    [HideInInspector]
    public bool canAccel = true;
    private bool isFlashOn = false;

    [SerializeField]
    private GameObject flashLight;
    private Rigidbody _rigidbody;

    public static PlayerController instance;

    private void Awake()
    {
        instance = this;
        _rigidbody = GetComponent<Rigidbody>();
    }

    void Start()
    {
        Cursor.lockState = CursorLockMode.Locked;
    }

    private void FixedUpdate()
    {
        Move();
    }

    private void LateUpdate()
    {
        if (canLook)
        {
            CameraLook();
        }
    }
    private void Move()
    {
        Vector3 direction = (transform.forward * curMovementInput.y * accelRateInput + transform.right * curMovementInput.x) * Time.fixedDeltaTime;
        direction *= moveSpeed;
        direction.y = _rigidbody.velocity.y;

        _rigidbody.velocity = direction;
    }

    private void CameraLook()
    {
        camCurXRot += mouseDelta.y * lookSensitivity;
        camCurXRot = Mathf.Clamp(camCurXRot, minXLook, maxXLook);
        cameraContainer.localEulerAngles = new Vector3(-camCurXRot, 0, 0); // 회전값은 시계회전이 +다.
        // 아래 위를 볼때마다 Player오브젝트가 통째로 움직이면 이상하니까 x축 cam회전은 camContainer에게 맡긴다.

        camCurYRot = mouseDelta.x * lookSensitivity;
        transform.eulerAngles += new Vector3(0, camCurYRot, 0);
    }

    public void OnMoveInput(InputAction.CallbackContext context) // 플레이어 이동 입력 : WASD
    {
        if (context.phase == InputActionPhase.Performed)
        {
            curMovementInput = context.ReadValue<Vector2>();
        }
        else if (context.phase == InputActionPhase.Canceled)
        {
            curMovementInput = Vector2.zero;
        }
    }

    public void OnLookInput(InputAction.CallbackContext context) // 캠 회전 입력 : Mouse Delta
    {
        mouseDelta = context.ReadValue<Vector2>();
    }

    public void OnAccelerateInput(InputAction.CallbackContext context) // 가속 입력 : LShift
    {
        if (!canAccel || context.phase == InputActionPhase.Canceled)
        {
            accelRateInput = 1f;
        }
        if (context.phase == InputActionPhase.Performed)
        {
            if (canAccel && curMovementInput.y >= 0f) // LShift누른 채로 플레이어 전진 상태일 때만
            {
                accelRateInput = 2.0f; // 가속 적용
            }
        }
    }

    public void OnFlashLightInput(InputAction.CallbackContext context)
    {
        if (context.phase == InputActionPhase.Started)
        {
            if (!isFlashOn)
            {
                flashLight.SetActive(true);
                isFlashOn = true;
            }
            else
            {
                flashLight.SetActive(false);
                isFlashOn = false;
            }
        }
    }
}

미로 생성 알고리즘

상당히 괜찮은 유튜브 영상을 보고 따라 제작했다. 재귀적인 알고리즘이다.

아래는 그 링크와 사용한 오브젝트, 스크립트다.
(실행 시 어마어마한 양의 오브젝트가 생성돼서 과연 성능 면에서 유용한 기능인지는 미지수다.)

https://www.youtube.com/watch?v=_aeYq5BmDMg 

미로의 한 칸이자 요소가 될 셀(과 이를 감싸는 4개의 벽)이다.
게임 실행 시 이 MazeGenerator오브젝트의 스크립트에 의해 미로가 생성된다. MazeWidth, MazeDepth로 미로 크기 조절 가능

MazeCell의 스크립트

더보기
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MazeCell : MonoBehaviour
{
    [SerializeField]
    private GameObject _leftWall;
    [SerializeField]
    private GameObject _rightWall;
    [SerializeField]
    private GameObject _frontWall;
    [SerializeField]
    private GameObject _backWall;
    [SerializeField]
    private GameObject _unvisitedBlock;

    public bool IsVisited { get; private set; }

    public void Visit() // 현재 위치의 셀 처리
    {
        IsVisited = true; // 해당 위치에 온 적이 있다.
        _unvisitedBlock.SetActive(false); // unvisitedBlock 비활성화 => 공간 만들기
    }

    public void ClearLeftWall()
    {
        _leftWall.SetActive(false);
    }
    public void ClearRightWall()
    {
        _rightWall.SetActive(false);
    }
    public void ClearFrontWall()
    {
        _frontWall.SetActive(false);
    }
    public void ClearBackWall()
    {
        _backWall.SetActive(false);
    }

}

MazeGenerator의 스크립트

더보기
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class MazeGenerator : MonoBehaviour
{
    [SerializeField]
    private MazeCell _mazeCellPrefab;

    [Range(3, 15)]
    public int _mazeWidth;

    [Range(3, 15)]
    public int _mazeDepth;

    private MazeCell[,] _mazeGrid;

    public static MazeGenerator instance;

    private void Awake()
    {
        instance = this;
    }

    IEnumerator Start() // _mazeWidth, _mazeDepth의 크기만큼 미로 판 깔기
    {
        _mazeGrid = new MazeCell[_mazeWidth, _mazeDepth];

        for (int x = 0; x < _mazeWidth; x++)
        {
            for (int z = 0; z < _mazeDepth; z++)
            {
                _mazeGrid[x,z] = Instantiate(_mazeCellPrefab, new Vector3(x, 0, z), Quaternion.identity);
            }
        }

        yield return GenerateMaze(null, _mazeGrid[0, 0]); // 이전셀 = null, 현재셀 = [0,0]으로 놓고 GenerateMaze시작
    }

    private IEnumerator GenerateMaze(MazeCell previousCell, MazeCell currentCell)
    {
        currentCell.Visit(); // 현재 위치의 셀 처리 호출
        ClearWalls(previousCell, currentCell); // 

        yield return new WaitForSeconds(0.005f); // 0.005초 기다렸다가 진행

        MazeCell nextCell; // currentCell -> nextCell로 갈 변수 선언

        do // 처음 한번은 무조건 해야하니까 do로 반복
        {
            nextCell = GetNextUnvisitedCell(currentCell); // 다음에 갈 셀 반환
            if (nextCell != null)
            {
                yield return GenerateMaze(currentCell, nextCell); // 재귀 호출, currentCell -> previousCell, nextCell -> currentCell
            }
        } while (nextCell != null);
    }

    private MazeCell GetNextUnvisitedCell(MazeCell currentCell)
    {
        var unvisitedCells = GetUnvisitedCells(currentCell); // 진행 대상 좌표를 모아 놓은 제네릭

        return unvisitedCells.OrderBy(x => Random.Range(1, 10)).FirstOrDefault(); // 제네릭 중 무작위로 하나 골라 nextCell로 반환
    }

    private IEnumerable<MazeCell> GetUnvisitedCells(MazeCell currentCell)
    {
        int x = (int)currentCell.transform.position.x; // 현재 셀의 위치
        int z = (int)currentCell.transform.position.z;

        if (x + 1 < _mazeWidth) // 현재 셀의 x좌표가 미로의 오른쪽 끝 x좌표 미만이면
        {
            var cellToRight = _mazeGrid[x + 1, z]; // 현재 셀의 오른쪽 좌표는 셀이 갈 수 있는 좌표가 있는 공간이다.
            
            if (cellToRight.IsVisited == false) // 그 오른쪽 좌표가 미로를 만드는 과정에서 아직 현재 셀이 지나친 적 없는 좌표라면
            {
                yield return cellToRight; // 그 좌표는 셀 진행 대상이 된다.
            }
        }
        if (x - 1 >= 0)
        {
            var cellToLeft = _mazeGrid[x - 1, z];

            if (cellToLeft.IsVisited == false)
            {
                yield return cellToLeft;
            }
        }
        if (z + 1 < _mazeDepth)
        {
            var cellToFront = _mazeGrid[x, z + 1];

            if (cellToFront.IsVisited == false)
            {
                yield return cellToFront;
            }
        }
        if (z - 1 >= 0)
        {
            var cellToBack = _mazeGrid[x, z - 1];

            if (cellToBack.IsVisited == false)
            {
                yield return cellToBack;
            }
        }
    }

    private void ClearWalls(MazeCell previousCell, MazeCell currentCell)
    {
        if (previousCell == null) // 이전 셀이 null이면(최초 미로 제작 시점)
        {
            return; // 그냥 넘어간다.
        }

        if (previousCell.transform.position.x < currentCell.transform.position.x) 
        { // previousCell || currentCell 인 상황(X축 상황을 가정하고 양 셀 사이에 두개의 벽 || 존재)일 때
            previousCell.ClearRightWall(); // previousCell의 오른쪽 벽 제거
            currentCell.ClearLeftWall(); // currentCell의 왼쪽 벽 제거
            return; // => previousCell currentCell, 양 셀의 공간이 이어졌다. 끝
        }
        if (previousCell.transform.position.x > currentCell.transform.position.x)
        {
            previousCell.ClearLeftWall();
            currentCell.ClearRightWall();
            return;
        }
        if (previousCell.transform.position.z < currentCell.transform.position.z)
        {
            previousCell.ClearFrontWall();
            currentCell.ClearBackWall();
            return;
        }
        if (previousCell.transform.position.z > currentCell.transform.position.z)
        {
            previousCell.ClearBackWall();
            currentCell.ClearFrontWall();
            return;
        }
    }
}