2026-11-05 14:31:27 +00:00
|
|
|
|
using System.Collections;
|
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
|
using UnityEngine;
|
|
|
|
|
|
|
|
|
|
|
|
namespace TKS
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>유닛 1기의 스킬 상태(액티브 쿨·예약 · 패시브 · 행동 뒤에도 남는 값).</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public class UnitSkills
|
|
|
|
|
|
{
|
|
|
|
|
|
public Unit Unit;
|
|
|
|
|
|
public string HeroId = "";
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>무장(영웅·보스·엘리트 장수) — 남성·여성 「영웅」 조건 판정.</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
// ---------- 전투 중 바뀌는 값(가상 실행이 떠 두고 되돌림 · CopyState) ----------
|
|
|
|
|
|
/// <summary>액티브를 쓸 수 있는 첫 턴(전투 시작 = 1 · 사용 턴 + 쿨).</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public int ReadyTurn = 1;
|
|
|
|
|
|
/// <summary>next_action 예약(다음 슬링샷 1회) · 행동 전 다시 탭 = 취소.</summary>
|
|
|
|
|
|
public bool Reserved;
|
|
|
|
|
|
/// <summary>예약한 효과가 지금 걸려 있다(그 유닛의 행동 처리·조준 미리보기 동안만).</summary>
|
|
|
|
|
|
public bool Armed;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>즉시형 액티브를 시전하는 중(그 효과·조건 armed 를 질의에 보임).</summary>
|
|
|
|
|
|
public bool Casting;
|
|
|
|
|
|
/// <summary>보병 목표점 명중 추가 이동(유지 · PD 확인 대상).</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public int BonusMovesLeft;
|
|
|
|
|
|
public bool BonusAttack;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <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>();
|
2026-11-05 14:31:27 +00:00
|
|
|
|
|
|
|
|
|
|
public bool HasActive
|
|
|
|
|
|
{
|
|
|
|
|
|
get { return Active != null && ActiveEffects != null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public int CooldownLeft(int turn)
|
|
|
|
|
|
{
|
|
|
|
|
|
return Mathf.Max(0, ReadyTurn - turn);
|
|
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>스킬 효과 1건이 어느 유닛·스킬에서 왔는가(SkillMods 질의 결과).</summary>
|
|
|
|
|
|
public struct SkillHit
|
|
|
|
|
|
{
|
|
|
|
|
|
public UnitSkills Owner;
|
|
|
|
|
|
public SkillDef Def;
|
|
|
|
|
|
public SkillEffect E;
|
|
|
|
|
|
public bool Passive;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
2026-11-10 11:36:58 +00:00
|
|
|
|
/// <summary>같은 편도 닿으면 피해·상태 이상(육손 불 장벽 · PD 2026-11-10 #1112 결정 ⑬).</summary>
|
|
|
|
|
|
public bool HitsAllies;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
public readonly HashSet<Unit> Touched = new HashSet<Unit>();
|
|
|
|
|
|
|
2026-11-10 11:36:58 +00:00
|
|
|
|
public bool Affects(Unit u)
|
|
|
|
|
|
{
|
|
|
|
|
|
return u != null && (u.Faction != Faction || HitsAllies);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
public float Distance(Vector2 p)
|
|
|
|
|
|
{
|
|
|
|
|
|
return Combat.SegmentPointDistance(A, B, p);
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// 전투 1판의 스킬 처리(2026-11-10 #1112 · PD 엑셀 새 기획 · 효과 표 skill_effects.csv): 유닛별 스킬 상태 · 시한 효과(라운드) · 상태 이상 · 지대 ·
|
|
|
|
|
|
/// 행동 전후 훅(예약 장착·쿨·재행동·출혈·중독) · 피해 적용(무적·무효화·호위·부활·흡혈·명중 상태 이상·처치 보상) · 회복 · 즉시형 액티브 · 턴 훅 · HUD 한 줄. 수치 질의 = SkillMods.
|
2026-11-05 14:31:27 +00:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class SkillSystem
|
|
|
|
|
|
{
|
|
|
|
|
|
public static SkillSystem Current;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>테스트·자동 플레이: 행동 중 방향 탭(조조·하후돈)을 기다리지 않고 기본값(슬링 방향 · 제자리)으로.</summary>
|
|
|
|
|
|
public static bool AutoTap;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
|
|
|
|
|
|
public class Timed
|
|
|
|
|
|
{
|
|
|
|
|
|
public UnitSkills Owner;
|
|
|
|
|
|
public Faction Faction;
|
|
|
|
|
|
public SkillDef Def;
|
|
|
|
|
|
public SkillEffect E;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>대상 고정(range = 시전 순간 범위 안 같은 편 · 버프 받은 아군) · null = 대상 칸 규칙대로.</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public HashSet<Unit> Targets;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>남은 라운드(주인 진영 턴 시작마다 1 감소 · 0 = 끝).</summary>
|
|
|
|
|
|
public int Rounds = 1;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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];
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
|
|
|
|
|
|
/// <summary>장합 변진: 목표 지점 탭 대기 중인 유닛.</summary>
|
2026-11-10 10:00:18 +00:00
|
|
|
|
public Unit PendingTeleport
|
|
|
|
|
|
{
|
|
|
|
|
|
get { return PendingKind == TapKind.Teleport ? PendingUnit : null; }
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
// 스킬 이펙트(SkillVfx · 2026-11-09): 지금 행동 중인 유닛 · 이펙트 표 키 · 돌진에 붙은 이펙트 · 이번 행동에 hit 이펙트를 띄운 대상
|
2026-11-09 14:12:52 +00:00
|
|
|
|
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;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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));
|
2026-11-09 14:12:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>슬링샷 직진(돌진) 구간 끝 — 돌진 이펙트(travel)만 멈춤.</summary>
|
2026-11-10 04:19:44 +00:00
|
|
|
|
public void DashEnd(Unit u)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (u == null || vfxActor != u) return;
|
|
|
|
|
|
foreach (var h in travelFx) SkillVfx.Stop(h);
|
|
|
|
|
|
travelFx.Clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>같은 행동 안의 새 이동 구간(복귀 · 튕겨 다시 돌진) — 그 방향으로 돌진 이펙트를 다시 재생.</summary>
|
2026-11-10 04:19:44 +00:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-09 14:12:52 +00:00
|
|
|
|
void VfxEnd()
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var h in travelFx) SkillVfx.Stop(h);
|
|
|
|
|
|
travelFx.Clear();
|
|
|
|
|
|
vfxActor = null;
|
|
|
|
|
|
vfxKeys.Clear();
|
|
|
|
|
|
vfxHit.Clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public SkillSystem(BattleManager b)
|
|
|
|
|
|
{
|
|
|
|
|
|
battle = b;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public BattleManager Battle
|
|
|
|
|
|
{
|
|
|
|
|
|
get { return battle; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public IReadOnlyList<UnitSkills> Entries
|
|
|
|
|
|
{
|
|
|
|
|
|
get { return entries; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
BalanceConfig Cfg
|
|
|
|
|
|
{
|
|
|
|
|
|
get { return battle.Config; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 14:31:27 +00:00
|
|
|
|
// ---------- 부착 ----------
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>스킬 부착(액티브·패시브 id · 레벨 · ★5 패시브 배율 · 무장 여부). 자기 최대 HP 패시브(hp_max_pct 대상 = 자신)는 바로 반영.</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-11-05 15:22:54 +00:00
|
|
|
|
float hp = 0f;
|
|
|
|
|
|
if (s.PassiveEffects != null)
|
|
|
|
|
|
foreach (var e in s.PassiveEffects)
|
2026-11-10 10:00:18 +00:00
|
|
|
|
if (e.Key == SkillKeys.HpMaxPct && e.Scope == SkillScope.Self) hp += e.P; // 같은 편 대상(진영)은 BeginBattle 에서
|
2026-11-05 15:22:54 +00:00
|
|
|
|
ScaleMaxHp(u, hp);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
return s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 15:22:54 +00:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>액티브 쿨 = 표 cooldown · 최소 1.</summary>
|
2026-11-05 15:22:54 +00:00
|
|
|
|
public int CooldownOf(UnitSkills s)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (s == null || s.Active == null) return 1;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
return Mathf.Max(1, s.Active.Cooldown);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>heroes.csv 영웅 id 로 부착(테스트·도구용) · 진영·성별도 유닛에 기록.</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
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;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
if (u != null)
|
|
|
|
|
|
{
|
|
|
|
|
|
u.Camp = h.faction ?? "";
|
|
|
|
|
|
u.Female = h.Female;
|
|
|
|
|
|
u.HeroId = h.id;
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>적 무장(보스·엘리트) = heroes.csv 같은 아트·이름 영웅의 패시브(Lv1) · withActive = 액티브도(적 AI · 1부 보스만 · PD #1098).</summary>
|
2026-11-05 16:20:07 +00:00
|
|
|
|
public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e, bool withActive = false)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
u.Camp = h.faction ?? "";
|
|
|
|
|
|
u.Female = h.Female;
|
|
|
|
|
|
u.HeroId = h.id;
|
2026-11-05 16:20:07 +00:00
|
|
|
|
return Attach(u, withActive ? h.skillActive : null, 1, h.skillPassive, 1, 1f, true);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
public static bool IsSummon(Unit u)
|
|
|
|
|
|
{
|
|
|
|
|
|
return u != null && summons.Contains(u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public void Clear()
|
|
|
|
|
|
{
|
|
|
|
|
|
map.Clear();
|
|
|
|
|
|
entries.Clear();
|
|
|
|
|
|
TimedEffects.Clear();
|
|
|
|
|
|
noted.Clear();
|
2026-11-10 10:00:18 +00:00
|
|
|
|
ClearTap();
|
2026-11-05 14:31:27 +00:00
|
|
|
|
startCount[0] = startCount[1] = 0;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
ClearStatuses();
|
|
|
|
|
|
ClearZones();
|
|
|
|
|
|
summons.Clear();
|
|
|
|
|
|
Ctx = null;
|
2026-11-09 14:12:52 +00:00
|
|
|
|
VfxEnd();
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public void BeginBattle()
|
|
|
|
|
|
{
|
|
|
|
|
|
Prune();
|
|
|
|
|
|
TimedEffects.Clear();
|
|
|
|
|
|
noted.Clear();
|
2026-11-10 10:00:18 +00:00
|
|
|
|
ClearTap();
|
|
|
|
|
|
ClearStatuses();
|
|
|
|
|
|
ClearZones();
|
|
|
|
|
|
Ctx = null;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
startCount[0] = battle.AliveCount(Faction.Player);
|
|
|
|
|
|
startCount[1] = battle.AliveCount(Faction.Enemy);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
foreach (var u in battle.Units)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (u == null || !u.IsAlive) continue;
|
|
|
|
|
|
float team = 0f;
|
|
|
|
|
|
foreach (var h in SkillMods.Get(u, SkillKeys.HpMaxPct))
|
2026-11-10 10:00:18 +00:00
|
|
|
|
if (h.E.Scope != SkillScope.Self) team += h.E.P;
|
2026-11-05 15:22:54 +00:00
|
|
|
|
ScaleMaxHp(u, team);
|
|
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
foreach (var s in entries) s.ResetBattle();
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
// ---------- 가상 실행(적 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 14:31:27 +00:00
|
|
|
|
// ---------- 턴 ----------
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>적 턴 시작(동기): 쓰지 않은 예약·추가 이동 정리.</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public void OnEnemyTurnStart()
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
ClearTap();
|
2026-11-05 14:31:27 +00:00
|
|
|
|
foreach (var s in entries)
|
|
|
|
|
|
{
|
|
|
|
|
|
s.Reserved = false;
|
|
|
|
|
|
s.Armed = false;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
s.Casting = false;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
s.BonusMovesLeft = 0;
|
|
|
|
|
|
s.BonusAttack = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>아군 턴 시작(동기) — 남은 것 없음(라운드 처리는 TurnBegin).</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public void OnPlayerTurnStart()
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 11:36:58 +00:00
|
|
|
|
// 지속 회복(방통 봉추의 계) = 라운드 감소·만료 전에 — 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-10 11:36:58 +00:00
|
|
|
|
foreach (var z in summons)
|
|
|
|
|
|
if (z == null || z.Faction == side) gone.Add(z); // 망령(스킬 항목 없음)은 소환 목록에서 직접
|
2026-11-10 10:00:18 +00:00
|
|
|
|
foreach (var z in gone)
|
|
|
|
|
|
{
|
2026-11-10 11:36:58 +00:00
|
|
|
|
if (z != null && z.IsAlive) yield return ZombieAct(z); // 망령 = 자기 턴 시작에 1번 공격 후 소멸(PD #1112 결정 ⑩)
|
2026-11-10 10:00:18 +00:00
|
|
|
|
summons.Remove(z);
|
2026-11-10 11:36:58 +00:00
|
|
|
|
if (z != null) battle.RemoveUnit(z);
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-11-10 11:36:58 +00:00
|
|
|
|
if (z.Faction == side && !z.HitsAllies) continue;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
foreach (var s in entries)
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
return any;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
static Faction Other(Faction f)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 액티브: 상태 · 탭 ----------
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-11-10 10:00:18 +00:00
|
|
|
|
ClearTap();
|
2026-11-05 14:31:27 +00:00
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
PendingKind = TapKind.Teleport;
|
|
|
|
|
|
PendingUnit = u;
|
|
|
|
|
|
PendingCenter = u.Position;
|
|
|
|
|
|
PendingRadius = TeleportRadius(s);
|
|
|
|
|
|
return s.Active.NameKo + " — 반경 " + PendingRadius.ToString("0.#") + " 안 빈 곳을 탭(다른 곳 = 취소)";
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
battle.RunSkillRoutine(Instant(s));
|
|
|
|
|
|
return s.Active.NameKo + " 발동!";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 14:31:27 +00:00
|
|
|
|
// ---------- 행동 훅(ActionResolver.Resolve) ----------
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>행동 시작: 예약한 next_action 효과를 장착(이동만인 추가 이동은 제외) · 발동 연출 · 행동 맥락(돌진 거리 비율).</summary>
|
|
|
|
|
|
public void BeginAction(Unit u, bool moveOnly, ActionOutcome o, Vector2 dir = default(Vector2), float slingDist = 0f)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
|
|
|
|
|
noted.Clear();
|
|
|
|
|
|
var s = Of(u);
|
2026-11-09 14:12:52 +00:00
|
|
|
|
bool armed = s != null && s.Reserved && s.HasActive && !moveOnly;
|
|
|
|
|
|
if (armed)
|
|
|
|
|
|
{
|
|
|
|
|
|
s.Armed = true;
|
|
|
|
|
|
o.SkillUsed = s.Active;
|
|
|
|
|
|
Announce(s, s.Active, dir);
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-11-09 14:12:52 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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; // 재행동 슬링 상한(감녕·고순)은 조준에서 이미 썼음 — 이 행동이 새로 주면 다시 생김
|
2026-11-09 14:12:52 +00:00
|
|
|
|
VfxBegin(u, armed ? s.Active.Id : null, moveOnly ? null : SkillVfx.BasicKey(u.Type), dir, true);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 행동 끝: 움직인 유닛 출혈 · 행동한 유닛 중독 · 지대 닿음 · 초선 다중 남성 기절 · 조인·이각·우금 패시브 ·
|
|
|
|
|
|
/// (장착) 재행동 · 아군 재행동(우금) · 지형 재행동(맹획) · 지속 효과 시작 · 예약 소모 · 쿨 → 여몽 연쇄 → 장로 망령.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public IEnumerator EndAction(Unit u, ActionOutcome o)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
|
|
|
|
|
var s = Of(u);
|
2026-11-10 10:00:18 +00:00
|
|
|
|
var ctx = Ctx;
|
2026-11-09 14:12:52 +00:00
|
|
|
|
if (vfxActor == u) VfxEnd();
|
2026-11-10 10:00:18 +00:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
|
|
|
|
|
foreach (var e in s.ActiveEffects)
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
s.Armed = false;
|
|
|
|
|
|
s.Reserved = false;
|
|
|
|
|
|
s.ReadyTurn = battle.TurnNumber + CooldownOf(s);
|
|
|
|
|
|
if (u.View != null) u.View.Refresh();
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
|
|
|
|
|
|
// 여몽 백의도강 연쇄: 피해 = +재행동 · 처치 = +재행동(남은 최대까지)
|
|
|
|
|
|
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)
|
2026-11-10 11:36:58 +00:00
|
|
|
|
SpawnZombie(u, dead);
|
|
|
|
|
|
yield return null;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 추가 이동(보병 목표점 명중) ----------
|
2026-11-05 14:31:27 +00:00
|
|
|
|
|
|
|
|
|
|
public bool BonusCanAttack(Unit u)
|
|
|
|
|
|
{
|
|
|
|
|
|
var s = Of(u);
|
|
|
|
|
|
return s != null && s.BonusAttack;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>행동 뒤 추가 이동을 (또) 주는가 — 처음 명중 = 1회 · 추가 이동 뒤 = 남은 횟수.</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>이번 적 턴에 교전 반응이 없는가 — 기절은 지형 기믹 행동 불가(GimmickLayer.Skips)가 처리.</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public bool Suppressed(Unit e)
|
|
|
|
|
|
{
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>교전 범위 가산(새 기획에 없음 = 0).</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public float AggroAdd(Faction actingFaction)
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
return 0f;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
// ---------- 피해 적용 ----------
|
2026-11-05 14:31:27 +00:00
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 피해 적용(by = 준 유닛 · kind = 종류): 무적(시한) → 무효화(조범) → 호위(관평·강유 · 강유 = 반격) → 고육계 누적 → 부활(유비·맹획) → HP 감소 →
|
|
|
|
|
|
/// 준 쪽 보상(흡혈 · 사마의 흡수 · 명중 상태 이상 · 장비 연속 기절 · 하후은 첫 명중 추가 피해) → 맞은 쪽 반응(서황 도끼 · 간옹 기절 · 원술 혼란) → 처치 처리. 반환 = 실제 받은 피해.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public int Hit(Unit d, int dmg, Unit by = null, HitKind kind = HitKind.Attack)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
|
|
|
|
|
if (d == null || !d.IsAlive || dmg <= 0) return 0;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
bool crit = (kind == HitKind.Attack || kind == HitKind.Skill || kind == HitKind.Counter) && Combat.LastCrit;
|
2026-11-09 14:12:52 +00:00
|
|
|
|
if (vfxActor != null && d.Faction != vfxActor.Faction && vfxHit.Add(d))
|
2026-11-10 04:19:44 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
foreach (var k in vfxKeys) SkillVfx.Play(vfxActor, k, SkillVfx.EventHit, Vector2.zero, d);
|
|
|
|
|
|
HitFeel.OnHit(vfxActor, d);
|
2026-11-10 04:19:44 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
var ds = Of(d);
|
|
|
|
|
|
if (kind != HitKind.Self && Invulnerable(d))
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
Text(d, "무적", SkillFx.GuardColor);
|
|
|
|
|
|
return 0;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
2026-11-10 11:36:58 +00:00
|
|
|
|
if (kind != HitKind.Self && ds != null && ds.PassiveEffects != null) // 조범 처세 「어떤 피해든」 = 상태 이상·지대 피해 포함(Fable 검토 C3)
|
2026-11-10 10:00:18 +00:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
2026-11-10 11:36:58 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
d.TakeDamage(dmg);
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>스킬 시스템이 없어도 쓰는 피해 적용.</summary>
|
2026-11-10 10:00:18 +00:00
|
|
|
|
public static int Deal(Unit d, int dmg, Unit by = null, HitKind kind = HitKind.Attack)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
if (Current != null) return Current.Hit(d, dmg, by, kind);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
if (d == null) return 0;
|
|
|
|
|
|
d.TakeDamage(dmg);
|
|
|
|
|
|
return dmg;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-11-10 11:36:58 +00:00
|
|
|
|
// 중독 = 한 번 명중에 1회만(액티브·패시브가 함께 걸려도 중첩 +1 · 가장 긴 턴·큰 비율 · Fable 검토 C1)
|
|
|
|
|
|
int pTurns = 0;
|
|
|
|
|
|
float pPct = 0f;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
foreach (var h in SkillMods.Get(by, SkillKeys.HitPoison, d, crit))
|
|
|
|
|
|
if (Roll(h.E.Get(2, 1f)))
|
|
|
|
|
|
{
|
2026-11-10 11:36:58 +00:00
|
|
|
|
pTurns = Mathf.Max(pTurns, Mathf.RoundToInt(h.E.P));
|
|
|
|
|
|
pPct = Mathf.Max(pPct, h.E.Get(1, 0.02f));
|
2026-11-10 10:00:18 +00:00
|
|
|
|
NoteHit(h);
|
|
|
|
|
|
}
|
2026-11-10 11:36:58 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 11:36:58 +00:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 장로 한중의 교주: 처치한 적이 체력 1 로 같은 편 망령이 된다(조작 불가 · 상대 턴 동안 공격받을 수 있음) →
|
|
|
|
|
|
/// 그 진영 다음 턴 시작(TurnBegin)에 가장 가까운 상대를 1번 공격하고 사라짐(PD 2026-11-10 #1112 결정 ⑩).
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
void SpawnZombie(Unit owner, Unit dead)
|
2026-11-10 10:00:18 +00:00
|
|
|
|
{
|
2026-11-10 11:36:58 +00:00
|
|
|
|
if (owner == null || dead == null || battle.Board == null) return;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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));
|
2026-11-10 11:36:58 +00:00
|
|
|
|
z.MarkActed();
|
2026-11-10 10:00:18 +00:00
|
|
|
|
summons.Add(z);
|
|
|
|
|
|
if (z.View != null) z.View.Refresh();
|
|
|
|
|
|
Text(z, "망령", SkillFx.NameColor);
|
|
|
|
|
|
if (s != null) NotePassive(s, s.Passive);
|
2026-11-10 11:36:58 +00:00
|
|
|
|
AddLog("망령 " + dead.DisplayName + " — 다음 턴 시작에 1번 공격 후 소멸");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>망령의 자동 공격 1회(가장 가까운 상대 쪽으로 슬링 최대 거리까지).</summary>
|
|
|
|
|
|
IEnumerator ZombieAct(Unit z)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (z == null || !z.IsAlive || battle.Board == null) yield break;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
Unit foe = null;
|
|
|
|
|
|
float best = float.MaxValue;
|
2026-11-10 11:36:58 +00:00
|
|
|
|
foreach (var t in battle.AliveUnits(Other(z.Faction)))
|
2026-11-10 10:00:18 +00:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 지대(주유 불꽃 · 육손 불 장벽 · 장로 독지대) ----------
|
|
|
|
|
|
|
2026-11-10 11:36:58 +00:00
|
|
|
|
public SkillZone AddZone(UnitSkills owner, Vector2 a, Vector2 b, float radius, int rounds, float ratio, SkillEffect status, Color color, bool hitsAllies = false)
|
2026-11-10 10:00:18 +00:00
|
|
|
|
{
|
2026-11-10 11:36:58 +00:00
|
|
|
|
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 };
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-11-10 11:36:58 +00:00
|
|
|
|
if (u == null || !u.IsAlive || !z.Affects(u) || !z.Touched.Add(u)) return;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
var src = z.Owner != null ? z.Owner.Unit : null;
|
|
|
|
|
|
if (z.Ratio > 0f && src != null)
|
|
|
|
|
|
{
|
2026-11-10 11:36:58 +00:00
|
|
|
|
int d = Mathf.Max(1, Mathf.RoundToInt(Combat.SkillDamage(Cfg, src, u, z.Ratio, battle.Rng))); // 아군도 같은 피해(PD 결정 ⑬)
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-11-10 11:36:58 +00:00
|
|
|
|
if (z.StatusKey == SkillKeys.HitBurn || z.StatusKey == SkillKeys.ZoneBurn) ApplyBurn(u, Mathf.RoundToInt(z.Status.P), z.Status.Get(1, 0.05f));
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
2026-11-10 11:36:58 +00:00
|
|
|
|
if (u == null || !u.IsAlive || !z.Affects(u)) continue;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 14:31:27 +00:00
|
|
|
|
// ---------- 즉시형 액티브 ----------
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>즉시 발동(행동 소모 없음): 시전 중 효과 장착 → 처리기 → 지속 효과(dur) → 쿨.</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public IEnumerator Instant(UnitSkills s)
|
|
|
|
|
|
{
|
|
|
|
|
|
var u = s.Unit;
|
|
|
|
|
|
Announce(s, s.Active);
|
2026-11-10 10:00:18 +00:00
|
|
|
|
VfxBegin(u, s.Active.Id, null, Vector2.zero, false);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
s.ReadyTurn = battle.TurnNumber + CooldownOf(s);
|
2026-11-10 10:00:18 +00:00
|
|
|
|
s.Casting = true;
|
|
|
|
|
|
var prevCtx = Ctx;
|
2026-11-10 11:36:58 +00:00
|
|
|
|
Ctx = new ActionCtx { Actor = u, Origin = u.Position, Seq = s.ActionSeq }; // 즉시형은 행동 번호를 올리지 않음(장비 연속 판정 · Fable 검토 C2)
|
2026-11-10 10:00:18 +00:00
|
|
|
|
var moved = new Dictionary<Unit, float>();
|
2026-11-05 14:31:27 +00:00
|
|
|
|
foreach (var e in s.ActiveEffects)
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
if (e.Dur > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
AddTimed(s, e, e.Scope == SkillScope.Range ? InRange(u) : null, e.Dur);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
switch (e.Key)
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
case SkillKeys.AoeStrike:
|
|
|
|
|
|
yield return AoeStrike(s, e, moved);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
break;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
case SkillKeys.AoeDrain:
|
|
|
|
|
|
AoeDrain(s, e);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
break;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
case SkillKeys.Volley:
|
|
|
|
|
|
yield return Volley(s, e, moved);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
break;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
case SkillKeys.CharmPull:
|
|
|
|
|
|
yield return CharmPull(s, e, moved);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
break;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
break;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
case SkillKeys.Cleanse:
|
|
|
|
|
|
foreach (var t in e.Scope == SkillScope.Self ? new HashSet<Unit> { u } : InRange(u)) Cleanse(t);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
break;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
case SkillKeys.HealSelfPct:
|
|
|
|
|
|
Heal(u, Mathf.RoundToInt(u.MaxHp * Mathf.Max(0f, e.P)), u);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
break;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
case SkillKeys.GrantReaction:
|
|
|
|
|
|
GrantReaction(u, Mathf.Max(1, Mathf.RoundToInt(e.P)));
|
2026-11-05 14:31:27 +00:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
ApplyBleed(moved);
|
|
|
|
|
|
s.Casting = false;
|
|
|
|
|
|
Ctx = prevCtx;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
yield return Fx.Wait(0.25f);
|
2026-11-09 14:12:52 +00:00
|
|
|
|
if (vfxActor == u) VfxEnd();
|
2026-11-05 14:31:27 +00:00
|
|
|
|
battle.NotifyChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>한 유닛에게 거는 시한 버프(미축 지원 · 순욱 보급 = 아군 명중 시 이동·공격 %).</summary>
|
|
|
|
|
|
public void AddBuff(UnitSkills owner, Unit target, string key, float value, int rounds)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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)
|
2026-11-05 15:22:54 +00:00
|
|
|
|
{
|
|
|
|
|
|
var u = s.Unit;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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)
|
2026-11-05 15:22:54 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
bool crit = false;
|
|
|
|
|
|
if (e.Get(1) > 0f)
|
2026-11-05 15:22:54 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
else ApplyHitStatuses(u, t, false, HitKind.Skill);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
yield return Fx.Wait(0.15f);
|
|
|
|
|
|
float push = e.Get(2);
|
|
|
|
|
|
if (push > 0.01f)
|
2026-11-05 15:22:54 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
battle.NotifyChanged();
|
2026-11-05 15:22:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
static void AddMoved(Dictionary<Unit, float> m, Unit u, float d)
|
2026-11-05 15:22:54 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
float v;
|
|
|
|
|
|
m.TryGetValue(u, out v);
|
|
|
|
|
|
m[u] = v + d;
|
2026-11-05 15:22:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>밀기(출혈은 호출 쪽이 모아서) — 반환 = 움직인 거리.</summary>
|
|
|
|
|
|
float ShoveRaw(Unit t, Vector2 dir, float force, Unit by)
|
2026-11-05 15:22:54 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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)
|
2026-11-05 15:22:54 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
t.transform.position = new Vector3(start.x, start.y, 0f);
|
|
|
|
|
|
Fx.Run(Fx.MoveAlong(t.transform, start, end, Cfg.moveSpeed, null));
|
|
|
|
|
|
t.Position = end;
|
2026-11-05 15:22:54 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
return Vector2.Distance(start, end);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>사마의: 행동 반경 안 상대 전원 = 공격력 × p1 → 준 피해 합 × p2 를 반경 안 다른 아군 각각 회복.</summary>
|
|
|
|
|
|
void AoeDrain(UnitSkills s, SkillEffect e)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
var u = s.Unit;
|
2026-11-10 11:36:58 +00:00
|
|
|
|
float r = SkillMods.BaseReach(u) * SkillMods.Product(u, SkillKeys.SplashRadiusMult, true); // 응시낭고 광역 배율(Fable 검토 B3)
|
2026-11-10 10:00:18 +00:00
|
|
|
|
int total = 0;
|
|
|
|
|
|
Fx.Burst(u.Position, r, Fx.SplashColor);
|
|
|
|
|
|
foreach (var t in battle.AliveUnits(Other(u.Faction)))
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>손상향 궁요희: 사거리 안 상대 전원에게 화살(공격력 × p1 · 치명타 판정) → 바깥으로 p2 밀기 · 치명타 = 출혈(조건 칸).</summary>
|
|
|
|
|
|
IEnumerator Volley(UnitSkills s, SkillEffect e, Dictionary<Unit, float> moved)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
|
|
|
|
|
var u = s.Unit;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>곽가 십승십패: 이미 행동한 다른 아군 n 기에 재행동(없으면 임의의 다른 아군이 행동 뒤 재행동 1회).</summary>
|
|
|
|
|
|
void GrantReaction(Unit u, int n)
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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();
|
2026-11-10 11:36:58 +00:00
|
|
|
|
else Ensure(pick).ReactCredits++; // 스킬 없는 병사도(Fable 검토 A2)
|
2026-11-10 10:00:18 +00:00
|
|
|
|
Text(pick, "재행동", SkillFx.NameColor);
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 변진(장합): 목표 지점 탭 ----------
|
|
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
if (c.GetComponent<BoardWall>() != null || GimmickLayer.BlocksMove(c)) return false;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
var o = c.GetComponent<Unit>();
|
|
|
|
|
|
if (o != null && o != u && o.IsAlive) return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>변진: 대상 무시하고 그 지점으로 → 이미 행동했으면 재행동(extra_action).</summary>
|
2026-11-05 14:31:27 +00:00
|
|
|
|
public IEnumerator TeleportRoutine(UnitSkills s, Vector2 w)
|
|
|
|
|
|
{
|
|
|
|
|
|
var u = s.Unit;
|
|
|
|
|
|
Announce(s, s.Active);
|
2026-11-05 15:22:54 +00:00
|
|
|
|
s.ReadyTurn = battle.TurnNumber + CooldownOf(s);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
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;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
if (s.Active.Has(SkillKeys.ExtraAction) && u.ActedThisTurn)
|
|
|
|
|
|
{
|
|
|
|
|
|
u.GrantReAction();
|
|
|
|
|
|
Text(u, "재행동", SkillFx.NameColor);
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
battle.NotifyChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 연출 · HUD 기록 ----------
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
/// <summary>액티브 발동 = 유닛 위 스킬 이름 + 플래시 + HUD 한 줄 · 주유 패시브 = 시전 HP 소모.</summary>
|
2026-11-09 14:12:52 +00:00
|
|
|
|
public void Announce(UnitSkills s, SkillDef def, Vector2 dir = default(Vector2))
|
2026-11-05 14:31:27 +00:00
|
|
|
|
{
|
|
|
|
|
|
if (s == null || def == null) return;
|
2026-11-10 10:00:18 +00:00
|
|
|
|
if (def.IsActive) BattleEvents.Raise(BattleEvents.ActiveUses, s.Unit);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
if (s.Unit != null) SkillFx.Announce(s.Unit, def.NameKo);
|
2026-11-10 10:00:18 +00:00
|
|
|
|
if (s.Unit != null && def.IsActive) SkillVfx.Play(s.Unit, def.Id, SkillVfx.EventCast, dir);
|
2026-11-05 14:31:27 +00:00
|
|
|
|
AddLog("액티브 · " + (s.Unit != null ? s.Unit.DisplayName : "") + " 「" + def.NameKo + "」");
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-11-05 14:31:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <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)
|
|
|
|
|
|
{
|
2026-11-10 10:00:18 +00:00
|
|
|
|
if (Sim.Active) return;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
Log.Add(new LogLine { Text = text, Time = Time.unscaledTime });
|
|
|
|
|
|
if (Log.Count > 8) Log.RemoveAt(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-10 10:00:18 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-05 14:31:27 +00:00
|
|
|
|
/// <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)
|
|
|
|
|
|
{
|
2026-11-05 16:20:07 +00:00
|
|
|
|
if (Sim.Active) return;
|
2026-11-05 14:31:27 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|