diff --git a/Assets/Prefabs/Platform.prefab b/Assets/Prefabs/Platform.prefab index 1ff8831..6249559 100644 --- a/Assets/Prefabs/Platform.prefab +++ b/Assets/Prefabs/Platform.prefab @@ -620,7 +620,7 @@ Transform: m_GameObject: {fileID: 1928374650918273} serializedVersion: 2 m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: -0.99, y: 3.8399992, z: 0} + m_LocalPosition: {x: -1.35, y: 2.68, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] @@ -787,6 +787,7 @@ MonoBehaviour: - 0.55 - 1.65 minSpacing: 1.5 + layout: 0 --- !u!1 &1987654321098766 GameObject: m_ObjectHideFlags: 0 @@ -813,8 +814,8 @@ Transform: m_GameObject: {fileID: 1987654321098766} serializedVersion: 2 m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: 0.26, y: 4.66, z: 0} - m_LocalScale: {x: 4.745, y: 1, z: 1} + m_LocalPosition: {x: -0.09999995, y: 3.5000005, z: 0} + m_LocalScale: {x: 3, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 4987654321098765} @@ -905,7 +906,7 @@ Transform: m_GameObject: {fileID: 1987654321098767} serializedVersion: 2 m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: 1.48, y: 3.8399992, z: 0} + m_LocalPosition: {x: 1.1199999, y: 2.6800003, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] diff --git a/Assets/Scripts/BackgroundLoop.cs b/Assets/Scripts/BackgroundLoop.cs index dcaa896..9864e58 100644 --- a/Assets/Scripts/BackgroundLoop.cs +++ b/Assets/Scripts/BackgroundLoop.cs @@ -1,6 +1,7 @@ -using UnityEngine; +using UnityEngine; // 왼쪽 끝으로 이동한 배경을 오른쪽 끝으로 재배치하는 스크립트 +[ExecuteAlways] public class BackgroundLoop : MonoBehaviour { public Sprite[] backgroundSprites; // 순서대로 교체할 공항 배경 스프라이트 public int startSpriteIndex = 0; // 이 배경 오브젝트가 시작할 스프라이트 번호 @@ -9,19 +10,26 @@ public class BackgroundLoop : MonoBehaviour { private float width; // 배경의 가로 길이 private int currentSpriteIndex; // 현재 표시 중인 스프라이트 번호 private SpriteRenderer spriteRenderer; // 배경을 표시하는 스프라이트 렌더러 + private BoxCollider2D backgroundCollider; // 루프 폭 계산용 콜라이더 private void Awake() { - // 가로 길이를 측정하는 처리 + InitializeBackground(); + } - BoxCollider2D backgroundCollider = GetComponent(); - width = backgroundCollider.size.x; - spriteRenderer = GetComponent(); + private void OnEnable() { + InitializeBackground(); + } - currentSpriteIndex = NormalizeSpriteIndex(startSpriteIndex); - ApplyBackgroundSprite(currentSpriteIndex); + private void OnValidate() { + InitializeBackground(); } private void Update() { + if(!Application.isPlaying) + { + return; + } + // 현재 위치가 원점에서 왼쪽으로 width 이상 이동했을때 위치를 리셋 if(transform.position.x <= -width) { @@ -29,6 +37,55 @@ public class BackgroundLoop : MonoBehaviour { } } + private void InitializeBackground() { + CacheComponents(); + + currentSpriteIndex = NormalizeSpriteIndex(startSpriteIndex); + ApplyBackgroundSprite(currentSpriteIndex); + SyncColliderSize(); + RefreshWidth(); + } + + private void CacheComponents() { + if(spriteRenderer == null) + { + spriteRenderer = GetComponent(); + } + + if(backgroundCollider == null) + { + backgroundCollider = GetComponent(); + } + } + + private void RefreshWidth() { + if(spriteRenderer != null && spriteRenderer.sprite != null) + { + width = spriteRenderer.bounds.size.x; + return; + } + + if(backgroundCollider != null) + { + width = backgroundCollider.size.x * Mathf.Abs(transform.lossyScale.x); + return; + } + + width = 0f; + } + + private void SyncColliderSize() { + if(spriteRenderer == null || spriteRenderer.sprite == null || backgroundCollider == null) + { + return; + } + + Vector2 spriteSize = spriteRenderer.sprite.bounds.size; + backgroundCollider.size = spriteSize; + backgroundCollider.offset = Vector2.zero; + backgroundCollider.isTrigger = true; + } + // 위치를 리셋하는 메서드 private void Reposition() { Vector2 offset = new Vector2(width * 2f, 0); @@ -36,6 +93,8 @@ public class BackgroundLoop : MonoBehaviour { currentSpriteIndex = NormalizeSpriteIndex(currentSpriteIndex + spriteAdvanceOnLoop); ApplyBackgroundSprite(currentSpriteIndex); + SyncColliderSize(); + RefreshWidth(); } private int NormalizeSpriteIndex(int index) { @@ -64,7 +123,9 @@ public class BackgroundLoop : MonoBehaviour { if(backgroundSprite != null) { + spriteRenderer.enabled = true; spriteRenderer.sprite = backgroundSprite; + spriteRenderer.color = Color.white; } } } diff --git a/Assets/Scripts/Flow.meta b/Assets/Scripts/Flow.meta new file mode 100644 index 0000000..7542567 --- /dev/null +++ b/Assets/Scripts/Flow.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7b0f46aa63d14f40a4976dc22e6710a8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Flow/GameFlowManager.cs b/Assets/Scripts/Flow/GameFlowManager.cs new file mode 100644 index 0000000..3222281 --- /dev/null +++ b/Assets/Scripts/Flow/GameFlowManager.cs @@ -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(); + 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(); + overlayCanvas.renderMode = RenderMode.ScreenSpaceOverlay; + overlayCanvas.sortingOrder = 100; + + CanvasScaler scaler = canvasObject.GetComponent(); + 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(); + overlayRoot.anchorMin = Vector2.zero; + overlayRoot.anchorMax = Vector2.one; + overlayRoot.offsetMin = Vector2.zero; + overlayRoot.offsetMax = Vector2.zero; + + Image background = rootObject.GetComponent(); + 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(); + 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(); + buttonImage.color = new Color(0.92f, 0.96f, 1f, 0.95f); + + Button button = buttonObject.GetComponent