역시 UI는 할 게 무지하게 많습니다.
할 게 산더미인데 벌써 3편이고, 이거로 끝나는 것도 아니라니요.
전체 화면:
미니맵
아군/적군 UI
게임 내 대사
조준점
기총 UI
경고 UI
색상 변경
화면에 안 보이는 목표물의 위치를 가리키는 화살표
미니맵을 제외한 부분도 해결해야 하고,
1인칭 시점 UI는 1인칭에서만 보이도록 수정
이것도 해결해야 합니다.
우선 지난 포스트에서 해야했던 1인칭 시점 UI 컨트롤부터 하죠.
이 스크린샷은 3인칭 시점,
이 스크린샷은 1인칭 시점입니다.
1인칭 시점에서만 보이고 3인칭 시점에서 보이지 않는 UI는,
- 속도계 (숫자 제외)
- 고도계 (숫자 제외)
- 방향계
- 자세계
- 스로틀
이렇게 5개입니다.
여기서 속도/고도 UI는 3인칭 뷰, 1인칭 뷰 모두 보이게 되는데요,
속도/고도 UI는 3인칭 뷰에서는 고개를 돌려도 위치가 고정되지만,
1인칭 뷰에서 고개를 돌릴 때 위치가 바뀌어야 합니다.
구현 방식을 생각해보면, 속도/고도 UI를 똑 떼어놓은 다음
카메라에 따라 3인칭 뷰, 1인칭 뷰에 붙여넣으면 되겠죠.
그리고 현재 각도에 따라서 위치를 조정해주면 될 것입니다.
먼저 활성화/비활성화될 1인칭 뷰는 한 곳에 모두 몰아넣어있는지 확인합니다.
(애초에 캔버스랑 카메라가 3인칭(공통) 뷰와는 분리해서 만들었죠?)
3인칭 뷰일 때 1인칭 UI 카메라를 비활성화시키느냐, 1인칭 UI 오브젝트나 캔버스를 비활성화시키느냐는 구현하는 사람 마음입니다.
저는 1인칭 UI 오브젝트를 비활성화하는 방향 으로 가겠습니다.
UIController에서 UI 오브젝트를 이미 변수로 얻어올텐데 또 카메라 변수를 얻어오기가 좀 귀찮아서요.
UIController.cs
[Header("1st-3rd View Control")]
public RectTransform commonCenterUI;
public RectTransform firstCenterViewTransform;
public RectTransform thirdCenterViewTransform;
public void SwitchUI(CameraController.CameraIndex index)
{
bool isFirstView = (index == CameraController.CameraIndex.FirstView ||
index == CameraController.CameraIndex.FirstViewWithCockpit);
firstCenterViewTransform.gameObject.SetActive(isFirstView);
RectTransform parentTransform = (isFirstView) ? firstCenterViewTransform : thirdCenterViewTransform;
commonCenterUI.SetParent(parentTransform);
}
CameraController.cs
void SetCamera()
{
...
GameManager.Instance.uiController.SwitchUI((CameraIndex)cameraViewIndex);
}
UIController
에 RectTransform.parent
를 바꾸는 코드를 추가했습니다.
CameraController
에 현재 활성화된 카메라 정보인 CameraIndex
가 있고,
이 값을 넘겨줘서 1인칭 뷰/3인칭 뷰 UI에 붙여놓는 작업을 하게 됩니다.
1인칭 UI, 그 UI의 부모가 될 객체를 모두 끌어다 붙입니다.
카메라에 따라 UI를 표시/비표시하는 기능은 만들어졌고,
이제 1인칭 기준으로는 카메라를 돌릴 때 1인칭 UI 위치가 바뀌는 기능을 만들어야 합니다.
주위를 돌아볼 때의 카메라 시선 정보 (회전값) 역시 CameraController
에 있습니다.
회전값을 잘 가공해서 위치로 변환해주면 될 거에요.
UIController.cs
[Header("1st-3rd View Control")]
public RectTransform commonCenterUI;
public RectTransform firstCenterViewTransform;
public RectTransform thirdCenterViewTransform;
public Canvas firstViewCanvas;
public Vector2 firstViewAdjustAngle;
void Start()
{
firstViewAdjustAngle = new Vector2(1 / firstViewAdjustAngle.x, 1 / firstViewAdjustAngle.y);
...
}
public void AdjustFirstViewUI(Vector3 cameraRotation)
{
Vector2 canvasResolution = new Vector2(firstViewCanvas.pixelRect.width,
firstViewCanvas.pixelRect.height);
Vector2 convertedRotation = new Vector2(cameraRotation.y * firstViewAdjustAngle.x,
cameraRotation.x * firstViewAdjustAngle.y);
firstCenterViewTransform.anchoredPosition = convertedRotation * canvasResolution;
}
CameraController.cs
void Rotate1stViewCamera()
{
...
uiController.AdjustFirstViewUI(rotateValue);
}
void Rotate1stViewWithCockpitCamera()
{
...
uiController.AdjustFirstViewUI(rotateValue);
}
AdjustFirstViewUI(Vector3 cameraRotation)
은 카메라 회전값을 좌표로 변환해주는 기능을 수행합니다.
여기서 Vector2 firstViewAdjustAngle
이라는 변수가 있는데,
시선을 돌릴 때 가로/세로 축으로 각각 몇 도를 넘어가면 화면의 가장자리에 UI를 놓을지 결정하는 값입니다.
그리고 현재 캔버스 크기를 구한 다음, 변환된 각도 값을 서로 곱해서 1인칭 UI의 위치를 확정합니다.
그리고 CameraController
에서 시선의 회전값을 가지고 있으므로, 이 값을 UIController
에 넘겨주게 됩니다.
UIController
에 값을 설정하고 실행합니다.
X축은 방향이 반전되어야 해서 음수로 설정합니다.
모니터 해상도마다 다를 수 있지만 일단 제 기준으로는 이 값이 어느정도 적당하게 움직이는 것 같습니다.
적절한 위치에 놓인 것 같네요.
여기서 주의할 부분이 있는데,
1인칭 뷰 카메라의 Viewport Rect
값은 (0, 0, 1, 1)이어야 합니다.
만약 3인칭 뷰 카메라처럼 이렇게 약간의 공백이 있게 되면,
이렇게 UI가 잘리게 됩니다.
UI가 잘리지 않게 하기 위해서, 1인칭 뷰 카메라는 공백이 없도록 설정합시다.
전체 화면:
미니맵
아군/적군 UI
게임 내 대사
조준점
기총 UI
경고 UI
색상 변경
화면에 안 보이는 목표물의 위치를 가리키는 화살표
이제 전체 화면에서의 묵은 이슈들을 해결할 시간입니다.
하나씩 차례대로 하죠.
표시할 미니맵은 3종류가 있습니다.
보시다시피 원래는 미니맵에 표현해줄 오브젝트가 되게 많습니다.
아군 비행기, 적 비행기, 적 타겟 비행기, 일반 목표물, 타겟 목표물 등이 있죠.
(뒤에 깔리는 지형 등은 둘째치고요)
근데 제가 만들려고 하는 스테이지는 1:1 보스전입니다.
게임 내내 미니맵에 빨간색 화살표 한 개만 있을 거라는 뜻입니다.
이렇게 일감을 성공적으로 줄이는군요.
유니티에서 미니맵을 만드는 고전적인 방식이 있습니다.
Clear Flags
= Solid Color
, Projection
= Orthographic
으로 설정합니다.참고로 저는 이미지를 2개 사용해서 Map이 맨 뒤에, Map_center가 맨 앞에 오고,
화면에 띄울 오브젝트는 그 사이에 표시하게끔 만들려 합니다.
그러면 Scene 화면에서 이렇게 미니맵이 만들어지게 됩니다.
움직이지 않지만 말이죠.
이제 스크립트로 카메라를 제어해야 합니다.
다시 원본 미니맵을 봅시다.
귀찮게도 플레이어가 미니맵의 중앙에 있는 게 아니라 약간 아래에 있습니다.
이 사실을 감안하면서 카메라 위치를 조정해줘야 해요.
MinimapCamera.cs
public class MinimapCamera : MonoBehaviour
{
public Transform target;
public float offsetRatio;
Camera cam;
void Start()
{
cam = GetComponent<Camera>();
}
void Update()
{
Vector3 targetForwardVector = target.forward;
targetForwardVector.y = 0;
targetForwardVector.Normalize();
Vector3 position = new Vector3(target.transform.position.x, 1, target.transform.position.z)
+ targetForwardVector * offsetRatio * cam.orthographicSize;
transform.position = position;
transform.eulerAngles = new Vector3(90, 0, -target.eulerAngles.y);
}
}
target
은 카메라가 따라갈 대상입니다. 플레이어의 비행기를 여기다 할당하면 됩니다.
offsetRatio
는 이미지 하단으로부터 몇 % 지점에 플레이어가 있는지 에 대한 값입니다. 이 값을 이용해서 카메라의 위치를 UI에 맞게 보정해줍니다.
미니맵 사진을 보면 대략 33% 쯤 되어보이네요.
간략하게 설명하면, UI에 맞도록 비행기가 향하는 방향(Y축 무시)의 일정 거리 앞에다가 카메라를 놓고, (Vector3 position = ...
)
비행기의 방향에 따라 카메라도 같이 회전하도록 구현했습니다. (transform.eulerAngles = ...
)
여기서 transform.position
의 y
값을 1로 고정하는데, 모든 미니맵 아이콘의 y
값은 0으로 둘 예정이기 때문입니다.
Clipping Planes
값을 최대한 낮추기 위해, 카메라와 미니맵 아이콘의 거리를 최소화하려고 합니다.
MinimapSprite.cs
[RequireComponent(typeof(SpriteRenderer))]
public class MinimapSprite : MonoBehaviour
{
// Update is called once per frame
void Update()
{
transform.rotation = Quaternion.Euler(90, transform.parent.eulerAngles.y, 0);
transform.position = new Vector3(transform.parent.position.x, 0, transform.parent.position.z);
}
}
미니맵 아이콘 스크립트입니다. 아직은 세상 간단하죠.
우선 미니맵 스프라이트는 본체의 각도가 어떻게 되었든 간에 항상 똑바로 보여야 합니다. 사진처럼 같이 돌아가면 곤란해져요.
그러므로 transform.rotation
값에서 x = 90
을 줘서 항상 눕혀주고,
비행기가 바라보는 각도를 표현하기 위해 y = transform.parent.eulerAngles.y
로 설정합니다.
위치는 부모를 따라가도록 position.x
와 z
는 부모의 transform.position
으로 설정합니다.
아까 카메라 설명할 때 미니맵 아이콘의 y값 (글로벌 좌표)을 모두 0으로 한다고 했었기 때문에, y 값은 0으로 고정시킵니다.
이제 적 비행기에 추가했던 미니맵 아이콘 스프라이트에 MinimapSprite
를 추가하고,
미니맵 카메라에는 MinimapCamera
를 추가한 다음,
target과 offsetRatio를 할당하고 실행시켜보죠.
타겟 비행기 근처를 비행하면서 미니맵에 제대로 보이는지 확인해봅니다.
사진의 왼쪽 하단을 보면, <
처럼 된 화살표가 보이죠?
미니맵 범위에 표시되지 않는 오브젝트들도 저렇게 표시해줘야 합니다.
...말하기는 쉽죠. 로직을 어떻게 짜야 할까요?
- 오브젝트가 화면에 안 잡히는 범위에 있는지 확인
- 그 때 미니맵 가장자리에 맞춰서 화살표 표시
- 1번 미니맵의 경우에는 플레이어의 회전 상태도 고려해서 화살표를 표시해야 함
- 근데 이 화살표를 띄우는 주체가 누구지?
오브젝트가 아이콘 스프라이트를 바꾸나? 아니면 미니맵이 화살표 오브젝트를 추가하나?
생각보다 신경쓸 게 많습니다.
우선 프로토타이핑만 해서 실증이 가능한지 정도만 다뤄보겠습니다.
카메라 하위에 화살표를 하나 만들어놓고, 그 화살표 하나만 움직여보죠.
(어차피 1:1 보스전이니까 화살표는 하나면 되잖아요)
미리 말씀드리자면, 제가 만든 화살표 표시 스크립트는 "오브젝트가 미니맵에 화살표 추가를 요청" 하는 방식입니다. 화살표를 띄우는 주체는 미니맵입니다.
MinimapCamera.cs
Camera cam;
Vector2 size;
public Transform indicator;
void Start()
{
...
size = new Vector2(cam.orthographicSize, cam.orthographicSize * cam.aspect);
}
// Update is called once per frame
void Update()
{
...
}
public void ShowBorderIndicator(Vector3 position)
{
float reciprocal;
float rotation;
Vector2 distance = new Vector3(transform.position.x - position.x, transform.position.z - position.z);
distance = Quaternion.Euler(0, 0, target.eulerAngles.y) * distance;
// X axis
if(Mathf.Abs(distance.x) > Mathf.Abs(distance.y))
{
reciprocal = Mathf.Abs(size.x / distance.x);
rotation = (distance.x > 0) ? 90 : -90;
}
// Y axis
else
{
reciprocal = Mathf.Abs(size.y / distance.y);
rotation = (distance.y > 0) ? 180 : 0;
}
indicator.localPosition = new Vector3(distance.x * -reciprocal, distance.y * -reciprocal, 1);
indicator.localEulerAngles = new Vector3(0, 0, rotation);
}
public void HideBorderIncitator()
{
indicator.gameObject.SetActive(false);
}
지금 작성한 코드는 미니맵 가장자리에 맞춰서 화살표 표시
를 구현한 부분입니다.
현재 카메라와 오브젝트 사이의 거리를 계산하고, (Vector2 distance = ...
)
카메라의 회전을 고려해서 거리 벡터를 회전시킵니다. (distance = ...
)
(3. 1번 미니맵의 경우 회전 상태를 고려
를 적용했습니다. 다른 미니맵의 경우 이 코드를 실행하지 않게끔 구현하면 되겠네요.)
이렇게 계산한 거리는 카메라의 가장자리에 위치할 수 있도록 값을 조절합니다.
이렇게 오브젝트들이 배치되었을 때를 가정해봅시다.
두 빨간색 오브젝트가 X, Y 값이 모두 미니맵 범위를 넘어섰다고 해서 두 오브젝트 모두 이렇게 미니맵 코너에 화살표를 넣으면 안 됩니다.
대부분의 오브젝트가 코너에 몰릴 가능성이 있기 때문입니다.
이런식으로 거리 벡터를 재조정해서 두 빨간색 점에 위치하도록 조정해줘야 합니다.
if
문에서 이 거리를 재조정하는 계산이 들어가 있고, distance의 부호에 따라서 화살표의 회전값도 조정하고 있습니다. (rotation = ...
)
화살표를 띄우는 위치와 회전값 계산이 끝나면 localPosition
, localEulerAngles
에 값을 대입합니다.
MinimapSprite.cs
[RequireComponent(typeof(SpriteRenderer))]
public class MinimapSprite : MonoBehaviour
{
SpriteRenderer spriteRenderer;
public MinimapCamera minimapCamera;
void Start()
{
spriteRenderer = GetComponent<SpriteRenderer>();
}
// Update is called once per frame
void Update()
{
...
if(spriteRenderer.isVisible == false)
{
minimapCamera.ShowBorderIndicator(transform.position);
}
else
{
minimapCamera.HideBorderIncitator();
}
}
}
그 다음, 화면에 오브젝트가 잡히지 않는지 확인하는 방법으로
미니맵 아이콘이 그려지고 있는가? 를 사용하겠습니다.
SpriteRenderer.isVisible()
이 함수를 사용하면 아이콘이 미니맵에 그려지고 있는지 확인할 수 있습니다.
isVisible() == false
일 때 위에서 만든 미니맵 카메라의 화살표 표시 함수를 호출하도록 구현했습니다. (minimapCamera.ShowBorderIndicator(transform.position);
)
다시 보여지게 되면 minimapCamera.HideBorderIncitator()
를 호출해서 화살표를 숨깁니다.
지금 구현한 MinimapSprite
는 미니맵 카메라를 필요로 합니다.
등록한 후 실행해보죠.
(프로토타이핑하느라 일단 구조는 신경쓰지 않고 화살표 출력이 잘 되는지 봅시다.)
화살표는 잘 나오는 것 같습니다.
2단계 미니맵의 특징을 보면,
- 1단계보다 넓음
- 카메라가 회전하지 않음
- 플레이어 아이콘이 바뀜
카메라의 Size를 조정해주고,
Render Texture의 UI 크기도 조정해주고,
회전 안 시키고,
플레이어 아이콘도 보여주게 하면 될 것 같습니다.
3단계 미니맵은 2단계에서 한 가지만 바뀝니다.
- 맵 전체를 보여줌
그러니까 한 번에 구현해보죠.
MinimapCamera.cs
public class MinimapCamera : MonoBehaviour
{
public enum MinimapIndex
{
Small,
Large,
All
}
public Transform target;
public GameObject playerIcon;
public float offsetRatio;
public float smallViewSize;
public float largeViewSize;
public float allViewSize;
Camera cam;
Vector2 size;
public Transform indicator;
public float indicatorSize;
float sizeReciprocal;
int minimapIndex;
public void ChangeMinimapView(InputAction.CallbackContext context)
{
if(context.action.phase == InputActionPhase.Performed)
{
minimapIndex = (++minimapIndex) % 3;
SetCamera();
}
}
public void SetCamera()
{
switch((MinimapIndex)minimapIndex)
{
case MinimapIndex.Small:
cam.orthographicSize = smallViewSize;
cam.cullingMask &= (1 << LayerMask.NameToLayer("Minimap"));
break;
case MinimapIndex.Large:
cam.orthographicSize = largeViewSize;
cam.cullingMask |= (1 << LayerMask.NameToLayer("Minimap (Player)"));
break;
case MinimapIndex.All:
cam.orthographicSize = allViewSize;
break;
}
size = new Vector2(cam.orthographicSize, cam.orthographicSize * cam.aspect);
}
public void ShowBorderIndicator(Vector3 position)
{
float reciprocal;
float rotation;
Vector2 distance = new Vector3(transform.position.x - position.x, transform.position.z - position.z);
// When the x, z positions are same
if(distance.x == 0 || distance.y == 0)
return;
if((MinimapIndex)minimapIndex == MinimapIndex.Small)
{
distance = Quaternion.Euler(0, 0, target.eulerAngles.y) * distance;
}
// X axis
if(Mathf.Abs(distance.x) > Mathf.Abs(distance.y))
{
reciprocal = -Mathf.Abs(size.x / distance.x);
rotation = (distance.x > 0) ? 90 : -90;
}
// Y axis
else
{
reciprocal = -Mathf.Abs(size.y / distance.y);
rotation = (distance.y > 0) ? 180 : 0;
}
float scale = sizeReciprocal * GetCameraViewSize();
indicator.localScale = new Vector3(scale, scale, scale);
indicator.localPosition = new Vector3(distance.x * reciprocal, distance.y * reciprocal, 1);
indicator.localEulerAngles = new Vector3(0, 0, rotation);
if(indicator.gameObject.activeInHierarchy == false)
{
indicator.gameObject.SetActive(true);
}
}
public void HideBorderIncitator()
{
indicator.gameObject.SetActive(false);
}
public float GetCameraViewSize()
{
return cam.orthographicSize;
}
void Awake()
{
minimapIndex = (int)MinimapIndex.Small;
cam = GetComponent<Camera>();
SetCamera();
sizeReciprocal = indicatorSize / GetCameraViewSize();
}
// Update is called once per frame
void Update()
{
Vector3 targetForwardVector = target.forward;
targetForwardVector.y = 0;
targetForwardVector.Normalize();
Vector3 position;
float cameraRotation;
if(minimapIndex == (int)MinimapIndex.Small)
{
position = new Vector3(target.transform.position.x, 1, target.transform.position.z)
+ targetForwardVector * offsetRatio * cam.orthographicSize;
cameraRotation = -target.eulerAngles.y;
}
else
{
if(minimapIndex == (int)MinimapIndex.Large)
{
position = new Vector3(target.transform.position.x, 1, target.transform.position.z);
}
else
{
position = new Vector3(0, 1, 0);
}
cameraRotation = 0;
}
transform.position = position;
transform.eulerAngles = new Vector3(90, 0, cameraRotation);
}
}
2, 3번 미니맵에서도 사용할 수 있도록 미니맵 카메라의 기능을 그냥 많이 추가했습니다.
orthographicSize
(카메라가 비추는 범위) 변경Culling Mask
값 변경AircraftController
에 연결되어 있던 Input Event를 MinimapCamera
에 연결시킵니다.
MinimapSprite.cs
public float iconSize;
public float depth;
float sizeReciprocal;
void Start()
{
spriteRenderer = GetComponent<SpriteRenderer>();
sizeReciprocal = iconSize / minimapCamera.GetCameraViewSize();
depth *= 0.01f;
}
// Update is called once per frame
void Update()
{
...
float scale = sizeReciprocal * minimapCamera.GetCameraViewSize();
transform.localScale = new Vector3(scale, scale, scale);
}
미니맵 스프라이트에는 현재 카메라 사이즈에 맞도록 크기를 조절하는 기능을 추가합니다.
카메라의 orthographicSize
가 커지면 더 넓은 범위를 비추게 되고, 미니맵 아이콘의 크기는 작게 보이게 됩니다.
크기에 맞게 스케일을 조정해서 어느 미니맵을 보든 적당한 크기로 보이도록 만들어줍니다.
*이 기능은 미니맵 밖에 있는 오브젝트를 보여주는 화살표도 적용되어야 합니다.
그리고 depth 기능도 추가했는데, 미니맵 아이콘이 겹쳐버릴 때는 아이콘들이 깜빡일 수 있기 때문에 꼭 구분되어야 하는 아이콘은 depth를 바꿔줍니다.
플레이어의 미니맵 아이콘은 항상 위에 있어야 하므로, depth를 1 올립니다.
(실제 y 좌표는 depth * 0.01만큼 올라갑니다.)
값을 모두 할당한 다음 실행합니다.
1단계 미니맵에서 벗어날 때까지 유지하다가, 2단계 - 3단계 순으로 넘어가는 모습입니다.
그러고보니 미니맵 배경을 안 바꿔줬네요.
빠르게 배경 이미지를 만들어서 넣어주고,
RenderTexture 크기도 키워줍니다.
음... 2단계와 3단계 미니맵은 아이콘 크기를 재설정해줘야 할 것 같습니다.
아니면 카메라를 좀 손보거나요.
MinimapCamera.cs
public GameObject[] minimaps = new GameObject[3];
public void SetCamera()
{
...
for(int i = 0; i < minimaps.Length; i++)
{
minimaps[i].gameObject.SetActive(i == minimapIndex);
}
}
CameraController
에서 카메라 전환할 때 카메라를 활성화/비활성화해줬던 것처럼,
여기서도 미니맵 3개를 활성화/비활성화해주는 코드를 넣습니다.
이제 타겟 근처를 지나가면서 아이콘들이 잘 보이는지 확인하면 됩니다.
코드의 구조가 좀 이상하고 아이콘 크기가 너무 커보이긴 하지만,
목표는 달성한 것 같습니다.
이렇게 1인칭 시점과 미니맵 부분을 끝냈는데요,
전체 화면:
미니맵
아군/적군 UI
게임 내 대사
조준점
기총 UI
경고 UI
색상 변경
화면에 안 보이는 목표물의 위치를 가리키는 화살표
이 남은 것들을 4편에 다 끝낼 수 있으면 좋겠군요.
생각보다 미니맵 만들기가 시간을 많이 잡아먹었습니다.
이 프로젝트의 작업 결과물은 Github에 업로드되고 있습니다.
https://github.com/lunetis/OperationZERO
도움이 정말 많이 되었습니다. 감사합니다 센세!