Unity - Particle , InputSystem, TPS - 4

땡구의 개발일지·2025년 5월 19일

Unity마스터

목록 보기
30/78
post-thumbnail

게임의 미적 효과들을 담당하는 파티클 시스템과 기존의 인풋매니저보다 한층 강화된 인풋 시스템을 배워보자


TPS 게임 만들기 - 4

  • 오늘은 사격마다 GetComponent를 해야 하는 Shoot 함수 부분을 수정하는 것부터 시작한다

GetComponent 피하기

  • 사격 할 때마다 GetComponent를 수행해, 컴포넌트들을 순회 하지 말고 딕셔너리 자료구조로 바로 불러 와보자
  • 먼저 스크립트를 2개 추가한다

ReferenceProvider

  • 참조시킬 데이터들을 외부에 제공하는 역할을 하는 스크립트. 데이터들은 딕셔너리 자료구조로 저장한다
  • 저장하는 부분과 컴포넌트를 가져오는 것은 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;
    }
}

ReferenceRegistry

  • 참조시킬 데이터들을 보관할 딕셔너리 스크립트. 키 값게임오브젝트, 벨류 값은 게임 오브젝트가 가지고 있는 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];
    }
}

Weapon 수정

  • 추가 된 스크립트들을 사용할 수 있게 수정한다
 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);
     }

PlayerController 수정

  • 여기서도 좀 수정해야된다
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를 수행할 수 없었다

InputSystem

  • 인풋매니저로 구현되어 있는 입력 시스템을 인풋시스템으로 교체 해보자

Input Actions 만드는 방법

  • 프로젝트 창에서 위와 같이 선택하면 만들 수 있다
  • 이름을 PlayerControl로 변경했다. 더블클릭으로 열어보자
  • 위의 사진과 같이 뜬다. 오른쪽 구석의 Auto-Save를 키자
  • Add Control Scheme를 누르면 스키마를 추가할 수 있다
  • 스키마는 일종의 프리셋 이라고 보면 된다
  • 위의 사진과 같이 인풋에 사용될 장치들을 추가할 수 있다
  • Action Map을 추가한다. 이름은 PlayerActions 으로 바꾼다

Move

  • ActionsMove라는 이름으로 추가한다. Action TypeValue, Control TypeVector2로 한다
  • Move 옆의 +키를 누르면 인풋을 추가할 수 있다. 위의 사진과 같이 키 할당을 해주자. 검색을 이용하면 빠르게 원하는 키를 찾을 수 있다

Player Input

  • 플레이어 캐릭터 프리팹에 Player Input 컴포넌트를 추가한다
  • 위와같이 Actions에 우리가 만들어 둔 PlayerControl을 참조시킨다

Rotate

  • 마우스의 입력을 받아 회전하는 것도 인풋 시스템으로 바꿔본다
  • Move와 마찬가지다
  • Mouse -> Delta 순으로 고르면 된다

Aim

  • 에임 조작을 InputSystem으로 바꾼다
  • 위의 사진과 같이 추가하면 된다

알아두면 좋음

  • 유니티이벤트로도 인풋 구현 가능. 다만, Send Messages를 쓰는 경우가 많음
  • Invoke Csharp Events는 굳이 쓸 일이 없다

InputAction Events

  • 인풋을 유니티이벤트로 발동 시키는 방법이다. 업데이트마다 조건문을 돌리지 않아서 연산이 줄어든다
  • 굳이 꼭 해야 할 필요는 없다! 더 좋은 방법들도 있다

게임패드 연동

  • 게임패드도 연동해보자
  • 위의 사진과 같이 쭉 추가하면된다. 어렵지 않다. 스크립트로 자동으로 연동이 된다

Interaction

  • Multi Tab : 더블클릭, 더블터치와 같은 기능을 쓸 수 있다
  • 그 외에도 많은 기능들이 있으니, 알아보자

스크립트 수정

PlayerController


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);
    }
}

PlayerMovement

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;
    }
}

Weapon

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 : 총구 화염 효과 : 파티클 시스템
    }
}

Particle

유니티 - 파티클 시스템

  • 게임 개발자가 더 많은 효과를 구현할 수 있도록 도입된 효과, 연출 기술
  • 많은 게임 내 효과들이 파티클로 연출되고 있다. 직접 사용해보도록 하자
  • 오늘은 총을 발사할 때의 이펙트와 적에게 탄착 시 발생하는 이펙트를 발생시켜 볼 것이다

파티클 추가

씬 창 파티클

  • 파티클 시스템을 추가하고 나면, 하이라키 창에서 생성한 파티클 시스템을 선택 시 씬 창에서 다음과 같은 인스펙터를 볼 수 있다
  • 버튼을 눌러가며 재생해볼 수 있고, 간단한 시뮬레이션 설정들이 가능하다

인스펙터 창 파티클

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

Emmision

  • 파티클의 입자들을 한 번에 내놓을 수 있는 기능이다. 샷건을 생각하면 이해가 쉬움

Shape

  • 파티클의 입자 모양을 변경할 수 있다. 여기서 눈여겨 볼 만한 것은 Randomize Direction- 입자의 시작 시 진행 방향에 랜덤성을 줄 수 있다

Color over Lifetime

  • 파티클의 재생 시간에 따른 입자의 색상을 변화시킬 수 있다

Trail

  • 파티클 입자의 진행경로에 꼬리를 만드는 기능
  • 보라색으로 뜨는 것은 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 타입으로 지정해야 한다
  • 제대로 실행된다
profile
개발 박살내자

0개의 댓글