using System; using System.Collections; using System.Collections.Generic; using UnityEngine; namespace TKS { public enum BattlePhase { Setup, PlayerTurn, Resolving, EnemyTurn, StageClear, Defeat } /// 출전 1칸(아웃게임 편성 프리셋 · 2026-11-05 #1093). 배율 = 영웅 배율 × 성장 계수(기획서 §3-2) · slot = playerSpawnPoints 자리. [Serializable] public class LineupEntry { public int slot; public string id; public string name; public UnitType type; public string art; public float hpMult = 1f; public float atkMult = 1f; public float defMult = 1f; // 스킬(PD #1093 B · 편성 → SkillRules.FillLineup) — 병사 유닛 = 비어 있음 public string skillActive = ""; public string skillPassive = ""; public int activeLv = 1; public int passiveLv = 1; /// ★5 패시브 배율(evolve.csv skill_unlock passive_x · 그 아래 ★ = 1). public float passiveMult = 1f; /// 무장(영웅) — 참장 대상. public bool general; } /// 턴 진행·유닛 관리·승패 판정. 아군은 모든 유닛이 행동하면 자동으로 적군 턴이 된다(버튼 없음). public class BattleManager : MonoBehaviour { public static BattleManager Instance { get; private set; } public BalanceConfig Config; public Board Board; public BattleHUD Hud; public SlingshotInput Sling; public BattlePhase Phase { get; private set; } public int StageIndex { get; private set; } public int TurnNumber { get; private set; } public System.Random Rng { get; private set; } public ActionResolver Resolver { get; private set; } /// 스킬 상태·시한 효과(PD #1093 B) · 수치 질의 = SkillMods. public SkillSystem Skills { get; private set; } public string LastMessage = ""; readonly List units = new List(); readonly List deployed = new List(); public IReadOnlyList Units { get { return units; } } /// 출전 편성 공급자(GameBootstrap → 아웃게임 OutGameRoot 가 편성 프리셋으로 연결). null 이면 아군 없이 시작. public Func> LineupProvider; /// 이번 전투에 출전한 칸(경험 지급 · 별 「전원 생존」 판정). public IReadOnlyList Deployed { get { return deployed; } } public BalanceConfig.StageDef Stage { get { return Config.stages[Mathf.Clamp(StageIndex, 0, Config.stages.Length - 1)]; } } /// 상태가 바뀔 때(HUD 갱신용). public event Action Changed; /// 단계가 바뀔 때(카메라 연동용). public event Action PhaseChanged; /// 어떤 유닛이 행동을 시작할 때(카메라 추적용). public event Action UnitActing; /// 스테이지 배치가 끝났을 때. public event Action StageStarted; void Awake() { Instance = this; Rng = new System.Random(Environment.TickCount); Fx.Runner = this; Phase = BattlePhase.Setup; Skills = new SkillSystem(this); SkillSystem.Current = Skills; } void OnDestroy() { if (Instance == this) Instance = null; if (Fx.Runner == this) Fx.Runner = null; if (SkillSystem.Current == Skills) SkillSystem.Current = null; } public void Configure(BalanceConfig cfg, Board board) { Config = cfg; Board = board; Resolver = new ActionResolver(cfg, this, Rng); } public void NotifyChanged() { if (Changed != null) Changed(); } void SetPhase(BattlePhase p) { if (Phase == p) return; Phase = p; if (PhaseChanged != null) PhaseChanged(p); NotifyChanged(); } // ---------- 유닛 ---------- public List AliveUnits(Faction f) { var list = new List(); for (int i = 0; i < units.Count; i++) { var u = units[i]; if (u != null && u.IsAlive && u.Faction == f) list.Add(u); } return list; } public int AliveCount(Faction f) { int n = 0; for (int i = 0; i < units.Count; i++) { var u = units[i]; if (u != null && u.IsAlive && u.Faction == f) n++; } return n; } public Unit UnitNear(Vector2 p, Faction? faction) { return Combat.UnitNear(units, p, faction, 0.25f); } public Unit SpawnUnit(UnitType type, Faction faction, Vector2 pos, string name, float hpMult = 1f, float atkMult = 1f, bool boss = false, string art = null) { var go = new GameObject("Unit"); go.transform.position = new Vector3(pos.x, pos.y, 0f); var u = go.AddComponent(); u.Init(Config, type, faction, name, hpMult, atkMult, boss, art); u.Position = Board.ClampInside(pos, u.Radius); units.Add(u); return u; } public void ClearUnits() { for (int i = 0; i < units.Count; i++) { if (units[i] == null) continue; units[i].gameObject.SetActive(false); // 콜라이더를 즉시 물리 월드에서 제거 Destroy(units[i].gameObject); } units.Clear(); if (Skills != null) Skills.Clear(); } // ---------- 스테이지 ---------- public void StartStage(int index) { StopAllCoroutines(); ClearUnits(); Phase = BattlePhase.Setup; StageIndex = Mathf.Clamp(index, 0, Config.stages.Length - 1); var stage = Config.stages[StageIndex]; // 아군 = 편성 프리셋(자리 0~2 전열 · 3~4 후열 → playerSpawnPoints) · 방어 배율은 Unit.Init 에 없어 생성 뒤 적용 deployed.Clear(); SkillCatalog.Invalidate(); // 임포트로 바뀐 스킬 표를 전투마다 다시 읽음 var lineup = LineupProvider != null ? LineupProvider() : null; if (lineup != null) { foreach (var h in lineup) { if (h == null || h.slot < 0 || h.slot >= Config.playerSpawnPoints.Length) continue; var u = SpawnUnit(h.type, Faction.Player, Config.playerSpawnPoints[h.slot], h.name, h.hpMult, h.atkMult, false, h.art); u.Def = Mathf.Max(0, Mathf.RoundToInt(Config.GetStats(h.type).def * h.defMult)); Skills.AttachLineup(u, h); // 액티브·패시브(레벨 · ★5) · 산채 체력은 여기서 최대 HP 반영 deployed.Add(h); } } foreach (var e in stage.enemies) { var u = SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss, e.art); Skills.AttachEnemy(u, e); // 적 무장 = 패시브만(액티브 AI 사용은 2차) } TurnNumber = 1; Skills.BeginBattle(); LastMessage = stage.title + " — 아군 차례"; if (StageStarted != null) StageStarted(); SetPhase(BattlePhase.PlayerTurn); } /// 결과 화면 탭 처리기(true = 승리). 있으면 다음 전투로 넘기지 않고 이 처리기(월드맵 복귀)를 부른다. public Action ResultHandler; /// 전투 없음(월드맵 표시 중): 진행 중 루틴·유닛을 치우고 Setup 단계로. public void GoIdle() { StopAllCoroutines(); ClearUnits(); SetPhase(BattlePhase.Setup); } public void ContinueAfterResult() { if (ResultHandler != null && (Phase == BattlePhase.StageClear || Phase == BattlePhase.Defeat)) { ResultHandler(Phase == BattlePhase.StageClear); return; } if (Phase == BattlePhase.StageClear) { StartStage((StageIndex + 1) % Config.stages.Length); } else if (Phase == BattlePhase.Defeat) { StartStage(StageIndex); } } // ---------- 아군 행동 ---------- public bool CanAct(Unit u) { return Phase == BattlePhase.PlayerTurn && u != null && u.Faction == Faction.Player && u.IsAlive && !u.ActedThisTurn; } /// 궁병·책사 공격 가능 범위(사거리 + 접근 허용 거리 · 가장자리 간격 기준) 안에 상대 유닛이 하나라도 있는가 — 도넛 색 표시용. public bool AnyEnemyWithinArcherReach(Unit u) { if (u == null) return false; float reach = SkillMods.Range(u) + u.ArcherMoveAllow; Faction other = u.Faction == Faction.Player ? Faction.Enemy : Faction.Player; foreach (var e in AliveUnits(other)) { float gap = Vector2.Distance(u.Position, e.Position) - u.Radius * Combat.CastRadiusScale - e.Radius; if (gap <= reach) return true; } return false; } public bool RequestPlayerAction(Unit u, Vector2 target) { if (!CanAct(u)) return false; SetPhase(BattlePhase.Resolving); if (UnitActing != null) UnitActing(u); StartCoroutine(PlayerActionRoutine(u, target)); return true; } IEnumerator PlayerActionRoutine(Unit u, Vector2 target) { bool wasBonus = u.BonusMoveAvailable; bool moveOnly = wasBonus && !Skills.BonusCanAttack(u); // 호치·강동의 호랑이 = 추가 이동에서 공격 가능 var o = new ActionOutcome(); yield return Resolver.Resolve(u, target, moveOnly, o); yield return ApplyOverwatch(u, o); // 추가 이동 = 첫 명중 n 회(인덕 extra_move_count) · 추가 이동 뒤에는 남은 횟수만 if (u.IsAlive && Skills.NextBonus(u, wasBonus, o)) u.GrantBonusMove(); else { u.MarkActed(); Skills.ClearBonus(u); } LastMessage = Describe(u, o, moveOnly); NotifyChanged(); if (CheckBattleEnd()) yield break; if (AllPlayersActed()) { yield return EnemyTurnRoutine(); } else { SetPhase(BattlePhase.PlayerTurn); } } bool AllPlayersActed() { foreach (var u in AliveUnits(Faction.Player)) { if (!u.ActedThisTurn) return false; } return true; } public bool OnlyBonusMovesRemain() { bool any = false; foreach (var u in AliveUnits(Faction.Player)) { if (u.ActedThisTurn) continue; if (!u.BonusMoveAvailable) return false; any = true; } return any; } /// 빈 곳 탭: 남은 행동이 추가 이동뿐이면 건너뛰고 적군 턴으로. public void OnEmptyTap() { if (Phase != BattlePhase.PlayerTurn) return; if (!OnlyBonusMovesRemain()) return; foreach (var u in AliveUnits(Faction.Player)) { if (!u.BonusMoveAvailable) continue; u.MarkActed(); Skills.ClearBonus(u); } SetPhase(BattlePhase.Resolving); StartCoroutine(EnemyTurnRoutine()); } /// 즉시형 액티브 연출(장판 일갈 밀기 · 변진 이동 등) — 아군 턴에만 · 끝나면 다시 아군 턴(행동 소모 없음). public bool RunSkillRoutine(IEnumerator routine) { if (Phase != BattlePhase.PlayerTurn || routine == null) return false; SetPhase(BattlePhase.Resolving); StartCoroutine(SkillRoutine(routine)); return true; } IEnumerator SkillRoutine(IEnumerator routine) { yield return routine; NotifyChanged(); if (CheckBattleEnd()) yield break; SetPhase(BattlePhase.PlayerTurn); } // ---------- 적군 턴 ---------- public Coroutine RunEnemyTurn() { return StartCoroutine(EnemyTurnRoutine()); } IEnumerator EnemyTurnRoutine() { Skills.OnEnemyTurnStart(); // 이번 아군 턴 효과(동남풍·고육계) · 쓰지 않은 예약 정리 SetPhase(BattlePhase.EnemyTurn); LastMessage = "적군 차례"; NotifyChanged(); foreach (var e in AliveUnits(Faction.Enemy)) e.ResetForNewTurn(); yield return Fx.Wait(0.4f); var enemies = AliveUnits(Faction.Enemy); float accuracy = Stage.aiAccuracy; float aggro = Mathf.Max(0f, Stage.aiAggroRange + Skills.AggroAdd(Faction.Enemy)); // 신산 = 적 교전 범위 − int acted = 0; for (int i = 0; i < enemies.Count; i++) { var e = enemies[i]; if (!e.IsAlive) continue; if (Skills.Suppressed(e)) continue; // 장판 일갈·태평요술 = 이번 적 턴 교전 반응 없음 var players = AliveUnits(Faction.Player); if (players.Count == 0) break; Vector2? target = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config); if (!target.HasValue) continue; // 교전 범위 밖 → 자리 지킴 if (UnitActing != null) UnitActing(e); yield return Fx.Wait(0.3f); // 카메라가 따라올 시간 var o = new ActionOutcome(); yield return Resolver.Resolve(e, target.Value, false, o); yield return ApplyOverwatch(e, o); LastMessage = Describe(e, o, false); NotifyChanged(); acted++; if (CheckBattleEnd()) yield break; yield return Fx.Wait(0.25f); } if (acted == 0) yield return Fx.Wait(0.3f); foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn(); Skills.OnPlayerTurnStart(); // 이번 적 턴 효과(호위·철벽·간웅·일갈·안개) · 견제 강화 정리 TurnNumber++; LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : ""); SetPhase(BattlePhase.PlayerTurn); } /// 창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회). public IEnumerator ApplyOverwatch(Unit mover, List path) { var o = new ActionOutcome(); if (path != null) o.Path.AddRange(path); yield return ApplyOverwatch(mover, o); } /// /// 창병 대기 견제(스킬 반영 · PD #1093 B): 반경 = SkillMods.OverwatchRadius(후계·백마의 창·청룡언월) · 지나간 적 1기당 1회(기본) × 견제 횟수(삼대 노신 = n 회 타격) · /// 「견제 무제한」(청룡언월) = 행동 유닛 경로가 반경을 스친 구간마다 + 그 행동으로 밀리거나 넉백된 상대 유닛도 타격 · 피해 × 교변 · 쓰러뜨리면 하북 명장 회복. /// public IEnumerator ApplyOverwatch(Unit mover, ActionOutcome o) { if (mover == null || o == null) yield break; var path = o.Path; Faction other = mover.Faction == Faction.Player ? Faction.Enemy : Faction.Player; foreach (var spear in AliveUnits(other)) { if (spear.Type != UnitType.Spear || !spear.Overwatch) continue; if (!mover.IsAlive && o.Displaced.Count == 0) yield break; float radius = SkillMods.OverwatchRadius(spear); bool unlimited = SkillMods.OverwatchUnlimited(spear); var victims = new List(); int passes = 0; for (int i = 0; path != null && i < path.Count - 1; i++) { Vector2 a = path[i]; Vector2 b = path[i + 1]; if (Vector2.Distance(a, b) < 0.05f) continue; if (Combat.SegmentPointDistance(a, b, spear.Position) > radius + mover.Radius) continue; passes++; if (!unlimited) break; } for (int i = 0; i < passes; i++) victims.Add(mover); if (unlimited) { foreach (var d in o.Displaced) { if (d.Unit == null || d.Unit == mover || d.Unit.Faction == spear.Faction) continue; if (Vector2.Distance(d.From, d.To) < 0.05f) continue; if (Combat.SegmentPointDistance(d.From, d.To, spear.Position) <= radius + d.Unit.Radius) victims.Add(d.Unit); } } if (victims.Count == 0) continue; int strikes = Mathf.Max(1, SkillMods.Count(spear, SkillKeys.OverwatchCount, 1, true)); foreach (var v in victims) { for (int k = 0; k < strikes; k++) { if (!v.IsAlive || !spear.IsAlive) break; int dmg = Combat.OverwatchDamage(Config, spear, v, Rng); if (spear.View != null) spear.View.PlayAttack(); yield return Fx.Lunge(spear, v.Position); dmg = SkillSystem.Deal(v, dmg); Fx.Popup(v.Position, dmg + " 견제", new Color(0.6f, 0.85f, 1f)); if (!v.IsAlive) HealOnOverwatchKill(spear); NotifyChanged(); yield return Fx.Wait(0.2f); } } } } /// 하북 명장: 견제로 적을 쓰러뜨리면 최대 HP × p 회복. void HealOnOverwatchKill(Unit spear) { float pct = SkillMods.Sum(spear, SkillKeys.HealOnOverwatchKill, true); if (pct <= 0f || !spear.IsAlive) return; int heal = Mathf.Max(1, Mathf.RoundToInt(spear.MaxHp * pct)); int before = spear.Hp; spear.Hp = Mathf.Min(spear.MaxHp, spear.Hp + heal); if (spear.View != null) spear.View.Refresh(); if (spear.Hp > before) Fx.Popup(spear.Position, "+" + (spear.Hp - before), SkillFx.HealColor); } // ---------- 승패 ---------- public bool CheckBattleEnd() { if (AliveCount(Faction.Enemy) == 0) { LastMessage = "승리 — 전장의 적을 모두 처치했습니다"; foreach (var p in AliveUnits(Faction.Player)) { if (p.View != null) p.View.PlayVictory(); } SetPhase(BattlePhase.StageClear); return true; } if (AliveCount(Faction.Player) == 0) { LastMessage = "패배 — 아군이 전멸했습니다"; SetPhase(BattlePhase.Defeat); return true; } return false; } // ---------- 테스트·자동 플레이용 ---------- public IEnumerator ResolveForTest(Unit u, Vector2 target, bool moveOnly, ActionOutcome o) { yield return Resolver.Resolve(u, target, moveOnly, o); } public void ForcePhase(BattlePhase p) { SetPhase(p); NotifyChanged(); } /// 테스트·캡처: 턴 번호 지정(스킬 쿨·선봉 1턴 확인). public void SetTurnForTest(int turn) { TurnNumber = Mathf.Max(1, turn); } static string Describe(Unit u, ActionOutcome o, bool moveOnly) { string who = u.DisplayName + "(" + UnitTypeInfo.Korean(u.Type) + ")" + (o.SkillUsed != null ? " 「" + o.SkillUsed.NameKo + "」" : ""); string s; if (o.Attacked.Count == 0) { s = who + (o.Fired ? " 사격" : moveOnly ? " 추가 이동" : " 이동"); } else { var names = new List(); foreach (var a in o.Attacked) { if (!names.Contains(a.DisplayName)) names.Add(a.DisplayName); } s = who + " → " + string.Join(", ", names.ToArray()) + " 공격 " + o.DamageDealt; if (o.Crit) s += " 크리티컬!"; if (o.CounterTaken > 0) s += " · 반격 " + o.CounterTaken; } if (o.Pushed.Count > 0) { var pushed = new List(); foreach (var p in o.Pushed) { if (!pushed.Contains(p.DisplayName)) pushed.Add(p.DisplayName); } s += " · " + string.Join(", ", pushed.ToArray()) + " 밀어냄"; } if (o.FriendlyHit.Count > 0) { var hurt = new List(); foreach (var f in o.FriendlyHit) { if (!hurt.Contains(f.DisplayName)) hurt.Add(f.DisplayName); } s += " · 아군 오사 " + string.Join(", ", hurt.ToArray()) + " " + o.FriendlyDamage; // PD 2026-11-05 #1087 ⑤⑥ } foreach (var n in o.Notes) s += " · " + n; if (o.BonusMove) s += " · 추가 이동!"; if (o.Returned) s += " · 제자리 복귀"; if (o.Overwatch) s += " · 대기(견제) 발동"; return s; } } }