using System.Collections; using System.Collections.Generic; using UnityEngine; namespace TKS { /// 유닛 1기의 기믹 상태(중독 · 행동 불가 · 상태 글자). public class GimmickUnitState { public int PoisonTurns; public int PoisonTick; public float PoisonPct; public float PoisonStep; /// 남은 행동 불가 턴(함정 · 강) — 그 진영 턴 시작에 1 소모하고 그 턴을 쉰다. public int Stun; /// 이번 턴 행동 불가(턴 시작에 정함). public bool Skip; public TextMesh Badge; public GimmickUnitState Copy() { return (GimmickUnitState)MemberwiseClone(); } } /// /// 지형 기믹 레이어(PD #1097 · 기획서 지형기믹_기획서.md) — 스테이지와 독립된 레이어. 전투 시작마다 그 스테이지의 배치(stage_gimmicks.csv)로 /// 장애물(콜라이더 + 내구)·지대(모양 · 경로 스윕)를 만든다(배경 위 · 유닛 아래). 전투 코드는 이 클래스의 질의만 부른다: /// 이동 막기(Combat.FreeTravel) · 함정 정지 · 늪·고지 이동 비용 · 고지 사거리·근접 피해 · 군기 공격 · 안개 표적 · 광역 막힘 · 턴 시작(중독·불·행동 불가·군기). /// 충돌 반응(튕김·밀기·파괴·폭발·미끄러짐)은 ActionResolver.Gimmicks.cs. /// public class GimmickLayer : MonoBehaviour { public static GimmickLayer Current { get; private set; } /// 조준 미리보기 계산 중(아군 = 숨은 함정을 모른다). public static bool PreviewMode; BattleManager battle; readonly List items = new List(); readonly Dictionary states = new Dictionary(); GimmickInstance splashTemplate; /// 지금 처리 중인 행동의 결과(기믹 효과가 Displaced·Notes 를 더한다). public ActionOutcome Outcome; public struct ForcedMove { public Unit Unit; public Vector2 From; public Vector2 To; } /// 이번 행동의 강제 이동(밀림·튕김·반작용·넉백) 구간 — 강가·다리에서 밀려 강에 들어가면 빠짐(PD 2026-11-06 개정). public readonly List Forced = new List(); /// 책사 광역이 걷어 낸 안개 자리(x, y, 반지름 · PD 2026-11-06). readonly List fogHoles = new List(); readonly List fogMasks = new List(); public struct Affect { public Unit Unit; public GimmickZone Kind; /// 앞으로 받을 피해 환산(중독 = 남은 턴 합계 · 그 밖 = 0 · 즉시 피해는 HP 로 따로 센다). public float Value; } /// 이번 행동에서 지대 효과를 받은 유닛(독·불·함정·강) — 적 AI 점수(목적지 기믹 가치 · PD #1098). public readonly List Affected = new List(); public GimmickPreview Preview { get; private set; } public IReadOnlyList 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(); layer.battle = bm; layer.Preview = new GimmickPreview(layer); Current = layer; return layer; } void OnDestroy() { if (Current == this) Current = null; } // ---------- 생성 ---------- /// 전투 시작: 그 스테이지(장-번호)의 배치를 만든다(정의·배치 = 임포트 자산 · 없으면 빈 레이어). 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(); } /// 배치(편성 규칙 좌표)가 장애물 기믹과 겹친 유닛은 가장 가까운 빈 자리로 옮긴다(겹친 채면 캐스트가 거리 0 으로 막힘). 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(); } /// 정의 + 배치로 1개 생성(전투 중 생성 · 테스트 · 페인터 공용). 불 spawn_on_splash 배치는 지대 없이 생성 규칙으로만 둔다. 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; } /// 테스트·페인터: id 와 모양으로 생성(params = 「키=값;…」). 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(); c.radius = s.R; col = c; break; } case GimmickShapeType.Rect: { cgo.transform.rotation = Quaternion.Euler(0f, 0f, s.Rot); var b = cgo.AddComponent(); b.size = s.Size; col = b; break; } default: { var pc = cgo.AddComponent(); 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().Inst = g; g.Colliders.Add(col); } // ---------- 정적 질의(레이어가 없으면 기본값) ---------- public static GimmickInstance InstOf(Collider2D c) { if (c == null) return null; var b = c.GetComponent(); return b != null ? b.Inst : null; } /// 이 콜라이더가 이동을 막는 기믹인가(부서진 잔해·제거된 덤불 = 아님). 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; } /// 강물 안(강 지대 · 다리 구간 제외)이면 그 강. public GimmickInstance RiverAt(Vector2 p) { var r = ZoneAt(p, GimmickZone.River); return r != null && !OnBridge(p) ? r : null; } /// 안개 안(책사 광역이 걷어 낸 자리 제외)이면 그 안개. 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; } } /// 책사 광역 = 그 반경만큼 안개를 걷는다(겹친 안개가 있을 때만 · 반환 = 걷었다 · PD 2026-11-06). 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().sprite = ProceduralSprites.Circle; fogMasks.Add(m); Fx.Popup(c, "안개 걷힘", new Color(0.9f, 0.95f, 1f)); } return true; } /// 숨은 함정: origin → dir 로 dist 안에서 중심이 처음 함정에 드는 거리(+0.06 = 안으로 살짝) · 없으면 dist. 조준 미리보기(아군)는 모른다. 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; } /// /// 이동 비용(늪 = 안 구간 ×p1 · 고지 = 밖에서 오르는 구간 ×p3): budget(슬링샷 이동량)으로 실제로 갈 수 있는 거리. /// 늪에서 멈추면 다음 행동도 늪 안 구간만큼 반감(출발점이 늪이어도 같은 계산). /// 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; } /// 고지 위의 궁·책 사거리 +p1. 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; } /// 고지 위 유닛이 아래(고지 밖)에서 오는 근접 피해 × (1 − p2). 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)); } /// 군기 점령 측 전원 공 × (1 + Σp2). 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; } /// 안개 안 유닛(걷힌 자리 제외) — 받는 원거리 피해 ×p1(PD 2026-11-06 개정 · 표적·사선은 정상). public bool Fogged(Unit u) { return u != null && FogAt(u.Position) != null; } /// 원거리 피해(화살 · 책사 중심·광역) 배율 = 안개 안이면 p1. public float RangedTakenMult(Unit d) { var f = d != null ? FogAt(d.Position) : null; return f != null ? Mathf.Max(0f, f.P(1, 1f)) : 1f; } /// 광역이 중심 → p 사이에서 막히는가(성문·성벽·바위 = 투사체 막힘 + 책사 제거 불가). 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; } /// 기병 관통 옆 밀기 배율: 늪 안 = 0 · 빙판 위 = ×p2. 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; } /// 정지점이 빙판이면 같은 방향으로 빙판이 끝나는 곳까지(최대 p1) 미끄러질 거리 · 아니면 0. 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)); } /// 책사 광역 반경 안의 제거 가능한 장애물(strategist_clear = 덤불 제거 · 폭약 수레 파괴). public List ClearableIn(Vector2 c, float r) { var res = new List(); foreach (var g in items) { if (!g.BlocksMove || !g.Def.strategistClear) continue; if (g.Shape.SignedDistance(c) <= r) res.Add(g); } return res; } /// 책사 광역이 떨어진 자리에 불(스테이지에 spawn_on_splash 불 배치가 있을 때 · 반경 = 광역 반경 · 지속 = 그 배치 burn_turns). public GimmickInstance OnSplash(Vector2 c, float r) { if (splashTemplate == null) return null; return SpawnBurning(c, r, splashTemplate.P(2, 0f)); } /// 폭약 폭발 자리 불(반경 = 폭발 반경 · 지속 = 스테이지 화공 배치 burn_turns · 없으면 정의 p2 · 0 = 영구). 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); } /// 내구 피해(흔들림 · 핍) → 0 이면 잔해. 반환 = 이번에 부서졌다. 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); } /// 스킬 정화(제갈량·방통·간옹 · 2026-11-10 #1112): 중독·행동 불가 해제. 반환 = 지운 것이 있었다. 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); } /// 최대 HP 비율 피해(지대 · 튕김 자기 피해) — 반격·사망과 같은 경로(SkillSystem.Deal). 반환 = 준 피해. 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 : ""; } // ---------- 턴 시작 ---------- /// /// side 진영 턴 시작: 군기 점령 판정 → (아군 턴 = 라운드 시작) 불 지속 −1 → 그 진영 유닛의 중독(최대 HP p2 + p3 × 지난 턴 수) · /// 불 위에서 멈춘 유닛(p1) · 행동 불가(남은 턴 1 소모 · 아군 = 행동 끝 표시). /// 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); } } // ---------- 행동 뒤 지대(경로 스윕) ---------- /// /// 이 행동에서 움직인 모든 유닛(행동 유닛 경로 + 밀림·넉백·튕김 구간)의 중심 경로가 지대와 겹치면 효과: /// 독늪 = 중독(재진입 갱신) · 불 = 최대 HP p1(행동당 1회) · 함정 = 그 자리 정지(이동 계산에서 이미 멈춤) + p1 턴 행동 불가 + 제거. /// 그 뒤 다리 위에서 옆으로 밀려 강에 막힌 유닛 = 최대 HP p1 + p2 턴 행동 불가. /// public void ApplyZones(Unit mover, ActionOutcome o) { if (items.Count == 0 || o == null) return; var segs = new Dictionary>(); if (mover != null && o.Path.Count > 0) segs[mover] = new List(o.Path); foreach (var d in o.Displaced) { if (d.Unit == null) continue; List l; if (!segs.TryGetValue(d.Unit, out l)) { l = new List(); 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(); 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 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); } /// 선분 a→b 가 강물(다리 제외)에 들어가는 첫 강 · 없으면 null. 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 Refs = new List(); public readonly List Hp = new List(); public readonly List Flags = new List(); public readonly List BurnLeft = new List(); public readonly List Owner = new List(); public readonly List Center = new List(); public readonly Dictionary States = new Dictionary(); 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(); 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; } } }