2026-10-29 13:21:25 +00:00
using System.Collections.Generic ;
using System.Numerics ;
using UnityEngine ;
using UnityEngine.EventSystems ;
using UnityEngine.UI ;
using VS.Sim ;
using Vector2 = UnityEngine . Vector2 ;
using Vector3 = UnityEngine . Vector3 ;
// 전투 탭 — 클래시 로얄 화면 구성(위 적 성 · 무작위 맵 · 아래 카드 칸 + 마나 막대 · 오른쪽 위 = 상대방 얼굴 + 스킬 정보)
// 화면 비율 = 클래시 로얄(맵이 화면 폭을 채움 · 비스듬히 내려다봐 칸 세로가 짧음 · 유닛 키 약 1.5칸) — 수치 = battle.json view
// 카드를 누르면 아래 가운데에서 바로 소환 · 맵을 누르면 깃발 → 내 유닛이 깃발을 거쳐 성으로 · 왼쪽 위 「시험」 = 맵·성 투사체·기믹을 골라 다음 전투에 적용
namespace VS.View
{
public partial class GameApp
{
public const float TabH = 150f ;
public RectTransform StreamRoot , BattleRoot ;
public int Tab ; // 0 방송 · 1 전투
readonly List < ( Image bg , Text label ) > tabs = new List < ( Image , Text ) > ( ) ;
public Battle B ;
public BattleSetup BattleTest ; // 시험 설정 (null = 단계 기본 · 무작위 맵)
RectTransform arena , unitLayer , fxLayerB , castleHpBar ;
Image arenaImg ;
BViewDef V = > T . Battle . view ;
Image castleImg , castleHpFill , manaFill , flagImg ;
Text castleHpText , stageText , manaText , manaMaxText , infoText ;
float flagPop ;
// 상대방(여성 캐릭터) 얼굴 + 스킬 정보 — 전투마다 성 구성에 맞춰 줄을 새로 만든다
RectTransform rivalBox ; Image rivalFrame , rivalFace ;
class RivalRow { public RectTransform rt ; public Image bg , fill ; public Text info ; public BWeapon w ; public BSummoner s ; public float lastCd , flash ; }
readonly List < RivalRow > rivalRows = new List < RivalRow > ( ) ;
const float RivalW = 240f , RivalTop = 116f , RivalRowH = 88f ; // 클래시 로얄 남은 시간 칸 자리 — 맵을 덜 가리게 작게
RectTransform overlay ; Image overlayRibbon ; Text overlayTitle , overlayInfo , overlayBtnLabel ;
readonly List < ( Button b , Image face , Text cost , Image lockImg , Text lockText ) > cards = new List < ( Button , Image , Text , Image , Text ) > ( ) ;
readonly Dictionary < BUnit , ( RectTransform rt , Image hp , Image stun ) > unitViews = new Dictionary < BUnit , ( RectTransform , Image , Image ) > ( ) ;
readonly Dictionary < BMinion , ( RectTransform rt , Image hp ) > minionViews = new Dictionary < BMinion , ( RectTransform , Image ) > ( ) ;
readonly List < Image > shotViews = new List < Image > ( ) , fxViews = new List < Image > ( ) ;
readonly List < ( Transform t , float key ) > depth = new List < ( Transform , float ) > ( ) ;
bool rewardGiven ;
2026-10-29 14:42:47 +00:00
// ---------------- 탭(로비 아래 메뉴) = 연구 | 전투 | 유닛 (2026-10-29 PD) · 연구 = 방송 화면 · 전투 중에는 숨김 ----------------
RectTransform tabBar ;
2026-10-29 13:21:25 +00:00
void BuildTabs ( )
{
var bar = UI . Img ( Root , "TabBar" , Skin . TabBar , Skin . On ? Pal . Navy : ( Color ) new Color32 ( 36 , 26 , 52 , 255 ) , true ) ;
2026-10-29 14:42:47 +00:00
tabBar = bar . rectTransform ;
tabBar . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 1 , 0 ) , Vector2 . zero , new Vector2 ( 0 , TabH ) , new Vector2 ( 0.5f , 0 ) ) ;
2026-10-29 13:21:25 +00:00
var hl = bar . gameObject . AddComponent < HorizontalLayoutGroup > ( ) ;
hl . childControlWidth = true ; hl . childControlHeight = true ; hl . childForceExpandWidth = true ; hl . padding = new RectOffset ( 20 , 20 , Skin . On ? 0 : 12 , Skin . On ? 6 : 12 ) ; hl . spacing = 16 ;
2026-10-29 14:42:47 +00:00
string [ ] names = { L ( "UI.Tab.Research" , "연구" ) , L ( "UI.Tab.Battle" , "전투" ) , L ( "UI.Tab.Units" , "유닛" ) } ;
string [ ] icons = { "skill" , "pk" , "follower" } ;
2026-10-29 13:21:25 +00:00
for ( int i = 0 ; i < names . Length ; i + + )
{
int idx = i ;
var b = UI . Btn ( bar . transform , "Tab" + i , Skin . On ? Skin . TabFocus : Art . Round , Pal . Purple , ( ) = > SwitchTab ( idx ) ) ;
2026-10-29 14:42:47 +00:00
UI . Icon ( b . transform , "Icon" , Skin . Icon ( icons [ i ] ) , Color . white ) . rectTransform . Place ( new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( - 68 , 0 ) , new Vector2 ( 80 , 80 ) ) ;
var t = UI . Txt ( b . transform , "Label" , names [ i ] , 42 , Color . white , TextAnchor . MiddleCenter , true ) ;
t . rectTransform . Place ( new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 42 , 0 ) , new Vector2 ( 150 , 80 ) ) ;
2026-10-29 13:21:25 +00:00
tabs . Add ( ( b . GetComponent < Image > ( ) , t ) ) ;
}
SwitchTab ( 0 ) ;
}
public void SwitchTab ( int t )
{
Tab = t ;
2026-10-29 14:42:47 +00:00
// 전투 탭 = 싸우는 중이면 전투 화면 · 아니면 스테이지 선택(다음에 깰 스테이지부터) · 유닛 탭 = 덱 + 유닛 목록
2026-10-29 13:58:23 +00:00
bool fighting = B ! = null & & B . State = = BattleState . Fighting ;
2026-10-29 13:21:25 +00:00
if ( StreamRoot ! = null ) StreamRoot . gameObject . SetActive ( t = = 0 ) ;
2026-10-29 13:58:23 +00:00
if ( BattleRoot ! = null ) BattleRoot . gameObject . SetActive ( t = = 1 & & fighting ) ;
if ( StageRoot ! = null ) StageRoot . gameObject . SetActive ( t = = 1 & & ! fighting ) ;
2026-10-29 14:42:47 +00:00
if ( UnitRoot ! = null ) UnitRoot . gameObject . SetActive ( t = = 2 ) ;
2026-10-29 13:21:25 +00:00
for ( int i = 0 ; i < tabs . Count ; i + + ) tabs [ i ] . bg . color = Skin . On ? ( i = = t ? Pal . NavyLight : new Color ( 0 , 0 , 0 , 0 ) ) : ( i = = t ? Pal . Pink : ( Color ) new Color32 ( 84 , 64 , 120 , 255 ) ) ;
2026-10-29 13:58:23 +00:00
if ( t = = 1 & & ! fighting ) ShowStage ( G . StageFocus ) ;
2026-10-29 14:42:47 +00:00
if ( t = = 2 ) ShowUnits ( ) ;
UpdateTabBar ( ) ;
2026-10-29 13:21:25 +00:00
CloseAllPopups ( ) ;
}
2026-10-29 14:42:47 +00:00
/// 전투 화면이 떠 있으면 아래 메뉴를 숨긴다(클래시 로얄처럼 전투는 화면 전체)
void UpdateTabBar ( ) { if ( tabBar ! = null ) tabBar . gameObject . SetActive ( BattleRoot = = null | | ! BattleRoot . gameObject . activeSelf ) ; }
2026-10-29 13:21:25 +00:00
// ---------------- 아레나 그림 (전투마다 맵에 맞춰 새로) ----------------
// 칸 → 화면 px: 가로 tileX · 세로 tileY (클래시 로얄처럼 비스듬히 내려다봐 땅의 세로가 짧다 · 땅 위 원 = 타원)
Vector2 TileToLocal ( Vector2 tp ) = > new Vector2 ( ( tp . x - B . C . arenaW * 0.5f ) * V . tileX , ( tp . y - B . C . arenaH * 0.5f ) * V . tileY ) ;
Vector2 TileVec ( Vector2 d ) = > new Vector2 ( d . x * V . tileX , d . y * V . tileY ) ;
/// 맵 그림 = 좌우 sideTiles 칸 맵 밖 장식(짙은 풀 + 돌 턱 · 강은 끝까지) + 가운데 맵 · 칸당 텍스처 S px → 화면에서 칸 가로·세로 비율대로 늘어남
Sprite ArenaSprite ( Battle b )
{
var c = b . C ; var m = b . Map ; const float S = 30f ;
float ox = c . view . sideTiles * S ;
int W = Mathf . RoundToInt ( ( c . arenaW + 2f * c . view . sideTiles ) * S ) , H = Mathf . RoundToInt ( c . arenaH * S ) ;
var r = new Raster ( W , H ) ;
Color32 g0 = new Color32 ( 96 , 180 , 110 , 255 ) , g1 = new Color32 ( 88 , 170 , 102 , 255 ) , e0 = new Color32 ( 66 , 132 , 78 , 255 ) , e1 = new Color32 ( 60 , 124 , 72 , 255 ) ;
for ( int y = 0 ; y < H ; y + + )
for ( int x = 0 ; x < W ; x + + )
{
float tx = ( x - ox ) / S ;
int k = ( Mathf . FloorToInt ( tx ) + ( int ) ( y / S ) ) & 1 ;
r . Px [ y * W + x ] = tx > = 0f & & tx < c . arenaW ? ( k = = 0 ? g0 : g1 ) : ( k = = 0 ? e0 : e1 ) ;
}
// 맵 가장자리 돌 턱(클래시 로얄 아레나 옆 벽처럼 — 유닛이 못 나가는 곳)
Color curbDk = new Color32 ( 104 , 100 , 116 , 255 ) , curb = new Color32 ( 156 , 152 , 166 , 255 ) ;
foreach ( float ex in new [ ] { ox , W - ox } )
{
r . RRect ( ex - S * 0.3f , - 4 , ex + S * 0.3f , H + 4 , 2 , curbDk ) ;
r . RRect ( ex - S * 0.18f , - 4 , ex + S * 0.18f , H + 4 , 2 , curb ) ;
}
// 성 앞마당 · 소환 자리(아래 가운데 — 내 진영은 평지)
r . Circle ( ox + m . Castle . x * S , m . Castle . y * S , ( c . castle . radius + c . castleSeedPad ) * S , new Color32 ( 214 , 186 , 130 , 255 ) ) ;
var sp = b . SpawnPoint ; float sr = ( c . spawnJitter + 0.8f ) * S ;
r . Circle ( ox + sp . x * S , sp . y * S , sr , new Color ( 1f , 1f , 1f , 0.16f ) ) ;
r . Ring ( ox + sp . x * S , sp . y * S , sr , S * 0.25f , new Color ( 1f , 1f , 1f , 0.55f ) ) ;
if ( m . River )
{
float ry = c . riverY * S , rh = c . riverHalf * S , hw = c . bridgeHalf * S ;
r . RRect ( - 4 , ry - rh , W + 4 , ry + rh , 4 , new Color32 ( 70 , 150 , 230 , 255 ) ) ; // 강은 맵 밖 장식까지 이어짐
r . RRect ( - 4 , ry - rh * 0.35f , W + 4 , ry + rh * 0.35f , 3 , new Color32 ( 110 , 190 , 250 , 255 ) ) ;
foreach ( var bx in m . Bridges )
{
float x = ox + bx * S , step = ( 2 * rh + S * 0.6f ) / 5f ;
r . RRect ( x - hw , ry - rh - S * 0.5f , x + hw , ry + rh + S * 0.5f , 4 , new Color32 ( 150 , 100 , 60 , 255 ) ) ;
for ( int k = 0 ; k < 5 ; k + + ) { float y0 = ry - rh - S * 0.3f + k * step ; r . RRect ( x - hw + S * 0.2f , y0 , x + hw - S * 0.2f , y0 + S * 0.2f , 2 , new Color32 ( 190 , 140 , 90 , 255 ) ) ; }
}
}
foreach ( var o in m . Obstacles ) r . Ellipse ( ox + o . pos . x * S + S * 0.2f , o . pos . y * S - S * 0.3f , o . r * S * 1.02f , o . r * S * 0.9f , new Color ( 0f , 0f , 0f , 0.22f ) ) ; // 그림자 먼저
foreach ( var o in m . Obstacles )
{
float x = ox + o . pos . x * S , y = o . pos . y * S , rr = o . r * S ;
if ( o . kind = = 0 ) // 바위
{
r . Circle ( x , y , rr , new Color32 ( 112 , 110 , 124 , 255 ) ) ;
r . Circle ( x - rr * 0.12f , y + rr * 0.12f , rr * 0.8f , new Color32 ( 152 , 150 , 164 , 255 ) ) ;
r . Circle ( x - rr * 0.36f , y + rr * 0.36f , rr * 0.26f , new Color32 ( 196 , 194 , 206 , 255 ) ) ;
}
else // 언덕(흙 둔덕 + 풀)
{
r . Circle ( x , y , rr , new Color32 ( 128 , 96 , 60 , 255 ) ) ;
r . Circle ( x - rr * 0.05f , y + rr * 0.08f , rr * 0.86f , new Color32 ( 178 , 140 , 90 , 255 ) ) ;
r . Circle ( x - rr * 0.25f , y + rr * 0.3f , rr * 0.45f , new Color32 ( 208 , 174 , 120 , 255 ) ) ;
r . Circle ( x + rr * 0.32f , y + rr * 0.42f , rr * 0.18f , new Color32 ( 96 , 160 , 80 , 255 ) ) ;
}
}
return r . Sprite ( ) ;
}
static readonly Dictionary < string , Sprite > bcache = new Dictionary < string , Sprite > ( ) ;
static Sprite CastleSprite ( bool enraged )
{
string key = "castle" + enraged ;
if ( bcache . TryGetValue ( key , out var s ) ) return s ;
var r = new Raster ( 200 , 220 ) ;
Color stone = new Color32 ( 150 , 150 , 168 , 255 ) , dark = new Color32 ( 108 , 108 , 126 , 255 ) , roof = enraged ? new Color32 ( 255 , 80 , 60 , 255 ) : new Color32 ( 220 , 70 , 80 , 255 ) ;
r . RRect ( 20 , 10 , 180 , 150 , 14 , dark ) ; r . RRect ( 30 , 20 , 170 , 140 , 10 , stone ) ;
for ( int i = 0 ; i < 5 ; i + + ) r . RRect ( 26 + i * 32 , 140 , 50 + i * 32 , 168 , 4 , stone ) ;
r . RRect ( 60 , 20 , 140 , 90 , 10 , new Color32 ( 90 , 70 , 60 , 255 ) ) ;
r . Poly ( new [ ] { new Vector2 ( 40 , 168 ) , new Vector2 ( 160 , 168 ) , new Vector2 ( 100 , 214 ) } , roof ) ;
r . Star ( 100 , 115 , 20 , Pal . Yellow ) ;
s = r . Sprite ( ) ; bcache [ key ] = s ; return s ;
}
static readonly Color32 [ ] UnitColor = { new Color32 ( 80 , 140 , 255 , 255 ) , new Color32 ( 90 , 200 , 110 , 255 ) , new Color32 ( 170 , 170 , 190 , 255 ) , new Color32 ( 170 , 100 , 240 , 255 ) , new Color32 ( 200 , 140 , 90 , 255 ) , new Color32 ( 110 , 210 , 140 , 255 ) } ;
public static Sprite UnitSprite ( int id )
{
string key = "u" + id ;
if ( bcache . TryGetValue ( key , out var s ) ) return s ;
s = UnitArt . ForUnit ( id ) ; // 도트 그림(Data/unit_art.json) 이 있으면 그것 · 없으면 아래 코드 그림
if ( s ! = null ) { bcache [ key ] = s ; return s ; }
var r = new Raster ( 96 , 96 ) ;
Color c = UnitColor [ ( id - 1 + UnitColor . Length ) % UnitColor . Length ] , dk = Color . Lerp ( c , Pal . Ink , 0.4f ) , skin = new Color32 ( 255 , 220 , 190 , 255 ) ;
switch ( id )
{
case 6 : r . Poly ( new [ ] { new Vector2 ( 10 , 60 ) , new Vector2 ( 48 , 40 ) , new Vector2 ( 86 , 60 ) , new Vector2 ( 48 , 30 ) } , dk ) ; r . Ellipse ( 48 , 44 , 22 , 18 , c ) ; r . Circle ( 48 , 66 , 14 , c ) ; r . Circle ( 43 , 70 , 3 , Pal . Ink ) ; r . Circle ( 53 , 70 , 3 , Pal . Ink ) ; break ;
case 5 : r . Ellipse ( 48 , 40 , 34 , 30 , c ) ; r . Circle ( 48 , 74 , 18 , skin ) ; r . Circle ( 16 , 40 , 12 , skin ) ; r . Circle ( 80 , 40 , 12 , skin ) ; r . Circle ( 42 , 76 , 3 , Pal . Ink ) ; r . Circle ( 54 , 76 , 3 , Pal . Ink ) ; break ;
default :
r . Ellipse ( 48 , 34 , 22 , 24 , c ) ; r . Circle ( 48 , 66 , 16 , skin ) ; r . Circle ( 43 , 68 , 3 , Pal . Ink ) ; r . Circle ( 53 , 68 , 3 , Pal . Ink ) ;
if ( id = = 1 ) { r . Line ( new Vector2 ( 70 , 20 ) , new Vector2 ( 84 , 70 ) , 6 , new Color32 ( 200 , 200 , 215 , 255 ) ) ; r . Ellipse ( 48 , 80 , 18 , 8 , dk ) ; }
if ( id = = 2 ) { r . Ring ( 78 , 44 , 18 , 4 , new Color32 ( 150 , 100 , 60 , 255 ) ) ; r . Poly ( new [ ] { new Vector2 ( 30 , 76 ) , new Vector2 ( 66 , 76 ) , new Vector2 ( 48 , 92 ) } , dk ) ; }
if ( id = = 3 ) { r . RRect ( 10 , 18 , 34 , 54 , 8 , dk ) ; r . Star ( 22 , 36 , 8 , Pal . Yellow ) ; r . Ellipse ( 48 , 80 , 18 , 8 , new Color32 ( 120 , 120 , 140 , 255 ) ) ; }
if ( id = = 4 ) { r . Poly ( new [ ] { new Vector2 ( 28 , 76 ) , new Vector2 ( 68 , 76 ) , new Vector2 ( 48 , 96 ) } , dk ) ; r . Circle ( 80 , 52 , 10 , new Color32 ( 255 , 140 , 255 , 255 ) ) ; r . Line ( new Vector2 ( 80 , 44 ) , new Vector2 ( 80 , 14 ) , 4 , new Color32 ( 150 , 100 , 60 , 255 ) ) ; }
break ;
}
s = r . Sprite ( ) ; bcache [ key ] = s ; return s ;
}
/// 성 소환수 — 붉은 몸 + 뿔 · 근접 = 몽둥이 · 원거리 = 활
static Sprite MinionSprite ( bool ranged )
{
string key = ranged ? "m1" : "m0" ;
if ( bcache . TryGetValue ( key , out var s ) ) return s ;
var r = new Raster ( 96 , 96 ) ;
Color body = ranged ? new Color32 ( 150 , 50 , 110 , 255 ) : new Color32 ( 178 , 52 , 52 , 255 ) , dk = Color . Lerp ( body , Pal . Ink , 0.45f ) ;
Color skin = ranged ? new Color32 ( 236 , 230 , 214 , 255 ) : new Color32 ( 140 , 196 , 92 , 255 ) , eye = new Color32 ( 220 , 30 , 30 , 255 ) ;
r . Ellipse ( 48 , 34 , 22 , 24 , body ) ;
r . Poly ( new [ ] { new Vector2 ( 34 , 78 ) , new Vector2 ( 38 , 95 ) , new Vector2 ( 45 , 80 ) } , dk ) ;
r . Poly ( new [ ] { new Vector2 ( 62 , 78 ) , new Vector2 ( 58 , 95 ) , new Vector2 ( 51 , 80 ) } , dk ) ;
r . Circle ( 48 , 66 , 16 , skin ) ; r . Circle ( 42 , 68 , 3.5f , eye ) ; r . Circle ( 54 , 68 , 3.5f , eye ) ;
if ( ranged ) { r . Ring ( 78 , 44 , 18 , 4 , new Color32 ( 110 , 70 , 40 , 255 ) ) ; r . Line ( new Vector2 ( 78 , 26 ) , new Vector2 ( 78 , 62 ) , 2 , Color . white ) ; }
else r . Line ( new Vector2 ( 70 , 18 ) , new Vector2 ( 84 , 56 ) , 9 , new Color32 ( 120 , 80 , 50 , 255 ) ) ;
s = r . Sprite ( ) ; bcache [ key ] = s ; return s ;
}
static Sprite FireballSprite ( )
{
if ( bcache . TryGetValue ( "fire" , out var s ) ) return s ;
var r = new Raster ( 96 , 96 ) ;
r . Circle ( 48 , 48 , 44 , new Color ( 1f , 0.35f , 0.1f , 0.35f ) ) ;
r . Circle ( 48 , 48 , 32 , new Color32 ( 255 , 110 , 40 , 255 ) ) ;
r . Circle ( 44 , 52 , 20 , new Color32 ( 255 , 190 , 70 , 255 ) ) ;
r . Circle ( 40 , 56 , 9 , new Color32 ( 255 , 245 , 200 , 255 ) ) ;
s = r . Sprite ( ) ; bcache [ "fire" ] = s ; return s ;
}
/// 레이저 띠(가로 = 길이) — 가운데 흰색 → 가장자리 분홍
static Sprite BeamSprite ( )
{
if ( bcache . TryGetValue ( "beam" , out var s ) ) return s ;
var r = new Raster ( 16 , 32 ) ;
for ( int y = 0 ; y < 32 ; y + + )
{
float d = Mathf . Abs ( y + 0.5f - 16f ) / 16f ;
var c = Color . Lerp ( Color . white , new Color ( 1f , 0.25f , 0.55f ) , Mathf . Clamp01 ( d * 1.6f ) ) ; c . a = Mathf . Clamp01 ( 1.15f - d ) ;
for ( int x = 0 ; x < 16 ; x + + ) r . Px [ y * 16 + x ] = c ;
}
s = r . Sprite ( ) ; bcache [ "beam" ] = s ; return s ;
}
/// 번개(세로 · 아래 끝 = 맞는 곳)
static Sprite BoltSprite ( )
{
if ( bcache . TryGetValue ( "bolt" , out var s ) ) return s ;
var r = new Raster ( 64 , 256 ) ;
var pts = new [ ] { new Vector2 ( 32 , 0 ) , new Vector2 ( 18 , 50 ) , new Vector2 ( 42 , 96 ) , new Vector2 ( 20 , 146 ) , new Vector2 ( 44 , 196 ) , new Vector2 ( 30 , 256 ) } ;
for ( int i = 0 ; i < pts . Length - 1 ; i + + ) r . Line ( pts [ i ] , pts [ i + 1 ] , 14 , new Color ( 1f , 0.95f , 0.4f , 0.45f ) ) ;
for ( int i = 0 ; i < pts . Length - 1 ; i + + ) r . Line ( pts [ i ] , pts [ i + 1 ] , 5 , Color . white ) ;
r . Circle ( 32 , 8 , 12 , new Color ( 1f , 1f , 0.6f , 0.7f ) ) ;
s = r . Sprite ( ) ; bcache [ "bolt" ] = s ; return s ;
}
static Sprite StunSprite ( )
{
if ( bcache . TryGetValue ( "stun" , out var s ) ) return s ;
var r = new Raster ( 96 , 48 ) ;
r . Star ( 20 , 24 , 16 , Pal . Yellow ) ; r . Star ( 48 , 30 , 14 , Pal . Yellow ) ; r . Star ( 76 , 24 , 16 , Pal . Yellow ) ;
s = r . Sprite ( ) ; bcache [ "stun" ] = s ; return s ;
}
static Sprite RingSprite ( )
{
if ( bcache . TryGetValue ( "ring" , out var s ) ) return s ;
var r = new Raster ( 128 , 128 ) ; r . Ring ( 64 , 64 , 56 , 10 , Color . white ) ;
s = r . Sprite ( ) ; bcache [ "ring" ] = s ; return s ;
}
/// 깃발(내 편 파랑) — 그림 왼쪽 아래 깃대 밑동 = 꽂은 곳
static Sprite FlagSprite ( )
{
if ( bcache . TryGetValue ( "flag" , out var s ) ) return s ;
var r = new Raster ( 72 , 104 ) ;
r . Ellipse ( 18 , 8 , 16 , 6 , new Color ( 0f , 0f , 0f , 0.3f ) ) ;
r . Line ( new Vector2 ( 18 , 8 ) , new Vector2 ( 18 , 100 ) , 6 , new Color32 ( 110 , 76 , 46 , 255 ) ) ;
r . Poly ( new [ ] { new Vector2 ( 20 , 98 ) , new Vector2 ( 68 , 82 ) , new Vector2 ( 20 , 64 ) } , new Color32 ( 70 , 130 , 255 , 255 ) ) ;
r . Poly ( new [ ] { new Vector2 ( 20 , 92 ) , new Vector2 ( 50 , 82 ) , new Vector2 ( 20 , 72 ) } , new Color32 ( 150 , 200 , 255 , 255 ) ) ;
r . Circle ( 18 , 100 , 5 , Pal . Yellow ) ;
s = r . Sprite ( ) ; bcache [ "flag" ] = s ; return s ;
}
/// 상대방 얼굴 = 방송 캐릭터 그림(의상 번호)의 머리 부분 · 분노 = 별 눈(프레임 3)
static Sprite RivalFace ( int outfit , bool enraged )
{
string key = $"rival{outfit}{enraged}" ;
if ( bcache . TryGetValue ( key , out var s ) ) return s ;
var tex = Art . Idol ( outfit , enraged ? 3 : 0 ) . texture ;
s = Sprite . Create ( tex , new Rect ( 40 , 190 , 220 , 220 ) , new Vector2 ( 0.5f , 0.5f ) , 100f ) ;
bcache [ key ] = s ; return s ;
}
/// 상대방 스킬 그림 — 성 투사체 5종 · 파이어 볼 · 라이트닝 · 소환수
static Sprite SkillIcon ( string id )
{
string key = "sk_" + id ;
if ( bcache . TryGetValue ( key , out var s ) ) return s ;
var r = new Raster ( 96 , 96 ) ;
switch ( id )
{
case "fireball" : s = FireballSprite ( ) ; bcache [ key ] = s ; return s ;
case "summonMelee" : s = MinionSprite ( false ) ; bcache [ key ] = s ; return s ;
case "summonRanged" : s = MinionSprite ( true ) ; bcache [ key ] = s ; return s ;
case "lightning" :
{
var pts = new [ ] { new Vector2 ( 60 , 92 ) , new Vector2 ( 34 , 52 ) , new Vector2 ( 56 , 50 ) , new Vector2 ( 30 , 4 ) } ;
for ( int i = 0 ; i < pts . Length - 1 ; i + + ) r . Line ( pts [ i ] , pts [ i + 1 ] , 18 , new Color ( 1f , 0.95f , 0.4f , 0.5f ) ) ;
for ( int i = 0 ; i < pts . Length - 1 ; i + + ) r . Line ( pts [ i ] , pts [ i + 1 ] , 8 , new Color32 ( 255 , 240 , 120 , 255 ) ) ;
break ;
}
case "splash" : r . Ring ( 48 , 48 , 40 , 6 , new Color32 ( 255 , 160 , 60 , 200 ) ) ; r . Circle ( 48 , 48 , 24 , new Color32 ( 255 , 160 , 60 , 255 ) ) ; r . Circle ( 42 , 54 , 8 , new Color ( 1 , 1 , 1 , 0.6f ) ) ; break ;
case "multi" :
for ( int i = 0 ; i < 5 ; i + + ) { float a = ( 120f - 15f * i ) * Mathf . Deg2Rad ; r . Line ( new Vector2 ( 48 , 10 ) , new Vector2 ( 48 + Mathf . Cos ( a ) * 78 , 10 + Mathf . Sin ( a ) * 78 ) , 7 , new Color32 ( 210 , 255 , 250 , 255 ) ) ; }
break ;
case "slow" : r . Circle ( 48 , 48 , 30 , new Color32 ( 140 , 220 , 255 , 255 ) ) ; for ( int i = 0 ; i < 3 ; i + + ) { float a = ( 90f + 60f * i ) * Mathf . Deg2Rad ; var v = new Vector2 ( Mathf . Cos ( a ) , Mathf . Sin ( a ) ) * 40f ; r . Line ( new Vector2 ( 48 , 48 ) - v , new Vector2 ( 48 , 48 ) + v , 6 , Color . white ) ; } break ;
case "laser" : r . Line ( new Vector2 ( 8 , 16 ) , new Vector2 ( 88 , 80 ) , 22 , new Color ( 1f , 0.3f , 0.6f , 0.5f ) ) ; r . Line ( new Vector2 ( 8 , 16 ) , new Vector2 ( 88 , 80 ) , 9 , Color . white ) ; break ;
default : r . Circle ( 48 , 48 , 26 , new Color32 ( 255 , 240 , 160 , 255 ) ) ; r . Circle ( 40 , 56 , 9 , new Color ( 1 , 1 , 1 , 0.7f ) ) ; break ; // normal
}
s = r . Sprite ( ) ; bcache [ key ] = s ; return s ;
}
static Sprite ManaDrop ( )
{
if ( bcache . TryGetValue ( "drop" , out var s ) ) return s ;
var r = new Raster ( 96 , 120 ) ;
r . Circle ( 48 , 44 , 38 , new Color32 ( 214 , 60 , 200 , 255 ) ) ; r . Poly ( new [ ] { new Vector2 ( 14 , 56 ) , new Vector2 ( 82 , 56 ) , new Vector2 ( 48 , 116 ) } , new Color32 ( 214 , 60 , 200 , 255 ) ) ;
r . Circle ( 36 , 56 , 10 , new Color ( 1 , 1 , 1 , 0.5f ) ) ;
s = r . Sprite ( ) ; bcache [ "drop" ] = s ; return s ;
}
// ---------------- 화면 만들기 ----------------
// 클래시 로얄 비율 — 맵 = 화면 폭(좌우 장식 포함) · 아래 카드 칸(view.trayH) 바로 위 · 단계·시험(왼쪽 위)·상대방(오른쪽 위)은 맵 위에 겹쳐 놓는다
void BuildBattleScreen ( )
{
2026-10-29 14:42:47 +00:00
BattleRoot = UI . Rect ( "Battle" , Root ) . Fill ( ) ; // 전투 = 화면 전체(아래 메뉴 숨김)
2026-10-29 13:21:25 +00:00
UI . Img ( BattleRoot , "Bg" , Art . Square , Skin . On ? Pal . Navy : ( Color ) new Color32 ( 70 , 66 , 88 , 255 ) ) . rectTransform . Fill ( ) ;
if ( Skin . Pattern ! = null ) UI . Img ( BattleRoot , "Pattern" , Skin . Pattern , new Color ( 1f , 1f , 1f , 0.05f ) ) . rectTransform . Fill ( ) ;
var c = T . Battle ; var v = c . view ;
arenaImg = UI . Img ( BattleRoot , "Arena" , Art . Square , Color . white , true ) ; // 그림은 NewBattleRound 에서 맵마다 넣는다 · 누르면 깃발
arena = arenaImg . rectTransform ;
arena . Place ( new Vector2 ( 0.5f , 0 ) , new Vector2 ( 0.5f , 0 ) , new Vector2 ( 0 , v . trayH + c . arenaH * v . tileY * 0.5f ) , new Vector2 ( ( c . arenaW + 2f * v . sideTiles ) * v . tileX , c . arenaH * v . tileY ) ) ;
var tap = new EventTrigger . Entry { eventID = EventTriggerType . PointerDown } ;
tap . callback . AddListener ( e = > OnArenaTap ( ( PointerEventData ) e ) ) ;
arenaImg . gameObject . AddComponent < EventTrigger > ( ) . triggers . Add ( tap ) ;
flagImg = UI . Img ( arena , "Flag" , FlagSprite ( ) , Color . white ) ;
flagImg . rectTransform . anchorMin = flagImg . rectTransform . anchorMax = new Vector2 ( 0.5f , 0.5f ) ;
flagImg . rectTransform . pivot = new Vector2 ( 18f / 72f , 8f / 104f ) ; flagImg . rectTransform . sizeDelta = new Vector2 ( 1.8f , 2.6f ) * v . tileX ; // 그림 72× 104 비율
flagImg . gameObject . SetActive ( false ) ;
unitLayer = UI . Rect ( "Units" , arena ) . Fill ( ) ;
// 성 = 유닛과 같은 층(앞뒤 순서를 같이 정렬 — 성 뒤로 돌아간 유닛은 성에 가린다) · 그림 비율 그대로 칸 가로 기준
castleImg = UI . Img ( unitLayer , "Castle" , CastleSprite ( false ) , Color . white ) ;
castleImg . rectTransform . anchorMin = castleImg . rectTransform . anchorMax = new Vector2 ( 0.5f , 0.5f ) ;
castleImg . rectTransform . sizeDelta = new Vector2 ( c . castle . radius * 2.4f , c . castle . radius * 2.6f ) * v . tileX ;
fxLayerB = UI . Rect ( "Fx" , arena ) . Fill ( ) ;
var hpBar = UI . Bar ( arena , "CastleHp" , new Color ( 0.12f , 0.12f , 0.2f , 0.9f ) , Pal . Red , out castleHpFill ) ;
castleHpBar = hpBar . rectTransform ;
castleHpBar . anchorMin = castleHpBar . anchorMax = new Vector2 ( 0.5f , 0.5f ) ;
castleHpBar . sizeDelta = new Vector2 ( 220 , 30 ) ;
castleHpText = UI . Txt ( hpBar . transform , "T" , "" , 26 , Color . white , TextAnchor . MiddleCenter , true ) ; castleHpText . rectTransform . Fill ( ) ;
// 오른쪽 위 = 상대방 얼굴 + 스킬 정보(클래시 로얄 남은 시간 칸 자리 · 2026-10-28 PD) — 맵 위에 겹치므로 작게
var rb = UI . Img ( BattleRoot , "Rival" , Skin . Round , new Color ( 0.17f , 0.19f , 0.32f , 0.92f ) ) ;
rivalBox = rb . rectTransform ;
rivalBox . Place ( new Vector2 ( 1 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( - 8 , - 8 ) , new Vector2 ( RivalW , RivalTop ) , new Vector2 ( 1 , 1 ) ) ;
rivalFrame = UI . Img ( rivalBox , "Frame" , Skin . ProfileFrame , new Color32 ( 255 , 110 , 150 , 255 ) ) ;
rivalFrame . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 0 , 1 ) , new Vector2 ( 8 , - 8 ) , new Vector2 ( 100 , 100 ) , new Vector2 ( 0 , 1 ) ) ;
UI . Img ( rivalFrame . transform , "In" , Skin . ProfileInner , new Color32 ( 255 , 236 , 244 , 255 ) ) . rectTransform . Fill ( 7 , 7 , 7 , 7 ) ;
rivalFace = UI . Img ( rivalFrame . transform , "Face" , null , Color . white ) ; rivalFace . preserveAspect = true ; rivalFace . rectTransform . Fill ( 9 , 9 , 9 , 9 ) ;
UI . Txt ( rivalBox , "Name" , "상대방" , 30 , Color . white , TextAnchor . MiddleCenter , true ) . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 54 , - 58 ) , new Vector2 ( - 124 , 44 ) , new Vector2 ( 0.5f , 0.5f ) ) ;
// 왼쪽 위 = 단계(클래시 로얄 상대 이름 자리) · 아래 시험 버튼 — 성 지붕을 가리지 않게 폭 300
var stageBg = UI . Img ( BattleRoot , "StageLabel" , Skin . Label , Skin . On ? Color . white : new Color ( 0 , 0 , 0 , 0 ) ) ;
stageBg . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 0 , 1 ) , new Vector2 ( 10 , - 10 ) , new Vector2 ( 300 , 76 ) , new Vector2 ( 0 , 1 ) ) ;
stageText = UI . Txt ( stageBg . transform , "Stage" , "" , 38 , Color . white , TextAnchor . MiddleLeft , true ) ;
stageText . rectTransform . Fill ( 30 , 4 , 16 , 4 ) ;
stageText . supportRichText = true ;
// 왼쪽 아래(내 진영 구석 — 유닛은 가운데에서 나옴) = 맵 정보 · 깃발 안내
var ib = UI . Img ( BattleRoot , "InfoBg" , Skin . RoundSoft , new Color ( 0.08f , 0.06f , 0.16f , 0.5f ) ) ;
ib . rectTransform . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 0 , 0 ) , new Vector2 ( 12 , v . trayH + 10 ) , new Vector2 ( 360 , 110 ) , new Vector2 ( 0 , 0 ) ) ;
infoText = UI . Txt ( ib . transform , "Info" , "" , 24 , new Color32 ( 236 , 228 , 255 , 255 ) , TextAnchor . UpperLeft , true ) ;
infoText . rectTransform . Fill ( 14 , 8 , 10 , 8 ) ;
infoText . supportRichText = true ;
// 아래 카드 칸 + 마나 — 높이 view.trayH(클래시 로얄 ≈ 화면 폭 × 0.34) · 아래 116 = 마나 줄 · 위 = 카드
float cardH = v . trayH - 136f , cardY = v . trayH - 10f - cardH * 0.5f ;
var panel = UI . Img ( BattleRoot , "CardPanel" , Skin . BottomBar , Skin . On ? Color . white : ( Color ) new Color32 ( 120 , 80 , 50 , 255 ) ) ;
panel . rectTransform . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 1 , 0 ) , Vector2 . zero , new Vector2 ( 0 , v . trayH ) , new Vector2 ( 0.5f , 0 ) ) ;
UI . Img ( panel . transform , "Inner" , Skin . RoundSoft , Skin . On ? ( Color ) new Color32 ( 54 , 62 , 98 , 255 ) : ( Color ) new Color32 ( 150 , 104 , 66 , 255 ) ) . rectTransform . Fill ( 10 , 10 , 10 , 10 ) ;
var upSp = Skin . Button ( Pal . Orange ) ;
var up = UI . Btn ( panel . transform , "Upgrade" , upSp ? ? Art . Round , upSp ! = null ? Color . white : Pal . Orange , ( ) = > OpenBattleUpgrade ( ) ) ;
up . GetComponent < RectTransform > ( ) . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 0 , 0 ) , new Vector2 ( 84 , cardY ) , new Vector2 ( 130 , Mathf . Min ( 150f , cardH ) ) ) ;
UI . Icon ( up . transform , "I" , Skin . Icon ( "up" ) , Color . white ) . rectTransform . Place ( new Vector2 ( 0.5f , 1 ) , new Vector2 ( 0.5f , 1 ) , new Vector2 ( 0 , - 46 ) , new Vector2 ( 70 , 70 ) ) ;
UI . Txt ( up . transform , "T" , "강화" , 32 , Color . white , TextAnchor . MiddleCenter , true ) . rectTransform . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 1 , 0 ) , new Vector2 ( 0 , 34 ) , new Vector2 ( 0 , 44 ) ) ;
var row = UI . Rect ( "Cards" , panel . transform ) . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 1 , 0 ) , new Vector2 ( 80 , cardY ) , new Vector2 ( - 200 , cardH ) , new Vector2 ( 0.5f , 0.5f ) ) ;
var hl = row . gameObject . AddComponent < HorizontalLayoutGroup > ( ) ;
hl . spacing = 12 ; hl . childControlWidth = true ; hl . childControlHeight = true ; hl . childForceExpandWidth = true ;
for ( int i = 0 ; i < c . slotMax ; i + + )
{
int slot = i ;
var b = UI . Btn ( row , "Card" + i , Skin . On ? Skin . CardOn : Art . Round , Skin . On ? Color . white : ( Color ) new Color32 ( 90 , 160 , 230 , 255 ) , ( ) = > OnCard ( slot ) ) ;
var face = UI . Img ( b . transform , "Face" , null , Color . white ) ; face . preserveAspect = true ; face . rectTransform . Fill ( 12 , 50 , 12 , 14 ) ;
var gem = UI . Icon ( b . transform , "Gem" , Skin . Gem ? ? ManaDrop ( ) , Color . white ) ; gem . rectTransform . Place ( new Vector2 ( 0.5f , 0 ) , new Vector2 ( 0.5f , 0 ) , new Vector2 ( 0 , 16 ) , new Vector2 ( 78 , 72 ) ) ;
var cost = UI . Txt ( gem . transform , "C" , "" , 38 , Color . white , TextAnchor . MiddleCenter , true ) ; cost . rectTransform . Fill ( 0 , 4 , 0 , 0 ) ;
var lk = UI . Icon ( b . transform , "Lock" , Skin . Icon ( "lock" ) , Color . white ) ; lk . rectTransform . Place ( new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 0 , 20 ) , new Vector2 ( 80 , 80 ) ) ;
var lt = UI . Txt ( b . transform , "LT" , "" , 24 , Color . white , TextAnchor . MiddleCenter , true ) ; lt . rectTransform . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 1 , 0 ) , new Vector2 ( 0 , 26 ) , new Vector2 ( 0 , 40 ) ) ;
cards . Add ( ( b , face , cost , lk , lt ) ) ;
}
var drop = UI . Icon ( panel . transform , "Drop" , Skin . ManaGem ? ? ManaDrop ( ) , Color . white ) ;
drop . rectTransform . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 0 , 0 ) , new Vector2 ( 84 , 70 ) , new Vector2 ( 110 , 100 ) ) ;
manaText = UI . Txt ( drop . transform , "N" , "5" , 50 , Color . white , TextAnchor . MiddleCenter , true ) ; manaText . rectTransform . Fill ( 0 , 4 , 0 , 0 ) ;
var mbar = UI . Img ( panel . transform , "ManaBar" , Skin . BarBgThick , Skin . On ? ( Color ) new Color32 ( 40 , 36 , 66 , 255 ) : new Color ( 0.18f , 0.1f , 0.2f , 0.9f ) ) ;
mbar . rectTransform . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 1 , 0 ) , new Vector2 ( 90 , 70 ) , new Vector2 ( - 240 , 56 ) , new Vector2 ( 0.5f , 0.5f ) ) ;
manaFill = UI . Img ( mbar . transform , "Fill" , Skin . BarFill , new Color32 ( 220 , 80 , 210 , 255 ) ) ;
manaFill . type = Image . Type . Filled ; manaFill . fillMethod = Image . FillMethod . Horizontal ; manaFill . preserveAspect = false ; manaFill . rectTransform . Fill ( 7 , 7 , 7 , 7 ) ;
manaMaxText = UI . Txt ( panel . transform , "Max" , "" , 28 , Color . white , TextAnchor . MiddleLeft , true ) ;
manaMaxText . rectTransform . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 0 , 0 ) , new Vector2 ( 150 , 26 ) , new Vector2 ( 300 , 40 ) , new Vector2 ( 0 , 0.5f ) ) ;
2026-10-29 13:58:23 +00:00
// 결과 덮개(승리·패배) — 왼쪽 「스테이지 선택」 · 오른쪽 = 다음 스테이지 / 다시 도전 (시작은 스테이지 선택 화면의 「시작」)
2026-10-29 13:21:25 +00:00
var ov = UI . Img ( BattleRoot , "Overlay" , Art . Square , new Color ( 0.05f , 0.03f , 0.1f , 0.55f ) , true ) ;
overlay = ov . rectTransform . Fill ( 0 , v . trayH , 0 , 0 ) ;
var card = UI . Img ( overlay , "Card" , Skin . PopupPlain , Pal . Panel ) ;
card . rectTransform . Place ( new Vector2 ( 0.5f , 0.5f ) , new Vector2 ( 0.5f , 0.5f ) , Vector2 . zero , new Vector2 ( 840 , 800 ) ) ;
overlayRibbon = UI . Img ( card . transform , "Ribbon" , Skin . Ribbon ( "Blue" ) , Skin . On ? Color . white : new Color ( 0 , 0 , 0 , 0 ) ) ;
overlayRibbon . rectTransform . Place ( new Vector2 ( 0.5f , 1 ) , new Vector2 ( 0.5f , 1 ) , new Vector2 ( 0 , 50 ) , new Vector2 ( 560 , 230 ) ) ;
overlayTitle = UI . Txt ( overlayRibbon . transform , "Title" , "" , 64 , Skin . On ? Color . white : Pal . Purple , TextAnchor . MiddleCenter , true ) ; overlayTitle . rectTransform . Fill ( 120 , 70 , 120 , 50 ) ;
overlayInfo = UI . Txt ( card . transform , "Info" , "" , 34 , Pal . Ink ) ; overlayInfo . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 0 , - 350 ) , new Vector2 ( - 60 , 400 ) ) ;
2026-10-29 13:58:23 +00:00
var ex = UI . TextBtn ( card . transform , "Exit" , "스테이지 선택" , Pal . Gray , 40 , ( ) = > OpenStageSelect ( B . State = = BattleState . Won ? G . StageFocus : B . Stage ) , out _ ) ;
ex . GetComponent < RectTransform > ( ) . Place ( new Vector2 ( 0.5f , 0 ) , new Vector2 ( 0.5f , 0 ) , new Vector2 ( - 195 , 90 ) , new Vector2 ( 370 , 150 ) ) ;
var ob = UI . TextBtn ( card . transform , "Go" , "" , Pal . Green , 44 , ( ) = > OnOverlayButton ( ) , out overlayBtnLabel ) ;
ob . GetComponent < RectTransform > ( ) . Place ( new Vector2 ( 0.5f , 0 ) , new Vector2 ( 0.5f , 0 ) , new Vector2 ( 195 , 90 ) , new Vector2 ( 370 , 150 ) ) ;
// 시험 버튼(덮개 위 — 결과 화면에서도 누를 수 있게)
2026-10-29 13:21:25 +00:00
var tb = UI . TextBtn ( BattleRoot , "TestBtn" , "시험" , Pal . Purple , 30 , ( ) = > OpenBattleTest ( ) , out _ ) ;
tb . GetComponent < RectTransform > ( ) . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 0 , 1 ) , new Vector2 ( 14 , - 94 ) , new Vector2 ( 130 , 72 ) , new Vector2 ( 0 , 1 ) ) ;
BattleRoot . gameObject . SetActive ( false ) ;
2026-10-29 13:58:23 +00:00
BuildStageScreen ( ) ;
2026-10-29 14:42:47 +00:00
BuildUnitScreen ( ) ;
2026-10-29 13:21:25 +00:00
}
2026-10-29 13:58:23 +00:00
/// 고른 스테이지(stageSel)로 새 전투 준비 + 전투 화면 띄움 — 시작(B.Start)은 부르는 쪽
2026-10-29 13:21:25 +00:00
public void NewBattleRound ( int seed )
{
foreach ( var v in unitViews . Values ) Kill ( v . rt . gameObject ) ;
unitViews . Clear ( ) ;
foreach ( var v in minionViews . Values ) Kill ( v . rt . gameObject ) ;
minionViews . Clear ( ) ;
2026-10-29 13:58:23 +00:00
B = G . NewBattle ( stageSel , seed , BattleTest ) ;
2026-10-29 14:42:47 +00:00
if ( Tab = = 1 ) { BattleRoot . gameObject . SetActive ( true ) ; StageRoot . gameObject . SetActive ( false ) ; UpdateTabBar ( ) ; }
2026-10-29 13:21:25 +00:00
rewardGiven = false ; lastBattleReward = BigInteger . Zero ;
var old = arenaImg . sprite ;
arenaImg . sprite = ArenaSprite ( B ) ;
if ( old ! = null & & old ! = Art . Square )
{
var tex = old . texture ;
if ( Application . isPlaying ) { Destroy ( old ) ; Destroy ( tex ) ; } else { DestroyImmediate ( old ) ; DestroyImmediate ( tex ) ; }
}
var cp = castleImg . rectTransform . anchoredPosition = TileToLocal ( B . CastlePos ) ;
// 성 체력 막대 = 지붕 위쪽 · 맵 맨 위를 넘지 않게
castleHpBar . anchoredPosition = new Vector2 ( cp . x , Mathf . Min ( cp . y + castleImg . rectTransform . sizeDelta . y * 0.5f - 6f , arena . sizeDelta . y * 0.5f - castleHpBar . sizeDelta . y * 0.5f - 6f ) ) ;
flagPop = 0f ;
BuildRivalRows ( ) ;
RefreshBattle ( ) ;
}
// ---------------- 상대방 스킬 줄: 그림 · 이름 · 수치(성 공격력 × %) · 재사용 막대 ----------------
void BuildRivalRows ( )
{
foreach ( var r in rivalRows ) Kill ( r . rt . gameObject ) ;
rivalRows . Clear ( ) ;
foreach ( var w in B . CastleShots ) AddRivalRow ( w . def . id , w . def . name , RivalInfo ( w . def ) , w , null ) ;
foreach ( var w in B . CastleSkills ) AddRivalRow ( w . def . id , w . def . name , RivalInfo ( w . def ) , w , null ) ;
foreach ( var s in B . Summoners ) AddRivalRow ( s . def . id , s . def . name , "" , null , s ) ;
rivalBox . sizeDelta = new Vector2 ( rivalBox . sizeDelta . x , RivalTop + rivalRows . Count * RivalRowH + 8f ) ;
}
void AddRivalRow ( string id , string name , string info , BWeapon w , BSummoner s )
{
float y = - RivalTop - rivalRows . Count * RivalRowH ;
var bg = UI . Img ( rivalBox , "Skill_" + id , Skin . RoundSoft , new Color ( 1f , 1f , 1f , 0.08f ) ) ;
// 한 줄 = 왼쪽 그림 · 오른쪽 이름 + 수치 2줄 · 맨 아래 재사용 막대
var rt = bg . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 0 , y ) , new Vector2 ( - 12 , RivalRowH - 6f ) , new Vector2 ( 0.5f , 1 ) ) ;
UI . Img ( rt , "Icon" , SkillIcon ( id ) , Color . white ) . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 0 , 1 ) , new Vector2 ( 4 , - 4 ) , new Vector2 ( 44 , 44 ) , new Vector2 ( 0 , 1 ) ) ;
UI . Txt ( rt , "Name" , name , 22 , Pal . Yellow , TextAnchor . MiddleLeft , true ) . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 26 , - 2 ) , new Vector2 ( - 58 , 28 ) , new Vector2 ( 0.5f , 1 ) ) ;
var tx = UI . Txt ( rt , "Info" , info , 18 , new Color32 ( 236 , 228 , 255 , 255 ) , TextAnchor . UpperLeft ) ;
tx . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 26 , - 30 ) , new Vector2 ( - 58 , 42 ) , new Vector2 ( 0.5f , 1 ) ) ;
var bar = UI . Bar ( rt , "Cd" , new Color ( 0.1f , 0.1f , 0.18f , 0.9f ) , new Color32 ( 255 , 200 , 90 , 255 ) , out var fill , true ) ;
bar . rectTransform . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 1 , 0 ) , new Vector2 ( 0 , 3 ) , new Vector2 ( - 12 , 7 ) , new Vector2 ( 0.5f , 0 ) ) ;
rivalRows . Add ( new RivalRow { rt = rt , bg = bg , fill = fill , info = tx , w = w , s = s , lastCd = w ! = null ? w . cd : s . cd } ) ;
}
/// 피해 = 성 공격력 × dmgPct % (battle.json) · 사거리 = 시험 배율 반영
2026-10-29 13:58:23 +00:00
string RivalInfo ( BAttackDef d ) = > AttackInfo ( d , B . Atk , B . Range ( d ) ) ;
/// 성 공격 한 가지 설명 2줄 — 피해(성 공격력 × %) · 덧붙는 효과 · 사거리(칸) (스테이지 정보 창과 같이 씀)
static string AttackInfo ( BAttackDef d , float atk , float range )
2026-10-29 13:21:25 +00:00
{
2026-10-29 13:58:23 +00:00
int dmg = Mathf . RoundToInt ( atk * d . dmgPct / 100f ) , sp = Mathf . RoundToInt ( atk * d . splashPct / 100f ) ;
2026-10-29 13:21:25 +00:00
string a = d . id = = "multi" ? $"피해 {dmg} × {d.count}발" : d . id = = "laser" ? $"줄 위 전원 {dmg}" : $"피해 {dmg}" ;
string b = d . splashRadius > 0f ? $"주변 {sp}" : d . slowSeconds > 0f ? $"감속 {d.slowPct:0}%" : d . stunSeconds > 0f ? $"기절 {d.stunSeconds:0.#}초" : "" ;
2026-10-29 13:58:23 +00:00
return a + "\n" + ( b . Length > 0 ? b + " · " : "" ) + $"{range:0.#}칸" ;
2026-10-29 13:21:25 +00:00
}
void RefreshRival ( )
{
rivalFace . sprite = RivalFace ( B . RivalOutfit , B . Enraged ) ;
rivalFace . color = B . CastleHitFlash > 0 ? new Color ( 1f , 0.75f , 0.75f ) : Color . white ;
rivalFrame . color = B . Enraged ? ( Color ) new Color32 ( 255 , 70 , 60 , 255 ) : ( Color ) new Color32 ( 255 , 110 , 150 , 255 ) ;
foreach ( var r in rivalRows )
{
float cd = r . w ! = null ? r . w . cd : r . s . cd , full = r . w ! = null ? r . w . def . interval * B . IntervalMul : r . s . def . interval ;
if ( cd > r . lastCd + 0.05f ) r . flash = 0.35f ; // 방금 발동 (TickBattle 에서 줄어듦)
r . lastCd = cd ;
r . fill . fillAmount = Mathf . Clamp01 ( 1f - cd / Mathf . Max ( 0.01f , full ) ) ;
r . bg . color = r . flash > 0f ? new Color ( 1f , 0.85f , 0.4f , 0.55f ) : new Color ( 1f , 1f , 1f , 0.08f ) ;
if ( r . s ! = null )
{
int n = 0 ; foreach ( var m in B . Minions ) if ( m . def = = r . s . def ) n + + ;
r . info . text = $"체력 {Mathf.RoundToInt(r.s.def.hp * B.SummonMul)}\n공격 {Mathf.RoundToInt(r.s.def.damage * B.SummonMul)} · {n}/{r.s.def.max}" ;
}
}
}
// ---------------- 깃발: 맵을 누른 곳 → 내 유닛이 먼저 깃발에 닿은 뒤 성으로 ----------------
void OnArenaTap ( PointerEventData e )
{
if ( B = = null | | B . State ! = BattleState . Fighting ) return ;
if ( RectTransformUtility . ScreenPointToLocalPointInRectangle ( arena , e . position , e . pressEventCamera , out var lp ) )
PlaceFlag ( new Vector2 ( lp . x / V . tileX + B . C . arenaW * 0.5f , lp . y / V . tileY + B . C . arenaH * 0.5f ) ) ;
}
public bool PlaceFlag ( Vector2 tile )
{
if ( B = = null | | ! B . SetFlag ( tile ) ) return false ;
flagPop = 0.25f ;
RefreshBattle ( ) ;
return true ;
}
BigInteger lastBattleReward ;
2026-10-29 13:58:23 +00:00
/// 결과 오른쪽 버튼 — 이겼고 다음 스테이지가 열렸으면 다음 스테이지 · 아니면 같은 스테이지 다시
2026-10-29 13:21:25 +00:00
void OnOverlayButton ( )
{
if ( B = = null ) return ;
2026-10-29 13:58:23 +00:00
BeginBattle ( NextOpen ( B ) ? B . Stage + 1 : B . Stage ) ;
2026-10-29 13:21:25 +00:00
}
2026-10-29 13:58:23 +00:00
bool NextOpen ( Battle b ) = > b . State = = BattleState . Won & & b . Stage < G . StageCount & & G . StageOpen ( b . Stage + 1 ) ;
2026-10-29 13:21:25 +00:00
void OnCard ( int slot )
{
if ( slot > = G . BattleSlots ) { OpenBattleUpgrade ( ) ; return ; }
DeployCard ( slot ) ;
}
/// 카드 = 즉시 소환(아래 가운데) — 위치 선택 없음
public bool DeployCard ( int slot )
{
var d = G . BattleUnit ( slot ) ;
if ( d = = null | | B = = null | | B . State ! = BattleState . Fighting ) return false ;
if ( B . Mana < d . cost ) { Toast ( "마나가 부족합니다" ) ; return false ; }
bool ok = B . Deploy ( d ) ;
RefreshBattle ( ) ;
return ok ;
}
public void OpenBattleUpgrade ( )
{
var p = NewPopup ( "BattleUpgrade" , "전투 강화" , 1000 , Pal . Orange ) ;
var t1 = UI . Txt ( p . body , "Slots" , "" , 40 , Pal . Ink , TextAnchor . MiddleLeft ) ; t1 . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 20 , - 90 ) , new Vector2 ( - 400 , 120 ) , new Vector2 ( 0 , 0.5f ) ) ;
var b1 = UI . TextBtn ( p . body , "UnlockSlot" , "" , Pal . Green , 34 , ( ) = > { var r = G . UnlockBattleSlot ( ) ; if ( r ! = BuyResult . Ok ) Toast ( BuyMsg ( r ) ) ; RefreshBattle ( ) ; RefreshAll ( ) ; } , out var l1 ) ;
b1 . GetComponent < RectTransform > ( ) . Place ( new Vector2 ( 1 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( - 190 , - 90 ) , new Vector2 ( 340 , 140 ) ) ;
var t2 = UI . Txt ( p . body , "Mana" , "" , 40 , Pal . Ink , TextAnchor . MiddleLeft ) ; t2 . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 20 , - 290 ) , new Vector2 ( - 400 , 120 ) , new Vector2 ( 0 , 0.5f ) ) ;
var b2 = UI . TextBtn ( p . body , "UpMana" , "" , Pal . Purple , 34 , ( ) = > { var r = G . UpgradeBattleMana ( ) ; if ( r ! = BuyResult . Ok ) Toast ( BuyMsg ( r ) ) ; RefreshBattle ( ) ; RefreshAll ( ) ; } , out var l2 ) ;
b2 . GetComponent < RectTransform > ( ) . Place ( new Vector2 ( 1 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( - 190 , - 290 ) , new Vector2 ( 340 , 140 ) ) ;
var note = UI . Txt ( p . body , "Note" , "비용·능력치 = 설계값(확정 전) · 강화는 다음 전투부터 적용" , 28 , Pal . Gray ) ; note . rectTransform . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 1 , 0 ) , new Vector2 ( 0 , 60 ) , new Vector2 ( - 40 , 60 ) ) ;
p . refresh = ( ) = >
{
var nu = G . BattleUnit ( G . BattleSlots ) ;
2026-10-29 14:42:47 +00:00
t1 . text = $"덱 칸 {G.BattleSlots} / {T.Battle.slotMax}" + ( G . BattleSlots < T . Battle . slotMax ? $"\n다음 칸: {(nu != null ? $" { nu . name } ( { nu . cost } 마 나 ) " : " 빈 칸 ")}" : "" ) ;
2026-10-29 13:21:25 +00:00
bool mx1 = G . BattleSlots > = T . Battle . slotMax ; l1 . text = mx1 ? "MAX" : "칸 열기\n" + Fmt . Num ( G . SlotUnlockPrice ( ) ) ; b1 . interactable = ! mx1 & & G . SlotUnlockPrice ( ) < = G . Coin ;
t2 . text = $"최대 마나 {G.BattleMaxMana} / {T.Battle.manaMaxCap}" ;
bool mx2 = G . BattleMaxMana > = T . Battle . manaMaxCap ; l2 . text = mx2 ? "MAX" : "최대 +1\n" + Fmt . Num ( G . ManaUpgradePrice ( ) ) ; b2 . interactable = ! mx2 & & G . ManaUpgradePrice ( ) < = G . Coin ;
} ;
p . refresh ( ) ;
}
// ---------------- 시험 설정: 맵 · 바위·언덕 · 성 투사체 · 성 기믹 · 사거리 배율 → 다음 전투 ----------------
BattleSetup StageDefaultSetup ( )
{
2026-10-29 13:58:23 +00:00
var lo = T . Battle . Loadout ( stageSel ) ;
2026-10-29 13:21:25 +00:00
return new BattleSetup { shots = new List < string > ( lo . shots ? ? new string [ 0 ] ) , gimmicks = new List < string > ( lo . gimmicks ? ? new string [ 0 ] ) } ;
}
public void OpenBattleTest ( )
{
var c = T . Battle ;
var s = BattleTest ! = null ? BattleTest . Clone ( ) : StageDefaultSetup ( ) ;
var def = StageDefaultSetup ( ) ;
if ( s . shots = = null ) s . shots = def . shots ;
if ( s . gimmicks = = null ) s . gimmicks = def . gimmicks ;
var p = NewPopup ( "BattleTest" , "전투 시험 설정" , 1600 , Pal . Purple ) ;
var refreshers = new List < System . Action > ( ) ;
float y = - 10f ;
var mapNames = new List < string > { "무작위" } ; mapNames . AddRange ( c . mapNames ) ;
y = TestRow ( p , refreshers , y , "맵" , mapNames , k = > s . mapType = = k - 1 , k = > s . mapType = k - 1 ) ;
y = TestRow ( p , refreshers , y , "바위·언덕" , new List < string > { "무작위" , "없음" , "있음" } , k = > s . obstacles = = k - 1 , k = > s . obstacles = k - 1 ) ;
var shotIds = c . shots . ConvertAll ( d = > d . id ) ;
y = TestRow ( p , refreshers , y , "성 투사체 (여러 개 선택)" , c . shots . ConvertAll ( d = > d . name ) , k = > s . shots . Contains ( shotIds [ k ] ) , k = > Toggle ( s . shots , shotIds [ k ] ) ) ;
var gIds = c . skills . ConvertAll ( d = > d . id ) ; gIds . AddRange ( c . summons . ConvertAll ( d = > d . id ) ) ;
var gNames = c . skills . ConvertAll ( d = > d . name ) ; gNames . AddRange ( c . summons . ConvertAll ( d = > d . name ) ) ;
y = TestRow ( p , refreshers , y , "성 기믹 (여러 개 선택)" , gNames , k = > s . gimmicks . Contains ( gIds [ k ] ) , k = > Toggle ( s . gimmicks , gIds [ k ] ) ) ;
var muls = new List < float > ( c . testRangeMuls ) ;
y = TestRow ( p , refreshers , y , "성 사거리 배율" , muls . ConvertAll ( m = > "× " + m . ToString ( "0.##" ) ) , k = > Mathf . Abs ( s . rangeMul - muls [ k ] ) < 1e-4f , k = > s . rangeMul = muls [ k ] ) ;
2026-10-29 13:58:23 +00:00
var note = UI . Txt ( p . body , "Note" , $"적용 = 이 설정으로 스테이지 {stageSel} 바로 시작(잠긴 스테이지도 가능 · 시험 전투는 클리어 기록 안 함) · 수치 = battle.json (설계값 · 확정 전)" , 28 , Pal . Gray ) ;
note . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 0 , y - 30 ) , new Vector2 ( - 20 , 90 ) , new Vector2 ( 0.5f , 1 ) ) ;
// 단계 기본으로 = 시험 해제(싸우는 중이면 기본 설정으로 다시 시작 · 스테이지 선택 화면이면 그대로)
var reset = UI . TextBtn ( p . body , "Reset" , "단계 기본으로" , Pal . Gray , 38 , ( ) = > { BattleTest = null ; CloseAllPopups ( ) ; if ( B ! = null & & B . State = = BattleState . Fighting ) BeginBattle ( stageSel ) ; else ShowStage ( stageSel ) ; } , out _ ) ;
2026-10-29 13:21:25 +00:00
reset . GetComponent < RectTransform > ( ) . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 0 , 0 ) , new Vector2 ( 220 , 80 ) , new Vector2 ( 400 , 130 ) ) ;
2026-10-29 13:58:23 +00:00
var apply = UI . TextBtn ( p . body , "Apply" , "적용 → 새 전투" , Pal . Green , 38 , ( ) = > { BattleTest = s ; CloseAllPopups ( ) ; BeginBattle ( stageSel ) ; } , out _ ) ;
2026-10-29 13:21:25 +00:00
apply . GetComponent < RectTransform > ( ) . Place ( new Vector2 ( 1 , 0 ) , new Vector2 ( 1 , 0 ) , new Vector2 ( - 220 , 80 ) , new Vector2 ( 400 , 130 ) ) ;
p . refresh = ( ) = > { foreach ( var r in refreshers ) r ( ) ; } ;
p . refresh ( ) ;
}
static void Toggle ( List < string > l , string id ) { if ( ! l . Remove ( id ) ) l . Add ( id ) ; }
float TestRow ( Popup p , List < System . Action > refreshers , float y , string label , List < string > names , System . Func < int , bool > isOn , System . Action < int > click )
{
UI . Txt ( p . body , "L" + label , label , 34 , Pal . Ink , TextAnchor . MiddleLeft ) . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 10 , y ) , new Vector2 ( - 20 , 52 ) , new Vector2 ( 0.5f , 1 ) ) ;
y - = 58f ;
var row = UI . Rect ( "R" + label , p . body ) . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 0 , y ) , new Vector2 ( 0 , 104 ) , new Vector2 ( 0.5f , 1 ) ) ;
var hl = row . gameObject . AddComponent < HorizontalLayoutGroup > ( ) ;
hl . spacing = 10 ; hl . childControlWidth = true ; hl . childControlHeight = true ; hl . childForceExpandWidth = true ; hl . childForceExpandHeight = true ;
for ( int i = 0 ; i < names . Count ; i + + )
{
int k = i ;
var b = UI . TextBtn ( row , "B" + i , names [ i ] , Pal . Gray , 30 , ( ) = > { click ( k ) ; p . refresh ? . Invoke ( ) ; } , out _ ) ;
refreshers . Add ( ( ) = > UI . SetBtnColor ( b , isOn ( k ) ? Pal . Pink : Pal . Gray ) ) ;
}
return y - 124f ;
}
// ---------------- 진행 · 갱신 ----------------
void TickBattle ( float dt )
{
if ( B = = null | | Tab ! = 1 ) return ;
B . Tick ( dt ) ;
flagPop = Mathf . Max ( 0f , flagPop - dt ) ;
foreach ( var r in rivalRows ) r . flash = Mathf . Max ( 0f , r . flash - dt ) ;
if ( B . State = = BattleState . Won & & ! rewardGiven ) { rewardGiven = true ; lastBattleReward = G . WinBattle ( B ) ; }
RefreshBattle ( ) ;
}
static string JoinNames ( List < string > l , string sep ) = > l . Count > 0 ? string . Join ( sep , l ) : "없음" ;
void RefreshBattle ( )
{
if ( B = = null | | castleImg = = null ) return ;
2026-10-29 13:58:23 +00:00
stageText . text = $"스테이지 {B.Stage}" + ( B . IsTest ? " <color=#9CF0FF>시험</color>" : "" ) + ( B . LastMinute & & B . State = = BattleState . Fighting ? " <color=#FF9CF5>마나 x2</color>" : "" ) ;
2026-10-29 13:21:25 +00:00
castleHpFill . fillAmount = B . CastleMaxHp > 0 ? B . CastleHp / B . CastleMaxHp : 0 ;
castleHpFill . color = B . CastleHealFlash > 0f ? ( Color ) new Color32 ( 90 , 230 , 120 , 255 ) : Pal . Red ;
castleHpText . text = Mathf . CeilToInt ( B . CastleHp ) . ToString ( ) ;
castleImg . sprite = CastleSprite ( B . Enraged ) ;
castleImg . color = B . CastleHitFlash > 0 ? new Color ( 1f , 0.75f , 0.75f ) : Color . white ;
manaText . text = Mathf . FloorToInt ( B . Mana ) . ToString ( ) ;
manaFill . fillAmount = B . MaxMana > 0 ? B . Mana / B . MaxMana : 0 ;
manaMaxText . text = $"최대: {Mathf.RoundToInt(B.MaxMana)}" ;
infoText . text = $"<color=#FFE27A>맵</color> {B.MapName} · 바위·언덕 {B.Map.Obstacles.Count}\n<color=#FFE27A>깃발</color> 맵을 누르면 유닛이 깃발을 거쳐 성으로" ;
RefreshRival ( ) ;
flagImg . gameObject . SetActive ( B . HasFlag & & B . State = = BattleState . Fighting ) ;
if ( B . HasFlag )
{
flagImg . rectTransform . anchoredPosition = TileToLocal ( B . FlagPos ) ;
flagImg . rectTransform . localScale = Vector3 . one * ( 1f + flagPop * 2f ) ;
flagImg . rectTransform . localEulerAngles = new Vector3 ( 0 , 0 , Mathf . Sin ( Time . unscaledTime * 3f ) * 3f ) ;
}
for ( int i = 0 ; i < cards . Count ; i + + )
{
var ( b , face , cost , lockImg , lockText ) = cards [ i ] ;
bool open = i < G . BattleSlots ; var d = G . BattleUnit ( i ) ;
face . gameObject . SetActive ( open & & d ! = null ) ; cost . transform . parent . gameObject . SetActive ( open & & d ! = null ) ;
2026-10-29 14:42:47 +00:00
lockImg . gameObject . SetActive ( ! open ) ; lockText . gameObject . SetActive ( ! open | | d = = null ) ;
2026-10-29 13:21:25 +00:00
if ( open & & d ! = null ) { face . sprite = UnitSprite ( d . id ) ; cost . text = d . cost . ToString ( ) ; }
2026-10-29 14:42:47 +00:00
lockText . text = ! open ? ( i = = G . BattleSlots ? "강화" : "" ) : d = = null ? "빈 칸" : "" ;
2026-10-29 13:21:25 +00:00
bool afford = open & & d ! = null & & B . Mana > = d . cost ;
var ci = b . GetComponent < Image > ( ) ;
if ( Skin . On ) { ci . sprite = open ? Skin . CardOn : Skin . CardOff ; ci . color = ! open ? new Color ( 0.8f , 0.8f , 0.86f ) : ( afford ? Color . white : new Color ( 0.55f , 0.6f , 0.72f ) ) ; }
else ci . color = ! open ? new Color32 ( 70 , 60 , 80 , 255 ) : ( afford ? new Color32 ( 90 , 160 , 230 , 255 ) : new Color32 ( 110 , 120 , 140 , 255 ) ) ;
}
// 내 유닛 (기절 = 별 · 감속 = 파란 빛)
var alive = new HashSet < BUnit > ( B . Units ) ;
var dead = new List < BUnit > ( ) ;
foreach ( var kv in unitViews ) if ( ! alive . Contains ( kv . Key ) ) dead . Add ( kv . Key ) ;
foreach ( var u in dead ) { Kill ( unitViews [ u ] . rt . gameObject ) ; unitViews . Remove ( u ) ; }
foreach ( var u in B . Units )
{
if ( ! unitViews . TryGetValue ( u , out var v ) )
{
var im = UI . Img ( unitLayer , "U" + u . uid , UnitSprite ( u . def . id ) , Color . white ) ;
float sz = u . def . radius * V . unitArt * V . tileX ; // 그림 한 변 = 반지름 × unitArt 칸(클래시 로얄 병사 키 ≈ 1.5칸)
im . rectTransform . anchorMin = im . rectTransform . anchorMax = new Vector2 ( 0.5f , 0.5f ) ; im . rectTransform . pivot = new Vector2 ( 0.5f , 0f ) ; im . rectTransform . sizeDelta = new Vector2 ( sz , sz ) ; // 그림 발밑 = 유닛 자리
var hb = UI . Bar ( im . transform , "Hp" , new Color ( 0.1f , 0.1f , 0.18f , 0.85f ) , new Color32 ( 90 , 200 , 255 , 255 ) , out var hf , true ) ;
hb . rectTransform . Place ( new Vector2 ( 0.5f , 1 ) , new Vector2 ( 0.5f , 1 ) , new Vector2 ( 0 , 6 ) , new Vector2 ( sz * 0.6f , 12 ) ) ;
var st = UI . Img ( im . transform , "Stun" , StunSprite ( ) , Color . white ) ;
st . rectTransform . Place ( new Vector2 ( 0.5f , 1 ) , new Vector2 ( 0.5f , 1 ) , new Vector2 ( 0 , 30 ) , new Vector2 ( sz * 0.7f , sz * 0.35f ) ) ;
v = ( im . rectTransform , hf , st ) ; unitViews [ u ] = v ;
}
v . rt . anchoredPosition = TileToLocal ( u . pos ) + ( u . def . flying ? new Vector2 ( 0 , V . flyLift * V . tileY ) : Vector2 . zero ) ;
v . hp . fillAmount = Mathf . Clamp01 ( u . hp / Mathf . Max ( 1f , u . def . hp ) ) ;
v . rt . GetComponent < Image > ( ) . color = u . hitFlash > 0 ? new Color ( 1f , 0.6f , 0.6f ) : ( u . slow > 0f ? new Color ( 0.55f , 0.78f , 1f ) : Color . white ) ;
v . rt . localScale = Vector3 . one * ( u . attacking ? 1f + 0.08f * Mathf . Sin ( Time . unscaledTime * 18f ) : 1f ) ;
v . stun . gameObject . SetActive ( u . stun > 0f ) ;
if ( u . stun > 0f ) v . stun . rectTransform . localEulerAngles = new Vector3 ( 0 , 0 , Mathf . Sin ( Time . unscaledTime * 6f ) * 15f ) ;
}
// 성 소환수
var aliveM = new HashSet < BMinion > ( B . Minions ) ;
var deadM = new List < BMinion > ( ) ;
foreach ( var kv in minionViews ) if ( ! aliveM . Contains ( kv . Key ) ) deadM . Add ( kv . Key ) ;
foreach ( var m in deadM ) { Kill ( minionViews [ m ] . rt . gameObject ) ; minionViews . Remove ( m ) ; }
foreach ( var m in B . Minions )
{
if ( ! minionViews . TryGetValue ( m , out var v ) )
{
var im = UI . Img ( unitLayer , "M" + m . uid , MinionSprite ( m . def . ranged ) , Color . white ) ;
float sz = m . def . radius * V . unitArt * V . tileX ;
im . rectTransform . anchorMin = im . rectTransform . anchorMax = new Vector2 ( 0.5f , 0.5f ) ; im . rectTransform . pivot = new Vector2 ( 0.5f , 0f ) ; im . rectTransform . sizeDelta = new Vector2 ( sz , sz ) ;
var hb = UI . Bar ( im . transform , "Hp" , new Color ( 0.1f , 0.1f , 0.18f , 0.85f ) , Pal . Red , out var hf , true ) ;
hb . rectTransform . Place ( new Vector2 ( 0.5f , 1 ) , new Vector2 ( 0.5f , 1 ) , new Vector2 ( 0 , 6 ) , new Vector2 ( sz * 0.6f , 12 ) ) ;
v = ( im . rectTransform , hf ) ; minionViews [ m ] = v ;
}
v . rt . anchoredPosition = TileToLocal ( m . pos ) ;
v . hp . fillAmount = Mathf . Clamp01 ( m . hp / Mathf . Max ( 1f , m . maxHp ) ) ;
v . rt . GetComponent < Image > ( ) . color = m . hitFlash > 0 ? new Color ( 1f , 0.6f , 0.6f ) : Color . white ;
v . rt . localScale = Vector3 . one * ( m . attacking ? 1f + 0.08f * Mathf . Sin ( Time . unscaledTime * 18f ) : 1f ) ;
}
// 앞뒤 순서 — 아래(가까운) 것이 위(먼) 것을 가린다 · 성은 가운데 줄 기준 · 나는 유닛은 맨 앞
depth . Clear ( ) ;
depth . Add ( ( castleImg . transform , B . CastlePos . y ) ) ;
foreach ( var kv in unitViews ) depth . Add ( ( kv . Value . rt , kv . Key . def . flying ? kv . Key . pos . y - 1000f : kv . Key . pos . y ) ) ;
foreach ( var kv in minionViews ) depth . Add ( ( kv . Value . rt , kv . Key . pos . y ) ) ;
depth . Sort ( ( a , b ) = > b . key . CompareTo ( a . key ) ) ;
for ( int i = 0 ; i < depth . Count ; i + + ) if ( depth [ i ] . t . GetSiblingIndex ( ) ! = i ) depth [ i ] . t . SetSiblingIndex ( i ) ;
// 투사체 — 크기 = 칸 가로 기준 · 유닛 그림이 발밑에서 위로 서 있으므로 몸통 높이(0.9칸)로 띄워 날린다(파이어 볼 = 땅에 떨어짐)
while ( shotViews . Count < B . Shots . Count ) { var s = UI . Img ( fxLayerB , "Shot" , Art . Circle , Color . white ) ; s . rectTransform . anchorMin = s . rectTransform . anchorMax = new Vector2 ( 0.5f , 0.5f ) ; shotViews . Add ( s ) ; }
float tw = V . tileX ;
for ( int i = 0 ; i < shotViews . Count ; i + + )
{
bool on = i < B . Shots . Count ; shotViews [ i ] . gameObject . SetActive ( on ) ; if ( ! on ) continue ;
var sh = B . Shots [ i ] ; var im = shotViews [ i ] ; var rt = im . rectTransform ;
rt . anchoredPosition = TileToLocal ( sh . pos ) + new Vector2 ( 0f , sh . type = = "fireball" ? 0f : 0.9f * V . tileY ) ; rt . localEulerAngles = Vector3 . zero ; im . sprite = Art . Circle ;
switch ( sh . type )
{
case "fireball" : im . sprite = FireballSprite ( ) ; rt . sizeDelta = Vector2 . one * 1.6f * tw ; im . color = Color . white ; break ;
case "splash" : rt . sizeDelta = Vector2 . one * 0.7f * tw ; im . color = new Color32 ( 255 , 160 , 60 , 255 ) ; break ;
case "slow" : rt . sizeDelta = Vector2 . one * 0.6f * tw ; im . color = new Color32 ( 140 , 220 , 255 , 255 ) ; break ;
case "multi" : { var dp = TileVec ( sh . dir ) ; rt . sizeDelta = new Vector2 ( 0.85f , 0.35f ) * tw ; im . color = new Color32 ( 210 , 255 , 250 , 255 ) ; rt . localEulerAngles = new Vector3 ( 0 , 0 , Mathf . Atan2 ( dp . y , dp . x ) * Mathf . Rad2Deg ) ; break ; }
case "normal" : rt . sizeDelta = Vector2 . one * 0.45f * tw ; im . color = new Color32 ( 255 , 240 , 160 , 255 ) ; break ;
case "minion" : rt . sizeDelta = Vector2 . one * 0.4f * tw ; im . color = new Color32 ( 255 , 90 , 90 , 255 ) ; break ;
default : rt . sizeDelta = Vector2 . one * 0.6f * tw ; im . color = sh . unitId = = 4 ? new Color32 ( 255 , 120 , 255 , 255 ) : ( sh . unitId = = 6 ? new Color32 ( 255 , 140 , 60 , 255 ) : new Color32 ( 220 , 255 , 220 , 255 ) ) ; break ;
}
}
// 순간 효과 — 레이저 · 번개 · 폭발 · 소환
while ( fxViews . Count < B . Fx . Count ) fxViews . Add ( UI . Img ( fxLayerB , "Fx" , Art . SoftCircle , Color . white ) ) ;
for ( int i = 0 ; i < fxViews . Count ; i + + )
{
bool on = i < B . Fx . Count ; fxViews [ i ] . gameObject . SetActive ( on ) ; if ( ! on ) continue ;
var f = B . Fx [ i ] ; var im = fxViews [ i ] ; var rt = im . rectTransform ;
float k = Mathf . Clamp01 ( f . t / Mathf . Max ( 0.01f , f . life ) ) ;
rt . anchorMin = rt . anchorMax = new Vector2 ( 0.5f , 0.5f ) ; rt . localEulerAngles = Vector3 . zero ;
switch ( f . type )
{
case "laser" :
{
var d = TileVec ( f . b - f . a ) ; // 화면 px(세로가 짧은 땅) 기준 길이·각도
im . sprite = BeamSprite ( ) ; rt . pivot = new Vector2 ( 0f , 0.5f ) ;
rt . anchoredPosition = TileToLocal ( f . a ) ; rt . sizeDelta = new Vector2 ( d . magnitude , f . r * tw * ( 1.2f - 0.5f * k ) ) ;
rt . localEulerAngles = new Vector3 ( 0 , 0 , Mathf . Atan2 ( d . y , d . x ) * Mathf . Rad2Deg ) ;
im . color = new Color ( 1f , 1f , 1f , 1f - k * 0.7f ) ;
break ;
}
case "lightning" :
{
var d = TileVec ( f . a - f . b ) ;
im . sprite = BoltSprite ( ) ; rt . pivot = new Vector2 ( 0.5f , 0f ) ;
rt . anchoredPosition = TileToLocal ( f . b ) ; rt . sizeDelta = new Vector2 ( 1.75f * tw , d . magnitude ) ;
rt . localEulerAngles = new Vector3 ( 0 , 0 , Mathf . Atan2 ( d . y , d . x ) * Mathf . Rad2Deg - 90f ) ;
im . color = new Color ( 1f , 1f , 1f , 1f - k * 0.6f ) ;
break ;
}
case "blast" :
im . sprite = Art . SoftCircle ; rt . pivot = new Vector2 ( 0.5f , 0.5f ) ;
rt . anchoredPosition = TileToLocal ( f . a ) ; rt . sizeDelta = TileVec ( Vector2 . one * f . r * 2f ) * ( 1.3f + 0.4f * k ) ; // 땅 위 타원 · 부드러운 원이라 1.3배 = 눈에 보이는 크기 ≈ 피해 반경
im . color = new Color ( 1f , 0.5f , 0.05f , 1f - 0.8f * k ) ;
break ;
case "heal" : // 성벽 회복 — 초록 빛이 성을 감쌈
im . sprite = Art . SoftCircle ; rt . pivot = new Vector2 ( 0.5f , 0.5f ) ;
rt . anchoredPosition = TileToLocal ( f . a ) ; rt . sizeDelta = TileVec ( Vector2 . one * f . r * 2f ) * ( 1.1f + 0.3f * k ) ;
im . color = new Color ( 0.35f , 1f , 0.45f , 0.75f * ( 1f - k ) ) ;
break ;
default : // spawn
im . sprite = RingSprite ( ) ; rt . pivot = new Vector2 ( 0.5f , 0.5f ) ;
rt . anchoredPosition = TileToLocal ( f . a ) ; rt . sizeDelta = TileVec ( Vector2 . one * f . r * 2f ) * ( 0.6f + 0.6f * k ) ;
im . color = new Color ( 0.7f , 0.9f , 1f , 0.9f * ( 1f - k ) ) ;
break ;
}
}
2026-10-29 13:58:23 +00:00
// 결과 덮개(승리·패배만 — 시작 정보는 스테이지 선택 화면의 지도 버튼)
overlay . gameObject . SetActive ( B . State = = BattleState . Won | | B . State = = BattleState . Lost ) ;
overlayRibbon . sprite = Skin . Ribbon ( B . State = = BattleState . Won ? "Yellow" : B . State = = BattleState . Lost ? "Red" : "Blue" ) ;
if ( B . State = = BattleState . Won )
2026-10-29 13:21:25 +00:00
{
2026-10-29 13:58:23 +00:00
bool next = NextOpen ( B ) ;
overlayTitle . text = "승리!" ;
overlayInfo . text = $"스테이지 {B.Stage}. {B.C.StageName(B.Stage)} 클리어" + ( B . IsTest ? "\n(시험 전투 — 클리어 기록 안 함)" : next & & G . StageFocus = = B . Stage + 1 ? $"\n스테이지 {B.Stage + 1} 열림!" : B . Stage > = G . StageCount ? "\n모든 스테이지 클리어!" : "" ) + $"\n보상 {Fmt.Num(lastBattleReward)} 코인" ;
overlayBtnLabel . text = next ? "다음 스테이지" : "다시 하기" ;
2026-10-29 13:21:25 +00:00
}
else if ( B . State = = BattleState . Lost ) { overlayTitle . text = "패배" ; overlayInfo . text = $"성 체력 {Mathf.CeilToInt(B.CastleHp)} 남음\n강화 후 다시 도전하세요" ; overlayBtnLabel . text = "다시 도전" ; }
}
}
}