Add travel map flow and update docs

This commit is contained in:
jongjae0305
2026-06-24 15:34:09 +09:00
parent 8561fab0a2
commit af26dd4311
37 changed files with 3114 additions and 165 deletions
+635
View File
@@ -0,0 +1,635 @@
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class GameFlowManager : MonoBehaviour {
public enum FlowScreen {
Intro,
Game,
Result,
DutyFreeShop,
MapPreview
}
public static GameFlowManager Instance { get; private set; }
public static bool IsGameplayActive {
get {
return Instance == null || Instance.gameplayActive;
}
}
public GameSaveData SaveData {
get { return saveData; }
}
public MapDefinition CurrentMap {
get { return currentMap; }
}
private const string IntroSceneName = "Intro";
private const string GameSceneName = "Main";
private const string ResultSceneName = "Result";
private const string DutyFreeShopSceneName = "DutyFreeShop";
private const string MapPreviewSceneName = "MapPreview";
private const int ShieldCost = 120;
private const int LunchboxCost = 80;
private const int SpeedShoesCost = 140;
private const int MileageCardCost = 160;
private GameSaveData saveData;
private MapDefinition currentMap;
private MapDefinition completedMap;
private FlowScreen currentScreen = FlowScreen.Intro;
private bool gameplayActive = false;
private bool pendingStartGameplay = false;
private bool lastResultCleared = false;
private int lastResultScore = 0;
private int lastResultMileage = 0;
private float lastResultTime = 0f;
private float lastResultDistance = 0f;
private Canvas overlayCanvas;
private RectTransform overlayRoot;
private TMP_FontAsset runtimeFont;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Bootstrap() {
if(Instance != null)
{
return;
}
GameObject flowObject = new GameObject("Game Flow Manager");
flowObject.AddComponent<GameFlowManager>();
DontDestroyOnLoad(flowObject);
}
private void Awake() {
if(Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
saveData = GameSaveManager.Load();
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
SceneManager.sceneLoaded += OnSceneLoaded;
}
private void Start() {
ShowIntro();
}
private void OnDestroy() {
if(Instance == this)
{
SceneManager.sceneLoaded -= OnSceneLoaded;
Instance = null;
}
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode) {
EnsureOverlay();
if(pendingStartGameplay)
{
StartCoroutine(StartGameplayAfterSceneLoad());
return;
}
if(currentScreen != FlowScreen.Game)
{
RefreshCurrentScreen();
}
}
public void SetTotalMileage(int totalMileage) {
if(saveData == null)
{
return;
}
saveData.totalMileage = Mathf.Max(0, totalMileage);
GameSaveManager.Save(saveData);
}
public void AddMileage(int amount) {
if(saveData == null)
{
return;
}
GameSaveManager.AddMileage(saveData, amount);
}
public void CompleteMap(bool cleared, int score, int stageMileage, float elapsedTime, float distance) {
if(!gameplayActive && currentScreen == FlowScreen.Result)
{
return;
}
gameplayActive = false;
currentScreen = FlowScreen.Result;
completedMap = currentMap;
lastResultCleared = cleared;
lastResultScore = score;
lastResultMileage = stageMileage;
lastResultTime = elapsedTime;
lastResultDistance = distance;
if(cleared)
{
saveData.bestScore = Mathf.Max(saveData.bestScore, score);
GameSaveManager.AddMileage(saveData, completedMap.clearMileageReward);
if(completedMap.hasNextMap)
{
GameSaveManager.UnlockMap(saveData, completedMap.nextMapId);
}
else
{
GameSaveManager.Save(saveData);
}
}
ShowResult();
}
private void StartNewJourney() {
saveData = GameSaveManager.ResetSave();
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
completedMap = null;
ShowMapPreview();
}
private void ContinueJourney() {
saveData = GameSaveManager.Load();
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
completedMap = null;
ShowMapPreview();
}
private void RetryCurrentMap() {
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
LoadGameSceneForCurrentMap();
}
private void LoadGameSceneForCurrentMap() {
currentScreen = FlowScreen.Game;
gameplayActive = false;
pendingStartGameplay = true;
ShowLoading("탑승 준비 중", currentMap.displayName + " 코스를 불러오는 중입니다.");
if(Application.CanStreamedLevelBeLoaded(GameSceneName))
{
SceneManager.LoadScene(GameSceneName);
return;
}
Scene activeScene = SceneManager.GetActiveScene();
if(!string.IsNullOrEmpty(activeScene.name))
{
SceneManager.LoadScene(activeScene.name);
}
}
private IEnumerator StartGameplayAfterSceneLoad() {
yield return null;
pendingStartGameplay = false;
currentScreen = FlowScreen.Game;
gameplayActive = true;
if(overlayCanvas != null)
{
overlayCanvas.gameObject.SetActive(false);
}
if(GameManager.instance != null)
{
GameManager.instance.BeginMap(currentMap);
}
}
private void ShowIntro() {
currentScreen = FlowScreen.Intro;
gameplayActive = false;
pendingStartGameplay = false;
completedMap = null;
if(TryLoadFlowScene(IntroSceneName))
{
return;
}
EnsureOverlay();
ClearOverlay();
SetOverlayVisible(true);
AddTitle("Uni-Run");
AddBody("여권을 챙기고 공항으로 출발합니다.\n진행도는 자동 저장됩니다.");
AddBody("현재 경로: " + currentMap.displayName + "\n해금: " + MapDatabase.GetMapByIndex(saveData.unlockedMapIndex).displayName + "\n마일리지: " + saveData.totalMileage);
AddButton("이어하기", ContinueJourney);
AddButton("새 여행", StartNewJourney);
AddButton("저장 초기화", ResetAndStayIntro);
}
private void ShowResult() {
if(TryLoadFlowScene(ResultSceneName))
{
return;
}
EnsureOverlay();
ClearOverlay();
SetOverlayVisible(true);
string title = lastResultCleared ? "맵 클리어" : "일정 실패";
string body = completedMap.displayName
+ "\n점수: " + lastResultScore
+ "\n획득 마일리지: " + lastResultMileage
+ "\n거리: " + Mathf.FloorToInt(lastResultDistance) + "m / " + Mathf.FloorToInt(completedMap.targetDistance) + "m"
+ "\n시간: " + lastResultTime.ToString("0.0") + "s";
if(lastResultCleared)
{
body += "\n클리어 보상: " + completedMap.clearMileageReward + " 마일리지";
}
AddTitle(title);
AddBody(body);
if(lastResultCleared)
{
AddButton("면세점으로", ShowDutyFreeShop);
}
else
{
AddButton("다시 도전", RetryCurrentMap);
}
AddButton("인트로", ShowIntro);
}
private void ShowDutyFreeShop() {
currentScreen = FlowScreen.DutyFreeShop;
gameplayActive = false;
pendingStartGameplay = false;
if(TryLoadFlowScene(DutyFreeShopSceneName))
{
return;
}
EnsureOverlay();
ClearOverlay();
SetOverlayVisible(true);
AddTitle("면세점");
AddBody("보유 마일리지: " + saveData.totalMileage
+ "\n도시락: " + saveData.lunchboxStock
+ " / 방어막: " + saveData.shieldStock
+ "\n운동화: " + saveData.speedShoesStock
+ " / 마일리지 카드: " + saveData.mileageCardStock);
AddButton("도시락 구매 " + LunchboxCost, BuyLunchbox);
AddButton("방어막 구매 " + ShieldCost, BuyShield);
AddButton("운동화 구매 " + SpeedShoesCost, BuySpeedShoes);
AddButton("마일리지 카드 구매 " + MileageCardCost, BuyMileageCard);
AddButton("다음 맵 보기", ShowMapPreview);
}
private void ShowMapPreview() {
currentScreen = FlowScreen.MapPreview;
gameplayActive = false;
pendingStartGameplay = false;
if(completedMap != null && lastResultCleared && completedMap.hasNextMap)
{
currentMap = MapDatabase.GetMap(completedMap.nextMapId);
}
else
{
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
}
if(TryLoadFlowScene(MapPreviewSceneName))
{
return;
}
EnsureOverlay();
ClearOverlay();
SetOverlayVisible(true);
AddTitle("다음 경로");
AddBody(currentMap.displayName
+ "\n" + currentMap.routeName
+ "\n목표 거리: " + Mathf.FloorToInt(currentMap.targetDistance) + "m"
+ "\n제한 시간: " + currentMap.timeLimit.ToString("0") + "s");
AddButton("출발", ConfirmMapAndStart);
AddButton("인트로", ShowIntro);
}
private void ConfirmMapAndStart() {
saveData.currentMapIndex = MapDatabase.GetIndex(currentMap.mapId);
saveData.unlockedMapIndex = Mathf.Max(saveData.unlockedMapIndex, saveData.currentMapIndex);
GameSaveManager.Save(saveData);
LoadGameSceneForCurrentMap();
}
private void RefreshCurrentScreen() {
switch(currentScreen)
{
case FlowScreen.Result:
ShowResult();
break;
case FlowScreen.DutyFreeShop:
ShowDutyFreeShop();
break;
case FlowScreen.MapPreview:
ShowMapPreview();
break;
case FlowScreen.Intro:
default:
ShowIntro();
break;
}
}
private void ResetAndStayIntro() {
saveData = GameSaveManager.ResetSave();
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
ShowIntro();
}
private void BuyLunchbox() {
if(TrySpendMileage(LunchboxCost))
{
saveData.lunchboxStock++;
GameSaveManager.Save(saveData);
}
ShowDutyFreeShop();
}
private void BuyShield() {
if(TrySpendMileage(ShieldCost))
{
saveData.shieldStock++;
GameSaveManager.Save(saveData);
}
ShowDutyFreeShop();
}
private void BuySpeedShoes() {
if(TrySpendMileage(SpeedShoesCost))
{
saveData.speedShoesStock++;
GameSaveManager.Save(saveData);
}
ShowDutyFreeShop();
}
private void BuyMileageCard() {
if(TrySpendMileage(MileageCardCost))
{
saveData.mileageCardStock++;
GameSaveManager.Save(saveData);
}
ShowDutyFreeShop();
}
private bool TrySpendMileage(int cost) {
if(saveData.totalMileage < cost)
{
return false;
}
saveData.totalMileage -= cost;
return true;
}
private void ShowLoading(string title, string body) {
EnsureOverlay();
ClearOverlay();
SetOverlayVisible(true);
AddTitle(title);
AddBody(body);
}
private bool TryLoadFlowScene(string sceneName) {
Scene activeScene = SceneManager.GetActiveScene();
if(activeScene.name == sceneName)
{
return false;
}
if(!Application.CanStreamedLevelBeLoaded(sceneName))
{
return false;
}
SceneManager.LoadScene(sceneName);
return true;
}
private void EnsureOverlay() {
if(overlayCanvas != null)
{
return;
}
EnsureEventSystem();
GameObject canvasObject = new GameObject("Flow Overlay", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
DontDestroyOnLoad(canvasObject);
overlayCanvas = canvasObject.GetComponent<Canvas>();
overlayCanvas.renderMode = RenderMode.ScreenSpaceOverlay;
overlayCanvas.sortingOrder = 100;
CanvasScaler scaler = canvasObject.GetComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1280f, 720f);
scaler.matchWidthOrHeight = 0.5f;
GameObject rootObject = new GameObject("Flow Overlay Root", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
rootObject.transform.SetParent(canvasObject.transform, false);
overlayRoot = rootObject.GetComponent<RectTransform>();
overlayRoot.anchorMin = Vector2.zero;
overlayRoot.anchorMax = Vector2.one;
overlayRoot.offsetMin = Vector2.zero;
overlayRoot.offsetMax = Vector2.zero;
Image background = rootObject.GetComponent<Image>();
background.color = new Color(0.02f, 0.035f, 0.045f, 0.92f);
background.raycastTarget = true;
runtimeFont = CreateKoreanFontAsset();
}
private void EnsureEventSystem() {
if(EventSystem.current != null)
{
return;
}
GameObject eventSystemObject = new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule));
DontDestroyOnLoad(eventSystemObject);
}
private void ClearOverlay() {
if(overlayRoot == null)
{
return;
}
for(int i = overlayRoot.childCount - 1; i >= 0; i--)
{
Destroy(overlayRoot.GetChild(i).gameObject);
}
}
private void SetOverlayVisible(bool visible) {
if(overlayCanvas != null)
{
overlayCanvas.gameObject.SetActive(visible);
}
}
private void AddTitle(string text) {
TextMeshProUGUI title = AddText("Title", text, 34f, FontStyles.Bold);
RectTransform rectTransform = title.rectTransform;
rectTransform.anchoredPosition = new Vector2(0f, 160f);
rectTransform.sizeDelta = new Vector2(760f, 62f);
}
private void AddBody(string text) {
TextMeshProUGUI body = AddText("Body", text, 17f, FontStyles.Normal);
RectTransform rectTransform = body.rectTransform;
rectTransform.anchoredPosition = new Vector2(0f, 52f - (GetBodyCount() * 82f));
rectTransform.sizeDelta = new Vector2(760f, 96f);
}
private void AddButton(string label, UnityEngine.Events.UnityAction action) {
int buttonIndex = GetButtonCount();
GameObject buttonObject = new GameObject("Button " + label, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button));
buttonObject.transform.SetParent(overlayRoot, false);
RectTransform buttonRect = buttonObject.GetComponent<RectTransform>();
buttonRect.anchorMin = new Vector2(0.5f, 0.5f);
buttonRect.anchorMax = new Vector2(0.5f, 0.5f);
buttonRect.pivot = new Vector2(0.5f, 0.5f);
buttonRect.anchoredPosition = new Vector2(0f, -82f - (buttonIndex * 46f));
buttonRect.sizeDelta = new Vector2(260f, 34f);
Image buttonImage = buttonObject.GetComponent<Image>();
buttonImage.color = new Color(0.92f, 0.96f, 1f, 0.95f);
Button button = buttonObject.GetComponent<Button>();
button.onClick.AddListener(action);
TextMeshProUGUI buttonText = AddTextTo(buttonObject.transform, "Label", label, 15f, FontStyles.Bold);
RectTransform textRect = buttonText.rectTransform;
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = Vector2.zero;
textRect.offsetMax = Vector2.zero;
buttonText.color = new Color(0.04f, 0.07f, 0.09f, 1f);
}
private int GetBodyCount() {
int count = 0;
for(int i = 0; i < overlayRoot.childCount; i++)
{
if(overlayRoot.GetChild(i).name.StartsWith("Body"))
{
count++;
}
}
return count;
}
private int GetButtonCount() {
int count = 0;
for(int i = 0; i < overlayRoot.childCount; i++)
{
if(overlayRoot.GetChild(i).name.StartsWith("Button"))
{
count++;
}
}
return count;
}
private TextMeshProUGUI AddText(string objectName, string text, float fontSize, FontStyles fontStyle) {
GameObject textObject = new GameObject(objectName + " " + GetBodyCount(), typeof(RectTransform), typeof(CanvasRenderer));
textObject.transform.SetParent(overlayRoot, false);
RectTransform rectTransform = textObject.GetComponent<RectTransform>();
rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
rectTransform.pivot = new Vector2(0.5f, 0.5f);
TextMeshProUGUI label = textObject.AddComponent<TextMeshProUGUI>();
label.text = text;
label.font = runtimeFont != null ? runtimeFont : label.font;
label.fontSize = fontSize;
label.fontStyle = fontStyle;
label.alignment = TextAlignmentOptions.Center;
label.color = new Color(0.95f, 0.98f, 1f, 1f);
label.raycastTarget = false;
label.textWrappingMode = TextWrappingModes.Normal;
return label;
}
private TextMeshProUGUI AddTextTo(Transform parent, string objectName, string text, float fontSize, FontStyles fontStyle) {
GameObject textObject = new GameObject(objectName, typeof(RectTransform), typeof(CanvasRenderer));
textObject.transform.SetParent(parent, false);
TextMeshProUGUI label = textObject.AddComponent<TextMeshProUGUI>();
label.text = text;
label.font = runtimeFont != null ? runtimeFont : label.font;
label.fontSize = fontSize;
label.fontStyle = fontStyle;
label.alignment = TextAlignmentOptions.Center;
label.raycastTarget = false;
label.textWrappingMode = TextWrappingModes.NoWrap;
return label;
}
private TMP_FontAsset CreateKoreanFontAsset() {
string[] familyNames = {
"Malgun Gothic",
"맑은 고딕",
"Arial Unicode MS"
};
for(int i = 0; i < familyNames.Length; i++)
{
TMP_FontAsset fontAsset = TMP_FontAsset.CreateFontAsset(familyNames[i], "Regular", 80);
if(fontAsset != null)
{
fontAsset.name = "Runtime Flow Korean Font";
return fontAsset;
}
}
return null;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8a22a48e05fb4c1aa0b7e2a4d6723a0f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+28
View File
@@ -0,0 +1,28 @@
using System;
[Serializable]
public class GameSaveData {
public int currentMapIndex;
public int unlockedMapIndex;
public int totalMileage;
public int bagSlots;
public int shieldStock;
public int lunchboxStock;
public int speedShoesStock;
public int mileageCardStock;
public int bestScore;
public static GameSaveData CreateDefault() {
return new GameSaveData {
currentMapIndex = 0,
unlockedMapIndex = 0,
totalMileage = 0,
bagSlots = 1,
shieldStock = 0,
lunchboxStock = 0,
speedShoesStock = 0,
mileageCardStock = 0,
bestScore = 0
};
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9cd5d89d88554d7ea48124a4f1780e0b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+75
View File
@@ -0,0 +1,75 @@
using UnityEngine;
public static class GameSaveManager {
private const string SaveKey = "UniRun.SaveData.v1";
private const string LegacyTotalMileageKey = "UniRun.TotalMileage";
public static GameSaveData Load() {
if(PlayerPrefs.HasKey(SaveKey))
{
string json = PlayerPrefs.GetString(SaveKey);
GameSaveData loadedData = JsonUtility.FromJson<GameSaveData>(json);
if(loadedData != null)
{
Normalize(loadedData);
return loadedData;
}
}
GameSaveData saveData = GameSaveData.CreateDefault();
saveData.totalMileage = PlayerPrefs.GetInt(LegacyTotalMileageKey, 0);
Normalize(saveData);
Save(saveData);
return saveData;
}
public static void Save(GameSaveData saveData) {
Normalize(saveData);
string json = JsonUtility.ToJson(saveData);
PlayerPrefs.SetString(SaveKey, json);
PlayerPrefs.SetInt(LegacyTotalMileageKey, saveData.totalMileage);
PlayerPrefs.Save();
}
public static GameSaveData ResetSave() {
PlayerPrefs.DeleteKey(SaveKey);
GameSaveData saveData = GameSaveData.CreateDefault();
Save(saveData);
return saveData;
}
public static void AddMileage(GameSaveData saveData, int amount) {
if(saveData == null)
{
return;
}
saveData.totalMileage = Mathf.Max(0, saveData.totalMileage + amount);
Save(saveData);
}
public static void UnlockMap(GameSaveData saveData, MapId mapId) {
if(saveData == null)
{
return;
}
int mapIndex = MapDatabase.GetIndex(mapId);
saveData.unlockedMapIndex = Mathf.Max(saveData.unlockedMapIndex, mapIndex);
Save(saveData);
}
private static void Normalize(GameSaveData saveData) {
saveData.currentMapIndex = Mathf.Clamp(saveData.currentMapIndex, 0, MapDatabase.LastMapIndex);
saveData.unlockedMapIndex = Mathf.Clamp(saveData.unlockedMapIndex, 0, MapDatabase.LastMapIndex);
saveData.currentMapIndex = Mathf.Min(saveData.currentMapIndex, saveData.unlockedMapIndex);
saveData.totalMileage = Mathf.Max(0, saveData.totalMileage);
saveData.bagSlots = Mathf.Clamp(saveData.bagSlots, 1, 3);
saveData.shieldStock = Mathf.Max(0, saveData.shieldStock);
saveData.lunchboxStock = Mathf.Max(0, saveData.lunchboxStock);
saveData.speedShoesStock = Mathf.Max(0, saveData.speedShoesStock);
saveData.mileageCardStock = Mathf.Max(0, saveData.mileageCardStock);
saveData.bestScore = Mathf.Max(0, saveData.bestScore);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b2f7f2cb496e477589a57fd237868fd3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: