ThreeKingdomsSlingshot/Assets/Scripts/Runtime/Combat.cs

326 lines
17 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

using System.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);
}
}
}