using System.Collections; using System.Collections.Generic; using System.IO; using UnityEngine; namespace TKS.OutGame { /// /// UI 리뉴얼 검증 캡처(PD #1100 · 에디터 DataImporter.CaptureUI 가 SessionState 로 출력 폴더를 넘기면 생성 · 세이브 = 메모리 전용 신규 유저). /// 1080×1920 = 로비 · 영웅 목록 · 영웅 상세 · 편성 · 거래 · 보물 · 내정 · 연무장 · 월드맵 팝업 · 전투 HUD · 전투 결과 · 팝업 + 1536×2048 = 로비 · 영웅 상세(겹침 확인) · 끝나면 스스로 종료. /// public class UICaptureDriver : MonoBehaviour { public const string DirKey = "TKS_UI_CAPTURE_DIR"; /// 찍을 화면만(쉼표 · 정확한 이름 또는 「접두*」 · 비면 전부) — PD 2026-11-09 「수정된 화면만 체크」. public const string OnlyKey = "TKS_UI_CAPTURE_ONLY"; public string OutputDir; string[] only; readonly HashSet matched = new HashSet(); const int W = 1080, H = 1920, W2 = 1536, H2 = 2048; readonly List log = new List(); int shot = 1; #if UNITY_EDITOR [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void AutoSpawn() { string dir = UnityEditor.SessionState.GetString(DirKey, ""); if (string.IsNullOrEmpty(dir)) return; UnityEditor.SessionState.EraseString(DirKey); var d = new GameObject("UICaptureDriver").AddComponent(); d.OutputDir = dir; string only = UnityEditor.SessionState.GetString(OnlyKey, ""); UnityEditor.SessionState.EraseString(OnlyKey); if (!string.IsNullOrEmpty(only)) d.only = only.Split(new[] { ',' }, System.StringSplitOptions.RemoveEmptyEntries); } #endif IEnumerator Start() { Directory.CreateDirectory(OutputDir); yield return null; yield return null; var root = OutGameRoot.Instance; var cam = Camera.main; if (root == null || cam == null) { Log("FAIL: OutGameRoot 또는 카메라 없음"); Finish(1); yield break; } var s = root.Session; var db = s.Db; var d = s.Save; Log("theme=" + (UITheme.Current != null) + " titleFont=" + (UISkin.TitleFont != null ? UISkin.TitleFont.name : "-") + " memoryOnly=" + SaveStore.MemoryOnly); // 캡처용 진행(메모리 세이브만): 재화 · 장비 · 보물 · 상자 · 순찰 d.gold = 124500; d.gem = 1280; var id = db.Economy.StartHeroes.Length > 1 ? db.Economy.StartHeroes[1] : db.Economy.StartHeroes[0]; var h = s.Def(id); var hs = s.Save.Hero(id); for (int i = 0; i < 4; i++) d.inv.AddGear(HeroRules.RequiredGear(db, h, hs, i), 1); for (int i = 0; i < 3; i++) s.Equip(id, i); HeroRules.GrantExp(hs, db.Levels.Get(1).expToNext + 5); var rng = new System.Random(1100); Counts.Set(d.inv.chests, "gold", 6); for (int i = 0; i < 6; i++) TreasureRules.Open(db, d, "gold", rng); Counts.Set(d.inv.chests, "bronze", 4); Counts.Set(d.inv.chests, "silver", 18); Counts.Set(d.inv.chests, "general", 1); d.inv.chestPoints = 35; d.patrol.startAt = SaveStore.Iso(s.Now.AddHours(-7)); s.Commit(); yield return LoadingShots(cam); // 로딩 화면(PD 2026-11-13 · -uiOnly loading* 일 때만) yield return BootShots(cam, root); // 시작 흐름 다운로드 → 페이드 → 로비(PD 2026-11-13 · -uiOnly boot_anim 일 때만) root.ShowLobby(); yield return Shot(cam, "lobby", W, H); yield return LobbyAnim(cam); // 목록 카드 등급색 확인용(메모리 세이브만): 나머지 시작 영웅 등급 = 각자 장비 세트의 다음 등급들 int gi = 1; foreach (var sid in db.Economy.StartHeroes) { var sh = s.Def(sid); var shs = s.Save.Hero(sid); if (sid == id || sh == null || shs == null || sh.gear.Count == 0) continue; shs.grade = sh.gear[Mathf.Min(gi++, sh.gear.Count - 1)].grade; } // 진영 원 배지 확인용(메모리 세이브만 · PD 2026-11-09): 위 · 오 · 군 영웅 1명씩 보유 추가 foreach (var fac in new[] { "wei", "wu", "other" }) foreach (var fh in db.Heroes.heroes) if (!fh.isSoldier && fh.faction == fac && !d.Owns(fh.id)) { HeroRules.Grant(db, d, fh, HeroRules.GearSlotCount(db)); break; } root.Show(root.HeroList); yield return Shot(cam, "hero_list", W, H); Log("hero_list cols=" + root.HeroList.Columns); // 해상도 대응(PD 2026-11-09): 넓은 화면(3:4) · 좁은 화면(9:20) yield return Shot(cam, "hero_list_1536x2048", W2, H2); Log("hero_list_1536x2048 cols=" + root.HeroList.Columns); yield return Shot(cam, "hero_list_1080x2400", 1080, 2400); Log("hero_list_1080x2400 cols=" + root.HeroList.Columns); yield return Shot(cam, "hero_list_1080x2520", 1080, 2520); Log("hero_list_1080x2520 cols=" + root.HeroList.Columns); root.ShowHero(id); yield return Shot(cam, "hero_detail", W, H); // 영웅 상세 등급 변형(UI 상업용 재설계 ① · 목업 hero_detail_5등급 대조 · 메모리 세이브만) = 그 영웅 장비 세트의 등급 순서대로 string g0 = hs.grade; foreach (var set in h.gear) { if (set.grade == g0) continue; hs.grade = set.grade; root.HeroDetail.Refresh(); yield return Shot(cam, "hero_detail_" + set.grade, W, H); } hs.grade = g0; root.HeroDetail.Refresh(); root.Show(root.Formation); yield return Shot(cam, "formation", W, H); root.Show(root.Trade); root.Trade.SetPage(0); yield return Shot(cam, "trade_gacha", W, H); root.Show(root.Treasures); root.Treasures.SetPage(2); root.Treasures.SelectChest("gold"); yield return Shot(cam, "treasure_chests", W, H); root.Treasures.SelectChest("silver"); // 보유 상자(열기 버튼 활성 · PD 2026-11-13 금고 받침대 확인) yield return Shot(cam, "treasure_chests_ready", W, H); root.Treasures.SetPage(0); yield return Shot(cam, "treasure_owned", W, H); // 유물 정보 팝업(PD 2026-11-13 2차 「선택된 유물의 정보 화면에 선반 컨셉 강조」) = 보유한 첫 유물 foreach (var ti in db.Treasures.items) if (d.inv.Treasure(ti.id) != null) { root.Treasures.Detail(ti.id); break; } yield return Shot(cam, "treasure_detail", W, H); root.CloseModal(); root.Treasures.SetPage(1); yield return Shot(cam, "treasure_sets", W, H); // 세트 탭(PD 2026-11-13 보물고 진열대 · 같은 배경) // 내정(PD 2026-11-12 내정 메뉴 개편 · 탭마다 배경 장면) — 훈련(병종별) · 훈련 동작 연속 · 순찰 · 드롭다운 펼침 · 수확 팝업(광고 2배 선택) · 개발 // 파견(메모리 세이브만 · PD 2026-11-12 「영웅 파견 · 지휘관 · 문 앞 · 개발 배치」) = 보유 영웅으로 훈련(보·창·기·궁·책 각 1) · 순찰 · 개발 foreach (var ut in root.Domestic.TrainTypes) { var c = DispatchRules.Candidates(db, d, DispatchRules.TrainSlot(ut)); if (c.Count > 0) DispatchRules.Assign(db, d, DispatchRules.TrainSlot(ut), c[0].id); } foreach (var slot in new[] { DispatchRules.Patrol, DispatchRules.Dev }) foreach (var c in DispatchRules.Candidates(db, d, slot)) if (DispatchRules.Where(d, c.id) == null) { DispatchRules.Assign(db, d, slot, c.id); break; } s.Commit(); root.Show(root.Domestic); root.Domestic.SetTrainType(UnitType.Infantry); root.Domestic.SetPage(0); yield return Shot(cam, "domestic_train", W, H); Log("domestic dispatch train=" + DispatchRules.Get(d, DispatchRules.TrainSlot(UnitType.Infantry)) + " patrol=" + DispatchRules.Get(d, DispatchRules.Patrol) + " dev=" + DispatchRules.Get(d, DispatchRules.Dev)); Log("domestic_train soldiers=" + root.Domestic.SoldierCount); yield return DomesticAnim(cam); foreach (var ut in root.Domestic.TrainTypes) { if (ut == UnitType.Infantry) continue; root.Domestic.SetTrainType(ut); yield return Shot(cam, "domestic_train_" + ut.ToString().ToLowerInvariant(), W, H); // 병종별 훈련 동작 연속 캡처(PD 2026-11-12 기병 박자 · 궁병 다시 제작 · 책사 크기) — -uiOnly 에 이름을 줄 때만 yield return AnimFrames(cam, "domestic_train_anim_" + ut.ToString().ToLowerInvariant(), 40, 0.06f); } root.Domestic.SetTrainType(UnitType.Infantry); root.Domestic.SetPage(1); yield return Shot(cam, "domestic_patrol", W, H); yield return AnimFrames(cam, "domestic_patrol_anim", 24, 0.12f); // 순찰 영웅·창병·NPC 대기 숨 고르기 확인(PD 2026-11-12) root.Domestic.OpenHarvest(); yield return Shot(cam, "domestic_patrol_harvest", W, H); root.CloseModal(); root.Domestic.OpenDispatch(DispatchRules.Patrol); yield return Shot(cam, "domestic_dispatch", W, H); Log("domestic_dispatch choices=" + root.Domestic.DispatchChoices); root.CloseModal(); root.Domestic.SetPage(2); yield return Shot(cam, "domestic_dev", W, H); Log("domestic_dev hero=" + root.Domestic.DevHeroShown + " nodes=" + root.Domestic.NodeCount); root.Arena.SetPage(0); root.Show(root.Arena); yield return Shot(cam, "arena", W, H); root.StaminaModal(); yield return Shot(cam, "popup_stamina", W, H); root.CloseModal(); root.ShowLobby(); root.ShowHero(id); yield return Shot(cam, "hero_detail_1536x2048", W2, H2); root.ShowLobby(); yield return Shot(cam, "lobby_1536x2048", W2, H2); root.OpenWorldMap(); yield return Wait(); var wm = root.Map; wm.OpenPopup(0); yield return Shot(cam, "worldmap_popup", W, H); wm.ConfirmPopup(); var bm = root.Battle; float t0 = Time.realtimeSinceStartup; while (bm.Phase != BattlePhase.PlayerTurn && Time.realtimeSinceStartup - t0 < 10f) yield return null; yield return new WaitForSecondsRealtime(0.6f); Unit pick = null; foreach (var u in bm.AliveUnits(Faction.Player)) if (bm.Skills != null && bm.Skills.Of(u) != null && bm.Skills.Of(u).Active != null) { pick = u; break; } if (pick != null) bm.Sling.Select(pick); Log("hud select=" + (pick != null ? pick.DisplayName : "-") + " skill=" + bm.Hud.SkillButtonText); yield return Shot(cam, "battle_hud", W, H); foreach (var e in bm.AliveUnits(Faction.Enemy)) e.TakeDamage(e.Hp); bm.CheckBattleEnd(); yield return new WaitForSecondsRealtime(0.6f); yield return Shot(cam, "battle_result", W, H); Finish(0); } /// /// 로비 배경 연출 연속 캡처(-uiOnly lobby_anim 일 때만 · PD 2026-11-11 로비 연출 개선 확인) — 원본 크기(1080×1920 · 해상도 판단용 · 반 크기는 축소로 깨져 보임) 40장 · /// 실제 시각(초)을 로그에 남김(움직임 영상 만들 때 간격). /// IEnumerator LobbyAnim(Camera cam) { if (only == null || Match("lobby_anim") == null) yield break; matched.Add("lobby_anim"); float t0 = Time.realtimeSinceStartup; var times = new List(); for (int f = 0; f < 40; f++) { yield return new WaitForSecondsRealtime(0.05f); times.Add((Time.realtimeSinceStartup - t0).ToString("0.000", System.Globalization.CultureInfo.InvariantCulture)); yield return CaptureUtil.Capture(cam, W, H, Path.Combine(OutputDir, "lobby_anim_" + f.ToString("00") + ".png")); } Log("lobby_anim times " + string.Join(",", times)); } /// /// 훈련 장면 병사 동작 연속 캡처(-uiOnly domestic_train_anim 일 때만 · PD 2026-11-12 내정 개편) — 원본 크기 45장 · 0.08 초 간격(동작 1회 3.0 초를 덮음) · 실제 시각을 로그에(영상 간격). /// IEnumerator DomesticAnim(Camera cam) { if (only == null || Match("domestic_train_anim") == null) yield break; matched.Add("domestic_train_anim"); float t0 = Time.realtimeSinceStartup; var times = new List(); for (int f = 0; f < 45; f++) { yield return new WaitForSecondsRealtime(0.08f); times.Add((Time.realtimeSinceStartup - t0).ToString("0.000", System.Globalization.CultureInfo.InvariantCulture)); yield return CaptureUtil.Capture(cam, W, H, Path.Combine(OutputDir, "domestic_train_anim_" + f.ToString("00") + ".png")); } Log("domestic_train_anim times " + string.Join(",", times)); } /// /// 로딩 화면 두 경우(PD 2026-11-13 · 지정했을 때만 · -uiOnly loading*): /// loading = 원격 다운로드(배경 + 위 타이틀 로고 + 조운·병졸 · 12.4 / 48.0 MB) · loading_anim = 그 화면 원본 크기 연속 48장(게임 시간 1/16 초 간격) /// loading_simple = 일반 로딩(검은 화면 + 유비·관우·장비 · 60 %) · loading_simple_anim = 그 화면 연속 48장. /// 확인용 입력값(용량·진행률)은 PD 지시 예시 그대로 — 게임 데이터 아님. /// IEnumerator LoadingShots(Camera cam) { if (only == null || Match("loading") == null && Match("loading_anim") == null && Match("loading_simple") == null && Match("loading_simple_anim") == null) yield break; var ls = LoadingScreen.Show(cam, LoadingMode.Download); ls.SetDownload(13002342L, 50331648L, true); yield return new WaitForSecondsRealtime(0.5f); // 먼지가 몇 개 나온 뒤 yield return Shot(cam, "loading", W, H); yield return LoadingAnim(cam, "loading_anim"); ls.SetMode(LoadingMode.Loading); ls.SetProgress(0.6f, true); yield return new WaitForSecondsRealtime(0.5f); yield return Shot(cam, "loading_simple", W, H); yield return LoadingAnim(cam, "loading_simple_anim"); ls.Hide(); yield return null; } /// /// 게임 시작 흐름(PD 2026-11-13 「스플래쉬 → 리소스 다운로드 화면 → 다 받으면 페이드 → 타이틀」 · -uiOnly boot_anim 일 때만) — /// 가짜 받기(48 MB · 초당 32 MB · 확인 0.1 초 · 확인용 값 · 게임 데이터 아님) → 100 % → 페이드 아웃 → 로비(타이틀 화면 아직 없음) → 페이드 인을 /// 원본 크기 56장(게임 시간 1/16 초 간격 = 3.5 초)으로. 칸마다 흐름 단계를 로그에. /// IEnumerator BootShots(Camera cam, OutGameRoot root) { const string name = "boot_anim"; if (only == null || Match(name) == null) yield break; matched.Add(name); float prevDt = Time.captureDeltaTime; Time.captureDeltaTime = 1f / 16f; float tOffset = LoadingScreen.Now - Time.time; LoadingScreen.Clock = () => Time.time + tOffset; BootFlow.OverrideDownloader = new SimulatedDownload(48f, 32f, 0.1f); var bf = BootFlow.Begin(cam, root.ShowLobby); float t0 = LoadingScreen.Now; var times = new List(); for (int f = 0; f < 56; f++) { string st = bf != null ? bf.State.ToString() : "Done"; string a = bf != null && bf.Fader != null ? bf.Fader.Alpha.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) : "-"; times.Add((LoadingScreen.Now - t0).ToString("0.000", System.Globalization.CultureInfo.InvariantCulture) + ":" + st + ":" + a); yield return CaptureUtil.Capture(cam, W, H, Path.Combine(OutputDir, name + "_" + f.ToString("00") + ".png")); } BootFlow.OverrideDownloader = null; LoadingScreen.Clock = null; Time.captureDeltaTime = prevDt; Log(name + " game-times:state:fade " + string.Join(",", times)); } /// /// 로딩 화면 연속 캡처 48장 — 게임 시간을 캡처 한 장마다 1/16 초씩만 진행(Time.captureDeltaTime + 로딩 화면 시계 = Time.time) = 원본 크기 화면 읽기가 느려도 16 fps 영상과 같은 간격 /// (Time.unscaledTime 은 captureDeltaTime 을 따르지 않음 · 2026-11-13 실측 장당 0.08 초). /// IEnumerator LoadingAnim(Camera cam, string name) { if (Match(name) == null) yield break; matched.Add(name); float prevDt = Time.captureDeltaTime; Time.captureDeltaTime = 1f / 16f; float tOffset = LoadingScreen.Now - Time.time; LoadingScreen.Clock = () => Time.time + tOffset; float t0 = LoadingScreen.Now; var times = new List(); for (int f = 0; f < 48; f++) { times.Add((LoadingScreen.Now - t0).ToString("0.000", System.Globalization.CultureInfo.InvariantCulture)); yield return CaptureUtil.Capture(cam, W, H, Path.Combine(OutputDir, name + "_" + f.ToString("00") + ".png")); } LoadingScreen.Clock = null; Time.captureDeltaTime = prevDt; Log(name + " game-times " + string.Join(",", times)); } /// 지정했을 때만(-uiOnly 이름) 원본 크기 연속 캡처 n 장 · 실제 시각을 로그에. IEnumerator AnimFrames(Camera cam, string name, int n, float gap) { if (only == null || Match(name) == null) yield break; matched.Add(name); float t0 = Time.realtimeSinceStartup; var times = new List(); for (int f = 0; f < n; f++) { yield return new WaitForSecondsRealtime(gap); times.Add((Time.realtimeSinceStartup - t0).ToString("0.000", System.Globalization.CultureInfo.InvariantCulture)); yield return CaptureUtil.Capture(cam, W, H, Path.Combine(OutputDir, name + "_" + f.ToString("00") + ".png")); } Log(name + " times " + string.Join(",", times)); } static IEnumerator Wait() { yield return null; yield return new WaitForSecondsRealtime(0.3f); } /// 지정 목록에 맞는 토큰(없으면 null). string Match(string name) { foreach (var t in only) { string k = t.Trim(); if (k.EndsWith("*") ? name.StartsWith(k.Substring(0, k.Length - 1)) : name == k) return k; } return null; } IEnumerator Shot(Camera cam, string name, int w, int h) { if (only != null) { string k = Match(name); if (k == null) { // 지정 화면을 모두 찍었으면 나머지 흐름(월드맵 · 전투 등)은 건너뛰고 끝낸다 if (matched.Count >= only.Length) Finish(0); yield break; } matched.Add(k); } yield return Wait(); // 캔버스 층 · 드로우콜 실측(PD 2026-11-10 UI 구조 개선 전·후 비교 · ui_layers.csv) yield return UiLayerProbe.Measure(name, Log, OutputDir); string file = Path.Combine(OutputDir, shot.ToString("00") + "_" + name + ".png"); shot++; yield return CaptureUtil.Capture(cam, w, h, file); Log("shot " + file); } void Log(string s) { log.Add(s); Debug.Log("[UICapture] " + s); try { File.WriteAllLines(Path.Combine(OutputDir, "capture_log.txt"), log.ToArray()); } catch { } } void Finish(int code) { Log("finish code=" + code); #if UNITY_EDITOR UnityEditor.EditorApplication.Exit(code); #else Application.Quit(code); #endif } } }