Files
Travel_Run/Assets/Scripts/GameManager.cs
T
2026-07-07 15:57:11 +09:00

1804 lines
68 KiB
C#

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 rightInfoHudBackground; // 오른쪽 거리/마일리지 수하물 라벨 배경
private TextMeshProUGUI rightMileageText; // 오른쪽 HUD의 적립 마일리지 텍스트
private Image[] lifeIcons; // 왼쪽 HUD의 목숨 도장 표시
private GameObject tutorialGuideObject; // 화면 상단 이미지 튜토리얼 패널
private Image tutorialMainImage; // 점프/슬라이드 대표 이미지
private TextMeshProUGUI tutorialMultiplierText; // 2단 점프 x2 표시
private TextMeshProUGUI tutorialTitleText; // 튜토리얼 제목
private TextMeshProUGUI tutorialBodyText; // 튜토리얼 설명
private Button tutorialNextButton; // 튜토리얼 안내 확인 버튼
private TextMeshProUGUI tutorialNextButtonText; // 확인 버튼 텍스트
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 bool tutorialGuidePauseActive = false; // 안내 일시정지 중인지 여부
private float tutorialGuidePreviousTimeScale = 1f; // 안내 일시정지 전 시간 배율
private int tutorialGuideDismissFrame = -1; // 확인 클릭이 플레이 입력으로 이어지지 않게 막는 프레임
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_PassportPhoto";
private const string PassportPanelResourcePath = "UI_LeftHudPassportCompactPanel";
private const string LifeStampResourcePath = "UI_LifeStamp";
private const string PassportItemSlotResourcePath = "UI_PassportItemSlot";
private const string TutorialGuideFrameResourcePath = "UI_TutorialAirportMonitorFrame";
private const string TutorialNextButtonResourcePath = "UI_TutorialNextButton";
private const string RightHudLuggageLabelKoreaResourcePath = "UI_RightHudLuggageLabel_Korea";
private const string RightHudLuggageLabelJapanResourcePath = "UI_RightHudLuggageLabel_Japan";
private const string RightHudLuggageLabelChinaResourcePath = "UI_RightHudLuggageLabel_China";
private const string RightHudLuggageLabelAmericaResourcePath = "UI_RightHudLuggageLabel_America";
private const float PassportHudWidth = 261f;
private const float PassportHudHeight = 102f;
private const float PassportHudScale = 0.5f;
private const float RightInfoHudWidth = 260f;
private const float RightInfoHudHeight = 64f;
private const float RightInfoHudScale = 0.6f;
private const float TutorialJumpGuideDuration = 3.5f;
private const float TutorialDoubleJumpGuideDuration = 3.5f;
private const float TutorialSlideGuideDuration = 3.5f;
private const float TutorialItemGuideDuration = 5.2f;
private const float TutorialGuideWidth = 392f;
private const float TutorialGuideHeight = 142f;
private const float TutorialGuideScale = 0.6f;
// 게임의 스피드를 올리는 변수
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 || tutorialGuideDismissFrame == Time.frameCount; }
}
// 게임 시작과 동시에 싱글톤을 구성
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);
UpdateRightInfoHudSkin();
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<PlatformSpawner>();
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();
}
private bool HasTutorialGuideContent(TutorialGuideType guideType, string title, string message) {
return guideType != TutorialGuideType.None
|| !string.IsNullOrEmpty(title)
|| !string.IsNullOrEmpty(message);
}
private void TryPauseForTutorialGuide() {
if(!pauseGameplayForTutorialGuide
|| isGameover
|| !GameFlowManager.IsGameplayActive)
{
return;
}
if(!tutorialGuidePauseActive)
{
tutorialGuidePreviousTimeScale = Time.timeScale;
tutorialGuidePauseActive = true;
}
Time.timeScale = 0f;
}
private void CompleteTutorialGuideByClick() {
tutorialGuideVersion++;
tutorialGuideType = TutorialGuideType.None;
tutorialTitle = "";
tutorialMessage = "";
tutorialGuideDismissFrame = Time.frameCount;
UpdateScoreUI();
RestoreTutorialGuideTimeScale();
}
private void StopTutorialGuidePause(bool restoreTimeScale) {
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++)
{
CourseStep 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<FinishGate>();
activeFinishGate.Setup(currentMapDefinition.finishGateStyle);
finishGateSpawned = true;
if(activePlatformSpawner != null)
{
activePlatformSpawner.StopCourseSpawningAfterFinishGate();
}
}
private void EnsureFinishGateLanding(float gateGroundY) {
if(activePlatformSpawner == null)
{
activePlatformSpawner = FindFirstObjectByType<PlatformSpawner>();
}
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 distanceText = currentMapDefinition != null
? Mathf.FloorToInt(stageDistance) + "/" + Mathf.FloorToInt(currentMapDefinition.targetDistance) + "m"
: Mathf.FloorToInt(stageDistance) + "m";
scoreText.text = "DIST\n" + distanceText;
}
if(rightMileageText != null)
{
rightMileageText.text = "MILE\n" + stageMileage;
}
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);
}
}
}
if(tutorialNextButton != null)
{
tutorialNextButton.gameObject.SetActive(hasGuide);
}
}
private void UpdateTutorialTextLayout(bool showItemRow, bool showFullText) {
Vector2 textPosition = new Vector2(118f, -38f);
Vector2 textSize = new Vector2(236f, 20f);
Vector2 bodyPosition = new Vector2(118f, -60f);
Vector2 bodySize = new Vector2(236f, 38f);
if(showItemRow)
{
textPosition = new Vector2(124f, -38f);
textSize = new Vector2(228f, 20f);
bodyPosition = new Vector2(124f, -60f);
bodySize = new Vector2(228f, 38f);
}
else if(showFullText)
{
textPosition = new Vector2(68f, -39f);
textSize = new Vector2(270f, 20f);
bodyPosition = new Vector2(68f, -61f);
bodySize = new Vector2(270f, 39f);
}
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;
CreateRightInfoHUD(canvasTransform);
CreateStatusHUD(canvasTransform);
CreateTutorialGuide(canvasTransform);
}
private void CreateRightInfoHUD(Transform canvasTransform) {
GameObject hudObject = new GameObject("Right Luggage HUD", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
hudObject.transform.SetParent(canvasTransform, false);
RectTransform hudRect = hudObject.GetComponent<RectTransform>();
hudRect.anchorMin = new Vector2(1f, 1f);
hudRect.anchorMax = new Vector2(1f, 1f);
hudRect.pivot = new Vector2(1f, 1f);
hudRect.anchoredPosition = new Vector2(-10f, -8f);
hudRect.sizeDelta = new Vector2(RightInfoHudWidth * RightInfoHudScale, RightInfoHudHeight * RightInfoHudScale);
hudRect.localScale = Vector3.one;
rightInfoHudBackground = hudObject.GetComponent<Image>();
rightInfoHudBackground.preserveAspect = false;
rightInfoHudBackground.raycastTarget = false;
UpdateRightInfoHudSkin();
scoreText.gameObject.name = "Distance Text";
scoreText.transform.SetParent(hudObject.transform, false);
ConfigureRightInfoText(scoreText, new Vector2(0.29f, 0.18f), new Vector2(0.61f, 0.74f));
GameObject mileageObject = new GameObject("Mileage Text", typeof(RectTransform), typeof(CanvasRenderer));
mileageObject.transform.SetParent(hudObject.transform, false);
rightMileageText = mileageObject.AddComponent<TextMeshProUGUI>();
rightMileageText.font = scoreText.font;
ConfigureRightInfoText(rightMileageText, new Vector2(0.62f, 0.18f), new Vector2(0.95f, 0.74f));
}
private void ConfigureRightInfoText(TextMeshProUGUI text, Vector2 anchorMin, Vector2 anchorMax) {
RectTransform rectTransform = text.rectTransform;
rectTransform.anchorMin = anchorMin;
rectTransform.anchorMax = anchorMax;
rectTransform.offsetMin = Vector2.zero;
rectTransform.offsetMax = Vector2.zero;
rectTransform.localScale = Vector3.one;
text.alignment = TextAlignmentOptions.Center;
text.fontSize = 9f;
text.enableAutoSizing = true;
text.fontSizeMin = 6.5f;
text.fontSizeMax = 10f;
text.lineSpacing = -7f;
text.color = new Color(0.015f, 0.045f, 0.085f, 1f);
text.fontStyle = FontStyles.Bold;
text.raycastTarget = false;
text.textWrappingMode = TextWrappingModes.NoWrap;
}
private void UpdateRightInfoHudSkin() {
if(rightInfoHudBackground == null)
{
return;
}
rightInfoHudBackground.sprite = LoadHudSprite(GetRightInfoHudResourcePath());
rightInfoHudBackground.color = rightInfoHudBackground.sprite != null
? Color.white
: new Color(0.95f, 0.97f, 1f, 0.88f);
}
private string GetRightInfoHudResourcePath() {
if(currentMapDefinition == null)
{
return RightHudLuggageLabelKoreaResourcePath;
}
switch(currentMapDefinition.mapId)
{
case MapId.Japan:
return RightHudLuggageLabelJapanResourcePath;
case MapId.China:
return RightHudLuggageLabelChinaResourcePath;
case MapId.America:
return RightHudLuggageLabelAmericaResourcePath;
case MapId.TutorialIncheon:
default:
return RightHudLuggageLabelKoreaResourcePath;
}
}
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<RectTransform>();
hudRect.anchorMin = new Vector2(0f, 1f);
hudRect.anchorMax = new Vector2(0f, 1f);
hudRect.pivot = new Vector2(0f, 1f);
hudRect.anchoredPosition = new Vector2(10f, -8f);
hudRect.sizeDelta = new Vector2(PassportHudWidth, PassportHudHeight);
hudRect.localScale = Vector3.one * PassportHudScale;
Image hudBackground = hudObject.GetComponent<Image>();
hudBackground.sprite = LoadHudSprite(PassportPanelResourcePath);
hudBackground.color = hudBackground.sprite != null
? Color.white
: new Color(0.04f, 0.08f, 0.11f, 0.68f);
hudBackground.preserveAspect = true;
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<RectTransform>();
frameRect.anchorMin = new Vector2(0f, 1f);
frameRect.anchorMax = new Vector2(0f, 1f);
frameRect.pivot = new Vector2(0f, 1f);
frameRect.anchoredPosition = new Vector2(32f, -20f);
frameRect.sizeDelta = new Vector2(72f, 68f);
Image frameImage = faceFrame.GetComponent<Image>();
frameImage.color = new Color(1f, 1f, 1f, 0f);
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<RectTransform>();
faceRect.anchorMin = Vector2.zero;
faceRect.anchorMax = Vector2.one;
faceRect.offsetMin = new Vector2(2f, 2f);
faceRect.offsetMax = new Vector2(-2f, -2f);
Image faceImage = faceObject.GetComponent<Image>();
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<RectTransform>();
lifeRect.anchorMin = new Vector2(0f, 1f);
lifeRect.anchorMax = new Vector2(0f, 1f);
lifeRect.pivot = new Vector2(0f, 1f);
lifeRect.anchoredPosition = new Vector2(125f + (i * 45f), -23f);
lifeRect.sizeDelta = new Vector2(28f, 28f);
Image lifeImage = lifeObject.GetComponent<Image>();
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<RectTransform>();
slotRect.anchorMin = new Vector2(0f, 1f);
slotRect.anchorMax = new Vector2(0f, 1f);
slotRect.pivot = new Vector2(0f, 1f);
slotRect.anchoredPosition = new Vector2(125f + (i * 45f), -62f);
slotRect.sizeDelta = new Vector2(30f, 30f);
Image slotImage = slotObject.GetComponent<Image>();
slotImage.sprite = slotSprite;
slotImage.color = slotSprite != null
? new Color(1f, 1f, 1f, 0.62f)
: new Color(0.92f, 0.97f, 1f, 0.22f);
slotImage.preserveAspect = true;
slotImage.raycastTarget = false;
}
}
private Sprite LoadHudSprite(string resourcePath) {
Sprite sprite = Resources.Load<Sprite>(resourcePath);
if(sprite != null)
{
return sprite;
}
Texture2D texture = Resources.Load<Texture2D>(resourcePath);
#if UNITY_EDITOR
if(texture == null)
{
sprite = AssetDatabase.LoadAssetAtPath<Sprite>("Assets/Resources/" + resourcePath + ".png");
if(sprite != null)
{
return sprite;
}
texture = AssetDatabase.LoadAssetAtPath<Texture2D>("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<RectTransform>();
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, -10f);
guideRect.sizeDelta = new Vector2(TutorialGuideWidth, TutorialGuideHeight);
guideRect.localScale = Vector3.one * TutorialGuideScale;
Image guideBackground = tutorialGuideObject.GetComponent<Image>();
guideBackground.sprite = LoadHudSprite(TutorialGuideFrameResourcePath);
guideBackground.color = guideBackground.sprite != null
? Color.white
: new Color(0.035f, 0.055f, 0.07f, 0.82f);
guideBackground.preserveAspect = false;
guideBackground.raycastTarget = false;
tutorialFontAsset = CreateKoreanTutorialFontAsset();
CreateTutorialMainImage(tutorialGuideObject.transform);
CreateTutorialItemRow(tutorialGuideObject.transform);
tutorialTitleText = CreateTutorialText(tutorialGuideObject.transform, "Tutorial Title", new Vector2(118f, -38f), new Vector2(236f, 20f), 12f, FontStyles.Bold);
tutorialBodyText = CreateTutorialText(tutorialGuideObject.transform, "Tutorial Body", new Vector2(118f, -60f), new Vector2(236f, 38f), 9f, FontStyles.Normal);
tutorialTitleText.color = new Color(0.06f, 0.13f, 0.19f, 0.98f);
tutorialBodyText.color = new Color(0.10f, 0.18f, 0.23f, 0.95f);
CreateTutorialNextButton(tutorialGuideObject.transform);
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<RectTransform>();
imageRect.anchorMin = new Vector2(0f, 1f);
imageRect.anchorMax = new Vector2(0f, 1f);
imageRect.pivot = new Vector2(0f, 1f);
imageRect.anchoredPosition = new Vector2(42f, -54f);
imageRect.sizeDelta = new Vector2(50f, 50f);
tutorialMainImage = imageObject.GetComponent<Image>();
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<RectTransform>();
multiplierRect.anchorMin = new Vector2(1f, 0f);
multiplierRect.anchorMax = new Vector2(1f, 0f);
multiplierRect.pivot = new Vector2(1f, 0f);
multiplierRect.anchoredPosition = new Vector2(8f, -4f);
multiplierRect.sizeDelta = new Vector2(42f, 28f);
tutorialMultiplierText = multiplierObject.AddComponent<TextMeshProUGUI>();
tutorialMultiplierText.font = scoreText.font;
tutorialMultiplierText.text = "X2";
tutorialMultiplierText.alignment = TextAlignmentOptions.Center;
tutorialMultiplierText.fontSize = 17f;
tutorialMultiplierText.fontStyle = FontStyles.Bold;
tutorialMultiplierText.color = new Color(1f, 0.91f, 0.33f, 1f);
tutorialMultiplierText.outlineColor = new Color(0.05f, 0.05f, 0.06f, 0.95f);
tutorialMultiplierText.outlineWidth = 0.18f;
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<RectTransform>();
itemRect.anchorMin = new Vector2(0f, 1f);
itemRect.anchorMax = new Vector2(0f, 1f);
itemRect.pivot = new Vector2(0f, 1f);
itemRect.anchoredPosition = new Vector2(38f, -50f);
itemRect.sizeDelta = new Vector2(58f, 58f);
Image itemImage = itemObject.GetComponent<Image>();
itemImage.sprite = LoadTutorialSprite(itemAssetPaths[i]);
itemImage.preserveAspect = true;
itemImage.raycastTarget = false;
itemImage.gameObject.SetActive(false);
tutorialItemImages[i] = itemImage;
}
}
private void CreateTutorialNextButton(Transform parent) {
GameObject buttonObject = new GameObject("Tutorial Confirm Button", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button));
buttonObject.transform.SetParent(parent, false);
RectTransform buttonRect = buttonObject.GetComponent<RectTransform>();
buttonRect.anchorMin = new Vector2(0f, 1f);
buttonRect.anchorMax = new Vector2(0f, 1f);
buttonRect.pivot = new Vector2(0f, 1f);
buttonRect.anchoredPosition = new Vector2(244f, -103f);
buttonRect.sizeDelta = new Vector2(112f, 30f);
Image buttonImage = buttonObject.GetComponent<Image>();
buttonImage.sprite = LoadHudSprite(TutorialNextButtonResourcePath);
buttonImage.color = buttonImage.sprite != null
? Color.white
: new Color(0.03f, 0.13f, 0.20f, 0.92f);
buttonImage.preserveAspect = false;
buttonImage.raycastTarget = true;
tutorialNextButton = buttonObject.GetComponent<Button>();
tutorialNextButton.targetGraphic = buttonImage;
tutorialNextButton.onClick.AddListener(CompleteTutorialGuideByClick);
ColorBlock colors = tutorialNextButton.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1f, 1f, 1f, 0.92f);
colors.pressedColor = new Color(0.78f, 0.92f, 1f, 0.95f);
colors.selectedColor = colors.highlightedColor;
colors.disabledColor = new Color(1f, 1f, 1f, 0.35f);
tutorialNextButton.colors = colors;
GameObject textObject = new GameObject("Tutorial Confirm Text", typeof(RectTransform), typeof(CanvasRenderer));
textObject.transform.SetParent(buttonObject.transform, false);
RectTransform textRect = textObject.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(34f, 4f);
textRect.offsetMax = new Vector2(-10f, -4f);
tutorialNextButtonText = textObject.AddComponent<TextMeshProUGUI>();
tutorialNextButtonText.font = tutorialFontAsset != null ? tutorialFontAsset : scoreText.font;
tutorialNextButtonText.text = "확인";
tutorialNextButtonText.alignment = TextAlignmentOptions.Center;
tutorialNextButtonText.fontSize = 9f;
tutorialNextButtonText.fontStyle = FontStyles.Bold;
tutorialNextButtonText.color = new Color(0.92f, 0.98f, 1f, 0.98f);
tutorialNextButtonText.raycastTarget = false;
tutorialNextButtonText.textWrappingMode = TextWrappingModes.NoWrap;
}
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<RectTransform>();
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<TextMeshProUGUI>();
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_2");
}
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<Sprite>(assetPath);
if(directSprite != null)
{
return directSprite;
}
Texture2D texture = AssetDatabase.LoadAssetAtPath<Texture2D>(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<GameObject> markerObjects = new List<GameObject>();
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<RectTransform>();
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<Image>();
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<RectTransform>();
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<Image>();
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);
CourseStep[] 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(CourseStep[] steps, ref float cursor, float targetDistance) {
if(steps == null)
{
return;
}
for(int i = 0; i < steps.Length; i++)
{
CourseStep 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(CourseStep step) {
if(step == null)
{
return CourseDistancePerInterval;
}
return Mathf.Max(0.3f, step.spawnInterval) * CourseDistancePerInterval;
}
private bool TryGetMarkerInfo(CourseStep 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 == 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 == 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 == 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(CourseStep 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);
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<RectTransform>();
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<Image>();
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<RectTransform>();
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<TextMeshProUGUI>();
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<RectTransform>();
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>();
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();
}
}
}