목차
멋쟁이사자처럼 로켓단 인턴쉽
25.08.29
회의록
>> 안건
- 업무 업데이트
- 질문 혹은 피드백 요청
- 주요 안건
- 앱 출시 작업 전 간단한 QA 및 밸런싱 논의
- 향후 마일스톤
- 앱 출시 및 안정화 작업
>> 회의 내용
- 게임을 플레이 해보며 굳이 안 할 거 같은 것까지 건드려서 버그나 이슈 찾기
- 모바일 환경 플레이 체감 밸런스 확인
- 밸런스 쪽 추가의견 없음.
- 출시 전 기능 전체 점검
- 추가 디테일 점검
버그 찾기
: 이제 게임이 거의 다 완성됐고, 게임 등급 심의가 확정되고 출시되기를 기다리고 있다. 하지만 인턴 기간이 남았으므로 자잘한 버그를 찾고 디테일을 더 살리려고 한다.
>> 찾은 버그
: 지난 번에 만든 앱이 중단됐을 때 Audio가 Mute되는 기능이 IOS 환경에서는 잘 작동했지만 안드로이드 기능에서는 작동하지 않는 버그
--> 현재 만든 게임이 모바일용으로 빌드한 것이 아니라 WebGL로 빌드한 다음 그걸 웹에 띄우고 토스 API를 통해 토스 앱 안에서 실행하는 구조라서 OnApplicationPause()가 제대로 동작하지 않았을 가능성이 있다.
▶ 토스 API와 연동하신 팀원 분께서 직접 해결하시기로 결정
결과창에서 바로 재시작 할 수 있도록
: 현재 게임이 끝났을 때 무조건 타이틀 화면으로 갔다가 게임을 시작해야하는데, 결과창에 재시작 버튼을 추가하여 바로 재시작 할 수 있도록 구현
--> 일시정지 상태에서 게임을 재시작 하는 것과 동일하게 구현하되, "퇴근하기" 버튼과 같이 점수가 다 나오기 전에는 비활성화 했다가 점수가 다 나오고 나서 활성화 되도록 구현
>> 작성한 코드
- ResultUIController.cs
<hide/>
[SerializeField] private Button _retryButton;
void Awake()
{
_quitButton.onClick.AddListener(OnClickQuitButton);
_retryButton.onClick.AddListener(OnClickRetryButton);
errorCheckImage.gameObject.SetActive(false);
}
public void InitResultItem(GameResultData resultData)
{
// 점수 보내기
//해당기능에서는 점수를 string 타입으로 받음. 임시로 정수 형변환을 시켰지만
//추후 반올림같은 로직을 넣는다면 그렇게 한 결과값을 인수로 넣도록 수정할 것.
NetworkManager.Instance.SendScore((int)resultData.Score);
// FadeOut Panel 초기화
fadeOutCanvasGroup.alpha = 0;
// 퇴근 및 재시작 버튼 비활성화
_quitButton.gameObject.SetActive(false);
_retryButton.gameObject.SetActive(false);
// New Record 이미지 비활성화
newRecordImage.gameObject.SetActive(false);
// BGM 볼륨을 절반으로 설정
AudioManager.Instance.BGM.SetBGMVolumeHalf();
// 처음에는 0으로 초기화
dayText.text = "0";
maxComboText.text = "0";
scoreText.text = "0";
// TODO: 유저의 최고기록 불러오기 (임시: PlayerPrefs)
float bestScore = PlayerPrefs.GetFloat("BestScore", 0f);
AudioManager.Instance.SFX.PlayScoreCalculating();
Sequence seq = DOTween.Sequence();
// Day Count Up
seq.Append(DOTween.To(() => 0, x => dayText.text = x.ToString() + "일", resultData.Day, 1f)
.OnComplete(() =>
{
AudioManager.Instance.SFX.PlayScoreCalculated();
}));
seq.AppendInterval(0.2f);
// MaxCombo Count Up
seq.Append(DOTween.To(() => 0, x => maxComboText.text = x.ToString(), resultData.MaxCombo, 1f)
.OnComplete(() =>
{
AudioManager.Instance.SFX.PlayScoreCalculated();
}));
seq.AppendInterval(0.2f);
// Score Count Up
seq.Append(DOTween.To(() => 0, x => scoreText.text = x.ToString("N0"), resultData.Score, 1.5f)
.OnComplete(() =>
{
AudioManager.Instance.SFX.StopScoreCalculating();
AudioManager.Instance.SFX.PlayScoreCalculated();
// 퇴근 및 재시작 버튼 활성화
_quitButton.gameObject.SetActive(true);
_retryButton.gameObject.SetActive(true);
// New Record 체크
if (resultData.Score > bestScore)
{
PlayerPrefs.SetFloat("BestScore", resultData.Score);
PlayerPrefs.Save();
ShowNewRecordEffect();
}
}));
}
public void OnClickRetryButton()
{
GameManager.Instance.ResumeGame();
GameManager.Instance.inGameController.Dispose();
GameManager.Instance.inGameController.UseRetry();
GameManager.Instance.inGameController.SkipResultUI();
GameManager.Instance.inGameController.QuitGame();
ClosePopup();
}
>> 참고 코드
- GameManager.cs
<hide/>
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.SceneManagement;
using static Constants;
public class GameManager : Singleton<GameManager>
{
public InGameController inGameController;
public ObstacleClearEffect obstacleClearEffect;
/// <summary>
/// 게임은 상태패턴으로 관리됩니다. Title, InGame, Pause 세가지로 관리됩니다.
/// IGameState 인터페이스를 기반으로 만들어졌으며, 각 상태에 진입(OnEnter)할 때 씬을 로드합니다.
/// GameState는 ChangeGameState를 통해 변경합니다.
/// </summary>
private GameState _previousState;
private GameState _currentState;
//public GameState CurrentGameState => _currentState;
private Dictionary<GameState, IGameState> _states = new Dictionary<GameState, IGameState>();
public Action<GameState> GameStateChanged;
//일시정지 관리.
public bool _isPaused;
protected override void Initialize()
{
//Initialize
_states[GameState.Title] = new TitleState();
_states[GameState.InGame] = new InGameState();
_states[GameState.Pause] = new PauseState();
inGameController = new InGameController();
obstacleClearEffect = new ObstacleClearEffect();
obstacleClearEffect.Initialize();
_isPaused = false;
SceneManager.sceneLoaded += OnSceneLoaded;
}
private void OnSceneLoaded(Scene scene, LoadSceneMode loadSceneMode)
{
SceneManager.sceneLoaded -= OnSceneLoaded;
_= LoadGameSystem();
}
//최초 실행시 초기화 진행
private async Task LoadGameSystem()
{
while (UIManager.Instance.popupUIController.introUIController == null)
{
await Task.Yield();
}
UIManager.Instance.popupUIController.introUIController.InitUI();
//Addressable 데이터 로드
await obstacleClearEffect.LoadSprites();
await UIManager.Instance.popupUIController.pauseUIController.LoadSprites();
await UIManager.Instance.titleUIController.subMenuUIController.LoadSprites();
await UIManager.Instance.inGameUIController.interactionUIController.LoadSprites();
StartCoroutine(LoadEndInitGame());
}
private IEnumerator LoadEndInitGame()
{
yield return StartCoroutine(
UIManager.Instance.popupUIController.introUIController.InitIntroUI());
yield return StartCoroutine(inGameController.Initialize());
ChangeGameState(GameState.Title);
yield return null;
}
private void Update()
{
if (_currentState != GameState.None)
{
_states[_currentState].OnUpdate();
}
// //테스트용 입력
// if (Input.GetKeyDown(KeyCode.Alpha1))
// {
// GoToInGame();
// }
// else if (Input.GetKeyDown(KeyCode.Alpha2))
// {
// //게임오버 시키기
// inGameController.QuitGame();
// //ReturnToTitle();
// }
// else if (Input.GetKeyDown(KeyCode.Alpha3))
// {
// PauseGame();
// }
// else if (Input.GetKeyDown(KeyCode.Alpha4))
// {
// ResumeGame();
// }
}
///게임을 시작합니다.
public void GoToInGame()
{
ChangeGameState(GameState.InGame);
}
///타이틀로 돌아갑니다.
public void ReturnToTitle()
{
ChangeGameState(GameState.Title);
}
///게임을 일시정지 합니다.
public void PauseGame()
{
GetDocumentController()._isClickable = false;
if (_isPaused)
{
Debug.LogWarning("Game is already paused");
return;
}
ChangeGameState(GameState.Pause);
_isPaused = true;
}
///게임을 재개 합니다.
public void ResumeGame()
{
if (!_isPaused)
{
Debug.LogWarning("Game is not paused");
return;
}
ChangeGameState(GameState.Pause, true);
_isPaused = false;
}
///게임의 상태를 변경합니다.
public void ChangeGameState(GameState newGameState, bool resume = false)
{
//기존 State 종료
if (_currentState != GameState.None)
{
_states[_currentState].OnExit();
}
//일시정지 해제 시
if (_currentState == GameState.Pause && newGameState == GameState.Pause && resume)
{
_currentState = _previousState;
}
else//새 State로 전환
{
_previousState = _currentState;
_currentState = newGameState;
_states[_currentState].OnEnter();
}
//State전환 후 실행할 Action이 있으면 실행
GameStateChanged?.Invoke(_currentState);
}
///TimeController가 필요할땐 이 함수를 쓰시면 됩니다.
public TimeController GetTimeController()
{
return inGameController.timeController != null ? inGameController.timeController : null;
}
///DocumentController가 필요할땐 이 함수를 쓰시면 됩니다.
public DocumentController GetDocumentController()
{
return inGameController.docController != null ? inGameController.docController : null;
}
///Classification이 필요할땐 이 함수를 쓰시면 됩니다.
public Classification GetClassification()
{
return inGameController.classification != null ? inGameController.classification : null;
}
public GameState GetGameState()
{
return _currentState;
}
///일시정지(백그라운드 상태) 되었을 때
private void OnApplicationPause(bool pauseStatus)
{
//Debug.Log("OnApplicationPause: " + pauseStatus);
}
///게임이 종료되었을 때
private void OnApplicationQuit()
{
//Debug.Log("OnApplicationQuit");
}
public new void OnDestroy()
{
obstacleClearEffect.ClearSprites();//List에 로드한 스프라이트 해제
base.OnDestroy();
}
}
- InGameController.cs
- Dispose() : _gameFinished가 true가 되면 EndGame()이 호출되면서 게임이 종료됨
- UseRetry() : 게임오버 연출을 스킵하고 곧바로 새 게임을 위한 초기화 후, 게임을 시작한다.
- SkipResultUI() : 결과창 UI를 스킵한다.
<hide/>
using System.Collections;
using UnityEngine;
using Object = UnityEngine.Object;
//인게임 주요 로직들을 제어합니다.
public class InGameController
{
public TimeController timeController;
public DocumentController docController;
public Classification classification;
public bool Initialized;
private bool _initComplete;
private bool _gameStarted;
private bool _gameFinished;
private bool _quitGame;
private bool _skipResultUI;
private bool _useRetry;
private bool _newRecordOn;
private bool _endAdmob;
public IEnumerator Initialize()
{
//TODO: 게임 실행시 초기화 할 로직
//classification = new Classification();
//씬 내 타이머, 문서생성 오브젝트 찾기
if (timeController == null)
{
yield return new WaitUntil(() => timeController = Object.FindObjectOfType<TimeController>());
}
if (docController == null)
{
yield return new WaitUntil(() => docController = Object.FindObjectOfType<DocumentController>());
}
if(classification == null)
{
yield return new WaitUntil(() => classification = Object.FindObjectOfType<Classification>());
classification.Initialize();
}
Initialized = true;
}
//게임이 시작되면 이 시퀀스를 통해 진행됩니다.
public IEnumerator RunSequence()
{
//실행 필수 초기화 진행 체크
yield return new WaitUntil(() => Initialized);
//게임 시퀀스 실행
yield return new WaitUntil(() => _initComplete);
yield return StartGame();
yield return new WaitUntil(() => _gameFinished);
yield return EndGame();
}
//게임 시작 전 초기화
public IEnumerator SetInitGame()
{
//실행 필수 초기화 완료전 까지 대기
yield return new WaitUntil(() => Initialized);
//TODO: 게임 시작시 초기화 할 로직
_initComplete = false;
_gameStarted = false;
_gameFinished = false;
_quitGame = false;
_skipResultUI = false;
_useRetry = false;
_newRecordOn = false;
_endAdmob = false;
// BGM 초기화
AudioManager.Instance.BGM.SetBGMVolumeMax(); // 볼륨을 최대로
AudioManager.Instance.BGM.SetBGMSpeedNormal(); // 배속을 기본으로
//타이머 초기화
timeController.InitTimeController();
//서류 풀 초기화
docController.ReloadDocument(true);
//classification.InitScore();
GameManager.Instance.GetClassification().InitScore();
_initComplete = true;
// NewRecord 이미지 위치 초기화
InGameUIController.Instance.scoreUIController.InitNewRecordImage();
// 시계 프레임 색상 초기화
InGameUIController.Instance.clockUIController.InitClockFrameColor();
}
//게임 시작
public IEnumerator StartGame()
{
_gameStarted = true;
//인게임UI 보이기
UIManager.Instance.inGameUIController.ShowTimeUI();
UIManager.Instance.inGameUIController.ShowInteractionUI();
UIManager.Instance.inGameUIController.ShowScoreUI();
UIManager.Instance.inGameUIController.ShowFeverUI();
UIManager.Instance.inGameUIController.ShowBackgroundUI();
UIManager.Instance.inGameUIController.ShowClockUI();
UIManager.Instance.inGameUIController.ShowClassificationUI();
UIManager.Instance.inGameUIController.ShowWaitThreeSecondsUI();
UIManager.Instance.inGameUIController.ShowDifficultyUpEffectUI();
// 인게임 BGM 재생
AudioManager.Instance.BGM.PlayBGMByState(GameManager.Instance.GetGameState());
//321
yield return UIManager.Instance.StartCoroutine(
UIManager.Instance.inGameUIController.waitThreeSecondsUI.WaitThreeSeconds()
);
//난이도 상승시점 모니터링 시작
DifficultyManager.Instance.InitLevelMonitor();
//타이머, 문서 생성 시작.
timeController.StartRunningTimer();
docController.InitDocuments();
while (!_gameFinished)
{
yield return null;
}
}
//게임 종료. 결과 보고
public IEnumerator EndGame()
{
//TODO: 게임 끝낼 시 실행할 로직
//시간 정지
timeController.StopTime();
docController._isClickable = false;
// 시간 부족 SFX 반복 재생 중지
AudioManager.Instance.SFX.StopTimeOutAlert();
//ex.게임오버 연출, 결과창UI등
var popupController = UIManager.Instance.popupUIController;
if (!_useRetry)//재시작 활성화 시 엔드연출 스킵
{
//게임오버
var gameOverUI = popupController.gameOverUIController;
yield return gameOverUI.ShowSequence(); //게임오버 연출동안 딜레이
}
if (!_skipResultUI)//필요 시 스킵
{
//결과창
var resultUI = popupController.resultUIController;
popupController.ShowResultUI();
resultUI.InitResultItem(new GameResultData(
timeController._day,
GameManager.Instance.GetClassification().GetMaxCombo(),
GameManager.Instance.GetClassification().GetScore()));
}
//게임이 끝난 후, 바로 돌아가지 않고 대기.
while (!_quitGame)
{
yield return null;
}
//결과창이 떠야 게임 한판을 완료했다는 것이므로
if (!_skipResultUI)
{
//광고호출
NetworkManager.Instance.ShowAd();
//광고 끝나기 전까지 대기
while (!_endAdmob)
{
yield return null;
}
//광고 제어 변수 초기화
_endAdmob = false;
//다음 광고 로드
NetworkManager.Instance.LoadAd();
}
//초기화.
_initComplete = false;
_skipResultUI = false;
//재시작 필요 시.
if (_useRetry)
{
//새 게임 코루틴 활성화
yield return GameManager.Instance.StartCoroutine(SetInitGame());
GameManager.Instance.StartCoroutine(RunSequence());
//재시작을 위해 타이틀로 복귀하지 않고 기존 코루틴을 중단한다.
yield break;
}
// BGM 초기화
AudioManager.Instance.BGM.SetBGMVolumeMax(); // 볼륨을 최대로
AudioManager.Instance.BGM.SetBGMSpeedNormal(); // 배속을 기본으로
//인게임 UI 닫기
UIManager.Instance.inGameUIController.HideInGameUI();
UIManager.Instance.inGameUIController.HideTimeUI();
UIManager.Instance.inGameUIController.HideInteractionUI();
UIManager.Instance.inGameUIController.HideScoreUI();
UIManager.Instance.inGameUIController.HideFeverUI();
UIManager.Instance.inGameUIController.HideBackgroundUI();
UIManager.Instance.inGameUIController.HideClockUI();
UIManager.Instance.inGameUIController.HideClassificationUI();
UIManager.Instance.inGameUIController.HideWaitThreeSecondsUI();
UIManager.Instance.inGameUIController.HideDifficultyUpEffectUI();
//타이틀 씬으로 복귀
GameManager.Instance.ReturnToTitle();
}
// New Record 체크 및 연출 재생
public void CheckNewRecord(float currentScore)
{
if (_newRecordOn) return; // 이미 NewRecord 연출이 재생됐으면 넘기도록
float bestScore = PlayerPrefs.GetFloat("BestScore", 0f);
if (currentScore > bestScore)
{
// New Record 연출
InGameUIController.Instance.scoreUIController.ShowNewRecordImage();
AudioManager.Instance.SFX.PlayNewRecordScoreBar();
_newRecordOn = true;
}
}
///게임 끝내기, 호출 시 진행중인 게임이 끝납니다.
public void Dispose()
{
//게임이 시작했을때만
if(_gameStarted) _gameFinished = true;
}
///게임 끝난 후, 호출 시 타이틀로 돌아갑니다.
public void QuitGame()
{
if(_gameFinished) _quitGame = true;
}
///결과 UI 스킵필요 시 게임 종료 전 호출
public void SkipResultUI()
{
_skipResultUI = true;
}
//재시작 필요 시 먼저 호출.
public void UseRetry()
{
_useRetry = true;
}
public bool GetGameStarted()
{
return _gameStarted;
}
public void EndAdMob()
{
_endAdmob = true;
}
}
└ 최종 코드
>> ResultUIController.cs
<hide/>
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using DG.Tweening;
public class ResultUIController : PopupController
{
[SerializeField] private RectTransform _rectTransform;
[SerializeField] private CanvasGroup _canvasGroup;
[SerializeField] private Button _quitButton;
[SerializeField] private Button _retryButton;
[SerializeField] private TMP_Text dayText;
[SerializeField] private TMP_Text maxComboText;
[SerializeField] private TMP_Text scoreText;
[SerializeField] private Image newRecordImage;
[SerializeField] private CanvasGroup fadeOutCanvasGroup;
public Image errorCheckImage;
void Awake()
{
_quitButton.onClick.AddListener(OnClickQuitButton);
_retryButton.onClick.AddListener(OnClickRetryButton);
errorCheckImage.gameObject.SetActive(false);
}
public void ShowPopup()
{
base.ShowPopup(gameObject);
}
public void ClosePopup()
{
base.ClosePopup(gameObject);
errorCheckImage.gameObject.SetActive(false);
}
public void InitResultItem(GameResultData resultData)
{
// 점수 보내기
//해당기능에서는 점수를 string 타입으로 받음. 임시로 정수 형변환을 시켰지만
//추후 반올림같은 로직을 넣는다면 그렇게 한 결과값을 인수로 넣도록 수정할 것.
NetworkManager.Instance.SendScore((int)resultData.Score);
// FadeOut Panel 초기화
fadeOutCanvasGroup.alpha = 0;
// 퇴근 및 재시작 버튼 비활성화
_quitButton.gameObject.SetActive(false);
_retryButton.gameObject.SetActive(false);
// New Record 이미지 비활성화
newRecordImage.gameObject.SetActive(false);
// BGM 볼륨을 절반으로 설정
AudioManager.Instance.BGM.SetBGMVolumeHalf();
// 처음에는 0으로 초기화
dayText.text = "0";
maxComboText.text = "0";
scoreText.text = "0";
// TODO: 유저의 최고기록 불러오기 (임시: PlayerPrefs)
float bestScore = PlayerPrefs.GetFloat("BestScore", 0f);
AudioManager.Instance.SFX.PlayScoreCalculating();
Sequence seq = DOTween.Sequence();
// Day Count Up
seq.Append(DOTween.To(() => 0, x => dayText.text = x.ToString() + "일", resultData.Day, 1f)
.OnComplete(() =>
{
AudioManager.Instance.SFX.PlayScoreCalculated();
}));
seq.AppendInterval(0.2f);
// MaxCombo Count Up
seq.Append(DOTween.To(() => 0, x => maxComboText.text = x.ToString(), resultData.MaxCombo, 1f)
.OnComplete(() =>
{
AudioManager.Instance.SFX.PlayScoreCalculated();
}));
seq.AppendInterval(0.2f);
// Score Count Up
seq.Append(DOTween.To(() => 0, x => scoreText.text = x.ToString("N0"), resultData.Score, 1.5f)
.OnComplete(() =>
{
AudioManager.Instance.SFX.StopScoreCalculating();
AudioManager.Instance.SFX.PlayScoreCalculated();
// 퇴근 및 재시작 버튼 활성화
_quitButton.gameObject.SetActive(true);
_retryButton.gameObject.SetActive(true);
// New Record 체크
if (resultData.Score > bestScore)
{
PlayerPrefs.SetFloat("BestScore", resultData.Score);
PlayerPrefs.Save();
ShowNewRecordEffect();
}
}));
}
// New Record 시, 효과
private void ShowNewRecordEffect()
{
newRecordImage.gameObject.SetActive(true);
// 초기화 (작고 안 보이는 상태)
newRecordImage.color = new Color(1f, 1f, 1f, 0f);
newRecordImage.rectTransform.localScale = Vector3.zero * 0.8f;
Sequence seq = DOTween.Sequence();
// Fade In + Scale Up
seq.Append(newRecordImage.DOFade(1f, 0.5f));
seq.Join(newRecordImage.rectTransform.DOScale(1.2f, 0.3f).SetEase(Ease.OutBack));
// SFX 재생
seq.AppendCallback(() =>
{
AudioManager.Instance.SFX.PlayNewRecordResult();
});
// 살짝 튕기면서 원래 크기로
seq.Append(newRecordImage.rectTransform.DOScale(1f, 0.2f).SetEase(Ease.OutBack));
// 착! 강조
seq.Append(newRecordImage.rectTransform.DOScale(0.95f, 0.1f).SetEase(Ease.InQuad));
seq.Append(newRecordImage.rectTransform.DOScale(1f, 0.15f).SetEase(Ease.OutQuad));
}
public void OnClickQuitButton()
{
// Title로 가기 전, FadeOut
fadeOutCanvasGroup.DOFade(1f, 1f)
.OnComplete(() =>
{
ClosePopup();
GameManager.Instance.ResumeGame();
GameManager.Instance.inGameController.QuitGame();
});
}
public void OnClickRetryButton()
{
GameManager.Instance.ResumeGame();
GameManager.Instance.inGameController.Dispose();
GameManager.Instance.inGameController.UseRetry();
GameManager.Instance.inGameController.SkipResultUI();
GameManager.Instance.inGameController.QuitGame();
ClosePopup();
}
}
'Development > Internship' 카테고리의 다른 글
| [멋사 로켓단 인턴쉽] 19일차 - QA 및 밸런스 조정 (0) | 2025.09.02 |
|---|---|
| [멋사 로켓단 인턴쉽] 18일차 - 앱 출시 전 디테일 보강 (1) | 2025.09.01 |
| [멋사 로켓단 인턴쉽] 16일차 - 밸런싱 및 기타 작업 (2) | 2025.08.28 |
| [멋사 로켓단 인턴쉽] 15일차 - NewRecord를 PlayerPrefs와 연동 (2) | 2025.08.27 |
| [멋사 로켓단 인턴쉽] 14일차 - 예비군으로 인해 불참 (0) | 2025.08.26 |