(주말 공부)XR플밍 - (20) 개인프로젝트 준비 - 2D 횡스크롤 액션 + 슈팅게임 테스트와 설계 (5/25)

이형원·2025년 5월 25일
0

XR플밍

목록 보기
84/215

0.들어가기에 앞서
어제자에서 플레이어의 좌우 이동 및 충돌체 위치 변경을 구현했었다. 오늘은 그에 이어 몇 가지 행동을 추가하여 테스트를 진행하고, 게임의 전체적인 구조를 만들어보고자 한다.

1. 전체적인 테스트 코드 구성 및 InputSystem 세팅

1.1 InputSystem 세팅

우선은 추가 기능을 넣기에 앞서 InputSystem의 세팅을 아래와 같이 진행했다.

원래는 SwordAttack을 키보드로 넣고 싶었지만, 테스트를 진행해보니 아무래도 공격을 왼손으로 하는 건 많이 불편했다.
어쩔 수 없이 현재 세팅으로는 마우스 좌클릭으로 검 공격을 하고, 오른쪽 마우스 버튼을 누른 채로 조준한 후 떼면 발사되는 형식으로 세팅했다.
다만 추가로 E키 등으로 스킬 캔슬 같은 건 넣어두는 편이 좋겠다는 생각을 했다.

이와 같이 세팅하고, 테스트를 위해 구현한 기능은 점프, 검 공격, 그리고 조준과 스펠 발사이다.

1.2 테스트용 코드

테스트만을 위해 작성한 코드라 중구난방하지만, 코드는 두 개만 작성하였다. (PlayerController, Spell)

  • PlayerController
using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerController : MonoBehaviour
{
    public Vector2 InputDirection { get; private set; }
    
    private Rigidbody2D _rigid;
    private Animator _animator;
    private SpriteRenderer _spriteRenderer;
    private CapsuleCollider2D _capsuleCollider2D;

	// InputSystem 키
    private InputAction _jumpInputAction;
    private InputAction _SwordAttackInputAction;
    private InputAction _aimInputAction;

    private bool _isMove;
    private bool _isJump;
    private bool _isAim;
    
    private float _spellCoolTime;

    private Transform _spellMuzzle;

    [SerializeField] private float _moveSpeed;
    [SerializeField] private float _jumpPow;
    [SerializeField] private int _spellDelay;
    [SerializeField] private GameObject _spellPrefab;

    private void Awake()
    {
        _rigid = GetComponent<Rigidbody2D>();
        _animator = GetComponent<Animator>();
        _spriteRenderer = GetComponent<SpriteRenderer>();
        _capsuleCollider2D = GetComponent<CapsuleCollider2D>();
        _jumpInputAction = GetComponent<PlayerInput>().actions["Jump"];
        _SwordAttackInputAction = GetComponent<PlayerInput>().actions["SwordAttack"];
        _aimInputAction = GetComponent<PlayerInput>().actions["Aim"];
        _spellMuzzle = GetComponentInChildren<Transform>();
    }

    private void Update()
    {
        _spellCoolTime -= Time.deltaTime;
        OnJump();
        OnAttack();
        OnAim();
    }

    private void FixedUpdate()
    {
        SetMove(_moveSpeed);
    }

    public void OnMove(InputValue value)
    {
        InputDirection = value.Get<Vector2>();
    }

	// 점프
    public void OnJump()
    {
        if(_isJump == false && _jumpInputAction.IsPressed())
        {
            _rigid.AddForce(Vector2.up * _jumpPow, ForceMode2D.Impulse);
            _isJump = true;
            _animator.SetBool("IsJump",_isJump);
        }
    }

	// 공격
    private void OnAttack()
    {
        if(_SwordAttackInputAction.WasPressedThisDynamicUpdate())
        {
            _animator.SetTrigger("SwordAttack");
        }
    }

	// 스펠 발동
    private void OnAim()
    {
        if (_spellCoolTime <= 0 && _aimInputAction.WasPressedThisDynamicUpdate())
        {
            _animator.SetTrigger("IsAim");
            _isAim = true;
        }
        if (_isAim && _aimInputAction.WasReleasedThisDynamicUpdate())
        {
            _animator.SetTrigger("IsShoot");
            Instantiate(_spellPrefab, _spellMuzzle);
            _spellCoolTime = _spellDelay;
            _isAim = false;
        }
    }

    private void SetMove(float moveSpeed)
    {
        Vector2 moveDirection = (transform.right * InputDirection.x).normalized;

        if(moveDirection.x == 0)
        {
            _isMove = false;
        }
        else
        {
            _rigid.velocity = new Vector2(moveDirection.x * moveSpeed, _rigid.velocity.y);
            _isMove = true;
            _spriteRenderer.flipX = moveDirection.x < 0;
            _capsuleCollider2D.offset = moveDirection.x < 0? new Vector2(-0.4f, 0.8f) : new Vector2(0.4f, 0.8f);
        }
        _animator.SetBool("IsMove", _isMove);
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.gameObject.CompareTag("Ground"))
        {
            _isJump = false;
            _animator.SetBool("IsJump", _isJump);
        }
    }
}
  • Spell
using System.Collections;
using UnityEngine;

public class Spell : MonoBehaviour
{
    private Animator _animator;
    private Rigidbody2D _rigid;

    [SerializeField] private float _spellSpeed;

    private void Awake()
    {
        _animator = GetComponent<Animator>();
        _rigid = GetComponent<Rigidbody2D>();
    }

    private void Start()
    {
        _rigid.AddForce(Vector2.right * _spellSpeed);
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        _animator.SetTrigger("Burst");
        StartCoroutine(DestroyTerm());
    }

    IEnumerator DestroyTerm()
    {
        yield return new WaitForSeconds(3f);
        Destroy(gameObject);
    }
}

1.3 기타 애니메이션 세팅

  • Player

  • Spell

2. 플레이어 점프 구현(개선 필요)

플레이어의 점프는 이전 강의에서 진행했던 방식 거의 그대로 구현했다. 점프를 했는지 확인을 하고, 점프를 하지 않은 상태이면 점프를 진행, 이후 Ground로 착지를 하면 다시 점프가 가능하게 했다.
다만 더 좋은 구현 방법이 있다면 고민해볼 필요가 있다.

점프 시에 조금 속도감을 주기 위해 중력을 8로 두고, 점프 높이를 본인 키높이 정도로 할 수 있게끔 세팅했다.

  • Task - 점프의 자연스러움 및 판정의 정확성을 위한 구조 개선이 필요

3. 검 공격(근접 공격) 구현

좌클릭을 누를 시 공격하도록 설정하였다. 애니메이터 파라미터를 Trigger로 선택하여 공격 종료 후 바로 Idle로 돌아가게 설정했다.

  • Bug - 마우스를 연타하여 클릭 시 공격이 선입력되는 문제가 존재함. 이에 대한 수정이 필요
  • Task - 검 공격의 타이밍에 몬스터에게 데미지를 넣을 수 있도록 설계 - 애니메이션 이벤트 활용이 필요해 보임
  • Challenge - 캐릭터 에셋에 연격 공격이 있으니, 시도해볼 것

4. 스펠 공격(원거리 공격) 구현

스펠 공격의 경우 생각해야 할 부분이 많았다.

4.1 차징 애니메이션과 캐스트 애니메이션의 분리

스펠 캐스트 모션은 아래와 같이 하나로 이어져 있는 느낌의 스프라이트였다.

하지만 아무래도 스킬로 조준을 했다가 우클릭을 떼면 발사되는 형태다 보니 차징하는 모션과 발동하는 모션을 따로 분리하는 게 나아 보였다. 따라서 차징하는 모션을 따로 만들고 반복할 수 있게 하고, 캐스트가 발동하면 Exit 하는 방식을 채용했다.

4.2 스펠의 발동 조건

최종적으로 코드가 저렇게 나왔지만, 처음에는 키 입력과 뗐을 시에만 해당 트리거가 발동하도록 했다.
하지만 스펠에 쿨타임을 넣어주고 나니 쿨타임 중에 키를 눌렀다가 떼면 캐스트가 선입력되는 문제가 발생했었다.
이를 해결하기 위해서 키입력이 들어간 상황에서 Aim이 true가 되고, 그때 비로소 Cast의 트리거가 들어가도록 고쳤다.

4.3 스펠 발사 프리팹 제작

오늘 작업에서 상당히 애먹었던 부분이다. 문제는 받아온 스프라이트가 제대로 잘리지 않는 것부터 시작했다.

이렇게 눈으로 봤을 때에는 딱 9개로 나눌 수 있는 스펠 스프라이트였다. 하지만 Automatic 으로 슬라이스를 해 주니 이렇게 잘라주었다.

의도했던 것과는 다른 모양이었기에 이걸 수동으로 자르는 방법에 대해서 찾아봤고, 상당히 애먹었다.
처음엔 다른 모드로 설정해서 자르라 하는 글을 보고 시도해봤는데, 아무리 시도해봐도 안 되서 다시 Automatic으로 해 보니, 이 사각형 영역이 선택된다는 사실을 알게 되었다.

그냥 단순하게 박스 사이즈 수동으로 조절하고, 빈 공간에서 드래그하면 또 새 박스 영역을 만들 수 있었다.
괜히 이상한 데서 애먹은 것 같지만, 일단은 해결했으니 배운 점으로 남겨놓고자 했다.

하지만 문제는 이게 끝이 아니었다. 왠지 모르겠는데 분명 영역을 제대로 잘랐는데 막상 스프라이트 사진을 보니 사진이 이상하게 표시되었다.

공백으로 표시되는 0번 스프라이트와 이상하게 출력되는 4번 스프라이트...
이거는 몇 번을 시도해 봐도 안 고쳐지기도 하고, 다행히 없어도 치명적인 파트는 아니라서 일단은 넘기기로 했다.

이제 프리팹과 스프라이트를 만들 건데 날아가는 모션에는 애니메이션을 넣지 않고, 터지는 모션만 애니메이션을 만들어서 제작했다.

여기서 충돌 발생으로 폭발로 넘어가는 형태인데, 플레이어, 그리고 스펠끼리는 충돌하면 안 되기 때문에 충돌 무시 설정을 해야 했다.

Project Setting에서 세팅을 진행했다.

분명 설정을 했는데 계속 플레이어와 충돌하는 것을 보고 왜 이렇지 생각을 해 봤는데, 보니까 Physics2D가 따로 있었다...

설마 이거까지 따로 있을 거라고는 생각을 못했었는데 기억해 두도록 하자.

4.4 테스트 결과 및 개선 사항

아직은 임시 테스트용으로 만든 거라 여기저기 구멍이 많은 상황이다. 스펠도 오른쪽으로만 날아가고, 플레이어가 왼쪽으로 돌아볼 때 Muzzle이 돌아가지 않아서 이 부분에 대한 개선이 필요하다.

또한 충돌에 의해 폭발 모션으로 넘어간 다음 파괴되는 형태로 테스트했기 때문에, 앞으로 이걸 오브젝트 풀 패턴으로 구현하여 최적화를 위한 방법을 고민해야 할 것이다.

  • Bug? Task? 스펠을 차징하는 동안 못 움직이게 할지, 아니면 허용할지에 대한 고민을 하고 있다.
    (못 움직이게 하는 게 자연스러우나, 게임의 불편함을 초래할 것에 대한 걱정)
  • Task - 캐릭터의 방향에 따라 스펠의 날아가는 방향에 대한 조정이 필요
  • Challenge - 플레이어의 마우스 방향에 따른 스펠 방향 날아가게 하기 및 애니메이션 회전. 스펠 차징시 일시적으로 시간 느려짐 효과

5. 필요한 디자인 패턴과 구조 개요

5.1 필요한 디자인 패턴

  • 싱글톤 패턴

    • 각종 매니저 등 필요한 요소에 활용
    • 싱글톤 제네릭 패턴으로 설계하여 전역적으로 사용하는 것을 시도
  • 옵저버 패턴

    • 플레이어의 상태를 콜백하는 용도로 사용
  • 오브젝트 풀 패턴

    • 플레이어가 쓰는 스펠 스킬을 저장하는 용도로 사용
  • 상태 패턴

    • 플레이어의 상태가 많을 것으로 예상되므로, 이를 분리하는 조치가 필요하다고 판단
    • 몬스터 또한 상태 패턴을 사용하여 분리 조치할 것
  • MVC/MVP 패턴

    • 플레이어의 행동과 정보를 분리하는 과정을 시도
    • 상태 패턴과 같이 사용할 수 있는지 여부는 시도해봐야 할 것

5.2 기능 구현 개요

1. 플레이어

  • 필요 스텟 : 체력, 스태미나, 공격력, 공격 범위, 공격 속도, 쿨타임 등
    (마나의 경우 검토중)
  • 필요한 기능(상태) : 이동, 점프, 근접공격, 원거리 스펠(조준 - 발사), 피격, 죽음 등
    (추가 검토 기능 : 달리기, 대쉬, 사다리 오르내리기 등)
    (추가 심화 기능 : 원거리 조준 중 일시적 시간 속도 느려짐, 스펠의 차징 시스템 등)

MVC/MVP 패턴과 상태패턴을 융합하여 관리 및 유지 보수 등에 활용할 수 있는지 확인할 예정
옵저버 패턴으로 행동 변화에 대한 입출력의 최적화를 시도해 볼 예정


2. 몬스터

  • 필요 스탯 : 체력, 공격력, 공격 범위, 공격 속도, 보스의 경우 스킬 쿨타임 등
  • 필요한 기능(상태) : 탐지, 감지 및 공격, 피격, 죽음 등
  • 몬스터의 경우 스크립터블 오브젝트를 이용한 제작을 시도해 볼 예정

3. 아이템

  • 최대 체력 증가, 공격력 증가, 방어막, 공격 속도 증가, 쿨타임 감소 등의 기능
  • 부정적인 효과는 지금 단계에선 안 넣는 것으로 진행
    (최대한 굴러가는 게임을 만드는 것을 우선으로 하여, 아이템 기능은 게임 내 핵심 기능은 아니기에 최대한 뒤로 미루도록 함)

4. 스테이지

  • 에셋 필요, 컨셉은 숲 느낌의 컨셉도 나쁘지 않다고 판단.
  • 보글보글처럼 아예 폐쇄된 형태의 맵에서 몬스터와 난전을 벌이는 형태를 초안으로 생각중
  • 1, 2 스테이지는 일반맵, 3스테이지는 보스 몬스터의 패턴을 피해다니면서 공격하는 형태로 설계

5. 게임매니저

  • 필요한 게임 매니저 - 게임 매니저, 오디오 매니저, 인풋 매니저, UI 매니저 등으로 예상됨

6. UI

  • 필수 UI - 메인 화면 UI, 튜토리얼 UI(키 표시), 인게임 스텟 표시 UI, 게임 오버/클리어 UI, 게임 일시정지 UI
  • 추가 구현 UI - Setting과 관련된 UI들(오디오 조절 UI, 조작키 변경 UI, 해상도 변경 UI, 아티펙트 선택 UI
profile
게임 만들러 코딩 공부중

0개의 댓글