![[2024 예.소] Project GK 개발일지 #1](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYEV4L%2FbtsI2FSix3L%2FniZSv23BdFvv9FiGxTUg91%2Fimg.png)
Project GK는 이번 년도 예술적인 소프트웨어 대회에서 진행하는 프로젝트이다.
이 대회를 간단히 설명하면, 예술 디자인 대학 학생들과 소프트웨어 융합대학 학생들이 모여서 팀을 꾸려 프로젝트를 진행하는 대회이다.
나는 선배님들 5명과 BANG 이라는 팀을 꾸려 여기 참가하게 됐다.
로고 디자인 내가 했다 ㅎㅎ
어쩌다보니 팀의 막내가 됐는데, 또 어쩌다보니 기획자를 맡게 되어서 프로젝트의 주도 아닌 주도자가 되었다...
그래도, 자신감을 가지고 프로젝트를 기획했다.
기획 완성
초기 기획
3월 초 쯤 기획을 하기 전에, 어떤 게임을 만들고 싶다, 이런게 있으면 좋겠다 등의 기획 니즈를 받아서 초기 기획을 했었다.
협동 게임, 퍼즐 게임, 레이드 게임 등 다양한 주제가 나왔었고, 그 중 앞서 말한 세 가지를 제일 강조해줬다.
최종 초안 기획
그렇게 초기 기획을 하다가, 이 프로젝트를 예소 대회로 가져가자는 의견이 나왔고, 모두 동의해서 예소 팀 컨택 시간에 내 기획을 들고 예디 분들에게 보여드리면서 열심히 컨택을 했다.
그렇게 두 분의 예디 선배님들과 협업하게 되었고, 또 예디 선배님들의 의견, 기술 스택, 그리고 소융대 쪽의 개발 기술 스택들을 모두 반영해 최종 기획의 초안을 완료하게 되었다.
기획 설명
간단하게 설명하자면,
"마법사 소인 마을에 거인국의 거인들이 쳐들어와 보물을 빼앗아갔고, 그것을 되찾기 위해 두 소인 마법사들이 거인국으로 잠입하는 이야기" 를 그린 게임이다.
2인 협동 온라인 멀티플레이이고, 퍼즐과 소울라이크 레이드가 합쳐진 장르를 가지고 있다.
3개의 스테이지와 마지막 탈출 스테이지로 구성되어있고, 그리고 각 3개의 스테이지는 퍼즐 스테이지와 레이드 스테이지로 구분되어있다.
서로 협동하여 퍼즐을 풀고, 앞선 퍼즐에 기반한 공격을 하는 보스를 함께 물리쳐서 게임을 진행하게 된다.
에피소드
사실 이 기획을 할 때 상상에 푹 빠져서 기획하느라 시간 가는줄 모른것도 맞지만 그만큼 양이 많아졌다. 그래도 기획을 선배님들께 보여드렸을 때 다들 반응이 긍정적이라서 한시름 놓았던 기억이 난다.
중간 피드백 때는 예디대 교수님께 기획이 상세하고 연결성이 좋게 짜여져있다고 칭찬도 받았었다...ㅎㅎㅎㅎㅎㅎ
그리고 시간이 지나면서 수정에 수정을 거치고, 개발을 하며 수정을 하고, 디자인을 하며 수정을 하고, 혼자 밥먹다가 문득 생각이 나서 수정을 하고... 엄청난 수정에 수정을 거듭한 끝에 최근에서야 기획이 굳어진 것 같다.
한번에 기획을 잘 하고 싶었지만, 기획을 하고 나니 이정도로 큰 기획은 한번에 될 수가 없겠구나 하는 생각이 들어 수정에 그렇게 연연하지 않았다. 오히려 쳐낼건 과감히 쳐내거나 바꾸고, 추가할 것은 적극적으로 개연성 있게 추가하며 기획을 하니 퀄리티가 더 좋아졌다.
그리고, 내 기획을 더 활용해서 다른 대회를 기획부문으로 나가보자는 의견이 나왔고, 그를 참가하기 위해 20장 짜리 상세 기획서를 2번 썼다. 그 과정에서 미처 생각 못했던 부분이나 이상한 부분들을 수정하면서 더 기획이 상세해졌다.
그렇게 탄생한...
이런 과정을 거쳐 최종 기획이 완성되게 되었다. 제대로된 기획은 이번이 처음이었지만 잘 해낸 것 같아 뿌듯하다.
그렇게 완성된 게임의 이름은 "Wi, Zard!" 이다.
이것의 비하인드가 있는데, 기획할 때 캐릭터 이름을 못정해서 마법사니까 임시로 '위' 랑 '자드' 로 구분했는데 이게 채택됐다...ㅎㅋ
작곡 진행중
최근 프로젝트에서 작곡을 좀 많이 한거같은데, 이번 프로젝트에서는 무려 내가 자원해서 작곡을 맡았다. 선배들 중에 내가 평소 기획하는것들 개발하는것들을 자주 보고하는 친한 선배님이 있는데, 그 때 내가 작곡한것도 들어보시고 이번 프로젝트에서 내가 작곡하겠다고 할 때 힘을 실어주셨다. Thanks to 건호형님
주인공이 소인이고 적(보스)가 거인이라서, 크기차이를 부각하기 위해 게임의 전체적인 사운드를 오케스트라 장르로 가져가기로 했다.
레이드 보스전 BGM은 웅장함을 키우기 위해 저음을 부각시키면 될거라고 감은 잡혔는데, 퍼즐 BGM은 아예 감도 잡히지 않았다. 열심히 레퍼런스를 찾던 도중 초등학교때 재밌게 했던 닌텐도 게임인 "레이튼 교수" 시리즈가 떠올랐다. 그 시리즈는 퍼즐로만 꽉차서 레퍼런스를 얻기 좋았다.
그래서 그걸 이용해 퍼즐 BGM을 만들었고, 보스가 퍼즐에 기반한 공격을 하여 BGM도 퍼즐에 기반해 레이드 보스 BGM을 만들었다.
DAW는 Ableton Live 12 Standard를 사용했고, 가상악기는 BBC Symphony Orchestra를 사용했다. 이 가상악기가 무료인데다가 퀄리티도 좋아서 이거 하나로 다 했다.
곡 설명
간단하게 설명하면, 퍼즐이 도서관 맵에서 진행되어 퍼즐 BGM은 높은 음의 퍼커션이랑 하프, 트라이앵글 같은 악기를 사용하여 약간 밝은 느낌을 주었고, 멜로디 진행에 반음이랑 약간의 변주를 계속 주어 미스터리 느낌을 살렸다.
그리고 보스가 전에 나왔던 퍼즐에 기반한 공격을 하기 때문에, BGM도 퍼즐 BGM 진행을 따와서 첼로나 베이스같은 스트링과 호른이나 트럼펫같은 금관악기로 웅장하게 바꿨다. 그리고 보스가 부각되도록 지나치게 화려하게 짜지 않고 뒤로 조용하면서도 웅장하게깔리도록 했다.
UI 개발
저번 Khuthon때 UI 개발을 맡았는데, 선배들에게 속도랑 퀄리티를 인정받아 이번에도 UI 개발을 맡게 됐다.
이번에 개발한 UI는 플레이어 UI, 보스 UI, 타이틀 및 시작 화면 UI를 개발했다.
코드를 올리고 싶지만 다른 일지에서 올렸던 것들에 비해 많이 간단하기도 하고, 모든 UI 애니메이션들을 DOTween 에셋으로 처리해서 애니메이션 부분은 더 올릴게 없었다. DOTween은 신이다
그래서, UI 측면에서 새로 알게 된 것들을 조금 적어보려고 한다.
CanvasGroup
UI를 짜다보면, 성격이 같은 UI 혹은 용도가 같은 UI들 끼리 묶어야 할 때가 있고, 이것들을 동시에 끄거나 켜야 할때가 있다. 그리고, 애니메이션을 넣는다면 이것들을 동시에 다뤄야 한다.
그럴 때 필요한 것이 이 CanvasGroup이다.
UI들을 묶어둔 상위 게임오브젝트에 CanvasGroup 컴포넌트를 부여하면 하위 오브젝트의 크기, 투명도 같은 것들을 동시에 조정할 수 있다.
이게 되게 편한게, 이미지랑 텍스트가 같이 있는 경우에 페이드 애니메이션을 넣으려고 하면 이미지는 이미지대로 처리하고, 텍스트는 텍스트대로 처리해야 하는데, 캔버스그룹을 상위 오브젝트로 주어서 거기서 투명도 조절을 하면 코드 한줄로 끝난다!
이걸 몰랐을때는 수작업으로 할당하고 코드 짜주고 했는데, 이걸 알고나니 속도도 빨라졌고 훨씬 편해졌다.
Horizontal / Vertical Layout Group
이것은 내가 찾아낸 것은 아니고 온라인 통신을 담당하는 선배가 쓴 것을 본 것이다.
이것을 상위 오브젝트에 사용하면 하위 오브젝트들이 웹에서 flex 태그 쓴 것 처럼 정리된다.
그동안 하위 오브젝트 추가되면 다른 것들 위치를 직접 바꿨는데, 하위 오브젝트 개수에 따라 위치가 조정되어야 할 때 이것을 사용하면 편리할 것 같다.
결과물
디자인은 아직 모두 임시 디자인이다. 나중에 예디대 선배들의 손길이 거친 것으로 교체할 예정이다.
시작 화면
플레이 화면의 UI 영상은 용량이 크다고 안올라간다...
아쉬운대로 코드에 대해 설명하자면, 우선 UIManager를 보스 / 플레이어 / 상호작용의 3개로 나눠 구성했고 세 개 모두 싱글톤으로 선언되어 어떤 코드에서든 접근 가능하도록 했다.
그리고 UI를 다루는 코드를 모두 함수화 하여 다른 스크립트에서 쉽게 접근하도록 했다.
아래는 가장 고생했던 보스의 UI매니저 코드이다.
using UnityEngine;
using UnityEngine.UI;
using DG.Tweening;
using TMPro;
using System;
public class UIManager_Ygg : MonoBehaviour
{
// for Singleton Pattern
public static UIManager_Ygg Instance;
// Get default Prefabs
public GameObject boss;
// Variables being used in default situation
public Image bossHealthBar;
// Variables being used in pattern1 logic
public TextMeshProUGUI hint;
public bool isCorrectedPrevCode;
public TextMeshProUGUI aggroText;
public int patternCode;
public GameObject inputCipherDisplay;
public GameObject inputCipherEnter;
public TextMeshProUGUI inputField;
public Button inputButton;
// Variables being used in pattern2 logic
public TextMeshProUGUI areaNumText;
// Variables being used in pattern3 logic
public GameObject attackNodeContainer;
private int playerAttackCount;
UIManager_Player uiManager;
// for Singleton Pattern
void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(this.gameObject);
}
}
void Start()
{
uiManager = FindObjectOfType<UIManager_Player>();
patternCode = 1234;
isCorrectedPrevCode = false;
}
//------------DEFAULT--------------
public void ManageHealth(float currentHealth, float maxHealth)
{
bossHealthBar.DOFillAmount(currentHealth / maxHealth, 0.05f).SetEase(Ease.OutSine);
}
//------------PATTERN 1--------------
public void EnableHint()
{
hint.DOFade(1f, 0.5f).SetEase(Ease.OutSine);
}
public void DisableHint()
{
hint.DOFade(0f, 0.5f).SetEase(Ease.OutSine);
}
public void WhosAggro()
{
aggroText.DOFade(1f, 0.25f).SetEase(Ease.OutSine);
}
public void AggroEnd()
{
aggroText.DOFade(0f, 0.25f).SetEase(Ease.OutSine);
}
public void ActivateCipher()
{
if (isCorrectedPrevCode)
{
ResetCipher();
isCorrectedPrevCode = true;
}
inputCipherDisplay.GetComponent<CanvasGroup>().DOFade(1, 0.15f);
inputCipherEnter.GetComponent<CanvasGroup>().DOFade(1, 0.15f);
inputCipherDisplay.GetComponent<RectTransform>().DOAnchorPosY(120f, 0.15f).SetEase(Ease.OutSine).OnStart(() => inputCipherDisplay.gameObject.SetActive(true));
inputCipherEnter.GetComponent<RectTransform>().DOAnchorPosY(-200f, 0.15f).SetEase(Ease.OutSine).OnStart(() => inputCipherEnter.gameObject.SetActive(true));
uiManager.interactionNotice.gameObject.SetActive(false);
}
public void DeactivateCipher()
{
inputCipherDisplay.GetComponent<CanvasGroup>().DOFade(0, 0.15f);
inputCipherEnter.GetComponent<CanvasGroup>().DOFade(0, 0.15f);
inputCipherDisplay.GetComponent<RectTransform>().DOAnchorPosY(0f, 0.15f).SetEase(Ease.OutSine).OnComplete(() => inputCipherDisplay.gameObject.SetActive(false));
inputCipherEnter.GetComponent<RectTransform>().DOAnchorPosY(0f, 0.15f).SetEase(Ease.OutSine).OnComplete(() => inputCipherEnter.gameObject.SetActive(false));
uiManager.interactionNotice.gameObject.SetActive(true);
}
public void InputNumber(int num)
{
inputField.text += num.ToString();
}
public void CheckCipher()
{
if (inputField.text == patternCode.ToString())
{
Debug.Log("That's Right!");
inputField.DOColor(Color.green, 0.2f).SetEase(Ease.OutSine);
boss.GetComponent<Boss1>().IsCorrect = true;
}
else
{
Debug.Log("Nope");
inputField.DOColor(Color.red, 0.2f).SetEase(Ease.OutSine);
inputField.DOColor(Color.white, 0.2f).SetEase(Ease.OutSine).SetDelay(0.2f);
boss.GetComponent<Boss1>().IsCorrect = false;
}
}
public void ResetCipher()
{
inputField.text = "";
inputField.color = Color.white;
}
//------------PATTERN 2--------------
public void EnableAreaNum()
{
areaNumText.DOFade(1, 0.25f).SetEase(Ease.OutSine).OnStart(() => areaNumText.enabled = true);
}
public void SetAreaNum(int num)
{
areaNumText.text = "Area " + num.ToString();
}
public void DisableAreaNum()
{
areaNumText.DOFade(0, 0.25f).SetEase(Ease.OutSine).OnComplete(() => areaNumText.enabled = false);
}
//------------PATTERN 3--------------
public void EnableAttackNode()
{
playerAttackCount = 0;
attackNodeContainer.GetComponent<CanvasGroup>().DOFade(1, 0.5f).SetEase(Ease.OutSine).OnStart(() => attackNodeContainer.SetActive(true));
}
public void NodeDeduction()
{
Image node = attackNodeContainer.GetComponent<RectTransform>().GetChild(playerAttackCount).GetComponent<Image>();
node.DOFade(0, 0.15f);
playerAttackCount++;
}
public void DisableAttackNode()
{
playerAttackCount = 0;
attackNodeContainer.GetComponent<CanvasGroup>().DOFade(0, 0.5f).SetEase(Ease.OutSine).OnComplete(() => attackNodeContainer.SetActive(false));
}
public void ResetAttackNode()
{
attackNodeContainer.GetComponent<CanvasGroup>().DOFade(0, 0.25f).SetEase(Ease.OutSine);
for (int i = 0; i < 7; i++)
{
attackNodeContainer.transform.GetChild(i).GetComponent<Image>().DOFade(1f, 0.01f).SetDelay(0.25f);
}
attackNodeContainer.GetComponent<CanvasGroup>().DOFade(1f, 0.25f).SetEase(Ease.OutSine).SetDelay(0.5f);
}
}
힐끔 보면 길고 복잡한 코드이지만 자세히 보면 DOTween이 반이다... ㅎㅎ
UI는 로직이나 화려한 코드보다 플레이어가 잘 볼 수 있고 몰입하도록 잘 구성하고 애니메이션을 센스있게 다루는 것이 더 중요한 것 같다. 이번에 코드를 짜면서 확실히 느꼈다.
이번에 개발을 와바바박 하면서 힘들긴 했어도, 확실히 많이 성장한 것 같다. 선배들이 보스 구현하고 멀티 기능 구현하고 상호작용 구현하는 것을 어깨너머로 봐 정말 많이 배웠다.
이제 앞으로 할 것이 아직 많이 남았다. 하지만 하기 싫거나 무섭지는 않다. 오히려 빨리 하고싶다. 진짜 재밌겠다!!!!
그리고 몇주동안 달린 우리 팀 정말 고생많았고 수고했습니다..!

안녕하세요! 코드 짜는 농부입니다! 경희대학교 소프트웨어융합학과 23학번 재학중입니다. 문의 : dsblue_jun@khu.ac.kr
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!