using System; using System.Collections; using System.Collections.Generic; using UnityEngine; namespace TKS { public enum BattlePhase { Setup, PlayerTurn, Resolving, EnemyTurn, StageClear, Defeat } /// 턴 진행·유닛 관리·승패 판정. 아군은 모든 유닛이 행동하면 자동으로 적군 턴이 된다(버튼 없음). 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; } public string LastMessage = ""; readonly List units = new List(); public IReadOnlyList Units { get { return units; } } 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; } void OnDestroy() { if (Instance == this) Instance = null; if (Fx.Runner == this) Fx.Runner = 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(); } // ---------- 스테이지 ---------- public void StartStage(int index) { StopAllCoroutines(); ClearUnits(); Phase = BattlePhase.Setup; StageIndex = Mathf.Clamp(index, 0, Config.stages.Length - 1); var stage = Config.stages[StageIndex]; int heroCount = Mathf.Min(Config.heroes.Length, Config.playerSpawnPoints.Length, 5); for (int i = 0; i < heroCount; i++) { var h = Config.heroes[i]; SpawnUnit(h.type, Faction.Player, Config.playerSpawnPoints[i], h.name, h.hpMult, h.atkMult, false, h.art); } foreach (var e in stage.enemies) { SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss, e.art); } TurnNumber = 1; LastMessage = stage.title + " — 아군 차례"; if (StageStarted != null) StageStarted(); SetPhase(BattlePhase.PlayerTurn); } public void ContinueAfterResult() { 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 = u.AttackRange + 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 moveOnly = u.BonusMoveAvailable; var o = new ActionOutcome(); yield return Resolver.Resolve(u, target, moveOnly, o); yield return ApplyOverwatch(u, o.Path); if (u.IsAlive && o.BonusMove) u.GrantBonusMove(); else u.MarkActed(); 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) u.MarkActed(); } SetPhase(BattlePhase.Resolving); StartCoroutine(EnemyTurnRoutine()); } // ---------- 적군 턴 ---------- public Coroutine RunEnemyTurn() { return StartCoroutine(EnemyTurnRoutine()); } IEnumerator EnemyTurnRoutine() { 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 = Stage.aiAggroRange; int acted = 0; for (int i = 0; i < enemies.Count; i++) { var e = enemies[i]; if (!e.IsAlive) continue; var players = AliveUnits(Faction.Player); if (players.Count == 0) break; Vector2? target = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board); 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.Path); 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(); TurnNumber++; LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : ""); SetPhase(BattlePhase.PlayerTurn); } /// 창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회). public IEnumerator ApplyOverwatch(Unit mover, List path) { if (mover == null || path == null || path.Count < 2) yield break; 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) yield break; bool triggered = false; for (int i = 0; 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) <= spear.AttackRange + mover.Radius) { triggered = true; break; } } if (!triggered) continue; int dmg = Combat.OverwatchDamage(Config, spear, mover, Rng); if (spear.View != null) spear.View.PlayAttack(); yield return Fx.Lunge(spear, mover.Position); mover.TakeDamage(dmg); Fx.Popup(mover.Position, dmg + " 견제", new Color(0.6f, 0.85f, 1f)); NotifyChanged(); yield return Fx.Wait(0.2f); } } // ---------- 승패 ---------- 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(); } static string Describe(Unit u, ActionOutcome o, bool moveOnly) { string who = u.DisplayName + "(" + UnitTypeInfo.Korean(u.Type) + ")"; string s; if (o.Attacked.Count == 0) { s = who + (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()) + " 밀어냄"; } foreach (var n in o.Notes) s += " · " + n; if (o.BonusMove) s += " · 추가 이동!"; if (o.Returned) s += " · 제자리 복귀"; if (o.Overwatch) s += " · 대기(견제) 발동"; return s; } } }