- 구글에서 제공하는 통합 플랫폼 서비스. 인증 / 서버 / 저장소 / DB 등 앱,웹 개발에 필요한 인프라 전반을 제공해 줌과 동시에 수익창출/ 사용 및 크래시 통계 / 테스트 등의 품질 관리 도구를 제공해주는 플랫폼입니다.
개발을 하다보면 인프라 / 환경 구축이 정말 귀찮은데 이것을 대신 해주는 것이죠. 개발자들이 해야할 일은 코드 몇 줄로 사용하고자 하는 기능을 웹/앱에 연동시켜주고 쓰고자 하는 내용을 밀어넣는 것입니다.
(대표적인 예로 서버로 쓸 수 있는 Firebase Cloud Functions는 API로 사용할 함수만 작성하고 디플로이하면 바로 API로 활용 가능합니다.)
이러한 방식을 '서버리스' 방식이라고 합니다. 실제로 서버를 사용은 하지만 유저가 서버를 직접 관리(환경 구성, 확장 등)하지 않기 때문입니다. 특히 Firebase의 경우에는 백엔드 기능 전반을 서비스로 제공해주는 Baas(Backend as a Service) 중 하나입니다.
장점은 위에서 계속 말한바와 같이 인프라 전반의 환경 구성과 확장 등을 알아서 해주기 때문에 사용자는 그저 필요한 기능을 연동시켜 사용하면 된다는 점입니다.
그리고 기본적인 기능은 다 제공되는 점도 장점이라 할만 할 것 같습니다. 복잡하지 않은 서버가 필요한 상황이라면 충분히 활용 가능할 것입니다. (복잡한 기능도 비용이 많이 들뿐 사용 가능할 것 같습니다.)
단점
단점으로는 관리를 개발자가 하지 못하기 때문에 제공되는 기능과 사용방법은 제한적일 수 밖에 없습니다. Firebase가 제공하는 서버 기능만 사용할 수 있기도 하고, 복잡한 기능이나 쿼리를 구현하기 위해서는 추가적인 교육 비용이 많이 들 수 있습니다. DB만을 봐도 RDB가 아닌 NoSQL DB만을 제공합니다. 그렇기 때문에 RDB에 익숙하신 분에게는 따로 공부가 필요할 수 있습니다. (다행히 나름의 쿼리를 제공하긴 합니다.)
Entity - Component - System 의 줄임말로, 각 용어에 대해 간단히 설명하자면 아래와 같습니다.
Component - 데이터 집합
요소를 이루는 데이터 집합
ex
position - 위치의 데이터만 가짐 (x,y,z)
rotation - 방향의 데이터만 가짐 (x,y,z)
Entity - 컴포넌트 집합
하나의 오브젝트를 이루는 데이터 집합의 집합
ex
character - position, rotation 컴포넌트를 가짐.
System - 데이터들을 가공하는 작업 ( method 개념)
ex
MoveSystem - position, rotation 컴포넌트를 가진 엔티티들을 대상으로 키 입력시 position의 데이터를 변경하기만 한다.
기존의 객체 지향 개념(OOP) 이 아닌 데이터 지향 방식(DOD)의 개념으로, 데이터 지향은 저장되는 데이터의 위치를 집적시켜 캐시 히트율을 높여 속도를 끌어올리는 목적의 개념으로 알고있습니다.
* 데이터 지향 부가 설명 및 운영체제 지식
- 프로그램에서 데이터 필요시 CPU 캐시공간을 먼저 검사하고, 거기 없으면 메모리(램)에서 해당 데이터를 가져와서 사용합니다. (메모리에서 가져올 때는 필요 데이터 주변의 데이터를 뭉태기로 캐시에 가져옵니다. 그 주변 데이터를 다음에 사용할 확률이 크기때문이죠) 이때 CPU 캐시에서 바로 가져오느냐, 메모리에서 가져와서 사용하느냐의 속도차이는 아마 몇 십배일거에요. 즉, 캐시에 미리 사용할 데이터가 없을 수록 속도는 몇십배씩 느려진다는 거죠. 그래서 데이터 지향 방식은 데이터들을 한 곳에 몰아 넣어서 캐시 적중률을 늘려 속도를 올릴 수 있는거죠. (객체 지향은 객체에 따라 데이터가 위치가 분산될겁니다.)
Unity Scripting 방법
* 현재 unity에서 제공하는 방식은 3가지 입니다.
Classic ( 현재 일반적으로 사용하는 컴포넌트 방식입니다.)
Hybrid ECS
Component + ECS 방식입니다.
에디터상의 작업은 기존의 Classic 방식을 따르고, 코딩은 ECS 방식으로 하는 형식입니다. 아마 ECS의 진입장벽을 낮추고, ECS의 속도면 이점도 어느정도 갖추기 위해 만들어졌을거 같네요.
Pure ECS
완전한 ECS 방식입니다.
기존의 Classic 방식과는 객체 생성및 등록 방식이 확 바뀝니다. 거의 모든 걸 스크립트상에서 처리하기에 진입장벽이 많이 높습니다.
장점 & 단점
장점
재사용성이 높다. / 상호 의존도가 낮다
위 부분은 객체지향의 장점에도 포함되지만 개인적으로 사용 경험상, 데이터 지향 방식이 더 활용도가 높다고 느껴집니다.
데이터 지향의 시스템은 자기가 필요한 엔티티 집합을 검색해 로직을 돌리는 방식이고, 엔티티는 시스템의 검색에 찾아지도록 필요 컴포넌트들을 포함시켜 만들어진 집합입니다. 즉, 특정 엔티티가 특정 시스템에 돌아가게 하고싶다면, 시스템이 필요로하는 컴포넌트만 엔티티에 추가만 하면 끝인거죠.
이런 방식으로 시스템과 컴포넌트를 구성해놓으면 엔티티는 자기가 원하는 것만 골라 재사용하기 용이하게 느껴집니다.
그리고 실제 설계 / 코딩을 할 때도 필요한 컴포넌트를 개별로 시스템에서 가져다 사용하기때문에 다른 시스템/ 엔티티에 영향도가 적습니다. (상호 의존성 낮음)
유지보수가 용이하다.
위의 장점과 일맥상통합니다.
시스템 별로 필요 엔티티만 집약되어있기에 시스템별로 찾고 고치기가 쉽습니다.
실행 속도가 빠르다
네, 엄청 빠릅니다. (이건 다음 포스팅에서 검증하도록 하겠습니다)
단점
진입장벽이 많이 크다.
Pure가 특히 높습니다. Hybrid는 기존의 방식을 어느정도 쓰기때문에 사실 ECS 개념만 좀 숙달되면 금방하는데, Pure는 스크립트로 거의 모든 걸 진행하여 에디터 UI상의 이점을 많이 뺏깁니다.
디버깅이 힘들다.
Pure의 단점으로, 위의 단점과 일맥상통합니다.
Pure는 에디터상의 하이러키창에 오브젝트가 안나옵니다. 그래서 하이러키창을 자주 활용하여 디버깅하시는 분은 꽤 당황스러울 수 있습니다.
베타 버전 (개인적으로 최고 단점으로 뽑습니다.)
Unity에서 기존에 제공하던 컴포넌트들을 직접 구현해야 할 수 있습니다.
일부 컴포넌트가 ECS형태로 아직 제공이 안됩니다 ㅠㅠ 자주 사용하는 Physics, animation관련 컴포넌트도 없는게 꽤 있습니다.
저는 아직 Pure ECS는 많이 어색하고 어렵더군요. 그래서 앞으로의 포스팅은 Hybrid ECS 위주로 진행하겠습니다. 애초에 개인 프로젝트도 Hybrid ECS를 이용해 만들기도 했거든요.
다음 포스팅은 유니티에서 제공해주는 샘플 프로젝트를 이용해 실험해본 간단한 성능 벤치마크를 하도록 하겠습니다.
(초록색 캐릭은 Gym 시설을 사용하는 마을 캐릭터고, 일반 캐릭은 Sell시설을 사용하는 관광객입니다.)
음... 원래 시설이 캐릭터가 시설을 사용할 때의 애니메이션 인덱스를 가지고 캐릭터가 사용할 때 지정을 해줘야하는데... 현재 만들어진 애니메이션이 없어서 진행하지 않았습니다.
일단 이전 포스팅들에 비해 약간 변경이 된 점이 시설 설치시 시설이 너비탐색을 통해 자신의 로드맵을 생성합니다. 그리고 캐릭터는 목표 시설의 로드맵을 보고 이동하게 됩니다. ( 사실 길찾기의 경우 다양한 길찾기 방법을 시도해 보면서 공부하려고 생각 중입니다. 그래서 초창기에 별도로 정해두지 않았습니다.)
캐릭터의 State는 Idle, Move, Job으로 나뉘어집니다. 최초에는 Idle 상태로 랜덤한 방향으로 서성거리다가 목표 시설(Fame 순으로 빈 시설 탐색) 발견시 Move 상태로 변경되어 목표시설로 이동합니다. 시설에 들어가는 순간 Job 상태가 되고, 시설을 나오면 Idle로 전환됩니다.
Character
public abstract class Character : MonoBehaviour { CharacterState state ; int money = 150; int maxHp = 100; int hp; int str = 100; int spd = 100; Facility useFacility; protected TileController tileController; protected FacilityController facilityController; Tile currentTile; Tile destTile; Tile nextTile; protected Facility beforeFacility; public Animator anim; public string textPoolName = "TextPool"; void Start() { tileController = GameObject.Find("TileController").GetComponent<TileController>(); facilityController = GameObject.Find("FacilityController").GetComponent<FacilityController>(); hp = maxHp; anim = GetComponent<Animator>(); state = CharacterState.stateIdle; currentTile = tileController.getTile(0, 0); nextTile = state.FindNext(this, tileController); } void Update() { if (state.Move(this, nextTile)) { currentTile = nextTile; state = state.CheckTile(this); nextTile = state.FindNext(this, tileController); } } public void setDestTile(Tile _dest) { destTile = _dest; } public Tile getDestTile() { return destTile; } public Tile getCurrentTile() { return currentTile; } public void addHp(int _hp) { hp = Mathf.Min(maxHp, hp + _hp); ShowMoveText("Hp " + _hp); } public bool isFullHp() { return hp >= maxHp; } public int getHp() { return hp; } public void addMoney(int _moeny) { money += _moeny; ShowMoveText("Price " + _moeny); } public bool isEmptyMoney(int price) { return money-price >= 0; } public int getMoney() { return money; } public void addStr(int _str) { str += _str; ShowMoveText("Strong " + _str); } public int getStr() { return str; } public void addSpd(int _spd) { spd += _spd; ShowMoveText("Speed " + _spd); } public int getSpd() { return spd; } // 건물에 들어가 애니메이션 동작. (건물별로 애니메이션 번호) public void EnterFacility(Facility _facility) { beforeFacility = _facility; useFacility = _facility; useFacility.EnterFacility(this); state = CharacterState.stateJob; StartCoroutine(UseFacilityInTime(useFacility.getUseTime())); } IEnumerator UseFacilityInTime(float time) { yield return new WaitForSeconds(time); while (useFacility.InteractWithCharacter(this)) { yield return new WaitForSeconds(time); } ExitFacility(); } public void ExitFacility() { useFacility.ExitFacility(); useFacility = new NullFacility(); state = CharacterState.stateIdle; nextTile = state.FindNext(this, tileController); } private void ShowMoveText(string _text) { MoveText textObj = PoolManager.Instance.getObject(textPoolName,transform).GetComponent<MoveText>(); textObj.setTextMessage(_text); textObj.transform.position = transform.position + new Vector3(0, 0, -1); textObj.gameObject.SetActive(true); } public abstract Tile FindDestination(); }
어우 길어... 외부에서 접근하기 위한 함수들 Setter,Getter는 빼고 설명하겠습니다.
Update - 대부분의 로직은 State에 위임. 기본 동작은 이동 -> 상태 유지체크 -> 다음 이동 타일 확인입니다. 자세한 건 CharacterState에서...
EnterFacility - 건물 들어갈시 동작
UseFacilityInTime - 건물 사용 함수
ExitFacility - 건물 나갈시 동작
FindDestination - 각 캐릭터별로 빈 시설이 있는지 확인하여 리턴하는 함수.
public class TouristCharacter : Character { public override Tile FindDestination() { List<SellFacility> builds = facilityController.getSellFacility(); if (!isEmptyMoney(100)) { return tileController.getTile(0, 0); } foreach (Facility build in builds) { if (build == beforeFacility) { continue; } if (build.isEmpty()) return build.getPositionTile(); } return null; } } public class TownCharacter : Character { Facility homeFacility; public void setHomeFacility(Facility _home) { homeFacility = _home; } public override Tile FindDestination() { List<GymFacility> builds = facilityController.getGymFacility(); ; if (getHp() < 10) { return homeFacility.getPositionTile(); } foreach (Facility build in builds) { if (build == beforeFacility) { continue; } if (build.isEmpty()) return build.getPositionTile(); } return null; } }
상단이 관광객, 하단이 마을 주민입니다. 각 캐릭터들은 FindDestination을 상속받아 구현하고 있고, 관광객은 판매점을 빈 곳이 있는지 탐색하고, 돈이 부족하면 되돌아갑니다. 마을 주민은 운동시설을 빈 곳이 있는지 탐색하고, 체력이 부족하면 집으로 돌아갑니다. 단, 이전에 사용한 건물은 다른 건물을 사용하긴 전까지 사용하지 않습니다. 안그러면 혼자 독점할 것 같아서요.
CharacterState
public abstract class CharacterState { static public CharacterStateIdle stateIdle = new CharacterStateIdle(); static public CharacterStateJob stateJob = new CharacterStateJob(); static public CharacterStateMove stateMove = new CharacterStateMove(); protected bool isRangeOut(int x, int y) { return (x < 0) || (y < 0) || (x >= Constants.Width) || (y >= Constants.Height); } public abstract bool Move(Character _character,Tile dest); public abstract Tile FindNext(Character _character, TileController tileController); public abstract CharacterState CheckTile(Character _character); }
추상 클래스는 위와 같습니다. 어차피 구현될 상태 클래스들은 함수만을 가지고 매개변수로 전달된 character 클래스의 필드를 이용해 로직이 진행될 것입니다. 그래서 static으로 각 state 인스턴스를 미리 만들어두고 모든 캐릭터가 공용으로 쓸 예정입니다.
Move - dest 타일로 이동하는 함수입니다. ( 이동은 한 칸씩 합니다.)
FindNext - 다음에 이동할 dest 타일을 찾는 함수입니다.
CheckTile - State를 변경해야할지 체크할 함수입니다.
public class CharacterStateIdle : CharacterState { public override bool Move(Character _character, Tile dest) { if (dest == null) { return true; } Transform trans = _character.transform; Vector3 desPos = dest.GetTileObject().transform.position + new Vector3(0, 0, -1.5f); Vector3 direct = desPos - trans.position; trans.Translate(direct.normalized * 0.4f *Time.deltaTime); float diffPos = Vector3.Distance(desPos, trans.position); if (diffPos > 0.1f) return false; return true; } public override Tile FindNext(Character _character, TileController tileController) { IntVector2[] checkPos = new IntVector2[4] { new IntVector2(0,1),new IntVector2(1,0),new IntVector2(0,-1),new IntVector2(-1,0) }; int startIndex = Random.Range(0, 4); Tile currentTile = _character.getCurrentTile(); int checkX = currentTile.GetPosX() + checkPos[startIndex].x; int checkY = currentTile.GetPosY() + checkPos[startIndex].y; for (int i = 0; i < 4; i++) { int index = (startIndex + i) % 4; int tempX = currentTile.GetPosX() + checkPos[index].x; int tempY = currentTile.GetPosY() + checkPos[index].y; if (isRangeOut(tempX,tempY)) { continue; } if (tileController.getTile(tempX, tempY).getFacility().isNull()) { _character.anim.SetFloat("PosX", checkPos[index].x); _character.anim.SetFloat("PosY", checkPos[index].y); return tileController.getTile(tempX, tempY); } checkX = tempX; checkY = tempY; } //사방이 건물로 막혔을 경우엔 마지막 체크지점을 반환. return tileController.getTile(checkX, checkY); } public override CharacterState CheckTile(Character _character) { Tile dest = _character.FindDestination(); if(dest != null) { _character.setDestTile(dest); return CharacterState.stateMove; } return CharacterState.stateIdle; } }
서성거리는 상태인 Idle입니다. 길을 랜덤한 방향으로 이동하면서, CheckTile을 통해서 빈 시설을 찾으면 Move 상태로 변경됩니다.
public class CharacterStateMove : CharacterState { public override bool Move(Character _character, Tile dest) { Transform trans = _character.transform; Vector3 desPos = dest.GetTileObject().transform.position + new Vector3(0, 0, -1.5f); Vector3 direct = desPos - trans.position; trans.Translate(direct.normalized * 0.4f * Time.deltaTime); float diffPos = Vector3.Distance(desPos, trans.position); if (diffPos > 0.1f) return false; if(dest == _character.getDestTile()) { if (!dest.getFacility().isNull()) { if (dest.getFacility().isEmpty()) { _character.EnterFacility(dest.getFacility()); } } } return true; } public override Tile FindNext(Character _character, TileController tileController) { IntVector2[] checkPos = new IntVector2[4] { new IntVector2(0,1),new IntVector2(1,0),new IntVector2(0,-1),new IntVector2(-1,0) }; Tile currentTile = _character.getCurrentTile(); Tile destTile = _character.getDestTile(); int value = destTile.getFacility().GetPathValue(currentTile.GetPosX(), currentTile.GetPosY()); int checkX = currentTile.GetPosX() + checkPos[0].x; int checkY = currentTile.GetPosY() + checkPos[0].y; for (int i = 0; i < 4; i++) { int tempX = currentTile.GetPosX() + checkPos[i].x; int tempY = currentTile.GetPosY() + checkPos[i].y; if (isRangeOut(tempX,tempY)) { continue; } if(destTile.getFacility().GetPathValue(tempX, tempY) == value-1) { _character.anim.SetFloat("PosX", checkPos[i].x); _character.anim.SetFloat("PosY", checkPos[i].y); return tileController.getTile(tempX, tempY); } //그 어디에도 v-1구간이 없다면 가장 작은 값을 가진 타일로 이동한다. if (destTile.getFacility().GetPathValue(tempX, tempY) < destTile.getFacility().GetPathValue(checkX, checkY)) { checkX = tempX; checkY = tempY; } } return tileController.getTile(checkX, checkY); } public override CharacterState CheckTile(Character _character) { Tile dest = _character.getDestTile(); if (dest.getFacility().isEmpty() && !dest.getFacility().isNull()) { return CharacterState.stateMove; } return CharacterState.stateIdle; } }
목적지로 이동하는 Move상태입니다. 시설이 가지고 있는 로드맵을 따라 작은 값에 수렴하도록 이동합니다. 그리고 목적지에 도착한다면 시설에 들어갑니다. 목적지 시설이 파괴되거나 타인이 먼저 사용을하게 되면 다시 Idle 상태로 돌아갑니다.
public class CharacterStateJob : CharacterState { public override bool Move(Character _character, Tile dest) { return false; } public override Tile FindNext(Character _character, TileController tileController) { return null; } public override CharacterState CheckTile(Character _character) { return CharacterState.stateJob; } }
Job은 실제 건물에 들어간 상태인데....이건 뭐 Null이랑 다를바가 없습니다. 캐릭터의 능력치를 업하는 것은 건물이 캐릭터에게 부여해주는 형식이기때문에 별다른 행동은 하지 않고있습니다.
크게는 시설쪽에서 로드맵 구현이 있는데... 이 부분은 향후에 기능에 살을 붙여나가면서 다시 포스팅이 될 것 같으니 그때 다시 하도록 하겠습니다.
이제... 다음엔 DB 연동을 통해 시설물을 좀 더 풍부하게 해볼 예정입니다. (전 sqlite를 사용할 예정입니다.) 그 이후부터는 기본 기능에 대해 살을 붙여나가면서 리펙토리을 같이 겸하는 포스팅이 될 것 같습니다.