Add travel map flow and update docs

This commit is contained in:
jongjae0305
2026-06-24 15:34:09 +09:00
parent 8561fab0a2
commit af26dd4311
37 changed files with 3114 additions and 165 deletions
+68 -7
View File
@@ -1,6 +1,7 @@
using UnityEngine;
using UnityEngine;
// 왼쪽 끝으로 이동한 배경을 오른쪽 끝으로 재배치하는 스크립트
[ExecuteAlways]
public class BackgroundLoop : MonoBehaviour {
public Sprite[] backgroundSprites; // 순서대로 교체할 공항 배경 스프라이트
public int startSpriteIndex = 0; // 이 배경 오브젝트가 시작할 스프라이트 번호
@@ -9,19 +10,26 @@ public class BackgroundLoop : MonoBehaviour {
private float width; // 배경의 가로 길이
private int currentSpriteIndex; // 현재 표시 중인 스프라이트 번호
private SpriteRenderer spriteRenderer; // 배경을 표시하는 스프라이트 렌더러
private BoxCollider2D backgroundCollider; // 루프 폭 계산용 콜라이더
private void Awake() {
// 가로 길이를 측정하는 처리
InitializeBackground();
}
BoxCollider2D backgroundCollider = GetComponent<BoxCollider2D>();
width = backgroundCollider.size.x;
spriteRenderer = GetComponent<SpriteRenderer>();
private void OnEnable() {
InitializeBackground();
}
currentSpriteIndex = NormalizeSpriteIndex(startSpriteIndex);
ApplyBackgroundSprite(currentSpriteIndex);
private void OnValidate() {
InitializeBackground();
}
private void Update() {
if(!Application.isPlaying)
{
return;
}
// 현재 위치가 원점에서 왼쪽으로 width 이상 이동했을때 위치를 리셋
if(transform.position.x <= -width)
{
@@ -29,6 +37,55 @@ public class BackgroundLoop : MonoBehaviour {
}
}
private void InitializeBackground() {
CacheComponents();
currentSpriteIndex = NormalizeSpriteIndex(startSpriteIndex);
ApplyBackgroundSprite(currentSpriteIndex);
SyncColliderSize();
RefreshWidth();
}
private void CacheComponents() {
if(spriteRenderer == null)
{
spriteRenderer = GetComponent<SpriteRenderer>();
}
if(backgroundCollider == null)
{
backgroundCollider = GetComponent<BoxCollider2D>();
}
}
private void RefreshWidth() {
if(spriteRenderer != null && spriteRenderer.sprite != null)
{
width = spriteRenderer.bounds.size.x;
return;
}
if(backgroundCollider != null)
{
width = backgroundCollider.size.x * Mathf.Abs(transform.lossyScale.x);
return;
}
width = 0f;
}
private void SyncColliderSize() {
if(spriteRenderer == null || spriteRenderer.sprite == null || backgroundCollider == null)
{
return;
}
Vector2 spriteSize = spriteRenderer.sprite.bounds.size;
backgroundCollider.size = spriteSize;
backgroundCollider.offset = Vector2.zero;
backgroundCollider.isTrigger = true;
}
// 위치를 리셋하는 메서드
private void Reposition() {
Vector2 offset = new Vector2(width * 2f, 0);
@@ -36,6 +93,8 @@ public class BackgroundLoop : MonoBehaviour {
currentSpriteIndex = NormalizeSpriteIndex(currentSpriteIndex + spriteAdvanceOnLoop);
ApplyBackgroundSprite(currentSpriteIndex);
SyncColliderSize();
RefreshWidth();
}
private int NormalizeSpriteIndex(int index) {
@@ -64,7 +123,9 @@ public class BackgroundLoop : MonoBehaviour {
if(backgroundSprite != null)
{
spriteRenderer.enabled = true;
spriteRenderer.sprite = backgroundSprite;
spriteRenderer.color = Color.white;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7b0f46aa63d14f40a4976dc22e6710a8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+635
View File
@@ -0,0 +1,635 @@
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class GameFlowManager : MonoBehaviour {
public enum FlowScreen {
Intro,
Game,
Result,
DutyFreeShop,
MapPreview
}
public static GameFlowManager Instance { get; private set; }
public static bool IsGameplayActive {
get {
return Instance == null || Instance.gameplayActive;
}
}
public GameSaveData SaveData {
get { return saveData; }
}
public MapDefinition CurrentMap {
get { return currentMap; }
}
private const string IntroSceneName = "Intro";
private const string GameSceneName = "Main";
private const string ResultSceneName = "Result";
private const string DutyFreeShopSceneName = "DutyFreeShop";
private const string MapPreviewSceneName = "MapPreview";
private const int ShieldCost = 120;
private const int LunchboxCost = 80;
private const int SpeedShoesCost = 140;
private const int MileageCardCost = 160;
private GameSaveData saveData;
private MapDefinition currentMap;
private MapDefinition completedMap;
private FlowScreen currentScreen = FlowScreen.Intro;
private bool gameplayActive = false;
private bool pendingStartGameplay = false;
private bool lastResultCleared = false;
private int lastResultScore = 0;
private int lastResultMileage = 0;
private float lastResultTime = 0f;
private float lastResultDistance = 0f;
private Canvas overlayCanvas;
private RectTransform overlayRoot;
private TMP_FontAsset runtimeFont;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Bootstrap() {
if(Instance != null)
{
return;
}
GameObject flowObject = new GameObject("Game Flow Manager");
flowObject.AddComponent<GameFlowManager>();
DontDestroyOnLoad(flowObject);
}
private void Awake() {
if(Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
saveData = GameSaveManager.Load();
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
SceneManager.sceneLoaded += OnSceneLoaded;
}
private void Start() {
ShowIntro();
}
private void OnDestroy() {
if(Instance == this)
{
SceneManager.sceneLoaded -= OnSceneLoaded;
Instance = null;
}
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode) {
EnsureOverlay();
if(pendingStartGameplay)
{
StartCoroutine(StartGameplayAfterSceneLoad());
return;
}
if(currentScreen != FlowScreen.Game)
{
RefreshCurrentScreen();
}
}
public void SetTotalMileage(int totalMileage) {
if(saveData == null)
{
return;
}
saveData.totalMileage = Mathf.Max(0, totalMileage);
GameSaveManager.Save(saveData);
}
public void AddMileage(int amount) {
if(saveData == null)
{
return;
}
GameSaveManager.AddMileage(saveData, amount);
}
public void CompleteMap(bool cleared, int score, int stageMileage, float elapsedTime, float distance) {
if(!gameplayActive && currentScreen == FlowScreen.Result)
{
return;
}
gameplayActive = false;
currentScreen = FlowScreen.Result;
completedMap = currentMap;
lastResultCleared = cleared;
lastResultScore = score;
lastResultMileage = stageMileage;
lastResultTime = elapsedTime;
lastResultDistance = distance;
if(cleared)
{
saveData.bestScore = Mathf.Max(saveData.bestScore, score);
GameSaveManager.AddMileage(saveData, completedMap.clearMileageReward);
if(completedMap.hasNextMap)
{
GameSaveManager.UnlockMap(saveData, completedMap.nextMapId);
}
else
{
GameSaveManager.Save(saveData);
}
}
ShowResult();
}
private void StartNewJourney() {
saveData = GameSaveManager.ResetSave();
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
completedMap = null;
ShowMapPreview();
}
private void ContinueJourney() {
saveData = GameSaveManager.Load();
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
completedMap = null;
ShowMapPreview();
}
private void RetryCurrentMap() {
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
LoadGameSceneForCurrentMap();
}
private void LoadGameSceneForCurrentMap() {
currentScreen = FlowScreen.Game;
gameplayActive = false;
pendingStartGameplay = true;
ShowLoading("탑승 준비 중", currentMap.displayName + " 코스를 불러오는 중입니다.");
if(Application.CanStreamedLevelBeLoaded(GameSceneName))
{
SceneManager.LoadScene(GameSceneName);
return;
}
Scene activeScene = SceneManager.GetActiveScene();
if(!string.IsNullOrEmpty(activeScene.name))
{
SceneManager.LoadScene(activeScene.name);
}
}
private IEnumerator StartGameplayAfterSceneLoad() {
yield return null;
pendingStartGameplay = false;
currentScreen = FlowScreen.Game;
gameplayActive = true;
if(overlayCanvas != null)
{
overlayCanvas.gameObject.SetActive(false);
}
if(GameManager.instance != null)
{
GameManager.instance.BeginMap(currentMap);
}
}
private void ShowIntro() {
currentScreen = FlowScreen.Intro;
gameplayActive = false;
pendingStartGameplay = false;
completedMap = null;
if(TryLoadFlowScene(IntroSceneName))
{
return;
}
EnsureOverlay();
ClearOverlay();
SetOverlayVisible(true);
AddTitle("Uni-Run");
AddBody("여권을 챙기고 공항으로 출발합니다.\n진행도는 자동 저장됩니다.");
AddBody("현재 경로: " + currentMap.displayName + "\n해금: " + MapDatabase.GetMapByIndex(saveData.unlockedMapIndex).displayName + "\n마일리지: " + saveData.totalMileage);
AddButton("이어하기", ContinueJourney);
AddButton("새 여행", StartNewJourney);
AddButton("저장 초기화", ResetAndStayIntro);
}
private void ShowResult() {
if(TryLoadFlowScene(ResultSceneName))
{
return;
}
EnsureOverlay();
ClearOverlay();
SetOverlayVisible(true);
string title = lastResultCleared ? "맵 클리어" : "일정 실패";
string body = completedMap.displayName
+ "\n점수: " + lastResultScore
+ "\n획득 마일리지: " + lastResultMileage
+ "\n거리: " + Mathf.FloorToInt(lastResultDistance) + "m / " + Mathf.FloorToInt(completedMap.targetDistance) + "m"
+ "\n시간: " + lastResultTime.ToString("0.0") + "s";
if(lastResultCleared)
{
body += "\n클리어 보상: " + completedMap.clearMileageReward + " 마일리지";
}
AddTitle(title);
AddBody(body);
if(lastResultCleared)
{
AddButton("면세점으로", ShowDutyFreeShop);
}
else
{
AddButton("다시 도전", RetryCurrentMap);
}
AddButton("인트로", ShowIntro);
}
private void ShowDutyFreeShop() {
currentScreen = FlowScreen.DutyFreeShop;
gameplayActive = false;
pendingStartGameplay = false;
if(TryLoadFlowScene(DutyFreeShopSceneName))
{
return;
}
EnsureOverlay();
ClearOverlay();
SetOverlayVisible(true);
AddTitle("면세점");
AddBody("보유 마일리지: " + saveData.totalMileage
+ "\n도시락: " + saveData.lunchboxStock
+ " / 방어막: " + saveData.shieldStock
+ "\n운동화: " + saveData.speedShoesStock
+ " / 마일리지 카드: " + saveData.mileageCardStock);
AddButton("도시락 구매 " + LunchboxCost, BuyLunchbox);
AddButton("방어막 구매 " + ShieldCost, BuyShield);
AddButton("운동화 구매 " + SpeedShoesCost, BuySpeedShoes);
AddButton("마일리지 카드 구매 " + MileageCardCost, BuyMileageCard);
AddButton("다음 맵 보기", ShowMapPreview);
}
private void ShowMapPreview() {
currentScreen = FlowScreen.MapPreview;
gameplayActive = false;
pendingStartGameplay = false;
if(completedMap != null && lastResultCleared && completedMap.hasNextMap)
{
currentMap = MapDatabase.GetMap(completedMap.nextMapId);
}
else
{
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
}
if(TryLoadFlowScene(MapPreviewSceneName))
{
return;
}
EnsureOverlay();
ClearOverlay();
SetOverlayVisible(true);
AddTitle("다음 경로");
AddBody(currentMap.displayName
+ "\n" + currentMap.routeName
+ "\n목표 거리: " + Mathf.FloorToInt(currentMap.targetDistance) + "m"
+ "\n제한 시간: " + currentMap.timeLimit.ToString("0") + "s");
AddButton("출발", ConfirmMapAndStart);
AddButton("인트로", ShowIntro);
}
private void ConfirmMapAndStart() {
saveData.currentMapIndex = MapDatabase.GetIndex(currentMap.mapId);
saveData.unlockedMapIndex = Mathf.Max(saveData.unlockedMapIndex, saveData.currentMapIndex);
GameSaveManager.Save(saveData);
LoadGameSceneForCurrentMap();
}
private void RefreshCurrentScreen() {
switch(currentScreen)
{
case FlowScreen.Result:
ShowResult();
break;
case FlowScreen.DutyFreeShop:
ShowDutyFreeShop();
break;
case FlowScreen.MapPreview:
ShowMapPreview();
break;
case FlowScreen.Intro:
default:
ShowIntro();
break;
}
}
private void ResetAndStayIntro() {
saveData = GameSaveManager.ResetSave();
currentMap = MapDatabase.GetMapByIndex(saveData.currentMapIndex);
ShowIntro();
}
private void BuyLunchbox() {
if(TrySpendMileage(LunchboxCost))
{
saveData.lunchboxStock++;
GameSaveManager.Save(saveData);
}
ShowDutyFreeShop();
}
private void BuyShield() {
if(TrySpendMileage(ShieldCost))
{
saveData.shieldStock++;
GameSaveManager.Save(saveData);
}
ShowDutyFreeShop();
}
private void BuySpeedShoes() {
if(TrySpendMileage(SpeedShoesCost))
{
saveData.speedShoesStock++;
GameSaveManager.Save(saveData);
}
ShowDutyFreeShop();
}
private void BuyMileageCard() {
if(TrySpendMileage(MileageCardCost))
{
saveData.mileageCardStock++;
GameSaveManager.Save(saveData);
}
ShowDutyFreeShop();
}
private bool TrySpendMileage(int cost) {
if(saveData.totalMileage < cost)
{
return false;
}
saveData.totalMileage -= cost;
return true;
}
private void ShowLoading(string title, string body) {
EnsureOverlay();
ClearOverlay();
SetOverlayVisible(true);
AddTitle(title);
AddBody(body);
}
private bool TryLoadFlowScene(string sceneName) {
Scene activeScene = SceneManager.GetActiveScene();
if(activeScene.name == sceneName)
{
return false;
}
if(!Application.CanStreamedLevelBeLoaded(sceneName))
{
return false;
}
SceneManager.LoadScene(sceneName);
return true;
}
private void EnsureOverlay() {
if(overlayCanvas != null)
{
return;
}
EnsureEventSystem();
GameObject canvasObject = new GameObject("Flow Overlay", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
DontDestroyOnLoad(canvasObject);
overlayCanvas = canvasObject.GetComponent<Canvas>();
overlayCanvas.renderMode = RenderMode.ScreenSpaceOverlay;
overlayCanvas.sortingOrder = 100;
CanvasScaler scaler = canvasObject.GetComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1280f, 720f);
scaler.matchWidthOrHeight = 0.5f;
GameObject rootObject = new GameObject("Flow Overlay Root", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
rootObject.transform.SetParent(canvasObject.transform, false);
overlayRoot = rootObject.GetComponent<RectTransform>();
overlayRoot.anchorMin = Vector2.zero;
overlayRoot.anchorMax = Vector2.one;
overlayRoot.offsetMin = Vector2.zero;
overlayRoot.offsetMax = Vector2.zero;
Image background = rootObject.GetComponent<Image>();
background.color = new Color(0.02f, 0.035f, 0.045f, 0.92f);
background.raycastTarget = true;
runtimeFont = CreateKoreanFontAsset();
}
private void EnsureEventSystem() {
if(EventSystem.current != null)
{
return;
}
GameObject eventSystemObject = new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule));
DontDestroyOnLoad(eventSystemObject);
}
private void ClearOverlay() {
if(overlayRoot == null)
{
return;
}
for(int i = overlayRoot.childCount - 1; i >= 0; i--)
{
Destroy(overlayRoot.GetChild(i).gameObject);
}
}
private void SetOverlayVisible(bool visible) {
if(overlayCanvas != null)
{
overlayCanvas.gameObject.SetActive(visible);
}
}
private void AddTitle(string text) {
TextMeshProUGUI title = AddText("Title", text, 34f, FontStyles.Bold);
RectTransform rectTransform = title.rectTransform;
rectTransform.anchoredPosition = new Vector2(0f, 160f);
rectTransform.sizeDelta = new Vector2(760f, 62f);
}
private void AddBody(string text) {
TextMeshProUGUI body = AddText("Body", text, 17f, FontStyles.Normal);
RectTransform rectTransform = body.rectTransform;
rectTransform.anchoredPosition = new Vector2(0f, 52f - (GetBodyCount() * 82f));
rectTransform.sizeDelta = new Vector2(760f, 96f);
}
private void AddButton(string label, UnityEngine.Events.UnityAction action) {
int buttonIndex = GetButtonCount();
GameObject buttonObject = new GameObject("Button " + label, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button));
buttonObject.transform.SetParent(overlayRoot, false);
RectTransform buttonRect = buttonObject.GetComponent<RectTransform>();
buttonRect.anchorMin = new Vector2(0.5f, 0.5f);
buttonRect.anchorMax = new Vector2(0.5f, 0.5f);
buttonRect.pivot = new Vector2(0.5f, 0.5f);
buttonRect.anchoredPosition = new Vector2(0f, -82f - (buttonIndex * 46f));
buttonRect.sizeDelta = new Vector2(260f, 34f);
Image buttonImage = buttonObject.GetComponent<Image>();
buttonImage.color = new Color(0.92f, 0.96f, 1f, 0.95f);
Button button = buttonObject.GetComponent<Button>();
button.onClick.AddListener(action);
TextMeshProUGUI buttonText = AddTextTo(buttonObject.transform, "Label", label, 15f, FontStyles.Bold);
RectTransform textRect = buttonText.rectTransform;
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = Vector2.zero;
textRect.offsetMax = Vector2.zero;
buttonText.color = new Color(0.04f, 0.07f, 0.09f, 1f);
}
private int GetBodyCount() {
int count = 0;
for(int i = 0; i < overlayRoot.childCount; i++)
{
if(overlayRoot.GetChild(i).name.StartsWith("Body"))
{
count++;
}
}
return count;
}
private int GetButtonCount() {
int count = 0;
for(int i = 0; i < overlayRoot.childCount; i++)
{
if(overlayRoot.GetChild(i).name.StartsWith("Button"))
{
count++;
}
}
return count;
}
private TextMeshProUGUI AddText(string objectName, string text, float fontSize, FontStyles fontStyle) {
GameObject textObject = new GameObject(objectName + " " + GetBodyCount(), typeof(RectTransform), typeof(CanvasRenderer));
textObject.transform.SetParent(overlayRoot, false);
RectTransform rectTransform = textObject.GetComponent<RectTransform>();
rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
rectTransform.pivot = new Vector2(0.5f, 0.5f);
TextMeshProUGUI label = textObject.AddComponent<TextMeshProUGUI>();
label.text = text;
label.font = runtimeFont != null ? runtimeFont : label.font;
label.fontSize = fontSize;
label.fontStyle = fontStyle;
label.alignment = TextAlignmentOptions.Center;
label.color = new Color(0.95f, 0.98f, 1f, 1f);
label.raycastTarget = false;
label.textWrappingMode = TextWrappingModes.Normal;
return label;
}
private TextMeshProUGUI AddTextTo(Transform parent, string objectName, string text, float fontSize, FontStyles fontStyle) {
GameObject textObject = new GameObject(objectName, typeof(RectTransform), typeof(CanvasRenderer));
textObject.transform.SetParent(parent, false);
TextMeshProUGUI label = textObject.AddComponent<TextMeshProUGUI>();
label.text = text;
label.font = runtimeFont != null ? runtimeFont : label.font;
label.fontSize = fontSize;
label.fontStyle = fontStyle;
label.alignment = TextAlignmentOptions.Center;
label.raycastTarget = false;
label.textWrappingMode = TextWrappingModes.NoWrap;
return label;
}
private TMP_FontAsset CreateKoreanFontAsset() {
string[] familyNames = {
"Malgun Gothic",
"맑은 고딕",
"Arial Unicode MS"
};
for(int i = 0; i < familyNames.Length; i++)
{
TMP_FontAsset fontAsset = TMP_FontAsset.CreateFontAsset(familyNames[i], "Regular", 80);
if(fontAsset != null)
{
fontAsset.name = "Runtime Flow Korean Font";
return fontAsset;
}
}
return null;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8a22a48e05fb4c1aa0b7e2a4d6723a0f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+28
View File
@@ -0,0 +1,28 @@
using System;
[Serializable]
public class GameSaveData {
public int currentMapIndex;
public int unlockedMapIndex;
public int totalMileage;
public int bagSlots;
public int shieldStock;
public int lunchboxStock;
public int speedShoesStock;
public int mileageCardStock;
public int bestScore;
public static GameSaveData CreateDefault() {
return new GameSaveData {
currentMapIndex = 0,
unlockedMapIndex = 0,
totalMileage = 0,
bagSlots = 1,
shieldStock = 0,
lunchboxStock = 0,
speedShoesStock = 0,
mileageCardStock = 0,
bestScore = 0
};
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9cd5d89d88554d7ea48124a4f1780e0b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+75
View File
@@ -0,0 +1,75 @@
using UnityEngine;
public static class GameSaveManager {
private const string SaveKey = "UniRun.SaveData.v1";
private const string LegacyTotalMileageKey = "UniRun.TotalMileage";
public static GameSaveData Load() {
if(PlayerPrefs.HasKey(SaveKey))
{
string json = PlayerPrefs.GetString(SaveKey);
GameSaveData loadedData = JsonUtility.FromJson<GameSaveData>(json);
if(loadedData != null)
{
Normalize(loadedData);
return loadedData;
}
}
GameSaveData saveData = GameSaveData.CreateDefault();
saveData.totalMileage = PlayerPrefs.GetInt(LegacyTotalMileageKey, 0);
Normalize(saveData);
Save(saveData);
return saveData;
}
public static void Save(GameSaveData saveData) {
Normalize(saveData);
string json = JsonUtility.ToJson(saveData);
PlayerPrefs.SetString(SaveKey, json);
PlayerPrefs.SetInt(LegacyTotalMileageKey, saveData.totalMileage);
PlayerPrefs.Save();
}
public static GameSaveData ResetSave() {
PlayerPrefs.DeleteKey(SaveKey);
GameSaveData saveData = GameSaveData.CreateDefault();
Save(saveData);
return saveData;
}
public static void AddMileage(GameSaveData saveData, int amount) {
if(saveData == null)
{
return;
}
saveData.totalMileage = Mathf.Max(0, saveData.totalMileage + amount);
Save(saveData);
}
public static void UnlockMap(GameSaveData saveData, MapId mapId) {
if(saveData == null)
{
return;
}
int mapIndex = MapDatabase.GetIndex(mapId);
saveData.unlockedMapIndex = Mathf.Max(saveData.unlockedMapIndex, mapIndex);
Save(saveData);
}
private static void Normalize(GameSaveData saveData) {
saveData.currentMapIndex = Mathf.Clamp(saveData.currentMapIndex, 0, MapDatabase.LastMapIndex);
saveData.unlockedMapIndex = Mathf.Clamp(saveData.unlockedMapIndex, 0, MapDatabase.LastMapIndex);
saveData.currentMapIndex = Mathf.Min(saveData.currentMapIndex, saveData.unlockedMapIndex);
saveData.totalMileage = Mathf.Max(0, saveData.totalMileage);
saveData.bagSlots = Mathf.Clamp(saveData.bagSlots, 1, 3);
saveData.shieldStock = Mathf.Max(0, saveData.shieldStock);
saveData.lunchboxStock = Mathf.Max(0, saveData.lunchboxStock);
saveData.speedShoesStock = Mathf.Max(0, saveData.speedShoesStock);
saveData.mileageCardStock = Mathf.Max(0, saveData.mileageCardStock);
saveData.bestScore = Mathf.Max(0, saveData.bestScore);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b2f7f2cb496e477589a57fd237868fd3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+648 -49
View File
@@ -2,10 +2,30 @@ 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; // 게임 오버 상태
@@ -16,14 +36,34 @@ public class GameManager : MonoBehaviour {
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 TextMeshProUGUI tutorialText; // 화면 하단 튜토리얼 안내
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; // 결과 화면이 이미 처리되었는지 여부
public int maxLife = 3; // 시작 목숨
public int initialMileage = 500; // 첫 시작시 지급할 초기 마일리지
public bool grantInitialMileage = true; // 초기 마일리지 지급 여부
public bool saveMileageImmediately = true; // 스테이지 정산 전까지는 획득 즉시 누적한다.
public bool playTutorialGuideSequence = false; // 시작 시 입력/아이템 안내를 시간 기준으로 보여준다.
public float itemInvincibleDuration = 2.2f; // 라멘 아이템 무적 지속 시간
public float itemSpeedBoostAmount = 0.35f; // 운동화 아이템 즉시 속도 보정량
public float mileageBonusDuration = 7f; // 마일리지 카드 보너스 지속 시간
public int mileageBonusMultiplier = 2; // 마일리지 카드 획득 배율
private const string TotalMileageKey = "UniRun.TotalMileage";
private const string InitialMileageGrantedKey = "UniRun.InitialMileageGranted";
@@ -31,6 +71,10 @@ public class GameManager : MonoBehaviour {
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;
@@ -38,6 +82,9 @@ public class GameManager : MonoBehaviour {
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; // 마일리지 카드 보너스 종료 시각
// 게임 시작과 동시에 싱글톤을 구성
void Awake() {
@@ -60,7 +107,9 @@ public class GameManager : MonoBehaviour {
private void Start() {
currentLife = maxLife;
totalMileage = PlayerPrefs.GetInt(TotalMileageKey, 0);
totalMileage = GameFlowManager.Instance != null
? GameFlowManager.Instance.SaveData.totalMileage
: PlayerPrefs.GetInt(TotalMileageKey, 0);
if(grantInitialMileage && PlayerPrefs.GetInt(InitialMileageGrantedKey, 0) == 0)
{
@@ -71,26 +120,90 @@ public class GameManager : MonoBehaviour {
}
BuildRuntimeHUD();
StartTutorialGuideSequence();
BeginMap(GameFlowManager.Instance != null
? GameFlowManager.Instance.CurrentMap
: MapDatabase.GetMap(MapId.TutorialIncheon));
SyncTotalMileageToFlow();
UpdateScoreUI();
}
void Update() {
// 게임 오버 상태에서 게임을 재시작할 수 있게 하는 처리
if(isGameover && Input.GetMouseButtonDown(0))
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;
if(currentMapDefinition != null && currentMapDefinition.targetDistance > 0f && stageDistance >= currentMapDefinition.targetDistance)
{
CompleteStage(true);
return;
}
if(currentMapDefinition != null && currentMapDefinition.timeLimit > 0f && stageElapsedTime >= currentMapDefinition.timeLimit)
{
CompleteStage(false);
return;
}
UpdateScoreUI();
}
}
public void BeginMap(MapDefinition mapDefinition) {
currentMapDefinition = mapDefinition != null
? mapDefinition
: MapDatabase.GetMap(MapId.TutorialIncheon);
score = 0;
stageMileage = 0;
currentLife = maxLife;
gameSpeed = minGameSpeed;
shieldCharges = 0;
itemInvincibleEndTime = 0f;
mileageBonusEndTime = 0f;
stageElapsedTime = 0f;
stageDistance = 0f;
stageCompleted = false;
isGameover = false;
tutorialGuideSequenceComplete = false;
currentTutorialGuideSequenceIndex = -1;
SetTutorialGuide(TutorialGuideType.None, "", "");
if(gameoverUI != null)
{
gameoverUI.SetActive(false);
}
PlatformSpawner platformSpawner = FindFirstObjectByType<PlatformSpawner>();
if(platformSpawner != null)
{
platformSpawner.ApplyMap(currentMapDefinition);
}
UpdateScoreUI();
}
// 점수를 증가시키는 메서드
public void AddScore(int newScore) {
@@ -103,10 +216,95 @@ public class GameManager : MonoBehaviour {
}
public void SetTutorialMessage(string message) {
SetTutorialGuide(TutorialGuideType.None, "", message);
}
public void SetTutorialGuide(TutorialGuideType guideType, string title, string message) {
tutorialGuideType = guideType;
tutorialTitle = title;
tutorialMessage = message;
UpdateScoreUI();
}
private void StartTutorialGuideSequence() {
if(!playTutorialGuideSequence)
{
return;
}
tutorialGuideSequenceStartTime = Time.time;
tutorialGuideSequenceComplete = false;
currentTutorialGuideSequenceIndex = -1;
UpdateTutorialGuideSequence();
}
private void UpdateTutorialGuideSequence() {
if(!playTutorialGuideSequence || tutorialGuideSequenceComplete)
{
return;
}
float elapsedTime = Time.time - tutorialGuideSequenceStartTime;
int guideIndex = GetTutorialGuideSequenceIndex(elapsedTime);
if(guideIndex == currentTutorialGuideSequenceIndex)
{
return;
}
currentTutorialGuideSequenceIndex = guideIndex;
ApplyTutorialGuideSequenceStep(guideIndex);
}
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)
@@ -114,14 +312,19 @@ public class GameManager : MonoBehaviour {
return;
}
stageMileage += amount;
score += Mathf.Max(1, amount / 10);
int finalAmount = IsMileageBonusActive()
? amount * Mathf.Max(1, mileageBonusMultiplier)
: amount;
stageMileage += finalAmount;
score += Mathf.Max(1, finalAmount / 10);
if(saveMileageImmediately)
{
totalMileage += amount;
totalMileage += finalAmount;
PlayerPrefs.SetInt(TotalMileageKey, totalMileage);
PlayerPrefs.Save();
SyncTotalMileageToFlow();
}
UpdateScoreUI();
@@ -129,7 +332,7 @@ public class GameManager : MonoBehaviour {
// 플레이어가 장애물에 부딪혔을 때 목숨과 속도를 줄이는 메서드
public bool TakeDamage(int damage = 1) {
if(isGameover)
if(isGameover || !GameFlowManager.IsGameplayActive)
{
return false;
}
@@ -145,18 +348,126 @@ public class GameManager : MonoBehaviour {
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);
SetTutorialGuide(TutorialGuideType.Items, "초밥", "방금 잃은 목숨을 1 회복합니다.");
break;
case TutorialItemType.InvincibleRamen:
itemInvincibleEndTime = Time.time + itemInvincibleDuration;
SetTutorialGuide(TutorialGuideType.Items, "라멘", "짧은 시간 동안 다음 충돌 피해를 받지 않습니다.");
break;
case TutorialItemType.Shield:
shieldCharges = Mathf.Max(shieldCharges, 1);
SetTutorialGuide(TutorialGuideType.Items, "방어막", "다음 충돌 1회를 방어막이 대신 막습니다.");
break;
case TutorialItemType.SpeedShoes:
gameSpeed = Mathf.Min(maxGameSpeed, gameSpeed + itemSpeedBoostAmount);
SetTutorialGuide(TutorialGuideType.Items, "운동화", "속도를 즉시 회복해 늦어진 일정을 따라잡습니다.");
break;
case TutorialItemType.MileageCard:
mileageBonusEndTime = Time.time + mileageBonusDuration;
SetTutorialGuide(TutorialGuideType.Items, "마일리지 카드", "잠시 동안 먹는 마일리지가 두 배로 들어옵니다.");
break;
}
UpdateScoreUI();
}
public bool TryBlockDamageWithItem() {
if(isGameover || !GameFlowManager.IsGameplayActive)
{
return false;
}
if(IsItemInvincibleActive())
{
SetTutorialGuide(TutorialGuideType.Items, "라멘", "무적 중이라 이번 충돌은 피해 없이 지나갑니다.");
return true;
}
if(shieldCharges > 0)
{
shieldCharges--;
SetTutorialGuide(TutorialGuideType.Items, "방어막", "방어막이 충돌 피해를 한 번 막았습니다.");
UpdateScoreUI();
return true;
}
return false;
}
private bool IsItemInvincibleActive() {
return Time.time < itemInvincibleEndTime;
}
private bool IsMileageBonusActive() {
return Time.time < mileageBonusEndTime;
}
// 플레이어 캐릭터가 사망시 게임 오버를 실행하는 메서드
public void OnPlayerDead() {
isGameover = true;
gameoverUI.SetActive(true);
if(gameoverUI != null)
{
gameoverUI.SetActive(GameFlowManager.Instance == null);
}
CompleteStage(false);
}
private void CompleteStage(bool cleared) {
if(stageCompleted)
{
return;
}
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 SyncTotalMileageToFlow() {
if(GameFlowManager.Instance != null)
{
GameFlowManager.Instance.SetTotalMileage(totalMileage);
}
}
private void UpdateScoreUI() {
if(scoreText != null)
{
scoreText.text = "SCORE " + score
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");
}
@@ -171,16 +482,73 @@ public class GameManager : MonoBehaviour {
bool hasLife = i < currentLife;
lifeIcons[i].color = hasLife
? Color.white
? GetActiveLifeIconColor(lifeIcons[i])
: new Color(0.34f, 0.34f, 0.34f, 0.72f);
}
}
if(tutorialText != null)
RefreshTutorialGuide();
}
private void RefreshTutorialGuide() {
if(tutorialGuideObject == null)
{
tutorialText.text = tutorialMessage;
tutorialText.gameObject.SetActive(!string.IsNullOrEmpty(tutorialMessage));
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;
}
bool showItemRow = tutorialGuideType == TutorialGuideType.Items;
Sprite mainSprite = GetTutorialMainSprite(tutorialGuideType);
if(tutorialMainImage != null)
{
tutorialMainImage.gameObject.SetActive(!showItemRow && 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(showItemRow && tutorialItemImages[i].sprite != null);
}
}
}
}
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() {
@@ -193,7 +561,7 @@ public class GameManager : MonoBehaviour {
ConfigureRightInfoText();
CreateStatusHUD(canvasTransform);
CreateTutorialText(canvasTransform);
CreateTutorialGuide(canvasTransform);
}
private void ConfigureRightInfoText() {
@@ -202,11 +570,11 @@ public class GameManager : MonoBehaviour {
rectTransform.anchorMax = new Vector2(1f, 1f);
rectTransform.pivot = new Vector2(1f, 1f);
rectTransform.anchoredPosition = new Vector2(-12f, -9f);
rectTransform.sizeDelta = new Vector2(132f, 62f);
rectTransform.sizeDelta = new Vector2(176f, 96f);
scoreText.alignment = TextAlignmentOptions.TopRight;
scoreText.fontSize = 9.5f;
scoreText.lineSpacing = -10f;
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;
@@ -220,11 +588,11 @@ public class GameManager : MonoBehaviour {
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(176f, 54f);
hudRect.anchoredPosition = new Vector2(8f, -7f);
hudRect.sizeDelta = new Vector2(108f, 34f);
Image hudBackground = hudObject.GetComponent<Image>();
hudBackground.sprite = Resources.Load<Sprite>(PassportPanelResourcePath);
hudBackground.sprite = LoadHudSprite(PassportPanelResourcePath);
hudBackground.color = hudBackground.sprite != null
? Color.white
: new Color(0.04f, 0.08f, 0.11f, 0.68f);
@@ -243,11 +611,11 @@ public class GameManager : MonoBehaviour {
frameRect.anchorMin = new Vector2(0f, 1f);
frameRect.anchorMax = new Vector2(0f, 1f);
frameRect.pivot = new Vector2(0f, 1f);
frameRect.anchoredPosition = new Vector2(9f, -10f);
frameRect.sizeDelta = new Vector2(29f, 29f);
frameRect.anchoredPosition = new Vector2(6f, -6f);
frameRect.sizeDelta = new Vector2(20f, 20f);
Image frameImage = faceFrame.GetComponent<Image>();
frameImage.color = new Color(0.84f, 0.87f, 0.80f, 0.58f);
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));
@@ -260,13 +628,16 @@ public class GameManager : MonoBehaviour {
faceRect.offsetMax = new Vector2(-2f, -2f);
Image faceImage = faceObject.GetComponent<Image>();
faceImage.sprite = Resources.Load<Sprite>(CharacterFaceResourcePath);
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 = Resources.Load<Sprite>(LifeStampResourcePath);
Sprite lifeSprite = LoadHudSprite(LifeStampResourcePath);
int iconCount = Mathf.Max(maxLife, 1);
lifeIcons = new Image[iconCount];
@@ -279,12 +650,14 @@ public class GameManager : MonoBehaviour {
lifeRect.anchorMin = new Vector2(0f, 1f);
lifeRect.anchorMax = new Vector2(0f, 1f);
lifeRect.pivot = new Vector2(0f, 1f);
lifeRect.anchoredPosition = new Vector2(50f + (i * 21f), -10f);
lifeRect.sizeDelta = new Vector2(18f, 18f);
lifeRect.anchoredPosition = new Vector2(33f + (i * 13f), -7f);
lifeRect.sizeDelta = new Vector2(10f, 10f);
Image lifeImage = lifeObject.GetComponent<Image>();
lifeImage.sprite = lifeSprite;
lifeImage.color = Color.white;
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;
@@ -292,7 +665,7 @@ public class GameManager : MonoBehaviour {
}
private void CreateItemSlots(Transform parent) {
Sprite slotSprite = Resources.Load<Sprite>(PassportItemSlotResourcePath);
Sprite slotSprite = LoadHudSprite(PassportItemSlotResourcePath);
for(int i = 0; i < 3; i++)
{
@@ -303,8 +676,8 @@ public class GameManager : MonoBehaviour {
slotRect.anchorMin = new Vector2(0f, 1f);
slotRect.anchorMax = new Vector2(0f, 1f);
slotRect.pivot = new Vector2(0f, 1f);
slotRect.anchoredPosition = new Vector2(50f + (i * 21f), -31f);
slotRect.sizeDelta = new Vector2(18f, 18f);
slotRect.anchoredPosition = new Vector2(33f + (i * 13f), -20f);
slotRect.sizeDelta = new Vector2(10f, 10f);
Image slotImage = slotObject.GetComponent<Image>();
slotImage.sprite = slotSprite;
@@ -316,25 +689,251 @@ public class GameManager : MonoBehaviour {
}
}
private void CreateTutorialText(Transform canvasTransform) {
GameObject tutorialObject = new GameObject("Tutorial Text", typeof(RectTransform), typeof(CanvasRenderer));
tutorialObject.transform.SetParent(canvasTransform, false);
private Sprite LoadHudSprite(string resourcePath) {
Sprite sprite = Resources.Load<Sprite>(resourcePath);
if(sprite != null)
{
return sprite;
}
RectTransform tutorialRect = tutorialObject.GetComponent<RectTransform>();
tutorialRect.anchorMin = new Vector2(0.5f, 0f);
tutorialRect.anchorMax = new Vector2(0.5f, 0f);
tutorialRect.pivot = new Vector2(0.5f, 0f);
tutorialRect.anchoredPosition = new Vector2(0f, 18f);
tutorialRect.sizeDelta = new Vector2(360f, 24f);
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;
}
tutorialText = tutorialObject.AddComponent<TextMeshProUGUI>();
tutorialText.font = scoreText.font;
tutorialText.alignment = TextAlignmentOptions.Center;
tutorialText.fontSize = 9.5f;
tutorialText.fontStyle = FontStyles.Bold;
tutorialText.color = new Color(0.96f, 0.98f, 1f, 0.95f);
tutorialText.raycastTarget = false;
tutorialText.textWrappingMode = TextWrappingModes.NoWrap;
tutorialText.gameObject.SetActive(false);
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, 0f);
guideRect.anchorMax = new Vector2(0.5f, 0f);
guideRect.pivot = new Vector2(0.5f, 0f);
guideRect.anchoredPosition = new Vector2(18f, 12f);
guideRect.sizeDelta = new Vector2(372f, 72f);
Image guideBackground = tutorialGuideObject.GetComponent<Image>();
guideBackground.color = new Color(0.035f, 0.055f, 0.07f, 0.76f);
guideBackground.raycastTarget = false;
tutorialFontAsset = CreateKoreanTutorialFontAsset();
CreateTutorialMainImage(tutorialGuideObject.transform);
CreateTutorialItemRow(tutorialGuideObject.transform);
tutorialTitleText = CreateTutorialText(tutorialGuideObject.transform, "Tutorial Title", new Vector2(70f, -9f), new Vector2(286f, 18f), 12f, FontStyles.Bold);
tutorialBodyText = CreateTutorialText(tutorialGuideObject.transform, "Tutorial Body", new Vector2(70f, -28f), new Vector2(286f, 34f), 9f, 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<RectTransform>();
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<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(-2f, 1f);
multiplierRect.sizeDelta = new Vector2(22f, 15f);
tutorialMultiplierText = multiplierObject.AddComponent<TextMeshProUGUI>();
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<RectTransform>();
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(11f + (i * 27f), 0f);
itemRect.sizeDelta = new Vector2(24f, 24f);
Image itemImage = itemObject.GetComponent<Image>();
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<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_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<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
}
}
+96
View File
@@ -0,0 +1,96 @@
using UnityEngine;
// 튜토리얼 중 플레이어가 직접 먹어 효과를 확인하는 필드 아이템
public class ItemPickup : MonoBehaviour
{
public GameManager.TutorialItemType itemType = GameManager.TutorialItemType.HealthSushi;
private static Sprite fallbackSprite;
private bool collected = false;
private SpriteRenderer spriteRenderer;
private Collider2D pickupCollider;
private void Awake()
{
spriteRenderer = GetComponent<SpriteRenderer>();
pickupCollider = GetComponent<Collider2D>();
}
public void Setup(Sprite sprite, GameManager.TutorialItemType nextItemType)
{
itemType = nextItemType;
if(spriteRenderer != null)
{
spriteRenderer.sprite = sprite != null ? sprite : GetFallbackSprite();
spriteRenderer.color = sprite != null ? Color.white : GetFallbackColor(nextItemType);
}
}
public void ResetPickup()
{
collected = false;
gameObject.SetActive(true);
if(pickupCollider != null)
{
pickupCollider.enabled = true;
}
}
private void OnTriggerEnter2D(Collider2D other)
{
if(collected || !other.CompareTag("Player") || !GameFlowManager.IsGameplayActive)
{
return;
}
collected = true;
if(GameManager.instance != null)
{
GameManager.instance.ApplyTutorialItem(itemType);
}
gameObject.SetActive(false);
}
private Color GetFallbackColor(GameManager.TutorialItemType fallbackItemType)
{
switch(fallbackItemType)
{
case GameManager.TutorialItemType.InvincibleRamen:
return new Color(1f, 0.58f, 0.12f, 1f);
case GameManager.TutorialItemType.Shield:
return new Color(0.32f, 0.78f, 1f, 1f);
case GameManager.TutorialItemType.SpeedShoes:
return new Color(0.35f, 1f, 0.5f, 1f);
case GameManager.TutorialItemType.MileageCard:
return new Color(1f, 0.92f, 0.26f, 1f);
case GameManager.TutorialItemType.HealthSushi:
default:
return new Color(1f, 0.34f, 0.42f, 1f);
}
}
private Sprite GetFallbackSprite()
{
if(fallbackSprite != null)
{
return fallbackSprite;
}
Texture2D texture = new Texture2D(16, 16);
Color[] pixels = new Color[16 * 16];
for(int i = 0; i < pixels.Length; i++)
{
pixels[i] = Color.white;
}
texture.SetPixels(pixels);
texture.Apply();
fallbackSprite = Sprite.Create(texture, new Rect(0f, 0f, 16f, 16f), new Vector2(0.5f, 0.5f), 16f);
return fallbackSprite;
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 24f0b6a8d1474f81a6d1a3b9a07e5c43
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4b552d8cc0244e64bcf24dfac5a7df5e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+249
View File
@@ -0,0 +1,249 @@
using UnityEngine;
public static class MapDatabase {
private static MapDefinition[] maps;
public static MapDefinition[] AllMaps {
get {
EnsureMaps();
return maps;
}
}
public static MapDefinition GetMap(MapId mapId) {
EnsureMaps();
for(int i = 0; i < maps.Length; i++)
{
if(maps[i].mapId == mapId)
{
return CloneMap(maps[i]);
}
}
return CloneMap(maps[0]);
}
public static MapDefinition GetMapByIndex(int mapIndex) {
EnsureMaps();
int clampedIndex = Mathf.Clamp(mapIndex, 0, maps.Length - 1);
return CloneMap(maps[clampedIndex]);
}
public static int GetIndex(MapId mapId) {
EnsureMaps();
for(int i = 0; i < maps.Length; i++)
{
if(maps[i].mapId == mapId)
{
return i;
}
}
return 0;
}
public static int LastMapIndex {
get {
EnsureMaps();
return maps.Length - 1;
}
}
private static void EnsureMaps() {
if(maps != null && maps.Length > 0)
{
return;
}
maps = new MapDefinition[] {
CreateTutorialIncheon(),
CreateJapan(),
CreateChina(),
CreateAmerica()
};
}
private static MapDefinition CreateTutorialIncheon() {
return new MapDefinition {
mapId = MapId.TutorialIncheon,
displayName = "Tutorial / Incheon",
routeName = "인천공항 출발 준비",
isTutorial = true,
targetDistance = 850f,
timeLimit = 95f,
clearMileageReward = 150,
hasNextMap = true,
nextMapId = MapId.Japan,
openingSteps = new PlatformSpawner.TutorialStep[] {
Step(Platform.PlatformPattern.LowMid, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.Jump, -2.4f, 1.05f, "점프", "가방 장애물이 보이면 왼쪽 클릭으로 뛰어넘습니다."),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.4f, 1f),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.DoubleJump, -2.4f, 2.1f, "2단 점프", "다음 발판이 멀리 있으면 공중에서 한 번 더 왼쪽 클릭합니다."),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.4f, 1.1f),
Step(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.CenterPair, GameManager.TutorialGuideType.Slide, -2.4f, 1.15f, "슬라이드", "천장 표지판이 보이면 오른쪽 클릭을 누르고 지나갑니다."),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.4f, 1f),
Step(Platform.PlatformPattern.ForceHit, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.WidePair, GameManager.TutorialGuideType.Damage, -2.4f, 1.15f, "피격", "이번 구간은 일부러 맞아 목숨과 속도 감소를 확인합니다."),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.Items, -2.4f, 0.95f, "초밥", "초밥을 먹으면 잃은 목숨을 1 회복합니다.", Platform.ItemPattern.HealthSushi),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.Items, -2.4f, 0.9f, "라멘", "라멘을 먹고 다음 충돌을 피해 없이 지나가 봅니다.", Platform.ItemPattern.InvincibleRamen),
Step(Platform.PlatformPattern.ForceHit, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.WidePair, GameManager.TutorialGuideType.None, -2.4f, 1.05f),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.Items, -2.4f, 0.9f, "방어막", "방어막을 먹고 다음 충돌 1회를 막아 봅니다.", Platform.ItemPattern.Shield),
Step(Platform.PlatformPattern.ForceHit, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.WidePair, GameManager.TutorialGuideType.None, -2.4f, 1.05f),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.Items, -2.4f, 0.95f, "운동화", "운동화는 속도를 회복해 일정이 늦어지는 것을 줄입니다.", Platform.ItemPattern.SpeedShoes),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.Items, -2.4f, 0.95f, "마일리지 카드", "카드를 먹은 뒤 마일리지를 모으면 획득량이 늘어납니다.", Platform.ItemPattern.MileageCard),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.4f, 1f),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.4f, 1f)
},
loopSteps = CreateEasyLoop()
};
}
private static MapDefinition CreateJapan() {
return new MapDefinition {
mapId = MapId.Japan,
displayName = "Japan",
routeName = "후쿠오카 라멘 투어",
isTutorial = false,
targetDistance = 1200f,
timeLimit = 105f,
clearMileageReward = 250,
hasNextMap = true,
nextMapId = MapId.China,
openingSteps = new PlatformSpawner.TutorialStep[] {
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.2f, 1.8f)
},
loopSteps = new PlatformSpawner.TutorialStep[] {
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.2f, 2.05f),
Step(Platform.PlatformPattern.LowMid, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.3f, 2.15f),
Step(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.CenterPair, GameManager.TutorialGuideType.None, -2.25f, 2.35f),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -1.95f, 2.05f),
Step(Platform.PlatformPattern.LowLeft, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.35f, 2.1f),
Step(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.WidePair, GameManager.TutorialGuideType.None, -2.3f, 2.45f)
}
};
}
private static MapDefinition CreateChina() {
return new MapDefinition {
mapId = MapId.China,
displayName = "China",
routeName = "베이징 시장길",
isTutorial = false,
targetDistance = 1450f,
timeLimit = 115f,
clearMileageReward = 350,
hasNextMap = true,
nextMapId = MapId.America,
openingSteps = new PlatformSpawner.TutorialStep[] {
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.1f, 1.9f)
},
loopSteps = new PlatformSpawner.TutorialStep[] {
Step(Platform.PlatformPattern.LowRight, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.25f, 2.0f),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -1.8f, 2.05f),
Step(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.LeftPair, GameManager.TutorialGuideType.None, -2.3f, 2.35f),
Step(Platform.PlatformPattern.LowMid, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.4f, 2.05f),
Step(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.RightPair, GameManager.TutorialGuideType.None, -2.25f, 2.35f),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2f, 1.95f)
}
};
}
private static MapDefinition CreateAmerica() {
return new MapDefinition {
mapId = MapId.America,
displayName = "America",
routeName = "LA 공항 고속도로",
isTutorial = false,
targetDistance = 1700f,
timeLimit = 125f,
clearMileageReward = 500,
hasNextMap = false,
nextMapId = MapId.America,
openingSteps = new PlatformSpawner.TutorialStep[] {
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.2f, 1.8f)
},
loopSteps = new PlatformSpawner.TutorialStep[] {
Step(Platform.PlatformPattern.LowMid, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.4f, 1.95f),
Step(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.WidePair, GameManager.TutorialGuideType.None, -2.25f, 2.25f),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -1.8f, 1.9f),
Step(Platform.PlatformPattern.LowLeft, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.35f, 1.95f),
Step(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.CenterPair, GameManager.TutorialGuideType.None, -2.25f, 2.2f),
Step(Platform.PlatformPattern.LowRight, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.1f, 1.9f)
}
};
}
private static PlatformSpawner.TutorialStep[] CreateEasyLoop() {
return new PlatformSpawner.TutorialStep[] {
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.4f, 2.1f),
Step(Platform.PlatformPattern.LowMid, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.4f, 2.2f),
Step(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.1f, 2.1f),
Step(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.CenterPair, GameManager.TutorialGuideType.None, -2.35f, 2.35f)
};
}
private static PlatformSpawner.TutorialStep Step(
Platform.PlatformPattern platformPattern,
Platform.MileagePattern mileagePattern,
SlideObstaclePattern.SlideLayout slideLayout,
GameManager.TutorialGuideType guideType,
float yPosition,
float spawnInterval,
string title = "",
string message = "",
Platform.ItemPattern itemPattern = Platform.ItemPattern.None) {
return new PlatformSpawner.TutorialStep {
platformPattern = platformPattern,
mileagePattern = mileagePattern,
slideLayout = slideLayout,
itemPattern = itemPattern,
guideType = guideType,
yPosition = yPosition,
spawnInterval = spawnInterval,
title = title,
message = message
};
}
private static MapDefinition CloneMap(MapDefinition source) {
return new MapDefinition {
mapId = source.mapId,
displayName = source.displayName,
routeName = source.routeName,
isTutorial = source.isTutorial,
targetDistance = source.targetDistance,
timeLimit = source.timeLimit,
clearMileageReward = source.clearMileageReward,
hasNextMap = source.hasNextMap,
nextMapId = source.nextMapId,
openingSteps = CloneSteps(source.openingSteps),
loopSteps = CloneSteps(source.loopSteps)
};
}
public static PlatformSpawner.TutorialStep[] CloneSteps(PlatformSpawner.TutorialStep[] source) {
if(source == null)
{
return new PlatformSpawner.TutorialStep[0];
}
PlatformSpawner.TutorialStep[] steps = new PlatformSpawner.TutorialStep[source.Length];
for(int i = 0; i < source.Length; i++)
{
PlatformSpawner.TutorialStep sourceStep = source[i];
steps[i] = Step(
sourceStep.platformPattern,
sourceStep.mileagePattern,
sourceStep.slideLayout,
sourceStep.guideType,
sourceStep.yPosition,
sourceStep.spawnInterval,
sourceStep.title,
sourceStep.message,
sourceStep.itemPattern);
}
return steps;
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 194fc0ddfc534d08801e82235a731527
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+16
View File
@@ -0,0 +1,16 @@
using System;
[Serializable]
public class MapDefinition {
public MapId mapId;
public string displayName;
public string routeName;
public bool isTutorial;
public float targetDistance;
public float timeLimit;
public int clearMileageReward;
public bool hasNextMap;
public MapId nextMapId;
public PlatformSpawner.TutorialStep[] openingSteps;
public PlatformSpawner.TutorialStep[] loopSteps;
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 65f34cfe92f647c39ff63111a09a3da8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+6
View File
@@ -0,0 +1,6 @@
public enum MapId {
TutorialIncheon = 0,
Japan = 1,
China = 2,
America = 3
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c10415d13fe84f39923b0f88bce0d90c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+1 -1
View File
@@ -38,7 +38,7 @@ public class MileagePickup : MonoBehaviour
private void OnTriggerEnter2D(Collider2D other)
{
if(collected || !other.CompareTag("Player"))
if(collected || !other.CompareTag("Player") || !GameFlowManager.IsGameplayActive)
{
return;
}
+189 -7
View File
@@ -1,4 +1,7 @@
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
// 발판으로서 필요한 동작을 담은 스크립트
public class Platform : MonoBehaviour {
@@ -8,7 +11,8 @@ public class Platform : MonoBehaviour {
LowLeft,
LowMid,
LowRight,
Slide
Slide,
ForceHit
}
public enum MileagePattern {
@@ -19,6 +23,15 @@ public class Platform : MonoBehaviour {
SlideLine
}
public enum ItemPattern {
None,
HealthSushi,
InvincibleRamen,
Shield,
SpeedShoes,
MileageCard
}
public GameObject[] obstacles; // 장애물 오브젝트들
public int emptyPatternWeight = 2; // 장애물이 없는 패턴이 선택될 가중치
public Sprite mileageSprite; // 이 발판에서 사용할 마일리지 토큰 스프라이트
@@ -28,20 +41,34 @@ public class Platform : MonoBehaviour {
private bool stepped = false; // 플레이어 캐릭터가 밟았었는가
private MileagePickup[] mileagePickups; // 런타임에 생성하는 마일리지 토큰 풀
private ItemPickup[] itemPickups; // 런타임에 생성하는 튜토리얼 아이템 풀
private PlatformPattern reservedPattern = PlatformPattern.Random; // 다음 활성화 때 사용할 수동 패턴
private MileagePattern reservedMileagePattern = MileagePattern.Random; // 다음 활성화 때 사용할 마일리지 패턴
private SlideObstaclePattern.SlideLayout reservedSlideLayout = SlideObstaclePattern.SlideLayout.Random; // 다음 슬라이딩 장애물 배치
private ItemPattern reservedItemPattern = ItemPattern.None; // 다음 활성화 때 사용할 아이템 배치
private const int MileagePoolSize = 5;
private const int ItemPoolSize = 1;
private const float MileageTokenScale = 0.55f;
private const float MileageColliderRadius = 0.4f;
private const float ItemTokenScale = 0.68f;
private const float ItemColliderRadius = 0.46f;
public void ConfigureForTutorial(PlatformPattern pattern, MileagePattern mileagePattern) {
public void ConfigureForTutorial(
PlatformPattern pattern,
MileagePattern mileagePattern,
SlideObstaclePattern.SlideLayout slideLayout = SlideObstaclePattern.SlideLayout.Random,
ItemPattern itemPattern = ItemPattern.None) {
reservedPattern = pattern;
reservedMileagePattern = mileagePattern;
reservedSlideLayout = slideLayout;
reservedItemPattern = itemPattern;
}
public void ConfigureForRandom() {
reservedPattern = PlatformPattern.Random;
reservedMileagePattern = MileagePattern.Random;
reservedSlideLayout = SlideObstaclePattern.SlideLayout.Random;
reservedItemPattern = ItemPattern.None;
}
// 컴포넌트가 활성화될때 마다 매번 실행되는 메서드
@@ -50,6 +77,7 @@ public class Platform : MonoBehaviour {
stepped = false;
EnsureMileagePickups();
EnsureItemPickups();
for(int i = 0; i < obstacles.Length; i++)
{
@@ -57,10 +85,11 @@ public class Platform : MonoBehaviour {
}
HideMileagePickups();
HideItemPickups();
if(reservedPattern != PlatformPattern.Random)
{
ApplyTutorialPattern();
ApplyReservedPattern();
return;
}
@@ -77,6 +106,7 @@ public class Platform : MonoBehaviour {
if(patternIndex < obstacles.Length)
{
activeObstacle = obstacles[patternIndex];
ConfigureSlideObstacle(activeObstacle);
activeObstacle.SetActive(true);
}
@@ -85,7 +115,7 @@ public class Platform : MonoBehaviour {
void OnCollisionEnter2D(Collision2D collision) {
// 플레이어 캐릭터가 자신을 밟았을때 점수를 추가하는 처리
if(collision.collider.tag == "Player" && !stepped)
if(collision.collider.tag == "Player" && !stepped && GameFlowManager.IsGameplayActive)
{
stepped = true;
GameManager.instance.AddScore(1);
@@ -122,6 +152,33 @@ public class Platform : MonoBehaviour {
}
}
private void EnsureItemPickups() {
if(itemPickups != null && itemPickups.Length == ItemPoolSize)
{
return;
}
itemPickups = new ItemPickup[ItemPoolSize];
for(int i = 0; i < ItemPoolSize; i++)
{
GameObject pickupObject = new GameObject("Tutorial Item Pickup " + (i + 1));
pickupObject.transform.SetParent(transform);
pickupObject.transform.localScale = GetParentScaleCompensatedVector(ItemTokenScale);
SpriteRenderer spriteRenderer = pickupObject.AddComponent<SpriteRenderer>();
spriteRenderer.sortingLayerName = "Foreground";
spriteRenderer.sortingOrder = 2;
CircleCollider2D pickupCollider = pickupObject.AddComponent<CircleCollider2D>();
pickupCollider.isTrigger = true;
pickupCollider.radius = ItemColliderRadius;
itemPickups[i] = pickupObject.AddComponent<ItemPickup>();
pickupObject.SetActive(false);
}
}
private void HideMileagePickups() {
if(mileagePickups == null)
{
@@ -137,6 +194,21 @@ public class Platform : MonoBehaviour {
}
}
private void HideItemPickups() {
if(itemPickups == null)
{
return;
}
for(int i = 0; i < itemPickups.Length; i++)
{
if(itemPickups[i] != null)
{
itemPickups[i].gameObject.SetActive(false);
}
}
}
private void TryPlaceMileagePattern(GameObject activeObstacle) {
if(mileageSprite == null || mileagePickups == null || Random.Range(0, 100) >= mileagePatternChance)
{
@@ -159,7 +231,7 @@ public class Platform : MonoBehaviour {
}
}
private void ApplyTutorialPattern() {
private void ApplyReservedPattern() {
GameObject activeObstacle = null;
switch(reservedPattern)
@@ -176,14 +248,46 @@ public class Platform : MonoBehaviour {
case PlatformPattern.Slide:
activeObstacle = FindObstacle("Slide Obstacle Pattern");
break;
case PlatformPattern.ForceHit:
activeObstacle = FindObstacle("Obstacle Mid");
ActivateObstacle(activeObstacle);
GameObject slideObstacle = FindObstacle("Slide Obstacle Pattern");
reservedSlideLayout = SlideObstaclePattern.SlideLayout.WidePair;
ActivateObstacle(slideObstacle);
break;
}
if(activeObstacle != null)
if(reservedPattern != PlatformPattern.ForceHit && activeObstacle != null)
{
activeObstacle.SetActive(true);
ActivateObstacle(activeObstacle);
}
PlaceReservedMileagePattern(activeObstacle);
PlaceReservedItemPattern();
}
private void ActivateObstacle(GameObject obstacle) {
if(obstacle == null)
{
return;
}
ConfigureSlideObstacle(obstacle);
obstacle.SetActive(true);
}
private void ConfigureSlideObstacle(GameObject activeObstacle) {
if(activeObstacle == null || !activeObstacle.name.Contains("Slide"))
{
return;
}
SlideObstaclePattern slideObstaclePattern = activeObstacle.GetComponent<SlideObstaclePattern>();
if(slideObstaclePattern != null)
{
slideObstaclePattern.ConfigureLayout(reservedSlideLayout);
}
}
private GameObject FindObstacle(string obstacleName) {
@@ -226,6 +330,84 @@ public class Platform : MonoBehaviour {
TryPlaceMileagePattern(activeObstacle);
}
private void PlaceReservedItemPattern() {
if(reservedItemPattern == ItemPattern.None || itemPickups == null || itemPickups.Length == 0)
{
return;
}
GameManager.TutorialItemType itemType = GetTutorialItemType(reservedItemPattern);
ItemPickup pickup = itemPickups[0];
pickup.Setup(LoadItemSprite(itemType), itemType);
pickup.transform.localPosition = GetParentScaleCompensatedPosition(GetItemPosition(reservedItemPattern));
pickup.ResetPickup();
}
private GameManager.TutorialItemType GetTutorialItemType(ItemPattern itemPattern) {
switch(itemPattern)
{
case ItemPattern.InvincibleRamen:
return GameManager.TutorialItemType.InvincibleRamen;
case ItemPattern.Shield:
return GameManager.TutorialItemType.Shield;
case ItemPattern.SpeedShoes:
return GameManager.TutorialItemType.SpeedShoes;
case ItemPattern.MileageCard:
return GameManager.TutorialItemType.MileageCard;
case ItemPattern.HealthSushi:
default:
return GameManager.TutorialItemType.HealthSushi;
}
}
private Vector2 GetItemPosition(ItemPattern itemPattern) {
switch(itemPattern)
{
case ItemPattern.HealthSushi:
return new Vector2(-1.6f, 1.55f);
case ItemPattern.InvincibleRamen:
return new Vector2(-0.8f, 1.55f);
case ItemPattern.Shield:
return new Vector2(-0.2f, 1.55f);
case ItemPattern.SpeedShoes:
return new Vector2(0.7f, 1.55f);
case ItemPattern.MileageCard:
return new Vector2(1.4f, 1.55f);
default:
return new Vector2(0f, 1.55f);
}
}
private Sprite LoadItemSprite(GameManager.TutorialItemType itemType) {
#if UNITY_EDITOR
string assetPath = "";
switch(itemType)
{
case GameManager.TutorialItemType.InvincibleRamen:
assetPath = "Assets/Sprites/Items/Item_Invincible_Ramen.png";
break;
case GameManager.TutorialItemType.Shield:
assetPath = "Assets/Sprites/Items/Item_Shield.png";
break;
case GameManager.TutorialItemType.SpeedShoes:
assetPath = "Assets/Sprites/Items/Item_Speed_Shoes.png";
break;
case GameManager.TutorialItemType.MileageCard:
assetPath = "Assets/Sprites/Items/Item_Mileage_Card.png";
break;
case GameManager.TutorialItemType.HealthSushi:
default:
assetPath = "Assets/Sprites/Items/Item_Health_Sushi.png";
break;
}
return AssetDatabase.LoadAssetAtPath<Sprite>(assetPath);
#else
return null;
#endif
}
private void PlaceSafeMileageLine() {
Vector2[] positions = {
new Vector2(-2f, 1.35f),
+240 -35
View File
@@ -4,18 +4,38 @@ using Random = UnityEngine.Random;
// 발판을 생성하고 주기적으로 재배치하는 스크립트
public class PlatformSpawner : MonoBehaviour {
private enum TutorialStepKind {
None,
Jump,
DoubleJumpTakeoff,
Slide,
ForceHit,
HealthItem,
InvincibleItem,
ShieldItem,
SpeedItem,
MileageCardItem
}
[Serializable]
public class TutorialStep {
public Platform.PlatformPattern platformPattern;
public Platform.MileagePattern mileagePattern;
public SlideObstaclePattern.SlideLayout slideLayout;
public Platform.ItemPattern itemPattern;
public GameManager.TutorialGuideType guideType;
public float yPosition;
public float spawnInterval;
public string title;
public string message;
}
public GameObject platformPrefab; // 생성할 발판의 원본 프리팹
public int count = 3; // 생성할 발판의 개수
public bool tutorialMode = true; // 튜토리얼에서는 랜덤 대신 정해진 순서로 배치
public bool usePatternCourse = true; // 검증된 발판 패턴 코스를 사용
public bool tutorialMode = true; // 처음에는 튜토리얼 패턴을 먼저 배치
public TutorialStep[] tutorialSteps; // 튜토리얼 발판 순서
public TutorialStep[] stagePatternSteps; // 튜토리얼 이후 반복할 검증된 발판 순서
public float timeBetSpawnMin = 1.25f; // 다음 배치까지의 시간 간격 최솟값
public float timeBetSpawnMax = 2.25f; // 다음 배치까지의 시간 간격 최댓값
@@ -28,13 +48,46 @@ public class PlatformSpawner : MonoBehaviour {
private GameObject[] platforms; // 미리 생성한 발판들
private int currentIndex = 0; // 사용할 현재 순번의 발판
private int tutorialStepIndex = 0; // 진행 중인 튜토리얼 순번
private int courseStepIndex = 0; // 진행 중인 패턴 코스 순번
private Vector2 poolPosition = new Vector2(0, -25); // 초반에 생성된 발판들을 화면 밖에 숨겨둘 위치
private float lastSpawnTime; // 마지막 배치 시점
public void ApplyMap(MapDefinition mapDefinition) {
if(mapDefinition == null)
{
return;
}
usePatternCourse = true;
tutorialMode = mapDefinition.isTutorial;
tutorialSteps = MapDatabase.CloneSteps(mapDefinition.openingSteps);
stagePatternSteps = MapDatabase.CloneSteps(mapDefinition.loopSteps);
courseStepIndex = 0;
currentIndex = 0;
lastSpawnTime = Time.time;
timeBetSpawn = 0f;
if(platforms != null)
{
for(int i = 0; i < platforms.Length; i++)
{
if(platforms[i] != null)
{
platforms[i].SetActive(false);
platforms[i].transform.position = poolPosition;
}
}
}
}
void Start() {
EnsureTutorialSteps();
EnsurePatternSteps();
if(usePatternCourse && tutorialMode)
{
count = Mathf.Max(count, 8);
}
// 변수들을 초기화하고 사용할 발판들을 미리 생성
platforms = new GameObject[count];
@@ -53,7 +106,7 @@ public class PlatformSpawner : MonoBehaviour {
void Update() {
// 순서를 돌아가며 주기적으로 발판을 배치
if(GameManager.instance.isGameover)
if(GameManager.instance == null || GameManager.instance.isGameover || !GameFlowManager.IsGameplayActive)
{
return;
}
@@ -62,12 +115,15 @@ public class PlatformSpawner : MonoBehaviour {
{
lastSpawnTime = Time.time;
timeBetSpawn = tutorialMode ? tutorialSpawnInterval : Random.Range(timeBetSpawnMin, timeBetSpawnMax);
TutorialStep step = usePatternCourse ? GetCurrentCourseStep() : null;
timeBetSpawn = step != null ? GetStepSpawnInterval(step) : Random.Range(timeBetSpawnMin, timeBetSpawnMax);
float yPos = tutorialMode ? GetTutorialYPosition() : Random.Range(yMin, yMax);
float yPos = step != null
? Mathf.Clamp(step.yPosition, yMin, yMax)
: Random.Range(yMin, yMax);
platforms[currentIndex].SetActive(false);
ConfigurePlatform(platforms[currentIndex]);
ConfigurePlatform(platforms[currentIndex], step);
platforms[currentIndex].SetActive(true);
platforms[currentIndex].transform.position = new Vector2(xPos, yPos);
@@ -79,14 +135,14 @@ public class PlatformSpawner : MonoBehaviour {
currentIndex = 0;
}
if(tutorialMode)
if(usePatternCourse)
{
tutorialStepIndex++;
courseStepIndex++;
}
}
}
private void ConfigurePlatform(GameObject platformObject) {
private void ConfigurePlatform(GameObject platformObject, TutorialStep step) {
Platform platform = platformObject.GetComponent<Platform>();
if(platform == null)
@@ -94,37 +150,74 @@ public class PlatformSpawner : MonoBehaviour {
return;
}
if(!tutorialMode)
if(step == null)
{
platform.ConfigureForRandom();
return;
}
TutorialStep step = GetCurrentTutorialStep();
platform.ConfigureForTutorial(step.platformPattern, step.mileagePattern);
platform.ConfigureForTutorial(step.platformPattern, step.mileagePattern, step.slideLayout, step.itemPattern);
if(GameManager.instance != null)
if(GameManager.instance != null && step.guideType != GameManager.TutorialGuideType.None)
{
GameManager.instance.SetTutorialMessage(step.message);
GameManager.instance.SetTutorialGuide(step.guideType, step.title, step.message);
}
if(GameManager.instance != null
&& step.guideType == GameManager.TutorialGuideType.None
&& (!tutorialMode || courseStepIndex >= tutorialSteps.Length))
{
GameManager.instance.SetTutorialGuide(GameManager.TutorialGuideType.None, "", "");
}
}
private float GetTutorialYPosition() {
return Mathf.Clamp(GetCurrentTutorialStep().yPosition, yMin, yMax);
private float GetStepSpawnInterval(TutorialStep step) {
if(step != null && step.spawnInterval > 0f)
{
return step.spawnInterval;
}
return tutorialSpawnInterval;
}
private TutorialStep GetCurrentTutorialStep() {
private TutorialStep GetCurrentCourseStep() {
EnsurePatternSteps();
if(tutorialMode && courseStepIndex < tutorialSteps.Length)
{
return tutorialSteps[courseStepIndex];
}
int loopIndex = tutorialMode
? courseStepIndex - tutorialSteps.Length
: courseStepIndex;
if(stagePatternSteps == null || stagePatternSteps.Length == 0)
{
return CreateStep(
Platform.PlatformPattern.Empty,
Platform.MileagePattern.SafeLine,
SlideObstaclePattern.SlideLayout.Random,
GameManager.TutorialGuideType.None,
-2.2f,
tutorialSpawnInterval,
"",
"");
}
loopIndex %= stagePatternSteps.Length;
if(loopIndex < 0)
{
loopIndex += stagePatternSteps.Length;
}
return stagePatternSteps[loopIndex];
}
private void EnsurePatternSteps() {
EnsureTutorialSteps();
if(tutorialStepIndex < tutorialSteps.Length)
{
return tutorialSteps[tutorialStepIndex];
}
int reviewStartIndex = Mathf.Max(0, tutorialSteps.Length - 3);
int reviewLength = tutorialSteps.Length - reviewStartIndex;
int reviewIndex = reviewStartIndex + ((tutorialStepIndex - tutorialSteps.Length) % reviewLength);
return tutorialSteps[reviewIndex];
EnsureStagePatternSteps();
}
private void EnsureTutorialSteps() {
@@ -134,21 +227,133 @@ public class PlatformSpawner : MonoBehaviour {
}
tutorialSteps = new TutorialStep[] {
CreateStep(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, -2.4f, "Tutorial 1: Run forward and collect mileage."),
CreateStep(Platform.PlatformPattern.LowMid, Platform.MileagePattern.JumpArc, -2.4f, "Tutorial 2: Left click to jump over suitcase obstacles."),
CreateStep(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, -2.4f, "Tutorial 3: Hold right click to slide under airport signs."),
CreateStep(Platform.PlatformPattern.LowLeft, Platform.MileagePattern.JumpArc, -2.1f, "Tutorial 4: Suitcases mean jump."),
CreateStep(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, -2.2f, "Tutorial 5: Hanging signs mean slide."),
CreateStep(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, -1.8f, "Tutorial complete: collect mileage and keep moving."),
CreateTutorialStep(TutorialStepKind.Jump, Platform.PlatformPattern.LowMid, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, -2.4f, 1.05f),
CreateTutorialStep(TutorialStepKind.None, Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, -2.4f, 1f),
CreateTutorialStep(TutorialStepKind.DoubleJumpTakeoff, Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, -2.4f, 2.10f),
CreateTutorialStep(TutorialStepKind.None, Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, -2.4f, 1.10f),
CreateTutorialStep(TutorialStepKind.Slide, Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.CenterPair, -2.4f, 1.15f),
CreateTutorialStep(TutorialStepKind.None, Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, -2.4f, 1f),
CreateTutorialStep(TutorialStepKind.ForceHit, Platform.PlatformPattern.ForceHit, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.WidePair, -2.4f, 1.15f),
CreateTutorialStep(TutorialStepKind.HealthItem, Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, -2.4f, 0.95f, Platform.ItemPattern.HealthSushi),
CreateTutorialStep(TutorialStepKind.InvincibleItem, Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, -2.4f, 0.9f, Platform.ItemPattern.InvincibleRamen),
CreateTutorialStep(TutorialStepKind.None, Platform.PlatformPattern.ForceHit, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.WidePair, -2.4f, 1.05f),
CreateTutorialStep(TutorialStepKind.ShieldItem, Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, -2.4f, 0.9f, Platform.ItemPattern.Shield),
CreateTutorialStep(TutorialStepKind.None, Platform.PlatformPattern.ForceHit, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.WidePair, -2.4f, 1.05f),
CreateTutorialStep(TutorialStepKind.SpeedItem, Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, -2.4f, 0.95f, Platform.ItemPattern.SpeedShoes),
CreateTutorialStep(TutorialStepKind.MileageCardItem, Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, -2.4f, 0.95f, Platform.ItemPattern.MileageCard),
CreateTutorialStep(TutorialStepKind.None, Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, -2.4f, 1f),
CreateTutorialStep(TutorialStepKind.None, Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, -2.4f, 1f),
};
}
private TutorialStep CreateStep(Platform.PlatformPattern platformPattern, Platform.MileagePattern mileagePattern, float yPosition, string message) {
private void EnsureStagePatternSteps() {
if(stagePatternSteps != null && stagePatternSteps.Length > 0)
{
return;
}
stagePatternSteps = new TutorialStep[] {
CreateStep(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.2f, 2.25f, "", ""),
CreateStep(Platform.PlatformPattern.LowMid, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.3f, 2.4f, "", ""),
CreateStep(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -1.95f, 2.2f, "", ""),
CreateStep(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.WidePair, GameManager.TutorialGuideType.None, -2.25f, 2.65f, "", ""),
CreateStep(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.0f, 2.2f, "", ""),
CreateStep(Platform.PlatformPattern.LowLeft, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.35f, 2.35f, "", ""),
CreateStep(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.LeftPair, GameManager.TutorialGuideType.None, -2.35f, 2.7f, "", ""),
CreateStep(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.1f, 2.2f, "", ""),
CreateStep(Platform.PlatformPattern.LowRight, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.0f, 2.35f, "", ""),
CreateStep(Platform.PlatformPattern.Empty, Platform.MileagePattern.None, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -1.85f, 2.15f, "", ""),
CreateStep(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.RightPair, GameManager.TutorialGuideType.None, -2.25f, 2.65f, "", ""),
CreateStep(Platform.PlatformPattern.LowMid, Platform.MileagePattern.JumpArc, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.4f, 2.35f, "", ""),
CreateStep(Platform.PlatformPattern.Empty, Platform.MileagePattern.SafeLine, SlideObstaclePattern.SlideLayout.Random, GameManager.TutorialGuideType.None, -2.0f, 2.2f, "", ""),
CreateStep(Platform.PlatformPattern.Slide, Platform.MileagePattern.SlideLine, SlideObstaclePattern.SlideLayout.CenterPair, GameManager.TutorialGuideType.None, -2.2f, 2.7f, "", ""),
};
}
private TutorialStep CreateStep(
Platform.PlatformPattern platformPattern,
Platform.MileagePattern mileagePattern,
SlideObstaclePattern.SlideLayout slideLayout,
GameManager.TutorialGuideType guideType,
float yPosition,
float spawnInterval,
string title,
string message,
Platform.ItemPattern itemPattern = Platform.ItemPattern.None) {
TutorialStep step = new TutorialStep();
step.platformPattern = platformPattern;
step.mileagePattern = mileagePattern;
step.slideLayout = slideLayout;
step.itemPattern = itemPattern;
step.guideType = guideType;
step.yPosition = yPosition;
step.spawnInterval = spawnInterval;
step.title = title;
step.message = message;
return step;
}
private TutorialStep CreateTutorialStep(
TutorialStepKind stepKind,
Platform.PlatformPattern platformPattern,
Platform.MileagePattern mileagePattern,
SlideObstaclePattern.SlideLayout slideLayout,
float yPosition,
float spawnInterval,
Platform.ItemPattern itemPattern = Platform.ItemPattern.None) {
GameManager.TutorialGuideType guideType = GameManager.TutorialGuideType.None;
string title = "";
string message = "";
switch(stepKind)
{
case TutorialStepKind.Jump:
guideType = GameManager.TutorialGuideType.Jump;
title = "점프";
message = "가방 장애물이 보이면 왼쪽 클릭으로 뛰어넘습니다.";
break;
case TutorialStepKind.DoubleJumpTakeoff:
guideType = GameManager.TutorialGuideType.DoubleJump;
title = "2단 점프";
message = "다음 발판이 멀리 있으면 공중에서 한 번 더 왼쪽 클릭합니다.";
break;
case TutorialStepKind.Slide:
guideType = GameManager.TutorialGuideType.Slide;
title = "슬라이드";
message = "천장 표지판이 보이면 오른쪽 클릭을 누르고 지나갑니다.";
break;
case TutorialStepKind.ForceHit:
guideType = GameManager.TutorialGuideType.Damage;
title = "피격";
message = "이번 구간은 일부러 맞아 목숨과 속도 감소를 확인합니다.";
break;
case TutorialStepKind.HealthItem:
guideType = GameManager.TutorialGuideType.Items;
title = "초밥";
message = "초밥을 먹으면 잃은 목숨을 1 회복합니다.";
break;
case TutorialStepKind.InvincibleItem:
guideType = GameManager.TutorialGuideType.Items;
title = "라멘";
message = "라멘을 먹고 다음 충돌을 피해 없이 지나가 봅니다.";
break;
case TutorialStepKind.ShieldItem:
guideType = GameManager.TutorialGuideType.Items;
title = "방어막";
message = "방어막을 먹고 다음 충돌 1회를 막아 봅니다.";
break;
case TutorialStepKind.SpeedItem:
guideType = GameManager.TutorialGuideType.Items;
title = "운동화";
message = "운동화는 속도를 회복해 일정이 늦어지는 것을 줄입니다.";
break;
case TutorialStepKind.MileageCardItem:
guideType = GameManager.TutorialGuideType.Items;
title = "마일리지 카드";
message = "카드를 먹은 뒤 마일리지를 모으면 획득량이 늘어납니다.";
break;
}
return CreateStep(platformPattern, mileagePattern, slideLayout, guideType, yPosition, spawnInterval, title, message, itemPattern);
}
}
+12 -1
View File
@@ -42,7 +42,7 @@ public class PlayerController : MonoBehaviour {
}
private void Update() {
if(isDead)
if(isDead || !GameFlowManager.IsGameplayActive)
{
return;
}
@@ -108,6 +108,11 @@ public class PlayerController : MonoBehaviour {
}
private void OnTriggerEnter2D(Collider2D other) {
if(!GameFlowManager.IsGameplayActive)
{
return;
}
if(other.CompareTag("Dead") && !isDead)
{
if(other.gameObject.name == "DeadZone")
@@ -163,6 +168,12 @@ public class PlayerController : MonoBehaviour {
return;
}
if(GameManager.instance != null && GameManager.instance.TryBlockDamageWithItem())
{
StartCoroutine(InvincibleRoutine());
return;
}
bool shouldDie = GameManager.instance.TakeDamage();
if(shouldDie)
+3 -3
View File
@@ -6,9 +6,9 @@ public class ScrollingObject : MonoBehaviour {
private void Update() {
// 게임 오브젝트를 왼쪽으로 일정 속도로 평행 이동하는 처리
if(!GameManager.instance.isGameover)
if(GameManager.instance != null && !GameManager.instance.isGameover && GameFlowManager.IsGameplayActive)
{
transform.Translate(Vector3.left * speed * GameManager.instance.gameSpeed * Time.deltaTime);
transform.Translate(Vector3.left * speed * GameManager.instance.gameSpeed * Time.deltaTime);
}
}
}
}
+85
View File
@@ -3,9 +3,18 @@ using UnityEngine;
// 슬라이딩 장애물 패턴이 켜질 때마다 매달린 장애물 위치를 섞는다.
public class SlideObstaclePattern : MonoBehaviour
{
public enum SlideLayout {
Random,
LeftPair,
CenterPair,
RightPair,
WidePair
}
public Transform[] obstacles; // 위치를 바꿀 고공 장애물들
public float[] xPositions = { -1.65f, -0.55f, 0.55f, 1.65f }; // 선택 가능한 로컬 x 위치
public float minSpacing = 1.5f; // 두 장애물 사이의 최소 간격
public SlideLayout layout = SlideLayout.Random; // 패턴 스포너가 지정하는 슬라이딩 장애물 배치
private void OnEnable()
{
@@ -14,6 +23,12 @@ public class SlideObstaclePattern : MonoBehaviour
return;
}
if(layout != SlideLayout.Random)
{
PlaceLayout(layout);
return;
}
int firstIndex = Random.Range(0, xPositions.Length);
if(obstacles.Length == 1 || xPositions.Length == 1)
@@ -38,6 +53,76 @@ public class SlideObstaclePattern : MonoBehaviour
PlaceObstacle(obstacles[1], rightX);
}
public void ConfigureLayout(SlideLayout nextLayout)
{
layout = nextLayout;
}
private void PlaceLayout(SlideLayout fixedLayout)
{
if(obstacles.Length == 1)
{
PlaceObstacle(obstacles[0], PickSingleX(fixedLayout));
return;
}
float leftX;
float rightX;
switch(fixedLayout)
{
case SlideLayout.LeftPair:
leftX = xPositions[0];
rightX = xPositions[Mathf.Min(2, xPositions.Length - 1)];
break;
case SlideLayout.CenterPair:
leftX = xPositions[Mathf.Min(1, xPositions.Length - 1)];
rightX = xPositions[Mathf.Min(2, xPositions.Length - 1)];
break;
case SlideLayout.RightPair:
leftX = xPositions[Mathf.Max(0, xPositions.Length - 3)];
rightX = xPositions[xPositions.Length - 1];
break;
case SlideLayout.WidePair:
default:
leftX = xPositions[0];
rightX = xPositions[xPositions.Length - 1];
break;
}
if(Mathf.Approximately(leftX, rightX) && xPositions.Length > 1)
{
leftX = xPositions[0];
rightX = xPositions[xPositions.Length - 1];
}
if(leftX > rightX)
{
float temp = leftX;
leftX = rightX;
rightX = temp;
}
PlaceObstacle(obstacles[0], leftX);
PlaceObstacle(obstacles[1], rightX);
}
private float PickSingleX(SlideLayout fixedLayout)
{
switch(fixedLayout)
{
case SlideLayout.LeftPair:
return xPositions[0];
case SlideLayout.CenterPair:
return xPositions[Mathf.Min(1, xPositions.Length - 1)];
case SlideLayout.RightPair:
return xPositions[xPositions.Length - 1];
case SlideLayout.WidePair:
default:
return xPositions[Mathf.Min(2, xPositions.Length - 1)];
}
}
private int PickSecondIndex(int firstIndex)
{
for(int i = 0; i < 12; i++)