
게임의 미적 효과들을 담당하는 파티클 시스템과 기존의 인풋매니저보다 한층 강화된 인풋 시스템을 배워보자
GetComponent를 해야 하는 Shoot 함수 부분을 수정하는 것부터 시작한다GetComponent를 수행해, 컴포넌트들을 순회 하지 말고 딕셔너리 자료구조로 바로 불러 와보자Registry 스크립트에서 담당한다/ 게임 오브젝트에 두 개 이상의 동일한 컴포넌트를 가지게 하는 것을 막는다
[DisallowMultipleComponent]
public class JYL_ReferenceProvider : MonoBehaviour
{
// MonoBehaviour는 Component를 상속받는다
// NormalMonster 컴포넌트를 직접 참조시켜줘야 한다
// 프리팹으로 참조시키면, transform이 들어감
[SerializeField] private Component component;
private void Awake()
{
JYL_ReferenceRegistry.Register(this);
}
private void OnDestroy()
{
JYL_ReferenceRegistry.unregister(this);
}
public T GetAs<T>() where T : Component
{
// 예외처리가 여기도 필요하다
return component as T;
}
}
키 값은 게임오브젝트, 벨류 값은 게임 오브젝트가 가지고 있는 Provider 컴포넌트다// MonoBehaviour를 상속받지 않는 static 클래스
public static class JYL_ReferenceRegistry
{
// 딕셔너리로 데이터들 관리
private static Dictionary<GameObject, JYL_ReferenceProvider> providers = new();
public static void Register(JYL_ReferenceProvider referenceProvider)
{
if (providers.ContainsKey(referenceProvider.gameObject)) return;
providers.Add(referenceProvider.gameObject, referenceProvider);
}
public static void unregister(JYL_ReferenceProvider referenceProvider)
{
if (!providers.ContainsKey(referenceProvider.gameObject)) return;
providers.Remove(referenceProvider.gameObject);
}
public static void Clear()
{
providers.Clear();
}
public static JYL_ReferenceProvider GetProvider(GameObject gameObject)
{
if (!providers.ContainsKey(gameObject)) return null;
return providers[gameObject];
}
}
public bool Shoot()
{
RaycastHit hit = default;
IDamagable target = RayShoot(out hit);
if (!hit.Equals(default(RaycastHit)))
{
PlayFireParticle(hit.collider.gameObject, hit.point, Quaternion.LookRotation(hit.normal), hit.normal);
}
private IDamagable RayShoot(out RaycastHit hitTarget)
{
Ray ray = new Ray(cam.transform.position, cam.transform.forward);
RaycastHit hit;
if (Physics.Raycast(ray, out hit, attackRange))
{
// 타겟으로 설정한 레이어를 가진 게임 오브젝트가
// 레이캐스트로 맞았을 때 반환한다
if (hit.collider.gameObject.layer == LayerMask.NameToLayer("Target"))
{
// 딕셔너리로 GetComponent 없이 바로 불러 올 수 있다
return JYL_ReferenceRegistry.GetProvider(hit.collider.gameObject).GetAs<NormalMonster>();
}
}
else
{
hitTarget = default;
}
return null;
}

NormalMonster 프리팹을 넣으면 Transform 컴포넌트가 참조된다. 그래서 IDamagable을 불러 올 수 없어서 TakeDamage를 수행할 수 없었다
프로젝트 창에서 위와 같이 선택하면 만들 수 있다
PlayerControl로 변경했다. 더블클릭으로 열어보자
Auto-Save를 키자
Add Control Scheme를 누르면 스키마를 추가할 수 있다

Action Map을 추가한다. 이름은 PlayerActions 으로 바꾼다
Actions에 Move라는 이름으로 추가한다. Action Type은 Value, Control Type은 Vector2로 한다
Move 옆의 +키를 누르면 인풋을 추가할 수 있다. 위의 사진과 같이 키 할당을 해주자. 검색을 이용하면 빠르게 원하는 키를 찾을 수 있다Player Input 컴포넌트를 추가한다
Actions에 우리가 만들어 둔 PlayerControl을 참조시킨다인풋 시스템으로 바꿔본다
Move와 마찬가지다

Mouse -> Delta 순으로 고르면 된다InputSystem으로 바꾼다

알아두면 좋음
유니티이벤트로도 인풋 구현 가능. 다만,Send Messages를 쓰는 경우가 많음Invoke Csharp Events는 굳이 쓸 일이 없다
유니티이벤트로 발동 시키는 방법이다. 업데이트마다 조건문을 돌리지 않아서 연산이 줄어든다

Multi Tab : 더블클릭, 더블터치와 같은 기능을 쓸 수 있다
public class JYL_PlayerController : MonoBehaviour, IDamagable
{
public bool IsControlActivate { get; set; } = true;
private JYL_PlayerStatus status;
private JYL_PlayerMovement movement;
private Animator animator;
private Image aimImage;
//InputSystem 추가
private InputAction aimInputAction;
private InputAction shootInputAction;
[Header("References")]
[SerializeField] private CinemachineVirtualCamera aimCamera;
[SerializeField] private JYL_Weapon weapon;
[SerializeField] private Animator aimAnimator;
[SerializeField] private JYL_HpGaugeUI hpUI;
void Awake() => Init();
private void OnEnable() => SubscribeEvents();
private void Update() => HandlePlayerControl();
private void OnDisable() => UnsubscribeEvents();
private void Init()
{
status = GetComponent<JYL_PlayerStatus>();
movement = GetComponent<JYL_PlayerMovement>();
animator = GetComponent<Animator>();
aimImage = aimAnimator.GetComponent<Image>();
// InputSystem의 PlayerInput 클래스 참조
aimInputAction = GetComponent<PlayerInput>().actions["Aim"];
shootInputAction = GetComponent<PlayerInput>().actions["Shoot"];
hpUI.SetImageFillAmount(1);
status.CurrentHP.Value = status.MaxHP;
}
// 이동과 에임을 합쳐서 플레이어 컨트롤로 묶음
private void HandlePlayerControl()
{
if (!IsControlActivate) return;
HandleMovement();
}
private void HandleMovement()
{
float moveSpeed;
if (status.IsAiming.Value) moveSpeed = status.walkSpeed;
else moveSpeed = status.runSpeed;
Vector3 moveDir = movement.SetMove(moveSpeed);
status.IsMoving.Value = (moveDir != Vector3.zero);
Vector3 camRotateDir = movement.SetAimRotation();
Vector3 avatarDir;
if (status.IsAiming.Value) avatarDir = camRotateDir;
else avatarDir = moveDir;
movement.SetAvatarRotation(avatarDir);
// 애니메이션 파라메터 설정
// Aim 상태일 때만 적용
if (status.IsAiming.Value)
{
// InputSystem 추가
animator.SetFloat("X", movement.InputDirection.x);
animator.SetFloat("Z", movement.InputDirection.y);
}
}
// 유니티 이벤트로 입력 처리. 입력이 눌릴 때, 뗄 때 한 번씩 호출됨
private void HandleAiming(InputAction.CallbackContext ctx)
{
status.IsAiming.Value = ctx.started;
// 눌린 상태로 유지하려면?
// 1. Key Down일때 -> 키 입력 시작된 시점인지 체크
// 2. Key Up일때 -> 키 입력 시작된 시점인지 체크
// ctx.started; -> 키 입력이 시작되었는지 판별
// ctx.performed; -> 키 입력이 진행 중인지 판별
// ctx.canceled; -> 키 입력이 중지 중인지 판별
}
// InputSystem 추가
public void HandleShooting(InputAction.CallbackContext ctx)
{
// 이 함수가 실행되는 시점이 입력이 있을 때 뿐임
if (status.IsAiming.Value && shootInputAction.IsPressed())
{
// Shoot의 반환 값이 false이면 쏠 수 없다
status.IsAttacking.Value = weapon.Shoot();
}
else
{
status.IsAttacking.Value = false;
}
// GetKeyDown
//shootInputAction.WasPressedThisFrame(); -> 이번 프레임에 눌렸는지 체크
// GetKeyUp
//shootInputAction.WasReleasedThisFrame(); -> 이번 프레임에 뗏는지 체크
// GetKey
//shootInputAction.IsPressed(); -> 이번 프레임에 눌리고 있는지(~ing) 체크
}
public void TakeDamage(int damage)
{
status.CurrentHP.Value -= damage;
if (status.CurrentHP.Value <= 0) PlayerDie();
}
public void RecoverHP(int recoverAmount)
{
int hp = status.CurrentHP.Value + recoverAmount;
status.CurrentHP.Value = Mathf.Clamp(hp, 0, status.MaxHP);
}
public void PlayerDie()
{
Debug.Log("플레이어 죽음");
}
public void SubscribeEvents()
{
status.CurrentHP.Subscribe(SetHpUIGauge);
status.IsAiming.Subscribe(aimCamera.gameObject.SetActive);
status.IsAiming.Subscribe(SetAimAnimation);
status.IsMoving.Subscribe(SetMoveAnimation);
status.IsAttacking.Subscribe(SetAttackAnimation);
// InputSystem 추가
aimInputAction.Enable();
aimInputAction.started += HandleAiming;
aimInputAction.canceled += HandleAiming;
shootInputAction.Enable();
shootInputAction.started += HandleShooting;
shootInputAction.canceled += HandleShooting;
}
public void UnsubscribeEvents()
{
status.CurrentHP.UnSubscribe(SetHpUIGauge);
status.IsAiming.UnSubscribe(aimCamera.gameObject.SetActive);
status.IsAiming.UnSubscribe(SetAimAnimation);
status.IsMoving.UnSubscribe(SetMoveAnimation);
status.IsAttacking.UnSubscribe(SetAttackAnimation);
// InputSystem 추가
aimInputAction.Disable();
aimInputAction.started -= HandleAiming;
aimInputAction.canceled -= HandleAiming;
aimInputAction.Disable();
aimInputAction.started -= HandleShooting;
aimInputAction.canceled -= HandleShooting;
}
private void SetAimAnimation(bool value)
{
if (!aimImage.enabled) aimImage.enabled = true;
animator.SetBool("IsAim", value);
aimAnimator.SetBool("IsAim", value);
}
private void SetMoveAnimation(bool value) => animator.SetBool("IsMove", value);
private void SetAttackAnimation(bool value) => animator.SetBool("IsAttack", value);
private void SetHpUIGauge(int currentHP)
{
float hp = (float)currentHP / status.MaxHP;
hpUI.SetImageFillAmount(hp);
}
}
public class JYL_PlayerMovement : MonoBehaviour
{
[Header("References")]
[SerializeField] private Transform avatar;
[SerializeField] private Transform aim;
[Header("Mouse Config")]
[SerializeField][Range(-90, 0)] private float minPitch;
[SerializeField][Range(0, 90)] private float maxPitch;
[SerializeField][Range(0.1f, 2)] private float mouseSensitivity = 1;
private Rigidbody rig;
private JYL_PlayerStatus playerStatus;
private Vector2 currentRotation;
private void Awake() => Init();
private void Init()
{
rig = GetComponent<Rigidbody>();
playerStatus = GetComponent<JYL_PlayerStatus>();
}
public Vector3 SetMove(float moveSpeed)
{
Vector3 moveDirection = GetMoveDirection();
Vector3 velocity = rig.velocity;
velocity.x = moveDirection.x * moveSpeed;
velocity.z = moveDirection.z * moveSpeed;
rig.velocity = velocity;
return moveDirection;
}
public Vector3 SetAimRotation()
{
currentRotation.x += MouseDirection.x;
currentRotation.y = Mathf.Clamp(currentRotation.y + MouseDirection.y, minPitch, maxPitch);
transform.rotation = Quaternion.Euler(0, currentRotation.x, 0);
Vector3 currentEuler = aim.localEulerAngles;
aim.localEulerAngles = new Vector3(currentRotation.y, currentEuler.y, currentEuler.z);
Vector3 rotateDirVector = transform.forward;
rotateDirVector.y = 0;
return rotateDirVector.normalized;
}
public void SetAvatarRotation(Vector3 direction)
{
if (direction == Vector3.zero) return;
Quaternion targetRotation = Quaternion.LookRotation(direction);
avatar.rotation = Quaternion.Lerp(avatar.rotation, targetRotation, playerStatus.RotateSpeed * Time.deltaTime);
}
public Vector3 GetMoveDirection()
{
Vector3 direction =
// 단위벡터 (1,0,0) * input.x { (-1~1,0,0) => -1~1 }
(transform.right * InputDirection.x) +
// 단위벡터 (0,0,1) + input.z { (0, 0, -1~1) => -1~1 }
(transform.forward * InputDirection.y);
return direction.normalized;
}
public Vector2 InputDirection { get; private set; }
public Vector2 MouseDirection { get; private set; }
// Input System의 Actions 이름과 같은것으로 함수 이름을 정하면 된다
// Move의 앞에 On만 붙이면 연동됨
// SendMessage 방식
public void OnMove(InputValue value)
{
InputDirection = value.Get<Vector2>();
}
//// InputSystem의 Callback(UnityEvent) 방식
//public void TestMove(InputAction.CallbackContext callBackContext)
//{
// Vector2 input = callBackContext.ReadValue<Vector2>();
// Debug.Log(input);
//}
public void OnRotate(InputValue value)
{
Vector2 mouseDir = value.Get<Vector2>();
mouseDir.y *= -1;
MouseDirection = mouseDir * mouseSensitivity;
}
}
public class JYL_Weapon : MonoBehaviour
{
[Header("Set Value")]
[SerializeField][Range(0, 100)] private float attackRange;
[SerializeField] private int shootDamage;
[SerializeField] private float shootDelay;
[SerializeField] private LayerMask targetLayer;
[Header("Set Audio")]
[SerializeField] private AudioClip shootSFX;
[Header("Set Reference for Particle")]
[SerializeField] Transform muzzlePoint;
[SerializeField] Transform bulletOutPoint;
[Header("Set Particle")]
[SerializeField] ParticleSystem fireParticle;
[SerializeField] ParticleSystem fireEffect;
[SerializeField] ParticleSystem bulletOutEffect;
private CinemachineImpulseSource impulse;
private Camera cam;
private bool canShoot { get => timer <= 0; }
private float timer;
private void Awake() => Init();
private void Update() => HandleCanShoot();
private void Init()
{
cam = Camera.main;
impulse = GetComponent<CinemachineImpulseSource>();
}
public bool Shoot()
{
if (!canShoot) return false;
PlayShootSound();
PlayCameraEffect();
PlayShootEffect();
timer = shootDelay;
RaycastHit hit = default;
IDamagable target = RayShoot(out hit);
if (!hit.Equals(default(RaycastHit)))
{
PlayFireParticle(hit.collider.gameObject, hit.point, Quaternion.LookRotation(hit.normal), hit.normal);
}
if (target == null) return true;
target.TakeDamage(shootDamage);
return true;
}
private IDamagable RayShoot(out RaycastHit hitTarget)
{
Ray ray = new Ray(cam.transform.position, cam.transform.forward);
RaycastHit hit;
if (Physics.Raycast(ray, out hit, attackRange))
{
hitTarget = hit;
if (hit.collider.gameObject.layer == LayerMask.NameToLayer("Target"))
{
// 딕셔너리로 GetComponent 없이 바로 불러 올 수 있다
return JYL_ReferenceRegistry.GetProvider(hit.collider.gameObject).GetAs<NormalMonster>();
}
}
else
{
hitTarget = default;
}
return null;
}
private void PlayFireParticle(GameObject gameObject, Vector3 position, Quaternion rotation, Vector3 normal)
{
Instantiate(fireParticle, position + normal * 0.02f, rotation).gameObject.transform.parent = gameObject.transform;
}
private void HandleCanShoot()
{
if (canShoot) return;
timer -= Time.deltaTime;
}
private void PlayShootSound()
{
JYL_SFXController sfx = JYL_GameManager.Instance.audioManager.GetSFX();
sfx.PlayClip(shootSFX);
}
private void PlayCameraEffect()
{
impulse.GenerateImpulse();
}
private void PlayShootEffect()
{
// TODO : 총구 화염 효과 : 파티클 시스템
}
}
파티클로 연출되고 있다. 직접 사용해보도록 하자
파티클 시스템을 추가하고 나면, 하이라키 창에서 생성한 파티클 시스템을 선택 시 씬 창에서 다음과 같은 인스펙터를 볼 수 있다

파티클을 설정할 수 있는 인스펙터 창이다. 지속시간, 반복여부, 시작까지 딜레이, 시작 이후 생존시간, 시작 시 파티클 재생속도 등등 설정 가능

Randomize Direction- 입자의 시작 시 진행 방향에 랜덤성을 줄 수 있다

Material을 새로 적용해야 하기 때문
Weapon 스크립트를 참고하자. Particle로 검색하면 찾기 쉽다ParticleSystem 타입을 에디터 상에서 참조시키면 된다

PlayFireParticle(hit.collider.gameObject,hit.point, Quaternion.LookRotation(hit.normal));
if (!hit.Equals(default(RaycastHit)))
{
Debug.Log($"{hit}이 디폴트가 아님");
PlayFireParticle(hit.collider.gameObject, hit.point, Quaternion.LookRotation(hit.normal), hit.normal);
}
default의 타입을 RaycastHit 타입으로 지정해야 한다
