TIL 0119 게임개발 입문 개인과제 - 2

강성원·2024년 1월 20일
0

TIL 오늘 배운 것

목록 보기
20/69

오늘 공부한 내용

강의가 생각보다 어려워서 과제 진행 못함.

오늘은 그냥 강의 하나씩 듣고 결과물을 이해한대로 설명하는 너낌으로
어려워서 이렇게라도 해야겠다.

하나하나 정리하는, 오래걸리는 공부가 도움이 됐던 경험이 있기 때문에 나의 방식을 믿고 진행할 것이다.

1-8 공격 시스템 구현

CharacterController

public class CharacterController : MonoBehaviour
{
    ,,,움직임, 조준 이벤트 생략,,,
    public event Action OnAttackEvent;

    private float _timeSinceLastAttack = float.MaxValue;
    protected bool IsAttacking { get; set; }


    protected virtual void Update()
    {
        HandleAttackDelay();
    }

    private void HandleAttackDelay()
    {
        if(_timeSinceLastAttack <= 0.2f)
        {
            _timeSinceLastAttack += Time.deltaTime;
        }
        
        if(IsAttacking && _timeSinceLastAttack > 0.2f)
        {
            _timeSinceLastAttack = 0;
            CallAttackEvent();
        }
    }

    ,,, 이벤트 호출 함수들 생략 ,,,
    public void CallAttackEvent()
    {
        OnAttackEvent?.Invoke();
    }
}
  • 우선 CharacterController 클래스는 이벤트를 모아놓고 호출하는 메서드를 지니고 있다.

  • 추가된 부분은 _timeSinceLastAttack가 0.2초가 지났고 IsAttacking가 true 상태면 공격 관련 델리게이트를 호출한다.

PlayerInputController : CharacterController

using UnityEngine.InputSystem;

public class PlayerInputController : CharacterController
{
    ,,,생략,,,
    
    public void OnLook(InputValue value)
    {
        Vector2 newAim = value.Get<Vector2>();
        //전체 스크린 좌표계에 종속된 마우스의 위치를 게임 화면의 좌표계로 변경
        Vector2 worldPos = _camera.ScreenToWorldPoint(newAim);
        newAim = (worldPos - (Vector2)transform.position).normalized;

        if(newAim.magnitude >= .9f)
            CallLookEvent(newAim);
    }    

    public void OnFire(InputValue value)
    {
        IsAttacking = value.isPressed;
    }
}
  • OnFire()메서드는 인풋 시스템에서 만들었던 Action에 바인딩 된 함수이다.
    마우스 클릭의 여부가 들어오는 값이다.

  • 마우스가 클릭 중이라면 CharacterController로부터 상속받은 IsAttacking에 클릭 여부를 할당해준다. -> 클릭 중이면 true 할당

Shooting

public class Shooting : MonoBehaviour
{
    private CharacterController _controller;
	
    
    [SerializeField] private Transform projectileSpawnPosition; // 총알 스폰 위치
    
    private Vector2 _aimDirection = Vector2.right; // 조준 방향
	
    [SerializeField] private GameObject tempPrefab; // 총알 프리팹

    private void Awake()
    {
        _controller = GetComponent<CharacterController>();
    }

    private void Start() // 이벤트 등록
    {
        _controller.OnAttackEvent += OnShoot;
        _controller.OnLookEvent += OnAim;

    }

    private void OnAim(Vector2 newAimDirection)
    {
        _aimDirection = newAimDirection;
    }

    private void OnShoot()
    {
        CreateProjectile();
    }

    private void CreateProjectile()
    {
        Instantiate(tempPrefab, projectileSpawnPosition.position, Quaternion.identity);
        
    }
}
  • OnAim() : 전달 받은 벡터 값이 조준하는 방향이 된다.
    이 메서드가 이벤트에 등록이 되고 -> 이벤트를 호출하는 부분에서 캐릭터에서 마우스로 향하는 방향이 인자로 전달된다.
    즉 (마우스 좌표 - 캐릭터 좌표)를 조준 방향으로 할당한다.

  • OnShoot() / CreateProjectile() : 화살 프리팹을 생성한다.


1-9 스크립터블 오브젝트로 스탯 관리하기

스크립터블 오브젝트

Scriptable Object클래스는 재사용 할 데이터를 설정하고 저장하는데 쓰인다.
이 기능을 사용하지 않으면 클래스의 객체마다 같은 형식의 데이터를 가지고 있고, 객체가 많아질수록 차지하는 메모리는 늘어날 수 밖에 없다.
아래 RangedAttackDataAttackSO는 스크립터블 오브젝트이다.

AttackSO : ScriptableObject

[CreateAssetMenu(fileName = "DefaultAttackData", menuName = "TopDownController/Attacks/Default", order = 0)]
public class AttackSO : ScriptableObject // 상속 다름
{
    [Header("Attack Info")]
    public float size;
    public float delay;
    public float power;
    public float speed;
    public LayerMask target;

    [Header("Knock Back Info")]
    public bool isOnKnockback;
    public float knockbackPower;
    public float knockbackTime;
}

ScriptableObject를 상속 시키고 사용할 데이터들을 이 곳에 적어준다.

  • CreateAssetMenu : 커스텀 에셋의 내용을 설정할 수 있다.
    fileName 은 에셋 생성 시 기본으로 입력되는 파일 명이다.
    menuName에 적은대로 생성 루트(?)가 보인다.order는 메뉴에 보이는 순서이다.
  • [Header("Attack Info")] : Header는 분류를 위한 이름 표시이다.

RangedAttackData : AttackSO

[CreateAssetMenu(fileName = "RangedAttackData", menuName = "TopDownController/Attacks/Ranged", order = 1)]

public class RangedAttackData : AttackSO
{
    [Header("Ranged Attack Data")]
    public string bulletNameTag;
    public float duration;
    public float spread;
    public int numberOfProjectilePerShot;
    public float multipleProjectilesAngle;
    public Color projectileColor;
}

위의 AttackSO를 상속 받아서 원거리 공격에서 사용 할 데이터들을 더 입력해준다.

  • 스크립터블 오브젝트 에셋을 하나 만들어준다.

캐릭터 스탯

CharacterStats

public enum StatsChangeType
{ 
    Add,
    Multiple,
    Override,
}

// 아무 것도 상속 받지 않음
[Serializable]
public class CharacterStats 
{
    public StatsChangeType statsChangeType;
    [Range(1, 100)] public int maxHp;
    [Range(1f, 20f)] public float speed;

    public AttackSO attackSO;
}
  • 캐릭터의 기본 스탯 정보만 담고 있는다.
  • 마지막 줄에 스크립터블 오브젝트 하나를 참조해준다.

CharacterStatsHandler

public class CharacterStatsHandler : MonoBehaviour
{
    [SerializeField] private CharacterStats baseStats; 

    public CharacterStats CurrentStates { get; private set; }
    public List<CharacterStats> statsModifiers = new List<CharacterStats>(); //이건 나중에?


    private void Awake()
    {
        UpdateCharacterStats();
    }

    private void UpdateCharacterStats()
    {
        AttackSO attackSO = null; // 공격 정보 있는 빈 스크립터블 오브젝트 변수
        if(baseStats.attackSO != null)
        {
        	//baseStats의 스크립터블 오브젝트 null아니면 하나 깊은 복사
            attackSO = Instantiate(baseStats.attackSO);
        }
		
        //
        CurrentStates = new CharacterStats { attackSO = attackSO };
        //TODO
        CurrentStates.statsChangeType = baseStats.statsChangeType;
        CurrentStates.maxHp = baseStats.maxHp;
        CurrentStates.speed = baseStats.speed;
    }
}
  • 솔직히 baseStats랑 CurrentStats랑 왜 따로 있는지 잘 이해는 안된다..
    베이스를 하나 두고 시작할 때 현재 캐릭터의 스탯으로 끌고오는게 널리 쓰이는 방법이라면 일단 기억 해둘 필요가 있어보인다.

기존 부분 수정

Movement

public class Movement : MonoBehaviour
{
    ,,,생략,,,
    private CharacterStatsHandler _stats;

    private void Awake()
    {
    	,,,생략,,,
        _stats = GetComponent<CharacterStatsHandler>();
    }

	,,,생략,,,
	
    private void ApplyMovement(Vector2 direction)
    {
        //direction = direction * 5;
		direction = direction * _stats.CurrentStates.speed;
        _rigidbody.velocity = direction;
    }
}
  • [객체의 컴포넌트에 있는 스탯 핸들러 -> 현재 스탯 -> 속도] 를 가져와서 강체의 속도에 할당해준다.

CharacterController

이벤트 선언한 클래스

public class CharacterController : MonoBehaviour
{
    public event Action<Vector2> OnMoveEvent;
    public event Action<Vector2> OnLookEvent;
    public event Action OnAttackEvent;

    private float _timeSinceLastAttack = float.MaxValue;
    protected bool IsAttacking { get; set; } //1-9

    protected CharacterStatsHandler Stats { get; private set; } //1-9

    protected virtual void Awake()
    {
        Stats = GetComponent<CharacterStatsHandler>(); // 1-9
    }

    protected virtual void Update(){ HandleAttackDelay(); }

    private void HandleAttackDelay()
    {
        if (Stats.CurrentStates.attackSO == null)//1-9
            return;

        if(_timeSinceLastAttack <= Stats.CurrentStates.attackSO.delay)//1-9
        {
            _timeSinceLastAttack += Time.deltaTime;
        }        
        ,,,생략,,,
    }
  • Awake()는 가상 메서드로 설정해주고 자식 쪽에서 호출할 것임.
  • 세팅된 스탯 정보를 가져오고 공격 주기를 스크립터블 오브젝트의 멤버 delay로 사용한다.

PlayerInputController : CharacterController

protected override void Awake()
{
    base.Awake();
    _camera = Camera.main;
}
  • 부모의 CharacterStatsHandler객체 변수를 세팅하는 부분을 불러주도록 변경

1-10 투사체 구현

CharacterController

public class CharacterController : MonoBehaviour
{
    ,,,생략,,,
    public event Action<AttackSO> OnAttackEvent;

    ,,,생략,,,

    private void HandleAttackDelay()
    {
        ,,,생략,,,
        // 공격 중이고 시간 됐다면
        if(IsAttacking && _timeSinceLastAttack > Stats.CurrentStates.attackSO.delay)
        {
            _timeSinceLastAttack = 0;
            CallAttackEvent(Stats.CurrentStates.attackSO);
        }
    }
    
    public void CallAttackEvent(AttackSO attackSO)
    {
        OnAttackEvent?.Invoke(attackSO);
    }
}

공격 이벤트를 스크립터블 오브젝트를 받는 형식으로 변경

Shooting

public class Shooting : MonoBehaviour
{
    private ProjectileManager _projectileManager;
    ,,, 멤버 변수 생략 ,,,


    private void Awake()
    {
        _controller = GetComponent<CharacterController>();
    }

    private void Start()
    {
        _projectileManager = ProjectileManager.instance;
        ,,,이벤트 등록 생략,,,
    }

    ,,,OnAim() 생략,,,

	// 공격 정보 받고 계산 후 탄 생성 함수에 정보 전달
    private void OnShoot(AttackSO attackSO) 
    {
        RangedAttackData rangedAttackData = attackSO as RangedAttackData; // 다운 캐스팅
        float projectilesAngleSpace = rangedAttackData.multipleProjectilesAngle; // 탄 퍼짐 각도
        int numberOfProjectilesPerShot = rangedAttackData.numberOfProjectilePerShot; // 발사 당 탄 수

        float minAngle = -(numberOfProjectilesPerShot / 2f) * projectilesAngleSpace + 0.5f * rangedAttackData.multipleProjectilesAngle;

        for (int i = 0; i < numberOfProjectilesPerShot; ++i) //발사당 탄 수 만큼
        {
            float angle = minAngle + projectilesAngleSpace * i;
            float randomSpread = Random.Range(-rangedAttackData.spread, rangedAttackData.spread);
            angle += randomSpread;

            CreateProjectile(rangedAttackData, angle);
        }
    }

    private void CreateProjectile(RangedAttackData rangedAttackData, float angle)
    {
        // 투사체 싱글톤에게 투사체 생성 메서드 호출
        _projectileManager.ShootBullet(
            projectileSpawnPosition.position,
            RotateVector2(_aimDirection, angle),
            rangedAttackData
            );
    }

    private static Vector2 RotateVector2(Vector2 v, float degree) // 벡터를 받은 각도로 회전
    {   // 벡터에 쿼터니언 곱함
        return Quaternion.Euler(0, 0, degree) * v;
    }
}
  • OnShoot()에서는 공격 정보와 계산된 각도를 CreateProjectile()에 전달

  • CreateProjectile()에서는 받은 인자로 투사체 싱글톤의 투사체 생성 메서드 호출

  • RotateVector2()는 투사체 방향 계산, 반환

ProjectileManager

투사체 관리 매니저 (싱글톤)

public class ProjectileManager : MonoBehaviour
{
    [SerializeField] private ParticleSystem _impactParticleSystem;

    public static ProjectileManager instance;

    [SerializeField] private GameObject tempObj;

    private void Awake()
    {
        instance = this;
    }

    /// <summary>발사체 생성하고 발사체의 초기화 함수 호출</summary>
    public void ShootBullet(Vector2 startPosition, Vector2 direction, RangedAttackData attackData)
    {
        GameObject obj = Instantiate(tempObj);

        obj.transform.position = startPosition;
        RangedAttackController attackController = obj.GetComponent<RangedAttackController>();
        attackController.InitializeAttack(direction, attackData, this);

        obj.SetActive(true);
    }
}
  • ShootBullet() : 발사체 프리팹 객체화, 발사체의 초기화 함수 호출

RangedAttackController

발사체에 직접 영향 미치는 컴포넌트

public class RangedAttackController : MonoBehaviour //발사체 
{
    [SerializeField] private LayerMask levelCollisionLayer;

    private RangedAttackData _attackData;
    private float _currentDuration;
    private Vector2 _direction;
    private bool _isReady;

    private Rigidbody2D _rigidbody;
    private SpriteRenderer _spriteRenderer;
    private TrailRenderer _trailRenderer;
    private ProjectileManager _projectileManager;

    public bool fxOnDestroy = true;

    private void Awake()
    {
        _spriteRenderer = GetComponentInChildren<SpriteRenderer>();
        _rigidbody = GetComponent<Rigidbody2D>();
        _trailRenderer = GetComponent<TrailRenderer>();
    }

    private void Update()
    {
        if(! _isReady )
        {
            return;
        }

        _currentDuration += Time.deltaTime;

        if(_currentDuration > _attackData.duration)
        {
            DestroyProjectile(transform.position, false);
        }

        _rigidbody.velocity = _direction * _attackData.speed;
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if(levelCollisionLayer.value == (levelCollisionLayer.value | (1<<collision.gameObject.layer)))
        {
            DestroyProjectile(collision.ClosestPoint(transform.position) - _direction * .2f, fxOnDestroy);
        }
    }

    /// <summary></summary>
    public void InitializeAttack(Vector2 direction, RangedAttackData attackData, ProjectileManager projectileManager)
    {
        _projectileManager = projectileManager;
        _direction = direction;
        _attackData = attackData;

        UpdateProjectileSprite();
        _trailRenderer.Clear();
        _currentDuration = 0;
        _spriteRenderer.color = attackData.projectileColor;

        transform.right = _direction;

        _isReady = true;
    }
	
    // 발사체 크기 조정
    private void UpdateProjectileSprite() 	
    
    {
        transform.localScale = Vector3.one * _attackData.size;
    }

	// 발사체 비활성화 (소멸X)
    private void DestroyProjectile(Vector3 position, bool createFx)
    {
        if(createFx)
        {

        }
        gameObject.SetActive(false);
    }
}
  • InitializeAttack() : 발사체의 방향이나 공격 관련 정보들을 세팅

한 주를 돌아보며

이번 주는 저번 주말에 컨디션 관리를 제대로 못해서 정말 쉬고싶었던 한 주였다.

컨디션 하나로 나타나는 의지가 달라지는 것을 느끼며 "노력" 이라는 것에 대한 재정의가 필요하다고 생각했다.

이전에는 노력이라고 하면, 내 몸 상태는 신경쓰지 않고 시간을 투자해서 공부하고 무언가를 완수해내는 것이라고 보았다.

하지만 작년 1년 동안의 공부와 이번 부트캠프의 연속적인 일정을 겪으며,
"공부와 활동을 하기 좋은 최상의 상태와 상황을 만드는 것"과 같이 일상을 통제하는 것도 노력의 한 부분이라는 것을 느꼈다.

一所懸命와 일상을 통제하는 인내, 열정과 냉정, 양면이 모두 성장할 수 있도록 하자.

一所懸命(잇쇼켄메-) : 무언가 하나에 목숨을 바칠 정도로 열심히 한다는 일본 표현

profile
개발은삼순이발

0개의 댓글