2026-11-05 12:11:10 +00:00
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 ; }
2026-11-09 02:11:20 +00:00
/// <summary>제목 띠(재화 바 아래 · 팩 PanelFrame03 진남 + 왼쪽 붙은 뒤로 · UI 상업용 재설계 공통 크롬) · back = 뒤로 버튼 · sub 빈 문자열 = 부제 없음.</summary>
protected RectTransform Header ( string title , string sub , bool back )
2026-11-05 12:11:10 +00:00
{
var band = UiKit . TopBand ( "Header" , Rt , OutGameRoot . TopBarH , OutGameRoot . HeaderH ) ;
2026-11-09 02:11:20 +00:00
if ( UISkin . Has ( "header_bar" ) ) UiKit . Skin ( band , "header_bar" , UiKit . Col ( "chrome_header" , UiKit . Header ) ) ;
else UiKit . Paint ( band , UiKit . Header , false ) ;
2026-11-05 12:11:10 +00:00
float x = 36f ;
2026-11-09 02:11:20 +00:00
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 )
2026-11-05 12:11:10 +00:00
{
2026-11-06 14:20:57 +00:00
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 = "" ;
2026-11-05 12:11:10 +00:00
x = 146f ;
}
2026-11-06 14:20:57 +00:00
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 ) ;
2026-11-05 12:11:10 +00:00
t . horizontalOverflow = HorizontalWrapMode . Overflow ;
2026-11-09 02:11:20 +00:00
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 ;
2026-11-05 12:11:10 +00:00
}
2026-11-06 14:20:57 +00:00
/// <summary>
/// 제목 띠 아래 ~ 하단 탭 위 본문 — 기준(1080× 1920)의 본문 높이보다 화면이 낮으면(예 1536× 2048 = 3:4) 본문 전체를 같은 비율로 줄여 탭 바 밑으로 숨지 않게 한다(PD #1100 · 배치는 그대로).
/// </summary>
2026-11-05 12:11:10 +00:00
protected RectTransform Body ( string name = "Body" )
{
2026-11-06 14:20:57 +00:00
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 ;
2026-11-05 12:11:10 +00:00
}
}
/// <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 ) ;
2026-11-06 14:20:57 +00:00
/// <summary>기준 화면의 본문 높이(재화 바 · 제목 띠 · 하단 탭 제외).</summary>
public static float BodyH
{
get { return Reference . y - TopBarH - HeaderH - TabBarH ; }
}
2026-11-05 12:11:10 +00:00
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 ; }
2026-11-05 15:22:54 +00:00
public DomesticScreen Domestic { get ; private set ; }
public TreasureScreen Treasures { get ; private set ; }
public ArenaScreen Arena { get ; private set ; }
2026-11-05 12:11:10 +00:00
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 > ( ) ;
2026-11-09 02:11:20 +00:00
readonly List < RectTransform > tabIcons = new List < RectTransform > ( ) ;
2026-11-05 12:11:10 +00:00
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 ;
2026-11-05 15:22:54 +00:00
BattleEvents . Raised - = OnBattleEvent ;
}
/// <summary>전투 이벤트 → 보물 조건 효과 카운터(저장은 전투 결과 때 · PD #1093 C).</summary>
void OnBattleEvent ( string key , int n )
{
if ( Session ! = null ) TreasureRules . AddCount ( Session . Save , key , n ) ;
2026-11-05 12:11:10 +00:00
}
public void Build ( BattleManager b , BattleHUD h , SlingshotInput s , WorldMapScreen wm , Camera cam )
{
Battle = b ;
hud = h ;
2026-11-09 09:19:06 +00:00
if ( h ! = null ) h . HideOnResult = true ;
2026-11-05 12:11:10 +00:00
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 ;
2026-11-05 15:22:54 +00:00
// 보물 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 ;
2026-11-05 12:11:10 +00:00
// 월드맵 연결: 팝업 줄 · 군량 · 별 · 소탕 · 본영
if ( wm ! = null )
{
wm . TopInset = TopBarH ;
wm . PopupInfo = PopupInfo ;
2026-11-09 09:03:43 +00:00
wm . PopupFill = PopupFill ;
wm . CostOf = si = > EconomyRules . StageCost ( Session . Db , Session . Save , b . Config . stages [ si ] ) ;
2026-11-05 12:11:10 +00:00
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 ;
}
2026-11-09 16:05:47 +00:00
// 캔버스 층(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 ) ;
2026-11-05 12:11:10 +00:00
TopRoot = ( RectTransform ) topCanvas . transform ;
var bg = UiKit . Fill ( "Bg" , mainCanvas . transform ) ;
UiKit . Paint ( bg , UiKit . Bg , false , true ) ;
2026-11-09 03:20:32 +00:00
// 목록형 B(설계서 §2-2): 재화 바 뒤 상단 띠 · 화면이 자기 바탕(로비 키아트 · 영웅 상세 등급색)을 깔면 가려짐
if ( UISkin . On ) UiKit . Paint ( UiKit . TopBand ( "BgTop" , bg , 0f , TopBarH ) , UiKit . Col ( "band_top" , UiKit . Bg ) , false ) ;
2026-11-05 12:11:10 +00:00
Lobby = Add ( new LobbyScreen ( ) ) ;
HeroList = Add ( new HeroListScreen ( ) ) ;
HeroDetail = Add ( new HeroDetailScreen ( ) ) ;
Formation = Add ( new FormationScreen ( ) ) ;
Trade = Add ( new TradeScreen ( ) ) ;
2026-11-05 15:22:54 +00:00
Domestic = Add ( new DomesticScreen ( ) ) ;
Treasures = Add ( new TreasureScreen ( ) ) ;
Arena = Add ( new ArenaScreen ( ) ) ;
2026-11-05 12:11:10 +00:00
BuildTabBar ( ) ;
BuildCurrencyBar ( ) ;
result = new BattleResultPanel ( this , ( RectTransform ) resultCanvas . transform ) ;
2026-11-09 09:03:43 +00:00
if ( s ! = null ) s . HoldResultTap = p = > result . PointerOnRetry ( p ) ;
2026-11-05 12:11:10 +00:00
mainCanvas . gameObject . SetActive ( false ) ;
}
T Add < T > ( T scr ) where T : OutGameScreen
{
scr . Root = this ;
scr . Rt = UiKit . Fill ( typeof ( T ) . Name , mainCanvas . transform ) ;
2026-11-09 16:05:47 +00:00
UiLayer . Nest ( scr . Rt . gameObject , UiRate . Event , true ) ; // 화면 1장 = 자기 캔버스(값 갱신이 바탕·탭·다른 화면을 다시 묶지 않게)
2026-11-05 12:11:10 +00:00
scr . Build ( ) ;
scr . Rt . gameObject . SetActive ( false ) ;
screens . Add ( scr ) ;
return scr ;
}
2026-11-09 16:05:47 +00:00
Canvas MakeCanvas ( string name , int order , float plane , UiRate rate )
2026-11-05 12:11:10 +00:00
{
var go = new GameObject ( name ) ;
go . transform . SetParent ( transform , false ) ;
2026-11-09 16:05:47 +00:00
return UiLayer . Root ( go , Cam , order , plane , Reference , rate ) ; // Screen Space - Camera = 카메라 캡처(검증)에도 찍힘
2026-11-05 12:11:10 +00:00
}
// ---------- 화면 전환 ----------
/// <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 ;
2026-11-09 02:11:20 +00:00
if ( UISkin . On )
{
tabImages [ i ] . gameObject . SetActive ( on ) ;
2026-11-10 04:50:57 +00:00
// 글자 = 선택 안 된 탭도 항상(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 ) ;
2026-11-09 02:11:20 +00:00
var ic = i < tabIcons . Count ? tabIcons [ i ] : null ;
if ( ic ! = null )
{
2026-11-10 04:50:57 +00:00
ic . sizeDelta = Vector2 . one * ( on ? TabIconOn : TabIconOff ) ;
ic . anchoredPosition = new Vector2 ( 0f , on ? TabIconYOn : TabIconYOff ) ;
2026-11-09 02:11:20 +00:00
}
}
2026-11-06 14:20:57 +00:00
else tabImages [ i ] . color = on ? UiKit . Red : UiKit . Dark ;
2026-11-05 12:11:10 +00:00
}
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 ) ;
2026-11-09 16:05:47 +00:00
UiLayer . Nest ( barRoot . gameObject , UiRate . Event , true ) ; // 군량 타이머 = 1초마다 · 팝업·토스트와 따로
2026-11-09 02:11:20 +00:00
// 테마 = 띠 없이 칩만(화면 바탕이 비침 · 목업 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 ) ;
2026-11-05 12:11:10 +00:00
staminaTimer = staminaText . transform . parent . Find ( "Name" ) . GetComponent < Text > ( ) ;
}
2026-11-09 02:11:20 +00:00
/// <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 )
2026-11-05 12:11:10 +00:00
{
float w = 336f ;
2026-11-06 14:20:57 +00:00
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 ) ;
2026-11-05 12:11:10 +00:00
n . gameObject . name = "Name" ;
2026-11-06 14:20:57 +00:00
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 ) ;
2026-11-05 12:11:10 +00:00
v . horizontalOverflow = HorizontalWrapMode . Overflow ;
2026-11-06 14:20:57 +00:00
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 ) ;
2026-11-05 12:11:10 +00:00
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" ) ;
2026-11-05 15:22:54 +00:00
staminaText . text = d . stamina + "/" + EconomyRules . StaminaMax ( Session . Db , d ) ;
2026-11-05 12:11:10 +00:00
int left = Session . StaminaSecondsToNext ( ) ;
2026-11-09 02:11:20 +00:00
staminaTimer . text = left > 0 ? ( left / 60 ) . ToString ( "00" ) + ":" + ( left % 60 ) . ToString ( "00" ) : UISkin . Icon ( "cur_stamina" ) ! = null ? "" : "군량" ;
2026-11-05 12:11:10 +00:00
}
// ---------- 하단 탭 ----------
2026-11-10 04:50:57 +00:00
// 탭 배치(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 ;
}
}
2026-11-05 12:11:10 +00:00
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 ) ;
2026-11-09 02:11:20 +00:00
if ( UISkin . Has ( "tabbar" ) ) UiKit . Skin ( bar , "tabbar" , UiKit . Col ( "chrome_tab" , UiKit . BarColor ) , true ) ;
else UiKit . Paint ( bar , UiKit . Dark , false , true ) ;
2026-11-05 12:11:10 +00:00
string [ ] names = { "영웅" , "보물" , "내정" , "동맹" , "거래" } ;
string [ ] marks = { "영" , "보" , "내" , "잠" , "거" } ;
2026-11-06 14:20:57 +00:00
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 ) ) ;
2026-11-10 04:50:57 +00:00
tabOffText = off ;
2026-11-05 12:11:10 +00:00
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 ;
2026-11-06 14:20:57 +00:00
var hit = cell . gameObject . AddComponent < Image > ( ) ;
hit . color = new Color ( 0f , 0f , 0f , 0f ) ;
2026-11-05 12:11:10 +00:00
var btn = cell . gameObject . AddComponent < Button > ( ) ;
btn . onClick . AddListener ( ( ) = > OnTab ( k ) ) ;
2026-11-06 14:20:57 +00:00
Image img ;
if ( UISkin . On )
{
2026-11-09 02:11:20 +00:00
// 선택 탭 = 팩 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 ) ) ;
2026-11-10 04:50:57 +00:00
var ic = UiKit . Icon ( cell , icons [ i ] , new Vector2 ( 0.5f , 0f ) , new Vector2 ( 0.5f , 0f ) , new Vector2 ( 0f , TabIconYOff ) , TabIconOff ) ;
2026-11-06 14:20:57 +00:00
if ( ic ! = null )
{
2026-11-09 02:11:20 +00:00
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 ) ;
2026-11-06 14:20:57 +00:00
}
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 ) ;
2026-11-09 02:11:20 +00:00
tabIcons . Add ( ic ! = null ? ic . rectTransform : null ) ;
2026-11-06 14:20:57 +00:00
}
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 ) ;
}
2026-11-09 02:11:20 +00:00
var t = UISkin . On
2026-11-10 04:50:57 +00:00
? 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 ) )
2026-11-09 02:11:20 +00:00
: 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 ) ;
2026-11-05 12:11:10 +00:00
tabImages . Add ( img ) ;
tabTexts . Add ( t ) ;
}
}
public void OnTab ( int i )
{
2026-11-10 04:50:57 +00:00
// 선택된 탭을 다시 누르면 닫기 = 로비(PD 2026-11-10 「해당 메뉴가 선택된 상태일 경우 X(닫기 버튼)처럼 동작」)
if ( current ! = null & & current ! = Lobby & & current . Tab = = i )
{
ShowLobby ( ) ;
return ;
}
2026-11-05 12:11:10 +00:00
switch ( i )
{
case 0 : Show ( HeroList ) ; break ;
2026-11-05 15:22:54 +00:00
case 1 : Show ( Treasures ) ; break ;
case 2 : Show ( Domestic ) ; break ;
2026-11-05 12:11:10 +00:00
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 ) ;
2026-11-09 16:05:47 +00:00
UiLayer . Nest ( dim . gameObject , UiRate . Event , true ) ;
2026-11-06 14:20:57 +00:00
var dimImg = UiKit . Paint ( dim , UISkin . Col ( "dim" , new Color ( 0f , 0f , 0f , 0.6f ) ) , false , true ) ;
2026-11-05 12:11:10 +00:00
dimImg . raycastTarget = true ;
modal = dim . gameObject ;
float h = height > 0f ? height : 600f ;
2026-11-06 14:20:57 +00:00
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 ) ;
2026-11-09 09:19:06 +00:00
UiKit . Skin ( head , "popup_head" , UiKit . Col ( "popup_band" , UiKit . Header ) ) ; // 팝업 제목 띠 통일(검수 결함 6)
2026-11-06 14:20:57 +00:00
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 ) ;
}
2026-11-05 12:11:10 +00:00
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 )
{
2026-11-06 14:20:57 +00:00
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 ) ) ;
2026-11-05 12:11:10 +00:00
toast = img . gameObject ;
2026-11-09 16:05:47 +00:00
UiLayer . Nest ( toast , UiRate . Event , false ) ;
2026-11-05 12:11:10 +00:00
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 ) ;
}
2026-11-09 09:03:43 +00:00
void StaminaModal ( string why , bool good = false )
2026-11-05 12:11:10 +00:00
{
var e = Session . Db . Economy ;
var d = Session . Save ;
EconomyRules . RollDaily ( d , Session . Now ) ;
2026-11-09 09:03:43 +00:00
if ( UISkin . Has ( "popup" ) & & UISkin . Has ( "panel_field" ) )
{
StaminaPopup ( why , good ) ;
return ;
}
2026-11-05 12:11:10 +00:00
string body = ( why ! = null ? why + "\n\n" : "" ) +
2026-11-05 15:22:54 +00:00
"군량 " + d . stamina + " / " + EconomyRules . StaminaMax ( Session . Db , d ) + " · " + ( EconomyRules . RegenSec ( Session . Db , d ) / 60f ) . ToString ( "0.#" ) + "분마다 1 회복(오프라인 포함)\n" +
2026-11-05 12:11:10 +00:00
"스테이지 " + 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 ) ) ;
}
2026-11-09 09:03:43 +00:00
/// <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 ( ) ;
} ) ;
2026-11-09 09:19:06 +00:00
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" , "보기" , ( ) = >
2026-11-09 09:03:43 +00:00
{
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 ) ;
2026-11-09 09:12:53 +00:00
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 ) ;
2026-11-09 09:19:06 +00:00
if ( skin . Contains ( "green" ) & & can ) UiKit . BtnCost ( btn , "ad" , "" , 36f , 24 ) ;
2026-11-09 09:03:43 +00:00
}
2026-11-05 12:11:10 +00:00
// ---------- 월드맵 연결 ----------
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 ;
2026-11-05 15:22:54 +00:00
int cost = EconomyRules . StageCost ( db , d , s ) ;
2026-11-05 12:11:10 +00:00
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 ( ) ;
2026-11-05 15:22:54 +00:00
sb . Append ( "<color=#8a3a22>군량 " ) . Append ( cost ) . Append ( " 소모</color> (보유 " ) . Append ( d . stamina ) . Append ( "/" ) . Append ( EconomyRules . StaminaMax ( db , d ) ) . Append ( ")" ) ;
2026-11-05 12:11:10 +00:00
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 ( ) ;
}
2026-11-09 09:03:43 +00:00
/// <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 ;
2026-11-09 10:59:56 +00:00
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)
2026-11-09 09:12:53 +00:00
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 ) ;
2026-11-09 09:03:43 +00:00
n . horizontalOverflow = HorizontalWrapMode . Overflow ;
}
2026-11-09 09:19:06 +00:00
y + = 128f ;
2026-11-09 09:03:43 +00:00
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 ) ;
}
2026-11-05 12:11:10 +00:00
bool TryEnter ( int si )
{
if ( Session . TryPayEntry ( Battle . Config . stages [ si ] ) ) return true ;
2026-11-05 15:22:54 +00:00
StaminaModal ( "군량이 부족합니다 — 필요 " + EconomyRules . StageCost ( Session . Db , Session . Save , Battle . Config . stages [ si ] ) ) ;
2026-11-05 12:11:10 +00:00
return false ;
}
void DoSweep ( int si )
{
var s = Battle . Config . stages [ si ] ;
var r = Session . Sweep ( s ) ;
if ( r = = null )
{
2026-11-05 15:22:54 +00:00
StaminaModal ( "군량이 부족합니다 — 필요 " + EconomyRules . StageCost ( Session . Db , Session . Save , s ) ) ;
2026-11-05 12:11:10 +00:00
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 ;
2026-11-05 15:22:54 +00:00
if ( Battle . Mode ! = null )
{
result . FillMode ( Session . OnModeEnd ( Battle , p = = BattlePhase . StageClear ) ) ;
return ;
}
2026-11-05 12:11:10 +00:00
var r = Session . OnBattleEnd ( Battle , p = = BattlePhase . StageClear ) ;
result . Fill ( r ) ;
}
2026-11-05 15:22:54 +00:00
2026-11-09 09:03:43 +00:00
/// <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 ( ) ;
}
2026-11-05 15:22:54 +00:00
// ---------- 연무장 전투(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 ) ;
} ,
} ) ;
}
2026-11-05 12:11:10 +00:00
}
}