using System.Collections; using System.Collections.Generic; using UnityEngine; namespace TKS { /// 유닛 1기의 스킬 상태(액티브 쿨·예약 · 패시브 · 행동 뒤에도 남는 효과). public class UnitSkills { public Unit Unit; public string HeroId = ""; /// 무장(영웅·보스·엘리트 장수) — 참장·파로(dmg_vs_general_mult) 대상 판정. 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; /// 액티브를 쓸 수 있는 첫 아군 턴(전투 시작 = 1 · 사용 턴 + 쿨). public int ReadyTurn = 1; /// next_action 예약(다음 슬링샷 1회) · 행동 전 다시 탭 = 취소. public bool Reserved; /// 예약한 효과가 지금 걸려 있다(그 유닛의 행동 처리·조준 미리보기 동안만). public bool Armed; /// 추가 이동: 지금 것 다음에 더 남은 횟수 · 추가 이동에서 공격 가능. public int BonusMovesLeft; public bool BonusAttack; /// 대기(견제) 중 액티브로 얻은 반경 배율·가산·무제한(그 적 턴까지). public float OwMult = 1f; public float OwAdd; public bool OwUnlimited; /// 전투 중 관통한 적 누적(장료 팔백 돌격). public int PierceHits; public bool HasActive { get { return Active != null && ActiveEffects != null; } } public int CooldownLeft(int turn) { return Mathf.Max(0, ReadyTurn - turn); } } /// 스킬 효과 1건이 어느 유닛·스킬에서 왔는가(SkillMods 질의 결과). public struct SkillHit { public UnitSkills Owner; public SkillDef Def; public SkillEffect E; public bool Passive; } /// /// 전투 1판의 스킬 처리(PD #1093 B · 시스템 기획 §5): 유닛별 스킬 상태 · 즉시형 액티브의 시한 효과(이번 아군 턴 / 이번 적 턴) · /// 행동 전후 훅(예약 효과 장착·쿨 소모) · 피해 적용(호위 분담) · HUD 한 줄 기록. 수치 질의는 SkillMods. /// public class SkillSystem { public static SkillSystem Current; public class Timed { public UnitSkills Owner; public Faction Faction; public SkillDef Def; public SkillEffect E; /// true = 이번 적 턴에만 걸림(다음 아군 턴 시작에 사라짐) · false = 이번 아군 턴(적 턴 시작에 사라짐). public bool EnemyWindow; /// 대상 고정(장판 일갈 = 밀어낸 적) · null = 키 규칙대로. public HashSet Targets; } 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]; /// 장합 변진: 목표 지점 탭 대기 중인 유닛. public Unit PendingTeleport { get; private set; } public SkillSystem(BattleManager b) { battle = b; } public BattleManager Battle { get { return battle; } } public IReadOnlyList Entries { get { return entries; } } // ---------- 부착 ---------- 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 = SkillMods.Sum(u, SkillKeys.HpMaxPct); if (Mathf.Abs(hp) > 1e-4f) { u.MaxHp = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * (1f + hp))); u.Hp = u.MaxHp; if (u.View != null) u.View.Refresh(); } return s; } /// 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; 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 · 액티브 AI 사용은 2차 · PD 확인 대상). public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e) { 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; } return Attach(u, 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 void Clear() { map.Clear(); entries.Clear(); TimedEffects.Clear(); noted.Clear(); PendingTeleport = null; startCount[0] = startCount[1] = 0; } /// 전투 시작(배치 뒤): 출전 인원 기록(패자 · team_atk_pct_full) · 쿨 = 바로 사용 가능 · 시한 효과 비움. public void BeginBattle() { Prune(); TimedEffects.Clear(); noted.Clear(); PendingTeleport = null; startCount[0] = battle.AliveCount(Faction.Player); startCount[1] = battle.AliveCount(Faction.Enemy); foreach (var s in entries) { s.ReadyTurn = 1; s.Reserved = false; s.Armed = false; s.BonusMovesLeft = 0; s.BonusAttack = false; s.OwMult = 1f; s.OwAdd = 0f; s.OwUnlimited = false; s.PierceHits = 0; } } 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); } // ---------- 턴 ---------- /// 적 턴 시작: 이번 아군 턴 효과(동남풍 · 고육계)와 쓰지 않은 예약·추가 이동 정리. public void OnEnemyTurnStart() { TimedEffects.RemoveAll(t => !t.EnemyWindow); PendingTeleport = null; foreach (var s in entries) { s.Reserved = false; s.Armed = false; s.BonusMovesLeft = 0; s.BonusAttack = false; } } /// 아군 턴 시작: 이번 적 턴 효과(호위 · 철벽 · 간웅 · 일갈 · 안개)와 아군 견제 강화 정리. public void OnPlayerTurnStart() { TimedEffects.RemoveAll(t => t.EnemyWindow); foreach (var s in entries) { if (s.Unit == null || s.Unit.Faction != Faction.Player) continue; s.OwMult = 1f; s.OwAdd = 0f; s.OwUnlimited = false; } } public bool WindowActive(Timed t) { return !t.EnemyWindow || battle.Phase == BattlePhase.EnemyTurn; } // ---------- 액티브: 상태 · 탭 ---------- 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: PendingTeleport = null; 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) { PendingTeleport = u; return s.Active.NameKo + " — 반경 " + TeleportRadius(s).ToString("0.#") + " 안 빈 곳을 탭(다른 곳 = 취소)"; } battle.RunSkillRoutine(Instant(s)); return s.Active.NameKo + " 발동!"; } // ---------- 행동 훅(ActionResolver.Resolve) ---------- /// 행동 시작: 예약한 next_action 효과를 장착(이동만인 추가 이동은 제외) · 발동 연출. public void BeginAction(Unit u, bool moveOnly, ActionOutcome o) { noted.Clear(); var s = Of(u); if (s == null || !s.Reserved || !s.HasActive || moveOnly) return; s.Armed = true; o.SkillUsed = s.Active; Announce(s, s.Active); } /// 행동 끝: 예약 소모 · 쿨(아군 턴 기준) · 견제 강화 이월. public void EndAction(Unit u, ActionOutcome o) { var s = Of(u); if (s == null || !s.Armed) return; if (o.Overwatch) { foreach (var e in s.ActiveEffects) { if (e.Key == SkillKeys.OverwatchRadiusMult) s.OwMult *= e.P; else if (e.Key == SkillKeys.OverwatchRadiusAdd) s.OwAdd += e.P; else if (e.Key == SkillKeys.OverwatchUnlimited) s.OwUnlimited = true; } } s.Armed = false; s.Reserved = false; s.ReadyTurn = battle.TurnNumber + s.Active.Cooldown; if (u.View != null) u.View.Refresh(); } /// 조준 미리보기 동안만 예약 효과를 장착(사거리 · 사선 · 광역 표시). 반환 = 복원값. 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; } // ---------- 추가 이동 ---------- public bool BonusCanAttack(Unit u) { var s = Of(u); return s != null && s.BonusAttack; } /// 행동 뒤 추가 이동을 (또) 주는가 — 처음 명중 = 추가 이동 n 회(인덕) · 추가 이동 뒤 = 남은 횟수. 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; } // ---------- 적 턴: 교전 반응 · 교전 범위 ---------- /// 이번 적 턴에 교전 반응이 없는가(장판 일갈 공포 · 태평요술 안개). public bool Suppressed(Unit e) { if (e == null) return false; foreach (var t in TimedEffects) { if (!WindowActive(t)) continue; if (t.E.Key == SkillKeys.AoePushFear && t.Targets != null && t.Targets.Contains(e)) return true; if (t.E.Key == SkillKeys.EnemyAggroOffTurn && t.Faction != e.Faction) return true; } return false; } /// 상대 진영 패시브(신산)가 actingFaction 의 교전 범위에 더하는 값. public float AggroAdd(Faction actingFaction) { float add = 0f; foreach (var s in entries) { if (s.Unit == null || !s.Unit.IsAlive || s.Unit.Faction == actingFaction || s.PassiveEffects == null) continue; foreach (var e in s.PassiveEffects) { if (e.Key != SkillKeys.EnemyAggroAdd) continue; add += e.P; NotePassive(s, s.Passive); } } return add; } // ---------- 피해 적용(호위) ---------- /// 피해 적용 — 관평 호위(이번 적 턴 반경 안 같은 편 피해 일부를 관평이 대신) 반영. 반환 = d 가 실제 받은 피해. public int Hit(Unit d, int dmg) { if (d == null || !d.IsAlive || dmg <= 0) return 0; foreach (var t in TimedEffects) { if (t.E.Key != SkillKeys.GuardShare || !WindowActive(t)) continue; var g = t.Owner.Unit; if (g == null || !g.IsAlive || g == d || g.Faction != d.Faction) continue; float r = t.E.HasA2 ? t.E.A2 : 0f; if (Vector2.Distance(g.Position, d.Position) > r + d.Radius) continue; int share = Mathf.Clamp(Mathf.RoundToInt(dmg * Mathf.Clamp01(t.E.P)), 0, dmg); if (share <= 0) continue; g.TakeDamage(share); Fx.Popup(g.Position, share + " 호위", SkillFx.GuardColor); dmg -= share; break; } d.TakeDamage(dmg); return dmg; } /// 스킬 시스템이 없어도 쓰는 피해 적용. public static int Deal(Unit d, int dmg) { if (Current != null) return Current.Hit(d, dmg); if (d == null) return 0; d.TakeDamage(dmg); return dmg; } // ---------- 즉시형 액티브 ---------- /// 즉시 발동(행동 소모 없음): 효과마다 처리 → 쿨. public IEnumerator Instant(UnitSkills s) { var u = s.Unit; Announce(s, s.Active); s.ReadyTurn = battle.TurnNumber + s.Active.Cooldown; foreach (var e in s.ActiveEffects) { switch (e.Key) { case SkillKeys.AoePushFear: yield return PushFear(s, e); break; case SkillKeys.HpCostAtk: PayHp(u, e.HasA2 ? e.A2 : 0f); AddTimed(s, e, false, null); break; case SkillKeys.GuardShare: case SkillKeys.TeamDmgTakenPctTurn: case SkillKeys.DmgTakenAura: case SkillKeys.EnemyAggroOffTurn: AddTimed(s, e, true, null); break; case SkillKeys.Teleport: break; // 목표 지점 탭(TryTeleport) default: AddTimed(s, e, false, null); // 동남풍(sling_dist_pct) 등 = 이번 아군 턴 break; } } yield return Fx.Wait(0.25f); battle.NotifyChanged(); } void AddTimed(UnitSkills s, SkillEffect e, bool enemyWindow, HashSet targets) { TimedEffects.Add(new Timed { Owner = s, Faction = s.Unit.Faction, Def = s.Active, E = e, EnemyWindow = enemyWindow, Targets = targets }); } static 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) { u.View.OnHit(); u.View.Refresh(); } Fx.Popup(u.Position, "HP −" + cost, SkillFx.CostColor); } /// 장판 일갈: 반경(p2) 안 상대 전원을 바깥으로 p1 밀고 이번 적 턴 교전 반응 없음. IEnumerator PushFear(UnitSkills s, SkillEffect e) { var u = s.Unit; float radius = e.HasA2 ? e.A2 : 0f; var targets = new HashSet(); int running = 0; Faction other = u.Faction == Faction.Player ? Faction.Enemy : Faction.Player; foreach (var t in battle.AliveUnits(other)) { if (Vector2.Distance(u.Position, t.Position) > radius + t.Radius) continue; targets.Add(t); Vector2 dir = t.Position - u.Position; dir = dir.sqrMagnitude > 1e-6f ? dir.normalized : Vector2.up; float force = e.P * SkillMods.KnockbackTakenMult(t, true); float slide = Combat.FreeTravel(t, t.Position, dir, force, true, true, u); if (slide <= 0.01f) continue; Vector2 end = battle.Board.ClampInside(t.Position + dir * slide, t.Radius); running++; var unit = t; if (unit.View != null) unit.View.SetFacing(dir); Fx.Run(Track(Fx.MoveAlong(unit.transform, unit.Position, end, battle.Config.moveSpeed, null), () => { unit.Position = end; running--; })); } Fx.Burst(u.Position, radius, SkillFx.FearColor); while (running > 0) yield return null; var t2 = new Timed { Owner = s, Faction = u.Faction, Def = s.Active, E = e, EnemyWindow = true, Targets = targets }; TimedEffects.Add(t2); battle.NotifyChanged(); } static IEnumerator Track(IEnumerator inner, System.Action onDone) { yield return inner; onDone(); } // ---------- 변진(장합): 목표 지점 탭 ---------- 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) return false; var o = c.GetComponent(); if (o != null && o != u && o.IsAlive) return false; } return true; } /// 변진 대기 중 화면 탭 → 처리했으면 true(유효 = 이동 · 아니면 취소). public bool TryTeleportTap(Vector2 w, out string message) { message = null; var u = PendingTeleport; if (u == null) return false; PendingTeleport = null; 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; } public IEnumerator TeleportRoutine(UnitSkills s, Vector2 w) { var u = s.Unit; Announce(s, s.Active); s.ReadyTurn = battle.TurnNumber + s.Active.Cooldown; 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; battle.NotifyChanged(); } // ---------- 연출 · HUD 기록 ---------- /// 액티브 발동 = 유닛 위 스킬 이름 + 플래시 + HUD 한 줄. public void Announce(UnitSkills s, SkillDef def) { if (s == null || def == null) return; if (s.Unit != null) SkillFx.Announce(s.Unit, def.NameKo); AddLog("액티브 · " + (s.Unit != null ? s.Unit.DisplayName : "") + " 「" + def.NameKo + "」"); } /// 패시브 발동 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) { Log.Add(new LogLine { Text = text, Time = Time.unscaledTime }); if (Log.Count > 8) Log.RemoveAt(0); } /// 최근 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) { 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); } } }