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 hidden = new List(); /// 타이틀이 화면을 다 덮는 동안 그 아래 루트 캔버스(로비·재화 바 등)를 끔(QA r1 M5 · 저사양 · 안 보이는 화면을 계속 그림) · 페이드처럼 위 순서는 그대로. void HideBelow(int order) { foreach (var c in FindObjectsByType(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(); } /// 끈 아래 캔버스 수(테스트). 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; } /// 장수 제자리(그림 공간) — 폭·높이가 홀수면 반 칸 밀어 모서리가 정수 픽셀에(QA r1 M6 · 선 흐림 방지). 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); } /// 대기 = 자세 0→1→2→3 한 방향 반복(R22 · 표 유지 초) · 깜빡임 = 쉼 자세(0)일 때만. 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)); } } } }