using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; namespace TKS.OutGame { /// 아웃게임 화면 1장(본영 로비 · 영웅 · 편성 · 거래). OutGameRoot 가 만들고 전환한다. public abstract class OutGameScreen { public OutGameRoot Root; public RectTransform Rt; /// 하단 탭 강조(0 영웅 · 4 거래 · −1 없음). public int Tab = -1; public OutGameSession S { get { return Root.Session; } } public abstract void Build(); public virtual void Refresh() { } public virtual void OnShow() { } /// 뒤로 가기를 화면 안에서 처리했으면 true. public virtual bool OnBack() { return false; } /// 제목 띠(재화 바 아래) · back = 「◀」 버튼. protected void Header(string title, string sub, bool back) { var band = UiKit.TopBand("Header", Rt, OutGameRoot.TopBarH, OutGameRoot.HeaderH); UiKit.Paint(band, UiKit.Header, false); float x = 36f; if (back) { var b = UiKit.Btn("Back", band, "◀", 44, UISkin.On ? UiKit.Red : UiKit.Dark, UiKit.White, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(24f, 0f), new Vector2(100f, 80f), () => Root.Back()); if (UiKit.Icon(b.transform, "arrow_back", new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 6f), 50f) != null) UiKit.LabelOf(b).text = ""; x = 146f; } var t = UiKit.Title(UiKit.Label(band, title, 52, UiKit.HeaderText, TextAnchor.MiddleLeft, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(x, 0f), new Vector2(360f, 90f)), true); t.horizontalOverflow = HorizontalWrapMode.Overflow; UiKit.Label(band, sub, 30, UiKit.HeaderSub, TextAnchor.MiddleLeft, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(x + t.preferredWidth + 18f, -4f), new Vector2(700f, 60f), false); } /// /// 제목 띠 아래 ~ 하단 탭 위 본문 — 기준(1080×1920)의 본문 높이보다 화면이 낮으면(예 1536×2048 = 3:4) 본문 전체를 같은 비율로 줄여 탭 바 밑으로 숨지 않게 한다(PD #1100 · 배치는 그대로). /// protected RectTransform Body(string name = "Body") { var outer = UiKit.Fill(name, Rt, 0f, 0f, OutGameRoot.TopBarH + OutGameRoot.HeaderH, OutGameRoot.TabBarH); var inner = UiKit.Rect("Fit", outer, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), Vector2.zero, new Vector2(OutGameRoot.Reference.x, OutGameRoot.BodyH)); inner.gameObject.AddComponent().designHeight = OutGameRoot.BodyH; return inner; } } /// /// 아웃게임 루트(2026-11-05 #1093 시스템 기획 §1 · §10 A) — 게임 시작 = 본영 로비 · 출정 → 기존 챕터 월드맵 · 편성 · 영웅 · 거래 · 준비 중 팝업 · /// 상단 재화 바(골드 · 보옥 · 군량 n/최대 + 회복 타이머 · 월드맵에도 표시) · 하단 탭 5 · 뒤로 가기(◀ · Esc) · 전투 결과 보상 패널. /// 데이터 = OutGameSession(표 + 세이브) · 화면은 도형 + 글자 와이어프레임(목업 img03·06·08·11 배치). /// public class OutGameRoot : MonoBehaviour { public static OutGameRoot Instance { get; private set; } public const float TopBarH = 120f; public const float HeaderH = 104f; public const float TabBarH = 150f; public static readonly Vector2 Reference = new Vector2(1080f, 1920f); /// 기준 화면의 본문 높이(재화 바 · 제목 띠 · 하단 탭 제외). public static float BodyH { get { return Reference.y - TopBarH - HeaderH - TabBarH; } } public OutGameSession Session { get; private set; } public BattleManager Battle { get; private set; } public WorldMapScreen Map { get; private set; } public Camera Cam { get; private set; } BattleHUD hud; SlingshotInput sling; Canvas mainCanvas; Canvas topCanvas; Canvas resultCanvas; public RectTransform TopRoot { get; private set; } public LobbyScreen Lobby { get; private set; } public HeroListScreen HeroList { get; private set; } public HeroDetailScreen HeroDetail { get; private set; } public FormationScreen Formation { get; private set; } public TradeScreen Trade { get; private set; } public DomesticScreen Domestic { get; private set; } public TreasureScreen Treasures { get; private set; } public ArenaScreen Arena { get; private set; } readonly List screens = new List(); OutGameScreen current; RectTransform barRoot; Text goldText; Text gemText; Text staminaText; Text staminaTimer; readonly List tabImages = new List(); readonly List tabTexts = new List(); GameObject modal; GameObject toast; Text toastText; float toastUntil; float nextTick; BattleResultPanel result; bool resultDone; public OutGameScreen Current { get { return current; } } /// 아웃게임 화면(로비 등)이 보이는가(월드맵·전투 중 = false). public bool ScreensVisible { get { return mainCanvas != null && mainCanvas.gameObject.activeSelf; } } public bool LobbyVisible { get { return ScreensVisible && current == Lobby; } } public bool ModalVisible { get { return modal != null; } } public bool ResultVisible { get { return result != null && result.Visible; } } void Awake() { Instance = this; } void OnDestroy() { if (Instance == this) Instance = null; BattleEvents.Raised -= OnBattleEvent; } /// 전투 이벤트 → 보물 조건 효과 카운터(저장은 전투 결과 때 · PD #1093 C). void OnBattleEvent(string key, int n) { if (Session != null) TreasureRules.AddCount(Session.Save, key, n); } public void Build(BattleManager b, BattleHUD h, SlingshotInput s, WorldMapScreen wm, Camera cam) { Battle = b; hud = h; sling = s; Map = wm; Cam = cam; Session = OutGameSession.Start(OutGameData.Default, b.Config); Session.Changed += RefreshAll; // 전투 연결: 출전 = 편성 프리셋 · 결과 = 보상 b.LineupProvider = Session.BuildLineup; b.StageStarted += () => resultDone = false; b.PhaseChanged += OnPhase; // 보물 crit·range·splash = 아군 진영 상시 효과(SkillMods) · 전투 이벤트 카운터(PD #1093 C) b.SideEffectsProvider = f => f == Faction.Player ? TreasureRules.BattleEffects(Session.Db, Session.Save) : null; BattleEvents.Raised -= OnBattleEvent; BattleEvents.Raised += OnBattleEvent; // 월드맵 연결: 팝업 줄 · 군량 · 별 · 소탕 · 본영 if (wm != null) { wm.TopInset = TopBarH; wm.PopupInfo = PopupInfo; wm.TryEnter = TryEnter; wm.StarsOf = si => EconomyRules.StarsOf(Session.Save, b.Config.stages[si]); wm.CanSweep = si => EconomyRules.CanSweep(Session.Save, b.Config.stages[si]); wm.Sweep = DoSweep; wm.BackRequested = ShowLobby; } mainCanvas = MakeCanvas("OutGameCanvas", 350, 0.45f); resultCanvas = MakeCanvas("BattleResultCanvas", 200, 0.8f); topCanvas = MakeCanvas("OutGameTopCanvas", 450, 0.4f); TopRoot = (RectTransform)topCanvas.transform; var bg = UiKit.Fill("Bg", mainCanvas.transform); UiKit.Paint(bg, UiKit.Bg, false, true); Lobby = Add(new LobbyScreen()); HeroList = Add(new HeroListScreen()); HeroDetail = Add(new HeroDetailScreen()); Formation = Add(new FormationScreen()); Trade = Add(new TradeScreen()); Domestic = Add(new DomesticScreen()); Treasures = Add(new TreasureScreen()); Arena = Add(new ArenaScreen()); BuildTabBar(); BuildCurrencyBar(); result = new BattleResultPanel(this, (RectTransform)resultCanvas.transform); mainCanvas.gameObject.SetActive(false); } T Add(T scr) where T : OutGameScreen { scr.Root = this; scr.Rt = UiKit.Fill(typeof(T).Name, mainCanvas.transform); scr.Build(); scr.Rt.gameObject.SetActive(false); screens.Add(scr); return scr; } Canvas MakeCanvas(string name, int order, float plane) { var go = new GameObject(name); go.transform.SetParent(transform, false); var c = go.AddComponent(); c.renderMode = RenderMode.ScreenSpaceCamera; // 카메라 캡처(검증)에도 찍히게 c.worldCamera = Cam; c.planeDistance = plane; c.sortingOrder = order; var sc = go.AddComponent(); sc.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; sc.referenceResolution = Reference; sc.matchWidthOrHeight = 0.5f; go.AddComponent(); return c; } // ---------- 화면 전환 ---------- /// 본영 로비(게임 시작 화면) — 월드맵·전투 HUD·조작을 끄고 로비를 연다. public void ShowLobby() { if (Map != null) Map.Hide(); if (Battle != null && Battle.Phase != BattlePhase.Setup) Battle.GoIdle(); Show(Lobby); } public void Show(OutGameScreen scr) { if (hud != null) hud.gameObject.SetActive(false); if (sling != null) sling.enabled = false; if (Map != null && Map.IsVisible) Map.Hide(); mainCanvas.gameObject.SetActive(true); foreach (var s in screens) s.Rt.gameObject.SetActive(s == scr); current = scr; for (int i = 0; i < tabImages.Count; i++) { bool on = scr.Tab == i; if (UISkin.On) tabImages[i].gameObject.SetActive(on); else tabImages[i].color = on ? UiKit.Red : UiKit.Dark; } scr.OnShow(); scr.Refresh(); RefreshBar(); } public void ShowHero(string id) { HeroDetail.Select(id); Show(HeroDetail); } /// 출정 = 챕터 월드맵(도전 중인 마지막 열린 챕터). public void OpenWorldMap() { if (Map == null || !Map.HasData) return; CloseModal(); mainCanvas.gameObject.SetActive(false); Map.Show(CurrentChapter()); } public int CurrentChapter() { if (Map == null) return 0; int ch = 0; for (int i = 0; i < Map.ChapterCount; i++) if (Map.ChapterUnlocked(i)) ch = i; return ch; } /// 뒤로 가기(◀ · Esc): 팝업 닫기 → 화면 안 처리 → 영웅 상세 = 목록 → 나머지 = 로비 · 월드맵 = 로비. public void Back() { if (modal != null) { CloseModal(); return; } if (Map != null && Map.IsVisible) { if (Map.PopupVisible) Map.ClosePopup(); else ShowLobby(); return; } if (!ScreensVisible || current == null) return; if (current.OnBack()) return; if (current == HeroDetail) Show(HeroList); else if (current != Lobby) Show(Lobby); } /// 「준비 중」(연무장·보물·내정) · 「추후 업데이트」(동맹) 안내. public void ComingSoon(string what, bool later = false) { Modal(what, later ? "추후 업데이트 예정입니다." : "준비 중입니다.\n(2차 개발 — 시스템 기획 §7~§9)", new ModalButton("확인", UiKit.Dark, null)); } void Update() { if (Battle == null || Session == null) return; bool inBattle = Battle.Phase != BattlePhase.Setup; bool onMap = Map != null && Map.IsVisible; if (inBattle && ScreensVisible) mainCanvas.gameObject.SetActive(false); // 테스트 등이 바로 StartStage 를 불러도 화면이 전투를 가리지 않게 bool barOn = !inBattle; if (barRoot.gameObject.activeSelf != barOn) barRoot.gameObject.SetActive(barOn); result.SetVisible(inBattle && resultDone && (Battle.Phase == BattlePhase.StageClear || Battle.Phase == BattlePhase.Defeat)); if (Time.unscaledTime >= nextTick) { nextTick = Time.unscaledTime + 0.5f; Session.TickStamina(); RefreshBar(); } if (toast != null && Time.unscaledTime > toastUntil) toast.SetActive(false); if (Input.GetKeyDown(KeyCode.Escape) && !inBattle) Back(); } void RefreshAll() { RefreshBar(); if (ScreensVisible && current != null) current.Refresh(); } // ---------- 상단 재화 바 ---------- void BuildCurrencyBar() { barRoot = UiKit.TopBand("CurrencyBar", TopRoot, 0f, TopBarH); UiKit.Paint(barRoot, UISkin.On ? UiKit.BarColor : new Color(0.15f, 0.11f, 0.09f), false, true); goldText = Pill(0, "cur_gold", "골", "골드", UiKit.Coin, () => Toast("골드 = 스테이지 보상 · 거래 > 상점 · (구매 상점 준비 중)")); gemText = Pill(1, "cur_gem", "보", "보옥", UiKit.Jade, () => Toast("보옥 구매(과금) = 준비 중 · 첫 클리어 · 보스 보상")); staminaText = Pill(2, "cur_stamina", "군", "군량", UiKit.Grain, StaminaModal); staminaTimer = staminaText.transform.parent.Find("Name").GetComponent(); } /// 재화 칩(팩 ResourceBar · 아이콘 = 테마 cur_* · 「+」 = 팩 노란 단추). Text Pill(int i, string icon, string mark, string name, Color c, Action plus) { float w = 336f; var p = UiKit.Rect("Pill" + i, barRoot, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(18f + i * (w + 18f), 0f), new Vector2(w, 84f)); UiKit.Skin(p, "pill", UISkin.Col("pill", new Color(0.27f, 0.21f, 0.18f))); UiKit.IconOrDot(p, icon, mark, c, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(-4f, 0f), 76f); var n = UiKit.Label(p, name, 26, UISkin.Col("bar_text_off", new Color(0.85f, 0.8f, 0.75f)), TextAnchor.MiddleLeft, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), new Vector2(80f, 0f), new Vector2(110f, 60f), false); n.gameObject.name = "Name"; if (i != 2 && UISkin.Icon(icon) != null) n.text = ""; // 아이콘이 이름을 대신(군량 칸은 회복 타이머 자리) var v = UiKit.Title(UiKit.Label(p, "", 36, UISkin.Col("bar_text", UiKit.White), TextAnchor.MiddleRight, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-70f, 0f), new Vector2(170f, 60f)), true); v.horizontalOverflow = HorizontalWrapMode.Overflow; if (UISkin.Has("plus")) { var pb = UiKit.Rect("Plus", p, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-6f, 0f), new Vector2(60f, 62f)); UiKit.Skin(pb, "plus", Color.white, true); pb.gameObject.AddComponent