using System.Collections.Generic; using System.Globalization; using UnityEngine; using UnityEngine.UI; namespace TKS.OutGame { /// /// 로딩 화면 종류(PD 2026-11-13 「원격 다운로드가 필요한 시점 · 로딩이 필요한 시점 2 경우에 사용」) — 표 s_set 이 모드마다 다른 화면을 고른다. /// Download = 원격 리소스 받기(배경 + 위 타이틀 로고 + 조운·병졸 + 용량) · Loading = 그 밖의 로딩(검은 화면 + 유비·관우·장비 말 타고 질주 · 심플). /// public enum LoadingMode { Download, Loading } /// /// 로딩·리소스 다운로드 화면(PD 2026-11-13) — 진행 바 위에서 무리가 제자리로 달리고(프레임 반복) 말 뒤로 먼지가 일어 흩어진다. /// 다운로드 = 풀 덮인 땅 바 위를 조운(아두를 품음)이 백마로 질주 · 뒤를 병졸이 따름 · 위에 타이틀 로고 · 진행률·용량. /// 로딩 = 배경 없이 검은 화면 · 단순 바 위를 유비·관우·장비가 각자 말을 타고 함께 질주 · 「Loading...」. /// 값(자리·크기·속도·문구 · 세트별) = 표 Resources/UI/loading_screen.csv(LoadingTable · s_set = download | loading | 빈칸 = 공통 · C45) · 그림 = Resources/UI/Loading/. /// 캔버스(P41 · 갱신 빈도별): 루트 Static(배경 · 로고 · 바 틀) → Live = Frame(바 채움 · 먼지 · 달리는 무리 · 점 3개) → Info = Event(상태 문구 · 진행률 · 용량). /// 연결점: Show(cam, mode) → SetProgress(0~1) 또는 SetDownload(받은 바이트, 전체 바이트) → Hide(). 모드를 바꾸면 그 세트로 다시 만든다(같은 객체). /// 게임에는 아직 리소스 다운로드·씬 비동기 로딩 흐름이 없다(Addressables 미도입 · 2026-11-13 실측) — 부르는 자리는 그 흐름을 만들 때 연결. /// public class LoadingScreen : MonoBehaviour { public static LoadingScreen Current { get; private set; } /// /// 이 화면의 시계(초) — 기본 = Time.unscaledTime(로딩 중 timeScale 과 무관). 확인 캡처만 Time.time + Time.captureDeltaTime 으로 바꿔 /// 원본 크기 화면 읽기가 느려도 캡처 한 장 = 게임 1/16 초(영상 간격 = 실제 재생 · UICaptureDriver.LoadingShots). /// public static System.Func Clock; public static float Now { get { return Clock != null ? Clock() : Time.unscaledTime; } } /// 모드 → 표 세트 이름. public static string SetOf(LoadingMode m) { return m == LoadingMode.Download ? "download" : "loading"; } float lastNow = -1f; Camera cam; public LoadingMode Mode { get; private set; } /// 지금 화면을 만든 표 세트(download | loading). public string Set { get; private set; } /// 목표 진행률(0~1 · 마지막으로 받은 값). public float Target { get; private set; } /// 화면에 보이는 진행률(목표를 향해 부드럽게 따라감). public float Shown { get; private set; } public long DoneBytes { get; private set; } public long TotalBytes { get; private set; } public Canvas RootCanvas { get; private set; } public RectTransform Live { get; private set; } public RectTransform Info { get; private set; } public Image Fill { get; private set; } public Image Logo { get; private set; } public Text StatusText { get; private set; } public Text PercentText { get; private set; } public Text SizeText { get; private set; } public Text DotsText { get; private set; } public readonly List Runners = new List(); public readonly List Dusts = new List(); LoadingTable.Row rowStatus; LoadingTable.Row rowPercent; LoadingTable.Row rowSize; LoadingTable.Row rowDots; float smooth = 1.5f; float dotStep = 0.4f; int dotMax = 3; int shownPct = -1; int dots = -1; /// 화면을 띄운다(이미 있으면 그것을 씀 · 모드가 다른 세트면 다시 만듦) · cam = 아웃게임과 같은 카메라(Screen Space - Camera · 캡처에도 찍힘). public static LoadingScreen Show(Camera cam, LoadingMode mode) { if (Current == null) { var go = new GameObject("LoadingScreen"); Current = go.AddComponent(); Current.cam = cam; } Current.SetMode(mode); Current.gameObject.SetActive(true); return Current; } public static void HideCurrent() { if (Current != null) Current.Hide(); } public void Hide() { if (Current == this) Current = null; Destroy(gameObject); } void OnDestroy() { if (Current == this) Current = null; } /// 진행률 0~1 · instant = 부드럽게 따라가지 않고 바로(첫 표시 · 테스트). public void SetProgress(float p01, bool instant = false) { Target = Mathf.Clamp01(p01); if (instant) Shown = Target; Apply(); } /// 받은 바이트 / 전체 바이트 → 용량 글자(그 세트에 size 줄이 있을 때) + 진행률(전체 0 이면 진행률은 그대로). public void SetDownload(long doneBytes, long totalBytes, bool instant = false) { DoneBytes = System.Math.Max(0L, doneBytes); TotalBytes = System.Math.Max(0L, totalBytes); ApplySize(); if (TotalBytes > 0) SetProgress((float)((double)DoneBytes / TotalBytes), instant); } /// 모드 바꾸기 — 표 세트가 다르면 같은 객체 안에서 화면을 다시 만든다(진행률·용량 값은 유지). public void SetMode(LoadingMode m) { Mode = m; string set = SetOf(m); if (Set != set) Rebuild(set); } /// 1 MB = 1024² 바이트 · 소수 한 자리. public static string Bytes(long b) { double mb = b / (1024.0 * 1024.0); if (mb >= 1024.0) return (mb / 1024.0).ToString("0.0", CultureInfo.InvariantCulture) + " GB"; if (mb >= 0.1) return mb.ToString("0.0", CultureInfo.InvariantCulture) + " MB"; return (b / 1024.0).ToString("0", CultureInfo.InvariantCulture) + " KB"; } void Rebuild(string set) { // 이전 세트의 자식은 지움(Destroy = 이번 프레임 그리기 전에 사라짐) · 루트 캔버스 컴포넌트는 그대로 다시 씀 for (int i = transform.childCount - 1; i >= 0; i--) { var c = transform.GetChild(i).gameObject; c.SetActive(false); Destroy(c); } Runners.Clear(); Dusts.Clear(); Fill = null; Logo = null; StatusText = PercentText = SizeText = DotsText = null; rowStatus = rowPercent = rowSize = rowDots = null; shownPct = -1; dots = -1; Set = set; Build(set); ApplySize(); } void Build(string set) { var opt = LoadingTable.First("option", "canvas", set); int order = opt != null ? (int)opt.a : 900; float plane = opt != null && opt.b > 0f ? opt.b : 0.3f; RootCanvas = UiLayer.Root(gameObject, cam, order, plane, UiLayer.Reference, UiRate.Static); var o = LoadingTable.First("option", "smooth", set); if (o != null && o.a > 0f) smooth = o.a; o = LoadingTable.First("option", "dots", set); if (o != null) { if (o.a > 0f) dotStep = o.a; if (o.size > 0) dotMax = o.size; } var root = (RectTransform)transform; // 배경(화면을 덮음 · 아래 게임 화면 누름을 막음) · 그림이 없으면 색만(로딩 = 검정) foreach (var r in LoadingTable.All("bg", set)) { var img = UiKit.Fill("Bg", root).gameObject.AddComponent(); img.sprite = LoadingTable.Sprite(r.sprite); img.color = r.color; img.raycastTarget = true; if (img.sprite != null) { var fit = img.gameObject.AddComponent(); fit.aspectMode = AspectRatioFitter.AspectMode.EnvelopeParent; fit.aspectRatio = img.sprite.rect.width / img.sprite.rect.height; } } // 타이틀 로고(다운로드 · 비율 유지 · f_w×f_h 상자 안) foreach (var r in LoadingTable.All("logo", set)) { Logo = Pic("Logo", root, r.sprite); Place(Logo, r); Logo.preserveAspect = true; } foreach (var r in LoadingTable.All("bar", set)) Place(Pic("Bar", root, r.sprite), r); // 매 프레임: 바 채움 · 먼지 · 달리는 무리 · 점 Live = UiLayer.Group("Live", root, UiRate.Frame); foreach (var r in LoadingTable.All("fill", set)) { Fill = Pic("Fill", Live, r.sprite); Place(Fill, r); Fill.type = Image.Type.Filled; Fill.fillMethod = Image.FillMethod.Horizontal; Fill.fillOrigin = (int)Image.OriginHorizontal.Left; Fill.fillAmount = 0f; } var dustRoot = UiKit.Fill("Dust", Live); var runnerRoot = UiKit.Fill("Runners", Live); var runnerByID = new Dictionary(); foreach (var r in LoadingTable.All("runner", set)) { var frames = LoadingTable.Frames(r.sprite); if (frames.Length == 0) continue; var img = Pic("Runner_" + r.id, runnerRoot, null); img.sprite = frames[0]; var rt = (RectTransform)img.transform; rt.pivot = new Vector2(0.5f, 0f); // 발(그림 아래 가운데) = 표 좌표 float aspect = frames[0].rect.width / frames[0].rect.height; rt.sizeDelta = new Vector2(r.h * aspect * Mathf.Abs(r.scale), r.h * Mathf.Abs(r.scale)); rt.anchoredPosition = Pos(r.x, r.y); if (r.scale < 0f) rt.localScale = new Vector3(-1f, 1f, 1f); var loop = UiSpriteLoop.Attach(img, frames, r.fps, r.phase); loop.name = "Runner_" + r.id; Runners.Add(loop); runnerByID[r.id] = loop; } foreach (var r in LoadingTable.All("dust", set)) { UiSpriteLoop host; runnerByID.TryGetValue(r.reference, out host); // 먼지 = 달리는 무리 전부의 뒤(앞 말 먼지가 뒤따르는 말 다리를 덮지 않게 · QA r3 M1 · r2 M2 · PD 2026-11-13 「경미한 수정사항 모두 반영」) var d = LoadingDust.Create(dustRoot, r, host, Pos); if (d == null) continue; Dusts.Add(d); } // 진행률·상태 글자(값이 바뀔 때만) Info = UiLayer.Group("Info", root, UiRate.Event); rowStatus = LoadingTable.First("text", "status", set); rowPercent = LoadingTable.First("text", "percent", set); rowSize = LoadingTable.First("text", "size", set); rowDots = LoadingTable.First("text", "dots", set); if (rowStatus != null) StatusText = Txt("Status", Info, rowStatus); if (rowPercent != null) PercentText = Txt("Percent", Info, rowPercent); if (rowSize != null) SizeText = Txt("Size", Info, rowSize); if (rowDots != null) { DotsText = Txt("Dots", Live, rowDots); DotsText.alignment = TextAnchor.MiddleLeft; var drt = (RectTransform)DotsText.transform; drt.pivot = new Vector2(0f, 0.5f); DotsText.text = ""; // 점 = 상태 문구 오른쪽 끝 바로 뒤(문구는 가운데 정렬) if (StatusText != null) { var srt = (RectTransform)StatusText.transform; drt.anchoredPosition = new Vector2(srt.anchoredPosition.x + StatusText.preferredWidth * 0.5f + rowDots.x, srt.anchoredPosition.y + rowDots.y); } } Apply(); } void Update() { if (RootCanvas == null) return; // Play 중 재컴파일 = 직렬화 안 된 값이 비면 멈춤 float now = Now; float dt = lastNow < 0f ? 0f : Mathf.Max(0f, now - lastNow); lastNow = now; if (!Mathf.Approximately(Shown, Target)) { Shown = Mathf.MoveTowards(Shown, Target, smooth * dt); Apply(); } if (DotsText != null && rowDots != null) { int n = (int)(now / dotStep) % (dotMax + 1); if (n != dots) { dots = n; DotsText.text = new string(rowDots.text.Length > 0 ? rowDots.text[0] : '.', n); } } } void Apply() { if (Fill != null && !Mathf.Approximately(Fill.fillAmount, Shown)) Fill.fillAmount = Shown; int pct = Mathf.FloorToInt(Shown * 100f + 0.0001f); if (PercentText != null && rowPercent != null && pct != shownPct) { shownPct = pct; PercentText.text = string.Format(CultureInfo.InvariantCulture, rowPercent.text, pct); } } void ApplySize() { if (SizeText != null && rowSize != null) SizeText.text = string.Format(CultureInfo.InvariantCulture, rowSize.text, Bytes(DoneBytes), Bytes(TotalBytes)); } // ---------- 만들기 도움 ---------- /// 표 좌표(기준 화면 1080×1920 px · 왼쪽 위 0,0 · 아래로 +) → 가운데 기준 anchoredPosition. static Vector2 Pos(float x, float y) { return new Vector2(x - UiLayer.Reference.x * 0.5f, UiLayer.Reference.y * 0.5f - y); } static Image Pic(string name, Transform parent, string sprite) { var rt = UiKit.Rect(name, parent, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(100f, 100f)); var img = rt.gameObject.AddComponent(); if (!string.IsNullOrEmpty(sprite)) img.sprite = LoadingTable.Sprite(sprite); img.raycastTarget = false; return img; } static void Place(Image img, LoadingTable.Row r) { var rt = (RectTransform)img.transform; rt.anchoredPosition = Pos(r.x, r.y); rt.sizeDelta = new Vector2(r.w, r.h); img.color = r.color; } static Text Txt(string name, Transform parent, LoadingTable.Row r) { var t = UiKit.Label(parent, r.text, r.size, r.color, TextAnchor.MiddleCenter, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Pos(r.x, r.y), new Vector2(r.w, r.h)); t.name = name; t.horizontalOverflow = HorizontalWrapMode.Overflow; UiKit.Title(t); if (r.b > 0f) { Color sc; if (!ColorUtility.TryParseHtmlString(r.reference, out sc)) sc = UiKit.Stroke; UISkin.Stroke(t, sc, r.b); } return t; } } /// UI 이미지 프레임 반복(로딩 화면 달리기 · 고른 간격 fps · 시작 위상 초) — 매 프레임 바뀌므로 Frame 캔버스 안에서만(UiLayer.Isolate). public class UiSpriteLoop : MonoBehaviour { public Image Img { get; private set; } public Sprite[] Frames { get; private set; } public float Fps { get; private set; } public int Index { get; private set; } float t0; public static UiSpriteLoop Attach(Image img, Sprite[] frames, float fps, float phase) { if (img == null || frames == null || frames.Length == 0) return null; var a = img.gameObject.AddComponent(); a.Img = img; a.Frames = frames; a.Fps = fps > 0f ? fps : 12f; a.t0 = LoadingScreen.Now - phase; UiLayer.Isolate(img.gameObject); return a; } /// 지금 시각의 프레임 번호(0 ~ n-1). public int FrameAt(float now) { int n = Frames.Length; int k = Mathf.FloorToInt((now - t0) * Fps) % n; return k < 0 ? k + n : k; } void Update() { if (Img == null || Frames == null || Frames.Length == 0) return; Index = FrameAt(LoadingScreen.Now); var sp = Frames[Index]; if (Img.sprite != sp) Img.sprite = sp; } } /// /// 말 뒤 먼지(표 dust 줄) — 달리는 그림의 정해진 프레임(발굽이 땅을 찰 때 · s_text 「on:3,10」)마다 또는 초당 f_fps 개로 먼지 덩이를 내고, /// 덩이는 뒤(왼쪽)로 밀리며 떠올라 커지고 옅어진다. 덩이 수 = i_size 개 재사용(만들고 지우지 않음). /// public class LoadingDust : MonoBehaviour { class Puff { public RectTransform rt; public Image img; public float t = -1f; public float x; public float y; public float s; public float rot; public float vx; } readonly List pool = new List(); LoadingTable.Row row; UiSpriteLoop host; Vector2 origin; Sprite[] sprites; int[] onFrames; int lastFrame = -1; float acc; int next; float lastNow = -1f; System.Random rng; public int Alive { get { int n = 0; foreach (var p in pool) if (p.t >= 0f) n++; return n; } } public static LoadingDust Create(Transform parent, LoadingTable.Row r, UiSpriteLoop host, System.Func pos) { var sprites = LoadingTable.SpriteList(r.sprite); if (sprites.Length == 0) return null; var rt = UiKit.Fill("Dust_" + r.id, parent); var d = rt.gameObject.AddComponent(); d.row = r; d.host = host; d.sprites = sprites; d.rng = new System.Random(r.id.GetHashCode()); // 먼지 기준점 = 표 f_x,f_y(기준 화면 px) · 달리는 그림을 따라 붙이지 않고 고정(무리도 제자리) d.origin = pos(r.x, r.y); if (r.text.StartsWith("on:")) { var parts = r.text.Substring(3).Split(new[] { ',', ';' }, System.StringSplitOptions.RemoveEmptyEntries); var list = new List(); foreach (var s in parts) { int v; if (int.TryParse(s.Trim(), out v)) list.Add(v); } d.onFrames = list.ToArray(); } int n = Mathf.Max(1, r.size); for (int i = 0; i < n; i++) { var prt = UiKit.Rect("Puff", rt, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(r.w, r.w)); var img = prt.gameObject.AddComponent(); img.raycastTarget = false; img.sprite = sprites[i % sprites.Length]; img.preserveAspect = true; img.color = new Color(r.color.r, r.color.g, r.color.b, 0f); prt.gameObject.SetActive(false); d.pool.Add(new Puff { rt = prt, img = img }); } UiLayer.Isolate(rt.gameObject); return d; } void Emit() { var p = pool[next]; next = (next + 1) % pool.Count; float j = row.phase; // 흩어짐 px p.t = 0f; p.x = origin.x + (float)(rng.NextDouble() * 2 - 1) * j; p.y = origin.y + (float)(rng.NextDouble() * 2 - 1) * j * 0.4f; p.s = 0.75f + (float)rng.NextDouble() * 0.5f; p.rot = (float)(rng.NextDouble() * 2 - 1) * 25f; p.vx = row.a * (0.8f + (float)rng.NextDouble() * 0.4f); p.img.sprite = sprites[rng.Next(sprites.Length)]; p.rt.gameObject.SetActive(true); } void Update() { if (row == null || pool.Count == 0) return; float now = LoadingScreen.Now; float dt = lastNow < 0f ? 0f : Mathf.Max(0f, now - lastNow); lastNow = now; if (onFrames != null && onFrames.Length > 0 && host != null) { int f = host.Index; if (f != lastFrame) { foreach (var k in onFrames) if (k == f) Emit(); lastFrame = f; } } else if (row.fps > 0f) { acc += dt * row.fps; while (acc >= 1f) { acc -= 1f; Emit(); } } float life = row.b > 0f ? row.b : 0.6f; foreach (var p in pool) { if (p.t < 0f) continue; p.t += dt; float u = p.t / life; if (u >= 1f) { p.t = -1f; p.rt.gameObject.SetActive(false); continue; } float ease = 1f - (1f - u) * (1f - u); p.rt.anchoredPosition = new Vector2(p.x + p.vx * p.t, p.y + row.c * ease); float size = Mathf.Lerp(row.w, row.h, ease) * p.s; p.rt.sizeDelta = new Vector2(size, size); p.rt.localEulerAngles = new Vector3(0f, 0f, p.rot); // 처음 15 % 는 나타나고 그 뒤로 옅어짐 float a = u < 0.15f ? u / 0.15f : 1f - (u - 0.15f) / 0.85f; p.img.color = new Color(row.color.r, row.color.g, row.color.b, row.color.a * a); } } } /// /// 로딩 화면 표(Resources/UI/loading_screen.csv · utf-8-sig) — 좌표 = 기준 화면 1080×1920 px · 왼쪽 위 0,0 · 아래로 +. /// s_kind: bg · logo · bar · fill · runner · dust · text · option · s_set = download | loading | 빈칸(공통). 칸 설명 = 표 머리 주석. /// public static class LoadingTable { public const string TablePath = "UI/loading_screen"; public class Row { public string kind = ""; /// 표 세트(download | loading · 빈칸 = 모든 세트 공통). public string set = ""; public string id = ""; public string sprite = ""; public string reference = ""; public string text = ""; public string note = ""; public Color color = Color.white; public float x; public float y; public float w; public float h; public float fps; public float phase; public float scale = 1f; public float a; public float b; public float c; public int size; } static List rows; public static void Reload() { rows = null; } public static List Rows { get { if (rows == null) rows = Load(TablePath); return rows; } } static bool InSet(Row r, string set) { return set == null || r.set.Length == 0 || r.set == set; } /// kind(·id) 의 첫 줄 · set = 그 세트 줄 + 공통 줄만(null = 모든 줄). public static Row First(string kind, string id = null, string set = null) { foreach (var r in Rows) if (r.kind == kind && (id == null || r.id == id) && InSet(r, set)) return r; return null; } public static List All(string kind, string set = null) { var list = new List(); foreach (var r in Rows) if (r.kind == kind && InSet(r, set)) list.Add(r); return list; } public static Sprite Sprite(string path) { return string.IsNullOrEmpty(path) ? null : Resources.Load(path); } /// | 로 나눈 스프라이트 경로들. public static Sprite[] SpriteList(string paths) { var list = new List(); foreach (var p in paths.Split(new[] { '|' }, System.StringSplitOptions.RemoveEmptyEntries)) { var s = Sprite(p.Trim()); if (s != null) list.Add(s); } return list.ToArray(); } /// 폴더 안 스프라이트 전부(이름 순 = 프레임 순). public static Sprite[] Frames(string folder) { var all = Resources.LoadAll(folder); System.Array.Sort(all, (p, q) => string.CompareOrdinal(p.name, q.name)); return all; } public static List Load(string path) { var list = new List(); var ta = Resources.Load(path); if (ta == null) return list; string[] head = null; foreach (var raw in ta.text.Replace("\r", "").Split('\n')) { var line = raw.TrimStart(''); if (line.Trim().Length == 0 || line.TrimStart().StartsWith("#")) continue; var cells = SkillVfx.SplitCsv(line); if (head == null) { head = cells; continue; } var r = new Row(); for (int i = 0; i < head.Length && i < cells.Length; i++) { string k = head[i].Trim(), v = cells[i].Trim(); if (v.Length == 0) continue; switch (k) { case "s_kind": r.kind = v.ToLowerInvariant(); break; case "s_set": r.set = v.ToLowerInvariant(); break; case "s_id": r.id = v; break; case "s_sprite": r.sprite = v; break; case "s_ref": r.reference = v; break; case "s_text": r.text = v; break; case "s_note": r.note = v; break; case "s_color": Color c; if (ColorUtility.TryParseHtmlString(v, out c)) r.color = c; break; case "f_x": r.x = F(v); break; case "f_y": r.y = F(v); break; case "f_w": r.w = F(v); break; case "f_h": r.h = F(v); break; case "f_fps": r.fps = F(v); break; case "f_phase": r.phase = F(v); break; case "f_scale": r.scale = F(v); break; case "f_a": r.a = F(v); break; case "f_b": r.b = F(v); break; case "f_c": r.c = F(v); break; case "i_size": r.size = (int)F(v); break; } } if (r.kind.Length > 0) list.Add(r); } return list; } static float F(string v) { float f; return float.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out f) ? f : 0f; } } }