VSClone/Assets/Scripts/Sim/Battle.cs

935 lines
54 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using UnityEngine;
using Vector2 = UnityEngine.Vector2;
// 전투 모드 — 클래시 로얄식 아레나(18×32 칸). 맵 = 스테이지마다 고정 지도(다리 폭 · 해자 · 협곡 · 필드 — Battle.Stage.cs) · 시험 설정 = 평지/다리 1~3개 무작위 + 성 둘레 바위·언덕.
// 내 유닛 = 카드 1장 = 1마리 · 아래 가운데에서 바로 소환(약하지만 마나를 모아 많이 소환 = 물량) · 맵을 누르면 깃발 → 유닛이 깃발을 거쳐 성으로.
// 성(항상 위쪽 · 내 유닛보다 매우 강함) = 투사체 5종 + 기술 8 · 소환 5 · 성 능력 4 + 안 맞으면 성벽 회복 · 스테이지 50(2026-10-30 PD 확정).
// 값 = Resources/Data/battle.json (설계값 · PD 확정 전)
namespace VS.Sim
{
[Serializable] public class BUnitDef { public int id; public string name; public int cost, grade; public float hp, damage, interval, range, speed, radius; public bool flying, projectile; }
/// 유닛 등급 — 일반 → 고급 → 희귀 → 에픽 → 전설 → 불멸 → 신화(색 = "#RRGGBB" · 신화 = "rainbow")
[Serializable] public class BGradeDef { public int id; public string name, color; }
/// 유닛 목록 한 칸 = 유닛 그림(px_<art>) + 이름 — 전투 유닛(units)이 쓰는 그림 = 보유 · 나머지 = 미보유
[Serializable] public class BRosterDef { public int art; public string name; }
/// 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% · 사거리 = 성 가장자리부터
/// kind 가 있으면 새 기술(Battle.Stage.cs): cloud(독구름·화염 장판 = radius · duration · 초당 dpsPct%) · boulder(radius · speed · knock) · meteor(count 개를 duration 동안 · warn 초 예고) · nova(range 안 얼림 freezeSeconds) · tornado(speed · duration · pull · push) · air = 나는 유닛도 · fx = 맞은 유닛 빛깔
[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;
public string kind, fx, desc, tip; public bool air;
public float radius, duration, dpsPct, knock, warn, pull, push, freezeSeconds;
}
/// 성 소환수 — 성 둘레 집에 주둔 · 집에서 guardRange 안에 들어온 내 유닛만 공격 · 종류별 최대 max
/// kind: beam(레이저 수호병 = 제자리 · beamPct % 광선) · bomb(폭탄병 = 달려가 blastRadius 폭발) · totem(치유 토템 = 제자리 · 성 회복 초당 healPct %) · 없음 = 근접/원거리
[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;
public string kind, desc, tip; public float beamPct, beamLength, beamWidth, beamSeconds, blastPct, blastRadius, healPct;
}
/// 스테이지 한 줄 = 번호 · 이름 · 등급 · 지형(다리 · 해자 입구 수 · 협곡) · 필드 · 성 투사체 · 기믹(기술 · 소환 · 성 능력)
[Serializable]
public class BLoadout
{
public int stage; public string name, rank; public string[] shots, gimmicks, fields;
public List<BBridgeDef> bridges; public int moat; public bool canyon;
}
[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 partial class BattleCfg
{
public float arenaW, arenaH, riverY, riverHalf, 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 List<BGradeDef> grades; public int deckPresets; public List<BRosterDef> roster;
public BUnitDef UnitDef(int id) => units?.Find(u => u.id == id);
public BGradeDef Grade(int id) => grades?.Find(g => g.id == id);
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 ?? Power(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] };
}
// 스테이지 = 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) * RankHpMul(stage));
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;
}
/// 시험 설정(전투 화면 「시험」) — 음수·null = 스테이지 기본
public class BattleSetup
{
public const int StageMap = -1, RandomMap = -2;
public int mapType = StageMap; // -1 스테이지 지도(고정) · -2 무작위 · 0 강 없는 평지 · 1~3 다리 수
public int obstacles = -1; // 0 없음 · 1 있음
public int moat = -1; // -1 스테이지 기본 · 0 없음 · 1~2 해자 입구 수
public int canyon = -1; // -1 스테이지 기본 · 0 없음 · 1 협곡
public float castleX = -1f;
public List<string> shots, gimmicks, fields; // null = 스테이지 기본(stageLoadouts)
public float rangeMul = 1f;
public List<BObstacle> customObstacles; // 검사용 고정 장애물
public BattleSetup Clone() => new BattleSetup
{
mapType = mapType, obstacles = obstacles, moat = moat, canyon = canyon, castleX = castleX, rangeMul = rangeMul, customObstacles = customObstacles,
shots = shots != null ? new List<string>(shots) : null, gimmicks = gimmicks != null ? new List<string>(gimmicks) : null, fields = fields != null ? new List<string>(fields) : null
};
}
public class BObstacle { public Vector2 pos; public float r; public int kind; } // kind 0 바위 · 1 언덕
/// 맵 = 강·다리·장애물(+ 해자 · 협곡 · 필드 = Battle.Stage.cs) + 칸 단위 길찾기(8방향 · 모서리 끼어가기 금지 · 거리장)
public partial 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); }
/// 스테이지 지도(시험 설정 없음 · mapType -1) = 그 스테이지 줄의 지형 + 고정 난수(stageMapSeed + 번호) → 매번 같은 지도
/// 그 밖(시험 설정 맵 지정 · 무작위) = 예전 방식(평지/다리 1~3개 · 성 x · 바위·언덕 무작위) + 시험의 해자·협곡
public static BMap Generate(BattleCfg c, System.Random rng, BattleSetup s, Vector2 spawn, int stage = 1, List<BFieldDef> fieldDefs = null)
{
var row = c.StageRow(stage);
bool fixedMap = (s == null || s.mapType == BattleSetup.StageMap) && row != null;
var r = fixedMap ? new System.Random(c.stageMapSeed + stage) : rng;
int type; float cx; bool obst; float[] bridges; string[] kinds;
int moat = s != null && s.moat >= 0 ? s.moat : fixedMap ? row.moat : 0;
bool canyon = s != null && s.canyon >= 0 ? s.canyon == 1 : fixedMap && row.canyon;
if (fixedMap)
{
var bl = row.bridges ?? new List<BBridgeDef>();
type = bl.Count; cx = c.arenaW * 0.5f;
bridges = new float[bl.Count]; kinds = new string[bl.Count];
for (int i = 0; i < bl.Count; i++) { kinds[i] = bl[i].kind; bridges[i] = SnapBridge(c, bl[i].x, c.BridgeCells(bl[i].kind)); }
obst = s != null && s.obstacles >= 0 ? s.obstacles == 1 : moat == 0 && !canyon && r.NextDouble() < c.obstacleChance; // 바위·언덕 = 해자·협곡 없는 지도만
}
else
{
type = s != null && s.mapType >= 0 ? Mathf.Clamp(s.mapType, 0, 3) : PickWeighted(c.mapWeights, rng);
cx = s != null && s.castleX >= 0f ? s.castleX : Mathf.Lerp(c.castle.xMin, c.castle.xMax, (float)rng.NextDouble());
obst = s != null && s.obstacles >= 0 ? s.obstacles == 1 : rng.NextDouble() < c.obstacleChance;
bridges = type > 0 ? MakeBridges(c, type, rng) : new float[0];
kinds = new string[bridges.Length]; for (int i = 0; i < kinds.Length; i++) kinds[i] = c.BridgeKind(null)?.id;
}
for (int attempt = 0; ; attempt++)
{
var m = new BMap(c) { Type = type, Castle = new Vector2(cx, c.castle.y) };
m.SetTerrain(bridges, kinds, moat, canyon);
if (s?.customObstacles != null) m.Obstacles.AddRange(s.customObstacles);
else if (obst && attempt < 30) m.PlaceObstacles(r);
m.Build();
if (s?.customObstacles != null || attempt >= 30 || m.Reachable(spawn)) // 길이 막힌 배치는 버리고 다시
{
m.PlaceFields(fieldDefs, r, spawn);
m.Label = m.MakeLabel(fixedMap);
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;
}
/// 무작위 맵 다리 = 보통 다리(bridgeKinds 기본) · 칸 경계에 맞춤
static float[] MakeBridges(BattleCfg c, int n, System.Random rng)
{
var set = c.bridgeSets[Mathf.Clamp(n - 1, 0, c.bridgeSets.Count - 1)].x;
int cells = c.BridgeCells(null);
var r = new float[set.Length];
for (int i = 0; i < set.Length; i++) r[i] = SnapBridge(c, set[i] + ((float)rng.NextDouble() * 2f - 1f) * c.bridgeJitter, cells);
Array.Sort(r);
for (int i = 1; i < r.Length; i++) if (r[i] - r[i - 1] < cells + 1f) r[i] = SnapBridge(c, r[i - 1] + cells + 1f, cells);
return r;
}
/// 다리 가운데 = 지나갈 칸 수가 홀수면 칸 가운데 · 짝수면 칸 경계(칸 수만큼 정확히 열림)
static float SnapBridge(BattleCfg c, float x, int cells)
{
float h = cells * 0.5f;
x = Mathf.Clamp(x, h, c.arenaW - h);
return cells % 2 == 1 ? Mathf.Floor(x) + 0.5f : Mathf.Round(x);
}
/// 성 둘레(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; // 강 = 칸 전체가 다리 길(칸 수만큼) 안일 때만 지나감
for (int i = 0; i < Bridges.Length; i++) if (x >= Bridges[i] - BridgeLane[i] - 1e-3f && x + 1 <= Bridges[i] + BridgeLane[i] + 1e-3f) ok = true;
}
if (ok && (InMoat(p) || InCanyonWall(x, y))) ok = false; // 해자 물(입구 밖) · 협곡 절벽
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 = 닿은 깃발 번호
// 스테이지 50: freeze = 얼림(기절처럼 멈춤) · poison = 중독 남은 초(초당 최대 체력 poisonPct %) · mudMul = 진흙 속도 배율(매 틱) · cursed = 저주 오라 안 · *Fx = 화면 빛깔 남은 초
public float freeze, poison, poisonPct, mudMul = 1f, poisonFx, burnFx, windFx; public bool cursed;
public float Speed => def.speed * (slow > 0f ? slowMul : 1f) * mudMul;
public bool Frozen => stun > 0f || freeze > 0f;
}
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 partial 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 => Rage2 ? rage2.intervalMul : Enraged ? C.castle.enrageIntervalMul : 1f; // 2단 분노(성 능력)는 첫 분노 배율을 대신함
public float SummonMul => C.SummonMulAt(Stage);
public int RivalOutfit => C.RivalOutfitAt(Stage);
public bool LastMinute => TimeLeft <= C.lastMinuteSeconds;
public bool IsTest => Setup != null;
public string MapName => Map.Label;
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;
CastleMaxHp = CastleHp = c.CastleHpAt(Stage);
Atk = c.CastleAtkAt(Stage);
RangeMul = setup != null && setup.rangeMul > 0f ? setup.rangeMul : 1f;
TimeLeft = c.battleSeconds; Mana = Mathf.Min(c.manaStart, maxMana);
var lo = c.Loadout(Stage);
var fieldDefs = new List<BFieldDef>();
foreach (var id in (IEnumerable<string>)setup?.fields ?? lo.fields ?? new string[0]) { var f = c.Field(id); if (f != null) fieldDefs.Add(f); }
Map = BMap.Generate(c, rng, setup, SpawnPoint, Stage, fieldDefs);
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 }); continue; }
var pw = c.Power(id); if (pw != null) AddPower(pw);
}
}
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); foreach (var p in Powers) l.Add(p.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); }
TickFields(dt);
TickUnits(dt);
Separate(dt);
TickCastle(dt);
TickMagic(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);
float curseMul = CurseMul(u); u.cursed = curseMul < 1f;
if (u.Frozen) { u.stun = Mathf.Max(0f, u.stun - dt); u.freeze = Mathf.Max(0f, u.freeze - 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;
float dmg = u.def.damage * curseMul; // 저주 오라 안 = 공격력 감소
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 = dmg, speed = C.unitShotSpeed });
else if (m != null) HitMinion(m, dmg);
else { HitCastle(dmg); Thorns(u, dmg); } // 가시 성벽 = 성을 근접 공격하면 일부 되돌림
}
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.Frozen || 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;
if (!string.IsNullOrEmpty(w.def.kind)) { w.cd = CastNew(w.def) ? w.def.interval * mul : C.castleRetarget; continue; } // 새 기술(Battle.Stage.cs)
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;
}
TickPowers(dt);
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 * ShotDmgMul; // 2단 분노 = 투사체 피해 배율
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) || d.speed <= 0f) 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);
if (TickSpecialMinion(m, dt)) continue; // 수호병 · 폭탄병 · 토템(Battle.Stage.cs)
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);
}
}
/// flash = 맞은 빛(지속 피해 — 독·용암·장판 — 은 빛 없이)
void Damage(BUnit u, float d, bool flash = true)
{
if (u == null || !u.alive) return;
u.hp -= d; if (flash) 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;
if (Shield > 0f) { float a = Mathf.Min(Shield, d); Shield -= a; d -= a; ShieldAbsorbed += a; }
CastleHp -= d; CastleHitFlash = 0.12f; CastleQuiet = 0f; regenWait = 0f;
}
/// 성벽 회복 — 마지막으로 맞은 뒤 regenDelay 초가 지나면 그때부터 regenInterval 초마다 최대 체력의 regenPct % (최대 체력까지) · 치유 토템 = 맞든 안 맞든 계속
void TickRegen(float dt)
{
var k = C.castle;
TickTotems(dt);
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);
/// 전투 카드 칸 slot 의 유닛 = 고른 덱 프리셋의 그 칸(0 = 빈 칸 → null) · 전투에는 열린 칸(BattleSlots)만 나감
public BUnitDef BattleUnit(int slot) { var d = ActiveDeck; return slot >= 0 && slot < d.Length ? BC.UnitDef(d[slot]) : null; }
// ---------------- 덱(유닛 메뉴) — 프리셋 deckPresets 개 · 한 덱 = slotMax 칸 · 처음 = 유닛 순서대로 ----------------
public int DeckPresets => Math.Max(1, BC.deckPresets);
public int DeckSel => Math.Max(0, Math.Min(DeckPresets - 1, S.deckSel));
public void SelectDeck(int p) => S.deckSel = Math.Max(0, Math.Min(DeckPresets - 1, p));
public int[] ActiveDeck => Deck(DeckSel);
public int[] Deck(int p)
{
if (S.decks == null) S.decks = new List<SaveDeck>();
while (S.decks.Count < DeckPresets) S.decks.Add(new SaveDeck());
var d = S.decks[Math.Max(0, Math.Min(DeckPresets - 1, p))];
if (d.ids == null || d.ids.Length != BC.slotMax)
{
var ids = new int[BC.slotMax];
for (int i = 0; i < ids.Length; i++) ids[i] = d.ids != null ? (i < d.ids.Length ? d.ids[i] : 0) : (i < BC.units.Count ? BC.units[i].id : 0);
d.ids = ids;
}
return d.ids;
}
public int DeckPos(int unitId) => Array.IndexOf(ActiveDeck, unitId);
/// 빈 칸에 넣기(열린 칸 먼저) — 빈 칸이 없으면 false(→ 바꿀 칸 고르기)
public bool DeckAdd(int unitId)
{
var d = ActiveDeck;
if (Array.IndexOf(d, unitId) >= 0) return true;
int at = -1;
for (int i = 0; i < d.Length && at < 0; i++) if (d[i] == 0 && i < BattleSlots) at = i;
for (int i = 0; i < d.Length && at < 0; i++) if (d[i] == 0) at = i;
if (at < 0) return false;
d[at] = unitId;
return true;
}
/// slot 칸에 놓기 — 이미 덱의 다른 칸에 있으면 두 칸을 맞바꿈
public void DeckPut(int slot, int unitId)
{
var d = ActiveDeck;
if (slot < 0 || slot >= d.Length) return;
int from = Array.IndexOf(d, unitId);
if (from >= 0) d[from] = d[slot];
d[slot] = unitId;
}
public void DeckRemove(int unitId) { var d = ActiveDeck; int i = Array.IndexOf(d, unitId); if (i >= 0) d[i] = 0; }
/// 열린 칸에 유닛이 하나라도 있어야 전투 시작
public bool DeckReady { get { var d = ActiveDeck; for (int i = 0; i < Math.Min(BattleSlots, d.Length); i++) if (BC.UnitDef(d[i]) != null) return true; return false; } }
/// 평균 마나 = 열린 칸 유닛 마나 평균
public float DeckAvgCost { get { var d = ActiveDeck; int n = 0, sum = 0; for (int i = 0; i < Math.Min(BattleSlots, d.Length); i++) { var u = BC.UnitDef(d[i]); if (u != null) { n++; sum += u.cost; } } return n > 0 ? (float)sum / n : 0f; } }
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);
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)); // 스테이지 선택 첫 화면 = 다음에 깰 스테이지(다 깼으면 마지막)
/// 승리 보상(기본) = 방치 수입(PassGpsClean) × (기본 + 단계당) 초분 — 설계값
public BigInteger BattleReward(int stage) => new BigInteger(decimal.Ceiling(PassGpsClean() * (decimal)(BC.rewardPassSeconds + BC.rewardPassSecondsPerStage * (stage - 1))));
/// 첫 클리어 = 지금 깰 차례인 스테이지(BattleStage) · 보상 × 등급의 firstMul(일반·정예 ×3 · 보스·최종 ×5) · 다시 깨기 = ×1
public bool IsFirstClear(int stage) => stage == BattleStage && stage <= StageCount;
public BigInteger StageWinReward(int stage, bool first) => first ? new BigInteger(decimal.Ceiling((decimal)BattleReward(stage) * (decimal)BC.FirstClearMul(stage))) : BattleReward(stage);
public BigInteger NextWinReward(int stage) => StageWinReward(stage, IsFirstClear(stage));
public BigInteger WinBattle(Battle b)
{
if (b == null || b.State != BattleState.Won) return BigInteger.Zero;
bool first = IsFirstClear(b.Stage) && !b.IsTest;
var r = StageWinReward(b.Stage, first);
AddCoin(r);
if (first) S.battleStage = b.Stage + 1; // 다음 스테이지 열기 = 지금 깰 차례인 스테이지를 시험 설정 없이 이겼을 때만(하나씩)
return r;
}
}
}