깃헙주소
https://github.com/BYEONGRYEOL/Chuseok
GitHub - BYEONGRYEOL/Chuseok: 항해99
항해99. Contribute to BYEONGRYEOL/Chuseok development by creating an account on GitHub.
github.com
코육대 참여 페이지
https://hanghaeplus-coyukdae.oopy.io/
항해 플러스: 제1회 코육대
이번 추석, 굳어버린 코딩 근육을 깨울 코딩 육상 대회가 왔다!
hanghaeplus-coyukdae.oopy.io
(너무 대충 적은 것 같아서 10.04 14:00 에 조금 수정했습니다.)
항해99 17기를 백엔드코스로 참가중인 나는 연휴에 공부하는 시간이 붕 뜨는게 싫어서 참가했다.
6개의 주제 중 원하는 주제를 골라서 "배포"하라고 했지만 구현에 사용되는 플랫폼은 자유라고 써있길래, 웹으로 하면 좋겠지만.. 시간제한도 있는 마당에 안해본 웹 배포까지 확실히 할 자신이 없어서 2년전에 만졌던 유니티를 다시 주섬주섬..
처음엔 송편 터뜨리기 주제가 하고싶었는데, 게임이 너무 정적일 것 같아서 총알피하기 주제를 보면서 송편피하기는 어때? 라는 생각이 들어서 바로 진행했다. -> 결과적으로는 자유주제나 다름없을수도..
구현 의도]
1. 송편 피하기 게임의 목표를 오래 피하는 것으로 구현하기에는 모두가 그렇게 할 것 같아서 아쉬워서 최대한 피하려고했다.
2. 사실 맞절, 세배는 설날 키워드지만 우리집이 예전엔 추석에도 세배 비슷하게 했으니까 명절과 잘 맞지 않나 하며 추가했다.
3. 1 + 2의 결과로 탄막피하기 게임에 목적이 있어서 목적을 빨리 달성하는 것을 목표로 하면 어떨까? 하는 아이디어가 떠올라서 그렇게 했다.
결국은 게임을 플레이하는 입장에서 아래의 두 개의 목표를 달리하여 플레이하여 신선하게 느껴질 수 있으면 좋겠다고 생각했다.
1. 할머니와 맞절을 많이 하여 용돈을 많이 벌고 클리어
2. 용돈 최소 조건을 달성한 이후 가장 빠르게 집에 도달하며 클리어
이외에도 송편에 피격 이벤트발생시 바로 게임오버가 아니라 살이 찌는 등의 여러아이디어가 많았지만 시간제한 관계상 여기까지만 해놓고 구현했다.
작업 하면서 활용한 것 + 배운 것
추상화, 추상 클래스
유니티 에디터상에서 직접 컴포넌트와 OnClick등의 메서드를 연결하지않고 동적으로 UI컴포넌트를 찾아 메서드를 할당하는 UI관리 이벤트 핸들러의 활용
실제 캐릭터 움직임 구현, 충돌 처리에 버그가 생기지 않도록, 키입력은 Update에, 실제 움직임은 FixedUpdate에 구현하였다. 키입력, 게임 시간 계산, 게임 진행 등의 매니저 클래스 구현
abstract,이벤트 핸들러, UI_Base, 콜백 함수, 상태머신, 블렌드 트리(애니메이션), NavMeshAgent, 싱글턴 패턴, DontDestroyOnLoad, 확장메서드, enum
1. 씬 변경
먼저 유니티에서 씬을 로드할 때는 아래 코드를 활용해야한다.
SceneManager.LoadScene("로드할 씬 이름");
그러면 유니티 엔진 내에서 build Settings에 빌드할 씬으로 정의된 씬 들 중 해당하는 이름의 씬을 찾아 씬을 로드해준다.
이 때 문자열로 씬 이름을 적어서 이동하는 것은 오타가 날 여지도 있고 매번 씬 이름을 기억하거나 참조해야한다는 불편이 생긴다.
따라서 아래와 같이 Enum형식을 받아서 Enum값을 문자열로 변경하는 코드를 통해 개발 도중 Enum 값을 참조하여 쉽게 개발할 수 있도록 하는 씬 매니저 클래스를 활용했다.
public class MySceneManager
{
//현재 씬에는 SceneBase 스크립트를 상속받고있는 게임오브젝트가 한 개일 것임 ( 한 씬에 한 개만 생성하기로했거든)
// 그래서 그냥 씬 내에서 SceneBase type의 게임오브젝트를 받아오는 것으로 현재 무슨 씬인지 체크할 수 있다.
public SceneBase CurrentScene { get { return GameObject.FindObjectOfType<SceneBase>(); } }
// 씬을 변경하는 로직
public void LoadScene(Enums.Scene type)
{
//Enums 를 이용하여 씬의 로드를 구현
// 다른 씬을 로드하기전에는 꼭 현재 씬의 Clear 함수를 실행해야한다.
CurrentScene.Clear();
SceneManager.LoadScene(GetSceneName(type));
}
// Enums.Scene enum을 참조하여, 씬의 이름을 반환하는 함수.
string GetSceneName(Enums.Scene type)
{
string name = System.Enum.GetName(typeof(Enums.Scene), type);
return name;
}
}
씬 변경에 매개변수로 활용될 씬 enum은 위의 BuildSettings에 있는 씬들의 이름과 똑같이 정의한 후,
public enum Scene
{
SceneLoading,
SceneMainMenu,
SceneGame,
SceneEnd,
SceneFailed,
SceneHowToPlay
}
[활용 예시]
예를 들어 메인메뉴로 이동하고 싶은 경우에는 아래처럼 활용할 수 있다.
Managers.Scene.LoadScene(Enums.Scene.SceneMainMenu);
2. 동적 UI 이벤트 처리
먼저 모든 UI 클래스들의 부모 클래스인 UI_Base를 살펴보면,
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Reflection;
using UnityEngine.UI;
using System;
using UnityEngine.EventSystems;
using Isometric.Utility;
using TMPro;
namespace Isometric.UI
{
public class UI_Base : MonoBehaviour
{
// UnityEngine.Object는 유니티 엔진에서 모든 오브젝트와 컴포넌트들의 부모클래스, 그니까 조상이다.
protected Dictionary<Type, UnityEngine.Object[]> objects = new Dictionary<Type, UnityEngine.Object[]>();
protected void Bind<T>(Type type) where T : UnityEngine.Object
{
// Enum형식으로 받아온 Bind할 객체의 타입을 이용해 객체 이름배열로
string[] names = Enum.GetNames(type);
UnityEngine.Object[] temp_objects = new UnityEngine.Object[names.Length];
// objects 딕셔너리에 key는 타입, value는 타입별 오브젝트 빈 배열
objects.Add(typeof(T), temp_objects);
for (int i = 0; i < names.Length; i++)
{
// 해당하는 type에 맞게 (Ex: 게임오브젝트는 그냥 게임오브젝트, 버튼은 버튼 컴포넌트가 추가된채로,
// 아까 추가한 타입별 오브젝트 빈배열에 추가한다.
if (typeof(T) == typeof(GameObject))
{
temp_objects[i] = Util.FindChild(gameObject, names[i], true);
}
else
{
temp_objects[i] = Util.FindChild<T>(gameObject, names[i], true);
}
if (temp_objects[i] == null)
{
//Debug.Log($"Failed to bind :: {names[i]}");
}
}
}
//지정해주는 UIEvent에 맞게 동적으로 Event를 등록해주는 함수
public static void BindEvent(GameObject go, Action<PointerEventData> action, Enums.UIEvent type = Enums.UIEvent.Click)
{
UI_EventHandler myEvent = Util.GetOrAddComponent<UI_EventHandler>(go);
switch (type)
{
case Enums.UIEvent.Click:
myEvent.OnClickHandler -= action;
myEvent.OnClickHandler += action;
break;
case Enums.UIEvent.Drag:
myEvent.OnDragHandler -= action;
myEvent.OnDragHandler += action;
break;
case Enums.UIEvent.BeginDrag:
myEvent.OnBeginDragHandler -= action;
myEvent.OnBeginDragHandler += action;
break;
case Enums.UIEvent.EndDrag:
myEvent.OnEndDragHandler -= action;
myEvent.OnEndDragHandler += action;
break;
}
}
public virtual void Init()
{
}
protected T Get<T>(int index) where T : UnityEngine.Object
{
UnityEngine.Object[] temp_objects = null;
if (objects.TryGetValue(typeof(T), out temp_objects) == false)
{
return null;
}
return temp_objects[index] as T;
}
protected GameObject GetObject(int index) { return Get<GameObject>(index); }
protected TextMeshProUGUI GetText(int index) { return Get<TextMeshProUGUI>(index); }
protected Button GetButton(int index) { return Get<Button>(index); }
protected Image GetImage(int index) { return Get<Image>(index); }
}
}
Bind 함수는 objects Dictionary에 UI 컴포넌트 타입별(버튼, 텍스트 등)로 UI내에 존재하는 오브젝트들을 코드상에서 자동으로 불러오는 함수이다.
BindEvent는 Bind하여 코드상에 등록된 UI 컴포넌트에 원하는 콜백함수를 등록할 수 있도록 하는 코드로,
코드만 보면 감이 안올텐데, 실제 예시를 들어보면
[MainMenu UI]
UI_MainMenu라는 캔버스 안에 Play, Mute, HowToPlay 버튼들이 있고, 아래 사진에서 확인할 수 있듯이 해당 버튼들은 유니티 에디터 내에서는 이벤트 등록이 되어있지 않다.
이벤트 처리를 코드비하인드로 위의 UI_Base 클래스를 상속받는 UI_MainMenu 클래스에서 처리했다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using UnityEngine.EventSystems;
using Isometric.Data;
using Isometric.Utility;
using UnityEditor;
namespace Isometric.UI
{
public class UI_MainMenu : UI_Scene
{
// UI_MainMenu프리팹 안에 존재하는 컴포넌트 종류별로 이름을 정확히 똑같이 하여 enum에 기록,
enum Buttons
{
Btn_Mute,
Btn_Play,
Btn_HowToPlay
}
enum TextMeshPro
{
Tmpro_Label
}
private void Awake()
{
Init();
}
public override void Init()
{
Managers.Sound.Play("BGM", Enums.Sound.Bgm);
base.Init();
//prefab 산하에 있는 ui 컴포넌트들 등록
Bind<Button>(typeof(Buttons));
Bind<TextMeshProUGUI>(typeof(TextMeshPro));
//Text 지정
GetText((int)TextMeshPro.Tmpro_Label).GetComponent<TextMeshProUGUI>().text = "컴백홈";
// 게임 시작 버튼을 누르면
BindEvent(GetButton((int)Buttons.Btn_Play).gameObject, PointerEventData
=> {
Managers.Sound.Play("Button"); //효과음
Managers.Scene.LoadScene(Enums.Scene.SceneGame); // 씬 변경
GameManagerEX.Instance.ReStartGame(); // 초기화
});
BindEvent(GetButton((int)Buttons.Btn_Mute).gameObject, PointerEventData =>
{
Managers.Sound.MuteAll(); // 음소거
});
BindEvent(GetButton((int)Buttons.Btn_HowToPlay).gameObject, PointerEventData =>
{
Managers.Scene.LoadScene(Enums.Scene.SceneHowToPlay);
});
}
}
}
위의 UI_Base를 상속받는 UI_Scene을 상속받는 UI_MainMenu 클래스에서, 먼저 enum으로 버튼 컴포넌트의 이름을 UI프리팹에 있는 그대로 정의하고, UI_Base에 정의된 Bind 함수를 정의한 enum을 활용하여 코드에 등록한다(딕셔너리에 추가한다.)
Bind<Button>(typeof(Buttons));
그 후, 버튼이 눌릴 경우의 실행될 콜백 함수를 Action 매개변수에 넣어서 버튼을 누를경우 Action이 실행될 수 있도록 한다.
BindEvent( //BindEvent
GetButton((int)Buttons.Btn_Play).gameObject, // 내가 정의한 버튼들 중 Play 버튼 객체를 불러오고 BindEvent에 전달
PointerEventData
=> {
Managers.Sound.Play("Button"); //효과음
Managers.Scene.LoadScene(Enums.Scene.SceneGame); // 씬 변경
GameManagerEX.Instance.ReStartGame(); // 초기화
}
);
3. 오브젝트 풀링
먼저 오브젝트 풀링에 대한 설명은 아래 블로그에서 잘 이해할 수 있었다. 아래는 PoolManager 클래스
https://starlightbox.tistory.com/84
[Unity] 오브젝트 풀링 (Object pooling)
안녕하세요. 오늘은 오브젝트 풀링에 대해서 알아볼게요. 1. 왜 오브젝트 풀링을 써야 할까요? 유니티에서 오브젝트를 생성하기 위해서는 Instantiate를 사용하고 삭제할 때는 Destroy를 사용해요. 하
starlightbox.tistory.com
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Isometric.Utility;
namespace Isometric
{
public class PoolManager
{
class Pool
{
// 어떤 오브젝트를
public GameObject Original { get; private set; }
//어디에 담을건지
public Transform Root { get; set; }
//pooling 기법은 스택으로 관리
Stack<Poolable> poolStack = new Stack<Poolable>();
//초기화 시 original 오브젝트를 10개 생성
public void Init(GameObject original, int count = 10)
{
Original = original;
Root = new GameObject().transform;
Root.name = $"{original.name}_Pool";
for (int i=0; i<count; i++)
{
Push(Create());
}
}
// 게임오브젝트를 실제로 생성
Poolable Create()
{
GameObject go = Object.Instantiate<GameObject>(Original);
go.name = Original.name;
return go.GetOrAddComponent<Poolable>();
}
// 게임 씬에 있던 사용중인 게임오브젝트를 다시 Pool에 넣는 과정
public void Push(Poolable poolable)
{
if(poolable == null)
{
//Debug.Log(poolable + "is null");
return;
}
//DontDestroyOnLoad 상태인 Root 산하에 들어가니까 각각의 오브젝트도 DDOL이 된다.
poolable.transform.parent = Root;
poolable.gameObject.SetActive(false);
poolable.isUsing = false;
}
// Pool안에서 비활성화 되어있던 오브젝트를 parent 산하에 빼내기
public Poolable Pop(Transform parent = null)
{
Poolable poolable;
if (poolStack.Count > 0)
{
poolable = poolStack.Pop();
}
else
{
poolable = Create();
}
poolable.gameObject.SetActive(true);
if(parent == null)
{
//현재 씬 담당 오브젝트, 그러니까 @Scene 산하에 임시로 옴겨낸 다음
poolable.transform.parent = Managers.Scene.CurrentScene.transform;
}
// parent 를 null 로 설정해 DDOL이 아닌 그냥 씬에 부모오브젝트 없이 존재할 수 있음
// DontdestroyOnLoad에 있었다면, 먼저 DontdestroyOnLoad가 아닌 씬의 어던 오브젝트의 자식으로 붙였다가 떼야 dontdestroyonload가 해제된다.
poolable.transform.parent = parent;
poolable.isUsing = true;
return poolable;
}
}
// 풀 종류별 관리
Dictionary<string, Pool> poolDict = new Dictionary<string, Pool>();
Transform _root;
public void Init()
{
if(_root == null)
{
_root = new GameObject { name = "@Pool" }.transform;
Object.DontDestroyOnLoad(_root);
}
}
// 현재 개발자가 생성하기로 한 object가 pooling 가능하며, 아직 pool이 만들어지지 않은경우 Pool 생성
public void CreatePool(GameObject original, int count = 10)
{
Pool pool = new Pool();
pool.Init(original, count);
pool.Root.parent = _root;
poolDict.Add(original.name, pool);
}
// 게임 씬에 있다가 쓸모없어진 poolable 객체 다시 pool에 push
public void Push(Poolable poolable)
{
string name = poolable.gameObject.name;
if(poolDict.ContainsKey(name) == false)
{
//관리하고있는 root Pool 게임오브젝트 산하에 넣고싶은데 없을 때는 그냥 삭제
GameObject.Destroy(poolable.gameObject);
return;
}
poolDict[name].Push(poolable);
}
//게임씬에서 사용하기위해 필요한 object를 pop, 만약 그러한 pool이 존재하지 ㅇ낳았다면 pool을 생성한다.
public Poolable Pop(GameObject original, Transform parent = null)
{
if(poolDict.ContainsKey(original.name) == false)
{
CreatePool(original);
}
return poolDict[original.name].Pop(parent);
}
public GameObject GetOriginal(string name)
{
//Debug.Log(poolDict);
if (poolDict.ContainsKey(name))
{
if (poolDict[name].Original == null)
return null;
return poolDict[name].Original;
}
return null;
}
public void Clear()
{
foreach(Transform childPool in _root)
{
GameObject.Destroy(childPool.gameObject);
poolDict.Clear();
}
}
}
}
이 클래스에서 하고자하는 것은,
내 게임에서 계속 생성되어야 할 송편이 계속해서 생성되고 삭제되고, 가비지컬렉터가 이를 인지하고, 다시 생성하고, 하는 부분에서 메모리를 필요 이상으로 잡아먹지 않도록 사용자의 눈에는 삭제된것 처럼 보이지만, 오브젝트 풀(스택) 에 필요없어진 송편을 넣고 나중에 재생성되는 로직에서 송편 오브젝트를 생성하는 것이 아니라 오브젝트 풀에서 꺼내서 쓰려고 하는 것이다. 위의 PoolManager 클래스의 CreatePool 메서드, 그리고 Pool클래스가 어떤 역할을 할 수 있냐면,
내가 게임 리소스를 불러올 때 사용할 리소스 매니저에서,
리소스를 불러올때 -> (생성 -> 만약 오브젝트 풀에 있다면 풀에서 pop 하여 사용 만약 리소스에 해당하는 오브젝트 풀이 존재하지 않으며 풀링할 수 있는 오브젝트라면 오브젝트 풀 자체를 생성한 후 pop하여 사용)
불러온 리소스가 필요없어졌을 때 -> (삭제 -> 만약 해당 리소스가 풀이 만들어져있다면 비활성화시켜 풀에 Push하여 언제든 다시 뺄 수 있도록 함)
하여 개발하는 입장에서는 PoolManager의 존재를 모르더라도 마음껏 Load, Destroy할 수 있도록 해준다.
아래는 리소스 매니저 클래스 코드 전체
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Isometric
{
public class ResourceManager
{
// 만약 Load하려는게, Pooling되고있다면 해당 pool에서 꺼내오는게 무조건 효율적이므로 먼저 검사, 없다면 정말 Load한다.
public T Load<T>(string filepath) where T: Object
{
if(typeof(T) == typeof(GameObject))
{
string name = filepath;
int index = name.LastIndexOf("/");
// Debug.Log(name);
if (index > 0)
name = name.Substring(index + 1);
GameObject go = Managers.Pool.GetOriginal(name);
if (go != null)
return go as T;
}
return Resources.Load<T>(filepath);
}
//Instantiate 사용시, 어차피 게임오브젝트라서 생성한다는 의미로 Instantiate 라고 했음
//그러니까 다른 스크립트에서 사용 시 경로에 Prefab/ 를 추가할 필요가 없음
public GameObject Instantiate(string filepath, Transform parent = null)
{
//메모리에만 오브젝트를 들고있는 상태
GameObject original = Load<GameObject>($"Prefabs/{filepath}");
if (original == null)
{
Debug.Log($"Failed to load prefab : {filepath}");
return null;
}
// 실제 게임씬에 생성하는 부분
if(original.GetComponent<Poolable>() != null)
{
return Managers.Pool.Pop(original, parent).gameObject;
}
GameObject go = Object.Instantiate(original, parent);
// 이름에 (Clone) 붙는거 싫어서
go.name = original.name;
return go;
}
public void Destroy(GameObject go)
{
if(go == null)
{
//Debug.Log($"Failed to Destroy GameObject : {go.name}");
return;
}
Poolable poolable = go.GetComponent<Poolable>();
if(poolable != null)
{
Managers.Pool.Push(poolable);
return;
}
Object.Destroy(go);
}
}
}
Load, Instatiate(유니티의 오브젝트 생성 함수), Destroy 함수 모두 현재 오브젝트가 오브젝트풀과 관련이 있는지를 먼저 검사하여 풀링 가능한(Poolable 한) 객체라면
Instantiate -> pop
Destroy -> push
하는 것을 확인할 수 있다.
3-1. 송편 오브젝트 풀링
게임씬 내에 하나만 송편 매니저 클래스가 하나만 존재하도록 했다.
using Isometric.Utility;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespace Isometric
{
public class BulletManager : SingletonMonoBehaviour<BulletManager>
{
float[] bulletTypeProbability = { 0.1f, 0.3f, 0.6f, 1.0f }; // 송편 타입 확률표
// 송편 생성 위치
int minx = -24;
int maxx = 45;
int miny = -5;
int maxy = 40;
// 송편에 줄 스프라이트 미리 들고잇기
Sprite[] sprites;
Coroutine instantiateCo; // 송편생성코루틴
List<Bullet> bullets = new List<Bullet>(); // 현재 씬에 나와있는 송편들
int maxCount = 500; // 송편 최대 갯수
int nowCount = 0;
public int frequency = 3; // 송편 출현 빈도
public int difficulty = 1; // 난이도에 따라 더 빠르게 생성
public void Start()
{
InvokeRepeating("checkBulletVaild", 5f, 3f); // 맵에 잇는 송편이 boundary를 넘어갔는지 3초마다 검사하기
sprites = new Sprite[4];
for(int i = 0; i < 4; i++)
{
sprites[i] = Managers.Resource.Load<Sprite>("Sprites/Bullets/bulletType_" + (i + 1)); // Sprite 불러오기
}
}
public void Update()
{
// 현재 송편생성 코루틴이 실행중이지 않으며 최대 송편 개수를 넘지 않은 경우
if(instantiateCo == null && nowCount < maxCount)
{
// 생성빈도 + 난이도 만큼으로 0.0부터 1.0 난수를 나누어 그만큼만 대기하고 송편을 생성시킨다.
instantiateCo = StartCoroutine(InstantiateBullet(Managers.Random.getRandomFloat(1) / (frequency + difficulty) ));
}
}
IEnumerator InstantiateBullet(float seconds)
{
// 난수들
int bulletType = getRandomBulletType(); // 송편의 색 별로 속도와 생성확률이 다르다. 송편의 타입 결정하기
int dFlag = Managers.Random.getRandomInt(0, 4); // 랜덤 방향ㄴ
float speed = Managers.Random.getRandomFloat(3) + (4 - bulletType) * 1.5f; // 송편의 속도 로직
Vector3 direction = GetBulletRandomDirection(dFlag);
Vector3 position = GetBulletRandomPosition(dFlag);
yield return new WaitForSeconds(seconds); //대기 후
instantiateCo = null; // 바로 재생성되는 것을 막기 위해
GameObject go = Managers.Resource.Instantiate("InGame/Bullet"); // pool manager를 통해 생성 ( pop )
Bullet bullet = go.GetComponent<Bullet>(); // 생성된 게임오브젝트는 prefab에 Bullet 스크립트가 붙어잇다.
// bullet manager 딴에 생성된 송편 추가 및 현재 송편 개수 +1
bullets.Add(bullet);
nowCount++;
// 송편 초기화 및 움직이도록 IsAble true
bullet.InitBullet(speed, direction, position, sprites[bulletType]);
bullet.IsAble = true;
//Debug.Log("bullet 생성");
}
public int getRandomBulletType()
{
int bulletType = 0;
float probability = Managers.Random.getRandomFloat(1);
for(int type =0; type <4; type++)
{
// 확률표보다 작은 확률일때 통과
//현재 0.1, 0.3, 0.6, 1.0 4개의 요소를 가진 배열인데, 따라서
// 배열의 index 0에서 멈출 확률 0.1
// 배열의 index 1에서 멈출 확률 0.2
// 배열의 index 2에서 멈출 확률 0.3
// 배열의 index 3에서 멈출 확률 0.4
if (bulletTypeProbability[type] > probability)
{
bulletType = type;
break;
}
}
return bulletType;
}
public Vector3 GetBulletRandomPosition(int direction)
{
int x=0, y=0;
switch (direction)
{
case 0: // 송편의 이동방향이 상 이라면 random x, with miny ** 아래에서 생성되어 위를 향해 가야한다.
x = Managers.Random.getRandomInt(minx, maxx);
y = miny;
break;
case 1: // 하, random x, with maxy
x = Managers.Random.getRandomInt(minx, maxx);
y = maxy;
break;
case 2: // 좌 방향으로 이동해야 하므로 x = maxx
x = maxx;
y = Managers.Random.getRandomInt(miny, maxy);
break;
case 3:
x = minx;
y = Managers.Random.getRandomInt(miny, maxy);
break;
}
return new Vector3((float)x,(float)y);
}
public Vector3 GetBulletRandomDirection(int direction)
{
int x = Managers.Random.getRandomInt(-10, 10);
int y = Managers.Random.getRandomInt(5, 20);
int temp = 0;
// direction이 0인 경우 상 방향, 생략
switch (direction)
{
case 1:
y *= -1;
break;
case 2: // 좌
temp = x;
x = -1 * y;
y = temp;
break;
case 3:
temp = x;
x = y;
y = temp;
break;
}
// 정규화된 방향벡터
float r = (float)Math.Sqrt(x * x + y * y);
return new Vector3(x / r, y / r);
}
public void checkBulletVaild()
{
// 역순으로 진행해야 index 오류 안생김
for(int i = bullets.Count-1; i >=0; i--)
{
// 현재 송편 매니저가 들고있는 송편리스트의 각 요소(송편)들이 맵 안에 잇는지 체크
if (bullets[i].IsInMap() == false) // 맵 밖으로 나간 경우 pool stack에 push
{
Managers.Resource.Destroy(bullets[i].gameObject);
bullets.RemoveAt(i);
nowCount--;
}
}
}
}
}
초기화하면 여러가지 변수들의 값을 설정하고, 송편 타입별로 스프라이트를 지정하고
난수를 활용한 송편 생성함수 InstantiateBullet 코루틴을 매 프레임마다 검사하여 현재 맵에 존재하는 송편 수 등의 생성조건에 부합하면 일정 빈도로 송편생성 코루틴을 실행한다.
getBulletRandomType, GetBulletRandomPosition, GetBullet RandomDirection 모두 송편 생성될 수 있는 위치와 방향등을 고려하여 생성되고, checkBulletVaild함수를 통해 생성된 송편들이 이동하여 맵 밖으로 멀리 나갔는지를 검사한다.
아래는 송편이 생성되는 화면
4. 플레이어 이동
먼저 유니티가 지원하는 Update함수에서 매 프레임마다 입력을 처리한다.
모바일의 경우 모바일 터치패드(조이스틱)으로부터 계산된 방향을 8방향으로 변환하며,
윈도우의 경우 W A S D 키를 입력받아 좌우 방향 -1, 0, 1, 상하 방향 -1, 0, 1의 9가지 경우의 수로,
0,0 인경우는 정지, 나머지는 8방향으로 이동할수 있도록 방향을 정했다.
플레이어를 이동하는 게 끝이 아니고, 플레이어가 어디를 바라보고있는지(방향) 알고 나중에 사용
(사실 시간 압박으로 모바일은 빌드를 못했다.. 정말 딱 빌드만 못했다.)
void Update()
{
// 좀 아쉬움이 남는 코드, 버그는 없으나 너무 보기 싫게 생겼다.
// 8방향 이동을 구현함 모바일의 경우 모바일 터치패드로 부터 구한 방향으로 8방향 설정
if (Managers.Data.gameData.IsMobile)
{
if (mobileDirection.x == 0 && mobileDirection.y == 0)
{
originalDirection.x = 0;
originalDirection.y = 0;
}
//Debug.Log(mobileDirection);
else if(sin22 > mobileDirection.x && sinminus22 < mobileDirection.x)
{
// x가 sin -22.5 와 22.5 사이에 있다면 상 or 하
if(mobileDirection.y > 0) //상
{
interActionBox.bowDirection = 0;
originalDirection.x = 0;
originalDirection.y = 1;
}
else // 하
{
interActionBox.bowDirection = 4;
originalDirection.x = 0;
originalDirection.y = -1;
}
}
else if(sin22 > mobileDirection.y && sinminus22 < mobileDirection.y)
{
// y가 sin-22.5와 22.5 사이에 있다면 좌or 우
if(mobileDirection.x > 0) // 우
{
interActionBox.bowDirection = 2;
originalDirection.x = 1;
originalDirection.y = 0;
}
else // 좌
{
interActionBox.bowDirection = 6;
originalDirection.x = -1;
originalDirection.y = 0;
}
}
else
{
if (mobileDirection.x > 0 && mobileDirection.y > 0)
{
originalDirection.x = 1;
originalDirection.y = 1;
interActionBox.bowDirection = 1;
}
else if ( mobileDirection.x > 0 && mobileDirection.y < 0)
{
originalDirection.x = 1;
originalDirection.y = -1;
interActionBox.bowDirection = 3;
}
else if (mobileDirection.x < 0 && mobileDirection.y > 0)
{
originalDirection.x = -1;
originalDirection.y = 1;
interActionBox.bowDirection = 7;
}
else
{
originalDirection.x = -1;
originalDirection.y = -1;
interActionBox.bowDirection = 5;
}
}
}
else
{
// 모바일이 아닌 경우 8방향 설정하는 로직
if (Input.GetKey(KeyCode.W)) // 상
originalDirection.y = 1;
else if (Input.GetKey(KeyCode.S)) // 하
originalDirection.y = -1;
else
originalDirection.y = 0; // 좌 우 인경우 y방향은 0
if (Input.GetKey(KeyCode.D)) // 우
originalDirection.x = 1;
else if (Input.GetKey(KeyCode.A)) // 좌
originalDirection.x = -1;
else
originalDirection.x = 0; // 상 하 이동인경우 x방향은 0
}
// z축이 안맞아서 충돌하지않는 경우가 있다.
originalDirection.z = 0;
}
이렇게 계산된 originalDirection은 플레이어 이동, 상호작용하는 방향 등에 활용된다.
플레이어 이동의 경우 아래의 이동 함수가 실행될 때 정규화하여 이동 방향을 계산해준다.
private void myMove(float speed)
{
// rigidbody moveposition으로 속도와 방향 벡터로 캐릭터 이동
normalizedDirection = originalDirection.normalized;
velocity = normalizedDirection * speed;
myRigid2D.MovePosition(transform.position + velocity * Time.deltaTime);
}
이동 함수는 플레이어 상태가 Move, Run 상태일 때 실행되는데, 이는 플레이어 상태로 넘어가서 자세히 확인해보도록 하자
5. 플레이어 상태 머신
구현 의도는, 플레이어의 상태에 따른 애니메이션 재생과 상태에 따라서 가능, 불가능한 행동 등을 쉽게 구현하도록 하였다. 아래는 플레이어 상태머신의 기본이 되는 State setter 부분이다.
protected Enums.CharacterState state = Enums.CharacterState.Idle;
// 플레이어 상태 머신
public Enums.CharacterState State
{
get => state;
set
{
state = value;
Debug.Log(state);
switch (state)
{
//아무 조작도 없는 상태
case Enums.CharacterState.Idle:
ActivateAnimationLayer(Enums.AnimationLayer.IdleLayer);
myAnimator.SetBool("isIdle", true);
break;
// 이동
case Enums.CharacterState.Move:
ActivateAnimationLayer(Enums.AnimationLayer.WalkLayer);
break;
// 달리기 상태
case Enums.CharacterState.Run:
ActivateAnimationLayer(Enums.AnimationLayer.RunLayer);
break;
// 상호작용 (절) 상태
case Enums.CharacterState.Interaction:
ActivateAnimationLayer(Enums.AnimationLayer.BowLayer);
myAnimator.SetBool("isIdle", false);
myAnimator.SetBool("isBowing", true);
break;
}
}
}
ActivateAnimationLayer 함수와 animator의 변수를 조정하는 부분이 있는데,
플레이어의 애니메이션 컨트롤러는 위와같은 레이어를 가지고 있고
public void ActivateAnimationLayer(Enums.AnimationLayer layerName)
{
// 모든 레이어의 무게값을 0 으로 만들어 줍니다.
for (int i = 0; i < myAnimator.layerCount; i++)
{
myAnimator.SetLayerWeight(i, 0);
}
myAnimator.SetLayerWeight((int)layerName, 1);
}
이렇게 구현되어있어서 현재 상태에 해당하는 애니메이션이 재생될 수 있도록 한다.
또한 각각의 애니메이션 레이어는
블렌드 트리로 8방향의 각각의 애니메이션이 지정되어있고, 여기에 아까 플레이어이동에서 계산한 originalDirection을 활용한다!
먼저 현재 플레이어 상태에 따라 FixedUpdate함수 내에서 각각 상태에 해당하는 Update함수를 실행하여 매 프레이마다 호출되는 것이 보장되고,
private void FixedUpdate()
{
// 상태머신, 현재 상태일때는 매 프레임당 어떤 것을 검사하는지, 어떤 조건에 의해 다른 상태로 변하는지 구현
switch (state)
{
case Enums.CharacterState.Idle:
Update_Idle();
break;
case Enums.CharacterState.Move:
Update_Move();
break;
case Enums.CharacterState.Attack_1:
Update_Attack();
break;
case Enums.CharacterState.Run:
Update_Run();
break;
case Enums.CharacterState.Interaction:
Update_InterAction();
break;
case Enums.CharacterState.TakeDamage:
Update_TakeDamage();
break;
}
}
예를 들어 Update_Move 함수의 경우
protected void Update_Move()
{
myAnimator.SetFloat("x", normalizedDirection.x);
myAnimator.SetFloat("y", normalizedDirection.y);
myMove(moveSpeed);
if (normalizedDirection.sqrMagnitude == 0)
{
State = Enums.CharacterState.Idle;
}
}
이렇게 매 프레임 마다 animator의 x,y변수를 정규화된 플레이어 방향벡터 값으로 설정해주어
애니메이션 작업 간 이해하기 쉽고 버그가 발생하지 않도록 구현할 수 있었다.
6. 플레이어 상호작용
먼저 키보드 입력으로 K를 받으면 상호작용( 내 게임에서는 절하기)을 하도록 했는데, 자세히보면 Input 받는 곳이 Update함수 내에 있지 않고 OnKeyboard함수안에 있다.
이는 키 입력을 콜백 형식으로 받기 위함으로 구현은 플레이어 컨트롤러에 되어있지만, 입력받는 부분은 플레이어 컨트롤러 내에 Update함수에 두지 않고, InputManager 클래스에서 입력을 받도록 하게 되어있다.
사실 플레이어 이동 WASD키도 이렇게 하고싶었는데, 직접 Update 함수에서 받지 않고 콜백처리해서 이동을 받으니까 플레이어의 이동이 매끄럽지 않은 부분이 있었다.
아래는 플레이어컨트롤러의 OnKeyboard 함수와 InputManager 클래스이다.
private void OnKeyboard()
{
// GetKeyUp을 상단에 위치시켜야 눌렀다가 뗄 때 버그가 없다
if (Input.GetKeyUp(KeyCode.K))
StopInterAction();
if (Input.GetKeyDown(KeyCode.K))
InterAction();
if (Input.GetKeyUp(KeyCode.LeftShift))
RunningCancel();
if (Input.GetKey(KeyCode.LeftShift))
Running();
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using UnityEngine.EventSystems;
public class InputManager
{
public Action KeyAction = null;
public void OnUpdate()
{
if (Input.anyKey)
if(KeyAction != null)
KeyAction.Invoke();
if (EventSystem.current.IsPointerOverGameObject())
return;
}
public void Clear()
{
KeyAction = null;
}
}
InputManager의 KeyAction에 등록을 해주어야 InputManager 클래스의 OnUpdate 함수내에서 Input 처리를 해주는데,
이는 플레이어 컨트롤러의 초기화 함수에 정의되어잇다.
protected void Init()
{
Reset();
// 키보드 입력 액션 bind
Managers.Input.KeyAction -= OnKeyboard;
Managers.Input.KeyAction += OnKeyboard;
interActionDuration = 0.5f;
interActionBox.gameObject.SetActive(true);
myRigid2D = GetComponent<Rigidbody2D>();
Time.timeScale = 1f;
}
이렇게 입력받아서 플레이어 컨트롤러 클래스에서 실행되는 Interaction함수는 아래와 같이 구현되어있다.
public void InterAction()
{
// 현재 상태를 상호작용 중으로 변경하고,
State = Enums.CharacterState.Interaction;
//Debug.Log("절");
InterActionBox_Location_Set(); // 할머니 검사 레이어 위치 조정
InterAction_Activate(); // 상호작용 박스 활성화
}
상태머신의 상태를 Interaction으로 변경하고, 상호작용하여 레이어 or 태그 검사에 사용될 콜라이더의 위치를 변경시키고 활성화한다! 이렇게 하면 플레이어 상호작용 콜라이더가 충돌을 감지하게되는데,
상호작용 로직은 다음과 같다.
다른 콜라이더와 충돌한채로 유지(Stay)할 때,
현재 상호작용이 가능(Able = =true)하며, 현재 충돌중인 콜라이더가 입혀진 오브젝트의 레이어가 Grandma이며,
현재 충돌중인 오브젝트와 이미 상호작용 Coroutine이 실행되지 않은 상태일 경우에만 실행하며, 지정된 시간동안 Coroutine이 유지되고나서, 아직도 상호작용 가능 상태라면, Grandma 객체와 맞절을 한 것으로 인정된다.
Coroutine bowRoutine;
// Start is called before the first frame update
private void OnTriggerStay2D(Collider2D collision)
{
if (IsAble == false)
return;
if (collision.gameObject.layer == LayerMask.NameToLayer("Grandma"))
{
if(bowRoutine == null)
{
Grandma grandma = collision.GetComponent<Grandma>();
bowRoutine = StartCoroutine(Bowing(0.8f, grandma));
}
}
}
IEnumerator Bowing(float seconds, Grandma grandma)
{
Debug.Log("절 코루틴 진입");
yield return new WaitForSeconds(seconds);
// 위 시간동안 coroutine이 null이 되지 않았다면 인정
// 0.8초간 절 모션을 유지하고있어야 한다는 말이다.
Debug.Log("절 판정");
if(IsAble)
grandma.TryBow(bowDirection);
}
7. 여러가지 엔딩
송편을 피하고 플레이어가 집에 돌아가는데 성공하면, 아래와 같은 로직으로 엔딩 씬이 로드된다.
public class UI_End : UI_Scene
{
//메인메뉴 UI가 갖고있어야할 Item들 enum으로 미리 선언,
enum Buttons
{
Btn_MainMenu
}
enum GameObjects
{
TypingEffect
}
private void Awake()
{
Init();
}
public override void Init()
{
base.Init();
// UI산하에 버튼과 Tmpro가 있음을 Dictionary로 받아 알고있도록
Bind<Button>(typeof(Buttons));
Bind<GameObject>(typeof(GameObjects));
//Event 지정
BindEvent(GetButton((int)Buttons.Btn_MainMenu).gameObject, PointerEventData
=> {
Managers.Sound.Play("Button");
Managers.Scene.LoadScene(Enums.Scene.SceneMainMenu);
});
SetEndingText();
}
public void SetEndingText()
{
int money = GameManagerEX.Instance.Money;
string time = Math.Round(Managers.Time.PlayingTime, 1).ToString();
string moneyString = money.ToString();
string[] endingThings = {"과자", "치킨", "캐쉬나 더", "컴퓨터" };
int endingThingIndex = 3;
if(money < 300000)
{
Get<GameObject>((int)GameObjects.TypingEffect).GetComponent<TypingEffect>().TypingString =
"용돈을 30만원 받았는데 왜 " + moneyString + "원 밖에 없냐고 쫓겨났다.. \n" + time+"초 동안이나 쫓겨나 있어서 집에 가고싶어ㅠㅠㅠ";
return;
}
else
Get<GameObject>((int)GameObjects.TypingEffect).GetComponent<TypingEffect>().TypingString = "@3초만에 집에 돌아왔다! \n엄마는 내가 캐쉬를 환불한줄 안다.\n @1원 남았으니까 이걸로 @2 사야지~~";
if (money - 300000 >= 1000000)
endingThingIndex = 3;
else if (money - 300000 >= 100000)
endingThingIndex = 2;
else if (money - 300000 >= 20000)
endingThingIndex = 1;
else if (money - 300000 >= 10000)
endingThingIndex = 0;
Get<GameObject>((int)GameObjects.TypingEffect).GetComponent<TypingEffect>().TypingString.Replace("@1", moneyString);
Get<GameObject>((int)GameObjects.TypingEffect).GetComponent<TypingEffect>().TypingString.Replace("@2", endingThings[endingThingIndex]);
Get<GameObject>((int)GameObjects.TypingEffect).GetComponent<TypingEffect>().TypingString.Replace("@3", time);
}
}
게임에 등장하는 기록 요소 두 가지인 용돈, 진행된 시간으로 ening ui에 있는 text를 수정하여 내 게임의 기록을 볼 수 있도록 했으며, 약간의 이스터에그로, 종료될 때 남아있는 돈의 액수에 따라 다른 엔딩메세지를 출력할 수 있도록 했다.
소감
연휴동안 재밌었다.
아쉬운 점
1. 넣고싶은 기능을 시간 제한때문에 넣지 못한 점이 아쉬웠다.
진행 시간에 따라 난이도를 높이는 기능만 있지 플레이어가 얼마나 어떻게 어려워졌는지 인지하기 어렵다.
2. 크로스플랫폼 배포를 위한 틀이 잘 짜여져있는 건 아니지만 시간 제한 내에 배포를 윈도우밖에 못했다.
아무래도 빌드와 배포에 약한 나로서 가장 아쉬운 부분이 이거...
3. 유니티 엔진의 씬 전환시의 오브젝트 파괴 시간 등의 이슈로 싱글톤 디자인패턴을 활용하지 않았어야하는 부분에도 싱글톤 패턴을활용해서 코드가 멋지다는 생각은 들지 않는다!