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; }
/// 제목 띠(재화 바 아래 · 팩 PanelFrame03 진남 + 왼쪽 붙은 뒤로 · UI 상업용 재설계 공통 크롬) · back = 뒤로 버튼 · sub 빈 문자열 = 부제 없음.
protected RectTransform Header(string title, string sub, bool back)
{
var band = UiKit.TopBand("Header", Rt, OutGameRoot.TopBarH, OutGameRoot.HeaderH);
if (UISkin.Has("header_bar")) UiKit.Skin(band, "header_bar", UiKit.Col("chrome_header", UiKit.Header));
else UiKit.Paint(band, UiKit.Header, false);
float x = 36f;
if (back && UISkin.Has("back_flush"))
{
var bb = UiKit.Rect("Back", band, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), Vector2.zero, new Vector2(132f, 90f));
UiKit.Skin(bb, "back_flush", UiKit.White, true);
bb.gameObject.AddComponent().onClick.AddListener(() => Root.Back());
if (UiKit.Icon(bb, "back", new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(-5f, 0f), 62f) == null)
UiKit.Title(UiKit.FillLabel(bb, "◀", 44, UiKit.White), true);
x = 156f;
}
else 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;
if (!string.IsNullOrEmpty(sub))
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);
return band;
}
///
/// 제목 띠 아래 ~ 하단 탭 위 본문 — 기준(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();
readonly List tabIcons = 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;
if (h != null) h.HideOnResult = true;
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.PopupFill = PopupFill;
wm.CostOf = si => EconomyRules.StageCost(Session.Db, Session.Save, b.Config.stages[si]);
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;
}
// 캔버스 층(PD 2026-11-10 · 프로젝트 CLAUDE.md 「UI 캔버스·아틀라스 구조」):
// 본 화면 루트 = 바탕 · 하단 탭(Static) + 화면마다 중첩 캔버스(Event) · 스크롤 내용 · 호흡 등 = Frame 중첩
// 위 덮개 루트 = 재화 바 · 팝업 · 토스트 · 끌기 유령 각각 중첩 · 전투 결과 루트 = 결과 패널(Event)
mainCanvas = MakeCanvas("OutGameCanvas", 350, 0.45f, UiRate.Static);
resultCanvas = MakeCanvas("BattleResultCanvas", 200, 0.8f, UiRate.Event);
topCanvas = MakeCanvas("OutGameTopCanvas", 450, 0.4f, UiRate.Event);
TopRoot = (RectTransform)topCanvas.transform;
var bg = UiKit.Fill("Bg", mainCanvas.transform);
UiKit.Paint(bg, UiKit.Bg, false, true);
// 목록형 B(설계서 §2-2): 재화 바 뒤 상단 띠 · 화면이 자기 바탕(로비 키아트 · 영웅 상세 등급색)을 깔면 가려짐
if (UISkin.On) UiKit.Paint(UiKit.TopBand("BgTop", bg, 0f, TopBarH), UiKit.Col("band_top", UiKit.Bg), false);
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);
if (s != null) s.HoldResultTap = p => result.PointerOnRetry(p);
mainCanvas.gameObject.SetActive(false);
}
T Add(T scr) where T : OutGameScreen
{
scr.Root = this;
scr.Rt = UiKit.Fill(typeof(T).Name, mainCanvas.transform);
UiLayer.Nest(scr.Rt.gameObject, UiRate.Event, true); // 화면 1장 = 자기 캔버스(값 갱신이 바탕·탭·다른 화면을 다시 묶지 않게)
scr.Build();
scr.Rt.gameObject.SetActive(false);
screens.Add(scr);
return scr;
}
Canvas MakeCanvas(string name, int order, float plane, UiRate rate)
{
var go = new GameObject(name);
go.transform.SetParent(transform, false);
return UiLayer.Root(go, Cam, order, plane, Reference, rate); // Screen Space - Camera = 카메라 캡처(검증)에도 찍힘
}
// ---------- 화면 전환 ----------
/// 본영 로비(게임 시작 화면) — 월드맵·전투 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);
// 글자 = 선택 안 된 탭도 항상(PD 2026-11-10 「선택되지 않았을 때에도 글자가 보이게」) · 선택 = 흰 큰 글자 · 아니면 흐린 작은 글자(동맹 = 잠김 색 그대로)
var t = tabTexts[i];
t.gameObject.SetActive(true);
t.color = on ? UiKit.White : tabOffText;
t.fontSize = on ? TabLabelOn : TabLabelOff;
t.rectTransform.anchoredPosition = new Vector2(0f, on ? TabLabelYOn : TabLabelYOff);
var ic = i < tabIcons.Count ? tabIcons[i] : null;
if (ic != null)
{
ic.sizeDelta = Vector2.one * (on ? TabIconOn : TabIconOff);
ic.anchoredPosition = new Vector2(0f, on ? TabIconYOn : TabIconYOff);
}
}
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);
UiLayer.Nest(barRoot.gameObject, UiRate.Event, true); // 군량 타이머 = 1초마다 · 팝업·토스트와 따로
// 테마 = 띠 없이 칩만(화면 바탕이 비침 · 목업 hero_detail) · 투명 판은 아래 클릭 막기용
UiKit.Paint(barRoot, UISkin.On ? Color.clear : new Color(0.15f, 0.11f, 0.09f), false, true);
goldText = Pill(0, "cur_gold", "plus", "골", "골드", UiKit.Coin, () => Toast("골드 = 스테이지 보상 · 거래 > 상점 · (구매 상점 준비 중)"));
gemText = Pill(1, "cur_gem", "plus_gem", "보", "보옥", UiKit.Jade, () => Toast("보옥 구매(과금) = 준비 중 · 첫 클리어 · 보스 보상"));
staminaText = Pill(2, "cur_stamina", "plus_stamina", "군", "군량", UiKit.Grain, StaminaModal);
staminaTimer = staminaText.transform.parent.Find("Name").GetComponent();
}
/// 재화 칩(목업 = 칩 280×58 · 아이콘 84 가 칩 왼쪽 밖으로 · 값 38 · 재화별 「+」 62×64 · x = 40 + i × 350).
Text Pill(int i, string icon, string plusSkin, string mark, string name, Color c, Action plus)
{
if (UISkin.Has("pill") && UISkin.Has("plus"))
{
float x = 40f + i * 350f;
var p = UiKit.Rect("Pill" + i, barRoot, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(x + 30f, -34f), new Vector2(280f, 58f));
UiKit.Skin(p, "pill", UiKit.Col("chrome_pill", UiKit.BarColor));
var ic = UiKit.IconOrDot(p, icon, mark, c, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(-36f, 14f), 84f);
var sh = ic.gameObject.AddComponent();
sh.effectColor = UiKit.Col("icon_shadow", Color.black);
sh.effectDistance = new Vector2(0f, -3f);
var n = UiKit.Label(p, name, 22, UISkin.Col("bar_text_off", UiKit.White), TextAnchor.MiddleLeft, new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(54f, 0f), new Vector2(140f, 30f), false);
n.gameObject.name = "Name";
n.text = "";
var v = UiKit.Title(UiKit.Label(p, "", 38, UISkin.Col("bar_text", UiKit.White), TextAnchor.MiddleRight, new Vector2(0f, 1f), new Vector2(1f, 0.5f), new Vector2(214f, -29f), new Vector2(170f, 60f)), true);
v.horizontalOverflow = HorizontalWrapMode.Overflow;
var pb = UiKit.Rect("Plus", p, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(232f, 4f), new Vector2(62f, 64f));
UiKit.Skin(pb, UISkin.Has(plusSkin) ? plusSkin : "plus", Color.white, true);
pb.gameObject.AddComponent().onClick.AddListener(() => plus());
return v;
}
return PillOld(i, icon, mark, name, c, plus);
}
/// 테마 없을 때(도형) 재화 칩.
Text PillOld(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().onClick.AddListener(() => plus());
}
else UiKit.Btn("Plus", p, "+", 40, new Color(0.42f, 0.35f, 0.31f), UiKit.White, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), new Vector2(-8f, 0f), new Vector2(56f, 56f), plus);
return v;
}
public void RefreshBar()
{
if (Session == null || goldText == null) return;
var d = Session.Save;
goldText.text = d.gold.ToString("N0");
gemText.text = d.gem.ToString("N0");
staminaText.text = d.stamina + "/" + EconomyRules.StaminaMax(Session.Db, d);
int left = Session.StaminaSecondsToNext();
staminaTimer.text = left > 0 ? (left / 60).ToString("00") + ":" + (left % 60).ToString("00") : UISkin.Icon("cur_stamina") != null ? "" : "군량";
}
// ---------- 하단 탭 ----------
// 탭 배치(px · 탭 바 아래 기준) — 글자를 항상 보이게 바꾸며(PD 2026-11-10) 아이콘을 글자 위로 올림 · 선택 탭 = 솟음 판(168) 위로 크게
const float TabIconOff = 96f, TabIconOn = 118f, TabIconYOff = 46f, TabIconYOn = 52f;
const int TabLabelOff = 30, TabLabelOn = 34;
const float TabLabelYOff = 24f, TabLabelYOn = 30f;
Color tabOffText = Color.gray;
/// 글자가 켜진 하단 탭 수(테스트 · PD 2026-11-10 「선택되지 않은 탭도 글자」).
public int VisibleTabLabels
{
get
{
int n = 0;
foreach (var t in tabTexts)
if (t != null && t.gameObject.activeSelf) n++;
return n;
}
}
void BuildTabBar()
{
var bar = UiKit.Rect("TabBar", mainCanvas.transform, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), Vector2.zero, new Vector2(Reference.x, TabBarH));
bar.anchorMin = new Vector2(0f, 0f);
bar.anchorMax = new Vector2(1f, 0f);
bar.offsetMin = new Vector2(0f, 0f);
bar.offsetMax = new Vector2(0f, TabBarH);
if (UISkin.Has("tabbar")) UiKit.Skin(bar, "tabbar", UiKit.Col("chrome_tab", UiKit.BarColor), true);
else UiKit.Paint(bar, UiKit.Dark, false, true);
string[] names = { "영웅", "보물", "내정", "동맹", "거래" };
string[] marks = { "영", "보", "내", "잠", "거" };
string[] icons = { "tab_hero", "tab_treasure", "tab_domestic", "tab_alliance", "tab_trade" };
Color off = UISkin.Col("bar_text_off", new Color(0.62f, 0.58f, 0.55f));
tabOffText = off;
for (int i = 0; i < names.Length; i++)
{
int k = i;
var cell = UiKit.Rect("Tab" + i, bar, new Vector2(0f, 0f), new Vector2(0f, 0f), Vector2.zero, Vector2.zero);
cell.anchorMin = new Vector2(i / 5f, 0f);
cell.anchorMax = new Vector2((i + 1) / 5f, 1f);
cell.offsetMin = cell.offsetMax = Vector2.zero;
var hit = cell.gameObject.AddComponent();
hit.color = new Color(0f, 0f, 0f, 0f);
var btn = cell.gameObject.AddComponent();
btn.onClick.AddListener(() => OnTab(k));
Image img;
if (UISkin.On)
{
// 선택 탭 = 팩 TabFocus 솟음 판 204×168(붉은 옻칠 + 광택) · 아이콘 104(선택 118 · 위로) · 라벨 = 선택 탭만(목업 hero_detail)
var focus = UiKit.Rect("Focus", cell, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), Vector2.zero, new Vector2(204f, 168f));
img = UiKit.Skin(focus, "tab_focus", UiKit.Col("tab_focus", UiKit.Red));
if (UISkin.Has("tab_focus_light"))
UiKit.Skin(UiKit.Rect("Light", focus, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -6f), new Vector2(188f, 70f)), "tab_focus_light", UiKit.Col("tab_focus_light", UiKit.White));
var ic = UiKit.Icon(cell, icons[i], new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, TabIconYOff), TabIconOff);
if (ic != null)
{
if (i == 3) ic.color = UiKit.Col("tab_locked", off);
var sh = ic.gameObject.AddComponent();
sh.effectColor = UiKit.Col("icon_shadow", Color.black);
sh.effectDistance = new Vector2(0f, -4f);
}
else UiKit.FillLabel(UiKit.Dot("Icon", cell, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -18f), 66f, UISkin.Col("tab_off", UiKit.Dark)).transform, marks[i], 32, UiKit.White);
tabIcons.Add(ic != null ? ic.rectTransform : null);
}
else
{
img = hit;
img.color = UiKit.Dark;
var dot = UiKit.Dot("Icon", cell, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -18f), 66f, i == 3 ? new Color(0.35f, 0.3f, 0.28f) : new Color(0.38f, 0.31f, 0.28f));
UiKit.FillLabel(dot.transform, marks[i], 32, i == 3 ? new Color(0.7f, 0.66f, 0.62f) : UiKit.White);
}
var t = UISkin.On
? UiKit.Label(cell, names[i], TabLabelOff, off, TextAnchor.MiddleCenter, new Vector2(0.5f, 0f), new Vector2(0.5f, 0.5f), new Vector2(0f, TabLabelYOff), new Vector2(204f, 50f))
: UiKit.Label(cell, names[i], 32, i == 3 ? off : UiKit.White, TextAnchor.MiddleCenter, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 14f), new Vector2(200f, 48f));
if (UISkin.On) UiKit.Title(t, true);
tabImages.Add(img);
tabTexts.Add(t);
}
}
public void OnTab(int i)
{
// 선택된 탭을 다시 누르면 닫기 = 로비(PD 2026-11-10 「해당 메뉴가 선택된 상태일 경우 X(닫기 버튼)처럼 동작」)
if (current != null && current != Lobby && current.Tab == i)
{
ShowLobby();
return;
}
switch (i)
{
case 0: Show(HeroList); break;
case 1: Show(Treasures); break;
case 2: Show(Domestic); break;
case 3: ComingSoon("동맹", true); break;
case 4: Show(Trade); break;
}
}
// ---------- 팝업 · 알림 ----------
public struct ModalButton
{
public string label;
public Color color;
public Action action;
public bool keepOpen;
public ModalButton(string label, Color color, Action action, bool keepOpen = false)
{
this.label = label;
this.color = color;
this.action = action;
this.keepOpen = keepOpen;
}
}
public void Modal(string title, string body, params ModalButton[] buttons)
{
var content = ModalCustom(title, 0f, buttons);
var t = UiKit.FillLabel(content, body, 36, UiKit.Ink, TextAnchor.MiddleCenter, false, 10f);
t.lineSpacing = 1.1f;
var rt = (RectTransform)content.parent;
float h = Mathf.Clamp(t.preferredHeight + 330f, 480f, 1500f);
rt.sizeDelta = new Vector2(rt.sizeDelta.x, h);
}
/// 제목 + 내용 칸 + 아래 버튼 줄 · 반환 = 내용 칸(높이 = height · 0 이면 Modal 이 맞춤).
public RectTransform ModalCustom(string title, float height, params ModalButton[] buttons)
{
CloseModal();
var dim = UiKit.Fill("Modal", TopRoot);
UiLayer.Nest(dim.gameObject, UiRate.Event, true);
var dimImg = UiKit.Paint(dim, UISkin.Col("dim", new Color(0f, 0f, 0f, 0.6f)), false, true);
dimImg.raycastTarget = true;
modal = dim.gameObject;
float h = height > 0f ? height : 600f;
Image panel;
RectTransform head;
if (UISkin.Has("popup"))
{
// 팩 팝업: 흰 틀 + 위 제목 띠(진남 틴트 · 광택) + 닫기 X
panel = UiKit.Skin(UiKit.Rect("Panel", dim, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 20f), new Vector2(940f, h)), "popup", UiKit.Paper, true);
head = UiKit.TopBand("Head", panel.transform, 6f, 110f, 7f, 7f);
UiKit.Skin(head, "popup_head", UiKit.Col("popup_band", UiKit.Header)); // 팝업 제목 띠 통일(검수 결함 6)
var tt = UiKit.Title(UiKit.FillLabel(head, title, 44, UiKit.HeaderText, TextAnchor.MiddleCenter, true, 110f), true);
tt.resizeTextForBestFit = true;
tt.resizeTextMinSize = 28;
tt.resizeTextMaxSize = 44;
var x = UiKit.Rect("Close", panel.transform, new Vector2(1f, 1f), new Vector2(0.5f, 0.5f), new Vector2(-58f, -60f), new Vector2(86f, 88f));
UiKit.Skin(x, "close", Color.white, true);
x.gameObject.AddComponent().onClick.AddListener(CloseModal);
}
else
{
panel = UiKit.Box("Panel", dim, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 20f), new Vector2(940f, h), UiKit.Paper);
panel.raycastTarget = true;
UiKit.Border(panel, UiKit.Line, 4f);
head = UiKit.TopBand("Head", panel.transform, 0f, 110f);
UiKit.Paint(head, UiKit.Dark);
UiKit.FillLabel(head, title, 44, UiKit.White);
}
var content = UiKit.Fill("Content", panel.transform, 40f, 40f, 130f, 160f);
int n = Mathf.Max(1, buttons.Length);
float bw = Mathf.Min(400f, (860f - (n - 1) * 20f) / n);
for (int i = 0; i < buttons.Length; i++)
{
var mb = buttons[i];
float x = (i - (n - 1) / 2f) * (bw + 20f);
UiKit.Btn("B" + i, panel.transform, mb.label, 34, mb.color, UiKit.White, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(x, 36f), new Vector2(bw, 100f), () =>
{
if (!mb.keepOpen) CloseModal();
if (mb.action != null) mb.action();
});
}
return content;
}
public void CloseModal()
{
if (modal == null) return;
modal.SetActive(false);
Destroy(modal);
modal = null;
}
public void Toast(string msg)
{
if (toast == null)
{
var img = UISkin.Has("toast")
? UiKit.Skin(UiKit.Rect("Toast", TopRoot, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, TabBarH + 40f), new Vector2(940f, 110f)), "toast", UiKit.BarColor)
: UiKit.Box("Toast", TopRoot, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, TabBarH + 40f), new Vector2(940f, 110f), new Color(0.1f, 0.08f, 0.07f, 0.92f));
toast = img.gameObject;
UiLayer.Nest(toast, UiRate.Event, false);
toastText = UiKit.FillLabel(img.transform, "", 32, UiKit.White, TextAnchor.MiddleCenter, false, 16f);
}
toast.transform.SetAsLastSibling();
toastText.text = msg;
toast.SetActive(true);
toastUntil = Time.unscaledTime + 2.2f;
}
public string LastToast
{
get { return toast != null && toast.activeSelf ? toastText.text : ""; }
}
// ---------- 군량 ----------
/// 군량 충전(보옥 구매 · 광고 스텁) 팝업.
public void StaminaModal()
{
StaminaModal(null);
}
void StaminaModal(string why, bool good = false)
{
var e = Session.Db.Economy;
var d = Session.Save;
EconomyRules.RollDaily(d, Session.Now);
if (UISkin.Has("popup") && UISkin.Has("panel_field"))
{
StaminaPopup(why, good);
return;
}
string body = (why != null ? why + "\n\n" : "") +
"군량 " + d.stamina + " / " + EconomyRules.StaminaMax(Session.Db, d) + " · " + (EconomyRules.RegenSec(Session.Db, d) / 60f).ToString("0.#") + "분마다 1 회복(오프라인 포함)\n" +
"스테이지 " + e.StageCost + " · 보스 " + e.BossCost + " 소모";
Modal("군량 충전", body,
new ModalButton("보옥 " + e.GemStaminaCost + " → +" + e.GemStaminaAmount + "\n(오늘 " + d.adsUsed.gemStamina + "/" + e.GemStaminaDaily + ")", UiKit.Jade, () =>
{
var w = Session.BuyStamina();
StaminaModal(w ?? "군량 +" + e.GemStaminaAmount);
if (Map != null) Map.RefreshPopup();
}, true),
new ModalButton("광고(시험) +" + e.AdStamina + "\n(오늘 " + d.adsUsed.stamina + "/" + e.AdStaminaDaily + ")", UiKit.Grain, () =>
{
var w = Session.AdStamina();
StaminaModal(w ?? "군량 +" + e.AdStamina + " (광고 SDK 미연동 · 시험 지급)");
if (Map != null) Map.RefreshPopup();
}, true),
new ModalButton("닫기", UiKit.Dark, null));
}
///
/// 군량 충전 팝업(Fable 제안서 §4-3 · #1104) — 팝업 안 필드(panel_field 인셋 20) · 현재 군량 블록 · 교환 행 2(보옥 → 군량 · 광고 → 군량)
/// = 비용 아이콘 + 숫자 → 화살표 → 군량 아이콘 + 「+n」 · 행 오른쪽 Secondary 버튼(하늘 · 녹) · 닫기 = 제목 띠 X 만.
///
void StaminaPopup(string why, bool good)
{
var e = Session.Db.Economy;
var d = Session.Save;
const float H = 760f;
var content = ModalCustom("군량 충전", H);
var panel = content.parent;
content.gameObject.SetActive(false);
var field = UiKit.Layer(panel, "Field", "panel_field", UiKit.Col("panel_field", UiKit.Dark), 20f, 126f, 20f, 20f).rectTransform;
float w = 940f - 40f;
// 현재 군량(아이콘 96 · 값 56 Sen · 회복 · 소모)
var ic = UiKit.Icon(field, "cur_stamina", new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(92f, -90f), 104f);
UiKit.DropShadow(ic, 5f);
var now = UiKit.Title(UiKit.Label(field, d.stamina + " / " + EconomyRules.StaminaMax(Session.Db, d) + " ", 60, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(170f, -26f), new Vector2(400f, 70f)), true);
now.horizontalOverflow = HorizontalWrapMode.Overflow;
int left = Session.StaminaSecondsToNext();
string regen = (EconomyRules.RegenSec(Session.Db, d) / 60f).ToString("0.#") + "분마다 1 회복" + (left > 0 ? " · 다음 " + (left / 60).ToString("00") + ":" + (left % 60).ToString("00") : " · 가득");
UiKit.Label(field, regen, 28, UiKit.Col("text_soft", UiKit.InkSoft), TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(172f, -100f), new Vector2(w - 190f, 40f), false);
UiKit.InfoChip(field, "Cost", w - 24f - 300f, 34f, 300f, 52f, null, "스테이지 " + e.StageCost + " · 보스 " + e.BossCost, 26);
// 안내 줄(구매 결과 · 부족)
if (!string.IsNullOrEmpty(why))
{
var wt = UiKit.Title(UiKit.Label(field, why, 30, good ? UiKit.Col("ok", UiKit.Ok) : UiKit.Col("alert_red", UiKit.Red), TextAnchor.MiddleCenter, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(24f, -160f), new Vector2(w - 48f, 50f)), true);
wt.horizontalOverflow = HorizontalWrapMode.Overflow;
}
UiKit.SubTitle(field, "충전", 24f, 222f, w - 48f);
ExchangeRow(field, 0, 286f, w, "cur_gem", e.GemStaminaCost.ToString("N0"), e.GemStaminaAmount, "오늘 " + d.adsUsed.gemStamina + " / " + e.GemStaminaDaily,
d.adsUsed.gemStamina < e.GemStaminaDaily, "btn_sky", "구매", () =>
{
var r = Session.BuyStamina();
StaminaModal(r ?? "군량 +" + e.GemStaminaAmount, r == null);
if (Map != null) Map.RefreshPopup();
});
ExchangeRow(field, 1, 286f + 160f, w, "ad", "광고", e.AdStamina, "오늘 " + d.adsUsed.stamina + " / " + e.AdStaminaDaily,
d.adsUsed.stamina < e.AdStaminaDaily, UISkin.Has("btn_green") ? "btn_green" : "btn_s_green", "보기", () =>
{
var r = Session.AdStamina();
StaminaModal(r ?? "군량 +" + e.AdStamina, r == null);
if (Map != null) Map.RefreshPopup();
});
}
/// 교환 행(필드 위 밝은 판 h140) — 왼쪽 [비용 아이콘 + 값] → [군량 + n] · 아래 오늘 횟수 · 오른쪽 Secondary 버튼(다 쓰면 회색).
void ExchangeRow(Transform field, int i, float y, float w, string costIcon, string cost, int gain, string note, bool can, string skin, string label, Action onClick)
{
const float Pad = 24f, Rh = 140f;
var row = UiKit.Skin(UiKit.At(field, "Row" + i, Pad, y, w - Pad * 2f, Rh), "panel12", UiKit.Col("row_on_field", UiKit.Paper)).transform;
var a = UiKit.Icon(row, costIcon, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(60f, -52f), costIcon == "ad" ? 44f : 64f);
if (a != null) UiKit.DropShadow(a, 3f);
var at = UiKit.Title(UiKit.Label(row, cost, 40, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(100f, -22f), new Vector2(140f, 60f)), true);
at.horizontalOverflow = HorizontalWrapMode.Overflow;
float ax = 108f + Mathf.Ceil(at.preferredWidth) + 22f;
var arrow = UiKit.Icon(row, "arrow_next", new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(ax + 12f, -52f), 40f);
if (arrow != null) arrow.color = UiKit.Col("text_soft", UiKit.InkSoft);
var b = UiKit.Icon(row, "cur_stamina", new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(ax + 72f, -52f), 64f);
if (b != null) UiKit.DropShadow(b, 3f);
var bt = UiKit.Title(UiKit.Label(row, "+" + gain, 40, UiKit.Col("ok", UiKit.Ok), TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(ax + 112f, -22f), new Vector2(140f, 60f)), true);
bt.horizontalOverflow = HorizontalWrapMode.Overflow;
UiKit.Label(row, note, 24, UiKit.Col("text_soft", UiKit.InkSoft), TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(30f, -96f), new Vector2(w - 380f, 36f), false);
var btn = UiKit.SkinBtn(row, "Btn", can || !UISkin.Has("btn_gray") ? skin : "btn_gray", UiKit.White, label, 38, UiKit.White, w - Pad * 2f - 24f - 250f, 20f, 250f, 100f, onClick);
if (skin.Contains("green") && can) UiKit.BtnCost(btn, "ad", "", 36f, 24);
}
// ---------- 월드맵 연결 ----------
string PopupInfo(int si)
{
var s = Battle.Config.stages[si];
var db = Session.Db;
var d = Session.Save;
var e = db.Economy;
var row = db.Rewards.For(s.chapter);
if (row == null) return "";
var st = d.Stage(s.chapter, s.stage, false);
bool first = st == null || st.clears == 0;
int cost = EconomyRules.StageCost(db, d, s);
float baseGold = row.goldBase + s.stage * e.GoldPerStage;
int gold = Mathf.RoundToInt(baseGold * (first ? e.FirstClearGoldMult : e.RepeatRewardMult));
int exp = Mathf.RoundToInt(row.expBase * (first ? 1f : e.RepeatRewardMult));
bool boss = s.kind == StageKind.Boss;
var sb = new System.Text.StringBuilder();
sb.Append("군량 ").Append(cost).Append(" 소모 (보유 ").Append(d.stamina).Append("/").Append(EconomyRules.StaminaMax(db, d)).Append(")");
sb.Append("\n보상 골드 ").Append(gold).Append(first ? "(첫 클리어)" : "(반복 ×" + e.RepeatRewardMult + ")").Append(" · 경험 ").Append(exp).Append("(출전 전원)");
if (first) sb.Append(" · 보옥 ").Append(row.gemFirst + (boss ? row.gemBoss : 0));
float drop = boss ? e.BossGearDropPct : row.gearDropPct;
sb.Append("\n장비 드랍 ").Append(Mathf.RoundToInt(drop * 100f)).Append("% (").Append(UiKit.GradeKo(row.gearGradeMax)).Append(" 등급 이하)");
int stars = st != null ? st.stars : 0;
sb.Append("\n별 ").Append(UiKit.Stars(stars, 3)).Append(" 클리어 · 전원 생존 · ").Append(row.starTurns).Append("턴 이내");
if (stars >= 3) sb.Append("\n별 3 = 소탕 가능(군량만 · 전투 없음) ");
return sb.ToString();
}
///
/// 월드맵 스테이지 팝업 본문(Fable 제안서 §4-3 · #1104 · 필드 안 860 폭) — 제목 · 장소 → 「적 구성」 병종 원 아이콘 ×n + 장수 칩 →
/// 「보상」 타일(골드 · 경험 · 보옥(첫 클리어) · 장비 드랍 % · 첫 클리어 뱃지) → 「별 조건」 행 3(별 아이콘).
///
void PopupFill(int si, bool locked, RectTransform field)
{
var s = Battle.Config.stages[si];
var db = Session.Db;
var d = Session.Save;
var e = db.Economy;
var row = db.Rewards.For(s.chapter);
const float W = 860f, Pad = 24f, Tile = 132f;
var soft = UiKit.Col("text_soft", UiKit.InkSoft);
var head = UiKit.Title(UiKit.Label(field, s.title, 38, UiKit.White, TextAnchor.MiddleCenter, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(Pad, -18f), new Vector2(W - Pad * 2f, 56f)), true);
head.horizontalOverflow = HorizontalWrapMode.Overflow;
if (!string.IsNullOrEmpty(s.place))
UiKit.Label(field, s.place + (string.IsNullOrEmpty(s.bgTheme) ? "" : " · " + s.bgTheme), 26, soft, TextAnchor.MiddleCenter, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(Pad, -76f), new Vector2(W - Pad * 2f, 34f), false);
// 적 구성 = 병종 원(type 색 Circle78 68 + 병종 아이콘 40) + 「×n」 · 가운데 정렬
float y = 124f;
UiKit.SubTitle(field, "적 구성", Pad, y, W - Pad * 2f);
var counts = new int[UnitTypeInfo.Count];
var lead = new List();
foreach (var en in s.enemies)
{
counts[(int)en.type]++;
if (en.isBoss || en.isGeneral) lead.Add((en.isBoss ? "보스 " : "") + en.name);
}
var kinds = new List();
for (int t = 0; t < counts.Length; t++)
if (counts[t] > 0) kinds.Add(t);
const float Ew = 172f;
float ex = (W - kinds.Count * Ew) * 0.5f;
for (int k = 0; k < kinds.Count; k++)
{
var ut = (UnitType)kinds[k];
float cx = ex + k * Ew + 44f;
var circ = UiKit.Rect("Type" + k, field, new Vector2(0f, 1f), new Vector2(0.5f, 0.5f), new Vector2(cx, -y - 94f), new Vector2(80f, 80f));
var ti = UiKit.Icon(circ, UiKit.TypeSymbolId(ut), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, 76f); // 병과 = 단색 심볼만(배경 틀 없음 · PD 2026-11-09)
var n = UiKit.Title(UiKit.Label(field, "x" + counts[kinds[k]], 36, UiKit.White, TextAnchor.MiddleLeft, new Vector2(0f, 1f), new Vector2(0f, 0.5f), new Vector2(cx + 50f, -y - 94f), new Vector2(100f, 50f)), true);
n.horizontalOverflow = HorizontalWrapMode.Overflow;
}
y += 128f;
if (lead.Count > 0)
{
string names = string.Join(" · ", lead.ToArray());
float cw = Mathf.Min(W - Pad * 2f, 120f + names.Length * 26f);
UiKit.InfoChip(field, "Lead", (W - cw) * 0.5f, y, cw, 48f, "badge_hot", names, 26);
y += 64f;
}
// 보상 타일
UiKit.SubTitle(field, "보상", Pad, y, W - Pad * 2f);
y += 56f;
var st = d.Stage(s.chapter, s.stage, false);
bool first = st == null || st.clears == 0;
bool boss = s.kind == StageKind.Boss;
if (row != null)
{
int gold = Mathf.RoundToInt((row.goldBase + s.stage * e.GoldPerStage) * (first ? e.FirstClearGoldMult : e.RepeatRewardMult));
int exp = Mathf.RoundToInt(row.expBase * (first ? 1f : e.RepeatRewardMult));
float drop = boss ? e.BossGearDropPct : row.gearDropPct;
var tiles = new List(); // 아이콘 · 수량 · 칸 색 · 이름 · 첫 뱃지
tiles.Add(new[] { "cur_gold", gold.ToString("N0"), "gear_white_", first ? "골드" : "골드 ×" + e.RepeatRewardMult, first ? "1" : "" });
tiles.Add(new[] { "cur_exp", exp.ToString("N0"), "gear_white_", "경험 · 전원", "" });
if (first) tiles.Add(new[] { "cur_gem", (row.gemFirst + (boss ? row.gemBoss : 0)).ToString(), "gear_blue_", "보옥", "1" });
string gg = string.IsNullOrEmpty(row.gearGradeMax) ? "white" : row.gearGradeMax;
tiles.Add(new[] { "cur_gear", Mathf.RoundToInt(drop * 100f) + "%", "gear_" + gg + "_", "장비 ~" + UiKit.GradeKo(gg), "" });
const float Gap = 30f;
float tx0 = (W - tiles.Count * Tile - (tiles.Count - 1) * Gap) * 0.5f;
for (int i = 0; i < tiles.Count; i++)
{
var tl = tiles[i];
float tx = tx0 + i * (Tile + Gap);
var cell = UiKit.RewardTile(field, "Tile" + i, tx, y, Tile, tl[2], tl[0], tl[1], tl[2] != "gear_white_");
if (tl[4] == "1")
{
var b = UiKit.Icon(cell, "badge_first", new Vector2(1f, 1f), new Vector2(0.5f, 0.5f), new Vector2(-8f, -6f), 58f);
if (b != null) UiKit.Title(UiKit.FillLabel(b.transform, "첫", 24, UiKit.White), true);
}
var nm = UiKit.Label(field, tl[3], 24, soft, TextAnchor.UpperCenter, new Vector2(0f, 1f), new Vector2(0.5f, 1f), new Vector2(tx + Tile * 0.5f, -y - Tile - 10f), new Vector2(Tile + Gap, 34f), false);
nm.horizontalOverflow = HorizontalWrapMode.Overflow;
}
}
y += Tile + 64f;
// 별 조건 3행(별 아이콘 = 지금까지 받은 별 수만큼 켜짐)
UiKit.SubTitle(field, "별 조건", Pad, y, W - Pad * 2f);
y += 56f;
int stars = st != null ? st.stars : 0;
string[] cond = { "스테이지 클리어", "아군 전원 생존", (row != null ? row.starTurns : 0) + "턴 이내 클리어" };
for (int i = 0; i < 3; i++)
UiKit.CondRow(field, "Star" + i, Pad, y + i * 64f, W - Pad * 2f, 58f, i < stars ? "star_on" : "star_off", i < stars, cond[i], 28, false, "row_on_field");
y += 3 * 64f;
if (locked)
UiKit.Label(field, "앞 스테이지를 먼저 깨세요", 28, UiKit.Col("alert_red", UiKit.Red), TextAnchor.MiddleCenter, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(Pad, -y), new Vector2(W - Pad * 2f, 40f));
else if (stars >= 3)
UiKit.Label(field, "별 3 = 소탕 가능(군량만 · 전투 없음)", 26, UiKit.Col("ok", UiKit.Ok), TextAnchor.MiddleCenter, new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(Pad, -y), new Vector2(W - Pad * 2f, 40f), false);
}
bool TryEnter(int si)
{
if (Session.TryPayEntry(Battle.Config.stages[si])) return true;
StaminaModal("군량이 부족합니다 — 필요 " + EconomyRules.StageCost(Session.Db, Session.Save, Battle.Config.stages[si]));
return false;
}
void DoSweep(int si)
{
var s = Battle.Config.stages[si];
var r = Session.Sweep(s);
if (r == null)
{
StaminaModal("군량이 부족합니다 — 필요 " + EconomyRules.StageCost(Session.Db, Session.Save, s));
return;
}
Modal("소탕 " + s.chapter + "-" + s.stage, BattleResultPanel.RewardText(Session, r), new ModalButton("확인", UiKit.Dark, null));
if (Map != null) Map.RefreshPopup();
}
// ---------- 전투 결과 ----------
void OnPhase(BattlePhase p)
{
if (p != BattlePhase.StageClear && p != BattlePhase.Defeat) return;
if (resultDone) return;
resultDone = true;
if (Battle.Mode != null)
{
result.FillMode(Session.OnModeEnd(Battle, p == BattlePhase.StageClear));
return;
}
var r = Session.OnBattleEnd(Battle, p == BattlePhase.StageClear);
result.Fill(r);
}
/// 결과 「다시」(#1104) — 월드맵이 있으면 같은 노드 재출전(군량 확인) · 없으면 화면 탭과 같음.
public void RetryStage()
{
if (Battle == null || (Battle.Phase != BattlePhase.StageClear && Battle.Phase != BattlePhase.Defeat)) return;
if (Map != null && Map.HasData && Battle.Mode == null) Map.RetryLast();
else Battle.ContinueAfterResult();
}
// ---------- 연무장 전투(PD #1093 C) ----------
/// 연무장 모드 전투 시작 — 아웃게임 화면을 닫고 HUD·조작을 켠 뒤 BattleManager.StartMode · 결과 탭 = 연무장 returnPage 로.
public void StartMode(string kind, string id, int index, BalanceConfig.StageDef stage, int maxTurns, int returnPage)
{
if (stage == null)
{
Toast("적 편성 없음");
return;
}
Session.Mode = new OutGameSession.ModeCtx { kind = kind, id = id, index = index };
CloseModal();
if (Map != null && Map.IsVisible) Map.Hide();
mainCanvas.gameObject.SetActive(false);
if (hud != null) hud.gameObject.SetActive(true);
if (sling != null) sling.enabled = true;
Battle.StartMode(new BattleMode
{
kind = kind, stage = stage, maxTurns = maxTurns,
onExit = () =>
{
Show(Arena);
Arena.SetPage(returnPage);
},
});
}
}
}