📌
학습 내용
1. 트러블슈팅
2. 플레이어 상태 및 UI
2. 인터페이스를 활용한 플레이어 데미지 처리
# 트러블슈팅
1. IsGround 실행 오류
코드를 분석하면서 다시 프로젝트를 만들어 작업하던 와중 오류가 발생했다. 무한점프를 막기 위해 땅에 닿았는지 체크하는 IsGround 함수에서 Ray가 땅을 감지하지 못하는 오류였다.
관련 내용은 아래 링크에서 Jump 구현을 참고하면 된다. 이번 오류를 고치면서 조금 더 자세히 이해하게 된 부분은 해당 게시글에서 추가 수정할 예정이다. (ray의 시작 위치가 앞뒤양옆 말고도 조금 위에서 시작되는 이유 등)
원인은 간단했다.
기존에는 Mesh 없이 오브젝트에 콜라이더만 적용해놓고 Center 위치를 조정하면서 Transform 위치가 최하단으로 내려가있다.
하지만 나는 리펙토링 과정에서 플레이어 오브젝트를 시각적으로 확인하고 싶어서 캡슐 Mesh를 가져오게 되었고, 이 과정에서 Transform 가 중앙으로 고정되었다.
처음에는 Transform을 어떻게든 기본 예제처럼 맞추려고 했지만 해당 방법은 Mesh 컴포넌트를 수정해야 하기 때문에 권장되지 않았다. Transform의 위치가 코드에서 무엇을 결정하는지만 정확히 안다면 간단히 코드만 바꿔서 해결이 가능했다.
Ray[] rays = new Ray[4]
{
new Ray(transform.position + (transform.forward * 0.2f) + (transform.up * 0.01f), Vector3.down),
new Ray(transform.position + (-transform.forward * 0.2f) + (transform.up * 0.01f), Vector3.down),
new Ray(transform.position + (transform.right * 0.2f) + (transform.up * 0.01f), Vector3.down),
new Ray(transform.position + (-transform.right * 0.2f) +(transform.up * 0.01f), Vector3.down)
};
Ray는 현재 Transform 위치(정확히는 Transform 에서 앞뒤양옆 + 조금 위쪽)에서 시작해 아래로 발사된다.
for (int i = 0; i < rays.Length; i++)
{
if (Physics.Raycast(rays[i], 1.1f, groundLayerMask))
{
Debug.DrawRay(rays[i].origin, rays[i].direction * 1f, Color.red, 1.0f);
return true;
}
}
기존 예제의 경우 Transform 이 객체의 최하단, 즉 땅과 가까웠기 때문에 0.1f의 길이만큼 발사해도 땅에 닿았지만, 현재는 중앙에 위치해 있기 때문에 길이를 1.1f로 길게 늘려서 해결했다. 시작 위치를 바꿔야 된다는 착각을 하고 있어서 헤맸던 오류였다.
✅ Debug.DrawRay
문제 해결을 위해 사용했던 Debug.DrawRay 메서드까지 가볍게 살펴보자.
Debug.DrawRay(//시작 위치, //방향과 길이, //색상, //지속 시간);
DrawRay 는 씬에서만 확인이 가능하다. 지속 시간이 너무 짧게 설정될 경우 육안으로 확인되지 않을 수도 있기 때문에 적절한 설정값이 필요하다.
# 기초 4강 플레이어 상태 및 UI
2. 플레이어 상태 및 UI
✅ 편리한 UI 작업 기능
클래스 분석에 들어가기 전에 UI 작업에 사용한 기능을 가볍게 정리해보자.
[1] 2D sprite
예전에는 게이지 바를 Scale로 조정했지만, Package Manager에서 2D Sprite를 설치해 관련 메서드를 활용하면 게이지 조절을 보다 섬세하고 쉽게 구현할 수 있다.
[2] Layout Group
UI를 자동으로 정렬하는 컴포넌트이다. 자식 레이아웃을 일정한 간격으로 배치하는 작업에 용이하며 주로 리스트형 UI에 사용된다. 종료로는 수직 레이아웃 그룹(Vertical Layout Group), 수평 레이아웃 그룹(Horizontal Layout Group), 그리드 레이아웃 그룹(Grid Layout Group)이 존재한다.
✅ 플레이어 다이어그램
컨디션의 공통 속성과 공통 메서드를 정의한 컨디션 클래스를 생성한다. UI 클래스는 컨디션 클래스를 참조한 매개 변수를 선언해 사용한다. 즉, 해당 변수들은 컨디션 클래스에 있는 속성과 메서드를 가지고 있게 된다. 플레이어 컨디션 클래스는 이 UI 클래스를 참조해 컨디션 클래스에 있는 속성과 메서드를 호출해서 값을 조정하고 관리하는 역할을 한다.
✅ Condition 클래스
public class Condition : MonoBehaviour
{
public float curValue;
public float maxValue;
public float startValue;
public float passiveValue;
public Image uiBar;
private void Start()
{
curValue = startValue;
}
private void Update()
{
uiBar.fillAmount = GetPercentage();
}
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;
}
}
컨디션 객체(체력, 배고픔, 스테미나 등)들이 공통적으로 가져야 하는 속성과 메서드를 관리한다. 해당 클래스는 오브젝트의 컴포넌트에 붙여서 인스펙터 창으로 최소값, 최대값 등을 조절해서 사용할 수 있다.
✅ UICondition 클래스
using UnityEngine;
public class UICondition : MonoBehaviour
{
public Condition health;
public Condition hunger;
public Condition stamina;
private void Start()
{
CharacterManager.Instance.Player.condition.uiCondition = this;
}
}
컨디션 클래스를 인스턴스로 가지는 매개 변수를 필드에 선언해 사용한다. 인스펙터에서 각 매개변수에서 condition 스크립트가 붙은 오브젝트를 드롭 앤 드롭으로 할당했을 때 condition 클래스의 컴포넌트를 받아올 수 있는 참조 관계가 성립된다.
✅ UICondition를 별도로 만드는 이유
객체 지향적인 관점에서, 공통된 속성과 메서드를 가지는 객체들을 독립적으로 만들 필요가 없기 때문이다.
체력 클래스, 스테미나 클래스를 따로 만드는 것보다 컨디션 객체가 가져야 하는 데이터를 독립된 클래스로 만들어놓고 해당 클래스를 참조해서 사용하는 것이 확장성과 유연성을 보장할 수 있다. UI에서 보여줘야 하는 플레이어의 상태 속성이 늘어난 경우에, 매개 변수만 하나 더 선언해주고 해당 UI 게임 오브젝트를 할당해주는 식의 편리한 응용이 가능하다는 것이다.
이처럼 여러 개의 객체가 완전히 동일한 속성과 메서드를 가질 때에는 인터페이스나 추상 클래스를 사용할 필요 없이 Composition 구성으로 직접적인 참조를 통해 접근하는 방식을 사용할 수 있다.
3. 인터페이스를 활용한 데미지 구현
✅ 인터페이스
객체 지향에서 사용되는 기능으로, 각기 다른 객체가 공통적으로 사용하는 기능(메서드)가 있을 때 해당 기능들을 인터페이스로 묶어서 구현한다. 인터페이스를 사용하면 중복된 코드를 줄일 수 있고 캡슐화 해서 관리하기 때문에 확장성과 유지 보수에 용이하다.
만약 플레이어와 몬스터 객체 모두에게 "데미지를 받는다"는 기능이 필요하다면 다음과 같이 활용이 가능하다.
public interface IDamagable // 인터페이스로 선언
{
void TakePhysicalDamage(int damageAmount); //
}
클래스는 인터페이스로 선언해야 하며, 공통으로 사용할 메서드는 코드를 작성하지 않고 선언만 한다.
public class Player : MonoBehaviour, IDamagable // 인터페이스 상속
{
void TakePhysicalDamage(// 인자);
{
// 플레이어가 데미지를 받을 때
}
}
public class Monster : MonoBehaviour, IDamagable // 인터페이스 상속
{
void TakePhysicalDamage(// 인자);
{
// 몬스터가 데미지를 받을 때
}
}
그 다음 TakePhysicalDamage 함수가 필요한 클래스 객체에서 인터페이스를 상속 받아서 사용하면 된다.
✅ 플레이어 데미지 구현
해당 강의에서는 캠프파이어 오브젝트에 충돌하면 플레이어가 데미지를 입는 기능을 구현했다. 다만, 캠프파이어 오브젝트는 데미지를 주는 역할만 하기 때문에 TakePhysicalDamage 함수가 필요하지 않다. 그래서 인터페이스를 상속 받지 않고 IDamagable을 구현한 다른 객체들과 상호작용을 하고 있다.
public class CampFire : MonoBehaviour
{
public int damage;
public float damageRate;
private List<IDamagable> things = new List<IDamagable>();
private void Start()
{
InvokeRepeating("DealDamage", 0, damageRate);
}
void DealDamage()
{
for(int i = 0; i<things.Count; i++)
{
things[i].TakePhysicalDamage(damage);
}
}
private void OnTriggerEnter(Collider other)
{
if(other.gameObject.TryGetComponent(out IDamagable damagable))
{
things.Add(damagable);
}
}
private void OnTriggerExit(Collider other)
{
if(other.gameObject.TryGetComponent(out IDamagable damagable))
{
things.Remove(damagable);
}
}
}
먼저 데미지를 받는 객체가 더 추가될 수 있기 때문에 IDamagable를 받는 객체를 List things로 저장한다. (객체가 얼마나 늘어날지 모르기 때문에 배열이 아닌 List를 사용하고 있다.) 그리고 DealDamage를 통해 things에 있는 모든 객체에게 damage를 준다. 이때의 damage가 TakePhysicalDamage 함수의 인자로 할당되게 되는 것이다.
아직 things에 있는 객체를 모르기 때문에 OnTriggerEnter 메서드로 트리거에 진입한 객체가 IDamagable 인터페이스를 구현하는지 확인하고, True 라면 things 리스트에 추가한다. 만약 트리거를 빠져나갔다면 리스트에서 제거한다.
public class DamageIndicator : MonoBehaviour
{
public Image image;
public float flashSpeed;
private Coroutine coroutine;
private void Start()
{
CharacterManager.Instance.Player.condition.onTakeDamage += Flash;
}
public void Flash()
{
if(coroutine != null)
{
StopCoroutine(coroutine);
}
image.enabled = true;
image.color = new Color(1f, 105f/255f, 105f/255f);
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(1f, 105f / 255f, 105f / 255f, a);
yield return null;
}
image.enabled = false;
}
}
DamageIndicator 클래스는 플레이어가 피해를 입을 때 화면에 깜빡이는 효과를 주는 역할을 한다. 이때 화면을 붉게 만드는 UI 이미지는 꺼진 상태로 존재하다가 플레이어가 데미지를 입었을 때 enabled의 값을 true로 바꿔서 켜지게 되며, 일정 간격(=flashspeed)으로 꺼졌다가 켜지기를 반복하게 된다.
// 플레이어 클래스
public void TakePhysicalDamage(int damageAmount)
{
health.Subtract(damageAmount);
onTakeDamage?.Invoke();
}
이때 플레이어의 TakePhysicalDamage가 Flash 메서드를 구독하고 있기 때문에 플레이어가 데미지를 입으면 자동으로 Flash 메서드가 호출되는 구조이다.
✅ GetComponent vs Invoke
GetComponent와 Invoke이 완전히 대체 관계는 아니지만, 기능적으로 큰 차이는 없다. GetComponent로 DamageIndicator를 참조해서 DamageIndicator.Flash 함수를 사용해도 똑같이 구현이 될 것이다. 다만 GetComponent로 사용할 경우 객체를 찾고, 해당 객체에 있는 컴포넌트를 불러와야 하기 때문에 구조가 복잡해지며 더 많은 메모리가 할당되게 된다.
GetComponent를 최소화 하기 위해서 Invoke가 존재한다고 생각하면 편리하다. 특히 onTakeDamage가 실행될 때 호출되는 메서드가 늘어날 경우에도 플레이어 클래스는 수정하지 않아도 되기 때문에 의존성을 약하게 만들어주고, 구독 기능을 사용해서 유지 보수성까지 잡을 수 있다.
✅ TryGetComponent
TryGetComponent 메서드는 특정 컴포넌트가 존재하는지 확인하고, 해당 컴포넌트를 out 키워드를 사용하여 호출한 변수에 저장한다. 일반 GetComponent 메서드와 달리 반환값을 가지는 것이 특징이며 만약 컴포넌트가 있다면 damagable은 true가 되고, 없다면 false를 반환한다.
다른 클래스에 접근하는 방식을 한 번 더 정리해보면 좋을 것 같다.
'[내배캠] 본 캠프 개발 학습 > 매일매일 쓰는 TIL' 카테고리의 다른 글
11월 14일 목요일 본 캠프 개발 일지 (0) | 2024.11.14 |
---|---|
11월 13일 수요일 본 캠프 개발 일지 | 오브젝트의 조작과 스크립트와의 연결 (3) | 2024.11.13 |
11월 11일 월요일 본 캠프 개발 일지 (3) | 2024.11.11 |
11월 9일 토요일 본 캠프 개발 일지 | 베이지반 특강 1회차 (3) | 2024.11.09 |
11월 8일 금요일 본 캠프 개발 일지 | 델리게이트 특강 (0) | 2024.11.08 |