711 lines
30 KiB
C#
711 lines
30 KiB
C#
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>
|
||
/// 연무장 모드 전투(PD #1093 C · 탑 · 일일 던전 · 투기장 · 적장 토벌) — 스테이지 표 대신 이 적 편성(배율 반영)으로 시작한다.
|
||
/// 아군 = 출전 편성(LineupProvider) 그대로 · maxTurns 를 넘기면 전투 종료(패배 단계 · 적장 토벌 누적 피해) · 결과 탭 = onExit(월드맵 대신).
|
||
/// </summary>
|
||
public class BattleMode
|
||
{
|
||
public string kind = "";
|
||
public BalanceConfig.StageDef stage;
|
||
/// <summary>0 = 제한 없음 · n = n 턴을 마치면 끝.</summary>
|
||
public int maxTurns;
|
||
/// <summary>결과 화면 탭(연무장 복귀).</summary>
|
||
public Action onExit;
|
||
}
|
||
|
||
/// <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>스킬 테스트 씬(2026-11-09): true = 적 턴에 적이 행동하지 않는다(더미).</summary>
|
||
public bool EnemyHold;
|
||
|
||
/// <summary>이번 전투에 출전한 칸(경험 지급 · 별 「전원 생존」 판정).</summary>
|
||
public IReadOnlyList<LineupEntry> Deployed
|
||
{
|
||
get { return deployed; }
|
||
}
|
||
|
||
public BalanceConfig.StageDef Stage
|
||
{
|
||
get { return Mode != null && Mode.stage != null ? Mode.stage : Config.stages[Mathf.Clamp(StageIndex, 0, Config.stages.Length - 1)]; }
|
||
}
|
||
|
||
/// <summary>지금 전투가 연무장 모드면 그 정의(스테이지 전투 = null · PD #1093 C).</summary>
|
||
public BattleMode Mode { get; private set; }
|
||
|
||
/// <summary>진영 상시 효과 공급자(보물 crit·range·splash → SkillMods · 아웃게임이 연결) · 전투 시작마다 읽어 둔다.</summary>
|
||
public Func<Faction, List<SkillEffect>> SideEffectsProvider;
|
||
readonly List<SkillEffect>[] sideEffects = new List<SkillEffect>[2];
|
||
|
||
public List<SkillEffect> SideEffects(Faction f)
|
||
{
|
||
return sideEffects[(int)f];
|
||
}
|
||
|
||
/// <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 (Sim.Active) return; // 가상 실행(적 AI 예측) 중 = HUD 갱신 없음
|
||
if (Changed != null) Changed();
|
||
}
|
||
|
||
/// <summary>지형 기믹 레이어(PD #1097 · 전투마다 스테이지 배치로 다시 만든다).</summary>
|
||
public GimmickLayer Gimmicks
|
||
{
|
||
get { return GimmickLayer.Current != null && GimmickLayer.Current.Battle == this ? GimmickLayer.Current : null; }
|
||
}
|
||
|
||
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();
|
||
if (Gimmicks != null) Gimmicks.Clear();
|
||
}
|
||
|
||
// ---------- 스테이지 ----------
|
||
|
||
public void StartStage(int index)
|
||
{
|
||
Mode = null;
|
||
StageIndex = Mathf.Clamp(index, 0, Config.stages.Length - 1);
|
||
Begin(Config.stages[StageIndex]);
|
||
}
|
||
|
||
/// <summary>연무장 모드 전투 시작(적 편성 = mode.stage · PD #1093 C).</summary>
|
||
public void StartMode(BattleMode mode)
|
||
{
|
||
if (mode == null || mode.stage == null) return;
|
||
Mode = mode;
|
||
Begin(mode.stage);
|
||
}
|
||
|
||
void Begin(BalanceConfig.StageDef stage)
|
||
{
|
||
StopAllCoroutines();
|
||
ClearUnits();
|
||
Phase = BattlePhase.Setup;
|
||
sideEffects[0] = SideEffectsProvider != null ? SideEffectsProvider(Faction.Player) : null;
|
||
sideEffects[1] = SideEffectsProvider != null ? SideEffectsProvider(Faction.Enemy) : null;
|
||
|
||
// 아군 = 편성 프리셋(자리 0~2 전열 · 3~4 후열 → playerSpawnPoints) · 방어 배율은 Unit.Init 에 없어 생성 뒤 적용
|
||
deployed.Clear();
|
||
SkillCatalog.Invalidate(); // 임포트로 바뀐 스킬 표를 전투마다 다시 읽음
|
||
SkillVfx.Reload(); // 스킬 이펙트 표(skill_vfx.csv)도 전투마다 다시 읽음
|
||
HitFeel.Reload(); // 타격감 표(hit_feel.csv)
|
||
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));
|
||
var hd = SkillCatalog.Hero(h.id);
|
||
u.Camp = hd != null && hd.faction != null ? hd.faction : ""; // 진영별 이펙트(skill_vfx.csv s_camp) · 스킬 진영 대상
|
||
u.Female = hd != null && hd.Female; // 스킬 조건 남성·여성(2026-11-10 #1112)
|
||
u.HeroId = h.id ?? "";
|
||
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, TacticalAI.BossUsesActive(stage, e)); // 적 무장 = 패시브 · 1부 보스 = 액티브도(적 AI 가 예약해 씀 · PD #1098)
|
||
}
|
||
GimmickLayer.Ensure(this).Build(stage); // 지형 기믹 레이어(배경 위 · 유닛 아래 · PD #1097)
|
||
|
||
TurnNumber = 1;
|
||
Skills.BeginBattle();
|
||
LastMessage = stage.title + " — 아군 차례";
|
||
string tip = GimmickTables.Table != null ? GimmickTables.Table.TipFor(stage.chapter, stage.stage) : "";
|
||
if (tip.Length > 0) LastMessage += " · " + tip; // 1장 튜토리얼 문구(tutorial_tips.csv · PD #1098 §3)
|
||
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 (Mode != null && (Phase == BattlePhase.StageClear || Phase == BattlePhase.Defeat))
|
||
{
|
||
var exit = Mode.onExit;
|
||
Mode = null;
|
||
GoIdle();
|
||
if (exit != null) exit();
|
||
return;
|
||
}
|
||
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);
|
||
|
||
// 추가 이동(보병 목표점 명중) → 끝나면 스킬 재행동(이도류·자오곡·백의도강·파로·군령 …) → 행동 끝
|
||
if (u.IsAlive && Skills.NextBonus(u, wasBonus, o)) u.GrantBonusMove();
|
||
else if (u.IsAlive && Skills.TakeReaction(u))
|
||
{
|
||
Skills.ClearBonus(u);
|
||
u.GrantReAction();
|
||
}
|
||
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();
|
||
if (Gimmicks != null)
|
||
{
|
||
yield return Gimmicks.TurnStart(Faction.Enemy); // 군기 · 중독 · 불 · 행동 불가(PD #1097) · 스킬 기절
|
||
if (CheckBattleEnd()) yield break;
|
||
}
|
||
yield return Skills.TurnBegin(Faction.Enemy); // 스킬 라운드(아군 턴 끝 회복·정화 · 시한 효과 · 중독·화상 · 지대 · 2026-11-10 #1112)
|
||
if (CheckBattleEnd()) yield break;
|
||
yield return Fx.Wait(0.4f);
|
||
|
||
float accuracy = Stage.aiAccuracy;
|
||
float aggro = Mathf.Max(0f, Stage.aiAggroRange + Skills.AggroAdd(Faction.Enemy)); // 신산 = 적 교전 범위 −
|
||
int acted = 0;
|
||
// 예측 시뮬레이션 AI(PD #1098): 남은 적 전원 평가 → 서포터 → 돌격 → 사격 · 1기 실행마다 재평가 · 교전 범위 밖 = 대기
|
||
var pending = new List<Unit>();
|
||
foreach (var e in AliveUnits(Faction.Enemy))
|
||
{
|
||
if (Skills.Suppressed(e)) continue; // 장판 일갈·태평요술 = 이번 적 턴 교전 반응 없음
|
||
if (GimmickLayer.Skips(e)) continue; // 함정·강 = 이번 턴 행동 불가
|
||
pending.Add(e);
|
||
}
|
||
if (EnemyHold) pending.Clear(); // 스킬 테스트 씬: 더미는 행동하지 않음(턴 · 쿨 · 시한 효과는 그대로 넘어감)
|
||
bool tactical = TacticalAI.Available;
|
||
while (pending.Count > 0)
|
||
{
|
||
pending.RemoveAll(x => x == null || !x.IsAlive);
|
||
var players = AliveUnits(Faction.Player);
|
||
if (players.Count == 0 || pending.Count == 0) break;
|
||
|
||
Unit e;
|
||
Vector2 target;
|
||
bool useSkill = false;
|
||
if (tactical)
|
||
{
|
||
var plan = TacticalAI.Next(this, pending, accuracy, aggro, Rng);
|
||
if (plan == null) break; // 남은 유닛 = 교전 범위 밖 → 자리 지킴
|
||
e = plan.Unit;
|
||
target = plan.Target;
|
||
useSkill = plan.UseSkill;
|
||
LastAiPlan = plan;
|
||
}
|
||
else
|
||
{
|
||
e = pending[0];
|
||
Vector2? aim = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config); // 가중치 자산이 없으면 기존 조준
|
||
if (!aim.HasValue)
|
||
{
|
||
pending.RemoveAt(0);
|
||
continue;
|
||
}
|
||
target = aim.Value;
|
||
}
|
||
pending.Remove(e);
|
||
|
||
if (UnitActing != null) UnitActing(e);
|
||
yield return Fx.Wait(0.3f); // 카메라가 따라올 시간
|
||
if (useSkill && Skills.Of(e) != null) Skills.Of(e).Reserved = true; // 적 보스 액티브(다음 행동형 · 1부 보스만)
|
||
var o = new ActionOutcome();
|
||
yield return Resolver.Resolve(e, target, false, o);
|
||
yield return ApplyOverwatch(e, o);
|
||
LastMessage = Describe(e, o, false);
|
||
NotifyChanged();
|
||
acted++;
|
||
if (e.IsAlive && Skills.TakeReaction(e)) pending.Add(e); // 적 스킬 재행동
|
||
if (CheckBattleEnd()) yield break;
|
||
yield return Fx.Wait(0.25f);
|
||
}
|
||
if (acted == 0) yield return Fx.Wait(0.3f);
|
||
|
||
if (Mode != null && Mode.maxTurns > 0 && TurnNumber >= Mode.maxTurns) // 연무장 제한 턴(적장 토벌) = 전투 끝
|
||
{
|
||
LastMessage = Mode.maxTurns + "턴 종료";
|
||
SetPhase(BattlePhase.Defeat);
|
||
yield break;
|
||
}
|
||
foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn();
|
||
Skills.OnPlayerTurnStart(); // 이번 적 턴 효과(호위·철벽·간웅·일갈·안개) · 견제 강화 정리
|
||
TurnNumber++;
|
||
if (Gimmicks != null)
|
||
{
|
||
yield return Gimmicks.TurnStart(Faction.Player); // 군기 · 불 지속 · 중독 · 불 · 행동 불가(PD #1097) · 스킬 기절
|
||
if (CheckBattleEnd()) yield break;
|
||
}
|
||
yield return Skills.TurnBegin(Faction.Player); // 스킬 라운드(적 턴 끝 처리 · 시한 효과 · 중독·화상 · 회복 · 지대 · 망령 소멸)
|
||
if (CheckBattleEnd()) yield break;
|
||
LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : "");
|
||
if (AliveCount(Faction.Player) > 0 && AllPlayersActed())
|
||
{
|
||
LastMessage = TurnNumber + "번째 턴 — 아군 전원 행동 불가";
|
||
yield return Fx.Wait(0.4f);
|
||
yield return EnemyTurnRoutine(); // 함정·강으로 아군 전원이 쉬는 턴
|
||
yield break;
|
||
}
|
||
SetPhase(BattlePhase.PlayerTurn);
|
||
}
|
||
|
||
/// <summary>마지막 적 AI 결정(디버그 · 테스트 · 캡처).</summary>
|
||
public AiPlan LastAiPlan;
|
||
|
||
/// <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 = spear.AttackRange; // 옛 견제(대기 유닛 없음 · 스킬 확장 제거 2026-11-10 #1112)
|
||
bool unlimited = false;
|
||
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;
|
||
const int strikes = 1;
|
||
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, spear, HitKind.Attack);
|
||
Fx.Popup(v.Position, dmg + " 견제", new Color(0.6f, 0.85f, 1f));
|
||
NotifyChanged();
|
||
yield return Fx.Wait(0.2f);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 승패 ----------
|
||
|
||
/// <summary>스킬 소환(장로 망령)을 뺀 생존 수 — 승패 판정용.</summary>
|
||
int RealAlive(Faction f)
|
||
{
|
||
int n = 0;
|
||
foreach (var u in units)
|
||
if (u != null && u.IsAlive && u.Faction == f && !SkillSystem.IsSummon(u)) n++;
|
||
return n;
|
||
}
|
||
|
||
/// <summary>유닛 제거(소환 소멸 · 콜라이더 즉시 제거).</summary>
|
||
public void RemoveUnit(Unit u)
|
||
{
|
||
if (u == null) return;
|
||
units.Remove(u);
|
||
u.gameObject.SetActive(false);
|
||
Destroy(u.gameObject);
|
||
}
|
||
|
||
public bool CheckBattleEnd()
|
||
{
|
||
if (RealAlive(Faction.Enemy) == 0)
|
||
{
|
||
LastMessage = "승리 — 전장의 적을 모두 처치했습니다";
|
||
foreach (var p in AliveUnits(Faction.Player))
|
||
{
|
||
if (p.View != null) p.View.PlayVictory();
|
||
}
|
||
SetPhase(BattlePhase.StageClear);
|
||
return true;
|
||
}
|
||
if (RealAlive(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 += " · 추가 이동!";
|
||
return s;
|
||
}
|
||
}
|
||
}
|