[Unity] (2024_09_30) 2D 플랫 포머 게임 개발일지
깃헙 링크(에셋 저작권 이슈 때문에 Script만 따로 빼옴) : https://github.com/ljs1206/NKProject-Script-
오늘 한거
구조 수정과 Attack 판정 구현(미완성)
갑자기 코딩 하다보니 PlayerMovement 스크립트에 범용성 너무 안좋다고 생각이 들어 코드를 수정하게 되었다.
using UnityEngine;
public class PlayerMovement : MonoBehaviour
{
private Rigidbody2D _rg2d;
private Transform _visualTrm;
[Header("Movement Settings")]
[SerializeField] private float _moveSpeed = 5f;
[Header("Jump Settings")]
[SerializeField] private Transform _footTrm;
[SerializeField] private float _jumpPower = 5f;
[SerializeField] private float _checkJumpRay;
[SerializeField] private LayerMask _whatIsGround;
[HideInInspector] public bool IsGround;
private void Awake()
{
_rg2d = GetComponent<Rigidbody2D>();
_visualTrm = transform.Find("Visual");
}
private void Start()
{
_InputReader.OnJumpEvent += HandleJumpEvent;
}
private void Update()
{
Move();
CheckOnTheGround();
}
private void CheckOnTheGround()
{
IsGround = Physics2D.Raycast(_footTrm.position, Vector3.down,
_checkJumpRay, _whatIsGround);
}
private void Move()
{
_rg2d.velocity = new Vector2(_InputReader.Movement.x *_moveSpeed ,_rg2d.velocity.y);
if (_InputReader.Movement.x != 0)
{
_visualTrm.localScale = new Vector3(_InputReader.Movement.x,
_visualTrm.localScale.y, _visualTrm.localScale.z) ;
}
}
private void HandleJumpEvent()
{
Debug.Log(IsGround);
if (IsGround)
{
_rg2d.AddForce(Vector2.up * _jumpPower, ForceMode2D.Impulse);
}
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
Gizmos.color = Color.green;
Gizmos.DrawRay(new Ray(_footTrm.position, Vector3.down));
}
#endif
}
원래 코드이다.
[Header("Input")]
[SerializeField] private InputReader _InputReader;
Player 스크립트에서도 InputReader를 사용하고 있는데 MovementCompo를 Player에서 받아오고 있으니 Player를 넘겨서 InputReader를 참조하는 식으로 변경하였다.
private PlayerMovement _playerMovement;
public PlayerMovement PlayerMovement => _playerMovement;
public InputReader InputReder => _InputReader;
// public PlayerVFX PlayerVFXCompo => VFXCompo as PlayerVFX;
protected override void Awake()
{
base.Awake();
_playerMovement = GetComponent<PlayerMovement>();
Player에서 따로 PlayerMovement를 들고오는 것이 아닌 Agent 에서 Movement류 스크립트를 들고 올 수 있도록 만들어 줬다. 왜냐하면 Agent를 상속 받는 클래스는 움직임이 있는 GameObject에 붙여질 예정이므로 굳이 자식에서 받는게 아니라 Agent에서 받아와 효율을 (같은 작업을 여러번 할 필요는 없다.) 챙기도록 하자 그러기 위해서는 상속을 통해서 Movement 스크립트에서 필요한 부분을 구현하였다.
Script : IMovement
using UnityEngine;
public interface IMovement
{
public Vector3 Velocity { get; }
public bool IsGround { get; }
public bool CanMove { get; set; }
public void Initialize(Agent agent);
public void Move();
public void StopImmediately();
public void GetKnockback(Vector3 force);
}
IMovement에서 Movement라면 꼭 필요한 요소들을 추가하였다.
현재 속도 (Velocity), 땅에 닿았는가? (IsGround), 움직일 수 있는가?(CanMove), 초기화(Initalize), 움직임 (Move), 즉시 정지 (StopImmediately), 넉백 (GetKnockback)
수정된 코드
using UnityEngine;
public class PlayerMovement : MonoBehaviour, IMovement
{
public Rigidbody2D Rg2d {get; protected set;}
public Vector3 Velocity => Rg2d.velocity;
public bool _isGround;
public bool IsGround => _isGround;
public bool CanMove { get; set; }
private Transform _visualTrm;
[Header("Movement Settings")]
[SerializeField] private float _moveSpeed = 5f;
[Header("Jump Settings")]
[SerializeField] private Transform _footTrm;
[SerializeField] private float _jumpPower = 5f;
[SerializeField] private float _checkJumpRay;
[SerializeField] private LayerMask _whatIsGround;
private Player _player;
public void Initialize(Agent agent)
{
_player = agent as Player;
Rg2d = GetComponent<Rigidbody2D>();
_visualTrm = transform.Find("Visual");
}
private void Start()
{
CanMove = true;
}
private void Update()
{
if(CanMove)
Move();
CheckOnTheGround();
}
public void StopImmediately(){
Rg2d.velocity = Vector2.zero;
}
private void CheckOnTheGround()
{
_isGround = Physics2D.Raycast(_footTrm.position, Vector3.down,
_checkJumpRay, _whatIsGround);
}
public void Move()
{
Rg2d.velocity = new Vector2(_player.InputReder.Movement.x *_moveSpeed ,Rg2d.velocity.y);
if (_player.InputReder.Movement.x != 0)
{
_visualTrm.localScale = new Vector3(_player.InputReder.Movement.x,
_visualTrm.localScale.y, _visualTrm.localScale.z) ;
}
}
public void Jump(){
if (IsGround)
{
Rg2d.AddForce(Vector2.up * _jumpPower, ForceMode2D.Impulse);
}
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
Gizmos.color = Color.green;
Gizmos.DrawRay(new Ray(_footTrm.position, Vector3.down));
}
public void GetKnockback(Vector3 force)
{
}
#endif
}
그리고 PlayerMovement에서 구현을 하였고 Initialize를 사용하여 Player를 받아와
InputReader를 사용하여 주었다.
public void Move()
{
Rg2d.velocity = new Vector2(_player.InputReder.Movement.x *_moveSpeed ,Rg2d.velocity.y);
if (_player.InputReder.Movement.x != 0)
{
_visualTrm.localScale = new Vector3(_player.InputReder.Movement.x,
_visualTrm.localScale.y, _visualTrm.localScale.z) ;
}
}
Agent도 다음과 같이 수정하였다.
public abstract class Agent : MonoBehaviour
{
#region component list section
public Animator AnimatorCompo { get; protected set; }
public SpriteRenderer SpriteRendererComp { get; protected set; }
public IMovement MovementCompo { get; protected set; }
#endregion
public Transform VisualTrm {get; private set;}
public bool CanStateChangeable { get; protected set; } = true;
public bool isDead;
protected virtual void Awake()
{
VisualTrm = transform.Find("Visual");
AnimatorCompo = VisualTrm.GetComponent<Animator>();
SpriteRendererComp = VisualTrm.GetComponent<SpriteRenderer>();
MovementCompo = GetComponent<IMovement>();
MovementCompo.Initialize(this);
IMovement를 통해서 무엇을 만들지 몰라도 가져올 수 있는 모습이다.
이외에 원래 PlayerMovement로 받아오던 스크립트들도 전부 MovementCompo로 수정하여주었다.
Ex)
PlayerRunState
public override void UpdateState()
{
base.UpdateState();
if(_player.InputReder.Movement.x == 0){
_stateMachine.ChangeState(PlayerStateEnum.Idle);
}
else{
_player.MovementCompo.Move();
}
}
Player
public void Jump()
{
(MovementCompo as PlayerMovement)?.Jump();
}
as를 하였을때 Null이 아니라면 PlayerMovement에 Jump를 사용하는 쪽으로 수정하였다.
Attack
그 다음으로는 공격을 구현하였다.
공격 같은 경우에는 특정 범위 만큼 BoxCast를 발사하는 식의 로직을 구현하였다.
우린 BoxCast 중에서 BoxCastAll을 사용할 것이다.
BoxCast는 특정위치에서 부터 특정방향으로 특정거리만큼 특정 크기의 Box모양의 충돌체를 발사하여 닿은 적의 정보를 확인하는 녀석이다. BoxCast는 종류가 여러가지 있는데 (BoxCast, BoxCastAll, BoxCastNonAlloc) 그중에서 BoxCastNonAlloc이 있다.
BoxCastNonAlloc에서 대해서 설명하기 전 비슷한 녀석인 BoxCastAll에서 대해서 설명을 하자면 단일 HitInfo만 체크 할수 있었던 BoxCast와 달리 여러개의 HitInfo를 반환해주는 녀석인데. 반환값이 RayCastHit2D의 배열이다. 여기서 문제가 생긴다. 반환을 할때마다 배열을 생성하면 공격을 할때마다 배열이 생기는 것 이므로 이부분에서 메모리 낭비를 하게 된다. 이 부분을 해결하기 위해서 나온 녀석이 BoxCastNonAlloc이다. 이녀석은 인자값으로 RayCastHit2D배열을 넣어 그 배열에 충돌 정보들을 넣어주어 반환해준다. 따라서 배열을 한번만 생성하고도 정보들을 모두 받을 수 있으므로 메모리 낭비를 줄었다고 볼 수 있다.
근데 왜 BoxCastAll을 쓰라고 할까? 2D 환경에서는 놀랍게도 NonAlloc이 지원하지 않는다! 정확하게는 2020버전에서 부터 BoxCast와 NonAlloc을 통합해서 하나의 메서드로 쓰기 위해서 변경했다고 한다. 나도 개발하면서 처음 알게 된 사실이다. (근데 Unity5에도 2D에 그대로 NonAlloc을 가져온 유니티가 참 이해가 안된다...)
Script : DamageCaster
using UnityEngine;
public class DamageCaster : MonoBehaviour
{
[Header("Debug")]
[SerializeField] private int _damage; // 적에게 줄 데미지(나중에 수정할 예정)
[Header("Cast Settings")]
[SerializeField] private Transform _attackTrm; // Cast시작 위치
[SerializeField] private LayerMask _whatIsTarget; // Cast에 걸릴 Layer
[SerializeField] private Vector2 _castBoxSize;
[SerializeField] private float _castDistance;
[SerializeField]
[Range(0, 1f)]
private float _casterInterpolation = 0.5f;
private Agent _owner;
public void InitCaster(Agent agent)
{
_owner = agent;
}
private RaycastHit2D[] _hitInfos = {};
public bool CastDamage()
{
Vector3 visualScaleVec = new Vector3(transform.parent.localScale.x, 0, 0);
Vector3 startPos = GetStartPos();
_hitInfos = Physics2D.BoxCastAll(startPos, _castBoxSize, 0,
visualScaleVec, _castDistance, _whatIsTarget);
if (_hitInfos.Length > 0)
{
foreach(RaycastHit2D hit in _hitInfos){
if(hit.collider.TryGetComponent(out IDamageable health)){
health.ApplyDamage(_damage, hit.point, hit.normal, 5, _owner, DamageType.Melee);
}
else{
Debug.Log($"{hit.collider.name} is don’t have Helath Componenet");
}
}
}
return _hitInfos.Length > 0;
}
private Vector3 GetStartPos()
{
return _attackTrm.position + new Vector3(transform.parent.localScale.x, 0, 0) * -_casterInterpolation * 2;
}
public void OnDrawGizmos(){
Vector3 visualScaleVec = new Vector3(transform.parent.localScale.x, 0, 0);
Gizmos.color = Color.green;
Gizmos.DrawLine(transform.position, transform.position + new Vector3(transform.parent.localScale.x, 0, 0));
Gizmos.DrawWireCube(GetStartPos(), _castBoxSize);
Gizmos.color = Color.red;
Gizmos.DrawWireCube(GetStartPos() + visualScaleVec * _castDistance, _castBoxSize);
Gizmos.color = Color.white;
}
}
변수 부터 차근차근 살펴보자
[Header("Debug")]
[SerializeField] private int _damage; // 적에게 줄 데미지(나중에 수정할 예정)
[Header("Cast Settings")]
[SerializeField] private Transform _attackTrm; // Cast시작 위치
[SerializeField] private LayerMask _whatIsTarget; // Cast에 걸릴 Layer
[SerializeField] private Vector2 _castBoxSize;
[SerializeField] private float _castDistance;
[SerializeField]
[Range(0, 1f)]
private float _casterInterpolation = 0.5f;
private Agent _owner;
Debug은 무시 해도 되고
CastSetting에서는
_attackTrm은 공격이 시작되는 지점 (BoxCast 시작 지점)
_whatIsTarget은 BoxCast에 감지될 Layer이다.
_castBoxSize는 Cast할 Box의 Size이다.
_castDistance는 BoxCast를 발사할 길이이다.
_casterInterpolation는 Cast의 위치가 원하는 위치와 맞지 않을 때 조절하여 위치를 맞추는 용도의 변수이다.
_owner는 누가 DamageCaster를 소유하고 있는 지를 알기 위해서 가지고 있다.
InitCaster는 넘어가고 (초기화 작업임)
private RaycastHit2D[] _hitInfos = {};
public bool CastDamage() // 피격 되었나?
{
bool isSucess = false;
Vector3 visualScaleVec = new Vector3(transform.parent.localScale.x, 0, 0);
Vector3 startPos = GetStartPos();
_hitInfos = Physics2D.BoxCastAll(startPos, _castBoxSize, 0,
visualScaleVec, _castDistance, _whatIsTarget);
if (_hitInfos.Length > 0)
{
foreach(RaycastHit2D hit in _hitInfos){
if(hit.collider.TryGetComponent(out IDamageable health))
{
if(!isSucess) isSucess = true;
health.ApplyDamage(_damage, hit.point, hit.normal, 5, _owner, DamageType.Melee);
}
else{
Debug.Log($"{hit.collider.name} is don’t have Helath Componenet");
}
}
}
return isSucess;
}
먼저 RayCastHit2D 배열을 미리 만들어서 한번만 초기화 시켜준다.
그 다음 RayCast 연산에 필요한 플레이어의 스케일 x값(방향 전환을 스케일로 함 따라서 방향에 따른 Caster의 변화가 필요하니 가지고 온다.)만 가지고 있는 백터와 Cast시작 위치를 구한다. 그다음 BoxCastAll를 사용하여 hitInfo에 넣어준다.
만일 배열에 하나라도 발견이 되었다면 foreach문을 돌아 TryGetCompontnent를 사용하여 IDamageable 스크립트를 상속받은 스크립트가 있는지 확인한다. 있다면 ApplyDamage를 통해서 실제 데미지를 입힘 없다면 현재 피격당한 Object에 IDamageable이 없음을 알려준다. 그리고 IDamageable을 하나라도 찾았다면 isSucess를 true로 만들어 공격이 성공했음을 반환 시킨다.
private Vector3 GetStartPos() // Cast 시작 위치 구하는 Method
{
return _attackTrm.position + new Vector3(transform.parent.localScale.x, 0, 0) * -_casterInterpolation * 2;
}
Cast의 시작 위치를 구하는 함수로 현재 공격 위치에서 부모(visual)의 localScaleX값을 들고 와 더해 회전을 적용 시키고 보간값의 2배의 -를 곱해서 보간을 적용시켜준다.
DrawGizoms는 말 그대로 원하는 Gizoms를 그려주는 역할을 한다.
https://docs.unity3d.com/ScriptReference/MonoBehaviour.OnDrawGizmos.html
Unity - Scripting API: MonoBehaviour.OnDrawGizmos()
Success! Thank you for helping us improve the quality of Unity Documentation. Although we cannot accept all submissions, we do read each suggested change from our users and will make updates where applicable. Close
docs.unity3d.com
공식 문서를 보면서 참고하자.
여튼 이런 식으로 코드를 짜고
이렇게 게임 오브젝트 만들고 하면
이렇게 나오게 된다. 초록색 박스가 Cast 시작 위치이고 빨간색 박스가 끝 위치이다.
Debug을 했더니
Hit가 잘 뜨는 모습이다. 공격 판정에 대한 값은 나중에 수정할 예정
그럼 공격이 잘 되니 Hp를 만들 차례이다. 아까 설명을 안하고 스킵했던 IDamageable이 Hp 역활 해준다.
Script : IDamageable
using UnityEngine;
public enum DamageType
{
Melee,
Projectile
}
public interface IDamageable
{
public void ApplyDamage(
int damage, Vector3 hitPoint, Vector3 normal, float knockbackPower, Agent dealer, DamageType damageType);
}
interface로 구현하였다 따로 인터페이스를 구현한 이유는 데미지를 입을 있는 적과 아닌 적을 구분하기 위한 용도이다. enum DamageType은 이 공격이 무슨 공격인지에 대해 알려주는 Enum이다. ApplyDamage는 데미지를 주는 함수이자. 데미지를 줄때 어떤 작업을 처리하는 함수이다. 매개변수로는 실제 입힐 데미지, 나중에 Effect를 위한 hitPoint 넉백을 구현하기 위한 normal, 넉백 파워와, 데미지를 입힌 Agent가 누구인지 알 수 있게 넘겨주고, 데미지의 타입 넘겨준다. (타입에 따라서 처리해야할 작업이 다를 수도 있음)
IDamageable을 상속받는 Health (실제 체력관련한 스크립트) 스크립트를 구현해준다.
Script : Health
using UnityEngine;
using UnityEngine.Events;
public class Health : MonoBehaviour, IDamageable
{
public UnityEvent OnHitEvent;
public UnityEvent OnDeadEvent;
[SerializeField] private float _maxHealth = 100f;
private Agent _owner;
private float _currentHealth;
public void Initialize(Agent player)
{
_owner = player;
_currentHealth = _maxHealth;
}
public void ApplyDamage(int damage, Vector3 hitPoint, Vector3 normal, float knockbackPower, Agent dealer,
DamageType damageType)
{
if (_owner.isDead) return;
_currentHealth = Mathf.Clamp(
_currentHealth - damage, 0, _maxHealth);
OnHitEvent?.Invoke();
if(_currentHealth <= 0)
{
OnDeadEvent?.Invoke();
}
}
}
전체적으로 하는 일은 ApplyDamage에서 전부 다 처리한다.
maxHealth는 Debug용 나중에 수정할 예정이다..
public UnityEvent OnHitEvent;
public UnityEvent OnDeadEvent;
히트 시에 실행 시킬 이벤트, 죽었을 때 실행 시킬 이벤트이다.
private Agent _owner;
private float _currentHealth;
소유자와 현재 체력이다. 그 다음 초기화함
public void ApplyDamage(int damage, Vector3 hitPoint, Vector3 normal, float knockbackPower, Agent dealer,
DamageType damageType)
{
if (_owner.isDead) return;
_currentHealth = Mathf.Clamp(
_currentHealth - damage, 0, _maxHealth);
OnHitEvent?.Invoke();
if(_currentHealth <= 0)
{
OnDeadEvent?.Invoke();
}
}
죽었다면 데미지를 받을 필요가 없으니까 반환한다. 현제 체력에서 damage를 뺀 값을 clamp를 사용하여 0이하 또는 maxHealth 이상으로 넘치지 않게 한다. 그리고 HitEvent 실행 만약 죽었다면 (currnetHealth <= 0) DeadEvent 실행
이럼 끝이다. 유니티에 잘 되는 지 확인해보자!
Dummy에 Health 컴포넌트 넣고 Agent가 존재해야 하니 Agent를 상속받는 Dummy 스크립트를 만들어줬다. 딱히 코드는 안적음 (Debug용이긴함)
Player DamageCaster의 데미지를 50으로 바꾸고 Event도 간단하게 출력되는 테스트 스크립트를 넣어준다.
이렇게 하고 실행하면
잘 된다! 다음에는 Hit Effect와 공격에 이펙트를 추가해봐야겠다. 없으니까 좀 이상하다 ㅋㅋ;;