2026-10-29 13:21:25 +00:00
using System ;
using System.Collections.Generic ;
using System.Numerics ;
using UnityEngine ;
using Vector2 = UnityEngine . Vector2 ;
// 전투 모드 — 클래시 로얄식 아레나(18× 32 칸). 맵 = 강 없는 평지 · 다리 1~3개 중 무작위 + 성 둘레 바위·언덕(땅 유닛은 돌아서 간다).
// 내 유닛 = 카드 1장 = 1마리 · 아래 가운데에서 바로 소환(약하지만 마나를 모아 많이 소환 = 물량) · 맵을 누르면 깃발 → 유닛이 깃발을 거쳐 성으로.
// 성(항상 위쪽 · 내 유닛보다 매우 강함) = 투사체 5종(일반·범위·멀티샷·감속·레이저) + 기믹(파이어 볼·라이트닝·소환수) + 안 맞으면 성벽 회복.
// 값 = Resources/Data/battle.json (설계값 · PD 확정 전)
namespace VS.Sim
{
[Serializable] public class BUnitDef { public int id ; public string name ; public int cost ; public float hp , damage , interval , range , speed , radius ; public bool flying , projectile ; }
/// regen = 마지막으로 맞은 뒤 regenDelay 초가 지나면 regenInterval 초마다 최대 체력의 regenPct %
[Serializable] public class BCastleDef { public float y , xMin , xMax , radius , hp , hpGrowth , atk , atkGrowth , summonGrowth , enrageHpRatio , enrageIntervalMul , regenDelay , regenPct , regenInterval ; }
/// 성 투사체(normal·splash·multi·slow·laser) · 성 스킬(fireball·lightning) — 피해 = 성 공격력 × dmgPct% · 사거리 = 성 가장자리부터
[Serializable]
public class BAttackDef
{
public string id , name ; public float dmgPct , interval , range , speed ;
public float splashPct , splashRadius , spreadDeg , slowPct , slowSeconds , width , beamSeconds , stunSeconds ; public int count ;
}
/// 성 소환수 — 성 둘레 집에 주둔 · 집에서 guardRange 안에 들어온 내 유닛만 공격 · 종류별 최대 max
[Serializable] public class BSummonDef { public string id , name ; public bool ranged ; public int max ; public float interval , hp , damage , attackRange , attackInterval , speed , guardRange , radius , homeDist , shotSpeed ; }
2026-10-29 13:58:23 +00:00
[Serializable] public class BLoadout { public int stage ; public string name ; public string [ ] shots , gimmicks ; } // 스테이지 한 줄 = 번호 · 이름 · 성 투사체 · 기믹
2026-10-29 13:21:25 +00:00
[Serializable] public class BBridgeSet { public float [ ] x ; }
/// 전투 화면 비율(화면 전용 · 시뮬 무관) — 칸 1개 = 가로 tileX · 세로 tileY px · 맵 좌우 장식 sideTiles 칸 · 아래 카드 칸 trayH px · 유닛 그림 한 변 = 반지름 × unitArt 칸 · 나는 유닛 = flyLift 칸 위
[Serializable] public class BViewDef { public float tileX , tileY , sideTiles , trayH , unitArt , flyLift ; }
[Serializable]
public class BattleCfg
{
public float arenaW , arenaH , riverY , riverHalf , bridgeHalf , bridgeJitter ;
public float [ ] mapWeights ; public string [ ] mapNames ; public List < BBridgeSet > bridgeSets ;
public float obstacleChance ; public int obstacleMin , obstacleMax , obstaclePiecesMin , obstaclePiecesMax ;
public float obstacleRadiusMin , obstacleRadiusMax , obstacleDistMin , obstacleDistMax , obstacleArcMin , obstacleArcMax , obstacleSpacing , obstacleWobble , obstaclePad , obstacleCastleGap , castleSeedPad ;
public float spawnY , spawnJitter , unitSight , unitShotSpeed , castleRetarget , summonArcMin , summonArcMax , flagReach ;
public float unitSpacing , unitPush ; // 내 유닛끼리 겹침 방지: 반지름 합 × unitSpacing 보다 가까우면 초당 unitPush 비율로 밀어냄
public int [ ] rivalOutfits ; // 상대방(여성 캐릭터) 얼굴 = 방송 캐릭터 그림의 의상 번호 · 단계마다 돌아가며
public float battleSeconds , lastMinuteSeconds , lastMinuteRegenMul , manaStart , manaRegenSeconds ;
public int manaMaxBase , manaMaxCap ; public string [ ] manaUpgradeCost ; public int slotStart , slotMax ; public string [ ] slotUnlockCost ;
public float rewardPassSeconds , rewardPassSecondsPerStage ;
public List < BUnitDef > units ; public BCastleDef castle ;
public List < BAttackDef > shots , skills ; public List < BSummonDef > summons ; public List < BLoadout > stageLoadouts ;
public float [ ] testRangeMuls ;
public BViewDef view ;
public BAttackDef Shot ( string id ) = > shots . Find ( s = > s . id = = id ) ;
public BAttackDef Skill ( string id ) = > skills . Find ( s = > s . id = = id ) ;
public BSummonDef Summon ( string id ) = > summons . Find ( s = > s . id = = id ) ;
public string GimmickName ( string id ) = > Skill ( id ) ? . name ? ? Summon ( id ) ? . name ? ? id ;
public string MapName ( int type ) = > mapNames ! = null & & type > = 0 & & type < mapNames . Length ? mapNames [ type ] : type . ToString ( ) ;
/// 단계 기본 구성 = stage 이하 중 가장 큰 stage 줄
public BLoadout Loadout ( int stage )
{
BLoadout best = null ;
if ( stageLoadouts ! = null ) foreach ( var l in stageLoadouts ) if ( l . stage < = stage & & ( best = = null | | l . stage > best . stage ) ) best = l ;
return best ? ? new BLoadout { shots = new string [ 0 ] , gimmicks = new string [ 0 ] } ;
}
2026-10-29 13:58:23 +00:00
// 스테이지 = stageLoadouts 줄 수만큼(하나씩 깨 나감) · 성 능력치는 스테이지마다 hpGrowth·atkGrowth·summonGrowth 배
public int StageCount { get { int n = 0 ; if ( stageLoadouts ! = null ) foreach ( var l in stageLoadouts ) n = Math . Max ( n , l . stage ) ; return n ; } }
public string StageName ( int stage ) = > stageLoadouts ? . Find ( l = > l . stage = = stage ) ? . name ? ? "" ;
public float CastleHpAt ( int stage ) = > Mathf . Round ( castle . hp * Mathf . Pow ( castle . hpGrowth , stage - 1 ) ) ;
public float CastleAtkAt ( int stage ) = > castle . atk * Mathf . Pow ( castle . atkGrowth , stage - 1 ) ;
public float SummonMulAt ( int stage ) = > Mathf . Pow ( castle . summonGrowth , stage - 1 ) ;
public int RivalOutfitAt ( int stage ) = > rivalOutfits ! = null & & rivalOutfits . Length > 0 ? rivalOutfits [ ( stage - 1 ) % rivalOutfits . Length ] : 0 ;
2026-10-29 13:21:25 +00:00
}
/// 시험 설정(전투 화면 「시험」) — 음수·null = 무작위 · 단계 기본
public class BattleSetup
{
public int mapType = - 1 ; // 0 강 없는 평지 · 1~3 다리 수
public int obstacles = - 1 ; // 0 없음 · 1 있음
public float castleX = - 1f ;
public List < string > shots , gimmicks ; // null = 단계 기본(stageLoadouts)
public float rangeMul = 1f ;
public List < BObstacle > customObstacles ; // 검사용 고정 장애물
public BattleSetup Clone ( ) = > new BattleSetup
{
mapType = mapType , obstacles = obstacles , castleX = castleX , rangeMul = rangeMul , customObstacles = customObstacles ,
shots = shots ! = null ? new List < string > ( shots ) : null , gimmicks = gimmicks ! = null ? new List < string > ( gimmicks ) : null
} ;
}
public class BObstacle { public Vector2 pos ; public float r ; public int kind ; } // kind 0 바위 · 1 언덕
/// 맵 = 강·다리·장애물 + 칸 단위 길찾기(8방향 · 모서리 끼어가기 금지 · 거리장)
public class BMap
{
public int Type ; // 0 강 없는 평지 · 1~3 다리 수
public float [ ] Bridges = new float [ 0 ] ;
public Vector2 Castle ;
public readonly List < BObstacle > Obstacles = new List < BObstacle > ( ) ;
public readonly int W , H ;
public bool [ ] Walk ;
public float [ ] CastleField ; // 성 앞 칸까지 남은 거리
public bool River = > Type > 0 ;
readonly BattleCfg C ;
readonly Dictionary < int , float [ ] > fields = new Dictionary < int , float [ ] > ( ) ;
static readonly int [ ] DX = { 1 , - 1 , 0 , 0 , 1 , 1 , - 1 , - 1 } , DY = { 0 , 0 , 1 , - 1 , 1 , - 1 , 1 , - 1 } ;
BMap ( BattleCfg c ) { C = c ; W = Mathf . RoundToInt ( c . arenaW ) ; H = Mathf . RoundToInt ( c . arenaH ) ; }
public static BMap Generate ( BattleCfg c , System . Random rng , BattleSetup s , Vector2 spawn )
{
int type = s ! = null & & s . mapType > = 0 ? Mathf . Clamp ( s . mapType , 0 , 3 ) : PickWeighted ( c . mapWeights , rng ) ;
float cx = s ! = null & & s . castleX > = 0f ? s . castleX : Mathf . Lerp ( c . castle . xMin , c . castle . xMax , ( float ) rng . NextDouble ( ) ) ;
bool obst = s ! = null & & s . obstacles > = 0 ? s . obstacles = = 1 : rng . NextDouble ( ) < c . obstacleChance ;
var bridges = type > 0 ? MakeBridges ( c , type , rng ) : new float [ 0 ] ;
for ( int attempt = 0 ; ; attempt + + )
{
var m = new BMap ( c ) { Type = type , Castle = new Vector2 ( cx , c . castle . y ) , Bridges = bridges } ;
if ( s ? . customObstacles ! = null ) m . Obstacles . AddRange ( s . customObstacles ) ;
else if ( obst & & attempt < 30 ) m . PlaceObstacles ( rng ) ;
m . Build ( ) ;
if ( s ? . customObstacles ! = null | | attempt > = 30 | | m . Reachable ( spawn ) ) return m ; // 길이 막힌 배치는 버리고 다시
}
}
static int PickWeighted ( float [ ] w , System . Random rng )
{
float sum = 0f ; foreach ( var x in w ) sum + = Mathf . Max ( 0f , x ) ;
double r = rng . NextDouble ( ) * sum ;
for ( int i = 0 ; i < w . Length ; i + + ) { r - = Mathf . Max ( 0f , w [ i ] ) ; if ( r < 0 ) return i ; }
return w . Length - 1 ;
}
static float [ ] MakeBridges ( BattleCfg c , int n , System . Random rng )
{
var set = c . bridgeSets [ Mathf . Clamp ( n - 1 , 0 , c . bridgeSets . Count - 1 ) ] . x ;
var r = new float [ set . Length ] ;
for ( int i = 0 ; i < set . Length ; i + + ) // 칸 가운데에 맞춤
r [ i ] = Mathf . Clamp ( Mathf . Floor ( set [ i ] + ( ( float ) rng . NextDouble ( ) * 2f - 1f ) * c . bridgeJitter ) + 0.5f , 1.5f , c . arenaW - 1.5f ) ;
Array . Sort ( r ) ;
for ( int i = 1 ; i < r . Length ; i + + ) if ( r [ i ] - r [ i - 1 ] < 3f ) r [ i ] = Mathf . Min ( c . arenaW - 0.5f , r [ i - 1 ] + 3f ) ;
return r ;
}
/// 성 둘레(obstacleArc 각도 · obstacleDist 거리)에 바위·언덕 줄기 obstacleMin~Max 개 — 줄기 = 원 obstaclePieces 개를 접선 방향으로 겹쳐 세운 벽
void PlaceObstacles ( System . Random rng )
{
int want = rng . Next ( C . obstacleMin , C . obstacleMax + 1 ) ;
float yMin = ( River ? C . riverY + C . riverHalf : C . arenaH * 0.5f ) + 1f ;
for ( int k = 0 , tries = 0 ; k < want & & tries < 40 ; tries + + )
{
float ang = Mathf . Lerp ( C . obstacleArcMin , C . obstacleArcMax , ( float ) rng . NextDouble ( ) ) * Mathf . Deg2Rad ;
var radial = new Vector2 ( Mathf . Cos ( ang ) , Mathf . Sin ( ang ) ) ; var tan = new Vector2 ( - radial . y , radial . x ) ;
var center = Castle + radial * Mathf . Lerp ( C . obstacleDistMin , C . obstacleDistMax , ( float ) rng . NextDouble ( ) ) ;
int pieces = rng . Next ( C . obstaclePiecesMin , C . obstaclePiecesMax + 1 ) , kind = rng . Next ( 2 ) , added = 0 ;
for ( int i = 0 ; i < pieces ; i + + )
{
float r = Mathf . Lerp ( C . obstacleRadiusMin , C . obstacleRadiusMax , ( float ) rng . NextDouble ( ) ) ;
var p = center + tan * ( ( i - ( pieces - 1 ) * 0.5f ) * r * C . obstacleSpacing ) + radial * ( ( ( float ) rng . NextDouble ( ) * 2f - 1f ) * C . obstacleWobble ) ;
if ( p . x < 0.5f | | p . x > C . arenaW - 0.5f | | p . y < yMin + r | | p . y > C . arenaH - 0.5f ) continue ;
if ( Vector2 . Distance ( p , Castle ) < C . castle . radius + C . castleSeedPad + r + C . obstacleCastleGap ) continue ; // 성 바로 앞 한 줄은 비워 둔다
Obstacles . Add ( new BObstacle { pos = p , r = r , kind = kind } ) ; added + + ;
}
if ( added > 0 ) k + + ;
}
}
void Build ( )
{
Walk = new bool [ W * H ] ;
for ( int y = 0 ; y < H ; y + + )
for ( int x = 0 ; x < W ; x + + )
{
var p = new Vector2 ( x + 0.5f , y + 0.5f ) ;
bool ok = Vector2 . Distance ( p , Castle ) > = C . castle . radius ;
if ( ok & & River & & Mathf . Abs ( p . y - C . riverY ) < = C . riverHalf )
{
ok = false ; // 강 = 칸 전체가 다리 위일 때만 지나감
foreach ( var b in Bridges ) if ( x > = b - C . bridgeHalf - 1e-3f & & x + 1 < = b + C . bridgeHalf + 1e-3f ) ok = true ;
}
if ( ok ) foreach ( var o in Obstacles ) if ( Vector2 . Distance ( p , o . pos ) < o . r + C . obstaclePad ) { ok = false ; break ; }
Walk [ y * W + x ] = ok ;
}
var seeds = new List < int > ( ) ;
for ( int i = 0 ; i < Walk . Length ; i + + ) if ( Walk [ i ] & & Vector2 . Distance ( Center ( i ) , Castle ) < = C . castle . radius + C . castleSeedPad ) seeds . Add ( i ) ;
CastleField = Dijkstra ( seeds ) ;
fields . Clear ( ) ;
}
public bool Reachable ( Vector2 p ) = > CastleField [ CellOf ( p ) ] < float . MaxValue ;
public int CellOf ( Vector2 p ) = > Mathf . Clamp ( Mathf . FloorToInt ( p . y ) , 0 , H - 1 ) * W + Mathf . Clamp ( Mathf . FloorToInt ( p . x ) , 0 , W - 1 ) ;
public Vector2 Center ( int i ) = > new Vector2 ( i % W + 0.5f , i / W + 0.5f ) ;
public bool Blocked ( Vector2 p ) = > ! Walk [ CellOf ( p ) ] ;
bool Free ( int x , int y ) = > x > = 0 & & y > = 0 & & x < W & & y < H & & Walk [ y * W + x ] ;
bool Step ( int x , int y , int nx , int ny ) = > Free ( nx , ny ) & & ( nx = = x | | ny = = y | | ( Free ( nx , y ) & & Free ( x , ny ) ) ) ; // 대각선은 양옆이 비어야
public int NearestFree ( int c )
{
if ( Walk [ c ] ) return c ;
var p = Center ( c ) ; int best = c ; float bd = float . MaxValue ;
for ( int i = 0 ; i < Walk . Length ; i + + ) if ( Walk [ i ] ) { float d = ( Center ( i ) - p ) . sqrMagnitude ; if ( d < bd ) { bd = d ; best = i ; } }
return best ;
}
/// goal 칸(막혔으면 가장 가까운 빈 칸)까지의 거리장 — 맵이 고정이라 칸별로 한 번만 만든다
public float [ ] FieldTo ( Vector2 goal )
{
int g = NearestFree ( CellOf ( goal ) ) ;
if ( ! fields . TryGetValue ( g , out var f ) ) { f = Dijkstra ( new List < int > { g } ) ; fields [ g ] = f ; }
return f ;
}
float [ ] Dijkstra ( List < int > seeds )
{
var d = new float [ W * H ] ;
for ( int i = 0 ; i < d . Length ; i + + ) d [ i ] = float . MaxValue ;
var heap = new Heap ( ) ;
foreach ( var s in seeds ) { d [ s ] = 0f ; heap . Push ( 0f , s ) ; }
while ( heap . Count > 0 )
{
heap . Pop ( out float dd , out int i ) ;
if ( dd > d [ i ] ) continue ;
int x = i % W , y = i / W ;
for ( int k = 0 ; k < 8 ; k + + )
{
int nx = x + DX [ k ] , ny = y + DY [ k ] ;
if ( ! Step ( x , y , nx , ny ) ) continue ;
int j = ny * W + nx ; float nd = dd + ( k < 4 ? 1f : 1.41421356f ) ;
if ( nd < d [ j ] ) { d [ j ] = nd ; heap . Push ( nd , j ) ; }
}
}
return d ;
}
/// 땅 유닛의 다음 목표점 — 거리장을 따라 최대 4칸 앞까지, 막힘 없이 곧게 갈 수 있는 가장 먼 칸 가운데
public Vector2 Next ( Vector2 pos , float [ ] f , Vector2 goal )
{
int c = CellOf ( pos ) ;
if ( ! Walk [ c ] ) return Center ( NearestFree ( c ) ) ;
if ( f [ c ] < = 0f | | f [ c ] = = float . MaxValue ) return goal ;
int cur = c ; Vector2 best = goal ;
for ( int k = 0 ; k < 4 ; k + + )
{
int n = Downhill ( cur , f ) ;
if ( n < 0 ) break ;
var cp = Center ( n ) ;
if ( k > 0 & & ! Clear ( pos , cp ) ) break ;
best = cp ; cur = n ;
if ( f [ n ] < = 0f ) break ;
}
return best ;
}
int Downhill ( int c , float [ ] f )
{
int x = c % W , y = c / W , best = - 1 ; float bv = f [ c ] ;
for ( int k = 0 ; k < 8 ; k + + )
{
int nx = x + DX [ k ] , ny = y + DY [ k ] ;
if ( ! Step ( x , y , nx , ny ) ) continue ;
int j = ny * W + nx ;
if ( f [ j ] < bv - 1e-4f ) { bv = f [ j ] ; best = j ; }
}
return best ;
}
/// a→b 직선이 지나는 칸 전부(모서리를 지나면 양옆 칸까지)가 비었나 — 칸 경계 순서대로 따라감
public bool Clear ( Vector2 a , Vector2 b )
{
int x = Mathf . FloorToInt ( a . x ) , y = Mathf . FloorToInt ( a . y ) , x1 = Mathf . FloorToInt ( b . x ) , y1 = Mathf . FloorToInt ( b . y ) ;
if ( ! Free ( x , y ) ) return false ;
var d = b - a ; int sx = d . x > 0f ? 1 : - 1 , sy = d . y > 0f ? 1 : - 1 ;
float tdx = d . x ! = 0f ? Mathf . Abs ( 1f / d . x ) : float . MaxValue , tdy = d . y ! = 0f ? Mathf . Abs ( 1f / d . y ) : float . MaxValue ;
float tx = d . x ! = 0f ? ( sx > 0 ? x + 1 - a . x : a . x - x ) * tdx : float . MaxValue ;
float ty = d . y ! = 0f ? ( sy > 0 ? y + 1 - a . y : a . y - y ) * tdy : float . MaxValue ;
for ( int guard = 0 ; ( x ! = x1 | | y ! = y1 ) & & guard < 4 * ( W + H ) ; guard + + )
{
if ( Mathf . Min ( tx , ty ) > 1f ) break ;
if ( Mathf . Abs ( tx - ty ) < 1e-5f ) { if ( ! Free ( x + sx , y ) | | ! Free ( x , y + sy ) ) return false ; x + = sx ; y + = sy ; tx + = tdx ; ty + = tdy ; }
else if ( tx < ty ) { x + = sx ; tx + = tdx ; }
else { y + = sy ; ty + = tdy ; }
if ( ! Free ( x , y ) ) return false ;
}
return true ;
}
class Heap
{
readonly List < float > k = new List < float > ( ) ; readonly List < int > v = new List < int > ( ) ;
public int Count = > k . Count ;
public void Push ( float key , int val )
{
k . Add ( key ) ; v . Add ( val ) ;
for ( int i = k . Count - 1 ; i > 0 ; ) { int p = ( i - 1 ) / 2 ; if ( k [ p ] < = k [ i ] ) break ; Swap ( i , p ) ; i = p ; }
}
public void Pop ( out float key , out int val )
{
key = k [ 0 ] ; val = v [ 0 ] ;
int last = k . Count - 1 ; k [ 0 ] = k [ last ] ; v [ 0 ] = v [ last ] ; k . RemoveAt ( last ) ; v . RemoveAt ( last ) ;
for ( int i = 0 ; ; )
{
int l = i * 2 + 1 , r = l + 1 , m = i ;
if ( l < k . Count & & k [ l ] < k [ m ] ) m = l ;
if ( r < k . Count & & k [ r ] < k [ m ] ) m = r ;
if ( m = = i ) break ;
Swap ( i , m ) ; i = m ;
}
}
void Swap ( int a , int b ) { float tk = k [ a ] ; k [ a ] = k [ b ] ; k [ b ] = tk ; int tv = v [ a ] ; v [ a ] = v [ b ] ; v [ b ] = tv ; }
}
}
public class BUnit
{
public int uid , flag ; public BUnitDef def ; public Vector2 pos ; public float hp , cd , hitFlash , stun , slow , slowMul = 1f ; public bool alive = true , attacking ; // flag = 닿은 깃발 번호
public float Speed = > def . speed * ( slow > 0f ? slowMul : 1f ) ;
}
public class BMinion { public int uid , slot ; public BSummonDef def ; public Vector2 pos , home ; public float hp , maxHp , damage , cd , hitFlash ; public bool alive = true , attacking ; }
/// owner 0 = 내 유닛(성·소환수 공격) · 1 = 성 · 2 = 소환수
public class BShot
{
public int owner , target , unitId ; public string type ; public BAttackDef def ;
public Vector2 pos , aim , dir ; public float damage , speed , left ; public bool toMinion , straight , done ;
}
/// 화면 연출용 순간 효과 — laser(a→b · 폭 r) · lightning(a→b) · blast(a · 반경 r) · spawn(a · 반경 r) · heal(성 회복 · 반경 r)
public class BFx { public string type ; public Vector2 a , b ; public float r , t , life ; }
public class BWeapon { public BAttackDef def ; public float cd ; }
public class BSummoner { public BSummonDef def ; public float cd ; }
public enum BattleState { Ready , Fighting , Won , Lost }
public class Battle
{
public readonly BattleCfg C ;
public readonly int Stage ;
public readonly BMap Map ;
public readonly BattleSetup Setup ;
public float Mana , MaxMana , TimeLeft , CastleHp , CastleMaxHp , CastleHitFlash , CastleHealFlash ;
public float CastleQuiet ; // 성이 마지막으로 맞은 뒤 지난 초
public int CastleHeals ;
public bool HasFlag ; public Vector2 FlagPos ; public int FlagVer ;
public readonly float Atk , RangeMul ;
float regenWait ;
Vector2 [ ] push = new Vector2 [ 0 ] ;
public BattleState State = BattleState . Ready ;
public readonly List < BUnit > Units = new List < BUnit > ( ) ;
public readonly List < BMinion > Minions = new List < BMinion > ( ) ;
public readonly List < BShot > Shots = new List < BShot > ( ) ;
public readonly List < BFx > Fx = new List < BFx > ( ) ;
public readonly List < BWeapon > CastleShots = new List < BWeapon > ( ) , CastleSkills = new List < BWeapon > ( ) ;
public readonly List < BSummoner > Summoners = new List < BSummoner > ( ) ;
public int Spawned , Killed , MinionsSpawned , MinionsKilled ;
int nextUid = 1 ;
readonly System . Random rng ;
public Vector2 CastlePos = > Map . Castle ;
public Vector2 SpawnPoint = > new Vector2 ( C . arenaW * 0.5f , C . spawnY ) ;
public bool Enraged = > CastleHp < = CastleMaxHp * C . castle . enrageHpRatio ;
public float IntervalMul = > Enraged ? C . castle . enrageIntervalMul : 1f ;
2026-10-29 13:58:23 +00:00
public float SummonMul = > C . SummonMulAt ( Stage ) ;
public int RivalOutfit = > C . RivalOutfitAt ( Stage ) ;
2026-10-29 13:21:25 +00:00
public bool LastMinute = > TimeLeft < = C . lastMinuteSeconds ;
public bool IsTest = > Setup ! = null ;
public string MapName = > C . MapName ( Map . Type ) ;
public float Range ( BAttackDef d ) = > d . range * RangeMul ;
public float Guard ( BSummonDef d ) = > d . guardRange * RangeMul ;
public Battle ( BattleCfg c , int stage , int maxMana , int seed = 7 , BattleSetup setup = null )
{
C = c ; Stage = Math . Max ( 1 , stage ) ; MaxMana = maxMana ; rng = new System . Random ( seed ) ; Setup = setup ;
2026-10-29 13:58:23 +00:00
CastleMaxHp = CastleHp = c . CastleHpAt ( Stage ) ;
Atk = c . CastleAtkAt ( Stage ) ;
2026-10-29 13:21:25 +00:00
RangeMul = setup ! = null & & setup . rangeMul > 0f ? setup . rangeMul : 1f ;
TimeLeft = c . battleSeconds ; Mana = Mathf . Min ( c . manaStart , maxMana ) ;
Map = BMap . Generate ( c , rng , setup , SpawnPoint ) ;
var lo = c . Loadout ( Stage ) ;
foreach ( var id in ( IEnumerable < string > ) setup ? . shots ? ? lo . shots ? ? new string [ 0 ] ) { var d = c . Shot ( id ) ; if ( d ! = null ) CastleShots . Add ( new BWeapon { def = d , cd = d . interval } ) ; }
foreach ( var id in ( IEnumerable < string > ) setup ? . gimmicks ? ? lo . gimmicks ? ? new string [ 0 ] )
{
var sk = c . Skill ( id ) ; if ( sk ! = null ) { CastleSkills . Add ( new BWeapon { def = sk , cd = sk . interval } ) ; continue ; }
var sm = c . Summon ( id ) ; if ( sm ! = null ) Summoners . Add ( new BSummoner { def = sm , cd = sm . interval } ) ;
}
}
public void Start ( ) { if ( State = = BattleState . Ready ) State = BattleState . Fighting ; }
public void CastleHpDebugKill ( ) { CastleHp = 0f ; }
public List < string > ShotNames ( ) { var l = new List < string > ( ) ; foreach ( var w in CastleShots ) l . Add ( w . def . name ) ; return l ; }
public List < string > GimmickNames ( ) { var l = new List < string > ( ) ; foreach ( var w in CastleSkills ) l . Add ( w . def . name ) ; foreach ( var s in Summoners ) l . Add ( s . def . name ) ; return l ; }
/// 카드 1장 = 1마리 즉시 소환 — 아래 가운데(SpawnPoint) 둘레 spawnJitter 안
public bool Deploy ( BUnitDef d )
{
if ( State ! = BattleState . Fighting | | d = = null | | Mana < d . cost ) return false ;
Mana - = d . cost ;
float a = ( float ) rng . NextDouble ( ) * Mathf . PI * 2f , r = Mathf . Sqrt ( ( float ) rng . NextDouble ( ) ) * C . spawnJitter ;
Units . Add ( new BUnit { uid = nextUid + + , def = d , pos = SpawnPoint + new Vector2 ( Mathf . Cos ( a ) , Mathf . Sin ( a ) ) * r , hp = d . hp , cd = d . interval * 0.5f } ) ;
Spawned + + ;
Fx . Add ( new BFx { type = "spawn" , a = SpawnPoint , r = C . spawnJitter + 0.6f , life = 0.4f } ) ;
return true ;
}
/// 깃발 = 맵을 누른 곳(막힌 칸이면 가장 가까운 빈 칸) — 꽂으면 내 유닛 전원(이후 소환 포함)이 깃발에 먼저 닿은 뒤 성으로
public bool SetFlag ( Vector2 p )
{
if ( State ! = BattleState . Fighting ) return false ;
p = new Vector2 ( Mathf . Clamp ( p . x , 0.5f , C . arenaW - 0.5f ) , Mathf . Clamp ( p . y , 0.5f , C . arenaH - 0.5f ) ) ;
if ( Map . Blocked ( p ) ) p = Map . Center ( Map . NearestFree ( Map . CellOf ( p ) ) ) ;
FlagPos = p ; HasFlag = true ; FlagVer + + ;
return true ;
}
bool ToFlag ( BUnit u )
{
if ( ! HasFlag | | u . flag = = FlagVer ) return false ;
if ( Vector2 . Distance ( u . pos , FlagPos ) > C . flagReach ) return true ;
u . flag = FlagVer ; return false ;
}
float DistToCastle ( BUnit u ) = > Vector2 . Distance ( u . pos , CastlePos ) - C . castle . radius - u . def . radius ;
public void Tick ( float dt )
{
if ( State ! = BattleState . Fighting | | dt < = 0f ) return ;
TimeLeft - = dt ;
Mana = Mathf . Min ( MaxMana , Mana + dt / Mathf . Max ( 0.01f , C . manaRegenSeconds ) * ( LastMinute ? C . lastMinuteRegenMul : 1f ) ) ;
CastleHitFlash = Mathf . Max ( 0f , CastleHitFlash - dt ) ;
for ( int i = Fx . Count - 1 ; i > = 0 ; i - - ) { Fx [ i ] . t + = dt ; if ( Fx [ i ] . t > = Fx [ i ] . life ) Fx . RemoveAt ( i ) ; }
TickUnits ( dt ) ;
Separate ( dt ) ;
TickCastle ( dt ) ;
TickMinions ( dt ) ;
TickShots ( dt ) ;
TickRegen ( dt ) ;
Units . RemoveAll ( u = > ! u . alive ) ;
Minions . RemoveAll ( m = > ! m . alive ) ;
if ( CastleHp < = 0f ) { CastleHp = 0f ; State = BattleState . Won ; }
else if ( TimeLeft < = 0f ) { TimeLeft = 0f ; State = BattleState . Lost ; }
}
// ---------------- 내 유닛: 시야 안 소환수 먼저 → 없으면 깃발(아직 안 닿았으면) → 성 ----------------
void TickUnits ( float dt )
{
foreach ( var u in Units )
{
if ( ! u . alive ) continue ;
u . hitFlash = Mathf . Max ( 0f , u . hitFlash - dt ) ;
u . slow = Mathf . Max ( 0f , u . slow - dt ) ;
if ( u . stun > 0f ) { u . stun = Mathf . Max ( 0f , u . stun - dt ) ; u . attacking = false ; continue ; } // 기절 = 이동·공격 멈춤
bool toFlag = ToFlag ( u ) ;
var m = NearestMinion ( u . pos , Mathf . Max ( C . unitSight , u . def . range + u . def . radius ) ) ;
float edge = m ! = null ? Vector2 . Distance ( u . pos , m . pos ) - u . def . radius - m . def . radius : toFlag ? float . MaxValue : DistToCastle ( u ) ;
if ( edge < = u . def . range )
{
u . attacking = true ;
u . cd - = dt ;
if ( u . cd < = 0f )
{
u . cd = u . def . interval ;
if ( u . def . projectile ) Shots . Add ( new BShot { owner = 0 , type = "unit" , unitId = u . def . id , pos = u . pos , target = m ! = null ? m . uid : 0 , toMinion = m ! = null , damage = u . def . damage , speed = C . unitShotSpeed } ) ;
else if ( m ! = null ) HitMinion ( m , u . def . damage ) ;
else HitCastle ( u . def . damage ) ;
}
continue ;
}
u . attacking = false ;
var goal = m ! = null ? m . pos : toFlag ? FlagPos : CastlePos ;
var wp = u . def . flying ? goal : Map . Next ( u . pos , m ! = null ? Map . FieldTo ( m . pos ) : toFlag ? Map . FieldTo ( FlagPos ) : Map . CastleField , goal ) ;
Move ( ref u . pos , wp , u . Speed * dt ) ;
if ( toFlag ) ToFlag ( u ) ;
}
}
/// 내 유닛끼리 겹치면 살짝 밀어냄(땅·하늘 따로 · 땅 유닛은 막힌 칸으로 안 밀림 · 기절 중엔 그대로) — 무리가 한 점에 포개지지 않고 퍼짐
void Separate ( float dt )
{
int n = Units . Count ;
if ( n < 2 | | C . unitPush < = 0f | | C . unitSpacing < = 0f ) return ;
if ( push . Length < n ) push = new Vector2 [ n * 2 ] ;
for ( int i = 0 ; i < n ; i + + ) push [ i ] = Vector2 . zero ;
float k = Mathf . Min ( 1f , C . unitPush * dt ) * 0.5f ;
for ( int i = 0 ; i < n ; i + + )
{
var a = Units [ i ] ; if ( ! a . alive ) continue ;
for ( int j = i + 1 ; j < n ; j + + )
{
var b = Units [ j ] ; if ( ! b . alive | | a . def . flying ! = b . def . flying ) continue ;
var d = b . pos - a . pos ; float min = ( a . def . radius + b . def . radius ) * C . unitSpacing , d2 = d . sqrMagnitude ;
if ( d2 > = min * min ) continue ;
float dist = Mathf . Sqrt ( d2 ) , ang = a . uid * 2.39996f + b . uid ; // 완전히 겹치면 번호로 정한 방향
var dir = dist > 1e-4f ? d / dist : new Vector2 ( Mathf . Cos ( ang ) , Mathf . Sin ( ang ) ) ;
var p = dir * ( ( min - dist ) * k ) ;
push [ i ] - = p ; push [ j ] + = p ;
}
}
for ( int i = 0 ; i < n ; i + + )
{
var u = Units [ i ] ;
if ( ! u . alive | | u . stun > 0f | | push [ i ] = = Vector2 . zero ) continue ;
var np = new Vector2 ( Mathf . Clamp ( u . pos . x + push [ i ] . x , 0.3f , C . arenaW - 0.3f ) , Mathf . Clamp ( u . pos . y + push [ i ] . y , 0.3f , C . arenaH - 0.3f ) ) ;
if ( u . def . flying | | ! Map . Blocked ( np ) ) u . pos = np ;
else if ( ! Map . Blocked ( new Vector2 ( np . x , u . pos . y ) ) ) u . pos = new Vector2 ( np . x , u . pos . y ) ;
else if ( ! Map . Blocked ( new Vector2 ( u . pos . x , np . y ) ) ) u . pos = new Vector2 ( u . pos . x , np . y ) ;
}
}
BMinion NearestMinion ( Vector2 p , float sight )
{
BMinion best = null ; float bd = float . MaxValue ;
foreach ( var m in Minions ) { if ( ! m . alive ) continue ; float d = Vector2 . Distance ( p , m . pos ) - m . def . radius ; if ( d < = sight & & d < bd ) { bd = d ; best = m ; } }
return best ;
}
static void Move ( ref Vector2 p , Vector2 wp , float step ) { var d = wp - p ; float len = d . magnitude ; p = len < = step ? wp : p + d / len * step ; }
// ---------------- 성: 투사체 · 스킬 · 소환 (분노 = 체력 절반 이하 → 간격 단축) ----------------
void TickCastle ( float dt )
{
float mul = IntervalMul ;
foreach ( var w in CastleShots )
{
if ( ( w . cd - = dt ) > 0f ) continue ;
var t = NearestUnit ( Range ( w . def ) ) ;
if ( t = = null ) { w . cd = C . castleRetarget ; continue ; }
FireShot ( w . def , t ) ; w . cd = w . def . interval * mul ;
}
foreach ( var w in CastleSkills )
{
if ( ( w . cd - = dt ) > 0f ) continue ;
var t = w . def . splashRadius > 0f ? DensestUnit ( Range ( w . def ) , w . def . splashRadius ) : NearestUnit ( Range ( w . def ) ) ;
if ( t = = null ) { w . cd = C . castleRetarget ; continue ; }
CastSkill ( w . def , t ) ; w . cd = w . def . interval * mul ;
}
foreach ( var s in Summoners )
{
if ( ( s . cd - = dt ) > 0f ) continue ;
s . cd = s . def . interval ;
if ( CountMinions ( s . def ) < s . def . max ) SpawnMinion ( s . def ) ;
}
}
BUnit NearestUnit ( float range )
{
BUnit best = null ; float bd = float . MaxValue ;
foreach ( var u in Units ) { if ( ! u . alive ) continue ; float d = DistToCastle ( u ) ; if ( d < = range & & d < bd ) { bd = d ; best = u ; } }
return best ;
}
/// 범위 기술 대상 = 사거리 안에서 둘레 radius 안에 내 유닛이 가장 많은 유닛(같으면 성에 가까운 쪽)
BUnit DensestUnit ( float range , float radius )
{
BUnit best = null ; int bn = 0 ; float bd = float . MaxValue ;
foreach ( var u in Units )
{
if ( ! u . alive ) continue ;
float d = DistToCastle ( u ) ; if ( d > range ) continue ;
int n = 0 ; foreach ( var v in Units ) if ( v . alive & & Vector2 . Distance ( u . pos , v . pos ) < = radius ) n + + ;
if ( n > bn | | ( n = = bn & & d < bd ) ) { bn = n ; bd = d ; best = u ; }
}
return best ;
}
void FireShot ( BAttackDef d , BUnit t )
{
float dmg = Atk * d . dmgPct / 100f ;
var dir = ( t . pos - CastlePos ) . normalized ;
if ( d . id = = "laser" ) // 레이저 = 성 가장자리부터 사거리 끝까지 선 위 전원 동시
{
var a = CastlePos + dir * C . castle . radius ; var b = a + dir * Range ( d ) ;
foreach ( var u in Units ) if ( u . alive & & SegDist ( u . pos , a , b ) < = d . width * 0.5f + u . def . radius ) Damage ( u , dmg ) ;
Fx . Add ( new BFx { type = "laser" , a = a , b = b , r = d . width , life = d . beamSeconds } ) ;
}
else if ( d . id = = "multi" ) // 멀티샷 = 부채꼴로 count 발 · 곧게 날아가 처음 닿는 유닛 1명씩
{
int n = Math . Max ( 1 , d . count ) ;
for ( int i = 0 ; i < n ; i + + )
{
var v = Rotate ( dir , n = = 1 ? 0f : - d . spreadDeg * 0.5f + d . spreadDeg * i / ( n - 1 ) ) ;
Shots . Add ( new BShot { owner = 1 , type = d . id , def = d , pos = CastlePos + v * C . castle . radius , dir = v , straight = true , damage = dmg , speed = d . speed , left = Range ( d ) } ) ;
}
}
else Shots . Add ( new BShot { owner = 1 , type = d . id , def = d , pos = CastlePos , aim = t . pos , target = t . uid , damage = dmg , speed = d . speed } ) ; // 일반 · 범위 · 감속 = 따라가는 탄
}
void CastSkill ( BAttackDef d , BUnit t )
{
float dmg = Atk * d . dmgPct / 100f ;
if ( d . speed > 0f ) { Shots . Add ( new BShot { owner = 1 , type = d . id , def = d , pos = CastlePos , aim = t . pos , target = t . uid , damage = dmg , speed = d . speed } ) ; return ; } // 파이어 볼 = 날아가는 탄
Damage ( t , dmg ) ; // 라이트닝 = 즉시 피해 + 기절
if ( t . alive & & d . stunSeconds > 0f ) t . stun = Mathf . Max ( t . stun , d . stunSeconds ) ;
Fx . Add ( new BFx { type = "lightning" , a = CastlePos + new Vector2 ( 0f , C . castle . radius ) , b = t . pos , life = d . beamSeconds } ) ;
}
/// 성 탄 명중 — 맞은 유닛 dmgPct · 범위(splashRadius) 안 다른 유닛 splashPct · 감속(slowPct · slowSeconds)
void Impact ( BShot s , BUnit t )
{
var d = s . def ; var at = t ! = null ? t . pos : s . aim ;
if ( t ! = null ) Damage ( t , s . damage ) ;
if ( d . splashRadius > 0f )
{
float sd = Atk * d . splashPct / 100f ;
foreach ( var u in Units ) if ( u ! = t & & u . alive & & Vector2 . Distance ( u . pos , at ) < = d . splashRadius ) Damage ( u , sd ) ;
Fx . Add ( new BFx { type = "blast" , a = at , r = d . splashRadius , life = 0.45f } ) ;
}
if ( t ! = null & & t . alive & & d . slowSeconds > 0f ) { t . slow = Mathf . Max ( t . slow , d . slowSeconds ) ; t . slowMul = 1f - d . slowPct / 100f ; }
}
void TickShots ( float dt )
{
foreach ( var s in Shots )
{
if ( s . done ) continue ;
float step = s . speed * dt ;
if ( s . straight )
{
s . pos + = s . dir * step ; s . left - = step ;
foreach ( var u in Units ) if ( u . alive & & Vector2 . Distance ( u . pos , s . pos ) < = u . def . radius + s . def . width * 0.5f ) { Damage ( u , s . damage ) ; s . done = true ; break ; }
if ( s . left < = 0f ) s . done = true ;
continue ;
}
BUnit tu = null ; BMinion tm = null ; Vector2 dest ;
if ( s . owner = = 0 )
{
if ( s . toMinion ) { tm = FindMinion ( s . target ) ; if ( tm = = null ) { s . done = true ; continue ; } dest = tm . pos ; }
else dest = CastlePos ;
}
else
{
tu = FindUnit ( s . target ) ;
if ( tu ! = null ) s . aim = tu . pos ;
else if ( s . def = = null | | s . def . splashRadius < = 0f ) { s . done = true ; continue ; } // 대상이 쓰러지면 사라짐(범위탄은 그 자리에서 터짐)
dest = s . aim ;
}
var dir = dest - s . pos ;
if ( dir . magnitude < = Mathf . Max ( step , 0.3f ) )
{
s . done = true ;
if ( s . owner = = 0 ) { if ( tm ! = null ) HitMinion ( tm , s . damage ) ; else HitCastle ( s . damage ) ; }
else if ( s . owner = = 2 ) Damage ( tu , s . damage ) ;
else Impact ( s , tu ) ;
}
else s . pos + = dir . normalized * step ;
}
Shots . RemoveAll ( s = > s . done ) ;
}
BUnit FindUnit ( int uid ) { foreach ( var u in Units ) if ( u . uid = = uid & & u . alive ) return u ; return null ; }
BMinion FindMinion ( int uid ) { foreach ( var m in Minions ) if ( m . uid = = uid & & m . alive ) return m ; return null ; }
// ---------------- 소환수: 성 둘레 집에 주둔 · 집에서 guardRange 안의 내 유닛만 ----------------
int CountMinions ( BSummonDef d ) { int n = 0 ; foreach ( var m in Minions ) if ( m . alive & & m . def = = d ) n + + ; return n ; }
/// 집 = 성 아래쪽 호(summonArcMin~Max°)에 max 자리 고르게 · 성 가장자리에서 homeDist · 막힌 칸이면 가장 가까운 빈 칸
public Vector2 HomeFor ( BSummonDef d , int slot )
{
float ang = ( d . max < = 1 ? ( C . summonArcMin + C . summonArcMax ) * 0.5f : Mathf . Lerp ( C . summonArcMin , C . summonArcMax , slot / ( float ) ( d . max - 1 ) ) ) * Mathf . Deg2Rad ;
var p = CastlePos + new Vector2 ( Mathf . Cos ( ang ) , Mathf . Sin ( ang ) ) * ( C . castle . radius + d . homeDist ) ;
p = new Vector2 ( Mathf . Clamp ( p . x , 0.5f , C . arenaW - 0.5f ) , Mathf . Clamp ( p . y , 0.5f , C . arenaH - 0.5f ) ) ;
return Map . Blocked ( p ) ? Map . Center ( Map . NearestFree ( Map . CellOf ( p ) ) ) : p ;
}
void SpawnMinion ( BSummonDef d )
{
int slot = 0 ;
for ( ; slot < d . max ; slot + + ) { bool used = false ; foreach ( var m in Minions ) if ( m . alive & & m . def = = d & & m . slot = = slot ) used = true ; if ( ! used ) break ; }
var home = HomeFor ( d , slot ) ;
var start = CastlePos + ( home - CastlePos ) . normalized * ( C . castle . radius + 0.3f ) ;
if ( Map . Blocked ( start ) ) start = home ;
float g = SummonMul ;
Minions . Add ( new BMinion { uid = nextUid + + , slot = slot , def = d , pos = start , home = home , maxHp = d . hp * g , hp = d . hp * g , damage = d . damage * g , cd = d . attackInterval * 0.5f } ) ;
MinionsSpawned + + ;
Fx . Add ( new BFx { type = "spawn" , a = start , r = d . radius * 2f , life = 0.4f } ) ;
}
void TickMinions ( float dt )
{
foreach ( var m in Minions )
{
if ( ! m . alive ) continue ;
m . hitFlash = Mathf . Max ( 0f , m . hitFlash - dt ) ;
var d = m . def ; float guard = Guard ( d ) ;
BUnit t = null ; float bd = float . MaxValue ;
foreach ( var u in Units ) // 집에서 guard 안에 들어온 내 유닛만 — 그 밖(내 진영)으로는 나가지 않는다
{
if ( ! u . alive | | Vector2 . Distance ( u . pos , m . home ) > guard ) continue ;
float dd = Vector2 . Distance ( u . pos , m . pos ) ; if ( dd < bd ) { bd = dd ; t = u ; }
}
if ( t = = null )
{
m . attacking = false ;
if ( ( m . pos - m . home ) . sqrMagnitude > 0.0025f ) Move ( ref m . pos , Map . Next ( m . pos , Map . FieldTo ( m . home ) , m . home ) , d . speed * dt ) ;
continue ;
}
if ( bd - d . radius - t . def . radius < = d . attackRange )
{
m . attacking = true ;
if ( ( m . cd - = dt ) < = 0f )
{
m . cd = d . attackInterval ;
if ( d . ranged ) Shots . Add ( new BShot { owner = 2 , type = "minion" , pos = m . pos , aim = t . pos , target = t . uid , damage = m . damage , speed = d . shotSpeed } ) ;
else Damage ( t , m . damage ) ;
}
continue ;
}
m . attacking = false ;
Move ( ref m . pos , Map . Next ( m . pos , Map . FieldTo ( t . pos ) , t . pos ) , d . speed * dt ) ;
}
}
void Damage ( BUnit u , float d )
{
if ( u = = null | | ! u . alive ) return ;
u . hp - = d ; u . hitFlash = 0.15f ;
if ( u . hp < = 0f ) { u . alive = false ; Killed + + ; }
}
void HitMinion ( BMinion m , float d )
{
if ( m = = null | | ! m . alive ) return ;
m . hp - = d ; m . hitFlash = 0.15f ;
if ( m . hp < = 0f ) { m . alive = false ; MinionsKilled + + ; }
}
public void HitCastle ( float d ) { if ( State ! = BattleState . Fighting ) return ; CastleHp - = d ; CastleHitFlash = 0.12f ; CastleQuiet = 0f ; regenWait = 0f ; }
/// 성벽 회복 — 마지막으로 맞은 뒤 regenDelay 초가 지나면 그때부터 regenInterval 초마다 최대 체력의 regenPct % (최대 체력까지)
void TickRegen ( float dt )
{
var k = C . castle ;
CastleQuiet + = dt ; CastleHealFlash = Mathf . Max ( 0f , CastleHealFlash - dt ) ;
if ( k . regenPct < = 0f | | k . regenInterval < = 0f | | CastleQuiet < k . regenDelay | | CastleHp < = 0f | | CastleHp > = CastleMaxHp ) { regenWait = 0f ; return ; }
while ( regenWait < = 0f & & CastleHp < CastleMaxHp )
{
CastleHp = Mathf . Min ( CastleMaxHp , CastleHp + CastleMaxHp * k . regenPct / 100f ) ;
regenWait + = k . regenInterval ; CastleHeals + + ; CastleHealFlash = 0.4f ;
Fx . Add ( new BFx { type = "heal" , a = CastlePos , r = C . castle . radius + 0.6f , life = 0.6f } ) ;
}
regenWait - = dt ;
}
static Vector2 Rotate ( Vector2 v , float deg ) { float r = deg * Mathf . Deg2Rad , c = Mathf . Cos ( r ) , s = Mathf . Sin ( r ) ; return new Vector2 ( v . x * c - v . y * s , v . x * s + v . y * c ) ; }
static float SegDist ( Vector2 p , Vector2 a , Vector2 b ) { var e = b - a ; float h = Mathf . Clamp01 ( Vector2 . Dot ( p - a , e ) / Mathf . Max ( 1e-6f , Vector2 . Dot ( e , e ) ) ) ; return ( p - a - e * h ) . magnitude ; }
}
public partial class Game
{
public BattleCfg BC = > T . Battle ;
public int BattleSlots = > Math . Max ( 1 , Math . Min ( BC . slotMax , S . battleSlots < = 0 ? BC . slotStart : S . battleSlots ) ) ;
public int BattleMaxMana = > Math . Min ( BC . manaMaxCap , BC . manaMaxBase + S . battleManaLevel ) ;
public int BattleStage = > Math . Max ( 1 , S . battleStage ) ;
public BUnitDef BattleUnit ( int slot ) = > slot > = 0 & & slot < BC . units . Count ? BC . units [ slot ] : null ;
public BigInteger SlotUnlockPrice ( ) = > BattleSlots < BC . slotMax & & BattleSlots - 1 < BC . slotUnlockCost . Length ? Big . P ( BC . slotUnlockCost [ BattleSlots - 1 ] ) : BigInteger . Zero ;
public BigInteger ManaUpgradePrice ( ) = > BattleMaxMana < BC . manaMaxCap & & S . battleManaLevel < BC . manaUpgradeCost . Length ? Big . P ( BC . manaUpgradeCost [ S . battleManaLevel ] ) : BigInteger . Zero ;
public BuyResult UnlockBattleSlot ( )
{
if ( BattleSlots > = BC . slotMax ) return BuyResult . Max ;
var p = SlotUnlockPrice ( ) ;
if ( p > Coin ) return BuyResult . NoMoney ;
SpendCoin ( p ) ;
S . battleSlots = BattleSlots + 1 ;
return BuyResult . Ok ;
}
public BuyResult UpgradeBattleMana ( )
{
if ( BattleMaxMana > = BC . manaMaxCap | | S . battleManaLevel > = BC . manaUpgradeCost . Length ) return BuyResult . Max ;
var p = ManaUpgradePrice ( ) ;
if ( p > Coin ) return BuyResult . NoMoney ;
SpendCoin ( p ) ;
S . battleManaLevel + + ;
return BuyResult . Ok ;
}
public Battle NewBattle ( int seed = 7 , BattleSetup setup = null ) = > new Battle ( BC , BattleStage , BattleMaxMana , seed , setup ) ;
2026-10-29 13:58:23 +00:00
public Battle NewBattle ( int stage , int seed , BattleSetup setup ) = > new Battle ( BC , stage , BattleMaxMana , seed , setup ) ;
// 스테이지 진행 — 다음에 깰 스테이지(BattleStage)까지 열림 · 그 앞은 클리어 · 전부 깨면 BattleStage = StageCount + 1
public int StageCount = > BC . StageCount ;
public bool StageCleared ( int stage ) = > stage > = 1 & & stage < BattleStage ;
public bool StageOpen ( int stage ) = > stage > = 1 & & stage < = Math . Min ( BattleStage , StageCount ) ;
public int StageFocus = > Math . Max ( 1 , Math . Min ( BattleStage , StageCount ) ) ; // 스테이지 선택 첫 화면 = 다음에 깰 스테이지(다 깼으면 마지막)
2026-10-29 13:21:25 +00:00
/// 승리 보상 = 방치 수입(PassGpsClean) × (기본 + 단계당) 초분 — 설계값
public BigInteger BattleReward ( int stage ) = > new BigInteger ( decimal . Ceiling ( PassGpsClean ( ) * ( decimal ) ( BC . rewardPassSeconds + BC . rewardPassSecondsPerStage * ( stage - 1 ) ) ) ) ;
public BigInteger WinBattle ( Battle b )
{
if ( b = = null | | b . State ! = BattleState . Won ) return BigInteger . Zero ;
var r = BattleReward ( b . Stage ) ;
AddCoin ( r ) ;
2026-10-29 13:58:23 +00:00
if ( b . Stage = = BattleStage & & ! b . IsTest ) S . battleStage = b . Stage + 1 ; // 다음 스테이지 열기 = 지금 깰 차례인 스테이지를 시험 설정 없이 이겼을 때만(하나씩)
2026-10-29 13:21:25 +00:00
return r ;
}
}
}