본문 바로가기

개발일지/액션던전_Unity

[개발일지] "액션던전"의 전투 시스템과 캐릭터 유닛 구현 개발기

현재까지 완성된 전투 화면 예시 이미지(좌: 공격, 우: 방어)

 

개요

이번 글에서는 "액션던전" 개발을 하며 사용한

캐릭터 유닛과 전투 시스템 구현 방식에 대해서 소개한다.

 

 

게임 화면 설명

 

게임 화면 구조 이미지

 

전체적으로 구상했던 게임의 메인 전투 플레이 화면은 이런식이다.

아직은 조금 더 꾸며야 할 UI 요소들이 많지만 아마 구조에서는 큰 틀로 달라지지는 않을 것 같다.

 

유닛 구조 구현

유닛은 게임에서 사용되는 플레이어와 적 캐릭터들의 베이스를 의미한다.

크게 유닛에서는 플레이어의 액션들을 제어하는 CharacterContoller와 스탯 데이터, 렌더러 등이 존재하고

이 유닛 클래스를 상속받아서 플레이어와 적 캐릭터(Enemy)를 제작했다.

매우 단순화한 클래스 다이어그램 구조

 

 

Units 클래스에서는 유닛 캐릭터의 스탯과 정보, 상태머신, 컨트롤러, 이벤트 Subject 객체등을 갖고 있고

각 Action클래스에서 수행로직을 내리면 Units 클래스에서 수행하는 식으로 구현했다.

using UnityEngine;
using UniRx;
using DG.Tweening;
using Cysharp.Threading.Tasks;

public class Units : MonoBehaviour
{
    // .. 유닛 정보 생략 ..
    
    [SerializeField] private CharacterController _characterController;
    public CharacterController CharacterController => _characterController;
    
    // .. 포지션 조정 함수 생략 .. 
    
    public void Attack(Units targetUnit)
    {
        AttackState atkState = new AttackState();
        _stateMachine.SetState(atkState);
        atkState.SetTargetUnit(targetUnit);

        this.transform.DOMove(_actionPosition.position, 0.3f).OnComplete(async () =>
        {
            unitRenderer.DoAttackAnim();
            _stateMachine.DoExecuteState();
            OnActionEnd.OnNext(Unit.Default);

            if (IsAlive)
            {
                await UniTask.Delay(1000);
                this.transform.DOMove(_unitPosition, 0.25f);
            }
        });
    }

    public void Defence()
    {
        DefenceState defenceState = new DefenceState();
        _stateMachine.SetState(defenceState);

        this.transform.DOMove(_actionPosition.position, 0.3f).OnComplete(async () =>
        {
            unitRenderer.DoDefenceAnim();
            _stateMachine.DoExecuteState();
            OnActionEnd.OnNext(Unit.Default);

            if (IsAlive)
            {
                await UniTask.Delay(1000);
                this.transform.DOMove(_unitPosition, 0.25f);
            }
        });
    }

    public void Dodge()
    {
        DodgeState dodgeState = new DodgeState();
        _stateMachine.SetState(dodgeState);

        this.transform.DOMove(_actionPosition.position, 0.3f).OnComplete(async () =>
        {
            unitRenderer.DoDodgeAnim();
            _stateMachine.DoExecuteState();
            OnActionEnd.OnNext(Unit.Default);
            await UniTask.Delay(600);
            this.transform.DOMove(_unitPosition, 0.25f);
        });
    }

    public void ActionFail()
    {
        FailState failState = new FailState();
        _stateMachine.SetState(failState);

        this.transform.DOMove(_actionPosition.position, 0.3f).OnComplete(async () =>
        {
            unitRenderer.DoActionFail();
            _stateMachine.DoExecuteState();
            OnActionEnd.OnNext(Unit.Default);

            if (IsAlive)
            {
                await UniTask.Delay(1000);
                this.transform.DOMove(_unitPosition, 0.25f);
            }
        });
    }

    public void Hit(float hitDamage)
    {
        OnHitSubject.OnNext(Unit.Default);
        _statData.hp -= hitDamage;
        if(_statData.hp <= 0)
        {
            Dead();
            return;
        }

        if(StateMachine.CurrentState is DefenceState)
        {
            unitRenderer.DoDefenceAnim();
        }
        else
        {
            unitRenderer.DoHitAnim();
            unitRenderer.DamageTextAnim(hitDamage);
        }
    }

    public void Dead()
    {
        _isAlive = false;
        Debug.Log(unitName + " is Dead");
        unitRenderer.DoDeathAnim();
    }
}

 

개인적으로 프로토타입 개발 단계에서는 명확하게 기능 컴포넌트들은 작게 만들고 객체를 크게 만들며

빠르게 구현을 한 다음 나중에 쪼개는 식으로 리팩토링하며 수정할 수 있게 작업 중이다.

추후 리팩토링을 진행한다면 각 Action 클래스들로 Units 내에 구현되어 있는 로직들을 옮길 예정이다.

 

CharacterController에는 당장 액션 명령과 무작위로 액션을 수행시킬 로직만을 구현해놓았다.

아래는 CharacterController의 현재 구현 코드이다.

using UnityEngine;

public class CharacterController : MonoBehaviour
{
    private AttackAct _attackAct = new AttackAct();     // 공격 액션 객체
    private DefenceAct _defenceAct = new DefenceAct();  // 방어 액션 객체
    private DodgeAct _dodgeAct = new DodgeAct();        // 회피 액션 객체

    private const float AP_BONUS_RATE = 0.15f;          // AP 보너스 적용 배율

    public void DoAttackAction(Units actUnit, Units targetUnit, float successRate)
    {
        _attackAct.SetTargetUnit(targetUnit);
        float calculateSuccessRate = successRate + (AP_BONUS_RATE * actUnit.ActionPoint);
        _attackAct.Act(actUnit, calculateSuccessRate);
    }

    public void DoDefenceAction(Units actUnit, float successRate)
    {
        float calculateSuccessRate = successRate + (AP_BONUS_RATE * actUnit.ActionPoint);
        _defenceAct.Act(actUnit, calculateSuccessRate);
    }

    public void DoDodgeAction(Units actUnit, float successRate)
    {
        float calculateSuccessRate = successRate + (AP_BONUS_RATE * actUnit.ActionPoint);
        _dodgeAct.Act(actUnit, calculateSuccessRate);
    }

	// 적 캐릭터가 수행할 랜덤 액션 수행 함수
    // 범위 내 난수를 뽑은 후 나온 결과값에 따라서 액션을 랜덤으로 수행하도록 구현했다.
    // 더 호전적인 공격성을 위해 0~2까지는 공격, 3은 방어, 4는 회피식으로 비중 조절을 했다.
    public void RandomAction(Units actUnit, Units targetUnit, float successRate)
    {
        int actionNumber = UnityEngine.Random.Range(0, 5);

        float calculateSuccessRate = successRate + (AP_BONUS_RATE * actUnit.ActionPoint);

        switch (actionNumber)
        {
            case int n when (0 <= n && n <= 2):
                DoAttackAction(actUnit, targetUnit, calculateSuccessRate);
            break;
            case 3:
                DoDefenceAction(actUnit, calculateSuccessRate);
            break;
            case 4:
                DoDodgeAction(actUnit, calculateSuccessRate);
            break;
        }
    }
}

 

 

전투 시스템 구현

 

한 번의 전투 순서도

 

전투의 경우 BattleManager라는 전투 담당 클래스를 만들어 처리를 담당했다.

BattleManager에서는 크게 아래의 기능들을 구현했다.

1. 플레이어 정보 초기화 및 이벤트 등록

2. 적 스폰 및 할당

3. 참조 된 UI 버튼에 액션 입력 이벤트 등록

4. 전투 플레이 루프 진행

5. 전투 결과에 따른 처리 실행

 

아래는 현재까지 구현된 BattleManager 클래스 코드이다.

using UnityEngine;
using UniRx;
using Zenject;
using UniRx.Triggers;
using Cysharp.Threading.Tasks;

public class BattleManager : MonoBehaviour
{
    public enum BattleState
    {
        READY,
        PROGRESS,
        END
    }

    private Player _player;
    private Enemy _currentEnemy;

    private EnemySpawner _enemySpawner;
    private GameInitializer _gameInitializer;
    private TurnClockSystem _turnClockSystem;
    private BattleReadyUI _battleReadyUI;
    private GameProgressUI _gameProgressUI;
    private CameraController _cameraController;

    private BattleState _battleState = BattleState.READY;
    public BattleState BState => _battleState;

    [SerializeField] private Transform _enemySpawnPosition;
    [SerializeField] private Transform _playerActionPosition;
    [SerializeField] private Transform _enemyActionPosition;

    [Header("Unit Stat UIs")]
    [SerializeField] private UnitStatUIPanel _unitStatUIPanel;

    [Header("Action Buttons")]
    [SerializeField] private ActionButton attackActionBtn;
    [SerializeField] private ActionButton defenceActionBtn;
    [SerializeField] private ActionButton dodgeActionBtn;

    [SerializeField] private ActionEnhanceBonus actionEnhanceBonus;
    [SerializeField] private BattleResultUI battleResultUI;

    public Subject<Unit> OnActionSubject = new Subject<Unit>();

    [Inject]
    public void Inject(GameInitializer gameInitializer, EnemySpawner enemySpawner, TurnClockSystem turnClockSystem, 
        BattleReadyUI battleReadyUI, GameProgressUI gameProgressUI, CameraController cameraController)
    {
        _gameInitializer = gameInitializer;
        _enemySpawner = enemySpawner;
        _turnClockSystem = turnClockSystem;
        _battleReadyUI = battleReadyUI;
        _gameProgressUI = gameProgressUI;
        _cameraController = cameraController;
    }

    public void Start()
    {
        InitPlayer();           // 플레이어 정보 초기화 및 이벤트 등록
        MatchingNewEnemy();     // 적 스폰 및 할당
        UniRxUpdate();
        ButtonsEventAllocate(); // UI 버튼에 액션 입력 이벤트 등록
        BattleResultEvent();    // 전투 결과 처리 이벤트 등록
    }

    // 기존 Unity Update를 UniRx 스트림으로 대체
    // Update 메서드 비용 최적화
    private void UniRxUpdate()
    {
        this.UpdateAsObservable().Subscribe((_) =>
        {
            CheckBattleEnd();
        }).AddTo(this);
    }
	
    private void ButtonsEventAllocate()
    {
        attackActionBtn.OnClickEvent += AttackActionEvent;
        defenceActionBtn.OnClickEvent += DefenceActionEvent;
        dodgeActionBtn.OnClickEvent += DodgeActionEvent;
    }

    private void InitPlayer()
    {
        if (_player is null)
        {
            _player = _gameInitializer.Player;
            _player.SetActionPosition(_playerActionPosition);
            _unitStatUIPanel.InitializeUnitStatUI(_player);
            actionEnhanceBonus.SetPlayer(_player);
			
            // 플레이어 피격 시 카메라 연출 이벤트
            _player.OnHitSubject.Subscribe((_) => {
                _cameraController.SetHitActionFocusCamera();
            }).AddTo(this);
			
            // 액션 수행 후 스탯UI 업데이트
            _player.OnActionEnd.Subscribe((_) =>
            {
                _unitStatUIPanel.UpdateUnitStatUI(_player, _currentEnemy);
            }).AddTo(this);

			// 성장 보너스 스탯 선택 후 스탯UI 업데이트
            _player.OnSelectBonusStatSubject.Subscribe((_) =>
            {
                _unitStatUIPanel.UpdateUnitStatUI(_player, _currentEnemy);
            }).AddTo(this);

			// 액션 성공 시 성공 보너스 스택 카운트 증가
            _player.ActionSuccessSubject.Subscribe((_) =>
            {
                actionEnhanceBonus.IncreaseSuccessCount();
            }).AddTo(this);
        }
    }

    private void BattleResultEvent()
    {
        battleResultUI.OnNextBattleSubject.Subscribe((_) =>
        {
            ReleaseCurrentEnemy();
            PrepareNextBattle();
        }).AddTo(this);
    }

    private void MatchingNewEnemy()
    {
        if (_currentEnemy is null)
        {
            _enemySpawner.InitSpawner();
            MatchingEnemy(_enemySpawner?.SpawnNewEnemy(_enemySpawnPosition));
            _battleReadyUI.SetEnemyInfoUI(_currentEnemy);
            _battleReadyUI.IntroduceNextEnemy();
        }
    }

    private void ReleaseCurrentEnemy()
    {
        Destroy(_enemySpawner.EnemyInstance);
        _currentEnemy = null;
    }

    private void MatchingEnemy(Enemy nextEnemy)
    {
        _currentEnemy = nextEnemy;
        _currentEnemy.SetActionPosition(_enemyActionPosition);
        _currentEnemy.SetUnitPosition(_enemySpawnPosition.position);

        _unitStatUIPanel.InitializeUnitStatUI(_currentEnemy);

        _currentEnemy.OnActionEnd.Subscribe((_) =>
        {
            _unitStatUIPanel.UpdateUnitStatUI(_player, _currentEnemy);
        }).AddTo(this);

        _currentEnemy.OnHitSubject.Subscribe((_) => {
            _cameraController.SetHitActionFocusCamera();
        }).AddTo(this);

        _battleState = BattleState.PROGRESS;
    }

    private void CheckBattleEnd()
    {
        if (BState != BattleState.PROGRESS)
        {
            return;
        }

        if (_player is not null && _currentEnemy is not null)
        {
            if (_player.IsAlive && !_currentEnemy.IsAlive)
            {
                Debug.Log("Player Win !!");
                _gameProgressUI.AddKilledEnemyCount(1);
                _battleState = BattleState.END;
                battleResultUI.BattleResultEnable(_player);
                actionEnhanceBonus.CloseDraftSystemUI();
            }
            else if (!_player.IsAlive)
            {
                Debug.Log("Player Lose TT");
                _battleState = BattleState.END;
                battleResultUI.BattleResultEnable(_player);
                actionEnhanceBonus.CloseDraftSystemUI();
            }
        }
    }

    private void PrepareNextBattle()
    {
        _battleState = BattleState.READY;
        MatchingNewEnemy();
    }

    private void AttackActionEvent()
    {
        if (BState != BattleState.PROGRESS)
        {
            return;
        }

        if (_turnClockSystem.IsDays)
        {
            _player.CharacterController.DoAttackAction(_player, _currentEnemy, _player.GetTotalStatData().luk);
            _currentEnemy.CharacterController.RandomAction(_currentEnemy, _player, _currentEnemy.GetTotalStatData().luk * 0.5f);
        }
        else
        {
            _player.CharacterController.DoAttackAction(_player, _currentEnemy, _player.GetTotalStatData().luk * 0.5f);
            _currentEnemy.CharacterController.RandomAction(_currentEnemy, _player, _currentEnemy.GetTotalStatData().luk);
        }

        OnActionSubject.OnNext(Unit.Default);
    }

    private void DefenceActionEvent()
    {
        if (BState != BattleState.PROGRESS)
        {
            return;
        }

        if (_turnClockSystem.IsDays)
        {
            _player.CharacterController.DoDefenceAction(_player, _player.GetTotalStatData().luk);
            _currentEnemy.CharacterController.RandomAction(_currentEnemy, _player, _currentEnemy.GetTotalStatData().luk * 0.5f);
        }
        else
        {
            _player.CharacterController.DoDefenceAction(_player, _player.GetTotalStatData().luk * 0.5f);
            _currentEnemy.CharacterController.RandomAction(_currentEnemy, _player, _currentEnemy.GetTotalStatData().luk);
        }

        OnActionSubject.OnNext(Unit.Default);
    }

    private void DodgeActionEvent()
    {
        if (BState != BattleState.PROGRESS)
        {
            return;
        }

        if (_turnClockSystem.IsDays)
        {
            _player.CharacterController.DoDodgeAction(_player, _player.GetTotalStatData().luk);
            _currentEnemy.CharacterController.RandomAction(_currentEnemy, _player, _currentEnemy.GetTotalStatData().luk * 0.5f);
        }
        else
        {
            _player.CharacterController.DoDodgeAction(_player, _player.GetTotalStatData().luk * 0.5f);
            _currentEnemy.CharacterController.RandomAction(_currentEnemy, _player, _currentEnemy.GetTotalStatData().luk);
        }

        OnActionSubject.OnNext(Unit.Default);
    }
}

 

BattleManager 클래스를 설계할 때

"Player와 Enemy를 1:1로 매칭시키고 각 전투 결과 처리를 한다." 가 구현하고자 하는 의도였다.

가장 우선 구현했던 부분이 Units과 BattleManager 클래스를 중심으로 한 전투 시스템이었고 기본 구현을 한 후

추가적인 연출과 밤/낮 시스템, 3중 1택 드래프팅 성장 시스템 등이 붙으면서 코드가 길어진 감이 적잖아 있다.

UI 이벤트를 입력받고 처리하는 부분과 플레이어에 대한 이벤트 등록처리 등의 부분은 의도와 맞지 않아

리팩토링이 필요한 부분이라고 느껴진다.