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 ;
2026-10-31 10:29:10 +00:00
class RivalRow { public RectTransform rt ; public Image bg , fill ; public Text info ; public BWeapon w ; public BSummoner s ; public BPower p ; public float lastCd , flash ; }
2026-10-29 13:21:25 +00:00
readonly List < RivalRow > rivalRows = new List < RivalRow > ( ) ;
const float RivalW = 240f , RivalTop = 116f , RivalRowH = 88f ; // 클래시 로얄 남은 시간 칸 자리 — 맵을 덜 가리게 작게
2026-10-31 10:29:10 +00:00
const int RivalCompactFrom = 7 ; const float RivalRowCompact = 54f ; // 줄이 7개 이상(보스 스테이지) = 좁은 줄(그림 · 이름 · 막대만 · 수치는 스테이지 정보)
float rivalRowH = RivalRowH ;
2026-10-29 13:21:25 +00:00
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 ) > ( ) ;
2026-10-30 01:44:46 +00:00
// 유닛 화면 = 그림 + 체력 막대 + 기절 별 + 애니 상태(2026-10-30 · 걷기 루프 · 공격 = 타격 순간에 hit 프레임이 오도록 예비 동작 → 타격 → 마무리)
2026-10-31 05:27:44 +00:00
class UnitView { public RectTransform rt ; public Image img , hp , stun ; public int art ; public Sprite baseSprite ; public Vector2 lastPos ; public float t , lastCd , sinceHit = 99f , sz ; public string act = "" ; }
2026-10-30 01:44:46 +00:00
readonly Dictionary < BUnit , UnitView > unitViews = new Dictionary < BUnit , UnitView > ( ) ;
2026-10-29 13:21:25 +00:00
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 15:31:24 +00:00
if ( t = = 2 ) OpenUnits ( ) ;
2026-10-29 14:42:47 +00:00
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 ) ;
2026-10-30 01:44:46 +00:00
/// 맵 그림 = 좌우 sideTiles 칸 맵 밖 장식(짙은 풀·나무 + 돌 턱 · 강은 끝까지) + 가운데 맵
/// 래스터 1칸 = 화면 2 px(도트 · 유닛 도트 2.1 px 와 같은 밀도 · 2026-10-30 PD 「배경 해상도가 떨어져 보임」 → 예전 칸당 30 px 를 가로 1.87·세로 1.53 배로 비균등 확대하던 것을 고침) · 점 필터
2026-10-29 13:21:25 +00:00
Sprite ArenaSprite ( Battle b )
{
2026-10-30 01:44:46 +00:00
var c = b . C ; var m = b . Map ;
float Sx = V . tileX * 0.5f , Sy = V . tileY * 0.5f ; // 칸 1개 = Sx × Sy 도트(28 × 23) — 화면 비율 그대로라 확대해도 도트가 정사각
float ox = c . view . sideTiles * Sx ;
int W = Mathf . RoundToInt ( ( c . arenaW + 2f * c . view . sideTiles ) * Sx ) , H = Mathf . RoundToInt ( c . arenaH * Sy ) ;
2026-10-29 13:21:25 +00:00
var r = new Raster ( W , H ) ;
2026-10-30 01:44:46 +00:00
// 그려진 지형(Resources/Art/Arena/arena_{river|plain}.png · 540× 736 도트 · scripts/pixel/arena_tiles.py · 같은 칸 좌표계)
// 이 그림이 있으면 풀밭·측면 장식·돌 턱·강은 그림으로 · 다리·장애물·앞마당·소환 고리는 맵마다 달라 아래 코드로 계속 그린다 · 없으면 코드 그림
2026-10-29 18:25:33 +00:00
var bgTex = Resources . Load < Texture2D > ( "Art/Arena/" + ( m . River ? "arena_river" : "arena_plain" ) ) ;
if ( bgTex ! = null & & bgTex . isReadable )
{
var bp = bgTex . GetPixels32 ( ) ; int bw = bgTex . width , bh = bgTex . height ;
for ( int y = 0 ; y < H ; y + + )
for ( int x = 0 ; x < W ; x + + )
r . Px [ y * W + x ] = bp [ Mathf . Min ( bh - 1 , y * bh / H ) * bw + Mathf . Min ( bw - 1 , x * bw / W ) ] ;
}
else
{
2026-10-30 01:44:46 +00:00
Color32 g0 = new Color32 ( 116 , 196 , 118 , 255 ) , g1 = new Color32 ( 106 , 186 , 108 , 255 ) , e0 = new Color32 ( 64 , 122 , 76 , 255 ) , e1 = new Color32 ( 58 , 112 , 70 , 255 ) ;
2026-10-29 18:25:33 +00:00
for ( int y = 0 ; y < H ; y + + )
for ( int x = 0 ; x < W ; x + + )
{
2026-10-30 01:44:46 +00:00
float tx = ( x - ox ) / Sx ;
int k = ( Mathf . FloorToInt ( tx ) + ( int ) ( y / Sy ) ) & 1 ;
2026-10-29 18:25:33 +00:00
r . Px [ y * W + x ] = tx > = 0f & & tx < c . arenaW ? ( k = = 0 ? g0 : g1 ) : ( k = = 0 ? e0 : e1 ) ;
}
// 맵 가장자리 돌 턱(클래시 로얄 아레나 옆 벽처럼 — 유닛이 못 나가는 곳)
2026-10-30 01:44:46 +00:00
Color curbDk = new Color32 ( 84 , 82 , 100 , 255 ) , curb = new Color32 ( 158 , 154 , 172 , 255 ) ;
2026-10-29 18:25:33 +00:00
foreach ( float ex in new [ ] { ox , W - ox } )
2026-10-29 13:21:25 +00:00
{
2026-10-30 01:44:46 +00:00
r . RRect ( ex - 3f , - 4 , ex + 3f , H + 4 , 1 , curbDk ) ;
r . RRect ( ex - 2f , - 4 , ex + 2f , H + 4 , 1 , curb ) ;
2026-10-29 13:21:25 +00:00
}
}
2026-10-30 01:44:46 +00:00
// 성 앞마당(모래 타원 — 칸 원은 화면에서 타원) · 소환 자리(아래 가운데 — 내 진영은 평지)
float pr = c . castle . radius + c . castleSeedPad ;
r . Ellipse ( ox + m . Castle . x * Sx , m . Castle . y * Sy , pr * Sx , pr * Sy , new Color32 ( 222 , 202 , 150 , 255 ) ) ;
EllipseRing ( r , ox + m . Castle . x * Sx , m . Castle . y * Sy , pr * Sx , pr * Sy , 1.5f , new Color32 ( 178 , 152 , 102 , 255 ) ) ;
var sp = b . SpawnPoint ; float sr = c . spawnJitter + 0.8f ;
r . Ellipse ( ox + sp . x * Sx , sp . y * Sy , sr * Sx , sr * Sy , new Color ( 1f , 1f , 1f , 0.16f ) ) ;
EllipseRing ( r , ox + sp . x * Sx , sp . y * Sy , sr * Sx , sr * Sy , 3f , new Color ( 1f , 1f , 1f , 0.55f ) ) ;
2026-10-31 10:35:28 +00:00
float rvY = c . riverY * Sy , rvH = c . riverHalf * Sy ; // 강 줄(도트)
2026-10-31 10:29:10 +00:00
if ( m . River & & bgTex = = null ) // 그려진 지형에는 강이 이미 있다 · 다리만 코드로
2026-10-29 13:21:25 +00:00
{
2026-10-31 10:35:28 +00:00
r . RRect ( - 4 , rvY - rvH , W + 4 , rvY + rvH , 3 , new Color32 ( 72 , 152 , 232 , 255 ) ) ; // 강은 맵 밖 장식까지 이어짐
r . RRect ( - 4 , rvY - rvH * 0.35f , W + 4 , rvY + rvH * 0.35f , 2 , new Color32 ( 112 , 188 , 246 , 255 ) ) ;
2026-10-29 13:21:25 +00:00
}
2026-10-31 10:35:28 +00:00
TintRaster ( r , c . Chapter ( b . Stage ) ) ; // 챕터 빛깔(늪 · 설원 · 화산 · 마왕성) — 바닥만 · 아래 다리·해자·절벽·필드·장애물은 제 빛깔
for ( int i = 0 ; m . River & & i < m . Bridges . Length ; i + + ) DrawBridge ( r , ox + m . Bridges [ i ] * Sx , rvY , rvH , m . BridgeHalf [ i ] * Sx , Sy ) ; // 다리 폭 = 다리마다(좁은 · 보통 · 넓은)
DrawStageTerrain ( r , b , ox , Sx , Sy ) ; // 해자 · 협곡 · 필드(GameApp.StageFx.cs)
2026-10-30 01:44:46 +00:00
// 장애물 = 도트 스프라이트(Resources/Art/Arena/rock_1~3 · hill_1~3 · Krea 2 + k2-pixel32 · 2026-10-30) · 먼 것(y 큰)부터 · 그림자 타원 먼저 · 그림 없으면 코드 그림
var obs = new List < BObstacle > ( m . Obstacles ) ;
obs . Sort ( ( p , q ) = > q . pos . y . CompareTo ( p . pos . y ) ) ;
foreach ( var o in obs ) r . Ellipse ( ox + o . pos . x * Sx + Sx * 0.12f , o . pos . y * Sy - Sy * 0.2f , o . r * Sx * 1.05f , o . r * Sy * 0.85f , new Color ( 0f , 0f , 0f , 0.22f ) ) ;
foreach ( var o in obs )
2026-10-29 13:21:25 +00:00
{
2026-10-30 01:44:46 +00:00
float x = ox + o . pos . x * Sx , y = o . pos . y * Sy , w = 2f * o . r * Sx ;
int k = 1 + Mathf . Abs ( Mathf . RoundToInt ( o . pos . x * 7.3f + o . pos . y * 3.1f ) ) % 3 ;
var t = Resources . Load < Texture2D > ( "Art/Arena/" + ( o . kind = = 0 ? "rock_" : "hill_" ) + k ) ;
if ( t ! = null & & t . isReadable ) { float h = w * t . height / t . width ; r . Blit ( t , x - w * 0.5f , y - o . r * Sy , w , h ) ; continue ; }
float rx = o . r * Sx , ry = o . r * Sy ;
2026-10-29 13:21:25 +00:00
if ( o . kind = = 0 ) // 바위
{
2026-10-30 01:44:46 +00:00
r . Ellipse ( x , y , rx , ry , new Color32 ( 112 , 110 , 124 , 255 ) ) ;
r . Ellipse ( x - rx * 0.12f , y + ry * 0.12f , rx * 0.8f , ry * 0.8f , new Color32 ( 152 , 150 , 164 , 255 ) ) ;
r . Ellipse ( x - rx * 0.36f , y + ry * 0.36f , rx * 0.26f , ry * 0.26f , new Color32 ( 196 , 194 , 206 , 255 ) ) ;
2026-10-29 13:21:25 +00:00
}
else // 언덕(흙 둔덕 + 풀)
{
2026-10-30 01:44:46 +00:00
r . Ellipse ( x , y , rx , ry , new Color32 ( 128 , 96 , 60 , 255 ) ) ;
r . Ellipse ( x - rx * 0.05f , y + ry * 0.08f , rx * 0.86f , ry * 0.86f , new Color32 ( 178 , 140 , 90 , 255 ) ) ;
r . Ellipse ( x - rx * 0.25f , y + ry * 0.3f , rx * 0.45f , ry * 0.45f , new Color32 ( 208 , 174 , 120 , 255 ) ) ;
r . Ellipse ( x + rx * 0.32f , y + ry * 0.42f , rx * 0.18f , ry * 0.18f , new Color32 ( 96 , 160 , 80 , 255 ) ) ;
2026-10-29 13:21:25 +00:00
}
}
2026-10-30 01:44:46 +00:00
return r . Sprite ( point : true ) ;
2026-10-29 13:21:25 +00:00
}
2026-10-30 01:44:46 +00:00
/// 타원 테두리(두께 w 도트) — 칸 원이 화면에서 타원이 되므로 Ring 대신
static void EllipseRing ( Raster r , float cx , float cy , float rx , float ry , float w , Color col )
= > r . Shape ( ( x , y ) = > { float dx = ( x - cx ) / rx , dy = ( y - cy ) / ry ; return Mathf . Abs ( Mathf . Sqrt ( dx * dx + dy * dy ) - 1f ) * Mathf . Min ( rx , ry ) - w * 0.5f ; } , col , cx - rx - w , cy - ry - w , cx + rx + w , cy + ry + w ) ;
2026-10-29 13:21:25 +00:00
static readonly Dictionary < string , Sprite > bcache = new Dictionary < string , Sprite > ( ) ;
2026-10-30 01:44:46 +00:00
/// 성 = 도트 스프라이트(Resources/Art/Arena/castle.png · castle_enraged.png · Krea 2 + k2-pixel32 · 2026-10-30 · 1 도트 = 화면 2 px) · 없으면 코드 그림
2026-10-29 13:21:25 +00:00
static Sprite CastleSprite ( bool enraged )
{
string key = "castle" + enraged ;
if ( bcache . TryGetValue ( key , out var s ) ) return s ;
2026-10-30 01:44:46 +00:00
s = Resources . Load < Sprite > ( "Art/Arena/" + ( enraged ? "castle_enraged" : "castle" ) ) ;
if ( s ! = null ) { bcache [ key ] = s ; return s ; }
2026-10-29 13:21:25 +00:00
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 ;
2026-10-31 10:29:10 +00:00
s = NewIcon ( id ) ; // 스테이지 50 새 요소(기술 · 소환 · 성 능력 · 필드 · 지형)
if ( s ! = null ) { bcache [ key ] = s ; return s ; }
2026-10-29 13:21:25 +00:00
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 ) ;
2026-10-31 10:29:10 +00:00
fieldLayer = UI . Rect ( "Fields" , arena ) . Fill ( ) ; // 필드 빛 · 장판 · 유성 표시 · 저주 고리(유닛 아래)
2026-10-29 13:21:25 +00:00
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 ;
2026-10-30 01:44:46 +00:00
if ( castleImg . sprite ! = null & & castleImg . sprite . texture ! = null & & castleImg . sprite . texture . filterMode = = FilterMode . Point )
castleImg . rectTransform . sizeDelta = castleImg . sprite . rect . size * 2f ; // 도트 성 = 1 도트 2 px(배경·유닛과 같은 밀도)
2026-10-29 13:21:25 +00:00
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 ) ) ;
2026-10-31 10:29:10 +00:00
ib . rectTransform . Place ( new Vector2 ( 0 , 0 ) , new Vector2 ( 0 , 0 ) , new Vector2 ( 12 , v . trayH + 10 ) , new Vector2 ( 430 , 140 ) , new Vector2 ( 0 , 0 ) ) ;
infoText = UI . Txt ( ib . transform , "Info" , "" , 22 , new Color32 ( 236 , 228 , 255 , 255 ) , TextAnchor . UpperLeft , true ) ;
2026-10-29 13:21:25 +00:00
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 ;
2026-10-31 10:29:10 +00:00
BuildStageFx ( ) ;
2026-10-29 13:21:25 +00:00
BuildRivalRows ( ) ;
RefreshBattle ( ) ;
}
// ---------------- 상대방 스킬 줄: 그림 · 이름 · 수치(성 공격력 × %) · 재사용 막대 ----------------
void BuildRivalRows ( )
{
foreach ( var r in rivalRows ) Kill ( r . rt . gameObject ) ;
rivalRows . Clear ( ) ;
2026-10-31 10:29:10 +00:00
int n = B . CastleShots . Count + B . CastleSkills . Count + B . Summoners . Count + B . Powers . Count ;
rivalRowH = n > = RivalCompactFrom ? RivalRowCompact : RivalRowH ;
2026-10-29 13:21:25 +00:00
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 ) ;
2026-10-31 10:29:10 +00:00
foreach ( var p in B . Powers ) AddRivalRow ( p . def . id , p . def . name , PowerInfo ( p . def , B . CastleMaxHp ) , null , null , p ) ;
rivalBox . sizeDelta = new Vector2 ( rivalBox . sizeDelta . x , RivalTop + rivalRows . Count * rivalRowH + 8f ) ;
2026-10-29 13:21:25 +00:00
}
2026-10-31 10:29:10 +00:00
void AddRivalRow ( string id , string name , string info , BWeapon w , BSummoner s , BPower p = null )
2026-10-29 13:21:25 +00:00
{
2026-10-31 10:29:10 +00:00
float y = - RivalTop - rivalRows . Count * rivalRowH ; bool compact = rivalRowH < RivalRowH ;
2026-10-29 13:21:25 +00:00
var bg = UI . Img ( rivalBox , "Skill_" + id , Skin . RoundSoft , new Color ( 1f , 1f , 1f , 0.08f ) ) ;
2026-10-31 10:29:10 +00:00
// 한 줄 = 왼쪽 그림 · 오른쪽 이름 + 수치 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 ) ) ;
float ic = compact ? 38f : 44f ;
UI . Img ( rt , "Icon" , SkillIcon ( id ) , Color . white ) . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 0 , 1 ) , new Vector2 ( 4 , - 4 ) , new Vector2 ( ic , ic ) , new Vector2 ( 0 , 1 ) ) ;
UI . Txt ( rt , "Name" , name , compact ? 21 : 22 , Pal . Yellow , TextAnchor . MiddleLeft , true ) . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( ic * 0.5f + 4f , compact ? - 6 : - 2 ) , new Vector2 ( - ic - 14f , 28 ) , new Vector2 ( 0.5f , 1 ) ) ;
2026-10-29 13:21:25 +00:00
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 ) ) ;
2026-10-31 10:29:10 +00:00
tx . gameObject . SetActive ( ! compact ) ;
2026-10-29 13:21:25 +00:00
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 ) ) ;
2026-10-31 10:29:10 +00:00
rivalRows . Add ( new RivalRow { rt = rt , bg = bg , fill = fill , info = tx , w = w , s = s , p = p , lastCd = w ! = null ? w . cd : s ! = null ? s . cd : 0f } ) ;
}
/// 성 능력 줄 — 보호막 = 켜진 동안 남은 시간 · 아니면 다음까지 · 2단 분노 = 체력 기준 · 가시 성벽 · 저주 오라 = 항상(막대 가득)
void RefreshPowerRow ( RivalRow r )
{
var p = r . p ; var d = p . def ; bool lit = false ;
switch ( d . id )
{
case "shield" :
lit = p . left > 0f ;
r . fill . fillAmount = lit ? p . left / Mathf . Max ( 0.01f , d . duration ) : Mathf . Clamp01 ( 1f - p . cd / Mathf . Max ( 0.01f , d . interval * B . IntervalMul ) ) ;
r . info . text = lit ? $"보호막 {Mathf.CeilToInt(B.Shield)} 남음\n{p.left:0.0}초" : PowerInfo ( d , B . CastleMaxHp ) ;
break ;
case "rage2" :
lit = B . Rage2 ;
r . fill . fillAmount = lit ? 1f : Mathf . Clamp01 ( 1f - ( B . CastleHp / Mathf . Max ( 1f , B . CastleMaxHp ) - d . hpRatio ) / Mathf . Max ( 0.01f , 1f - d . hpRatio ) ) ;
break ;
default : r . fill . fillAmount = 1f ; break ;
}
r . bg . color = lit ? new Color ( 1f , 0.55f , 0.35f , 0.45f + 0.15f * Mathf . Sin ( Time . unscaledTime * 6f ) ) : new Color ( 1f , 1f , 1f , 0.08f ) ;
2026-10-29 13:21:25 +00:00
}
/// 피해 = 성 공격력 × dmgPct % (battle.json) · 사거리 = 시험 배율 반영
2026-10-29 13:58:23 +00:00
string RivalInfo ( BAttackDef d ) = > AttackInfo ( d , B . Atk , B . Range ( d ) ) ;
2026-10-31 10:29:10 +00:00
/// 성 공격 한 가지 설명 2줄 — 피해(성 공격력 × %) · 덧붙는 효과 · 사거리(칸) (스테이지 정보 창과 같이 씀) · 새 기술 = 종류별
2026-10-29 13:58:23 +00:00
static string AttackInfo ( BAttackDef d , float atk , float range )
2026-10-29 13:21:25 +00:00
{
2026-10-31 10:29:10 +00:00
int dmg = Mathf . RoundToInt ( atk * d . dmgPct / 100f ) , sp = Mathf . RoundToInt ( atk * d . splashPct / 100f ) , dps = Mathf . RoundToInt ( atk * d . dpsPct / 100f ) ;
switch ( d . kind )
{
case "cloud" : return $"초당 {dps} · {d.duration:0.#}초\n반지름 {d.radius:0.#}칸 · {range:0.#}칸" + ( d . air ? "" : " · 땅만" ) ;
case "boulder" : return $"피해 {dmg} · 밀침 {d.knock:0.#}칸\n가장 많은 줄로 굴림" ;
case "meteor" : return $"{d.count}개 × {dmg}\n반지름 {d.radius:0.#}칸 · 예고 {d.warn:0.#}초" ;
case "nova" : return $"피해 {dmg} · 얼림 {d.freezeSeconds:0.#}초\n성 둘레 {range:0.#}칸" ;
case "tornado" : return $"초당 {dps} · {d.duration:0.#}초\n끌어당김 · 나는 유닛 밀침" ;
}
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 )
{
2026-10-31 10:29:10 +00:00
if ( r . p ! = null ) { RefreshPowerRow ( r ) ; continue ; }
2026-10-29 13:21:25 +00:00
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 + + ;
2026-10-31 10:29:10 +00:00
r . info . text = SummonInfo ( r . s . def , B . SummonMul , B . Atk , n ) ;
2026-10-29 13:21:25 +00:00
}
}
}
// ---------------- 깃발: 맵을 누른 곳 → 내 유닛이 먼저 깃발에 닿은 뒤 성으로 ----------------
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-31 10:29:10 +00:00
int s = NextOpen ( B ) ? B . Stage + 1 : B . Stage ;
if ( ! TryIntro ( s , - 1 ) ) BeginBattle ( s ) ; // 다음 스테이지에 처음 나오는 요소가 있으면 안내 카드부터
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-31 10:29:10 +00:00
return new BattleSetup { shots = new List < string > ( lo . shots ? ? new string [ 0 ] ) , gimmicks = new List < string > ( lo . gimmicks ? ? new string [ 0 ] ) , fields = new List < string > ( lo . fields ? ? new string [ 0 ] ) } ;
2026-10-29 13:21:25 +00:00
}
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 ;
2026-10-31 10:29:10 +00:00
if ( s . fields = = null ) s . fields = def . fields ;
var p = NewPopup ( "BattleTest" , "전투 시험 설정" , 1820 , Pal . Purple ) ;
2026-10-29 13:21:25 +00:00
var refreshers = new List < System . Action > ( ) ;
2026-10-31 10:29:10 +00:00
float y = - 6f ;
// 지도: 스테이지(고정 지도) · 무작위 · 평지/다리 1~3개 — 해자 · 협곡 = 기본(스테이지 그대로) · 없음 · 있음
var mapNames = new List < string > { "스테이지" , "무작위" } ; mapNames . AddRange ( c . mapNames ) ;
y = TestRow ( p , refreshers , y , "지도" , mapNames , k = > s . mapType = = ( k = = 0 ? BattleSetup . StageMap : k = = 1 ? BattleSetup . RandomMap : k - 2 ) , k = > s . mapType = k = = 0 ? BattleSetup . StageMap : k = = 1 ? BattleSetup . RandomMap : k - 2 ) ;
y = TestRow ( p , refreshers , y , "바위·언덕" , new List < string > { "기본" , "없음" , "있음" } , k = > s . obstacles = = k - 1 , k = > s . obstacles = k - 1 ) ;
var gates = new List < string > { "기본" , "없음" } ; for ( int g = 1 ; g < = ( c . moat ? . gates ? . Count ? ? 0 ) ; g + + ) gates . Add ( $"입구 {g}" ) ;
y = TestRow ( p , refreshers , y , c . moat ? . name ? ? "해자" , gates , k = > s . moat = = k - 1 , k = > s . moat = k - 1 ) ;
y = TestRow ( p , refreshers , y , c . canyon ? . name ? ? "협곡" , new List < string > { "기본" , "없음" , "있음" } , k = > s . canyon = = k - 1 , k = > s . canyon = k - 1 ) ;
var fIds = c . fields . ConvertAll ( d = > d . id ) ;
y = TestRow ( p , refreshers , y , "필드 (여러 개 선택)" , c . fields . ConvertAll ( d = > d . name ) , k = > s . fields . Contains ( fIds [ k ] ) , k = > Toggle ( s . fields , fIds [ k ] ) ) ;
2026-10-29 13:21:25 +00:00
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 ] ) ) ;
2026-10-31 10:29:10 +00:00
var gIds = c . skills . ConvertAll ( d = > d . id ) ; gIds . AddRange ( c . summons . ConvertAll ( d = > d . id ) ) ; gIds . AddRange ( c . powers . ConvertAll ( d = > d . id ) ) ;
var gNames = c . skills . ConvertAll ( d = > d . name ) ; gNames . AddRange ( c . summons . ConvertAll ( d = > d . name ) ) ; gNames . AddRange ( c . powers . ConvertAll ( d = > d . name ) ) ;
y = TestRow ( p , refreshers , y , "성 기믹 (기술 · 소환 · 성 능력 · 여러 개 선택)" , gNames , k = > s . gimmicks . Contains ( gIds [ k ] ) , k = > Toggle ( s . gimmicks , gIds [ k ] ) ) ;
2026-10-29 13:21:25 +00:00
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-31 10:29:10 +00:00
var note = UI . Txt ( p . body , "Note" , $"적용 = 이 설정으로 스테이지 {stageSel} 바로 시작(잠긴 스테이지도 가능 · 시험 전투는 클리어 기록 안 함) · 수치 = battle.json (설계값 · 확정 전)" , 26 , Pal . Gray ) ;
note . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 0 , y - 6 ) , new Vector2 ( - 20 , 80 ) , new Vector2 ( 0.5f , 1 ) ) ;
2026-10-29 13:58:23 +00:00
// 단계 기본으로 = 시험 해제(싸우는 중이면 기본 설정으로 다시 시작 · 스테이지 선택 화면이면 그대로)
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 ) ; }
2026-10-31 10:29:10 +00:00
/// 시험 줄 — 글 + 버튼(한 줄 최대 TestPerRow 개 · 넘치면 다음 줄)
const int TestPerRow = 6 ;
2026-10-29 13:21:25 +00:00
float TestRow ( Popup p , List < System . Action > refreshers , float y , string label , List < string > names , System . Func < int , bool > isOn , System . Action < int > click )
{
2026-10-31 10:29:10 +00:00
UI . Txt ( p . body , "L" + label , label , 30 , Pal . Ink , TextAnchor . MiddleLeft ) . rectTransform . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 10 , y ) , new Vector2 ( - 20 , 42 ) , new Vector2 ( 0.5f , 1 ) ) ;
y - = 44f ;
for ( int start = 0 , line = 0 ; start < names . Count ; start + = TestPerRow , line + + )
2026-10-29 13:21:25 +00:00
{
2026-10-31 10:29:10 +00:00
var row = UI . Rect ( "R" + label + line , p . body ) . Place ( new Vector2 ( 0 , 1 ) , new Vector2 ( 1 , 1 ) , new Vector2 ( 0 , y ) , new Vector2 ( 0 , 78 ) , new Vector2 ( 0.5f , 1 ) ) ;
var hl = row . gameObject . AddComponent < HorizontalLayoutGroup > ( ) ;
hl . spacing = 8 ; hl . childControlWidth = true ; hl . childControlHeight = true ; hl . childForceExpandWidth = true ; hl . childForceExpandHeight = true ;
for ( int i = start ; i < Mathf . Min ( names . Count , start + TestPerRow ) ; i + + )
{
int k = i ;
var b = UI . TextBtn ( row , "B" + i , names [ i ] , Pal . Gray , 24 , ( ) = > { click ( k ) ; p . refresh ? . Invoke ( ) ; } , out _ ) ;
refreshers . Add ( ( ) = > UI . SetBtnColor ( b , isOn ( k ) ? Pal . Pink : Pal . Gray ) ) ;
}
y - = 86f ;
2026-10-29 13:21:25 +00:00
}
2026-10-31 10:29:10 +00:00
return y - 8f ;
2026-10-29 13:21:25 +00:00
}
// ---------------- 진행 · 갱신 ----------------
2026-10-30 01:44:46 +00:00
/// 에디터 배치 검증용 — 재생 중이 아닐 때 애니 프레임을 흘릴 시간(초/틱) · 음수 = 정지 (EditorRender.AnimSeq)
public static float AnimDtOverride = - 1f ;
2026-10-29 13:21:25 +00:00
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 ;
2026-10-31 10:29:10 +00:00
castleHpFill . color = B . ShieldUp ? ( Color ) new Color32 ( 90 , 160 , 255 , 255 ) : B . CastleHealFlash > 0f | | B . TotemHeal > 0f ? ( Color ) new Color32 ( 90 , 230 , 120 , 255 ) : Pal . Red ;
castleHpText . text = Mathf . CeilToInt ( B . CastleHp ) + ( B . ShieldUp ? $" +{Mathf.CeilToInt(B.Shield)}" : "" ) ;
2026-10-29 13:21:25 +00:00
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)}" ;
2026-10-31 10:29:10 +00:00
infoText . text = $"<color=#FFE27A>맵</color> {B.MapName}" + ( B . Map . Obstacles . Count > 0 ? $" · 바위·언덕 {B.Map.Obstacles.Count}" : "" )
+ $"\n<color=#FFE27A>필드</color> {FieldNames()}\n<color=#FFE27A>깃발</color> 맵을 누르면 유닛이 깃발을 거쳐 성으로" ;
2026-10-29 13:21:25 +00:00
RefreshRival ( ) ;
2026-10-31 10:29:10 +00:00
RefreshStageFx ( ) ;
2026-10-29 13:21:25 +00:00
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 ) ; }
2026-10-30 01:44:46 +00:00
float adt = Application . isPlaying ? Time . unscaledDeltaTime : Mathf . Max ( 0f , AnimDtOverride ) , fps = UnitArt . Fps ;
2026-10-29 13:21:25 +00:00
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 ) ) ;
2026-10-31 05:27:44 +00:00
v = new UnitView { rt = im . rectTransform , img = im , hp = hf , stun = st , art = UnitArt . ArtOf ( u . def . id ) , baseSprite = im . sprite , lastPos = u . pos , lastCd = u . cd , sz = sz } ;
2026-10-30 01:44:46 +00:00
unitViews [ u ] = v ;
2026-10-29 13:21:25 +00:00
}
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 ) ) ;
2026-10-31 10:35:28 +00:00
v . img . color = UnitTint ( u ) ; // 맞음 · 얼림 · 화상 · 중독 · 저주 · 진흙 · 감속 빛깔(GameApp.StageFx.cs)
2026-10-30 01:44:46 +00:00
// 애니 프레임 — 공격: 타격(cd 가 interval 로 되돌아간 순간)에 hit 프레임이 오도록 cd 로 예비 동작을 맞추고 타격 뒤 마무리 프레임 · 걷기: 자리가 바뀌는 동안(나는 유닛은 항상) 루프 · 그 외 = 걷기 0번 · 프레임 없는 유닛 = 예전 흔들림
2026-10-31 05:27:44 +00:00
var walk = v . art > 0 ? UnitArt . Frames ( v . art , "walk" ) : null ; var atk = v . art > 0 ? UnitArt . Frames ( v . art , "attack" ) : null ; var idle = v . art > 0 ? UnitArt . Frames ( v . art , "idle" ) : null ;
2026-10-30 01:44:46 +00:00
bool moving = ( u . pos - v . lastPos ) . sqrMagnitude > 1e-8f ; v . lastPos = u . pos ;
if ( u . cd > v . lastCd + 1e-4f ) v . sinceHit = 0f ; else v . sinceHit + = adt ;
v . lastCd = u . cd ;
2026-10-31 10:35:28 +00:00
if ( u . attacking & & ! u . Frozen & & atk ! = null ) // 기절·얼림 중엔 멈춘 자세
2026-10-30 01:44:46 +00:00
{
int h = UnitArt . HitFrame ( v . art , atk . Length ) , f = - 1 ;
2026-10-31 05:27:44 +00:00
float afps = UnitArt . ClipFps ( v . art , "attack" ) ; // 공격은 클립 fps(15 · PD 2026-10-31 「조금 더 빨라야」) · 없으면 전역 10
2026-10-30 02:51:06 +00:00
// 공격 클립 = 5 프레임 안(0 = 대기 자세 · 예비 · 타격 · 마무리) · 고정 fps 로 짧게 재생(PD 2026-10-30 「공격은 최대 5 프레임 · 과한 역동성 금지」) · 타격 전후 밖의 시간 = 0 번(대기)
2026-10-31 05:27:44 +00:00
if ( v . sinceHit * afps < atk . Length - h ) f = h + ( int ) ( v . sinceHit * afps ) ; // 타격 틱 = hit 프레임 → 마무리 (h+1 로 시작하면 hit 프레임이 건너뛰어짐)
else { f = h - Mathf . CeilToInt ( u . cd * afps ) ; if ( f < 0 ) f = 0 ; } // 다음 타격 직전 예비 동작 · 그 전 = 0 번
2026-10-30 01:44:46 +00:00
v . img . sprite = atk [ Mathf . Clamp ( f , 0 , atk . Length - 1 ) ] ; v . act = "attack" ; v . rt . localScale = Vector3 . one ;
}
2026-10-31 10:35:28 +00:00
else if ( ( moving | | u . def . flying ) & & ! u . Frozen & & walk ! = null )
2026-10-30 01:44:46 +00:00
{
if ( v . act ! = "walk" ) v . t = 0f ;
v . t + = adt ; int wf = ( int ) ( v . t * fps ) ; v . img . sprite = walk [ wf % walk . Length ] ; v . act = "walk" ; v . rt . localScale = Vector3 . one ;
2026-10-30 04:27:30 +00:00
// 들썩임은 걷기 프레임 안에 있음(CONTACT·DOWN·PASSING·UP · 2026-10-30 pixel-art-principles) — 코드 들썩임은 뺌(겹치면 2칸 튐)
2026-10-30 01:44:46 +00:00
}
2026-10-31 05:27:44 +00:00
else if ( walk ! = null | | atk ! = null | | idle ! = null )
{
// 대기 = idle 클립이 있으면 루프(③ 원화 스타일 409·105·416 · 2026-10-30 PD 승인) · 없으면 걷기 0번
if ( idle ! = null ) { if ( v . act ! = "idle" ) v . t = 0f ; v . t + = adt ; v . img . sprite = idle [ ( int ) ( v . t * fps ) % idle . Length ] ; }
else v . img . sprite = walk ! = null ? walk [ 0 ] : v . baseSprite ;
v . act = "idle" ; v . rt . localScale = Vector3 . one ;
}
2026-10-30 01:44:46 +00:00
else v . rt . localScale = Vector3 . one * ( u . attacking ? 1f + 0.08f * Mathf . Sin ( Time . unscaledTime * 18f ) : 1f ) ;
2026-10-31 05:27:44 +00:00
// 그림 폭 = 스프라이트 비율(세로 = sz 고정 · 발밑 pivot) — ③ 긴 무기 유닛의 128× 96 프레임(409 대검)이 눌리지 않게(2026-10-30)
if ( v . img . sprite ! = null ) { var rr = v . img . sprite . rect ; float wv = v . sz * rr . width / Mathf . Max ( 1f , rr . height ) ; if ( Mathf . Abs ( v . rt . sizeDelta . x - wv ) > 0.5f ) v . rt . sizeDelta = new Vector2 ( wv , v . sz ) ; }
2026-10-29 13:21:25 +00:00
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 ) )
{
2026-10-31 10:29:10 +00:00
var im = UI . Img ( unitLayer , "M" + m . uid , MinionArt ( m . def ) , Color . white ) ;
2026-10-29 13:21:25 +00:00
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 ;
2026-10-31 10:29:10 +00:00
case "beam" : // 레이저 수호병 광선 — 청록
{
var d = TileVec ( f . b - f . a ) ;
im . sprite = BeamSprite ( ) ; rt . pivot = new Vector2 ( 0f , 0.5f ) ;
rt . anchoredPosition = TileToLocal ( f . a ) + new Vector2 ( 0f , 0.6f * V . tileY ) ; 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 ( 0.35f , 1f , 0.95f , 1f - k * 0.7f ) ;
break ;
}
case "nova" : // 얼음 파동 — 성에서 퍼지는 하늘색 고리
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.35f + 0.7f * k ) ;
im . color = new Color ( 0.7f , 0.93f , 1f , 1f - k ) ;
break ;
case "rubble" : // 바위가 멈춘 자리 — 흙먼지
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 ) * ( 1f + 0.4f * k ) ;
im . color = new Color ( 0.6f , 0.52f , 0.42f , 0.85f * ( 1f - k ) ) ;
break ;
case "shield" : // 보호막 켜짐 — 파란 고리
im . sprite = RingSprite ( ) ; rt . pivot = new Vector2 ( 0.5f , 0.5f ) ;
rt . anchoredPosition = TileToLocal ( f . a ) ; rt . sizeDelta = TileVec ( Vector2 . one * f . r * 2f ) * ( 1f + 0.5f * k ) ;
im . color = new Color ( 0.45f , 0.75f , 1f , 1f - k ) ;
break ;
2026-10-29 13:21:25 +00:00
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 = "다시 도전" ; }
}
}
}