using System.Collections; using System.Collections.Generic; using UnityEngine; namespace TKS { /// 유닛 1기의 스킬 상태(액티브 쿨·예약 · 패시브 · 행동 뒤에도 남는 값). public class UnitSkills { public Unit Unit; public string HeroId = ""; /// 무장(영웅·보스·엘리트 장수) — 남성·여성 「영웅」 조건 판정. public bool IsGeneral; public SkillDef Active; public int ActiveLv = 1; public List ActiveEffects; public SkillDef Passive; public int PassiveLv = 1; public float PassiveMult = 1f; public List PassiveEffects; // ---------- 전투 중 바뀌는 값(가상 실행이 떠 두고 되돌림 · CopyState) ---------- /// 액티브를 쓸 수 있는 첫 턴(전투 시작 = 1 · 사용 턴 + 쿨). public int ReadyTurn = 1; /// next_action 예약(다음 슬링샷 1회) · 행동 전 다시 탭 = 취소. public bool Reserved; /// 예약한 효과가 지금 걸려 있다(그 유닛의 행동 처리·조준 미리보기 동안만). public bool Armed; /// 즉시형 액티브를 시전하는 중(그 효과·조건 armed 를 질의에 보임). public bool Casting; /// 보병 목표점 명중 추가 이동(유지 · PD 확인 대상). public int BonusMovesLeft; public bool BonusAttack; /// 남은 재행동 · 재행동의 슬링 상한(감녕·고순 남은 거리 · 0 = 없음) · 이번 턴 재행동 수(여몽 패시브). public int ReactCredits; public float ReactLimit; public int ReactsThisTurn; /// 여몽 백의도강 연쇄(남은 최대 · 피해·처치당 재행동). public int ChainLeft; public int ChainHitN; public int ChainKillN; public int Kills; public bool KillReactUsed; public int RevivesUsed; public int NegateUsed; public int AttackedStunCd; /// 조인 수성: 남은 이동 비율(0~1 · 자기 턴 시작 = 1). public float UnusedMove = 1f; /// 우금 진중: 쌓인 추가 이동(배) · 이번 라운드에 행동했나. public int Bank; public bool MovedThisRound; /// 황개 고육계: 창 동안 받은 피해 → 다음 턴 공격력(가산). public float DmgTakenRound; public float AtkFlat; public int AtkFlatRounds; /// 노숙: 쓰러진 자리 깃발(남은 턴 · 위치). public int BannerLeft; public Vector2 BannerPos; /// 장비 만인지적: 행동 번호 · 대상별 (마지막 행동 번호, 연속 수). public int ActionSeq; public Dictionary Streak = new Dictionary(); public bool HasActive { get { return Active != null && ActiveEffects != null; } } public int CooldownLeft(int turn) { return Mathf.Max(0, ReadyTurn - turn); } public UnitSkills CopyState() { var c = (UnitSkills)MemberwiseClone(); c.Streak = new Dictionary(Streak); return c; } public void RestoreState(UnitSkills c) { ReadyTurn = c.ReadyTurn; Reserved = c.Reserved; Armed = c.Armed; Casting = c.Casting; BonusMovesLeft = c.BonusMovesLeft; BonusAttack = c.BonusAttack; ReactCredits = c.ReactCredits; ReactLimit = c.ReactLimit; ReactsThisTurn = c.ReactsThisTurn; ChainLeft = c.ChainLeft; ChainHitN = c.ChainHitN; ChainKillN = c.ChainKillN; Kills = c.Kills; KillReactUsed = c.KillReactUsed; RevivesUsed = c.RevivesUsed; NegateUsed = c.NegateUsed; AttackedStunCd = c.AttackedStunCd; UnusedMove = c.UnusedMove; Bank = c.Bank; MovedThisRound = c.MovedThisRound; DmgTakenRound = c.DmgTakenRound; AtkFlat = c.AtkFlat; AtkFlatRounds = c.AtkFlatRounds; BannerLeft = c.BannerLeft; BannerPos = c.BannerPos; ActionSeq = c.ActionSeq; Streak = new Dictionary(c.Streak); } public void ResetBattle() { ReadyTurn = 1; Reserved = Armed = Casting = BonusAttack = KillReactUsed = MovedThisRound = false; BonusMovesLeft = ReactCredits = ReactsThisTurn = ChainLeft = Kills = RevivesUsed = NegateUsed = AttackedStunCd = Bank = 0; ReactLimit = DmgTakenRound = AtkFlat = 0f; AtkFlatRounds = BannerLeft = ActionSeq = 0; UnusedMove = 1f; Streak.Clear(); } } /// 스킬 효과 1건이 어느 유닛·스킬에서 왔는가(SkillMods 질의 결과). public struct SkillHit { public UnitSkills Owner; public SkillDef Def; public SkillEffect E; public bool Passive; public SkillSystem.Timed Timed; } /// 피해 종류 — 흡혈·명중 상태 이상·반격·호위가 어떤 피해에 붙는지. public enum HitKind { Attack, Counter, Skill, Dot, Gimmick, Indirect, Guard, Self } /// 행동 1회의 스킬 맥락(돌진 거리 비율 · 맞힌 수 · 처치 · 지형). public class ActionCtx { public Unit Actor; public Vector2 Origin; public float SlingRatio; public int HitCount; public bool FirstHitDone; public int Kills; public bool DamagedEnemy; public bool HitTerrain; public int Seq; public readonly HashSet MalesHit = new HashSet(); public readonly List ZombieFrom = new List(); } /// 스킬이 만든 지대(주유 불꽃 · 육손 불 장벽 · 장로 독지대) — 원 또는 선분 두께. 닿는 상대 = 즉시 피해 + 상태 이상(행동마다 1번 · 상대 턴 시작에 안에 있으면 1번). public class SkillZone { public UnitSkills Owner; public Faction Faction; public Vector2 A; public Vector2 B; public float Radius; public int RoundsLeft; public float Ratio; public string StatusKey; public SkillEffect Status; public GameObject View; /// 같은 편도 닿으면 피해·상태 이상(육손 불 장벽 · PD 2026-11-10 #1112 결정 ⑬). public bool HitsAllies; public readonly HashSet Touched = new HashSet(); public bool Affects(Unit u) { return u != null && (u.Faction != Faction || HitsAllies); } public float Distance(Vector2 p) { return Combat.SegmentPointDistance(A, B, p); } } /// /// 전투 1판의 스킬 처리(2026-11-10 #1112 · PD 엑셀 새 기획 · 효과 표 skill_effects.csv): 유닛별 스킬 상태 · 시한 효과(라운드) · 상태 이상 · 지대 · /// 행동 전후 훅(예약 장착·쿨·재행동·출혈·중독) · 피해 적용(무적·무효화·호위·부활·흡혈·명중 상태 이상·처치 보상) · 회복 · 즉시형 액티브 · 턴 훅 · HUD 한 줄. 수치 질의 = SkillMods. /// public class SkillSystem { public static SkillSystem Current; /// 테스트·자동 플레이: 행동 중 방향 탭(조조·하후돈)을 기다리지 않고 기본값(슬링 방향 · 제자리)으로. public static bool AutoTap; public class Timed { public UnitSkills Owner; public Faction Faction; public SkillDef Def; public SkillEffect E; /// 대상 고정(range = 시전 순간 범위 안 같은 편 · 버프 받은 아군) · null = 대상 칸 규칙대로. public HashSet Targets; /// 남은 라운드(주인 진영 턴 시작마다 1 감소 · 0 = 끝). public int Rounds = 1; } public struct LogLine { public string Text; public float Time; } readonly BattleManager battle; readonly Dictionary map = new Dictionary(); readonly List entries = new List(); public readonly List TimedEffects = new List(); public readonly List Log = new List(); readonly HashSet noted = new HashSet(); readonly int[] startCount = new int[2]; readonly Dictionary statuses = new Dictionary(); public readonly List Zones = new List(); static readonly HashSet summons = new HashSet(); /// 지금 행동 중인 유닛의 맥락(BeginAction ~ EndAction). public ActionCtx Ctx { get; private set; } // ---------- 행동 중 탭(조조 밀칠 방향 · 하후돈 복귀 지점) · 장합 변진 목표 ---------- public enum TapKind { None, Teleport, Aim, Return } public TapKind PendingKind { get; private set; } public Unit PendingUnit { get; private set; } public Vector2 PendingCenter { get; private set; } public float PendingRadius { get; private set; } Vector2? tapResult; /// 장합 변진: 목표 지점 탭 대기 중인 유닛. public Unit PendingTeleport { get { return PendingKind == TapKind.Teleport ? PendingUnit : null; } } // 스킬 이펙트(SkillVfx · 2026-11-09): 지금 행동 중인 유닛 · 이펙트 표 키 · 돌진에 붙은 이펙트 · 이번 행동에 hit 이펙트를 띄운 대상 Unit vfxActor; readonly List vfxKeys = new List(); readonly List travelFx = new List(); readonly HashSet vfxHit = new HashSet(); void VfxBegin(Unit u, string skillKey, string basicKey, Vector2 dir, bool travel) { VfxEnd(); vfxActor = u; if (!string.IsNullOrEmpty(skillKey)) vfxKeys.Add(skillKey); if (!string.IsNullOrEmpty(basicKey)) vfxKeys.Add(basicKey); if (!travel) return; if (!string.IsNullOrEmpty(basicKey) && !string.IsNullOrEmpty(SkillVfx.TestTravelKey)) vfxKeys.Add(SkillVfx.TestTravelKey); foreach (var k in vfxKeys) travelFx.Add(SkillVfx.Play(u, k, SkillVfx.EventTravel, dir)); } /// 슬링샷 직진(돌진) 구간 끝 — 돌진 이펙트(travel)만 멈춤. public void DashEnd(Unit u) { if (u == null || vfxActor != u) return; foreach (var h in travelFx) SkillVfx.Stop(h); travelFx.Clear(); } /// 같은 행동 안의 새 이동 구간(복귀 · 튕겨 다시 돌진) — 그 방향으로 돌진 이펙트를 다시 재생. public void DashBegin(Unit u, Vector2 dir) { if (u == null || vfxActor != u || dir.sqrMagnitude < 1e-6f) return; DashEnd(u); foreach (var k in vfxKeys) travelFx.Add(SkillVfx.Play(u, k, SkillVfx.EventTravel, dir.normalized)); } void VfxEnd() { foreach (var h in travelFx) SkillVfx.Stop(h); travelFx.Clear(); vfxActor = null; vfxKeys.Clear(); vfxHit.Clear(); } public SkillSystem(BattleManager b) { battle = b; } public BattleManager Battle { get { return battle; } } public IReadOnlyList Entries { get { return entries; } } BalanceConfig Cfg { get { return battle.Config; } } // ---------- 부착 ---------- public UnitSkills Of(Unit u) { if (u == null) return null; UnitSkills s; return map.TryGetValue(u, out s) ? s : null; } UnitSkills Ensure(Unit u) { var s = Of(u); if (s != null) return s; s = new UnitSkills { Unit = u }; map[u] = s; entries.Add(s); return s; } /// 스킬 부착(액티브·패시브 id · 레벨 · ★5 패시브 배율 · 무장 여부). 자기 최대 HP 패시브(hp_max_pct 대상 = 자신)는 바로 반영. public UnitSkills Attach(Unit u, string activeId, int activeLv, string passiveId, int passiveLv, float passiveMult, bool general) { if (u == null) return null; var s = Ensure(u); s.IsGeneral = general; s.Active = SkillCatalog.Get(activeId); s.ActiveLv = Mathf.Max(1, activeLv); s.ActiveEffects = s.Active != null ? s.Active.ScaledEffects(s.ActiveLv, 1f) : null; s.Passive = SkillCatalog.Get(passiveId); s.PassiveLv = Mathf.Max(1, passiveLv); s.PassiveMult = passiveMult > 0f ? passiveMult : 1f; s.PassiveEffects = s.Passive != null ? s.Passive.ScaledEffects(s.PassiveLv, s.PassiveMult) : null; if (s.Passive != null) s.HeroId = s.Passive.HeroId; else if (s.Active != null) s.HeroId = s.Active.HeroId; float hp = 0f; if (s.PassiveEffects != null) foreach (var e in s.PassiveEffects) if (e.Key == SkillKeys.HpMaxPct && e.Scope == SkillScope.Self) hp += e.P; // 같은 편 대상(진영)은 BeginBattle 에서 ScaleMaxHp(u, hp); return s; } static void ScaleMaxHp(Unit u, float pct) { if (Mathf.Abs(pct) <= 1e-4f) return; u.MaxHp = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * (1f + pct))); u.Hp = u.MaxHp; if (u.View != null) u.View.Refresh(); } /// 액티브 쿨 = 표 cooldown · 최소 1. public int CooldownOf(UnitSkills s) { if (s == null || s.Active == null) return 1; return Mathf.Max(1, s.Active.Cooldown); } /// heroes.csv 영웅 id 로 부착(테스트·도구용) · 진영·성별도 유닛에 기록. public UnitSkills AttachHero(Unit u, string heroId, int activeLv = 1, int passiveLv = 1, float passiveMult = 1f, bool general = true) { var h = SkillCatalog.Hero(heroId); if (h == null) return null; if (u != null) { u.Camp = h.faction ?? ""; u.Female = h.Female; u.HeroId = h.id; } return Attach(u, h.skillActive, activeLv, h.skillPassive, passiveLv, passiveMult, general && !h.isSoldier); } /// 출전 칸(편성 → 스킬 id·레벨·★ 배율이 채워져 있음). public UnitSkills AttachLineup(Unit u, LineupEntry e) { if (e == null) return null; if (string.IsNullOrEmpty(e.skillActive) && string.IsNullOrEmpty(e.skillPassive)) { if (e.general) Ensure(u).IsGeneral = true; return Of(u); } return Attach(u, e.skillActive, e.activeLv, e.skillPassive, e.passiveLv, e.passiveMult, e.general); } /// 적 무장(보스·엘리트) = heroes.csv 같은 아트·이름 영웅의 패시브(Lv1) · withActive = 액티브도(적 AI · 1부 보스만 · PD #1098). public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e, bool withActive = false) { if (u == null || e == null || !(e.isBoss || e.isGeneral)) return null; var h = SkillCatalog.HeroForEnemy(e.art, e.name); if (h == null) { var s = Ensure(u); s.IsGeneral = true; return s; } u.Camp = h.faction ?? ""; u.Female = h.Female; u.HeroId = h.id; return Attach(u, withActive ? h.skillActive : null, 1, h.skillPassive, 1, 1f, true); } public void MarkGeneral(Unit u) { if (u != null) Ensure(u).IsGeneral = true; } public bool IsGeneral(Unit u) { if (u == null) return false; if (u.IsBoss) return true; var s = Of(u); return s != null && s.IsGeneral; } public static bool IsSummon(Unit u) { return u != null && summons.Contains(u); } public void Clear() { map.Clear(); entries.Clear(); TimedEffects.Clear(); noted.Clear(); ClearTap(); startCount[0] = startCount[1] = 0; ClearStatuses(); ClearZones(); summons.Clear(); Ctx = null; VfxEnd(); } void ClearStatuses() { foreach (var kv in statuses) if (kv.Value.Badge != null) Object.Destroy(kv.Value.Badge.gameObject); statuses.Clear(); } void ClearZones() { foreach (var z in Zones) if (z.View != null) Object.Destroy(z.View); Zones.Clear(); } /// 전투 시작(배치 뒤): 출전 인원 기록 · 쿨 = 바로 사용 가능 · 시한 효과·상태 이상 비움 · 같은 편 최대 HP 패시브(진영 대상). public void BeginBattle() { Prune(); TimedEffects.Clear(); noted.Clear(); ClearTap(); ClearStatuses(); ClearZones(); Ctx = null; startCount[0] = battle.AliveCount(Faction.Player); startCount[1] = battle.AliveCount(Faction.Enemy); foreach (var u in battle.Units) { if (u == null || !u.IsAlive) continue; float team = 0f; foreach (var h in SkillMods.Get(u, SkillKeys.HpMaxPct)) if (h.E.Scope != SkillScope.Self) team += h.E.P; ScaleMaxHp(u, team); } foreach (var s in entries) s.ResetBattle(); } public int StartCount(Faction f) { return startCount[(int)f]; } void Prune() { for (int i = entries.Count - 1; i >= 0; i--) { if (entries[i].Unit != null) continue; entries.RemoveAt(i); } var dead = new List(); foreach (var kv in map) if (kv.Key == null) dead.Add(kv.Key); foreach (var k in dead) map.Remove(k); } // ---------- 가상 실행(적 AI 예측)용 상태 사본 ---------- public class State { public readonly List> Entries = new List>(); public List Timed; public readonly Dictionary Statuses = new Dictionary(); public List Zones; public int LogCount; } public State TakeState() { var st = new State { Timed = new List(TimedEffects), Zones = new List(Zones), LogCount = Log.Count }; foreach (var e in entries) st.Entries.Add(new KeyValuePair(e, e.CopyState())); foreach (var kv in statuses) st.Statuses[kv.Key] = kv.Value.Copy(); return st; } public void RestoreState(State st) { if (st == null) return; foreach (var kv in st.Entries) kv.Key.RestoreState(kv.Value); TimedEffects.Clear(); TimedEffects.AddRange(st.Timed); foreach (var z in Zones) if (!st.Zones.Contains(z) && z.View != null) Object.Destroy(z.View); Zones.Clear(); Zones.AddRange(st.Zones); foreach (var kv in statuses) { UnitStatus old; if (st.Statuses.TryGetValue(kv.Key, out old)) continue; kv.Value.Clear(); // 가상 실행 중 새로 생긴 상태 = 비움(배지는 그대로 둠) } foreach (var kv in st.Statuses) { UnitStatus cur; if (!statuses.TryGetValue(kv.Key, out cur)) statuses[kv.Key] = kv.Value.Copy(); else { var badge = cur.Badge; var copy = kv.Value.Copy(); copy.Badge = badge; statuses[kv.Key] = copy; } } if (Log.Count > st.LogCount) Log.RemoveRange(st.LogCount, Log.Count - st.LogCount); Ctx = null; } // ---------- 턴 ---------- /// 적 턴 시작(동기): 쓰지 않은 예약·추가 이동 정리. public void OnEnemyTurnStart() { ClearTap(); foreach (var s in entries) { s.Reserved = false; s.Armed = false; s.Casting = false; s.BonusMovesLeft = 0; s.BonusAttack = false; } } /// 아군 턴 시작(동기) — 남은 것 없음(라운드 처리는 TurnBegin). public void OnPlayerTurnStart() { } /// /// side 진영 턴 시작(코루틴 · BattleManager 가 기믹 턴 시작 뒤에 부름): 상대 진영 턴 끝 처리(턴 종료 회복·정화 · 혼란·출혈 1 감소) → /// side 주인의 시한 효과 1 감소(고육계 이월) · 턴마다 초기화 · 깃발·소환 정리 → side 유닛 중독·화상 → 재생·지속 회복·턴 시작 회복 → side 지대 1 감소 · 상대 지대 안 유닛. /// public IEnumerator TurnBegin(Faction side) { bool any = false; Faction other = Other(side); any |= TurnEnd(other); // 고육계 이월 공격력은 자기 턴 1번 — 다음 자기 턴 시작에 사라짐(새 이월은 아래 시한 효과 만료에서) foreach (var s in entries) { if (s.Unit == null || s.Unit.Faction != side || s.AtkFlatRounds <= 0) continue; s.AtkFlat = 0f; s.AtkFlatRounds = 0; } // 지속 회복(방통 봉추의 계) = 라운드 감소·만료 전에 — dur n 이면 n 번 회복(Fable 검토 B1) foreach (var t in TimedEffects) { if (t.E.Key != SkillKeys.Hot || t.Faction != side || t.Owner == null || t.Owner.Unit == null) continue; int amt = Mathf.RoundToInt(AtkOf(t.Owner.Unit) * t.E.P); foreach (var u in battle.AliveUnits(side)) if (t.Targets == null ? u == t.Owner.Unit : t.Targets.Contains(u)) if (Heal(u, amt, t.Owner.Unit) > 0) any = true; } for (int i = TimedEffects.Count - 1; i >= 0; i--) { var t = TimedEffects[i]; if (t.Faction != side) continue; if (--t.Rounds > 0) continue; TimedEffects.RemoveAt(i); if (t.E.Key == SkillKeys.DamageToAtk && t.Owner != null && t.Owner.DmgTakenRound > 0f) { t.Owner.AtkFlat = t.Owner.DmgTakenRound; // 고육계: 받은 피해 → 이번 턴 공격력 t.Owner.AtkFlatRounds = 1; t.Owner.DmgTakenRound = 0f; if (t.Owner.Unit != null) Text(t.Owner.Unit, "공격 +" + Mathf.RoundToInt(t.Owner.AtkFlat), SkillFx.NameColor); any = true; } } var gone = new List(); foreach (var s in entries) { var u = s.Unit; if (u == null || u.Faction != side) continue; s.KillReactUsed = false; s.ReactsThisTurn = 0; s.ReactCredits = 0; s.ReactLimit = 0f; s.ChainLeft = 0; if (s.AttackedStunCd > 0) s.AttackedStunCd--; if (s.BannerLeft > 0 && !u.IsAlive && --s.BannerLeft == 0) Text(u, "깃발 소멸", SkillFx.CostColor); if (u.IsAlive) { s.UnusedMove = 1f; var bank = Eff(s, SkillKeys.MoveBank); if (bank != null && !s.MovedThisRound) s.Bank = Mathf.Min(Mathf.Max(0, Mathf.RoundToInt(bank.P) - 1), s.Bank + 1); s.MovedThisRound = false; } } foreach (var z in summons) if (z == null || z.Faction == side) gone.Add(z); // 망령(스킬 항목 없음)은 소환 목록에서 직접 foreach (var z in gone) { if (z != null && z.IsAlive) yield return ZombieAct(z); // 망령 = 자기 턴 시작에 1번 공격 후 소멸(PD #1112 결정 ⑩) summons.Remove(z); if (z != null) battle.RemoveUnit(z); any = true; } // 상태 이상(중독 · 화상) — side 유닛 foreach (var u in battle.AliveUnits(side)) { var st = StatusOf(u); if (st == null) continue; if (st.PoisonTurns > 0) { PoisonTick(u, st); st.PoisonTurns--; if (st.PoisonTurns <= 0) st.PoisonStacks = 0; any = true; } if (u.IsAlive && st.BurnTurns > 0) { int d = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * st.BurnPct)); d = Hit(u, d, null, HitKind.Dot); Text(u, "화상 −" + d, new Color(1f, 0.5f, 0.2f)); st.BurnTurns--; any = true; } RefreshBadge(u); } // 회복: 재생(허저) · 지속 회복(방통) · 턴 시작 범위 회복(노숙 · 깃발) foreach (var u in battle.AliveUnits(side)) { float regen = SkillMods.Sum(u, SkillKeys.RegenPct, true); if (regen > 0f && Heal(u, Mathf.RoundToInt(u.MaxHp * regen), u) > 0) any = true; } foreach (var s in entries) { var src = s.Unit; if (src == null || src.Faction != side || s.PassiveEffects == null) continue; if (!src.IsAlive && s.BannerLeft <= 0) continue; var e = Find(s.PassiveEffects, SkillKeys.TurnStartHealRange); if (e == null) continue; Vector2 c = src.IsAlive ? src.Position : s.BannerPos; foreach (var u in battle.AliveUnits(side)) if (Vector2.Distance(c, u.Position) <= SkillMods.BaseReach(src) + u.Radius) if (Heal(u, Mathf.RoundToInt(u.MaxHp * e.P), src) > 0) any = true; NotePassive(s, s.Passive); } // 지대: side 주인 지대 1 감소 · 상대(side) 유닛이 상대 지대 안에 있으면 닿음 1번 for (int i = Zones.Count - 1; i >= 0; i--) { var z = Zones[i]; if (z.Faction == side && --z.RoundsLeft <= 0) { if (z.View != null) Object.Destroy(z.View); Zones.RemoveAt(i); continue; } if (z.Faction == side && !z.HitsAllies) continue; z.Touched.Clear(); foreach (var u in battle.AliveUnits(side)) if (z.Distance(u.Position) <= z.Radius + u.Radius * 0.5f) { ZoneTouch(z, u); any = true; } } if (any) { battle.NotifyChanged(); yield return Fx.Wait(0.35f); } } bool HasTimed(UnitSkills s, string key) { foreach (var t in TimedEffects) if (t.Owner == s && t.E.Key == key) return true; return false; } /// side 진영 턴 끝(다음 진영 턴 시작에 부름): 턴 종료 회복·정화(소교·제갈량·방통) · 그 진영 유닛의 혼란·출혈 1 감소. bool TurnEnd(Faction side) { bool any = false; foreach (var t in TimedEffects) { if (t.E.Key != SkillKeys.TurnEndHeal || t.Faction != side || t.Owner == null || t.Owner.Unit == null || !t.Owner.Unit.IsAlive) continue; var src = t.Owner.Unit; int amt = Mathf.RoundToInt(AtkOf(src) * t.E.Get(1)); foreach (var u in battle.AliveUnits(side)) if (Vector2.Distance(src.Position, u.Position) <= t.E.P + u.Radius && Heal(u, amt, src) > 0) any = true; } foreach (var s in entries) { var src = s.Unit; if (src == null || !src.IsAlive || src.Faction != side || s.PassiveEffects == null) continue; var lowest = Find(s.PassiveEffects, SkillKeys.TurnEndHealLowest); if (lowest != null) { Unit pick = null; foreach (var u in battle.AliveUnits(side)) { if (Vector2.Distance(src.Position, u.Position) > lowest.P + u.Radius || u.Hp >= u.MaxHp) continue; if (pick == null || (float)u.Hp / u.MaxHp < (float)pick.Hp / pick.MaxHp) pick = u; } if (pick != null && Heal(pick, Mathf.RoundToInt(AtkOf(src) * lowest.Get(1)), src) > 0) { NotePassive(s, s.Passive); any = true; } } var cl = Find(s.PassiveEffects, SkillKeys.TurnEndCleanse); if (cl != null) { int n = Mathf.Max(1, Mathf.RoundToInt(cl.Get(1, 1f))); foreach (var u in battle.AliveUnits(side)) { if (n <= 0) break; if (Vector2.Distance(src.Position, u.Position) > cl.P + u.Radius || !SkillStatus.HasAny(u)) continue; Cleanse(u); n--; NotePassive(s, s.Passive); any = true; } } } foreach (var kv in statuses) { var u = kv.Key; if (u == null || u.Faction != side) continue; var st = kv.Value; if (st.ConfuseTurns > 0) st.ConfuseTurns--; if (st.BleedTurns > 0) st.BleedTurns--; if (st.ConfuseTurns <= 0) st.ConfuseMult = 1f; RefreshBadge(u); } return any; } static Faction Other(Faction f) { return f == Faction.Player ? Faction.Enemy : Faction.Player; } static SkillEffect Find(List l, string key) { if (l == null) return null; foreach (var e in l) if (e.Key == key) return e; return null; } /// 그 유닛 스킬의 효과(패시브 · 지금 장착/시전 중인 액티브) 첫 줄. static SkillEffect Eff(UnitSkills s, string key) { if (s == null) return null; var e = Find(s.PassiveEffects, key); if (e != null) return e; return (s.Armed || s.Casting) ? Find(s.ActiveEffects, key) : null; } // ---------- 액티브: 상태 · 탭 ---------- public enum ActiveState { None, Ready, Reserved, Cooldown, Acted, BonusMove, NotTurn, Targeting } public ActiveState StateOf(Unit u) { var s = Of(u); if (u == null || s == null || !s.HasActive || u.Faction != Faction.Player || !u.IsAlive) return ActiveState.None; if (PendingTeleport == u) return ActiveState.Targeting; if (s.Reserved) return ActiveState.Reserved; if (battle.Phase != BattlePhase.PlayerTurn) return ActiveState.NotTurn; if (s.CooldownLeft(battle.TurnNumber) > 0) return ActiveState.Cooldown; if (s.Active.Trigger == SkillTrigger.NextAction) { if (u.BonusMoveAvailable) return ActiveState.BonusMove; if (!battle.CanAct(u)) return ActiveState.Acted; } return ActiveState.Ready; } /// HUD 스킬 버튼 탭: next_action = 예약/취소 · instant = 즉시 발동(변진 = 목표 지점 탭 대기). 반환 = 안내 문구. public string TapActive(Unit u) { var s = Of(u); var st = StateOf(u); switch (st) { case ActiveState.None: return null; case ActiveState.Targeting: ClearTap(); return s.Active.NameKo + " 취소"; case ActiveState.Reserved: s.Reserved = false; if (u.View != null) u.View.Refresh(); return s.Active.NameKo + " 예약 취소(쿨 소모 없음)"; case ActiveState.NotTurn: return "아군 턴에만 쓸 수 있습니다"; case ActiveState.Cooldown: return s.Active.NameKo + " — 쿨 " + s.CooldownLeft(battle.TurnNumber) + "턴"; case ActiveState.Acted: return s.Active.NameKo + " — 이미 행동한 유닛"; case ActiveState.BonusMove: return s.Active.NameKo + " — 추가 이동 중에는 예약할 수 없음"; } if (s.Active.Trigger == SkillTrigger.NextAction) { s.Reserved = true; if (u.View != null) u.View.Refresh(); return s.Active.NameKo + " 예약 — 다음 슬링샷에 발동 · 다시 탭 = 취소"; } if (s.Active.First(SkillKeys.Teleport) != null) { PendingKind = TapKind.Teleport; PendingUnit = u; PendingCenter = u.Position; PendingRadius = TeleportRadius(s); return s.Active.NameKo + " — 반경 " + PendingRadius.ToString("0.#") + " 안 빈 곳을 탭(다른 곳 = 취소)"; } battle.RunSkillRoutine(Instant(s)); return s.Active.NameKo + " 발동!"; } void ClearTap() { PendingKind = TapKind.None; PendingUnit = null; tapResult = null; } /// 행동 중 방향·지점 탭 대기(조조 난세의 간웅 · 하후돈 맹장) — 적·가상 실행·자동 = 기본값. public IEnumerator WaitTap(Unit u, TapKind kind, Vector2 center, float radius, string message, Vector2 fallback, System.Action done) { if (Sim.Active || AutoTap || u == null || u.Faction != Faction.Player) { done(fallback); yield break; } PendingKind = kind; PendingUnit = u; PendingCenter = center; PendingRadius = radius; tapResult = null; battle.LastMessage = message; battle.NotifyChanged(); while (tapResult == null && PendingKind == kind && u != null && u.IsAlive) yield return null; Vector2 r = tapResult ?? fallback; ClearTap(); done(r); } /// 화면 탭 → 대기 중이면 처리하고 true(변진 = 유효면 이동 · 아니면 취소 / 행동 중 탭 = 결과 지점). public bool TryTeleportTap(Vector2 w, out string message) { message = null; if (PendingKind == TapKind.Aim || PendingKind == TapKind.Return) { Vector2 d = w - PendingCenter; if (PendingRadius > 0f && d.magnitude > PendingRadius) w = PendingCenter + d.normalized * PendingRadius; tapResult = w; return true; } var u = PendingTeleport; if (u == null) return false; ClearTap(); var s = Of(u); if (s == null || !u.IsAlive || battle.Phase != BattlePhase.PlayerTurn) return true; if (!TeleportValid(u, w)) { message = s.Active.NameKo + " 취소 — 반경 안 빈 곳이 아님"; return true; } battle.RunSkillRoutine(TeleportRoutine(s, w)); message = s.Active.NameKo + " 발동!"; return true; } // ---------- 행동 훅(ActionResolver.Resolve) ---------- /// 행동 시작: 예약한 next_action 효과를 장착(이동만인 추가 이동은 제외) · 발동 연출 · 행동 맥락(돌진 거리 비율). public void BeginAction(Unit u, bool moveOnly, ActionOutcome o, Vector2 dir = default(Vector2), float slingDist = 0f) { noted.Clear(); var s = Of(u); bool armed = s != null && s.Reserved && s.HasActive && !moveOnly; if (armed) { s.Armed = true; o.SkillUsed = s.Active; Announce(s, s.Active, dir); var rh = Find(s.ActiveEffects, SkillKeys.ReactionOnHit); var rk = Find(s.ActiveEffects, SkillKeys.ReactionOnKill); if (rh != null || rk != null) { s.ChainHitN = rh != null ? Mathf.RoundToInt(rh.P) : 0; s.ChainKillN = rk != null ? Mathf.RoundToInt(rk.P) : 0; s.ChainLeft = Mathf.Max(rh != null ? Mathf.RoundToInt(rh.Get(1, 1f)) : 0, rk != null ? Mathf.RoundToInt(rk.Get(1, 1f)) : 0); } } if (s != null) s.ActionSeq++; Ctx = new ActionCtx { Actor = u, Origin = u.Position, Seq = s != null ? s.ActionSeq : 0 }; float max = SkillMods.SlingMax(u); Ctx.SlingRatio = max > 0.01f ? Mathf.Clamp01(slingDist / max) : 0f; if (s != null) s.ReactLimit = 0f; // 재행동 슬링 상한(감녕·고순)은 조준에서 이미 썼음 — 이 행동이 새로 주면 다시 생김 VfxBegin(u, armed ? s.Active.Id : null, moveOnly ? null : SkillVfx.BasicKey(u.Type), dir, true); } /// /// 행동 끝: 움직인 유닛 출혈 · 행동한 유닛 중독 · 지대 닿음 · 초선 다중 남성 기절 · 조인·이각·우금 패시브 · /// (장착) 재행동 · 아군 재행동(우금) · 지형 재행동(맹획) · 지속 효과 시작 · 예약 소모 · 쿨 → 여몽 연쇄 → 장로 망령. /// public IEnumerator EndAction(Unit u, ActionOutcome o) { var s = Of(u); var ctx = Ctx; if (vfxActor == u) VfxEnd(); var moved = MovedDistances(u, o); ApplyBleed(moved); if (u.IsAlive) { var st = StatusOf(u); if (st != null && st.PoisonTurns > 0) PoisonTick(u, st); // 중독 = 행동 종료 시점에도(가후·축융 정의) } ZoneSweep(u, o); AxeSweep(u, o); if (ctx != null && ctx.Actor == u) { var mm = SkillMods.First(u, SkillKeys.MultiMaleStun); if (mm != null && ctx.MalesHit.Count >= Mathf.RoundToInt(mm.P)) foreach (var m in ctx.MalesHit) if (m != null && m.IsAlive && Roll(mm.Get(1, 1f))) ApplyStun(m, Mathf.Max(1, Mathf.RoundToInt(mm.Get(2, 1f))), "매혹 기절"); } if (s != null && u.IsAlive) { float pathLen = PathLength(o.Path); s.MovedThisRound = true; s.Bank = 0; var heal = Find(s.PassiveEffects, SkillKeys.HealOnActionEnd); if (heal != null && Heal(u, Mathf.RoundToInt(u.MaxHp * heal.P), u) > 0) NotePassive(s, s.Passive); if (Find(s.PassiveEffects, SkillKeys.UnusedMoveDr) != null) s.UnusedMove = Mathf.Clamp01(1f - pathLen / Mathf.Max(0.1f, SkillMods.SlingMax(u))); Sacrifice(s); } if (s != null && s.Armed) { foreach (var e in s.ActiveEffects) { if (e.Dur > 0) AddTimed(s, e, e.Scope == SkillScope.Range ? InRange(u) : null, e.Dur); else if (e.Key == SkillKeys.ExtraAction) s.ReactCredits += Mathf.Max(0, Mathf.RoundToInt(e.P)); else if (e.Key == SkillKeys.ReactionOnTerrain && ctx != null && ctx.HitTerrain) s.ReactCredits += Mathf.Max(0, Mathf.RoundToInt(e.P)); else if (e.Key == SkillKeys.AllyHitReaction) foreach (var p in o.Pushed) if (p != null && p != u && p.IsAlive && p.Faction == u.Faction && p.ActedThisTurn) { p.GrantReAction(); Text(p, "재행동", SkillFx.NameColor); } } s.Armed = false; s.Reserved = false; s.ReadyTurn = battle.TurnNumber + CooldownOf(s); if (u.View != null) u.View.Refresh(); } // 여몽 백의도강 연쇄: 피해 = +재행동 · 처치 = +재행동(남은 최대까지) if (s != null && s.ChainLeft > 0 && ctx != null && ctx.Actor == u) { int add = (ctx.DamagedEnemy ? s.ChainHitN : 0) + (ctx.Kills > 0 ? s.ChainKillN : 0); add = Mathf.Min(add, s.ChainLeft); s.ChainLeft -= add; s.ReactCredits += add; if (add == 0) s.ChainLeft = 0; } if (ctx != null && ctx.ZombieFrom.Count > 0 && !Sim.Active) foreach (var dead in ctx.ZombieFrom) SpawnZombie(u, dead); yield return null; Ctx = null; battle.NotifyChanged(); } /// 재행동을 1회 쓴다(남았으면 true · 이번 턴 재행동 수 +1). public bool TakeReaction(Unit u) { var s = Of(u); if (s == null || s.ReactCredits <= 0 || !u.IsAlive) return false; s.ReactCredits--; s.ReactsThisTurn++; Text(u, "재행동!", SkillFx.NameColor); AddLog(u.DisplayName + " 재행동"); return true; } /// 재행동의 슬링 상한(감녕·고순 남은 거리)은 그 재행동 1회로 끝. public void ClearReactLimit(Unit u) { var s = Of(u); if (s != null && s.ReactCredits <= 0) s.ReactLimit = 0f; } /// 조준 미리보기 동안만 예약 효과를 장착(사거리 · 사선 · 광역 표시). 반환 = 복원값. public bool PreviewBegin(Unit u) { var s = Of(u); if (s == null) return false; bool prev = s.Armed; if (s.Reserved && !u.BonusMoveAvailable) s.Armed = true; return prev; } public void PreviewEnd(Unit u, bool prev) { var s = Of(u); if (s != null) s.Armed = prev; } static float PathLength(List path) { float l = 0f; for (int i = 0; path != null && i < path.Count - 1; i++) l += Vector2.Distance(path[i], path[i + 1]); return l; } static Dictionary MovedDistances(Unit actor, ActionOutcome o) { var m = new Dictionary(); if (actor != null) m[actor] = PathLength(o.Path); foreach (var d in o.Displaced) { if (d.Unit == null) continue; float v; m.TryGetValue(d.Unit, out v); m[d.Unit] = v + Vector2.Distance(d.From, d.To); } return m; } /// 출혈 = 움직인 거리 / 자기 슬링 최대 × 최대 HP × 비율(서황 정의 「이동 거리의 50 %에 비례」 · PD 확인 대상). public void ApplyBleed(Dictionary moved) { foreach (var kv in moved) { var u = kv.Key; if (u == null || !u.IsAlive || kv.Value < 0.05f) continue; var st = StatusOf(u); if (st == null || st.BleedTurns <= 0) continue; int d = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * st.BleedPct * kv.Value / Mathf.Max(0.1f, u.SlingMaxDistance))); d = Hit(u, d, null, HitKind.Dot); Text(u, "출혈 −" + d, new Color(0.9f, 0.15f, 0.2f)); } } void PoisonTick(Unit u, UnitStatus st) { int d = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * st.PoisonPct * Mathf.Max(1, st.PoisonStacks))); d = Hit(u, d, null, HitKind.Dot); Text(u, "중독 −" + d, new Color(0.7f, 0.4f, 1f)); } /// 이각 패시브: 행동 끝 = HP p1 소모(10 % 이하면 소모 없이) → 인접(p3) 아군 공격 +p2 1턴 · 대상 없으면 발동 안 함. void Sacrifice(UnitSkills s) { var e = Find(s.PassiveEffects, SkillKeys.ActionEndSacrifice); var u = s.Unit; if (e == null || u == null || !u.IsAlive) return; var targets = new HashSet(); foreach (var a in battle.AliveUnits(u.Faction)) if (a != u && SkillMods.Within(u, a, e.Get(2, 1.5f))) targets.Add(a); if (targets.Count == 0) return; if (u.Hp > u.MaxHp * e.P) PayHp(u, e.P); var buff = new SkillEffect { Key = SkillKeys.AtkPct, P = e.Get(1), Scope = SkillScope.Range, Dur = 1 }; TimedEffects.Add(new Timed { Owner = s, Faction = u.Faction, Def = s.Passive, E = buff, Targets = targets, Rounds = 1 }); foreach (var t in targets) Text(t, "공격 +" + Mathf.RoundToInt(buff.P * 100f) + "%", SkillFx.NameColor); NotePassive(s, s.Passive); } HashSet InRange(Unit owner) { var set = new HashSet(); foreach (var a in battle.AliveUnits(owner.Faction)) if (SkillMods.Within(owner, a, SkillMods.BaseReach(owner))) set.Add(a); return set; } // ---------- 추가 이동(보병 목표점 명중) ---------- public bool BonusCanAttack(Unit u) { var s = Of(u); return s != null && s.BonusAttack; } /// 행동 뒤 추가 이동을 (또) 주는가 — 처음 명중 = 1회 · 추가 이동 뒤 = 남은 횟수. public bool NextBonus(Unit u, bool wasBonus, ActionOutcome o) { var s = Of(u); if (!wasBonus) { if (!o.BonusMove) return false; if (s != null) { s.BonusMovesLeft = Mathf.Max(0, o.BonusMoves - 1); s.BonusAttack = o.BonusAttack; } return true; } if (s != null && s.BonusMovesLeft > 0) { s.BonusMovesLeft--; return true; } ClearBonus(u); return false; } public void ClearBonus(Unit u) { var s = Of(u); if (s == null) return; s.BonusMovesLeft = 0; s.BonusAttack = false; } /// 이번 적 턴에 교전 반응이 없는가 — 기절은 지형 기믹 행동 불가(GimmickLayer.Skips)가 처리. public bool Suppressed(Unit e) { return false; } /// 교전 범위 가산(새 기획에 없음 = 0). public float AggroAdd(Faction actingFaction) { return 0f; } // ---------- 피해 적용 ---------- /// /// 피해 적용(by = 준 유닛 · kind = 종류): 무적(시한) → 무효화(조범) → 호위(관평·강유 · 강유 = 반격) → 고육계 누적 → 부활(유비·맹획) → HP 감소 → /// 준 쪽 보상(흡혈 · 사마의 흡수 · 명중 상태 이상 · 장비 연속 기절 · 하후은 첫 명중 추가 피해) → 맞은 쪽 반응(서황 도끼 · 간옹 기절 · 원술 혼란) → 처치 처리. 반환 = 실제 받은 피해. /// public int Hit(Unit d, int dmg, Unit by = null, HitKind kind = HitKind.Attack) { if (d == null || !d.IsAlive || dmg <= 0) return 0; bool crit = (kind == HitKind.Attack || kind == HitKind.Skill || kind == HitKind.Counter) && Combat.LastCrit; if (vfxActor != null && d.Faction != vfxActor.Faction && vfxHit.Add(d)) { foreach (var k in vfxKeys) SkillVfx.Play(vfxActor, k, SkillVfx.EventHit, Vector2.zero, d); HitFeel.OnHit(vfxActor, d); } var ds = Of(d); if (kind != HitKind.Self && Invulnerable(d)) { Text(d, "무적", SkillFx.GuardColor); return 0; } if (kind != HitKind.Self && ds != null && ds.PassiveEffects != null) // 조범 처세 「어떤 피해든」 = 상태 이상·지대 피해 포함(Fable 검토 C3) { var ng = Find(ds.PassiveEffects, SkillKeys.NegateHits); if (ng != null && ds.NegateUsed < Mathf.RoundToInt(ng.P)) { ds.NegateUsed++; Text(d, "피해 무효", SkillFx.GuardColor); NotePassive(ds, ds.Passive); return 0; } } if (kind != HitKind.Guard && kind != HitKind.Dot && kind != HitKind.Self) { float gr; SkillEffect ge; var g = Guarder(d, out gr, out ge); if (g != null) { int share = Mathf.Clamp(Mathf.RoundToInt(dmg * Mathf.Clamp01(ge.Get(1, 1f))), 0, dmg); if (share > 0) { float dr = 1f + SkillMods.Sum(g, SkillKeys.GuardDr); int took = Hit(g, Mathf.Max(1, Mathf.RoundToInt(share * Mathf.Max(0f, dr))), by, HitKind.Guard); Fx.Popup(g.Position, took + " 호위", SkillFx.GuardColor); NotePassive(Of(g), Of(g).Passive); dmg -= share; if (ge.Key == SkillKeys.GuardCounter && by != null && by.IsAlive && g.IsAlive && kind == HitKind.Attack) { int c = Combat.CounterDamage(Cfg, g, by, battle.Rng); if (c > 0) { c = Hit(by, c, g, HitKind.Counter); Fx.Popup(by.Position, c + " 호위 반격", new Color(1f, 0.45f, 0.45f)); } } if (dmg <= 0) return 0; } } } if (ds != null && dmg > 0 && HasTimed(ds, SkillKeys.DamageToAtk)) ds.DmgTakenRound += Mathf.Min(dmg, d.Hp); int before = d.Hp; if (dmg >= d.Hp && TryRevive(d, ds, before)) { // 부활을 부른 타격도 준 쪽 효과(흡혈·첫 명중·연속 기절·명중 상태 이상) · 맞은 쪽 반응 — 처치는 아님(Fable 검토 C7) if (by != null && by.Faction != d.Faction) OnDealt(by, d, before, kind, crit); if (d.IsAlive && by != null && by.IsAlive && by.Faction != d.Faction && kind == HitKind.Attack) OnAttacked(d, by); return before; } d.TakeDamage(dmg); int dealt = before - d.Hp; if (by != null && by.Faction != d.Faction) OnDealt(by, d, dealt, kind, crit); if (d.IsAlive && by != null && by.IsAlive && by.Faction != d.Faction && kind == HitKind.Attack) OnAttacked(d, by); if (!d.IsAlive) OnDeath(d, by); return dealt; } /// 스킬 시스템이 없어도 쓰는 피해 적용. public static int Deal(Unit d, int dmg, Unit by = null, HitKind kind = HitKind.Attack) { if (Current != null) return Current.Hit(d, dmg, by, kind); if (d == null) return 0; d.TakeDamage(dmg); return dmg; } bool Invulnerable(Unit d) { foreach (var t in TimedEffects) { if (t.E.Key != SkillKeys.Invuln) continue; if (t.Targets != null ? t.Targets.Contains(d) : (t.Owner != null && t.Owner.Unit == d)) return true; } return false; } /// d 를 대신 맞아 줄 호위(관평 호위 · 강유 반격 범위 · 시한 범위 배율) — 가장 가까운 1기. Unit Guarder(Unit d, out float radius, out SkillEffect effect) { radius = 0f; effect = null; Unit best = null; float bestD = float.MaxValue; foreach (var s in entries) { var g = s.Unit; if (g == null || g == d || !g.IsAlive || g.Faction != d.Faction || s.PassiveEffects == null) continue; var e = Find(s.PassiveEffects, SkillKeys.Guard) ?? Find(s.PassiveEffects, SkillKeys.GuardCounter); if (e == null) continue; float r = e.P * Mathf.Max(0f, SkillMods.Product(g, SkillKeys.GuardRadiusMult)); float dist = Vector2.Distance(g.Position, d.Position); if (dist > r + d.Radius || dist >= bestD) continue; best = g; bestD = dist; radius = r; effect = e; } return best; } bool TryRevive(Unit d, UnitSkills ds, int before) { if (ds == null || ds.PassiveEffects == null) return false; var e = Find(ds.PassiveEffects, SkillKeys.Revive); if (e == null || ds.RevivesUsed >= Mathf.RoundToInt(e.Get(1, 1f))) return false; float need = e.Get(2, 0f); if (need > 0f && before < d.MaxHp * need) return false; // 맹획: 맞기 전 HP 가 조건 미만 = 즉사 ds.RevivesUsed++; d.Hp = Mathf.Max(1, Mathf.RoundToInt(d.MaxHp * Mathf.Clamp01(e.P))); if (d.View != null) { d.View.OnHit(); d.View.Refresh(); } Text(d, "부활!", SkillFx.HealColor); NotePassive(ds, ds.Passive); AddLog("패시브 · " + d.DisplayName + " 「" + ds.Passive.NameKo + "」 부활"); return true; } /// 준 쪽: 흡혈(조건 = 상대) · 반격 흡혈 · 사마의 흡수 · 행동 맥락(맞힌 수 · 남성 · 첫 명중) · 명중 상태 이상 · 장비 연속 피해 기절. void OnDealt(Unit by, Unit d, int dealt, HitKind kind, bool crit) { if (dealt <= 0) return; if (by.IsAlive && (kind == HitKind.Attack || kind == HitKind.Skill || kind == HitKind.Counter)) { float ls = 0f; foreach (var h in SkillMods.Get(by, SkillKeys.Lifesteal, d)) { ls += h.E.P + (h.E.HasA2 ? h.E.A2 * SkillMods.DeadAllies(by) : 0f); if (h.Passive) NotePassive(h.Owner, h.Def); } if (kind == HitKind.Counter) ls += SkillMods.Sum(by, SkillKeys.CounterLifesteal, true); if (ls > 0f) Heal(by, Mathf.RoundToInt(dealt * ls), by); } foreach (var s in entries) { var sm = s.Unit; if (sm == null || !sm.IsAlive || sm.Faction == d.Faction || s.PassiveEffects == null) continue; var dr = Find(s.PassiveEffects, SkillKeys.DrainAura); if (dr == null || !SkillMods.Within(sm, d, SkillMods.BaseReach(sm))) continue; if (Heal(sm, Mathf.RoundToInt(dealt * dr.P), sm) > 0) NotePassive(s, s.Passive); } var ctx = Ctx; bool actor = ctx != null && ctx.Actor == by; if (actor && (kind == HitKind.Attack || kind == HitKind.Skill)) { ctx.HitCount++; ctx.DamagedEnemy = true; if (!d.Female) ctx.MalesHit.Add(d); } if (kind == HitKind.Attack || kind == HitKind.Skill || kind == HitKind.Indirect || kind == HitKind.Gimmick) ApplyHitStatuses(by, d, crit, kind); if (actor && kind == HitKind.Attack && !ctx.FirstHitDone) { ctx.FirstHitDone = true; float fb = SkillMods.Sum(by, SkillKeys.FirstHitBonus, true, d); if (fb > 0f && d.IsAlive) { int extra = Mathf.Max(1, Mathf.RoundToInt(AtkOf(by, d) * fb)); extra = Hit(d, extra, by, HitKind.Skill); Fx.Popup(d.Position + new Vector2(0f, 0.3f), "+" + extra, SkillFx.NameColor); } } if (actor && kind == HitKind.Attack) { var bs = Of(by); var dh = Eff(bs, SkillKeys.DoubleHitStun); if (dh != null && d.IsAlive) { Vector2Int v; bs.Streak.TryGetValue(d, out v); if (v.x != ctx.Seq) { v.y = v.x == ctx.Seq - 1 ? v.y + 1 : 1; v.x = ctx.Seq; if (v.y >= Mathf.RoundToInt(dh.P)) { ApplyStun(d, Mathf.Max(1, Mathf.RoundToInt(dh.Get(1, 1f))), "연속 피해 기절"); v.y = 0; NotePassive(bs, bs.Passive); } bs.Streak[d] = v; } } } } /// 명중 상태 이상(hit_*): 조건(치명타·상대) · 확률 p3 · 간접(밀린 아군·지형) = 고순(hit_stun p2 1)만. public void ApplyHitStatuses(Unit by, Unit d, bool crit, HitKind kind) { if (by == null || d == null || !d.IsAlive || by.Faction == d.Faction) return; bool indirect = kind == HitKind.Indirect || kind == HitKind.Gimmick; foreach (var h in SkillMods.Get(by, SkillKeys.HitStun, d, crit)) if ((!indirect || h.E.Get(1) >= 0.5f) && Roll(h.E.Get(2, 1f))) { ApplyStun(d, Mathf.Max(1, Mathf.RoundToInt(h.E.P)), "기절"); NoteHit(h); } if (indirect) return; foreach (var h in SkillMods.Get(by, SkillKeys.HitBleed, d, crit)) if (Roll(h.E.Get(2, 1f))) { ApplyBleedStatus(d, Mathf.RoundToInt(h.E.P), h.E.Get(1, 0.5f)); NoteHit(h); } // 중독 = 한 번 명중에 1회만(액티브·패시브가 함께 걸려도 중첩 +1 · 가장 긴 턴·큰 비율 · Fable 검토 C1) int pTurns = 0; float pPct = 0f; foreach (var h in SkillMods.Get(by, SkillKeys.HitPoison, d, crit)) if (Roll(h.E.Get(2, 1f))) { pTurns = Mathf.Max(pTurns, Mathf.RoundToInt(h.E.P)); pPct = Mathf.Max(pPct, h.E.Get(1, 0.02f)); NoteHit(h); } if (pTurns > 0) { int max = 1; foreach (var ps in SkillMods.Get(by, SkillKeys.PoisonStack)) max = Mathf.Max(max, Mathf.RoundToInt(ps.E.P)); ApplyPoison(d, pTurns, pPct, max); } foreach (var h in SkillMods.Get(by, SkillKeys.HitBurn, d, crit)) if (Roll(h.E.Get(2, 1f))) { ApplyBurn(d, Mathf.RoundToInt(h.E.P), h.E.Get(1, 0.05f)); NoteHit(h); } foreach (var h in SkillMods.Get(by, SkillKeys.HitConfuse, d, crit)) if (Roll(h.E.Get(2, 1f))) { ApplyConfuse(d, Mathf.RoundToInt(h.E.P), h.E.Get(1, 0.5f)); NoteHit(h); } foreach (var h in SkillMods.Get(by, SkillKeys.HitPush, d, crit)) { Vector2 dir = d.Position - by.Position; if (dir.sqrMagnitude < 1e-6f) continue; Shove(d, dir.normalized, h.E.P * by.SlingMaxDistance, by); NoteHit(h); } } void NoteHit(SkillHit h) { if (h.Passive) NotePassive(h.Owner, h.Def); } bool Roll(float chance) { if (chance >= 0.999f) return true; if (chance <= 0f) return false; if (Sim.Active) return chance >= 0.5f; // 가상 실행 = 결정적 return battle.Rng.NextDouble() < chance; } /// 맞은 쪽(근접·원거리 공격을 받음 · 살아 있음): 서황 도끼 반격 · 간옹 기절(재충전) · 원술 혼란(확률). void OnAttacked(Unit d, Unit by) { var ds = Of(d); foreach (var h in SkillMods.Get(d, SkillKeys.AxeAura)) { if (!by.IsAlive) break; if (Vector2.Distance(d.Position, by.Position) > d.Radius + by.Radius + h.E.P) continue; int ax = Mathf.Max(1, Mathf.RoundToInt(AtkOf(d, by) * h.E.Get(1, 0.8f))); ax = Hit(by, ax, d, HitKind.Skill); Fx.Popup(by.Position, ax + " 도끼", SkillFx.NameColor); break; } if (ds == null || ds.PassiveEffects == null || !by.IsAlive) return; var st = Find(ds.PassiveEffects, SkillKeys.OnAttackedStun); if (st != null && ds.AttackedStunCd <= 0) { ApplyStun(by, Mathf.Max(1, Mathf.RoundToInt(st.P)), "말재주 기절"); ds.AttackedStunCd = Mathf.Max(0, Mathf.RoundToInt(st.Get(1, 0f))); NotePassive(ds, ds.Passive); } var cf = Find(ds.PassiveEffects, SkillKeys.OnAttackedConfuse); if (cf != null && Roll(cf.Get(2, 1f))) { ApplyConfuse(by, Mathf.Max(1, Mathf.RoundToInt(cf.P)), cf.Get(1, 0.5f)); NotePassive(ds, ds.Passive); } } /// 처치: 무신 누적 · 처치 회복(문추·공손찬·고육계) · 파로 재행동(턴당) · 곽가 귀재(범위 안 처치 = 쿨 −·회복) · 한중의 교주(망령) · 노숙 깃발. void OnDeath(Unit d, Unit by) { var dsk = Of(d); if (dsk != null && dsk.PassiveEffects != null) { var bn = Find(dsk.PassiveEffects, SkillKeys.DeathBanner); if (bn != null) { dsk.BannerLeft = Mathf.Max(1, Mathf.RoundToInt(bn.P)); dsk.BannerPos = d.Position; Text(d, "깃발", SkillFx.NameColor); NotePassive(dsk, dsk.Passive); } } if (by == null || by.Faction == d.Faction || summons.Contains(d)) return; var s = Of(by); if (s != null) s.Kills++; var ctx = Ctx; if (ctx != null && ctx.Actor == by) ctx.Kills++; if (!by.IsAlive) return; float hk = SkillMods.Sum(by, SkillKeys.HealOnKill, true); if (hk > 0f) Heal(by, Mathf.RoundToInt(by.MaxHp * hk), by); if (s != null && !s.KillReactUsed && Eff(s, SkillKeys.KillReaction) != null) { s.KillReactUsed = true; s.ReactCredits++; NotePassive(s, s.Passive); } foreach (var g in entries) { var gu = g.Unit; if (gu == null || !gu.IsAlive || gu.Faction != by.Faction || g.PassiveEffects == null) continue; var rw = Find(g.PassiveEffects, SkillKeys.KillInRangeReward); if (rw == null || Vector2.Distance(gu.Position, d.Position) > SkillMods.BaseReach(gu) + d.Radius) continue; if (s != null && s.HasActive) s.ReadyTurn = Mathf.Max(battle.TurnNumber, s.ReadyTurn - Mathf.RoundToInt(rw.P)); Heal(by, Mathf.RoundToInt(by.MaxHp * rw.Get(1, 0f)), gu); NotePassive(g, g.Passive); } if (ctx != null && ctx.Actor == by && Eff(s, SkillKeys.Zombie) != null && !Sim.Active) ctx.ZombieFrom.Add(d); } /// /// 장로 한중의 교주: 처치한 적이 체력 1 로 같은 편 망령이 된다(조작 불가 · 상대 턴 동안 공격받을 수 있음) → /// 그 진영 다음 턴 시작(TurnBegin)에 가장 가까운 상대를 1번 공격하고 사라짐(PD 2026-11-10 #1112 결정 ⑩). /// void SpawnZombie(Unit owner, Unit dead) { if (owner == null || dead == null || battle.Board == null) return; var s = Of(owner); var e = Eff(s, SkillKeys.Zombie); var z = battle.SpawnUnit(dead.Type, owner.Faction, dead.Position, "망령 " + dead.DisplayName, 1f, 1f, false, dead.ArtId); z.Atk = dead.Atk; z.Hp = Mathf.Max(1, Mathf.RoundToInt(e != null ? e.P : 1f)); z.MarkActed(); summons.Add(z); if (z.View != null) z.View.Refresh(); Text(z, "망령", SkillFx.NameColor); if (s != null) NotePassive(s, s.Passive); AddLog("망령 " + dead.DisplayName + " — 다음 턴 시작에 1번 공격 후 소멸"); } /// 망령의 자동 공격 1회(가장 가까운 상대 쪽으로 슬링 최대 거리까지). IEnumerator ZombieAct(Unit z) { if (z == null || !z.IsAlive || battle.Board == null) yield break; Unit foe = null; float best = float.MaxValue; foreach (var t in battle.AliveUnits(Other(z.Faction))) { float dd = Vector2.Distance(z.Position, t.Position); if (dd < best) { best = dd; foe = t; } } if (foe == null) yield break; yield return Fx.Wait(0.2f); var prevCtx = Ctx; Vector2 aim = foe.Position - z.Position; float max = SkillMods.SlingMax(z); if (aim.magnitude > max) aim = aim.normalized * max; var o = new ActionOutcome(); yield return battle.Resolver.Resolve(z, battle.Board.ClampInside(z.Position + aim, z.Radius), false, o); Ctx = prevCtx; } // ---------- 상태 이상 ---------- public UnitStatus StatusOf(Unit u, bool create = false) { if (u == null) return null; UnitStatus s; if (statuses.TryGetValue(u, out s)) return s; if (!create) return null; s = new UnitStatus(); statuses[u] = s; return s; } /// 자기 진영 턴 중에 걸리면 그 턴은 세지 않는다(다음 자기 턴부터). int TurnsFor(Unit u, int turns) { bool ownTurn = (u.Faction == Faction.Player && (battle.Phase == BattlePhase.PlayerTurn || (battle.Phase == BattlePhase.Resolving && Ctx != null && Ctx.Actor != null && Ctx.Actor.Faction == Faction.Player))) || (u.Faction == Faction.Enemy && battle.Phase == BattlePhase.EnemyTurn); return turns + (ownTurn ? 1 : 0); } public void ApplyStun(Unit u, int turns, string why) { if (u == null || !u.IsAlive || turns <= 0) return; var g = GimmickLayer.Current; if (g != null) g.Stun(u, turns, why); } public void ApplyConfuse(Unit u, int turns, float mult) { if (u == null || !u.IsAlive || turns <= 0) return; var s = StatusOf(u, true); s.ConfuseTurns = Mathf.Max(s.ConfuseTurns, TurnsFor(u, turns)); s.ConfuseMult = Mathf.Min(s.ConfuseTurns > 0 && s.ConfuseMult < 1f ? s.ConfuseMult : 1f, mult); Text(u, "혼란", SkillStatus.TextColor); RefreshBadge(u); } public void ApplyBleedStatus(Unit u, int turns, float pct) { if (u == null || !u.IsAlive || turns <= 0) return; var s = StatusOf(u, true); s.BleedTurns = Mathf.Max(s.BleedTurns, TurnsFor(u, turns)); s.BleedPct = Mathf.Max(s.BleedPct, pct); Text(u, "출혈", new Color(0.9f, 0.15f, 0.2f)); RefreshBadge(u); } public void ApplyPoison(Unit u, int turns, float pct, int maxStacks) { if (u == null || !u.IsAlive || turns <= 0) return; var s = StatusOf(u, true); s.PoisonStacks = s.PoisonTurns > 0 ? Mathf.Min(Mathf.Max(1, maxStacks), s.PoisonStacks + 1) : 1; s.PoisonTurns = Mathf.Max(s.PoisonTurns, turns); s.PoisonPct = Mathf.Max(s.PoisonPct, pct); Text(u, s.PoisonStacks > 1 ? "중독 ×" + s.PoisonStacks : "중독", new Color(0.7f, 0.4f, 1f)); RefreshBadge(u); } public void ApplyBurn(Unit u, int turns, float pct) { if (u == null || !u.IsAlive || turns <= 0) return; var s = StatusOf(u, true); s.BurnTurns = Mathf.Max(s.BurnTurns, turns); s.BurnPct = Mathf.Max(s.BurnPct, pct); Text(u, "화상", new Color(1f, 0.5f, 0.2f)); RefreshBadge(u); } /// 상태 이상 제거 = 스킬 상태(혼란·출혈·중독·화상) + 지형 기믹 중독·행동 불가. public void Cleanse(Unit u) { if (u == null) return; var s = StatusOf(u); bool any = s != null && s.Any; if (s != null) s.Clear(); if (GimmickLayer.Current != null) any |= GimmickLayer.Current.Cleanse(u); if (any) Text(u, "정화", SkillFx.HealColor); RefreshBadge(u); } void RefreshBadge(Unit u) { if (Sim.Active || u == null) return; var s = StatusOf(u); if (s == null) return; string t = SkillStatus.BadgeText(s); if (s.Badge == null) { if (t.Length == 0) return; s.Badge = TextUtil.CreateWorldText(u.transform, t, 0.24f, SkillStatus.TextColor, 6); s.Badge.gameObject.name = "SkillStatus"; float top = u.View != null && u.View.HasArt ? -u.Radius * 0.55f + u.ArtHeight + 0.12f : u.Radius + 0.22f; s.Badge.transform.localPosition = new Vector3(0f, top + 1.05f, 0f); } s.Badge.text = t; s.Badge.gameObject.SetActive(t.Length > 0 && u.IsAlive); } // ---------- 회복 ---------- /// 공격력(효과 반영) — 「공격력의 X %」 회복·추가 피해 기준. public static float AtkOf(Unit u, Unit vs = null) { return u != null ? u.Atk * SkillMods.AttackMult(u, false, vs) : 0f; } /// 회복(최대 HP 넘지 않음) · 간옹 패시브 = 회복한 대상 정화. 반환 = 실제 회복량. public int Heal(Unit u, int amount, Unit healer = null) { if (u == null || !u.IsAlive || amount <= 0) return 0; int before = u.Hp; u.Hp = Mathf.Min(u.MaxHp, u.Hp + amount); if (u.View != null && !Sim.Active) u.View.Refresh(); int got = u.Hp - before; if (got > 0) Fx.Popup(u.Position, "+" + got, SkillFx.HealColor); if (healer != null && healer != u && SkillMods.Has(healer, SkillKeys.HealCleanse, true) && SkillStatus.HasAny(u)) Cleanse(u); return got; } void PayHp(Unit u, float ratio) { int cost = Mathf.RoundToInt(u.MaxHp * Mathf.Max(0f, ratio)); if (cost <= 0) return; u.Hp = Mathf.Max(1, u.Hp - cost); // 스스로 쓰러지지는 않는다 if (u.View != null && !Sim.Active) { u.View.OnHit(); u.View.Refresh(); } Fx.Popup(u.Position, "HP −" + cost, SkillFx.CostColor); } // ---------- 밀기(스킬) ---------- /// 스킬 밀기(원소 치명타 밀침 · 즉시형 광역 밀기): 벽·유닛·장애물에서 멈춤 · 움직인 거리 = 출혈. public float Shove(Unit t, Vector2 dir, float dist, Unit by) { if (t == null || !t.IsAlive || dist < 0.01f) return 0f; float force = dist * SkillMods.KnockbackTakenMult(t, true); float slide = Combat.FreeTravel(t, t.Position, dir, force, true, true, by); if (slide <= 0.01f) return 0f; Vector2 start = t.Position; Vector2 end = battle.Board.ClampInside(start + dir * slide, t.Radius); if (t.View != null && !Sim.Active) t.View.SetFacing(dir); t.Position = end; if (!Sim.Active) { t.transform.position = new Vector3(start.x, start.y, 0f); Fx.Run(Fx.MoveAlong(t.transform, start, end, Cfg.moveSpeed, null)); t.Position = end; // 논리 위치는 바로(콜라이더 질의) · 그림은 따라감 } float moved = Vector2.Distance(start, end); ApplyBleed(new Dictionary { { t, moved } }); return moved; } // ---------- 지대(주유 불꽃 · 육손 불 장벽 · 장로 독지대) ---------- public SkillZone AddZone(UnitSkills owner, Vector2 a, Vector2 b, float radius, int rounds, float ratio, SkillEffect status, Color color, bool hitsAllies = false) { var z = new SkillZone { Owner = owner, Faction = owner.Unit.Faction, A = a, B = b, Radius = Mathf.Max(0.2f, radius), RoundsLeft = Mathf.Max(1, rounds), Ratio = ratio, Status = status, StatusKey = status != null ? status.Key : null, HitsAllies = hitsAllies }; if (!Sim.Active) { var root = new GameObject("SkillZone"); Vector2 mid = (a + b) * 0.5f; root.transform.position = new Vector3(mid.x, mid.y, 0f); float len = Vector2.Distance(a, b); if (len > 0.05f) { var bar = ProceduralSprites.NewSprite(root.transform, "Body", ProceduralSprites.Square, color, 1f, -12); bar.transform.localScale = new Vector3(len, z.Radius * 2f, 1f); Vector2 d = b - a; bar.transform.rotation = Quaternion.Euler(0f, 0f, Mathf.Atan2(d.y, d.x) * Mathf.Rad2Deg); } foreach (var p in new[] { a, b }) { var cap = ProceduralSprites.NewSprite(root.transform, "Cap", ProceduralSprites.Circle, color, z.Radius * 2f, -12); cap.transform.position = new Vector3(p.x, p.y, 0f); } z.View = root; } Zones.Add(z); return z; } /// 지대 닿음: 상대 = 주인 공격력 × 배율 피해 + 지대 상태 이상(행동마다 · 턴마다 1번). void ZoneTouch(SkillZone z, Unit u) { if (u == null || !u.IsAlive || !z.Affects(u) || !z.Touched.Add(u)) return; var src = z.Owner != null ? z.Owner.Unit : null; if (z.Ratio > 0f && src != null) { int d = Mathf.Max(1, Mathf.RoundToInt(Combat.SkillDamage(Cfg, src, u, z.Ratio, battle.Rng))); // 아군도 같은 피해(PD 결정 ⑬) d = Hit(u, d, src, HitKind.Dot); Text(u, (z.StatusKey == SkillKeys.HitPoison ? "독지대 −" : "불꽃 −") + d, new Color(1f, 0.55f, 0.25f)); } if (z.Status != null && u.IsAlive) { if (z.StatusKey == SkillKeys.HitBurn || z.StatusKey == SkillKeys.ZoneBurn) ApplyBurn(u, Mathf.RoundToInt(z.Status.P), z.Status.Get(1, 0.05f)); else if (z.StatusKey == SkillKeys.HitPoison) ApplyPoison(u, Mathf.RoundToInt(z.Status.P), z.Status.Get(1, 0.02f), 1); } } /// 이 행동에서 움직인 상대 유닛의 경로가 지대와 겹치면 닿음(행동 유닛 경로 + 밀림·넉백 구간). void ZoneSweep(Unit actor, ActionOutcome o) { if (Zones.Count == 0) return; foreach (var z in Zones) z.Touched.Clear(); var segs = new List>(); for (int i = 0; actor != null && i < o.Path.Count - 1; i++) segs.Add(new KeyValuePair(actor, new Vector4(o.Path[i].x, o.Path[i].y, o.Path[i + 1].x, o.Path[i + 1].y))); foreach (var d in o.Displaced) if (d.Unit != null) segs.Add(new KeyValuePair(d.Unit, new Vector4(d.From.x, d.From.y, d.To.x, d.To.y))); foreach (var z in Zones) foreach (var kv in segs) { var u = kv.Key; if (u == null || !u.IsAlive || !z.Affects(u)) continue; var s = kv.Value; if (SegSegDistance(new Vector2(s.x, s.y), new Vector2(s.z, s.w), z.A, z.B) <= z.Radius + u.Radius * 0.5f) ZoneTouch(z, u); } } /// 서황 대부: 도끼가 도는 동안 자신의 이동 경로 도끼 반경(p1)에 닿은 상대 = 공격력 × p2 추가 피해(행동마다 1번). void AxeSweep(Unit u, ActionOutcome o) { if (u == null || !u.IsAlive || o.Path.Count < 2) return; var axe = SkillMods.First(u, SkillKeys.AxeAura); if (axe == null) return; foreach (var e in battle.AliveUnits(Other(u.Faction))) { bool near = false; for (int i = 0; i < o.Path.Count - 1 && !near; i++) near = Combat.SegmentPointDistance(o.Path[i], o.Path[i + 1], e.Position) <= u.Radius + e.Radius + axe.P; if (!near) continue; int d = Mathf.Max(1, Mathf.RoundToInt(AtkOf(u, e) * axe.Get(1, 0.8f))); d = Hit(e, d, u, HitKind.Skill); Fx.Popup(e.Position, d + " 도끼", SkillFx.NameColor); } } public static float SegSegDistance(Vector2 p1, Vector2 q1, Vector2 p2, Vector2 q2) { if (SegmentsIntersect(p1, q1, p2, q2)) return 0f; return Mathf.Min(Mathf.Min(Combat.SegmentPointDistance(p1, q1, p2), Combat.SegmentPointDistance(p1, q1, q2)), Mathf.Min(Combat.SegmentPointDistance(p2, q2, p1), Combat.SegmentPointDistance(p2, q2, q1))); } static bool SegmentsIntersect(Vector2 a, Vector2 b, Vector2 c, Vector2 d) { float d1 = Cross(d - c, a - c), d2 = Cross(d - c, b - c), d3 = Cross(b - a, c - a), d4 = Cross(b - a, d - a); return ((d1 > 0f && d2 < 0f) || (d1 < 0f && d2 > 0f)) && ((d3 > 0f && d4 < 0f) || (d3 < 0f && d4 > 0f)); } static float Cross(Vector2 a, Vector2 b) { return a.x * b.y - a.y * b.x; } // ---------- 즉시형 액티브 ---------- /// 즉시 발동(행동 소모 없음): 시전 중 효과 장착 → 처리기 → 지속 효과(dur) → 쿨. public IEnumerator Instant(UnitSkills s) { var u = s.Unit; Announce(s, s.Active); VfxBegin(u, s.Active.Id, null, Vector2.zero, false); s.ReadyTurn = battle.TurnNumber + CooldownOf(s); s.Casting = true; var prevCtx = Ctx; Ctx = new ActionCtx { Actor = u, Origin = u.Position, Seq = s.ActionSeq }; // 즉시형은 행동 번호를 올리지 않음(장비 연속 판정 · Fable 검토 C2) var moved = new Dictionary(); foreach (var e in s.ActiveEffects) { if (e.Dur > 0) { AddTimed(s, e, e.Scope == SkillScope.Range ? InRange(u) : null, e.Dur); continue; } switch (e.Key) { case SkillKeys.AoeStrike: yield return AoeStrike(s, e, moved); break; case SkillKeys.AoeDrain: AoeDrain(s, e); break; case SkillKeys.Volley: yield return Volley(s, e, moved); break; case SkillKeys.CharmPull: yield return CharmPull(s, e, moved); break; case SkillKeys.HealAtk: { int amt = Mathf.RoundToInt(AtkOf(u) * e.P); foreach (var t in e.Scope == SkillScope.Self ? new HashSet { u } : InRange(u)) Heal(t, amt, u); break; } case SkillKeys.Cleanse: foreach (var t in e.Scope == SkillScope.Self ? new HashSet { u } : InRange(u)) Cleanse(t); break; case SkillKeys.HealSelfPct: Heal(u, Mathf.RoundToInt(u.MaxHp * Mathf.Max(0f, e.P)), u); break; case SkillKeys.GrantReaction: GrantReaction(u, Mathf.Max(1, Mathf.RoundToInt(e.P))); break; } } ApplyBleed(moved); s.Casting = false; Ctx = prevCtx; yield return Fx.Wait(0.25f); if (vfxActor == u) VfxEnd(); battle.NotifyChanged(); } /// 한 유닛에게 거는 시한 버프(미축 지원 · 순욱 보급 = 아군 명중 시 이동·공격 %). public void AddBuff(UnitSkills owner, Unit target, string key, float value, int rounds) { if (owner == null || target == null || owner.Unit == null) return; var e = new SkillEffect { Key = key, P = value, Scope = SkillScope.Range, Dur = rounds }; TimedEffects.Add(new Timed { Owner = owner, Faction = owner.Unit.Faction, Def = owner.Passive ?? owner.Active, E = e, Targets = new HashSet { target }, Rounds = Mathf.Max(1, rounds) }); Text(target, key == SkillKeys.AtkPct ? "공격 +" + Mathf.RoundToInt(value * 100f) + "%" : "이동 +" + Mathf.RoundToInt(value * 100f) + "%", SkillFx.NameColor); } void AddTimed(UnitSkills s, SkillEffect e, HashSet targets, int rounds) { TimedEffects.Add(new Timed { Owner = s, Faction = s.Unit.Faction, Def = s.Active, E = e, Targets = targets, Rounds = Mathf.Max(1, rounds) }); } /// 관우 청룡언월 · 장비 장판 일갈 · 능통: 반경 p1 안 상대 전원 = 공격력 × p2 피해(0 = 없음) → 바깥으로 p3 밀기 · 명중 상태 이상(피해 없어도). IEnumerator AoeStrike(UnitSkills s, SkillEffect e, Dictionary moved) { var u = s.Unit; float radius = e.P; var targets = new List(); foreach (var t in battle.AliveUnits(Other(u.Faction))) if (Vector2.Distance(u.Position, t.Position) <= radius + t.Radius) targets.Add(t); Fx.Burst(u.Position, radius, SkillFx.FearColor); if (u.View != null && !Sim.Active) u.View.PlayAttack(); foreach (var t in targets) { bool crit = false; if (e.Get(1) > 0f) { int d = Combat.SkillDamage(Cfg, u, t, e.Get(1), battle.Rng); crit = Combat.LastCrit; d = Hit(t, d, u, HitKind.Skill); Fx.Popup(t.Position, crit ? d + "!" : d.ToString(), Color.white); } else ApplyHitStatuses(u, t, false, HitKind.Skill); } yield return Fx.Wait(0.15f); float push = e.Get(2); if (push > 0.01f) { targets.Sort((a, b) => Vector2.Distance(u.Position, b.Position).CompareTo(Vector2.Distance(u.Position, a.Position))); // 바깥부터 foreach (var t in targets) { if (!t.IsAlive) continue; Vector2 dir = t.Position - u.Position; dir = dir.sqrMagnitude > 1e-6f ? dir.normalized : Vector2.up; float m = ShoveRaw(t, dir, push * SkillMods.KnockbackTakenMult(t, true), u); if (m > 0f) AddMoved(moved, t, m); } yield return Fx.Wait(0.2f); } battle.NotifyChanged(); } static void AddMoved(Dictionary m, Unit u, float d) { float v; m.TryGetValue(u, out v); m[u] = v + d; } /// 밀기(출혈은 호출 쪽이 모아서) — 반환 = 움직인 거리. float ShoveRaw(Unit t, Vector2 dir, float force, Unit by) { float slide = Combat.FreeTravel(t, t.Position, dir, force, true, true, by); if (slide <= 0.01f) return 0f; Vector2 start = t.Position; Vector2 end = battle.Board.ClampInside(start + dir * slide, t.Radius); if (t.View != null && !Sim.Active) t.View.SetFacing(dir); t.Position = end; if (!Sim.Active) { t.transform.position = new Vector3(start.x, start.y, 0f); Fx.Run(Fx.MoveAlong(t.transform, start, end, Cfg.moveSpeed, null)); t.Position = end; } return Vector2.Distance(start, end); } /// 사마의: 행동 반경 안 상대 전원 = 공격력 × p1 → 준 피해 합 × p2 를 반경 안 다른 아군 각각 회복. void AoeDrain(UnitSkills s, SkillEffect e) { var u = s.Unit; float r = SkillMods.BaseReach(u) * SkillMods.Product(u, SkillKeys.SplashRadiusMult, true); // 응시낭고 광역 배율(Fable 검토 B3) int total = 0; Fx.Burst(u.Position, r, Fx.SplashColor); foreach (var t in battle.AliveUnits(Other(u.Faction))) { if (!SkillMods.Within(u, t, r)) continue; int d = Hit(t, Combat.SkillDamage(Cfg, u, t, e.P, battle.Rng), u, HitKind.Skill); Fx.Popup(t.Position, d.ToString(), Color.white); total += d; } int amt = Mathf.RoundToInt(total * e.Get(1)); foreach (var a in battle.AliveUnits(u.Faction)) if (a != u && SkillMods.Within(u, a, r)) Heal(a, amt, u); } /// 손상향 궁요희: 사거리 안 상대 전원에게 화살(공격력 × p1 · 치명타 판정) → 바깥으로 p2 밀기 · 치명타 = 출혈(조건 칸). IEnumerator Volley(UnitSkills s, SkillEffect e, Dictionary moved) { var u = s.Unit; float range = SkillMods.Range(u); var targets = new List(); foreach (var t in battle.AliveUnits(Other(u.Faction))) if (Vector2.Distance(u.Position, t.Position) - t.Radius - u.Radius <= range) targets.Add(t); if (u.View != null && !Sim.Active) u.View.PlayAttack(); foreach (var t in targets) Fx.Run(Fx.Arrow(u.Position, t.Position)); yield return Fx.Wait(0.2f); foreach (var t in targets) { if (!t.IsAlive) continue; int d = Combat.SkillDamage(Cfg, u, t, e.P, battle.Rng); bool crit = Combat.LastCrit; d = Hit(t, d, u, HitKind.Skill); Fx.Popup(t.Position, crit ? d + "!" : d.ToString(), crit ? new Color(1f, 0.7f, 0.2f) : Color.white); if (t.IsAlive && e.Get(1) > 0.01f) { Vector2 dir = (t.Position - u.Position).normalized; float m = ShoveRaw(t, dir, e.Get(1) * SkillMods.KnockbackTakenMult(t, true), u); if (m > 0f) AddMoved(moved, t, m); } } yield return Fx.Wait(0.15f); } /// 초선 미인계: 행동 반경 안 상대 남성(p2 1) = p1 턴 행동 불가 + 자신 쪽으로 끌려옴(유닛·장애물에 막힘). IEnumerator CharmPull(UnitSkills s, SkillEffect e, Dictionary moved) { var u = s.Unit; float r = SkillMods.BaseReach(u); bool maleOnly = e.Get(1, 1f) >= 0.5f; var targets = new List(); foreach (var t in battle.AliveUnits(Other(u.Faction))) if (SkillMods.Within(u, t, r) && (!maleOnly || !t.Female)) targets.Add(t); targets.Sort((a, b) => Vector2.Distance(u.Position, a.Position).CompareTo(Vector2.Distance(u.Position, b.Position))); Fx.Burst(u.Position, r, SkillFx.FearColor); if (targets.Count == 0) AddLog(s.Active.NameKo + " 대상 없음 — 쿨만 소모"); foreach (var t in targets) { ApplyStun(t, Mathf.Max(1, Mathf.RoundToInt(e.P)), "행동 불가"); Vector2 to = u.Position - t.Position; float want = to.magnitude - u.Radius - t.Radius - 0.05f; if (want <= 0.05f) continue; float m = ShoveRaw(t, to.normalized, want, null); if (m > 0f) AddMoved(moved, t, m); } yield return Fx.Wait(0.25f); } /// 곽가 십승십패: 이미 행동한 다른 아군 n 기에 재행동(없으면 임의의 다른 아군이 행동 뒤 재행동 1회). void GrantReaction(Unit u, int n) { var acted = new List(); var others = new List(); foreach (var a in battle.AliveUnits(u.Faction)) { if (a == u) continue; others.Add(a); if (a.ActedThisTurn) acted.Add(a); } for (int i = 0; i < n; i++) { var pool = acted.Count > 0 ? acted : others; if (pool.Count == 0) return; var pick = pool[battle.Rng.Next(pool.Count)]; pool.Remove(pick); if (pick.ActedThisTurn) pick.GrantReAction(); else Ensure(pick).ReactCredits++; // 스킬 없는 병사도(Fable 검토 A2) Text(pick, "재행동", SkillFx.NameColor); } } // ---------- 변진(장합): 목표 지점 탭 ---------- public static float TeleportRadius(UnitSkills s) { if (s == null || s.ActiveEffects == null) return 0f; foreach (var e in s.ActiveEffects) if (e.Key == SkillKeys.Teleport) return e.P; return 0f; } /// 목표 지점이 반경 안 빈 곳인가(벽·유닛과 겹치지 않음 · 전장 안). public bool TeleportValid(Unit u, Vector2 w) { var s = Of(u); if (s == null) return false; if (Vector2.Distance(u.Position, w) > TeleportRadius(s) + 1e-3f) return false; if ((battle.Board.ClampInside(w, u.Radius) - w).sqrMagnitude > 1e-4f) return false; Physics2D.SyncTransforms(); foreach (var c in Physics2D.OverlapCircleAll(w, u.Radius)) { if (c.GetComponent() != null || GimmickLayer.BlocksMove(c)) return false; var o = c.GetComponent(); if (o != null && o != u && o.IsAlive) return false; } return true; } /// 변진: 대상 무시하고 그 지점으로 → 이미 행동했으면 재행동(extra_action). public IEnumerator TeleportRoutine(UnitSkills s, Vector2 w) { var u = s.Unit; Announce(s, s.Active); s.ReadyTurn = battle.TurnNumber + CooldownOf(s); if (u.View != null) u.View.SetFacing(w - u.Position); yield return Fx.MoveAlong(u.transform, u.Position, w, battle.Config.moveSpeed * 2f, null); u.Position = w; if (s.Active.Has(SkillKeys.ExtraAction) && u.ActedThisTurn) { u.GrantReAction(); Text(u, "재행동", SkillFx.NameColor); } battle.NotifyChanged(); } // ---------- 연출 · HUD 기록 ---------- /// 액티브 발동 = 유닛 위 스킬 이름 + 플래시 + HUD 한 줄 · 주유 패시브 = 시전 HP 소모. public void Announce(UnitSkills s, SkillDef def, Vector2 dir = default(Vector2)) { if (s == null || def == null) return; if (def.IsActive) BattleEvents.Raise(BattleEvents.ActiveUses, s.Unit); if (s.Unit != null) SkillFx.Announce(s.Unit, def.NameKo); if (s.Unit != null && def.IsActive) SkillVfx.Play(s.Unit, def.Id, SkillVfx.EventCast, dir); AddLog("액티브 · " + (s.Unit != null ? s.Unit.DisplayName : "") + " 「" + def.NameKo + "」"); if (def.IsActive && s.Unit != null) { var cost = Find(s.PassiveEffects, SkillKeys.SkillHpCost); if (cost != null) { PayHp(s.Unit, cost.P); NotePassive(s, s.Passive); } } } /// 패시브 발동 HUD 한 줄(같은 행동 안에서는 스킬마다 1번). public void NotePassive(UnitSkills s, SkillDef def) { if (s == null || def == null || s.Unit == null) return; string key = s.Unit.GetInstanceID() + "/" + def.Id; if (!noted.Add(key)) return; AddLog("패시브 · " + s.Unit.DisplayName + " 「" + def.NameKo + "」"); } public void AddLog(string text) { if (Sim.Active) return; Log.Add(new LogLine { Text = text, Time = Time.unscaledTime }); if (Log.Count > 8) Log.RemoveAt(0); } static void Text(Unit u, string text, Color c) { if (Sim.Active || u == null) return; Fx.Popup(u.Position + new Vector2(0f, 0.55f), text, c); } /// 최근 seconds 초 안의 기록(최신이 마지막). public List RecentLog(float seconds, int max) { var res = new List(); float now = Time.unscaledTime; for (int i = Log.Count - 1; i >= 0 && res.Count < max; i--) if (now - Log[i].Time <= seconds) res.Insert(0, Log[i].Text); return res; } } /// 스킬 연출(이름 글자 · 플래시). public static class SkillFx { public static readonly Color NameColor = new Color(1f, 0.85f, 0.3f); public static readonly Color FlashColor = new Color(1f, 0.9f, 0.45f, 0.55f); public static readonly Color GuardColor = new Color(0.55f, 0.85f, 1f); public static readonly Color FearColor = new Color(1f, 0.4f, 0.25f, 0.4f); public static readonly Color CostColor = new Color(1f, 0.55f, 0.35f); public static readonly Color HealColor = new Color(0.45f, 1f, 0.5f); public static void Announce(Unit u, string name) { if (Sim.Active) return; Fx.Burst(u.Position, u.Radius * 2.2f, FlashColor); Fx.Run(NameRoutine(u.Position + new Vector2(0f, u.ArtHeight > 0f ? u.ArtHeight * 0.6f : u.Radius + 0.6f), name)); } static IEnumerator NameRoutine(Vector2 pos, string text) { var tm = TextUtil.CreateWorldText(null, text, 0.55f, NameColor, 21); tm.gameObject.name = "SkillName"; var tr = tm.transform; float t = 0f; const float dur = 1.1f; while (t < dur) { t += Time.deltaTime; float k = Mathf.Clamp01(t / dur); tr.position = new Vector3(pos.x, pos.y + 0.4f * k, 0f); var c = NameColor; c.a = k < 0.7f ? 1f : 1f - (k - 0.7f) / 0.3f; tm.color = c; yield return null; } Object.Destroy(tm.gameObject); } } }