5번째 씬
닷트윈을 활용한 맵 이동 효과와 UI 효과
원본 게임과 최대한 비슷한 느낌을 연출하려고 시도했다.
닷트윈에 대해 R&D를 해 봤을 때, 그 주요 기능은 정말로 유용한 것임을 알 수 있었다.
예를 들어, 단순히 오브젝트를 특정 좌표에서 특장 좌표로 이동하는 효과를 만들려고 할 때에도 직접 코딩을 하려고 한다면 Transform.traslate을 쓰든, 부드럽게 이동하는 효과를 주려면 Lerp를 주면서 코루틴을 돌리든 이렇게 복잡한 과정이 필요했다.
하지만 닷트윈이 있으면, 원하는 효과를 한 줄로 끝낼 수 있을 정도로 놀라운 툴이었다.
예를 들어, 위 GIF에서 맵이 자연스럽게 이동하는 모습은, 이와 같은 코드로 만든 것이다.
using DG.Tweening;
using UnityEngine;
public class MapDynamicMovement : MonoBehaviour
{
private MapController mapController;
private RandomMapPresetCreator randomMapPresetCreator;
[SerializeField] GameObject[] mapComponents;
[SerializeField] float moveDurationOffset = 0.2f;
private void Start()
{
mapController = GetComponentInParent<MapController>();
randomMapPresetCreator = GetComponentInParent<RandomMapPresetCreator>();
}
public void DynamicMove()
{
for(int i = 0; i < mapComponents.Length; i++)
{
float duration = 1f + (i * moveDurationOffset);
mapComponents[i].transform.DOMove(mapComponents[i].transform.position + new Vector3(-randomMapPresetCreator.MapTransformOffset, 0, 0), duration)
.SetDelay(mapController.MapChangeDelay).SetEase(Ease.InOutCirc);
}
}
}
여기서 맵을 이동시킨 코드는 mapComponents[i].transform.DOMove(mapComponents[i].transform.position + new Vector3(-randomMapPresetCreator.MapTransformOffset, 0, 0), duration).SetDelay(mapController.MapChangeDelay).SetEase(Ease.InOutCirc);
이다.
변수명이 길어서 그런 거지, 실제 코드로는 거의 한줄짜리로 시작에 부드럽게 나갔다가 가속하고, 마지막에 부드럽게 안착하는 연출을 만들 수 있는 것이다.
이런 부분에서 DOTween을 잘 활용하면 멋진 연출을 간단한 코드로 만들 수 있다는 것을 알게되었다.
해당 사용 방식에 대해서는 나중에 따로 정리하는 편이 좋을 것 같아서 우선 사용 방법 자체는 생략한다.
오늘 작업은 어려운 게 있었다기 보다는 디테일에 대한 신경을 쓰는 것이 어려웠다. 맵의 효과를 주는 작업 자체는 어려운 것이 아니었지만, 원본 게임과 같이 드라마틱한 연출을 만드는 것이 꽤 어려웠다.
맵을 이동하는 효과를 줄 때 SetEase라는 시퀸스 함수를 붙일 수 있다. 이는 '움직인다'는 단순한 행동에도 변화량에 차이를 두어 이동시키는 데 사용된다.
이와 같은 Ease 기능이 있으며, 해당 기능에 맞춰 Ease를 사용하는 것이 중요하다는 것을 알게 되었다.
그리고 Ease를 연속해서 쓴다고 전부 다 섞어서 발동되는 게 아니라 맨 마지막 것만 작동된다는 것도 알게되었다. (눈으로 보기엔 그렇다?)
결국 최종적으로 맵에 사용된 Ease는 Ease.InOutCirc로 사용하는 것이 연출적으로 괜찮아서 해당 방식으로 진행했다.
처음에 맵을 이동할 때, 모든 플랫폼을 각각의 개체로 두어 랜덤한 소요시간으로 이동하도록 했다. 이것도 나름대로 극적인 효과를 주긴 하지만, 더욱 자연스러운 연출을 위해 방식을 변경했다.
맵에서 이동하는 플랫품의 묶음을 네 묶음 정도로 분류하고, 랜덤성이 아닌 일정한 수치 간격으로 맵을 이동시키는 방식을 사용했다. 이 편이 훨씬 깔끔하고 그럴싸한 연출이 되었다.
구상하는 것 자체가 어려웠다기 보다는 사소한 디테일이라도 어긋나면 연출의 허점이 잘 보여서, 그 디테일을 챙기는 작업이 매우 어려웠다.
새로 구성한 씬의 경우 몇 개의 스크립트를 추가하고, 기존의 스크립트를 다시 맞게 변형하여 사용하였다.
추가된 코드 및 많이 변경된 코드 위주로 기록을 남기고자 한다.
using UnityEngine;
using DG.Tweening;
using System.Collections;
public class MapController : MonoBehaviour
{
[Header("Offset")]
[SerializeField] private float mapChangeDelay = 0.8f;
public float MapChangeDelay { get { return mapChangeDelay; } }
private Coroutine moveCoroutine;
private void OnEnable()
{
TestIngameManager.OnRoundOver += GoToNextStage;
}
private void OnDisable()
{
TestIngameManager.OnRoundOver -= GoToNextStage;
}
public void GoToNextStage()
{
MapShake();
MapMove();
}
private void MapShake()
{
gameObject.transform.DOShakePosition(0.5f, 1, 10, 90);
}
private void MapMove()
{
moveCoroutine = StartCoroutine(MovementCoroutine());
}
IEnumerator MovementCoroutine()
{
WaitForSeconds delay = new WaitForSeconds(mapChangeDelay);
MapDynamicMovement[] movements = GetComponentsInChildren<MapDynamicMovement>();
for (int i = 0; i < movements.Length; i++)
{
if (movements[i] != null)
{
movements[i].DynamicMove();
yield return delay;
}
}
moveCoroutine = null;
}
}
using UnityEngine;
public class FinishedMapDisable : MonoBehaviour
{
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.isActiveAndEnabled) collision.gameObject.GetComponentInParent<MapDynamicMovement>().gameObject.SetActive(false);
}
}
using System.Collections;
using UnityEngine;
public class IngameUIManager : MonoBehaviour
{
[Header("Panels")]
[SerializeField] GameObject roundOverPanel;
[SerializeField] GameObject gameRestartPanel;
[Header("Offset")]
[SerializeField] private float roundOverPanelDuration = 3.5f;
public float RoundOverPanelDuration { get { return roundOverPanelDuration; } }
Coroutine ROPanelCoroutine;
private void OnEnable()
{
TestIngameManager.OnRoundOver += RoundOverPanelShow;
}
private void OnDisable()
{
TestIngameManager.OnRoundOver -= RoundOverPanelShow;
}
private void RoundOverPanelShow()
{
ROPanelCoroutine = StartCoroutine(RoundOverPanelCoroutine());
}
public void HideRoundOverPanel()
{
roundOverPanel.SetActive(false);
}
public void RestartPanelShow()
{
gameRestartPanel.SetActive(true);
}
public void HideRestartPanel()
{
gameRestartPanel.SetActive(false);
}
IEnumerator RoundOverPanelCoroutine()
{
WaitForSeconds delay = new WaitForSeconds(roundOverPanelDuration);
roundOverPanel.SetActive(true);
yield return delay;
HideRoundOverPanel();
TestIngameManager.Instance.RoundStart();
if(TestIngameManager.Instance.IsGameSetOver)
{
TestIngameManager.Instance.GameSetStart();
}
ROPanelCoroutine = null;
}
}
using DG.Tweening;
using TMPro;
using UnityEditor.Rendering;
using UnityEngine;
using UnityEngine.UI;
public class RoundOverPanelController : MonoBehaviour
{
[Header("Reference")]
[SerializeField] private IngameUIManager gameUIManager;
[Header("UI")]
[SerializeField] private TMP_Text winnerText;
[SerializeField] private Image leftImage;
[SerializeField] private Image leftFillImage;
[SerializeField] private Transform leftInitTransform;
[SerializeField] private Image rightImage;
[SerializeField] private Image rightFillImage;
[SerializeField] private Transform rightInitTransform;
[SerializeField] private Image sceneChangePanel;
[SerializeField] private Transform[] leftImageWinSpot;
[SerializeField] private Transform[] rightImageWinSpot;
[SerializeField] private Transform losePosition;
[Header("Offset")]
[SerializeField] private float imageShrinkDelay = 0.1f;
[SerializeField] private float winImageShrinkDuration = 0.1f;
[SerializeField] private float winImageShrinkDelay = 1.6f;
[SerializeField] private float winImageMoveDuration = 0.3f;
Color textColor;
string leftTextColor = "#FF8400";
string rightTextColor = "#009EFF";
private void OnEnable()
{
Init();
}
private void Init()
{
leftImage.rectTransform.localScale = Vector3.one;
leftImage.rectTransform.position = leftInitTransform.position;
rightImage.rectTransform.localScale = Vector3.one;
rightImage.rectTransform.position = rightInitTransform.position;
string winner = TestIngameManager.Instance.ReadScore(out int left, out int right);
TextInit(winner, left, right);
ImageInit(left, right);
}
private void TextInit(string winner, int left, int right)
{
if(winner == "Left")
{
if (ColorUtility.TryParseHtmlString(leftTextColor, out textColor))
{
winnerText.color = textColor;
}
if(left == 1)
{
winnerText.text = "Half Orange";
}
else if (left == 2)
{
winnerText.text = "Round Orange";
}
}
else if (winner == "Right")
{
if (ColorUtility.TryParseHtmlString(rightTextColor, out textColor))
{
winnerText.color = textColor;
}
if (right == 1)
{
winnerText.text = "Half Blue";
}
else if (right == 2)
{
winnerText.text = "Round Blue";
}
}
}
private void ImageInit(int left, int right)
{
if(left == 0) leftFillImage.fillAmount = 0;
else leftFillImage.fillAmount = (float)left / 2;
if(right == 0) rightFillImage.fillAmount = 0;
else rightFillImage.fillAmount = (float)right / 2;
if(left == 2)
{
sceneChangePanel.transform.DOMove(transform.position, 1f).SetDelay(1f);
AddScoreAnimation(leftImage);
return;
}
else if(right == 2)
{
sceneChangePanel.transform.DOMove(transform.position, 1f).SetDelay(1f);
AddScoreAnimation(rightImage);
return;
}
leftImage.rectTransform.DOScale(new Vector3(0, 0, 0), imageShrinkDelay).SetDelay(gameUIManager.RoundOverPanelDuration - imageShrinkDelay);
rightImage.rectTransform.DOScale(new Vector3(0, 0, 0), imageShrinkDelay).SetDelay(gameUIManager.RoundOverPanelDuration - imageShrinkDelay);
}
private void AddScoreAnimation(Image winnerImage)
{
string currentWinner = TestIngameManager.Instance.ReadRoundScore(out int leftScore, out int rightScore);
Debug.Log(leftScore);
if (currentWinner == "Left")
{
leftImage.transform.DOScale(new Vector3(0.13f, 0.13f, 0.13f), winImageShrinkDuration).SetDelay(winImageShrinkDelay);
float imageShrinkTime = winImageShrinkDuration + winImageShrinkDelay;
switch (leftScore)
{
case 1:
leftImage.rectTransform.DOMove(leftImageWinSpot[0].position, winImageMoveDuration).SetDelay(imageShrinkTime);
break;
case 2:
leftImage.rectTransform.DOMove(leftImageWinSpot[1].position, winImageMoveDuration).SetDelay(imageShrinkTime);
break;
case 3:
leftImage.rectTransform.DOMove(leftImageWinSpot[2].position, winImageMoveDuration).SetDelay(imageShrinkTime);
break;
default:
break;
}
float winnerImageMoveTime = imageShrinkTime + winImageMoveDuration;
rightImage.transform.DOMove(losePosition.position, winImageMoveDuration).SetDelay(winnerImageMoveTime);
rightImage.transform.DOScale(new Vector3(3.5f, 3.5f, 3.5f), winImageShrinkDuration).SetDelay(winnerImageMoveTime + 0.3f);
}
else if (currentWinner == "Right")
{
rightImage.transform.DOScale(new Vector3(0.13f, 0.13f, 0.13f), winImageShrinkDuration).SetDelay(winImageShrinkDelay);
float imageShrinkTime = winImageShrinkDuration + winImageShrinkDelay;
switch (rightScore)
{
case 1:
rightImage.rectTransform.DOMove(rightImageWinSpot[0].position, winImageMoveDuration).SetDelay(imageShrinkTime);
break;
case 2:
rightImage.rectTransform.DOMove(rightImageWinSpot[1].position, winImageMoveDuration).SetDelay(imageShrinkTime);
break;
case 3:
rightImage.rectTransform.DOMove(rightImageWinSpot[2].position, winImageMoveDuration).SetDelay(imageShrinkTime);
break;
default:
break;
}
float winnerImageMoveTime = imageShrinkTime + winImageMoveDuration;
leftImage.transform.DOMove(losePosition.position, winImageShrinkDuration).SetDelay(winnerImageMoveTime);
leftImage.transform.DOScale(new Vector3(3.5f, 3.5f, 3.5f), winImageShrinkDuration).SetDelay(winnerImageMoveTime + 0.3f);
}
}
}
using DG.Tweening;
using UnityEngine;
public class StaticScoreUI : MonoBehaviour
{
[SerializeField] GameObject[] leftWinImages;
[SerializeField] GameObject[] rightWinImages;
[Header("Offset")]
[SerializeField] float scoreObtainDelay = 2f;
private void OnEnable()
{
Init();
TestIngameManager.OnRoundOver += RoundScoreChange;
TestIngameManager.OnGameSetOver += GameScoreChange;
}
private void OnDisable()
{
TestIngameManager.OnRoundOver -= RoundScoreChange;
TestIngameManager.OnGameSetOver -= GameScoreChange;
}
private void Init()
{
for (int i = 0; i < leftWinImages.Length; i++)
{
leftWinImages[i].SetActive(false);
rightWinImages[i].SetActive(false);
}
}
private void RoundScoreChange()
{
string currentWinner = TestIngameManager.Instance.ReadScore(out int left, out int right);
if (currentWinner == "Left" && left == 1)
{
for (int i = 0; i < leftWinImages.Length; i++)
{
if (leftWinImages[i].activeSelf) continue;
if (!leftWinImages[i].activeSelf)
{
leftWinImages[i].SetActive(true);
break;
}
}
}
else if (currentWinner == "Right" && right == 1)
{
for (int i = 0; i < leftWinImages.Length; i++)
{
if (rightWinImages[i].activeSelf) continue;
if (!rightWinImages[i].activeSelf)
{
rightWinImages[i].SetActive(true);
break;
}
}
}
}
private void GameScoreChange()
{
string currentWinner = TestIngameManager.Instance.ReadRoundScore(out int leftWinNum, out int rightWinNum);
currentWinner = TestIngameManager.Instance.ReadScore(out int leftRoundNum, out int rightRoundNum);
if (currentWinner == "Left")
{
leftWinImages[leftWinNum - 1].transform.DOScale(new Vector3(2.5f, 2.5f, 2.5f), 0.1f).SetDelay(scoreObtainDelay);
if (rightRoundNum == 1)
{
for (int i = rightWinImages.Length - 1; i >= 0; i--)
{
if (rightWinImages[i].activeSelf)
{
rightWinImages[i].SetActive(false);
break;
}
}
}
}
else if (currentWinner == "Right")
{
rightWinImages[rightWinNum - 1].transform.DOScale(new Vector3(2.5f, 2.5f, 2.5f), 0.1f).SetDelay(scoreObtainDelay);
if (leftRoundNum == 1)
{
for (int i = leftWinImages.Length - 1; i >= 0; i--)
{
if (leftWinImages[i].activeSelf)
{
leftWinImages[i].SetActive(false);
break;
}
}
}
}
TestIngameManager.Instance.SceneChange();
}
}
프로토 타입 작업은 내일 12:00까지 완성되어야 한다. 지금 만든 기능은 프로토타입에 들어가지는 않는 부분이며, 우선은 기존에 완성된 기능에 대한 버그 수정 및 테스트 등이 진행되어야 한다. 당장 게임이 굴러갈 수 있는 수준까지는 되길 바란다.
(저희 팀장님이 디테일 집착이 심해서 이 사람이 기획팀인지 뭐하는 사람인지 구분이 안갑니다)
연출에 대한 틀은 잡았고, 디테일에 대한 작업이 들어가야 한다.
아직까지도 게임 매니저는 구성되지 않았고, 전체적인 연결작업이 들어가야 한다.
기술 문서화 작업을 한 후 최종 발표에 쓰일 내용을 작성해야 한다.