커스텀 로딩 화면
앱인토스 Unity 게임에서 브랜드에 맞는 커스텀 로딩 화면을 구현하여 사용자 경험을 향상시키는 방법을 다뤄요.
1. 커스텀 로딩 시스템 개요
로딩 화면 구성 요소
🎨 앱인토스 로딩 화면 구조
├── 브랜드 영역
│ ├── 게임 로고
│ └── 브랜딩 배경
├── 진행률 표시
│ ├── 프로그레스 바
│ ├── 퍼센트 텍스트
│ └── 로딩 메시지
├── 인터랙션 요소
│ ├── 애니메이션 효과
│ ├── 터치 힌트
│ └── 스킵 버튼 (선택적)
└── 시스템 정보
├── 네트워크 상태
├── 에러 메시지
└── 재시도 버튼커스텀 로딩 매니저
c#
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using System.Collections.Generic;
using TMPro;
public class AppsInTossLoadingManager : MonoBehaviour
{
public static AppsInTossLoadingManager Instance { get; private set; }
[System.Serializable]
public class LoadingConfig
{
[Header("브랜딩 설정")]
public Sprite tossLogo;
public Sprite gameLogo;
public Color primaryColor = Color.blue;
public Color secondaryColor = Color.white;
public Gradient backgroundGradient;
[Header("애니메이션 설정")]
public float logoAnimationDuration = 2f;
public AnimationCurve logoScaleCurve = AnimationCurve.EaseInOut(0, 0.8f, 1, 1f);
public bool enableParticleEffect = true;
public ParticleSystem backgroundParticles;
[Header("진행률 설정")]
public bool showProgressBar = true;
public bool showPercentage = true;
public bool showLoadingTips = true;
public string[] loadingTips;
[Header("터치 인터랙션")]
public bool enableTouchToSkip = false;
public float skipAfterSeconds = 3f;
public string skipHintText = "화면을 탭하여 건너뛰기";
}
[Header("로딩 설정")]
public LoadingConfig config;
[Header("UI 컴포넌트")]
public Canvas loadingCanvas;
public Image backgroundImage;
public Image tossLogoImage;
public Image gameLogoImage;
public Slider progressSlider;
public TextMeshProUGUI progressText;
public TextMeshProUGUI statusText;
public TextMeshProUGUI tipText;
public Button skipButton;
public GameObject errorPanel;
public TextMeshProUGUI errorText;
public Button retryButton;
[Header("애니메이션 컴포넌트")]
public Animator loadingAnimator;
public ParticleSystem[] particleSystems;
// 내부 상태
private float currentProgress = 0f;
private string currentStatus = "";
private bool isLoadingComplete = false;
private bool canSkip = false;
private int currentTipIndex = 0;
private Coroutine tipRotationCoroutine;
void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
InitializeLoadingScreen();
}
else
{
Destroy(gameObject);
}
}
void InitializeLoadingScreen()
{
// 브랜딩 적용
ApplyBrandingSettings();
// 초기 UI 설정
SetupInitialUI();
// 애니메이션 시작
StartLoadingAnimations();
// 이벤트 리스너 등록
RegisterEventListeners();
Debug.Log("AppsInToss 커스텀 로딩 화면 초기화 완료");
}
void ApplyBrandingSettings()
{
// 로고 설정
if (config.tossLogo != null)
tossLogoImage.sprite = config.tossLogo;
if (config.gameLogo != null)
gameLogoImage.sprite = config.gameLogo;
// 색상 테마 적용
progressSlider.fillRect.GetComponent<Image>().color = config.primaryColor;
// 배경 그라디언트 적용
if (config.backgroundGradient != null)
{
ApplyGradientBackground();
}
// 파티클 효과 설정
if (config.enableParticleEffect && particleSystems.Length > 0)
{
SetupParticleEffects();
}
}
void ApplyGradientBackground()
{
// 그라디언트 머티리얼 생성 및 적용
var gradientMaterial = new Material(Shader.Find("UI/Gradient"));
gradientMaterial.SetColor("_TopColor", config.backgroundGradient.Evaluate(0f));
gradientMaterial.SetColor("_BottomColor", config.backgroundGradient.Evaluate(1f));
backgroundImage.material = gradientMaterial;
}
void SetupParticleEffects()
{
foreach (var particles in particleSystems)
{
if (particles != null)
{
var main = particles.main;
main.startColor = config.primaryColor;
particles.Play();
}
}
}
void SetupInitialUI()
{
// 진행률 초기화
progressSlider.value = 0f;
progressSlider.gameObject.SetActive(config.showProgressBar);
// 텍스트 초기화
progressText.gameObject.SetActive(config.showPercentage);
tipText.gameObject.SetActive(config.showLoadingTips);
// 스킵 버튼 설정
skipButton.gameObject.SetActive(false);
skipButton.onClick.AddListener(OnSkipButtonClicked);
// 재시도 버튼 설정
retryButton.onClick.AddListener(OnRetryButtonClicked);
errorPanel.SetActive(false);
// 초기 상태 설정
UpdateStatus("게임을 준비하고 있습니다...");
}
void StartLoadingAnimations()
{
// 로고 등장 애니메이션
StartCoroutine(AnimateLogoEntrance());
// 로딩 팁 순환 시작
if (config.showLoadingTips && config.loadingTips.Length > 0)
{
tipRotationCoroutine = StartCoroutine(RotateLoadingTips());
}
// 스킵 가능 시점 설정
if (config.enableTouchToSkip)
{
StartCoroutine(EnableSkipAfterDelay());
}
}
IEnumerator AnimateLogoEntrance()
{
// 토스 로고 애니메이션
tossLogoImage.transform.localScale = Vector3.one * 0.8f;
gameLogoImage.transform.localScale = Vector3.one * 0.8f;
float elapsedTime = 0f;
while (elapsedTime < config.logoAnimationDuration)
{
elapsedTime += Time.deltaTime;
float progress = elapsedTime / config.logoAnimationDuration;
float scaleValue = config.logoScaleCurve.Evaluate(progress);
tossLogoImage.transform.localScale = Vector3.one * scaleValue;
gameLogoImage.transform.localScale = Vector3.one * scaleValue;
yield return null;
}
// 최종 스케일 설정
tossLogoImage.transform.localScale = Vector3.one;
gameLogoImage.transform.localScale = Vector3.one;
// 로고 펄스 애니메이션 시작
if (loadingAnimator != null)
{
loadingAnimator.SetTrigger("StartPulse");
}
}
IEnumerator RotateLoadingTips()
{
while (!isLoadingComplete)
{
if (config.loadingTips.Length > 0)
{
tipText.text = config.loadingTips[currentTipIndex];
currentTipIndex = (currentTipIndex + 1) % config.loadingTips.Length;
}
yield return new WaitForSeconds(3f);
}
}
IEnumerator EnableSkipAfterDelay()
{
yield return new WaitForSeconds(config.skipAfterSeconds);
if (!isLoadingComplete)
{
canSkip = true;
skipButton.gameObject.SetActive(true);
// 스킵 힌트 표시
StartCoroutine(ShowSkipHint());
}
}
IEnumerator ShowSkipHint()
{
var hintText = skipButton.GetComponentInChildren<TextMeshProUGUI>();
hintText.text = config.skipHintText;
// 깜박임 효과
while (canSkip && !isLoadingComplete)
{
hintText.color = new Color(hintText.color.r, hintText.color.g, hintText.color.b, 0.5f);
yield return new WaitForSeconds(0.5f);
hintText.color = new Color(hintText.color.r, hintText.color.g, hintText.color.b, 1f);
yield return new WaitForSeconds(0.5f);
}
}
void RegisterEventListeners()
{
AppsInToss.OnEvent += HandleAppsInTossEvent;
AppsInToss.OnError += HandleAppsInTossError;
}
void HandleAppsInTossEvent(string eventName, Dictionary<string, object> data)
{
switch (eventName)
{
case "loading_progress":
float progress = (float)data["progress"];
string status = data["status"] as string;
UpdateProgress(progress, status);
break;
case "loading_complete":
CompleteLoading();
break;
case "asset_loaded":
string assetName = data["asset_name"] as string;
UpdateStatus($"{assetName} 로딩 완료");
break;
}
}
void HandleAppsInTossError(string errorType, string errorMessage)
{
ShowError(errorMessage);
}
public void UpdateProgress(float progress, string status = null)
{
currentProgress = Mathf.Clamp01(progress);
// UI 업데이트
if (config.showProgressBar)
{
progressSlider.value = currentProgress;
}
if (config.showPercentage)
{
progressText.text = $"{currentProgress * 100f:F0}%";
}
if (!string.IsNullOrEmpty(status))
{
UpdateStatus(status);
}
// 완료 체크
if (currentProgress >= 1f)
{
CompleteLoading();
}
}
public void UpdateStatus(string status)
{
currentStatus = status;
statusText.text = status;
Debug.Log($"로딩 상태 업데이트: {status}");
}
public void ShowError(string errorMessage)
{
errorText.text = errorMessage;
errorPanel.SetActive(true);
// 로딩 애니메이션 일시 정지
if (loadingAnimator != null)
{
loadingAnimator.speed = 0f;
}
Debug.LogError($"로딩 에러: {errorMessage}");
}
public void CompleteLoading()
{
if (isLoadingComplete) return;
isLoadingComplete = true;
canSkip = false;
// 팁 순환 중지
if (tipRotationCoroutine != null)
{
StopCoroutine(tipRotationCoroutine);
}
// 완료 애니메이션 시작
StartCoroutine(PlayCompletionAnimation());
}
IEnumerator PlayCompletionAnimation()
{
UpdateStatus("게임 시작 준비 완료!");
// 진행률 100% 표시
if (config.showProgressBar)
{
progressSlider.value = 1f;
}
if (config.showPercentage)
{
progressText.text = "100%";
}
// 완료 애니메이션
if (loadingAnimator != null)
{
loadingAnimator.SetTrigger("Complete");
}
// 완료 효과
PlayCompletionEffects();
yield return new WaitForSeconds(1f);
// 로딩 화면 페이드 아웃
yield return StartCoroutine(FadeOutLoadingScreen());
// 게임 씬 로드
LoadGameScene();
}
void PlayCompletionEffects()
{
// 파티클 효과 강화
foreach (var particles in particleSystems)
{
if (particles != null)
{
var emission = particles.emission;
emission.rateOverTime = emission.rateOverTime.constant * 3f;
}
}
// 로고 펄스 효과
StartCoroutine(PulseLogo(tossLogoImage.transform));
StartCoroutine(PulseLogo(gameLogoImage.transform));
}
IEnumerator PulseLogo(Transform logoTransform)
{
Vector3 originalScale = logoTransform.localScale;
Vector3 targetScale = originalScale * 1.1f;
// 확대
float duration = 0.3f;
float elapsedTime = 0f;
while (elapsedTime < duration)
{
elapsedTime += Time.deltaTime;
float progress = elapsedTime / duration;
logoTransform.localScale = Vector3.Lerp(originalScale, targetScale, progress);
yield return null;
}
// 축소
elapsedTime = 0f;
while (elapsedTime < duration)
{
elapsedTime += Time.deltaTime;
float progress = elapsedTime / duration;
logoTransform.localScale = Vector3.Lerp(targetScale, originalScale, progress);
yield return null;
}
logoTransform.localScale = originalScale;
}
IEnumerator FadeOutLoadingScreen()
{
CanvasGroup canvasGroup = loadingCanvas.GetComponent<CanvasGroup>();
if (canvasGroup == null)
{
canvasGroup = loadingCanvas.gameObject.AddComponent<CanvasGroup>();
}
float fadeTime = 1f;
float elapsedTime = 0f;
while (elapsedTime < fadeTime)
{
elapsedTime += Time.deltaTime;
canvasGroup.alpha = 1f - (elapsedTime / fadeTime);
yield return null;
}
canvasGroup.alpha = 0f;
loadingCanvas.gameObject.SetActive(false);
}
void LoadGameScene()
{
// 게임 씬 로드
UnityEngine.SceneManagement.SceneManager.LoadScene("GameScene");
// 분석 데이터 전송
SendLoadingAnalytics();
}
void SendLoadingAnalytics()
{
var analyticsData = new Dictionary<string, object>
{
{"loading_completed", true},
{"final_progress", currentProgress},
{"last_status", currentStatus},
{"skip_used", false},
{"completion_time", Time.time},
{"device_model", SystemInfo.deviceModel},
{"timestamp", System.DateTime.UtcNow.ToString("o")}
};
AppsInToss.SendAnalytics("custom_loading_complete", analyticsData);
}
void OnSkipButtonClicked()
{
if (canSkip && !isLoadingComplete)
{
Debug.Log("사용자가 로딩을 건너뛰었습니다");
// 스킵 분석 데이터
var skipData = new Dictionary<string, object>
{
{"skip_time", Time.time},
{"progress_at_skip", currentProgress},
{"status_at_skip", currentStatus}
};
AppsInToss.SendAnalytics("loading_skipped", skipData);
// 강제 완료
CompleteLoading();
}
}
void OnRetryButtonClicked()
{
// 에러 패널 숨기기
errorPanel.SetActive(false);
// 로딩 재시작
currentProgress = 0f;
isLoadingComplete = false;
if (loadingAnimator != null)
{
loadingAnimator.speed = 1f;
}
UpdateStatus("다시 시도하는 중...");
// 재시도 이벤트 발생
AppsInToss.SendEvent("loading_retry", new Dictionary<string, object>());
}
// 터치 입력 처리
void Update()
{
if (config.enableTouchToSkip && canSkip && !isLoadingComplete)
{
if (Input.touchCount > 0 && Input.GetTouch(0).phase == TouchPhase.Began)
{
OnSkipButtonClicked();
}
// 마우스 클릭도 지원 (에디터 테스트용)
if (Input.GetMouseButtonDown(0))
{
OnSkipButtonClicked();
}
}
}
// 공개 API
public bool IsLoadingComplete()
{
return isLoadingComplete;
}
public float GetCurrentProgress()
{
return currentProgress;
}
public string GetCurrentStatus()
{
return currentStatus;
}
public void SetCustomTip(string tip)
{
if (tipText != null)
{
tipText.text = tip;
}
}
void OnDestroy()
{
AppsInToss.OnEvent -= HandleAppsInTossEvent;
AppsInToss.OnError -= HandleAppsInTossError;
}
}2. 로딩 화면 템플릿
기본 템플릿
c#
public class BasicLoadingTemplate : MonoBehaviour
{
[Header("기본 설정")]
public Image backgroundImage;
public Image logoImage;
public Slider progressBar;
public Text statusText;
public void SetupBasicTemplate(Color brandColor, Sprite logo)
{
// 브랜드 색상 적용
progressBar.fillRect.GetComponent<Image>().color = brandColor;
// 로고 설정
logoImage.sprite = logo;
// 단순한 배경색
backgroundImage.color = new Color(brandColor.r, brandColor.g, brandColor.b, 0.1f);
}
}고급 템플릿
c#
public class AdvancedLoadingTemplate : MonoBehaviour
{
[Header("고급 설정")]
public ParticleSystem backgroundParticles;
public Animator logoAnimator;
public Image[] decorativeElements;
public TextMeshProUGUI[] animatedTexts;
[Header("애니메이션 설정")]
public AnimationCurve fadeInCurve;
public AnimationCurve scaleCurve;
public float animationDuration = 2f;
public void SetupAdvancedTemplate(AppsInTossLoadingManager.LoadingConfig config)
{
// 파티클 시스템 설정
SetupParticles(config);
// 로고 애니메이션 설정
SetupLogoAnimation(config);
// 장식 요소 애니메이션
SetupDecorativeAnimations(config);
// 텍스트 애니메이션
SetupTextAnimations(config);
}
void SetupParticles(AppsInTossLoadingManager.LoadingConfig config)
{
if (backgroundParticles != null)
{
var main = backgroundParticles.main;
main.startColor = config.primaryColor;
main.startSize = 0.1f;
var emission = backgroundParticles.emission;
emission.rateOverTime = 50;
var shape = backgroundParticles.shape;
shape.shapeType = ParticleSystemShapeType.Rectangle;
backgroundParticles.Play();
}
}
void SetupLogoAnimation(AppsInTossLoadingManager.LoadingConfig config)
{
if (logoAnimator != null)
{
logoAnimator.SetFloat("Duration", config.logoAnimationDuration);
logoAnimator.SetTrigger("StartAnimation");
}
}
void SetupDecorativeAnimations(AppsInTossLoadingManager.LoadingConfig config)
{
for (int i = 0; i < decorativeElements.Length; i++)
{
StartCoroutine(AnimateDecorativeElement(decorativeElements[i], i * 0.2f));
}
}
IEnumerator AnimateDecorativeElement(Image element, float delay)
{
yield return new WaitForSeconds(delay);
element.color = new Color(element.color.r, element.color.g, element.color.b, 0f);
float elapsedTime = 0f;
while (elapsedTime < animationDuration)
{
elapsedTime += Time.deltaTime;
float progress = fadeInCurve.Evaluate(elapsedTime / animationDuration);
element.color = new Color(element.color.r, element.color.g, element.color.b, progress);
element.transform.localScale = Vector3.one * scaleCurve.Evaluate(progress);
yield return null;
}
}
void SetupTextAnimations(AppsInTossLoadingManager.LoadingConfig config)
{
foreach (var text in animatedTexts)
{
StartCoroutine(AnimateText(text));
}
}
IEnumerator AnimateText(TextMeshProUGUI text)
{
string originalText = text.text;
text.text = "";
for (int i = 0; i <= originalText.Length; i++)
{
text.text = originalText.Substring(0, i);
yield return new WaitForSeconds(0.05f);
}
}
}3. 에디터 도구
로딩 화면 프리뷰어
c#
#if UNITY_EDITOR
using UnityEngine;
using UnityEditor;
[CustomEditor(typeof(AppsInTossLoadingManager))]
public class LoadingManagerEditor : Editor
{
private float previewProgress = 0f;
private bool isPreviewMode = false;
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space();
EditorGUILayout.LabelField("프리뷰 도구", EditorStyles.boldLabel);
AppsInTossLoadingManager manager = target as AppsInTossLoadingManager;
// 프리뷰 모드 토글
isPreviewMode = EditorGUILayout.Toggle("프리뷰 모드", isPreviewMode);
if (isPreviewMode)
{
// 진행률 슬라이더
previewProgress = EditorGUILayout.Slider("진행률", previewProgress, 0f, 1f);
if (Application.isPlaying && manager != null)
{
manager.UpdateProgress(previewProgress, $"테스트 진행률: {previewProgress * 100f:F0}%");
}
EditorGUILayout.Space();
// 테스트 버튼들
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("에러 테스트"))
{
if (Application.isPlaying && manager != null)
{
manager.ShowError("테스트 에러 메시지입니다.");
}
}
if (GUILayout.Button("완료 테스트"))
{
if (Application.isPlaying && manager != null)
{
manager.CompleteLoading();
}
}
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.Space();
// 설정 검증 버튼
if (GUILayout.Button("설정 검증"))
{
ValidateLoadingConfig(manager);
}
// 브랜딩 가이드라인 버튼
if (GUILayout.Button("브랜딩 가이드라인 확인"))
{
ShowBrandingGuidelines();
}
}
void ValidateLoadingConfig(AppsInTossLoadingManager manager)
{
var issues = new List<string>();
// 필수 컴포넌트 체크
if (manager.tossLogoImage == null)
issues.Add("토스 로고 이미지가 설정되지 않았습니다.");
if (manager.progressSlider == null && manager.config.showProgressBar)
issues.Add("진행률 바가 활성화되었지만 Slider 컴포넌트가 없습니다.");
// 브랜딩 체크
if (manager.config.tossLogo == null)
issues.Add("토스 로고 스프라이트가 설정되지 않았습니다.");
// 결과 표시
if (issues.Count == 0)
{
EditorUtility.DisplayDialog("검증 완료", "로딩 설정이 올바릅니다.", "확인");
}
else
{
string message = "다음 문제들을 해결해주세요:\n\n" + string.Join("\n", issues);
EditorUtility.DisplayDialog("검증 실패", message, "확인");
}
}
void ShowBrandingGuidelines()
{
string guidelines = @"AppsInToss 브랜딩 가이드라인:
1. 로고 사용
- 토스 로고는 항상 게임 로고와 함께 표시
- 최소 크기: 64x64 픽셀
- 명확한 여백 확보
2. 색상 사용
- 주 색상: 토스 블루 (#3182F7)
- 보조 색상: 화이트 (#FFFFFF)
- 배경: 그라디언트 권장
3. 애니메이션
- 부드러운 전환 효과 사용
- 과도한 움직임 피하기
- 2초 이내 완료 권장
4. 텍스트
- 한글: 본고딕 또는 시스템 기본
- 영문: Roboto 또는 시스템 기본
- 가독성 우선";
EditorUtility.DisplayDialog("브랜딩 가이드라인", guidelines, "확인");
}
}
#endif4. 성능 최적화
로딩 성능 모니터
c#
public class LoadingPerformanceMonitor : MonoBehaviour
{
private float loadingStartTime;
private Dictionary<string, float> phaseTimings = new Dictionary<string, float>();
void Start()
{
loadingStartTime = Time.realtimeSinceStartup;
AppsInToss.OnEvent += TrackLoadingPhases;
}
void TrackLoadingPhases(string eventName, Dictionary<string, object> data)
{
if (eventName.StartsWith("loading_"))
{
float currentTime = Time.realtimeSinceStartup - loadingStartTime;
phaseTimings[eventName] = currentTime;
Debug.Log($"로딩 단계: {eventName} - {currentTime:F2}초");
}
if (eventName == "loading_complete")
{
GeneratePerformanceReport();
}
}
void GeneratePerformanceReport()
{
float totalTime = Time.realtimeSinceStartup - loadingStartTime;
var report = new Dictionary<string, object>
{
{"total_loading_time", totalTime},
{"phase_timings", phaseTimings},
{"device_model", SystemInfo.deviceModel},
{"device_memory", SystemInfo.systemMemorySize},
{"graphics_device", SystemInfo.graphicsDeviceName}
};
AppsInToss.SendAnalytics("loading_performance", report);
Debug.Log($"로딩 성능 보고서: 총 {totalTime:F2}초 소요");
}
void OnDestroy()
{
AppsInToss.OnEvent -= TrackLoadingPhases;
}
}브랜드 일관성을 유지하면서도 사용자가 기다리는 시간을 즐겁게 만드는 로딩 화면을 설계하세요.
진행률과 상태를 명확히 표시하여 사용자 불안감을 해소하는 것이 중요해요.
