AngryBird 클론 코딩
앵그리 버드 클론 코딩 프로젝트를 진행했다.
다른 시스템이나 기능을 접목하려 했지만, 아이디어가 떠오르지 않아 최대한 유사하게 만들어보기로 했다.
기본 시스템
우선, 앵그리 버드의 기본 시스템을 다음과 같이 정의했다.
- 새총을 뒤로 당겨 새를 발사한다.
- 구조물이 존재하고 발사된 새가 부딪혀 피해를 줄 수 있다.
- 구조물 사이에 적(돼지)이 존재하고 구조물이나 새에 부딪혀 피해를 입는다.
새총 발사
새총을 뒤로 당겨 새를 발사하기 위해서 새총 기능을 담당할 오브젝트를 생성했다.
마우스로 해당 오브젝트를 클릭한 뒤, 드래그해 줄을 당기도록 구현하고 싶었다.
이를 구현하기 위해 마우스 이벤트를 처리하는 인터페이스를 상속받아 진행하였다.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Numerics;
using UnityEngine;
using UnityEngine.EventSystems;
using Quaternion = UnityEngine.Quaternion;
using Vector2 = UnityEngine.Vector2;
using Vector3 = UnityEngine.Vector3;
public class Slingshot : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IDragHandler
{
[Header("Projectile")]
private Projectile _projectile;
private Rigidbody2D _projectileRb;
[Header("Fire")]
public Transform _muzzleTransform;
[SerializeField] private float _maxDistance = 5f;
private Vector3 _pullDirection;
[SerializeField] private float _power = 1;
[Header("Path")]
public GameObject PathPointPrefab;
private List<Vector3> _pathPointPositions;
private List<GameObject>_pathPoints;
[SerializeField] private int maxStep;
[SerializeField] private float timeStep;
[Header("Camera")]
public Camera _camera;
[Header("UI")]
[SerializeField] private SpriteRenderer _slingshotRight;
[SerializeField] private SpriteRenderer _slingshotLeft;
[Header("Visual")]
private SlingshotLines _slingshotLines;
public float _leftRoationValue = 1.5f;
public float _rightRoationValue = 1f;
public float _maxRotation = 15f;
[Header("SFX")]
[SerializeField] private SOUND_TYPE _stretchSFXType;
[SerializeField] private SOUND_TYPE _fireSFXType;
private bool _isPulling;
private void Start()
{
_pathPointPositions = new List<Vector3>();
_pathPoints = new List<GameObject>();
_slingshotLines = GetComponent<SlingshotLines>();
_isPulling = false;
}
public void OnPointerDown(PointerEventData eventData)
{
_projectile = ProjectileManager.instance.GetNextProjectile();
if (_projectile == null) return;
_projectile.transform.position = _muzzleTransform.position;
_projectileRb = _projectile.GetComponent<Rigidbody2D>();
_projectileRb.simulated = false;
_slingshotLines.IsPulled = true;
_slingshotLines.SetEndPostion(_projectile.transform);
}
public void OnPointerUp(PointerEventData eventData)
{
if (_projectile == null) return;
_isPulling = false;
SoundManager.instance?.OnEventSound(_fireSFXType);
ClearProjectilePath();
_slingshotRight.transform.rotation = Quaternion.identity;
_slingshotLeft.transform.rotation = Quaternion.identity;
_projectile.Fire(_pullDirection, _power);
_slingshotLines.IsPulled = false;
}
public void OnDrag(PointerEventData eventData)
{
if (_projectile == null) return;
if(!_isPulling) SoundManager.instance?.OnEventSound(_stretchSFXType);
_isPulling = true;
var pullPosition = _camera.ScreenToWorldPoint(eventData.position);
pullPosition.z = 0;
_pullDirection = pullPosition - _muzzleTransform.position;
if (_pullDirection.magnitude > _maxDistance)
{
_pullDirection = _pullDirection.normalized * _maxDistance;
pullPosition = _muzzleTransform.position + _pullDirection;
}
RotateSlingshot();
SpawnProjectilePath();
_projectile.transform.position = pullPosition;
_projectile.transform.LookAt(_muzzleTransform);
_projectile.transform.Rotate(new Vector3(0, -90, 0));
_slingshotLines.SetEndPostion(_projectile.transform);
}
void RotateSlingshot()
{
if (Mathf.Approximately(_pullDirection.magnitude, _maxDistance)) return;
var magnitude = _pullDirection.magnitude;
if (_maxRotation < magnitude) magnitude = _maxRotation;
if (0 <= _pullDirection.x) magnitude *= -1;
_slingshotRight.transform.rotation = Quaternion.Euler(new Vector3(0f, 0f, magnitude * _rightRoationValue));
_slingshotLeft.transform.rotation = Quaternion.Euler(new Vector3(0f, 0f, magnitude * _leftRoationValue));
}
void ClearProjectilePath()
{
foreach (var pathPoint in _pathPoints)
{
Destroy(pathPoint);
}
_pathPoints.Clear();
}
private void SpawnProjectilePath()
{
ClearProjectilePath();
CalculatePath();
var canvasTransform = UIManager.instance.GetCanvasTransform();
foreach (var pos in _pathPointPositions)
{
var pathPoint = Instantiate(PathPointPrefab, pos, Quaternion.identity, canvasTransform);
pathPoint.transform.localScale *= .7f;
_pathPoints.Add(pathPoint);
}
}
private void CalculatePath()
{
_pathPointPositions.Clear();
Vector3 position = _projectile.transform.position;
Vector3 velocity = new Vector2(_pullDirection.x, _pullDirection.y) * -_power / _projectileRb.mass;
_pathPointPositions.Add(position);
for (int i = 1; i <= maxStep; i++)
{
float timeElapsed = timeStep * i;
float dragFactor = Mathf.Exp(-_projectileRb.angularDrag * Time.fixedDeltaTime);
var newPos = position + velocity * timeElapsed + Physics.gravity * (0.5f * timeElapsed * timeElapsed) * dragFactor;
newPos.z = 0;
_pathPointPositions.Add(newPos);
if (CheckCollision(_pathPointPositions[i - 1], _pathPointPositions[i], out Vector3 hitPoint))
{
_pathPointPositions[i] = hitPoint;
break;
}
}
}
private bool CheckCollision(Vector3 start, Vector3 end, out Vector3 hitPoint)
{
hitPoint = end;
Vector3 direction = end - start;
float distance = direction.magnitude;
int layer = 1 << LayerMask.NameToLayer("Default");
layer += 1 << LayerMask.NameToLayer("Ground");
RaycastHit2D hit = Physics2D.Raycast(start, direction, distance, layer);
if (hit)
{
hitPoint = hit.point;
return true;
}
return false;
}
}
마우스 이벤트를 처리하는 함수는 다음과 같이 동작한다.
- OnPointerDown: 마우스가 클릭되었을 때 실행
- 다음 발사될 새를 발사위치에 위치시킨다.
- 발사되어 충돌이 가능하도록 설정해 준다.
- OnPointerUp: 마우스가 클릭이 해제되었을 때 실행
- 새를 발사한 뒤, 다음 새가 발사될 수 있도록 값들을 초기화해 준다.
- OnDrag: 마우스가 클릭된 채로 드래그되었을 때 실행
- 마우스 포인터 위치로 현재 발사될 새를 이동시킨다.
- 새를 어느 방향으로 얼마나 세게 날릴지 볼 수 있는 Trail을 생성한다.
- 새총을 회전시켜 새총이 당겨지는 느낌을 준다.
OnPointerDown, OnPointerUp은 간단하게 구현할 수 있다.
하지만, OnDrag에서는 몇 가지 추가적인 작업을 한다.
우선, OnDrag에서는 새총 자체를 회전시켜 새총이 당겨져 휘어지는 느낌을 주도록 구현하였다.
void RotateSlingshot()
{
if (Mathf.Approximately(_pullDirection.magnitude, _maxDistance)) return;
var magnitude = _pullDirection.magnitude;
if (_maxRotation < magnitude) magnitude = _maxRotation;
if (0 <= _pullDirection.x) magnitude *= -1;
_slingshotRight.transform.rotation = Quaternion.Euler(new Vector3(0f, 0f, magnitude * _rightRoationValue));
_slingshotLeft.transform.rotation = Quaternion.Euler(new Vector3(0f, 0f, magnitude * _leftRoationValue));
}
또한, 발사체가 날아갈 궤적을 보여주는 Trail을 그려준다.
private void SpawnProjectilePath()
{
ClearProjectilePath();
CalculatePath();
var canvasTransform = UIManager.instance.GetCanvasTransform();
foreach (var pos in _pathPointPositions)
{
var pathPoint = Instantiate(PathPointPrefab, pos, Quaternion.identity, canvasTransform);
pathPoint.transform.localScale *= .7f;
_pathPoints.Add(pathPoint);
}
}
private void CalculatePath()
{
_pathPointPositions.Clear();
Vector3 position = _projectile.transform.position;
Vector3 velocity = new Vector2(_pullDirection.x, _pullDirection.y) * -_power / _projectileRb.mass;
_pathPointPositions.Add(position);
for (int i = 1; i <= maxStep; i++)
{
float timeElapsed = timeStep * i;
float dragFactor = Mathf.Exp(-_projectileRb.angularDrag * Time.fixedDeltaTime);
var newPos = position + velocity * timeElapsed + Physics.gravity * (0.5f * timeElapsed * timeElapsed) * dragFactor;
newPos.z = 0;
_pathPointPositions.Add(newPos);
if (CheckCollision(_pathPointPositions[i - 1], _pathPointPositions[i], out Vector3 hitPoint))
{
_pathPointPositions[i] = hitPoint;
break;
}
}
}
이전 프레임에 그렸던 Path를 삭제한 뒤, 다시 궤적을 계산한다.
궤적이 계산되어 점들이 위치할 position을 기록한 뒤 모든 position에 궤적을 나타낼 점을 그린다.
궤적을 계산하는 부분에는 물리 계산이 포함되어 있다.
발사체는 RigidBody에 의해 발사되지만 이 역시 물리 계산에 의해 진행되기 때문에 미리 계산할 수 있다.
newPos는 기본적으로 $s=y(v0 ⋅ yt− 1/2 gt^2 )+ x(v0 ⋅ xt)$와 같은 공식을 따라 위치가 정해지지만, 실제로는 저항이 있기 때문에 dragFactor를 계산하여 곱해주었다.
또한, 새총이 당겨질 때 새총 줄이 보이면 좋을 것 같았다.
따라서, LineRenderer를 통해 새총에서부터 발사체까지 선을 그려주었다.
이를 Slingshot에서 처리할 수 있었지만 기능이 많아지고 코드가 복잡해질 것 같아 따로 분리하였다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SlingshotLines : MonoBehaviour
{
[Header("Line")]
[SerializeField] private LineRenderer _leftLine;
[SerializeField] private LineRenderer _rightLine;
[Header("Pivot")]
[SerializeField] private GameObject _leftStartPosition;
[SerializeField] private GameObject _rightStartPosition;
private bool ispull = false;
public bool IsPulled
{
get=>ispull;
set
{
ispull = value;
_leftStartPosition.SetActive(ispull);
_rightStartPosition.SetActive(ispull);
}
}
void Update()
{
if (!ispull) return;
_leftLine.SetPosition(0, _leftStartPosition.transform.position);
_rightLine.SetPosition(0, _rightStartPosition.transform.position);
}
public void SetEndPostion(Transform projectile)
{
_leftLine.SetPosition(1, projectile.position);
_rightLine.SetPosition(1, projectile.position);
}
}
현재 체력에 의해 Sprite 변경
구조물, 적(돼지)은 피해를 받아 체력이 줄어들면 외형이 바뀌는 시스템이 기존 앵그리버드에 존재한다.
이를 구현하기 위해, 현재 체력에 비례하여 외형을 변경하는 컴포넌트를 제작하였다.
충돌이 일어나면 충돌의 세기를 알기 위해 충돌한 두 물체의 상대 속도의 크기를 이용하였다.
상대 속도의 크기가 일정 수준이상이라면 속도의 크기에 비례한 데미지를 주도록 만들었다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Random = UnityEngine.Random;
public class DamagedSprite : MonoBehaviour
{
public Action<int> OnChangedState;
[Header("Stat")]
[SerializeField] private float MaxHP = 10f;
[SerializeField] private float _currentHP = 10f;
[Header("Sprite")]
public List<Sprite> Sprites;
[SerializeField] private int MaxState = 4;
private int currentState = 0;
[Header("Hit")]
public float _hitThreshold = 5f;
public float _hitMaxSpeed = 10f;
public float _damageMult = 3f;
[Header("Comp")]
[SerializeField] private SpriteRenderer _spriteRenderer;
[Header("SFX")]
[SerializeField] private List<SOUND_TYPE> _collisionSFXTypes;
float ConvertDamage(float speed)
{
return speed / _hitMaxSpeed * _damageMult;
}
private void OnCollisionEnter2D(Collision2D other)
{
var speed = other.relativeVelocity.magnitude;
if (speed >= _hitThreshold)
{
_currentHP -= ConvertDamage(speed);
if (_currentHP < 0)
{
_currentHP = 0;
Destroy(gameObject);
return;
}
var idx = Random.Range(0, _collisionSFXTypes.Count);
SoundManager.instance?.OnEventSound(_collisionSFXTypes[idx]);
int nextState = Mathf.FloorToInt((MaxHP - _currentHP) / MaxHP * MaxState);
if (currentState != nextState) ChangeState(nextState);
}
}
private void ChangeState(int nextState)
{
OnChangedState?.Invoke(nextState);
currentState = nextState;
if (_spriteRenderer == null) return;
_spriteRenderer.sprite = Sprites[currentState];
}
}
또한, 데미지를 받아 체력이 줄어들면 정해진 Sprite의 개수에 따라 현재 Sprite를 변경해 준 뒤, 이에 맞는 Animation이 실행될 수 있도록 Action을 이용해 Delegate를 주었다.
Enemy에서 다음과 같이 Delegate를 받아 Animation을 변경한다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Random = UnityEngine.Random;
public class Enemy : MonoBehaviour
{
[Header("Comp")]
private Animator _animator;
[SerializeField] private DamagedSprite _damagedSprite;
[Header("SFX")]
[SerializeField] private SOUND_TYPE _destroySFXType;
private void Start()
{
_animator = GetComponent<Animator>();
_damagedSprite = GetComponent<DamagedSprite>();
_damagedSprite.OnChangedState += SetAnimation;
}
private void SetAnimation(int state)
{
_animator.Play($"Anim_Enemy_Idle_{state}");
}
private void OnDestroy()
{
SoundManager.instance?.OnEventSound(_destroySFXType);
}
}
소멸 FX
앵그리버드의 모든 오브젝트는 소멸할 때, FX가 출력된다.
이를 관리하기 위한 컴포넌트를 제작하여 모든 오브젝트에 추가하였다.
using System;
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
public class DestroyHandler : MonoBehaviour
{
[Header("FX")]
[SerializeField] private GameObject _destroyFXPrefab;
[SerializeField] private float _fxSize = .4f;
[Header("Score")]
public ObjectType _objectType;
public Action<DestroyHandler> OnDestroyAction;
private void OnDestroy()
{
if(!gameObject.scene.isLoaded) return;
var fx = Instantiate(_destroyFXPrefab, gameObject.transform.position, Quaternion.identity);
var spriteRenderer = GetComponent<SpriteRenderer>();
if (spriteRenderer != null)
{
fx.GetComponent<FXScaleSetter>()._maxScale = Mathf.Min(spriteRenderer.sprite.bounds.size.x, spriteRenderer.sprite.bounds.size.y) * _fxSize;
}
var animator = fx.GetComponent<Animator>();
if(animator != null)
{
animator.Play("Anim_Object_Destroy");
}
//Add Score
OnDestroyAction?.Invoke(this);
Destroy(fx, 2f);
}
private void Update()
{
Vector3 viewPos = Camera.main.WorldToViewportPoint(transform.position);
// 시야 영역을 벗어나면 제거
if (viewPos.x < 0 || viewPos.x > 1 || viewPos.y < 0 )
{
Destroy(gameObject);
}
}
}
부모가 소멸하여 해당 컴포넌트의 OnDestroy가 실행될 때 FX를 생성한 후, 삭제되도록 설정하는 것을 볼 수 있다.
해당 부분에서 animator를 통해 어떠한 Animtion을 실행하는데, 이는 FX의 스케일을 조정하는 Animtion이다.
FX를 Sprite Animation으로 만들게 되면 스케일이 Animation에 고정되어 아무리 변경해도 스케일이 바뀌지 않는다.
이를 해결하기 위해, 스케일을 조정할 변수를 선언한 뒤, 해당 변수의 값에 Animation을 주어 0~1 사이의 값으로 변경되도록 만들었다.
이를 적당한 값을 곱해 스케일을 조정하는 컴포넌트를 만들어 FX의 크기를 조절하였다.
Sprite 자체의 크기가 변경되는 Sprite의 시퀀스가 있다면 상관없지만 한 장이거나 몇 장 없는 Sprite를 사용할 때 괜찮은 방법인 것 같다.
또한, Update를 통해 오브젝트가 화면 위쪽을 제외한 화면밖으로 나가면 삭제되도록 구현하였다.
이렇게 모든 오브젝트에 해당되는 기능을 따로 모아 BaseObject로 만들어 상속받는 것도 좋아 보인다.
Bird Skill
앵그리버드의 새들은 각자 고유한 스킬을 갖고 있다.
새가 날아가는 중, 땅에 닿기 전에 화면을 한 번 더 클릭하면 스킬이 발동된다.
새들은 스킬을 갖고 있다는 것은 동일하지만, 실제 실행되는 스킬은 모두 다르다.
따라서, 스킬에 관련된 Interface를 사용해 실제 스킬 구현에 대해 확장성과 유연성을 고려하여 설계하였다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SkillBase : MonoBehaviour, IClickSkill
{
[Header("Comp")]
protected Projectile _projectile;
protected Animator _projectileAnimator;
[Header("SFX")]
[SerializeField] protected SOUND_TYPE _activeSFXType;
protected void Start()
{
_projectile = GetComponent<Projectile>();
_projectileAnimator = GetComponent<Animator>();
}
public virtual void ActivateSkill()
{
SoundManager.instance?.OnEventSound(_activeSFXType);
_projectileAnimator.Play("Anim_Bird_ActiveSkill");
}
}
새의 스킬이 실행될 때 항상 소리와 Animation이 실행되기 때문에 Base클래스에서 이를 처리한다.
SkillBase를 상속받아 실제로 새가 실행하는 스킬을 구현하면 된다.
우선, 노란 삼각형 새는 스킬이 발동되면 바라보고 있는 방향으로 빠르게 이동한다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SkillSpeedUp : SkillBase
{
[SerializeField] private float _power;
public override void ActivateSkill()
{
base.ActivateSkill();
var projectileRb = _projectile.GetComponent<Rigidbody2D>();
var forward = transform.root.right;
projectileRb.velocity += new Vector2(forward.x * _power, forward.y * _power);
}
}
2D이기 때문에 right 벡터가 새가 바라보고 있는 방향이 된다.
해당 방향으로 power만큼 속도를 더해준다.
다음으로, 검은 새의 스킬이다.
검은 새는 스킬이 발동되면 그 자리에서 폭발하여 주변에게 영향을 준다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SkillExplosion : SkillBase
{
[SerializeField] private float _power;
[SerializeField] private float _range;
[SerializeField] private GameObject _explosionFXPrefab;
public override void ActivateSkill()
{
base.ActivateSkill();
Vector2 projectilePos = _projectile.transform.position;
var hitResults = Physics2D.CircleCastAll(projectilePos, _range, Vector2.zero, 0, (-1) - (1 << LayerMask.NameToLayer("Ground")));
foreach (var hit in hitResults)
{
var victimRb = hit.collider.GetComponent<Rigidbody2D>();
if(!victimRb) continue;
victimRb.AddForceAtPosition(hit.point - projectilePos.normalized * _power, projectilePos, ForceMode2D.Impulse);
}
var explosion = Instantiate(_explosionFXPrefab, _projectile.transform.position, Quaternion.identity);
var size = _projectile.GetComponent<SpriteRenderer>().size;
explosion.transform.localScale = size * .5f;
Destroy(explosion, 2f);
Destroy(gameObject);
}
}
Physics2D의 CircleCast를 이용하여 자신 주변에 원형으로 위치하는 오브젝트를 찾아낸다.
찾아낸 오브젝트들에게 자신의 위치를 기준으로 설정한 만큼의 충격을 준다.
Managers
게임이 진행되며 수시로 참조되거나 계속하여 필요한 기능이 있을 수 있다.
이를 Singleton패턴을 적용한 매니저 클래스로 제작하여 관리하였다.
제작한 매니저 목록은 다음과 같다.
- GameManager: 게임의 시작, 종료 등 데이터를 관리하는 매니저
- ProjectileManager: 투사체(새)들의 Prefab, 순서 등을 관리하는 매니저
- ScoreManager: 게임의 점수를 관리하는 매니저
- SoundManager: 게임에서 실행되는 모든 Sound를 관리하는 매니저
- UIManager: 게임의 UI를 관리하는 매니저
GameManager
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Unity.VisualScripting;
using UnityEngine;
public class GameManager : MonoBehaviour
{
public static GameManager instance;
private List<GameObject> Enemies;
private List<GameObject> Buildings;
private List<GameObject> Birds = new List<GameObject>();
private void Awake()
{
instance = this;
}
void Start()
{
Enemies = GameObject.FindGameObjectsWithTag("Enemy").ToList();
foreach (var enemy in Enemies)
{
enemy.GetComponent<DestroyHandler>().OnDestroyAction += ((DestroyHandler destroyHandler) =>
{
ScoreManager.instance?.AddScore(destroyHandler);
Enemies.Remove(enemy);
if (Enemies.Count == 0) StartCoroutine(EndGame());
});
}
Buildings = GameObject.FindGameObjectsWithTag("Building").ToList();
foreach (var building in Buildings)
{
building.GetComponentInChildren<DestroyHandler>().OnDestroyAction += ((DestroyHandler destroyHandler) =>
{
ScoreManager.instance?.AddScore(destroyHandler);
});
}
}
public void AddBird(GameObject bird)
{
bird.GetComponent<DestroyHandler>().OnDestroyAction += ((DestroyHandler destroyHandler) =>
{
Birds.Remove(bird);
if(Birds.Count == 0) StartCoroutine(EndGame());
});
Birds.Add(bird);
}
IEnumerator EndGame()
{
if (!UIManager.instance) yield break;
float time = 0f;
float duration = 3f;
while (time < duration)
{
time += Time.deltaTime;
if (Birds.Count == 0 && Enemies.Count == 0) break;
yield return null;
}
bool bComplete = Enemies.Count == 0;
SoundManager.instance?.OnGameFinished(bComplete);
UIManager.instance.ShowEndUI(bComplete);
}
}
게임의 규칙을 정하고 관리하는 매니저이다.
모든 새가 없어졌거나, 모든 적이 없어지면 게임을 종료한다.
게임 종료 시, 모든 적이 없다면 스테이지를 완료했다고 판정했다.
모든 적을 찾는 과정이 Tag를 통해 진행되는데 Find함수와 Tag를 통한 연산이 효율이 좋지 않다고 알고 있다.
하지만, 특정 위치에 적이 있어야 하기 때문에 씬에 직접 배치하였다.
씬에서 적들의 위치를 관리하는 빈 오브젝트를 통해 위치 정보만을 가져와도 되지만, 더욱 번거롭다고 생각하여 위와 같이 진행했다.
만약, 성능상 큰 문제가 있다면 개선해야 된다.
ProjectileManager
ProjectileManager는 스테이지에서 사용할 수 있는 발사체(새)들에 대한 정보를 관리하는 매니저 클래스이다.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public enum EBird
{
Red = 0,
Yellow,
Black,
};
public class ProjectileManager : MonoBehaviour
{
public static ProjectileManager instance;
[Header("Projectile")]
[SerializeField] private List<GameObject> _projectilePrefabs;
[SerializeField] private List<GameObject> _projectiles;
[Header("Order")]
[SerializeField] private List<EBird> _birdOrder;
[Header("Visual")]
[SerializeField] private GameObject _originPos;
[SerializeField] private Vector3 _margin;
void Awake()
{
instance = this;
}
private void Start()
{
//Instancing bird
var origin = _originPos.transform.position;
for (int i = 0 ; i < _birdOrder.Count; i++)
{
var projectile = Instantiate(_projectilePrefabs[(int)_birdOrder[i]], origin + _margin * i, Quaternion.identity);
GameManager.instance.AddBird(projectile);
_projectiles.Add(projectile);
}
}
public Projectile GetNextProjectile()
{
if (_projectiles.Count == 0) return null;
var result = _projectiles[0].GetComponent<Projectile>();
if (result == null) return null;
_projectiles[0].layer = LayerMask.NameToLayer("Projectile");
//reorder
_projectiles.RemoveAt(0);
foreach (var projectile in _projectiles)
{
projectile.transform.position -= _margin;
}
return result;
}
}
설정된 정보를 통해 미리 발사체를 생성하여 List에 저장해 놓는다.
미리 생성한 발사체들은 새총 뒤에 위치해 남아있는 새들을 볼 수 있도록 해준다.
발사체를 발사한 후, 새총을 새로 당기면 가장 앞에 있는 발사체를 새총에 전달한다.
이후, 비어 있는 공간을 다시 채운다.
ScoreManager
ScoreManager는 게임의 점수를 계산 및 관리하는 매니저 클래스이다.
각 구조물, 적, Prop 등 점수를 얻을 수 있는 오브젝트에 대해 미리 점수를 설정해 놓은 뒤, 오브젝트가 파괴되어 Delegate가 전달되면 현재 점수에 더해 UIManager를 통해 점수를 업데이트한다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public enum ObjectType
{
Enemy,
Thin,
Square,
Triangle,
Circle,
Size
}
[Serializable]
public class ObjectScore
{
public ObjectType key;
public int value;
}
public class ScoreManager : MonoBehaviour
{
public static ScoreManager instance;
[Header("Score")]
[SerializeField] private List<ObjectScore> ObjectScores;
private int _currentScore = 0;
[SerializeField] private int _maxScore = 0;
public int CurrentScore
{
get => _currentScore;
set => _currentScore = value;
}
public int MaxScore
{
get => _maxScore;
set => _maxScore = value;
}
void Awake()
{
instance = this;
}
public void AddScore(DestroyHandler destroyHandler)
{
var score = GetScoreByType(destroyHandler._objectType);
UIManager.instance?.ShowScoreText(score, destroyHandler.transform);
_currentScore += score;
UpdateScoreUI();
}
private int GetScoreByType(ObjectType type)
{
foreach (var objScore in ObjectScores)
{
if (objScore.key == type) return objScore.value;
}
return 0;
}
private void UpdateScoreUI()
{
UIManager.instance.UpdateScore(_currentScore);
}
}
SoundManager
SoundManager는 게임에서 실행되는 모든 Sound를 관리하는 매니저 클래스이다.
새총을 당기거나 발사하는 등 게임에서 소리가 날 수 있는 상황에 어떠한 SFX가 실행될지 미리 설정해 놓는다.
이후, 각 오브젝트들은 특정 상황에 Type을 전달하여 원하는 소리가 출력되도록 구현하였다.
모든 소리를 해당 클래스에서 관리하기 때문에 유지보수가 쉽다.
또한, enum을 활용하여 에디터에서 간편하게 설정할 수 있도록 설계하였다.
using System;
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
[Serializable]
public enum SOUND_TYPE
{
INTRO = 0,
GAME,
SLINGSHOT_STRETCH,
SLINGSHOT_FIRE,
RED_BIRD_FLYING,
YELLOW_BIRD_FLYING,
BLACK_BIRD_FLYING,
BIRD_DESTROY,
PIG_COLLISION_1,
PIG_COLLISION_2,
PIG_COLLISION_3,
PIG_DESTROY,
WOOD_COLLISION_1,
WOOD_COLLISION_2,
WOOD_COLLISION_3,
WOOD_COLLISION_4,
WOOD_COLLISION_5,
WOOD_COLLISION_6,
WOOD_DESTROY_1,
WOOD_DESTROY_2,
WOOD_DESTROY_3,
SKILL_SPEED_UP,
SKILL_EXPLOSION,
UI_BUTTON_HOVER,
UI_BUTTON_CLICK,
GAME_LEVEL_COMPLETE,
GAME_LEVEL_FAIL,
TNT_EXPLODE,
}
[Serializable]
public struct SFX
{
public SOUND_TYPE type;
public AudioClip clip;
}
public class SoundManager : MonoBehaviour
{
public static SoundManager instance;
[SerializeField] private AudioSource _audioSource;
[SerializeField] private List<SFX> _clips;
private void Awake()
{
instance = this;
_audioSource = GetComponent<AudioSource>();
}
void Start()
{
//TODO: Lobby를 만들거면 Intro 아니면 바로 게임
OnMainBGM();
}
void OnIntroBGM()
{
_audioSource.clip = GetAudioClip(SOUND_TYPE.INTRO);
_audioSource.Play();
}
public void OnMainBGM()
{
_audioSource.clip = GetAudioClip(SOUND_TYPE.GAME);
_audioSource.Play();
}
public void OnEventSound(SOUND_TYPE soundType)
{
if (_audioSource.IsUnityNull()) return;
_audioSource.PlayOneShot(GetAudioClip(soundType));
}
public void OnButtonHover()
{
if (_audioSource.IsUnityNull()) return;
_audioSource.PlayOneShot(GetAudioClip(SOUND_TYPE.UI_BUTTON_HOVER));
}
public void OnButtonClick()
{
if (_audioSource.IsUnityNull()) return;
_audioSource.PlayOneShot(GetAudioClip(SOUND_TYPE.UI_BUTTON_CLICK));
}
public void OnGameFinished(bool bComplete)
{
if (_audioSource.IsUnityNull()) return;
_audioSource.Stop();
var clip = bComplete ? GetAudioClip(SOUND_TYPE.GAME_LEVEL_COMPLETE) : GetAudioClip(SOUND_TYPE.GAME_LEVEL_FAIL);
_audioSource.PlayOneShot(clip);
}
private AudioClip GetAudioClip(SOUND_TYPE type)
{
foreach (var clip in _clips)
{
if (clip.type == type) return clip.clip;
}
return null;
}
}
단, 모든 소리를 enum으로 표현하기 때문에 enum이 굉장히 많아졌다.
이를 기능이나 의미상으로 나누어 관리해도 괜찮을 것 같다.
만약, enum을 변경하면 에디터 상에서도 변화가 있기 때문에 잊지 말고 다시 확인해야 한다.
SoundManager를 통해 SFX를 실행하는 예시이다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Random = UnityEngine.Random;
public class Enemy : MonoBehaviour
{
private void OnDestroy()
{
SoundManager.instance?.OnEventSound(_destroySFXType);
}
}
Enemy 코드 중 일부이다.
SoundManager는 Singleton패턴으로 구현했기 때문에 static 변수로 instance를 단 하나 갖고 있다.
SFX를 실행하려면 해당 인스턴스의 OnEventSound 함수를 실행하면 된다.
이때, SFX의 타입을 전달하면 된다.
UIManager
UIManager는 게임의 UI를 관리하는 매니저 클래스이다.
게임 상에는 점수를 표시하는 UI, 게임 종료 시 표시될 결과 UI 등 이 존재하며 앞으로 추가 및 확장이 가능하기에 매니저 클래스로 분리하였다.
또한, UI는 대부분의 오브젝트에서 참조할 가능성이 높고 canvas나 camera 등을 빈번하게 참조하므로 따로 관리하는 것이 좋다고 생각했다.
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class UIManager : MonoBehaviour
{
public static UIManager instance;
[Header("Camera")]
[SerializeField] private Camera _mainCamera;
[Header("UI")]
[SerializeField] private Canvas _canvas;
[SerializeField] private Canvas _overlayCanvas;
[SerializeField] private TMP_Text _scoreText;
[SerializeField] private GameObject _resultOverlay;
[SerializeField] private TMP_Text _textResult;
[SerializeField] private List<Image> _starImages;
[SerializeField] private List<Sprite> _starSprites;
[SerializeField] private Button _exitButton;
[SerializeField] private Button _reStartButton;
[Header("Prefabs")]
[SerializeField] private GameObject ScoreTextPrefab;
private void Awake()
{
instance = this;
_mainCamera = Camera.main;
}
void Start()
{
_resultOverlay.SetActive(false);
_exitButton.onClick.AddListener(() =>
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
});
_reStartButton.onClick.AddListener(() =>
{
SceneManager.LoadScene("Stage");
});
}
public Transform GetCanvasTransform()
{
return _canvas.transform;
}
public void UpdateScore(int score)
{
_scoreText.text = $"Score: {score}";
}
public void ShowEndUI(bool bSuccess)
{
int currentScore = ScoreManager.instance.CurrentScore;
int maxScore = ScoreManager.instance.MaxScore;
int step = Mathf.FloorToInt((float)currentScore / (float)maxScore * 3);
if (bSuccess)
{
for (int i = 0; i < _starImages.Count; i++)
{
int idx = i < step ? 1 : 0;
_starImages[i].sprite = _starSprites[idx];
}
_textResult.text = "Stage Clear";
}
else
{
foreach (var star in _starImages)
{
star.gameObject.SetActive(false);
}
_textResult.text = "Try Agian";
}
_resultOverlay.SetActive(true);
}
public void ShowScoreText(int score, Transform targetPos)
{
StartCoroutine(RenderScoreText(score, targetPos));
}
IEnumerator RenderScoreText(int score, Transform targetPos)
{
var scoreText = Instantiate(ScoreTextPrefab, targetPos.position, Quaternion.identity, _overlayCanvas.transform);
var textComp = scoreText.GetComponent<TMP_Text>();
textComp.text = $"{score}";
float time = 0f;
float duration = 3f;
var screenPosition = _mainCamera.WorldToScreenPoint(scoreText.transform.position);
scoreText.transform.position = screenPosition;
while (time < duration)
{
time += Time.deltaTime;
screenPosition += new Vector3(0f, 10f * Time.deltaTime, 0f);
scoreText.transform.position = screenPosition;
var nextColor = textComp.color;
nextColor.a *= .99f;
textComp.color = nextColor;
yield return null;
}
Destroy(scoreText);
}
}
UIManager는 크게 점수를 업데이트하는 부분과 게임 결과를 출력하는 부분으로 나눌 수 있다.
점수를 업데이트하는 것은 간단하다.
점수를 나타내는 Text를 변경하면 된다.
눈여겨볼 부분은 오브젝트에 점수를 표시하는 부분이다.
오브젝트가 파괴되면 ScoreManager를 통해 현재 점수를 업데이트함과 동시에 UIManager에게 추가된 점수와 오브젝트가 있던 위치를 전달하여 추가된 점수를 표시한다.
이 과정에서 코루틴을 통해 애니메이션을 주었다.
기존 UI는 Space가 ScreenSpace - Camera로 설정되어 있어 오브젝트 위로 표시할 수 없었다.
따라서, canvas를 하나 더 추가하여 overlay 되도록 설정하였다.
하지만, 이를 추가하고 나니 게임이 조금 버벅거리는 것 같은 느낌이었다.
이를 해결할 방안을 찾아봐야 할 것 같다.
게임 결과를 출력하는 부분은 게임 종료 시 총점수와 클리어 여부를 출력한다.
이때, 총점수와 미리 설정한 최대 점수를 기준으로 획득하는 별의 개수를 다르게 설정해 주었다.
또한, 결과창에 나타나는 두 개의 버튼(End, Retry)에 Listener를 추가하여 게임을 종료할지, 다시 시작할지 선택할 수 있게 구현하였다.
Application.Quit();
위의 명령어는 빌드된 게임에서는 동작하지만, 에디터 상에서는 동작하지 않는다.
따라서 #if 전처리를 통해 에디터에서는 다음과 같이 동작하도록 구현하였다.
UnityEditor.EditorApplication.isPlaying = false;
Prop
앵그리버드에는 구조물 외에도 다양한 소품이 존재한다.
그중, 대표적인 TNT를 구현해 보았다.
다른 구조물과는 다르게 외형은 변경되지 않지만, 충돌 시 특정 수준을 넘게 되면 폭발하도록 제작하였다.
검은 새의 스킬과 비슷하게 구현할 수 있다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TNT : MonoBehaviour
{
[Header("Hit")]
[SerializeField] private float _threshold = 3f;
[SerializeField] private float _range = 3f;
[SerializeField] private float _power = 3f;
[Header("FX")]
[SerializeField] private GameObject _explosionFXPrefab;
private void OnCollisionEnter2D(Collision2D other)
{
var velocity = other.relativeVelocity.magnitude;
if (velocity < _threshold) return;
Vector2 pos = transform.position;
var hitResults = Physics2D.CircleCastAll(pos, _range, Vector2.zero, 0, (-1) - (1 << LayerMask.NameToLayer("Ground")));
foreach (var hit in hitResults)
{
var victimRb = hit.collider.GetComponent<Rigidbody2D>();
if(!victimRb) continue;
victimRb.AddForceAtPosition(hit.point - pos.normalized * _power, pos, ForceMode2D.Impulse);
}
var explosion = Instantiate(_explosionFXPrefab, pos, Quaternion.identity);
Destroy(explosion, 2f);
Destroy(gameObject);
}
}
Particle
구조물이나 새와 같이 충돌이 일어나서 외형이 바뀌는 오브젝트에서 나무 조각이나 깃털처럼 Particle이 튀면 게임이 더욱 풍성하게 보일 수 있다.
이를 구현하기 위해 Particle을 생성하여 임의의 방향으로 퍼져나가는 시스템을 만들어 봤다.
우선, 파티클이 생기는 오브젝트도 있지만 그렇지 않은 오브젝트도 있기에 컴포넌트로 만들어 파티클을 생성하였다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ParticleSpawner : MonoBehaviour
{
[Header("Particle")]
[SerializeField] private int _maxParticleNum = 3;
[SerializeField] private GameObject _particlePrefab;
[SerializeField] private List<Sprite> _particleSprites;
[SerializeField] private float _size;
public void SpawnParticle()
{
int rand = Random.Range(0, _maxParticleNum);
for (int i = 0; i < rand; i++)
{
int randIdx = Random.Range(0, _particleSprites.Count);
var particle = Instantiate(_particlePrefab, transform.position, Quaternion.identity);
particle.transform.localScale = Random.insideUnitCircle * _size;
particle.GetComponent<SpriteRenderer>().sprite = _particleSprites[randIdx];
particle.GetComponent<Rigidbody2D>().AddForce(Random.insideUnitCircle, ForceMode2D.Impulse);
Destroy(particle, 5f);
}
}
}
파티클을 나타내는 오브젝트는 SpriteRenderer와 Collider, RigidBody2D만을 갖고 있다.
이 오브젝트를 임의의 개수로 생성하고 Sprite 역시 임의로 설정한다.
그리고 동일하게 떨어지지 않도록 Random.insideUnitCircle을 통해 난수를 발생시켜 방향을 바꿔주었다.
작아서 보이지는 않지만 나무 조각이 바닥으로 떨어진다.
마무리
시스템을 만들면서 유니티가 게임을 만들기 간편하다고 느꼈다.
기능을 컴포넌트로 구성할 수 있어 재활용이 무척 쉬웠다.
또한, 상속구조도 활용할 수 있기 때문에 더욱 편했다.
즉, 모듈화가 굉장히 잘 지원되고 활용하기 쉽다고 생각했다.
언리얼에서도 GAS나 Lyra등에서 모듈화를 강조하는 이유를 알 수 있었다.
프로젝트 중, UI를 표시하는 부분에서 시간이 조금 걸렸다.
Screen Space - Camera는 무조건 자신보다 앞에 있는 오브젝트 위에 출력할 수 없다는 점을 몰랐다.
Z값을 조정하면 오브젝트 위에 출력할 수 있다고 생각했지만, Canvas위에 있는 UI는 Z값이 어떻게 되는 Canvas에 붙어있다는 사실을 인지해야 한다.