어제 애니메이션을 구현해보려 했지만, 게임이 시작되자마자 자동으로 애니메이션이 실행되는 문제를 겪었다. 먼저 이를 해결하고, 사운드, UI 등을 구현하며 투사체 발사 시스템을 마무리해보고자 한다.
Entry 상태에서 DeathAnim으로 연결된 트랜지션을 제거해야 했다.Idle State를 만들고, Entry 트랜지션을 Idle에 연결시켰다.
Enemy에게도 적용하였다.Enemy가 파괴되었더니 Ally의 애니메이션이 실행되는 오류가 발생했다.Ally의 Sprite 구조와 Enemy의 구조가 달라서, 하나의 애니메이션 클립이 두 오브젝트 모두에 동일하게 적용되지 않았다.
-애니메이션이 의도한대로 실행되었다.
Animator 내부에서 사운드를 재생할 수 있지 않을까 고민했지만, 유지보수나 디버깅이 복잡해질 수 있어서 스크립트로 제어하는 방식을 선택했다.AudioSource를 붙였다.Play On Awake 옵션은 꺼주었고, Loop도 비활성화.UnitHealth.cs의 OnDeath() 함수를 다음과 같이 수정해주었다. protected virtual void OnDeath()
{
animator.SetTrigger("Death");
Destroy(gameObject, 1.0f);
AudioSource audio = GetComponent<AudioSource>();
if (audio != null)
{
audio.Play();
}
AudioSource.Play()를 통해 사망 시점에서 사운드를 재생하게 하였다.이번에는 투사체의 발사 시점과 충돌 시점에 각각 다른 사운드를 재생하도록 구현해보았다. 그 과정을 아래와 같이 정리한다.
BulletManager.cs의 Start() 함수에서 AudioSource.PlayOneShot(shootSound)를 호출하여 발사음을 재생하도록 설정하였다.shootSound를 등록하여, 발사 시 동일한 사운드가 출력되도록 하였다.AudioSource를 추가하고, Play On Awake와 Loop는 꺼두었다.OnTriggerEnter2D() 내부에서 impactSound를 재생하도록 구성했지만, 실행 결과 충돌 시 소리가 출력되지 않았다.impactSound를 설정했음에도 반응이 없었고, 콘솔에서 오류 메시지를 확인함.Can not play a disabled audio source
UnityEngine.AudioSource:PlayOneShot
AudioSource가 비활성화되었거나, 이미 파괴된 오브젝트의 컴포넌트에서 사운드를 재생하려 할 때 발생한다.OnTriggerEnter2D() 실행 도중 OnHit()가 호출되고, 내부에서 Destroy(gameObject)가 호출되기 때문에 소리가 재생되기 전에 오브젝트가 사라졌던 것이다.Destroy(gameObject)를 즉시 호출하는 대신 Destroy(gameObject, 0.1f)로 약간의 지연을 주기로 결정하였다.
이를 통해 impactSound가 PlayOneShot()을 통해 정상적으로 출력될 수 있는 시간을 확보하였다.
구조를 복잡하게 만들지 않으면서도 안정적인 사운드 출력이 가능하다는 점에서 가장 간단하고 효과적인 방식으로 판단하였다.
이후 ExplosionBullet.cs에서 audioSource.PlayOneShot() 호출 시, audioSource가 정의되지 않았다는 컴파일 오류가 발생하였다.
이는 BulletManager 내부에 정의된 audioSource가 private 접근 제한자로 설정되어 있었기 때문이었다.
해결을 위해 해당 변수를 public으로 변경하였고, 자식 클래스에서도 문제없이 접근할 수 있게 되었다
수정 후 BulletManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public abstract class BulletManager : MonoBehaviour
{
public float speed = 5f;
public float shootForce = 10f;
public int damage = 1;
public string targetTag;
public GameObject hitEffectPrefab;
public float lifetime = 3f;
public AudioClip shootSound;
public AudioClip impactSound;
public AudioSource audioSource;
protected virtual void Awake()
{
audioSource = GetComponent<AudioSource>();
}
protected virtual void Start()
{
if (shootSound != null && audioSource != null)
{
audioSource.PlayOneShot(shootSound);
}
Destroy(gameObject, lifetime);
}
protected virtual void Update()
{
transform.Translate(Vector2.right * speed * Time.deltaTime);
}
protected virtual void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag(targetTag) || collision.CompareTag("Ground"))
{
if (impactSound != null && audioSource != null)
{
audioSource.PlayOneShot(impactSound);
}
}
if (!collision.CompareTag(targetTag)) return;
if (hitEffectPrefab != null)
{
GameObject fx = Instantiate(hitEffectPrefab, transform.position, Quaternion.identity);
Destroy(fx, 0.5f);
}
OnHit(collision);
}
protected abstract void OnHit(Collider2D col);
}
ExplosionBullet.csusing System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ExplosionBullet : BulletManager
{
public float explosionRadius = 2f;
protected override void OnTriggerEnter2D(Collider2D collision)
{
if (collision.CompareTag(targetTag) || collision.CompareTag("Ground"))
{
if (hitEffectPrefab != null)
{
GameObject fx = Instantiate(hitEffectPrefab, transform.position, Quaternion.identity);
Destroy(fx, 0.5f);
}
OnHit(collision);
Destroy(gameObject, 0.1f);
}
}
protected override void OnHit(Collider2D col)
{
Collider2D[] hits = Physics2D.OverlapCircleAll(transform.position, explosionRadius);
foreach (Collider2D hit in hits)
{
if (hit.CompareTag(targetTag))
{
UnitHealth unit = hit.GetComponent<UnitHealth>();
if (unit != null)
{
unit.TakeDamage(damage);
}
}
}
if (impactSound != null && audioSource != null)
{
audioSource.PlayOneShot(impactSound);
}
Destroy(gameObject, 0.1f);
}
}
숫자 1, 2, 3으로 변경한 현재 장착 중인 투사체 종류(예: 일반 / 회복 / 폭발)를 화면에 Text 또는 Image UI로 표시
구현 방법
Canvas 아래 Text 생성Shooter.cs 수정Shooter.cs에 using UnityEngine.UI 추가public Text currentBulletText 선언EquipBullet() 함수 내부에서 텍스트를 변경하도록 구현public Text currentBulletText;
void EquipBullet(GameObject prefab, string targetTag, string label)
{
currentBulletPrefab = prefab;
currentTargetTag = targetTag;
if (currentBulletText != null)
{
currentBulletText.text = "장착 중: " + label;
}
}
if (Input.GetKeyDown(KeyCode.Alpha1))
{
EquipBullet(BulletPrefab, "Enemy", "일반");
}
else if (Input.GetKeyDown(KeyCode.Alpha2))
{
EquipBullet(healingBulletPrefab, "Ally", "회복");
}
else if (Input.GetKeyDown(KeyCode.Alpha3))
{
EquipBullet(explosionBulletPrefab, "Enemy", "폭발");
}
연결 시 주의할 점
CurrentBulletText 오브젝트는 Shooter 스크립트가 붙은 오브젝트에 드래그해서 연결해야 한다.Shooter가 붙은 오브젝트가 아니라서 Inspector에 할당이 안 됐던 것.Shooter.cs가 붙은 Player 오브젝트에 Inspector 할당.
새로 제작한 UI가 잘 구현되었음을 확인했다.
gif파일에서 볼 수 있듯이 유닛이 사망한 이후에도 체력바(Slider)가 남아있는 문제가 발생하였다.
현재 체력바는 Slider라는 이름의 프리팹으로 구성되어 있으며, HealthUI.cs에서 관리되고 있다.
이를 해결하기 위해 유닛이 파괴되기 직전에 Slider 오브젝트도 함께 제거되도록 UnitHealth.cs에서 처리하였다.
아래처럼 Destroy(hpSlider.gameObject)를 통해 체력바도 함께 제거되도록 하였다.
protected virtual void OnDeath()
{
animator.SetTrigger("Death");
if (hpSlider != null)
{
Destroy(hpSlider.gameObject);
}
.
.
.

이 프로젝트는 단순히 총알을 발사하는 기능에서 출발했다. 처음에는 마우스 클릭 시 하나의 투사체가 나가는 구조로 시작했지만, 이를 확장하여 다양한 효과를 가진 투사체들을 구현하고 싶었다.
그래서 각각의 투사체를 별도로 만들기보다는 공통된 구조를 갖는 시스템을 설계하기로 했고, BulletManager라는 추상 클래스를 도입했다. 이후 Shooter.cs에서는 키보드 입력을 통해 실시간으로 투사체를 변경할 수 있도록 하였고, 각각의 투사체는 독립된 스크립트로 기능을 오버라이드하는 방식으로 구성하였다.
모든 유닛들은 UnitHealth라는 공통 스크립트를 상속받도록 구성하여, 체력 관리, 데미지 처리, 사망 처리 로직을 하나로 통합했다. 덕분에 유닛의 타입이 달라도 데미지를 주고받는 구조가 일관되게 동작하도록 만들 수 있었다.
또한 데미지를 입거나 회복할 때 화면에 떠오르는 데미지 텍스트(DamageText)를 구현하여 시각적으로 명확한 피드백을 줄 수 있었다. 이 텍스트는 월드 좌표를 UI 좌표로 변환해 Canvas에 생성되도록 처리하여, 유닛 머리 위에 자연스럽게 나타나도록 구성했다.
UI 측면에서는 현재 장착 중인 투사체를 보여주는 텍스트를 추가하였고, 유닛이 사망하면 체력바 UI(Slider 프리팹)도 함께 제거되도록 처리함으로써 깔끔한 마무리를 할 수 있었다. 게임 진행 도중 투사체 상태나 유닛 UI가 꼬이지 않도록 구조를 꾸준히 정리하면서 구현했다.
간단하게 만들 수 있는 방법을 찾는 게 핵심이라고 생각한다.
기능 하나를 구현할 때에도 어떻게 하면 가장 단순하고 명확하게 만들 수 있을지를 먼저 고민해야 한다. 처음에는 되는 대로 만들어 놓고 나중에 수정하자고 생각했지만, 실제로는 그렇게 만든 구조일수록 디버깅이 어렵고 수정이 더 복잡해졌다.
이번 프로젝트에서는 추상 클래스와 가상 메서드 등 객체지향적인 설계를 통해 공통된 기능은 상속으로 처리하고, 서로 다른 기능은 오버라이드로 확장하면서 깔끔하게 구현할 수 있다는 걸 직접 체감했다.
또한 UI를 구성하면서 월드 좌표와 스크린 좌표 사이의 차이를 이해하고, 어떻게 하면 게임 화면에 자연스럽게 정보를 띄울 수 있을지도 실습을 통해 익힐 수 있었다. 특히 데미지 텍스트와 체력바 제거는 눈에 보이는 피드백을 통해 완성도 높은 시스템처럼 느껴지도록 만들어주는 중요한 요소였다.
이렇게 설계와 구현을 반복하면서 배운 내용을 실제로 응용해볼 수 있었고, 다음 프로젝트에 대한 불안감도 많이 줄었다. 이제는 새로운 기능을 설계할 때도, 어떤 방식으로 구현할 수 있을지 스스로 구상해볼 수 있을 것 같다.
재밌는 경험이었다.