792 lines
44 KiB
C#
792 lines
44 KiB
C#
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; }
|
||
[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<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] };
|
||
}
|
||
|
||
// 스테이지 = 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<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;
|
||
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<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);
|
||
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;
|
||
}
|
||
}
|
||
}
|