using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using UnityEngine.UI;
namespace TKS.OutGame
{
///
/// 타이틀 화면(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 · 로고·반짝이·글자 · 화면 공간).
///
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;
/// 테스트·검증 캡처가 메모리 세이브에서도 타이틀을 띄울 때(표 skip_memory 무시).
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; }
/// 그림 공간(1080×1920 · 화면을 꽉 채우게 크기만 바꿈 · 배경·연출·장수의 좌표 = 그림 px).
public RectTransform Art { get; private set; }
public LobbyAmbient Ambient { get; private set; }
/// 무대 층(Frame · 장수·로고·글자) — 안의 StageArt = 그림 좌표 1080×1920 · 화면 안에 다 들어오는 배율(contain).
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 Heroes = new List();
/// 등장 연출이 끝났는가(로고까지 내려앉음).
public bool IntroDone { get; private set; }
/// 「Press Any Key」가 떠서 누르면 다음으로 가는가.
public bool PressReady { get; private set; }
public bool Leaving { get; private set; }
/// 등장 연출 시계(초 · 건너뛰면 끝 시각으로).
public float Elapsed { get; private set; }
public float IntroEnd { get; private set; }
Camera cam;
Action onStart;
List rows;
LoadingTable.Row rowRays;
LoadingTable.Row rowLogo;
LoadingTable.Row rowPress;
LoadingTable.Row rowPuff;
readonly List sparks = new List();
readonly List puffs = new List();
Sprite puffSprite;
float last;
Vector2 lastRoot;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Register()
{
BootFlow.TitleProvider = Provide;
}
/// BootFlow 훅 — 표가 있고(메모리 세이브면 표 skip_memory 에 따라 건너뜀) 타이틀을 띄우면 true.
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;
}
/// 타이틀을 띄운다(이미 있으면 닫고 새로) · onStart = 누름 → 페이드 뒤 부름(로비).
public static TitleScreen Show(Camera cam, Action onStart)
{
if (Current != null) Current.Close();
var go = new GameObject("TitleScreen");
var t = go.AddComponent();
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();
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();
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();
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();
o.effectColor = new Color(0.12f, 0.06f, 0.03f, 0.95f);
o.effectDistance = new Vector2(3f, -3f);
var sh = PressText.gameObject.AddComponent();
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();
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();
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);
}
/// 그림 공간이 화면을 덮게(가로·세로 중 큰 배율 · 화면 밖으로 나가는 쪽은 잘림).
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);
}
}
/// 연출 시계를 정함(검증 캡처 · 테스트 · 착지 먼지는 그 순간 착지한 장수만).
public void SetTime(float t)
{
Elapsed = Mathf.Max(0f, t);
Apply();
}
/// 등장 연출을 끝 모습으로(누름 · 테스트).
public void SkipIntro()
{
if (Elapsed < IntroEnd) Elapsed = IntroEnd;
Apply();
}
/// 누름 — 등장 중이면 건너뜀(표 skip) · 글자가 떴으면 페이드 뒤 onStart.
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