ThreeKingdomsSlingshot/Assets/Scripts/Runtime/Combat.cs

245 lines
11 KiB
C#
Raw 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.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중).</summary>
public static 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) × 궁병 근접 배율 × 난수.
/// melee = 근접 전투(돌진·반격·관통·견제·밀려 닿은 공격). 원거리 병종(궁병·책사)이 근접 유닛(보병·기병·창병)과 근접 전투하면
/// 주는 피해 × archerMeleeDealtRatio · 받는 피해 × archerMeleeTakenRatio (PD 2026-11-04 #1080 · 책사 #1087 ⑤ 동일) · 원거리 사격은 패널티 없음.
/// </summary>
public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee)
{
float mult = cfg.Matchup(attacker.Type, defender.Type);
float raw = attacker.Atk * mult * (crit ? cfg.critMultiplier : 1f) - defender.Def * cfg.defenseFactor;
if (melee)
{
if (UnitTypeInfo.IsRanged(attacker.Type) && UnitTypeInfo.IsMelee(defender.Type)) raw *= cfg.archerMeleeDealtRatio;
if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type)) raw *= cfg.archerMeleeTakenRatio;
}
float variance = 1f + ((float)rng.NextDouble() * 2f - 1f) * cfg.damageVariance;
return Mathf.Max(1, Mathf.RoundToInt(raw * variance));
}
/// <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));
}
public static int CounterDamage(BalanceConfig cfg, Unit defender, Unit attacker, System.Random rng)
{
int baseDmg = Damage(cfg, defender, attacker, false, rng, true);
return Mathf.Max(1, Mathf.RoundToInt(baseDmg * cfg.counterRatio));
}
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>경로(원 캐스트) 위 가장 가까운 유닛(아군·적군 불문). 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>
/// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외).
/// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist).
/// </summary>
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, out float travel)
{
travel = dist;
Physics2D.SyncTransforms();
var hits = Physics2D.CircleCastAll(from, arrowRadius, dir, dist);
System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
foreach (var h in hits)
{
if (h.collider.GetComponent<BoardWall>() != null)
{
travel = h.distance;
return null;
}
var u = UnitOf(h.collider);
if (u == null || u == shooter || !u.IsAlive) continue;
travel = h.distance;
return u;
}
return null;
}
/// <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 = 밀어 주는 유닛처럼 출발 지점에서 닿아 있어도 무시할 유닛.</summary>
public static float FreeTravel(Unit actor, Vector2 origin, Vector2 dir, float dist, bool enemiesBlock, bool friendsBlock = true, Unit ignore = null)
{
float min = dist;
foreach (var h in Cast(actor, origin, dir, dist))
{
if (h.collider.GetComponent<BoardWall>() != null)
{
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);
}
}
}