ThreeKingdomsSlingshot/Assets/Scripts/Runtime/Skills/SkillSystem.cs

2199 lines
97 KiB
C#
Raw Permalink 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>유닛 1기의 스킬 상태(액티브 쿨·예약 · 패시브 · 행동 뒤에도 남는 값).</summary>
public class UnitSkills
{
public Unit Unit;
public string HeroId = "";
/// <summary>무장(영웅·보스·엘리트 장수) — 남성·여성 「영웅」 조건 판정.</summary>
public bool IsGeneral;
public SkillDef Active;
public int ActiveLv = 1;
public List<SkillEffect> ActiveEffects;
public SkillDef Passive;
public int PassiveLv = 1;
public float PassiveMult = 1f;
public List<SkillEffect> PassiveEffects;
// ---------- 전투 중 바뀌는 값(가상 실행이 떠 두고 되돌림 · CopyState) ----------
/// <summary>액티브를 쓸 수 있는 첫 턴(전투 시작 = 1 · 사용 턴 + 쿨).</summary>
public int ReadyTurn = 1;
/// <summary>next_action 예약(다음 슬링샷 1회) · 행동 전 다시 탭 = 취소.</summary>
public bool Reserved;
/// <summary>예약한 효과가 지금 걸려 있다(그 유닛의 행동 처리·조준 미리보기 동안만).</summary>
public bool Armed;
/// <summary>즉시형 액티브를 시전하는 중(그 효과·조건 armed 를 질의에 보임).</summary>
public bool Casting;
/// <summary>보병 목표점 명중 추가 이동(유지 · PD 확인 대상).</summary>
public int BonusMovesLeft;
public bool BonusAttack;
/// <summary>남은 재행동 · 재행동의 슬링 상한(감녕·고순 남은 거리 · 0 = 없음) · 이번 턴 재행동 수(여몽 패시브).</summary>
public int ReactCredits;
public float ReactLimit;
public int ReactsThisTurn;
/// <summary>여몽 백의도강 연쇄(남은 최대 · 피해·처치당 재행동).</summary>
public int ChainLeft;
public int ChainHitN;
public int ChainKillN;
public int Kills;
public bool KillReactUsed;
public int RevivesUsed;
public int NegateUsed;
public int AttackedStunCd;
/// <summary>조인 수성: 남은 이동 비율(0~1 · 자기 턴 시작 = 1).</summary>
public float UnusedMove = 1f;
/// <summary>우금 진중: 쌓인 추가 이동(배) · 이번 라운드에 행동했나.</summary>
public int Bank;
public bool MovedThisRound;
/// <summary>황개 고육계: 창 동안 받은 피해 → 다음 턴 공격력(가산).</summary>
public float DmgTakenRound;
public float AtkFlat;
public int AtkFlatRounds;
/// <summary>노숙: 쓰러진 자리 깃발(남은 턴 · 위치).</summary>
public int BannerLeft;
public Vector2 BannerPos;
/// <summary>장비 만인지적: 행동 번호 · 대상별 (마지막 행동 번호, 연속 수).</summary>
public int ActionSeq;
public Dictionary<Unit, Vector2Int> Streak = new Dictionary<Unit, Vector2Int>();
public bool HasActive
{
get { return Active != null && ActiveEffects != null; }
}
public int CooldownLeft(int turn)
{
return Mathf.Max(0, ReadyTurn - turn);
}
public UnitSkills CopyState()
{
var c = (UnitSkills)MemberwiseClone();
c.Streak = new Dictionary<Unit, Vector2Int>(Streak);
return c;
}
public void RestoreState(UnitSkills c)
{
ReadyTurn = c.ReadyTurn; Reserved = c.Reserved; Armed = c.Armed; Casting = c.Casting;
BonusMovesLeft = c.BonusMovesLeft; BonusAttack = c.BonusAttack;
ReactCredits = c.ReactCredits; ReactLimit = c.ReactLimit; ReactsThisTurn = c.ReactsThisTurn;
ChainLeft = c.ChainLeft; ChainHitN = c.ChainHitN; ChainKillN = c.ChainKillN;
Kills = c.Kills; KillReactUsed = c.KillReactUsed; RevivesUsed = c.RevivesUsed; NegateUsed = c.NegateUsed; AttackedStunCd = c.AttackedStunCd;
UnusedMove = c.UnusedMove; Bank = c.Bank; MovedThisRound = c.MovedThisRound;
DmgTakenRound = c.DmgTakenRound; AtkFlat = c.AtkFlat; AtkFlatRounds = c.AtkFlatRounds;
BannerLeft = c.BannerLeft; BannerPos = c.BannerPos; ActionSeq = c.ActionSeq;
Streak = new Dictionary<Unit, Vector2Int>(c.Streak);
}
public void ResetBattle()
{
ReadyTurn = 1;
Reserved = Armed = Casting = BonusAttack = KillReactUsed = MovedThisRound = false;
BonusMovesLeft = ReactCredits = ReactsThisTurn = ChainLeft = Kills = RevivesUsed = NegateUsed = AttackedStunCd = Bank = 0;
ReactLimit = DmgTakenRound = AtkFlat = 0f;
AtkFlatRounds = BannerLeft = ActionSeq = 0;
UnusedMove = 1f;
Streak.Clear();
}
}
/// <summary>스킬 효과 1건이 어느 유닛·스킬에서 왔는가(SkillMods 질의 결과).</summary>
public struct SkillHit
{
public UnitSkills Owner;
public SkillDef Def;
public SkillEffect E;
public bool Passive;
public SkillSystem.Timed Timed;
}
/// <summary>피해 종류 — 흡혈·명중 상태 이상·반격·호위가 어떤 피해에 붙는지.</summary>
public enum HitKind
{
Attack,
Counter,
Skill,
Dot,
Gimmick,
Indirect,
Guard,
Self
}
/// <summary>행동 1회의 스킬 맥락(돌진 거리 비율 · 맞힌 수 · 처치 · 지형).</summary>
public class ActionCtx
{
public Unit Actor;
public Vector2 Origin;
public float SlingRatio;
public int HitCount;
public bool FirstHitDone;
public int Kills;
public bool DamagedEnemy;
public bool HitTerrain;
public int Seq;
public readonly HashSet<Unit> MalesHit = new HashSet<Unit>();
public readonly List<Unit> ZombieFrom = new List<Unit>();
}
/// <summary>스킬이 만든 지대(주유 불꽃 · 육손 불 장벽 · 장로 독지대) — 원 또는 선분 두께. 닿는 상대 = 즉시 피해 + 상태 이상(행동마다 1번 · 상대 턴 시작에 안에 있으면 1번).</summary>
public class SkillZone
{
public UnitSkills Owner;
public Faction Faction;
public Vector2 A;
public Vector2 B;
public float Radius;
public int RoundsLeft;
public float Ratio;
public string StatusKey;
public SkillEffect Status;
public GameObject View;
/// <summary>같은 편도 닿으면 피해·상태 이상(육손 불 장벽 · PD 2026-11-10 #1112 결정 ⑬).</summary>
public bool HitsAllies;
public readonly HashSet<Unit> Touched = new HashSet<Unit>();
public bool Affects(Unit u)
{
return u != null && (u.Faction != Faction || HitsAllies);
}
public float Distance(Vector2 p)
{
return Combat.SegmentPointDistance(A, B, p);
}
}
/// <summary>
/// 전투 1판의 스킬 처리(2026-11-10 #1112 · PD 엑셀 새 기획 · 효과 표 skill_effects.csv): 유닛별 스킬 상태 · 시한 효과(라운드) · 상태 이상 · 지대 ·
/// 행동 전후 훅(예약 장착·쿨·재행동·출혈·중독) · 피해 적용(무적·무효화·호위·부활·흡혈·명중 상태 이상·처치 보상) · 회복 · 즉시형 액티브 · 턴 훅 · HUD 한 줄. 수치 질의 = SkillMods.
/// </summary>
public class SkillSystem
{
public static SkillSystem Current;
/// <summary>테스트·자동 플레이: 행동 중 방향 탭(조조·하후돈)을 기다리지 않고 기본값(슬링 방향 · 제자리)으로.</summary>
public static bool AutoTap;
public class Timed
{
public UnitSkills Owner;
public Faction Faction;
public SkillDef Def;
public SkillEffect E;
/// <summary>대상 고정(range = 시전 순간 범위 안 같은 편 · 버프 받은 아군) · null = 대상 칸 규칙대로.</summary>
public HashSet<Unit> Targets;
/// <summary>남은 라운드(주인 진영 턴 시작마다 1 감소 · 0 = 끝).</summary>
public int Rounds = 1;
}
public struct LogLine
{
public string Text;
public float Time;
}
readonly BattleManager battle;
readonly Dictionary<Unit, UnitSkills> map = new Dictionary<Unit, UnitSkills>();
readonly List<UnitSkills> entries = new List<UnitSkills>();
public readonly List<Timed> TimedEffects = new List<Timed>();
public readonly List<LogLine> Log = new List<LogLine>();
readonly HashSet<string> noted = new HashSet<string>();
readonly int[] startCount = new int[2];
readonly Dictionary<Unit, UnitStatus> statuses = new Dictionary<Unit, UnitStatus>();
public readonly List<SkillZone> Zones = new List<SkillZone>();
static readonly HashSet<Unit> summons = new HashSet<Unit>();
/// <summary>지금 행동 중인 유닛의 맥락(BeginAction ~ EndAction).</summary>
public ActionCtx Ctx { get; private set; }
// ---------- 행동 중 탭(조조 밀칠 방향 · 하후돈 복귀 지점) · 장합 변진 목표 ----------
public enum TapKind
{
None,
Teleport,
Aim,
Return
}
public TapKind PendingKind { get; private set; }
public Unit PendingUnit { get; private set; }
public Vector2 PendingCenter { get; private set; }
public float PendingRadius { get; private set; }
Vector2? tapResult;
/// <summary>장합 변진: 목표 지점 탭 대기 중인 유닛.</summary>
public Unit PendingTeleport
{
get { return PendingKind == TapKind.Teleport ? PendingUnit : null; }
}
// 스킬 이펙트(SkillVfx · 2026-11-09): 지금 행동 중인 유닛 · 이펙트 표 키 · 돌진에 붙은 이펙트 · 이번 행동에 hit 이펙트를 띄운 대상
Unit vfxActor;
readonly List<string> vfxKeys = new List<string>();
readonly List<SkillVfx.Handle> travelFx = new List<SkillVfx.Handle>();
readonly HashSet<Unit> vfxHit = new HashSet<Unit>();
void VfxBegin(Unit u, string skillKey, string basicKey, Vector2 dir, bool travel)
{
VfxEnd();
vfxActor = u;
if (!string.IsNullOrEmpty(skillKey)) vfxKeys.Add(skillKey);
if (!string.IsNullOrEmpty(basicKey)) vfxKeys.Add(basicKey);
if (!travel) return;
if (!string.IsNullOrEmpty(basicKey) && !string.IsNullOrEmpty(SkillVfx.TestTravelKey)) vfxKeys.Add(SkillVfx.TestTravelKey);
foreach (var k in vfxKeys) travelFx.Add(SkillVfx.Play(u, k, SkillVfx.EventTravel, dir));
}
/// <summary>슬링샷 직진(돌진) 구간 끝 — 돌진 이펙트(travel)만 멈춤.</summary>
public void DashEnd(Unit u)
{
if (u == null || vfxActor != u) return;
foreach (var h in travelFx) SkillVfx.Stop(h);
travelFx.Clear();
}
/// <summary>같은 행동 안의 새 이동 구간(복귀 · 튕겨 다시 돌진) — 그 방향으로 돌진 이펙트를 다시 재생.</summary>
public void DashBegin(Unit u, Vector2 dir)
{
if (u == null || vfxActor != u || dir.sqrMagnitude < 1e-6f) return;
DashEnd(u);
foreach (var k in vfxKeys) travelFx.Add(SkillVfx.Play(u, k, SkillVfx.EventTravel, dir.normalized));
}
void VfxEnd()
{
foreach (var h in travelFx) SkillVfx.Stop(h);
travelFx.Clear();
vfxActor = null;
vfxKeys.Clear();
vfxHit.Clear();
}
public SkillSystem(BattleManager b)
{
battle = b;
}
public BattleManager Battle
{
get { return battle; }
}
public IReadOnlyList<UnitSkills> Entries
{
get { return entries; }
}
BalanceConfig Cfg
{
get { return battle.Config; }
}
// ---------- 부착 ----------
public UnitSkills Of(Unit u)
{
if (u == null) return null;
UnitSkills s;
return map.TryGetValue(u, out s) ? s : null;
}
UnitSkills Ensure(Unit u)
{
var s = Of(u);
if (s != null) return s;
s = new UnitSkills { Unit = u };
map[u] = s;
entries.Add(s);
return s;
}
/// <summary>스킬 부착(액티브·패시브 id · 레벨 · ★5 패시브 배율 · 무장 여부). 자기 최대 HP 패시브(hp_max_pct 대상 = 자신)는 바로 반영.</summary>
public UnitSkills Attach(Unit u, string activeId, int activeLv, string passiveId, int passiveLv, float passiveMult, bool general)
{
if (u == null) return null;
var s = Ensure(u);
s.IsGeneral = general;
s.Active = SkillCatalog.Get(activeId);
s.ActiveLv = Mathf.Max(1, activeLv);
s.ActiveEffects = s.Active != null ? s.Active.ScaledEffects(s.ActiveLv, 1f) : null;
s.Passive = SkillCatalog.Get(passiveId);
s.PassiveLv = Mathf.Max(1, passiveLv);
s.PassiveMult = passiveMult > 0f ? passiveMult : 1f;
s.PassiveEffects = s.Passive != null ? s.Passive.ScaledEffects(s.PassiveLv, s.PassiveMult) : null;
if (s.Passive != null) s.HeroId = s.Passive.HeroId;
else if (s.Active != null) s.HeroId = s.Active.HeroId;
float hp = 0f;
if (s.PassiveEffects != null)
foreach (var e in s.PassiveEffects)
if (e.Key == SkillKeys.HpMaxPct && e.Scope == SkillScope.Self) hp += e.P; // 같은 편 대상(진영)은 BeginBattle 에서
ScaleMaxHp(u, hp);
return s;
}
static void ScaleMaxHp(Unit u, float pct)
{
if (Mathf.Abs(pct) <= 1e-4f) return;
u.MaxHp = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * (1f + pct)));
u.Hp = u.MaxHp;
if (u.View != null) u.View.Refresh();
}
/// <summary>액티브 쿨 = 표 cooldown · 최소 1.</summary>
public int CooldownOf(UnitSkills s)
{
if (s == null || s.Active == null) return 1;
return Mathf.Max(1, s.Active.Cooldown);
}
/// <summary>heroes.csv 영웅 id 로 부착(테스트·도구용) · 진영·성별도 유닛에 기록.</summary>
public UnitSkills AttachHero(Unit u, string heroId, int activeLv = 1, int passiveLv = 1, float passiveMult = 1f, bool general = true)
{
var h = SkillCatalog.Hero(heroId);
if (h == null) return null;
if (u != null)
{
u.Camp = h.faction ?? "";
u.Female = h.Female;
u.HeroId = h.id;
}
return Attach(u, h.skillActive, activeLv, h.skillPassive, passiveLv, passiveMult, general && !h.isSoldier);
}
/// <summary>출전 칸(편성 → 스킬 id·레벨·★ 배율이 채워져 있음).</summary>
public UnitSkills AttachLineup(Unit u, LineupEntry e)
{
if (e == null) return null;
if (string.IsNullOrEmpty(e.skillActive) && string.IsNullOrEmpty(e.skillPassive))
{
if (e.general) Ensure(u).IsGeneral = true;
return Of(u);
}
return Attach(u, e.skillActive, e.activeLv, e.skillPassive, e.passiveLv, e.passiveMult, e.general);
}
/// <summary>적 무장(보스·엘리트) = heroes.csv 같은 아트·이름 영웅의 패시브(Lv1) · withActive = 액티브도(적 AI · 1부 보스만 · PD #1098).</summary>
public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e, bool withActive = false)
{
if (u == null || e == null || !(e.isBoss || e.isGeneral)) return null;
var h = SkillCatalog.HeroForEnemy(e.art, e.name);
if (h == null)
{
var s = Ensure(u);
s.IsGeneral = true;
return s;
}
u.Camp = h.faction ?? "";
u.Female = h.Female;
u.HeroId = h.id;
return Attach(u, withActive ? h.skillActive : null, 1, h.skillPassive, 1, 1f, true);
}
public void MarkGeneral(Unit u)
{
if (u != null) Ensure(u).IsGeneral = true;
}
public bool IsGeneral(Unit u)
{
if (u == null) return false;
if (u.IsBoss) return true;
var s = Of(u);
return s != null && s.IsGeneral;
}
public static bool IsSummon(Unit u)
{
return u != null && summons.Contains(u);
}
public void Clear()
{
map.Clear();
entries.Clear();
TimedEffects.Clear();
noted.Clear();
ClearTap();
startCount[0] = startCount[1] = 0;
ClearStatuses();
ClearZones();
summons.Clear();
Ctx = null;
VfxEnd();
}
void ClearStatuses()
{
foreach (var kv in statuses)
if (kv.Value.Badge != null) Object.Destroy(kv.Value.Badge.gameObject);
statuses.Clear();
}
void ClearZones()
{
foreach (var z in Zones)
if (z.View != null) Object.Destroy(z.View);
Zones.Clear();
}
/// <summary>전투 시작(배치 뒤): 출전 인원 기록 · 쿨 = 바로 사용 가능 · 시한 효과·상태 이상 비움 · 같은 편 최대 HP 패시브(진영 대상).</summary>
public void BeginBattle()
{
Prune();
TimedEffects.Clear();
noted.Clear();
ClearTap();
ClearStatuses();
ClearZones();
Ctx = null;
startCount[0] = battle.AliveCount(Faction.Player);
startCount[1] = battle.AliveCount(Faction.Enemy);
foreach (var u in battle.Units)
{
if (u == null || !u.IsAlive) continue;
float team = 0f;
foreach (var h in SkillMods.Get(u, SkillKeys.HpMaxPct))
if (h.E.Scope != SkillScope.Self) team += h.E.P;
ScaleMaxHp(u, team);
}
foreach (var s in entries) s.ResetBattle();
}
public int StartCount(Faction f)
{
return startCount[(int)f];
}
void Prune()
{
for (int i = entries.Count - 1; i >= 0; i--)
{
if (entries[i].Unit != null) continue;
entries.RemoveAt(i);
}
var dead = new List<Unit>();
foreach (var kv in map)
if (kv.Key == null) dead.Add(kv.Key);
foreach (var k in dead) map.Remove(k);
}
// ---------- 가상 실행(적 AI 예측)용 상태 사본 ----------
public class State
{
public readonly List<KeyValuePair<UnitSkills, UnitSkills>> Entries = new List<KeyValuePair<UnitSkills, UnitSkills>>();
public List<Timed> Timed;
public readonly Dictionary<Unit, UnitStatus> Statuses = new Dictionary<Unit, UnitStatus>();
public List<SkillZone> Zones;
public int LogCount;
}
public State TakeState()
{
var st = new State { Timed = new List<Timed>(TimedEffects), Zones = new List<SkillZone>(Zones), LogCount = Log.Count };
foreach (var e in entries) st.Entries.Add(new KeyValuePair<UnitSkills, UnitSkills>(e, e.CopyState()));
foreach (var kv in statuses) st.Statuses[kv.Key] = kv.Value.Copy();
return st;
}
public void RestoreState(State st)
{
if (st == null) return;
foreach (var kv in st.Entries) kv.Key.RestoreState(kv.Value);
TimedEffects.Clear();
TimedEffects.AddRange(st.Timed);
foreach (var z in Zones)
if (!st.Zones.Contains(z) && z.View != null) Object.Destroy(z.View);
Zones.Clear();
Zones.AddRange(st.Zones);
foreach (var kv in statuses)
{
UnitStatus old;
if (st.Statuses.TryGetValue(kv.Key, out old)) continue;
kv.Value.Clear(); // 가상 실행 중 새로 생긴 상태 = 비움(배지는 그대로 둠)
}
foreach (var kv in st.Statuses)
{
UnitStatus cur;
if (!statuses.TryGetValue(kv.Key, out cur)) statuses[kv.Key] = kv.Value.Copy();
else
{
var badge = cur.Badge;
var copy = kv.Value.Copy();
copy.Badge = badge;
statuses[kv.Key] = copy;
}
}
if (Log.Count > st.LogCount) Log.RemoveRange(st.LogCount, Log.Count - st.LogCount);
Ctx = null;
}
// ---------- 턴 ----------
/// <summary>적 턴 시작(동기): 쓰지 않은 예약·추가 이동 정리.</summary>
public void OnEnemyTurnStart()
{
ClearTap();
foreach (var s in entries)
{
s.Reserved = false;
s.Armed = false;
s.Casting = false;
s.BonusMovesLeft = 0;
s.BonusAttack = false;
}
}
/// <summary>아군 턴 시작(동기) — 남은 것 없음(라운드 처리는 TurnBegin).</summary>
public void OnPlayerTurnStart()
{
}
/// <summary>
/// side 진영 턴 시작(코루틴 · BattleManager 가 기믹 턴 시작 뒤에 부름): 상대 진영 턴 끝 처리(턴 종료 회복·정화 · 혼란·출혈 1 감소) →
/// side 주인의 시한 효과 1 감소(고육계 이월) · 턴마다 초기화 · 깃발·소환 정리 → side 유닛 중독·화상 → 재생·지속 회복·턴 시작 회복 → side 지대 1 감소 · 상대 지대 안 유닛.
/// </summary>
public IEnumerator TurnBegin(Faction side)
{
bool any = false;
Faction other = Other(side);
any |= TurnEnd(other);
// 고육계 이월 공격력은 자기 턴 1번 — 다음 자기 턴 시작에 사라짐(새 이월은 아래 시한 효과 만료에서)
foreach (var s in entries)
{
if (s.Unit == null || s.Unit.Faction != side || s.AtkFlatRounds <= 0) continue;
s.AtkFlat = 0f;
s.AtkFlatRounds = 0;
}
// 지속 회복(방통 봉추의 계) = 라운드 감소·만료 전에 — dur n 이면 n 번 회복(Fable 검토 B1)
foreach (var t in TimedEffects)
{
if (t.E.Key != SkillKeys.Hot || t.Faction != side || t.Owner == null || t.Owner.Unit == null) continue;
int amt = Mathf.RoundToInt(AtkOf(t.Owner.Unit) * t.E.P);
foreach (var u in battle.AliveUnits(side))
if (t.Targets == null ? u == t.Owner.Unit : t.Targets.Contains(u))
if (Heal(u, amt, t.Owner.Unit) > 0) any = true;
}
for (int i = TimedEffects.Count - 1; i >= 0; i--)
{
var t = TimedEffects[i];
if (t.Faction != side) continue;
if (--t.Rounds > 0) continue;
TimedEffects.RemoveAt(i);
if (t.E.Key == SkillKeys.DamageToAtk && t.Owner != null && t.Owner.DmgTakenRound > 0f)
{
t.Owner.AtkFlat = t.Owner.DmgTakenRound; // 고육계: 받은 피해 → 이번 턴 공격력
t.Owner.AtkFlatRounds = 1;
t.Owner.DmgTakenRound = 0f;
if (t.Owner.Unit != null) Text(t.Owner.Unit, "공격 +" + Mathf.RoundToInt(t.Owner.AtkFlat), SkillFx.NameColor);
any = true;
}
}
var gone = new List<Unit>();
foreach (var s in entries)
{
var u = s.Unit;
if (u == null || u.Faction != side) continue;
s.KillReactUsed = false;
s.ReactsThisTurn = 0;
s.ReactCredits = 0;
s.ReactLimit = 0f;
s.ChainLeft = 0;
if (s.AttackedStunCd > 0) s.AttackedStunCd--;
if (s.BannerLeft > 0 && !u.IsAlive && --s.BannerLeft == 0) Text(u, "깃발 소멸", SkillFx.CostColor);
if (u.IsAlive)
{
s.UnusedMove = 1f;
var bank = Eff(s, SkillKeys.MoveBank);
if (bank != null && !s.MovedThisRound) s.Bank = Mathf.Min(Mathf.Max(0, Mathf.RoundToInt(bank.P) - 1), s.Bank + 1);
s.MovedThisRound = false;
}
}
foreach (var z in summons)
if (z == null || z.Faction == side) gone.Add(z); // 망령(스킬 항목 없음)은 소환 목록에서 직접
foreach (var z in gone)
{
if (z != null && z.IsAlive) yield return ZombieAct(z); // 망령 = 자기 턴 시작에 1번 공격 후 소멸(PD #1112 결정 ⑩)
summons.Remove(z);
if (z != null) battle.RemoveUnit(z);
any = true;
}
// 상태 이상(중독 · 화상) — side 유닛
foreach (var u in battle.AliveUnits(side))
{
var st = StatusOf(u);
if (st == null) continue;
if (st.PoisonTurns > 0)
{
PoisonTick(u, st);
st.PoisonTurns--;
if (st.PoisonTurns <= 0) st.PoisonStacks = 0;
any = true;
}
if (u.IsAlive && st.BurnTurns > 0)
{
int d = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * st.BurnPct));
d = Hit(u, d, null, HitKind.Dot);
Text(u, "화상 −" + d, new Color(1f, 0.5f, 0.2f));
st.BurnTurns--;
any = true;
}
RefreshBadge(u);
}
// 회복: 재생(허저) · 지속 회복(방통) · 턴 시작 범위 회복(노숙 · 깃발)
foreach (var u in battle.AliveUnits(side))
{
float regen = SkillMods.Sum(u, SkillKeys.RegenPct, true);
if (regen > 0f && Heal(u, Mathf.RoundToInt(u.MaxHp * regen), u) > 0) any = true;
}
foreach (var s in entries)
{
var src = s.Unit;
if (src == null || src.Faction != side || s.PassiveEffects == null) continue;
if (!src.IsAlive && s.BannerLeft <= 0) continue;
var e = Find(s.PassiveEffects, SkillKeys.TurnStartHealRange);
if (e == null) continue;
Vector2 c = src.IsAlive ? src.Position : s.BannerPos;
foreach (var u in battle.AliveUnits(side))
if (Vector2.Distance(c, u.Position) <= SkillMods.BaseReach(src) + u.Radius)
if (Heal(u, Mathf.RoundToInt(u.MaxHp * e.P), src) > 0) any = true;
NotePassive(s, s.Passive);
}
// 지대: side 주인 지대 1 감소 · 상대(side) 유닛이 상대 지대 안에 있으면 닿음 1번
for (int i = Zones.Count - 1; i >= 0; i--)
{
var z = Zones[i];
if (z.Faction == side && --z.RoundsLeft <= 0)
{
if (z.View != null) Object.Destroy(z.View);
Zones.RemoveAt(i);
continue;
}
if (z.Faction == side && !z.HitsAllies) continue;
z.Touched.Clear();
foreach (var u in battle.AliveUnits(side))
if (z.Distance(u.Position) <= z.Radius + u.Radius * 0.5f)
{
ZoneTouch(z, u);
any = true;
}
}
if (any)
{
battle.NotifyChanged();
yield return Fx.Wait(0.35f);
}
}
bool HasTimed(UnitSkills s, string key)
{
foreach (var t in TimedEffects)
if (t.Owner == s && t.E.Key == key) return true;
return false;
}
/// <summary>side 진영 턴 끝(다음 진영 턴 시작에 부름): 턴 종료 회복·정화(소교·제갈량·방통) · 그 진영 유닛의 혼란·출혈 1 감소.</summary>
bool TurnEnd(Faction side)
{
bool any = false;
foreach (var t in TimedEffects)
{
if (t.E.Key != SkillKeys.TurnEndHeal || t.Faction != side || t.Owner == null || t.Owner.Unit == null || !t.Owner.Unit.IsAlive) continue;
var src = t.Owner.Unit;
int amt = Mathf.RoundToInt(AtkOf(src) * t.E.Get(1));
foreach (var u in battle.AliveUnits(side))
if (Vector2.Distance(src.Position, u.Position) <= t.E.P + u.Radius && Heal(u, amt, src) > 0) any = true;
}
foreach (var s in entries)
{
var src = s.Unit;
if (src == null || !src.IsAlive || src.Faction != side || s.PassiveEffects == null) continue;
var lowest = Find(s.PassiveEffects, SkillKeys.TurnEndHealLowest);
if (lowest != null)
{
Unit pick = null;
foreach (var u in battle.AliveUnits(side))
{
if (Vector2.Distance(src.Position, u.Position) > lowest.P + u.Radius || u.Hp >= u.MaxHp) continue;
if (pick == null || (float)u.Hp / u.MaxHp < (float)pick.Hp / pick.MaxHp) pick = u;
}
if (pick != null && Heal(pick, Mathf.RoundToInt(AtkOf(src) * lowest.Get(1)), src) > 0)
{
NotePassive(s, s.Passive);
any = true;
}
}
var cl = Find(s.PassiveEffects, SkillKeys.TurnEndCleanse);
if (cl != null)
{
int n = Mathf.Max(1, Mathf.RoundToInt(cl.Get(1, 1f)));
foreach (var u in battle.AliveUnits(side))
{
if (n <= 0) break;
if (Vector2.Distance(src.Position, u.Position) > cl.P + u.Radius || !SkillStatus.HasAny(u)) continue;
Cleanse(u);
n--;
NotePassive(s, s.Passive);
any = true;
}
}
}
foreach (var kv in statuses)
{
var u = kv.Key;
if (u == null || u.Faction != side) continue;
var st = kv.Value;
if (st.ConfuseTurns > 0) st.ConfuseTurns--;
if (st.BleedTurns > 0) st.BleedTurns--;
if (st.ConfuseTurns <= 0) st.ConfuseMult = 1f;
RefreshBadge(u);
}
return any;
}
static Faction Other(Faction f)
{
return f == Faction.Player ? Faction.Enemy : Faction.Player;
}
static SkillEffect Find(List<SkillEffect> l, string key)
{
if (l == null) return null;
foreach (var e in l)
if (e.Key == key) return e;
return null;
}
/// <summary>그 유닛 스킬의 효과(패시브 · 지금 장착/시전 중인 액티브) 첫 줄.</summary>
static SkillEffect Eff(UnitSkills s, string key)
{
if (s == null) return null;
var e = Find(s.PassiveEffects, key);
if (e != null) return e;
return (s.Armed || s.Casting) ? Find(s.ActiveEffects, key) : null;
}
// ---------- 액티브: 상태 · 탭 ----------
public enum ActiveState
{
None,
Ready,
Reserved,
Cooldown,
Acted,
BonusMove,
NotTurn,
Targeting
}
public ActiveState StateOf(Unit u)
{
var s = Of(u);
if (u == null || s == null || !s.HasActive || u.Faction != Faction.Player || !u.IsAlive) return ActiveState.None;
if (PendingTeleport == u) return ActiveState.Targeting;
if (s.Reserved) return ActiveState.Reserved;
if (battle.Phase != BattlePhase.PlayerTurn) return ActiveState.NotTurn;
if (s.CooldownLeft(battle.TurnNumber) > 0) return ActiveState.Cooldown;
if (s.Active.Trigger == SkillTrigger.NextAction)
{
if (u.BonusMoveAvailable) return ActiveState.BonusMove;
if (!battle.CanAct(u)) return ActiveState.Acted;
}
return ActiveState.Ready;
}
/// <summary>HUD 스킬 버튼 탭: next_action = 예약/취소 · instant = 즉시 발동(변진 = 목표 지점 탭 대기). 반환 = 안내 문구.</summary>
public string TapActive(Unit u)
{
var s = Of(u);
var st = StateOf(u);
switch (st)
{
case ActiveState.None: return null;
case ActiveState.Targeting:
ClearTap();
return s.Active.NameKo + " 취소";
case ActiveState.Reserved:
s.Reserved = false;
if (u.View != null) u.View.Refresh();
return s.Active.NameKo + " 예약 취소(쿨 소모 없음)";
case ActiveState.NotTurn: return "아군 턴에만 쓸 수 있습니다";
case ActiveState.Cooldown: return s.Active.NameKo + " — 쿨 " + s.CooldownLeft(battle.TurnNumber) + "턴";
case ActiveState.Acted: return s.Active.NameKo + " — 이미 행동한 유닛";
case ActiveState.BonusMove: return s.Active.NameKo + " — 추가 이동 중에는 예약할 수 없음";
}
if (s.Active.Trigger == SkillTrigger.NextAction)
{
s.Reserved = true;
if (u.View != null) u.View.Refresh();
return s.Active.NameKo + " 예약 — 다음 슬링샷에 발동 · 다시 탭 = 취소";
}
if (s.Active.First(SkillKeys.Teleport) != null)
{
PendingKind = TapKind.Teleport;
PendingUnit = u;
PendingCenter = u.Position;
PendingRadius = TeleportRadius(s);
return s.Active.NameKo + " — 반경 " + PendingRadius.ToString("0.#") + " 안 빈 곳을 탭(다른 곳 = 취소)";
}
battle.RunSkillRoutine(Instant(s));
return s.Active.NameKo + " 발동!";
}
void ClearTap()
{
PendingKind = TapKind.None;
PendingUnit = null;
tapResult = null;
}
/// <summary>행동 중 방향·지점 탭 대기(조조 난세의 간웅 · 하후돈 맹장) — 적·가상 실행·자동 = 기본값.</summary>
public IEnumerator WaitTap(Unit u, TapKind kind, Vector2 center, float radius, string message, Vector2 fallback, System.Action<Vector2> done)
{
if (Sim.Active || AutoTap || u == null || u.Faction != Faction.Player)
{
done(fallback);
yield break;
}
PendingKind = kind;
PendingUnit = u;
PendingCenter = center;
PendingRadius = radius;
tapResult = null;
battle.LastMessage = message;
battle.NotifyChanged();
while (tapResult == null && PendingKind == kind && u != null && u.IsAlive) yield return null;
Vector2 r = tapResult ?? fallback;
ClearTap();
done(r);
}
/// <summary>화면 탭 → 대기 중이면 처리하고 true(변진 = 유효면 이동 · 아니면 취소 / 행동 중 탭 = 결과 지점).</summary>
public bool TryTeleportTap(Vector2 w, out string message)
{
message = null;
if (PendingKind == TapKind.Aim || PendingKind == TapKind.Return)
{
Vector2 d = w - PendingCenter;
if (PendingRadius > 0f && d.magnitude > PendingRadius) w = PendingCenter + d.normalized * PendingRadius;
tapResult = w;
return true;
}
var u = PendingTeleport;
if (u == null) return false;
ClearTap();
var s = Of(u);
if (s == null || !u.IsAlive || battle.Phase != BattlePhase.PlayerTurn) return true;
if (!TeleportValid(u, w))
{
message = s.Active.NameKo + " 취소 — 반경 안 빈 곳이 아님";
return true;
}
battle.RunSkillRoutine(TeleportRoutine(s, w));
message = s.Active.NameKo + " 발동!";
return true;
}
// ---------- 행동 훅(ActionResolver.Resolve) ----------
/// <summary>행동 시작: 예약한 next_action 효과를 장착(이동만인 추가 이동은 제외) · 발동 연출 · 행동 맥락(돌진 거리 비율).</summary>
public void BeginAction(Unit u, bool moveOnly, ActionOutcome o, Vector2 dir = default(Vector2), float slingDist = 0f)
{
noted.Clear();
var s = Of(u);
bool armed = s != null && s.Reserved && s.HasActive && !moveOnly;
if (armed)
{
s.Armed = true;
o.SkillUsed = s.Active;
Announce(s, s.Active, dir);
var rh = Find(s.ActiveEffects, SkillKeys.ReactionOnHit);
var rk = Find(s.ActiveEffects, SkillKeys.ReactionOnKill);
if (rh != null || rk != null)
{
s.ChainHitN = rh != null ? Mathf.RoundToInt(rh.P) : 0;
s.ChainKillN = rk != null ? Mathf.RoundToInt(rk.P) : 0;
s.ChainLeft = Mathf.Max(rh != null ? Mathf.RoundToInt(rh.Get(1, 1f)) : 0, rk != null ? Mathf.RoundToInt(rk.Get(1, 1f)) : 0);
}
}
if (s != null) s.ActionSeq++;
Ctx = new ActionCtx { Actor = u, Origin = u.Position, Seq = s != null ? s.ActionSeq : 0 };
float max = SkillMods.SlingMax(u);
Ctx.SlingRatio = max > 0.01f ? Mathf.Clamp01(slingDist / max) : 0f;
if (s != null) s.ReactLimit = 0f; // 재행동 슬링 상한(감녕·고순)은 조준에서 이미 썼음 — 이 행동이 새로 주면 다시 생김
VfxBegin(u, armed ? s.Active.Id : null, moveOnly ? null : SkillVfx.BasicKey(u.Type), dir, true);
}
/// <summary>
/// 행동 끝: 움직인 유닛 출혈 · 행동한 유닛 중독 · 지대 닿음 · 초선 다중 남성 기절 · 조인·이각·우금 패시브 ·
/// (장착) 재행동 · 아군 재행동(우금) · 지형 재행동(맹획) · 지속 효과 시작 · 예약 소모 · 쿨 → 여몽 연쇄 → 장로 망령.
/// </summary>
public IEnumerator EndAction(Unit u, ActionOutcome o)
{
var s = Of(u);
var ctx = Ctx;
if (vfxActor == u) VfxEnd();
var moved = MovedDistances(u, o);
ApplyBleed(moved);
if (u.IsAlive)
{
var st = StatusOf(u);
if (st != null && st.PoisonTurns > 0) PoisonTick(u, st); // 중독 = 행동 종료 시점에도(가후·축융 정의)
}
ZoneSweep(u, o);
AxeSweep(u, o);
if (ctx != null && ctx.Actor == u)
{
var mm = SkillMods.First(u, SkillKeys.MultiMaleStun);
if (mm != null && ctx.MalesHit.Count >= Mathf.RoundToInt(mm.P))
foreach (var m in ctx.MalesHit)
if (m != null && m.IsAlive && Roll(mm.Get(1, 1f))) ApplyStun(m, Mathf.Max(1, Mathf.RoundToInt(mm.Get(2, 1f))), "매혹 기절");
}
if (s != null && u.IsAlive)
{
float pathLen = PathLength(o.Path);
s.MovedThisRound = true;
s.Bank = 0;
var heal = Find(s.PassiveEffects, SkillKeys.HealOnActionEnd);
if (heal != null && Heal(u, Mathf.RoundToInt(u.MaxHp * heal.P), u) > 0) NotePassive(s, s.Passive);
if (Find(s.PassiveEffects, SkillKeys.UnusedMoveDr) != null)
s.UnusedMove = Mathf.Clamp01(1f - pathLen / Mathf.Max(0.1f, SkillMods.SlingMax(u)));
Sacrifice(s);
}
if (s != null && s.Armed)
{
foreach (var e in s.ActiveEffects)
{
if (e.Dur > 0) AddTimed(s, e, e.Scope == SkillScope.Range ? InRange(u) : null, e.Dur);
else if (e.Key == SkillKeys.ExtraAction) s.ReactCredits += Mathf.Max(0, Mathf.RoundToInt(e.P));
else if (e.Key == SkillKeys.ReactionOnTerrain && ctx != null && ctx.HitTerrain) s.ReactCredits += Mathf.Max(0, Mathf.RoundToInt(e.P));
else if (e.Key == SkillKeys.AllyHitReaction)
foreach (var p in o.Pushed)
if (p != null && p != u && p.IsAlive && p.Faction == u.Faction && p.ActedThisTurn)
{
p.GrantReAction();
Text(p, "재행동", SkillFx.NameColor);
}
}
s.Armed = false;
s.Reserved = false;
s.ReadyTurn = battle.TurnNumber + CooldownOf(s);
if (u.View != null) u.View.Refresh();
}
// 여몽 백의도강 연쇄: 피해 = +재행동 · 처치 = +재행동(남은 최대까지)
if (s != null && s.ChainLeft > 0 && ctx != null && ctx.Actor == u)
{
int add = (ctx.DamagedEnemy ? s.ChainHitN : 0) + (ctx.Kills > 0 ? s.ChainKillN : 0);
add = Mathf.Min(add, s.ChainLeft);
s.ChainLeft -= add;
s.ReactCredits += add;
if (add == 0) s.ChainLeft = 0;
}
if (ctx != null && ctx.ZombieFrom.Count > 0 && !Sim.Active)
foreach (var dead in ctx.ZombieFrom)
SpawnZombie(u, dead);
yield return null;
Ctx = null;
battle.NotifyChanged();
}
/// <summary>재행동을 1회 쓴다(남았으면 true · 이번 턴 재행동 수 +1).</summary>
public bool TakeReaction(Unit u)
{
var s = Of(u);
if (s == null || s.ReactCredits <= 0 || !u.IsAlive) return false;
s.ReactCredits--;
s.ReactsThisTurn++;
Text(u, "재행동!", SkillFx.NameColor);
AddLog(u.DisplayName + " 재행동");
return true;
}
/// <summary>재행동의 슬링 상한(감녕·고순 남은 거리)은 그 재행동 1회로 끝.</summary>
public void ClearReactLimit(Unit u)
{
var s = Of(u);
if (s != null && s.ReactCredits <= 0) s.ReactLimit = 0f;
}
/// <summary>조준 미리보기 동안만 예약 효과를 장착(사거리 · 사선 · 광역 표시). 반환 = 복원값.</summary>
public bool PreviewBegin(Unit u)
{
var s = Of(u);
if (s == null) return false;
bool prev = s.Armed;
if (s.Reserved && !u.BonusMoveAvailable) s.Armed = true;
return prev;
}
public void PreviewEnd(Unit u, bool prev)
{
var s = Of(u);
if (s != null) s.Armed = prev;
}
static float PathLength(List<Vector2> path)
{
float l = 0f;
for (int i = 0; path != null && i < path.Count - 1; i++) l += Vector2.Distance(path[i], path[i + 1]);
return l;
}
static Dictionary<Unit, float> MovedDistances(Unit actor, ActionOutcome o)
{
var m = new Dictionary<Unit, float>();
if (actor != null) m[actor] = PathLength(o.Path);
foreach (var d in o.Displaced)
{
if (d.Unit == null) continue;
float v;
m.TryGetValue(d.Unit, out v);
m[d.Unit] = v + Vector2.Distance(d.From, d.To);
}
return m;
}
/// <summary>출혈 = 움직인 거리 / 자기 슬링 최대 × 최대 HP × 비율(서황 정의 「이동 거리의 50 %에 비례」 · PD 확인 대상).</summary>
public void ApplyBleed(Dictionary<Unit, float> moved)
{
foreach (var kv in moved)
{
var u = kv.Key;
if (u == null || !u.IsAlive || kv.Value < 0.05f) continue;
var st = StatusOf(u);
if (st == null || st.BleedTurns <= 0) continue;
int d = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * st.BleedPct * kv.Value / Mathf.Max(0.1f, u.SlingMaxDistance)));
d = Hit(u, d, null, HitKind.Dot);
Text(u, "출혈 −" + d, new Color(0.9f, 0.15f, 0.2f));
}
}
void PoisonTick(Unit u, UnitStatus st)
{
int d = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * st.PoisonPct * Mathf.Max(1, st.PoisonStacks)));
d = Hit(u, d, null, HitKind.Dot);
Text(u, "중독 −" + d, new Color(0.7f, 0.4f, 1f));
}
/// <summary>이각 패시브: 행동 끝 = HP p1 소모(10 % 이하면 소모 없이) → 인접(p3) 아군 공격 +p2 1턴 · 대상 없으면 발동 안 함.</summary>
void Sacrifice(UnitSkills s)
{
var e = Find(s.PassiveEffects, SkillKeys.ActionEndSacrifice);
var u = s.Unit;
if (e == null || u == null || !u.IsAlive) return;
var targets = new HashSet<Unit>();
foreach (var a in battle.AliveUnits(u.Faction))
if (a != u && SkillMods.Within(u, a, e.Get(2, 1.5f))) targets.Add(a);
if (targets.Count == 0) return;
if (u.Hp > u.MaxHp * e.P) PayHp(u, e.P);
var buff = new SkillEffect { Key = SkillKeys.AtkPct, P = e.Get(1), Scope = SkillScope.Range, Dur = 1 };
TimedEffects.Add(new Timed { Owner = s, Faction = u.Faction, Def = s.Passive, E = buff, Targets = targets, Rounds = 1 });
foreach (var t in targets) Text(t, "공격 +" + Mathf.RoundToInt(buff.P * 100f) + "%", SkillFx.NameColor);
NotePassive(s, s.Passive);
}
HashSet<Unit> InRange(Unit owner)
{
var set = new HashSet<Unit>();
foreach (var a in battle.AliveUnits(owner.Faction))
if (SkillMods.Within(owner, a, SkillMods.BaseReach(owner))) set.Add(a);
return set;
}
// ---------- 추가 이동(보병 목표점 명중) ----------
public bool BonusCanAttack(Unit u)
{
var s = Of(u);
return s != null && s.BonusAttack;
}
/// <summary>행동 뒤 추가 이동을 (또) 주는가 — 처음 명중 = 1회 · 추가 이동 뒤 = 남은 횟수.</summary>
public bool NextBonus(Unit u, bool wasBonus, ActionOutcome o)
{
var s = Of(u);
if (!wasBonus)
{
if (!o.BonusMove) return false;
if (s != null)
{
s.BonusMovesLeft = Mathf.Max(0, o.BonusMoves - 1);
s.BonusAttack = o.BonusAttack;
}
return true;
}
if (s != null && s.BonusMovesLeft > 0)
{
s.BonusMovesLeft--;
return true;
}
ClearBonus(u);
return false;
}
public void ClearBonus(Unit u)
{
var s = Of(u);
if (s == null) return;
s.BonusMovesLeft = 0;
s.BonusAttack = false;
}
/// <summary>이번 적 턴에 교전 반응이 없는가 — 기절은 지형 기믹 행동 불가(GimmickLayer.Skips)가 처리.</summary>
public bool Suppressed(Unit e)
{
return false;
}
/// <summary>교전 범위 가산(새 기획에 없음 = 0).</summary>
public float AggroAdd(Faction actingFaction)
{
return 0f;
}
// ---------- 피해 적용 ----------
/// <summary>
/// 피해 적용(by = 준 유닛 · kind = 종류): 무적(시한) → 무효화(조범) → 호위(관평·강유 · 강유 = 반격) → 고육계 누적 → 부활(유비·맹획) → HP 감소 →
/// 준 쪽 보상(흡혈 · 사마의 흡수 · 명중 상태 이상 · 장비 연속 기절 · 하후은 첫 명중 추가 피해) → 맞은 쪽 반응(서황 도끼 · 간옹 기절 · 원술 혼란) → 처치 처리. 반환 = 실제 받은 피해.
/// </summary>
public int Hit(Unit d, int dmg, Unit by = null, HitKind kind = HitKind.Attack)
{
if (d == null || !d.IsAlive || dmg <= 0) return 0;
bool crit = (kind == HitKind.Attack || kind == HitKind.Skill || kind == HitKind.Counter) && Combat.LastCrit;
if (vfxActor != null && d.Faction != vfxActor.Faction && vfxHit.Add(d))
{
foreach (var k in vfxKeys) SkillVfx.Play(vfxActor, k, SkillVfx.EventHit, Vector2.zero, d);
HitFeel.OnHit(vfxActor, d);
}
var ds = Of(d);
if (kind != HitKind.Self && Invulnerable(d))
{
Text(d, "무적", SkillFx.GuardColor);
return 0;
}
if (kind != HitKind.Self && ds != null && ds.PassiveEffects != null) // 조범 처세 「어떤 피해든」 = 상태 이상·지대 피해 포함(Fable 검토 C3)
{
var ng = Find(ds.PassiveEffects, SkillKeys.NegateHits);
if (ng != null && ds.NegateUsed < Mathf.RoundToInt(ng.P))
{
ds.NegateUsed++;
Text(d, "피해 무효", SkillFx.GuardColor);
NotePassive(ds, ds.Passive);
return 0;
}
}
if (kind != HitKind.Guard && kind != HitKind.Dot && kind != HitKind.Self)
{
float gr;
SkillEffect ge;
var g = Guarder(d, out gr, out ge);
if (g != null)
{
int share = Mathf.Clamp(Mathf.RoundToInt(dmg * Mathf.Clamp01(ge.Get(1, 1f))), 0, dmg);
if (share > 0)
{
float dr = 1f + SkillMods.Sum(g, SkillKeys.GuardDr);
int took = Hit(g, Mathf.Max(1, Mathf.RoundToInt(share * Mathf.Max(0f, dr))), by, HitKind.Guard);
Fx.Popup(g.Position, took + " 호위", SkillFx.GuardColor);
NotePassive(Of(g), Of(g).Passive);
dmg -= share;
if (ge.Key == SkillKeys.GuardCounter && by != null && by.IsAlive && g.IsAlive && kind == HitKind.Attack)
{
int c = Combat.CounterDamage(Cfg, g, by, battle.Rng);
if (c > 0)
{
c = Hit(by, c, g, HitKind.Counter);
Fx.Popup(by.Position, c + " 호위 반격", new Color(1f, 0.45f, 0.45f));
}
}
if (dmg <= 0) return 0;
}
}
}
if (ds != null && dmg > 0 && HasTimed(ds, SkillKeys.DamageToAtk)) ds.DmgTakenRound += Mathf.Min(dmg, d.Hp);
int before = d.Hp;
if (dmg >= d.Hp && TryRevive(d, ds, before))
{
// 부활을 부른 타격도 준 쪽 효과(흡혈·첫 명중·연속 기절·명중 상태 이상) · 맞은 쪽 반응 — 처치는 아님(Fable 검토 C7)
if (by != null && by.Faction != d.Faction) OnDealt(by, d, before, kind, crit);
if (d.IsAlive && by != null && by.IsAlive && by.Faction != d.Faction && kind == HitKind.Attack) OnAttacked(d, by);
return before;
}
d.TakeDamage(dmg);
int dealt = before - d.Hp;
if (by != null && by.Faction != d.Faction) OnDealt(by, d, dealt, kind, crit);
if (d.IsAlive && by != null && by.IsAlive && by.Faction != d.Faction && kind == HitKind.Attack) OnAttacked(d, by);
if (!d.IsAlive) OnDeath(d, by);
return dealt;
}
/// <summary>스킬 시스템이 없어도 쓰는 피해 적용.</summary>
public static int Deal(Unit d, int dmg, Unit by = null, HitKind kind = HitKind.Attack)
{
if (Current != null) return Current.Hit(d, dmg, by, kind);
if (d == null) return 0;
d.TakeDamage(dmg);
return dmg;
}
bool Invulnerable(Unit d)
{
foreach (var t in TimedEffects)
{
if (t.E.Key != SkillKeys.Invuln) continue;
if (t.Targets != null ? t.Targets.Contains(d) : (t.Owner != null && t.Owner.Unit == d)) return true;
}
return false;
}
/// <summary>d 를 대신 맞아 줄 호위(관평 호위 · 강유 반격 범위 · 시한 범위 배율) — 가장 가까운 1기.</summary>
Unit Guarder(Unit d, out float radius, out SkillEffect effect)
{
radius = 0f;
effect = null;
Unit best = null;
float bestD = float.MaxValue;
foreach (var s in entries)
{
var g = s.Unit;
if (g == null || g == d || !g.IsAlive || g.Faction != d.Faction || s.PassiveEffects == null) continue;
var e = Find(s.PassiveEffects, SkillKeys.Guard) ?? Find(s.PassiveEffects, SkillKeys.GuardCounter);
if (e == null) continue;
float r = e.P * Mathf.Max(0f, SkillMods.Product(g, SkillKeys.GuardRadiusMult));
float dist = Vector2.Distance(g.Position, d.Position);
if (dist > r + d.Radius || dist >= bestD) continue;
best = g;
bestD = dist;
radius = r;
effect = e;
}
return best;
}
bool TryRevive(Unit d, UnitSkills ds, int before)
{
if (ds == null || ds.PassiveEffects == null) return false;
var e = Find(ds.PassiveEffects, SkillKeys.Revive);
if (e == null || ds.RevivesUsed >= Mathf.RoundToInt(e.Get(1, 1f))) return false;
float need = e.Get(2, 0f);
if (need > 0f && before < d.MaxHp * need) return false; // 맹획: 맞기 전 HP 가 조건 미만 = 즉사
ds.RevivesUsed++;
d.Hp = Mathf.Max(1, Mathf.RoundToInt(d.MaxHp * Mathf.Clamp01(e.P)));
if (d.View != null)
{
d.View.OnHit();
d.View.Refresh();
}
Text(d, "부활!", SkillFx.HealColor);
NotePassive(ds, ds.Passive);
AddLog("패시브 · " + d.DisplayName + " 「" + ds.Passive.NameKo + "」 부활");
return true;
}
/// <summary>준 쪽: 흡혈(조건 = 상대) · 반격 흡혈 · 사마의 흡수 · 행동 맥락(맞힌 수 · 남성 · 첫 명중) · 명중 상태 이상 · 장비 연속 피해 기절.</summary>
void OnDealt(Unit by, Unit d, int dealt, HitKind kind, bool crit)
{
if (dealt <= 0) return;
if (by.IsAlive && (kind == HitKind.Attack || kind == HitKind.Skill || kind == HitKind.Counter))
{
float ls = 0f;
foreach (var h in SkillMods.Get(by, SkillKeys.Lifesteal, d))
{
ls += h.E.P + (h.E.HasA2 ? h.E.A2 * SkillMods.DeadAllies(by) : 0f);
if (h.Passive) NotePassive(h.Owner, h.Def);
}
if (kind == HitKind.Counter) ls += SkillMods.Sum(by, SkillKeys.CounterLifesteal, true);
if (ls > 0f) Heal(by, Mathf.RoundToInt(dealt * ls), by);
}
foreach (var s in entries)
{
var sm = s.Unit;
if (sm == null || !sm.IsAlive || sm.Faction == d.Faction || s.PassiveEffects == null) continue;
var dr = Find(s.PassiveEffects, SkillKeys.DrainAura);
if (dr == null || !SkillMods.Within(sm, d, SkillMods.BaseReach(sm))) continue;
if (Heal(sm, Mathf.RoundToInt(dealt * dr.P), sm) > 0) NotePassive(s, s.Passive);
}
var ctx = Ctx;
bool actor = ctx != null && ctx.Actor == by;
if (actor && (kind == HitKind.Attack || kind == HitKind.Skill))
{
ctx.HitCount++;
ctx.DamagedEnemy = true;
if (!d.Female) ctx.MalesHit.Add(d);
}
if (kind == HitKind.Attack || kind == HitKind.Skill || kind == HitKind.Indirect || kind == HitKind.Gimmick)
ApplyHitStatuses(by, d, crit, kind);
if (actor && kind == HitKind.Attack && !ctx.FirstHitDone)
{
ctx.FirstHitDone = true;
float fb = SkillMods.Sum(by, SkillKeys.FirstHitBonus, true, d);
if (fb > 0f && d.IsAlive)
{
int extra = Mathf.Max(1, Mathf.RoundToInt(AtkOf(by, d) * fb));
extra = Hit(d, extra, by, HitKind.Skill);
Fx.Popup(d.Position + new Vector2(0f, 0.3f), "+" + extra, SkillFx.NameColor);
}
}
if (actor && kind == HitKind.Attack)
{
var bs = Of(by);
var dh = Eff(bs, SkillKeys.DoubleHitStun);
if (dh != null && d.IsAlive)
{
Vector2Int v;
bs.Streak.TryGetValue(d, out v);
if (v.x != ctx.Seq)
{
v.y = v.x == ctx.Seq - 1 ? v.y + 1 : 1;
v.x = ctx.Seq;
if (v.y >= Mathf.RoundToInt(dh.P))
{
ApplyStun(d, Mathf.Max(1, Mathf.RoundToInt(dh.Get(1, 1f))), "연속 피해 기절");
v.y = 0;
NotePassive(bs, bs.Passive);
}
bs.Streak[d] = v;
}
}
}
}
/// <summary>명중 상태 이상(hit_*): 조건(치명타·상대) · 확률 p3 · 간접(밀린 아군·지형) = 고순(hit_stun p2 1)만.</summary>
public void ApplyHitStatuses(Unit by, Unit d, bool crit, HitKind kind)
{
if (by == null || d == null || !d.IsAlive || by.Faction == d.Faction) return;
bool indirect = kind == HitKind.Indirect || kind == HitKind.Gimmick;
foreach (var h in SkillMods.Get(by, SkillKeys.HitStun, d, crit))
if ((!indirect || h.E.Get(1) >= 0.5f) && Roll(h.E.Get(2, 1f)))
{
ApplyStun(d, Mathf.Max(1, Mathf.RoundToInt(h.E.P)), "기절");
NoteHit(h);
}
if (indirect) return;
foreach (var h in SkillMods.Get(by, SkillKeys.HitBleed, d, crit))
if (Roll(h.E.Get(2, 1f)))
{
ApplyBleedStatus(d, Mathf.RoundToInt(h.E.P), h.E.Get(1, 0.5f));
NoteHit(h);
}
// 중독 = 한 번 명중에 1회만(액티브·패시브가 함께 걸려도 중첩 +1 · 가장 긴 턴·큰 비율 · Fable 검토 C1)
int pTurns = 0;
float pPct = 0f;
foreach (var h in SkillMods.Get(by, SkillKeys.HitPoison, d, crit))
if (Roll(h.E.Get(2, 1f)))
{
pTurns = Mathf.Max(pTurns, Mathf.RoundToInt(h.E.P));
pPct = Mathf.Max(pPct, h.E.Get(1, 0.02f));
NoteHit(h);
}
if (pTurns > 0)
{
int max = 1;
foreach (var ps in SkillMods.Get(by, SkillKeys.PoisonStack)) max = Mathf.Max(max, Mathf.RoundToInt(ps.E.P));
ApplyPoison(d, pTurns, pPct, max);
}
foreach (var h in SkillMods.Get(by, SkillKeys.HitBurn, d, crit))
if (Roll(h.E.Get(2, 1f)))
{
ApplyBurn(d, Mathf.RoundToInt(h.E.P), h.E.Get(1, 0.05f));
NoteHit(h);
}
foreach (var h in SkillMods.Get(by, SkillKeys.HitConfuse, d, crit))
if (Roll(h.E.Get(2, 1f)))
{
ApplyConfuse(d, Mathf.RoundToInt(h.E.P), h.E.Get(1, 0.5f));
NoteHit(h);
}
foreach (var h in SkillMods.Get(by, SkillKeys.HitPush, d, crit))
{
Vector2 dir = d.Position - by.Position;
if (dir.sqrMagnitude < 1e-6f) continue;
Shove(d, dir.normalized, h.E.P * by.SlingMaxDistance, by);
NoteHit(h);
}
}
void NoteHit(SkillHit h)
{
if (h.Passive) NotePassive(h.Owner, h.Def);
}
bool Roll(float chance)
{
if (chance >= 0.999f) return true;
if (chance <= 0f) return false;
if (Sim.Active) return chance >= 0.5f; // 가상 실행 = 결정적
return battle.Rng.NextDouble() < chance;
}
/// <summary>맞은 쪽(근접·원거리 공격을 받음 · 살아 있음): 서황 도끼 반격 · 간옹 기절(재충전) · 원술 혼란(확률).</summary>
void OnAttacked(Unit d, Unit by)
{
var ds = Of(d);
foreach (var h in SkillMods.Get(d, SkillKeys.AxeAura))
{
if (!by.IsAlive) break;
if (Vector2.Distance(d.Position, by.Position) > d.Radius + by.Radius + h.E.P) continue;
int ax = Mathf.Max(1, Mathf.RoundToInt(AtkOf(d, by) * h.E.Get(1, 0.8f)));
ax = Hit(by, ax, d, HitKind.Skill);
Fx.Popup(by.Position, ax + " 도끼", SkillFx.NameColor);
break;
}
if (ds == null || ds.PassiveEffects == null || !by.IsAlive) return;
var st = Find(ds.PassiveEffects, SkillKeys.OnAttackedStun);
if (st != null && ds.AttackedStunCd <= 0)
{
ApplyStun(by, Mathf.Max(1, Mathf.RoundToInt(st.P)), "말재주 기절");
ds.AttackedStunCd = Mathf.Max(0, Mathf.RoundToInt(st.Get(1, 0f)));
NotePassive(ds, ds.Passive);
}
var cf = Find(ds.PassiveEffects, SkillKeys.OnAttackedConfuse);
if (cf != null && Roll(cf.Get(2, 1f)))
{
ApplyConfuse(by, Mathf.Max(1, Mathf.RoundToInt(cf.P)), cf.Get(1, 0.5f));
NotePassive(ds, ds.Passive);
}
}
/// <summary>처치: 무신 누적 · 처치 회복(문추·공손찬·고육계) · 파로 재행동(턴당) · 곽가 귀재(범위 안 처치 = 쿨 −·회복) · 한중의 교주(망령) · 노숙 깃발.</summary>
void OnDeath(Unit d, Unit by)
{
var dsk = Of(d);
if (dsk != null && dsk.PassiveEffects != null)
{
var bn = Find(dsk.PassiveEffects, SkillKeys.DeathBanner);
if (bn != null)
{
dsk.BannerLeft = Mathf.Max(1, Mathf.RoundToInt(bn.P));
dsk.BannerPos = d.Position;
Text(d, "깃발", SkillFx.NameColor);
NotePassive(dsk, dsk.Passive);
}
}
if (by == null || by.Faction == d.Faction || summons.Contains(d)) return;
var s = Of(by);
if (s != null) s.Kills++;
var ctx = Ctx;
if (ctx != null && ctx.Actor == by) ctx.Kills++;
if (!by.IsAlive) return;
float hk = SkillMods.Sum(by, SkillKeys.HealOnKill, true);
if (hk > 0f) Heal(by, Mathf.RoundToInt(by.MaxHp * hk), by);
if (s != null && !s.KillReactUsed && Eff(s, SkillKeys.KillReaction) != null)
{
s.KillReactUsed = true;
s.ReactCredits++;
NotePassive(s, s.Passive);
}
foreach (var g in entries)
{
var gu = g.Unit;
if (gu == null || !gu.IsAlive || gu.Faction != by.Faction || g.PassiveEffects == null) continue;
var rw = Find(g.PassiveEffects, SkillKeys.KillInRangeReward);
if (rw == null || Vector2.Distance(gu.Position, d.Position) > SkillMods.BaseReach(gu) + d.Radius) continue;
if (s != null && s.HasActive) s.ReadyTurn = Mathf.Max(battle.TurnNumber, s.ReadyTurn - Mathf.RoundToInt(rw.P));
Heal(by, Mathf.RoundToInt(by.MaxHp * rw.Get(1, 0f)), gu);
NotePassive(g, g.Passive);
}
if (ctx != null && ctx.Actor == by && Eff(s, SkillKeys.Zombie) != null && !Sim.Active) ctx.ZombieFrom.Add(d);
}
/// <summary>
/// 장로 한중의 교주: 처치한 적이 체력 1 로 같은 편 망령이 된다(조작 불가 · 상대 턴 동안 공격받을 수 있음) →
/// 그 진영 다음 턴 시작(TurnBegin)에 가장 가까운 상대를 1번 공격하고 사라짐(PD 2026-11-10 #1112 결정 ⑩).
/// </summary>
void SpawnZombie(Unit owner, Unit dead)
{
if (owner == null || dead == null || battle.Board == null) return;
var s = Of(owner);
var e = Eff(s, SkillKeys.Zombie);
var z = battle.SpawnUnit(dead.Type, owner.Faction, dead.Position, "망령 " + dead.DisplayName, 1f, 1f, false, dead.ArtId);
z.Atk = dead.Atk;
z.Hp = Mathf.Max(1, Mathf.RoundToInt(e != null ? e.P : 1f));
z.MarkActed();
summons.Add(z);
if (z.View != null) z.View.Refresh();
Text(z, "망령", SkillFx.NameColor);
if (s != null) NotePassive(s, s.Passive);
AddLog("망령 " + dead.DisplayName + " — 다음 턴 시작에 1번 공격 후 소멸");
}
/// <summary>망령의 자동 공격 1회(가장 가까운 상대 쪽으로 슬링 최대 거리까지).</summary>
IEnumerator ZombieAct(Unit z)
{
if (z == null || !z.IsAlive || battle.Board == null) yield break;
Unit foe = null;
float best = float.MaxValue;
foreach (var t in battle.AliveUnits(Other(z.Faction)))
{
float dd = Vector2.Distance(z.Position, t.Position);
if (dd < best)
{
best = dd;
foe = t;
}
}
if (foe == null) yield break;
yield return Fx.Wait(0.2f);
var prevCtx = Ctx;
Vector2 aim = foe.Position - z.Position;
float max = SkillMods.SlingMax(z);
if (aim.magnitude > max) aim = aim.normalized * max;
var o = new ActionOutcome();
yield return battle.Resolver.Resolve(z, battle.Board.ClampInside(z.Position + aim, z.Radius), false, o);
Ctx = prevCtx;
}
// ---------- 상태 이상 ----------
public UnitStatus StatusOf(Unit u, bool create = false)
{
if (u == null) return null;
UnitStatus s;
if (statuses.TryGetValue(u, out s)) return s;
if (!create) return null;
s = new UnitStatus();
statuses[u] = s;
return s;
}
/// <summary>자기 진영 턴 중에 걸리면 그 턴은 세지 않는다(다음 자기 턴부터).</summary>
int TurnsFor(Unit u, int turns)
{
bool ownTurn = (u.Faction == Faction.Player && (battle.Phase == BattlePhase.PlayerTurn || (battle.Phase == BattlePhase.Resolving && Ctx != null && Ctx.Actor != null && Ctx.Actor.Faction == Faction.Player)))
|| (u.Faction == Faction.Enemy && battle.Phase == BattlePhase.EnemyTurn);
return turns + (ownTurn ? 1 : 0);
}
public void ApplyStun(Unit u, int turns, string why)
{
if (u == null || !u.IsAlive || turns <= 0) return;
var g = GimmickLayer.Current;
if (g != null) g.Stun(u, turns, why);
}
public void ApplyConfuse(Unit u, int turns, float mult)
{
if (u == null || !u.IsAlive || turns <= 0) return;
var s = StatusOf(u, true);
s.ConfuseTurns = Mathf.Max(s.ConfuseTurns, TurnsFor(u, turns));
s.ConfuseMult = Mathf.Min(s.ConfuseTurns > 0 && s.ConfuseMult < 1f ? s.ConfuseMult : 1f, mult);
Text(u, "혼란", SkillStatus.TextColor);
RefreshBadge(u);
}
public void ApplyBleedStatus(Unit u, int turns, float pct)
{
if (u == null || !u.IsAlive || turns <= 0) return;
var s = StatusOf(u, true);
s.BleedTurns = Mathf.Max(s.BleedTurns, TurnsFor(u, turns));
s.BleedPct = Mathf.Max(s.BleedPct, pct);
Text(u, "출혈", new Color(0.9f, 0.15f, 0.2f));
RefreshBadge(u);
}
public void ApplyPoison(Unit u, int turns, float pct, int maxStacks)
{
if (u == null || !u.IsAlive || turns <= 0) return;
var s = StatusOf(u, true);
s.PoisonStacks = s.PoisonTurns > 0 ? Mathf.Min(Mathf.Max(1, maxStacks), s.PoisonStacks + 1) : 1;
s.PoisonTurns = Mathf.Max(s.PoisonTurns, turns);
s.PoisonPct = Mathf.Max(s.PoisonPct, pct);
Text(u, s.PoisonStacks > 1 ? "중독 ×" + s.PoisonStacks : "중독", new Color(0.7f, 0.4f, 1f));
RefreshBadge(u);
}
public void ApplyBurn(Unit u, int turns, float pct)
{
if (u == null || !u.IsAlive || turns <= 0) return;
var s = StatusOf(u, true);
s.BurnTurns = Mathf.Max(s.BurnTurns, turns);
s.BurnPct = Mathf.Max(s.BurnPct, pct);
Text(u, "화상", new Color(1f, 0.5f, 0.2f));
RefreshBadge(u);
}
/// <summary>상태 이상 제거 = 스킬 상태(혼란·출혈·중독·화상) + 지형 기믹 중독·행동 불가.</summary>
public void Cleanse(Unit u)
{
if (u == null) return;
var s = StatusOf(u);
bool any = s != null && s.Any;
if (s != null) s.Clear();
if (GimmickLayer.Current != null) any |= GimmickLayer.Current.Cleanse(u);
if (any) Text(u, "정화", SkillFx.HealColor);
RefreshBadge(u);
}
void RefreshBadge(Unit u)
{
if (Sim.Active || u == null) return;
var s = StatusOf(u);
if (s == null) return;
string t = SkillStatus.BadgeText(s);
if (s.Badge == null)
{
if (t.Length == 0) return;
s.Badge = TextUtil.CreateWorldText(u.transform, t, 0.24f, SkillStatus.TextColor, 6);
s.Badge.gameObject.name = "SkillStatus";
float top = u.View != null && u.View.HasArt ? -u.Radius * 0.55f + u.ArtHeight + 0.12f : u.Radius + 0.22f;
s.Badge.transform.localPosition = new Vector3(0f, top + 1.05f, 0f);
}
s.Badge.text = t;
s.Badge.gameObject.SetActive(t.Length > 0 && u.IsAlive);
}
// ---------- 회복 ----------
/// <summary>공격력(효과 반영) — 「공격력의 X %」 회복·추가 피해 기준.</summary>
public static float AtkOf(Unit u, Unit vs = null)
{
return u != null ? u.Atk * SkillMods.AttackMult(u, false, vs) : 0f;
}
/// <summary>회복(최대 HP 넘지 않음) · 간옹 패시브 = 회복한 대상 정화. 반환 = 실제 회복량.</summary>
public int Heal(Unit u, int amount, Unit healer = null)
{
if (u == null || !u.IsAlive || amount <= 0) return 0;
int before = u.Hp;
u.Hp = Mathf.Min(u.MaxHp, u.Hp + amount);
if (u.View != null && !Sim.Active) u.View.Refresh();
int got = u.Hp - before;
if (got > 0) Fx.Popup(u.Position, "+" + got, SkillFx.HealColor);
if (healer != null && healer != u && SkillMods.Has(healer, SkillKeys.HealCleanse, true) && SkillStatus.HasAny(u)) Cleanse(u);
return got;
}
void PayHp(Unit u, float ratio)
{
int cost = Mathf.RoundToInt(u.MaxHp * Mathf.Max(0f, ratio));
if (cost <= 0) return;
u.Hp = Mathf.Max(1, u.Hp - cost); // 스스로 쓰러지지는 않는다
if (u.View != null && !Sim.Active)
{
u.View.OnHit();
u.View.Refresh();
}
Fx.Popup(u.Position, "HP −" + cost, SkillFx.CostColor);
}
// ---------- 밀기(스킬) ----------
/// <summary>스킬 밀기(원소 치명타 밀침 · 즉시형 광역 밀기): 벽·유닛·장애물에서 멈춤 · 움직인 거리 = 출혈.</summary>
public float Shove(Unit t, Vector2 dir, float dist, Unit by)
{
if (t == null || !t.IsAlive || dist < 0.01f) return 0f;
float force = dist * SkillMods.KnockbackTakenMult(t, true);
float slide = Combat.FreeTravel(t, t.Position, dir, force, true, true, by);
if (slide <= 0.01f) return 0f;
Vector2 start = t.Position;
Vector2 end = battle.Board.ClampInside(start + dir * slide, t.Radius);
if (t.View != null && !Sim.Active) t.View.SetFacing(dir);
t.Position = end;
if (!Sim.Active)
{
t.transform.position = new Vector3(start.x, start.y, 0f);
Fx.Run(Fx.MoveAlong(t.transform, start, end, Cfg.moveSpeed, null));
t.Position = end; // 논리 위치는 바로(콜라이더 질의) · 그림은 따라감
}
float moved = Vector2.Distance(start, end);
ApplyBleed(new Dictionary<Unit, float> { { t, moved } });
return moved;
}
// ---------- 지대(주유 불꽃 · 육손 불 장벽 · 장로 독지대) ----------
public SkillZone AddZone(UnitSkills owner, Vector2 a, Vector2 b, float radius, int rounds, float ratio, SkillEffect status, Color color, bool hitsAllies = false)
{
var z = new SkillZone { Owner = owner, Faction = owner.Unit.Faction, A = a, B = b, Radius = Mathf.Max(0.2f, radius), RoundsLeft = Mathf.Max(1, rounds), Ratio = ratio, Status = status, StatusKey = status != null ? status.Key : null, HitsAllies = hitsAllies };
if (!Sim.Active)
{
var root = new GameObject("SkillZone");
Vector2 mid = (a + b) * 0.5f;
root.transform.position = new Vector3(mid.x, mid.y, 0f);
float len = Vector2.Distance(a, b);
if (len > 0.05f)
{
var bar = ProceduralSprites.NewSprite(root.transform, "Body", ProceduralSprites.Square, color, 1f, -12);
bar.transform.localScale = new Vector3(len, z.Radius * 2f, 1f);
Vector2 d = b - a;
bar.transform.rotation = Quaternion.Euler(0f, 0f, Mathf.Atan2(d.y, d.x) * Mathf.Rad2Deg);
}
foreach (var p in new[] { a, b })
{
var cap = ProceduralSprites.NewSprite(root.transform, "Cap", ProceduralSprites.Circle, color, z.Radius * 2f, -12);
cap.transform.position = new Vector3(p.x, p.y, 0f);
}
z.View = root;
}
Zones.Add(z);
return z;
}
/// <summary>지대 닿음: 상대 = 주인 공격력 × 배율 피해 + 지대 상태 이상(행동마다 · 턴마다 1번).</summary>
void ZoneTouch(SkillZone z, Unit u)
{
if (u == null || !u.IsAlive || !z.Affects(u) || !z.Touched.Add(u)) return;
var src = z.Owner != null ? z.Owner.Unit : null;
if (z.Ratio > 0f && src != null)
{
int d = Mathf.Max(1, Mathf.RoundToInt(Combat.SkillDamage(Cfg, src, u, z.Ratio, battle.Rng))); // 아군도 같은 피해(PD 결정 ⑬)
d = Hit(u, d, src, HitKind.Dot);
Text(u, (z.StatusKey == SkillKeys.HitPoison ? "독지대 −" : "불꽃 −") + d, new Color(1f, 0.55f, 0.25f));
}
if (z.Status != null && u.IsAlive)
{
if (z.StatusKey == SkillKeys.HitBurn || z.StatusKey == SkillKeys.ZoneBurn) ApplyBurn(u, Mathf.RoundToInt(z.Status.P), z.Status.Get(1, 0.05f));
else if (z.StatusKey == SkillKeys.HitPoison) ApplyPoison(u, Mathf.RoundToInt(z.Status.P), z.Status.Get(1, 0.02f), 1);
}
}
/// <summary>이 행동에서 움직인 상대 유닛의 경로가 지대와 겹치면 닿음(행동 유닛 경로 + 밀림·넉백 구간).</summary>
void ZoneSweep(Unit actor, ActionOutcome o)
{
if (Zones.Count == 0) return;
foreach (var z in Zones) z.Touched.Clear();
var segs = new List<KeyValuePair<Unit, Vector4>>();
for (int i = 0; actor != null && i < o.Path.Count - 1; i++) segs.Add(new KeyValuePair<Unit, Vector4>(actor, new Vector4(o.Path[i].x, o.Path[i].y, o.Path[i + 1].x, o.Path[i + 1].y)));
foreach (var d in o.Displaced)
if (d.Unit != null) segs.Add(new KeyValuePair<Unit, Vector4>(d.Unit, new Vector4(d.From.x, d.From.y, d.To.x, d.To.y)));
foreach (var z in Zones)
foreach (var kv in segs)
{
var u = kv.Key;
if (u == null || !u.IsAlive || !z.Affects(u)) continue;
var s = kv.Value;
if (SegSegDistance(new Vector2(s.x, s.y), new Vector2(s.z, s.w), z.A, z.B) <= z.Radius + u.Radius * 0.5f) ZoneTouch(z, u);
}
}
/// <summary>서황 대부: 도끼가 도는 동안 자신의 이동 경로 도끼 반경(p1)에 닿은 상대 = 공격력 × p2 추가 피해(행동마다 1번).</summary>
void AxeSweep(Unit u, ActionOutcome o)
{
if (u == null || !u.IsAlive || o.Path.Count < 2) return;
var axe = SkillMods.First(u, SkillKeys.AxeAura);
if (axe == null) return;
foreach (var e in battle.AliveUnits(Other(u.Faction)))
{
bool near = false;
for (int i = 0; i < o.Path.Count - 1 && !near; i++)
near = Combat.SegmentPointDistance(o.Path[i], o.Path[i + 1], e.Position) <= u.Radius + e.Radius + axe.P;
if (!near) continue;
int d = Mathf.Max(1, Mathf.RoundToInt(AtkOf(u, e) * axe.Get(1, 0.8f)));
d = Hit(e, d, u, HitKind.Skill);
Fx.Popup(e.Position, d + " 도끼", SkillFx.NameColor);
}
}
public static float SegSegDistance(Vector2 p1, Vector2 q1, Vector2 p2, Vector2 q2)
{
if (SegmentsIntersect(p1, q1, p2, q2)) return 0f;
return Mathf.Min(Mathf.Min(Combat.SegmentPointDistance(p1, q1, p2), Combat.SegmentPointDistance(p1, q1, q2)),
Mathf.Min(Combat.SegmentPointDistance(p2, q2, p1), Combat.SegmentPointDistance(p2, q2, q1)));
}
static bool SegmentsIntersect(Vector2 a, Vector2 b, Vector2 c, Vector2 d)
{
float d1 = Cross(d - c, a - c), d2 = Cross(d - c, b - c), d3 = Cross(b - a, c - a), d4 = Cross(b - a, d - a);
return ((d1 > 0f && d2 < 0f) || (d1 < 0f && d2 > 0f)) && ((d3 > 0f && d4 < 0f) || (d3 < 0f && d4 > 0f));
}
static float Cross(Vector2 a, Vector2 b)
{
return a.x * b.y - a.y * b.x;
}
// ---------- 즉시형 액티브 ----------
/// <summary>즉시 발동(행동 소모 없음): 시전 중 효과 장착 → 처리기 → 지속 효과(dur) → 쿨.</summary>
public IEnumerator Instant(UnitSkills s)
{
var u = s.Unit;
Announce(s, s.Active);
VfxBegin(u, s.Active.Id, null, Vector2.zero, false);
s.ReadyTurn = battle.TurnNumber + CooldownOf(s);
s.Casting = true;
var prevCtx = Ctx;
Ctx = new ActionCtx { Actor = u, Origin = u.Position, Seq = s.ActionSeq }; // 즉시형은 행동 번호를 올리지 않음(장비 연속 판정 · Fable 검토 C2)
var moved = new Dictionary<Unit, float>();
foreach (var e in s.ActiveEffects)
{
if (e.Dur > 0)
{
AddTimed(s, e, e.Scope == SkillScope.Range ? InRange(u) : null, e.Dur);
continue;
}
switch (e.Key)
{
case SkillKeys.AoeStrike:
yield return AoeStrike(s, e, moved);
break;
case SkillKeys.AoeDrain:
AoeDrain(s, e);
break;
case SkillKeys.Volley:
yield return Volley(s, e, moved);
break;
case SkillKeys.CharmPull:
yield return CharmPull(s, e, moved);
break;
case SkillKeys.HealAtk:
{
int amt = Mathf.RoundToInt(AtkOf(u) * e.P);
foreach (var t in e.Scope == SkillScope.Self ? new HashSet<Unit> { u } : InRange(u)) Heal(t, amt, u);
break;
}
case SkillKeys.Cleanse:
foreach (var t in e.Scope == SkillScope.Self ? new HashSet<Unit> { u } : InRange(u)) Cleanse(t);
break;
case SkillKeys.HealSelfPct:
Heal(u, Mathf.RoundToInt(u.MaxHp * Mathf.Max(0f, e.P)), u);
break;
case SkillKeys.GrantReaction:
GrantReaction(u, Mathf.Max(1, Mathf.RoundToInt(e.P)));
break;
}
}
ApplyBleed(moved);
s.Casting = false;
Ctx = prevCtx;
yield return Fx.Wait(0.25f);
if (vfxActor == u) VfxEnd();
battle.NotifyChanged();
}
/// <summary>한 유닛에게 거는 시한 버프(미축 지원 · 순욱 보급 = 아군 명중 시 이동·공격 %).</summary>
public void AddBuff(UnitSkills owner, Unit target, string key, float value, int rounds)
{
if (owner == null || target == null || owner.Unit == null) return;
var e = new SkillEffect { Key = key, P = value, Scope = SkillScope.Range, Dur = rounds };
TimedEffects.Add(new Timed { Owner = owner, Faction = owner.Unit.Faction, Def = owner.Passive ?? owner.Active, E = e, Targets = new HashSet<Unit> { target }, Rounds = Mathf.Max(1, rounds) });
Text(target, key == SkillKeys.AtkPct ? "공격 +" + Mathf.RoundToInt(value * 100f) + "%" : "이동 +" + Mathf.RoundToInt(value * 100f) + "%", SkillFx.NameColor);
}
void AddTimed(UnitSkills s, SkillEffect e, HashSet<Unit> targets, int rounds)
{
TimedEffects.Add(new Timed { Owner = s, Faction = s.Unit.Faction, Def = s.Active, E = e, Targets = targets, Rounds = Mathf.Max(1, rounds) });
}
/// <summary>관우 청룡언월 · 장비 장판 일갈 · 능통: 반경 p1 안 상대 전원 = 공격력 × p2 피해(0 = 없음) → 바깥으로 p3 밀기 · 명중 상태 이상(피해 없어도).</summary>
IEnumerator AoeStrike(UnitSkills s, SkillEffect e, Dictionary<Unit, float> moved)
{
var u = s.Unit;
float radius = e.P;
var targets = new List<Unit>();
foreach (var t in battle.AliveUnits(Other(u.Faction)))
if (Vector2.Distance(u.Position, t.Position) <= radius + t.Radius) targets.Add(t);
Fx.Burst(u.Position, radius, SkillFx.FearColor);
if (u.View != null && !Sim.Active) u.View.PlayAttack();
foreach (var t in targets)
{
bool crit = false;
if (e.Get(1) > 0f)
{
int d = Combat.SkillDamage(Cfg, u, t, e.Get(1), battle.Rng);
crit = Combat.LastCrit;
d = Hit(t, d, u, HitKind.Skill);
Fx.Popup(t.Position, crit ? d + "!" : d.ToString(), Color.white);
}
else ApplyHitStatuses(u, t, false, HitKind.Skill);
}
yield return Fx.Wait(0.15f);
float push = e.Get(2);
if (push > 0.01f)
{
targets.Sort((a, b) => Vector2.Distance(u.Position, b.Position).CompareTo(Vector2.Distance(u.Position, a.Position))); // 바깥부터
foreach (var t in targets)
{
if (!t.IsAlive) continue;
Vector2 dir = t.Position - u.Position;
dir = dir.sqrMagnitude > 1e-6f ? dir.normalized : Vector2.up;
float m = ShoveRaw(t, dir, push * SkillMods.KnockbackTakenMult(t, true), u);
if (m > 0f) AddMoved(moved, t, m);
}
yield return Fx.Wait(0.2f);
}
battle.NotifyChanged();
}
static void AddMoved(Dictionary<Unit, float> m, Unit u, float d)
{
float v;
m.TryGetValue(u, out v);
m[u] = v + d;
}
/// <summary>밀기(출혈은 호출 쪽이 모아서) — 반환 = 움직인 거리.</summary>
float ShoveRaw(Unit t, Vector2 dir, float force, Unit by)
{
float slide = Combat.FreeTravel(t, t.Position, dir, force, true, true, by);
if (slide <= 0.01f) return 0f;
Vector2 start = t.Position;
Vector2 end = battle.Board.ClampInside(start + dir * slide, t.Radius);
if (t.View != null && !Sim.Active) t.View.SetFacing(dir);
t.Position = end;
if (!Sim.Active)
{
t.transform.position = new Vector3(start.x, start.y, 0f);
Fx.Run(Fx.MoveAlong(t.transform, start, end, Cfg.moveSpeed, null));
t.Position = end;
}
return Vector2.Distance(start, end);
}
/// <summary>사마의: 행동 반경 안 상대 전원 = 공격력 × p1 → 준 피해 합 × p2 를 반경 안 다른 아군 각각 회복.</summary>
void AoeDrain(UnitSkills s, SkillEffect e)
{
var u = s.Unit;
float r = SkillMods.BaseReach(u) * SkillMods.Product(u, SkillKeys.SplashRadiusMult, true); // 응시낭고 광역 배율(Fable 검토 B3)
int total = 0;
Fx.Burst(u.Position, r, Fx.SplashColor);
foreach (var t in battle.AliveUnits(Other(u.Faction)))
{
if (!SkillMods.Within(u, t, r)) continue;
int d = Hit(t, Combat.SkillDamage(Cfg, u, t, e.P, battle.Rng), u, HitKind.Skill);
Fx.Popup(t.Position, d.ToString(), Color.white);
total += d;
}
int amt = Mathf.RoundToInt(total * e.Get(1));
foreach (var a in battle.AliveUnits(u.Faction))
if (a != u && SkillMods.Within(u, a, r)) Heal(a, amt, u);
}
/// <summary>손상향 궁요희: 사거리 안 상대 전원에게 화살(공격력 × p1 · 치명타 판정) → 바깥으로 p2 밀기 · 치명타 = 출혈(조건 칸).</summary>
IEnumerator Volley(UnitSkills s, SkillEffect e, Dictionary<Unit, float> moved)
{
var u = s.Unit;
float range = SkillMods.Range(u);
var targets = new List<Unit>();
foreach (var t in battle.AliveUnits(Other(u.Faction)))
if (Vector2.Distance(u.Position, t.Position) - t.Radius - u.Radius <= range) targets.Add(t);
if (u.View != null && !Sim.Active) u.View.PlayAttack();
foreach (var t in targets) Fx.Run(Fx.Arrow(u.Position, t.Position));
yield return Fx.Wait(0.2f);
foreach (var t in targets)
{
if (!t.IsAlive) continue;
int d = Combat.SkillDamage(Cfg, u, t, e.P, battle.Rng);
bool crit = Combat.LastCrit;
d = Hit(t, d, u, HitKind.Skill);
Fx.Popup(t.Position, crit ? d + "!" : d.ToString(), crit ? new Color(1f, 0.7f, 0.2f) : Color.white);
if (t.IsAlive && e.Get(1) > 0.01f)
{
Vector2 dir = (t.Position - u.Position).normalized;
float m = ShoveRaw(t, dir, e.Get(1) * SkillMods.KnockbackTakenMult(t, true), u);
if (m > 0f) AddMoved(moved, t, m);
}
}
yield return Fx.Wait(0.15f);
}
/// <summary>초선 미인계: 행동 반경 안 상대 남성(p2 1) = p1 턴 행동 불가 + 자신 쪽으로 끌려옴(유닛·장애물에 막힘).</summary>
IEnumerator CharmPull(UnitSkills s, SkillEffect e, Dictionary<Unit, float> moved)
{
var u = s.Unit;
float r = SkillMods.BaseReach(u);
bool maleOnly = e.Get(1, 1f) >= 0.5f;
var targets = new List<Unit>();
foreach (var t in battle.AliveUnits(Other(u.Faction)))
if (SkillMods.Within(u, t, r) && (!maleOnly || !t.Female)) targets.Add(t);
targets.Sort((a, b) => Vector2.Distance(u.Position, a.Position).CompareTo(Vector2.Distance(u.Position, b.Position)));
Fx.Burst(u.Position, r, SkillFx.FearColor);
if (targets.Count == 0) AddLog(s.Active.NameKo + " 대상 없음 — 쿨만 소모");
foreach (var t in targets)
{
ApplyStun(t, Mathf.Max(1, Mathf.RoundToInt(e.P)), "행동 불가");
Vector2 to = u.Position - t.Position;
float want = to.magnitude - u.Radius - t.Radius - 0.05f;
if (want <= 0.05f) continue;
float m = ShoveRaw(t, to.normalized, want, null);
if (m > 0f) AddMoved(moved, t, m);
}
yield return Fx.Wait(0.25f);
}
/// <summary>곽가 십승십패: 이미 행동한 다른 아군 n 기에 재행동(없으면 임의의 다른 아군이 행동 뒤 재행동 1회).</summary>
void GrantReaction(Unit u, int n)
{
var acted = new List<Unit>();
var others = new List<Unit>();
foreach (var a in battle.AliveUnits(u.Faction))
{
if (a == u) continue;
others.Add(a);
if (a.ActedThisTurn) acted.Add(a);
}
for (int i = 0; i < n; i++)
{
var pool = acted.Count > 0 ? acted : others;
if (pool.Count == 0) return;
var pick = pool[battle.Rng.Next(pool.Count)];
pool.Remove(pick);
if (pick.ActedThisTurn) pick.GrantReAction();
else Ensure(pick).ReactCredits++; // 스킬 없는 병사도(Fable 검토 A2)
Text(pick, "재행동", SkillFx.NameColor);
}
}
// ---------- 변진(장합): 목표 지점 탭 ----------
public static float TeleportRadius(UnitSkills s)
{
if (s == null || s.ActiveEffects == null) return 0f;
foreach (var e in s.ActiveEffects)
if (e.Key == SkillKeys.Teleport) return e.P;
return 0f;
}
/// <summary>목표 지점이 반경 안 빈 곳인가(벽·유닛과 겹치지 않음 · 전장 안).</summary>
public bool TeleportValid(Unit u, Vector2 w)
{
var s = Of(u);
if (s == null) return false;
if (Vector2.Distance(u.Position, w) > TeleportRadius(s) + 1e-3f) return false;
if ((battle.Board.ClampInside(w, u.Radius) - w).sqrMagnitude > 1e-4f) return false;
Physics2D.SyncTransforms();
foreach (var c in Physics2D.OverlapCircleAll(w, u.Radius))
{
if (c.GetComponent<BoardWall>() != null || GimmickLayer.BlocksMove(c)) return false;
var o = c.GetComponent<Unit>();
if (o != null && o != u && o.IsAlive) return false;
}
return true;
}
/// <summary>변진: 대상 무시하고 그 지점으로 → 이미 행동했으면 재행동(extra_action).</summary>
public IEnumerator TeleportRoutine(UnitSkills s, Vector2 w)
{
var u = s.Unit;
Announce(s, s.Active);
s.ReadyTurn = battle.TurnNumber + CooldownOf(s);
if (u.View != null) u.View.SetFacing(w - u.Position);
yield return Fx.MoveAlong(u.transform, u.Position, w, battle.Config.moveSpeed * 2f, null);
u.Position = w;
if (s.Active.Has(SkillKeys.ExtraAction) && u.ActedThisTurn)
{
u.GrantReAction();
Text(u, "재행동", SkillFx.NameColor);
}
battle.NotifyChanged();
}
// ---------- 연출 · HUD 기록 ----------
/// <summary>액티브 발동 = 유닛 위 스킬 이름 + 플래시 + HUD 한 줄 · 주유 패시브 = 시전 HP 소모.</summary>
public void Announce(UnitSkills s, SkillDef def, Vector2 dir = default(Vector2))
{
if (s == null || def == null) return;
if (def.IsActive) BattleEvents.Raise(BattleEvents.ActiveUses, s.Unit);
if (s.Unit != null) SkillFx.Announce(s.Unit, def.NameKo);
if (s.Unit != null && def.IsActive) SkillVfx.Play(s.Unit, def.Id, SkillVfx.EventCast, dir);
AddLog("액티브 · " + (s.Unit != null ? s.Unit.DisplayName : "") + " 「" + def.NameKo + "」");
if (def.IsActive && s.Unit != null)
{
var cost = Find(s.PassiveEffects, SkillKeys.SkillHpCost);
if (cost != null)
{
PayHp(s.Unit, cost.P);
NotePassive(s, s.Passive);
}
}
}
/// <summary>패시브 발동 HUD 한 줄(같은 행동 안에서는 스킬마다 1번).</summary>
public void NotePassive(UnitSkills s, SkillDef def)
{
if (s == null || def == null || s.Unit == null) return;
string key = s.Unit.GetInstanceID() + "/" + def.Id;
if (!noted.Add(key)) return;
AddLog("패시브 · " + s.Unit.DisplayName + " 「" + def.NameKo + "」");
}
public void AddLog(string text)
{
if (Sim.Active) return;
Log.Add(new LogLine { Text = text, Time = Time.unscaledTime });
if (Log.Count > 8) Log.RemoveAt(0);
}
static void Text(Unit u, string text, Color c)
{
if (Sim.Active || u == null) return;
Fx.Popup(u.Position + new Vector2(0f, 0.55f), text, c);
}
/// <summary>최근 seconds 초 안의 기록(최신이 마지막).</summary>
public List<string> RecentLog(float seconds, int max)
{
var res = new List<string>();
float now = Time.unscaledTime;
for (int i = Log.Count - 1; i >= 0 && res.Count < max; i--)
if (now - Log[i].Time <= seconds) res.Insert(0, Log[i].Text);
return res;
}
}
/// <summary>스킬 연출(이름 글자 · 플래시).</summary>
public static class SkillFx
{
public static readonly Color NameColor = new Color(1f, 0.85f, 0.3f);
public static readonly Color FlashColor = new Color(1f, 0.9f, 0.45f, 0.55f);
public static readonly Color GuardColor = new Color(0.55f, 0.85f, 1f);
public static readonly Color FearColor = new Color(1f, 0.4f, 0.25f, 0.4f);
public static readonly Color CostColor = new Color(1f, 0.55f, 0.35f);
public static readonly Color HealColor = new Color(0.45f, 1f, 0.5f);
public static void Announce(Unit u, string name)
{
if (Sim.Active) return;
Fx.Burst(u.Position, u.Radius * 2.2f, FlashColor);
Fx.Run(NameRoutine(u.Position + new Vector2(0f, u.ArtHeight > 0f ? u.ArtHeight * 0.6f : u.Radius + 0.6f), name));
}
static IEnumerator NameRoutine(Vector2 pos, string text)
{
var tm = TextUtil.CreateWorldText(null, text, 0.55f, NameColor, 21);
tm.gameObject.name = "SkillName";
var tr = tm.transform;
float t = 0f;
const float dur = 1.1f;
while (t < dur)
{
t += Time.deltaTime;
float k = Mathf.Clamp01(t / dur);
tr.position = new Vector3(pos.x, pos.y + 0.4f * k, 0f);
var c = NameColor;
c.a = k < 0.7f ? 1f : 1f - (k - 0.7f) / 0.3f;
tm.color = c;
yield return null;
}
Object.Destroy(tm.gameObject);
}
}
}