ThreeKingdomsSlingshot/Assets/Scripts/OutGame/UI/TitleScreen.cs

557 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using UnityEngine.UI;
namespace TKS.OutGame
{
/// <summary>
/// 타이틀 화면(PD 2026-11-13 「타이틀 화면이 등장할 때 캐릭터들이 타이틀 화면의 현재 위치로 모이는 연출과 함께 시작 · 이후 대기 애니메이션처럼 움직이는 연출 ·
/// 꾸미기 요소도 가벼운 애니메이션 · "Press Any Key" 깜박임 · 터치하면 다음 조작으로」).
/// 시작 흐름 = BootFlow.TitleProvider(스플래시 → (받기) → 타이틀) → 누르면 검정 페이드 → 본영 로비.
/// 값 = 표 Resources/UI/title_screen.csv(LoadingTable 형식) · 꾸미기 = title_ambient.csv(LobbyAmbient · 로비·내정과 같은 규칙) · C45.
/// 층(P41): 루트 = Static(배경) → 그림 공간 Ambient(Frame · 뒤 연출 → 햇살·장수·착지 먼지 → 앞 불씨) → Ui(Frame · 로고·반짝이·글자 · 화면 공간).
/// </summary>
public class TitleScreen : MonoBehaviour
{
public const string TablePath = "UI/title_screen";
public const string AmbientPath = "UI/title_ambient";
public const float ArtW = 1080f;
public const float ArtH = 1920f;
/// <summary>테스트·검증 캡처가 메모리 세이브에서도 타이틀을 띄울 때(표 skip_memory 무시).</summary>
public static bool ForceShow;
public static TitleScreen Current { get; private set; }
public class Hero
{
public string id;
public RectTransform rt;
public Image img;
public Sprite[] frames;
public LoadingTable.Row row;
public float[] dur;
public int blinkFrame = -1;
public float blinkMin;
public float blinkMax;
public float blinkDur;
public bool landed;
public float idleT0;
public float nextBlink;
public float blinkEnd = -1f;
}
class Spark
{
public RectTransform rt;
public Image img;
public LoadingTable.Row row;
}
class Puff
{
public RectTransform rt;
public Image img;
public float t0;
public float baseW;
}
public Canvas RootCanvas { get; private set; }
/// <summary>그림 공간(1080×1920 · 화면을 꽉 채우게 크기만 바꿈 · 배경·연출·장수의 좌표 = 그림 px).</summary>
public RectTransform Art { get; private set; }
public LobbyAmbient Ambient { get; private set; }
/// <summary>무대 층(Frame · 장수·로고·글자) — 안의 StageArt = 그림 좌표 1080×1920 · 화면 안에 다 들어오는 배율(contain).</summary>
public RectTransform Ui { get; private set; }
public RectTransform Stage { get; private set; }
public Image Logo { get; private set; }
public Image Rays { get; private set; }
public Text PressText { get; private set; }
public readonly List<Hero> Heroes = new List<Hero>();
/// <summary>등장 연출이 끝났는가(로고까지 내려앉음).</summary>
public bool IntroDone { get; private set; }
/// <summary>「Press Any Key」가 떠서 누르면 다음으로 가는가.</summary>
public bool PressReady { get; private set; }
public bool Leaving { get; private set; }
/// <summary>등장 연출 시계(초 · 건너뛰면 끝 시각으로).</summary>
public float Elapsed { get; private set; }
public float IntroEnd { get; private set; }
Camera cam;
Action onStart;
List<LoadingTable.Row> rows;
LoadingTable.Row rowRays;
LoadingTable.Row rowLogo;
LoadingTable.Row rowPress;
LoadingTable.Row rowPuff;
readonly List<Spark> sparks = new List<Spark>();
readonly List<Puff> puffs = new List<Puff>();
Sprite puffSprite;
float last;
Vector2 lastRoot;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Register()
{
BootFlow.TitleProvider = Provide;
}
/// <summary>BootFlow 훅 — 표가 있고(메모리 세이브면 표 skip_memory 에 따라 건너뜀) 타이틀을 띄우면 true.</summary>
public static bool Provide(Camera cam, Action toLobby)
{
var list = LoadingTable.Load(TablePath);
if (list.Count == 0) return false;
var skip = list.Find(x => x.kind == "option" && x.id == "skip_memory");
if (!ForceShow && skip != null && skip.a > 0f && SaveStore.MemoryOnly) return false;
Show(cam, toLobby);
return true;
}
/// <summary>타이틀을 띄운다(이미 있으면 닫고 새로) · onStart = 누름 → 페이드 뒤 부름(로비).</summary>
public static TitleScreen Show(Camera cam, Action onStart)
{
if (Current != null) Current.Close();
var go = new GameObject("TitleScreen");
var t = go.AddComponent<TitleScreen>();
Current = t;
t.cam = cam;
t.onStart = onStart;
t.Build();
return t;
}
LoadingTable.Row Opt(string id)
{
return rows.Find(x => x.kind == "option" && x.id == id);
}
LoadingTable.Row First(string kind)
{
return rows.Find(x => x.kind == kind);
}
void Build()
{
rows = LoadingTable.Load(TablePath);
var oc = Opt("canvas");
RootCanvas = UiLayer.Root(gameObject, cam, oc != null ? (int)oc.a : 850, oc != null && oc.b > 0f ? oc.b : 0.3f, UiLayer.Reference, UiRate.Static);
var root = (RectTransform)transform;
HideBelow(RootCanvas.sortingOrder);
// 그림 공간(배경 크기 그대로 · 화면을 덮는 배율만 Update 에서)
var bgRow = First("bg");
Art = UiKit.Rect("Art", root, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(ArtW, ArtH));
var bg = Art.gameObject.AddComponent<Image>();
bg.sprite = bgRow != null ? LoadingTable.Sprite(bgRow.sprite) : null;
bg.color = bgRow != null ? bgRow.color : Color.black;
bg.raycastTarget = true; // 아래 화면 누름을 막음
FitArt();
// 꾸미기(로비·내정과 같은 LobbyAmbient · Frame 캔버스 1장 · 배경 그림에 붙음 = 배경과 같은 배율) — Back(빛·흙먼지) → Lineup(햇살) → Front(불씨)
Ambient = LobbyAmbient.Build(Art, ArtW, ArtH, LobbyScene.Load(AmbientPath));
var lineup = Ambient.Lineup;
// 무대(장수·착지 먼지·로고·반짝이·글자) = 그림 좌표 그대로 · 화면 안에 다 들어오는 배율(QA r1 B1~B4: 배경 배율(cover)로 키우면 20:9 = 로고·황충 활 잘림 · 4:3 = 로고가 얼굴을 가리고 관우 하체 잘림)
Ui = UiLayer.Group("Stage", root, UiRate.Frame);
Stage = UiKit.Rect("StageArt", Ui, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(ArtW, ArtH));
lastRoot = Vector2.zero;
FitArt();
var stage = Stage;
rowRays = First("rays");
if (rowRays != null)
{
Rays = ArtImage("Rays", lineup, rowRays.sprite, rowRays.x, rowRays.y, rowRays.w, rowRays.w);
Rays.color = WithA(rowRays.color, 0f);
}
var heroRows = rows.FindAll(x => x.kind == "hero");
heroRows.Sort((p, q) => p.size.CompareTo(q.size));
foreach (var r in heroRows)
{
var fr = LoadingTable.Frames(r.sprite);
if (fr.Length == 0) continue;
var h = new Hero { id = r.id, row = r, frames = fr };
h.img = ArtImage("Hero_" + r.id, stage, null, r.x, r.y, r.w, r.h);
h.img.sprite = fr[0];
h.rt = (RectTransform)h.img.transform;
h.dur = ParseList(r.text);
var b = ParseList(r.reference);
if (b.Length >= 4 && (int)b[0] < fr.Length)
{
h.blinkFrame = (int)b[0];
h.blinkMin = b[1];
h.blinkMax = b[2];
h.blinkDur = b[3];
}
Heroes.Add(h);
}
rowPuff = First("puff");
if (rowPuff != null) puffSprite = LoadingTable.Sprite(rowPuff.sprite);
// 로고·글자 = 무대 위(장수와 같은 배율 · 로고 f_y = 그림 위에서 · 글자 f_y = 그림 아래에서)
rowLogo = First("logo");
if (rowLogo != null)
{
var sp = LoadingTable.Sprite(rowLogo.sprite);
float h = sp != null ? rowLogo.w * sp.rect.height / sp.rect.width : rowLogo.w * 0.6f;
var rt = UiKit.Rect("Logo", stage, new Vector2(0.5f, 1f), new Vector2(0.5f, 0.5f), new Vector2(rowLogo.x, -rowLogo.y), new Vector2(rowLogo.w, h));
Logo = rt.gameObject.AddComponent<Image>();
Logo.sprite = sp;
Logo.raycastTarget = false;
Logo.color = WithA(rowLogo.color, 0f);
foreach (var r in rows.FindAll(x => x.kind == "sparkle"))
{
var s = UiKit.Rect("Spark_" + r.id, rt, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(r.x, r.y), new Vector2(r.w, r.w));
var im = s.gameObject.AddComponent<Image>();
im.sprite = LoadingTable.Sprite(r.sprite);
im.raycastTarget = false;
im.color = WithA(r.color, 0f);
sparks.Add(new Spark { rt = s, img = im, row = r });
}
}
rowPress = First("press");
if (rowPress != null)
{
PressText = UiKit.Label(stage, rowPress.text, rowPress.size, rowPress.color, TextAnchor.MiddleCenter,
new Vector2(0.5f, 0f), new Vector2(0.5f, 0.5f), new Vector2(rowPress.x, rowPress.y), new Vector2(rowPress.w, rowPress.h));
if (UISkin.TitleFont != null) PressText.font = UISkin.TitleFont;
var o = PressText.gameObject.AddComponent<Outline>();
o.effectColor = new Color(0.12f, 0.06f, 0.03f, 0.95f);
o.effectDistance = new Vector2(3f, -3f);
var sh = PressText.gameObject.AddComponent<Shadow>();
sh.effectColor = new Color(0f, 0f, 0f, 0.55f);
sh.effectDistance = new Vector2(0f, -6f);
PressText.color = WithA(rowPress.color, 0f);
}
// 등장 연출이 끝나는 시각 = 가장 늦은 장수 착지 · 로고 내려앉음
IntroEnd = 0f;
foreach (var h in Heroes) IntroEnd = Mathf.Max(IntroEnd, h.row.phase + h.row.fps);
if (rowLogo != null) IntroEnd = Mathf.Max(IntroEnd, rowLogo.phase + rowLogo.fps);
Elapsed = 0f;
last = Time.unscaledTime;
Apply();
}
Image ArtImage(string name, Transform parent, string sprite, float x, float y, float w, float h)
{
// 그림 px(왼쪽 위 0,0 · 아래 +) → 그림 공간(부모 = 그림 크기 그대로 1080×1920)
var rt = UiKit.Rect(name, parent, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(x, -y), new Vector2(w, h));
var img = rt.gameObject.AddComponent<Image>();
if (!string.IsNullOrEmpty(sprite)) img.sprite = LoadingTable.Sprite(sprite);
img.raycastTarget = false;
return img;
}
static float[] ParseList(string s)
{
if (string.IsNullOrEmpty(s)) return new float[0];
var parts = s.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
var list = new List<float>();
foreach (var p in parts)
{
float f;
if (float.TryParse(p.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out f)) list.Add(f);
}
return list.ToArray();
}
static Color WithA(Color c, float a)
{
c.a = a;
return c;
}
static void SetA(Graphic g, Color baseC, float a01)
{
var c = g.color;
float a = baseC.a * Mathf.Clamp01(a01);
if (!Mathf.Approximately(c.a, a))
{
c.a = a;
g.color = c;
}
}
static float EaseOutBack(float p, float k)
{
p = Mathf.Clamp01(p) - 1f;
return 1f + p * p * ((k + 1f) * p + k);
}
/// <summary>그림 공간이 화면을 덮게(가로·세로 중 큰 배율 · 화면 밖으로 나가는 쪽은 잘림).</summary>
void FitArt()
{
var root = (RectTransform)transform;
var sz = root.rect.size;
if (sz == lastRoot || sz.x <= 0f || sz.y <= 0f) return;
lastRoot = sz;
float s = Mathf.Max(sz.x / ArtW, sz.y / ArtH); // 배경 = 화면을 덮음(cover)
Art.localScale = new Vector3(s, s, 1f);
if (Stage != null)
{
float c = Mathf.Min(sz.x / ArtW, sz.y / ArtH); // 무대 = 화면 안에 다 들어옴(contain · 9:16 이면 배경과 같음)
Stage.localScale = new Vector3(c, c, 1f);
}
}
/// <summary>연출 시계를 정함(검증 캡처 · 테스트 · 착지 먼지는 그 순간 착지한 장수만).</summary>
public void SetTime(float t)
{
Elapsed = Mathf.Max(0f, t);
Apply();
}
/// <summary>등장 연출을 끝 모습으로(누름 · 테스트).</summary>
public void SkipIntro()
{
if (Elapsed < IntroEnd) Elapsed = IntroEnd;
Apply();
}
/// <summary>누름 — 등장 중이면 건너뜀(표 skip) · 글자가 떴으면 페이드 뒤 onStart.</summary>
public void Press()
{
if (Leaving) return;
if (!PressReady)
{
var sk = Opt("skip");
if (sk == null || sk.a > 0f) SkipIntro();
return;
}
Leaving = true;
var lv = Opt("leave");
float fOut = lv != null ? lv.a : 0.3f, fHold = lv != null ? lv.b : 0.1f, fIn = lv != null ? lv.c : 0.3f;
int order = lv != null && lv.size > 0 ? lv.size : 960;
var fader = ScreenFader.Create(cam, order, lv != null ? lv.color : Color.black);
fader.StartCoroutine(LeaveRoutine(fader, fOut, fHold, fIn));
}
IEnumerator LeaveRoutine(ScreenFader fader, float fOut, float fHold, float fIn)
{
yield return fader.FadeTo(1f, fOut);
var cb = onStart;
Close();
if (cb != null) cb();
for (float w = 0f; w < fHold; w += Time.unscaledDeltaTime) yield return null;
yield return fader.FadeTo(0f, fIn);
Destroy(fader.gameObject);
}
public void Close()
{
RestoreBelow();
if (Current == this) Current = null;
Destroy(gameObject);
}
void OnDestroy()
{
RestoreBelow();
if (Current == this) Current = null;
}
readonly List<Canvas> hidden = new List<Canvas>();
/// <summary>타이틀이 화면을 다 덮는 동안 그 아래 루트 캔버스(로비·재화 바 등)를 끔(QA r1 M5 · 저사양 · 안 보이는 화면을 계속 그림) · 페이드처럼 위 순서는 그대로.</summary>
void HideBelow(int order)
{
foreach (var c in FindObjectsByType<Canvas>(FindObjectsSortMode.None))
{
if (c == null || !c.isRootCanvas || !c.enabled || c.gameObject == gameObject || c.sortingOrder >= order) continue;
c.enabled = false;
hidden.Add(c);
}
}
void RestoreBelow()
{
foreach (var c in hidden)
if (c != null)
c.enabled = true;
hidden.Clear();
}
/// <summary>끈 아래 캔버스 수(테스트).</summary>
public int HiddenCount
{
get { return hidden.Count; }
}
static bool AnyInput()
{
if (Input.anyKeyDown) return true; // 키보드 · 마우스 단추
for (int i = 0; i < Input.touchCount; i++)
if (Input.GetTouch(i).phase == TouchPhase.Began)
return true;
return false;
}
void Update()
{
if (rows == null) return; // Play 중 재컴파일 = 직렬화 안 된 값이 비면 멈춤
float now = Time.unscaledTime;
float dt = Mathf.Min(0.1f, Mathf.Max(0f, now - last));
last = now;
Elapsed += dt;
FitArt();
if (!Leaving && AnyInput()) Press();
Apply();
}
void Apply()
{
float t = Elapsed;
float now = Time.unscaledTime;
// 햇살 = 천천히 돌며 숨
if (Rays != null && rowRays != null)
{
Rays.rectTransform.localEulerAngles = new Vector3(0f, 0f, -rowRays.a * now);
float breath = 0.5f + 0.5f * Mathf.Sin(now / Mathf.Max(0.1f, rowRays.fps) * Mathf.PI * 2f);
float alpha = Mathf.Lerp(rowRays.b, rowRays.c, breath) * Mathf.Clamp01(t / Mathf.Max(0.05f, rowRays.phase + 0.5f));
SetA(Rays, new Color(1f, 1f, 1f, 1f), alpha);
}
// 장수 = 화면 밖 → 제자리(EaseOutBack) → 착지 먼지 → 대기 프레임
foreach (var h in Heroes)
{
var r = h.row;
float p = r.fps > 0f ? (t - r.phase) / r.fps : (t >= r.phase ? 1f : 0f);
if (p <= 0f)
{
SetA(h.img, r.color, 0f);
continue;
}
float e = EaseOutBack(p, r.c);
var target = Target(r);
var from = target + new Vector2(r.a, -r.b);
var pos = Vector2.LerpUnclamped(from, target, e);
if (h.rt.anchoredPosition != pos) h.rt.anchoredPosition = pos;
float sc = Mathf.LerpUnclamped(r.scale, 1f, e);
var sv = new Vector3(sc, sc, 1f);
if (h.rt.localScale != sv) h.rt.localScale = sv;
SetA(h.img, r.color, Mathf.Clamp01(p * 4f));
if (p >= 1f && !h.landed)
{
h.landed = true;
h.idleT0 = now - UnityEngine.Random.value * Cycle(h);
h.nextBlink = now + UnityEngine.Random.Range(h.blinkMin, h.blinkMax);
if (t - r.phase - r.fps < 0.2f) SpawnPuff(h); // 건너뛰기로 한꺼번에 착지하면 먼지 없음
}
if (h.landed) IdleFrame(h, now);
}
UpdatePuffs(now);
// 로고 = 크게 → 제자리(EaseOutBack) · 그 뒤 둥실 · 반짝이
if (Logo != null && rowLogo != null)
{
float p = rowLogo.fps > 0f ? (t - rowLogo.phase) / rowLogo.fps : 1f;
float e = p <= 0f ? 0f : EaseOutBack(p, 1.7f);
float sc = p <= 0f ? rowLogo.scale : Mathf.LerpUnclamped(rowLogo.scale, 1f, e);
var sv = new Vector3(sc, sc, 1f);
if (Logo.rectTransform.localScale != sv) Logo.rectTransform.localScale = sv;
SetA(Logo, rowLogo.color, Mathf.Clamp01(p * 3f));
float fl = p >= 1f ? rowLogo.a * Mathf.Sin((t - rowLogo.phase - rowLogo.fps) / Mathf.Max(0.1f, rowLogo.b) * Mathf.PI * 2f) : 0f;
var lp = new Vector2(rowLogo.x, -rowLogo.y + fl);
if (Logo.rectTransform.anchoredPosition != lp) Logo.rectTransform.anchoredPosition = lp;
foreach (var s in sparks)
{
float k = p >= 1f ? Mathf.Repeat((t - rowLogo.phase - rowLogo.fps) / Mathf.Max(0.1f, s.row.a) + s.row.phase, 1f) : 0f;
float tw = p >= 1f ? Mathf.Sin(k * Mathf.PI) : 0f; // 0 → 1 → 0
tw = tw * tw;
SetA(s.img, s.row.color, tw);
var ss = new Vector3(0.4f + 0.6f * tw, 0.4f + 0.6f * tw, 1f);
if (s.rt.localScale != ss) s.rt.localScale = ss;
s.rt.localEulerAngles = new Vector3(0f, 0f, k * 90f);
}
}
IntroDone = t >= IntroEnd;
// 「Press Any Key」 = 등장 연출 끝 + f_phase 초 뒤부터 깜박임 · 그때부터 누르면 다음으로
if (PressText != null && rowPress != null)
{
float since = t - IntroEnd - rowPress.phase;
PressReady = since >= 0f;
float blink = 0.5f + 0.5f * Mathf.Cos(Mathf.Max(0f, since) / Mathf.Max(0.1f, rowPress.a) * Mathf.PI * 2f);
SetA(PressText, rowPress.color, since < 0f ? 0f : Mathf.Lerp(rowPress.b, 1f, blink) * Mathf.Clamp01(since / 0.25f));
}
else PressReady = IntroDone;
}
/// <summary>장수 제자리(그림 공간) — 폭·높이가 홀수면 반 칸 밀어 모서리가 정수 픽셀에(QA r1 M6 · 선 흐림 방지).</summary>
public static Vector2 Target(LoadingTable.Row r)
{
float ox = Mathf.RoundToInt(r.w) % 2 == 1 ? 0.5f : 0f;
float oy = Mathf.RoundToInt(r.h) % 2 == 1 ? 0.5f : 0f;
return new Vector2(r.x + ox, -(r.y + oy));
}
float Cycle(Hero h)
{
float c = 0f;
foreach (var d in h.dur) c += d;
return Mathf.Max(0.1f, c);
}
/// <summary>대기 = 자세 0→1→2→3 한 방향 반복(R22 · 표 유지 초) · 깜빡임 = 쉼 자세(0)일 때만.</summary>
void IdleFrame(Hero h, float now)
{
int n = Mathf.Min(h.dur.Length, h.blinkFrame >= 0 ? h.blinkFrame : h.frames.Length);
int k = 0;
if (n > 1)
{
float c = Cycle(h);
float u = Mathf.Repeat(now - h.idleT0, c);
for (k = 0; k < n - 1; k++)
{
if (u < h.dur[k]) break;
u -= h.dur[k];
}
}
if (h.blinkFrame >= 0)
{
if (now >= h.blinkEnd && now >= h.nextBlink && k == 0)
{
h.blinkEnd = now + h.blinkDur;
h.nextBlink = now + UnityEngine.Random.Range(h.blinkMin, h.blinkMax);
}
if (now < h.blinkEnd && k == 0) k = h.blinkFrame;
}
var sp = h.frames[Mathf.Clamp(k, 0, h.frames.Length - 1)];
if (h.img.sprite != sp) h.img.sprite = sp;
}
void SpawnPuff(Hero h)
{
if (puffSprite == null || rowPuff == null) return;
var r = h.row;
float w = r.w * rowPuff.w;
float hh = w * puffSprite.rect.height / puffSprite.rect.width;
var img = ArtImage("Puff_" + h.id, Stage, null, r.x, r.y + r.h * rowPuff.c, w, hh);
img.sprite = puffSprite;
img.color = WithA(rowPuff.color, 0f);
img.transform.SetSiblingIndex(h.rt.GetSiblingIndex() + 1); // 그 장수 바로 앞(발을 덮는 먼지 · QA r1 U4 = 뒤에 두면 바닥과 같은 색이라 안 보임)
puffs.Add(new Puff { rt = img.rectTransform, img = img, t0 = Time.unscaledTime, baseW = w });
}
void UpdatePuffs(float now)
{
for (int i = puffs.Count - 1; i >= 0; i--)
{
var p = puffs[i];
float u = (now - p.t0) / Mathf.Max(0.05f, rowPuff.a);
if (u >= 1f || p.img == null)
{
if (p.img != null) Destroy(p.img.gameObject);
puffs.RemoveAt(i);
continue;
}
float s = Mathf.Lerp(0.6f, 1.25f, 1f - (1f - u) * (1f - u));
p.rt.localScale = new Vector3(s, s, 1f);
SetA(p.img, rowPuff.color, rowPuff.b * (1f - u));
}
}
}
}