이전 편에서 캐릭터 스폰 기능이 완성 되었으니 이번에는 캐릭터를 이동하는 기능을 만들어보자.
구현 방식
1. 일정 주기마다 이동 대상(적)을 찾는다.
- 적 캐릭터가 1명 이상 존재하면 적들 중 가장 가까운 적을 찾고, 이동한다.
- 적 캐릭터가 없으면 적 성채를 향해 이동한다.
2. 1번의 거리 비교 과정 중 거리가 공격 범위 보다 작으면 이동을 멈추고 공격을 시도한다.
- 공격 시도 중에는 1번을 멈추고, 공격 모션이 끝나면 1번을 재개한다.
이 게임의 경우 캐릭터마다 서로 다른 속도를 가질 수 있으며, 이에 따라 전투 중 가장 가까운 캐릭터가 바뀔 수 있다.
때문에 공격 모션중에는 캐릭터 하나를 타겟팅 하는 것으로 하고, 공격이 끝난 후 가장 가까운 캐릭터를 다시 찾도록 하여 게임의 변화에 대응 할 수 있도록 한다. (추가로... 이 기능 없으면 속도가 빠른 캐릭터가 그냥 바로 성채로 달려 가거나 제일 앞의 적을 무시하고 지나가버릴 수 있다.)
적(이동대상) 찾는 기능 구현
SpawnManager에서 소환 목록 가져 오기
이전 글에서 스폰 매니저를 작성했을 때 외부 class 에서 소환 된 캐릭터들을 저장하는 리스트를 건들지 못 하게 private로 선언했었다. 외부에서 이걸 가져 오는 기능은 구현해 두지 않았었다. 처음에 생각한 방식이 단순하게 프로퍼티(Property)만 구현해 두는 것이었기 때문이다. 그러나, 유닛이 가진 팀 정보에 따라 요구되는 관리 리스트가 다르게 될 것으로 생각 되는 만큼, 팀 정보를 체크해서 상황에 알맞는 리스트를 반환하도록 하는 함수를 만드는 것이 더 좋을 것 같아 아래 코드를 작성했다.
// 팀 정보를 받아 해당 팀의 스폰리스트를 반환합니다.
public List<CharacterUnit> GetUnitList(Team targetTeamData)
{
switch (targetTeamData)
{
case Team.Red:
return AIUnits;
case Team.Blue:
return PlayerUnits;
default:
throw new System.NotSupportedException($"팀 정보 {myTeamData}에 대한 유닛리스트는 지원되지 않습니다.");
}
}
// 팀 정보를 받아 해당 팀에 대치되는 적 팀의 스폰 리스트를 반환합니다.
public List<CharacterUnit> GetEnemyUnitList(Team myTeamData)
{
switch (myTeamData)
{
case Team.Red:
return PlayerUnits;
case Team.Blue:
return AIUnits;
default:
throw new System.NotSupportedException($"팀 정보 {myTeamData}에 대한 유닛리스트는 지원되지 않습니다.");
}
}
성체 정보 캐싱했다가 불러오기
캐릭터들은 공격 대상을 적 캐릭터로 했다가도 적 유닛이 죽거나 없으면 성체로 목표를 바꿔야 한다.
공격 대상을 바꾸기 위해서는 어딘가에 성채에 대한 정보가 저장되도록 할 필요가 있다고 생각했다.
이유는 다음과 같다.
- 이동 대상이 되는 성채는 항상 게임에 각 팀별로 하나씩이다. 둘 이상이 되지 않는다.
- 캐릭터별로 성채 유닛 인스턴스에 대한 참조를 하는 것은 같은 캐릭터들이 많이 나올 수 있다는 상황에 부적합하다.
- 캐릭터별로 성채정보를 갖게 하려면 매번 캐릭터가 소환될 때 마다 성채를 찾아야 하는데, 한번만 찾아서 저장해 두고 필요할 때 불러 오는 방식이 더 깔끔하다.
using UnityEngine;
public class GameManager : MonoBehaviour
{
private const string PlayerCastleTag = "playerCastle";
private const string AICastleTag = "aiCastle";
[SerializeField]
private static CastleUnit _playerCastle;
[SerializeField]
private static CastleUnit _aiCastle;
private void Awake()
{
_playerCastle = GameObject.FindGameObjectWithTag(PlayerCastleTag)?.GetComponent<CastleUnit>();
_aiCastle = GameObject.FindGameObjectWithTag(AICastleTag)?.GetComponent<CastleUnit>();
if (_playerCastle is null || _aiCastle is null)
throw new System.NullReferenceException("성채를 찾을 수 없습니다!");
}
//자신의 팀에 해당하는 성채 유닛을 가져 옵니다.
public static CastleUnit GetMyCastle(Team team)
{
switch (team)
{
case Team.Red:
return _aiCastle;
case Team.Blue:
return _playerCastle;
default:
throw new System.NotSupportedException($"팀 정보 {team}에 대한 성채는 없습니다.");
}
}
//적팀에 해당하는 성채 유닛을 가져 옵니다.
public static CastleUnit GetEnemyCastle(Team team)
{
switch (team)
{
case Team.Red:
return _playerCastle;
case Team.Blue:
return _aiCastle;
default:
throw new System.NotSupportedException($"팀 정보 {team}에 대한 성채는 없습니다.");
}
}
}
적(공격) 대상 찾기 & 이동하기
관련 커밋로그 보는 링크 ← Click
여러 상황을 고려하다 보니 코드가 생각보다 길게 나왔다. 빠르게 확인하고 싶다면 FindTarget() 함수 위주로 확인하자.
가장 가까운 적에 대한 정보는 실시간으로 변화한다.
여기에 대응하기 위해선 끊김 없이 타겟을 탐색하고, 상황에 맞추어 대응 해 줄 필요가 있다. 이를 위해서 코루틴을 사용했다.
물론 그냥 Update 에 넣어서 사용해도 되지만, Update는 한 곳에 로직을 다 넣어야하는 것에 비해 코루틴을 이용하면 캐릭터 이동에 필요한 구문과 공격할 적에 대한 탐색을 별도의 함수로 분리 할 수 있다. 또한 코루틴은 임의로 멈췄다가 재실행 할 수 있기 때문에 스턴이나 정지 등의 기능 구현을 하기 편리하다.
코루틴의 시작은 Start 함수에서 실행되도록 했다.
CharacterSpawner가 수행하는 게임오브젝트 소환과정에서 이루어지는 함수 수행 순서때문인데,
Instanciate() → Awake() -> Instanciate 다음에 기술 된 로직 → Start()
이렇게 하지 않으면(Awake문에서 실행) 팀 배정이 이루어 지기 전에(팀 값이 None일 때) 적을 탐색하여 로직이 꼬이게 된다.
이동 및 공격 대상 찾기는 FindTarget() 에서 수행하도록 했다.
0. 캐릭터가 소환 후 초기화 될 때 적 성채를 타겟으로 하도록 설정된다.
1. 적 캐릭터 유닛 소환 목록을 저장해 둔 List를 가져 온다.
2. 1에서 가져온 캐릭터 소환 목록 List의 원자수를 확인하여 소환된 적 캐릭터가 있는지 확인한다.
- 적 캐릭터가 있으면 3번으로...
- 적 캐릭터가 없다면 적 성채를 타겟팅한다.
3. 캐릭터 소환 목록을 for문으로 돌면서 가장 가까운 거리의 적 캐릭터를 찾는다.(GetNearestEnemyCharacter())
4. 이전에 찾은 캐릭터가 있을 경우 3번에서 찾은 캐릭터와 같은 캐릭터인지 비교한다. 만약 다른 캐릭터라면 타겟을 바꾼 것임으로, 이전에 찾은 캐릭터의 사망 이벤트를 구독해지하고 새로운 캐릭터의 사망 이벤트 구독을 수행한다.
5. 3번에서 찾은 적 캐릭터를 타겟팅한다.
6. 매 프레임마다 업데이트를 수행하여 게임 상황의 변화에 대응하고, 공격 중이던 적 캐릭터의 사망 등의 이벤트 발생 시 새로운 적을 찾을 수 있도록 임의로 이 함수를 수행한다.
이동은 MovePosition() 에서 수행하도록했다.
RgidBody에서제공하는 이동함수를 사용했고, 이동위치 계산은 Vector2.MoveTowards를 사용했다.
이 방식을 사용하면 rigidBody.position에 값을 넣는 것 보다 더 부드럽게 캐릭터를 움직일 수 있다.
공격 가능 거리안에 적이있으면 정지하고, 공격 가능 거리보다 멀리 적이 있으면 이동하도록 했다.
(추후에 적 캐릭터 공격 후 지나가도록 구현할 예정)
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public enum LookDirection { Left, Right }
[RequireComponent(typeof(SpriteRenderer))]
[RequireComponent(typeof(Rigidbody2D))]
public class CharacterUnit : Unit
{
public string CharacterName { get => _characterName; }
public float MoveSpeed { get => _moveSpeed; }
public float ATK { get => _atk; }
public float EnemyDetectionRadious { get => _enemyDetectRadius; }
public float NormalAttackCooldown { get => _normalAttackCooldown; }
public float NormalAttackCastingTime { get => _noramlAttackCastingTime; }
public float SpawnWaitingTime { get => _spawnWaitingTime; }
public float AttackableDistance { get => _attackableDistance; }
protected Rigidbody2D RigidbodyComponent { get => _rigidbody; }
/// <summary>
/// 기본 시선 방향
/// </summary>
public LookDirection defaultLookDirection;
public bool Targetable = true;
[SerializeField] protected Unit AttackTarget;
private string _characterName = "Unit";
private float _moveSpeed = 1f;
private float _atk = 1f;
private float _enemyDetectRadius = 1f;
private float _normalAttackCooldown = 1f;
private float _noramlAttackCastingTime = 0.25f;
private float _spawnWaitingTime = 1f;
private float _attackableDistance = 0.3f;
private Rigidbody2D _rigidbody;
private Coroutine _findTagetRoutine;
private Coroutine _moveRoutine;
private const float DieAnimDuration = 2f;
public override void Initialize()
{
base.Initialize();
_rigidbody = GetComponent<Rigidbody2D>();
if (_rigidbody is null)
throw new NullReferenceException("RigidBody 찾기 실패! 설정이 되었는지 확인해 주세요.");
AttackTarget = GameManager.GetEnemyCastle(TeamInfo);
}
private void Start()
{
_findTagetRoutine = StartCoroutine(FindTarget());
_moveRoutine = StartCoroutine(Move());
}
public override void SetTeam(Team settingValue)
{
if (settingValue == Team.Blue && defaultLookDirection == LookDirection.Left
|| settingValue == Team.Red && defaultLookDirection == LookDirection.Right)
{
GetComponent<SpriteRenderer>().flipX = true;
}
base.SetTeam(settingValue);
}
protected virtual IEnumerator Move()
{
while (true)
{
float distance = Vector2.Distance(transform.position, AttackTarget.transform.position);
//공격 가능 범위밖에 대상이 있다면 이동합니다.
if (distance > _attackableDistance)
RigidbodyComponent.MovePosition(Vector2.MoveTowards(transform.position, AttackTarget.transform.position, MoveSpeed * Time.fixedDeltaTime));
// TODO : else StartAttack()//공격 가능 거리 안에 적이 있음으로 공격 단계를 수행합니다.
yield return new WaitForFixedUpdate();
}
}
protected virtual IEnumerator FindTarget()
{
List<CharacterUnit> enemyList = new List<CharacterUnit>();
while (true)
{
enemyList = SpawnManager.Instance.GetEnemyUnitList(TeamInfo);
if (enemyList.Count >= 1)//소환된 적이 있는지 체크
{
Unit foundtarget = GetNearestEnemyCharacter();
// 찾은 타겟이 현재 타겟팅중인 대상과 다르다면
if (!ReferenceEquals(AttackTarget, foundtarget))
{
// 현재 타켓팅중인 적의 사망에 대한 구독을 해제합니다.
AttackTarget.DieEvent.RemoveListener(OnEnemyDead);
}
AttackTarget = foundtarget; //적 타겟팅
AttackTarget.DieEvent.AddListener(OnEnemyDead); //타겟팅 된 적의 사망에 대한 구독
}
else AttackTarget = GameManager.GetEnemyCastle(this.TeamInfo);
yield return null;// update() 함수 대용
}
CharacterUnit GetNearestEnemyCharacter()
{
float min = float.MaxValue;
int nearestItemOrder = 0;
for (int i = 0; i < enemyList.Count; i++)
{
if (!enemyList[i].Targetable) continue;
float distance = Vector2.Distance(transform.position, enemyList[i].transform.position);
if (distance < min)
{
min = distance;
nearestItemOrder = i;
}
}
return enemyList[nearestItemOrder];
}
}
/// <summary>
/// Callback 함수. 공격대상이던 적 캐릭터가 죽었을때 이 캐릭터가 보여 줄 반응을 이곳에 작성합니다.
/// </summary>
protected virtual void OnEnemyDead()
{
//타겟팅 중이던 적이 죽으면 새로운 대상을 찾도록 합니다.
FindTarget();
}
/// <summary>
/// 이 캐릭터가 죽임을 당했을 때 보여줄 이 캐릭터의 반응을 이곳에 작성합니다.
/// </summary>
protected override void OnDie()
{
base.OnDie();
Targetable = false;
StopAllCoroutines();
SpawnManager.Instance.GetUnitList(this.TeamInfo).Remove(this);
DieEvent.RemoveAllListeners();
// TODO : 사망연출
Destroy(gameObject, DieAnimDuration);
}
}
결과물
'개발 > 게임 개발' 카테고리의 다른 글
간단한 유니티 2D 타워 디펜스 게임 만들기 4편 - 캐릭터 스폰 (0) | 2022.09.24 |
---|---|
간단한 유니티 2D 타워 디펜스 게임 만들기 3편 - 캐릭터 유닛의 행동에 관한 고려 (0) | 2022.09.13 |
간단한 유니티 2D 타워 디펜스 게임 만들기 2편 - 기초 쌓기 (0) | 2022.09.09 |
간단한 유니티 2D 타워 디펜스 게임 만들기 1편 - 개요 (0) | 2022.08.21 |