ThreeKingdomsSlingshot/Assets/Scripts/Runtime/BattleManager.cs

573 lines
22 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;
// 스킬(PD #1093 B · 편성 → SkillRules.FillLineup) — 병사 유닛 = 비어 있음
public string skillActive = "";
public string skillPassive = "";
public int activeLv = 1;
public int passiveLv = 1;
/// <summary>★5 패시브 배율(evolve.csv skill_unlock passive_x · 그 아래 ★ = 1).</summary>
public float passiveMult = 1f;
/// <summary>무장(영웅) — 참장 대상.</summary>
public bool general;
}
/// <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; }
/// <summary>스킬 상태·시한 효과(PD #1093 B) · 수치 질의 = SkillMods.</summary>
public SkillSystem Skills { 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;
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<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();
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);
}
/// <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 = 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;
}
/// <summary>빈 곳 탭: 남은 행동이 추가 이동뿐이면 건너뛰고 적군 턴으로.</summary>
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());
}
/// <summary>즉시형 액티브 연출(장판 일갈 밀기 · 변진 이동 등) — 아군 턴에만 · 끝나면 다시 아군 턴(행동 소모 없음).</summary>
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);
}
/// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary>
public IEnumerator ApplyOverwatch(Unit mover, List<Vector2> path)
{
var o = new ActionOutcome();
if (path != null) o.Path.AddRange(path);
yield return ApplyOverwatch(mover, o);
}
/// <summary>
/// 창병 대기 견제(스킬 반영 · PD #1093 B): 반경 = SkillMods.OverwatchRadius(후계·백마의 창·청룡언월) · 지나간 적 1기당 1회(기본) × 견제 횟수(삼대 노신 = n 회 타격) ·
/// 「견제 무제한」(청룡언월) = 행동 유닛 경로가 반경을 스친 구간마다 + 그 행동으로 밀리거나 넉백된 상대 유닛도 타격 · 피해 × 교변 · 쓰러뜨리면 하북 명장 회복.
/// </summary>
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<Unit>();
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);
}
}
}
}
/// <summary>하북 명장: 견제로 적을 쓰러뜨리면 최대 HP × p 회복.</summary>
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();
}
/// <summary>테스트·캡처: 턴 번호 지정(스킬 쿨·선봉 1턴 확인).</summary>
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<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;
}
}
}