✌️개요
3인칭 액션게임을 개인 프로젝트로 작업 중입니다
유니티 공식 템플릿인 3D Game Kit Lite를 분석중이었는데(아래 링크)
데미지를 입을 수 있는 오브젝트에 사용하는 `Damageable`스크립트가 모듈화가 굉장히 잘돼있다고 느꼈습니다
제가 개인적으로 사용하던 같은 용도의 스크립트보다 이게 더 낫다 싶어서
이 참에 구조를 좀 기록해둘려고 합니다
🔥본문
📌 Damageable 스크립트
우선 메인이되는 `Damageable`스크립트는 이렇습니다
원본의 주석과 툴팁 속성은 한글로 번역해놨습니다
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using Oni.Message;
using MessageType = Oni.Message.MessageType;
public class Damageable : MonoBehaviour
{
// HP
public int maxHitPoints;
[Tooltip("데미지 받은 이후 무적 시간")]
public float invulnerabiltyTime;
[Tooltip("데미지를 입을 수 있는 각도를 제한 hitForwardRotation을 기준으로 설정됨")]
[Range(0.0f, 360.0f)]
public float hitAngle = 360.0f;
[Tooltip("hitAngleZone을 설정할 월드 forward방향")]
[Range(0.0f, 360.0f)]
public float hitForwardRotation = 360.0f;
// 지금 무적상태인지 여부
public bool isInvulnerable { get; set; }
// currentHP
public int currentHitPoints { get; private set; }
public UnityEvent OnReceiveDamage, OnDeath, OnHitWhileInvulnerable, OnBecomeVulnerable, OnResetDamage;
[Tooltip("이 오브젝트가 데미지를 입을 때 알림받을 오브젝트들")]
public List<MonoBehaviour> onDamageMessageReceivers;
protected float m_timeSinceLastHit = 0.0f;
protected Collider m_Collider;
System.Action schedule;
void Start()
{
ResetDamage();
m_Collider = GetComponent<Collider>();
}
void Update()
{
if (isInvulnerable)
{
m_timeSinceLastHit += Time.deltaTime;
if (m_timeSinceLastHit > invulnerabiltyTime)
{
m_timeSinceLastHit = 0.0f;
isInvulnerable = false;
OnBecomeVulnerable.Invoke();
}
}
}
public void ResetDamage()
{
currentHitPoints = maxHitPoints;
isInvulnerable = false;
m_timeSinceLastHit = 0.0f;
OnResetDamage.Invoke();
}
public void SetColliderState(bool enabled)
{
m_Collider.enabled = enabled;
}
public void ApplyDamage(DamageMessage data)
{
if (currentHitPoints <= 0)
{
//이미 죽은 상태라면 리턴. TODO : 죽은 이후에도 데미지 판정을 체크해야 할 경우 처리...
return;
}
if (isInvulnerable)
{
OnHitWhileInvulnerable.Invoke();
return;
}
Vector3 forward = transform.forward;
forward = Quaternion.AngleAxis(hitForwardRotation, transform.up) * forward;
// 데미지를 가한 오브젝트와의 각도를 구하기 위한 dot 연산
Vector3 positionToDamager = data.damageSource - transform.position;
positionToDamager -= transform.up * Vector3.Dot(transform.up, positionToDamager);
if (Vector3.Angle(forward, positionToDamager) > hitAngle * 0.5f)
return;
isInvulnerable = true;
currentHitPoints -= data.amount;
if (currentHitPoints <= 0)
schedule += OnDeath.Invoke; // 서로 동시에 죽이는 상황이 왔을 때 발생할 문제를 대비하여 LateUpdate로 사망 이벤트를 넘김
else
OnReceiveDamage.Invoke();
var messageType = currentHitPoints <= 0 ? MessageType.DEAD : MessageType.DAMAGED;
for (var i = 0; i < onDamageMessageReceivers.Count; ++i)
{
var receiver = onDamageMessageReceivers[i] as IMessageReceiver;
receiver.OnReceiveMessage(messageType, this, data);
}
}
void LateUpdate()
{
if (schedule != null)
{
schedule();
schedule = null;
}
}
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
Vector3 forward = transform.forward;
forward = Quaternion.AngleAxis(hitForwardRotation, transform.up) * forward;
if (Event.current.type == EventType.Repaint)
{
UnityEditor.Handles.color = Color.blue;
UnityEditor.Handles.ArrowHandleCap(0, transform.position, Quaternion.LookRotation(forward), 1.0f,
EventType.Repaint);
}
UnityEditor.Handles.color = new Color(1.0f, 0.0f, 0.0f, 0.5f);
forward = Quaternion.AngleAxis(-hitAngle * 0.5f, transform.up) * forward;
UnityEditor.Handles.DrawSolidArc(transform.position, transform.up, forward, hitAngle, 1.0f);
}
#endif
// 파라미터로 넘기는 DamageMessage구조체. 데미지 이벤트에 필요한 정보들을 추가
public struct DamageMessage
{
public MonoBehaviour damager;
public int amount;
public Vector3 direction;
public Vector3 damageSource;
public bool throwing;
public bool stopCamera;
}
}
🙆♂️이 스크립트에서 제가 생각한 잘돼있다고 느끼는 부분들은 이렇습니다
- `Line 28: 다양한 경우의 이벤트`
- 다양한 케이스의 이벤트들이 정의돼있습니다
- 데미지를 받을 때, 죽을 때, 무적 시간 중 피격됐을 때, 무적이 끝났을 때, 초기화될 때
- `Line 89: 데미지가 들어올 수 있는 각도를 제한할 수 있는 기능`
- 데미지를 가한 객체의 위치 벡터와 본 객체의 위치 벡터로 내적하여 각도를 구한 후 데미지 범위 안에 있는지 확인합니다
- 프로젝트 기획에 따라서 아예 쓸모 없을 수 있는 기능이지만 이런 연산을 이해하기 좋은 예제입니다
- `Line 99: Death이벤트를 `LateUpdate 에서 처리하도록 스케쥴링`
- 원본 주석에 따르면 두 캐릭터가 전투 중 동시에 죽었을때의 문제를 대비한 처리라고 합니다
- 확실히 구현에 따라서 A오브젝트가 막타를 맞고 죽었을 때 막타를 친 오브젝트 B도 죽어있는 상태라고 확인되면 문제가 예외가 생길 수 있습니다
- 구현에 따라서 유용할 수 있어보입니다
- `Line 103: 메세지 시스템`
- 데미지를 입었을 때 캐릭터의 메인 클래스로 알려야할겁니다 그걸 위한 메세지 시스템입니다
- `IMessageReceiver`인터페이스를 상속하는 오브젝트들을 가지고 있다가 순회하며 알려줍니다
- 현재 HP가 남아있으면 `DAMAGED`메세지, HP가 없으면 `DEAD`메세지를 전송합니다
- 제가 생각하는 이 메세지 시스템의 장점은 이렇습니다
- 데미지를 입었을때 받을 이벤트가 `UnityEvent`가 아니라서 인스펙터에서 손으로 함수를 직접 연결하는 번거로움이 없습니다
- 그리고 인스펙터에서 메세지를 받을 객체들은 리스트에 넣기때문에 어떤 오브젝트들이 피격 이벤트를 받는지 한눈에 들어옵니다
- 케이스가 새로 생겼을 땐 보내는쪽은 `MessageType` enum을 변경해서 보내면돼서 수정이 쉽습니다
- Receiver들을 가지고있다가 알려준다는 개념이 `Observer`패턴과 유사합니다
📌 MessageSystem 스크립트
위에서 극찬한 `IMessageReceiver`와 `MessageType`의 구현부는 이렇습니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Oni
{
namespace Message
{
public enum MessageType
{
DAMAGED,
DEAD,
RESPAWN,
//Add your user defined message type after
}
public interface IMessageReceiver
{
void OnReceiveMessage(MessageType type, object sender, object msg);
}
}
}
참고로 `enum MessageType`은 `UnityEditor`네임스페이스에도 같은 이름으로 있기때문에,
이 스크립트처럼 네임스페이스로 감싸주는게 좋습니다
🙆♂️`IMessageReceiver`인터페이스에서 제가 생각한 잘돼있다고 느끼는 부분들은 이렇습니다
- 일단 간단 명료합니다 라인도 본문은 10줄밖에 안됩니다
- 파라미터 `sender, msg`가 `object`타입입니다
- 파라미터가 특정한 한 타입으로 종속돼있지 않습니다
- 따라서 확장성이 용이합니다
- `Damageable Line 108`처럼 `DamageMessage`구조체를 파라미터로 담아서 보내면 받는쪽에서는 `object`타입을 다시 `DamageMessage`타입으로 변환해서 사용하면 됩니다
🙅♂️다만 단점도 있습니다
- 이런식으로 모든 타입에대해서 `object`로 boxing하고 다시 Receiver에서 원하는 타입으로 unboxing해서 쓰는것은 계산비용이 많이 드는 과정입니다
- 성능에 민감한 프로젝트이거나 최적화가 아주 중요하다면 이처럼 boxing, unboxing하는 과정보다는 Generic메소드로 변경하거나, 경우의 수에따른 파라미터 타입들을 오버로딩해서 정의하는게 좋아보입니다
📌 메세지를 받는 쪽 예시
`IMessageReceiver`인터페이스를 상속받고
`Damageable`로부터 메세지를 받는쪽인 이런식으로 인터페이스 함수를 구성합니다
public void OnReceiveMessage(MessageType type, object sender, object data)
{
switch (type)
{
case MessageType.DAMAGED:
{
Damageable.DamageMessage damageData = (Damageable.DamageMessage)data;
Damaged(damageData);
}
break;
case MessageType.DEAD:
{
Damageable.DamageMessage damageData = (Damageable.DamageMessage)data;
Die(damageData);
}
break;
}
}
`MessageType`에
위에서 설명했듯이 `object`타입으로 들어오는 데이터를 필요한 타입(`DamageMessage)로 변환한 후 후처리 함수를 호출합니다
한눈에 들어오고 확장도 용이해 보입니다
🍎결론
해당 템플릿의 `PlayerController`스크립트도 아주 예제입니다
다만 `PlayerController`스크립트의 경우 프로세스들의 종착지들이 많기때문에 종속된게 많아서 타고 타고 들어가며 확인을 좀 해야합니다
아무튼 잘 구현된 `Damageable`스크립트를 분석해봤습니다
이제 이 스크립트를 토대로 제 `Damageable`스크립트, 그리고 다른 스크립트들도 적용 가능한 부분이 있다면 업그레이드 시켜봐야겠습니다
'Unity > C#' 카테고리의 다른 글
[C#] 빠르게 C# 단일 스크립트를 슥 작성하고 쇽 실행하는 법 (polyglot notebooks) (0) | 2024.06.27 |
---|---|
[Unity] JsonUtility.ToJson() 대신 Jobject.FromObject()를 쓰자 (1) | 2024.06.17 |
[Unity] 짧은 팁 - 사용중인 시스템 메모리 용량 구하기 (0) | 2024.04.29 |
[C#] DateTime에서 남은시간 계산하기 (0) | 2023.02.25 |
[Unity] Builder패턴으로 팝업 시스템 구현 (0) | 2023.02.16 |