984 lines
40 KiB
C#
984 lines
40 KiB
C#
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
|
||
namespace TKS
|
||
{
|
||
/// <summary>유닛 1기의 기믹 상태(중독 · 행동 불가 · 상태 글자).</summary>
|
||
public class GimmickUnitState
|
||
{
|
||
public int PoisonTurns;
|
||
public int PoisonTick;
|
||
public float PoisonPct;
|
||
public float PoisonStep;
|
||
/// <summary>남은 행동 불가 턴(함정 · 강) — 그 진영 턴 시작에 1 소모하고 그 턴을 쉰다.</summary>
|
||
public int Stun;
|
||
/// <summary>이번 턴 행동 불가(턴 시작에 정함).</summary>
|
||
public bool Skip;
|
||
public TextMesh Badge;
|
||
|
||
public GimmickUnitState Copy()
|
||
{
|
||
return (GimmickUnitState)MemberwiseClone();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 지형 기믹 레이어(PD #1097 · 기획서 지형기믹_기획서.md) — 스테이지와 독립된 레이어. 전투 시작마다 그 스테이지의 배치(stage_gimmicks.csv)로
|
||
/// 장애물(콜라이더 + 내구)·지대(모양 · 경로 스윕)를 만든다(배경 위 · 유닛 아래). 전투 코드는 이 클래스의 질의만 부른다:
|
||
/// 이동 막기(Combat.FreeTravel) · 함정 정지 · 늪·고지 이동 비용 · 고지 사거리·근접 피해 · 군기 공격 · 안개 표적 · 광역 막힘 · 턴 시작(중독·불·행동 불가·군기).
|
||
/// 충돌 반응(튕김·밀기·파괴·폭발·미끄러짐)은 ActionResolver.Gimmicks.cs.
|
||
/// </summary>
|
||
public class GimmickLayer : MonoBehaviour
|
||
{
|
||
public static GimmickLayer Current { get; private set; }
|
||
|
||
/// <summary>조준 미리보기 계산 중(아군 = 숨은 함정을 모른다).</summary>
|
||
public static bool PreviewMode;
|
||
|
||
BattleManager battle;
|
||
readonly List<GimmickInstance> items = new List<GimmickInstance>();
|
||
readonly Dictionary<Unit, GimmickUnitState> states = new Dictionary<Unit, GimmickUnitState>();
|
||
GimmickInstance splashTemplate;
|
||
|
||
/// <summary>지금 처리 중인 행동의 결과(기믹 효과가 Displaced·Notes 를 더한다).</summary>
|
||
public ActionOutcome Outcome;
|
||
|
||
public struct ForcedMove
|
||
{
|
||
public Unit Unit;
|
||
public Vector2 From;
|
||
public Vector2 To;
|
||
}
|
||
|
||
/// <summary>이번 행동의 강제 이동(밀림·튕김·반작용·넉백) 구간 — 강가·다리에서 밀려 강에 들어가면 빠짐(PD 2026-11-06 개정).</summary>
|
||
public readonly List<ForcedMove> Forced = new List<ForcedMove>();
|
||
|
||
/// <summary>책사 광역이 걷어 낸 안개 자리(x, y, 반지름 · PD 2026-11-06).</summary>
|
||
readonly List<Vector3> fogHoles = new List<Vector3>();
|
||
readonly List<GameObject> fogMasks = new List<GameObject>();
|
||
|
||
public struct Affect
|
||
{
|
||
public Unit Unit;
|
||
public GimmickZone Kind;
|
||
/// <summary>앞으로 받을 피해 환산(중독 = 남은 턴 합계 · 그 밖 = 0 · 즉시 피해는 HP 로 따로 센다).</summary>
|
||
public float Value;
|
||
}
|
||
|
||
/// <summary>이번 행동에서 지대 효과를 받은 유닛(독·불·함정·강) — 적 AI 점수(목적지 기믹 가치 · PD #1098).</summary>
|
||
public readonly List<Affect> Affected = new List<Affect>();
|
||
|
||
public GimmickPreview Preview { get; private set; }
|
||
|
||
public IReadOnlyList<GimmickInstance> Items
|
||
{
|
||
get { return items; }
|
||
}
|
||
|
||
public BattleManager Battle
|
||
{
|
||
get { return battle; }
|
||
}
|
||
|
||
public bool HasAny
|
||
{
|
||
get { return items.Count > 0; }
|
||
}
|
||
|
||
public static GimmickLayer Ensure(BattleManager bm)
|
||
{
|
||
if (Current != null && Current.battle == bm) return Current;
|
||
var go = new GameObject("GimmickLayer");
|
||
var layer = go.AddComponent<GimmickLayer>();
|
||
layer.battle = bm;
|
||
layer.Preview = new GimmickPreview(layer);
|
||
Current = layer;
|
||
return layer;
|
||
}
|
||
|
||
void OnDestroy()
|
||
{
|
||
if (Current == this) Current = null;
|
||
}
|
||
|
||
// ---------- 생성 ----------
|
||
|
||
/// <summary>전투 시작: 그 스테이지(장-번호)의 배치를 만든다(정의·배치 = 임포트 자산 · 없으면 빈 레이어).</summary>
|
||
public void Build(BalanceConfig.StageDef stage)
|
||
{
|
||
Clear();
|
||
GimmickTables.Invalidate();
|
||
var db = GimmickTables.Db;
|
||
var table = GimmickTables.Table;
|
||
if (db == null || table == null || stage == null) return;
|
||
foreach (var p in table.For(stage.chapter, stage.stage))
|
||
{
|
||
var def = db.Get(p.gimmickId);
|
||
if (def == null) continue;
|
||
Spawn(def, p.Clone(), false);
|
||
}
|
||
ResolveOverlaps();
|
||
}
|
||
|
||
/// <summary>배치(편성 규칙 좌표)가 장애물 기믹과 겹친 유닛은 가장 가까운 빈 자리로 옮긴다(겹친 채면 캐스트가 거리 0 으로 막힘).</summary>
|
||
public int ResolveOverlaps()
|
||
{
|
||
if (battle == null) return 0;
|
||
Physics2D.SyncTransforms();
|
||
int moved = 0;
|
||
foreach (var u in battle.Units)
|
||
{
|
||
if (u == null || !u.IsAlive || Free(u, u.Position)) continue;
|
||
bool done = false;
|
||
for (float r = 0.2f; r <= 4f && !done; r += 0.2f)
|
||
{
|
||
for (int k = 0; k < 16 && !done; k++)
|
||
{
|
||
float a = k * Mathf.PI / 8f;
|
||
Vector2 p = battle.Board.ClampInside(u.Position + new Vector2(Mathf.Cos(a), Mathf.Sin(a)) * r, u.Radius);
|
||
if (!Free(u, p)) continue;
|
||
u.Position = p;
|
||
done = true;
|
||
moved++;
|
||
}
|
||
}
|
||
}
|
||
if (moved > 0) Physics2D.SyncTransforms();
|
||
return moved;
|
||
}
|
||
|
||
bool Free(Unit u, Vector2 p)
|
||
{
|
||
foreach (var g in items)
|
||
{
|
||
if (g.BlocksMove && g.Shape.SignedDistance(p) < u.Radius + 0.05f) return false;
|
||
}
|
||
foreach (var o in battle.Units)
|
||
{
|
||
if (o == null || o == u || !o.IsAlive) continue;
|
||
if (Vector2.Distance(o.Position, p) < o.Radius + u.Radius + 0.02f) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
public void Clear()
|
||
{
|
||
foreach (var g in items)
|
||
{
|
||
if (g.Go != null)
|
||
{
|
||
g.Go.SetActive(false);
|
||
Destroy(g.Go);
|
||
}
|
||
}
|
||
items.Clear();
|
||
foreach (var kv in states)
|
||
{
|
||
if (kv.Value.Badge != null) Destroy(kv.Value.Badge.gameObject);
|
||
}
|
||
states.Clear();
|
||
splashTemplate = null;
|
||
Forced.Clear();
|
||
fogHoles.Clear();
|
||
foreach (var m in fogMasks)
|
||
{
|
||
if (m != null) Destroy(m);
|
||
}
|
||
fogMasks.Clear();
|
||
Outcome = null;
|
||
if (Preview != null) Preview.Hide();
|
||
}
|
||
|
||
/// <summary>정의 + 배치로 1개 생성(전투 중 생성 · 테스트 · 페인터 공용). 불 spawn_on_splash 배치는 지대 없이 생성 규칙으로만 둔다.</summary>
|
||
public GimmickInstance Spawn(GimmickDef def, GimmickPlacement src, bool spawned)
|
||
{
|
||
var g = new GimmickInstance { Def = def, Src = src, Shape = GimmickShape.From(src), Spawned = spawned };
|
||
g.MaxHp = def.durable ? (src.hpOverride > 0 ? src.hpOverride : def.hp) : 0;
|
||
g.Hp = g.MaxHp;
|
||
if (def.zone == GimmickZone.Burn)
|
||
{
|
||
float turns = g.P(2, 0f);
|
||
g.BurnLeft = turns > 0f ? Mathf.RoundToInt(turns) : -1;
|
||
if (!spawned && g.P(3, 0f) >= 0.5f)
|
||
{
|
||
g.Template = true; // 8-15 화공: 책사 광역이 떨어진 자리에 생긴다
|
||
splashTemplate = g;
|
||
}
|
||
}
|
||
items.Add(g);
|
||
if (!g.Template && !Sim.Active) BuildObject(g);
|
||
return g;
|
||
}
|
||
|
||
/// <summary>테스트·페인터: id 와 모양으로 생성(params = 「키=값;…」).</summary>
|
||
public GimmickInstance Spawn(string id, GimmickShape shape, string param = null, int hpOverride = -1)
|
||
{
|
||
var def = GimmickTables.Db != null ? GimmickTables.Db.Get(id) : null;
|
||
if (def == null)
|
||
{
|
||
Debug.LogError("[TKS] 기믹 정의 없음: " + id + " (TKS/Import Gimmicks)");
|
||
return null;
|
||
}
|
||
var p = new GimmickPlacement
|
||
{
|
||
stage = "", gimmickId = id, shape = shape.Type, cx = shape.Center.x, cy = shape.Center.y,
|
||
wOrR = shape.Type == GimmickShapeType.Circle ? shape.R : shape.Size.x, h = shape.Size.y, rot = shape.Rot,
|
||
points = shape.Points != null ? (Vector2[])shape.Points.Clone() : new Vector2[0], hpOverride = hpOverride, paramsRaw = param ?? ""
|
||
};
|
||
if (!string.IsNullOrEmpty(param))
|
||
{
|
||
foreach (var kv in param.Split(';'))
|
||
{
|
||
var f = kv.Split('=');
|
||
float v;
|
||
if (f.Length != 2 || !float.TryParse(f[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v)) continue;
|
||
string k = f[0].Trim();
|
||
if (k == "p1" || k == "slide_max") p.pOver1 = v;
|
||
else if (k == "p2" || k == "burn_turns") p.pOver2 = v;
|
||
else if (k == "p3" || k == "spawn_on_splash") p.pOver3 = v;
|
||
}
|
||
}
|
||
return Spawn(def, p, false);
|
||
}
|
||
|
||
void BuildObject(GimmickInstance g)
|
||
{
|
||
g.Go = new GameObject("G_" + g.Def.id);
|
||
g.Go.transform.SetParent(transform, false);
|
||
g.Go.transform.position = new Vector3(g.Shape.Center.x, g.Shape.Center.y, 0f);
|
||
if (g.IsBlock && g.Zone != GimmickZone.River) AddCollider(g, g.Shape, g.Go.transform);
|
||
g.View = new GimmickView(g);
|
||
}
|
||
|
||
static void AddCollider(GimmickInstance g, GimmickShape s, Transform parent)
|
||
{
|
||
var cgo = new GameObject("Collider");
|
||
cgo.transform.SetParent(parent, false);
|
||
cgo.transform.position = new Vector3(s.Center.x, s.Center.y, 0f);
|
||
Collider2D col;
|
||
switch (s.Type)
|
||
{
|
||
case GimmickShapeType.Circle:
|
||
{
|
||
var c = cgo.AddComponent<CircleCollider2D>();
|
||
c.radius = s.R;
|
||
col = c;
|
||
break;
|
||
}
|
||
case GimmickShapeType.Rect:
|
||
{
|
||
cgo.transform.rotation = Quaternion.Euler(0f, 0f, s.Rot);
|
||
var b = cgo.AddComponent<BoxCollider2D>();
|
||
b.size = s.Size;
|
||
col = b;
|
||
break;
|
||
}
|
||
default:
|
||
{
|
||
var pc = cgo.AddComponent<PolygonCollider2D>();
|
||
var pts = new Vector2[s.Points.Length];
|
||
for (int i = 0; i < pts.Length; i++) pts[i] = s.Points[i] - s.Center;
|
||
pc.points = pts;
|
||
col = pc;
|
||
break;
|
||
}
|
||
}
|
||
col.isTrigger = false;
|
||
cgo.AddComponent<GimmickBody>().Inst = g;
|
||
g.Colliders.Add(col);
|
||
}
|
||
|
||
// ---------- 정적 질의(레이어가 없으면 기본값) ----------
|
||
|
||
public static GimmickInstance InstOf(Collider2D c)
|
||
{
|
||
if (c == null) return null;
|
||
var b = c.GetComponent<GimmickBody>();
|
||
return b != null ? b.Inst : null;
|
||
}
|
||
|
||
/// <summary>이 콜라이더가 이동을 막는 기믹인가(부서진 잔해·제거된 덤불 = 아님).</summary>
|
||
public static bool BlocksMove(Collider2D c)
|
||
{
|
||
var g = InstOf(c);
|
||
return g != null && g.BlocksMove;
|
||
}
|
||
|
||
public static float AttackMultOf(Unit a)
|
||
{
|
||
var l = Current;
|
||
return l == null ? 1f : l.AttackMult(a);
|
||
}
|
||
|
||
public static float MeleeTakenMultOf(Unit a, Unit d)
|
||
{
|
||
var l = Current;
|
||
return l == null ? 1f : l.MeleeTakenMult(a, d);
|
||
}
|
||
|
||
public static float RangeBonusOf(Unit u)
|
||
{
|
||
var l = Current;
|
||
return l == null ? 0f : l.RangeBonus(u);
|
||
}
|
||
|
||
public static bool IsFogged(Unit u)
|
||
{
|
||
var l = Current;
|
||
return l != null && l.Fogged(u);
|
||
}
|
||
|
||
public static float RangedTakenMultOf(Unit d)
|
||
{
|
||
var l = Current;
|
||
return l == null ? 1f : l.RangedTakenMult(d);
|
||
}
|
||
|
||
public static bool Skips(Unit u)
|
||
{
|
||
var l = Current;
|
||
if (l == null || u == null) return false;
|
||
GimmickUnitState s;
|
||
return l.states.TryGetValue(u, out s) && s.Skip;
|
||
}
|
||
|
||
// ---------- 위치 질의 ----------
|
||
|
||
public GimmickUnitState StateOf(Unit u, bool create = false)
|
||
{
|
||
GimmickUnitState s;
|
||
if (u == null) return null;
|
||
if (!states.TryGetValue(u, out s) && create)
|
||
{
|
||
s = new GimmickUnitState();
|
||
states[u] = s;
|
||
}
|
||
return s;
|
||
}
|
||
|
||
public GimmickInstance ZoneAt(Vector2 p, GimmickZone z)
|
||
{
|
||
foreach (var g in items)
|
||
{
|
||
if (g.ZoneActive && g.Zone == z && g.Shape.Contains(p)) return g;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
public bool OnBridge(Vector2 p)
|
||
{
|
||
foreach (var g in items)
|
||
{
|
||
if (g.IsBridge && !g.Removed && g.Shape.Contains(p)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>강물 안(강 지대 · 다리 구간 제외)이면 그 강.</summary>
|
||
public GimmickInstance RiverAt(Vector2 p)
|
||
{
|
||
var r = ZoneAt(p, GimmickZone.River);
|
||
return r != null && !OnBridge(p) ? r : null;
|
||
}
|
||
|
||
/// <summary>안개 안(책사 광역이 걷어 낸 자리 제외)이면 그 안개.</summary>
|
||
public GimmickInstance FogAt(Vector2 p)
|
||
{
|
||
var f = ZoneAt(p, GimmickZone.Fog);
|
||
if (f == null) return null;
|
||
foreach (var h in fogHoles)
|
||
{
|
||
if (Vector2.Distance(p, h) <= h.z) return null;
|
||
}
|
||
return f;
|
||
}
|
||
|
||
public int FogHoleCount
|
||
{
|
||
get { return fogHoles.Count; }
|
||
}
|
||
|
||
/// <summary>책사 광역 = 그 반경만큼 안개를 걷는다(겹친 안개가 있을 때만 · 반환 = 걷었다 · PD 2026-11-06).</summary>
|
||
public bool ClearFog(Vector2 c, float r)
|
||
{
|
||
bool any = false;
|
||
foreach (var g in items)
|
||
{
|
||
if (g.Zone == GimmickZone.Fog && g.ZoneActive && g.Shape.SignedDistance(c) <= r) any = true;
|
||
}
|
||
if (!any || r <= 0f) return false;
|
||
fogHoles.Add(new Vector3(c.x, c.y, r));
|
||
if (!Sim.Active)
|
||
{
|
||
var m = new GameObject("FogCleared");
|
||
m.transform.SetParent(transform, false);
|
||
m.transform.position = new Vector3(c.x, c.y, 0f);
|
||
m.transform.localScale = new Vector3(r * 2f, r * 2f, 1f);
|
||
m.AddComponent<SpriteMask>().sprite = ProceduralSprites.Circle;
|
||
fogMasks.Add(m);
|
||
Fx.Popup(c, "안개 걷힘", new Color(0.9f, 0.95f, 1f));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/// <summary>숨은 함정: origin → dir 로 dist 안에서 중심이 처음 함정에 드는 거리(+0.06 = 안으로 살짝) · 없으면 dist. 조준 미리보기(아군)는 모른다.</summary>
|
||
public float TrapStop(Unit actor, Vector2 origin, Vector2 dir, float dist)
|
||
{
|
||
if (PreviewMode) return dist;
|
||
float best = dist;
|
||
foreach (var g in items)
|
||
{
|
||
if (g.Zone != GimmickZone.Trap || !g.ZoneActive || g.Shape.Contains(origin)) continue;
|
||
float t = g.Shape.EnterDistance(origin, dir, dist);
|
||
if (t >= 0f) best = Mathf.Min(best, t + 0.06f);
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 이동 비용(늪 = 안 구간 ×p1 · 고지 = 밖에서 오르는 구간 ×p3): budget(슬링샷 이동량)으로 실제로 갈 수 있는 거리.
|
||
/// 늪에서 멈추면 다음 행동도 늪 안 구간만큼 반감(출발점이 늪이어도 같은 계산).
|
||
/// </summary>
|
||
public float CostReach(Unit u, Vector2 origin, Vector2 dir, float budget)
|
||
{
|
||
if (budget <= 0f) return 0f;
|
||
bool any = false;
|
||
foreach (var g in items)
|
||
{
|
||
if (!g.ZoneActive || (g.Zone != GimmickZone.Slow && g.Zone != GimmickZone.High && g.Zone != GimmickZone.River)) continue;
|
||
if (Combat.SegmentPointDistance(origin, origin + dir * budget, g.Shape.Center) > g.Shape.BoundingRadius + 0.01f) continue;
|
||
any = true;
|
||
break;
|
||
}
|
||
if (!any) return budget;
|
||
const float step = GimmickShape.SweepStep;
|
||
float used = 0f, t = 0f;
|
||
int guard = 0;
|
||
while (used < budget - 1e-4f && guard++ < 4000)
|
||
{
|
||
Vector2 p = origin + dir * (t + step * 0.5f);
|
||
float m = 1f;
|
||
foreach (var g in items)
|
||
{
|
||
if (!g.ZoneActive || !g.Shape.Contains(p)) continue;
|
||
if (g.Zone == GimmickZone.Slow) m *= Mathf.Max(1f, g.P(1, 1f));
|
||
else if (g.Zone == GimmickZone.High && !g.Shape.Contains(origin)) m *= Mathf.Max(1f, g.P(3, 1f));
|
||
else if (g.Zone == GimmickZone.River && !OnBridge(p)) m *= Mathf.Max(1f, g.P(3, 1f)); // 강 = 늪과 같은 판정(다리 구간 제외 · PD 2026-11-06)
|
||
}
|
||
float cost = step * m;
|
||
if (used + cost >= budget)
|
||
{
|
||
t += (budget - used) / m;
|
||
return t;
|
||
}
|
||
used += cost;
|
||
t += step;
|
||
}
|
||
return t;
|
||
}
|
||
|
||
/// <summary>고지 위의 궁·책 사거리 +p1.</summary>
|
||
public float RangeBonus(Unit u)
|
||
{
|
||
if (u == null || !UnitTypeInfo.IsRanged(u.Type)) return 0f;
|
||
var g = ZoneAt(u.Position, GimmickZone.High);
|
||
return g != null ? g.P(1, 0f) : 0f;
|
||
}
|
||
|
||
/// <summary>고지 위 유닛이 아래(고지 밖)에서 오는 근접 피해 × (1 − p2).</summary>
|
||
public float MeleeTakenMult(Unit a, Unit d)
|
||
{
|
||
if (a == null || d == null) return 1f;
|
||
var g = ZoneAt(d.Position, GimmickZone.High);
|
||
if (g == null || ZoneAt(a.Position, GimmickZone.High) != null) return 1f;
|
||
return Mathf.Max(0f, 1f - g.P(2, 0f));
|
||
}
|
||
|
||
/// <summary>군기 점령 측 전원 공 × (1 + Σp2).</summary>
|
||
public float AttackMult(Unit a)
|
||
{
|
||
if (a == null) return 1f;
|
||
float add = 0f;
|
||
foreach (var g in items)
|
||
{
|
||
if (g.Zone == GimmickZone.Capture && g.ZoneActive && g.Owner == (int)a.Faction) add += g.P(2, 0f);
|
||
}
|
||
return 1f + add;
|
||
}
|
||
|
||
/// <summary>안개 안 유닛(걷힌 자리 제외) — 받는 원거리 피해 ×p1(PD 2026-11-06 개정 · 표적·사선은 정상).</summary>
|
||
public bool Fogged(Unit u)
|
||
{
|
||
return u != null && FogAt(u.Position) != null;
|
||
}
|
||
|
||
/// <summary>원거리 피해(화살 · 책사 중심·광역) 배율 = 안개 안이면 p1.</summary>
|
||
public float RangedTakenMult(Unit d)
|
||
{
|
||
var f = d != null ? FogAt(d.Position) : null;
|
||
return f != null ? Mathf.Max(0f, f.P(1, 1f)) : 1f;
|
||
}
|
||
|
||
/// <summary>광역이 중심 → p 사이에서 막히는가(성문·성벽·바위 = 투사체 막힘 + 책사 제거 불가).</summary>
|
||
public bool SplashBlocked(Vector2 c, Vector2 p)
|
||
{
|
||
if (items.Count == 0 || (p - c).sqrMagnitude < 1e-4f) return false;
|
||
Physics2D.SyncTransforms();
|
||
foreach (var h in Physics2D.LinecastAll(c, p))
|
||
{
|
||
var g = InstOf(h.collider);
|
||
if (g == null || !g.BlocksMove) continue;
|
||
if (g.Def.projectile == GimmickProjectile.Block && !g.Def.strategistClear && !g.Shape.Contains(c)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>기병 관통 옆 밀기 배율: 늪 안 = 0 · 빙판 위 = ×p2.</summary>
|
||
public float KnockMult(Unit e)
|
||
{
|
||
if (e == null) return 1f;
|
||
if (ZoneAt(e.Position, GimmickZone.Slow) != null || RiverAt(e.Position) != null) return 0f; // 늪·강 안 = 밀기 0
|
||
var ice = ZoneAt(e.Position, GimmickZone.Slide);
|
||
return ice != null ? Mathf.Max(0f, ice.P(2, 1f)) : 1f;
|
||
}
|
||
|
||
/// <summary>정지점이 빙판이면 같은 방향으로 빙판이 끝나는 곳까지(최대 p1) 미끄러질 거리 · 아니면 0.</summary>
|
||
public float SlideDistance(Vector2 pos, Vector2 dir)
|
||
{
|
||
var ice = ZoneAt(pos, GimmickZone.Slide);
|
||
if (ice == null || dir.sqrMagnitude < 1e-6f) return 0f;
|
||
float max = Mathf.Max(0f, ice.P(1, 0f));
|
||
return Mathf.Min(max, ice.Shape.ExitDistance(pos, dir.normalized, max));
|
||
}
|
||
|
||
/// <summary>책사 광역 반경 안의 제거 가능한 장애물(strategist_clear = 덤불 제거 · 폭약 수레 파괴).</summary>
|
||
public List<GimmickInstance> ClearableIn(Vector2 c, float r)
|
||
{
|
||
var res = new List<GimmickInstance>();
|
||
foreach (var g in items)
|
||
{
|
||
if (!g.BlocksMove || !g.Def.strategistClear) continue;
|
||
if (g.Shape.SignedDistance(c) <= r) res.Add(g);
|
||
}
|
||
return res;
|
||
}
|
||
|
||
/// <summary>책사 광역이 떨어진 자리에 불(스테이지에 spawn_on_splash 불 배치가 있을 때 · 반경 = 광역 반경 · 지속 = 그 배치 burn_turns).</summary>
|
||
public GimmickInstance OnSplash(Vector2 c, float r)
|
||
{
|
||
if (splashTemplate == null) return null;
|
||
return SpawnBurning(c, r, splashTemplate.P(2, 0f));
|
||
}
|
||
|
||
/// <summary>폭약 폭발 자리 불(반경 = 폭발 반경 · 지속 = 스테이지 화공 배치 burn_turns · 없으면 정의 p2 · 0 = 영구).</summary>
|
||
public GimmickInstance SpawnFire(Vector2 c, float r)
|
||
{
|
||
float turns = 0f;
|
||
if (splashTemplate != null) turns = splashTemplate.P(2, 0f);
|
||
else
|
||
{
|
||
var def = GimmickTables.Db != null ? GimmickTables.Db.Get(GimmickIds.BurningDebris) : null;
|
||
if (def != null && !float.IsNaN(def.p2)) turns = def.p2;
|
||
}
|
||
return SpawnBurning(c, r, turns);
|
||
}
|
||
|
||
public GimmickInstance SpawnBurning(Vector2 c, float r, float turns)
|
||
{
|
||
var db = GimmickTables.Db;
|
||
var def = db != null ? db.Get(GimmickIds.BurningDebris) : null;
|
||
if (def == null) return null;
|
||
var p = new GimmickPlacement { gimmickId = def.id, shape = GimmickShapeType.Circle, cx = c.x, cy = c.y, wOrR = Mathf.Max(0.3f, r), pOver2 = turns, pOver3 = 0f };
|
||
return Spawn(def, p, true);
|
||
}
|
||
|
||
public void Remove(GimmickInstance g)
|
||
{
|
||
if (g == null || g.Removed) return;
|
||
g.Removed = true;
|
||
g.SetCollidersEnabled(false);
|
||
if (!Sim.Active && g.View != null) g.View.Vanish(g.Zone == GimmickZone.Trap);
|
||
}
|
||
|
||
/// <summary>내구 피해(흔들림 · 핍) → 0 이면 잔해. 반환 = 이번에 부서졌다.</summary>
|
||
public bool Hit(GimmickInstance g, int dmg)
|
||
{
|
||
if (g == null || !g.BlocksMove || !g.Def.durable || dmg <= 0) return false;
|
||
g.Hp = Mathf.Max(0, g.Hp - dmg);
|
||
bool broke = g.Hp <= 0;
|
||
if (broke)
|
||
{
|
||
g.Broken = true;
|
||
g.SetCollidersEnabled(false);
|
||
}
|
||
if (!Sim.Active && g.View != null)
|
||
{
|
||
g.View.Shake();
|
||
if (broke) g.View.Shatter();
|
||
else g.View.Refresh();
|
||
Fx.Popup(g.Shape.Center, broke ? g.Def.nameKo + " 파괴" : g.Def.nameKo + " −" + dmg, new Color(1f, 0.8f, 0.4f));
|
||
}
|
||
return broke;
|
||
}
|
||
|
||
// ---------- 상태(중독 · 행동 불가) ----------
|
||
|
||
public void Poison(Unit u, GimmickInstance g)
|
||
{
|
||
var s = StateOf(u, true);
|
||
if (s.PoisonTurns <= 0) s.PoisonTick = 0; // 재진입 = 지속만 갱신(중첩 없음)
|
||
s.PoisonTurns = Mathf.Max(1, Mathf.RoundToInt(g.P(1, 1f)));
|
||
s.PoisonPct = g.P(2, 0f);
|
||
s.PoisonStep = g.P(3, 0f);
|
||
Text(u, "중독", new Color(0.75f, 0.45f, 1f));
|
||
RefreshBadge(u);
|
||
}
|
||
|
||
/// <summary>스킬 정화(제갈량·방통·간옹 · 2026-11-10 #1112): 중독·행동 불가 해제. 반환 = 지운 것이 있었다.</summary>
|
||
public bool Cleanse(Unit u)
|
||
{
|
||
var s = StateOf(u);
|
||
if (s == null) return false;
|
||
bool any = s.PoisonTurns > 0 || s.Stun > 0 || s.Skip;
|
||
s.PoisonTurns = 0;
|
||
s.Stun = 0;
|
||
s.Skip = false;
|
||
RefreshBadge(u);
|
||
return any;
|
||
}
|
||
|
||
public void Stun(Unit u, int turns, string why)
|
||
{
|
||
if (u == null || turns <= 0) return;
|
||
var s = StateOf(u, true);
|
||
s.Stun = Mathf.Max(s.Stun, turns);
|
||
Text(u, why, new Color(1f, 0.85f, 0.3f));
|
||
RefreshBadge(u);
|
||
}
|
||
|
||
/// <summary>최대 HP 비율 피해(지대 · 튕김 자기 피해) — 반격·사망과 같은 경로(SkillSystem.Deal). 반환 = 준 피해.</summary>
|
||
public int PctDamage(Unit u, float pct, string label)
|
||
{
|
||
if (u == null || !u.IsAlive || pct <= 0f) return 0;
|
||
int dmg = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * pct));
|
||
dmg = SkillSystem.Deal(u, dmg);
|
||
Text(u, (string.IsNullOrEmpty(label) ? "" : label + " ") + "−" + dmg, new Color(1f, 0.55f, 0.3f));
|
||
return dmg;
|
||
}
|
||
|
||
public void Text(Unit u, string text, Color c)
|
||
{
|
||
if (Sim.Active || u == null) return;
|
||
Fx.Popup(u.Position + new Vector2(0f, 0.35f), text, c);
|
||
}
|
||
|
||
void RefreshBadge(Unit u)
|
||
{
|
||
if (Sim.Active || u == null) return;
|
||
var s = StateOf(u);
|
||
if (s == null) return;
|
||
string t = "";
|
||
if (s.PoisonTurns > 0) t += "독" + s.PoisonTurns;
|
||
if (s.Stun > 0 || s.Skip) t += (t.Length > 0 ? " " : "") + "행동불가";
|
||
if (s.Badge == null)
|
||
{
|
||
if (t.Length == 0) return;
|
||
s.Badge = TextUtil.CreateWorldText(u.transform, t, 0.24f, new Color(0.85f, 0.6f, 1f), 6);
|
||
s.Badge.gameObject.name = "GimmickStatus";
|
||
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 + 0.78f, 0f);
|
||
}
|
||
s.Badge.text = t;
|
||
s.Badge.gameObject.SetActive(t.Length > 0 && u.IsAlive);
|
||
}
|
||
|
||
public string BadgeText(Unit u)
|
||
{
|
||
var s = StateOf(u);
|
||
return s != null && s.Badge != null && s.Badge.gameObject.activeSelf ? s.Badge.text : "";
|
||
}
|
||
|
||
// ---------- 턴 시작 ----------
|
||
|
||
/// <summary>
|
||
/// side 진영 턴 시작: 군기 점령 판정 → (아군 턴 = 라운드 시작) 불 지속 −1 → 그 진영 유닛의 중독(최대 HP p2 + p3 × 지난 턴 수) ·
|
||
/// 불 위에서 멈춘 유닛(p1) · 행동 불가(남은 턴 1 소모 · 아군 = 행동 끝 표시).
|
||
/// </summary>
|
||
public IEnumerator TurnStart(Faction side)
|
||
{
|
||
if (items.Count == 0 && states.Count == 0) yield break;
|
||
bool any = false;
|
||
foreach (var g in items)
|
||
{
|
||
if (g.Zone != GimmickZone.Capture || !g.ZoneActive) continue;
|
||
float r = g.P(1, 0f);
|
||
bool p = false, e = false;
|
||
foreach (var u in battle.Units)
|
||
{
|
||
if (u == null || !u.IsAlive) continue;
|
||
if (Vector2.Distance(u.Position, g.Shape.Center) - u.Radius > r) continue;
|
||
if (u.Faction == Faction.Player) p = true;
|
||
else e = true;
|
||
}
|
||
int owner = p && e ? -1 : p ? (int)Faction.Player : e ? (int)Faction.Enemy : g.Owner;
|
||
if (owner != g.Owner)
|
||
{
|
||
g.Owner = owner;
|
||
if (!Sim.Active && g.View != null)
|
||
{
|
||
g.View.Refresh();
|
||
Fx.Popup(g.Shape.Center, owner < 0 ? "군기 중립" : owner == 0 ? "아군 군기 점령" : "적군 군기 점령", new Color(1f, 0.9f, 0.4f));
|
||
}
|
||
any = true;
|
||
}
|
||
}
|
||
if (side == Faction.Player)
|
||
{
|
||
foreach (var g in items)
|
||
{
|
||
if (g.Zone != GimmickZone.Burn || !g.ZoneActive || g.BurnLeft <= 0) continue;
|
||
g.BurnLeft--;
|
||
if (g.BurnLeft == 0) g.Out = true;
|
||
if (!Sim.Active && g.View != null) g.View.Refresh();
|
||
}
|
||
}
|
||
foreach (var u in battle.AliveUnits(side))
|
||
{
|
||
var s = StateOf(u);
|
||
if (s != null && s.PoisonTurns > 0)
|
||
{
|
||
PctDamage(u, s.PoisonPct + s.PoisonStep * s.PoisonTick, "중독");
|
||
s.PoisonTick++;
|
||
s.PoisonTurns--;
|
||
any = true;
|
||
}
|
||
if (u.IsAlive)
|
||
{
|
||
var burn = ZoneAt(u.Position, GimmickZone.Burn);
|
||
if (burn != null)
|
||
{
|
||
PctDamage(u, burn.P(1, 0f), "불");
|
||
any = true;
|
||
}
|
||
}
|
||
if (s != null)
|
||
{
|
||
s.Skip = false;
|
||
if (s.Stun > 0 && u.IsAlive)
|
||
{
|
||
s.Stun--;
|
||
s.Skip = true;
|
||
if (side == Faction.Player) u.MarkActed();
|
||
Text(u, "행동 불가", new Color(1f, 0.85f, 0.3f));
|
||
any = true;
|
||
}
|
||
RefreshBadge(u);
|
||
}
|
||
}
|
||
if (any)
|
||
{
|
||
battle.NotifyChanged();
|
||
yield return Fx.Wait(0.35f);
|
||
}
|
||
}
|
||
|
||
// ---------- 행동 뒤 지대(경로 스윕) ----------
|
||
|
||
/// <summary>
|
||
/// 이 행동에서 움직인 모든 유닛(행동 유닛 경로 + 밀림·넉백·튕김 구간)의 중심 경로가 지대와 겹치면 효과:
|
||
/// 독늪 = 중독(재진입 갱신) · 불 = 최대 HP p1(행동당 1회) · 함정 = 그 자리 정지(이동 계산에서 이미 멈춤) + p1 턴 행동 불가 + 제거.
|
||
/// 그 뒤 다리 위에서 옆으로 밀려 강에 막힌 유닛 = 최대 HP p1 + p2 턴 행동 불가.
|
||
/// </summary>
|
||
public void ApplyZones(Unit mover, ActionOutcome o)
|
||
{
|
||
if (items.Count == 0 || o == null) return;
|
||
var segs = new Dictionary<Unit, List<Vector2>>();
|
||
if (mover != null && o.Path.Count > 0) segs[mover] = new List<Vector2>(o.Path);
|
||
foreach (var d in o.Displaced)
|
||
{
|
||
if (d.Unit == null) continue;
|
||
List<Vector2> l;
|
||
if (!segs.TryGetValue(d.Unit, out l))
|
||
{
|
||
l = new List<Vector2>();
|
||
segs[d.Unit] = l;
|
||
}
|
||
l.Add(d.From);
|
||
l.Add(d.To);
|
||
l.Add(new Vector2(float.NaN, float.NaN)); // 구간 끊김
|
||
}
|
||
foreach (var kv in segs)
|
||
{
|
||
var u = kv.Key;
|
||
var pts = kv.Value;
|
||
bool burned = false;
|
||
foreach (var g in items)
|
||
{
|
||
if (!g.ZoneActive || !u.IsAlive) continue;
|
||
if (g.Zone != GimmickZone.Poison && g.Zone != GimmickZone.Burn && g.Zone != GimmickZone.Trap) continue;
|
||
if (!Touches(g.Shape, pts, u.Position)) continue;
|
||
switch (g.Zone)
|
||
{
|
||
case GimmickZone.Poison:
|
||
{
|
||
Poison(u, g);
|
||
var s = StateOf(u);
|
||
float future = 0f;
|
||
for (int k = 0; k < s.PoisonTurns; k++) future += (s.PoisonPct + s.PoisonStep * (s.PoisonTick + k)) * u.MaxHp;
|
||
Affected.Add(new Affect { Unit = u, Kind = g.Zone, Value = future });
|
||
break;
|
||
}
|
||
case GimmickZone.Burn:
|
||
if (burned) break;
|
||
burned = true;
|
||
PctDamage(u, g.P(1, 0f), "불");
|
||
Affected.Add(new Affect { Unit = u, Kind = g.Zone });
|
||
break;
|
||
case GimmickZone.Trap:
|
||
Remove(g);
|
||
g.Revealed = true;
|
||
Stun(u, Mathf.Max(1, Mathf.RoundToInt(g.P(1, 1f))), "함정!");
|
||
o.Notes.Add(u.DisplayName + " 함정");
|
||
Affected.Add(new Affect { Unit = u, Kind = g.Zone });
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// 강(PD 2026-11-06 개정): 강가·다리에서 밀려(밀림·튕김·반작용·넉백) 강물에 들어가면 최대 HP p1 + p2 턴 행동 불가 · 행동당 1회 · 걸어 들어가면 느려지기만
|
||
var fell = new HashSet<Unit>();
|
||
foreach (var f in Forced)
|
||
{
|
||
var u = f.Unit;
|
||
if (u == null || !u.IsAlive || fell.Contains(u) || RiverAt(f.From) != null) continue;
|
||
var river = RiverEntered(f.From, f.To);
|
||
if (river == null) continue;
|
||
fell.Add(u);
|
||
PctDamage(u, river.P(1, 0f), "강에 빠짐");
|
||
Stun(u, Mathf.Max(1, Mathf.RoundToInt(river.P(2, 1f))), "강에 빠짐");
|
||
o.Notes.Add(u.DisplayName + " 강에 빠짐");
|
||
Affected.Add(new Affect { Unit = u, Kind = GimmickZone.River });
|
||
}
|
||
Forced.Clear();
|
||
}
|
||
|
||
static bool Touches(GimmickShape s, List<Vector2> pts, Vector2 end)
|
||
{
|
||
if (pts.Count == 1) return s.Contains(pts[0]);
|
||
for (int i = 0; i + 1 < pts.Count; i++)
|
||
{
|
||
if (float.IsNaN(pts[i].x) || float.IsNaN(pts[i + 1].x)) continue;
|
||
if (s.SegmentTouches(pts[i], pts[i + 1])) return true;
|
||
}
|
||
return s.Contains(end);
|
||
}
|
||
|
||
/// <summary>선분 a→b 가 강물(다리 제외)에 들어가는 첫 강 · 없으면 null.</summary>
|
||
GimmickInstance RiverEntered(Vector2 a, Vector2 b)
|
||
{
|
||
float len = Vector2.Distance(a, b);
|
||
if (len < 0.01f) return null;
|
||
int n = Mathf.Max(1, Mathf.CeilToInt(len / GimmickShape.SweepStep));
|
||
for (int i = 1; i <= n; i++)
|
||
{
|
||
var r = RiverAt(Vector2.Lerp(a, b, (float)i / n));
|
||
if (r != null) return r;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
public void NoteForced(Unit u, Vector2 from, Vector2 to)
|
||
{
|
||
if (u == null || items.Count == 0 || (to - from).sqrMagnitude < 1e-6f) return;
|
||
Forced.Add(new ForcedMove { Unit = u, From = from, To = to });
|
||
}
|
||
|
||
// ---------- 가상 실행(적 AI) 스냅숏 ----------
|
||
|
||
public class Snapshot
|
||
{
|
||
public int Count;
|
||
public readonly List<GimmickInstance> Refs = new List<GimmickInstance>();
|
||
public readonly List<int> Hp = new List<int>();
|
||
public readonly List<int> Flags = new List<int>();
|
||
public readonly List<int> BurnLeft = new List<int>();
|
||
public readonly List<int> Owner = new List<int>();
|
||
public readonly List<Vector2> Center = new List<Vector2>();
|
||
public readonly Dictionary<Unit, GimmickUnitState> States = new Dictionary<Unit, GimmickUnitState>();
|
||
public GimmickInstance Template;
|
||
public int FogHoles;
|
||
}
|
||
|
||
public Snapshot Take()
|
||
{
|
||
var s = new Snapshot { Count = items.Count, Template = splashTemplate, FogHoles = fogHoles.Count };
|
||
foreach (var g in items)
|
||
{
|
||
s.Refs.Add(g);
|
||
s.Hp.Add(g.Hp);
|
||
s.Flags.Add((g.Broken ? 1 : 0) | (g.Removed ? 2 : 0) | (g.Revealed ? 4 : 0) | (g.Out ? 8 : 0));
|
||
s.BurnLeft.Add(g.BurnLeft);
|
||
s.Owner.Add(g.Owner);
|
||
s.Center.Add(g.Shape.Center);
|
||
}
|
||
foreach (var kv in states) s.States[kv.Key] = kv.Value.Copy();
|
||
return s;
|
||
}
|
||
|
||
public void Restore(Snapshot s)
|
||
{
|
||
if (s == null) return;
|
||
for (int i = items.Count - 1; i >= s.Count; i--)
|
||
{
|
||
if (items[i].Go != null) Destroy(items[i].Go);
|
||
items.RemoveAt(i);
|
||
}
|
||
for (int i = 0; i < s.Count; i++)
|
||
{
|
||
var g = s.Refs[i];
|
||
items[i] = g;
|
||
g.Hp = s.Hp[i];
|
||
int f = s.Flags[i];
|
||
g.Broken = (f & 1) != 0;
|
||
g.Removed = (f & 2) != 0;
|
||
g.Revealed = (f & 4) != 0;
|
||
g.Out = (f & 8) != 0;
|
||
g.BurnLeft = s.BurnLeft[i];
|
||
g.Owner = s.Owner[i];
|
||
if ((g.Shape.Center - s.Center[i]).sqrMagnitude > 1e-8f) g.MoveBy(s.Center[i] - g.Shape.Center);
|
||
g.SetCollidersEnabled(g.BlocksMove);
|
||
}
|
||
splashTemplate = s.Template;
|
||
if (fogHoles.Count > s.FogHoles) fogHoles.RemoveRange(s.FogHoles, fogHoles.Count - s.FogHoles);
|
||
foreach (var kv in s.States)
|
||
{
|
||
GimmickUnitState cur;
|
||
if (states.TryGetValue(kv.Key, out cur))
|
||
{
|
||
var badge = cur.Badge;
|
||
states[kv.Key] = kv.Value.Copy();
|
||
states[kv.Key].Badge = badge;
|
||
}
|
||
else states[kv.Key] = kv.Value.Copy();
|
||
}
|
||
var extra = new List<Unit>();
|
||
foreach (var kv in states)
|
||
{
|
||
if (!s.States.ContainsKey(kv.Key)) extra.Add(kv.Key);
|
||
}
|
||
foreach (var u in extra)
|
||
{
|
||
if (states[u].Badge == null) states.Remove(u);
|
||
else
|
||
{
|
||
var b = states[u].Badge;
|
||
states[u] = new GimmickUnitState { Badge = b };
|
||
}
|
||
}
|
||
Forced.Clear();
|
||
Affected.Clear();
|
||
Outcome = null;
|
||
}
|
||
}
|
||
}
|