[Unity] Knife Hit (4)

suhan0304·2024년 8월 26일

유니티 - Knife Hit

목록 보기
4/9
post-thumbnail

Knife Destroy Logic

Target 오브젝트가 파괴되면 Target에 꽂혀있던 Knife 오브젝트도 날라가면서 오브젝트가 파괴되도록 해보자.

원래는 반복 사용하는 오브젝트에 대해서는 오브젝트 풀링 을 사용해서 오브젝트를 반환받는게 맞는데 간단하게 구현하는게 목표라서 그냥 진행했다.

Knife.cs

public void KnifeDestroy() {
    hasInteracted = true;
    StartCoroutine(KnifeDestroyCoroutine());
}

IEnumerator KnifeDestroyCoroutine() {
    rb.bodyType = RigidbodyType2D.Dynamic;

    float randBounceForce = Random.Range(bounceForce, bounceForce * 1.5f);
    rb.AddForce(Vector3.up * randBounceForce, ForceMode2D.Impulse);

    float randomTorque = Random.Range(-200f, 200f);
    rb.angularVelocity = randomTorque;

    yield return new WaitForSeconds(delayDestroyTime);

    Destroy(gameObject);
}

정말 델리게이트를 잘 사용하지 못하는 나도 이 편리성을 느끼는데.. 잘 사용하는 사람은 얼마나 더 편할까..

문제점이 생겼는데, Target이 파괴되면 그 다음 Target이 생성되기 전까지 Knife 생성을 하지 못하도록 해주어야한다. SpawnKnife() 로직을 수정해주자.

지금 Knife 스폰 과정은 아래와 같다.


  1. Start 시에 knife 생성
  2. Touch 시 OnTouchScreen Event + Fire 호출
    • GameManager의 RemainKnives--;
    • UIManager의 knifeIcon 변경
  3. Fire Knife가 Target과 충돌
  4. OnHit 함수 호출
    • RemainKnives == 0이면 OnAllKnivesOnHit 호출
  5. 무조건 Knife 재생성

이 과정을 아래와 같이 바꿔주자.


  1. Start 시가 아니라 Spawn Target 시에 knife 생성
  2. Touch 시 OnTouchScreen Event + Fire 호출
    • GameManager의 RemainKnives--;
    • UIManager의 knifeIcon 변경
  3. Fire Knife가 Target과 충돌
  4. OnHit 함수 호출
    • RemainKnives == 0이면 OnAllKnivesOnHit 호출
  5. ReminaKnives가 0보다 클 때 Knife 재생성

[Button("SpawnTarget")]
public void SpawnTarget() {
    SpawnKnife();
    GameObject currentTarget = Instantiate(target);
    RemainKnives = currentTarget.GetComponent<Target>().knivesToDestroy;
    UIManager.Instance.Initialize();
    UIManager.Instance.SpawnKnivesIcon(RemainKnives);
}

public void Start() {
    SpawnTarget();
}

이제 Target이 파괴된 이후에 다시 Target이 생성되도록 하자.

IEnumerator DestroyTargetCoroutine() {
    yield return new WaitForSeconds(destructionDelay);
    Destroy(gameObject);
    yield return null;
    GameManager.Instance.SpawnTarget();
}

대충 magnitude와 gravityScale을 좀 조절해주면 아래와 같이 연출이 가능하다.


Animation

칼과 Target이 생성될 때 실행될 간단한 Animation 하나만 만들어주자. DOTween으로 구현해주었다. 유니티 Animation을 사용하지 않은 이유는 일단 첫번째로 굳이 Animation 파일을 별도로 추가로 만들고 연결하고 하는 과정이 낭비같기도 하고, Animation 진행 중에 다른 이벤트를 처리하려면 같은 스크립트 내에서 제어하는게 편할 거 같아서 DOTween을 사용해주었다.

knife.cs

private void OnEnable() {
    Events.OnTouchScreen += FireKnife;    
    Events.OnAllKnivesOnHit += KnifeDestroy;    
}
private void OnDisable() {
    Events.OnTouchScreen -= FireKnife;  
    Events.OnAllKnivesOnHit -= KnifeDestroy;    
}

private void Start() {
    rb = GetComponent<Rigidbody2D>();
    sr = GetComponent<SpriteRenderer>();

    rb.gravityScale = knifeGravityScale;

    StartKnifeAnimation();
}

private void StartKnifeAnimation() {
    animEndPosition = transform.position;
    animStartPosition = transform.position - new Vector3(0, 1f, 0);

    sr.color = new Color(1, 1, 1, 0); 
    transform.position = animStartPosition;

    animationTween = DOTween.Sequence()
        .Append(transform.DOMove(animEndPosition, animationDuration))
        .Join(sr.DOFade(1, animationDuration))
        .OnKill(() => {EndKnifeAnimation();
        });
}

private void EndKnifeAnimation() {
    sr.color = new Color(1, 1, 1, 1);
    transform.position = animEndPosition;
}

[Button("Fire")]
public void FireKnife() {
    if (animationTween != null)
        if(animationTween.IsPlaying()) 
            animationTween.Kill();
    EndKnifeAnimation();
    rb.velocity = Vector3.up * speed;
}

이렇게 작성해줬는데 자꾸 아래와 같이 DOTween이 이미 Kill된 Tween이라고 경고가 뜨는데 어디서 뜨는지 찾아봤다.

public void FireKnife() {
    if (animationTween != null)
        if(animationTween.IsPlaying()) 
            animationTween.Kill();
    EndKnifeAnimation();
    rb.velocity = Vector3.up * speed;
}

여기서 뜨는데 아마도 Kill이 된 이후에 Fire을 또 하게 되면 아직 날라가고 있는 칼, 즉 Events.OnTouchScreen -= FireKnife;이 실행되지 않은 knife의 Fire이 또 실행되면서 이미 죽은 Tween을 자꾸 확인해서 오류가 생긴것 같다.

그래서 어차피 무조건 죽일거니까 IsPlaying을 굳이 검사할 필요가 있나 싶어서 아래와 같이 수정해주니 경고가 모두 없어졌다.

public void FireKnife() {
    animationTween?.Kill();
    EndKnifeAnimation();
    rb.velocity = Vector3.up * speed;
}

이와 유사하게 Target도 애니메이션을 넣어주자.

Target.cs

private void Start() {
    transform.position = new Vector3(0f, startPositionY, 0f);
    rotateCoroutine = StartCoroutine(RotateTargetObject());

    StartTargetAnimation();
}


private void StartTargetAnimation() {
    transform.localScale = Vector3.zero;
    
    DOTween.Sequence()
        .Append(transform.DOScale(targetScale, animationDuration)
            .SetEase(Ease.OutBack)) 
        .Play();
}

Ease 그래프는 사이트를 참고하여 적절한 그래프를 선택해 사용하자.

추가적으로 knife와 Target에 Destroy Animation도 넣어주었다.

Knife.cs

public void KnifeDestroy() {
    hasInteracted = true;
    rb.bodyType = RigidbodyType2D.Dynamic;

    float randBounceForce = Random.Range(bounceForce, bounceForce * 1.5f);
    rb.AddForce(Vector3.up * randBounceForce, ForceMode2D.Impulse);

    float randomTorque = Random.Range(-200f, 200f);
    rb.angularVelocity = randomTorque;

    fadeAnimationTween = DOTween.Sequence()
        .AppendInterval(delayBeforeFade)
        .Append(sr.DOFade(0, destroyDuration)
                    .OnComplete(() => {
                        fadeAnimationTween?.Kill();
                        Destroy(gameObject);
                    }));
}

Target.cs

private void ApplyForceToSegments() {
    Vector3 parentPosition = transform.position;

    foreach(GameObject segment in Segments) {
        Rigidbody2D rb = segment.GetComponent<Rigidbody2D>();
        
        float RandomUpwardForceMultiplier = Random.Range(upwardForceMultiplier - 2 , upwardForceMultiplier + 2);
        Vector3 direction = ((segment.transform.position - parentPosition).normalized + Vector3.up * RandomUpwardForceMultiplier).normalized;

        float RandomforceMagnitude = Random.Range(forceMagnitude - 2 , forceMagnitude + 2);
        Vector3 force = direction * RandomforceMagnitude;

        rb.gravityScale = segmentGravityScale;
        rb.bodyType = RigidbodyType2D.Dynamic;

        rb.AddForce(force, ForceMode2D.Impulse);
        
        float randomTorque = Random.Range(-torqueMagnitude, torqueMagnitude);
        rb.angularVelocity = randomTorque;

        SpriteRenderer sr = segment.GetComponent<SpriteRenderer>();
        Tween fadeAnimationTween = null;

        fadeAnimationTween = DOTween.Sequence()
            .AppendInterval(delayBeforeFade)
            .Append(sr.DOFade(0, destroyDuration)
                        .OnComplete(() => {
                            Segments.Remove(segment);
                            Destroy(segment);
                        }))
            .OnComplete(() =>  {
                fadeAnimationTween?.Kill();
                if (Segments.Count == 0) {
                    DOTween.KillAll();
                    Destroy(gameObject);
                }
            });
    }
}

DOTWeen.KillAll()로 오브젝트에 실행중인 모든 Tween을 종료 시킬 수 있다. (종료 안시키고 오브젝트를 Destory 하면, 경고가 발생할 수 있다.)


점수 시스템 구현 전에 Segment와 Target의 코드를 분할하는게 유지보수에서 더 유리할 것 같아서 아래와 같이 분할해주었다.

Target.cs

[Button("DestroyTarget")]
public void DestroyTarget() {
    StopCoroutine(rotateCoroutine);

    rotateTween?.Kill();

    
    foreach(GameObject segment in Segments) {
        segment.GetComponent<Segment>().ApplyForceToSegments();
    }

    DOTween.Sequence()
        .AppendInterval(1f)
        .OnComplete(() => {
            DOTween.KillAll();
            Events.OnFinishStage.Invoke();
            Destroy(gameObject);
        });
}

Segment.cs

using System.Collections;
using System.Collections.Generic;
using DG.Tweening;
using Sirenix.OdinInspector;
using UnityEngine;

public class Segment : MonoBehaviour
{
    [TabGroup("Destruction Effect","Segements")] public float forceMagnitude = 10f; 
    [TabGroup("Destruction Effect","Segements")] public float upwardForceMultiplier = 2f;
    [TabGroup("Destruction Effect","Segements")] public float destroyDuration = 0.5f; 
    [TabGroup("Destruction Effect","Segements")] public float delayBeforeFade = 0.5f; 
    [TabGroup("Destruction Effect","Segements")] public float torqueMagnitude = 300f;
    [TabGroup("Destruction Effect","Segements")] public float segmentGravityScale = 2f;
    
    Tween fadeAnimationTween;

    public void Start() {
        Debug.Log(transform.position);
    }
    
    public void ApplyForceToSegments() {

        Vector3 parentPosition = transform.position;
        Rigidbody2D rb = transform.GetComponent<Rigidbody2D>();
        
        float RandomUpwardForceMultiplier = Random.Range(upwardForceMultiplier - 2 , upwardForceMultiplier + 2);
        Vector3 direction = ((transform.position - parentPosition).normalized + Vector3.up * RandomUpwardForceMultiplier).normalized;

        float RandomforceMagnitude = Random.Range(forceMagnitude - 2 , forceMagnitude + 2);
        Vector3 force = direction * RandomforceMagnitude;

        rb.gravityScale = segmentGravityScale;
        rb.bodyType = RigidbodyType2D.Dynamic;

        rb.AddForce(force, ForceMode2D.Impulse);
        
        float randomTorque = Random.Range(-torqueMagnitude, torqueMagnitude);
        rb.angularVelocity = randomTorque;

        SpriteRenderer sr = transform.GetComponent<SpriteRenderer>();

        fadeAnimationTween = DOTween.Sequence()
            .AppendInterval(delayBeforeFade)
            .Append(sr.DOFade(0, destroyDuration)
                        .OnComplete(() => {
                            fadeAnimationTween?.Kill();
                            Destroy(gameObject);
                        }));
    }
}

문제가 발생했는데 자꾸 Segment의 LocalPosition이 초기화되는 문제가 발생했다.

-> 원인을 찾았는데 StartTargetAnimation에서 localPosition이 초기화되는 문제가 발생했다. 왜 그런지도 원인을 찾았는데 local Scale이 0이 되면서 local Position도 0이 되버리는 문제였다.

왜 이런지는 구글링을 해봤는데도 잘 안나와서 나중에 시간 날 때 한 번 찾아보는게 좋을 것 같다. 내 예상에는 local Scale이 부모 오브젝트의 스케일을 기준으로 얼마나 확대 또는 축소될지를 나타내는건데 Vector3.zero로 한 순간 오브젝트가 사라지는 것과 동일하게 보이지 않게된다. 즉, 크기가 0인 오브젝트의 좌표 공간에서의 위치가 유효하지 않기 때문에 local Position이 Zero로 초기화된게 아닌가 하고 예상하고 있다.


Score UI

이제 점수 시스템을 구현해보자.

private float fadeDuration = 0.2f;

private void OnEnable() {
    if (Instance == null) {
        Instance = this;
        DontDestroyOnLoad(this);
    }
    else {
        Destroy(gameObject);
    }

    Events.OnStartStage += OnStartStage;
    Events.OnFinishStage += OnFinishStage;
}

private void OnDisable() {
    Events.OnStartStage -= OnStartStage;
    Events.OnFinishStage -= OnFinishStage;
}

public void OnStartStage(int _stageNum) {
    stageText.DOFade(1, fadeDuration);
    stageText.text = "STAGE " + _stageNum;
}

public void OnFinishStage() {
    stageText.DOFade(0, fadeDuration);
}

사용하면 할수록 느끼는 거지만 델리게이트 패턴만 완전히 익숙해져도 개발이 엄청 편해질 것 같다. 이렇게 StartStage와 FinishStage에 연결만 잘 시켜주면 자동으로 실행이 잘 된다.

근데 이게 FinishStage 이후에 바로 Start Stage가 되다보니깐 Fade 애니메이션이 잘 실행되지 않아서 OnFinishStage가 아니라 모든 칼이 꽂히자마자 시작하도록 OnAllKnivesOnHit 이벤트에 연결해줬다.

private void OnEnable() {
    if (Instance == null) {
        Instance = this;
        DontDestroyOnLoad(this);
    }
    else {
        Destroy(gameObject);
    }

    Events.OnStartStage += OnStartStage;
    Events.OnAllKnivesOnHit += OnAllKnivesOnHit;
}

private void OnDisable() {
    Events.OnStartStage -= OnStartStage;
    Events.OnAllKnivesOnHit -= OnAllKnivesOnHit;
}

public void OnStartStage(int _stageNum) {
    stageText.text = "STAGE " + _stageNum;
    ShowFadeAnimation();
}

public void OnAllKnivesOnHit() {
    HideFadeAnimation();
}

private void ShowFadeAnimation() {
    stageText.DOFade(1, fadeDuration);
}
private void HideFadeAnimation() {
    stageText.DOFade(0, fadeDuration);
}


TitleScene

대충 이미지와 버튼, 텍스트를 아래와 같이 잘 배치해준다. (하단의 여유 공간에는 아마 추가 버튼이 들어갈 수도 있음)

여기에 애니메이션과 버튼, 텍스트 처리 스크립트를 넣어주자.

Title Animation

using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;

public class TitleAnimation : MonoBehaviour
{
    public Image titleImage1;
    public Image titleImage2;

    public float moveDistance = 60f;
    public float animationDuration = 1.5f;  

    public void Start() {
        StartTitleAnimation();
    }

    public void StartTitleAnimation() {
        titleImage1.rectTransform.DOAnchorPosX(titleImage1.rectTransform.anchoredPosition.x + moveDistance, animationDuration)
            .SetEase(Ease.InOutSine)
            .SetLoops(-1,LoopType.Yoyo);

        titleImage2.rectTransform.DOAnchorPosX(titleImage2.rectTransform.anchoredPosition.x - moveDistance, animationDuration)
            .SetEase(Ease.InOutSine)
            .SetLoops(-1,LoopType.Yoyo);
    }

    public void StopTitleAnimation() {
        DOTween.KillAll();
    }
}

DOTween.LoopType

  • Incremental : 종료된 시점을 시작 지점으로 모션이 다시 시작 (연속)
  • Restart :처음으로 되돌아가서 다시 시작
  • Yoyo :실행 된 모션이 되감기 되며 다시 시작 (※ 반복횟수는 돌아가는 모션까지 포함해야함)

StageScoreText.cs

using System.Collections;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
using TMPro;

public class StageScoreText : MonoBehaviour
{
    private int bestStage;
    private int bestScore;

    public TMP_Text stageText;
    public TMP_Text scoreText;
    

    private void Start() {
        InitailizeTexts();
    }

    private void InitailizeTexts() {
        bestStage = PlayerPrefs.GetInt("BestStage", 9);
        bestScore = PlayerPrefs.GetInt("BestScore", 9);

        stageText.text = "STAGE " + bestStage;
        scoreText.text = "SCORE " + bestScore;
    }
}

PlayButton.cs

using UnityEngine;
using UnityEngine.SceneManagement;

public class PlayButton : MonoBehaviour
{
    public TitleAnimation titleAnimation;
    public void Play() {
        titleAnimation.StopTitleAnimation();
        SceneManager.LoadScene("MainScene");
    }
}

이제 인스펙터로 잘 연결해준다.

최고 점수, 최고 스테이지는 불러오는건 되는데 아직 저장하는 SetInt 부분을 구현하지 않아서 일단 기본값으로 9를 불러오도록 해놓았다.

profile
Be Honest, Be Harder, Be Stronger

0개의 댓글