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);
}
}
}