using System.Collections.Generic; using UnityEngine; namespace TKS { /// /// 스킬 수치 질의(2026-11-10 #1112 · 효과 표 skill_effects.csv) — ActionResolver · Combat · EnemyAI · BattleManager · SlingshotInput 이 수치를 읽을 때 부른다. /// Get(unit, key, other, crit) = 그 유닛에게 지금 걸린 효과: 패시브(대상 칸 = 자신 · 같은 편 · 진영 · 반경 · 슬링 범위) · 장착된 예약 액티브 · 시한 효과 · 보물 진영 효과. /// 조건 칸(치명타 · 상대 상태 이상 · 병종 · 성별 · 턴 · 장착 중)은 other(공격 대상 또는 공격자)·crit 으로 판정한다. 효과가 없으면 중립값(합 0 · 곱 1). /// note = 실제 적용 때만 true → 패시브 발동 HUD 한 줄. /// public static class SkillMods { static readonly List Empty = new List(); public static List Get(Unit u, string key, Unit other = null, bool crit = false) { var sys = SkillSystem.Current; if (sys == null || u == null) return Empty; List list = null; foreach (var s in sys.Entries) { var src = s.Unit; if (src == null) continue; if (s.PassiveEffects != null && (src.IsAlive || s.BannerLeft > 0)) { foreach (var e in s.PassiveEffects) { if (e.Key != key || !Applies(s, src, u, e, false) || !CondOk(sys, s, e, u, other, crit)) continue; if (list == null) list = new List(); list.Add(new SkillHit { Owner = s, Def = s.Passive, E = e, Passive = true }); } } if ((s.Armed || s.Casting) && src == u && s.ActiveEffects != null) { foreach (var e in s.ActiveEffects) { if (e.Key != key || e.Key == SkillKeys.Invuln || !CondOk(sys, s, e, u, other, crit)) continue; if (e.Scope != SkillScope.Self && e.Dur > 0) continue; // 대상이 다른 지속 효과 = 시한 효과로만 if (list == null) list = new List(); list.Add(new SkillHit { Owner = s, Def = s.Active, E = e, Passive = false }); } } } foreach (var t in sys.TimedEffects) { if (t.E.Key != key || !TimedApplies(t, u) || !CondOk(sys, t.Owner, t.E, u, other, crit)) continue; if (list == null) list = new List(); list.Add(new SkillHit { Owner = t.Owner, Def = t.Def, E = t.E, Passive = false, Timed = t }); } // 진영 상시 효과(보물 수집품 crit·range·splash · PD #1093 C) — 주인 없음 · HUD 한 줄 없음 var side = sys.Battle != null ? sys.Battle.SideEffects(u.Faction) : null; if (side != null) foreach (var e in side) { if (e.Key != key) continue; if (list == null) list = new List(); list.Add(new SkillHit { Owner = null, Def = null, E = e, Passive = false }); } return list ?? Empty; } /// 보물 전용 크리 피해 % 키(skills 표 키 아님 · SkillKeys.All 에 넣지 않음 · PD #1093 C). public const string SideCritPct = "side_crit_pct"; /// 「행동 반경」 = 주인의 기본 슬링 최대 거리(효과로 늘어난 값이 아님 — 질의가 서로 부르지 않게). public static float BaseReach(Unit owner) { return owner != null ? owner.SlingMaxDistance : 0f; } public static bool Within(Unit owner, Unit u, float r) { return owner != null && u != null && Vector2.Distance(owner.Position, u.Position) <= r + u.Radius; } /// 대상 칸 판정(dynamic = 매번 · Range 는 시한 효과에서 고정 대상으로 처리). public static bool Applies(UnitSkills s, Unit src, Unit u, SkillEffect e, bool timed) { if (e.Scope == SkillScope.Self) return src == u; if (src.Faction != u.Faction) return false; switch (e.Scope) { case SkillScope.Allies: return true; case SkillScope.Camp: return u.Camp == e.Camp && (!e.HasType || u.Type == e.Type); case SkillScope.CampMale: return u.Camp == e.Camp && !u.Female; case SkillScope.Radius: return u != src && Within(src, u, e.Radius); case SkillScope.Range: case SkillScope.Aura: if (!src.IsAlive && s != null && s.BannerLeft > 0) return Vector2.Distance(s.BannerPos, u.Position) <= BaseReach(src) + u.Radius; return Within(src, u, BaseReach(src)); } return false; } static bool TimedApplies(SkillSystem.Timed t, Unit u) { if (t.Targets != null) return t.Targets.Contains(u); var src = t.Owner != null ? t.Owner.Unit : null; if (src == null) return false; if (t.E.Scope != SkillScope.Self && !src.IsAlive) return false; return Applies(t.Owner, src, u, t.E, true); } /// 조건 칸 — self = 효과를 받는 유닛 · other = 상대(공격 대상 또는 공격자). static bool CondOk(SkillSystem sys, UnitSkills owner, SkillEffect e, Unit self, Unit other, bool crit) { switch (e.Cond) { case SkillCondKind.None: return true; case SkillCondKind.Crit: return crit; case SkillCondKind.TurnLe: return sys.Battle != null && sys.Battle.TurnNumber <= e.CondN; case SkillCondKind.Armed: return owner != null && (owner.Armed || owner.Casting); } if (other == null) return false; switch (e.Cond) { case SkillCondKind.VsStatus: return SkillStatus.HasAny(other); case SkillCondKind.VsBleed: return SkillStatus.Bleeding(other); case SkillCondKind.VsConfused: return SkillStatus.Confused(other); case SkillCondKind.VsStunned: return SkillStatus.Stunned(other); case SkillCondKind.VsType: return other.Type == e.CondType; case SkillCondKind.VsMale: return !other.Female; case SkillCondKind.VsMaleHero: return !other.Female && sys.IsGeneral(other); case SkillCondKind.VsFemaleHero: return other.Female && sys.IsGeneral(other); } return false; } static void Note(SkillHit h, bool note) { if (!note || !h.Passive || SkillSystem.Current == null) return; SkillSystem.Current.NotePassive(h.Owner, h.Def); } // ---------- 일반 ---------- public static float Sum(Unit u, string key, bool note = false, Unit other = null, bool crit = false) { float s = 0f; foreach (var h in Get(u, key, other, crit)) { s += h.E.P; Note(h, note); } return s; } public static float Product(Unit u, string key, bool note = false, Unit other = null) { float p = 1f; foreach (var h in Get(u, key, other)) { p *= h.E.P; Note(h, note); } return p; } /// Π(1 + 값) · 0 아래로 내려가지 않음(받는 피해 −% 류). public static float PctProduct(Unit u, string key, bool note = false, Unit other = null) { float p = 1f; foreach (var h in Get(u, key, other)) { p *= Mathf.Max(0f, 1f + h.E.P); Note(h, note); } return p; } public static bool Has(Unit u, string key, bool note = false, Unit other = null, bool crit = false) { var l = Get(u, key, other, crit); foreach (var h in l) Note(h, note); return l.Count > 0; } /// 첫 효과(수치 칸을 그대로 쓰는 처리기) · 없으면 null. public static SkillEffect First(Unit u, string key, bool note = false) { var l = Get(u, key); if (l.Count == 0) return null; Note(l[0], note); return l[0].E; } // ---------- 슬링 거리 · 사거리 · 광역 ---------- /// /// 슬링 최대 거리 = 기본 × (1 + 이동 % 합) × (1 + 진중 축적) × 혼란 배율 · 재행동 남은 거리 상한(감녕·고순) — 조준 · 실제 이동 · 적 AI 공용. /// public static float SlingMax(Unit u, bool note = false) { if (u == null) return 0f; float pct = Sum(u, SkillKeys.SlingDistPct, note); var sys = SkillSystem.Current; var s = sys != null ? sys.Of(u) : null; float bank = s != null ? s.Bank : 0f; float v = u.SlingMaxDistance * Mathf.Max(0.1f, 1f + pct) * (1f + bank) * SkillStatus.ConfuseMult(u); if (s != null && s.ReactLimit > 0f) v = Mathf.Min(v, s.ReactLimit); return Mathf.Max(0.1f, v); } /// 궁병·책사 사거리 = (기본 + 고지 p1(PD #1097)) × range_mult(대상·조건 칸 · 보물) · 근접 병종이 원거리로 쏠 때(축융 비도·주태 검기) = 슬링 거리 기준. public static float Range(Unit u, bool note = false) { if (u == null) return 0f; float b = UnitTypeInfo.IsRanged(u.Type) ? u.AttackRange : u.SlingMaxDistance; float r = b + GimmickLayer.RangeBonusOf(u); return Mathf.Max(0f, r * Product(u, SkillKeys.RangeMult, note)); } public static float SplashRadius(Unit u, BalanceConfig cfg, bool note = false) { return cfg.strategistSplashRadius * Product(u, SkillKeys.SplashRadiusMult, note); } public static float SplashRatio(Unit u, BalanceConfig cfg, bool note = false) { return cfg.strategistSplashRatio; } /// 광역 아군 비율 = 서서·제갈근 치환값(여럿이면 작은 값) · 없으면 자산 값. public static float SplashAllyRatio(Unit u, BalanceConfig cfg, bool note = false) { var l = Get(u, SkillKeys.SplashAllyRatio); if (l.Count == 0) return cfg.strategistAllyDamageRatio; float r = float.MaxValue; foreach (var h in l) { r = Mathf.Min(r, h.E.P); Note(h, note); } return Mathf.Max(0f, r); } // ---------- 피해 ---------- /// 상성 하한(불리 상성 무시 = 1 · 0 = 없음). public static float MatchupFloor(Unit a, bool note = false) { float f = 0f; foreach (var h in Get(a, SkillKeys.MatchupFloor)) { f = Mathf.Max(f, h.E.P); Note(h, note); } return f; } /// /// 공격력 배율 = (1 + 공 % 합(조건 = 상대 d) + 처치 누적 + 잃은 HP + 범위 안 아군 + 아군 전사 + 돌진 거리 + 재행동 누적 + 고육계 이월) × 「공격력의 X %」(atk_ratio) × 저HP 스킬 위력(주유). /// public static float AttackMult(Unit a, bool note = false, Unit d = null) { var sys = SkillSystem.Current; if (sys == null || a == null) return 1f; var s = sys.Of(a); float pct = Sum(a, SkillKeys.AtkPct, note, d); foreach (var h in Get(a, SkillKeys.KillStackAtk)) { int kills = s != null ? s.Kills : 0; if (kills <= 0) continue; pct += Mathf.Min(h.E.HasA2 ? h.E.A2 : float.MaxValue, h.E.P * kills); Note(h, note); } float missing = a.MaxHp > 0 ? Mathf.Clamp01(1f - (float)a.Hp / a.MaxHp) : 0f; foreach (var h in Get(a, SkillKeys.AtkMissingHp)) { if (missing <= 0f) continue; pct += Mathf.Min(h.E.HasA2 && h.E.A2 > 0f ? h.E.A2 : float.MaxValue, h.E.P * missing); Note(h, note); } foreach (var h in Get(a, SkillKeys.AtkPerAllyRange)) { int n = 0; foreach (var o in sys.Battle.AliveUnits(a.Faction)) if (o != a && Within(a, o, BaseReach(a))) n++; if (n == 0) continue; pct += h.E.P * n; Note(h, note); } var deaths = Get(a, SkillKeys.OnAllyDeathAtk); if (deaths.Count > 0) { int dead = DeadAllies(a); if (dead > 0) foreach (var h in deaths) { pct += h.E.P * dead; Note(h, note); } } var ctx = sys.Ctx; if (ctx != null && ctx.Actor == a && ctx.SlingRatio > 0f) foreach (var h in Get(a, SkillKeys.DashDmgPct)) { pct += h.E.P * ctx.SlingRatio; Note(h, note); } if (s != null && s.ReactsThisTurn > 0) foreach (var h in Get(a, SkillKeys.ReactionAtkStack)) { pct += h.E.P * Mathf.Pow(2f, s.ReactsThisTurn - 1); Note(h, note); } if (s != null && s.AtkFlat > 0f && a.Atk > 0) pct += s.AtkFlat / a.Atk; // 고육계: 지난 턴 받은 피해 → 이번 턴 공격력 float m = Mathf.Max(0f, 1f + pct); m *= Product(a, SkillKeys.AtkRatio, note); if (s != null && (s.Armed || s.Casting)) foreach (var h in Get(a, SkillKeys.SkillPowerLowHp)) { float step = h.E.HasA2 && h.E.A2 > 0f ? h.E.A2 : 0.1f; int k = Mathf.FloorToInt(missing / step + 1e-4f); if (k <= 0) continue; m *= 1f + h.E.P * k; Note(h, note); } return m; } public static int DeadAllies(Unit a) { var sys = SkillSystem.Current; if (sys == null) return 0; int n = 0; foreach (var u in sys.Battle.Units) if (u != null && u != a && u.Faction == a.Faction && !u.IsAlive && !SkillSystem.IsSummon(u)) n++; return n; } /// 주는 피해 배율 = 1 + 피해 % 합(조건 = 상대 d) + 마초 「맞힌 적 1기당」. public static float DealtMult(Unit a, Unit d, bool note = false) { var sys = SkillSystem.Current; float m = 1f + Sum(a, SkillKeys.DmgPct, note, d); var ctx = sys != null ? sys.Ctx : null; if (ctx != null && ctx.Actor == a && ctx.HitCount > 0) foreach (var h in Get(a, SkillKeys.DmgPerHit)) { m += h.E.P * ctx.HitCount; Note(h, note); } return Mathf.Max(0f, m); } /// 받는 피해 배율 = 받는 피해 %(조건 = 공격자 a) × 원거리 받는 피해 × 적 궁병 피해 × 수성(남은 이동 거리) × 노장(잃은 HP). public static float TakenMult(Unit d, Unit a, bool note = false, bool ranged = false) { float m = PctProduct(d, SkillKeys.DmgTakenPct, note, a); if (ranged) m *= PctProduct(d, SkillKeys.DmgTakenRangedPct, note, a); if (a != null && a.Type == UnitType.Archer) m *= PctProduct(d, SkillKeys.DmgTakenFromArcherPct, note, a); var sys = SkillSystem.Current; var s = sys != null ? sys.Of(d) : null; if (s != null && s.UnusedMove > 0f) foreach (var h in Get(d, SkillKeys.UnusedMoveDr)) { m *= Mathf.Max(0f, 1f - h.E.P * s.UnusedMove); Note(h, note); } float lost = d.MaxHp > 0 ? Mathf.Clamp01(1f - (float)d.Hp / d.MaxHp) * 100f : 0f; if (lost > 0f) foreach (var h in Get(d, SkillKeys.LowHpTaken)) { m *= 1f + h.E.P * lost; Note(h, note); } return m; } // ---------- 치명타 ---------- /// 크리 배율 = 자산 critMultiplier × (1 + 보물 크리 %) × (1 + 치명타 피해 % 합). public static float CritMult(Unit a, BalanceConfig cfg, Unit d = null) { return cfg.critMultiplier * (1f + Sum(a, SideCritPct)) * (1f + Sum(a, SkillKeys.CritDmgPct, true, d)); } public static float CritChance(Unit a, Unit d) { return Mathf.Clamp01(Sum(a, SkillKeys.CritChance, false, d)); } public static bool CritAlways(Unit a, Unit d) { return Has(a, SkillKeys.CritAlways, true, d); } // ---------- 반격 ---------- /// 받는 반격 피해 배율(공격한 유닛 a 기준 · 조건 = 반격하는 상대) · 0 = 반격 안 받음. public static float CounterTakenMult(Unit a, Unit counterer, bool note = false) { return PctProduct(a, SkillKeys.CounterTakenPct, note, counterer); } /// 주는 반격 피해 배율 = 1 + 반격 피해 % 합 + 노장(잃은 HP 1 %당 p2). public static float CounterDmgMult(Unit counterer, bool note = false) { float m = 1f + Sum(counterer, SkillKeys.CounterDmgPct, note); float lost = counterer.MaxHp > 0 ? Mathf.Clamp01(1f - (float)counterer.Hp / counterer.MaxHp) * 100f : 0f; if (lost > 0f) foreach (var h in Get(counterer, SkillKeys.LowHpTaken)) if (h.E.HasA2) m += h.E.A2 * lost; return Mathf.Max(0f, m); } // ---------- 밀기 · 튕김 ---------- public static float PushMult(Unit actor, bool note = false) { return Mathf.Max(0f, Product(actor, SkillKeys.PushMult, note)); } public static float SelfBounceMult(Unit actor, bool note = false) { return Mathf.Max(0f, Product(actor, SkillKeys.SelfBounceMult, note)); } /// 밀리는 거리 배율(거구 −100 % = 안 밀림). public static float KnockbackTakenMult(Unit target, bool note = false) { return PctProduct(target, SkillKeys.KnockbackTakenPct, note); } /// 서황 대부: 도끼가 도는 동안 자신을 공격한 유닛은 × p3 멀리 튕긴다. public static float AxeBounce(Unit struck) { if (struck == null) return 1f; float m = 1f; foreach (var h in Get(struck, SkillKeys.AxeAura)) if (h.E.HasA3) m = Mathf.Max(m, h.E.A3); return m; } /// 전위 고지악래: 지형 오브젝트 밀기 거리·그것으로 준 피해 배율. public static float GimmickMult(Unit u) { return u != null ? Mathf.Max(0f, Product(u, SkillKeys.GimmickMult, true)) : 1f; } } }