시작 스토리 디자인 가이드
AppsInToss Unity 게임에서 매력적이고 몰입도 높은 게임 시작 경험을 설계하는 방법을 다뤄요.
1. 런치 오페라 개념
런치 오페라란?
🎭 런치 오페라 구성요소
├── 브랜드 스토리텔링
│ ├── 토스 × 게임 브랜딩
│ ├── 게임 세계관 소개
│ └── 캐릭터/스토리 티저
├── 사용자 온보딩
│ ├── 직관적인 조작법 안내
│ ├── 핵심 게임 메커니즘 소개
│ └── 진행 목표 제시
├── 감정적 연결
│ ├── 음악과 사운드 디자인
│ ├── 시각적 임팩트
│ └── 개인화된 경험
└── 즉시 참여 유도
├── 빠른 액션 시퀀스
├── 성취감 제공
└── 다음 단계로의 자연스러운 전환런치 오페라 매니저
c#
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using System.Collections.Generic;
using TMPro;
public class LaunchOperaManager : MonoBehaviour
{
public static LaunchOperaManager Instance { get; private set; }
[System.Serializable]
public class OperaSequence
{
[Header("시퀀스 설정")]
public string sequenceName;
public float duration = 3f;
public bool skippable = true;
public bool autoAdvance = true;
[Header("시각적 요소")]
public GameObject[] visualElements;
public AnimationClip[] animations;
public Sprite backgroundImage;
public Color backgroundColor = Color.black;
[Header("오디오")]
public AudioClip backgroundMusic;
public AudioClip[] soundEffects;
public float musicVolume = 0.7f;
[Header("텍스트")]
public string title;
public string[] dialogues;
public float textSpeed = 50f; // 글자/초
public bool showCharacterByCharacter = true;
[Header("인터랙션")]
public bool requiresUserInput = false;
public string inputPrompt = "터치하여 계속";
public KeyCode[] skipKeys = { KeyCode.Space, KeyCode.Return };
}
[Header("런치 오페라 설정")]
public OperaSequence[] operaSequences;
public bool enableSkipAll = true;
public string skipAllText = "모두 건너뛰기";
[Header("UI 컴포넌트")]
public Canvas operaCanvas;
public Image backgroundImage;
public TextMeshProUGUI titleText;
public TextMeshProUGUI dialogueText;
public Button continueButton;
public Button skipButton;
public Button skipAllButton;
public Slider progressSlider;
[Header("애니메이션 컴포넌트")]
public Animator sceneAnimator;
public ParticleSystem[] atmosphereParticles;
[Header("오디오 컴포넌트")]
public AudioSource musicSource;
public AudioSource sfxSource;
// 내부 상태
private int currentSequenceIndex = 0;
private bool isPlaying = false;
private bool isSequenceComplete = false;
private bool userWantsToSkip = false;
private Coroutine currentSequenceCoroutine;
private Coroutine textAnimationCoroutine;
void Awake()
{
if (Instance == null)
{
Instance = this;
InitializeLaunchOpera();
}
else
{
Destroy(gameObject);
}
}
void InitializeLaunchOpera()
{
// UI 초기 설정
SetupInitialUI();
// 이벤트 리스너 등록
RegisterEventListeners();
// 오디오 초기화
InitializeAudio();
Debug.Log("런치 오페라 시스템 초기화 완료");
}
void SetupInitialUI()
{
// 초기 UI 상태
operaCanvas.gameObject.SetActive(true);
titleText.text = "";
dialogueText.text = "";
// 버튼 설정
continueButton.onClick.AddListener(OnContinueClicked);
skipButton.onClick.AddListener(OnSkipClicked);
skipAllButton.onClick.AddListener(OnSkipAllClicked);
// 스킵 버튼 표시/숨김
skipAllButton.gameObject.SetActive(enableSkipAll);
// 진행률 바 초기화
progressSlider.value = 0f;
progressSlider.maxValue = operaSequences.Length;
}
void RegisterEventListeners()
{
AppsInToss.OnEvent += HandleAppsInTossEvent;
}
void InitializeAudio()
{
// 오디오 소스 초기 설정
if (musicSource == null)
musicSource = gameObject.AddComponent<AudioSource>();
if (sfxSource == null)
sfxSource = gameObject.AddComponent<AudioSource>();
musicSource.loop = true;
sfxSource.loop = false;
}
void HandleAppsInTossEvent(string eventName, Dictionary<string, object> data)
{
switch (eventName)
{
case "opera_start":
StartLaunchOpera();
break;
case "opera_sequence_complete":
OnSequenceComplete();
break;
case "opera_user_skip":
userWantsToSkip = true;
break;
}
}
public void StartLaunchOpera()
{
if (isPlaying) return;
isPlaying = true;
currentSequenceIndex = 0;
Debug.Log("런치 오페라 시작");
// 첫 번째 시퀀스 시작
StartCurrentSequence();
// 분석 데이터 전송
SendOperaAnalytics("opera_started", currentSequenceIndex);
}
void StartCurrentSequence()
{
if (currentSequenceIndex >= operaSequences.Length)
{
CompleteLaunchOpera();
return;
}
var sequence = operaSequences[currentSequenceIndex];
isSequenceComplete = false;
userWantsToSkip = false;
Debug.Log($"시퀀스 시작: {sequence.sequenceName}");
// 현재 시퀀스 실행
if (currentSequenceCoroutine != null)
{
StopCoroutine(currentSequenceCoroutine);
}
currentSequenceCoroutine = StartCoroutine(PlaySequence(sequence));
// 진행률 업데이트
UpdateProgress();
}
IEnumerator PlaySequence(OperaSequence sequence)
{
// 시퀀스 시작 준비
yield return StartCoroutine(PrepareSequence(sequence));
// 백그라운드 뮤직 시작
if (sequence.backgroundMusic != null)
{
PlayBackgroundMusic(sequence.backgroundMusic, sequence.musicVolume);
}
// 시각적 요소 애니메이션
StartCoroutine(AnimateVisualElements(sequence));
// 대화/텍스트 표시
yield return StartCoroutine(ShowSequenceText(sequence));
// 사용자 입력 대기 또는 자동 진행
if (sequence.requiresUserInput)
{
yield return StartCoroutine(WaitForUserInput(sequence));
}
else if (!sequence.autoAdvance)
{
yield return new WaitForSeconds(sequence.duration);
}
// 시퀀스 완료
yield return StartCoroutine(CompleteSequence(sequence));
isSequenceComplete = true;
// 다음 시퀀스로 진행
if (!userWantsToSkip)
{
yield return new WaitForSeconds(0.5f);
NextSequence();
}
}
IEnumerator PrepareSequence(OperaSequence sequence)
{
// 배경 설정
if (sequence.backgroundImage != null)
{
backgroundImage.sprite = sequence.backgroundImage;
backgroundImage.color = Color.white;
}
else
{
backgroundImage.color = sequence.backgroundColor;
}
// 시각적 요소 초기화
foreach (var element in sequence.visualElements)
{
if (element != null)
{
element.SetActive(true);
}
}
// 페이드 인 효과
yield return StartCoroutine(FadeInSequence());
// 분위기 파티클 시작
StartAtmosphereParticles();
}
IEnumerator FadeInSequence()
{
CanvasGroup canvasGroup = operaCanvas.GetComponent<CanvasGroup>();
if (canvasGroup == null)
{
canvasGroup = operaCanvas.gameObject.AddComponent<CanvasGroup>();
}
float fadeTime = 0.5f;
float elapsedTime = 0f;
canvasGroup.alpha = 0f;
while (elapsedTime < fadeTime)
{
elapsedTime += Time.deltaTime;
canvasGroup.alpha = elapsedTime / fadeTime;
yield return null;
}
canvasGroup.alpha = 1f;
}
void StartAtmosphereParticles()
{
foreach (var particles in atmosphereParticles)
{
if (particles != null && !particles.isPlaying)
{
particles.Play();
}
}
}
IEnumerator AnimateVisualElements(OperaSequence sequence)
{
// 씬 애니메이터 트리거
if (sceneAnimator != null && !string.IsNullOrEmpty(sequence.sequenceName))
{
sceneAnimator.SetTrigger(sequence.sequenceName);
}
// 개별 요소 애니메이션
for (int i = 0; i < sequence.animations.Length && i < sequence.visualElements.Length; i++)
{
if (sequence.animations[i] != null && sequence.visualElements[i] != null)
{
var elementAnimator = sequence.visualElements[i].GetComponent<Animator>();
if (elementAnimator != null)
{
elementAnimator.Play(sequence.animations[i].name);
}
}
}
yield return null;
}
IEnumerator ShowSequenceText(OperaSequence sequence)
{
// 제목 표시
if (!string.IsNullOrEmpty(sequence.title))
{
titleText.text = sequence.title;
yield return StartCoroutine(AnimateTextAppearance(titleText));
}
// 대화 텍스트 순차 표시
foreach (var dialogue in sequence.dialogues)
{
if (userWantsToSkip) break;
if (sequence.showCharacterByCharacter)
{
yield return StartCoroutine(ShowTextCharacterByCharacter(dialogue, sequence.textSpeed));
}
else
{
dialogueText.text = dialogue;
yield return StartCoroutine(AnimateTextAppearance(dialogueText));
}
// 대화 간 대기 시간
if (sequence.dialogues.Length > 1)
{
yield return new WaitForSeconds(1f);
}
}
}
IEnumerator ShowTextCharacterByCharacter(string text, float charactersPerSecond)
{
dialogueText.text = "";
float timePerCharacter = 1f / charactersPerSecond;
for (int i = 0; i <= text.Length; i++)
{
if (userWantsToSkip)
{
dialogueText.text = text;
break;
}
dialogueText.text = text.Substring(0, i);
// 문장부호에서 약간 더 대기
if (i < text.Length && (text[i] == '.' || text[i] == '!' || text[i] == '?'))
{
yield return new WaitForSeconds(timePerCharacter * 3f);
}
else
{
yield return new WaitForSeconds(timePerCharacter);
}
}
}
IEnumerator AnimateTextAppearance(TextMeshProUGUI textComponent)
{
textComponent.color = new Color(textComponent.color.r, textComponent.color.g, textComponent.color.b, 0f);
float fadeTime = 0.5f;
float elapsedTime = 0f;
while (elapsedTime < fadeTime)
{
if (userWantsToSkip) break;
elapsedTime += Time.deltaTime;
float alpha = elapsedTime / fadeTime;
textComponent.color = new Color(textComponent.color.r, textComponent.color.g, textComponent.color.b, alpha);
yield return null;
}
textComponent.color = new Color(textComponent.color.r, textComponent.color.g, textComponent.color.b, 1f);
}
IEnumerator WaitForUserInput(OperaSequence sequence)
{
// 입력 안내 표시
continueButton.gameObject.SetActive(true);
continueButton.GetComponentInChildren<TextMeshProUGUI>().text = sequence.inputPrompt;
bool inputReceived = false;
while (!inputReceived && !userWantsToSkip)
{
// 터치/클릭 입력 체크
if (Input.touchCount > 0 && Input.GetTouch(0).phase == TouchPhase.Began)
{
inputReceived = true;
}
if (Input.GetMouseButtonDown(0))
{
inputReceived = true;
}
// 키보드 입력 체크
foreach (var key in sequence.skipKeys)
{
if (Input.GetKeyDown(key))
{
inputReceived = true;
break;
}
}
yield return null;
}
continueButton.gameObject.SetActive(false);
}
IEnumerator CompleteSequence(OperaSequence sequence)
{
// 시퀀스 완료 애니메이션
yield return StartCoroutine(FadeOutSequence());
// 시각적 요소 정리
foreach (var element in sequence.visualElements)
{
if (element != null)
{
element.SetActive(false);
}
}
// 분석 데이터 전송
SendOperaAnalytics("sequence_completed", currentSequenceIndex);
}
IEnumerator FadeOutSequence()
{
CanvasGroup canvasGroup = operaCanvas.GetComponent<CanvasGroup>();
float fadeTime = 0.3f;
float elapsedTime = 0f;
while (elapsedTime < fadeTime)
{
elapsedTime += Time.deltaTime;
canvasGroup.alpha = 1f - (elapsedTime / fadeTime);
yield return null;
}
canvasGroup.alpha = 0f;
// 텍스트 정리
titleText.text = "";
dialogueText.text = "";
}
void PlayBackgroundMusic(AudioClip music, float volume)
{
if (musicSource != null && music != null)
{
musicSource.clip = music;
musicSource.volume = volume;
musicSource.Play();
}
}
void PlaySoundEffect(AudioClip sfx)
{
if (sfxSource != null && sfx != null)
{
sfxSource.PlayOneShot(sfx);
}
}
void NextSequence()
{
currentSequenceIndex++;
StartCurrentSequence();
}
void CompleteLaunchOpera()
{
isPlaying = false;
Debug.Log("런치 오페라 완료");
// 완료 애니메이션
StartCoroutine(PlayCompletionSequence());
}
IEnumerator PlayCompletionSequence()
{
// 모든 UI 페이드 아웃
CanvasGroup canvasGroup = operaCanvas.GetComponent<CanvasGroup>();
float fadeTime = 1f;
float elapsedTime = 0f;
while (elapsedTime < fadeTime)
{
elapsedTime += Time.deltaTime;
canvasGroup.alpha = 1f - (elapsedTime / fadeTime);
yield return null;
}
operaCanvas.gameObject.SetActive(false);
// 파티클 정리
foreach (var particles in atmosphereParticles)
{
if (particles != null)
{
particles.Stop();
}
}
// 완료 분석 데이터 전송
SendOperaAnalytics("opera_completed", operaSequences.Length);
// 게임 시작 신호 전송
AppsInToss.SendEvent("opera_finished", new Dictionary<string, object>
{
{"total_sequences", operaSequences.Length},
{"completion_time", Time.time}
});
// 메인 게임 씬으로 전환
StartMainGame();
}
void StartMainGame()
{
UnityEngine.SceneManagement.SceneManager.LoadScene("GameScene");
}
void UpdateProgress()
{
if (progressSlider != null)
{
progressSlider.value = currentSequenceIndex;
}
}
void OnContinueClicked()
{
// 현재 텍스트 애니메이션이 진행 중이면 즉시 완료
if (textAnimationCoroutine != null)
{
StopCoroutine(textAnimationCoroutine);
userWantsToSkip = true;
}
else if (isSequenceComplete)
{
NextSequence();
}
}
void OnSkipClicked()
{
if (operaSequences[currentSequenceIndex].skippable)
{
userWantsToSkip = true;
SendOperaAnalytics("sequence_skipped", currentSequenceIndex);
}
}
void OnSkipAllClicked()
{
if (enableSkipAll)
{
// 모든 시퀀스 건너뛰기
SendOperaAnalytics("opera_skipped", currentSequenceIndex);
CompleteLaunchOpera();
}
}
void SendOperaAnalytics(string eventType, int sequenceIndex)
{
var analyticsData = new Dictionary<string, object>
{
{"event_type", eventType},
{"sequence_index", sequenceIndex},
{"sequence_name", sequenceIndex < operaSequences.Length ? operaSequences[sequenceIndex].sequenceName : "complete"},
{"play_time", Time.time},
{"device_model", SystemInfo.deviceModel},
{"timestamp", System.DateTime.UtcNow.ToString("o")}
};
AppsInToss.SendAnalytics("launch_opera", analyticsData);
}
// 입력 처리
void Update()
{
// ESC 키로 전체 스킵
if (Input.GetKeyDown(KeyCode.Escape) && enableSkipAll)
{
OnSkipAllClicked();
}
}
// 공개 API
public bool IsPlaying()
{
return isPlaying;
}
public int GetCurrentSequenceIndex()
{
return currentSequenceIndex;
}
public float GetProgress()
{
if (operaSequences.Length == 0) return 1f;
return (float)currentSequenceIndex / operaSequences.Length;
}
public void PauseOpera()
{
if (currentSequenceCoroutine != null)
{
StopCoroutine(currentSequenceCoroutine);
}
if (musicSource != null)
{
musicSource.Pause();
}
}
public void ResumeOpera()
{
if (musicSource != null)
{
musicSource.UnPause();
}
if (!isSequenceComplete)
{
StartCurrentSequence();
}
}
void OnDestroy()
{
AppsInToss.OnEvent -= HandleAppsInTossEvent;
}
}2. 스토리 템플릿
게임 온보딩 템플릿
c#
public class GameOnboardingTemplate : MonoBehaviour
{
[Header("온보딩 단계")]
public OnboardingStep[] onboardingSteps;
[System.Serializable]
public class OnboardingStep
{
public string stepName;
public Sprite illustrationImage;
public string title;
public string description;
public bool hasInteraction;
public UnityEngine.Events.UnityEvent onStepComplete;
}
public void SetupOnboardingOpera(LaunchOperaManager operaManager)
{
var sequences = new List<LaunchOperaManager.OperaSequence>();
foreach (var step in onboardingSteps)
{
var sequence = new LaunchOperaManager.OperaSequence
{
sequenceName = step.stepName,
backgroundImage = step.illustrationImage,
title = step.title,
dialogues = new string[] { step.description },
requiresUserInput = step.hasInteraction,
duration = 3f
};
sequences.Add(sequence);
}
operaManager.operaSequences = sequences.ToArray();
}
}브랜드 스토리 템플릿
c#
public class BrandStoryTemplate : MonoBehaviour
{
[Header("브랜드 스토리")]
public BrandStoryElement[] brandElements;
[System.Serializable]
public class BrandStoryElement
{
public string brandMessage;
public Sprite brandVisual;
public Color brandColor;
public AudioClip brandMusic;
public float displayDuration = 4f;
}
public void CreateBrandOpera(LaunchOperaManager operaManager)
{
var sequences = new List<LaunchOperaManager.OperaSequence>();
// 토스 브랜딩 시퀀스
var tossSequence = new LaunchOperaManager.OperaSequence
{
sequenceName = "TossBranding",
title = "토스와 함께하는 게임",
dialogues = new string[] { "토스에서 즐기는 새로운 게임 경험" },
backgroundColor = new Color(49/255f, 130/255f, 247/255f), // 토스 블루
duration = 2f,
autoAdvance = true
};
sequences.Add(tossSequence);
// 게임별 브랜드 요소들
foreach (var element in brandElements)
{
var sequence = new LaunchOperaManager.OperaSequence
{
sequenceName = "Brand_" + element.brandMessage.Replace(" ", "_"),
backgroundImage = element.brandVisual,
backgroundColor = element.brandColor,
dialogues = new string[] { element.brandMessage },
backgroundMusic = element.brandMusic,
duration = element.displayDuration,
autoAdvance = true
};
sequences.Add(sequence);
}
operaManager.operaSequences = sequences.ToArray();
}
}3. 인터랙티브 요소
터치 인터랙션 시스템
c#
public class OperaInteractionSystem : MonoBehaviour
{
[Header("인터랙션 설정")]
public InteractionType[] interactions;
[System.Serializable]
public class InteractionType
{
public string name;
public Vector2 screenPosition; // 0-1 범위
public float radius = 0.1f;
public Sprite hintIcon;
public string hintText;
public UnityEngine.Events.UnityEvent onInteraction;
public ParticleSystem interactionEffect;
}
private Camera uiCamera;
private Canvas interactionCanvas;
void Start()
{
uiCamera = Camera.main;
interactionCanvas = GetComponent<Canvas>();
}
public void ShowInteractionHint(InteractionType interaction)
{
StartCoroutine(AnimateInteractionHint(interaction));
}
IEnumerator AnimateInteractionHint(InteractionType interaction)
{
// 힌트 UI 생성
var hintObject = CreateInteractionHint(interaction);
// 펄스 애니메이션
while (hintObject != null)
{
yield return StartCoroutine(PulseHint(hintObject.transform));
}
}
GameObject CreateInteractionHint(InteractionType interaction)
{
var hintObject = new GameObject("InteractionHint");
hintObject.transform.SetParent(interactionCanvas.transform);
var rectTransform = hintObject.AddComponent<RectTransform>();
var image = hintObject.AddComponent<Image>();
// 화면 위치 설정
Vector2 screenPos = new Vector2(
interaction.screenPosition.x * Screen.width,
interaction.screenPosition.y * Screen.height
);
rectTransform.anchoredPosition = screenPos;
rectTransform.sizeDelta = Vector2.one * 100f;
// 힌트 이미지 설정
image.sprite = interaction.hintIcon;
image.color = Color.white;
return hintObject;
}
IEnumerator PulseHint(Transform hintTransform)
{
Vector3 originalScale = Vector3.one;
Vector3 targetScale = Vector3.one * 1.2f;
float duration = 0.8f;
float elapsedTime = 0f;
// 확대
while (elapsedTime < duration / 2f)
{
elapsedTime += Time.deltaTime;
float progress = elapsedTime / (duration / 2f);
hintTransform.localScale = Vector3.Lerp(originalScale, targetScale, progress);
yield return null;
}
elapsedTime = 0f;
// 축소
while (elapsedTime < duration / 2f)
{
elapsedTime += Time.deltaTime;
float progress = elapsedTime / (duration / 2f);
hintTransform.localScale = Vector3.Lerp(targetScale, originalScale, progress);
yield return null;
}
hintTransform.localScale = originalScale;
}
void Update()
{
HandleTouchInput();
}
void HandleTouchInput()
{
if (Input.touchCount > 0)
{
Touch touch = Input.GetTouch(0);
if (touch.phase == TouchPhase.Began)
{
CheckInteractionHit(touch.position);
}
}
// 마우스 클릭도 지원 (에디터 테스트용)
if (Input.GetMouseButtonDown(0))
{
CheckInteractionHit(Input.mousePosition);
}
}
void CheckInteractionHit(Vector2 inputPosition)
{
foreach (var interaction in interactions)
{
Vector2 screenPos = new Vector2(
interaction.screenPosition.x * Screen.width,
interaction.screenPosition.y * Screen.height
);
float distance = Vector2.Distance(inputPosition, screenPos);
float hitRadius = interaction.radius * Screen.width;
if (distance <= hitRadius)
{
TriggerInteraction(interaction);
break;
}
}
}
void TriggerInteraction(InteractionType interaction)
{
// 이벤트 실행
interaction.onInteraction.Invoke();
// 파티클 효과
if (interaction.interactionEffect != null)
{
interaction.interactionEffect.Play();
}
// 성공 피드백
StartCoroutine(ShowInteractionSuccess(interaction));
Debug.Log($"인터랙션 트리거: {interaction.name}");
}
IEnumerator ShowInteractionSuccess(InteractionType interaction)
{
// 성공 애니메이션이나 피드백 표시
yield return new WaitForSeconds(0.5f);
}
}4. 성능 최적화
오페라 성능 모니터
c#
public class OperaPerformanceMonitor : MonoBehaviour
{
private Dictionary<string, float> sequencePerformance = new Dictionary<string, float>();
private float operaStartTime;
void Start()
{
AppsInToss.OnEvent += TrackOperaPerformance;
}
void TrackOperaPerformance(string eventName, Dictionary<string, object> data)
{
if (eventName == "opera_started")
{
operaStartTime = Time.realtimeSinceStartup;
}
else if (eventName == "sequence_completed")
{
int sequenceIndex = (int)data["sequence_index"];
float duration = Time.realtimeSinceStartup - operaStartTime;
string sequenceName = $"sequence_{sequenceIndex}";
sequencePerformance[sequenceName] = duration;
// 메모리 사용량 체크
CheckMemoryUsage(sequenceName);
}
else if (eventName == "opera_completed")
{
GeneratePerformanceReport();
}
}
void CheckMemoryUsage(string sequenceName)
{
long memoryUsage = System.GC.GetTotalMemory(false);
var memoryData = new Dictionary<string, object>
{
{"sequence_name", sequenceName},
{"memory_usage_mb", memoryUsage / (1024f * 1024f)},
{"timestamp", Time.realtimeSinceStartup}
};
AppsInToss.SendAnalytics("opera_memory_usage", memoryData);
}
void GeneratePerformanceReport()
{
float totalDuration = Time.realtimeSinceStartup - operaStartTime;
var report = new Dictionary<string, object>
{
{"total_duration", totalDuration},
{"sequence_performance", sequencePerformance},
{"device_model", SystemInfo.deviceModel},
{"graphics_memory", SystemInfo.graphicsMemorySize},
{"timestamp", System.DateTime.UtcNow.ToString("o")}
};
AppsInToss.SendAnalytics("opera_performance_report", report);
Debug.Log($"런치 오페라 성능 보고서: 총 {totalDuration:F2}초 소요");
}
void OnDestroy()
{
AppsInToss.OnEvent -= TrackOperaPerformance;
}
}런치 오페라는 첫인상이 결정되는 중요한 순간이에요.
사용자의 감정적 몰입을 이끌어내면서도 건너뛸 수 있는 선택권을 제공하여 다양한 사용자 취향을 배려하세요.
