오늘부터 3주차 Unity 입문 팀 프로젝트를 시작했습니다.
3가지의 고전 게임들 중 하나를 골라 현대적으로 리메이크를 하는 프로젝트입니다.
일명 똥 피하기라고도 불리는 하늘에서 떨어지는 낙하물 피하기 게임, 닷지라고도 불리는 총알 피하기 게임, 그리고 대중적인 벽돌 깨기 게임 중 하나를 골라서 만드는 프로젝트입니다.
저희 팀은 총알 피하기 게임을 만들어 보길 원했으나, 필수 요구사항을 보니 단순한 총알 피하기 게임이 아니었고 이 점에서 제가 지레 겁을 먹어 낙하물 피하기 게임을 하고 싶다고 말씀드렸습니다.
그래서 결국 낙하물 피하기 게임으로 결정되는 듯 했으나, 팀원들의 아쉬움도 있었고 다시 한 번 천천히 살펴보니 간단하게 만든다면 그렇게 어렵지 않은 구현 사항들인 걸 알게 됐습니다.
팀원들에게 죄송하다는 말씀을 드리고 총알 피하기 게임을 만들어보자고 말씀드리자 팀원들 모두 흔쾌히 바꿔주셨고 결국 총알 피하기 게임을 만들기로 했습니다.
먼저 필수 구현사항들에 대한 클래스 다이어그램을 먼저 만들었습니다.
그렇게 만들어진 클래스 다이어그램을 보고 팀원들의 실력에 맞춰 구현을 나눴습니다.
저는 가장 중요한 것 중 하나인 총알과 그를 관리할 오브젝트 풀을 구현하기로 했습니다.
총알을 구현하기 위해 먼저 투사체의 정보를 관리할 Scriptable Object가 필요했습니다.
그래서 오늘은 Scriptable Object에 대해 공부했고 이를 프로젝트에 적용시켰습니다.
이에 대한 내용을 작성해 보겠습니다.
Scriptable Object : Unity에서 제공하는 데이터를 저장하고 관리할 수 있는 유연한 데이터 컨테이너
게임에서 재사용 가능한 데이터들 또는 설정들을 저장하는데 사용됩니다.
재사용 가능한 데이터들을 모아놓는다는 점에서 구조체와 유사하다고 생각했습니다.
하지만, Scriptable Object의 가장 큰 특징 중 하나로는 내부에 저장된 데이터들은 변경이 되지 않는 데이터들이라는 점입니다.
구조체나 클래스의 멤버 변수들은 코드가 진행됨에 따라 저장된 데이터가 변경될 수 있지만 Scriptable Object의 데이터들은 변경되지 않을 데이터들이 저장되어 있기 때문에 절대로 그 데이터들이 변경되지 않습니다.
이를 통해, 코드와 데이터를 분리해 코드를 좀 더 깔끔하고 관리하기 쉽게 만들 수 있습니다.
또, Unity 에디터와 통합되어 Inspector 창에서 직접 수정하고 관리할 수 있습니다.
Scriptable Object의 또 다른 가장 큰 특징으로는 하나의 Scriptable Object를 여러 Game Object들에서 참조하거나 재사용할 수 있다는 점입니다.

Scriptable Object를 사용하지 않은 기존 방법으로는 중복되는 필드들이 있다면 그 크기만큼 메모리 공간을 차지했습니다.
예를 들어, 3종류의 몬스터들이 있고 그 몬스터들이 모두 이동속도, 시야라는 int형 필드들을 가지고 있다면 각 몬스터 타입 객체를 만들 때마다 이동속도, 시야 필드를 만들기 위해 8 Byte의 메모리 공간을 필요로 하게 됩니다.
각 타입별 몬스터가 100마리씩 총 300마리가 있다면 8 * 300 = 2400 Byte라는 공간을 차지하게 되는 것입니다.

그러나, Scriptable Object를 사용하면 그만큼의 메모리 공간이 필요하지 않게 됩니다.
하나의 Scriptable Object를 여러 Game Object들에서 참조해서 재사용할 수 있다는 점을 이용하는 것입니다.
이동속도와 시야를 가지고 있는 Scriptable Object를 하나 만들고 각 타입별 객체가 이를 참조하게 합니다.
그렇게 하면 하나의 Scriptable Object를 만들기 위해 8 Byte의 메모리 공간을 필요로 하게 되고, 이를 참조하기 때문에 각 타입별 몬스터들이 100마리씩, 300마리가 있다해도 처음 만든 하나의 Scriptable Object가 차지하는 8 Byte의 공간만 차지하게 됩니다.
이제 저희 팀 프로젝트에 Scriptable Object를 어떻게 적용했는지 적어보겠습니다.
우선 플레이어 총알이나 적 총알, 랜덤하게 날아오는 운석 총알에 공통적으로 사용되면서 변경되지 않을 필드들을 생각해 봤습니다.
위와 같이 SO의 멤버들을 생각하고 총알에 사용될 SO인 AttackSO를 작성했습니다.
using UnityEngine;
// CreateAssetMenu를 통해 만든 SO를 Unity 에디터에서 직접 수정하고 관리할 수 있습니다.
[CreateAssetMenu(fileName = "AttackSO", menuName = "Controller/Attacks/Default", order = 0)]
public class AttackSO : ScriptableObject
{
[Header("Attack Info")]
public string bulletNameTag; // 총알 이름
public float size; // 총알 크기
public float delay; // 총알 발사 딜레이
public float power; // 총알 데미지
public float speed; // 총알 속도
public LayerMask target; // 총알을 맞을 타겟
public Color bulletColor; // 총알 색깔
public string effectTag; // 총알 피격 시 이펙트 이름
}
이렇게 작성된 SO는 CreateAssetMenu를 통해 Unity 에디터에서 필드들의 수치를 변경시킬 수 있게 됩니다.

CreateAssetMenu를 통해서 Create를 눌렀을 때 보여지는 메뉴를 추가할 수 있습니다.
menuName으로 Controller/Attacks/Default를 설정했기 때문에 그대로 Controller -> Attacks -> Default를 눌러야 코드로 만든 AttackSO를 만들 수 있습니다.

화면 밖에서 플레이어를 향해 총알을 쏘는 적들이 사용할 총알 SO입니다.
Inspector 창을 통해서 위에서 만든 AttackSO의 필드를 설정해줄 수 있습니다.
[System.Serializable]
public class CharacterStat
{
public StatsChangeType statsChangeType;
[Range(1, 100)] public int maxHealth;
[Range(1f, 20f)] public float speed;
[Range(1f, 20f)] public float power;
// Inspector 창에서 캐릭터에 맞는 AttackSO를 연결시켜주기 위함
public AttackSO attackSO;
}
그 다음, 각 캐릭터에 맞는 AttackSO를 연결시켜 주기 위해 모든 캐릭터가 가질 CharacterStat 클래스의 필드로 attackSO를 추가시켜 줍니다.

그러면 위 사진과 같이 AttackSO를 연결시켜 줄 수 있게 Inspector 창에 AttackSO가 추가되고, 캐릭터에 맞는 AttackSO를 드래그 해서 연결시켜 줍니다.
public class BulletController : MonoBehaviour
{
protected Rigidbody2D rigidBody;
protected SpriteRenderer spriteRenderer;
// AttackSO를 사용하기 위해 필드로 attackData를 선언
protected AttackSO attackData;
protected Vector2 direction;
...
// BulletController 초기화 함수
public virtual void InitailizeAttack(Vector2 direction, AttackSO attackData)
{
// 매개변수로 받은 attackData를 필드 attackData에 저장
this.attackData = attackData;
this.direction = direction;
UpdateBulletSprite();
transform.up = this.direction;
// 스프라이트 이미지의 색을 AttackSO의 bulletColor로 설정
spriteRenderer.color = attackData.bulletColor;
isReady = true;
}
}
이제 연결시킨 AttackSO를 코드를 통해 사용할 수 있습니다.
총알의 컨트롤을 담당하는 BulletController 클래스에서 초기화 함수의 매개변수로 AttackSO를 받아와 필드에 저장시켜 줍니다.
그 후, 이미지의 색을 AttackSO의 필드인 bulletColor로 변경시켜 AttackSO의 필드를 사용해 봤습니다.
private void OnTriggerEnter2D(Collider2D collision)
{
// attackData의 target과 동일한 layer를 가진 gameObject와 충돌한 경우
if (IsLayerMatched(attackData.target.value, collision.gameObject.layer))
{
DestroyBullet(collision.ClosestPoint(transform.position), fxOnDestroy);
if (IsLayerMatched(LayerMask.GetMask("Player"), collision.gameObject.layer))
{
CharacterStatsHandler stats = collision.gameObject.GetComponent<CharacterStatsHandler>();
if (!stats.canAttacked)
return;
}
HealthSystem healthSystem = collision.GetComponent<HealthSystem>();
// 플레이어의 HP를 attackData의 power만큼 차감
if (null != healthSystem)
healthSystem.ChangeHealth(-attackData.power);
}
}
private void DestroyBullet(Vector3 position, bool createFX)
{
GameManager gameManager = GameManager.Instance;
if (createFX)
{
// ObjectPool에서 attackData의 effectTag에 해당하는 이펙트 생성
GameObject obj = gameManager.GetComponent<ObjectPool>().SpawnFromPool(attackData.effectTag);
obj.GetComponent<Effect>().InitializeEffect(position);
obj.GetComponent<Effect>().SetTag(attackData.effectTag);
}
isInWindow = false;
gameObject.SetActive(false);
// attackData의 bulletNameTag로 ObjectPool에 반환
gameManager.GetComponent<ObjectPool>().RetrieveObject(attackData.bulletNameTag, this.gameObject);
}
또, 충돌 시에 AttackSO의 LayerMask 필드인 target을 이용해 어떤 gameObject와 충돌했는지 확인합니다.
그렇게 target과 동일한 layer를 가진 gameObject와 충돌했다면, 해당 gameObject의 HP를 AttackSO의 power만큼 차감합니다.
총알을 파괴할 때도 AttackSO가 이용됩니다.
AttackSO의 effectTag에 해당하는 이펙트를 생성하고 bulletNameTag를 Key로 하는 ObjectPool에 반환하는 것으로 총알의 파괴를 수행합니다.
이처럼 Scriptable Object를 사용하면 다양한 기능 구현을 편하게 할 수 있습니다.
총알의 정보들 뿐 아니라 플레이어나 적들을 만들 때도 SO를 사용해서 만들 수 있습니다.
Scriptable Object를 적절히 사용해서 앞으로의 프로젝트 진행을 수월히 할 수 있다고 느꼈습니다.
Scriptable Object의 특징에 사용된 이미지 출처 : https://everyday-devup.tistory.com/53