개요
이번 글에서는 "액션던전" 개발을 하며 사용한
캐릭터 유닛과 전투 시스템 구현 방식에 대해서 소개한다.
게임 화면 설명
전체적으로 구상했던 게임의 메인 전투 플레이 화면은 이런식이다.
아직은 조금 더 꾸며야 할 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 이벤트를 입력받고 처리하는 부분과 플레이어에 대한 이벤트 등록처리 등의 부분은 의도와 맞지 않아
리팩토링이 필요한 부분이라고 느껴진다.
'개발일지 > 액션던전_Unity' 카테고리의 다른 글
[개발일지] 클리커 2D 로그라이크 던전 RPG 게임 "액션던전" 기획과 개발환경 (3) | 2024.10.18 |
---|