using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; using TMPro; using UnityEngine.UI; #if UNITY_EDITOR using UnityEditor; #endif // 게임 오버 상태를 표현하고, 게임 점수와 UI를 관리하는 게임 매니저 // 씬에는 단 하나의 게임 매니저만 존재할 수 있다. public class GameManager : MonoBehaviour { public enum TutorialGuideType { None, Jump, DoubleJump, Slide, Damage, Items } public enum TutorialItemType { HealthSushi, InvincibleRamen, Shield, SpeedShoes, MileageCard } public static GameManager instance; // 싱글톤을 할당할 전역 변수 public bool isGameover = false; // 게임 오버 상태 public TextMeshProUGUI scoreText; // 점수를 출력할 UI 텍스트 public GameObject gameoverUI; // 게임 오버시 활성화 할 UI 게임 오브젝트 private int score = 0; // 게임 점수 private int currentLife; // 현재 남은 목숨 private int stageMileage = 0; // 현재 플레이 중 획득한 마일리지 private int totalMileage = 0; // 누적 보유 마일리지 private TutorialGuideType tutorialGuideType = TutorialGuideType.None; // 현재 튜토리얼 안내 이미지 종류 private string tutorialTitle = ""; // 튜토리얼 안내 제목 private string tutorialMessage = ""; // 튜토리얼 안내 문구 private Image[] lifeIcons; // 왼쪽 HUD의 목숨 도장 표시 private GameObject tutorialGuideObject; // 화면 상단 이미지 튜토리얼 패널 private Image tutorialMainImage; // 점프/슬라이드 대표 이미지 private TextMeshProUGUI tutorialMultiplierText; // 2단 점프 x2 표시 private TextMeshProUGUI tutorialTitleText; // 튜토리얼 제목 private TextMeshProUGUI tutorialBodyText; // 튜토리얼 설명 private Image[] tutorialItemImages; // 아이템 순서 설명 이미지 private TMP_FontAsset tutorialFontAsset; // 한글 튜토리얼 문구용 런타임 폰트 private float tutorialGuideSequenceStartTime; // 튜토리얼 안내 시작 시각 private int currentTutorialGuideSequenceIndex = -1; // 현재 표시 중인 안내 단계 private bool tutorialGuideSequenceComplete = false; // 기본 튜토리얼 안내 완료 여부 private MapDefinition currentMapDefinition; // 현재 플레이 중인 맵 데이터 private float stageElapsedTime = 0f; // 현재 맵 진행 시간 private float stageDistance = 0f; // 현재 맵 진행 거리 private bool stageCompleted = false; // 결과 화면이 이미 처리되었는지 여부 private Coroutine tutorialGuidePauseRoutine; // 안내를 보여주는 동안 잠깐 멈추는 코루틴 private bool tutorialGuidePauseActive = false; // 안내 일시정지 중인지 여부 private float tutorialGuidePreviousTimeScale = 1f; // 안내 일시정지 전 시간 배율 private int tutorialGuideVersion = 0; // 오래된 자동 숨김 요청을 무시하기 위한 버전 private FinishGate activeFinishGate; // 목표 지점에 나타나는 맵별 클리어 게이트 private bool finishGateSpawned = false; // 현재 맵에서 도착 게이트를 이미 만들었는지 여부 private PlatformSpawner activePlatformSpawner; // 현재 맵의 발판/장애물 생성기 public int maxLife = 3; // 시작 목숨 public int initialMileage = 500; // 첫 시작시 지급할 초기 마일리지 public bool grantInitialMileage = true; // 초기 마일리지 지급 여부 public bool saveMileageImmediately = true; // 스테이지 정산 전까지는 획득 즉시 누적한다. public bool playTutorialGuideSequence = false; // 코스 데이터가 없을 때만 시간 기준 안내를 보여준다. public bool pauseGameplayForTutorialGuide = true; // 안내가 뜨는 동안 게임을 잠깐 멈춘다. public float tutorialGuidePauseDuration = 2f; // 안내를 보여주고 자동으로 숨길 시간 public float itemInvincibleDuration = 2.2f; // 라멘 아이템 무적 지속 시간 public float itemSpeedBoostAmount = 0.35f; // 운동화 아이템 즉시 속도 보정량 public float mileageBonusDuration = 7f; // 마일리지 카드 보너스 지속 시간 public int mileageBonusMultiplier = 2; // 마일리지 카드 획득 배율 public float finishGateSpawnX = 20f; // 도착 게이트가 처음 나타나는 x 위치 public float finishGatePlayerX = -6f; // 게이트가 클리어 판정을 내릴 플레이어 기준 x 위치 public float finishGateGroundY = -2.42f; // 도착 게이트가 바닥에 붙는 y 위치 public float finishGateFallbackDistance = 18f; // 게이트 트리거를 놓쳤을 때의 안전 클리어 거리 private const string TotalMileageKey = "UniRun.TotalMileage"; private const string InitialMileageGrantedKey = "UniRun.InitialMileageGranted"; private const string CharacterFaceResourcePath = "UI_JJ_Face"; private const string PassportPanelResourcePath = "UI_PassportPanel"; private const string LifeStampResourcePath = "UI_LifeStamp"; private const string PassportItemSlotResourcePath = "UI_PassportItemSlot"; private const float TutorialJumpGuideDuration = 3.5f; private const float TutorialDoubleJumpGuideDuration = 3.5f; private const float TutorialSlideGuideDuration = 3.5f; private const float TutorialItemGuideDuration = 5.2f; // 게임의 스피드를 올리는 변수 public float gameSpeed = 1f; public float minGameSpeed = 1f; public float speedIncreaseRate = 0.05f; public float maxGameSpeed = 2f; public float hitSpeedPenalty = 0.3f; private int shieldCharges = 0; // 방어막 아이템으로 막을 수 있는 남은 피격 횟수 private float itemInvincibleEndTime = 0f; // 라멘 무적 종료 시각 private float mileageBonusEndTime = 0f; // 마일리지 카드 보너스 종료 시각 public bool IsTutorialGuidePaused { get { return tutorialGuidePauseActive; } } // 게임 시작과 동시에 싱글톤을 구성 void Awake() { // 싱글톤 변수 instance가 비어있는가? if(instance == null) { // instance가 비어있다면(null) 그곳에 자기 자신을 할당 instance = this; } else { // instance에 이미 다른 GameManager 오브젝트가 할당되어 있는 경우 // 씬에 두개 이상의 GameManager 오브젝트가 존재한다는 의미. // 싱글톤 오브젝트는 하나만 존재해야 하므로 자신의 게임 오브젝트를 파괴 Debug.LogWarning("씬에 두개 이상의 게임 매니저가 존재합니다!"); Destroy(gameObject); } } private void Start() { currentLife = maxLife; totalMileage = GameFlowManager.Instance != null ? GameFlowManager.Instance.SaveData.totalMileage : PlayerPrefs.GetInt(TotalMileageKey, 0); if(grantInitialMileage && PlayerPrefs.GetInt(InitialMileageGrantedKey, 0) == 0) { totalMileage += initialMileage; PlayerPrefs.SetInt(TotalMileageKey, totalMileage); PlayerPrefs.SetInt(InitialMileageGrantedKey, 1); PlayerPrefs.Save(); } BuildRuntimeHUD(); BeginMap(GameFlowManager.Instance != null ? GameFlowManager.Instance.CurrentMap : MapDatabase.GetMap(MapId.TutorialIncheon)); SyncTotalMileageToFlow(); UpdateScoreUI(); } void Update() { // 게임 오버 상태에서 게임을 재시작할 수 있게 하는 처리 if(isGameover && GameFlowManager.Instance == null && Input.GetMouseButtonDown(0)) { SceneManager.LoadScene(SceneManager.GetActiveScene().name); } if(!GameFlowManager.IsGameplayActive) { UpdateScoreUI(); return; } if(!isGameover) { UpdateTutorialGuideSequence(); stageElapsedTime += Time.deltaTime; // 게임 진행시 게임 속도는 시간에 따라 증가 gameSpeed += speedIncreaseRate * Time.deltaTime; // 게임 최대 속도는 max를 넘어서지 못하게 gameSpeed = Mathf.Min(gameSpeed, maxGameSpeed); stageDistance += 10f * gameSpeed * Time.deltaTime; UpdateFinishGate(); if(currentMapDefinition != null && currentMapDefinition.targetDistance > 0f && stageDistance >= currentMapDefinition.targetDistance) { if(!ShouldUseFinishGate() || stageDistance >= currentMapDefinition.targetDistance + finishGateFallbackDistance) { CompleteStage(true); return; } } if(currentMapDefinition != null && currentMapDefinition.timeLimit > 0f && stageElapsedTime >= currentMapDefinition.timeLimit) { CompleteStage(false); return; } UpdateScoreUI(); } } public void BeginMap(MapDefinition mapDefinition) { StopTutorialGuidePause(true); Time.timeScale = 1f; currentMapDefinition = mapDefinition != null ? mapDefinition : MapDatabase.GetMap(MapId.TutorialIncheon); BackgroundLoop.ApplyResourceBackground(currentMapDefinition.backgroundResourcePath); score = 0; stageMileage = 0; currentLife = maxLife; gameSpeed = minGameSpeed; shieldCharges = 0; itemInvincibleEndTime = 0f; mileageBonusEndTime = 0f; stageElapsedTime = 0f; stageDistance = 0f; stageCompleted = false; finishGateSpawned = false; isGameover = false; tutorialGuideSequenceComplete = false; currentTutorialGuideSequenceIndex = -1; SetTutorialGuide(TutorialGuideType.None, "", ""); RemoveActiveFinishGate(); if(gameoverUI != null) { gameoverUI.SetActive(false); } activePlatformSpawner = FindFirstObjectByType(); if(activePlatformSpawner != null) { activePlatformSpawner.ApplyMap(currentMapDefinition); } StartTutorialGuideSequence(); UpdateScoreUI(); } // 점수를 증가시키는 메서드 public void AddScore(int newScore) { if(!isGameover) { score += newScore; UpdateScoreUI(); } } public void SetTutorialMessage(string message) { SetTutorialGuide(TutorialGuideType.None, "", message); } public void SetTutorialGuide(TutorialGuideType guideType, string title, string message) { tutorialGuideVersion++; tutorialGuideType = guideType; tutorialTitle = title; tutorialMessage = message; if(!HasTutorialGuideContent(guideType, title, message)) { StopTutorialGuidePause(true); UpdateScoreUI(); return; } UpdateScoreUI(); TryPauseForTutorialGuide(tutorialGuideVersion); } private bool HasTutorialGuideContent(TutorialGuideType guideType, string title, string message) { return guideType != TutorialGuideType.None || !string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(message); } private void TryPauseForTutorialGuide(int guideVersion) { if(!pauseGameplayForTutorialGuide || tutorialGuidePauseDuration <= 0f || isGameover || !GameFlowManager.IsGameplayActive) { return; } if(!tutorialGuidePauseActive) { tutorialGuidePreviousTimeScale = Time.timeScale; tutorialGuidePauseActive = true; } Time.timeScale = 0f; if(tutorialGuidePauseRoutine != null) { StopCoroutine(tutorialGuidePauseRoutine); } tutorialGuidePauseRoutine = StartCoroutine(HideTutorialGuideAfterPause(guideVersion)); } private IEnumerator HideTutorialGuideAfterPause(int guideVersion) { yield return new WaitForSecondsRealtime(tutorialGuidePauseDuration); tutorialGuidePauseRoutine = null; if(guideVersion == tutorialGuideVersion) { tutorialGuideVersion++; tutorialGuideType = TutorialGuideType.None; tutorialTitle = ""; tutorialMessage = ""; UpdateScoreUI(); } RestoreTutorialGuideTimeScale(); } private void StopTutorialGuidePause(bool restoreTimeScale) { if(tutorialGuidePauseRoutine != null) { StopCoroutine(tutorialGuidePauseRoutine); tutorialGuidePauseRoutine = null; } if(restoreTimeScale) { RestoreTutorialGuideTimeScale(); } } private void RestoreTutorialGuideTimeScale() { if(!tutorialGuidePauseActive) { return; } Time.timeScale = Mathf.Approximately(tutorialGuidePreviousTimeScale, 0f) ? 1f : tutorialGuidePreviousTimeScale; tutorialGuidePauseActive = false; } private void StartTutorialGuideSequence() { if(!ShouldUseTimeBasedTutorialGuide()) { tutorialGuideSequenceComplete = true; return; } tutorialGuideSequenceStartTime = Time.time; tutorialGuideSequenceComplete = false; currentTutorialGuideSequenceIndex = -1; UpdateTutorialGuideSequence(); } private void UpdateTutorialGuideSequence() { if(!ShouldUseTimeBasedTutorialGuide() || tutorialGuideSequenceComplete) { return; } float elapsedTime = Time.time - tutorialGuideSequenceStartTime; int guideIndex = GetTutorialGuideSequenceIndex(elapsedTime); if(guideIndex == currentTutorialGuideSequenceIndex) { return; } currentTutorialGuideSequenceIndex = guideIndex; ApplyTutorialGuideSequenceStep(guideIndex); } private bool ShouldUseTimeBasedTutorialGuide() { return playTutorialGuideSequence && !HasCourseDrivenTutorialGuide(); } private bool HasCourseDrivenTutorialGuide() { if(currentMapDefinition == null || currentMapDefinition.openingSteps == null || currentMapDefinition.openingSteps.Length == 0) { return false; } for(int i = 0; i < currentMapDefinition.openingSteps.Length; i++) { PlatformSpawner.TutorialStep step = currentMapDefinition.openingSteps[i]; if(step != null && (step.guideType != TutorialGuideType.None || !string.IsNullOrEmpty(step.title) || !string.IsNullOrEmpty(step.message))) { return true; } } return false; } private int GetTutorialGuideSequenceIndex(float elapsedTime) { if(elapsedTime < TutorialJumpGuideDuration) { return 0; } elapsedTime -= TutorialJumpGuideDuration; if(elapsedTime < TutorialDoubleJumpGuideDuration) { return 1; } elapsedTime -= TutorialDoubleJumpGuideDuration; if(elapsedTime < TutorialSlideGuideDuration) { return 2; } elapsedTime -= TutorialSlideGuideDuration; if(elapsedTime < TutorialItemGuideDuration) { return 3; } return 4; } private void ApplyTutorialGuideSequenceStep(int guideIndex) { switch(guideIndex) { case 0: SetTutorialGuide(TutorialGuideType.Jump, "점프", "왼쪽 클릭으로 가볍게 뛰어오릅니다."); break; case 1: SetTutorialGuide(TutorialGuideType.DoubleJump, "2단 점프", "공중에서 왼쪽 클릭을 한 번 더 누릅니다."); break; case 2: SetTutorialGuide(TutorialGuideType.Slide, "슬라이드", "오른쪽 클릭을 누르고 있으면 낮게 지나갑니다."); break; case 3: SetTutorialGuide(TutorialGuideType.Items, "아이템", "초밥 -> 라멘 -> 방어막 -> 운동화 -> 마일리지 카드 순서입니다."); break; default: tutorialGuideSequenceComplete = true; SetTutorialGuide(TutorialGuideType.None, "", ""); break; } } // 마일리지 토큰을 먹었을 때 호출되는 메서드 public void AddMileage(int amount) { if(isGameover) { return; } int finalAmount = IsMileageBonusActive() ? amount * Mathf.Max(1, mileageBonusMultiplier) : amount; stageMileage += finalAmount; score += Mathf.Max(1, finalAmount / 10); if(saveMileageImmediately) { totalMileage += finalAmount; PlayerPrefs.SetInt(TotalMileageKey, totalMileage); PlayerPrefs.Save(); SyncTotalMileageToFlow(); } UpdateScoreUI(); } // 플레이어가 장애물에 부딪혔을 때 목숨과 속도를 줄이는 메서드 public bool TakeDamage(int damage = 1) { if(isGameover || !GameFlowManager.IsGameplayActive) { return false; } currentLife -= damage; currentLife = Mathf.Max(currentLife, 0); gameSpeed -= hitSpeedPenalty; gameSpeed = Mathf.Max(gameSpeed, minGameSpeed); UpdateScoreUI(); return currentLife <= 0; } public void ApplyTutorialItem(TutorialItemType itemType) { if(isGameover || !GameFlowManager.IsGameplayActive) { return; } switch(itemType) { case TutorialItemType.HealthSushi: currentLife = Mathf.Min(maxLife, currentLife + 1); AddScore(3); if(ShouldShowItemEffectGuide()) { SetTutorialGuide(TutorialGuideType.Items, "초밥", "방금 잃은 목숨을 1 회복합니다."); } break; case TutorialItemType.InvincibleRamen: itemInvincibleEndTime = Time.time + itemInvincibleDuration; if(ShouldShowItemEffectGuide()) { SetTutorialGuide(TutorialGuideType.Items, "라멘", "짧은 시간 동안 다음 충돌 피해를 받지 않습니다."); } break; case TutorialItemType.Shield: shieldCharges = Mathf.Max(shieldCharges, 1); if(ShouldShowItemEffectGuide()) { SetTutorialGuide(TutorialGuideType.Items, "방어막", "다음 충돌 1회를 방어막이 대신 막습니다."); } break; case TutorialItemType.SpeedShoes: gameSpeed = Mathf.Min(maxGameSpeed, gameSpeed + itemSpeedBoostAmount); if(ShouldShowItemEffectGuide()) { SetTutorialGuide(TutorialGuideType.Items, "운동화", "속도를 즉시 회복해 늦어진 일정을 따라잡습니다."); } break; case TutorialItemType.MileageCard: mileageBonusEndTime = Time.time + mileageBonusDuration; if(ShouldShowItemEffectGuide()) { SetTutorialGuide(TutorialGuideType.Items, "마일리지 카드", "잠시 동안 먹는 마일리지가 두 배로 들어옵니다."); } break; } UpdateScoreUI(); } public bool TryBlockDamageWithItem() { if(isGameover || !GameFlowManager.IsGameplayActive) { return false; } if(IsItemInvincibleActive()) { if(ShouldShowItemEffectGuide()) { SetTutorialGuide(TutorialGuideType.Items, "라멘", "무적 중이라 이번 충돌은 피해 없이 지나갑니다."); } SpawnItemBlockFeedback(TutorialItemType.InvincibleRamen); return true; } if(shieldCharges > 0) { shieldCharges--; SpawnItemBlockFeedback(TutorialItemType.Shield); if(ShouldShowItemEffectGuide()) { SetTutorialGuide(TutorialGuideType.Items, "방어막", "방어막이 충돌 피해를 한 번 막았습니다."); } UpdateScoreUI(); return true; } return false; } private void SpawnItemBlockFeedback(TutorialItemType itemType) { GameObject playerObject = GameObject.FindGameObjectWithTag("Player"); if(playerObject == null) { return; } ItemFeedbackEffect.Spawn(playerObject.transform.position, null, itemType, true); } private bool ShouldShowItemEffectGuide() { return currentMapDefinition == null || !currentMapDefinition.isTutorial; } private bool IsItemInvincibleActive() { return Time.time < itemInvincibleEndTime; } private bool IsMileageBonusActive() { return Time.time < mileageBonusEndTime; } // 플레이어 캐릭터가 사망시 게임 오버를 실행하는 메서드 public void OnPlayerDead() { isGameover = true; if(gameoverUI != null) { gameoverUI.SetActive(GameFlowManager.Instance == null); } CompleteStage(false); } public void CompleteCurrentStageFromFinishGate() { if(currentMapDefinition != null && currentMapDefinition.targetDistance > 0f) { stageDistance = Mathf.Max(stageDistance, currentMapDefinition.targetDistance); } CompleteStage(true); } private void CompleteStage(bool cleared) { if(stageCompleted) { return; } StopTutorialGuidePause(true); RemoveActiveFinishGate(); stageCompleted = true; isGameover = true; if(GameFlowManager.Instance != null) { GameFlowManager.Instance.CompleteMap(cleared, score, stageMileage, stageElapsedTime, stageDistance); } else if(gameoverUI != null) { gameoverUI.SetActive(true); } } private void UpdateFinishGate() { if(!ShouldUseFinishGate() || finishGateSpawned || stageCompleted) { return; } float spawnLeadDistance = Mathf.Max(0f, finishGateSpawnX - finishGatePlayerX); float spawnDistance = Mathf.Max(0f, currentMapDefinition.targetDistance - spawnLeadDistance); if(stageDistance < spawnDistance) { return; } float gateGroundY = finishGateGroundY; EnsureFinishGateLanding(gateGroundY); GameObject gateObject = new GameObject("Finish Gate - " + currentMapDefinition.displayName); gateObject.transform.position = new Vector3(finishGateSpawnX, gateGroundY, 0f); activeFinishGate = gateObject.AddComponent(); activeFinishGate.Setup(currentMapDefinition.finishGateStyle); finishGateSpawned = true; if(activePlatformSpawner != null) { activePlatformSpawner.StopCourseSpawningAfterFinishGate(); } } private void EnsureFinishGateLanding(float gateGroundY) { if(activePlatformSpawner == null) { activePlatformSpawner = FindFirstObjectByType(); } if(activePlatformSpawner != null) { activePlatformSpawner.SpawnFinishLandingPlatform(finishGateSpawnX, gateGroundY); } } private bool ShouldUseFinishGate() { return currentMapDefinition != null && currentMapDefinition.finishGateStyle != FinishGateStyle.None && currentMapDefinition.targetDistance > 0f; } private void RemoveActiveFinishGate() { if(activeFinishGate != null) { Destroy(activeFinishGate.gameObject); activeFinishGate = null; } if(activePlatformSpawner != null) { activePlatformSpawner.ClearFinishLandingPlatform(); } } private void SyncTotalMileageToFlow() { if(GameFlowManager.Instance != null) { GameFlowManager.Instance.SetTotalMileage(totalMileage); } } private void UpdateScoreUI() { if(scoreText != null) { string mapName = currentMapDefinition != null ? currentMapDefinition.displayName : "RUN"; string distanceText = currentMapDefinition != null ? Mathf.FloorToInt(stageDistance) + "/" + Mathf.FloorToInt(currentMapDefinition.targetDistance) + "m" : Mathf.FloorToInt(stageDistance) + "m"; string timeText = currentMapDefinition != null && currentMapDefinition.timeLimit > 0f ? Mathf.Max(0f, currentMapDefinition.timeLimit - stageElapsedTime).ToString("0") + "s" : stageElapsedTime.ToString("0") + "s"; scoreText.text = mapName + "\nSCORE " + score + "\nMILE " + stageMileage + "\nTOTAL " + totalMileage + "\nDIST " + distanceText + "\nTIME " + timeText + "\nSPD " + gameSpeed.ToString("0.0"); } if(lifeIcons != null) { for(int i = 0; i < lifeIcons.Length; i++) { if(lifeIcons[i] == null) { continue; } bool hasLife = i < currentLife; lifeIcons[i].color = hasLife ? GetActiveLifeIconColor(lifeIcons[i]) : new Color(0.34f, 0.34f, 0.34f, 0.72f); } } RefreshTutorialGuide(); } private void RefreshTutorialGuide() { if(tutorialGuideObject == null) { return; } bool hasGuide = tutorialGuideType != TutorialGuideType.None || !string.IsNullOrEmpty(tutorialTitle) || !string.IsNullOrEmpty(tutorialMessage); tutorialGuideObject.SetActive(hasGuide); if(!hasGuide) { return; } if(tutorialTitleText != null) { tutorialTitleText.text = tutorialTitle; } if(tutorialBodyText != null) { tutorialBodyText.text = tutorialMessage; } int activeItemImageIndex = GetTutorialItemImageIndex(); bool showItemImage = tutorialGuideType == TutorialGuideType.Items && HasTutorialItemImage(activeItemImageIndex); Sprite mainSprite = showItemImage ? null : GetTutorialMainSprite(tutorialGuideType); bool showFullText = !showItemImage && mainSprite == null; UpdateTutorialTextLayout(showItemImage, showFullText); if(tutorialMainImage != null) { tutorialMainImage.gameObject.SetActive(!showItemImage && mainSprite != null); tutorialMainImage.sprite = mainSprite; } if(tutorialMultiplierText != null) { tutorialMultiplierText.gameObject.SetActive(tutorialGuideType == TutorialGuideType.DoubleJump); } if(tutorialItemImages != null) { for(int i = 0; i < tutorialItemImages.Length; i++) { if(tutorialItemImages[i] != null) { tutorialItemImages[i].gameObject.SetActive(showItemImage && i == activeItemImageIndex); } } } } private void UpdateTutorialTextLayout(bool showItemRow, bool showFullText) { Vector2 textPosition = new Vector2(66f, -8f); Vector2 textSize = new Vector2(268f, 17f); Vector2 bodyPosition = new Vector2(66f, -26f); Vector2 bodySize = new Vector2(268f, 31f); if(showItemRow) { textPosition = new Vector2(80f, -8f); textSize = new Vector2(252f, 17f); bodyPosition = new Vector2(80f, -26f); bodySize = new Vector2(252f, 31f); } else if(showFullText) { textPosition = new Vector2(16f, -8f); textSize = new Vector2(316f, 17f); bodyPosition = new Vector2(16f, -26f); bodySize = new Vector2(316f, 31f); } if(tutorialTitleText != null) { tutorialTitleText.rectTransform.anchoredPosition = textPosition; tutorialTitleText.rectTransform.sizeDelta = textSize; } if(tutorialBodyText != null) { tutorialBodyText.rectTransform.anchoredPosition = bodyPosition; tutorialBodyText.rectTransform.sizeDelta = bodySize; } } private bool HasTutorialItemImage(int itemImageIndex) { return tutorialItemImages != null && itemImageIndex >= 0 && itemImageIndex < tutorialItemImages.Length && tutorialItemImages[itemImageIndex] != null && tutorialItemImages[itemImageIndex].sprite != null; } private int GetTutorialItemImageIndex() { if(tutorialGuideType != TutorialGuideType.Items) { return -1; } string itemText = (tutorialTitle + " " + tutorialMessage).Trim(); if(itemText.Contains("초밥")) { return 0; } if(itemText.Contains("라멘")) { return 1; } if(itemText.Contains("방어막")) { return 2; } if(itemText.Contains("운동화")) { return 3; } if(itemText.Contains("마일리지")) { return 4; } return -1; } private Color GetActiveLifeIconColor(Image lifeIcon) { if(lifeIcon != null && lifeIcon.sprite == null) { return new Color(0.94f, 0.18f, 0.12f, 0.9f); } return Color.white; } private void BuildRuntimeHUD() { if(scoreText == null) { return; } Transform canvasTransform = scoreText.transform.parent; ConfigureRightInfoText(); CreateStatusHUD(canvasTransform); CreateTutorialGuide(canvasTransform); } private void ConfigureRightInfoText() { RectTransform rectTransform = scoreText.rectTransform; rectTransform.anchorMin = new Vector2(1f, 1f); rectTransform.anchorMax = new Vector2(1f, 1f); rectTransform.pivot = new Vector2(1f, 1f); rectTransform.anchoredPosition = new Vector2(-12f, -9f); rectTransform.sizeDelta = new Vector2(176f, 96f); scoreText.alignment = TextAlignmentOptions.TopRight; scoreText.fontSize = 8.5f; scoreText.lineSpacing = -13f; scoreText.color = new Color(0.94f, 0.97f, 1f, 0.92f); scoreText.raycastTarget = false; scoreText.textWrappingMode = TextWrappingModes.NoWrap; } private void CreateStatusHUD(Transform canvasTransform) { GameObject hudObject = new GameObject("Status HUD", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); hudObject.transform.SetParent(canvasTransform, false); RectTransform hudRect = hudObject.GetComponent(); hudRect.anchorMin = new Vector2(0f, 1f); hudRect.anchorMax = new Vector2(0f, 1f); hudRect.pivot = new Vector2(0f, 1f); hudRect.anchoredPosition = new Vector2(8f, -7f); hudRect.sizeDelta = new Vector2(108f, 34f); Image hudBackground = hudObject.GetComponent(); hudBackground.sprite = LoadHudSprite(PassportPanelResourcePath); hudBackground.color = hudBackground.sprite != null ? Color.white : new Color(0.04f, 0.08f, 0.11f, 0.68f); hudBackground.raycastTarget = false; CreateFaceIcon(hudObject.transform); CreateLifeIcons(hudObject.transform); CreateItemSlots(hudObject.transform); } private void CreateFaceIcon(Transform parent) { GameObject faceFrame = new GameObject("Face Frame", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); faceFrame.transform.SetParent(parent, false); RectTransform frameRect = faceFrame.GetComponent(); frameRect.anchorMin = new Vector2(0f, 1f); frameRect.anchorMax = new Vector2(0f, 1f); frameRect.pivot = new Vector2(0f, 1f); frameRect.anchoredPosition = new Vector2(6f, -6f); frameRect.sizeDelta = new Vector2(20f, 20f); Image frameImage = faceFrame.GetComponent(); frameImage.color = new Color(0.12f, 0.16f, 0.17f, 0.36f); frameImage.raycastTarget = false; GameObject faceObject = new GameObject("Character Face", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); faceObject.transform.SetParent(faceFrame.transform, false); RectTransform faceRect = faceObject.GetComponent(); faceRect.anchorMin = Vector2.zero; faceRect.anchorMax = Vector2.one; faceRect.offsetMin = new Vector2(2f, 2f); faceRect.offsetMax = new Vector2(-2f, -2f); Image faceImage = faceObject.GetComponent(); faceImage.sprite = LoadHudSprite(CharacterFaceResourcePath); faceImage.preserveAspect = true; faceImage.raycastTarget = false; faceImage.color = faceImage.sprite != null ? Color.white : new Color(1f, 1f, 1f, 0f); } private void CreateLifeIcons(Transform parent) { Sprite lifeSprite = LoadHudSprite(LifeStampResourcePath); int iconCount = Mathf.Max(maxLife, 1); lifeIcons = new Image[iconCount]; for(int i = 0; i < iconCount; i++) { GameObject lifeObject = new GameObject("Life Stamp " + (i + 1), typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); lifeObject.transform.SetParent(parent, false); RectTransform lifeRect = lifeObject.GetComponent(); lifeRect.anchorMin = new Vector2(0f, 1f); lifeRect.anchorMax = new Vector2(0f, 1f); lifeRect.pivot = new Vector2(0f, 1f); lifeRect.anchoredPosition = new Vector2(33f + (i * 13f), -7f); lifeRect.sizeDelta = new Vector2(10f, 10f); Image lifeImage = lifeObject.GetComponent(); lifeImage.sprite = lifeSprite; lifeImage.color = lifeSprite != null ? Color.white : new Color(0.94f, 0.18f, 0.12f, 0.9f); lifeImage.preserveAspect = true; lifeImage.raycastTarget = false; lifeIcons[i] = lifeImage; } } private void CreateItemSlots(Transform parent) { Sprite slotSprite = LoadHudSprite(PassportItemSlotResourcePath); for(int i = 0; i < 3; i++) { GameObject slotObject = new GameObject("Item Slot " + (i + 1), typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); slotObject.transform.SetParent(parent, false); RectTransform slotRect = slotObject.GetComponent(); slotRect.anchorMin = new Vector2(0f, 1f); slotRect.anchorMax = new Vector2(0f, 1f); slotRect.pivot = new Vector2(0f, 1f); slotRect.anchoredPosition = new Vector2(33f + (i * 13f), -20f); slotRect.sizeDelta = new Vector2(10f, 10f); Image slotImage = slotObject.GetComponent(); slotImage.sprite = slotSprite; slotImage.color = slotSprite != null ? new Color(1f, 1f, 1f, 0.92f) : new Color(0.92f, 0.97f, 1f, 0.22f); slotImage.preserveAspect = true; slotImage.raycastTarget = false; } } private Sprite LoadHudSprite(string resourcePath) { Sprite sprite = Resources.Load(resourcePath); if(sprite != null) { return sprite; } Texture2D texture = Resources.Load(resourcePath); #if UNITY_EDITOR if(texture == null) { sprite = AssetDatabase.LoadAssetAtPath("Assets/Resources/" + resourcePath + ".png"); if(sprite != null) { return sprite; } texture = AssetDatabase.LoadAssetAtPath("Assets/Resources/" + resourcePath + ".png"); } #endif if(texture == null) { return null; } return Sprite.Create( texture, new Rect(0f, 0f, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100f); } private void CreateTutorialGuide(Transform canvasTransform) { tutorialGuideObject = new GameObject("Tutorial Guide", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); tutorialGuideObject.transform.SetParent(canvasTransform, false); RectTransform guideRect = tutorialGuideObject.GetComponent(); guideRect.anchorMin = new Vector2(0.5f, 1f); guideRect.anchorMax = new Vector2(0.5f, 1f); guideRect.pivot = new Vector2(0.5f, 1f); guideRect.anchoredPosition = new Vector2(0f, -12f); guideRect.sizeDelta = new Vector2(348f, 66f); Image guideBackground = tutorialGuideObject.GetComponent(); guideBackground.color = new Color(0.035f, 0.055f, 0.07f, 0.82f); guideBackground.raycastTarget = false; tutorialFontAsset = CreateKoreanTutorialFontAsset(); CreateTutorialMainImage(tutorialGuideObject.transform); CreateTutorialItemRow(tutorialGuideObject.transform); tutorialTitleText = CreateTutorialText(tutorialGuideObject.transform, "Tutorial Title", new Vector2(66f, -8f), new Vector2(268f, 17f), 11.5f, FontStyles.Bold); tutorialBodyText = CreateTutorialText(tutorialGuideObject.transform, "Tutorial Body", new Vector2(66f, -26f), new Vector2(268f, 31f), 8.5f, FontStyles.Normal); tutorialGuideObject.SetActive(false); } private void CreateTutorialMainImage(Transform parent) { GameObject imageObject = new GameObject("Tutorial Main Image", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); imageObject.transform.SetParent(parent, false); RectTransform imageRect = imageObject.GetComponent(); imageRect.anchorMin = new Vector2(0f, 0.5f); imageRect.anchorMax = new Vector2(0f, 0.5f); imageRect.pivot = new Vector2(0f, 0.5f); imageRect.anchoredPosition = new Vector2(14f, 0f); imageRect.sizeDelta = new Vector2(48f, 48f); tutorialMainImage = imageObject.GetComponent(); tutorialMainImage.preserveAspect = true; tutorialMainImage.raycastTarget = false; GameObject multiplierObject = new GameObject("Double Jump Marker", typeof(RectTransform), typeof(CanvasRenderer)); multiplierObject.transform.SetParent(imageObject.transform, false); RectTransform multiplierRect = multiplierObject.GetComponent(); multiplierRect.anchorMin = new Vector2(1f, 0f); multiplierRect.anchorMax = new Vector2(1f, 0f); multiplierRect.pivot = new Vector2(1f, 0f); multiplierRect.anchoredPosition = new Vector2(-2f, 1f); multiplierRect.sizeDelta = new Vector2(22f, 15f); tutorialMultiplierText = multiplierObject.AddComponent(); tutorialMultiplierText.font = scoreText.font; tutorialMultiplierText.text = "x2"; tutorialMultiplierText.alignment = TextAlignmentOptions.Center; tutorialMultiplierText.fontSize = 9f; tutorialMultiplierText.fontStyle = FontStyles.Bold; tutorialMultiplierText.color = new Color(1f, 0.91f, 0.33f, 1f); tutorialMultiplierText.raycastTarget = false; tutorialMultiplierText.textWrappingMode = TextWrappingModes.NoWrap; tutorialMultiplierText.gameObject.SetActive(false); } private void CreateTutorialItemRow(Transform parent) { string[] itemAssetPaths = { "Assets/Sprites/Items/Item_Health_Sushi.png", "Assets/Sprites/Items/Item_Invincible_Ramen.png", "Assets/Sprites/Items/Item_Shield.png", "Assets/Sprites/Items/Item_Speed_Shoes.png", "Assets/Sprites/Items/Item_Mileage_Card.png" }; tutorialItemImages = new Image[itemAssetPaths.Length]; for(int i = 0; i < itemAssetPaths.Length; i++) { GameObject itemObject = new GameObject("Tutorial Item " + (i + 1), typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); itemObject.transform.SetParent(parent, false); RectTransform itemRect = itemObject.GetComponent(); itemRect.anchorMin = new Vector2(0f, 0.5f); itemRect.anchorMax = new Vector2(0f, 0.5f); itemRect.pivot = new Vector2(0f, 0.5f); itemRect.anchoredPosition = new Vector2(14f, 0f); itemRect.sizeDelta = new Vector2(54f, 54f); Image itemImage = itemObject.GetComponent(); itemImage.sprite = LoadTutorialSprite(itemAssetPaths[i]); itemImage.preserveAspect = true; itemImage.raycastTarget = false; itemImage.gameObject.SetActive(false); tutorialItemImages[i] = itemImage; } } private TextMeshProUGUI CreateTutorialText(Transform parent, string objectName, Vector2 anchoredPosition, Vector2 sizeDelta, float fontSize, FontStyles fontStyle) { GameObject textObject = new GameObject(objectName, typeof(RectTransform), typeof(CanvasRenderer)); textObject.transform.SetParent(parent, false); RectTransform textRect = textObject.GetComponent(); textRect.anchorMin = new Vector2(0f, 1f); textRect.anchorMax = new Vector2(0f, 1f); textRect.pivot = new Vector2(0f, 1f); textRect.anchoredPosition = anchoredPosition; textRect.sizeDelta = sizeDelta; TextMeshProUGUI text = textObject.AddComponent(); text.font = tutorialFontAsset != null ? tutorialFontAsset : scoreText.font; text.alignment = TextAlignmentOptions.Left; text.fontSize = fontSize; text.fontStyle = fontStyle; text.color = new Color(0.96f, 0.98f, 1f, 0.96f); text.raycastTarget = false; text.textWrappingMode = TextWrappingModes.Normal; return text; } private TMP_FontAsset CreateKoreanTutorialFontAsset() { string[] familyNames = { "Malgun Gothic", "맑은 고딕", "Arial Unicode MS" }; string[] styleNames = { "Regular", "Normal" }; for(int familyIndex = 0; familyIndex < familyNames.Length; familyIndex++) { for(int styleIndex = 0; styleIndex < styleNames.Length; styleIndex++) { TMP_FontAsset fontAsset = TMP_FontAsset.CreateFontAsset(familyNames[familyIndex], styleNames[styleIndex], 80); if(fontAsset != null) { fontAsset.name = "Runtime Korean Tutorial Font"; return fontAsset; } } } return null; } private Sprite GetTutorialMainSprite(TutorialGuideType guideType) { if(guideType == TutorialGuideType.Jump || guideType == TutorialGuideType.DoubleJump) { return LoadTutorialSprite("Assets/Sprites/JJ_Jump.png", "JJ_Jump_2"); } if(guideType == TutorialGuideType.Slide) { return LoadTutorialSprite("Assets/Sprites/JJ_Slide.png", "JJ_Slide_0"); } if(guideType == TutorialGuideType.Damage) { return LoadTutorialSprite("Assets/Resources/UI_LifeStamp.png"); } return null; } private Sprite LoadTutorialSprite(string assetPath, string spriteName = "") { #if UNITY_EDITOR Object[] assets = AssetDatabase.LoadAllAssetRepresentationsAtPath(assetPath); Sprite firstSprite = null; if(assets != null && assets.Length > 0) { for(int i = 0; i < assets.Length; i++) { Sprite sprite = assets[i] as Sprite; if(sprite == null) { continue; } if(firstSprite == null) { firstSprite = sprite; } if(sprite.name == spriteName) { return sprite; } } if(firstSprite != null) { return firstSprite; } } Sprite directSprite = AssetDatabase.LoadAssetAtPath(assetPath); if(directSprite != null) { return directSprite; } Texture2D texture = AssetDatabase.LoadAssetAtPath(assetPath); if(texture == null) { return null; } return Sprite.Create( texture, new Rect(0f, 0f, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100f); #else return null; #endif } private class CourseMinimapView { private struct MarkerInfo { public string label; public string kind; public Color color; } private const float RootWidth = 452f; private const float RootHeight = 72f; private const float CourseWidth = 386f; private const float CourseAreaHeight = 36f; private const float CourseAreaY = -9f; private const float GroundY = -22f; private const float CeilingY = 3f; private const float PlatformHeight = 6f; private const float PlatformDistanceWidth = 7.4f; private const float CourseDistancePerInterval = 10f; private const int MaxCourseObjectCount = 96; private readonly List markerObjects = new List(); private RectTransform rootRect; private RectTransform progressFillRect; private RectTransform playerMarkerRect; private TextMeshProUGUI routeText; private TextMeshProUGUI progressText; private TMP_FontAsset fontAsset; private bool hasActiveMap = false; private MapId activeMapId; private float activeTargetDistance = -1f; public void Build(Transform canvasTransform, TMP_FontAsset minimapFont) { fontAsset = minimapFont; GameObject rootObject = new GameObject("Course Minimap", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); rootObject.transform.SetParent(canvasTransform, false); rootRect = rootObject.GetComponent(); rootRect.anchorMin = new Vector2(0.5f, 0f); rootRect.anchorMax = new Vector2(0.5f, 0f); rootRect.pivot = new Vector2(0.5f, 0f); rootRect.anchoredPosition = new Vector2(0f, 9f); rootRect.sizeDelta = new Vector2(RootWidth, RootHeight); Image background = rootObject.GetComponent(); background.color = new Color(0.025f, 0.045f, 0.055f, 0.78f); background.raycastTarget = false; routeText = CreateText(rootObject.transform, "Route Label", new Vector2(-207f, 24f), new Vector2(270f, 14f), 8.4f, TextAlignmentOptions.Left); progressText = CreateText(rootObject.transform, "Route Progress", new Vector2(174f, 24f), new Vector2(94f, 14f), 8.4f, TextAlignmentOptions.Right); CreateBlock(rootObject.transform, "Course Drawing Area", new Vector2(0f, CourseAreaY), new Vector2(CourseWidth + 18f, CourseAreaHeight), new Color(0.04f, 0.08f, 0.095f, 0.78f)); GameObject progressObject = new GameObject("Track Progress", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); progressObject.transform.SetParent(rootObject.transform, false); progressFillRect = progressObject.GetComponent(); progressFillRect.anchorMin = new Vector2(0.5f, 0.5f); progressFillRect.anchorMax = new Vector2(0.5f, 0.5f); progressFillRect.pivot = new Vector2(0f, 0.5f); progressFillRect.anchoredPosition = new Vector2(-CourseWidth * 0.5f, CourseAreaY); progressFillRect.sizeDelta = new Vector2(0f, CourseAreaHeight); Image progressImage = progressObject.GetComponent(); progressImage.color = new Color(0.18f, 0.55f, 0.68f, 0.28f); progressImage.raycastTarget = false; Image playerMarker = CreateBlock(rootObject.transform, "Player Marker", new Vector2(GetCourseX(0f), GroundY + 14f), new Vector2(7f, 24f), new Color(1f, 0.78f, 0.21f, 1f)); playerMarkerRect = playerMarker.rectTransform; } public void Refresh(MapDefinition mapDefinition, float stageDistance) { if(rootRect == null) { return; } if(mapDefinition == null) { rootRect.gameObject.SetActive(false); return; } rootRect.gameObject.SetActive(true); float targetDistance = Mathf.Max(1f, mapDefinition.targetDistance); bool mapChanged = !hasActiveMap || activeMapId != mapDefinition.mapId || !Mathf.Approximately(activeTargetDistance, targetDistance); if(mapChanged) { hasActiveMap = true; activeMapId = mapDefinition.mapId; activeTargetDistance = targetDistance; RebuildMarkers(mapDefinition, targetDistance); if(routeText != null) { routeText.text = !string.IsNullOrEmpty(mapDefinition.routeName) ? mapDefinition.routeName : mapDefinition.displayName; } } float progress = Mathf.Clamp01(stageDistance / targetDistance); if(progressFillRect != null) { progressFillRect.sizeDelta = new Vector2(CourseWidth * progress, CourseAreaHeight); } if(playerMarkerRect != null) { playerMarkerRect.anchoredPosition = new Vector2(GetCourseX(progress), GroundY + 14f); playerMarkerRect.SetAsLastSibling(); } if(progressText != null) { progressText.text = Mathf.FloorToInt(stageDistance) + "/" + Mathf.FloorToInt(targetDistance) + "m"; } } private void RebuildMarkers(MapDefinition mapDefinition, float targetDistance) { ClearMarkers(); float cursor = 0f; AddStepMarkers(mapDefinition.openingSteps, ref cursor, targetDistance); PlatformSpawner.TutorialStep[] loopSteps = mapDefinition.loopSteps; int loopGuard = 0; while(cursor < targetDistance && loopSteps != null && loopSteps.Length > 0 && markerObjects.Count < MaxCourseObjectCount && loopGuard < 300) { AddStepMarkers(loopSteps, ref cursor, targetDistance); loopGuard++; } } private void AddStepMarkers(PlatformSpawner.TutorialStep[] steps, ref float cursor, float targetDistance) { if(steps == null) { return; } for(int i = 0; i < steps.Length; i++) { PlatformSpawner.TutorialStep step = steps[i]; float stepDistance = GetStepDistance(step); if(markerObjects.Count < MaxCourseObjectCount) { float centerDistance = cursor + (stepDistance * 0.5f); float centerProgress = Mathf.Clamp01(centerDistance / targetDistance); float platformWidth = Mathf.Max(8f, CourseWidth * (PlatformDistanceWidth / targetDistance)); CreatePlatformPiece(centerProgress, platformWidth, GetPlatformColor(step)); if(TryGetMarkerInfo(step, out MarkerInfo markerInfo)) { CreateCourseFeature(centerProgress, markerInfo); } } cursor += stepDistance; if(cursor >= targetDistance && markerObjects.Count >= MaxCourseObjectCount) { return; } } } private float GetStepDistance(PlatformSpawner.TutorialStep step) { if(step == null) { return CourseDistancePerInterval; } return Mathf.Max(0.3f, step.spawnInterval) * CourseDistancePerInterval; } private bool TryGetMarkerInfo(PlatformSpawner.TutorialStep step, out MarkerInfo markerInfo) { markerInfo = new MarkerInfo(); if(step == null) { return false; } if(step.itemPattern != Platform.ItemPattern.None) { markerInfo.label = GetItemMarkerLabel(step.itemPattern); markerInfo.color = new Color(0.38f, 0.93f, 0.57f, 0.98f); markerInfo.kind = "Item"; return true; } if(step.coursePlatformType == PlatformSpawner.CoursePlatformType.ForceHit || step.platformPattern == Platform.PlatformPattern.ForceHit) { markerInfo.label = "!"; markerInfo.color = new Color(1f, 0.34f, 0.28f, 0.98f); markerInfo.kind = "ForceHit"; return true; } if(step.guideType == TutorialGuideType.DoubleJump || step.spawnInterval >= 1.4f) { markerInfo.label = "2"; markerInfo.color = new Color(0.40f, 0.89f, 1f, 0.98f); markerInfo.kind = "DoubleJump"; return true; } if(step.coursePlatformType == PlatformSpawner.CoursePlatformType.Slide || step.platformPattern == Platform.PlatformPattern.Slide) { markerInfo.label = "S"; markerInfo.color = new Color(0.47f, 0.63f, 1f, 0.98f); markerInfo.kind = "Slide"; return true; } if(step.coursePlatformType == PlatformSpawner.CoursePlatformType.Jump || step.platformPattern == Platform.PlatformPattern.LowLeft || step.platformPattern == Platform.PlatformPattern.LowMid || step.platformPattern == Platform.PlatformPattern.LowRight) { markerInfo.label = "J"; markerInfo.color = new Color(1f, 0.83f, 0.28f, 0.98f); markerInfo.kind = "Jump"; return true; } return false; } private string GetItemMarkerLabel(Platform.ItemPattern itemPattern) { switch(itemPattern) { case Platform.ItemPattern.HealthSushi: return "+"; case Platform.ItemPattern.InvincibleRamen: return "R"; case Platform.ItemPattern.Shield: return "G"; case Platform.ItemPattern.SpeedShoes: return "V"; case Platform.ItemPattern.MileageCard: return "M"; default: return "I"; } } private void CreatePlatformPiece(float progress, float width, Color color) { Image platformImage = CreateBlock(rootRect, "Course Platform", new Vector2(GetCourseX(progress), GroundY), new Vector2(width, PlatformHeight), color); markerObjects.Add(platformImage.gameObject); } private Color GetPlatformColor(PlatformSpawner.TutorialStep step) { if(step != null && step.spawnInterval >= 1.4f) { return new Color(0.28f, 0.50f, 0.57f, 0.96f); } return new Color(0.33f, 0.63f, 0.70f, 0.96f); } private void CreateCourseFeature(float progress, MarkerInfo markerInfo) { if(markerInfo.kind == "Slide") { CreateSlideFeature(progress, markerInfo); return; } if(markerInfo.kind == "ForceHit") { CreateJumpFeature(progress, markerInfo); CreateSlideFeature(progress, markerInfo); return; } if(markerInfo.kind == "Item") { CreateItemFeature(progress, markerInfo); return; } if(markerInfo.kind == "DoubleJump") { CreateDoubleJumpFeature(progress, markerInfo); return; } CreateJumpFeature(progress, markerInfo); } private void CreateJumpFeature(float progress, MarkerInfo markerInfo) { float x = GetCourseX(progress); Image obstacle = CreateBlock(rootRect, "Jump Obstacle", new Vector2(x, GroundY + 8f), new Vector2(10f, 12f), markerInfo.color); markerObjects.Add(obstacle.gameObject); CreateTinyLabel(markerInfo.label, new Vector2(x, GroundY + 8f), new Color(0.02f, 0.04f, 0.05f, 0.95f)); } private void CreateSlideFeature(float progress, MarkerInfo markerInfo) { float x = GetCourseX(progress); Image ceiling = CreateBlock(rootRect, "Slide Ceiling", new Vector2(x, CeilingY), new Vector2(32f, 6f), new Color(0.56f, 0.70f, 0.98f, 0.95f)); markerObjects.Add(ceiling.gameObject); Image hanging = CreateBlock(rootRect, "Slide Hanging Obstacle", new Vector2(x, CeilingY - 8f), new Vector2(18f, 10f), markerInfo.color); markerObjects.Add(hanging.gameObject); CreateTinyLabel(markerInfo.label, new Vector2(x, CeilingY - 8f), new Color(0.02f, 0.04f, 0.05f, 0.95f)); } private void CreateItemFeature(float progress, MarkerInfo markerInfo) { float x = GetCourseX(progress); GameObject markerObject = new GameObject("Course Marker " + markerInfo.label, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); markerObject.transform.SetParent(rootRect, false); RectTransform markerRect = markerObject.GetComponent(); markerRect.anchorMin = new Vector2(0.5f, 0.5f); markerRect.anchorMax = new Vector2(0.5f, 0.5f); markerRect.pivot = new Vector2(0.5f, 0.5f); markerRect.anchoredPosition = new Vector2(x, GroundY + 15f); markerRect.sizeDelta = new Vector2(11f, 11f); Image markerImage = markerObject.GetComponent(); markerImage.color = markerInfo.color; markerImage.raycastTarget = false; TextMeshProUGUI markerText = CreateText(markerObject.transform, "Label", Vector2.zero, new Vector2(11f, 11f), 6.4f, TextAlignmentOptions.Center); markerText.color = new Color(0.02f, 0.04f, 0.05f, 0.95f); markerText.fontStyle = FontStyles.Bold; markerText.text = markerInfo.label; markerObjects.Add(markerObject); } private void CreateDoubleJumpFeature(float progress, MarkerInfo markerInfo) { float x = GetCourseX(progress); Image gapMarker = CreateBlock(rootRect, "Double Jump Gap", new Vector2(x, GroundY + 14f), new Vector2(24f, 3f), markerInfo.color); markerObjects.Add(gapMarker.gameObject); CreateTinyLabel(markerInfo.label, new Vector2(x, GroundY + 21f), markerInfo.color); } private void CreateTinyLabel(string label, Vector2 anchoredPosition, Color color) { TextMeshProUGUI markerText = CreateText(rootRect, "Course Label " + label, anchoredPosition, new Vector2(14f, 11f), 6.4f, TextAlignmentOptions.Center); markerText.color = color; markerText.fontStyle = FontStyles.Bold; markerText.text = label; markerObjects.Add(markerText.gameObject); } private TextMeshProUGUI CreateText(Transform parent, string objectName, Vector2 anchoredPosition, Vector2 sizeDelta, float fontSize, TextAlignmentOptions alignment) { GameObject textObject = new GameObject(objectName, typeof(RectTransform), typeof(CanvasRenderer)); textObject.transform.SetParent(parent, false); RectTransform textRect = textObject.GetComponent(); textRect.anchorMin = new Vector2(0.5f, 0.5f); textRect.anchorMax = new Vector2(0.5f, 0.5f); textRect.pivot = new Vector2(0.5f, 0.5f); textRect.anchoredPosition = anchoredPosition; textRect.sizeDelta = sizeDelta; TextMeshProUGUI text = textObject.AddComponent(); text.font = fontAsset; text.alignment = alignment; text.fontSize = fontSize; text.color = new Color(0.88f, 0.96f, 1f, 0.92f); text.raycastTarget = false; text.textWrappingMode = TextWrappingModes.NoWrap; return text; } private Image CreateBlock(Transform parent, string objectName, Vector2 anchoredPosition, Vector2 sizeDelta, Color color) { GameObject blockObject = new GameObject(objectName, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); blockObject.transform.SetParent(parent, false); RectTransform blockRect = blockObject.GetComponent(); blockRect.anchorMin = new Vector2(0.5f, 0.5f); blockRect.anchorMax = new Vector2(0.5f, 0.5f); blockRect.pivot = new Vector2(0.5f, 0.5f); blockRect.anchoredPosition = anchoredPosition; blockRect.sizeDelta = sizeDelta; Image image = blockObject.GetComponent(); image.color = color; image.raycastTarget = false; return image; } private float GetCourseX(float progress) { return (-CourseWidth * 0.5f) + (CourseWidth * Mathf.Clamp01(progress)); } private void ClearMarkers() { for(int i = 0; i < markerObjects.Count; i++) { if(markerObjects[i] != null) { Object.Destroy(markerObjects[i]); } } markerObjects.Clear(); } } }