Tune tutorial coin arcs and finish flow

This commit is contained in:
jongjae0305
2026-07-06 17:34:13 +09:00
parent 2ba3882f1b
commit 5423382296
17 changed files with 266 additions and 65 deletions
+67 -21
View File
@@ -1,12 +1,15 @@
using TMPro;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
public enum FinishGateStyle {
None,
AirportDeparture,
JapanTorii,
ChinaPaifang,
AmericaRoadSign
AmericaLiberty
}
public class FinishGate : MonoBehaviour {
@@ -75,8 +78,8 @@ public class FinishGate : MonoBehaviour {
case FinishGateStyle.ChinaPaifang:
BuildChinaPaifang();
break;
case FinishGateStyle.AmericaRoadSign:
BuildAmericaRoadSign();
case FinishGateStyle.AmericaLiberty:
BuildAmericaLibertyFallback();
break;
case FinishGateStyle.AirportDeparture:
default:
@@ -98,19 +101,7 @@ public class FinishGate : MonoBehaviour {
return false;
}
Sprite sprite = Resources.Load<Sprite>(resourcePath);
if(sprite == null)
{
Texture2D texture = Resources.Load<Texture2D>(resourcePath);
if(texture != null)
{
sprite = Sprite.Create(
texture,
new Rect(0f, 0f, texture.width, texture.height),
new Vector2(0.5f, 0.5f),
RuntimeSpritePixelsPerUnit);
}
}
Sprite sprite = LoadGateSprite(resourcePath);
if(sprite == null)
{
@@ -129,10 +120,55 @@ public class FinishGate : MonoBehaviour {
float spriteHeight = Mathf.Max(0.01f, sprite.bounds.size.y);
float scale = targetHeight / spriteHeight;
spriteObject.transform.localScale = new Vector3(scale, scale, 1f);
spriteObject.transform.localPosition = new Vector3(0f, targetHeight * 0.5f, 0f);
spriteObject.transform.localPosition = new Vector3(0f, targetHeight * 0.5f - GetSpriteGateBottomInset(style), 0f);
return true;
}
private Sprite LoadGateSprite(string resourcePath) {
Sprite sprite = Resources.Load<Sprite>(resourcePath);
if(sprite != null)
{
return sprite;
}
Sprite[] sprites = Resources.LoadAll<Sprite>(resourcePath);
if(sprites != null && sprites.Length > 0 && sprites[0] != null)
{
return sprites[0];
}
Texture2D texture = Resources.Load<Texture2D>(resourcePath);
if(texture != null)
{
return CreateRuntimeSprite(texture);
}
#if UNITY_EDITOR
string assetPath = "Assets/Resources/" + resourcePath + ".png";
sprite = AssetDatabase.LoadAssetAtPath<Sprite>(assetPath);
if(sprite != null)
{
return sprite;
}
texture = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
if(texture != null)
{
return CreateRuntimeSprite(texture);
}
#endif
return null;
}
private Sprite CreateRuntimeSprite(Texture2D texture) {
return Sprite.Create(
texture,
new Rect(0f, 0f, texture.width, texture.height),
new Vector2(0.5f, 0.5f),
RuntimeSpritePixelsPerUnit);
}
private string GetSpriteResourcePath(FinishGateStyle style) {
switch(style)
{
@@ -142,8 +178,8 @@ public class FinishGate : MonoBehaviour {
return "FinishGate_Japan_Torii";
case FinishGateStyle.ChinaPaifang:
return "FinishGate_China_Paifang";
case FinishGateStyle.AmericaRoadSign:
return "FinishGate_America_Highway";
case FinishGateStyle.AmericaLiberty:
return "FinishGate_America_Liberty";
default:
return "";
}
@@ -154,13 +190,23 @@ public class FinishGate : MonoBehaviour {
{
case FinishGateStyle.ChinaPaifang:
return 4.65f;
case FinishGateStyle.AmericaRoadSign:
case FinishGateStyle.AmericaLiberty:
return 4.45f;
default:
return 4.35f;
}
}
private float GetSpriteGateBottomInset(FinishGateStyle style) {
switch(style)
{
case FinishGateStyle.JapanTorii:
return 0.36f;
default:
return 0f;
}
}
private void BuildAirportDepartureGate() {
Color navy = new Color(0.05f, 0.2f, 0.34f, 1f);
Color blue = new Color(0.08f, 0.42f, 0.67f, 1f);
@@ -212,7 +258,7 @@ public class FinishGate : MonoBehaviour {
CreateBlock("Paifang Lantern Right", new Vector2(0.78f, 2.18f), new Vector2(0.26f, 0.34f), red, 7);
}
private void BuildAmericaRoadSign() {
private void BuildAmericaLibertyFallback() {
Color metal = new Color(0.48f, 0.56f, 0.58f, 1f);
Color green = new Color(0.03f, 0.38f, 0.22f, 1f);
Color yellow = new Color(1f, 0.8f, 0.18f, 1f);
+86 -16
View File
@@ -62,6 +62,7 @@ public class GameManager : MonoBehaviour {
private int tutorialGuideVersion = 0; // 오래된 자동 숨김 요청을 무시하기 위한 버전
private FinishGate activeFinishGate; // 목표 지점에 나타나는 맵별 클리어 게이트
private bool finishGateSpawned = false; // 현재 맵에서 도착 게이트를 이미 만들었는지 여부
private PlatformSpawner activePlatformSpawner; // 현재 맵의 발판/장애물 생성기
public int maxLife = 3; // 시작 목숨
public int initialMileage = 500; // 첫 시작시 지급할 초기 마일리지
@@ -222,10 +223,10 @@ public class GameManager : MonoBehaviour {
gameoverUI.SetActive(false);
}
PlatformSpawner platformSpawner = FindFirstObjectByType<PlatformSpawner>();
if(platformSpawner != null)
activePlatformSpawner = FindFirstObjectByType<PlatformSpawner>();
if(activePlatformSpawner != null)
{
platformSpawner.ApplyMap(currentMapDefinition);
activePlatformSpawner.ApplyMap(currentMapDefinition);
}
StartTutorialGuideSequence();
@@ -646,11 +647,31 @@ public class GameManager : MonoBehaviour {
return;
}
float gateGroundY = finishGateGroundY;
EnsureFinishGateLanding(gateGroundY);
GameObject gateObject = new GameObject("Finish Gate - " + currentMapDefinition.displayName);
gateObject.transform.position = new Vector3(finishGateSpawnX, finishGateGroundY, 0f);
gateObject.transform.position = new Vector3(finishGateSpawnX, gateGroundY, 0f);
activeFinishGate = gateObject.AddComponent<FinishGate>();
activeFinishGate.Setup(currentMapDefinition.finishGateStyle);
finishGateSpawned = true;
if(activePlatformSpawner != null)
{
activePlatformSpawner.StopCourseSpawningAfterFinishGate();
}
}
private void EnsureFinishGateLanding(float gateGroundY) {
if(activePlatformSpawner == null)
{
activePlatformSpawner = FindFirstObjectByType<PlatformSpawner>();
}
if(activePlatformSpawner != null)
{
activePlatformSpawner.SpawnFinishLandingPlatform(finishGateSpawnX, gateGroundY);
}
}
private bool ShouldUseFinishGate() {
@@ -665,6 +686,11 @@ public class GameManager : MonoBehaviour {
Destroy(activeFinishGate.gameObject);
activeFinishGate = null;
}
if(activePlatformSpawner != null)
{
activePlatformSpawner.ClearFinishLandingPlatform();
}
}
private void SyncTotalMileageToFlow() {
@@ -739,14 +765,15 @@ public class GameManager : MonoBehaviour {
tutorialBodyText.text = tutorialMessage;
}
bool showItemRow = tutorialGuideType == TutorialGuideType.Items;
Sprite mainSprite = GetTutorialMainSprite(tutorialGuideType);
bool showFullText = !showItemRow && mainSprite == null;
UpdateTutorialTextLayout(showItemRow, showFullText);
int activeItemImageIndex = GetTutorialItemImageIndex();
bool showItemImage = tutorialGuideType == TutorialGuideType.Items && HasTutorialItemImage(activeItemImageIndex);
Sprite mainSprite = showItemImage ? null : GetTutorialMainSprite(tutorialGuideType);
bool showFullText = !showItemImage && mainSprite == null;
UpdateTutorialTextLayout(showItemImage, showFullText);
if(tutorialMainImage != null)
{
tutorialMainImage.gameObject.SetActive(!showItemRow && mainSprite != null);
tutorialMainImage.gameObject.SetActive(!showItemImage && mainSprite != null);
tutorialMainImage.sprite = mainSprite;
}
@@ -761,7 +788,7 @@ public class GameManager : MonoBehaviour {
{
if(tutorialItemImages[i] != null)
{
tutorialItemImages[i].gameObject.SetActive(showItemRow && tutorialItemImages[i].sprite != null);
tutorialItemImages[i].gameObject.SetActive(showItemImage && i == activeItemImageIndex);
}
}
}
@@ -775,10 +802,10 @@ public class GameManager : MonoBehaviour {
if(showItemRow)
{
textPosition = new Vector2(154f, -8f);
textSize = new Vector2(178f, 17f);
bodyPosition = new Vector2(154f, -26f);
bodySize = new Vector2(178f, 31f);
textPosition = new Vector2(80f, -8f);
textSize = new Vector2(252f, 17f);
bodyPosition = new Vector2(80f, -26f);
bodySize = new Vector2(252f, 31f);
}
else if(showFullText)
{
@@ -801,6 +828,49 @@ public class GameManager : MonoBehaviour {
}
}
private bool HasTutorialItemImage(int itemImageIndex) {
return tutorialItemImages != null
&& itemImageIndex >= 0
&& itemImageIndex < tutorialItemImages.Length
&& tutorialItemImages[itemImageIndex] != null
&& tutorialItemImages[itemImageIndex].sprite != null;
}
private int GetTutorialItemImageIndex() {
if(tutorialGuideType != TutorialGuideType.Items)
{
return -1;
}
string itemText = (tutorialTitle + " " + tutorialMessage).Trim();
if(itemText.Contains("초밥"))
{
return 0;
}
if(itemText.Contains("라멘"))
{
return 1;
}
if(itemText.Contains("방어막"))
{
return 2;
}
if(itemText.Contains("운동화"))
{
return 3;
}
if(itemText.Contains("마일리지"))
{
return 4;
}
return -1;
}
private Color GetActiveLifeIconColor(Image lifeIcon) {
if(lifeIcon != null && lifeIcon.sprite == null)
{
@@ -1061,8 +1131,8 @@ public class GameManager : MonoBehaviour {
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);
itemRect.anchoredPosition = new Vector2(14f, 0f);
itemRect.sizeDelta = new Vector2(54f, 54f);
Image itemImage = itemObject.GetComponent<Image>();
itemImage.sprite = LoadTutorialSprite(itemAssetPaths[i]);
+1 -1
View File
@@ -193,7 +193,7 @@ public static class MapDatabase {
isTutorial = false,
platformSkin = Platform.PlatformSkin.USA,
backgroundResourcePath = "Map_America_NewYork",
finishGateStyle = FinishGateStyle.AmericaRoadSign,
finishGateStyle = FinishGateStyle.AmericaLiberty,
targetDistance = 1700f,
timeLimit = 125f,
clearMileageReward = 500,
+21 -5
View File
@@ -60,6 +60,8 @@ public class Platform : MonoBehaviour {
private const float MileageColliderRadius = 0.4f;
private const float ItemTokenScale = 0.68f;
private const float ItemColliderRadius = 0.46f;
private const float JumpMileageMinWidthScale = 1.8f;
private const float JumpMileageMaxWidthScale = 2.5f;
public void ConfigureForTutorial(
PlatformPattern pattern,
@@ -619,15 +621,29 @@ public class Platform : MonoBehaviour {
}
private void PlaceJumpMileageArc(float obstacleX) {
float widthScale = GetJumpMileageArcWidthScale();
Vector2[] positions = {
new Vector2(obstacleX - 1.2f, 1.65f),
new Vector2(obstacleX, 2.25f),
new Vector2(obstacleX + 1.2f, 1.65f),
new Vector2(obstacleX + 1.9f, 1.35f)
new Vector2(obstacleX - 2.25f * widthScale, 1.88f),
new Vector2(obstacleX - 1.15f * widthScale, 2.88f),
new Vector2(obstacleX, 3.58f),
new Vector2(obstacleX + 1.15f * widthScale, 2.88f),
new Vector2(obstacleX + 2.25f * widthScale, 1.88f)
};
PlaceMileageTokens(positions, smallMileageValue);
SetPickupValue(1, bonusMileageValue);
SetPickupValue(2, bonusMileageValue);
}
private float GetJumpMileageArcWidthScale() {
if(GameManager.instance == null)
{
return JumpMileageMinWidthScale;
}
float minSpeed = Mathf.Max(0.1f, GameManager.instance.minGameSpeed);
float maxSpeed = Mathf.Max(minSpeed + 0.1f, GameManager.instance.maxGameSpeed);
float speedProgress = Mathf.InverseLerp(minSpeed, maxSpeed, GameManager.instance.gameSpeed);
return Mathf.Lerp(JumpMileageMinWidthScale, JumpMileageMaxWidthScale, speedProgress);
}
private void PlaceSlideMileageLine() {
+49
View File
@@ -84,6 +84,8 @@ public class PlatformSpawner : MonoBehaviour {
private int courseStepIndex = 0; // 진행 중인 패턴 코스 순번
private Platform.PlatformSkin mapPlatformSkin = Platform.PlatformSkin.Airport; // 현재 맵에서 사용할 발판 스킨
private readonly List<PendingTutorialGuide> pendingTutorialGuides = new List<PendingTutorialGuide>(); // 실제 체험 직전에 띄울 설명들
private GameObject activeFinishLandingPlatform; // 도착 게이트가 공중에 뜨지 않도록 깔아두는 전용 발판
private bool courseSpawningStopped = false; // 도착 게이트 뒤로 새 발판/장애물이 나오지 않게 막는다.
private Vector2 poolPosition = new Vector2(0, -25); // 초반에 생성된 발판들을 화면 밖에 숨겨둘 위치
private float lastSpawnTime; // 마지막 배치 시점
@@ -100,7 +102,9 @@ public class PlatformSpawner : MonoBehaviour {
tutorialSteps = MapDatabase.CloneSteps(mapDefinition.openingSteps);
stagePatternSteps = MapDatabase.CloneSteps(mapDefinition.loopSteps);
courseStepIndex = 0;
courseSpawningStopped = false;
pendingTutorialGuides.Clear();
ClearFinishLandingPlatform();
ResetPoolIndices();
lastSpawnTime = Time.time;
timeBetSpawn = 0f;
@@ -109,6 +113,46 @@ public class PlatformSpawner : MonoBehaviour {
DeactivateAllPlatformPools();
}
public void SpawnFinishLandingPlatform(float spawnX, float groundY) {
if(platformPrefab == null)
{
return;
}
ClearFinishLandingPlatform();
activeFinishLandingPlatform = Instantiate(platformPrefab, poolPosition, Quaternion.identity);
activeFinishLandingPlatform.SetActive(false);
TutorialStep landingStep = CreateStep(
Platform.PlatformPattern.Empty,
Platform.MileagePattern.None,
SlideObstaclePattern.SlideLayout.Random,
GameManager.TutorialGuideType.None,
Mathf.Clamp(groundY, yMin, yMax),
FallbackLandingSpacing,
"",
"",
Platform.ItemPattern.None,
CoursePlatformType.Basic);
ConfigurePlatform(activeFinishLandingPlatform, landingStep);
activeFinishLandingPlatform.transform.position = new Vector2(spawnX, landingStep.yPosition);
activeFinishLandingPlatform.SetActive(true);
}
public void ClearFinishLandingPlatform() {
if(activeFinishLandingPlatform != null)
{
Destroy(activeFinishLandingPlatform);
activeFinishLandingPlatform = null;
}
}
public void StopCourseSpawningAfterFinishGate() {
courseSpawningStopped = true;
pendingTutorialGuides.Clear();
}
void Start() {
EnsurePatternSteps();
@@ -205,6 +249,11 @@ public class PlatformSpawner : MonoBehaviour {
return;
}
if(courseSpawningStopped)
{
return;
}
UpdatePendingTutorialGuides();
if(GameManager.instance != null && GameManager.instance.IsTutorialGuidePaused)
{
+1 -1
View File
@@ -5,7 +5,7 @@ using UnityEngine;
// PlayerController는 플레이어 캐릭터로서 Player 게임 오브젝트를 제어한다.
public class PlayerController : MonoBehaviour {
public AudioClip deathClip; // 사망시 재생할 오디오 클립
public float jumpForce = 700f; // 점프 힘
public float jumpForce = 600f; // 점프 힘
public int maxJumpCount = 2; // 최대 점프 횟수
public float invincibleDuration = 1.5f; // 피해를 입은 뒤 무적 시간
public float groundStickGraceTime = 0.12f; // 발판 이음새에서 접지가 아주 잠깐 끊겨도 유지하는 시간