ThreeKingdomsSlingshot/Assets/Scripts/Runtime/BattleManager.cs

475 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
public enum BattlePhase
{
Setup,
PlayerTurn,
Resolving,
EnemyTurn,
StageClear,
Defeat
}
/// <summary>출전 1칸(아웃게임 편성 프리셋 · 2026-11-05 #1093). 배율 = 영웅 배율 × 성장 계수(기획서 §3-2) · slot = playerSpawnPoints 자리.</summary>
[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;
}
/// <summary>턴 진행·유닛 관리·승패 판정. 아군은 모든 유닛이 행동하면 자동으로 적군 턴이 된다(버튼 없음).</summary>
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<Unit> units = new List<Unit>();
readonly List<LineupEntry> deployed = new List<LineupEntry>();
public IReadOnlyList<Unit> Units
{
get { return units; }
}
/// <summary>출전 편성 공급자(GameBootstrap → 아웃게임 OutGameRoot 가 편성 프리셋으로 연결). null 이면 아군 없이 시작.</summary>
public Func<List<LineupEntry>> LineupProvider;
/// <summary>이번 전투에 출전한 칸(경험 지급 · 별 「전원 생존」 판정).</summary>
public IReadOnlyList<LineupEntry> Deployed
{
get { return deployed; }
}
public BalanceConfig.StageDef Stage
{
get { return Config.stages[Mathf.Clamp(StageIndex, 0, Config.stages.Length - 1)]; }
}
/// <summary>상태가 바뀔 때(HUD 갱신용).</summary>
public event Action Changed;
/// <summary>단계가 바뀔 때(카메라 연동용).</summary>
public event Action<BattlePhase> PhaseChanged;
/// <summary>어떤 유닛이 행동을 시작할 때(카메라 추적용).</summary>
public event Action<Unit> UnitActing;
/// <summary>스테이지 배치가 끝났을 때.</summary>
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<Unit> AliveUnits(Faction f)
{
var list = new List<Unit>();
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<Unit>();
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];
// 아군 = 편성 프리셋(자리 0~2 전열 · 3~4 후열 → playerSpawnPoints) · 방어 배율은 Unit.Init 에 없어 생성 뒤 적용
deployed.Clear();
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));
deployed.Add(h);
}
}
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);
}
/// <summary>결과 화면 탭 처리기(true = 승리). 있으면 다음 전투로 넘기지 않고 이 처리기(월드맵 복귀)를 부른다.</summary>
public Action<bool> ResultHandler;
/// <summary>전투 없음(월드맵 표시 중): 진행 중 루틴·유닛을 치우고 Setup 단계로.</summary>
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;
}
/// <summary>궁병·책사 공격 가능 범위(사거리 + 접근 허용 거리 · 가장자리 간격 기준) 안에 상대 유닛이 하나라도 있는가 — 도넛 색 표시용.</summary>
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;
}
/// <summary>빈 곳 탭: 남은 행동이 추가 이동뿐이면 건너뛰고 적군 턴으로.</summary>
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, 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.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);
}
/// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary>
public IEnumerator ApplyOverwatch(Unit mover, List<Vector2> 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 + (o.Fired ? " 사격" : moveOnly ? " 추가 이동" : " 이동");
}
else
{
var names = new List<string>();
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<string>();
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<string>();
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;
}
}
}