유니티에서 게임 오브젝트를 활성화하거나 비활성화 할 때, enable과 SetActive를 주로 사용한다.

 

enable

enable로 활성/비활성화 하면 특정 컴포넌트만 활성/비활성화 된다.

gameObject.enabled = false;

 

SetActive

SetActive로 활성/비활성화 하면 해당 게임 오브젝트의 하위 오브젝트도 활성/비활성화 된다.

gameObject.SetActive(false);

 

 

아래는 리듬게임 노트의 이미지를 비활성화하는 메서드로, 스페이스바를 누르면 HideImage 메서드를 호출하여 이미지를 비활성화 하고 일정 범위를 벗어나면 노트 오브젝트를 파괴하고 싶었다.

private Image noteImage;

public void HideImage()
{
    noteImage.gameObject.SetActive(false);
}

 

그런데 SetActive를 사용했더니 이미지 뿐만 아니라 오브젝트 자체를 비활성화하여 일정 범위가 벗어나도 복제되고 있는 노트들이 파괴되지 않고 계속해서 쌓이는 문제가 있었다.

 

enable을 사용하여 게임 오브젝트 전체가 아닌 이미지만 비활성화하여 해결했다.

public void HideImage()
{
    noteImage.enabled = false;
}

 

 

오른쪽에서 왼쪽으로 오는 노트를 스페이스로 리듬에 맞춰 누르면 플레이어가 헤엄치는 간단한 리듬게임을 기획했다.

 

Note 구현

빈 캔버스에 노트 컨테이너 UI를 만든다.

 

일단 노트가 오른쪽에서 왼쪽으로 지나가도록 하기 위해 캔버스에 스크립트를 추가한다.

public class Note : MonoBehaviour
{
    private float noteSpeed = 500f;

    private void Update()
    {
        transform.localPosition += Vector3.left * noteSpeed * Time.deltaTime;
    }
}

좌표를 local로 한 이유는 캔버스 내의 좌표에서 생성되어야하기 때문이다. position으로 하면 월드좌표에 생성되기 때문에 엉뚱한 곳에 생성될 것이다.

 

노트의 충돌을 감지하기 위해 Rigidbody와 Collider를 붙여준다.

충돌로 인한 물리 효과는 필요 없기 때문에 Is Trigger를 체크하고 Dynamic이 아닌 Kinematic을 선택한다. 노트는 계속해서 생성할 것이기 때문에 Prefab으로 뺀다.

 

유니티에서 노트를 생성할 Transform을 지정하는 오브젝트를 만들어준 뒤, 비트 당 시간마다 노트를 생성시킨다.

현재 시간을 0으로 초기화하지 않는 이유는 float나 doubl은 부동 소수점 표현 방식을 사용하기 때문에 오차가 발생하여 60 / bpm을 빼줌으로써 오차를 줄이는 것이다.

public class NoteManager : MonoBehaviour
{
    public float bpm;
    private double currentTime; // 오차를 줄이기 위해서 double로 선언

    [SerializeField] Transform noteCreate;
    [SerializeField] GameObject note;

    private void Update()
    {
        currentTime += Time.deltaTime;

        if (currentTime >= 60 / bpm) // 1beat 당 시간
        {
            GameObject touchNote = Instantiate(note, noteCreate.position, Quaternion.identity); // 노트 생성
            touchNote.transform.SetParent(this.transform); // 부모인 UI 캔버스 내에서 생성
            currentTime -= 60 / bpm;
        }
    }

    private void OnTriggerEnter(Collider other) // 오브젝트 간 충돌 감지
    {
        if (other.tag == "Note")
        {
            Destroy(other.gameObject); // 카메라 밖의 Collider와 만나면 파괴
        }
    }
}

노트가 계속해서 생성되고 파괴되기 때문에 나중에 오브젝트 풀 방식으로 바꿔줄 예정이다.

 

노트가 나오면 음악이 나오도록 임시로 오디오 소스를 넣는다. 자동으로 재생하지 않도록 Play On Awake를 꺼준다.

 

오디오 소스가 붙은 오브젝트에 스크립트를 추가한다. 첫 오브젝트가 바의 Collider와 충돌하면 음악이 재생된다.

public class Audio : MonoBehaviour
{
    private AudioSource audio;

    private bool isMusicStart;

    private void Start()
    {
        audio = GetComponent<AudioSource>();
    }

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (!isMusicStart)
        {
            if (other.tag == "Note")
            {
                audio.Play();
                isMusicStart = true;
            }
        }
    }
}

 

그런데 노트가 Collider와 충돌하면 파괴되도록 했는데 왜 바와 충돌해도 파괴되지 않을까?

 

OnTriggerEnter 같은 이벤트 함수들은 자신->자식 순서로 이벤트가 일어나는지 확인 후, 상호작용하기 때문에 자식에 Collider가 붙어있어도 이미 자기 자신에 Collider가 붙어있기 때문에 파괴되지 않는 것이라고 예상하고 있다. 자세한건 테스트 해봐야겠다.

 

 

유니티에서 작업한 프로젝트를 Windows와 Android로 실행할 수 있는 파일로 변환하는 빌드 작업을 해볼 것이다.

 

빌드 과정(Windows편)

  1. File - Build Settings
  2. Platform - Windows 선택 후, Switch Platform 클릭
  3. Scenes in Build에 원하는 씬 추가
  4. Player Settings에서 게임 설정 변경(회사 이름, 게임 아이콘, 해상도 등)
  5. Build 파일을 따로 만들어서 Build 하기

Player Setting

 

 

빌드 과정(Android편)

  1. File - Build Settings
  2. Platform - Android 선택 후, Switch Platform 클릭
  3. Scenes in Build에 원하는 씬 추가
  4. Player Settings에서 게임 설정 변경(회사 이름, 게임 아이콘 등)
  5. Minimun API Level과 Target API Level 설정
  6. Build을 따로 만들어서 Build 하기

 

 

윈도우와 안드로이드 등 빌드할 때 플러그인을 추가했다면 호완성 이슈가 발생할 수 있다. 안드로이드의 경우 JDK나 Android SDK가 기본적으로 설치 되어있는지 확인해야한다. 스마트폰은 해상도가 다양하기 때문에 UI를 구성할 때 Pivot이나 Anchor 등을 잘 설정해야한다.

 

 

Windows Build 화면 크기(해상도) 오류

윈도우를 빌드 할 때 창모드로 진행하는 경우가 많은데, 창모드로 진행하고 저장했을 때 해상도와 위치 또한 저장되기 때문에 다음에 빌드할 때 화상도를 저장해도 적용되지 않는 경우가 있다.

레지스트리편집기에서 HKCU\Software\[회사명]\[제품명] 검색 후 설정된 해상도 값을 수정하거나 삭제하면 된다.

 

 

적 구현

적은 플레이어 오브젝트를 복사 + 붙여넣기 하여 기본 틀로 사용한다.

적 스크립트 또한 플레이어 스크립트와 비슷하다.

[field: Header("References")]
    [field: SerializeField] public EnemySO Data { get; private set; }

    [field: Header("Animations")]
    [field: SerializeField] public PlayerAnimationData AnimationData { get; private set; }

    public Rigidbody Rigidbody { get; private set; }
    public Animator Animator { get; private set; }
    public ForceReceiver ForceReceiver { get; private set; }
    public CharacterController Controller { get; private set; }

    private EnemyStateMachine stateMachine;

    void Awake()
    {
        AnimationData.Initialize();

        Rigidbody = GetComponent<Rigidbody>();
        Animator = GetComponentInChildren<Animator>();
        Controller = GetComponent<CharacterController>();
        ForceReceiver = GetComponent<ForceReceiver>();

        stateMachine = new EnemyStateMachine(this);
    }

    private void Start()
    {
        stateMachine.ChangeState(stateMachine.IdlingState);
    }

    private void Update()
    {
        stateMachine.HandleInput();

        stateMachine.Update();
    }

    private void FixedUpdate()
    {
        stateMachine.PhysicsUpdate();
    }

 

적의 Scriptable Object와 상태 머신 또한 이름만 바뀌고 비슷하다.

[CreateAssetMenu(fileName = "EnemySO", menuName = "Characters/Enemy")]
public class EnemySO : ScriptableObject
{
    [field: SerializeField] public float PlayerChasingRange { get; private set; } = 10f;
    [field: SerializeField] public float AttackRange { get; private set; } = 1.5f;
    [field: SerializeField][field: Range(0f, 3f)] public float ForceTransitionTime { get; private set; }
    [field: SerializeField][field: Range(-10f, 10f)] public float Force { get; private set; }
    [field: SerializeField] public int Damage { get; private set; }
    [field: SerializeField][field: Range(0f, 1f)] public float Dealing_Start_TransitionTime { get; private set; }
    [field: SerializeField][field: Range(0f, 1f)] public float Dealing_End_TransitionTime { get; private set; }

    [field: SerializeField] public PlayerGroundData GroundedData { get; private set; }

}
public class EnemyStateMachine : StateMachine
{
    public Enemy Enemy { get; }

    // States

    public Transform Target { get; private set; }

    public EnemyIdleState IdlingState { get; }
    public EnemyChasingState ChasingState { get; }
    public EnemyAttackState AttackState { get; }

    // 이동
    public Vector2 MovementInput { get; set; }
    public float MovementSpeed { get; private set; }
    public float RotationDamping { get; private set; }
    public float MovementSpeedModifier { get; set; } = 1f;

    public EnemyStateMachine(Enemy enemy)
    {
        Enemy = enemy;
        Target = GameObject.FindGameObjectWithTag("Player").transform;

        IdlingState = new EnemyIdleState(this);
        ChasingState = new EnemyChasingState(this);
        AttackState = new EnemyAttackState(this);

        MovementSpeed = enemy.Data.GroundedData.BaseSpeed;
        RotationDamping = enemy.Data.GroundedData.BaseRotationDamping;
    }
}

 

기본 상태는 아래 플레이어를 쫓아가는 코드 외에는 전부 똑같다.

sqrMagnitude의 sqr은 제곱근으로, 제곱근을 푸는 연산이 무겁기 때문에 벡터의 크기에 한 번 더 곱해버리는 계산이다.

private Vector3 GetMovementDirection()
{
    return (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).normalized;
}

protected bool IsInChaseRange()
{
    // if (stateMachine.Target.IsDead) { return false; } // 플레이어가 죽었을 때 쫓아가지 않는다

    float playerDistanceSqr = (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).sqrMagnitude;

    return playerDistanceSqr <= stateMachine.Enemy.Data.PlayerChasingRange * stateMachine.Enemy.Data.PlayerChasingRange;
}
public class EnemyIdleState : EnemyBaseState
{
    public EnemyIdleState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
    {
    }

    // 플레이어의 Idle 공유
    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 0f;

        base.Enter();
        StartAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StartAnimation(stateMachine.Enemy.AnimationData.IdleParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StopAnimation(stateMachine.Enemy.AnimationData.IdleParameterHash);
    }

    public override void Update()
    {
        if (IsInChaseRange())
        {
            stateMachine.ChangeState(stateMachine.ChasingState); // 적과 플레이어가 일정 거리가 되면 쫓아간다
            return;
        }
    }
}

 

적이 플레이어를 쫓는 상태

public class EnemyChasingState : EnemyBaseState
{
    public EnemyChasingState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
    {
    }
    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 1;
        base.Enter();
        StartAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StartAnimation(stateMachine.Enemy.AnimationData.RunParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Enemy.AnimationData.GroundParameterHash);
        StopAnimation(stateMachine.Enemy.AnimationData.RunParameterHash);
    }

    public override void Update()
    {
        base.Update();

        if (!IsInChaseRange()) // 플레이어가 사정거리에 들어오지 않았다면
        {
            stateMachine.ChangeState(stateMachine.IdlingState);
            return;
        }
        else if (IsInAttackRange())
        {
            stateMachine.ChangeState(stateMachine.AttackState);
            return;
        }
    }

    private bool IsInAttackRange()
    {
        // if (stateMachine.Target.IsDead) { return false; }

        float playerDistanceSqr = (stateMachine.Target.transform.position - stateMachine.Enemy.transform.position).sqrMagnitude;

        return playerDistanceSqr <= stateMachine.Enemy.Data.AttackRange * stateMachine.Enemy.Data.AttackRange;
    }
}

 

적이 플레이어를 공격하는 상태

public class EnemyAttackState : EnemyBaseState
{

    private bool alreadyAppliedForce;

    public EnemyAttackState(EnemyStateMachine ememyStateMachine) : base(ememyStateMachine)
    {
    }

    public override void Enter()
    {
        stateMachine.MovementSpeedModifier = 0;
        base.Enter();
        StartAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
        StartAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);
    }

    public override void Exit()
    {
        base.Exit();
        StopAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
        StopAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);

    }

    public override void Update()
    {
        base.Update();

        ForceMove();

        float normalizedTime = GetNormalizedTime(stateMachine.Enemy.Animator, "Attack");
        if (normalizedTime < 1f)
        {
            if (normalizedTime >= stateMachine.Enemy.Data.ForceTransitionTime)
                TryApplyForce();

        }
        else
        {
            if (IsInChaseRange()) // 플레이어가 쫓아가는 거리에 있다면
            {
                stateMachine.ChangeState(stateMachine.ChasingState);
                return;
            }
            else
            {
                stateMachine.ChangeState(stateMachine.IdlingState);
                return;
            }
        }

    }

    private void TryApplyForce()
    {
        if (alreadyAppliedForce) return;
        alreadyAppliedForce = true;

        stateMachine.Enemy.ForceReceiver.Reset();

        stateMachine.Enemy.ForceReceiver.AddForce(stateMachine.Enemy.transform.forward * stateMachine.Enemy.Data.Force);

    }
}

 

플레이어의 애니메이션 스크립트를 기반으로 동작하기 때문에 플레이어 애니메이션 스크립트에 적의 기본 공격을 추가한다.

[SerializeField] private string baseAttackParameterName = "BaseAttack";

public int BaseAttackParameterHash { get; private set; }

public void Initialize()
{
    BaseAttackParameterHash = Animator.StringToHash(baseAttackParameterName);
}

 

유니티로 돌아와서 적의 Scriptable Object를 생성하고 적 스크립트에 연결한다.

 

기본 공격에 대한 애니메이션을 연결하면 적이 플레이어를 쫓아와서 때린다.

 

 

피격 및 처치

피격을 위해 HP와 무기 스크립트를 만든다.

public class Health : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;
    private int health;
    public event Action OnDie;

    public bool IsDead => health == 0;

    private void Start()
    {
        health = maxHealth;
    }

    public void TakeDamage(int damage)
    {
        if (health == 0) return;
        health = Mathf.Max(health - damage, 0);

        if (health == 0)
            OnDie?.Invoke();

        Debug.Log(health);
    }
}
public class Weapon : MonoBehaviour
{
    [SerializeField] private Collider myCollider;

    private int damage;
    private float knockback;

    private List<Collider> alreadyColliderWith = new List<Collider>();

    private void OnEnable()
    {
        alreadyColliderWith.Clear();
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other == myCollider) return;
        if (alreadyColliderWith.Contains(other)) return;

        alreadyColliderWith.Add(other);

        if(other.TryGetComponent(out CharacterHealth health))
        {
            health.TakeDamage(damage);
        }

        if(other.TryGetComponent(out ForceReceiver forceReceiver))
        {
            Vector3 direction = (other.transform.position - myCollider.transform.position).normalized;
            forceReceiver.AddForce(direction * knockback);
        }
    }

    public void SetAttack(int damage, float knockback)
    {
        this.damage = damage;
        this.knockback = knockback;
    }
}

 

공격 애니메이션이 처리 될 때, 데미지를 줄 것인지 처리하기 위해 적 공격 상태 스크립트에 코드를 추가한다.

private bool alreadyAppliedDealing;

public override void Enter()
{
    alreadyAppliedForce = false;
    alreadyAppliedDealing = false;

    stateMachine.MovementSpeedModifier = 0;
    base.Enter();
    StartAnimation(stateMachine.Enemy.AnimationData.AttackParameterHash);
    StartAnimation(stateMachine.Enemy.AnimationData.BaseAttackParameterHash);
}

public override void Update()
{
    base.Update();

    ForceMove();

    float normalizedTime = GetNormalizedTime(stateMachine.Enemy.Animator, "Attack");
    if (normalizedTime < 1f)
    {
        if (normalizedTime >= stateMachine.Enemy.Data.ForceTransitionTime)
            TryApplyForce();

        if (!alreadyAppliedDealing && normalizedTime >= stateMachine.Enemy.Data.Dealing_Start_TransitionTime) // 딜 안했을 때
        {
            stateMachine.Enemy.Weapon.SetAttack(stateMachine.Enemy.Data.Damage, stateMachine.Enemy.Data.Force);
            stateMachine.Enemy.Weapon.gameObject.SetActive(true);
            alreadyAppliedDealing = true;
        }

        if (alreadyAppliedDealing && normalizedTime >= stateMachine.Enemy.Data.Dealing_End_TransitionTime) // 딜 했을 때
        {
            stateMachine.Enemy.Weapon.gameObject.SetActive(false);
        }

    }
    else
    {
        if (IsInChaseRange()) // 플레이어가 쫓아가는 거리에 있다면
        {
            stateMachine.ChangeState(stateMachine.ChasingState);
            return;
        }
        else
        {
            stateMachine.ChangeState(stateMachine.IdlingState);
            return;
        }
    }
}

 

적 스크립트에 무기와 플레이어가 죽었는지에 대한 코드를 추가한다.

[field: SerializeField] public Weapon Weapon { get; private set; }
public Health Health { get; private set; }

void Awake()
{
    AnimationData.Initialize();

    Rigidbody = GetComponent<Rigidbody>();
    Animator = GetComponentInChildren<Animator>();
    Controller = GetComponent<CharacterController>();
    ForceReceiver = GetComponent<ForceReceiver>();

    Health = GetComponent<Health>();

    stateMachine = new EnemyStateMachine(this);
}

private void Start()
{
    stateMachine.ChangeState(stateMachine.IdlingState);
    Health.OnDie += OnDie;
}

void OnDie()
{
    Animator.SetTrigger("Die");
    enabled = false;
}

 

유니티로 돌아와서 적 오른쪽 손에 무기 Collider와 Rigidbody를 걸어주고 무기 스크립트를 붙여준다.

 

적 오브젝트에 붙은 스크립트에 무기를 넣어준다.

 

플레이어에도 동일하게 스크립트와 무기에 Collider, Rigidbody를 추가한다.

주석 처리했던적 기본 상태 스크립트에서 주석 처리했던 죽음 처리를 풀고 적 상태 머신에서 임시로 처리한 Transform을 Health로 바꾼다.

public class EnemyStateMachine : StateMachine
{
    public Enemy Enemy { get; }

    // States

    public Health Target { get; private set; }
    
    public EnemyStateMachine(Enemy enemy)
	{
    Enemy = enemy;
    Target = GameObject.FindGameObjectWithTag("Player").GetComponent<Health>();

    IdlingState = new EnemyIdleState(this);
    ChasingState = new EnemyChasingState(this);
    AttackState = new EnemyAttackState(this);

    MovementSpeed = enemy.Data.GroundedData.BaseSpeed;
    RotationDamping = enemy.Data.GroundedData.BaseRotationDamping;
	}
}

 

 

+ Recent posts