[Unity] Animator를 사용한 플레이어 조작 및 Raycast를 사용한 Knockback 효과 구현
[Unity] Animator를 사용한 플레이어 조작 및 Raycast를 사용한 Knockback 효과 구현 이전에 멈춰두었던 프로젝트를 다시 잡았다. 인트로 씬부터 다시 플레이 해 보았는데, 뜯어 고칠게 한 두개가
shin0624.tistory.com
이전에 구현한 에너미 넉백(Knockback) 효과를 보완하고, 공격 시 플레이어의 무기에서 파티클 효과가 발생하도록 구현해보았다.
1. 넉백 효과 |
우선 공격 시 에너미가 넉백되는 효과를 Goblin 이외에 다른 에너미들을 구현할 때 재사용하기 쉽도록 interface를 생성하고, 이 인터페이스를 상속받은 BaseHitHandler 추상 클래스를 생성하여 OnHit()메서드를 구현했다.
<IDamageable 인터페이스>
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public interface IDamageable
{
void OnHit(float damage, Vector3 hitPoint, Vector3 hitNormal, float knockBackForce);
}
<BaseHitHandler 클래스>
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public abstract class BaseHitHandler : MonoBehaviour, IDamageable
{
//재사용 가능한 추상클래스 베이스 히트 핸들러 생성
protected Animator animator;
protected NavMeshAgent agent;
protected Rigidbody rb;
protected virtual void Awake()
{
animator = GetComponent<Animator>();
if (animator == null)
Debug.LogError($"{gameObject.name} is missing an Animator component.");
agent = GetComponent<NavMeshAgent>();
if (agent == null)
Debug.LogError($"{gameObject.name} is missing a NavMeshAgent component.");
rb = GetComponent<Rigidbody>();
if (rb == null)
Debug.LogError($"{gameObject.name} is missing a Rigidbody component.");
}
public abstract void OnHit(float damage, Vector3 hitPoint, Vector3 hitNormal, float knockBackForce);
/*
IDamageable 인터페이스의 OnHit 메서드를 구현. 파생클래스에서 오버라이드하여 사용
damage : 플레이어가 적에게 가하는 데미지 값. 체력 감소량을 결정
hitpoint : 공격이 적중한 정확한 위치. 파티클 효과나 피격 효과를 표시할 위치 지정에 사용. RaycastHit.point 값을 전달받음
hitNormal : 충돌이 발생한 표면의 법선 벡터. 튕겨나가는 방향이나 이펙트의 방향을 결정할 때 사용. RaycastHit.normal 값을 전달받음
knocBackForce : 피격 시 밀려나가는 힘의 크기. 거리에 따라 감소하는 넉백 효과 적용 등에 사용.
*/
}
플레이어의 공격 애니메이션과 에너미의 넉백 타이밍을 맞추기 위해, PlayerController의 Attack메서드를 코루틴으로 바꾸었다.
public class PlayerController : MonoBehaviour
{
private IEnumerator AttackRountine()// Attack메서드를 코루틴으로 변경. 애니메이션 클립과 실제 공격 판정 간 간극 해소
{
SetState(Define.PlayerState.ATTACK, "ATTACK");
Anim.SetBool("IsAttack", true);
yield return new WaitForSeconds(attackDelay);//공격 딜레이 변수값 만큼 대기 후 재생.
//실제 공격 판정
Vector3 rayOrigin = transform.position + Vector3.up * 1.5f;
Vector3 rayDirection = transform.forward;
float rayRadius = 1.0f;//구체 레이캐스트의 반지름
//구체 레이캐스트를 사용하여 넓은 범위 감지. 단일 레이캐스트보다 더 자연스러운 무기 판정 가능
RaycastHit[] hits = Physics.SphereCastAll(rayOrigin, rayRadius, rayDirection, attackRange, enemyLayer);// 에너미 레이어를 새로 설정해서 에너미에게만 효과가 가해지도록 함.
//디버그 시각화
Debug.DrawRay(rayOrigin, rayDirection * attackRange, Color.red, 3.0f);
Debug.DrawLine(
rayOrigin + rayDirection * attackRange + Vector3.up * rayRadius,
rayOrigin + rayDirection * attackRange - Vector3.up * rayRadius,
Color.blue,
3.0f
);
foreach(RaycastHit hit in hits)
{
if(hit.collider.CompareTag("Enemy"))
{
Debug.Log($"Hit object: {hit.collider.gameObject.name} | Layer: {LayerMask.LayerToName(hit.collider.gameObject.layer)}");
//Rigidbody enemyRb = hit.collider.GetComponent<Rigidbody>();
IDamageable damageable = hit.collider.GetComponent<IDamageable>();
if(damageable != null)
{
//거리에 따른 넉백 힘 감소
float distanceMultiplier = 1 - (hit.distance / attackRange);
float finalKnockbackForce = knockBackForce * distanceMultiplier; // 타격 지점에서 멀 수록 넉백 효과가 약해짐
damageable.OnHit(10.0f, hit.point, hit.normal, finalKnockbackForce);//10의 데미지, 공격이 적중한 위치, 충돌 표면의 법선벡터, 밀려나가는 힘의 크기를 매개변수로 전달.
// ray가 닿은 콜라이더를 가진 에너미의 idamageable 인터페이스를 찾아서 OnHit 메서드를 호출
}
}
}
yield return new WaitForSeconds(Anim.GetCurrentAnimatorStateInfo(0).length);//애니메이션 클립 길이만큼 대기
ResetAttack();//공격 애니메이션 리셋
}
private void ResetAttack()
{
Anim.SetBool("IsAttack", false); // 공격 애니메이션 리셋
}
}
Goblin의 피격 판정을 위해 Animator에 Hit 클립을 추가하고, HitCoroutine을 선언했다. 어떤 상태에 있던 피격을 당하면 한번 넉백 되고, 플레이어와의 거리에 따라서 적절한 상태로 전환되도록 했다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class EnemyController : MonoBehaviour, IDamageable
{
//----------------- 고블린 에너미 기본 변수 -----------------
[SerializeField] private GameObject Player;
[SerializeField] private NavMeshAgent Agent; // Bake된 NavMesh에서 활동할 에너미
[SerializeField] private Animator Anim;
[SerializeField] private Rigidbody rb;
private Define.EnemyState state;//에너미 상태 변수
private float currentHitTime = 0.0f; // 현재 피격 시간
//----------------- 범위, 거리 변수 -----------------
[SerializeField, Range(0f, 20.0f)] private float ChaseRange = 12.0f;//플레이어 추격 가능 범위
[SerializeField, Range(0f, 20.0f)] private float DetectionRange = 8.0f;// 플레이어 탐지 거리
[SerializeField, Range(0f, 20.0f)] private float AttackRange = 2.0f;// 공격 가능 범위
[SerializeField] private float hitRecoveryTime = 0.5f; // 피격 후 회복 시간
private List<Vector3> Path = new List<Vector3>();// A*알고리즘으로 계산된 경로를저장할 리스트
private int CurrentPathIndex = 0;// 에너미가 현재 이동중인 경로 지점의 인덱스. 처음에는 Path[0]으로 이동.
private float DistanceToPlayer;//플레이어와의 거리를 저장할 변수
//----------------- 소리, 기타 -----------------
[SerializeField] private AudioClip roarSound;
[SerializeField] private AudioClip hitSound;
private AudioManager audioManager;
private Coroutine hitCoroutine; // hit 상태 처리를 코루틴으로 수행
private void Start()
{
Player = GameObject.FindGameObjectWithTag("Player");
state = Define.EnemyState.IDLE;//초기상태 : IDLE
Agent = GetComponent<NavMeshAgent>();
Agent.isStopped = true;
rb = GetComponent<Rigidbody>();
audioManager = FindAnyObjectByType<AudioManager>();
BeginPatrol();//처음에 탐지 시작
}
private void Update()//Hit상태는 코루틴에서 처리하니까 switch문에서 제외
{
DistanceToPlayer = Vector3.Distance(transform.position, Player.transform.position);//플레이어와 에너미 사이의 거리를 계산
switch (state)
{
case Define.EnemyState.IDLE:
case Define.EnemyState.WALKING:
Patrol();//경로에 따라 탐색을 계속 진행
break;
case Define.EnemyState.RUNNING:
UpdateChase();// 플레이어를 추격
break;
case Define.EnemyState.ATTACK:
UpdateAttack();//플레이어를 공격
break;
}
}
private void Patrol()// 탐색상태
{
if (Agent.isOnNavMesh && Path.Count > 0)
{
Agent.isStopped = false;
Agent.speed = 3.0f;
if (DistanceToPlayer <= DetectionRange && state != Define.EnemyState.ATTACK)//탐지 범위 내에 플레이어가 존재하면 && 공격 상태가 아닐 때 추격을 시작한다.
{
audioManager.PlaySound(roarSound);
SetState(Define.EnemyState.RUNNING, "RUNNING");
return;
}
if (!Agent.hasPath || Agent.remainingDistance < 1.0f)//현재 경로가 없거나, 목표 지점에 도달하면
{
if (CurrentPathIndex < Path.Count)//경로 상의 다음 지점으로 이동
{
Agent.SetDestination(Path[CurrentPathIndex]);
CurrentPathIndex++;//현재 이동중인 경로의 다음 경로로 이동할 것.
}
else// 경로 끝에 도달하면 새 경로 계산
{
CalculateNewPath();
}
}
}
}
private void BeginPatrol()
{
SetState(Define.EnemyState.WALKING, "WALKING"); // 걸어다니며 탐색 시작
Agent.isStopped = false;
CalculateNewPath();// 새로운 경로를 계산
}
private void UpdateAttack()// 공격 후 -> 플레이어와의 거리가 공격 가능 범위를 넘어간 상태 && 플레이어와의 거리가 아직 탐지 범위에 포함될 때 다시 쫒아가 플레이어를 공격해야 함.
{
if (Agent.isOnNavMesh)
{
Agent.isStopped = true;//공격 시 그 자리에서 멈춤
SetState(Define.EnemyState.ATTACK, "ATTACK");
if (DistanceToPlayer > AttackRange)// 공격범위를 벗어났다면
{
UpdateChase();
return;
}
}
}
private void UpdateChase()
{
if (Agent.isOnNavMesh)
{
SetState(Define.EnemyState.RUNNING, "RUNNING");
Agent.isStopped = false;
Agent.speed = 4.0f;
Agent.destination = Player.transform.position;// 목적지를 플레이어 포지션으로 설정하여 추격
if (DistanceToPlayer > ChaseRange)//플레이어와의 거리가 추격 가능 범위를 벗어났다면
{
BeginPatrol();//탐지 상태로 전환
}
else if (DistanceToPlayer < AttackRange)//공격 가능 범위까지 다가갔다면
{
UpdateAttack();
return;
}
}
}
private void CalculateNewPath()// 새로운 경로를 계산하는 메서드
{
Vector3 RandomDirection = Random.insideUnitSphere * 10.0f;// 반경 1을 갖는 구 안의 임의 지점 * 10으로 경로 설정
RandomDirection += transform.position;// 에너미 포지션 값에 랜덤 값을 더한다
NavMeshHit hit;
//SamplePosition((Vector3 sourcePosition, out NavmeshHit hit, float maxDistance, int areaMask)
// 샘플포지션 메서드 : areaMask에 해당하는 NavMesh 중에서, maxDistance 반경 내에서 sourcePosition에 최근접한 위치를 찾아 hit에 담는다.
if (NavMesh.SamplePosition(RandomDirection, out hit, 10.0f, NavMesh.AllAreas))
{
Path.Clear();
Path.Add(hit.position);
CurrentPathIndex = 0;// 현재위치를 담는 변수 초기화
Agent.SetDestination(Path[CurrentPathIndex]);
}
}
public void OnHit(float damage, Vector3 hitPoint, Vector3 hitNormal, float knockbackForece)//피격 처리 메서드
{
if (state == Define.EnemyState.HIT) return;//피격 상태일 때는 추가 피격을 받지 않는다. 피격 회복시간을 매우 짧게 설정
if(hitCoroutine!=null)
{
StopCoroutine(hitCoroutine);//이전 Hit코루틴이 실행 중이라면 중지.
}
hitCoroutine = StartCoroutine(HitRoutine(hitPoint, knockbackForece));//새로운 피격 코루틴 시작
}
private IEnumerator HitRoutine(Vector3 hitPoint, float knockbackForece)// Hit상태를 코루틴으로 관리. 이를 통해 피격 판정과 애니메이션 속도 간 동기화 및 피격 후 상태 고정을 예방한다.
{
//상태 변화 및 애니메이션 설정
SetState(Define.EnemyState.HIT, "HIT");
Agent.isStopped = true;
currentHitTime = 0.0f;
if(rb!=null)//넉백 효과 및 사운드 재생
{
Vector3 knockbackDirection = (transform.position - hitPoint).normalized;
knockbackDirection.y = 0;
rb.AddForce(knockbackDirection * knockbackForece, ForceMode.Impulse);
}
if(audioManager!=null && hitSound!=null)
{
audioManager.PlaySound(hitSound);
}
//Hit 애니메이션 길이만큼 대기
float hitAnimLength = Anim.GetCurrentAnimatorStateInfo(0).length;// 현재 재생중인 애니메이션의 길이를 반환
yield return new WaitForSeconds(hitAnimLength);
//Hit 상태에서 복귀
currentHitTime = 0.0f;
Agent.isStopped = false;
// Hit 후 플레이어와의 거리에 따라 적절한 상태로 전환
if (DistanceToPlayer <= AttackRange)// 공격 당한 후 플레이어와의 거리가 공격 가능 범위 내에 있다면
{
SetState(Define.EnemyState.ATTACK, "ATTACK");
}
else if (DistanceToPlayer <= DetectionRange)// 공격 당한 후 플레이어를 쫒아갈 수 있다면
{
SetState(Define.EnemyState.RUNNING, "RUNNING");
}
else//그 외의 경우
{
SetState(Define.EnemyState.WALKING, "WALKING");
BeginPatrol();
}
hitCoroutine = null;
}
private void SetState(Define.EnemyState NewState, string AnimationTrigger)// 상태변경 메서드
{
if (state != NewState)
{
state = NewState;
Anim.ResetTrigger("IDLE");
Anim.ResetTrigger("WALKING");
Anim.ResetTrigger("RUNNING");
Anim.ResetTrigger("HIT");
Anim.ResetTrigger("ATTACK");
Anim.SetTrigger(AnimationTrigger);
Debug.Log($"Enemy state changed to: {NewState} with animation: {AnimationTrigger}");
}
}
}
2. 파티클 시스템 적용 |
플레이어가 공격할 때, 에너미가 공격 당할 때 각각 파티클 시스템을 적용하려 한다.
에셋 스토어에서 파티클 에셋을 찾아서 임포트해준다.
파티클 시스템을 적용하려면 파티클 객체가 Hierarchy에 존재해야 하는데, 공격 할 때 마다 Instantiate하는 것 보다 오브젝트 풀링으로 관리하면 더 나을 듯 하다.
크기 10의 Queue를 사용하여 오브젝트 풀링을 위한 ParticlePool 스크립트를 작성한다. 이 스크립트를 Manager에 생성했던 공격 파티클 풀, 히트 파티컬 풀 객체에 각각 추가하고, 파티클 시스템 객체를 할당한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ParticlePool : MonoBehaviour
{
//파티클 시스템을 오브젝트 풀링으로 관리하여 성능 최적화를 시도하기 위한 클래스. 파티클 시스템이 작동하려면 Hierarchy에 객체가 존재하거나 Instantiate로 생성해주어야 하는데, 이를 최소화하기 위함
[SerializeField] private GameObject particlePrefab;//파티클 프리팹
[SerializeField] private int poolSize = 10;//풀 크기
private Queue<GameObject> pool = new Queue<GameObject>();//파티클 오브젝트 풀
private void Awake() {//초기화 : 풀 크기만큼 오브젝트 생성
for(int i=0; i<poolSize; i++)
{
GameObject particle = Instantiate(particlePrefab);//파티클 생성
particle.SetActive(false);//비활성화
pool.Enqueue(particle);//풀에 추가
}
}
public GameObject GetParticle()//파티클 오브젝트 풀에서 파티클을 가져오는 메서드
{
if(pool.Count>0)//풀에 파티클이 존재하면
{
GameObject particle = pool.Dequeue();//풀에서 파티클을 꺼내옴
particle.SetActive(true);//활성화
return particle;
}
else{
GameObject particle = Instantiate(particlePrefab, transform);//풀에 파티클이 없으면 새로 생성
particle.SetActive(true);//활성화
return particle;
}
}
public void ReturnParticle(GameObject particle)//파티클을 풀로 반환하는 메서드
{
particle.SetActive(false);//비활성화
pool.Enqueue(particle);//풀로 반환
}
}
![]() |
![]() |
이 다음에, 각각 별도의 풀을 사용하여 파티클을 관리할 PlayerAttackParticleManager 클래스를 생성하고, Managers 객체에 추가한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerAttackParticleManager : MonoBehaviour
{
[SerializeField] private ParticlePool attackParticlePool;//공격 파티클 풀
[SerializeField] private ParticlePool hitParticlePool;//히트 파티클 풀
public void PlayAttackParticle(Vector3 position, Quaternion rotation)//공격 시 파티클 재생
{
if (attackParticlePool != null)//공격 파티클 풀이 존재하면
{
GameObject particle = attackParticlePool.GetParticle();//파티클 오브젝트 풀에서 파티클 가져오기
particle.transform.position = position;//위치 설정
particle.transform.rotation = rotation;//회전 설정
ParticleSystem ps = particle.GetComponent<ParticleSystem>();//파티클 시스템 컴포넌트 가져오기
if (ps != null)//파티클 시스템이 존재하면
{
ps.Play();//파티클 재생
StartCoroutine(ReturnToPoolAfterDuration(particle, ps.main.duration + ps.main.startLifetime.constant));//파티클 재생 시간 후 풀로 반환
}
}
}
public void PlayHitParticle(Vector3 hitPoint, Vector3 hitNormal)// 피격 시 파티클 재생
{
if (hitParticlePool != null)//히트 파티클 풀이 존재하면
{
GameObject particle = hitParticlePool.GetParticle();//파티클 오브젝트 풀에서 파티클 가져오기
particle.transform.position = hitPoint;//위치 설정
particle.transform.rotation = Quaternion.LookRotation(hitNormal);//회전 설정
ParticleSystem ps = particle.GetComponent<ParticleSystem>();//파티클 시스템 컴포넌트 가져오기
if (ps != null)
{
ps.Play();
StartCoroutine(ReturnToPoolAfterDuration(particle, ps.main.duration + ps.main.startLifetime.constant));
}
}
}
private IEnumerator ReturnToPoolAfterDuration(GameObject particle, float delay)// 지정된 시간이 지난 후 파티클 풀로 반환
{
yield return new WaitForSeconds(delay);//지정된 시간만큼 대기
if (attackParticlePool!=null && particle.name.Contains("Attack"))// 공격 파티클이 존재하면
{
attackParticlePool.ReturnParticle(particle);//공격 파티클 풀로 반환
}
else if(hitParticlePool!=null) //히트 파티클이 존재하면
{
hitParticlePool.ReturnParticle(particle);//히트 파티클 풀로 반환
}
}
}
이제 PlayerController와 EnemyController에 각각 파티클 재생을 호출할 코드 몇 줄만 추가하면 완성이다.
PlayerController : PlayerAttackParticleManager 인스턴스 선언, AttackCoroutine에 파티클 호출 추가
EnemyController : PlayerAttackParticleManager 인스턴스 선언, OnHit에 파티클 호출 추가
public class EnemyController : MonoBehaviour, IDamageable
{
......
[SerializeField] private PlayerAttackParticleManager particleManager; // 에너미 공격 이펙트 매니저
......
public void OnHit(float damage, Vector3 hitPoint, Vector3 hitNormal, float knockbackForece)//피격 처리 메서드
{
if (state == Define.EnemyState.HIT) return;//피격 상태일 때는 추가 피격을 받지 않는다. 피격 회복시간을 매우 짧게 설정
if(hitCoroutine!=null)
{
StopCoroutine(hitCoroutine);//이전 Hit코루틴이 실행 중이라면 중지.
}
hitCoroutine = StartCoroutine(HitRoutine(hitPoint, knockbackForece));//새로운 피격 코루틴 시작
particleManager.PlayHitParticle(hitPoint,hitNormal);//피격 파티클 재생
}
......
}
public class PlayerController : MonoBehaviour
{
......
[SerializeField] private PlayerAttackParticleManager particleManager;//플레이어 공격 이펙트 매니저
......
private IEnumerator AttackRountine()// Attack메서드를 코루틴으로 변경. 애니메이션 클립과 실제 공격 판정 간 간극 해소
{
SetState(Define.PlayerState.ATTACK, "ATTACK");
Anim.SetBool("IsAttack", true);
//공격 파티클 호출
Vector3 particlePosition = transform.position + transform.forward * 1.5f; //파티클 위치를 검 위치로
Quaternion particleRotation = transform.rotation;
particleManager.PlayAttackParticle(particlePosition, particleRotation);//공격 파티클 재생
yield return new WaitForSeconds(attackDelay);//공격 딜레이 변수값 만큼 대기 후 재생.
//실제 공격 판정
Vector3 rayOrigin = transform.position + Vector3.up * 1.5f;
Vector3 rayDirection = transform.forward;
float rayRadius = 1.0f;//구체 레이캐스트의 반지름
//구체 레이캐스트를 사용하여 넓은 범위 감지. 단일 레이캐스트보다 더 자연스러운 무기 판정 가능
RaycastHit[] hits = Physics.SphereCastAll(rayOrigin, rayRadius, rayDirection, attackRange, enemyLayer);// 에너미 레이어를 새로 설정해서 에너미에게만 효과가 가해지도록 함.
//디버그 시각화
Debug.DrawRay(rayOrigin, rayDirection * attackRange, Color.red, 3.0f);
Debug.DrawLine(
rayOrigin + rayDirection * attackRange + Vector3.up * rayRadius,
rayOrigin + rayDirection * attackRange - Vector3.up * rayRadius,
Color.blue,
3.0f
);
foreach(RaycastHit hit in hits)
{
if(hit.collider.CompareTag("Enemy"))
{
Debug.Log($"Hit object: {hit.collider.gameObject.name} | Layer: {LayerMask.LayerToName(hit.collider.gameObject.layer)}");
//Rigidbody enemyRb = hit.collider.GetComponent<Rigidbody>();
IDamageable damageable = hit.collider.GetComponent<IDamageable>();
if(damageable != null)
{
//거리에 따른 넉백 힘 감소
float distanceMultiplier = 1 - (hit.distance / attackRange);
float finalKnockbackForce = knockBackForce * distanceMultiplier; // 타격 지점에서 멀 수록 넉백 효과가 약해짐
damageable.OnHit(10.0f, hit.point, hit.normal, finalKnockbackForce);//10의 데미지, 공격이 적중한 위치, 충돌 표면의 법선벡터, 밀려나가는 힘의 크기를 매개변수로 전달.
// ray가 닿은 콜라이더를 가진 에너미의 idamageable 인터페이스를 찾아서 OnHit 메서드를 호출
}
}
}
yield return new WaitForSeconds(Anim.GetCurrentAnimatorStateInfo(0).length);//애니메이션 클립 길이만큼 대기
ResetAttack();//공격 애니메이션 리셋
}
......
}
이제 플레이 해보면, 의도한대로 플레이어가 공격했을 때 에너미가 넉백되고, 파티클이 호출되는 것을 볼 수 있다.
피격 파티클이 에너미 위치에서 터지게 하고, 애니메이션 재생 속도와 타이밍 동기화가 더 필요할 것 같다.
파티클 + 넉백이 모두 추가된 코드를 첨부하면 글이 너무 길어져서, 깃허브 링크로 대체한다.
PlayerController
Unity_LoeGriA_Remake/Assets/Scripts/24.07new/Controller/CharacterController/PlayerController.cs at main · shin0624/Unity_LoeGri
프로젝트 구조 변경으로 LoeGriA 리포지토리 재생성. Contribute to shin0624/Unity_LoeGriA_Remake development by creating an account on GitHub.
github.com
EnemyController
Unity_LoeGriA_Remake/Assets/Scripts/24.07new/Controller/CharacterController/EnemyContoller.cs at main · shin0624/Unity_LoeGriA_
프로젝트 구조 변경으로 LoeGriA 리포지토리 재생성. Contribute to shin0624/Unity_LoeGriA_Remake development by creating an account on GitHub.
github.com
'UnityPortfolio > LoeGriA' 카테고리의 다른 글
[Unity] 스킬 기능 구현 (1) PlayerController 책임 분리 (6) | 2025.01.02 |
---|---|
[Unity] 캐릭터 HP 시스템 구현 (3) | 2025.01.01 |
[Unity] Object Pooling으로 에너미 객체 관리 (3) | 2024.12.31 |
[Unity] Animator를 사용한 플레이어 조작 및 Raycast를 사용한 Knockback 효과 구현 (6) | 2024.12.27 |
(1인 프로젝트) 3D 오픈 월드 RPG LoeGriA (3) | 2024.09.12 |