2026-11-04 18:38:47 +00:00
using System ;
using System.Collections.Generic ;
2026-11-05 03:17:19 +00:00
using System.Globalization ;
using System.Text.RegularExpressions ;
2026-11-04 18:38:47 +00:00
using UnityEngine ;
using UnityEngine.UI ;
namespace TKS
{
// ---------- 데이터(Resources/ChapterMap/Data/world.json · 원화 세션 chap_install.py 생성 · 좌표 = 맵 px · 왼쪽 위 원점 · y 아래로) ----------
2026-11-05 09:56:15 +00:00
// 맵 그림·길 = world.json(맵 id 별) · 노드 자리 = Data/<맵>.json 의 stages[].at(기획 확정판 · 없으면 slots_N) · 게임 챕터·스테이지·노드 수 = BalanceConfig.chapters·stages(스테이지 표 CSV)
2026-11-04 18:38:47 +00:00
[Serializable]
public class WorldKitItem
{
public string name ;
public float [ ] pivot ; // 이미지 좌표(위 → 아래) 비율
public float [ ] refSize ; // UI 기준 px
public float [ ] numberCenter ; // 번호 자리(이미지 좌표 비율)
}
[Serializable]
public class WorldLandmark
{
public string ko ;
public float x ;
public float y ;
public string role ;
}
[Serializable]
public class WorldChapter
{
public string id ;
public int no ;
public string ko ;
public string years ;
public string map ;
public float [ ] roadX ;
public float [ ] roadY ;
public WorldLandmark [ ] landmarks ;
2026-11-05 03:17:19 +00:00
// world.json 의 nodes·stageLink(구 임시 연결 spec/stage_plan.json) 는 읽지 않는다 — 노드 = 스테이지 표(2026-11-05 145 스테이지)
2026-11-04 18:38:47 +00:00
}
[Serializable]
public class WorldData
{
public float [ ] mapSize ;
public float [ ] viewSize ;
public float dotSpacing ;
public float [ ] flagStars ; // x0,y0,x1,y1,… (노드 기준 · y 아래로)
public float [ ] flagBossStars ;
public float [ ] flagCursor ;
public float [ ] flagBossCursor ;
public float [ ] flagFrame ;
public bool showLabels ; // 장소 이름판 표시(그림 속 장소 위치와 맞춘 뒤 켬)
public WorldKitItem [ ] kit ;
public WorldChapter [ ] chapters ;
public const string Path = "ChapterMap/Data/world" ;
2026-11-05 03:17:19 +00:00
public const string DataRoot = "ChapterMap/Data/" ;
public WorldChapter FindMap ( string map )
{
if ( chapters ! = null )
foreach ( var c in chapters )
if ( c . map = = map ) return c ;
return null ;
}
2026-11-05 09:56:15 +00:00
/// <summary>Data/<map>.json 의 stages(기획 확정판 맵 · 스테이지 번호·보스·지명·노드 좌표 at). 없으면 null.</summary>
public static AnchorStage [ ] AnchorStages ( string map )
{
var t = Resources . Load < TextAsset > ( DataRoot + map ) ;
if ( t = = null ) return null ;
try
{
var f = JsonUtility . FromJson < AnchorFile > ( t . text ) ;
return f ! = null & & f . stages ! = null & & f . stages . Length > 0 ? f . stages : null ;
}
catch ( Exception e )
{
Debug . LogWarning ( "[TKS] " + DataRoot + map + " stages 읽기 실패: " + e . Message ) ;
return null ;
}
}
2026-11-05 03:17:19 +00:00
/// <summary>Data/<map>.json(원화 세션 anchors) 의 slots_n(맵 px · 첫 칸 = 출발). 없으면 null.</summary>
public static Vector2 [ ] AnchorSlots ( string map , int n )
{
var t = Resources . Load < TextAsset > ( DataRoot + map ) ;
if ( t = = null | | n < = 0 ) return null ;
var m = Regex . Match ( t . text , "\"slots_" + n + "\"\\s*:\\s*\\[((?:\\s*\\[[^\\]]*\\]\\s*,?)*)\\s*\\]" ) ;
if ( ! m . Success ) return null ;
var nums = Regex . Matches ( m . Groups [ 1 ] . Value , @"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?" ) ;
if ( nums . Count ! = n * 2 ) return null ;
var res = new Vector2 [ n ] ;
for ( int i = 0 ; i < n ; i + + )
res [ i ] = new Vector2 ( float . Parse ( nums [ 2 * i ] . Value , CultureInfo . InvariantCulture ) , float . Parse ( nums [ 2 * i + 1 ] . Value , CultureInfo . InvariantCulture ) ) ;
return res ;
}
/// <summary>점 p 에 가장 가까운 길 위 지점의 호 길이.</summary>
public static float ArcOf ( WorldChapter c , float [ ] acc , Vector2 p )
{
int m = acc . Length ;
if ( m = = 1 ) return 0f ;
float best = float . MaxValue , res = 0f ;
for ( int i = 1 ; i < m ; i + + )
{
var a = new Vector2 ( c . roadX [ i - 1 ] , c . roadY [ i - 1 ] ) ;
var b = new Vector2 ( c . roadX [ i ] , c . roadY [ i ] ) ;
var ab = b - a ;
float t = Mathf . Clamp01 ( Vector2 . Dot ( p - a , ab ) / Mathf . Max ( ab . sqrMagnitude , 1e-6f ) ) ;
float d = ( a + ab * t - p ) . sqrMagnitude ;
if ( d < best )
{
best = d ;
res = acc [ i - 1 ] + ( acc [ i ] - acc [ i - 1 ] ) * t ;
}
}
return res ;
}
2026-11-04 18:38:47 +00:00
public static WorldData Load ( )
{
var t = Resources . Load < TextAsset > ( Path ) ;
if ( t = = null ) return null ;
try
{
return JsonUtility . FromJson < WorldData > ( t . text ) ;
}
catch ( Exception e )
{
Debug . LogError ( "[TKS] world.json 읽기 실패: " + e . Message ) ;
return null ;
}
}
public WorldKitItem Kit ( string name )
{
if ( kit ! = null )
foreach ( var k in kit )
if ( k . name = = name ) return k ;
return null ;
}
/// <summary>길(road) 을 호 길이로 n 등분한 노드 자리(맵 px). 첫 칸 = 출발 · 마지막 = 보스.</summary>
public static Vector2 [ ] Slots ( WorldChapter c , int n )
{
var acc = Arc ( c ) ;
var res = new Vector2 [ Mathf . Max ( 0 , n ) ] ;
float total = acc [ acc . Length - 1 ] ;
for ( int i = 0 ; i < res . Length ; i + + ) res [ i ] = At ( c , acc , n < = 1 ? 0f : total * i / ( n - 1 ) ) ;
return res ;
}
public static float [ ] Arc ( WorldChapter c )
{
int m = Mathf . Min ( c . roadX . Length , c . roadY . Length ) ;
var acc = new float [ Mathf . Max ( 1 , m ) ] ;
for ( int i = 1 ; i < m ; i + + )
acc [ i ] = acc [ i - 1 ] + Vector2 . Distance ( new Vector2 ( c . roadX [ i - 1 ] , c . roadY [ i - 1 ] ) , new Vector2 ( c . roadX [ i ] , c . roadY [ i ] ) ) ;
return acc ;
}
public static Vector2 At ( WorldChapter c , float [ ] acc , float len )
{
int m = acc . Length ;
if ( m = = 1 ) return new Vector2 ( c . roadX [ 0 ] , c . roadY [ 0 ] ) ;
for ( int i = 1 ; i < m ; i + + )
{
if ( acc [ i ] > = len )
{
float t = ( len - acc [ i - 1 ] ) / Mathf . Max ( acc [ i ] - acc [ i - 1 ] , 1e-4f ) ;
return Vector2 . Lerp ( new Vector2 ( c . roadX [ i - 1 ] , c . roadY [ i - 1 ] ) , new Vector2 ( c . roadX [ i ] , c . roadY [ i ] ) , t ) ;
}
}
return new Vector2 ( c . roadX [ m - 1 ] , c . roadY [ m - 1 ] ) ;
}
}
2026-11-05 09:56:15 +00:00
/// <summary>Data/<맵>.json 의 stages 한 칸(챕터맵 보강 세션 기획 확정판 · 기획서 §4 번호·유형·지명 + 노드 좌표).</summary>
[Serializable]
public class AnchorStage
{
public int stage ;
public bool boss ;
public string place ;
public string title ;
public float [ ] at ; // 맵 px(왼쪽 위 원점 · y 아래로)
}
[Serializable]
public class AnchorFile
{
public AnchorStage [ ] stages ;
}
2026-11-04 18:38:47 +00:00
public enum NodeState { Lock , Open , Clear }
2026-11-05 09:56:15 +00:00
/// <summary>노드 자리 출처: 기획 확정판 stages[].at · 가안 slots_N · 길 위 균등(대체).</summary>
public enum NodeSource { Stages , Slots , Road }
2026-11-05 12:11:10 +00:00
/// <summary>진행도(실행 중 메모리) · 챕터별 깬 노드 수. 시작 때 아웃게임 세이브(stages)에서 채운다(OutGameSession.SyncWorldProgress · #1093).</summary>
2026-11-04 18:38:47 +00:00
public static class WorldProgress
{
static readonly Dictionary < int , int > cleared = new Dictionary < int , int > ( ) ;
public static int Cleared ( int chapter )
{
int v ;
return cleared . TryGetValue ( chapter , out v ) ? v : 0 ;
}
public static void MarkCleared ( int chapter , int node )
{
if ( node + 1 > Cleared ( chapter ) ) cleared [ chapter ] = node + 1 ;
}
public static void Reset ( )
{
cleared . Clear ( ) ;
}
}
/// <summary>
/// 게임 시작 화면 = 챕터 월드맵(스크롤 · 상하좌우로 끌기). 길 위 깃발 노드를 누르면 스테이지 팝업 → 출전 = 전투 시작 ·
2026-11-05 09:56:15 +00:00
/// 전투 결과를 탭하면 월드맵으로 돌아와 다음 노드가 열린다. 그림·길·부품 배치 = world.json · 노드 자리 = Data/<맵>.json stages[].at(없으면 slots_N → 길 위 균등) ·
2026-11-05 03:17:19 +00:00
/// 챕터·노드·전투 = BalanceConfig.chapters·stages(스테이지 표 · C45) · 보스 노드 = type boss.
2026-11-04 18:38:47 +00:00
/// </summary>
public class WorldMapScreen : MonoBehaviour
{
public static WorldMapScreen Instance { get ; private set ; }
const string UiRoot = "ChapterMap/UI/" ;
const string MapRoot = "ChapterMap/Maps/" ;
static readonly Color Ink = new Color ( 0.14f , 0.09f , 0.07f ) ;
static readonly Color Paper = new Color ( 1f , 0.98f , 0.93f ) ;
BattleManager battle ;
BattleHUD hud ;
SlingshotInput sling ;
WorldData data ;
2026-11-05 03:17:19 +00:00
BalanceConfig cfg ;
readonly List < int > nodeStages = new List < int > ( ) ; // 현재 챕터 노드 → stages 인덱스
2026-11-04 18:38:47 +00:00
GameObject root ;
CanvasGroup group ;
ScrollRect scroll ;
RectTransform viewport ;
RectTransform content ;
Image mapImage ;
Text bannerText ;
Button prevButton ;
Button nextButton ;
RectTransform cursor ;
Vector2 cursorBase ;
readonly List < GameObject > spawned = new List < GameObject > ( ) ;
readonly List < Button > nodeButtons = new List < Button > ( ) ;
GameObject popup ;
Text popupTitle ;
Text popupBody ;
Button popupGo ;
Text popupGoLabel ;
2026-11-05 12:11:10 +00:00
Button popupSweep ;
Text popupSweepLabel ;
2026-11-09 09:03:43 +00:00
RectTransform popupField ;
Text popupCost ;
2026-11-09 09:19:06 +00:00
Text popupCode ;
2026-11-04 18:38:47 +00:00
int popupNode = - 1 ;
2026-11-05 12:11:10 +00:00
RectTransform bannerRt ;
RectTransform prevRt ;
RectTransform nextRt ;
Button backButton ;
// ---------- 아웃게임 연결(2026-11-05 #1093 · OutGame/OutGameRoot 가 채움 · 비면 기존 동작) ----------
/// <summary>스테이지 인덱스 → 팝업 추가 줄(군량 · 보상 · 별).</summary>
public Func < int , string > PopupInfo ;
2026-11-09 09:03:43 +00:00
/// <summary>스테이지 팝업 본문 그리기(스테이지 인덱스 · 잠김 · 필드) — 있으면 글자 본문 대신(Fable 제안서 §4-3 · #1104).</summary>
public Action < int , bool , RectTransform > PopupFill ;
/// <summary>출전 군량(스테이지 인덱스) — 출정 버튼 비용 칩.</summary>
public Func < int , int > CostOf ;
2026-11-05 12:11:10 +00:00
/// <summary>출전 직전(군량 지불) · false = 출전 안 함(부족 안내는 연결한 쪽이 띄움).</summary>
public Func < int , bool > TryEnter ;
/// <summary>깬 노드 별 수(0~3) · null 이면 깬 노드 = 별 전부.</summary>
public Func < int , int > StarsOf ;
/// <summary>소탕(별 3 · 전투 없음) 가능 여부 · 실행.</summary>
public Func < int , bool > CanSweep ;
public Action < int > Sweep ;
/// <summary>「◀ 본영」(로비로) · null 이면 버튼 숨김.</summary>
public Action BackRequested ;
/// <summary>위 공통 재화 바 높이(UI px) — 챕터 띠·넘김 버튼을 그만큼 내린다.</summary>
public float TopInset ;
2026-11-04 18:38:47 +00:00
float inputBlockUntil ;
int chapter ;
int entryChapter = - 1 ;
int entryNode = - 1 ;
2026-11-05 03:17:19 +00:00
public bool HasData
{
get { return data ! = null & & data . chapters ! = null & & data . chapters . Length > 0 & & cfg ! = null & & cfg . chapters ! = null & & cfg . chapters . Length > 0 & & cfg . stages . Length > 0 ; }
}
2026-11-04 18:38:47 +00:00
public bool IsVisible { get { return root ! = null & & root . activeSelf ; } }
public bool PopupVisible { get { return popup ! = null & & popup . activeSelf ; } }
public int Chapter { get { return chapter ; } }
2026-11-05 03:17:19 +00:00
public int ChapterCount { get { return HasData ? cfg . chapters . Length : 0 ; } }
2026-11-04 18:38:47 +00:00
public WorldData Data { get { return data ; } }
public Sprite MapSprite { get { return mapImage ! = null ? mapImage . sprite : null ; } }
public int NodeCount { get { return nodeButtons . Count ; } }
2026-11-05 03:17:19 +00:00
public int BossNodeCount { get ; private set ; }
/// <summary>챕터(0부터)의 노드 수 = 스테이지 표의 그 챕터 스테이지 수.</summary>
public int NodesIn ( int ch )
{
return HasData & & ch > = 0 & & ch < cfg . chapters . Length ? cfg . StagesOfChapter ( cfg . chapters [ ch ] . no ) . Count : 0 ;
}
/// <summary>챕터(0부터) · 노드(0부터) → BalanceConfig.stages 인덱스(없으면 − 1).</summary>
public int StageIndexOf ( int ch , int node )
{
if ( ! HasData | | ch < 0 | | ch > = cfg . chapters . Length ) return - 1 ;
var list = cfg . StagesOfChapter ( cfg . chapters [ ch ] . no ) ;
return node > = 0 & & node < list . Count ? list [ node ] : - 1 ;
}
/// <summary>챕터(0부터)의 맵 길(world.json · chapters.csv map 열). 없으면 첫 맵.</summary>
public WorldChapter MapOf ( int ch )
{
if ( ! HasData ) return null ;
var wc = ch > = 0 & & ch < cfg . chapters . Length ? data . FindMap ( cfg . chapters [ ch ] . map ) : null ;
return wc ? ? data . chapters [ 0 ] ;
}
2026-11-05 09:56:15 +00:00
/// <summary>
/// 노드 자리(맵 px) = Data/<맵>.json stages[].at(스테이지 번호로 맞춤 · 기획 확정판 맵) → 없거나 번호가 빠지면 slots_N → 그것도 없으면 길 위 균등.
/// </summary>
public Vector2 [ ] NodeSlots ( int ch , out NodeSource source )
2026-11-05 03:17:19 +00:00
{
2026-11-05 09:56:15 +00:00
var list = HasData & & ch > = 0 & & ch < cfg . chapters . Length ? cfg . StagesOfChapter ( cfg . chapters [ ch ] . no ) : new List < int > ( ) ;
int n = list . Count ;
2026-11-05 03:17:19 +00:00
var wc = MapOf ( ch ) ;
2026-11-05 09:56:15 +00:00
var plan = wc ! = null ? WorldData . AnchorStages ( wc . map ) : null ;
if ( plan ! = null )
{
var res = new Vector2 [ n ] ;
int found = 0 ;
for ( int i = 0 ; i < n ; i + + )
foreach ( var p in plan )
if ( p . stage = = cfg . stages [ list [ i ] ] . stage & & p . at ! = null & & p . at . Length > = 2 )
{
res [ i ] = new Vector2 ( p . at [ 0 ] , p . at [ 1 ] ) ;
found + + ;
break ;
}
if ( found = = n )
{
source = NodeSource . Stages ;
return res ;
}
Debug . LogWarning ( "[TKS] " + wc . map + " stages[].at 이 스테이지 표와 안 맞음(" + found + "/" + n + ") → slots_N" ) ;
}
2026-11-05 03:17:19 +00:00
var s = wc ! = null ? WorldData . AnchorSlots ( wc . map , n ) : null ;
2026-11-05 09:56:15 +00:00
source = s ! = null ? NodeSource . Slots : NodeSource . Road ;
2026-11-05 03:17:19 +00:00
return s ? ? ( wc ! = null ? WorldData . Slots ( wc , n ) : new Vector2 [ 0 ] ) ;
}
2026-11-04 18:38:47 +00:00
void Awake ( )
{
Instance = this ;
}
void OnDestroy ( )
{
if ( Instance = = this ) Instance = null ;
}
public void Build ( BattleManager b , BattleHUD h , SlingshotInput s , Camera cam )
{
battle = b ;
hud = h ;
sling = s ;
data = WorldData . Load ( ) ;
2026-11-05 03:17:19 +00:00
cfg = b ! = null ? b . Config : null ;
2026-11-04 18:38:47 +00:00
if ( ! HasData )
{
2026-11-05 03:17:19 +00:00
Debug . LogWarning ( "[TKS] 월드맵 데이터 없음(" + WorldData . Path + " 또는 BalanceConfig.chapters — TKS/Import Stages) → 바로 전투" ) ;
2026-11-04 18:38:47 +00:00
return ;
}
battle . ResultHandler = OnBattleResult ;
2026-11-09 16:05:47 +00:00
// 캔버스 층(PD 2026-11-10): 루트 = 바탕 · 챕터 띠 · 넘김 · 본영 버튼(챕터를 바꿀 때만 · Static)
// → Content(맵 · 노드 · 스크롤로 움직임 · Frame) → 커서(매 프레임 흔들림 · Frame) · Popup(Event)
2026-11-04 18:38:47 +00:00
root = new GameObject ( "WorldMapCanvas" ) ;
root . transform . SetParent ( transform , false ) ;
2026-11-09 16:05:47 +00:00
UiLayer . Root ( root , cam , 300 , 0.5f , new Vector2 ( data . viewSize [ 0 ] , data . viewSize [ 1 ] ) , UiRate . Static ) ; // 카메라 캡처(자동 플레이)에도 찍힘
2026-11-04 18:38:47 +00:00
group = root . AddComponent < CanvasGroup > ( ) ;
// 스크롤(맵 = 콘텐츠 · 왼쪽 위 기준)
viewport = Rect ( "Viewport" , root . transform , Vector2 . zero , Vector2 . one , new Vector2 ( 0.5f , 0.5f ) , Vector2 . zero , Vector2 . zero ) ;
var vpImg = viewport . gameObject . AddComponent < Image > ( ) ;
vpImg . color = new Color ( 0.1f , 0.12f , 0.1f ) ;
viewport . gameObject . AddComponent < RectMask2D > ( ) ;
content = Rect ( "Content" , viewport , new Vector2 ( 0f , 1f ) , new Vector2 ( 0f , 1f ) , new Vector2 ( 0f , 1f ) , Vector2 . zero , new Vector2 ( data . mapSize [ 0 ] , data . mapSize [ 1 ] ) ) ;
2026-11-09 16:05:47 +00:00
UiLayer . Nest ( content . gameObject , UiRate . Frame , true ) ; // 스크롤 내용 = 자기 캔버스(노드 = 누름)
2026-11-04 18:38:47 +00:00
mapImage = content . gameObject . AddComponent < Image > ( ) ;
mapImage . raycastTarget = true ; // 맵 아무 데나 끌기
scroll = viewport . gameObject . AddComponent < ScrollRect > ( ) ;
scroll . viewport = viewport ;
scroll . content = content ;
scroll . horizontal = true ;
scroll . vertical = true ;
scroll . movementType = ScrollRect . MovementType . Clamped ;
scroll . inertia = true ;
scroll . decelerationRate = 0.12f ;
scroll . scrollSensitivity = 60f ;
// 위 챕터 띠 + 챕터 넘김
var banner = Img ( "Banner" , root . transform , Sprite ( "banner" ) , new Vector2 ( 0.5f , 1f ) , new Vector2 ( 0.5f , 1f ) , new Vector2 ( 0.5f , 1f ) , new Vector2 ( 0f , - 40f ) , new Vector2 ( 640f , 112f ) ) ;
banner . type = Image . Type . Sliced ;
var bk = data . Kit ( "banner" ) ;
float tcy = bk ! = null & & bk . numberCenter ! = null ? bk . numberCenter [ 1 ] : 0.4f ;
bannerText = Label ( banner . transform , "" , 40 , Paper , new Vector2 ( 0.5f , 1f - tcy ) ) ;
2026-11-05 12:11:10 +00:00
bannerRt = banner . rectTransform ;
2026-11-04 18:38:47 +00:00
prevButton = SmallButton ( "Prev" , root . transform , "◀" , new Vector2 ( 0f , 1f ) , new Vector2 ( 70f , - 96f ) , ( ) = > ShowChapter ( chapter - 1 ) ) ;
nextButton = SmallButton ( "Next" , root . transform , "▶" , new Vector2 ( 1f , 1f ) , new Vector2 ( - 70f , - 96f ) , ( ) = > ShowChapter ( chapter + 1 ) ) ;
2026-11-05 12:11:10 +00:00
prevRt = ( RectTransform ) prevButton . transform ;
nextRt = ( RectTransform ) nextButton . transform ;
2026-11-04 18:38:47 +00:00
var hint = Label ( root . transform , "맵을 끌어 둘러보고 깃발을 눌러 출전" , 30 , Paper , new Vector2 ( 0.5f , 0f ) ) ;
var hrt = hint . rectTransform ;
hrt . anchorMin = hrt . anchorMax = new Vector2 ( 0.5f , 0f ) ;
hrt . anchoredPosition = new Vector2 ( 0f , 90f ) ;
2026-11-05 12:11:10 +00:00
// 본영(로비)으로 — 아웃게임이 BackRequested 를 채우면 보인다
var back = Img ( "Back" , root . transform , Sprite ( "plate" ) , new Vector2 ( 0f , 0f ) , new Vector2 ( 0f , 0f ) , new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 130f , 90f ) , new Vector2 ( 220f , 96f ) ) ;
back . type = Image . Type . Sliced ;
back . raycastTarget = true ;
backButton = back . gameObject . AddComponent < Button > ( ) ;
backButton . onClick . AddListener ( ( ) = >
{
if ( BackRequested ! = null & & Time . unscaledTime > = inputBlockUntil ) BackRequested ( ) ;
} ) ;
var bl = Label ( back . transform , "◀ 본영" , 36 , new Color ( 0.45f , 0.22f , 0.12f ) , new Vector2 ( 0.5f , 0.55f ) ) ;
bl . GetComponent < Outline > ( ) . enabled = false ;
2026-11-04 18:38:47 +00:00
BuildPopup ( ) ;
root . SetActive ( false ) ;
}
// ---------- 화면 전환 ----------
/// <summary>월드맵을 연다(전투 정리 · 전투 HUD·조작 끔).</summary>
public void Show ( int ch )
{
if ( ! HasData ) return ;
if ( battle ! = null & & battle . Phase ! = BattlePhase . Setup ) battle . GoIdle ( ) ;
if ( hud ! = null ) hud . gameObject . SetActive ( false ) ;
if ( sling ! = null ) sling . enabled = false ;
root . SetActive ( true ) ;
inputBlockUntil = Time . unscaledTime + 0.25f ; // 결과 화면 탭이 노드 클릭으로 이어지지 않게
2026-11-05 12:11:10 +00:00
bannerRt . anchoredPosition = new Vector2 ( 0f , - 40f - TopInset ) ;
prevRt . anchoredPosition = new Vector2 ( 70f , - 96f - TopInset ) ;
nextRt . anchoredPosition = new Vector2 ( - 70f , - 96f - TopInset ) ;
backButton . gameObject . SetActive ( BackRequested ! = null ) ;
2026-11-04 18:38:47 +00:00
ShowChapter ( ch ) ;
}
2026-11-05 12:11:10 +00:00
/// <summary>월드맵을 닫는다(로비로 돌아갈 때 · 전투는 건드리지 않음).</summary>
public void Hide ( )
{
ClosePopup ( ) ;
if ( root ! = null ) root . SetActive ( false ) ;
}
/// <summary>팝업이 열려 있으면 내용을 다시 채운다(군량 구매·소탕 뒤).</summary>
public void RefreshPopup ( )
{
if ( PopupVisible & & popupNode > = 0 ) OpenPopup ( popupNode ) ;
}
2026-11-04 18:38:47 +00:00
public void ShowChapter ( int ch )
{
if ( ! HasData ) return ;
2026-11-05 03:17:19 +00:00
chapter = Mathf . Clamp ( ch , 0 , cfg . chapters . Length - 1 ) ;
2026-11-04 18:38:47 +00:00
ClosePopup ( ) ;
2026-11-05 03:17:19 +00:00
var def = cfg . chapters [ chapter ] ;
var c = MapOf ( chapter ) ;
2026-11-04 18:38:47 +00:00
foreach ( var go in spawned )
if ( go ! = null ) Destroy ( go ) ;
spawned . Clear ( ) ;
nodeButtons . Clear ( ) ;
cursor = null ;
2026-11-05 03:17:19 +00:00
nodeStages . Clear ( ) ;
nodeStages . AddRange ( cfg . StagesOfChapter ( def . no ) ) ;
BossNodeCount = 0 ;
2026-11-04 18:38:47 +00:00
2026-11-05 03:17:19 +00:00
if ( c . map ! = def . map ) Debug . LogWarning ( "[TKS] 챕터 " + def . no + " 맵 「" + def . map + "」 이 world.json 에 없음 → " + c . map ) ;
2026-11-04 18:38:47 +00:00
var sp = Resources . Load < Sprite > ( MapRoot + c . map ) ;
if ( sp = = null ) Debug . LogWarning ( "[TKS] 맵 그림 없음: " + MapRoot + c . map ) ;
mapImage . sprite = sp ;
mapImage . color = sp ! = null ? Color . white : new Color ( 0.55f , 0.7f , 0.45f ) ;
2026-11-05 03:17:19 +00:00
bannerText . text = "제" + def . no + "장 " + def . title ;
2026-11-04 18:38:47 +00:00
prevButton . interactable = chapter > 0 ;
2026-11-05 03:17:19 +00:00
nextButton . interactable = chapter < cfg . chapters . Length - 1 ;
2026-11-04 18:38:47 +00:00
2026-11-05 09:56:15 +00:00
NodeSource source ;
var slots = NodeSlots ( chapter , out source ) ;
2026-11-04 18:38:47 +00:00
var acc = WorldData . Arc ( c ) ;
2026-11-05 03:17:19 +00:00
var len = new float [ slots . Length ] ;
for ( int i = 0 ; i < slots . Length ; i + + ) len [ i ] = WorldData . ArcOf ( c , acc , slots [ i ] ) ;
2026-11-04 18:38:47 +00:00
int cleared = ChapterUnlocked ( chapter ) ? WorldProgress . Cleared ( chapter ) : 0 ;
2026-11-05 03:17:19 +00:00
// 길 점(노드 사이 · 노드 자리를 길에 투영한 호 길이 구간)
2026-11-04 18:38:47 +00:00
float spacing = Mathf . Max ( 8f , data . dotSpacing ) ;
for ( int i = 0 ; i + 1 < slots . Length ; i + + )
{
2026-11-05 03:17:19 +00:00
float l0 = Mathf . Min ( len [ i ] , len [ i + 1 ] ) + 44f ;
float l1 = Mathf . Max ( len [ i ] , len [ i + 1 ] ) - 44f ;
if ( l1 < = l0 ) continue ;
2026-11-04 18:38:47 +00:00
int m = Mathf . Max ( 0 , Mathf . FloorToInt ( ( l1 - l0 ) / spacing ) ) ;
for ( int j = 0 ; j < = m ; j + + )
{
var p = WorldData . At ( c , acc , Mathf . Lerp ( l0 , l1 , m = = 0 ? 0.5f : ( float ) j / m ) ) ;
Piece ( i < cleared ? "dot_on" : "dot_off" , p , false ) ;
}
}
// 장소 이름판
if ( data . showLabels & & c . landmarks ! = null )
foreach ( var L in c . landmarks )
{
if ( string . IsNullOrEmpty ( L . ko ) ) continue ;
var plate = Piece ( "plate" , new Vector2 ( L . x , L . y ) , false ) ;
if ( plate = = null ) continue ;
plate . type = Image . Type . Sliced ;
var t = Label ( plate . transform , L . ko , 26 , new Color ( 0.28f , 0.17f , 0.11f ) , new Vector2 ( 0.5f , 0.5f ) ) ;
t . GetComponent < Outline > ( ) . enabled = false ;
plate . rectTransform . sizeDelta = new Vector2 ( t . preferredWidth + 60f , plate . rectTransform . sizeDelta . y ) ;
}
// 노드
for ( int i = 0 ; i < slots . Length ; i + + )
{
2026-11-05 03:17:19 +00:00
var stage = cfg . stages [ nodeStages [ i ] ] ;
bool boss = stage . kind = = StageKind . Boss ;
if ( boss ) BossNodeCount + + ;
2026-11-04 18:38:47 +00:00
var st = StateOf ( chapter , i ) ;
string key = ( boss ? "flag_boss_" : "flag_" ) + ( st = = NodeState . Clear ? "clear" : st = = NodeState . Open ? "open" : "lock" ) ;
if ( boss ) Piece ( "frame" , slots [ i ] + V ( data . flagFrame ) , false ) ;
var img = Piece ( key , slots [ i ] , true ) ;
if ( img = = null ) continue ;
int node = i ;
var btn = img . gameObject . AddComponent < Button > ( ) ;
btn . transition = Selectable . Transition . ColorTint ;
btn . onClick . AddListener ( ( ) = > OnNodeClicked ( node ) ) ;
nodeButtons . Add ( btn ) ;
var kit = data . Kit ( key ) ;
if ( st ! = NodeState . Lock & & kit ! = null )
{
2026-11-05 03:17:19 +00:00
var t = Label ( img . transform , def . no + "-" + stage . stage , boss ? 38 : ( def . no > = 10 | | stage . stage > = 10 ? 26 : 32 ) , Paper , new Vector2 ( kit . numberCenter [ 0 ] , 1f - kit . numberCenter [ 1 ] ) ) ;
2026-11-04 18:38:47 +00:00
t . raycastTarget = false ;
}
if ( st = = NodeState . Clear )
{
var so = boss ? data . flagBossStars : data . flagStars ;
2026-11-05 12:11:10 +00:00
int stars = StarsOf ! = null ? StarsOf ( nodeStages [ i ] ) : int . MaxValue ; // 아웃게임 세이브 별(#1093 §6)
for ( int k = 0 ; k + 1 < so . Length ; k + = 2 ) Piece ( k / 2 < stars ? "star_on" : "star_off" , slots [ i ] + new Vector2 ( so [ k ] , so [ k + 1 ] ) , false ) ;
2026-11-04 18:38:47 +00:00
}
if ( st = = NodeState . Open )
{
var co = boss ? data . flagBossCursor : data . flagCursor ;
var cimg = Piece ( "cursor" , slots [ i ] + V ( co ) , false ) ;
if ( cimg ! = null )
{
cursor = cimg . rectTransform ;
cursorBase = cursor . anchoredPosition ;
2026-11-09 16:05:47 +00:00
// 커서 = 매 프레임 위아래 흔들림 → 자기 캔버스(맵·노드 전체를 매 프레임 다시 묶지 않게 · PD 2026-11-10)
UiLayer . Nest ( cimg . gameObject , UiRate . Frame , false ) ;
2026-11-04 18:38:47 +00:00
}
}
}
// 처음 위치 = 도전할 노드(없으면 출발)
int focus = Mathf . Clamp ( Mathf . Min ( cleared , slots . Length - 1 ) , 0 , Mathf . Max ( 0 , slots . Length - 1 ) ) ;
if ( slots . Length > 0 ) ScrollTo ( slots [ focus ] ) ;
}
/// <summary>맵 px 지점을 화면 가운데로(맵 끝에서 멈춤).</summary>
public void ScrollTo ( Vector2 p )
{
Canvas . ForceUpdateCanvases ( ) ;
var vr = viewport . rect ;
float w = data . mapSize [ 0 ] , h = data . mapSize [ 1 ] ;
float nx = w > vr . width ? Mathf . Clamp01 ( ( p . x - vr . width / 2f ) / ( w - vr . width ) ) : 0f ;
float ny = h > vr . height ? Mathf . Clamp01 ( ( p . y - vr . height / 2f ) / ( h - vr . height ) ) : 0f ;
scroll . StopMovement ( ) ;
scroll . normalizedPosition = new Vector2 ( nx , 1f - ny ) ;
}
public NodeState StateOf ( int ch , int node )
{
if ( ! HasData | | ! ChapterUnlocked ( ch ) ) return NodeState . Lock ;
int cleared = WorldProgress . Cleared ( ch ) ;
if ( node < cleared ) return NodeState . Clear ;
return node = = cleared ? NodeState . Open : NodeState . Lock ;
}
public bool ChapterUnlocked ( int ch )
{
2026-11-05 03:17:19 +00:00
if ( ! HasData | | ch < 0 | | ch > = cfg . chapters . Length ) return false ;
return ch = = 0 | | WorldProgress . Cleared ( ch - 1 ) > = NodesIn ( ch - 1 ) ;
2026-11-04 18:38:47 +00:00
}
// ---------- 팝업 ----------
void OnNodeClicked ( int node )
{
if ( Time . unscaledTime < inputBlockUntil ) return ;
OpenPopup ( node ) ;
}
public void OpenPopup ( int node )
{
if ( ! HasData ) return ;
2026-11-05 03:17:19 +00:00
int si = StageIndexOf ( chapter , node ) ;
if ( si < 0 ) return ;
2026-11-04 18:38:47 +00:00
popupNode = node ;
var st = StateOf ( chapter , node ) ;
2026-11-05 03:17:19 +00:00
var def = cfg . chapters [ chapter ] ;
var stage = cfg . stages [ si ] ;
string tag = stage . kind = = StageKind . Boss ? " 보스" : stage . kind = = StageKind . Elite ? " 엘리트" : "" ;
popupTitle . text = def . no + "-" + stage . stage + tag + "\n제" + def . no + "장 " + def . title ;
2026-11-04 18:38:47 +00:00
var body = new System . Text . StringBuilder ( ) ;
2026-11-05 03:17:19 +00:00
body . Append ( stage . title ) ;
if ( ! string . IsNullOrEmpty ( stage . place ) ) body . Append ( "\n<size=26>" ) . Append ( stage . place ) . Append ( string . IsNullOrEmpty ( stage . bgTheme ) ? "" : " · " + stage . bgTheme ) . Append ( "</size>" ) ;
body . Append ( "\n\n" ) . Append ( EnemySummary ( stage ) ) ;
2026-11-05 12:11:10 +00:00
if ( PopupInfo ! = null )
{
var extra = PopupInfo ( si ) ;
if ( ! string . IsNullOrEmpty ( extra ) ) body . Append ( "\n\n" ) . Append ( extra ) ;
}
2026-11-04 18:38:47 +00:00
if ( st = = NodeState . Lock ) body . Append ( "\n\n잠긴 스테이지 — 앞 스테이지를 먼저 깨세요" ) ;
popupBody . text = body . ToString ( ) ;
popupGo . interactable = st ! = NodeState . Lock ;
2026-11-05 12:11:10 +00:00
bool sweep = st = = NodeState . Clear & & CanSweep ! = null & & Sweep ! = null & & CanSweep ( si ) ;
popupSweep . gameObject . SetActive ( sweep ) ;
2026-11-09 09:03:43 +00:00
if ( popupField ! = null & & PopupFill ! = null )
{
// 팩 팝업(#1104): 본문 = 필드 안 그림 · 출정(붉은 Primary + 군량 비용) · 소탕(녹 Secondary) 왼쪽
popupBody . gameObject . SetActive ( false ) ;
popupField . gameObject . SetActive ( true ) ;
foreach ( Transform c in popupField ) c . gameObject . SetActive ( false ) ;
for ( int i = popupField . childCount - 1 ; i > = 0 ; i - - ) Destroy ( popupField . GetChild ( i ) . gameObject ) ;
PopupFill ( si , st = = NodeState . Lock , popupField ) ;
popupGoLabel . text = st = = NodeState . Lock ? "잠김" : st = = NodeState . Clear ? "다시 출정" : "출정" ;
2026-11-09 09:19:06 +00:00
if ( popupCode ! = null )
{
popupCode . text = def . no + "-" + stage . stage + ( stage . kind = = StageKind . Boss ? " 보스" : stage . kind = = StageKind . Elite ? " 정예" : "" ) ;
popupTitle . text = "제" + def . no + "장 " + def . title ;
}
2026-11-09 09:03:43 +00:00
if ( popupCost ! = null ) popupCost . text = CostOf ! = null ? CostOf ( si ) . ToString ( ) : "" ;
( ( RectTransform ) popupGo . transform ) . anchoredPosition = new Vector2 ( sweep ? 390f : 240f , - PopupBtnY ) ;
}
else
{
popupBody . gameObject . SetActive ( true ) ;
if ( popupField ! = null ) popupField . gameObject . SetActive ( false ) ;
popupGoLabel . text = st = = NodeState . Clear ? "다시 출전" : "출전" ;
}
2026-11-04 18:38:47 +00:00
popup . SetActive ( true ) ;
}
2026-11-05 12:11:10 +00:00
void OnSweepClicked ( )
{
if ( popupNode < 0 | | Sweep = = null ) return ;
int si = StageIndexOf ( chapter , popupNode ) ;
if ( si > = 0 ) Sweep ( si ) ;
}
2026-11-04 18:38:47 +00:00
public void ClosePopup ( )
{
popupNode = - 1 ;
if ( popup ! = null ) popup . SetActive ( false ) ;
}
public void ConfirmPopup ( )
{
if ( popupNode < 0 ) return ;
if ( StateOf ( chapter , popupNode ) = = NodeState . Lock ) return ;
2026-11-05 12:11:10 +00:00
if ( TryEnter ! = null & & ! TryEnter ( StageIndexOf ( chapter , popupNode ) ) ) return ; // 군량 부족(#1093 §2)
2026-11-04 18:38:47 +00:00
EnterStage ( chapter , popupNode ) ;
}
/// <summary>노드의 전투로 들어간다(월드맵 닫음 · HUD·조작 켬).</summary>
public void EnterStage ( int ch , int node )
{
if ( ! HasData )
{
battle . StartStage ( 0 ) ;
return ;
}
2026-11-05 03:17:19 +00:00
chapter = Mathf . Clamp ( ch , 0 , cfg . chapters . Length - 1 ) ;
int si = StageIndexOf ( chapter , node ) ;
if ( si < 0 ) return ;
2026-11-04 18:38:47 +00:00
entryChapter = chapter ;
entryNode = node ;
ClosePopup ( ) ;
root . SetActive ( false ) ;
if ( hud ! = null ) hud . gameObject . SetActive ( true ) ;
if ( sling ! = null ) sling . enabled = true ;
2026-11-05 03:17:19 +00:00
battle . StartStage ( si ) ;
2026-11-04 18:38:47 +00:00
}
2026-11-09 09:03:43 +00:00
/// <summary>결과 화면 「다시」(#1104) — 결과 처리(진행 기록 · 월드맵 복귀)를 한 뒤 같은 노드로 다시 출전 · 군량이 모자라면 월드맵에 남음(충전 팝업).</summary>
public void RetryLast ( )
{
int ch = entryChapter > = 0 ? entryChapter : chapter ;
int node = entryNode ;
battle . ContinueAfterResult ( ) ;
if ( node < 0 | | ! HasData ) return ;
if ( TryEnter ! = null & & ! TryEnter ( StageIndexOf ( ch , node ) ) ) return ;
EnterStage ( ch , node ) ;
}
2026-11-04 18:38:47 +00:00
void OnBattleResult ( bool win )
{
int ch = entryChapter > = 0 ? entryChapter : chapter ;
if ( win & & entryNode > = 0 )
{
WorldProgress . MarkCleared ( ch , entryNode ) ;
2026-11-05 03:17:19 +00:00
// 챕터 마지막 스테이지를 깨면 다음 챕터 맵으로
if ( entryNode = = NodesIn ( ch ) - 1 & & ch + 1 < cfg . chapters . Length ) ch + + ;
2026-11-04 18:38:47 +00:00
}
entryChapter = entryNode = - 1 ;
Show ( ch ) ;
}
2026-11-05 03:17:19 +00:00
/// <summary>적 수·병종 + 장수(보스 · 엘리트) 이름.</summary>
2026-11-04 18:38:47 +00:00
static string EnemySummary ( BalanceConfig . StageDef stage )
{
var counts = new int [ UnitTypeInfo . Count ] ;
2026-11-05 03:17:19 +00:00
var bosses = new List < string > ( ) ;
var elites = new List < string > ( ) ;
2026-11-04 18:38:47 +00:00
foreach ( var e in stage . enemies )
{
counts [ ( int ) e . type ] + + ;
2026-11-05 03:17:19 +00:00
if ( e . isBoss ) bosses . Add ( e . name ) ;
2026-11-05 04:23:01 +00:00
else if ( e . isGeneral ) elites . Add ( e . name ) ;
2026-11-04 18:38:47 +00:00
}
var sb = new System . Text . StringBuilder ( "적 " + stage . enemies . Count + "기 · " ) ;
bool first = true ;
for ( int t = 0 ; t < counts . Length ; t + + )
{
if ( counts [ t ] = = 0 ) continue ;
if ( ! first ) sb . Append ( " " ) ;
sb . Append ( UnitTypeInfo . Korean ( ( UnitType ) t ) ) . Append ( " " ) . Append ( counts [ t ] ) ;
first = false ;
}
2026-11-05 03:17:19 +00:00
if ( bosses . Count > 0 ) sb . Append ( "\n보스 " ) . Append ( string . Join ( " · " , bosses . ToArray ( ) ) ) ;
if ( elites . Count > 0 ) sb . Append ( "\n장수 " ) . Append ( string . Join ( " · " , elites . ToArray ( ) ) ) ;
2026-11-04 18:38:47 +00:00
return sb . ToString ( ) ;
}
2026-11-09 09:03:43 +00:00
// 팩 스테이지 팝업 배치(UI px · 패널 왼쪽 위 기준 · #1104)
const float PopupW = 900f ;
const float PopupH = 1160f ;
2026-11-09 09:19:06 +00:00
const float PopupBtnY = PopupH - 52f - 104f ;
2026-11-09 09:03:43 +00:00
2026-11-04 18:38:47 +00:00
void BuildPopup ( )
{
var dim = Img ( "Popup" , root . transform , null , Vector2 . zero , Vector2 . one , new Vector2 ( 0.5f , 0.5f ) , Vector2 . zero , Vector2 . zero ) ;
dim . color = new Color ( 0f , 0f , 0f , 0.6f ) ;
dim . raycastTarget = true ;
popup = dim . gameObject ;
2026-11-09 16:05:47 +00:00
UiLayer . Nest ( popup , UiRate . Event , true ) ; // 스테이지 팝업 = 자기 캔버스(열 때·소탕 때만)
2026-11-05 12:11:10 +00:00
// 아웃게임 줄(군량 · 보상 · 별 · #1093)이 들어가도록 2026-11-05 패널을 키움(880× 760 → 900× 1080)
var panel = Img ( "Panel" , popup . transform , Sprite ( "plate" ) , new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 0f , 40f ) , new Vector2 ( 900f , 1080f ) ) ;
2026-11-04 18:38:47 +00:00
panel . type = Image . Type . Sliced ;
panel . raycastTarget = true ;
2026-11-06 14:20:57 +00:00
// 스테이지 팝업만 팩 Popup(PD #1100 · 거점 kit 유지) · 테마가 없으면 kit plate·banner
bool pack = UISkin . Apply ( panel , "popup" ) ;
if ( pack )
{
panel . color = UISkin . Col ( "paper" , Color . white ) ;
var band = Img ( "Head" , panel . transform , null , new Vector2 ( 0f , 1f ) , new Vector2 ( 1f , 1f ) , new Vector2 ( 0.5f , 1f ) , new Vector2 ( 0f , - 6f ) , new Vector2 ( - 14f , 150f ) ) ;
UISkin . Apply ( band , "popup_head" ) ;
band . color = UISkin . Col ( "header" , Ink ) ;
popupTitle = Label ( band . transform , "" , 38 , Color . white , new Vector2 ( 0.5f , 0.5f ) ) ;
popupTitle . font = UISkin . TitleFont ;
popupTitle . GetComponent < Outline > ( ) . effectColor = UISkin . Col ( "stroke" , Ink ) ;
var x = Img ( "Close" , panel . transform , null , new Vector2 ( 1f , 1f ) , new Vector2 ( 1f , 1f ) , new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( - 58f , - 60f ) , new Vector2 ( 86f , 88f ) ) ;
UISkin . Apply ( x , "close" ) ;
x . raycastTarget = true ;
x . gameObject . AddComponent < Button > ( ) . onClick . AddListener ( ClosePopup ) ;
}
else
{
var head = Img ( "Head" , panel . transform , Sprite ( "banner" ) , new Vector2 ( 0.5f , 1f ) , new Vector2 ( 0.5f , 1f ) , new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 0f , 0f ) , new Vector2 ( 760f , 112f ) ) ;
head . type = Image . Type . Sliced ;
var bk = data . Kit ( "banner" ) ;
popupTitle = Label ( head . transform , "" , 34 , Paper , new Vector2 ( 0.5f , 1f - ( bk ! = null ? bk . numberCenter [ 1 ] : 0.4f ) ) ) ;
}
2026-11-04 18:38:47 +00:00
popupTitle . lineSpacing = 0.9f ;
2026-11-06 14:20:57 +00:00
popupBody = Label ( panel . transform , "" , 30 , pack ? UISkin . Col ( "ink" , new Color ( 0.25f , 0.15f , 0.1f ) ) : new Color ( 0.25f , 0.15f , 0.1f ) , new Vector2 ( 0.5f , pack ? 0.545f : 0.56f ) ) ;
2026-11-04 18:38:47 +00:00
popupBody . GetComponent < Outline > ( ) . enabled = false ;
2026-11-06 14:20:57 +00:00
popupBody . rectTransform . sizeDelta = new Vector2 ( 800f , pack ? 640f : 720f ) ;
2026-11-04 18:38:47 +00:00
popupBody . horizontalOverflow = HorizontalWrapMode . Wrap ;
popupBody . supportRichText = true ;
2026-11-09 09:03:43 +00:00
if ( pack & & UISkin . Has ( "panel_field" ) & & UISkin . Has ( "btn_l_red" ) )
{
// Fable 제안서 §4-3(#1104): 팝업 900× 1160 · 본문 필드(panel_field 인셋 20) · 출정 = 붉은 Primary 420× 104 + 군량 비용 · 소탕 = 녹 Secondary · 닫기 = X 만
panel . rectTransform . sizeDelta = new Vector2 ( PopupW , PopupH ) ;
var field = Img ( "Field" , panel . transform , null , Vector2 . zero , Vector2 . one , new Vector2 ( 0.5f , 0.5f ) , Vector2 . zero , Vector2 . zero ) ;
UISkin . Apply ( field , "panel_field" ) ;
field . color = UISkin . Col ( "panel_field" , Ink ) ;
field . rectTransform . offsetMin = new Vector2 ( 20f , PopupH - PopupBtnY + 16f ) ;
2026-11-09 09:19:06 +00:00
field . rectTransform . offsetMax = new Vector2 ( - 20f , - 126f ) ;
// 제목 띠 = 공용 팝업과 같은 h110 · #1F2138 · 왼쪽 스테이지 칩(검수 결함 6)
var head = panel . transform . Find ( "Head" ) as RectTransform ;
if ( head ! = null )
{
head . sizeDelta = new Vector2 ( head . sizeDelta . x , 110f ) ;
head . GetComponent < Image > ( ) . color = UISkin . Col ( "popup_band" , Ink ) ;
popupCode = TKS . OutGame . UiKit . InfoChip ( head , "Code" , 30f , 27f , 190f , 56f , null , "" , 30 ) ;
popupTitle . fontSize = 40 ;
}
2026-11-09 09:03:43 +00:00
popupField = field . rectTransform ;
popupGo = TKS . OutGame . UiKit . SkinBtn ( panel . transform , "Go" , "btn_l_red" , UISkin . Col ( "red" , new Color ( 0.89f , 0.33f , 0.23f ) ) , "출정" , 46 , Color . white , 240f , PopupBtnY , 420f , 104f , ConfirmPopup ) ;
popupGoLabel = TKS . OutGame . UiKit . LabelOf ( popupGo ) ;
popupCost = TKS . OutGame . UiKit . BtnCost ( popupGo , "cur_stamina" , "" , 44f , 38 ) ;
popupSweep = TKS . OutGame . UiKit . SkinBtn ( panel . transform , "Sweep" , "btn_s_green" , Color . white , "소탕" , 40 , Color . white , 90f , PopupBtnY + 4f , 280f , 100f , OnSweepClicked ) ;
popupSweepLabel = TKS . OutGame . UiKit . LabelOf ( popupSweep ) ;
popupSweep . gameObject . SetActive ( false ) ;
popup . SetActive ( false ) ;
return ;
}
2026-11-06 14:20:57 +00:00
popupGo = WideButton ( "Go" , panel . transform , "출전" , new Vector2 ( - 180f , 80f ) , UISkin . Col ( "red" , new Color ( 0.89f , 0.33f , 0.23f ) ) , ConfirmPopup , out popupGoLabel ) ;
2026-11-04 18:38:47 +00:00
Text closeLabel ;
2026-11-06 14:20:57 +00:00
WideButton ( "Close" , panel . transform , "닫기" , new Vector2 ( 180f , 80f ) , UISkin . Col ( "dark" , new Color ( 0.6f , 0.55f , 0.5f ) ) , ClosePopup , out closeLabel ) ;
popupSweep = WideButton ( "Sweep" , panel . transform , "소탕" , new Vector2 ( 0f , 200f ) , UISkin . Col ( "ok" , new Color ( 0.35f , 0.5f , 0.35f ) ) , OnSweepClicked , out popupSweepLabel ) ;
2026-11-05 12:11:10 +00:00
popupSweep . gameObject . SetActive ( false ) ;
2026-11-04 18:38:47 +00:00
popup . SetActive ( false ) ;
}
// ---------- 그리기 도우미 ----------
void Update ( )
{
if ( cursor ! = null ) cursor . anchoredPosition = cursorBase + new Vector2 ( 0f , 10f * Mathf . Abs ( Mathf . Sin ( Time . unscaledTime * 4f ) ) ) ;
2026-11-09 16:05:47 +00:00
bool interact = Time . unscaledTime > = inputBlockUntil ;
if ( group ! = null & & group . interactable ! = interact ) group . interactable = interact ; // 바뀔 때만(버튼 색 전환 → 재구성 방지)
2026-11-04 18:38:47 +00:00
}
static Vector2 V ( float [ ] a )
{
return a ! = null & & a . Length > = 2 ? new Vector2 ( a [ 0 ] , a [ 1 ] ) : Vector2 . zero ;
}
static Sprite Sprite ( string name )
{
return Resources . Load < Sprite > ( UiRoot + name ) ;
}
/// <summary>맵 px 위치(y 아래로)에 부품 1개. 크기 = kit refSize · 피벗 = kit pivot.</summary>
Image Piece ( string name , Vector2 mapPos , bool raycast )
{
var sp = Sprite ( name ) ;
var kit = data . Kit ( name ) ;
if ( sp = = null | | kit = = null ) return null ;
var go = new GameObject ( name ) ;
go . transform . SetParent ( content , false ) ;
var rt = go . AddComponent < RectTransform > ( ) ;
rt . anchorMin = rt . anchorMax = new Vector2 ( 0f , 1f ) ;
rt . pivot = new Vector2 ( kit . pivot [ 0 ] , 1f - kit . pivot [ 1 ] ) ;
rt . sizeDelta = new Vector2 ( kit . refSize [ 0 ] , kit . refSize [ 1 ] ) ;
rt . anchoredPosition = new Vector2 ( mapPos . x , - mapPos . y ) ;
var img = go . AddComponent < Image > ( ) ;
img . sprite = sp ;
img . raycastTarget = raycast ;
spawned . Add ( go ) ;
return img ;
}
static RectTransform Rect ( string name , Transform parent , Vector2 aMin , Vector2 aMax , Vector2 pivot , Vector2 pos , Vector2 size )
{
var go = new GameObject ( name ) ;
go . transform . SetParent ( parent , false ) ;
var rt = go . AddComponent < RectTransform > ( ) ;
rt . anchorMin = aMin ;
rt . anchorMax = aMax ;
rt . pivot = pivot ;
rt . anchoredPosition = pos ;
rt . sizeDelta = size ;
return rt ;
}
static Image Img ( string name , Transform parent , Sprite sp , Vector2 aMin , Vector2 aMax , Vector2 pivot , Vector2 pos , Vector2 size )
{
var rt = Rect ( name , parent , aMin , aMax , pivot , pos , size ) ;
var img = rt . gameObject . AddComponent < Image > ( ) ;
img . sprite = sp ;
img . raycastTarget = false ;
return img ;
}
/// <summary>부모 안 anchor 지점(비율 · 아래 원점)에 가운데 맞춤 글자(흰 글자 + 검정 테).</summary>
static Text Label ( Transform parent , string text , int size , Color color , Vector2 anchor )
{
var rt = Rect ( "Text" , parent , anchor , anchor , new Vector2 ( 0.5f , 0.5f ) , Vector2 . zero , new Vector2 ( 600f , size * 2.6f ) ) ;
var t = rt . gameObject . AddComponent < Text > ( ) ;
t . font = TextUtil . Font ;
t . fontSize = size ;
t . fontStyle = FontStyle . Bold ;
t . alignment = TextAnchor . MiddleCenter ;
t . color = color ;
t . horizontalOverflow = HorizontalWrapMode . Overflow ;
t . verticalOverflow = VerticalWrapMode . Overflow ;
t . raycastTarget = false ;
t . text = text ;
var o = rt . gameObject . AddComponent < Outline > ( ) ;
o . effectColor = Ink ;
o . effectDistance = new Vector2 ( 3f , - 3f ) ;
return t ;
}
Button SmallButton ( string name , Transform parent , string label , Vector2 anchor , Vector2 pos , Action onClick )
{
var img = Img ( name , parent , Sprite ( "plate" ) , anchor , anchor , new Vector2 ( 0.5f , 0.5f ) , pos , new Vector2 ( 110f , 80f ) ) ;
img . type = Image . Type . Sliced ;
img . raycastTarget = true ;
var b = img . gameObject . AddComponent < Button > ( ) ;
b . onClick . AddListener ( ( ) = > onClick ( ) ) ;
var t = Label ( img . transform , label , 40 , new Color ( 0.45f , 0.22f , 0.12f ) , new Vector2 ( 0.5f , 0.55f ) ) ;
t . GetComponent < Outline > ( ) . enabled = false ;
return b ;
}
Button WideButton ( string name , Transform parent , string label , Vector2 pos , Color tint , Action onClick , out Text labelText )
{
var img = Img ( name , parent , Sprite ( "plate" ) , new Vector2 ( 0.5f , 0f ) , new Vector2 ( 0.5f , 0f ) , new Vector2 ( 0.5f , 0.5f ) , pos , new Vector2 ( 300f , 100f ) ) ;
img . type = Image . Type . Sliced ;
2026-11-06 14:20:57 +00:00
bool pack = UISkin . Apply ( img , "button" ) ;
if ( pack ) img . rectTransform . sizeDelta = new Vector2 ( 320f , 112f ) ;
2026-11-04 18:38:47 +00:00
img . color = tint ;
img . raycastTarget = true ;
var b = img . gameObject . AddComponent < Button > ( ) ;
b . onClick . AddListener ( ( ) = > onClick ( ) ) ;
2026-11-06 14:20:57 +00:00
labelText = Label ( img . transform , label , 42 , pack ? Color . white : Paper , new Vector2 ( 0.5f , pack ? 0.6f : 0.55f ) ) ;
if ( pack )
{
labelText . font = UISkin . TitleFont ;
labelText . GetComponent < Outline > ( ) . effectColor = UISkin . Col ( "stroke" , Ink ) ;
}
2026-11-04 18:38:47 +00:00
return b ;
}
}
}