2D에서 3D로 프로젝트 파일을 만들면 차이점이 하이어라키 창에 MainCamera 대신 Directional Light라는 오브젝트가 있는 것이다. Light 소스는 특정한 위치와 방향으로 오브젝트에 빛을 쏴주는 역할을 하며 여러가지 유형이 있다. 연산이 비싸고 성능에 밀접한 영향을 끼치기 때문에 적당히 사용하는 것이 좋다.

 

준비된 프리팹을 불러오고 SkyBox 용도로 사용할 Material을 만든다.

SkyBox는 게임 배경을 둘러싸는 환경 매핑 기술로, 유니티에서 씬 배경으로 사용된다. 낮과 밤 등 시간대에 맞게 변화시킬 수 있고 주로 하늘, 구름, 산 등의 자연 배경을 표현하는데 사용되며, 보통 박스나 구 형태이다.

 

유니티 우측 하단에 Auto Generate Lighting Off를 누른다. (Window - Rendering - Lighting을 눌러도 된다.)

 

Enviroment에서 Default 값으로 되어있는 Skybox에 방금 만든 SkyBox를 넣어준다.

 

 

플레이어 만들기

기본 오브젝트를 생성하여 플레이어를 만들고 하위에 눈(시야) 역할을 할 오브젝트를 추가한다. Main Camera를 하위에 넣어 좌표를 플레이어에게 고정시킨다.

 

어디부터 어디까지 보일 것인지 Main Camera의 시야 절도체를 조절한다.

 

플레이어에 Capsule Colider와 Rigidbody를 달아준다. Rigidbody의 질량(Mass)는 20 정도로 너무 가볍지 않게 주고 Freeze Rotation의 X, Y, Z축을 물리적인 회전을 하지 않게 켜준다.

 

Player의 태그를 Player로, 레이어에 Player를 추가하여 바꿔주고, Input System 패키지를 받아 플레이어의 Input Actions를 추가하여 필요한 값들을 설정한다.

 

Player 오브젝트에 Player Input을 추가하고 방금 만든 Input Actions를 넣어준다.

 

저번 2D 개발 글에서는 onMove, onAttack, onJump 등 SendMessage 상태로 바로 호출하여 사용하는 방식을 사용했다. 이번에는 Event를 이용해볼 것이다. Behavior를 Invoke Unity Events로 바꾼다. Invoke Unity Events는 Delegate의 종류 중 하나인데 유니티에서 사용하기에 더 특화되어 있다.

 

Player를 컨트롤하기 위한 스크립트를 만들어 연결한다.

마우스를 돌리면 카메라 시야를 돌릴 수 있게 하고 땅에 닿았을 때만 점프하도록 제한을 둔다.

[Header("Movement")]
public float moveSpeed;
private Vector2 curMovementInput;
public float jumpForce;
public LayerMask groundLayerMask;

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

private Vector2 mouseDelta;

[HideInInspector]
public bool canLook = true;

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 dir = transform.forward * curMovementInput.y + transform.right * curMovementInput.x;
    dir *= moveSpeed;
    dir.y = _rigidbody.velocity.y;

    _rigidbody.velocity = dir;
}

// 카메라를 마우스로 볼 수 있게하는 코드
void CameraLook()
{
    camCurXRot += mouseDelta.y * lookSensitivity;
    camCurXRot = Mathf.Clamp(camCurXRot, minXLook, maxXLook);
    cameraContainer.localEulerAngles = new Vector3(-camCurXRot, 0, 0);

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

public void OnLookInput(InputAction.CallbackContext context)
{
    mouseDelta = context.ReadValue<Vector2>();
}

public void OnMoveInput(InputAction.CallbackContext context)
{
    if (context.phase == InputActionPhase.Performed)
    {
        curMovementInput = context.ReadValue<Vector2>();
    }
    else if (context.phase == InputActionPhase.Canceled)
    {
        curMovementInput = Vector2.zero;
    }
}

public void OnJumpInput(InputAction.CallbackContext context)
{
    if (context.phase == InputActionPhase.Started)
    {
        if (IsGrounded())
            _rigidbody.AddForce(Vector2.up * jumpForce, ForceMode.Impulse);

    }
}

// 땅을 밟고 있을 때만 점프
private bool IsGrounded()
{
    Ray[] rays = new Ray[4]
    {
        new Ray(transform.position + (transform.forward * 0.2f) + (Vector3.up * 0.01f) , Vector3.down),
        new Ray(transform.position + (-transform.forward * 0.2f)+ (Vector3.up * 0.01f), Vector3.down),
        new Ray(transform.position + (transform.right * 0.2f) + (Vector3.up * 0.01f), Vector3.down),
        new Ray(transform.position + (-transform.right * 0.2f) + (Vector3.up * 0.01f), Vector3.down),
    };

    for (int i = 0; i < rays.Length; i++)
    {
        if (Physics.Raycast(rays[i], 0.1f, groundLayerMask))
        {
            return true;
        }
    }

    return false;
}

private void OnDrawGizmos()
{
    Gizmos.color = Color.red;
    Gizmos.DrawRay(transform.position + (transform.forward * 0.2f), Vector3.down);
    Gizmos.DrawRay(transform.position + (-transform.forward * 0.2f), Vector3.down);
    Gizmos.DrawRay(transform.position + (transform.right * 0.2f), Vector3.down);
    Gizmos.DrawRay(transform.position + (-transform.right * 0.2f), Vector3.down);
}

public void ToggleCursor(bool toggle)
{
    Cursor.lockState = toggle ? CursorLockMode.None : CursorLockMode.Locked;
    canLook = !toggle;
}

 

 

플레이어 상태 및 UI

UI를 만들기 위해 빈 오브젝트를 만든다.

하단에 Canvas를 추가하고 해상도를 설정해준다.

 

HP 표시를 위해 Image들을 추가해준다.

 

이번엔 Slider가 아닌 Sprite로 HP를 표시해볼 것이기 때문에 Package Manager에서 2D Sprite를 설치한다.

Asset에 폴더를 만들고 그 안에 자유롭게 자르거나 늘리는 등 수정할 Sprite - Square를 만들어준다. Health 캔버스 아래 이미지 오브젝트를 하나 더 생성하고 Square를 넣어준 뒤, Image TypeFiled로 바꾼다. Sliced는 이미지를 잘라서 늘렸을 때, 늘어나야하는 방향을 설정해줘서 이미지가 깨지지 않게 한다. Tiled는 바둑 방식, Filed는 채울 때 사용한다.

 

빈 오브젝트를 추가하여 Vertical Layout Group으로 Health를 포함한 상태들을 묶어준다.

 

화면 중앙에 Image 두 개를 추가하여 Crosshair와 DamageIndicator을 만든다. DamageIndicator는 데미지를 입었을 때만 켜지도록 꺼두고 Player에 플레이어의 상태를 관리해줄 스크립트를 추가한다.

 

public으로 Max값, 시작값, 회복률, 감소율 등을 추가하고, [HideInInspector]로 Inspector창에서 수정할 수 없게 한다.

상태들을 불러오고 배고픔이 다 닳았을 때는 데미지가 감소하도록, 데미지를 받았을 때 처리할 이벤트를 받기 위해 UnityEvent [System.Serializable]로 플레이어의 상태들을 Inspector창에 노출시키고 상태 bar들을 넣어준다.

ublic interface IDamagable
{
    void TakePhysicalDamage(int damageAmount);
}

[System.Serializable]
public class Condition
{
    [HideInInspector]
    public float curValue;
    public float maxValue;
    public float startValue;
    public float regenRate;
    public float decayRate;
    public Image uiBar;

    public void Add(float amount)
    {
        curValue = Mathf.Min(curValue+ amount, maxValue);
    }

    public void Subtract(float amount) 
    {
        curValue = Mathf.Max(curValue - amount, 0.0f);
    }

    public float GetPercentage()
    {
        return curValue / maxValue;
    }

}

public class PlayerConditions : MonoBehaviour, IDamagable
{
    public Condition health;
    public Condition hunger;
    public Condition stamina;

    public float noHungerHealthDecay;

    public UnityEvent onTakeDamage;

    void Start()
    {
        health.curValue = health.startValue;
        hunger.curValue = hunger.startValue;
        stamina.curValue = stamina.startValue;
    }

    // Update is called once per frame
    void Update()
    {
        hunger.Subtract(hunger.decayRate * Time.deltaTime);
        stamina.Add(stamina.regenRate * Time.deltaTime);

        if(hunger.curValue == 0.0f)
            health.Subtract(noHungerHealthDecay * Time.deltaTime);

        if (health.curValue == 0.0f)
            Die();

        health.uiBar.fillAmount = health.GetPercentage();
        hunger.uiBar.fillAmount = hunger.GetPercentage();
        stamina.uiBar.fillAmount = stamina.GetPercentage();
    }

    public void Heal(float amount)
    {
        health.Add(amount);
    }

    public void Eat(float amount)
    {
        hunger.Add(amount);
    }

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

        stamina.Subtract(amount);
        return true;
    }

    public void Die()
    {
        Debug.Log("플레이어가 죽었다.");
    }

    public void TakePhysicalDamage(int damageAmount)
    {
        health.Subtract(damageAmount);
        onTakeDamage?.Invoke();
    }

 

Campfire 오브젝트를 만들고 범위를 지정하여 닿았을 때, 데미지가 들어오도록 한다.

 

Update에서 델타 타임을 쌓아가며 지정한 시간보다 넘어가면 실행하거나 Coroutine으로 wait for second로 시간을 기다리는 코들르 사용하지 않고 InvokeRepeating로 지연 실행시킨다. 아래 코드에서는 List를 사용했는데 삽입, 삭제가 한 번에 일어나기 때문에 더 빠른 HashSet을 사용해도 된다.

public int damage;
    public float damageRate;

    private List<IDamagable> thingsToDamage = new List<IDamagable>();

    private void Start()
    {
        InvokeRepeating("DealDamage", 0, damageRate);
    }

    void DealDamage()
    {
        for(int i =0;i<thingsToDamage.Count;i++)
        {
            thingsToDamage[i].TakePhysicalDamage(damage);
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        if(other.gameObject.TryGetComponent(out IDamagable damagable))
        {
            thingsToDamage.Add(damagable);
        }
    }

    private void OnTriggerExit(Collider other)
    {
        if (other.gameObject.TryGetComponent(out IDamagable damagable))
        {
            thingsToDamage.Remove(damagable);
        }
    }

 

데미지를 받았을 때, 아까 만들어둔 DamageIndicator이 켜지고 일정 시간 동안 감소되어 사라지는  스크립트를 추가한다.

public Image image;
    public float flashSpeed;

    private Coroutine coroutine;

    public void Flash()
    {
        if(coroutine != null)
        {
            StopCoroutine(coroutine);
        }

        image.enabled = true;
        image.color = Color.red;
        coroutine = StartCoroutine(FadeAway());
    }

    private IEnumerator FadeAway()
    {
        float startAlpha = 0.3f;
        float a = startAlpha;

        while(a > 0.0f)
        {
            a -= (startAlpha / flashSpeed) * Time.deltaTime;
            image.color = new Color(1.0f, 0.0f, 0.0f, a);
            yield return null;
        }

        image.enabled = false;
    }

 

플레이어가 데미지를 받았을 때, DamageIndicator가 실행하도록 한다.

 

그런데 안켜진다... image.enabled를 했기 때문에 오브젝트가 아니라 image 컴포넌트를 꺼야한다.

+ Recent posts