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, 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_) + 이름 — 전투 유닛(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% · 사거리 = 성 가장자리부터 [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; } [Serializable] public class BLoadout { public int stage; public string name; public string[] shots, gimmicks; } // 스테이지 한 줄 = 번호 · 이름 · 성 투사체 · 기믹 [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 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 units; public BCastleDef castle; public List shots, skills; public List summons; public List stageLoadouts; public float[] testRangeMuls; public BViewDef view; public List grades; public int deckPresets; public List 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 ?? 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)); 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 int mapType = -1; // 0 강 없는 평지 · 1~3 다리 수 public int obstacles = -1; // 0 없음 · 1 있음 public float castleX = -1f; public List shots, gimmicks; // null = 단계 기본(stageLoadouts) public float rangeMul = 1f; public List customObstacles; // 검사용 고정 장애물 public BattleSetup Clone() => new BattleSetup { mapType = mapType, obstacles = obstacles, castleX = castleX, rangeMul = rangeMul, customObstacles = customObstacles, shots = shots != null ? new List(shots) : null, gimmicks = gimmicks != null ? new List(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 Obstacles = new List(); public readonly int W, H; public bool[] Walk; public float[] CastleField; // 성 앞 칸까지 남은 거리 public bool River => Type > 0; readonly BattleCfg C; readonly Dictionary fields = new Dictionary(); 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(); 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 { g }); fields[g] = f; } return f; } float[] Dijkstra(List 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 k = new List(); readonly List v = new List(); 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 Units = new List(); public readonly List Minions = new List(); public readonly List Shots = new List(); public readonly List Fx = new List(); public readonly List CastleShots = new List(), CastleSkills = new List(); public readonly List Summoners = new List(); 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; 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 => 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; 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); Map = BMap.Generate(c, rng, setup, SpawnPoint); var lo = c.Loadout(Stage); foreach (var id in (IEnumerable)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)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 ShotNames() { var l = new List(); foreach (var w in CastleShots) l.Add(w.def.name); return l; } public List GimmickNames() { var l = new List(); 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); /// 전투 카드 칸 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(); 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)))); public BigInteger WinBattle(Battle b) { if (b == null || b.State != BattleState.Won) return BigInteger.Zero; var r = BattleReward(b.Stage); AddCoin(r); if (b.Stage == BattleStage && !b.IsTest) S.battleStage = b.Stage + 1; // 다음 스테이지 열기 = 지금 깰 차례인 스테이지를 시험 설정 없이 이겼을 때만(하나씩) return r; } } }