using System; using System.Collections; using UnityEngine; using UnityEngine.UI; namespace TKS.OutGame { /// /// 리소스 받기 공급자 — Begin() 으로 확인을 시작하고 매 프레임 Tick(dt). 확인이 끝나면(Checked) Needed·TotalBytes 가 정해진다. /// 지금 게임 = 원격 리소스 없음 → NoDownload · 화면·흐름 확인 = SimulatedDownload · 실제 원격 받기(Play Asset Delivery · Addressables 원격 등)는 PD 결정 뒤 구현 하나 추가. /// public interface IResourceDownloader { bool Checked { get; } bool Needed { get; } long TotalBytes { get; } long DoneBytes { get; } bool Finished { get; } /// 실패 사유(없으면 null) — BootFlow 가 표 retry 초 뒤 Begin() 을 다시 부른다. string Error { get; } void Begin(); void Tick(float dt); } /// 받을 것 없음(지금 게임 · 표 download = none). public class NoDownload : IResourceDownloader { public bool Checked { get { return true; } } public bool Needed { get { return false; } } public long TotalBytes { get { return 0L; } } public long DoneBytes { get { return 0L; } } public bool Finished { get { return true; } } public string Error { get { return null; } } public void Begin() { } public void Tick(float dt) { } } /// 가짜 받기(표 download = simulate · 화면·흐름 확인용) — checkSec 동안 확인 → 초당 mbPerSec 로 totalMB 를 받는다. public class SimulatedDownload : IResourceDownloader { readonly long total; readonly double bytesPerSec; readonly float checkSec; float t; bool begun; public SimulatedDownload(float totalMB, float mbPerSec, float checkSec) { total = (long)(Math.Max(0f, totalMB) * 1024.0 * 1024.0); bytesPerSec = Math.Max(0.01, mbPerSec) * 1024.0 * 1024.0; this.checkSec = Mathf.Max(0f, checkSec); } public bool Checked { get { return begun && t >= checkSec; } } public bool Needed { get { return total > 0; } } public long TotalBytes { get { return total; } } public long DoneBytes { get; private set; } public bool Finished { get { return Checked && DoneBytes >= total; } } public string Error { get { return null; } } public void Begin() { begun = true; t = 0f; DoneBytes = 0L; } public void Tick(float dt) { if (!begun) return; t += Mathf.Max(0f, dt); if (t > checkSec) DoneBytes = (long)Math.Min(total, (t - checkSec) * bytesPerSec); } } /// 화면 전체를 덮는 단색 페이드(자기 캔버스 · 표 fade i_size 정렬 순서 = 로딩 화면 위 · 페이드 중 입력 막음). public class ScreenFader : MonoBehaviour { public Image Img { get; private set; } public Canvas Canvas { get; private set; } public float Alpha { get { return Img != null ? Img.color.a : 0f; } set { if (Img == null) return; var c = Img.color; c.a = Mathf.Clamp01(value); Img.color = c; } } public static ScreenFader Create(Camera cam, int order, Color color) { var go = new GameObject("ScreenFader"); var f = go.AddComponent(); // P41: 페이드 중에는 매 프레임 알파가 바뀜 = Frame · 요소 1개 f.Canvas = UiLayer.Root(go, cam, order, 0.25f, UiLayer.Reference, UiRate.Frame); f.Img = UiKit.Fill("Fade", go.transform).gameObject.AddComponent(); f.Img.color = new Color(color.r, color.g, color.b, 0f); f.Img.raycastTarget = true; return f; } /// 알파를 to 로 sec 초 동안(로딩 화면 시계 = 확인 캡처 때 게임 시간과 같음). public IEnumerator FadeTo(float to, float sec) { float from = Alpha; float t = 0f; float last = LoadingScreen.Now; while (t < sec) { yield return null; float now = LoadingScreen.Now; t += Mathf.Max(0f, now - last); last = now; Alpha = Mathf.Lerp(from, to, sec > 0f ? Mathf.Clamp01(t / sec) : 1f); } Alpha = to; } } /// /// 게임 시작 흐름(PD 2026-11-13 「게임 실행 시 스플래쉬 화면 후 타이틀 화면 전환 전, 리소스 다운로드가 필요한 경우, 리소스 다운로드 화면이 우선 재생되고 /// 리소스 다운로드가 끝나면 화면 페이드 연출과 함께 타이틀 화면으로 전환」). /// (Unity 스플래시) → 받기 확인 → 받을 것이 있으면 로딩 화면 Download(진행률·용량) → 다 받으면 100 % 를 잠깐 보여 주고 → 페이드 아웃 → 타이틀(없으면 본영 로비) → 페이드 인. /// 받을 것이 없으면 그 자리에서 바로 타이틀(없으면 로비) = 기존 시작과 같음(기존 테스트 = Battle 씬 2 프레임 안 로비). /// 값 = 표 Resources/UI/boot_flow.csv(C45). 타이틀 화면 = TitleProvider 훅(타이틀 화면 코드가 등록 · 비면 로비). /// public class BootFlow : MonoBehaviour { public const string TablePath = "UI/boot_flow"; /// 개발 미리보기: 이 키가 1 이면 다음 시작 한 번만 가짜 받기(메뉴 TKS/Boot · 에디터·개발 빌드만 · 읽으면 지움). public const string PreviewKey = "tks.boot.preview_download"; public enum Step { Check, Download, FadeOut, Switch, FadeIn, Done } /// /// 타이틀 화면 훅 — (카메라, 시작 눌림 → 로비로 가는 함수) 를 받아 타이틀을 띄우고 true. 비었거나 false 면 바로 로비. /// 타이틀 화면이 게임에 들어올 때 그 코드가 등록한다(2026-11-13 현재 게임에 타이틀 화면 없음). /// public static Func TitleProvider; /// 테스트·캡처가 공급자를 바꿔 끼움(null = 표대로). public static IResourceDownloader OverrideDownloader; public static BootFlow Current { get; private set; } public Step State { get; private set; } public IResourceDownloader Downloader { get; private set; } public ScreenFader Fader { get; private set; } /// 타이틀 훅이 타이틀을 띄웠는가(false = 로비로 감). public bool TitleShown { get; private set; } Camera cam; Action toLobby; /// /// 시작 흐름을 연다 — 받을 것이 없다고 바로 알면 그 자리에서 타이틀/로비를 띄우고 null(흐름 객체 없음). /// 확인이 더 걸리거나 받을 것이 있으면 흐름 객체를 만들어 돌린다. /// public static BootFlow Begin(Camera cam, Action toLobby) { var dl = OverrideDownloader ?? FromTable(); dl.Begin(); if (dl.Checked && !dl.Needed) { ShowTitleOrLobby(cam, toLobby); return null; } var go = new GameObject("BootFlow"); var bf = go.AddComponent(); bf.cam = cam; bf.toLobby = toLobby; bf.Downloader = dl; Current = bf; bf.StartCoroutine(bf.Run()); return bf; } /// 표 download 줄로 공급자를 고른다(미리보기 키가 있으면 한 번 가짜 받기). public static IResourceDownloader FromTable() { var r = LoadingTable.Load(TablePath).Find(x => x.kind == "option" && x.id == "download"); string kind = r != null && r.text.Length > 0 ? r.text.ToLowerInvariant() : "none"; #if UNITY_EDITOR || DEVELOPMENT_BUILD if (PlayerPrefs.GetInt(PreviewKey, 0) == 1) { PlayerPrefs.DeleteKey(PreviewKey); PlayerPrefs.Save(); kind = "simulate"; } #endif if (kind == "simulate" && r != null) return new SimulatedDownload(r.a, r.b, r.c); return new NoDownload(); } static LoadingTable.Row Opt(string id) { return LoadingTable.Load(TablePath).Find(x => x.kind == "option" && x.id == id); } static bool ShowTitleOrLobby(Camera cam, Action toLobby) { if (TitleProvider != null && TitleProvider(cam, toLobby)) return true; if (toLobby != null) toLobby(); return false; } IEnumerator Run() { float last = LoadingScreen.Now; Func dt = () => { float now = LoadingScreen.Now; float d = Mathf.Max(0f, now - last); last = now; return d; }; // 1. 확인(실제 공급자는 원격 목록을 묻는 시간) State = Step.Check; while (!Downloader.Checked) { yield return null; Downloader.Tick(dt()); } if (!Downloader.Needed) { Finish(ShowTitleOrLobby(cam, toLobby)); yield break; } // 2. 받기 — 로딩 화면 Download 세트(배경 · 타이틀 로고 · 조운·병졸 · 진행률·용량) State = Step.Download; var ls = LoadingScreen.Show(cam, LoadingMode.Download); ls.SetDownload(Downloader.DoneBytes, Downloader.TotalBytes, true); var retry = Opt("retry"); while (!Downloader.Finished) { yield return null; Downloader.Tick(dt()); if (Downloader.Error != null) { Debug.LogWarning("[TKS] 리소스 받기 실패 · 다시 시도: " + Downloader.Error); float wait = retry != null && retry.a > 0f ? retry.a : 3f; for (float w = 0f; w < wait;) { yield return null; w += dt(); } Downloader.Begin(); continue; } ls.SetDownload(Downloader.DoneBytes, Downloader.TotalBytes); } ls.SetDownload(Downloader.TotalBytes, Downloader.TotalBytes); // 보이는 진행률이 100 % 에 닿을 때까지(최대 2 초) + done_hold 초 for (float w = 0f; ls != null && ls.Shown < 0.999f && w < 2f;) { yield return null; w += dt(); } var hold = Opt("done_hold"); for (float w = 0f, h = hold != null ? hold.a : 0.4f; w < h;) { yield return null; w += dt(); } // 3. 페이드 아웃 → 화면 바꿈(로딩 화면 닫고 타이틀/로비) → 검정 유지 → 페이드 인 var fade = Opt("fade"); float fOut = fade != null ? fade.a : 0.35f; float fHold = fade != null ? fade.b : 0.15f; float fIn = fade != null ? fade.c : 0.35f; int order = fade != null && fade.size > 0 ? fade.size : 950; Color col = fade != null ? fade.color : Color.black; Fader = ScreenFader.Create(cam, order, col); State = Step.FadeOut; yield return Fader.FadeTo(1f, fOut); State = Step.Switch; LoadingScreen.HideCurrent(); TitleShown = ShowTitleOrLobby(cam, toLobby); for (float w = 0f; w < fHold;) { yield return null; w += dt(); } State = Step.FadeIn; yield return Fader.FadeTo(0f, fIn); Destroy(Fader.gameObject); Fader = null; Finish(TitleShown); } void Finish(bool titleShown) { TitleShown = titleShown; State = Step.Done; if (Current == this) Current = null; Destroy(gameObject); } void OnDestroy() { if (Current == this) Current = null; if (Fader != null) Destroy(Fader.gameObject); } } }