326 lines
17 KiB
C#
326 lines
17 KiB
C#
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
|
||
namespace TKS
|
||
{
|
||
/// <summary>피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중). 지형 기믹 질의 = Gimmicks/Combat.Gimmicks.cs.</summary>
|
||
public static partial class Combat
|
||
{
|
||
/// <summary>경로 질의에 쓰는 원 반지름 배율(살짝 작게 캐스트해 스침을 관대하게).</summary>
|
||
public const float CastRadiusScale = 0.9f;
|
||
public const float Epsilon = 0.02f;
|
||
/// <summary>두 유닛 가장자리 간격이 이 이하면 「닿아 있다」(밀린 아군의 공격 기회 판정).</summary>
|
||
public const float ContactTolerance = 0.12f;
|
||
|
||
// ---------- 피해 공식 ----------
|
||
|
||
/// <summary>
|
||
/// 피해 = (공격 × 스킬 공 배율 × 상성(스킬 하한) × 크리 − 방어 × defenseFactor) × 궁병 근접 배율 × scale × 스킬 주는·받는 피해 배율 × 난수.
|
||
/// melee = 근접 전투(돌진·반격·관통·견제·밀려 닿은 공격). 원거리 병종(궁병·책사)이 근접 유닛(보병·기병·창병)과 근접 전투하면
|
||
/// 주는 피해 × archerMeleeDealtRatio · 받는 피해 × archerMeleeTakenRatio (PD 2026-11-04 #1080 · 책사 #1087 ⑤ 동일 · 돌파 melee_penalty_off 면 없음) · 원거리 사격은 패널티 없음.
|
||
/// scale = 호출 쪽 배율(관통 피해 배율 · 밀린 아군 공격 % 등) · 스킬 수치 = SkillMods(PD #1093 B).
|
||
/// </summary>
|
||
public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee, float scale = 1f)
|
||
{
|
||
return DamageCore(cfg, attacker, defender, crit, rng, melee, scale, 1f);
|
||
}
|
||
|
||
/// <summary>마지막 피해 계산이 치명타였나(명중 상태 이상 「치명타 시」 조건 · SkillSystem.Hit 가 읽음 · 2026-11-10 #1112).</summary>
|
||
public static bool LastCrit;
|
||
|
||
/// <summary>스킬 피해 「공격력의 ratio 배」(원거리 판정 · 반격 없음 · 치명타 판정 있음).</summary>
|
||
public static int SkillDamage(BalanceConfig cfg, Unit attacker, Unit defender, float ratio, System.Random rng, bool melee = false)
|
||
{
|
||
if (ratio <= 0f) return 0;
|
||
return DamageCore(cfg, attacker, defender, false, rng, melee, 1f, ratio);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 피해 = (공격 × 공격 배율(스킬) × atkRatio × 상성(불리 무시 하한) × 치명타 − 방어 × defenseFactor) × 근접 배율 × scale × 주는·받는 피해 배율 × 난수.
|
||
/// 치명타 = 호출 쪽 지정(궁병 목표점 명중) 또는 스킬 「무조건 치명타」·「치명타 확률」 판정(가상 실행 = 확률 판정 없음) · 배율 = critMultiplier × (1 + 치명타 피해 %).
|
||
/// </summary>
|
||
static int DamageCore(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee, float scale, float atkRatio)
|
||
{
|
||
if (!crit) crit = SkillMods.CritAlways(attacker, defender) || RollCrit(attacker, defender, rng);
|
||
LastCrit = crit;
|
||
float mult = Mathf.Max(cfg.Matchup(attacker.Type, defender.Type), SkillMods.MatchupFloor(attacker, true));
|
||
float atk = attacker.Atk * SkillMods.AttackMult(attacker, true, defender) * atkRatio * GimmickLayer.AttackMultOf(attacker); // 군기 점령(PD #1097)
|
||
float raw = atk * mult * (crit ? SkillMods.CritMult(attacker, cfg, defender) : 1f) - defender.Def * cfg.defenseFactor; // 보물 크리 % 포함(PD #1093 C)
|
||
if (melee)
|
||
{
|
||
if (UnitTypeInfo.IsRanged(attacker.Type) && UnitTypeInfo.IsMelee(defender.Type) && !SkillMods.Has(attacker, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeDealtRatio;
|
||
if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type) && !SkillMods.Has(defender, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeTakenRatio;
|
||
raw *= GimmickLayer.MeleeTakenMultOf(attacker, defender); // 고지 위 = 아래에서 오는 근접 피해 −(PD #1097)
|
||
}
|
||
else raw *= GimmickLayer.RangedTakenMultOf(defender); // 안개 안 = 받는 원거리 피해 ×p1(화살 · 책사 중심·광역 · PD 2026-11-06 개정)
|
||
raw *= scale * SkillMods.DealtMult(attacker, defender, true) * SkillMods.TakenMult(defender, attacker, true, !melee);
|
||
return Mathf.Max(1, Mathf.RoundToInt(raw * Variance(cfg, rng))); // 가상 실행 = 분산 0(PD #1098)
|
||
}
|
||
|
||
static bool RollCrit(Unit a, Unit d, System.Random rng)
|
||
{
|
||
float c = SkillMods.CritChance(a, d);
|
||
if (c <= 0f || rng == null || Sim.Active) return false;
|
||
return rng.NextDouble() < c;
|
||
}
|
||
|
||
/// <summary>책사 광역 피해(PD 2026-11-05 #1087 ⑤) = 대상별 사격 피해(상성 적용 · 크리 없음) × ratio · ratio ≤ 0 이면 0.</summary>
|
||
public static int SplashDamage(BalanceConfig cfg, Unit attacker, Unit defender, float ratio, System.Random rng)
|
||
{
|
||
if (ratio <= 0f) return 0;
|
||
int baseDmg = Damage(cfg, attacker, defender, false, rng, false);
|
||
return Mathf.Max(1, Mathf.RoundToInt(baseDmg * ratio));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 반격 피해 = 반격하는 쪽(defender) 피해 × 반격 배율(무성·진중 등 · 기본 counterRatio) × 받는 쪽(attacker) 반격 피해 배율(군율 · 금범적).
|
||
/// defenderPushed = 반격하는 유닛이 이 교환에서 밀렸다(알까기·밀며 전진·관통 넉백) — 진중(밀렸을 때만) 조건. 0 = 반격 없음.
|
||
/// </summary>
|
||
public static int CounterDamage(BalanceConfig cfg, Unit defender, Unit attacker, System.Random rng, bool defenderPushed = false)
|
||
{
|
||
float taken = SkillMods.CounterTakenMult(attacker, defender, true); // 받는 반격 %(조건 = 반격하는 상대 · 2026-11-10 #1112)
|
||
if (taken <= 0f) return 0;
|
||
float ratio = cfg.counterRatio * SkillMods.CounterDmgMult(defender, true); // 반격 피해 +%
|
||
int baseDmg = Damage(cfg, defender, attacker, false, rng, true);
|
||
return Mathf.Max(1, Mathf.RoundToInt(baseDmg * ratio * taken));
|
||
}
|
||
|
||
/// <summary>견제 피해(옛 창병 대기 · 대기 유닛이 없어 쓰이지 않음) = 근접 피해 × overwatchDamageRatio.</summary>
|
||
public static int OverwatchDamage(BalanceConfig cfg, Unit spear, Unit mover, System.Random rng)
|
||
{
|
||
int baseDmg = Damage(cfg, spear, mover, false, rng, true);
|
||
return Mathf.Max(1, Mathf.RoundToInt(baseDmg * cfg.overwatchDamageRatio));
|
||
}
|
||
|
||
// ---------- 기하 질의 ----------
|
||
|
||
static RaycastHit2D[] Cast(Unit actor, Vector2 origin, Vector2 dir, float dist)
|
||
{
|
||
Physics2D.SyncTransforms();
|
||
var hits = Physics2D.CircleCastAll(origin, actor.Radius * CastRadiusScale, dir, dist);
|
||
System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
|
||
return hits;
|
||
}
|
||
|
||
static Unit UnitOf(Collider2D c)
|
||
{
|
||
return c != null ? c.GetComponent<Unit>() : null;
|
||
}
|
||
|
||
/// <summary>경로(원 캐스트) 위 가장 가까운 적. hitDist = 그 적에 닿기까지 이동 거리.</summary>
|
||
public static Unit FirstEnemyOnPath(Unit actor, Vector2 origin, Vector2 dir, float dist, out float hitDist)
|
||
{
|
||
hitDist = 0f;
|
||
foreach (var h in Cast(actor, origin, dir, dist))
|
||
{
|
||
var u = UnitOf(h.collider);
|
||
if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue;
|
||
hitDist = h.distance;
|
||
return u;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>경로 위 모든 유닛(아군·적군 · 가까운 순 · 접촉 거리) — 스킬 관통(손견·안량·동탁 · 아군 통과 시).</summary>
|
||
public static List<Unit> UnitsOnPath(Unit actor, Vector2 origin, Vector2 dir, float dist, List<float> distances, float radius = -1f)
|
||
{
|
||
var list = new List<Unit>();
|
||
distances.Clear();
|
||
Physics2D.SyncTransforms();
|
||
var hits = Physics2D.CircleCastAll(origin, radius > 0f ? radius : actor.Radius * CastRadiusScale, dir, dist);
|
||
System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
|
||
foreach (var h in hits)
|
||
{
|
||
var u = UnitOf(h.collider);
|
||
if (u == null || u == actor || !u.IsAlive || list.Contains(u)) continue;
|
||
list.Add(u);
|
||
distances.Add(h.distance);
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/// <summary>전장 벽까지 나아갈 수 있는 거리와 그 벽의 면 방향(핀볼 튕김 · 손책·동탁). 벽에 안 닿으면 normal = 0.</summary>
|
||
public static float BoardWallTravel(Unit actor, Vector2 origin, Vector2 dir, float dist, out Vector2 normal)
|
||
{
|
||
normal = Vector2.zero;
|
||
Physics2D.SyncTransforms();
|
||
float best = dist;
|
||
foreach (var h in Physics2D.CircleCastAll(origin, actor.Radius * CastRadiusScale, dir, dist))
|
||
{
|
||
if (h.collider.GetComponent<BoardWall>() == null || h.distance >= best) continue;
|
||
best = h.distance;
|
||
normal = h.normal;
|
||
}
|
||
return Mathf.Max(0f, best - Epsilon);
|
||
}
|
||
|
||
/// <summary>경로(원 캐스트) 위 가장 가까운 유닛(아군·적군 불문). hitDist = 닿기까지 이동 거리. 벽은 무시한다(호출 쪽에서 FreeTravel 과 비교).</summary>
|
||
public static Unit FirstUnitOnPath(Unit actor, Vector2 origin, Vector2 dir, float dist, out float hitDist)
|
||
{
|
||
hitDist = 0f;
|
||
foreach (var h in Cast(actor, origin, dir, dist))
|
||
{
|
||
var u = UnitOf(h.collider);
|
||
if (u == null || u == actor || !u.IsAlive) continue;
|
||
hitDist = h.distance;
|
||
return u;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>경로 위 가장 가까운 유닛(except 제외) — 보병 연쇄 튕김(PD 2026-11-06 #1098 · 방금 부딪힌 유닛은 뺀다).</summary>
|
||
public static Unit FirstUnitOnPathExcept(Unit actor, Vector2 origin, Vector2 dir, float dist, Unit except, out float hitDist)
|
||
{
|
||
hitDist = 0f;
|
||
foreach (var h in Cast(actor, origin, dir, dist))
|
||
{
|
||
var u = UnitOf(h.collider);
|
||
if (u == null || u == actor || u == except || !u.IsAlive) continue;
|
||
hitDist = h.distance;
|
||
return u;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외).
|
||
/// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist).
|
||
/// passFaction = 그 진영 유닛은 통과(백발백중 arrow_pass_allies · 기본 null).
|
||
/// </summary>
|
||
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, out float travel)
|
||
{
|
||
return FirstUnitOnArrow(shooter, from, dir, dist, arrowRadius, null, out travel);
|
||
}
|
||
|
||
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel)
|
||
{
|
||
GimmickInstance g;
|
||
return FirstUnitOnArrow(shooter, from, dir, dist, arrowRadius, passFaction, out travel, out g); // 기믹 pass/block/destroy · 안개(Combat.Gimmicks.cs)
|
||
}
|
||
|
||
/// <summary>점 center 반경 radius 원에 몸(원)이 닿는 살아 있는 유닛(가까운 순 · 진영 불문 · exclude 둘 제외) — 책사 광역(보스는 반지름이 커서 더 잘 닿는다).</summary>
|
||
public static List<Unit> UnitsInCircle(Vector2 center, float radius, Unit excludeA, Unit excludeB)
|
||
{
|
||
Physics2D.SyncTransforms();
|
||
var list = new List<Unit>();
|
||
foreach (var c in Physics2D.OverlapCircleAll(center, radius))
|
||
{
|
||
var u = UnitOf(c);
|
||
if (u == null || u == excludeA || u == excludeB || !u.IsAlive || list.Contains(u)) continue;
|
||
list.Add(u);
|
||
}
|
||
list.Sort((a, b) => Vector2.Distance(center, a.Position).CompareTo(Vector2.Distance(center, b.Position)));
|
||
return list;
|
||
}
|
||
|
||
/// <summary>u 와 가장자리가 닿아 있는(ContactTolerance 이내) 가장 가까운 상대 진영 유닛.</summary>
|
||
public static Unit TouchingOpponent(Unit u)
|
||
{
|
||
Physics2D.SyncTransforms();
|
||
Unit best = null;
|
||
float bestGap = float.MaxValue;
|
||
foreach (var c in Physics2D.OverlapCircleAll(u.Position, u.Radius + ContactTolerance + 0.5f))
|
||
{
|
||
var o = UnitOf(c);
|
||
if (o == null || o == u || !o.IsAlive || o.Faction == u.Faction) continue;
|
||
float gap = Vector2.Distance(u.Position, o.Position) - u.Radius - o.Radius;
|
||
if (gap <= ContactTolerance && gap < bestGap)
|
||
{
|
||
bestGap = gap;
|
||
best = o;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/// <summary>경로 위의 모든 적(가까운 순)과 각 접촉 거리.</summary>
|
||
public static List<Unit> EnemiesOnPath(Unit actor, Vector2 origin, Vector2 dir, float dist, List<float> distances)
|
||
{
|
||
var list = new List<Unit>();
|
||
distances.Clear();
|
||
foreach (var h in Cast(actor, origin, dir, dist))
|
||
{
|
||
var u = UnitOf(h.collider);
|
||
if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue;
|
||
if (list.Contains(u)) continue;
|
||
list.Add(u);
|
||
distances.Add(h.distance);
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 벽·장애물 기믹·(옵션) 유닛에 막히기 전까지 실제로 나아갈 수 있는 거리. ignore = 밀어 주는 유닛처럼 출발 지점에서 닿아 있어도 무시할 유닛.
|
||
/// 숨은 함정에 중심이 들면 그 자리에서 멈춘다(PD #1097 · 아군 조준 미리보기는 함정을 모른다).
|
||
/// </summary>
|
||
public static float FreeTravel(Unit actor, Vector2 origin, Vector2 dir, float dist, bool enemiesBlock, bool friendsBlock = true, Unit ignore = null)
|
||
{
|
||
float min = TrapStop(actor, origin, dir, dist);
|
||
foreach (var h in Cast(actor, origin, dir, dist))
|
||
{
|
||
if (h.collider.GetComponent<BoardWall>() != null || GimmickLayer.BlocksMove(h.collider))
|
||
{
|
||
min = Mathf.Min(min, h.distance);
|
||
continue;
|
||
}
|
||
var u = UnitOf(h.collider);
|
||
if (u == null || u == actor || u == ignore || !u.IsAlive) continue;
|
||
bool enemy = u.Faction != actor.Faction;
|
||
if (enemy && !enemiesBlock) continue;
|
||
if (!enemy && !friendsBlock) continue;
|
||
min = Mathf.Min(min, h.distance);
|
||
}
|
||
return Mathf.Max(0f, min - Epsilon);
|
||
}
|
||
|
||
/// <summary>목표점이 적 원 안(여유 포함)에 들어왔으면 그 적.</summary>
|
||
public static Unit EnemyAtPoint(Unit actor, Vector2 point, float tolerance)
|
||
{
|
||
Physics2D.SyncTransforms();
|
||
Unit best = null;
|
||
float bestD = float.MaxValue;
|
||
foreach (var c in Physics2D.OverlapCircleAll(point, tolerance))
|
||
{
|
||
var u = UnitOf(c);
|
||
if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue;
|
||
float d = Vector2.Distance(point, u.Position);
|
||
if (d < bestD)
|
||
{
|
||
bestD = d;
|
||
best = u;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/// <summary>점 p 에서 가장 가까운 지정 진영 유닛(터치 여유 extra 포함).</summary>
|
||
public static Unit UnitNear(IEnumerable<Unit> units, Vector2 p, Faction? faction, float extra)
|
||
{
|
||
Unit best = null;
|
||
float bestD = float.MaxValue;
|
||
foreach (var u in units)
|
||
{
|
||
if (!u.IsAlive) continue;
|
||
if (faction.HasValue && u.Faction != faction.Value) continue;
|
||
float d = Vector2.Distance(p, u.Position);
|
||
if (d <= u.Radius + extra && d < bestD)
|
||
{
|
||
bestD = d;
|
||
best = u;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/// <summary>선분 ab 와 점 p 의 최단 거리.</summary>
|
||
public static float SegmentPointDistance(Vector2 a, Vector2 b, Vector2 p)
|
||
{
|
||
Vector2 ab = b - a;
|
||
float len2 = ab.sqrMagnitude;
|
||
if (len2 < 1e-6f) return Vector2.Distance(a, p);
|
||
float t = Mathf.Clamp01(Vector2.Dot(p - a, ab) / len2);
|
||
return Vector2.Distance(a + ab * t, p);
|
||
}
|
||
}
|
||
}
|