From 8a7c565609b2d4dcec310b8e80eb068b02e9dd37 Mon Sep 17 00:00:00 2001 From: swrring Date: Fri, 6 Nov 2026 01:42:15 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=95=C2=B7=EC=95=88=EA=B0=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=A0=95(PD=202026-11-06=20#1101)=20=E2=80=94=20=EA=B0=95=20?= =?UTF-8?q?=3D=20=EB=8A=AA=EC=B2=98=EB=9F=BC=20=ED=86=B5=EA=B3=BC(?= =?UTF-8?q?=EA=B0=95=20=EC=95=88=20=C3=972=20=C2=B7=20=EB=8B=A4=EB=A6=AC?= =?UTF-8?q?=20=3D=20=ED=9A=A8=EA=B3=BC=20=EC=97=86=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=91=20=EB=84=89=EB=B0=B1=200)=20=C2=B7=20?= =?UTF-8?q?=EA=B0=95=EA=B0=80=C2=B7=EB=8B=A4=EB=A6=AC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B0=80=EB=A0=A4=20=EA=B0=95=EB=AC=BC=EC=97=90=20=EB=93=A4?= =?UTF-8?q?=EB=A9=B4=2030=20%=20+=201=ED=84=B4=20=C2=B7=20=EC=95=88?= =?UTF-8?q?=EA=B0=9C=20=3D=20=EC=9B=90=EA=B1=B0=EB=A6=AC=20=ED=94=BC?= =?UTF-8?q?=ED=95=B4(=ED=99=94=EC=82=B4=C2=B7=EC=B1=85=EC=82=AC)=20=C3=970?= =?UTF-8?q?.5=20=C2=B7=20=ED=91=9C=EC=A0=81=20=EC=A0=95=EC=83=81=20=C2=B7?= =?UTF-8?q?=20=EC=B1=85=EC=82=AC=20=EA=B4=91=EC=97=AD=EC=9D=B4=20=EA=B7=B8?= =?UTF-8?q?=20=EB=B0=98=EA=B2=BD=20=EC=95=88=EA=B0=9C=EB=A5=BC=20=EA=B1=B7?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gimmicks.csv river(kind zone · p3 2.0) · fog(p1 = 원거리 피해 배율) · bridge(desc) · 강 콜라이더 조각 폐기 - 강제 이동 기록 = 구간(From·To) → 강물 진입 판정 · Combat.Damage 원거리 = 안개 ×p1 · 안개 걷힘 = SpriteMask - 테스트 113/113(강·안개 2건 새 규칙) · console error 0 Co-Authored-By: Claude Opus 5.5 --- Assets/Data/Gimmicks/gimmicks.csv | 6 +- Assets/Resources/Gimmicks/GimmickDb.asset | 30 +-- Assets/Scripts/Runtime/ActionResolver.cs | 6 +- Assets/Scripts/Runtime/Combat.cs | 2 +- .../Gimmicks/ActionResolver.Gimmicks.cs | 15 +- .../Runtime/Gimmicks/Combat.Gimmicks.cs | 3 +- .../Runtime/Gimmicks/GimmickCaptureDriver.cs | 7 +- .../Scripts/Runtime/Gimmicks/GimmickLayer.cs | 198 +++++++++--------- .../Runtime/Gimmicks/GimmickPreview.cs | 13 +- .../Scripts/Runtime/Gimmicks/GimmickView.cs | 1 + Assets/Tests/PlayMode/GimmickTests.cs | 84 ++++++-- CLAUDE.md | 2 +- 12 files changed, 211 insertions(+), 156 deletions(-) diff --git a/Assets/Data/Gimmicks/gimmicks.csv b/Assets/Data/Gimmicks/gimmicks.csv index 21bf35fa..68f96a11 100644 --- a/Assets/Data/Gimmicks/gimmicks.csv +++ b/Assets/Data/Gimmicks/gimmicks.csv @@ -7,11 +7,11 @@ thorn_bush,가시덤불,block,0,0,0,0,0,0,0.05,0.05,block,1,,,,,gim_thorn,,통 ice,빙판,zone,0,0,0,0,0,0,0,0,pass,0,slide,3.0,1.5,,gim_ice,,정지 지점이 빙판이면 같은 방향으로 빙판 끝까지(최대 p1) 미끄러짐 · 충돌 규칙 그대로 · 기병 빙판 위 밀기 ×p2 [p1 최대 거리 · p2 기병 밀기 배율] boulder,바위,block,0,0,0,0,0,0.5,0.05,0,block,0,,1.0,1.0,,gim_boulder,,보·창이 밀면 바위 이동(남은 거리 × spear_push) · 밀린 바위가 적에 닿으면 공 ×p1 피해 + 밀기 p2 · 기병 충돌 정지·5 % · 투사체 막힘 · 파괴 불가 [p1 피해 배율 · p2 밀기] high_ground,고지,zone,0,0,0,0,0,0,0,0,pass,0,high,1.0,0.15,1.5,gim_high,,위에 선 궁·책 사거리 +p1 · 아래에서 오는 근접 피해 −p2 · 오르는 경로 이동량 ×p3 [p1 사거리 · p2 피해 감소 · p3 이동 비용] -fog,안개,zone,0,0,0,0,0,0,0,0,pass,0,fog,0.5,0,,gim_fog,,안개 안 유닛은 사선 표적 제외(안에서 쏘기는 가능) · 근접 정상 · 광역 중심이 안개면 범위 ×p1 [p1 광역 배율] +fog,안개,zone,0,0,0,0,0,0,0,0,pass,0,fog,0.5,0,,gim_fog,,안개 안 유닛이 받는 원거리 피해(화살·책사 광역) ×p1 · 표적·사선·근접은 정상 · 책사 광역이 터진 반경만큼 안개가 걷힘(PD 2026-11-06 개정) [p1 원거리 피해 배율] powder_cart,폭약 수레,block,1,1,1,1,1,0,0,0,destroy,1,,2.0,1.5,1.0,gim_cart,gim_cart_broken,내구 1 · 어떤 공격·화살에도 파괴 · 파괴 시 반경 p1 광역 공 ×p2(아군 포함) + 밀기 p3 · 불타는 잔해 생성 [p1 반경 · p2 피해 배율 · p3 밀기] banner,군기,zone,0,0,0,0,0,0,0,0,pass,0,capture,1.5,0.1,,gim_banner,,반경 p1 안에 한쪽 편만 있으면 턴 시작 시 점령 → 점령 측 전원 공 +p2 · 양쪽 있으면 중립 [p1 반경 · p2 공 증가] gate,성문,block,1,6,2,1,1,0,0.1,0,block,0,,,,,gim_gate,gim_gate_broken,내구 6 · 보 2 · 창 1(밀기 0) · 기 1 + 자기 HP 10 % · 화살·광역 막힘 · 파괴 시 통로 열림(성벽 구간은 wall) wall,성벽,block,0,0,0,0,0,0,0.05,0,block,0,,,,,gim_wall,,파괴 불가 · 부딪히면 튕김(기병 5 %) · 투사체 막힘 · 성문과 짝 -river,강,block,0,0,0,0,0,0,0,0,pass,0,river,0.3,1,,gim_river,,통과 불가(투사체 통과) · 다리 위에서 밀려 강에 빠지면 최대 HP p1 + p2 턴 행동 불가 · 다리 = bridge 레코드가 겹친 구간만 통행 [p1 피해 · p2 턴] -bridge,다리,zone,0,0,0,0,0,0,0,0,pass,0,,1.2,,,gim_bridge,,강 위 통로(폭 p1) · 다리 위에서 옆으로 밀리면 river 판정 [p1 폭] +river,강,zone,0,0,0,0,0,0,0,0,pass,0,river,0.3,1,2.0,gim_river,,늪처럼 통과 가능 · 강 안 구간 이동량 ×p3 · 기병 넉백 강 안 0 · 밀려서(강가·다리) 강에 들어가면 최대 HP p1 + p2 턴 행동 불가 · 걸어 들어가면 느려지기만 · 다리 구간 = 효과 없음 · 투사체 통과(PD 2026-11-06 개정) [p1 피해 · p2 턴 · p3 이동 비용] +bridge,다리,zone,0,0,0,0,0,0,0,0,pass,0,,1.2,,,gim_bridge,,강 위 통로(폭 p1) · 다리 위 = 강 효과 없음(이동 비용·빠짐 없음) · 다리에서 옆으로 밀려 강에 들어가면 빠짐 [p1 폭] pit_trap,함정,zone,0,0,0,0,0,0,0,0,pass,0,trap,1,,,gim_trap,gim_trap_open,숨김(아군 조준선 미표시 · 적 AI 는 인지) · 밟으면 그 자리 정지 + p1 턴 행동 불가 · 발동 후 제거 [p1 턴] diff --git a/Assets/Resources/Gimmicks/GimmickDb.asset b/Assets/Resources/Gimmicks/GimmickDb.asset index e32d068e..9fa1ad40 100644 --- a/Assets/Resources/Gimmicks/GimmickDb.asset +++ b/Assets/Resources/Gimmicks/GimmickDb.asset @@ -218,10 +218,11 @@ MonoBehaviour: p3: NaN art: gim_fog artBroken: - descKo: "\uC548\uAC1C \uC548 \uC720\uB2DB\uC740 \uC0AC\uC120 \uD45C\uC801 \uC81C\uC678(\uC548\uC5D0\uC11C - \uC3D8\uAE30\uB294 \uAC00\uB2A5) \xB7 \uADFC\uC811 \uC815\uC0C1 \xB7 \uAD11\uC5ED - \uC911\uC2EC\uC774 \uC548\uAC1C\uBA74 \uBC94\uC704 \xD7p1 [p1 \uAD11\uC5ED - \uBC30\uC728]" + descKo: "\uC548\uAC1C \uC548 \uC720\uB2DB\uC774 \uBC1B\uB294 \uC6D0\uAC70\uB9AC + \uD53C\uD574(\uD654\uC0B4\xB7\uCC45\uC0AC \uAD11\uC5ED) \xD7p1 \xB7 \uD45C\uC801\xB7\uC0AC\uC120\xB7\uADFC\uC811\uC740 + \uC815\uC0C1 \xB7 \uCC45\uC0AC \uAD11\uC5ED\uC774 \uD130\uC9C4 \uBC18\uACBD\uB9CC\uD07C + \uC548\uAC1C\uAC00 \uAC77\uD798(PD 2026-11-06 \uAC1C\uC815) [p1 \uC6D0\uAC70\uB9AC + \uD53C\uD574 \uBC30\uC728]" - id: powder_cart nameKo: "\uD3ED\uC57D \uC218\uB808" kind: 0 @@ -314,7 +315,7 @@ MonoBehaviour: 5 %) \xB7 \uD22C\uC0AC\uCCB4 \uB9C9\uD798 \xB7 \uC131\uBB38\uACFC \uC9DD" - id: river nameKo: "\uAC15" - kind: 0 + kind: 1 durable: 0 hp: 0 dmgInf: 0 @@ -328,13 +329,16 @@ MonoBehaviour: zone: 9 p1: 0.3 p2: 1 - p3: NaN + p3: 2 art: gim_river artBroken: - descKo: "\uD1B5\uACFC \uBD88\uAC00(\uD22C\uC0AC\uCCB4 \uD1B5\uACFC) \xB7 \uB2E4\uB9AC - \uC704\uC5D0\uC11C \uBC00\uB824 \uAC15\uC5D0 \uBE60\uC9C0\uBA74 \uCD5C\uB300 - HP p1 + p2 \uD134 \uD589\uB3D9 \uBD88\uAC00 \xB7 \uB2E4\uB9AC = bridge \uB808\uCF54\uB4DC\uAC00 - \uACB9\uCE5C \uAD6C\uAC04\uB9CC \uD1B5\uD589 [p1 \uD53C\uD574 \xB7 p2 \uD134]" + descKo: "\uB2AA\uCC98\uB7FC \uD1B5\uACFC \uAC00\uB2A5 \xB7 \uAC15 \uC548 \uAD6C\uAC04 + \uC774\uB3D9\uB7C9 \xD7p3 \xB7 \uAE30\uBCD1 \uB109\uBC31 \uAC15 \uC548 0 \xB7 + \uBC00\uB824\uC11C(\uAC15\uAC00\xB7\uB2E4\uB9AC) \uAC15\uC5D0 \uB4E4\uC5B4\uAC00\uBA74 + \uCD5C\uB300 HP p1 + p2 \uD134 \uD589\uB3D9 \uBD88\uAC00 \xB7 \uAC78\uC5B4 + \uB4E4\uC5B4\uAC00\uBA74 \uB290\uB824\uC9C0\uAE30\uB9CC \xB7 \uB2E4\uB9AC \uAD6C\uAC04 + = \uD6A8\uACFC \uC5C6\uC74C \xB7 \uD22C\uC0AC\uCCB4 \uD1B5\uACFC(PD 2026-11-06 + \uAC1C\uC815) [p1 \uD53C\uD574 \xB7 p2 \uD134 \xB7 p3 \uC774\uB3D9 \uBE44\uC6A9]" - id: bridge nameKo: "\uB2E4\uB9AC" kind: 1 @@ -354,8 +358,10 @@ MonoBehaviour: p3: NaN art: gim_bridge artBroken: - descKo: "\uAC15 \uC704 \uD1B5\uB85C(\uD3ED p1) \xB7 \uB2E4\uB9AC \uC704\uC5D0\uC11C - \uC606\uC73C\uB85C \uBC00\uB9AC\uBA74 river \uD310\uC815 [p1 \uD3ED]" + descKo: "\uAC15 \uC704 \uD1B5\uB85C(\uD3ED p1) \xB7 \uB2E4\uB9AC \uC704 = \uAC15 + \uD6A8\uACFC \uC5C6\uC74C(\uC774\uB3D9 \uBE44\uC6A9\xB7\uBE60\uC9D0 \uC5C6\uC74C) + \xB7 \uB2E4\uB9AC\uC5D0\uC11C \uC606\uC73C\uB85C \uBC00\uB824 \uAC15\uC5D0 + \uB4E4\uC5B4\uAC00\uBA74 \uBE60\uC9D0 [p1 \uD3ED]" - id: pit_trap nameKo: "\uD568\uC815" kind: 1 diff --git a/Assets/Scripts/Runtime/ActionResolver.cs b/Assets/Scripts/Runtime/ActionResolver.cs index 80bc8a3e..a23206f5 100644 --- a/Assets/Scripts/Runtime/ActionResolver.cs +++ b/Assets/Scripts/Runtime/ActionResolver.cs @@ -315,7 +315,7 @@ namespace TKS { // 반작용: 벽·유닛·장애물에 닿으면 정지 · 피해 없음 · 보는 방향은 유지(밀친 쪽을 본 채 물러남) float back = Combat.FreeTravel(u, u.Position, -dir, recoil, true, true, first); - NoteForced(u, -dir, recoil, back); + NoteForced(u, u.Position, u.Position - dir * back); if (back > 0.01f) { Vector2 start = u.Position; @@ -826,7 +826,7 @@ namespace TKS if (self >= 0.01f && striker.IsAlive) { float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck); - NoteForced(striker, -n, self, back); + NoteForced(striker, striker.Position, striker.Position - n * back); if (back > 0.01f) { Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082) @@ -852,7 +852,7 @@ namespace TKS if (pusher != null && pusher.Faction != target.Faction) BattleEvents.Raise(BattleEvents.Pushes, pusher); // 보물 조건 효과(PD #1093 C) force = Reach(target, target.Position, n, force); // 늪 = 밀리는 거리도 이동 비용(PD #1097) float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher); - NoteForced(target, n, force, slide); // 막힘 기록 → 다리 위에서 강 쪽이면 강에 빠짐 + NoteForced(target, target.Position, target.Position + n * slide); // 강제 이동 기록 → 강가·다리에서 밀려 강물에 들면 빠짐 float moved = 0f; if (slide > 0.01f) { diff --git a/Assets/Scripts/Runtime/Combat.cs b/Assets/Scripts/Runtime/Combat.cs index 4fde08d0..b2c6330c 100644 --- a/Assets/Scripts/Runtime/Combat.cs +++ b/Assets/Scripts/Runtime/Combat.cs @@ -31,6 +31,7 @@ namespace TKS if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type) && !SkillMods.Has(defender, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeTakenRatio; raw *= GimmickLayer.MeleeTakenMultOf(attacker, defender); // 고지 위 = 아래에서 오는 근접 피해 −(PD #1097) } + else raw *= GimmickLayer.RangedTakenMultOf(defender); // 안개 안 = 받는 원거리 피해 ×p1(화살 · 책사 중심·광역 · PD 2026-11-06 개정) raw *= scale * SkillMods.DealtMult(attacker, defender, true) * SkillMods.TakenMult(defender, attacker, true); return Mathf.Max(1, Mathf.RoundToInt(raw * Variance(cfg, rng))); // 가상 실행 = 분산 0(PD #1098) } @@ -86,7 +87,6 @@ namespace TKS { var u = UnitOf(h.collider); if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue; - if (GimmickLayer.IsFogged(u)) continue; // 안개 안 = 사선 표적 제외(PD #1097) hitDist = h.distance; return u; } diff --git a/Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs b/Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs index 14c21814..b46d1268 100644 --- a/Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs +++ b/Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs @@ -41,9 +41,9 @@ namespace TKS return Gim ? Layer.CostReach(u, origin, dir, budget) : budget; } - static void NoteForced(Unit u, Vector2 dir, float intended, float actual) + static void NoteForced(Unit u, Vector2 from, Vector2 to) { - if (Gim) Layer.NoteForced(u, dir, intended, actual); + if (Gim) Layer.NoteForced(u, from, to); } void GimmickBegin(ActionOutcome o) @@ -285,7 +285,7 @@ namespace TKS return Mathf.Max(0f, min - Combat.Epsilon); } - /// 기병 관통 옆 밀기: 늪 안 = 0 · 빙판 위 = ×p2 · 장애물·함정에서 멈춤 · 다리 위에서 강 쪽이면 강 판정. + /// 기병 관통 옆 밀기: 늪·강 안 = 0 · 빙판 위 = ×p2 · 장애물·함정에서 멈춤 · 강가·다리에서 강물로 밀리면 빠짐. Vector2 KnockTo(Unit e, Vector2 dir, float kb) { if (Gim) kb *= Layer.KnockMult(e); @@ -297,13 +297,13 @@ namespace TKS 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, dir, kb, free); + NoteForced(e, e.Position, e.Position + dir * free); return e.Position + dir * free; } // ---------- 투사체 · 책사 광역 ---------- - /// 책사 광역 계획: 중심 = 첫 피격 유닛 · 유닛이 없고 제거 가능 기믹(덤불·수레)에 막혔으면 그 자리 · 반경 × 안개 · 성문·성벽 너머 제외 · 지울 기믹. + /// 책사 광역 계획: 중심 = 첫 피격 유닛 · 유닛이 없고 제거 가능 기믹(덤불·수레)에 막혔으면 그 자리 · 성문·성벽 너머 제외 · 지울 기믹. void AimSplash(Unit u, ShotPlan p) { p.Splash.Clear(); @@ -313,7 +313,7 @@ namespace TKS else if (p.HitGimmick != null && p.HitGimmick.Def.strategistClear) p.SplashCenter = p.To; else return; p.HasSplash = true; - p.SplashRadius = SkillMods.SplashRadius(u, cfg) * (Gim ? Layer.FogSplashMult(p.SplashCenter) : 1f); + 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)); @@ -333,7 +333,7 @@ namespace TKS } } - /// 책사 광역의 기믹: 덤불 제거 · 수레 파괴(폭발) · 화공 스테이지 = 그 자리 불. + /// 책사 광역의 기믹: 덤불 제거 · 수레 파괴(폭발) · 그 반경만큼 안개 걷힘(PD 2026-11-06) · 화공 스테이지 = 그 자리 불. IEnumerator SplashGimmicks(Unit u, ShotPlan p, ActionOutcome o) { if (!Gim || !p.HasSplash) yield break; @@ -347,6 +347,7 @@ namespace TKS 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("불길"); } diff --git a/Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs b/Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs index e312c1c5..040d0cec 100644 --- a/Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs +++ b/Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs @@ -36,7 +36,7 @@ namespace TKS /// /// 투사체(PD 2026-11-05 #1087 ⑥ + 기믹 #1097): 첫 유닛 또는 막는 기믹. 목책·강 = 통과(pass) · 덤불·바위·성문·성벽 = 막힘(gimmick 에 담고 null) · - /// 폭약 수레 = 맞으면 파괴(gimmick 에 담고 null) · 안개 안 유닛 = 사선 판정 제외(통과). + /// 폭약 수레 = 맞으면 파괴(gimmick 에 담고 null) · 안개 안 유닛도 정상 피격(피해만 ×p1 · PD 2026-11-06 개정). /// public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel, out GimmickInstance gimmick) { @@ -63,7 +63,6 @@ namespace TKS var u = UnitOf(h.collider); if (u == null || u == shooter || !u.IsAlive) continue; if (passFaction.HasValue && u.Faction == passFaction.Value) continue; - if (GimmickLayer.IsFogged(u)) continue; travel = h.distance; return u; } diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs index 460e9092..e6d57751 100644 --- a/Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs @@ -190,9 +190,10 @@ namespace TKS IEnumerator BridgePushShot(BattleManager bm, Camera cam, CameraController cc) { yield return Stage(bm); - var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, 0.8f), "보병"); - bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.15f, 3f), "다리 위 적"); - yield return AimCapture(bm, cam, cc, u, new Vector2(0f, 3.2f), new Vector2(0f, 2.2f), "aim_9-11_bridge_push"); + var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-0.6f, 0.8f), "보병"); + var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.35f, 3f), "다리 위 적"); + Vector2 n = (foe.Position - u.Position).normalized; + yield return AimCapture(bm, cam, cc, u, u.Position + n * 4f, new Vector2(0f, 2.2f), "aim_9-11_bridge_push"); } /// AI 전술: 적 보병이 아군을 함정 쪽으로 튕겨낸다(함정 발동 순간 캡처). diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs index 6d8d0af4..3f5ae4fd 100644 --- a/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs @@ -47,14 +47,17 @@ namespace TKS public struct ForcedMove { public Unit Unit; - public Vector2 Dir; - public float Intended; - public float Actual; + 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; @@ -115,7 +118,6 @@ namespace TKS if (def == null) continue; Spawn(def, p.Clone(), false); } - RebuildRivers(); ResolveOverlaps(); } @@ -178,6 +180,12 @@ namespace TKS 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(); } @@ -231,9 +239,7 @@ namespace TKS else if (k == "p3" || k == "spawn_on_splash") p.pOver3 = v; } } - var g = Spawn(def, p, false); - if (def.zone == GimmickZone.River || def.id == GimmickIds.Bridge) RebuildRivers(); - return g; + return Spawn(def, p, false); } void BuildObject(GimmickInstance g) @@ -283,82 +289,6 @@ namespace TKS g.Colliders.Add(col); } - /// 강 콜라이더 = 강 사각형에서 다리가 겹친 구간(강 가로축 투영)을 뺀 조각들. 원·다각형 강은 다리 없이 통째로. - void RebuildRivers() - { - if (Sim.Active) return; - foreach (var r in items) - { - if (r.Zone != GimmickZone.River || r.Go == null) continue; - foreach (var c in r.Colliders) - { - if (c == null) continue; - c.gameObject.SetActive(false); // 콜라이더를 즉시 물리 월드에서 제거 - Destroy(c.gameObject); - } - r.Colliders.Clear(); - if (r.Shape.Type != GimmickShapeType.Rect) - { - AddCollider(r, r.Shape, r.Go.transform); - continue; - } - float half = r.Shape.Size.x * 0.5f; - var cuts = new List(); - Vector2 ax = r.Shape.LocalAxisX; - foreach (var b in items) - { - if (!b.IsBridge || b.Removed) continue; - if (r.Shape.SignedDistance(b.Shape.Center) > b.Shape.BoundingRadius) continue; - float lo = float.MaxValue, hi = float.MinValue; - foreach (var corner in Corners(b.Shape)) - { - float x = Vector2.Dot(corner - r.Shape.Center, ax); - lo = Mathf.Min(lo, x); - hi = Mathf.Max(hi, x); - } - cuts.Add(new Vector2(Mathf.Max(lo, -half), Mathf.Min(hi, half))); - } - cuts.Sort((a, b) => a.x.CompareTo(b.x)); - float cur = -half; - var pieces = new List(); - foreach (var c in cuts) - { - if (c.x > cur + 0.01f) pieces.Add(new Vector2(cur, c.x)); - cur = Mathf.Max(cur, c.y); - } - if (half > cur + 0.01f) pieces.Add(new Vector2(cur, half)); - foreach (var pc in pieces) - { - float mid = (pc.x + pc.y) * 0.5f; - var s = GimmickShape.Box(r.Shape.Center + ax * mid, new Vector2(pc.y - pc.x, r.Shape.Size.y), r.Shape.Rot); - AddCollider(r, s, r.Go.transform); - } - } - } - - static IEnumerable Corners(GimmickShape s) - { - if (s.Type == GimmickShapeType.Circle) - { - yield return s.Center + new Vector2(-s.R, -s.R); - yield return s.Center + new Vector2(s.R, s.R); - yield return s.Center + new Vector2(-s.R, s.R); - yield return s.Center + new Vector2(s.R, -s.R); - yield break; - } - if (s.Type == GimmickShapeType.Poly) - { - foreach (var p in s.Points) yield return p; - yield break; - } - Vector2 ax = s.LocalAxisX, ay = new Vector2(-ax.y, ax.x); - Vector2 hx = ax * s.Size.x * 0.5f, hy = ay * s.Size.y * 0.5f; - yield return s.Center + hx + hy; - yield return s.Center + hx - hy; - yield return s.Center - hx + hy; - yield return s.Center - hx - hy; - } - // ---------- 정적 질의(레이어가 없으면 기본값) ---------- public static GimmickInstance InstOf(Collider2D c) @@ -399,6 +329,12 @@ namespace TKS 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; @@ -439,6 +375,53 @@ namespace TKS 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) { @@ -463,7 +446,7 @@ namespace TKS bool any = false; foreach (var g in items) { - if (!g.ZoneActive || (g.Zone != GimmickZone.Slow && g.Zone != GimmickZone.High)) continue; + 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; @@ -481,6 +464,7 @@ namespace TKS 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) @@ -523,17 +507,17 @@ namespace TKS return 1f + add; } - /// 안개 안 유닛 = 사선 표적·화살 판정에서 빠진다(안에서 쏘기는 가능). + /// 안개 안 유닛(걷힌 자리 제외) — 받는 원거리 피해 ×p1(PD 2026-11-06 개정 · 표적·사선은 정상). public bool Fogged(Unit u) { - return u != null && ZoneAt(u.Position, GimmickZone.Fog) != null; + return u != null && FogAt(u.Position) != null; } - /// 광역 중심이 안개면 범위 × p1. - public float FogSplashMult(Vector2 c) + /// 원거리 피해(화살 · 책사 중심·광역) 배율 = 안개 안이면 p1. + public float RangedTakenMult(Unit d) { - var g = ZoneAt(c, GimmickZone.Fog); - return g != null ? g.P(1, 1f) : 1f; + var f = d != null ? FogAt(d.Position) : null; + return f != null ? Mathf.Max(0f, f.P(1, 1f)) : 1f; } /// 광역이 중심 → p 사이에서 막히는가(성문·성벽·바위 = 투사체 막힘 + 책사 제거 불가). @@ -554,7 +538,7 @@ namespace TKS public float KnockMult(Unit e) { if (e == null) return 1f; - if (ZoneAt(e.Position, GimmickZone.Slow) != null) return 0f; + 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; } @@ -848,12 +832,15 @@ namespace TKS } } } + // 강(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 || f.Intended - f.Actual < 0.05f || !OnBridge(u.Position)) continue; - var river = RiverAhead(u, f.Dir, f.Intended - f.Actual + 0.1f); + 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 + " 강에 빠짐"); @@ -873,21 +860,24 @@ namespace TKS return s.Contains(end); } - GimmickInstance RiverAhead(Unit u, Vector2 dir, float dist) + /// 선분 a→b 가 강물(다리 제외)에 들어가는 첫 강 · 없으면 null. + GimmickInstance RiverEntered(Vector2 a, Vector2 b) { - Physics2D.SyncTransforms(); - foreach (var h in Physics2D.CircleCastAll(u.Position, u.Radius * Combat.CastRadiusScale, dir, dist)) + 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 g = InstOf(h.collider); - if (g != null && g.Zone == GimmickZone.River && g.BlocksMove) return g; + var r = RiverAt(Vector2.Lerp(a, b, (float)i / n)); + if (r != null) return r; } return null; } - public void NoteForced(Unit u, Vector2 dir, float intended, float actual) + public void NoteForced(Unit u, Vector2 from, Vector2 to) { - if (u == null || items.Count == 0 || dir.sqrMagnitude < 1e-6f) return; - Forced.Add(new ForcedMove { Unit = u, Dir = dir.normalized, Intended = intended, Actual = actual }); + if (u == null || items.Count == 0 || (to - from).sqrMagnitude < 1e-6f) return; + Forced.Add(new ForcedMove { Unit = u, From = from, To = to }); } // ---------- 가상 실행(적 AI) 스냅숏 ---------- @@ -903,11 +893,12 @@ namespace TKS 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 }; + var s = new Snapshot { Count = items.Count, Template = splashTemplate, FogHoles = fogHoles.Count }; foreach (var g in items) { s.Refs.Add(g); @@ -945,6 +936,7 @@ namespace TKS 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; diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs index bdae98ec..64c5b86c 100644 --- a/Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs @@ -51,6 +51,7 @@ namespace TKS case GimmickZone.Slide: return new Color(0.55f, 0.9f, 1f, 0.75f); case GimmickZone.High: return new Color(0.85f, 0.7f, 0.35f, 0.7f); case GimmickZone.Fog: return new Color(0.95f, 0.95f, 1f, 0.6f); + case GimmickZone.River: return new Color(0.25f, 0.5f, 0.95f, 0.75f); } return new Color(1f, 1f, 1f, 0f); } @@ -119,6 +120,7 @@ namespace TKS { if (n.Contains("미끄러짐")) events.Add("미끄러짐"); if (n.Contains("강에 빠짐")) events.Add("강에 빠짐"); + if (n.Contains("안개 걷힘")) events.Add("안개 걷힘"); int k = n.IndexOf("자기 −"); if (k >= 0) events.Add(n.Substring(k)); // 튕김 자기 피해(bounce_pct · cav_self_pct) } @@ -193,7 +195,7 @@ namespace TKS int start = -1; for (int k = 0; k <= n; k++) { - bool inside = k < n + 1 && g.Shape.Contains(Vector2.Lerp(a, b, (float)k / n)); + bool inside = Inside(g, Vector2.Lerp(a, b, (float)k / n)); if (inside && start < 0) start = k; if ((!inside || k == n) && start >= 0) { @@ -210,6 +212,15 @@ namespace TKS } } + /// 띠를 그릴 자리: 강 = 다리 구간 제외 · 안개 = 걷힌 자리 제외. + bool Inside(GimmickInstance g, Vector2 p) + { + if (!g.Shape.Contains(p)) return false; + if (g.Zone == GimmickZone.River) return layer.RiverAt(p) != null; + if (g.Zone == GimmickZone.Fog) return layer.FogAt(p) != null; + return true; + } + // ---------- 그리기(풀) ---------- static Material DashMaterial diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickView.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickView.cs index 87d7201a..d5011f44 100644 --- a/Assets/Scripts/Runtime/Gimmicks/GimmickView.cs +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickView.cs @@ -109,6 +109,7 @@ namespace TKS sr.transform.localRotation = Quaternion.Euler(0f, 0f, s.Type == GimmickShapeType.Rect ? s.Rot : 0f); fill = sr; if (art != null) baseColor = Color.white; + if (g.Zone == GimmickZone.Fog) sr.maskInteraction = SpriteMaskInteraction.VisibleOutsideMask; // 책사 광역이 걷은 자리(SpriteMask)는 안 그림 } float h = Mathf.Clamp(Mathf.Min(s.Type == GimmickShapeType.Rect ? Mathf.Min(s.Size.x, s.Size.y) : s.BoundingRadius) * 0.55f, 0.22f, 0.5f); diff --git a/Assets/Tests/PlayMode/GimmickTests.cs b/Assets/Tests/PlayMode/GimmickTests.cs index 5a42f5be..00d010aa 100644 --- a/Assets/Tests/PlayMode/GimmickTests.cs +++ b/Assets/Tests/PlayMode/GimmickTests.cs @@ -321,22 +321,45 @@ namespace TKS.Tests } [UnityTest] - public IEnumerator Fog_TargetExcluded_SplashCenterInFogShrinks() + public IEnumerator Fog_RangedDamageXp1_TargetableNormally_StrategistSplashClearsFog() { var d = Def("fog"); yield return Fresh(); - L.Spawn("fog", GimmickShape.Circle(new Vector2(0f, 1.5f), 1.5f)); + L.Spawn("fog", GimmickShape.Circle(new Vector2(0f, 2f), 3.5f)); var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병"); var hidden = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.5f), "안개 속 적"); + var far = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 4.6f), "안개 깊은 적"); + hidden.MaxHp = hidden.Hp = far.MaxHp = far.Hp = 5000; + Assert.AreEqual(d.p1, GimmickLayer.RangedTakenMultOf(hidden), 1e-4f, "안개 안 = 받는 원거리 피해 ×p1(PD 2026-11-06 개정)"); + Assert.AreEqual(1f, GimmickLayer.RangedTakenMultOf(archer), 1e-4f); var plan = bm.Resolver.PreviewShot(archer, hidden.Position, false); - Assert.IsNotNull(plan); - Assert.IsNull(plan.Target, "안개 안 유닛 = 사선 표적 제외"); + Assert.AreEqual(hidden, plan.Target, "표적·사선은 정상"); + + Sim.Active = true; // 분산 0 으로 같은 공식 비교 + int inFog = Combat.Damage(cfg, archer, hidden, false, bm.Rng, false); + int melee = Combat.Damage(cfg, archer, hidden, false, bm.Rng, true); + Vector2 keep = hidden.Position; + hidden.Position = new Vector2(6f, -6f); + int open = Combat.Damage(cfg, archer, hidden, false, bm.Rng, false); + int meleeOpen = Combat.Damage(cfg, archer, hidden, false, bm.Rng, true); + hidden.Position = keep; + Sim.Active = false; + Assert.AreEqual(open * d.p1, inFog, 1.01f, "원거리 피해 ×p1"); + Assert.AreEqual(meleeOpen, melee, "근접은 그대로"); + var o = new ActionOutcome(); yield return Act(archer, hidden.Position, o); - Assert.IsFalse(o.Fired, "쏘지 않고 이동"); - Assert.AreEqual(hidden.MaxHp, hidden.Hp); - Assert.AreEqual(d.p1, L.FogSplashMult(new Vector2(0f, 1.5f)), 1e-4f, "광역 중심이 안개 = 범위 ×p1"); - Assert.AreEqual(1f, L.FogSplashMult(new Vector2(5f, -5f)), 1e-4f); + Assert.AreEqual(hidden, o.ShotHit, "안개 속 적도 화살에 맞는다"); + Assert.Less(hidden.Hp, hidden.MaxHp); + + var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(1.5f, -3f), "책사"); + var os = new ActionOutcome(); + yield return Act(st, hidden.Position, os); + Assert.IsTrue(os.Fired); + Assert.AreEqual(1, L.FogHoleCount, "책사 광역 = 그 반경만큼 안개 걷힘"); + Assert.IsFalse(L.Fogged(hidden), "광역 반경 안 = 안개 효과 없음"); + Assert.AreEqual(1f, GimmickLayer.RangedTakenMultOf(hidden), 1e-4f); + Assert.IsTrue(L.Fogged(far), "반경 밖 안개는 그대로"); } [UnityTest] @@ -388,15 +411,25 @@ namespace TKS.Tests } [UnityTest] - public IEnumerator RiverBridge_BlocksMove_ArrowPasses_KnockedOffBridgeFalls() + public IEnumerator River_LikeSwamp_WalkInOut_PushedInFromBankOrBridgeFalls_ArrowPasses() { var river = Def("river"); yield return Fresh(); L.Spawn("river", GimmickShape.Box(new Vector2(0f, 3f), new Vector2(20f, 2f))); L.Spawn("bridge", GimmickShape.Box(new Vector2(0f, 3f), new Vector2(1.2f, 2f))); + // 걸어 들어가기 = 늪처럼 강 안 구간 이동량 ×p3 · 빠짐 없음 · 다시 걸어 나올 수 있음(PD 2026-11-06 개정) var walker = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-5f, 0.5f), "보병"); - yield return Act(walker, new Vector2(-5f, 4.8f), new ActionOutcome(), true); - Assert.AreEqual(Contact(walker, 2f), walker.Position.y, 0.08f, "강 = 통과 불가"); + Vector2 reach = bm.Resolver.ReachPoint(walker, new Vector2(-5f, 4.5f)); + Assert.AreEqual(2f + (4f - 1.5f) / river.p3, reach.y, 0.1f, "강 안 구간 ×p3"); + yield return Act(walker, new Vector2(-5f, 4.5f), new ActionOutcome(), true); + Assert.AreEqual(reach.y, walker.Position.y, 0.08f); + var ws = L.StateOf(walker); + Assert.IsTrue(ws == null || ws.Stun == 0, "걸어 들어가면 빠짐 없음"); + yield return Act(walker, walker.Position + new Vector2(0f, 4.5f), new ActionOutcome(), true); + Assert.Greater(walker.Position.y, 4.5f, "강에서 걸어 나온다"); + + var onFoot = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, 0.5f), "다리 보병"); + Assert.AreEqual(4.5f, bm.Resolver.ReachPoint(onFoot, new Vector2(0f, 4.5f)).y, 0.08f, "다리 위 = 강 효과 없음"); var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(-7f, 0.5f), "궁병"); var across = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(-7f, 4.5f), "건너편 적"); @@ -404,16 +437,26 @@ namespace TKS.Tests yield return Act(archer, across.Position, oa); Assert.AreEqual(across, oa.ShotHit, "투사체는 강 통과"); - var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, 0f), "기병"); - var onBridge = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.05f, 3f), "다리 위 적"); + // 강가에서 밀려 강물에 = 빠짐 + var pusher = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(6f, -1.5f), "밀치는 보병"); + var bank = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(6f, 1f), "강가 적"); + bank.MaxHp = bank.Hp = 5000; + var ob = new ActionOutcome(); + yield return Act(pusher, new Vector2(6f, 3f), ob); + Assert.Greater(bank.Position.y, 2f, "강물로 밀려 들어감"); + Assert.AreEqual(Mathf.RoundToInt(river.p2), L.StateOf(bank).Stun, "강가에서 밀려도 빠짐 = p2 턴 행동 불가"); + Assert.IsTrue(ob.Notes.Exists(n => n.Contains("강에 빠짐"))); + Assert.AreEqual(0f, L.KnockMult(bank), 1e-4f, "강 안 = 기병 넉백 0(늪과 같음)"); + + // 다리에서 옆으로 밀려 강물에 = 빠짐 + var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -2f), "기병"); + var onBridge = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.4f, 3f), "다리 위 적"); onBridge.MaxHp = onBridge.Hp = 5000; + onFoot.Position = new Vector2(-8f, -8f); var o = new ActionOutcome(); - yield return Act(cav, new Vector2(0f, 5.6f), o); - Assert.Contains(onBridge, o.Attacked, "다리 = 통행 가능(관통)"); - var s = L.StateOf(onBridge); - Assert.IsNotNull(s); - Assert.AreEqual(Mathf.RoundToInt(river.p2), s.Stun, "다리에서 옆으로 밀려 강 = p2 턴 행동 불가"); - Assert.IsTrue(o.Notes.Exists(n => n.Contains("강에 빠짐"))); + yield return Act(cav, new Vector2(0f, 4.4f), o); + Assert.Contains(onBridge, o.Attacked, "다리 = 통행(관통)"); + Assert.AreEqual(Mathf.RoundToInt(river.p2), L.StateOf(onBridge).Stun, "다리에서 옆으로 밀려 강 = 빠짐"); yield return L.TurnStart(Faction.Enemy); Assert.IsTrue(GimmickLayer.Skips(onBridge), "그 진영 턴을 쉰다"); } @@ -504,7 +547,8 @@ namespace TKS.Tests if (id == "9-11") { var river = items.Find(g => g.Zone == GimmickZone.River); - Assert.AreEqual(2, river.Colliders.Count, "강 = 다리 구간을 뺀 2 조각"); + Assert.AreEqual(0, river.Colliders.Count, "강 = 늪처럼 통과 가능한 지대(콜라이더 없음 · PD 2026-11-06 개정)"); + Assert.IsTrue(layer.OnBridge(new Vector2(0f, 3f)) && layer.RiverAt(new Vector2(0f, 3f)) == null, "다리 구간 = 강 효과 없음"); } foreach (var u in bm.Units) { diff --git a/CLAUDE.md b/CLAUDE.md index 3356f7b7..65ab9b2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Unity.exe -projectPath -executeMethod TKS.EditorTools.ProjectSetup.AutoPl - **스킬(2026-11-05 #1093 B)** — SOT = `Assets/Data/OutGame/skills.csv`(effect_key `;` 복수 · p1~p3 · lv_scale · needs_impl) + `skill_levels.csv` → 임포트 자산 `SkillInfoDb`(effect 열 포함 · 표시 + 전투 공용). 처리기 = `Runtime/Skills/`: `SkillKeys`(51종 · 레벨 계수 방식 · 팀 키 · 표에 없는 키 = 임포트 오류) · `SkillSystem`(유닛별 쿨·예약 · 즉시형 시한 효과 · 행동 훅 · 호위 피해 분담 · HUD 한 줄) · `SkillMods`(전투 코드가 수치 질의) · `SkillRules`(상한 = 등급 + ★3 · ★5 패시브 배율 = evolve.csv skill_unlock · 레벨업 · 스킬서 판매). 전투 코드에 스킬 수치를 직접 쓰지 않고 `SkillMods` 로 읽는다. 테스트 = `Tests/PlayMode/SkillTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureSkills -skillDir `. - **아웃게임 2차(2026-11-05~06 #1093 C)** — 표 10장 추가(`training` · `training_ranks` · `patrol` · `dev_tree` · `treasures` · `treasure_sets` · `chests`(+ iron/buy 열) · `tower` · `dungeons`(+ reward_amount) · `arena`(+ 상점 키)) + `economy.csv` C 키 24 → 임포트 자산 18(A 9 + C 9 · `OutGameTables.C.cs` 검증). 코드 = `OutGame/Domestic/`(`TrainingRules` 훈련 · `PatrolRules` 순찰 · `DevRules` 개발 효과 키 → 적용 지점 · `DomesticScreen`) · `OutGame/Treasure/`(`TreasureRules` 보유·세트·강화·조건 카운터·상자 · `TreasureScreen`) · `OutGame/Arena/`(`ArenaRules` 탑·던전·투기장·적장 · `ArenaScreen`). 스탯 = §3-2 × (1 + 훈련(병종·항목)) × (1 + 보물(항목)) = `HeroRules.Compute(…, save)` · 보물 crit/range/splash = `BattleManager.SideEffectsProvider` → `SkillMods.Get` 진영 효과(`side_crit_pct` · `range_mult` · `splash_radius_mult`) · 조건 효과 카운터 = `Runtime/BattleEvents.Raise`(전투 코드 1줄씩 · 아군만) · 연무장 전투 = `BattleManager.StartMode(BattleMode)`(적 편성 · 제한 턴 · 결과 탭 = `onExit`) → `OutGameSession.OnModeEnd`. 테스트 = `Tests/PlayMode/OutGameCTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureC -cDir `(사본 · 10장). - **영웅 56명(PD #1096 · 2026-11-06)** — heroes 71행(영웅 56 + 병사 15) · skills 112 · hero_gear 1344 · 신규 effect_key 4(`team_dmg_pct_turn` · `cooldown_add` · `charm` · `heal_self_pct`) + 기존 키 조건 열(받는 피해 p2 = HP 임계/2 보스/3 근접 · p3 = 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) = `SkillMods`·`SkillSystem` · 대장군 `pushed_ally_attack_on_push` 는 표에서 미사용(처리기 유지). -- **지형 기믹 레이어(PD #1097 · 2026-11-06 세션 D)** — SOT = `Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv`(기획 `tables/` 사본 · utf-8-sig) → 메뉴 `TKS/Import Gimmicks (기믹 임포트)`(`Editor/GimmickImporter` · 오류 시 자산 불변 · `ai_weights.csv` 도 함께) → `Resources/Gimmicks/GimmickDb`·`StageGimmickTable` · `Resources/AI/AiWeights`. 전투 시작 = `GimmickLayer.Build(stage)`(장-번호 배치 · 배경 위 · 유닛 아래 · 장애물 = 콜라이더 + `GimmickBody` · 지대 = 모양 스윕 · 강 콜라이더 = 다리 구간을 뺀 조각 · 겹친 유닛은 빈 자리로). 전투 코드는 질의만: `Combat.FreeTravel`(장애물 = 벽 · 숨은 함정 정지) · `FirstUnitOnArrow`(pass/block/destroy · 안개 제외) · `Combat.Damage`(군기 공 · 고지 근접 피격) · `SkillMods.Range`(고지 사거리) · `BattleManager` 턴 시작 `TurnStart`(군기·중독·불·행동 불가). 충돌 반응·폭발·굴림·미끄러짐·지대 = `Gimmicks/ActionResolver.Gimmicks.cs`(partial). 배치 도구 = `TKS/Gimmick Painter (기믹 배치)`. 테스트 = `Tests/PlayMode/GimmickTests` · 캡처 = `-executeMethod TKS.EditorTools.GimmickImporter.Capture -gimmickDir `(사본). +- **지형 기믹 레이어(PD #1097 · 2026-11-06 세션 D)** — SOT = `Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv`(기획 `tables/` 사본 · utf-8-sig) → 메뉴 `TKS/Import Gimmicks (기믹 임포트)`(`Editor/GimmickImporter` · 오류 시 자산 불변 · `ai_weights.csv` 도 함께) → `Resources/Gimmicks/GimmickDb`·`StageGimmickTable` · `Resources/AI/AiWeights`. 전투 시작 = `GimmickLayer.Build(stage)`(장-번호 배치 · 배경 위 · 유닛 아래 · 장애물 = 콜라이더 + `GimmickBody` · 지대 = 모양 스윕 · 강 = 늪처럼 통과하는 지대(강 안 ×p3 · 다리 구간 = 효과 없음 · 강가·다리에서 밀려 강물에 들면 빠짐 · PD 2026-11-06 개정) · 겹친 유닛은 빈 자리로). 전투 코드는 질의만: `Combat.FreeTravel`(장애물 = 벽 · 숨은 함정 정지) · `FirstUnitOnArrow`(pass/block/destroy) · `Combat.Damage`(군기 공 · 고지 근접 피격 · 안개 원거리 피격 ×p1 · 책사 광역이 그 반경 안개를 걷음) · `SkillMods.Range`(고지 사거리) · `BattleManager` 턴 시작 `TurnStart`(군기·중독·불·행동 불가). 충돌 반응·폭발·굴림·미끄러짐·지대 = `Gimmicks/ActionResolver.Gimmicks.cs`(partial). 배치 도구 = `TKS/Gimmick Painter (기믹 배치)`. 테스트 = `Tests/PlayMode/GimmickTests` · 캡처 = `-executeMethod TKS.EditorTools.GimmickImporter.Capture -gimmickDir `(사본). - **병종 규칙 개정 + 예측 AI(PD #1098 · 2026-11-06)** — 보병 = 적과 싸운 뒤 튕겨나는 길의 다른 적과 연쇄 교환 1회(`ActionOutcome.Chains`) · 창병 = 상대만 밀리고 자신은 × `spearRecoilRatio` 반대로(밀며 전진 폐기). 적 턴 = `Runtime/AI/TacticalAI`(후보 방위 × 파워 3 × 액티브 → `Sim` 가상 실행(연출 없음 · 분산 0 · 상태 복원) → `ai_weights.csv` 점수 · 서포터 → 돌격 → 사격 · 1기마다 재평가) · 가중치 자산이 없으면 `EnemyAI.Decide`(기존 조준) · 적 액티브 = 1부 보스만(`boss_active`). 가상 실행 중(`Sim.Active`)에는 `Fx`·`UnitView`·`BattleEvents`·HUD 갱신이 꺼진다 — 새 연출 코드도 `Sim.Active` 면 건너뛸 것. 조준 미리보기도 같은 가상 실행(`GimmickPreview` · 숨은 함정 모름). 테스트 = `Tests/PlayMode/TacticalAITests`. - 아웃게임 코드 = `Assets/Scripts/OutGame/**`(asmref → TKS.Runtime) — `OutGameSession`(표 + 세이브 · 바꾸는 동작은 즉시 저장) · 규칙 `HeroRules`·`EconomyRules`·`GachaRules` · 화면 `UI/`(로비·영웅·편성·거래·결과 패널 · `OutGameRoot` 가 전환). 세이브 = `persistentDataPath/save.json`(테스트·검증 캡처는 `SaveStore.UseMemory` / SessionState `TKS_SAVE_MEMORY` 로 메모리 전용). 출전 = `BattleManager.LineupProvider`(편성 프리셋 → `playerSpawnPoints` 자리 0~2 전열 · 3~4 후열). 화면 캡처 = `Unity.exe -projectPath <사본> -executeMethod TKS.EditorTools.DataImporter.CaptureOutGame -outgameDir `. - **챕터·스테이지 SOT = `Assets/Data/Stages/*.csv`(utf-8-sig · 2026-11-05 145 스테이지 · 기획서 #1086 §4 전사)** — `stages.csv`(chapter·stage·title·type normal|elite|boss·place·faction·병종 4 수·generals `id:병종:true|false`·bg_theme·note) · `chapters.csv`(장 제목·연도·**월드맵 맵 id**·AI 출발값) · `generals.csv`(무장 id → 이름·배율 덮어쓰기) · `factions.csv`(세력 코드 → 이름·병사 아트 접두). 고친 뒤 메뉴 `TKS/Import Stages (스테이지 임포트)`(`StageImporter` · 형식 오류면 자산 미변경) → `BalanceConfig.chapters·stages`. 적 좌표 = `BalanceConfig.formation` 규칙(전위·중군·후군·장수 줄 · `StageTable.Enemies`) · 장수 배율 = `generals.csv` 가 비면 `boss/eliteGeneral*Mult`. 월드맵 노드 = 스테이지 표(챕터별 수 · 보스 = type boss) · 노드 자리 = `ChapterMap/Data/<맵>.json` `slots_N`(없으면 길 위 균등).