일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 애니메이션
- 모작
- pixel art
- 도트
- Pixelart
- Aseprite
- 스마일게이트
- 멋쟁이사자처럼
- 연습
- layer
- 인디게임 개발
- 포토샵
- 기초
- 반환원정대
- 드로잉 연습
- 장학팀
- menu
- 채색
- photoshop
- 개발
- 노하우
- 서포터즈
- 에이세프라이트
- 픽셀 아트
- 픽셀아트
- 도트공부
- COSMO
- 자원순환보증금관리센터
- TOOL
- 드로잉
- Today
- Total
소소한 나의 하루들
2d 종스크롤 슈팅(12) - 모바일 슈팅게임 만들기 본문
출처: https://youtube.com/playlist?list=PLO-mt5Iu5TeYI4dbYwWP8JqZMC9iuUIW2&feature=shared
#1. 무적 시간
플레이어가 한번 죽고 다시 리스폰될 때 무적 시간이 없다. 무적 시간은 2d 플랫포머 강좌에서도 한번 진행했었기 때문에 그것과 유사하게 하면 된다.
//Player
public bool isRespawnTime;
SpriteRenderer spriteRenderer;
private void Awake()
{
anime = GetComponent<Animator>();
spriteRenderer = GetComponent<SpriteRenderer>();
}
private void OnEnable()
{
StartCoroutine(UnbetableWithDelay(3.0f));
}
IEnumerator UnbetableWithDelay(float delay)
{
Unbetable();
yield return new WaitForSeconds(delay);
Unbetable();
}
void Unbetable()
{
isRespawnTime = !isRespawnTime;
if (isRespawnTime)
{
spriteRenderer.color = new Color(1, 1, 1, 0.5f);
}
else
{
spriteRenderer.color = new Color(1, 1, 1, 1);
}
}
Player 스크립트에서 무적 타임을 나타내는 bool형 플래그 변수를 생성한다. 그리고 이제는 OnEnable()을 활용해보도록 한다. 무적타임을 관리하는 함수(Unbetable())를 생성해서 여기서 로직을 작성해보도록 한다. [Unbetable : 무적의]
보통 이런 무적 타임에는 sprite를 투명하게 설정한다. 그러면 SpriteRenderer를 가져와서 알파 값만 조정해주면 되겠다.
: Sprite 색상 변경 참고
else if(collision.gameObject.tag == "Enemy" || collision.gameObject.tag == "EnemyBullet")
{
if (isRespawnTime)
return;
isRespawnTime을 적 총알이나 적에게 맞았을 때에도 활용한다.
: 변수를 활용하여 충돌 이벤트를 제한시킨다.
//Plyaer
public GameObject[] followers;
void Unbetable()
{
isRespawnTime = !isRespawnTime;
if (isRespawnTime)
{
spriteRenderer.color = new Color(1, 1, 1, 0.5f);
for (int index = 0; index < followers.Length; index++)
followers[index].GetComponent<SpriteRenderer>().color = new Color(1, 1, 1, 0.5f);
}
else
{
spriteRenderer.color = new Color(1, 1, 1, 1);
for (int index = 0; index < followers.Length; index++)
followers[index].GetComponent<SpriteRenderer>().color = new Color(1, 1, 1, 1);
}
}
실행해보고 문제가 없는 것을 확인했으면, follower 보조무기도 같이 투명해질 수 있게 해준다.
이제 실행하기 전에는 보스 로직 작업하기 전, 일반 적 비행기 생성 텍스트를 앞에 붙여넣고 저장해준다.
#2. 폭발 효과
지금까지 적 비행기를 잡았는데 그냥 없어지고 끝나서 밋밋하다. 그래서 폭발 이펙트를 만들어보도록 한다. 제공받은 에섯의 sprite에 대한 설정을 한다. (Sprite Mode는 Multiple, Pixels Per Unit은 24, Filter Mode는 Point (no filter), Compression은 None) Sprite Editor에서 Slice는 Grid By Cell Size (x24 y24 padding(여백) x1 y1) 꼭 RGB 클릭해서 확인한 다음에 Apply하고 창 나와서 Apply까지 해준다.
설정이 다 끝나고 Apply까지 마쳤으면 Scene에 Explosion 오브젝트를 생성한다. (sprite 하나를 끌어다 Scene에 배치) 애니메이션에 필요한 sprite를 다중선택해서 폭발 애니메이션을 생성한다. (Explosion)
Explosion 오브젝트의 애니메이터를 보면 기본 state 값(Entry)이 Explosion 애니메이션은 아니다. 기본 값은 아무것도 없어야한다. 따라서 빈 state(=Idle)를 하나 생성해주고, 이것을 Set as Layer Default State으로 기본 state로 설정해준다.
: 빈 State를 디폴트 상태로 설정
그리고 Any state를 Explosion state에 transition(화살표)를 연결시켜주고, 다시 Idle State(=Idle)에 연결시킨다. 매개변수로 Trigger 변수 하나를 만들어준다. (OnExplosion)
Any State에서 Explosion으로 이동할 때는 Exit Time은 체크 해제하고 Transition Duration은 0, 그리고 Trigger 변수는 컨디션에 추가해준다. 다시 Explosion에서 Idle로 이동할 때는 Exit Time은 체크하고 Transition Duration은 0, 컨디션은 추가하지 않는다.
= Idle 애니메이션은 아무것도 없으니까 한번 폭발하고 그 다음에는 안보이게 된다. 애니메이션 파일의 Loop Time은 체크해제한다.
: 애니메이터 설정
//Explosion
public class Explosion : MonoBehaviour
{
Animator anime;
void Awake()
{
anime = GetComponent<Animator>();
}
public void StartExplosion(string target)
{
anime.SetTrigger("OnExplosion");
switch (target)
{
case "S":
transform.localScale = Vector3.one * 0.7f;
break;
case "P":
case "M":
transform.localScale = Vector3.one * 1f;
break;
case "L":
transform.localScale = Vector3.one * 2f;
break;
case "B":
transform.localScale = Vector3.one * 3f;
break;
default:
Debug.Log("크기변화 없음");
break;
}
}
}
애니메이션 트리거를 위해 Explosion 스크립트를 생성한다. 그리고 Explosion 오브젝트에 스크립트 드래그해서 컴포넌트로 적용시켜준다.
StartExplosion()에 매개변수 target을 전달해서 터지는 것이 플레이어 뿐만 아니라 적 비행기의 크기에 따라서 애니메이션 크기도 같이 맞춰가는 것으로 한다.
: 비활성화되는 대상의 크기에 따라 스케일 변화를 주도록 작성
Vector.one은 (x,y,z)가 (1,1,1)로 구성된 벡터를 의미한다.
추가
//Explosion
private void OnEnable()
{
StartCoroutine(DisableWithDelay(2.0f));
}
IEnumerator DisableWithDelay(float delay)
{
yield return new WaitForSeconds(delay);
gameObject.SetActive(false);
}
애니메이션을 작동시킨 다음에 자기 스스로 Explosion 프리펩을 비활성화 시켜줘야한다.
이렇게 스크립트까지 작성됐으면, Explosion 오브젝트를 프리펩으로 저장하고, transform의 position은 (0,0,0)으로 초기화해준 뒤, 저장이 끝났으면 Scene의 Explosion 오브젝트는 삭제한다. 그리고 오브젝트 풀링에 등록한다. (ObjectManager에 로직 추가)
//GameManager
public void CallExplosion(Vector3 Pos, string type)
{
GameObject explosion = objectManager.MakeObj("Explosion");
Explosion explosionLogic = explosion.GetComponent<Explosion>();
explosion.transform.position = Pos;
explosionLogic.StartExplosion(type);
}
우리가 폭발 애니메이션을 사용할 때는 Player가 터질 때, 또는 Enemy가 터질 때이다. 그래서 GameManager에서 Explosion 프리펩을 생성하는 함수를 생성할 것인데, 폭발하는 대상의 위치, 타입 2가지 정보를 전달받아야한다. 그래서 Player 또는 Enemy가 터진 위치에 바로 Explosion 프리펩을 갖다 놓는다. 그리고 애니메이션을 작동시킨다.
//Player
else if(collision.gameObject.tag == "Enemy" || collision.gameObject.tag == "EnemyBullet")
{
if (isRespawnTime)
return;
if (isHit)
return;
isHit = true;
life--;
gameManager.UpdateLifeUI(life);
gameManager.CallExplosion(transform.position, "P"); ;
//Enemy
public GameManager gameManager;
public void OnHit(int damage)
{
if (health <= 0)
return;
health -= damage;
if (enemyName == "B")
{
anime.SetTrigger("OnHit");
}
else
{
spriteRenderer.sprite = sprites[1];
StartCoroutine(ReturnSpriteAfterDelay(0.1f));
}
if (health <= 0)
{
...
transform.rotation = Quaternion.identity;
gameObject.SetActive(false);
gameManager.CallExplosion(transform.position, enemyName);
}
}
그리고 GameManager에서 생성한 폭발 함수를 플레이어 죽음, 적 죽음에서 호출한다.
enemyLogic.gameManager = this;
Enemy 스크립트에서 GameManager를 안갖고 있다. 따라서 gameManager 변수를 생성해준다. 그리고 어차피 GameManager에서 적을 생성해주기 때문에 바로 초기화해줄 수 있다.
this : 클래스 자신을 가리키는 키워드
이제 실행하기 전, ObjectManager에 Explosion 프리펩을 참조시켜주고 실행한다.
실행해보니 적 비행기 / 플레이어 비행기가 터지고 잠시 sprite가 남았다가 비활성화되니까 처음에 오브젝트 sprite로 넣어준 이미지 값을 None으로 설정해준다.
#3. 모바일 컨트롤 UI
이제 모바일로 옮겨야 한다. 모바일 게임에는 보통 비주얼 버튼과 조이스틱이 있다. 우선 주어진 에셋의 sprite 아틀라스 설정을 한다. (Sprite Mode는 Multiple, Pixels Per Unit은 24, Filter Mode는 Point (no filter), Compression은 None)
Sprite Editor에서 Automatic으로 Slice해준다.
방향키
Scene의 UI와 실제 인게임 UI를 맞춰봐야하기 때문에 구도를 바꿔본다. Hierarchy창의 Canvas오브젝트의 Canvas 컴포넌트에서 Render Mode를 Screen Space - Overlay(기본값)에서 Screen Space - Camera로 변경한다. 그리고 Render할 카메라를 드래그하여 참조시켜준다.
→ Render Mode가 Overlay로 되어있으면 UI가 Screen창에 맞춰서 크게 보인다. 왜 이렇게 크게 보이냐면, 게임이랑 별개로 UI 구성만 따로 보고 싶을 때 설정하는 것이다. (기본값)
→ 하지만 게임 화면과 UI 화면을 같이 보고 싶을 때에는 Render Mode를 Screen Space - Camera로 설정한다. 그리고 참조할 Camera를 적용시켜준다.
이렇게 Screen Space - Camera로 설정해준 다음에는 Order in Layer 우선순위에 의해 UI가 오브젝트로 가려질 수 있어서 Canvas의 Order in Layer를 큰 값으로 설정해준다.
: UI를 가릴만한 오브젝트가 없을 정도로 큰 값으로 설정
Pixel Perfect 선택은 필요하다면 체크해준다.
Canvas 설정이 끝났으면, 이제 Image UI를 생성하고 (Joy Panel) 주어진 에셋 sprite를 적용시킨다.
이번 게임에서는 이전 탑다운 쯔꾸르 게임에서처럼 상/하/좌/우로만 움직이는 것이 아니라 대각선 방향으로도 움직이도록 한다. 참고
이미지에서 따로 표시는 없지만, 손가락이 대각선 구간을 누르고 있으면 대각선으로 움직일 수 있게 가로세로 3등분씩 나눈다. 크기를 적절히 설정해주고, Anchor를 shift+alt를 눌러서 좌측 하단으로 변경해준다. 위치는 적당하게 맞춰주고, 비행기를 가리지 않도록 알파값을 낮춰준다. :이미지 설정
아직 버튼은 아니다. 그래서 버튼으로 만들어주기 위해 Joy Panel UI 안에 Button UI를 생성한다. 그리고 버튼의 sprite 알파값을 0로 낮추고, 텍스트도 지운다. (이미지 안의 버튼은 이미지와 색상이 보이지 않게 설정)
크기는 Image Size의 1/9정도 크기(축 길이의 1/3)로 맞춰준다. (Image가 300*300이므로 Button은 100*100으로) 이 버튼을 총 9개까지 만들어줘서 Anchor 위치를 조정해주고 Image를 다 채워준다.
: 3등분으로 총 9개의 투명한 버튼 배치
이제 남은건 이벤트인데, OnClick() 이벤트는 아니다. 방향을 바꿀 때마다 손을 뗐다 눌렀다 반복해야해서 힘들다. 그래서 드래그형식으로 손으로 문지르면 버튼에 이벤트가 발생하도록 하고싶다.
그러면 어떤 이벤트가 필요할까 Button UI들을 전부 다중선택해서 Event Trigger 컴포넌트를 추가해준다. 참고
Add New Event Type을 클릭해서 PointerDown, PointerUp, PointerEneter 이벤트를 추가해준다.
PointerDown은 GetButtonDown()같이 누르는 순간에 활성화되는 이벤트
PointerUp은 떼는 순간에 활성화되는 이벤트
PointerEnter는 마우스가 해당 영역에 들어갔을 때 활성화되는 이벤트
여기에 하나씩 함수와 매핑을 시켜줘야한다.
//Player
public bool[] joyControl; //어떤 버튼을 눌렀나
public bool isControl; //버튼을 눌렀나
public void JoyPanel(int type)
{
for (int index = 0; index < 9; index++)
{
joyControl[index] = index == type;
}
}
public void JoyDown()
{
isControl = true;
}
public void JoyUp()
{
isControl= false;
}
방향 버튼에 대한 함수를 생성해준다. (JoyPanel()) 이 함수는 방향에 대한 int형 변수 type을 받는데 방향 버튼에 대한 bool형 변수를 추가한다.
1 | 2 | 3 |
4 | 5 | 6 |
7 | 8 | 9 |
만약 5번 버튼을 눌렀다면 인덱스 4만 true가 되고, 나머지는 false가 될 것이다.
joyControl[] = {false, false, false, false, true, false, false, false, false}
joyControl 배열로 어느 방향키를 눌렀는지 뗐는지 알 수 있다. 버튼 이벤트를 3개 만들었으니 똑같이 함수도 3개 만들었다. 이제 버튼에 함수를 매핑시켜주면 된다.
이렇게 버튼 하나에 매핑시켜주고, Copy Component해서 나머지 버튼들을 다중선택하고 Paste Component Value해주면 된다. 꼭 버튼 순서에 맞게 type 값을 기입해준다.
//Player
void Move()
{
//Keyboard Control Value
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
//Joy Control Value
if (joyControl[0]) { h = -1; v = 1; }
if (joyControl[1]) { h = 0; v = 1; }
if (joyControl[2]) { h = 1; v = 1; }
if (joyControl[3]) { h = -1; v = 0; }
if (joyControl[4]) { h = 0; v = 0; }
if (joyControl[5]) { h = 1; v = 0; }
if (joyControl[6]) { h = -1; v = -1; }
if (joyControl[7]) { h = 0; v = -1; }
if (joyControl[8]) { h = 1; v = -1; }
if ((isTouchRight && h == 1) || (isTouchLeft && h == -1))
h = 0;
if ((isTouchTop && v == 1) || (isTouchBottom && v == -1))
v = 0;
Vector3 curPos = transform.position;
Vector3 nextPos = new Vector3(h, v, 0) * speed * Time.deltaTime;
transform.position = curPos + nextPos;
if (Input.GetButtonDown("Horizontal") || Input.GetButtonUp("Horizontal"))
anime.SetInteger("Input", (int)h);
}
버튼 이벤트 끝났으면, 이제 적용해야한다. 방향 버튼 변수에 따라 수평, 수직 값을 적용한다. 어차피 경계면에서의 정지 처리는 밑에서 해주니까 로직 작성은 완료되었다.
실행 전 Player에서 Joy Control Size는 9로 설정해준다. 실행해서 마우스를 갖다 대기만 해도 그 방향으로 움직이는 것을 확인할 수 있다. 그런데 버튼을 눌렀을 때(누르고 드래그)에만 버튼이 작동해야한다. 버튼을 누르지 않은 상태에서 버튼이 작동하면 안된다.
그래서 Pointer Down과 Pointer Up을 만든 것이다.
예외 처리
if ((isTouchRight && h == 1) || (isTouchLeft && h == -1) || !isControl)
h = 0;
if ((isTouchTop && v == 1) || (isTouchBottom && v == -1) || !isControl)
v = 0;
isControl 되어있지 않으면 가만히 작동하지 않도록 한다. 그러니까 버튼 위에 마우스가 놓여있기만 해도 위치가 어디인지에 따라 true가 되지만, 버튼이 눌러져있어야 h와 v 값이 유지가 된다. (버튼이 안눌러져있으면 값이 0이 된다)
: 방향 Down 변수 조건을 추가하여 버튼 UI를 누른 상태에서만 작동할 수 있도록 한다.
그런데 방향키를 조작하는데 자동으로 총알을 발사하고 있다. 그 이유는 Fire1키(마우스 왼쪽버튼/왼쪽 ctrl)도 동시에 동작하고 있기 때문이다. 그래서 발사 키는 따로 만들어보도록 한다.
발사키
Button UI 2개를 추가하고 하나는 Button A, 다른 하나는 Button B로 한다. 이미지 자체에 텍스트가 그려져 있으니 텍스트는 필요없다. Anchor를 조절해서 오른쪽 아래에 적절히 배치하고, 보기좋게 여백도 넣어준다. 방향키 UI와 마찬가지로 투명도도 적당하게 낮춰준다.
: 공격 버튼, 필살기 폭탄 버튼을 우측 하단에 배치
이것도 이벤트를 위해 함수를 만들어준다.
//Player
public bool isButtonA;
public bool isButtonB;
public void ButtonADown()
{
isButtonA = true;
}
public void ButtonAUp()
{
isButtonA= false;
}
public void ButtonBDown()
{
isButtonB = true;
}
public void ButtonBUp()
{
isButtonB = false;
}
void Fire()
{
//if (!Input.GetButton("Fire1"))
//return;
if (!isButtonA)
return;
...
void Boom()
{
//if (!Input.GetButton("Fire2"))
//return;
if (!isButtonB)
return;
...
ButtonA는 공격, ButtonB는 필살기 폭탄인데, 폭탄 버튼은 누르자마자 바로 폭탄이 터지면 되므로 따로 ButtonBUp() 함수는 작성해주지 않아도 괜찮을 것 같다. 한번 실행해보니까 처음 폭탄을 터트린 이후에 폭탄을 먹으면 자동으로 터지는 문제가 있어서 ButtonBUp()도 작성해준다.
이런 버튼 UI는 보통 플래그 변수를 활용해서 로직을 작성하기 때문에, 플래그 변수를 만들어준다.
그리고 기존 공격 조건을 주석처리하고 공격조건을 플래그 변수로 변경해준다. 기존 폭탄 조건도 주석처리하고, 플래그 변수를 조건으로 추가해준다.
: 기존 공격, 폭탄 조건을 '키 입력'에서 플래그 변수로 변경
실행하기 전 버튼에 Event Trigger 컴포넌트로 이벤트를 추가해준 후, 함수를 매핑해준다.
#4. 스테이지 관리
현재 스테이지는 스테이지 구분이 없는 Stage 0이다. 앞에서 스테이지 구분이 없이 몹이 소환되는 패턴을 가진 Stage 0를 만들어보았다.
이제 이 시스템을 적극적으로 활용할 때가 됐다. Project창 Assets 폴더 아래 Resources 폴더 내에 있는 Stage 0 텍스트 파일을 복사해서 붙여넣어서 여러 개로 늘린다. 복붙하면 유니티에서 자동으로 넘버링된다.
이제 Stage 0은 삭제한다. (Stage 0부터 시작하는 곳은 없으므로) Stage 1과 Stage 2만 남겨둔다. 지금 스테이지 시스템이 없는 상태다. GameManager에서 스테이지 시스템을 관리해주도록 한다.
public class GameManager : MonoBehaviour
{
public int stage;
void ReadSpawnFile()
{
//변수 초기화
spawnList.Clear();
spawnIndex = 0;
spawnEnd = false;
//리스폰 파일 읽기
TextAsset textFile = Resources.Load("Stage " + stage.ToString()) as TextAsset;
StringReader stringReader = new StringReader(textFile.text);
while (stringReader != null)
{
string line = stringReader.ReadLine();
//Debug.Log(line);
if (line == null)
break;
//리스폰 데이터 생성
Spawn spawnData = new Spawn();
spawnData.delay = float.Parse(line.Split(',')[0]);
spawnData.type = line.Split(',')[1];
spawnData.point = int.Parse(line.Split(',')[2]);
spawnList.Add(spawnData);
}
//텍스트 파일 닫기
stringReader.Close();
//첫번째 스폰 딜레이 적용
nextSpawnDelay = spawnList[0].delay;
}
GameManager 스크립트 맨 위에서 스테이지 숫자 변수를 생성해준다. 그리고 이제 ReadSpawnFile()에서 "Stage 0"을 바꿔준다.
public int stage;
TextAsset textFile = Resources.Load("Stage " + stage.ToString()) as TextAsset;
int형 변수 stage를 Strig으로 형 변환해준다. (ToString()해주지 않아도 강제 형 변환된다.)
//GameManager
private void Awake()
{
spawnList = new List<Spawn>();
playerLogic = player.GetComponent<Player>();
enemyObjs = new string[] { "EnemyL", "EnemyM", "EnemyS", "EnemyB"};
ReadSpawnFile();
StageStart();
}
public void StageStart()
{
//Stage UI Load
//Enemy Spawn File Read
ReadSpawnFile();
//Fade In
}
public void StageEnd()
{
//Clear UI Load
//Stage Increament
stage++;
//FadeOut
//Player Reposition
이제 stage대로 파일을 불러올 것이다. 그런데 스테이지가 시작하는 부분과 끝나는 부분을 만들어줘야 스테이지를 계속 옮길 것이다.
스테이지를 시작할 때 ReadSpawnFile()을 실행해서 적을 불러오는 것도 좋은데, UI로 스테이지가 시작했고, 끝났음을 알려주는 것도 좋을 것이다. 그런데 스테이지가 시작하고 끝났을 때 페이드 인 아웃 효과를 주는 것이 더 깔끔하고 보기 좋을 것 같다.
페이드 인(Fade In) : 밝아지는 것
페이드 아웃(Fade Out) : 어두워지는 것
페이드 인 아웃에는 '암막'이 필요하다.
*보통 이런 시각적 효과는 로직 마지막에 작성해준다.
그리고 페이드 아웃을 하고 플레이어가 어디에 있을지 모르기 때문에 플레이어 위치를 다시 잡아준다.
우선 스테이지 시작과 종료를 알리는 Text UI를 생성한다. 그리고 Text UI에 애니메이션을 넣어본다.
UI에도 애니메이션을 넣을 수 있다.
이제 애니메이션을 통해서 Text UI가 나타났다 사라지는 것을 구현해보도록 한다. Animation 폴더 안에서 Animator Controller를 생성한다. 그리고 Animation도 같은 이름으로 생성한다. (Text)
: UI Text 효과를 위한 애니메이터 & 애니메이션 생성
Stage Start Text UI와 Stage Clear Text UI에 바로 Animator Controller를 드래그하여 컴포넌트로 적용시킨다. 그리고 Animator창을 열어서 Text 애니메이션을 드래그하여 State로 만든다. 그런데 이렇게 하면 바로 Text 애니메이션이 실행되기 때문에 빈 State(: Idle)를 생성해서 Set As Default Layer로 기본값으로 설정 (Entry에 연결) 해준다. 그리고 트리거 매개변수 On을 생성한다. (트리거 On이 활성화되면 텍스트가 나타나도록)
Any State에서 Text State으로 이동하는 화살표에는 Has Exit Time을 체크해제하고, Transition Duration을 0으로, 컨디션에는 트리거 변수 On을 추가해준다. Text State에서 Idle로 이동하는 화살표에는 Has Exit TIme은 체크해주고 Transition Duration은 0, 컨디션에는 아무것도 추가하지 않는다.
이렇게 기본적인 애니메이션 설정은 끝났다.
남은 것은 GameManager에서 Animator를 가지고 와서 On 트리거를 활성화시키고, Text 애니메이션을 작동시키면 된다.
//GameManager
public int stage;
public Animator stageAnime;
public Animator clearAnime;
private void Awake()
{
spawnList = new List<Spawn>();
playerLogic = player.GetComponent<Player>();
enemyObjs = new string[] { "EnemyL", "EnemyM", "EnemyS", "EnemyB"};
ReadSpawnFile();
StageStart();
}
public void StageStart()
{
//Stage UI Load
stageAnime.SetTrigger("On");
//Enemy Spawn File Read
ReadSpawnFile();
//Fade In
}
public void StageEnd()
{
//Clear UI Load
clearAnime.SetTrigger("On");
//Stage Increament
stage++;
//FadeOut
//Player Reposition
}
Animator 변수 2개를 생성해서 스테이지 시작할 때 / 스테이지가 끝날 때 트리거 변수 On을 호출한다.
아직 형식뿐인 이 애니메이션을 만들어줘야한다.
애니메이션 창을 켜서 Text 애니메이션에서 Add Property>Rect Transform에서 Scale을 활용해준다. 처음에는 Scale.x Scale.y Scale.z를 전부 0으로 설정한다. 0.3초 뒤에는 전부 1.2로 설정한다. 그리고 0.1초 뒤에는 key frame을 추가해서 전부 1로 설정해준다.
그리고 좀 더 길게 5초 뒤에 key frame을 추가하고, 여기서 다시 0.1초 뒤에는 전부 1.2로 조금 커지게 해주고, 약 5.2초 정도 뒤에 key frame을 추가하고 전부 0으로 알아서 사라지도록 해준다.
: 살짝 오버 스케일 프레임을 넣어서 튀는 애니메이션 연출
*적절하게 자연스러운 애니메이션 연출이 가능하도록 시간 조절
그래서 이 애니메이션이 적용되는 Stage Start Text UI와 Stage Clear Text UI의 Scale은 0으로 설정해준다. (안보이도록)
실행하기 전에 GameManager에 Text UI들을 드래그하여 참조시켜주고, Stage 값도 1로 입력한다.
그런데 실행해보면 스테이지 Text UI가 사라지다가 중간에 끊기고 없어지는 문제가 발견된다. 이건 Text 애니메이션에서 Idle 애니메이션으로 이동할 때 겹치는 부분만큼 손해를 본 것이다. 그러면 해당 Transition(화살표)의 타임라인 상단 핀을 조절해서 서로 겹치지 않도록 옮겨주면 된다. (트랜지션의 겹치는 부분이 최대한 없도록 조정)
이제 스테이지 정보를 Text UI에 반영해야한다.
//GameManager
private void Awake()
{
spawnList = new List<Spawn>();
playerLogic = player.GetComponent<Player>();
enemyObjs = new string[] { "EnemyL", "EnemyM", "EnemyS", "EnemyB"};
ReadSpawnFile();
StageStart();
}
public void StageStart()
{
//Stage UI Load
stageAnime.SetTrigger("On");
stageAnime.GetComponent<TextMeshProUGUI>().text = "Stage " + stage.ToString() + "\nStart!!";
clearAnime.GetComponent<TextMeshProUGUI>().text = "Stage " + stage.ToString() + "\nClear!!";
//Enemy Spawn File Read
ReadSpawnFile();
//Fade In
}
public void StageEnd()
{
//Clear UI Load
clearAnime.SetTrigger("On");
//Stage Increament
stage++;
//FadeOut
//Player Reposition
}
선언 후 Awake()에 초기화를 시켜주지 않아도, Awake()에서 호출되는 함수에서 (같은 오브젝트 안이라면) 바로 가져올 수 있다. 상단에 using TMPro;가 선언되어있어야 한다.
/n : 줄바꿈 기호
여기서 Stage Start Text와 Stage Clear Text 모두 문자열을 한번에 설정해준다.
이제 해야할 것은 페이드 인 아웃 효과 처리이다. 암막 처리를 해야한다. 주어진 에셋에서 Fade Black sprite를 Scene에 드래그하여 오브젝트로 생성한다.
sprite 크기는 보이는 인게임 스크린 화면 전체를 뒤덮을 수 있도록 설정해준다.
그리고 Fade Black 오브젝트에도 애니메이션을 추가한다. Animation 폴더 아래에 Animation Controller 파일(Fade)을 생성하고, Animation 파일은 2개(Fade In, Fade Out) 생성한다. Fade Animation Controller는 Fade Black 오브젝트에 컴포넌트로 적용시킨다.
애니메이터 창을 열어서 기본 Empty State 하나 생성해준다. (Idle) 그리고 생성한 Fade In Animator와 Fade Out Animator를 준비한다. Any State에서 Fade In state와 Fad Out state를 연결해준다.
지금은 Fade In Fade Out에서 다시 Idle로 연결하지 않고 그대로 두어도 된다.
트리거 매개변수 2개를 생성한다. (In, Out) 그리고 Any State에서 Fade In 애니메이션과 Fade Out 애니메이션에 연결되는 Transition 화살표 컨디션에 연결해준다.
//GameManager
public Animator fadeAnime;
private void Awake()
{
spawnList = new List<Spawn>();
playerLogic = player.GetComponent<Player>();
enemyObjs = new string[] { "EnemyL", "EnemyM", "EnemyS", "EnemyB"};
ReadSpawnFile();
StageStart();
}
public void StageStart()
{
//Stage UI Load
stageAnime.SetTrigger("On");
stageAnime.GetComponent<TextMeshProUGUI>().text = "Stage " + stage.ToString() + "\nStart!!";
clearAnime.GetComponent<TextMeshProUGUI>().text = "Stage " + stage.ToString() + "\nClear!!";
//Enemy Spawn File Read
ReadSpawnFile();
//Fade In
fadeAnime.SetTrigger("In");
}
public void StageEnd()
{
//Clear UI Load
clearAnime.SetTrigger("On");
//Stage Increament
stage++;
//FadeOut
fadeAnime.SetTrigger("Out");
//Player Reposition
}
이것도 GameManager에서 관리해준다.
그리고 애니메이션을 설정해준다. 우선 Fade Black 오브젝트 클릭하고 애니메이션 창을 연다.
Fade In부터 설정한다. Add Property에서 Sprite Renderer 클릭하고, Sprite Renderer.Material.Color를 선택한다. 알파값(alpha)만 0으로 설정해주면 점점 투명해지니까 점점 밝아지게 된다. Fade Out은 반대로 해주면 다시 어두워진다.
애니메이션이 반복되면 안되니까 Loop Time을 체크 해제하고, GameManager에는 Fade Black을 드래그하여 참조시킨다.
이제 스테이지가 끝났을 때 GameManager에서 플레이어 위치만 조정해주면 된다.
//GameManager
public Transform playerPos;
private void Awake()
{
spawnList = new List<Spawn>();
playerLogic = player.GetComponent<Player>();
enemyObjs = new string[] { "EnemyL", "EnemyM", "EnemyS", "EnemyB"};
ReadSpawnFile();
StageStart();
}
public void StageStart()
{
//Stage UI Load
stageAnime.SetTrigger("On");
stageAnime.GetComponent<TextMeshProUGUI>().text = "Stage " + stage.ToString() + "\nStart!!";
clearAnime.GetComponent<TextMeshProUGUI>().text = "Stage " + stage.ToString() + "\nClear!!";
//Enemy Spawn File Read
ReadSpawnFile();
//Fade In
fadeAnime.SetTrigger("In");
}
public void StageEnd()
{
//Clear UI Load
clearAnime.SetTrigger("On");
//Stage Increament
stage++;
//FadeOut
fadeAnime.SetTrigger("Out");
//Player Reposition
player.transform.position = playerPos.position;
}
Unity에서 GameObject의 Transform 컴포넌트를 transform이라는 문법으로 접근할 때는 이를 GetComponent<Transform>()으로 호출하는 것과 동일하다. 따라서 player.transform.position을 직접 대입하는 것은 GetComponent<Transform>()을 명시적으로 호출하지 않고도 Transform 컴포넌트에 접근하는 편리한 방법이다.
(사실 바로 다음에 좌표 만들어서 참조시킬 것이지만, 만약 참조시키지 않았다면 (0, 0, 0) 값을 갖고 대입되었을 것이다)
*위쪽의 TextMeshProUGUI와 비슷한 맥락인 것 같다.
그러면 원위치 잡고, 다음 스테이지 시작하면서 Fade In 애니메이션이 끝나면(밝아지면), 플레이어가 playerPos 좌표에 위치해있을 것이다.
플레이어 초기 위치를 잡을 빈 오브젝트를 생성한다. (Player Position) 참고
그리고 해당 오브젝트를 GameManager의 playerPos에 드래그하여 참조시킨다. 그러면 이제 StageStart()와 StageEnd()에 대한 로직은 전부 구현이 되었다.
이제 호출해야하는데, StageStart()는 Awake()에서 호출되고 있는데, StageEnd()는 보스까지 다 잡았을 때 호출되어야 한다.
//GameManager
private void Awake()
{
spawnList = new List<Spawn>();
playerLogic = player.GetComponent<Player>();
enemyObjs = new string[] { "EnemyL", "EnemyM", "EnemyS", "EnemyB"};
ReadSpawnFile();
StartCoroutine(StageStartWithDelay(0.2f));
}
IEnumerator StageStartWithDelay(float delay)
{
yield return new WaitForSeconds(delay);
//Stage UI Load
stageAnime.SetTrigger("On");
stageAnime.GetComponent<TextMeshProUGUI>().text = "Stage " + stage.ToString() + "\nStart!!";
clearAnime.GetComponent<TextMeshProUGUI>().text = "Stage " + stage.ToString() + "\nClear!!";
//Enemy Spawn File Read
ReadSpawnFile();
//Fade In
fadeAnime.SetTrigger("In");
}
public void StageEnd()
{
//Clear UI Load
clearAnime.SetTrigger("On");
//FadeOut
fadeAnime.SetTrigger("Out");
//Player Reposition
player.transform.position = playerPos.position;
//Stage Increament
stage++;
if (stage > 2)
StartCoroutine(GameOver(4.5f));
else
StartCoroutine(StageStartWithDelay(4.5f));
}
IEnumerator GameOver(float delay)
{
yield return new WaitForSeconds(delay);
gameOver.SetActive(false);
}
//Player
if(life == 0) {
gameManager.StartCoroutine(gameManager.GameOver(0.1f));
}
//Enemey
public void OnHit(int damage)
{
if (health <= 0)
return;
health -= damage;
if (enemyName == "B")
{
anime.SetTrigger("OnHit");
}
else
{
spriteRenderer.sprite = sprites[1];
StartCoroutine(ReturnSpriteAfterDelay(0.1f));
}
if (health <= 0)
{
Player playerLogic = player.GetComponent<Player>();
playerLogic.score += enemyScore;
//Random Ratio Item Drop
int ran = enemyName == "B" ? 0 : UnityEngine.Random.Range(0, 10);
if(ran < 5) //Nothing 0.5
{
Debug.Log("Not Item");
}
else if (ran < 8) //Coin 0.3
{
GameObject itemCoin = objectManager.MakeObj("ItemCoin");
itemCoin.transform.position = transform.position;
}
else if (ran < 9) //Power 0.1
{
GameObject itemPower = objectManager.MakeObj("ItemPower");
itemPower.transform.position = transform.position;
}
else if (ran < 10) //Boom 0.1
{
GameObject itemBoom = objectManager.MakeObj("ItemBoom");
itemBoom.transform.position = transform.position;
}
transform.rotation = Quaternion.identity;
gameObject.SetActive(false);
gameManager.CallExplosion(transform.position, enemyName);
//Boss Kill
if(enemyName == "B")
{
gameManager.StageEnd();
}
}
}
Enemy 스크립트의 OnHit()에서 health<=0인 조건 마지막에 gameManager의 StageEnd()를 호출해준다. 그리고, 다음 스테이지로 넘어가야한다.
: 스테이지가 끝나면 다음 스테이지를 시작하도록 함수 호출
그리고 시간차를 두고 코루틴으로 다음 스테이지가 시작할 수 있게 바꿔주고, StageEnd()에서는 맨 마지막에 stage 수가 증가하면서 시간 차를 두고 StageStart()를 호출한다. 오버로딩으로 작성 가능할지 모르겠다.
*코루틴으로 작성하고 시간 초를 0초로 주면 될 문제였다. (반환형/매개변수 타입이 같기 때문에 두 GameOver()는 오버로딩된 것이 아니라, 별도의 메소드로 취급)
//GameManager
public void StageEnd()
{
//Clear UI Load
clearAnime.SetTrigger("On");
//FadeOut
fadeAnime.SetTrigger("Out");
//Player Reposition
player.transform.position = playerPos.position;
//Stage Increament
stage++;
if (stage > 2)
StartCoroutine(GameOver(5.5f));
else
StartCoroutine(StageStartWithDelay(4.5f));
}
플레이어 health가 0가 될 때 GameOver되도록 해주었는데, 구현해놓은 스테이지를 넘기면 게임 오버로 재실행할 수 있도록 흐름을 작성해준다.
*게임 오버 텍스트 UI와 게임 오버 창이 동시에 뜨지 않도록 코루틴을 통해 시간차를 준다.
이렇게 로직을 다 작성했고, 테스트를 위해 보스를 빨리 잡아서 다음 스테이지로 넘어가야하니까 보스 체력도 낮춰주고, 스테이지 난이도도 조절해준다. [밸런스 조정]
*반드시 스테이지 맨 마지막에 보스가 중앙에서 나타나는 것으로 마무리되어야한다.
#5. 모바일
이제 게임을 모바일로 구동할 수 있도록 한다. 지금까지 했던 것들을 우선 전부 저장하고, File 메뉴에서 Build Settings창을 연다. 보통 기준은 pc 기준인데, 이것을 Android로 바꿔주고 Switch Platform을 해준다.
플랫폼이 바뀌면 Player Setting에 들어간다. (Edit>Project Settings>Player) 참고
Player Setting에서 모바일 버전으로 빌드하기 전 설정할 수 있는 여러 설정 옵션이 나오는데, 아이콘은 sprite 파일만 적용 가능하다.
이번에는 게임화면이 세로니까 Orientation에서 Potrait(초상화)로 변경한다.
그리고 안드로이드 경로는 Identification에서 Package Name으로 설정해줄 수 있다. (안드로이드 경로com.회사이름.어플이름)
버전은 업데이트 할때마다 새롭게 값을 갱신해주면 된다. (초기 버전은 1이나 1.1 등 초기값)
만약 실제로 어플리케이션을 마켓에 등록하려면 ILCPP(x64)로 변경한다. 가능하면 ARMv7, ARM64는 체크하여 64비트로 빌드하도록 설정한다. (최신 버전은 ARMv7에만 체크되어있는 것 같음)
이런저런 모바일 버전 빌드를 위한 설정이 끝나면, 빌드를 해준다.
※모바일 버전은 Build And Run을 바로 클릭해주면 안된다. (Windows는 바로 APK 파일을 실행 못하므로)
따라서 Build 버튼으로 APK 파일만 생성되도록 해준다.
문제상황 - 피드백
일반 적 비행기가 피격된 다음에 생성되는 같은 타입 적 비행기의 sprite가 피격 상태로 생성되는 문제
//Enemy
private void OnEnable()
{
switch (enemyName)
{
case "B":
health = 3000;
//Invoke("Stop", 2.0f);
StartCoroutine(StopAfterDelay(2.0f));
break;
case "L":
health = 40;
spriteRenderer.sprite = sprites[0];
break;
case "M":
health = 10;
spriteRenderer.sprite = sprites[0];
break;
case "S":
health = 5;
spriteRenderer.sprite = sprites[0];
break;
}
}
이것 역시 새로 생성할 때 health처럼 초기화해줘야하는 부분이기 때문에 OnEnable()에서 sprite를 초기화해준다.
실행했을 때 키보드 조작을 하면 플레이어가 움직이지 않는 문제
//Player
void Move()
{
//Keyboard Control Value
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
//Joy Control Value
if (joyControl[0] && isControl) { h = -1; v = 1; }
if (joyControl[1] && isControl) { h = 0; v = 1; }
if (joyControl[2] && isControl) { h = 1; v = 1; }
if (joyControl[3] && isControl) { h = -1; v = 0; }
if (joyControl[4] && isControl) { h = 0; v = 0; }
if (joyControl[5] && isControl) { h = 1; v = 0; }
if (joyControl[6] && isControl) { h = -1; v = -1; }
if (joyControl[7] && isControl) { h = 0; v = -1; }
if (joyControl[8] && isControl) { h = 1; v = -1; }
if ((isTouchRight && h == 1) || (isTouchLeft && h == -1))
h = 0;
if ((isTouchTop && v == 1) || (isTouchBottom && v == -1))
v = 0;
앞에서 if ((isTouchRight && h == 1) || (isTouchLeft && h == -1) || !isControl) { h = 0} 의 방식으로 UI 버튼을 '클릭'했을 때 UI 키 위에 놓여진 마우스 위치에 따른 h와 v 값이 유지가 되도록 했는데, 그렇게 하면 결국 UI 키 조작이 아닌 키보드 조작을 했을 때 !isControl이 true가 되어버려서 무조건 h와 v가 0이 된다. : 가만히 있는다.
따라서 UI 버튼 조작과 별도로 키보드 조작을 위해서는 위의 joyControl[] 조건에 isControl 조건도 추가하여 현재 버튼을 '클릭'하고 있는지 여부도 판단한다.
코루틴 사용 시 타입 관련 에러 메세지
같아보이는 IEnumerator인데, 아래는 IEnumerator<T> 타입이고, 위는 IEnumerator 타입이라서 아래쪽은 에러가 발생하는 것이다.
Severity Code Description Project File Line Suppression State
Error CS0305 Using the generic type 'IEnumerator<T>' requires 1 type arguments Assembly-CSharp
실제로 이런 에러 메세지가 나타난다.
따라서 IEnumerator<타입>으로 작성하거나, 그냥 IEnumerator으로 작성해줄 수 있게 구분해야한다.
코루틴 함수 다른 스크립트에서 호출하기
//GameManager
public IEnumerator GameOver(float delay)
{
yield return new WaitForSeconds(delay);
gameOver.SetActive(false);
}
//Player
if(life == 0) {
gameManager.StartCoroutine(gameManager.GameOver(0.1f));
}
이런 식으로 코루틴 함수를 다른 스크립트에서 호출하려면, StartCoroutine()함수로 코루틴 함수를 호출할 때 내부에서도 스크립트를 통해 접근해야한다.
'개발 > 유니티' 카테고리의 다른 글
2d 종스크롤 슈팅(11) - 탄막을 뿜어대는 보스 만들기 (0) | 2024.02.26 |
---|---|
2d 종스크롤 슈팅(10) - 따라다니는 보조무기 만들기 (0) | 2024.02.25 |
2d 종스크롤 슈팅(9) - 텍스트 파일을 이용한 커스텀 배치 구현 (0) | 2024.02.25 |
2d 종스크롤 슈팅(8) - 최적화의 기본, 오브젝트 풀링 (0) | 2024.02.22 |
2d 종스크롤 슈팅(7) - 원근감있는 무한 배경 만들기 (0) | 2024.02.21 |