using System.Collections; using System.Collections.Generic; using UnityEngine; namespace TKS { /// /// 지형 기믹 충돌 반응(PD #1097 · 지형기믹_기획서 §2~§4 · 병종×기믹 표): /// 보병 = 내구 피해 + 튕김(알까기 자기 튕김 재사용 · bounce_pct = 자기 HP 피해) · 바위는 굴림 / 창병 = 내구 피해 + spear_push 만큼 밀고 정지(밀린 끝 유닛도 밀림) · 바위 굴림 / /// 기병 = 관통 불가 → 거기서 정지(복귀 없음) · 내구 피해 + 튕김 + 자기 HP(cav_self_pct) · 바위 = 정지 / 궁·책 이동 = 피해 없이 튕김 · /// 투사체 = pass·block·destroy · 책사 광역 = 덤불 제거·수레 파괴·(화공) 불 생성 · 폭약 = 광역 파이프라인(아군 포함 + 밀기 + 불) · /// 행동 끝 = 빙판 미끄러짐(행동 유닛 = 병종 루틴 재실행 · 밀린 유닛 = 밀기) → 지대 스윕(독·불·함정) → 다리 → 강. /// public partial class ActionResolver { public static readonly Color ExplosionColor = new Color(1f, 0.5f, 0.15f, 0.55f); static readonly Color IceTextColor = new Color(0.65f, 0.9f, 1f); static GimmickLayer Layer { get { return GimmickLayer.Current; } } static bool Gim { get { return GimmickLayer.Current != null && GimmickLayer.Current.HasAny; } } /// 근접 병종 슬링샷 도달점(늪 ×p1 · 고지 오르기 ×p3) — Resolve·조준 미리보기 공용. 원거리 병종은 접근·이동 단계에서 따로 계산. public Vector2 ReachPoint(Unit u, Vector2 target) { if (u == null || UnitTypeInfo.IsRanged(u.Type)) return target; Vector2 d = target - u.Position; float dist = d.magnitude; if (dist < 0.001f) return target; float reach = SlowZoneReach(u, u.Position, d / dist, dist); // 조인 철벽(상대 반경 안 이동 비용 +) if (Gim && !SkillMods.Has(u, SkillKeys.TerrainImmune)) reach = Layer.CostReach(u, u.Position, d / dist, reach); // 축융 = 지형 이동 감소 무시 return u.Position + d / dist * reach; } static float Reach(Unit u, Vector2 origin, Vector2 dir, float budget) { if (u != null && SkillMods.Has(u, SkillKeys.TerrainImmune)) return budget; return Gim ? Layer.CostReach(u, origin, dir, budget) : budget; } /// 조준 경로가 지형 이동 감소(늪·고지 오르기)에 걸리는가(맹획 남만왕 재행동 · 지형 무시 유닛도 지형은 「지남」으로 봄). static bool CrossesSlowTerrain(Unit u, Vector2 origin, Vector2 aimed) { if (!Gim || u == null) return false; Vector2 d = aimed - origin; float dist = d.magnitude; if (dist < 0.001f) return false; return Layer.CostReach(u, origin, d / dist, dist) < dist - 1e-3f; } /// 자기 행동 이동(궁·책 접근·이동만) = 조인 철벽 감속 → 지형 이동 비용(Fable 검토 B2 · 근접 병종은 ReachPoint). static float MoveReach(Unit u, Vector2 origin, Vector2 dir, float budget) { return Reach(u, origin, dir, SlowZoneReach(u, origin, dir, budget)); } /// 조인 철벽(zone_slow): 상대 시전자의 행동 반경(기본 슬링 거리) 안을 지나는 구간 = 이동 비용 × (1 + p1). static float SlowZoneReach(Unit u, Vector2 origin, Vector2 dir, float budget) { var sys = SkillSystem.Current; if (sys == null) return budget; List> zones = null; foreach (var t in sys.TimedEffects) { if (t.E.Key != SkillKeys.ZoneSlow || t.Faction == u.Faction || t.Owner == null || t.Owner.Unit == null || !t.Owner.Unit.IsAlive) continue; if (zones == null) zones = new List>(); zones.Add(new KeyValuePair(t.Owner.Unit, t.E.P)); } if (zones == null) return budget; const float step = 0.05f; float spent = 0f, moved = 0f; while (moved < budget + 10f) { Vector2 p = origin + dir * (moved + step * 0.5f); float cost = 1f; foreach (var z in zones) if (Vector2.Distance(z.Key.Position, p) <= SkillMods.BaseReach(z.Key)) cost = Mathf.Max(cost, 1f + z.Value); if (spent + step * cost > budget) return moved + (budget - spent) / cost; spent += step * cost; moved += step; } return budget; } static void NoteForced(Unit u, Vector2 from, Vector2 to) { if (Gim) Layer.NoteForced(u, from, to); } void GimmickBegin(ActionOutcome o) { if (Layer == null) return; Layer.Outcome = o; Layer.Forced.Clear(); Layer.Affected.Clear(); } /// 경로 위 첫 장애물 기믹 — limit(첫 유닛 접촉) · 전장 벽 · 숨은 함정보다 앞일 때만. GimmickInstance BlockAhead(Unit u, Vector2 origin, Vector2 dir, float dist, float limit, out float hitDist, out Vector2 normal) { hitDist = 0f; normal = -dir; if (!Gim) return null; var g = Combat.FirstGimmickOnPath(u, origin, dir, dist, out hitDist, out normal); if (g == null) return null; if (hitDist > limit + Combat.Epsilon || hitDist > Combat.WallTravel(u, origin, dir, dist) + Combat.Epsilon * 2f) return null; if (Layer.TrapStop(u, origin, dir, dist) < hitDist) return null; return g; } /// 궁·책 등 이동만 하는 병종: 유닛 앞의 장애물이면 부딪힘(튕김) · 아니면 그대로 이동. IEnumerator MoveOrHit(Unit u, Vector2 origin, Vector2 dir, float dist, ActionOutcome o) { float uh; var first = Combat.FirstUnitOnPath(u, origin, dir, dist, out uh); float gh; Vector2 gn; var g = BlockAhead(u, origin, dir, dist, first != null ? uh : dist, out gh, out gn); if (g != null) { yield return HitGimmick(u, g, origin, dir, dist, gh, gn, o); yield break; } yield return MoveFree(u, origin, dir, dist, o); } /// 보병·창병·궁·책 이동이 장애물 기믹에 부딪힘. IEnumerator HitGimmick(Unit u, GimmickInstance g, Vector2 origin, Vector2 dir, float dist, float hitDist, Vector2 normal, ActionOutcome o) { Vector2 contact = origin + dir * Mathf.Max(0f, hitDist - Combat.Epsilon); yield return MoveStraight(u, contact, o); o.MovedOnly = true; float remaining = Mathf.Max(0f, dist - hitDist); var d = g.Def; var sk = SkillSystem.Current; if (sk != null && sk.Ctx != null && sk.Ctx.Actor == u) sk.Ctx.HitTerrain = true; // 맹획 남만왕: 지형물에 닿음 = 재행동 float gm = SkillMods.GimmickMult(u); // 전위 고지악래: 지형 오브젝트 밀기 거리 × bool pusher = u.Type == UnitType.Infantry || u.Type == UnitType.Spear; if (pusher && g.Rolls) { yield return RollBoulder(u, g, dir, remaining * d.spearPush * gm, o); // 보·창 = 바위를 남은 거리 × spear_push 만큼 굴린다 · 자신은 정지 PushContinue(u, remaining); // 고순: 오브젝트를 밀친 뒤 남은 이동력으로 재행동 yield break; } int dmg = d.DamageBy(u.Type); if (dmg > 0) yield return DamageGimmick(g, Mathf.RoundToInt(dmg * gm), u, o); if (!u.IsAlive) yield break; if (u.Type == UnitType.Spear && dmg > 0) { if (d.spearPush > 0.01f && g.BlocksMove) yield return ShoveGimmick(g, dir, d.spearPush * gm, u, o); // 창 = spear_push 만큼 밀고 정지(튕기지 않음) yield break; } if (d.bouncePct > 0f) o.Notes.Add(u.DisplayName + " 자기 −" + Layer.PctDamage(u, d.bouncePct, d.nameKo)); if (!u.IsAlive) yield break; yield return Bounce(u, null, -normal, remaining, o, 0); // 알까기 자기 튕김(남은 거리 × collisionSelfBounceRatio) · 면 바깥으로 } /// 기병 관통이 장애물에 막힘(복귀 없음): 내구 피해 → 바위 = 정지 + cav_self_pct · 그 밖 = 튕김 + max(cav_self_pct, bounce_pct). IEnumerator CavalryHitsGimmick(Unit u, GimmickInstance g, float remaining, Vector2 normal, ActionOutcome o) { var d = g.Def; int dmg = d.DamageBy(UnitType.Cavalry); if (dmg > 0) yield return DamageGimmick(g, dmg, u, o); float pct = g.Rolls ? d.cavSelfPct : Mathf.Max(d.cavSelfPct, d.bouncePct); if (pct > 0f) o.Notes.Add(u.DisplayName + " 자기 −" + Layer.PctDamage(u, pct, d.nameKo)); if (!u.IsAlive || g.Rolls) yield break; o.Notes.Add(d.nameKo + "에 막힘"); yield return Bounce(u, null, -normal, remaining, o, 0); } IEnumerator DamageGimmick(GimmickInstance g, int dmg, Unit by, ActionOutcome o) { bool broke = Layer.Hit(g, dmg); o.Notes.Add(g.Def.nameKo + (broke ? " 파괴" : " −" + dmg)); if (broke && g.Def.projectile == GimmickProjectile.Destroy) yield return Explode(g, by, o, 0); } /// /// 폭약 수레 폭발(광역 파이프라인 재사용): 반경 p1 안 유닛 전원(아군 포함 · 성문·성벽 너머 제외) 피해 = 터뜨린 유닛 공 × p2 · 바깥으로 p3 밀기 · /// 반경 안 다른 수레 연쇄 · 그 자리 불 지대(반경 p1 · 지속 = 스테이지 화공 배치 burn_turns · 없으면 정의 p2). /// IEnumerator Explode(GimmickInstance g, Unit by, ActionOutcome o, int depth) { if (depth > 4) yield break; Vector2 c = g.Shape.Center; float r = g.P(1, 0f), mult = g.P(2, 1f), push = g.P(3, 0f); if (!g.Broken) { g.Broken = true; g.Hp = 0; g.SetCollidersEnabled(false); if (!Sim.Active && g.View != null) g.View.Shatter(); } if (!Sim.Active) { Fx.Burst(c, r, ExplosionColor); Fx.Popup(c, g.Def.nameKo + " 폭발!", new Color(1f, 0.6f, 0.2f)); } o.Notes.Add(g.Def.nameKo + " 폭발"); var victims = Combat.UnitsInCircle(c, r, null, null); victims.RemoveAll(v => Layer.SplashBlocked(c, v.Position)); if (by != null) { foreach (var v in victims) { if (!v.IsAlive) continue; int dmg = SkillSystem.Deal(v, Mathf.RoundToInt(Combat.GimmickDamage(cfg, by, v, mult, rng) * SkillMods.GimmickMult(by)), by, HitKind.Gimmick); // 전위 고지악래 × if (v.Faction == by.Faction) { o.FriendlyHit.Add(v); o.FriendlyDamage += dmg; if (!Sim.Active) Fx.Popup(v.Position, dmg + " 폭발", Fx.FriendlyFireColor); } else { o.Attacked.Add(v); o.DamageDealt += dmg; if (!Sim.Active) Fx.Popup(v.Position, dmg.ToString(), new Color(1f, 0.7f, 0.3f)); } } } int running = 0; foreach (var v in victims) { if (!v.IsAlive || push < 0.01f) continue; Vector2 n = v.Position - c; n = n.sqrMagnitude > 1e-6f ? n.normalized : Vector2.up; running++; Fx.Run(Track(Push(v, n, push, null, o), () => running--)); } while (running > 0) yield return null; foreach (var other in new List(Layer.Items)) { if (other == g || !other.BlocksMove || other.Def.projectile != GimmickProjectile.Destroy) continue; if (other.Shape.SignedDistance(c) <= r) yield return Explode(other, by, o, depth + 1); } Layer.SpawnFire(c, r); battle.NotifyChanged(); yield return Fx.Wait(0.2f); } /// 바위 굴림: 바위 원을 dir 로 dist 만큼(벽·다른 장애물·유닛에서 멈춤) · 상대 유닛에 닿으면 공 × p1 피해 + p2 밀기. IEnumerator RollBoulder(Unit pusher, GimmickInstance g, Vector2 dir, float dist, ActionOutcome o) { if (dist < 0.01f || !g.BlocksMove) yield break; Physics2D.SyncTransforms(); float r = g.Shape.Type == GimmickShapeType.Circle ? g.Shape.R : g.Shape.BoundingRadius; var hits = Physics2D.CircleCastAll(g.Shape.Center, r * 0.95f, dir, dist); System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance)); float travel = dist; Unit victim = null; foreach (var h in hits) { if (g.Colliders.Contains(h.collider)) continue; if (h.collider.GetComponent() != null || GimmickLayer.BlocksMove(h.collider)) { travel = h.distance; break; } var v = h.collider.GetComponent(); if (v == null || v == pusher || !v.IsAlive) continue; travel = h.distance; victim = v; break; } travel = Mathf.Max(0f, travel - Combat.Epsilon); if (travel > 0.01f) { Vector2 from = g.Shape.Center; if (!Sim.Active && g.Go != null) yield return Fx.MoveAlong(g.Go.transform, from, from + dir * travel, cfg.moveSpeed, null); g.MoveBy(dir * travel); o.Notes.Add(g.Def.nameKo + " 굴림"); } if (victim != null && victim.Faction != pusher.Faction) { int dmg = SkillSystem.Deal(victim, Mathf.RoundToInt(Combat.GimmickDamage(cfg, pusher, victim, g.P(1, 1f), rng) * SkillMods.GimmickMult(pusher)), pusher, HitKind.Gimmick); o.Attacked.Add(victim); o.DamageDealt += dmg; if (!Sim.Active) Fx.Popup(victim.Position, dmg + " 바위", new Color(1f, 0.75f, 0.4f)); if (victim.IsAlive) yield return Push(victim, dir, g.P(2, 0f), pusher, o); } battle.NotifyChanged(); } /// 목책 밀기(창병): spear_push 만큼(벽·다른 장애물에서 멈춤) · 새 자리와 겹칠 유닛은 먼저 그만큼 밀고 · 다 못 밀리면 목책도 그만큼만. IEnumerator ShoveGimmick(GimmickInstance g, Vector2 dir, float dist, Unit pusher, ActionOutcome o) { float travel = ShapeTravel(g, dir, dist); if (travel < 0.01f) yield break; var moved = g.Shape.Translated(dir * travel); int running = 0; foreach (var u in battle.Units) { if (u == null || !u.IsAlive || u == pusher) continue; if (moved.SignedDistance(u.Position) >= u.Radius) continue; float need = 0f; while (need < travel + u.Radius * 2f && moved.SignedDistance(u.Position + dir * need) < u.Radius + 0.01f) need += 0.02f; running++; Fx.Run(Track(Push(u, dir, need + 0.02f, pusher, o), () => running--)); } while (running > 0) yield return null; float allowed = travel; foreach (var u in battle.Units) { if (u == null || !u.IsAlive || u == pusher) continue; while (allowed > 0f && g.Shape.Translated(dir * allowed).SignedDistance(u.Position) < u.Radius - 0.01f) allowed -= 0.02f; } allowed = Mathf.Max(0f, allowed); if (allowed < 0.01f) yield break; Vector2 from = g.Shape.Center; if (!Sim.Active && g.Go != null) yield return Fx.MoveAlong(g.Go.transform, from, from + dir * allowed, cfg.moveSpeed, null); g.MoveBy(dir * allowed); o.Notes.Add(g.Def.nameKo + " 밀림"); battle.NotifyChanged(); } static float ShapeTravel(GimmickInstance g, Vector2 dir, float dist) { Physics2D.SyncTransforms(); var s = g.Shape; RaycastHit2D[] hits = s.Type == GimmickShapeType.Rect ? Physics2D.BoxCastAll(s.Center, s.Size * 0.98f, s.Rot, dir, dist) : Physics2D.CircleCastAll(s.Center, (s.Type == GimmickShapeType.Circle ? s.R : s.BoundingRadius) * 0.95f, dir, dist); float min = dist; foreach (var h in hits) { if (g.Colliders.Contains(h.collider)) continue; if (h.collider.GetComponent() != null || GimmickLayer.BlocksMove(h.collider)) min = Mathf.Min(min, h.distance); } return Mathf.Max(0f, min - Combat.Epsilon); } /// 기병 관통 옆 밀기: 늪·강 안 = 0 · 빙판 위 = ×p2 · 장애물·함정에서 멈춤 · 강가·다리에서 강물로 밀리면 빠짐. Vector2 KnockTo(Unit e, Vector2 dir, float kb) { if (Gim) kb *= Layer.KnockMult(e); Vector2 to = battle.Board.ClampInside(e.Position + dir * kb, e.Radius); if (!Gim || kb < 0.01f) return to; float want = Vector2.Distance(e.Position, to); float free = want; float h; Vector2 n; if (Combat.FirstGimmickOnPath(e, e.Position, dir, want, out h, out n) != null) free = Mathf.Max(0f, h - Combat.Epsilon); free = Mathf.Min(free, Layer.TrapStop(e, e.Position, dir, want)); NoteForced(e, e.Position, e.Position + dir * free); return e.Position + dir * free; } // ---------- 투사체 · 책사 광역 ---------- /// 책사 광역 계획: 중심 = 첫 피격 유닛 · 유닛이 없고 제거 가능 기믹(덤불·수레)에 막혔으면 그 자리 · 성문·성벽 너머 제외 · 지울 기믹. void AimSplash(Unit u, ShotPlan p) { p.Splash.Clear(); p.ClearGimmicks.Clear(); p.HasSplash = false; if (p.Hit != null) p.SplashCenter = p.Hit.Position; else if (p.HitGimmick != null && p.HitGimmick.Def.strategistClear) p.SplashCenter = p.To; else return; p.HasSplash = true; p.SplashRadius = SkillMods.SplashRadius(u, cfg); // 안개 = 반경은 그대로 · 원거리 피해 ×p1(Combat.Damage · PD 2026-11-06 개정) p.Splash.AddRange(Combat.UnitsInCircle(p.SplashCenter, p.SplashRadius, p.Hit, u)); if (!Gim) return; p.Splash.RemoveAll(s => Layer.SplashBlocked(p.SplashCenter, s.Position)); p.ClearGimmicks.AddRange(Layer.ClearableIn(p.SplashCenter, p.SplashRadius)); } /// 투사체가 기믹에 막힘: 수레 = 파괴(폭발) · 그 밖 = 막힘(피해 없음). IEnumerator ArrowHitsGimmick(Unit u, ShotPlan p, ActionOutcome o) { var g = p.HitGimmick; if (g == null) yield break; if (g.Def.projectile == GimmickProjectile.Destroy) yield return DamageGimmick(g, Mathf.Max(1, g.Hp), u, o); else { o.Notes.Add(g.Def.nameKo + "에 막힘"); if (!Sim.Active) Fx.Popup(p.To, "막힘", new Color(0.85f, 0.85f, 0.85f)); } } /// 책사 광역의 기믹: 덤불 제거 · 수레 파괴(폭발) · 그 반경만큼 안개 걷힘(PD 2026-11-06) · 화공 스테이지 = 그 자리 불. IEnumerator SplashGimmicks(Unit u, ShotPlan p, ActionOutcome o) { if (!Gim || !p.HasSplash) yield break; foreach (var g in p.ClearGimmicks) { if (!g.BlocksMove) continue; if (g.Def.projectile == GimmickProjectile.Destroy) yield return DamageGimmick(g, Mathf.Max(1, g.Hp), u, o); else { Layer.Remove(g); o.Notes.Add(g.Def.nameKo + " 제거"); } } if (Layer.ClearFog(p.SplashCenter, p.SplashRadius)) o.Notes.Add("안개 걷힘"); if (Layer.OnSplash(p.SplashCenter, p.SplashRadius) != null) o.Notes.Add("불길"); } /// 빙판: 사격 전 이동이 빙판에서 끝나면 미끄러짐 → 사거리 밖이면 사격 취소(PD #1097 §9 ③ 제안) · 아니면 새 자리에서 다시 조준. IEnumerator SlideBeforeShot(Unit u, Vector2 dir, ShotPlan p, Vector2 target, ActionOutcome o) { if (!Gim) yield break; float s = Layer.SlideDistance(u.Position, dir); if (s < 0.05f) yield break; Layer.Text(u, "미끄러짐", IceTextColor); o.Notes.Add(u.DisplayName + " 미끄러짐"); yield return MoveOrHit(u, u.Position, dir, s, o); p.From = u.Position; float range = SkillMods.Range(u); float gap = Vector2.Distance(u.Position, p.Target.Position) - u.Radius * Combat.CastRadiusScale - p.Target.Radius; if (!p.Target.IsAlive || gap > range + 0.05f) { p.Fires = false; o.Notes.Add("사거리 이탈 · 사격 취소"); yield break; } Aim(u, p, dir, target, range); } // ---------- 행동 끝 ---------- /// 행동 끝(기믹): 행동 유닛 미끄러짐(행동당 1회 · 최대 p1) → 밀린 유닛 미끄러짐 → 지대 스윕(독·불·함정) → 다리 → 강. IEnumerator GimmickAfter(Unit u, bool moveOnly, ActionOutcome o, bool slideMover) { if (!Gim) yield break; Vector2 dir; if (slideMover && u.IsAlive && LastDir(o.Path, out dir)) { float s = Layer.SlideDistance(u.Position, dir); if (s >= 0.05f) { Layer.Text(u, "미끄러짐", IceTextColor); o.Notes.Add(u.DisplayName + " 미끄러짐"); yield return SlideMover(u, dir, s, moveOnly, o); } } var seen = new HashSet(); for (int i = o.Displaced.Count - 1; i >= 0; i--) { var d = o.Displaced[i]; if (d.Unit == null || d.Unit == u || !d.Unit.IsAlive || !seen.Add(d.Unit)) continue; Vector2 dd = d.To - d.From; if (dd.sqrMagnitude < 1e-4f) continue; float s = Layer.SlideDistance(d.Unit.Position, dd); if (s < 0.05f) continue; Layer.Text(d.Unit, "미끄러짐", IceTextColor); yield return Push(d.Unit, dd.normalized, s, null, o); } Layer.ApplyZones(u, o); battle.NotifyChanged(); } static bool LastDir(List path, out Vector2 dir) { dir = Vector2.zero; for (int i = path.Count - 1; i > 0; i--) { Vector2 d = path[i] - path[i - 1]; if (d.sqrMagnitude < 1e-4f) continue; dir = d.normalized; return true; } return false; } /// 행동 유닛 미끄러짐 = 같은 방향으로 병종 루틴을 미끄러질 거리만큼 다시(충돌 규칙 그대로) · 명중 보상·복귀·견제는 원래 행동 것만. IEnumerator SlideMover(Unit u, Vector2 dir, float s, bool moveOnly, ActionOutcome o) { bool bonus = o.BonusMove, ret = o.Returned, ow = o.Overwatch, moved = o.MovedOnly, battack = o.BonusAttack; int bonusN = o.BonusMoves; bool unitOw = u.Overwatch; Vector2 from = u.Position; Vector2 target = from + dir * s; switch (u.Type) { case UnitType.Infantry: yield return InfantryRoutine(u, from, dir, s, target, moveOnly, o); break; case UnitType.Spear: yield return SpearRoutine(u, from, dir, s, target, moveOnly, o); break; case UnitType.Cavalry: yield return CavalryRoutine(u, from, dir, s, target, moveOnly, o, new List(o.Attacked)); break; default: yield return MoveOrHit(u, from, dir, s, o); break; } o.BonusMove = bonus; o.Returned = ret; o.Overwatch = ow; o.MovedOnly = moved && o.Attacked.Count == 0; o.BonusAttack = battack; o.BonusMoves = bonusN; if (u.Overwatch != unitOw) u.SetOverwatch(unitOw); } } }