ThreeKingdomsSlingshot/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs

984 lines
40 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>유닛 1기의 기믹 상태(중독 · 행동 불가 · 상태 글자).</summary>
public class 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;
}
}
}