안녕하세요. 💪
이번 작업은 플레이어의 기본 공격과 애니메이션 작업을 병행해 주었습니다.
가장 먼저 애니메이션은 믹사모에서 무료로 가져와 주었습니다.
링크 : https://www.mixamo.com
다음은 플레이어의 애니메이터에 "IsAttack" 파라미터를 생성해 주어
애니메이션 "Attack"의 전이 조건으로 true/false로 설정해 주었습니다.
다음은 스크립트입니다.
"PlayerAttack" 스크립트를 만들어 주었으며
하나씩 살펴보겠습니다.
using System.Collections;
using UnityEngine;
public class PlayerAttack : MonoBehaviour
{
[Header("Player Components")]
[SerializeField] private PlayerMovement playerMovement;
[SerializeField] private PlayerStats playerStats;
[SerializeField] private Animator playerAnim;
[Header("Player Setting Value")]
public bool performMeleeAttack = true;
private float attackInterval;
private float nextAttackTime = 0;
[Header("Enumy Target Setting")]
public GameObject enumyGameobject;
[SerializeField] private GameObject[] basicAttackObjects;
private GameObject currentBasicAttackObject;
[SerializeField] private Vector3 attackSpawnPoint;
void Update()
{
attackInterval = playerStats.playerAttackSpeed / ((500 + playerStats.playerAttackSpeed) * 0.01f);
if (playerMovement.enumyGameobject != null)
{
enumyGameobject = playerMovement.enumyGameobject.gameObject;
}
else
{
enumyGameobject = null;
}
if (enumyGameobject != null && performMeleeAttack && Time.time > nextAttackTime)
{
if (Vector3.Distance(transform.position, enumyGameobject.transform.position) <= playerMovement.stopDistance)
{
StartCoroutine(PlayerAttackInterval());
}
}
}
private IEnumerator PlayerAttackInterval()
{
performMeleeAttack = false;
playerAnim.SetBool("IsAttack", true);
yield return new WaitForSeconds(attackInterval);
if (enumyGameobject == null)
{
playerAnim.SetBool("IsAttack", false);
performMeleeAttack = true;
}
}
public void OnPlayerAttack()
{
if (enumyGameobject != null)
{
GetBasicAttacjObject();
if (currentBasicAttackObject != null)
{
PlayerTargetObject playerTargetObject = currentBasicAttackObject.GetComponent<PlayerTargetObject>();
if (playerTargetObject != null)
{
playerTargetObject.SetTarget(enumyGameobject.transform);
}
}
nextAttackTime = Time.time + attackInterval;
performMeleeAttack = true;
playerAnim.SetBool("IsAttack", false);
}
}
private void GetBasicAttacjObject()
{
foreach (var basicAttackObject in basicAttackObjects)
{
if (!basicAttackObject.activeSelf)
{
basicAttackObject.transform.position = transform.position + attackSpawnPoint;
currentBasicAttackObject = basicAttackObject;
basicAttackObject.SetActive(true);
return;
}
}
}
}
1. 플레어와 관련된 클래스와 컴포넌트입니다.
playerMovement : 플레이어 이동과 적 추적 정보를 가져오기 위함입니다.
playerStats : 플레이어의 기본 스텟을 가져오기 위함입니다.
playerAnim : 플레이어의 애니메이션을 사용하기 위함입니다.
[SerializeField] private PlayerMovement playerMovement;
[SerializeField] private PlayerStats playerStats;
[SerializeField] private Animator playerPlayerAnim;
2. 공격 상태와 쿨타임 변수입니다.
perFormMeleeAttack : 공격 중인지 판단할 플래그입니다.
attackInterval : 현재 공격 간격입니다.
nextAttackTime : 다음 공격 가능 시간입니다. (Time.time 기준)
public bool performMeleeAttack = true;
private float attackInterval;
private float nextAttackTime = 0;
3. 타깃 및 기본 공격 관련 변수입니다.
enumyGameobject : 현재 공격 대상입니다.
basicAttackObjects : 풀링 공격 오브젝트 배열입니다.
currentBasicObject : 공격에 사용할 현재 오브젝트입니다.
attackSpawnPoint : 플레이어 기준 공격 오브젝트의 초기 위치입니다.
public GameObject enumyGameobject;
[SerializeField] private GameObject[] basicAttackObjects;
private GameObject currentBasicAttackObject;
[SerializeField] private Vector3 attackSpawnPoint;
4. Update 함수에서는 공격 실행 조건을 확인하며 타깃을 가져와 줍니다.
기본 ㅇㅇ는 뭐 뭐로 잡았고 현재 playerAttackSpeed는 3입니다.
공식을 보면
(500 + 3) * 0.01 = 503 * 0.01
즉 5.03초가 됩니다.
3 / 5.03초를 하게 되면 0.596초가 됩니다.
아직 아이템이 추가되진 않았지만, 공격속도를 퍼센트로 관리할 생각이며 약 10%가 향상이 될 땐
float buffRatio = 0.9f; // 공격속도 +10%
float buffedAttackSpeed = playerStats.playerAttackSpeed * buffRatio;
attackInterval = buffedAttackSpeed / ((500 + buffedAttackSpeed) * 0.01f);
와 같은 방식으로 구현할 예정입니다.
void Update()
{
attackInterval = playerStats.playerAttackSpeed / ((500 + playerStats.playerAttackSpeed) * 0.01f);
if (playerMovement.enumyGameobject != null)
{
enumyGameobject = playerMovement.enumyGameobject.gameObject;
}
else
{
enumyGameobject = null;
}
if (enumyGameobject != null && performMeleeAttack && Time.time > nextAttackTime)
{
if (Vector3.Distance(transform.position, enumyGameobject.transform.position) <= playerMovement.stopDistance)
{
StartCoroutine(PlayerAttackInterval());
}
}
}
4-1 타깃 가져오기입니다.
플레이어 이동 컴포넌트에서 현재 추적 중인 적을 가져오며, 적이 없으면 공격 대상을 null로 처리해 주었습니다.
if (playerMovement.enumyGameobject != null)
{
enumyGameobject = playerMovement.enumyGameobject.gameObject;
}
else
{
enumyGameobject = null;
}
4-2 공격 실행 조건입니다.
조건문을 보면
if (공격 대상이 존재할 때, 공격 중이 아니고, 공격 가능한지 체크)
를 합니다.
또한 거리체크는 Distance()를 활용해 현재 위치에서 타깃 오브젝트의 거리가 플레이어가 멈춰야 할 거리보다 작다면
공격 범위에 해당한다고 판단하여 코루틴으로 공격을 실행해 줬습니다.
if (enumyGameobject != null && performMeleeAttack && Time.time > nextAttackTime)
{
if (Vector3.Distance(transform.position, enumyGameobject.transform.position) <= playerMovement.stopDistance)
{
StartCoroutine(PlayerAttackInterval());
}
}
5. 공격 코루틴입니다.
공격 시작 시 performMeleeAttack을 즉시 false로 바꿔주어 중첩 실행을 방지하여 주었으며
공격 간격만큼 대기를 하게 되어 원하는 시간만큼 대기를 하게 해 주었습니다.
또한 만일 타깃 오브젝트가 null로 사라진다면 = 사용자가 "Ground"를 클릭했을 때
공격은 멈추게 해 주었습니다.
private IEnumerator PlayerAttackInterval()
{
performMeleeAttack = false;
playerAnim.SetBool("IsAttack", true);
yield return new WaitForSeconds(attackInterval);
if (enumyGameobject == null)
{
playerAnim.SetBool("IsAttack", false);
performMeleeAttack = true;
}
}
6. 애니메이션 함수를 이벤트로 연결해 주었습니다.
먼저 GetBasicAttackObject() 함수는 기본 공격할 때 발사될 오브젝트를 간단한 풀링 방식으로
활성/비활성화하는 함수입니다. (7.)에서 살펴보겠습니다.
또한 활성화가 된 오브젝트의 SetTarget() 함수에 접근하여 타깃 Enumy를 넘겨주었으며
nextAttackTime(공격 가능한 시간) = Time.time(흐른 시간) + attackInterval (공격 대기시간)
으로 공격 완료 후 쿨다운 적용 및 공격 상태 복귀를 해주었습니다.
public void OnPlayerAttack()
{
if (enumyGameobject != null)
{
GetBasicAttacjObject();
if (currentBasicAttackObject != null)
{
PlayerTargetObject playerTargetObject = currentBasicAttackObject.GetComponent<PlayerTargetObject>();
if (playerTargetObject != null)
{
playerTargetObject.SetTarget(enumyGameobject.transform);
}
}
nextAttackTime = Time.time + attackInterval;
performMeleeAttack = true;
playerAnim.SetBool("IsAttack", false);
}
}
7. 오브젝트 풀링 함수입니다.
간단히 현재 배열을 순차적으로 반복하며 비활성화된 오브젝트가 있을 때
그 해당 오브젝트를 활성화시켜 주며 전역으로 선언한 currentBasicAttackObject에 대입해 줍니다.
또한 활성화된 오브젝트가 있다면 바로 return을 해주어 함수를 종료하게 해 주었습니다.
private void GetBasicAttacjObject()
{
foreach (var basicAttackObject in basicAttackObjects)
{
if (!basicAttackObject.activeSelf)
{
basicAttackObject.transform.position = transform.position + attackSpawnPoint;
currentBasicAttackObject = basicAttackObject;
basicAttackObject.SetActive(true);
return;
}
}
}
다음은 "PlayerTargetObject" 스크립트입니다.
using UnityEngine;
public class PlayerTargetObject : MonoBehaviour
{
[Header("Components")]
[SerializeField] private Transform targetEnumyTr;
[SerializeField] private Rigidbody basicAttackRb;
[SerializeField] private PlayerStats playerStats;
[Header("Setting Value")]
[SerializeField] private float basicAttackSpeed = 0f;
void FixedUpdate()
{
if (targetEnumyTr != null)
{
Vector3 direction = targetEnumyTr.position - transform.position;
basicAttackRb.velocity = direction.normalized * basicAttackSpeed;
}
}
public void SetTarget(Transform newTarget)
{
targetEnumyTr = newTarget;
}
void OnTriggerEnter(Collider other)
{
if (targetEnumyTr != null && ReferenceEquals(other.gameObject, targetEnumyTr.gameObject))
{
EnumyStats enumyStats = targetEnumyTr.GetComponent<EnumyStats>();
enumyStats?.TakeDamage(playerStats.playerDamage);
targetEnumyTr = null;
this.gameObject.SetActive(false);
}
}
}
1. 클래스와 컴포넌트 및 기본공격 오브젝트의 이동속도 변수를 선언해 주었습니다.
targetEnumyTr : 현재 공격 목표(Enumy)의 Transform입니다.
basicAttackRb : 기본 공격 오브젝트의 Rigidbody입니다.
playerStats : 플레이어의 기본 스텟이 담긴 스크립트입니다.
basicAttackSpeed : 오브젝트의 이동 속도입니다.
(높을수록 적에게 더 빨리 날아갑니다.)
[Header("Components")]
[SerializeField] private Transform targetEnumyTr;
[SerializeField] private Rigidbody basicAttackRb;
[SerializeField] private PlayerStats playerStats;
[Header("Setting Value")]
[SerializeField] private float basicAttackSpeed = 0f;
2. 기본공격 오브젝트의 이동은 RigidBody를 사용하였으며 FixedUpdate()에서 활용을 해주었습니다.
( FixedUpdate()를 사용한 이유는 물리 연산이 프레임과 독립적이게 안정적으로 수행되도록 하기 위함입니다.)
타깃이 존재할 때만 이동을 하게 되며
direction : 오브젝트와 타깃 간 방향을 계산해 주고
velocity : Rigidbody의 속도를 이용해 투사체를 이동시켜 주었습니다.
void FixedUpdate()
{
if (targetEnumyTr != null)
{
Vector3 direction = targetEnumyTr.position - transform.position;
basicAttackRb.velocity = direction.normalized * basicAttackSpeed;
}
}
3. 타깃 설정입니다.
위에서 본 PlayerAttack 스크립트에서, 선택된 Enumy를 인자로 넘겨주면
PlayerTargetObject의 SetTarget 함수에서는 그 해당 Enumy를 받아 제어를 해줍니다.
public void SetTarget(Transform newTarget)
{
targetEnumyTr = newTarget;
}
4. 충돌 처리입니다.
OnTriggerEnter()를 사용해 주었으며, 오브젝트가 타깃과 충돌했는지 검사합니다.
또한 ReferenceEquals()를 사용하여 정확히 같은 게임오브젝트인지 체크를 해주었습니다.
(ReferenceEquals(A, B) = A와 B 두 변수가 같은 메모리 주소를 가리키는지 검사합니다
동일하면 true / 다른 객체면 false를 나타내어줍니다.)
또한 목표와 충돌하면 적에게 대미지를 적용하며, 오브젝트는 비활성화를 해주어 재사용을 준비하도록 해주었습니다.
void OnTriggerEnter(Collider other
{
if (targetEnumyTr != null && ReferenceEquals(other.gameObject, targetEnumyTr.gameObject))
{
EnumyStats enumyStats = targetEnumyTr.GetComponent<EnumyStats>();
enumyStats?.TakeDamage(playerStats.playerDamage);
targetEnumyTr = null;
this.gameObject.SetActive(false);
}
}
다음은 "EnumyStats" 스크립트입니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class EnumyStats : MonoBehaviour
{
public float enumyHp = 100f;
public float enumyDamage = 10f;
public float enumyAttackSpeed = 3f;
public void TakeDamage(float damage)
{
enumyHp -= damage;
if (enumyHp <= 0)
{
Destroy(this.gameObject);
}
}
}
1. 클래스와 변수를 정의해 주었습니다.
enumyHp : Enumy의 기본 체력입니다.
enumyDamage : Enumy의 기본 대미지로 사용 예정입니다.
enumyAttackSpeed : Enumy의 공격 속도로 사용 예정입니다.
public float enumyHp = 100f;
public float enumyDamage = 10f;
public float enumyAttackSpeed = 3f;
2. 외부에서 공격을 받으면 실행될 메서드입니다.
현재의 체력에서 damage를 빼서 체력을 갱신시켜 주며
체력이 0보다 아래 거나 같다면 Destory로 현재 오브젝트를 파괴시켜 줍니다.
public void TakeDamage(float damage)
{
enumyHp -= damage;
if (enumyHp <= 0)
{
Destroy(this.gameObject);
}
}
(현재는 UI나 공격 등 아직 작업이 진행되지 않아서, 단순 확인용으로 임시 작성을 하였습니다.)
- 참고 -
Player의 현재 공격력 : 10
Player의 현재 공격 스피드 : 0.56
Player의 현재 공격 범위 : 5
Enumy의 현재 체력 : 100
다음은 UI와 Enumy의 이동 및 공격을 작업하려고 했는데, 시간이 어느덧 예정보다 너무 흘러서
잠을 청한뒤 이어서 작업을 해야 할 것 같습니다.😂
음...
감사합니다. 😊