개요
현재 개발중인 Keeping Night의 씬 구조 설계를 소개하고자 한다.
내용
배경
Keeping Night은 개발 규모가 작고 싱글 플레이 환경을 기반으로 한 프로젝트다. 이러한 특성을 고려했을 때, 프로젝트 구조는 아래의 두 가지 목표를 충족하는 방향으로 설계했다.
- 복잡한 구조를 지양: 간결한 설계를 통해 개발 과정에서의 불필요한 복잡성을 줄이는 것.
- 유지보수와 확장성 보장: 기능 추가나 변경이 용이하도록 설계하는 것.
설계 전략
씬 분할
게임은 크게 네 가지 주요 씬(Title, Lobby, Loading, InGame)으로 나누어 구성하였다. 각 씬은 명확한 역할을 가지며, 이를 통해 어떤 씬의 무엇을 작업해야하는지 명확하게 구분하였다.
Title 씬
/// <summary>
/// Title 모듈들의 초기화, 그리고 시퀀스 진행을 맡습니다
/// </summary>
public sealed class TitleManager : MonoBehaviour
{
[SerializeField] private VW_FadeController fadeController;
private void Awake()
{
Debug.Assert(fadeController);
}
private void Start()
{
ApplySettings();
GameStart();
}
private void GameStart()
{
CommonManager commonManager = CommonManager.Ins;
commonManager.StorageManager.LoadingHelper.InitGameStartSetUp(
loadingStartUIAction: fadeController.FadeOutAsync,
loadingEndUIAction: fadeController.FadeInAsync).Forget();
commonManager.AudioManager.PlayBGM(EBGM.Demo);
}
private void ApplySettings()
{
// 저장된 화면 설정 적용
UserSettingsHandler.ApplyScreenSettings();
UserSettingsHandler.ApplyAudioSettings();
UserSettingsHandler.ApplyLanguageSettings();
}
}
Title 씬은 씬에 배치된 CommonManager의 초기화와 저장된 유저 세팅을 불러오는 역할을 한다.
Lobby 씬
로비 씬은 아웃게임 컨텐츠를 관리한다.
LobbyUIManager의 CanvasStackManager를 통하여 UI의 전체적인 동작을 관리하며, 각 UI는 MVP 구조로 구성되어있다.
UI를 씬에 동적으로 인스턴싱하며 배치하는 방법도 있지만, 프로젝트 규모가 작기 때문에 씬 자체에 각 컴포넌트를 배치하기로 결정하였다.
UI 시스템
public sealed class LobbyCanvasStackManager : MonoBehaviour
{
[SerializeField] private Canvas mainLobbyCanvas;
[SerializeField] private Transform enableUisTr;
[SerializeField] private Transform disableUisTr;
private readonly Stack<Canvas> _lobbyCanvasStack = new Stack<Canvas>();
private void Awake()
{
Debug.Assert(mainLobbyCanvas);
Debug.Assert(enableUisTr);
Debug.Assert(disableUisTr);
// 항상 메인 캔버스는 첫번째로 활성화 되어야 합니다.
_lobbyCanvasStack.Push(mainLobbyCanvas);
}
public void PushCanvas(Canvas canvas)
{
if (_lobbyCanvasStack.Contains(canvas)) return;
_lobbyCanvasStack.Peek().enabled = false;
canvas.enabled = true;
// enableUIsTr의 맨 마지막 자식으로 설정
canvas.transform.SetParent(enableUisTr);
// 맨 마지막 자식으로 설정
canvas.transform.SetSiblingIndex(enableUisTr.childCount);
// 스택에 추가
_lobbyCanvasStack.Push(canvas);
}
public void PopCanvas()
{
if (_lobbyCanvasStack.Peek() == mainLobbyCanvas) return;
// 스택에서 제거된 Canvas를 비활성화하고 disableUIsTr의 자식으로 이동
var canvasToDisable = _lobbyCanvasStack.Pop();
canvasToDisable.enabled = false;
canvasToDisable.transform.SetParent(disableUisTr);
// 스택의 맨 위 Canvas를 활성화
_lobbyCanvasStack.Peek().enabled = true;
}
public void Clear()
{
while (_lobbyCanvasStack.Count > 1) // mainLobbyCanvas만 남을 때까지 반복
{
PopCanvas();
}
}
}
// PR_MainLobby 클래스 내부
private void InitRx()
{
// ...
_view.MapSelectBut.OnClickAsObservable()
.Subscribe(_ => _mdl.SetLobbyState(ELobbyState.MapSelect))
.AddTo(_disposable);
_view.SettingsBut.OnClickAsObservable()
.Subscribe(_ => _mdl.SetLobbyState(ELobbyState.MainSettings))
.AddTo(_disposable);
_view.CreditsBut.OnClickAsObservable()
.Subscribe(_ => _mdl.SetLobbyState(ELobbyState.Credits))
.AddTo(_disposable);
// ...
}
// PR_Play 클래스 내부
private void InitRx()
{
_mdl.CurrentLobbyState
.Subscribe(_view.SetActiveUIStatus)
.AddTo(_disposable);
foreach (MapSelectModule module in _view.SelectModules)
{
module.PlayBut.OnClickAsObservable()
.Subscribe(_ => LoadSelectedScene(module.Type))
.AddTo(_disposable);
}
}
public sealed class VW_Play : MonoBehaviour
{
[SerializeField] private Canvas canvas;
[SerializeField] private Button backBut;
[SerializeField] private List<MapSelectModule> selectModules;
public List<MapSelectModule> SelectModules => selectModules;
private void Awake()
{
Debug.Assert(canvas);
Debug.Assert(backBut);
}
private void Start()
{
backBut.OnClickAsObservable()
.Subscribe(_ => LobbyManager.Ins.CanvasStackManager.PopCanvas())
.AddTo(this);
}
public void SetActiveUIStatus(ELobbyState state)
{
if (state == ELobbyState.MapSelect)
{
LobbyManager.Ins.CanvasStackManager.PushCanvas(canvas);
}
}
}
캔버스 스택 매니저를 활용하여 버튼 클릭 시 모델에 원하는 로비 상태의 enum 값을 전달하고, 모델의 UniRx와 연동된 뷰가 해당 상태에 맞게 동작하도록 구현하였다.
Loading 씬
Loading씬은 로딩 연출과 내부 분기 조절을 담당한다.
CommonManager.Ins.StorageManager.LoadingHelper.LoadInGameSceneAsync().Forget();
로딩 씬은 연출을 표현하는 UI 요소들로만 구성되어 있으며, 실제 로딩 작업은 CommonManager에 있는 SceneLoadingHelper를 통해 요청하는 방식으로 구현되었다.
로딩 기능의 구체적인 구현에 대해서는 별도의 포스팅에서 자세히 다룰 예정이다.
InGame 씬
InGame 씬은 실질적인 게임 플레이 콘텐츠를 담당한다.
로비에서 원하는 씬을 선택하면 InGame 씬으로 진입하며, 플레이 중 패배할 경우 다시 로비로 돌아가게 된다.
빠른 테스트가 가능하도록 초기화 플래그 추가
// InGameManager 클래스 내부
private void Start()
{
switch (_inGameLoadingFlag)
{
case EInGameLoadingFlag.FullGameMode:
WaitForLoadingHelperInitAsync().Forget();
break;
case EInGameLoadingFlag.SceneTestMode:
Init();
break;
default:
Debug.LogError("InGameManager: Invalid InGameLoadingFlag");
break;
}
}
private async UniTaskVoid WaitForLoadingHelperInitAsync()
{
var loadingHelper = CommonManager.Ins.StorageManager.LoadingHelper;
// 초기화 시작 신호를 기다림
await UniTask.WaitUntil(() => loadingHelper.IsInGameLoadingCompleted);
// 초기화 메서드 호출
Init();
}
private void Init()
{
// 인게임 구동 로직...
}
원활한 테스트 환경을 위해 _inGameLoadingFlag 변수를 사용하여 초기화 분기를 조정하였다.
빌드용 씬에서는 WaitForLoadingHelperInitAsync()가 호출되어 로딩이 정상적으로 진행되고, 테스트용 씬에서는 바로 Init()을 호출하여 인게임 초기화를 시작하게 된다.
플래그의 가짓수가 많지 않고 코드가 단순하기 때문에, 스위치문을 사용하여 간결하게 구현하였다.
마무리
위와 같은 구조 설계는 프로젝트 특성에 맞춰 간결하면서도 효율적인 구조를 구축했다고 생각한다. 각 씬의 역할을 명확히 구분하고, 공통 관리 로직과 UI 시스템을 체계적으로 정리함으로써 개발과 유지보수 과정에서의 효율성을 크게 향상시킬 수 있었다.
더불어, 테스트 환경과 빌드 환경 모두에서 유연하게 동작할 수 있는 구조를 마련함으로써 빠른 테스트로 낭비되는 시간을 줄여 개발 시간을 확보하였다.
25년 부터는 사이드 프로젝트로 전환되는 만큼, 시간을 많이 쏟지 못할 예정입니다. 하지만 이러한 설계를 바탕으로 앞으로의 기능 개발 및 개선 작업도 더욱 원활하게 진행될 것으로 기대된다.
'프로젝트 > Keeping Night' 카테고리의 다른 글
오브젝트 풀링 시스템 (0) | 2025.01.03 |
---|---|
로딩 시스템 구현 (0) | 2025.01.03 |