지금까지 테스트하면서 목표물에 미사일이랑 총알을 신나게 날렸지만, 목표물은 건재했습니다.
이제 없애버릴 시간입니다.
플레이어를 포함해서 체력을 가지고, 데미지를 받을 수 있는 목표물은 모두 TargetObject
를 상속받도록 만들었습니다.
데미지를 받으면 체력이 깎이고, 체력이 0 이하가 되면 목표물은 파괴됩니다.
여기에 파괴되는 이펙트도 아마 오브젝트마다 다르겠죠.
일단 기본적인 시스템 구현부터 하겠습니다.
TargetObject.cs
public class TargetObject : MonoBehaviour
{
[SerializeField]
protected ObjectInfo objectInfo;
[SerializeField]
protected GameObject destroyEffect;
protected bool isEnemy;
protected float hp;
public bool isNextTarget;
public ObjectInfo Info
{
get
{
return objectInfo;
}
}
protected void CommonDestroyFunction()
{
GameObject obj = Instantiate(destroyEffect, transform.position, Quaternion.identity);
if(isEnemy == true)
{
GameManager.Instance?.RemoveEnemy(this);
GameManager.TargetController?.RemoveTargetUI(this);
GameManager.WeaponController?.ChangeTarget();
GameManager.PlayerAircraft.OnScore(objectInfo.Score);
}
}
public virtual void OnDamage(float damage)
{
hp -= damage;
if(hp <= 0)
{
DestroyObject();
}
}
protected virtual void DestroyObject()
{
CommonDestroyFunction();
Destroy(gameObject);
}
protected virtual void Start()
{
isEnemy = gameObject.layer != LayerMask.NameToLayer("Player");
if(isEnemy == true)
{
GameManager.TargetController.CreateTargetUI(this);
GameManager.Instance.AddEnemy(this);
}
hp = objectInfo.HP;
}
protected void OnDestroy()
{
}
}
TargetObject
는 상속받는 것을 염두로 하기 때문에, 거의 모든 변수와 함수를 protected (virtual)
로 두었습니다.
체력 데이터는 ScriptableObject
인 ObjectInfo
에 있습니다.
이 데이터를 가져와서 TargetObject.hp
에 대입해서 초기 체력을 설정해줍니다.
피격당할 때는 OnDamage()
를 호출하고, 자체 체력을 damage만큼 감소시킵니다.
체력이 0 이하로 떨어지면 DestroyObject()
를 호출합니다.
파괴 이펙트를 출력하고, GameManager
에서 관리하는 타겟 목록에서 삭제한 후 Destroy()
를 호출해서 오브젝트를 삭제합니다.
총알과 미사일에는 충돌한 오브젝트에게 데미지를 주는 기능을 추가해야 합니다.
TargetObject.OnDamage()를 호출해주면 되겠죠.
Missile.cs
[SerializeField]
float damage;
void OnCollisionEnter(Collision other)
{
other.gameObject.GetComponent<TargetObject>()?.OnDamage(damage);
Explode();
DisableMissile();
}
Bullet.cs
[SerializeField]
float damage;
void OnCollisionEnter(Collision other)
{
ObjectPool effectPool;
if(other.gameObject.layer == LayerMask.NameToLayer("Ground"))
{
effectPool = GameManager.Instance.groundHitEffectObjectPool;
}
else
{
effectPool = GameManager.Instance.bulletHitEffectObjectPool;
other.gameObject.GetComponent<TargetObject>()?.OnDamage(damage);
}
CreateHitEffect(effectPool);
DisableBullet();
}
미사일과 총알 코드의 OnCollisionEnter()
에서는 충돌한 대상인 Collision other
를 매개 변수로 얻어옵니다.
이 대상에 TargetObject
컴포넌트가 있으면, OnDamage()
를 실행합니다.
미사일과 총알에 Damage
값을 설정하고,
TargetObject
또는 상속받은 컴포넌트의 DestroyEffect
(파괴 이펙트)를 설정하고 실행합니다.
미사일 두 방을 맞고 사라져버렸습니다.
(체력은 좌측 상단의 디버그 텍스트에서 확인 가능합니다)
총알도 쏴보죠.
RPM이 너무 높아서 그런지 순삭되는 모습입니다.
데미지를 주고, 오브젝트가 파괴되는 것까지 확인했으니 목표를 달성했냐고요?
아뇨, 아직 허점이 많습니다.
보시다시피 총알로 파괴했을 때 목표물이 사라졌는데도 타겟 UI가 남아있잖아요.
게다가 잡았으면 점수가 올라가야 하는데, 올라가지도 않습니다.
목표물이 파괴되면 그 목표물을 가리키는 UI들이 모두 바뀌어야 합니다.
- 목표물 UI (정사각형 UI)
- 화살표 UI
- 미사일 락온 UI, 기총 UI
주변에 다른 목표물이 없으면 각각의 UI를 숨기면 되고,
다른 타게팅 가능한 목표물이 있으면 그 목표물을 가리키도록 UI를 설정해줘야 합니다.
TargetObject.cs
protected virtual void DestroyObject()
{
GameObject obj = Instantiate(destroyEffect, transform.position, Quaternion.identity);
if(isEnemy == true)
{
GameManager.Instance?.RemoveEnemy(this);
GameManager.TargetController?.RemoveTargetUI(this); // Test Only
GameManager.WeaponController?.ChangeTarget();
}
Destroy(gameObject);
}
목표물이 파괴될 때, 적군 오브젝트인 경우 플레이어의 타겟을 다음 목표물로 지정해주는 WeaponController.ChangeTarget()
을 실행합니다.
TargetController.RemoveTargetUI
는 화면 상에 보여지는 정사각형의 타겟 UI를 지우는 역할입니다. 같이 실행해줍니다.
아, 그리고 보스전에 사용할 비행기는 클래스를 따로 구현해줬는데요,
EnemyAircraft.cs
public class EnemyAircraft : TargetObject
{
[SerializeField]
float destroyDelay = 3;
[SerializeField]
float rotateSpeed;
[SerializeField]
float moveSpeed;
[SerializeField]
Transform smokeTransformParent;
protected override void DestroyObject()
{
CommonDestroyFunction();
Invoke("DelayedDestroy", destroyDelay);
}
void DelayedDestroy()
{
GameObject obj = Instantiate(destroyEffect, transform.position, Quaternion.identity);
obj.transform.localScale *= 3;
Destroy(gameObject);
}
public override void OnDamage(float damage)
{
base.OnDamage(damage);
for(int i = 0; i < smokeTransformParent.childCount; i++)
{
GameManager.Instance.CreateDamageSmokeEffect(smokeTransformParent.GetChild(i));
}
}
// Start is called before the first frame update
protected override void Start()
{
base.Start();
}
// Update is called once per frame
void Update()
{
transform.Rotate(new Vector3(0, rotateSpeed * Time.deltaTime, 0));
transform.Translate(new Vector3(0, 0, moveSpeed * Time.deltaTime));
GameManager.Instance.debugText.AddText(name + " HP : " + hp);
}
}
일단 DestroyObject()
는 TargetObject
와 비슷합니다.
대표적인 차이점으로 일반적인 오브젝트는 파괴 판정이 난 즉시 없애지만,
비행기는 파괴 판정 후 일정 거리를 그대로 날아가다가 2차 폭발과 함께 오브젝트를 없앱니다.
*출처: https://youtu.be/JQJpRwkXHj4 (여러 번 이 영상을 참조할 예정입니다.)
그 2차 폭발이 여러 번 일어나긴 하지만, 아무튼 바로 없애지는 않습니다.
그래서 바로 Destroy()
를 하지 않고, DelayedDestroy()
를 일정 딜레이 이후에 실행합니다.
OnDamage()
도 기능을 하나 추가했습니다.
비행기가 피격될 경우 미리 지정된 위치에 검은색 연기를 내뿜게 하는 기능입니다.
그리고 Update()
에서는 transform.Rotate
, transform.Translate
를 이용해서 매 초마다 약간씩 움직이도록 만들어줬습니다.
대충 1초에 5도, 거리는 30씩 이동하도록 설정해보겠습니다.
알아서 잘 가는 것 같죠?
미사일을 하나 먹여줘서 연기가 잘 나는지 테스트합시다.
(*현재 충돌 범위를 키워놓은 상태입니다.)
WeaponController.cs
public void OnChangeTarget(InputAction.CallbackContext context)
{
...
else if(context.action.phase == InputActionPhase.Canceled)
{
// Hold : Focus
if(isFocusingTarget == true)
{
GameManager.CameraController.LockOnTarget(null);
}
// Press : Change Target
else
{
ChangeTarget();
}
}
}
public void ChangeTarget()
{
TargetObject newTarget = GetNextTarget();
if(newTarget == null) // No target
{
GameManager.TargetController.ChangeTarget(null);
gunCrosshair.SetTarget(null);
return;
}
if(newTarget != null && newTarget == target) return;
target = GetNextTarget();
target.isNextTarget = false;
GameManager.TargetController.ChangeTarget(target);
gunCrosshair.SetTarget(target.transform);
}
WeaponController
에서는 타겟을 바꾸는 코드가 ChangeTarget(InputAction...)
내부에 있었습니다.
버튼 입력을 처리하는 함수에 들어있었던 코드를 꺼내서 ChangeTarget()
을 만들어줍시다.
이름이 같아도 함수 오버로딩으로 실행은 잘 되겠지만, 서로 하는 역할이 다른 함수가 이름이 같으면 몹시 불편해지므로 입력 이벤트로 호출되는 함수는 앞에 On
을 붙여줬습니다.
앞으로는 InputEvent 함수는 미리 이름을
On...
으로 만들어야겠습니다.
GetNextTarget()
에서 다음 목표물을 얻어오는데, 더 이상 근처에 목표물이 없다면 이 값은 null
을 반환합니다.
null
일 때는 관련 UI 코드의 파라미터에 맞게 null을 넘기고,
null
이 아니라면 추가적인 설정들을 하고 UI 코드에 맞는 파라미터를 넘겨줍니다.
TargetController.cs
// Remove from TargetUI List, stop functioning and Destroy
public void RemoveTargetUI(TargetObject targetObject)
{
TargetUI targetUI = FindTargetUI();
if(targetUI?.Target != null)
{
targetUI.DestroyUI();
targetUIs.Remove(targetUI);
Destroy(targetUI.gameObject);
}
}
public void ChangeTarget(TargetObject newTarget)
{
// No target
if(newTarget == null)
{
targetArrow.SetTarget(null);
GameManager.UIController.SetTargetText(null);
targetLock.SetTarget(null);
return;
}
lockedTarget = newTarget;
targetArrow.SetTarget(lockedTarget);
GameManager.UIController.SetTargetText(lockedTarget.Info);
TargetUI targetUI = FindTargetUI();
targetLock.SetTarget(lockedTarget.transform);
if(targetUI != null && targetUI.Target != null)
{
if(currentTargettedUI != null)
{
currentTargettedUI.SetTargetted(false); // Disable Prev Target
}
currentTargettedUI = targetUI;
currentTargettedUI.SetTargetted(true); // Enable Current Target
}
}
TargetController
에서도 코드를 수정해줍니다.
이전에는 ChangeTarget()
에서 null
이 넘겨질 때에 대한 처리를 제대로 하지 않았습니다.
null
일 때는 화살표, 락온 UI, 현재 선택된 타겟의 점수 UI를 모두 숨기는 코드를 추가합니다.
TargetUI.cs
// Call before destroy
public void DestroyUI()
{
targetObject = null;
CancelInvoke();
}
TargetController
에서 TargetUI
를 없앨 때, 실행중인 컴포넌트가 갑자기 사라져서 MissingReferenceException
이 뜨는 것을 방지하기 위해 약간의 코드를 추가합니다.
미사일을 맞으면 연기가 치솟고, 두 번 맞아서 HP가 0이 되면 미리 설정된 딜레이 (1초) 후에 2차 폭발이 일어나서 오브젝트가 사라지게 됩니다.
그리고 그 오브젝트를 가리키고 있는 UI, 좌측 상단에 있는 TARGET UI도 같이 사라졌습니다.
근데 미니맵 표시는 알아서 사라지지 않네요.
TargetObject.cs
protected void DeleteMinimapSprite()
{
for(int i = 0; i < transform.childCount; i++)
{
GameObject childObject = transform.GetChild(i).gameObject;
if(childObject.layer == LayerMask.NameToLayer("Minimap"))
{
Destroy(childObject);
}
}
}
protected override void DestroyObject()
{
...
DeleteMinimapSprite();
}
미니맵 표시들은 위 사진처럼 오브젝트 자식으로 붙어있는 형태이고,
미니맵 카메라에 보여주기 위해 레이어도 일괄적으로 "Minimap"으로 되어 있습니다.
오브젝트 하위에 Layer == Minimap인 오브젝트들을 모두 삭제하면 해결되겠네요.
이제 미니맵 표시도 같이 사라졌습니다.
목표물을 파괴했는데 점수가 안 오르네요.
그냥 뒀다가는 하이스코어가 항상 0점이 되겠습니다.
TargetObject.cs
int lastHitLayer;
protected void CommonDestroyFunction()
{
GameObject obj = Instantiate(destroyEffect, transform.position, Quaternion.identity);
if(isEnemy == true)
{
GameManager.Instance?.RemoveEnemy(this);
GameManager.TargetController?.RemoveTargetUI(this);
GameManager.WeaponController?.ChangeTarget();
if(lastHitLayer == LayerMask.NameToLayer("Player"))
{
GameManager.PlayerAircraft.OnScore(objectInfo.Score);
}
}
DeleteMinimapSprite();
}
public virtual void OnDamage(float damage, int layer)
{
hp -= damage;
lastHitLayer = layer;
if(hp <= 0)
{
DestroyObject();
}
}
에이스 컴뱃은 리그 오브 레전드처럼 결정타를 날린 대상에게 점수를 주는 시스템을 가지고 있습니다.
내가 아무리 미사일을 많이 맞춰도, 아군 중 누군가가 결정타를 날려서 목표물을 파괴하면 점수는 그 목표물을 파괴한 아군에게 들어옵니다.
가장 마지막에 목표물에 데미지를 준 대상을 저장하기 위해 lastHitLayer
를 추가하고, OnDamage()
에서 그 레이어를 저장해줍니다.
(현재 목표물 관련 Layer는 플레이어와 적군으로 구분됩니다.)
Missile.cs
, Bullet.cs
void OnCollisionEnter(Collision other)
{
...
other.gameObject.GetComponent<TargetObject>()?.OnDamage(damage, gameObject.layer);
...
}
미사일과 총알 스크립트의 OnDamage()
를 호출하는 부분에 레이어를 추가해줍시다.
이 레이어 정보는 미사일을 발사할 때 발사한 대상의 레이어로 지정되도록 코드가 구현되어 있습니다.
적군 목표물이 파괴될 때, 플레이어가 발사한 미사일이나 총알에 파괴되었다면 점수를 추가해줍시다.
픽시를 터뜨려서 5,000점을 획득한 모습입니다.
이번에는 매 초마다 체력을 깎아서 자폭을 시켜봅시다.
연기가 나면서 비실비실 날아다니다가 점수도 안 주고 폭발해버렸습니다.
국물도 없네
한편으로는 플레이어가 피격당했을 때도 생각해야 합니다.
오른쪽 아래 UI에 DMG ?%와 함께 비행기 그림이 표시되는 부분이 있습니다.
DMG는 0%부터 99%까지 표시되고, 100%가 되면 플레이어의 비행기가 폭발하면서 게임 오버가 됩니다.
비행기 그림은 현재 DMG에 따라서 색깔이 달라지고요.
AircraftController.cs
public override void OnDamage(float damage, int layer)
{
base.OnDamage(damage, layer);
uiController.SetDamage((int)(Info.HP - hp / Info.HP * 100));
}
플레이어의 HP가 100이 아닐 수 있으니, 100으로 변환하는 코드를 작성합니다.
UIController.cs
[SerializeField]
Color cautionColor;
public void SetDamageText(int damage)
{
string text = string.Format("<align=left>DMG<line-height=0>\n<align=right>{0}%<line-height=0>", damage);
dmgText.text = text;
if(damage < 34)
{
aircraftImage.color = GameManager.NormalColor;
}
else if(damage < 67)
{
aircraftImage.color = cautionColor;
}
else
{
aircraftImage.color = GameManager.WarningColor;
}
}
텍스트를 조정하는 코드는 이미 추가했고,
현재 체력에 따라 비행기 이미지의 색을 변경하는 코드를 추가합니다.
그리고 제 체력을 깎아보죠.
색깔이 잘 변경되는 모습인...
어...
여기는 나중에 생각하겠습니다.
게임 오버 부분은 연출 파트에서 다시 다룰 예정이에요.
목표물에 미사일이나 총알을 맞추면 HIT,
목표물을 파괴하면 DESTROYED 라고 써진 UI가 표시됩니다.
이 부분도 이전에 UI만 만들어놓고 코드로 연결시켜놓지 않아서,
이번에 구현해줘야 합니다.
일단, 보시다시피 HIT 와 DESTROYED UI 는 같은 위치에 놓입니다.
HIT 가 표시되다가 DESTROYED 가 뜨면, 내가 다른 목표물을 맞추고 있어도 HIT UI 는 무시됩니다.
알고리즘을 생각해보죠.
- 위치가 겹치는 라벨 UI는 우선순위가 있음
- 우선순위가 높은 라벨이 표시되면, 낮은 라벨을 표시하는 명령은 무시됨
- 우선순위가 높은 라벨이 뜨는 동안에는 낮은 라벨이 뜨는 함수가 아예 실행이 되지 않는 것으로 보임
예를 들어 각 라벨 지속시간이 1초고,
우선순위가 높은 DESTROYED가 0초,
우선순위가 낮은 HIT가 0.5초 시점에 표시된다고 칩시다.
그러면 DESTROYED는 0 ~ 1초, HIT는 0.5 ~ 1.5초 구간에 표시되어야 합니다.
0.5초 ~ 1초 구간에는 HIT는 무시되고 DESTROYED가 계속 뜨는데,
1초가 지나고 HIT가 계속 표시될 수 있는 1초 ~ 1.5초 구간에도 HIT는 표시되지 않습니다.
DESTROYED가 표시되는 0 ~ 1초 동안에는 HIT를 출력하라는 메시지가 아예 무시되게끔 구현되는 것으로 추정할 수 있습니다.
무슨 알고리즘 문제 같네요.
LabelInfo.cs
[CreateAssetMenu(fileName = "LabelInfo", menuName = "Scriptable Object Asset/LabelInfo")]
public class LabelInfo : ScriptableObject
{
[SerializeField]
Texture labelTexture;
[SerializeField]
Color labelColor;
[SerializeField]
float visibleTime;
public Texture LabelTexture
{
get { return labelTexture; }
}
public Color LabelColor
{
get { return labelColor; }
}
public float VisibleTime
{
get { return visibleTime; }
}
}
목표물 데이터에 이어, 라벨 데이터를 담는 ScriptableObject
인 LabelInfo
를 만들었습니다.
표시할 텍스쳐, 색상, 표시되는 시간을 가지고 있습니다.
AlertUIController.cs
public class AlertUIController : MonoBehaviour
{
[Header("Warning/Alert Label Object")]
[SerializeField]
RawImage labelImage;
[Space(10)]
[SerializeField]
LabelInfo destroyed;
[SerializeField]
LabelInfo hit;
[SerializeField]
LabelInfo missed;
[SerializeField]
LabelInfo missionAccomplished;
[SerializeField]
LabelInfo missionFailed;
int currentPriority;
float labelTimer;
public enum LabelEnum // Used for Priority
{
Missed = 1,
Hit,
Destroyed,
MissionAccomplished,
MissionFailed
}
Color transparentColor = new Color(0, 0, 0, 0);
...
public void SetLabel(LabelEnum labelEnum)
{
LabelInfo labelInfo;
switch(labelEnum)
{
case LabelEnum.Missed:
labelInfo = missed;
break;
case LabelEnum.Hit:
labelInfo = hit;
break;
case LabelEnum.Destroyed:
labelInfo = destroyed;
break;
case LabelEnum.MissionFailed:
labelInfo = missionFailed;
break;
case LabelEnum.MissionAccomplished:
labelInfo = missionAccomplished;
break;
default: // Error case
labelInfo = missed;
break;
}
int labelPriority = (int)labelEnum;
if(currentPriority < labelPriority)
{
currentPriority = labelPriority;
labelTimer = labelInfo.VisibleTime;
labelImage.texture = labelInfo.LabelTexture;
labelImage.color = labelInfo.LabelColor;
}
else if(currentPriority == labelPriority)
{
labelTimer = labelInfo.VisibleTime;
}
}
...
void Start()
{
labelImage.color = transparentColor;
}
// Update is called once per frame
void Update()
{
...
if(labelTimer > 0)
{
labelTimer -= Time.deltaTime;
// Set Invisible
if(labelTimer <= 0)
{
labelTimer = 0;
currentPriority = 0;
labelImage.color = transparentColor;
}
}
}
}
라벨 우선순위는 enum
으로 처리했습니다.
Missed
, Hit
, Destroyed
, Mission Failed
, Mission Accomplished
순서로 설정한 상태입니다.
라벨을 조정하는 코드는 타이머(labelTimer
)를 기반으로 작동합니다.
SetLabel
을 호출하면 enum
값에 맞는 LabelInfo
를 가져오고, 현재 띄워져있는 라벨의 우선순위를 비교합니다.
Update
에서는 타이머를 작동시키고, 타이머가 0초가 되면 현재 라벨의 투명도를 0으로 설정해서 보이지 않게 만듭니다.
이제 각각의 라벨에 대한 LabelInfo
를 만들어주고,
데이터를 설정하고,
스크립트에 있는 변수를 모두 할당한 다음 실행합니다.
목표물을 맞히면 HIT 라벨이 뜨고,
파괴하면 DESTROYED 라벨이 뜹니다.
기총을 맞힐 때마다 HIT가 뜨고, 파괴되면 DESTROYED로 라벨이 바뀝니다.
못맞췄지롱
미사일이 빗나가게 되었을 경우에도 UI가 출력되어야 합니다.
빗나가는 경우는 여러가지가 있을 수 있습니다.
- 미사일의 탐색 범위를 벗어났을 때
- 유도 중 지형을 비롯한 다른 구조물에 맞았을 때
- 미사일의 지속시간이 지났을 때
3가지 중 하나라도 해당된다면 MISSED가 떠야 합니다.
중요한 점이 하나 있는데, 목표물이 지정된 미사일에 대해서만 MISSED가 뜹니다.
락온을 하지 않고 발사한 미사일은 MISSED가 뜨지 않습니다.
bool isHit = false;
bool isDisabled = false;
void LookAtTarget()
{
...
if(angle > boresightAngle)
{
GameManager.UIController.SetLabel(AlertUIController.LabelEnum.Missed);
isDisabled = true;
target = null;
return;
}
...
}
void OnCollisionEnter(Collision other)
{
if(target != null && other.gameObject == target.gameObject)
{
isHit = true;
}
other.gameObject.GetComponent<TargetObject>()?.OnDamage(damage, gameObject.layer);
Explode();
DisableMissile();
}
...
void DisableMissile()
{
if(target != null && isDisabled == false && isHit == false)
{
GameManager.UIC**텍스트**ontroller.SetLabel(AlertUIController.LabelEnum.Missed);
}
transform.parent = parent;
gameObject.SetActive(false);
}
먼저 LookAtTarget()
은 "1. 미사일의 탐색 범위를 벗어났을 때" 에 해당합니다.
MISSED 라벨을 표시하고, 미사일의 비활성화 여부를 설정하는 isDisabled
를 true
로 놓습니다.
OnCollisionEnter()
에서는 "2. 유도 중 지형을 비롯한 다른 구조물에 맞았을 때" 를 판단합니다.
미사일에 맞은 객체 other.gameObject가 target.gameObject와 같으면,
목표물에 명중한 것이므로 MISSED가 떠서는 안 됩니다. (사실 뜨더라도 HIT에 가려지겠지만요)
그래서 목표물에 맞았다면 isHit
를 true
로 설정합니다.
DisableMissile()
은 미사일이 폭발하거나, 지속시간이 다 되는 경우 호출됩니다.
(그냥 사라질 때 항상 호출된다고 보면 됩니다.)
여기서는 3가지 조건을 확인합니다.
target != null
: 락온된 미사일인지 확인합니다.
isDisabled == false
: 탐색 범위를 벗어나서 이미 MISSED 라벨을 출력한 미사일이 아닌지 확인합니다.
isHit == false
목표물과 충돌한 미사일이 아닌지 확인합니다.
이 3개가 모두 해당된다고요?
축하합니다! 당신은 MISSED 라벨을 출력할 자격이 있는 미사일입니다!
탐지 범위 밖으로 목표물이 나가서 유도를 멈춘 미사일,
목표물을 맞추지 못하고 땅바닥에 박힌 미사일,
수명을 다한 미사일 모두 MISSED 라벨이 출력되는 것을 확인할 수 있습니다.
점점 게임의 구실을 갖추고 있는 것 같습니다.
근데 다음에 뭐 하기로 했었죠?
날아다니는 것만 해도 환장할 것 같은데요.
UI는 4편을 썼는데, 이건 얼마나 걸릴까요?
이 프로젝트의 작업 결과물은 Github에 업로드되고 있습니다.
https://github.com/lunetis/OperationZERO