ThreeKingdomsSlingshot/Assets/Scripts/OutGame/UICaptureDriver.cs

577 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
namespace TKS.OutGame
{
/// <summary>
/// UI 리뉴얼 검증 캡처(PD #1100 · 에디터 DataImporter.CaptureUI 가 SessionState 로 출력 폴더를 넘기면 생성 · 세이브 = 메모리 전용 신규 유저).
/// 1080×1920 = 로비 · 영웅 목록 · 영웅 상세 · 편성 · 거래 · 보물 · 내정 · 연무장 · 월드맵 팝업 · 전투 HUD · 전투 결과 · 팝업 + 1536×2048 = 로비 · 영웅 상세(겹침 확인) · 끝나면 스스로 종료.
/// </summary>
public class UICaptureDriver : MonoBehaviour
{
public const string DirKey = "TKS_UI_CAPTURE_DIR";
/// <summary>찍을 화면만(쉼표 · 정확한 이름 또는 「접두*」 · 비면 전부) — PD 2026-11-09 「수정된 화면만 체크」.</summary>
public const string OnlyKey = "TKS_UI_CAPTURE_ONLY";
public string OutputDir;
string[] only;
readonly HashSet<string> matched = new HashSet<string>();
const int W = 1080, H = 1920, W2 = 1536, H2 = 2048;
readonly List<string> log = new List<string>();
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<UICaptureDriver>();
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);
yield return RecruitShots(cam, root); // 명장 등용 배너(PD 2026-11-13 · 픽업 · 국가별 상시 4 · 상세 정보 팝업)
yield return GachaShots(cam, root); // 뽑기 연출(삼고초려 장면 → 카드 획득 · PD 2026-11-13 · -uiOnly gacha_cut1|2|3 · gacha_cards 일 때만)
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 3차 「등급 칩 줄 → 드롭다운 · 전체 ~ 등급별」) — 펼친 목록 · 영웅 등급만 거른 목록
root.Treasures.ToggleGradeFilter();
yield return Shot(cam, "treasure_owned_filter_open", W, H);
root.Treasures.SetGradeFilter(db.Economy.TreasureGrades.Count > 1 ? db.Economy.TreasureGrades[1].Key : "");
yield return Shot(cam, "treasure_owned_filtered", W, H);
root.Treasures.SetGradeFilter("");
// 유물 정보 팝업(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);
}
/// <summary>
/// 로비 배경 연출 연속 캡처(-uiOnly lobby_anim 일 때만 · PD 2026-11-11 로비 연출 개선 확인) — 원본 크기(1080×1920 · 해상도 판단용 · 반 크기는 축소로 깨져 보임) 40장 ·
/// 실제 시각(초)을 로그에 남김(움직임 영상 만들 때 간격).
/// </summary>
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<string>();
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));
}
/// <summary>
/// 훈련 장면 병사 동작 연속 캡처(-uiOnly domestic_train_anim 일 때만 · PD 2026-11-12 내정 개편) — 원본 크기 45장 · 0.08 초 간격(동작 1회 3.0 초를 덮음) · 실제 시각을 로그에(영상 간격).
/// </summary>
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<string>();
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));
}
/// <summary>
/// 로딩 화면 두 경우(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 지시 예시 그대로 — 게임 데이터 아님.
/// </summary>
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;
}
/// <summary>
/// 게임 시작 흐름(PD 2026-11-13 「스플래쉬 → 리소스 다운로드 화면 → 다 받으면 페이드 → 타이틀」 · -uiOnly boot_anim 일 때만) —
/// 가짜 받기(48 MB · 초당 32 MB · 확인 0.1 초 · 확인용 값 · 게임 데이터 아님) → 100 % → 페이드 아웃 → 로비(타이틀 화면 아직 없음) → 페이드 인을
/// 원본 크기 56장(게임 시간 1/16 초 간격 = 3.5 초)으로. 칸마다 흐름 단계를 로그에.
/// </summary>
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<string>();
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));
}
/// <summary>
/// 로딩 화면 연속 캡처 48장 — 게임 시간을 캡처 한 장마다 1/16 초씩만 진행(Time.captureDeltaTime + 로딩 화면 시계 = Time.time) = 원본 크기 화면 읽기가 느려도 16 fps 영상과 같은 간격
/// (Time.unscaledTime 은 captureDeltaTime 을 따르지 않음 · 2026-11-13 실측 장당 0.08 초).
/// </summary>
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<string>();
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));
}
/// <summary>
/// 뽑기 연출 연속 캡처(PD 2026-11-13 삼고초려 · 지정했을 때만): gacha_cut1 · gacha_cut2 · gacha_cut3 = 그 등급 1장 뽑기(기다림 → 덜컹 → 서서히 열리며 빛 → 활짝 → 카드 1장) ·
/// gacha_cards = 10장(장면을 탭으로 건너뛴 뒤 카드 획득). 원본 크기 · 연출 시간 1/15 초 간격(연출을 한 칸씩 직접 진행 = 캡처에 걸리는 시간과 무관).
/// 뽑힌 영웅 = 캡처용 지정(등급별 풀에서 고름 · 확률 표와 무관) — 세이브(메모리)에는 실제 뽑기처럼 반영.
/// </summary>
IEnumerator GachaShots(Camera cam, OutGameRoot root)
{
if (only == null) yield break;
var s = root.Session;
const float dt = 1f / 15f;
GachaCutscene.Manual = true;
for (int tier = 1; tier <= 3; tier++)
{
string name = "gacha_cut" + tier;
if (Match(name) == null) continue;
matched.Add(name);
var pulls = new List<GachaPull> { CapturePull(s, tier, 0) };
var cut = GachaCutscene.Show(root, s, pulls, null);
yield return null;
int n = Mathf.CeilToInt((cut.SceneEnd + 1.8f) / dt);
bool probed = false;
for (int f = 0; f < n; f++)
{
cut.Tick(dt);
yield return CaptureUtil.Capture(cam, W, H, Path.Combine(OutputDir, name + "_" + f.ToString("000") + ".png"));
if (!probed && cut.Stage == GachaCutscene.Step.Open)
{
probed = true;
yield return UiLayerProbe.Measure(name, Log, OutputDir);
}
}
Log(name + " frames=" + n + " dt=" + dt.ToString("0.0000", System.Globalization.CultureInfo.InvariantCulture) + " sceneEnd=" + cut.SceneEnd.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) +
" tier=" + cut.Tier + " reaction=" + cut.Reaction + " actors=" + cut.ActorCount + " rattles=" + cut.RattleCount + " hero=" + pulls[0].heroId +
" cards landed=" + cut.CardsLanded + "/" + cut.CardCount + " stage=" + cut.Stage);
if (cut != null) Destroy(cut.gameObject);
yield return null;
}
if (Match("gacha_cards") != null)
{
matched.Add("gacha_cards");
// 등급이 섞인 10장(★★★ 1 · ★★ 3 · ★ 6 · 같은 영웅 중복 포함 = 조각 게이지가 차례로 쌓이는지)
int[] tiers = { 1, 2, 1, 1, 3, 1, 2, 1, 2, 1 };
int[] pick = { 0, 0, 1, 0, 0, 2, 0, 1, 1, 3 };
var pulls = new List<GachaPull>();
for (int i = 0; i < tiers.Length; i++) pulls.Add(CapturePull(s, tiers[i], pick[i]));
var cut = GachaCutscene.Show(root, s, pulls, null);
yield return null;
cut.Tick(0.6f);
cut.Tap(); // 장면 건너뛰기 → 카드
for (int f = 0; f < 54; f++)
{
cut.Tick(dt);
yield return CaptureUtil.Capture(cam, W, H, Path.Combine(OutputDir, "gacha_cards_" + f.ToString("000") + ".png"));
}
Log("gacha_cards landed=" + cut.CardsLanded + "/" + cut.CardCount + " stage=" + cut.Stage + " tier=" + cut.Tier);
yield return UiLayerProbe.Measure("gacha_cards", Log, OutputDir);
if (cut != null) Destroy(cut.gameObject);
yield return null;
}
GachaCutscene.Manual = false;
}
/// <summary>캡처용 뽑기 1장 = 등급 tier 풀의 k 번째 유닛 · 세이브에도 실제 뽑기처럼(새 유닛 = 획득 · 중복 = 조각 / 병사 = 골드 · GachaRules.Draw 와 같은 처리).</summary>
const string GachaShowcase = "zhugeliang";
/// <summary>
/// 명장 등용 배너 캡처(PD 2026-11-13): trade_recruit_pickup(픽업 배너) · trade_recruit_&lt;배너 id&gt;(국가별 상시 = 진영 깃발 고름) ·
/// trade_recruit_info(픽업 영웅 상세 정보 팝업) · trade_recruit_info_hero(상시 배너 가운데 영웅 상세 정보). 기간이 지난 뒤에도 찍히게 시계를 픽업 기간 안으로 둔다(캡처 동안만).
/// </summary>
IEnumerator RecruitShots(Camera cam, OutGameRoot root)
{
var s = root.Session;
var clock = s.Clock;
GachaConfig.Banner pick = null;
foreach (var b in s.Db.Gacha.banners)
if (b.IsPickup && pick == null) pick = b;
if (pick != null && !GachaRules.IsOpen(pick, s.Now) && GachaRules.StartOf(pick) != null)
{
var inside = GachaRules.StartOf(pick).Value.AddHours(22.5);
s.Clock = () => inside;
}
if (pick != null)
{
root.Trade.SelectBanner(pick.id);
yield return Shot(cam, "trade_recruit_pickup", W, H);
yield return Shot(cam, "trade_recruit_pickup_1080x2400", 1080, 2400); // 좁고 긴 화면(9:20)
yield return Shot(cam, "trade_recruit_pickup_1536x2048", W2, H2); // 넓은 화면(3:4)
Log("recruit pickup banner=" + root.Trade.Banner.id + " page=" + root.Trade.BannerPage + "/" + root.Trade.BannerPageCount + " open=" + GachaRules.IsOpen(pick, s.Now));
}
GachaConfig.Banner first = null;
foreach (var b in s.Db.Gacha.banners)
{
if (b.IsPickup) continue;
if (first == null) first = b;
root.Trade.SelectBanner(b.id);
yield return Shot(cam, "trade_recruit_" + b.id, W, H);
Log("recruit " + b.id + " featured=" + root.Trade.FeaturedShown + " flags=" + root.Trade.FlagCount);
}
if (pick != null)
{
root.Trade.SelectBanner(pick.id);
root.Trade.ShowBannerInfo();
yield return Shot(cam, "trade_recruit_info", W, H);
var st = HeroInfoPopup.LastStats;
Log("recruit info " + HeroInfoPopup.LastHeroId + " hp=" + st.Hp + " atk=" + st.Atk + " def=" + st.Def + " power=" + st.power);
root.CloseModal();
}
if (first != null && first.featured.Count > 0)
{
root.Trade.SelectBanner(first.id);
root.Trade.ShowHeroInfo(first.featured[0]);
yield return Shot(cam, "trade_recruit_info_hero", W, H);
root.CloseModal();
}
s.Clock = clock;
root.Trade.SelectBanner(GachaRules.Default(s.Db, s.Now).id);
}
static GachaPull CapturePull(OutGameSession s, int tier, int k)
{
// 전 진영이 나오는 배너의 풀(없으면 지금 열린 첫 배너)
GachaConfig.Banner all = null;
foreach (var b in s.Db.Gacha.banners)
if (all == null && b.pool.Contains(GachaConfig.PoolAll)) all = b;
var list = GachaRules.Candidates(s.Db, all ?? GachaRules.Default(s.Db, s.Now), tier);
var h = s.Def(list[k % list.Count].heroId);
// ★★★ 첫 장 = 제갈량(삼고초려 장면 확인용 · 풀에 있을 때만 · 캡처 전용 선택 — 게임 확률과 무관)
if (tier == 3 && k == 0)
foreach (var e in list)
if (e.heroId == GachaShowcase) h = s.Def(e.heroId);
var p = new GachaPull { heroId = h.id, tier = tier };
if (HeroRules.Grant(s.Db, s.Save, h, HeroRules.GearSlotCount(s.Db))) p.isNew = true;
else if (h.isSoldier)
{
p.gold = s.Db.Economy.SoldierDupGold;
s.Save.gold += p.gold;
}
else
{
p.shards = s.Db.Economy.ShardDup(h.rarity);
s.Save.Hero(h.id).shards += p.shards;
}
return p;
}
/// <summary>지정했을 때만(-uiOnly 이름) 원본 크기 연속 캡처 n 장 · 실제 시각을 로그에.</summary>
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<string>();
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);
}
/// <summary>지정 목록에 맞는 토큰(없으면 null).</summary>
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
}
}
}