ThreeKingdomsSlingshot/Assets/Scripts/OutGame/UI/OutGameRoot.cs

956 lines
52 KiB
C#
Raw Permalink 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;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace TKS.OutGame
{
/// <summary>아웃게임 화면 1장(본영 로비 · 영웅 · 편성 · 거래). OutGameRoot 가 만들고 전환한다.</summary>
public abstract class OutGameScreen
{
public OutGameRoot Root;
public RectTransform Rt;
/// <summary>하단 탭 강조(0 영웅 · 4 거래 · −1 없음).</summary>
public int Tab = -1;
public OutGameSession S
{
get { return Root.Session; }
}
public abstract void Build();
public virtual void Refresh() { }
public virtual void OnShow() { }
/// <summary>뒤로 가기를 화면 안에서 처리했으면 true.</summary>
public virtual bool OnBack() { return false; }
/// <summary>제목 띠(재화 바 아래 · 팩 PanelFrame03 진남 + 왼쪽 붙은 뒤로 · UI 상업용 재설계 공통 크롬) · back = 뒤로 버튼 · sub 빈 문자열 = 부제 없음.</summary>
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<Button>().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;
}
/// <summary>
/// 제목 띠 아래 ~ 하단 탭 위 본문 — 기준(1080×1920)의 본문 높이보다 화면이 낮으면(예 1536×2048 = 3:4) 본문 전체를 같은 비율로 줄여 탭 바 밑으로 숨지 않게 한다(PD #1100 · 배치는 그대로).
/// </summary>
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<UiFitHeight>().designHeight = OutGameRoot.BodyH;
return inner;
}
}
/// <summary>
/// 아웃게임 루트(2026-11-05 #1093 시스템 기획 §1 · §10 A) — 게임 시작 = 본영 로비 · 출정 → 기존 챕터 월드맵 · 편성 · 영웅 · 거래 · 준비 중 팝업 ·
/// 상단 재화 바(골드 · 보옥 · 군량 n/최대 + 회복 타이머 · 월드맵에도 표시) · 하단 탭 5 · 뒤로 가기(◀ · Esc) · 전투 결과 보상 패널.
/// 데이터 = OutGameSession(표 + 세이브) · 화면은 도형 + 글자 와이어프레임(목업 img03·06·08·11 배치).
/// </summary>
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);
/// <summary>기준 화면의 본문 높이(재화 바 · 제목 띠 · 하단 탭 제외).</summary>
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<OutGameScreen> screens = new List<OutGameScreen>();
OutGameScreen current;
RectTransform barRoot;
Text goldText;
Text gemText;
Text staminaText;
Text staminaTimer;
readonly List<Image> tabImages = new List<Image>();
readonly List<Text> tabTexts = new List<Text>();
readonly List<RectTransform> tabIcons = new List<RectTransform>();
GameObject modal;
GameObject toast;
Text toastText;
float toastUntil;
float nextTick;
BattleResultPanel result;
bool resultDone;
public OutGameScreen Current
{
get { return current; }
}
/// <summary>아웃게임 화면(로비 등)이 보이는가(월드맵·전투 중 = false).</summary>
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;
}
/// <summary>전투 이벤트 → 보물 조건 효과 카운터(저장은 전투 결과 때 · PD #1093 C).</summary>
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>(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 = 카메라 캡처(검증)에도 찍힘
}
// ---------- 화면 전환 ----------
/// <summary>본영 로비(게임 시작 화면) — 월드맵·전투 HUD·조작을 끄고 로비를 연다.</summary>
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);
}
/// <summary>출정 = 챕터 월드맵(도전 중인 마지막 열린 챕터).</summary>
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;
}
/// <summary>뒤로 가기(◀ · Esc): 팝업 닫기 → 화면 안 처리 → 영웅 상세 = 목록 → 나머지 = 로비 · 월드맵 = 로비.</summary>
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);
}
/// <summary>「준비 중」(연무장·보물·내정) · 「추후 업데이트」(동맹) 안내.</summary>
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<Text>();
}
/// <summary>재화 칩(목업 = 칩 280×58 · 아이콘 84 가 칩 왼쪽 밖으로 · 값 38 · 재화별 「+」 62×64 · x = 40 + i × 350).</summary>
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<Shadow>();
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<Button>().onClick.AddListener(() => plus());
return v;
}
return PillOld(i, icon, mark, name, c, plus);
}
/// <summary>테마 없을 때(도형) 재화 칩.</summary>
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<Button>().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;
/// <summary>글자가 켜진 하단 탭 수(테스트 · PD 2026-11-10 「선택되지 않은 탭도 글자」).</summary>
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<Image>();
hit.color = new Color(0f, 0f, 0f, 0f);
var btn = cell.gameObject.AddComponent<Button>();
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<Shadow>();
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);
}
/// <summary>제목 + 내용 칸 + 아래 버튼 줄 · 반환 = 내용 칸(높이 = height · 0 이면 Modal 이 맞춤).</summary>
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<Button>().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 : ""; }
}
// ---------- 군량 ----------
/// <summary>군량 충전(보옥 구매 · 광고 스텁) 팝업.</summary>
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));
}
/// <summary>
/// 군량 충전 팝업(Fable 제안서 §4-3 · #1104) — 팝업 안 필드(panel_field 인셋 20) · 현재 군량 블록 · 교환 행 2(보옥 → 군량 · 광고 → 군량)
/// = 비용 아이콘 + 숫자 → 화살표 → 군량 아이콘 + 「+n」 · 행 오른쪽 Secondary 버튼(하늘 · 녹) · 닫기 = 제목 띠 X 만.
/// </summary>
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 + " <size=40>/ " + EconomyRules.StaminaMax(Session.Db, d) + "</size>", 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();
});
}
/// <summary>교환 행(필드 위 밝은 판 h140) — 왼쪽 [비용 아이콘 + 값] → [군량 + n] · 아래 오늘 횟수 · 오른쪽 Secondary 버튼(다 쓰면 회색).</summary>
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("<color=#8a3a22>군량 ").Append(cost).Append(" 소모</color> (보유 ").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<color=#3a6a30>별 3 = 소탕 가능(군량만 · 전투 없음)</color>");
return sb.ToString();
}
/// <summary>
/// 월드맵 스테이지 팝업 본문(Fable 제안서 §4-3 · #1104 · 필드 안 860 폭) — 제목 · 장소 → 「적 구성」 병종 원 아이콘 ×n + 장수 칩 →
/// 「보상」 타일(골드 · 경험 · 보옥(첫 클리어) · 장비 드랍 % · 첫 클리어 뱃지) → 「별 조건」 행 3(별 아이콘).
/// </summary>
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<string>();
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<int>();
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<string[]>(); // 아이콘 · 수량 · 칸 색 · 이름 · 첫 뱃지
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);
}
/// <summary>결과 「다시」(#1104) — 월드맵이 있으면 같은 노드 재출전(군량 확인) · 없으면 화면 탭과 같음.</summary>
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) ----------
/// <summary>연무장 모드 전투 시작 — 아웃게임 화면을 닫고 HUD·조작을 켠 뒤 BattleManager.StartMode · 결과 탭 = 연무장 returnPage 로.</summary>
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);
},
});
}
}
}