ThreeKingdomsSlingshot/Assets/Scripts/Runtime/ActionResolver.cs

320 lines
13 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.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>슬링샷 한 번의 결과 요약.</summary>
public class ActionOutcome
{
public bool BonusMove; // 보병: 최종 목표점 명중 → 추가 이동
public bool Returned; // 기병: 최종 목표점 명중 → 제자리 복귀
public bool Overwatch; // 창병: 최종 목표점 명중 → 대기(견제)
public bool Crit; // 궁병: 최종 목표점 명중 → 크리티컬
public bool MovedOnly; // 공격 없이 이동만
public int DamageDealt;
public int CounterTaken;
public List<Unit> Attacked = new List<Unit>();
/// <summary>실제로 지나간 경로(시작 포함). 창병 견제 판정용.</summary>
public List<Vector2> Path = new List<Vector2>();
}
/// <summary>
/// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다.
/// ① 보병: 경로 위 첫 적까지 이동 후 공격 · 목표점 명중 시 추가 이동 1회
/// ② 기병: 경로 위 적을 모두 관통 피해 · 목표점까지 이동 · 목표점 명중 시 제자리 복귀
/// ③ 창병: 보병과 동일 · 목표점 명중 시 대기 → 반경 안에서 움직이는 적에게 피해
/// ④ 궁병: 조준 방향 슬링샷 거리 + 사거리(슬링샷 최대 × archerRangeRatio) 안에 적이 있으면
/// 적 가장자리가 사거리에 들어올 때까지만 이동 후 사격(반격 없음) · 목표점 명중 시 크리티컬 ·
/// 적이 없으면 목표점까지 이동만(PD 2026-11-04 2차 지시 · 1차의 「행동 취소」 폐기)
/// </summary>
public class ActionResolver
{
readonly BalanceConfig cfg;
readonly BattleManager battle;
readonly System.Random rng;
public ActionResolver(BalanceConfig cfg, BattleManager battle, System.Random rng)
{
this.cfg = cfg;
this.battle = battle;
this.rng = rng;
}
public IEnumerator Resolve(Unit u, Vector2 target, bool moveOnly, ActionOutcome o)
{
Vector2 origin = u.Position;
Vector2 delta = target - origin;
float dist = delta.magnitude;
o.Path.Add(origin);
if (dist < 0.001f || !u.IsAlive) yield break;
Vector2 dir = delta / dist;
switch (u.Type)
{
case UnitType.Infantry:
case UnitType.Spear:
yield return MeleeRoutine(u, origin, dir, dist, target, moveOnly, o);
break;
case UnitType.Cavalry:
yield return CavalryRoutine(u, origin, dir, dist, target, moveOnly, o);
break;
case UnitType.Archer:
yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o);
break;
}
}
// ---------- 미리보기(드라이런) ----------
/// <summary>조작 중 표시용: 이 슬링샷이 공격하게 될 적 목록.</summary>
public List<Unit> PredictTargets(Unit u, Vector2 target, bool moveOnly)
{
var result = new List<Unit>();
if (moveOnly) return result;
Vector2 origin = u.Position;
Vector2 delta = target - origin;
float dist = delta.magnitude;
if (dist < 0.001f) return result;
Vector2 dir = delta / dist;
float hitDist;
switch (u.Type)
{
case UnitType.Infantry:
case UnitType.Spear:
{
var first = Combat.FirstEnemyOnPath(u, origin, dir, dist, out hitDist);
if (first != null && Combat.FreeTravel(u, origin, dir, hitDist, false) >= hitDist - 0.05f) result.Add(first);
break;
}
case UnitType.Cavalry:
{
float travel = Combat.FreeTravel(u, origin, dir, dist, false);
var dists = new List<float>();
result.AddRange(Combat.EnemiesOnPath(u, origin, dir, travel, dists));
break;
}
case UnitType.Archer:
{
var first = Combat.FirstEnemyOnPath(u, origin, dir, dist + u.AttackRange, out hitDist);
if (first != null) result.Add(first);
break;
}
}
return result;
}
// ---------- 보병 · 창병 ----------
IEnumerator MeleeRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{
float hitDist = 0f;
Unit first = moveOnly ? null : Combat.FirstEnemyOnPath(u, origin, dir, dist, out hitDist);
if (first == null)
{
float free = Combat.FreeTravel(u, origin, dir, dist, true);
yield return Move(u, origin + dir * free, o);
o.MovedOnly = true;
yield break;
}
float freeToEnemy = Combat.FreeTravel(u, origin, dir, hitDist, false);
if (freeToEnemy < hitDist - 0.05f)
{
// 아군·벽이 먼저 막는다 → 거기까지만 이동
yield return Move(u, origin + dir * freeToEnemy, o);
o.MovedOnly = true;
yield break;
}
yield return Move(u, origin + dir * hitDist, o);
Unit finalHit = Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance);
bool isFinal = finalHit == first;
yield return Attack(u, first, false, false, o);
if (!u.IsAlive) yield break;
if (u.Type == UnitType.Infantry && isFinal) o.BonusMove = true;
if (u.Type == UnitType.Spear && isFinal)
{
u.SetOverwatch(true);
o.Overwatch = true;
}
}
// ---------- 기병 ----------
IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{
float travel = Combat.FreeTravel(u, origin, dir, dist, false);
var dists = new List<float>();
var enemies = moveOnly ? new List<Unit>() : Combat.EnemiesOnPath(u, origin, dir, travel, dists);
Unit finalHit = moveOnly ? null : Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance);
bool willReturn = finalHit != null && enemies.Contains(finalHit);
Vector2 end = origin + dir * travel;
if (!willReturn) end = BackOffFromEnemies(u, origin, dir, travel);
if (enemies.Count == 0)
{
yield return Move(u, end, o);
o.MovedOnly = true;
yield break;
}
if (u.View != null) u.View.PlayAttack();
int idx = 0;
var hit = new List<Unit>();
yield return Fx.MoveAlong(u.transform, origin, end, cfg.moveSpeed, travelled =>
{
while (idx < enemies.Count && travelled >= dists[idx] - 0.01f)
{
PierceHit(u, enemies[idx], dir, o);
hit.Add(enemies[idx]);
idx++;
}
});
u.Position = end;
o.Path.Add(end);
for (; idx < enemies.Count; idx++)
{
if (dists[idx] <= travel + 0.01f)
{
PierceHit(u, enemies[idx], dir, o);
hit.Add(enemies[idx]);
}
}
yield return Fx.Wait(0.15f);
// 살아남은 근접 적의 반격
foreach (var e in hit)
{
if (!u.IsAlive) break;
if (!e.IsAlive || !UnitTypeInfo.IsMelee(e.Type)) continue;
int c = Combat.CounterDamage(cfg, e, u, rng);
u.TakeDamage(c);
o.CounterTaken += c;
Fx.Popup(u.Position, c.ToString(), new Color(1f, 0.45f, 0.45f));
battle.NotifyChanged();
yield return Fx.Wait(0.12f);
}
if (willReturn && u.IsAlive)
{
yield return Fx.Wait(0.1f);
yield return Move(u, origin, o);
o.Returned = true;
}
}
void PierceHit(Unit cav, Unit e, Vector2 dir, ActionOutcome o)
{
if (!e.IsAlive) return;
int dmg = Combat.Damage(cfg, cav, e, false, rng);
e.TakeDamage(dmg);
o.Attacked.Add(e);
o.DamageDealt += dmg;
Fx.Popup(e.Position, dmg.ToString(), Color.white);
// 옆으로 밀어내기
Vector2 side = new Vector2(-dir.y, dir.x);
float sign = Vector2.Dot(e.Position - cav.Position, side) >= 0f ? 1f : -1f;
if (e.IsAlive)
{
Vector2 to = battle.Board.ClampInside(e.Position + side * sign * cfg.cavalryKnockback, e.Radius);
Fx.Run(Fx.MoveAlong(e.transform, e.Position, to, cfg.moveSpeed * 0.6f, null));
}
battle.NotifyChanged();
}
/// <summary>종착점이 적과 겹치면 경로를 되짚어 겹치지 않는 지점까지 물러선다.</summary>
Vector2 BackOffFromEnemies(Unit u, Vector2 origin, Vector2 dir, float travel)
{
float t = travel;
for (int i = 0; i < 40 && t > 0f; i++)
{
Vector2 p = origin + dir * t;
bool overlap = false;
foreach (var e in battle.AliveUnits(u.Faction == Faction.Player ? Faction.Enemy : Faction.Player))
{
if (Vector2.Distance(p, e.Position) < u.Radius + e.Radius - 0.01f)
{
overlap = true;
break;
}
}
if (!overlap) return p;
t -= 0.1f;
}
return origin;
}
// ---------- 궁병 ----------
IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{
// 조준 방향으로 슬링샷 거리 + 사거리 안의 첫 적. hitDist = 그 적 가장자리에 닿기까지의 이동 거리.
float hitDist = 0f;
Unit first = moveOnly ? null : Combat.FirstEnemyOnPath(u, origin, dir, dist + u.AttackRange, out hitDist);
if (first == null)
{
// 적이 없으면 목표점까지 이동(PD 2026-11-04 2차: 「슬링샷 범위 내 적이 없을 경우 해당 위치까지 이동」)
float free = Combat.FreeTravel(u, origin, dir, dist, true);
yield return Move(u, origin + dir * free, o);
o.MovedOnly = true;
yield break;
}
// 적 가장자리까지 남은 간격이 사거리 안에 들어올 때까지만 이동(슬링샷 거리 상한)
float need = Mathf.Clamp(hitDist - u.AttackRange, 0f, dist);
float freeMove = Combat.FreeTravel(u, origin, dir, need, true);
if (freeMove > 0.01f) yield return Move(u, origin + dir * freeMove, o);
if (hitDist - freeMove <= u.AttackRange + 0.05f)
{
bool crit = Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first;
yield return Attack(u, first, true, crit, o);
}
else
{
o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감
}
}
// ---------- 공통 ----------
IEnumerator Move(Unit u, Vector2 to, ActionOutcome o)
{
yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null);
u.Position = to;
o.Path.Add(to);
}
IEnumerator Attack(Unit a, Unit d, bool ranged, bool crit, ActionOutcome o)
{
if (a.View != null) a.View.PlayAttack();
if (ranged) yield return Fx.Arrow(a.Position, d.Position);
else yield return Fx.Lunge(a, d.Position);
int dmg = Combat.Damage(cfg, a, d, crit, rng);
d.TakeDamage(dmg);
o.Attacked.Add(d);
o.DamageDealt += dmg;
o.Crit |= crit;
Fx.Popup(d.Position, crit ? dmg + "!" : dmg.ToString(), crit ? new Color(1f, 0.7f, 0.2f) : Color.white);
battle.NotifyChanged();
yield return Fx.Wait(0.2f);
if (!ranged && d.IsAlive && a.IsAlive)
{
int c = Combat.CounterDamage(cfg, d, a, rng);
if (d.View != null) d.View.PlayAttack();
yield return Fx.Lunge(d, a.Position);
a.TakeDamage(c);
o.CounterTaken += c;
Fx.Popup(a.Position, c.ToString(), new Color(1f, 0.45f, 0.45f));
battle.NotifyChanged();
yield return Fx.Wait(0.2f);
}
}
}
}