956 lines
52 KiB
C#
956 lines
52 KiB
C#
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);
|
||
},
|
||
});
|
||
}
|
||
}
|
||
}
|