강·안개 개정(PD 2026-11-06 #1101) — 강 = 늪처럼 통과(강 안 ×2 · 다리 = 효과 없음 · 기병 넉백 0) · 강가·다리에서 밀려 강물에 들면 30 % + 1턴 · 안개 = 원거리 피해(화살·책사) ×0.5 · 표적 정상 · 책사 광역이 그 반경 안개를 걷음

- 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 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-11-06 01:42:15 +09:00
parent 98f675dfda
commit 8a7c565609
12 changed files with 211 additions and 156 deletions

View File

@ -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 기병 밀기 배율] 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 밀기] 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 이동 비용] 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 밀기] 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 공 증가] 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) 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 %) · 투사체 막힘 · 성문과 짝 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 턴] 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) · 다리 위에서 옆으로 밀리면 river 판정 [p1 폭] 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 턴] pit_trap,함정,zone,0,0,0,0,0,0,0,0,pass,0,trap,1,,,gim_trap,gim_trap_open,숨김(아군 조준선 미표시 · 적 AI 는 인지) · 밟으면 그 자리 정지 + p1 턴 행동 불가 · 발동 후 제거 [p1 턴]

1 id name_ko kind durable hp dmg_inf dmg_spear dmg_cav spear_push cav_self_pct bounce_pct projectile strategist_clear zone_effect p1 p2 p3 art art_broken desc_ko
7 ice 빙판 zone 0 0 0 0 0 0 0 0 pass 0 slide 3.0 1.5 gim_ice 정지 지점이 빙판이면 같은 방향으로 빙판 끝까지(최대 p1) 미끄러짐 · 충돌 규칙 그대로 · 기병 빙판 위 밀기 ×p2 [p1 최대 거리 · p2 기병 밀기 배율]
8 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 밀기]
9 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 이동 비용]
10 fog 안개 zone 0 0 0 0 0 0 0 0 pass 0 fog 0.5 0 gim_fog 안개 안 유닛은 사선 표적 제외(안에서 쏘기는 가능) · 근접 정상 · 광역 중심이 안개면 범위 ×p1 [p1 광역 배율] 안개 안 유닛이 받는 원거리 피해(화살·책사 광역) ×p1 · 표적·사선·근접은 정상 · 책사 광역이 터진 반경만큼 안개가 걷힘(PD 2026-11-06 개정) [p1 원거리 피해 배율]
11 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 밀기]
12 banner 군기 zone 0 0 0 0 0 0 0 0 pass 0 capture 1.5 0.1 gim_banner 반경 p1 안에 한쪽 편만 있으면 턴 시작 시 점령 → 점령 측 전원 공 +p2 · 양쪽 있으면 중립 [p1 반경 · p2 공 증가]
13 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)
14 wall 성벽 block 0 0 0 0 0 0 0.05 0 block 0 gim_wall 파괴 불가 · 부딪히면 튕김(기병 5 %) · 투사체 막힘 · 성문과 짝
15 river 강 block zone 0 0 0 0 0 0 0 0 pass 0 river 0.3 1 2.0 gim_river 통과 불가(투사체 통과) · 다리 위에서 밀려 강에 빠지면 최대 HP p1 + p2 턴 행동 불가 · 다리 = bridge 레코드가 겹친 구간만 통행 [p1 피해 · p2 턴] 늪처럼 통과 가능 · 강 안 구간 이동량 ×p3 · 기병 넉백 강 안 0 · 밀려서(강가·다리) 강에 들어가면 최대 HP p1 + p2 턴 행동 불가 · 걸어 들어가면 느려지기만 · 다리 구간 = 효과 없음 · 투사체 통과(PD 2026-11-06 개정) [p1 피해 · p2 턴 · p3 이동 비용]
16 bridge 다리 zone 0 0 0 0 0 0 0 0 pass 0 1.2 gim_bridge 강 위 통로(폭 p1) · 다리 위에서 옆으로 밀리면 river 판정 [p1 폭] 강 위 통로(폭 p1) · 다리 위 = 강 효과 없음(이동 비용·빠짐 없음) · 다리에서 옆으로 밀려 강에 들어가면 빠짐 [p1 폭]
17 pit_trap 함정 zone 0 0 0 0 0 0 0 0 pass 0 trap 1 gim_trap gim_trap_open 숨김(아군 조준선 미표시 · 적 AI 는 인지) · 밟으면 그 자리 정지 + p1 턴 행동 불가 · 발동 후 제거 [p1 턴]

View File

@ -218,10 +218,11 @@ MonoBehaviour:
p3: NaN p3: NaN
art: gim_fog art: gim_fog
artBroken: artBroken:
descKo: "\uC548\uAC1C \uC548 \uC720\uB2DB\uC740 \uC0AC\uC120 \uD45C\uC801 \uC81C\uC678(\uC548\uC5D0\uC11C descKo: "\uC548\uAC1C \uC548 \uC720\uB2DB\uC774 \uBC1B\uB294 \uC6D0\uAC70\uB9AC
\uC3D8\uAE30\uB294 \uAC00\uB2A5) \xB7 \uADFC\uC811 \uC815\uC0C1 \xB7 \uAD11\uC5ED \uD53C\uD574(\uD654\uC0B4\xB7\uCC45\uC0AC \uAD11\uC5ED) \xD7p1 \xB7 \uD45C\uC801\xB7\uC0AC\uC120\xB7\uADFC\uC811\uC740
\uC911\uC2EC\uC774 \uC548\uAC1C\uBA74 \uBC94\uC704 \xD7p1 [p1 \uAD11\uC5ED \uC815\uC0C1 \xB7 \uCC45\uC0AC \uAD11\uC5ED\uC774 \uD130\uC9C4 \uBC18\uACBD\uB9CC\uD07C
\uBC30\uC728]" \uC548\uAC1C\uAC00 \uAC77\uD798(PD 2026-11-06 \uAC1C\uC815) [p1 \uC6D0\uAC70\uB9AC
\uD53C\uD574 \uBC30\uC728]"
- id: powder_cart - id: powder_cart
nameKo: "\uD3ED\uC57D \uC218\uB808" nameKo: "\uD3ED\uC57D \uC218\uB808"
kind: 0 kind: 0
@ -314,7 +315,7 @@ MonoBehaviour:
5 %) \xB7 \uD22C\uC0AC\uCCB4 \uB9C9\uD798 \xB7 \uC131\uBB38\uACFC \uC9DD" 5 %) \xB7 \uD22C\uC0AC\uCCB4 \uB9C9\uD798 \xB7 \uC131\uBB38\uACFC \uC9DD"
- id: river - id: river
nameKo: "\uAC15" nameKo: "\uAC15"
kind: 0 kind: 1
durable: 0 durable: 0
hp: 0 hp: 0
dmgInf: 0 dmgInf: 0
@ -328,13 +329,16 @@ MonoBehaviour:
zone: 9 zone: 9
p1: 0.3 p1: 0.3
p2: 1 p2: 1
p3: NaN p3: 2
art: gim_river art: gim_river
artBroken: artBroken:
descKo: "\uD1B5\uACFC \uBD88\uAC00(\uD22C\uC0AC\uCCB4 \uD1B5\uACFC) \xB7 \uB2E4\uB9AC descKo: "\uB2AA\uCC98\uB7FC \uD1B5\uACFC \uAC00\uB2A5 \xB7 \uAC15 \uC548 \uAD6C\uAC04
\uC704\uC5D0\uC11C \uBC00\uB824 \uAC15\uC5D0 \uBE60\uC9C0\uBA74 \uCD5C\uB300 \uC774\uB3D9\uB7C9 \xD7p3 \xB7 \uAE30\uBCD1 \uB109\uBC31 \uAC15 \uC548 0 \xB7
HP p1 + p2 \uD134 \uD589\uB3D9 \uBD88\uAC00 \xB7 \uB2E4\uB9AC = bridge \uB808\uCF54\uB4DC\uAC00 \uBC00\uB824\uC11C(\uAC15\uAC00\xB7\uB2E4\uB9AC) \uAC15\uC5D0 \uB4E4\uC5B4\uAC00\uBA74
\uACB9\uCE5C \uAD6C\uAC04\uB9CC \uD1B5\uD589 [p1 \uD53C\uD574 \xB7 p2 \uD134]" \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 - id: bridge
nameKo: "\uB2E4\uB9AC" nameKo: "\uB2E4\uB9AC"
kind: 1 kind: 1
@ -354,8 +358,10 @@ MonoBehaviour:
p3: NaN p3: NaN
art: gim_bridge art: gim_bridge
artBroken: artBroken:
descKo: "\uAC15 \uC704 \uD1B5\uB85C(\uD3ED p1) \xB7 \uB2E4\uB9AC \uC704\uC5D0\uC11C descKo: "\uAC15 \uC704 \uD1B5\uB85C(\uD3ED p1) \xB7 \uB2E4\uB9AC \uC704 = \uAC15
\uC606\uC73C\uB85C \uBC00\uB9AC\uBA74 river \uD310\uC815 [p1 \uD3ED]" \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 - id: pit_trap
nameKo: "\uD568\uC815" nameKo: "\uD568\uC815"
kind: 1 kind: 1

View File

@ -315,7 +315,7 @@ namespace TKS
{ {
// 반작용: 벽·유닛·장애물에 닿으면 정지 · 피해 없음 · 보는 방향은 유지(밀친 쪽을 본 채 물러남) // 반작용: 벽·유닛·장애물에 닿으면 정지 · 피해 없음 · 보는 방향은 유지(밀친 쪽을 본 채 물러남)
float back = Combat.FreeTravel(u, u.Position, -dir, recoil, true, true, first); 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) if (back > 0.01f)
{ {
Vector2 start = u.Position; Vector2 start = u.Position;
@ -826,7 +826,7 @@ namespace TKS
if (self >= 0.01f && striker.IsAlive) if (self >= 0.01f && striker.IsAlive)
{ {
float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck); 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) if (back > 0.01f)
{ {
Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082) 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) if (pusher != null && pusher.Faction != target.Faction) BattleEvents.Raise(BattleEvents.Pushes, pusher); // 보물 조건 효과(PD #1093 C)
force = Reach(target, target.Position, n, force); // 늪 = 밀리는 거리도 이동 비용(PD #1097) force = Reach(target, target.Position, n, force); // 늪 = 밀리는 거리도 이동 비용(PD #1097)
float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher); 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; float moved = 0f;
if (slide > 0.01f) if (slide > 0.01f)
{ {

View File

@ -31,6 +31,7 @@ namespace TKS
if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type) && !SkillMods.Has(defender, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeTakenRatio; 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) 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); 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) return Mathf.Max(1, Mathf.RoundToInt(raw * Variance(cfg, rng))); // 가상 실행 = 분산 0(PD #1098)
} }
@ -86,7 +87,6 @@ namespace TKS
{ {
var u = UnitOf(h.collider); var u = UnitOf(h.collider);
if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue; if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue;
if (GimmickLayer.IsFogged(u)) continue; // 안개 안 = 사선 표적 제외(PD #1097)
hitDist = h.distance; hitDist = h.distance;
return u; return u;
} }

View File

@ -41,9 +41,9 @@ namespace TKS
return Gim ? Layer.CostReach(u, origin, dir, budget) : budget; 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) void GimmickBegin(ActionOutcome o)
@ -285,7 +285,7 @@ namespace TKS
return Mathf.Max(0f, min - Combat.Epsilon); return Mathf.Max(0f, min - Combat.Epsilon);
} }
/// <summary>기병 관통 옆 밀기: 늪 안 = 0 · 빙판 위 = ×p2 · 장애물·함정에서 멈춤 · 다리 위에서 강 쪽이면 강 판정.</summary> /// <summary>기병 관통 옆 밀기: 늪·강 안 = 0 · 빙판 위 = ×p2 · 장애물·함정에서 멈춤 · 강가·다리에서 강물로 밀리면 빠짐.</summary>
Vector2 KnockTo(Unit e, Vector2 dir, float kb) Vector2 KnockTo(Unit e, Vector2 dir, float kb)
{ {
if (Gim) kb *= Layer.KnockMult(e); if (Gim) kb *= Layer.KnockMult(e);
@ -297,13 +297,13 @@ namespace TKS
Vector2 n; Vector2 n;
if (Combat.FirstGimmickOnPath(e, e.Position, dir, want, out h, out n) != null) free = Mathf.Max(0f, h - Combat.Epsilon); 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)); 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; return e.Position + dir * free;
} }
// ---------- 투사체 · 책사 광역 ---------- // ---------- 투사체 · 책사 광역 ----------
/// <summary>책사 광역 계획: 중심 = 첫 피격 유닛 · 유닛이 없고 제거 가능 기믹(덤불·수레)에 막혔으면 그 자리 · 반경 × 안개 · 성문·성벽 너머 제외 · 지울 기믹.</summary> /// <summary>책사 광역 계획: 중심 = 첫 피격 유닛 · 유닛이 없고 제거 가능 기믹(덤불·수레)에 막혔으면 그 자리 · 성문·성벽 너머 제외 · 지울 기믹.</summary>
void AimSplash(Unit u, ShotPlan p) void AimSplash(Unit u, ShotPlan p)
{ {
p.Splash.Clear(); p.Splash.Clear();
@ -313,7 +313,7 @@ namespace TKS
else if (p.HitGimmick != null && p.HitGimmick.Def.strategistClear) p.SplashCenter = p.To; else if (p.HitGimmick != null && p.HitGimmick.Def.strategistClear) p.SplashCenter = p.To;
else return; else return;
p.HasSplash = true; 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)); p.Splash.AddRange(Combat.UnitsInCircle(p.SplashCenter, p.SplashRadius, p.Hit, u));
if (!Gim) return; if (!Gim) return;
p.Splash.RemoveAll(s => Layer.SplashBlocked(p.SplashCenter, s.Position)); p.Splash.RemoveAll(s => Layer.SplashBlocked(p.SplashCenter, s.Position));
@ -333,7 +333,7 @@ namespace TKS
} }
} }
/// <summary>책사 광역의 기믹: 덤불 제거 · 수레 파괴(폭발) · 화공 스테이지 = 그 자리 불.</summary> /// <summary>책사 광역의 기믹: 덤불 제거 · 수레 파괴(폭발) · 그 반경만큼 안개 걷힘(PD 2026-11-06) · 화공 스테이지 = 그 자리 불.</summary>
IEnumerator SplashGimmicks(Unit u, ShotPlan p, ActionOutcome o) IEnumerator SplashGimmicks(Unit u, ShotPlan p, ActionOutcome o)
{ {
if (!Gim || !p.HasSplash) yield break; if (!Gim || !p.HasSplash) yield break;
@ -347,6 +347,7 @@ namespace TKS
o.Notes.Add(g.Def.nameKo + " 제거"); 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("불길"); if (Layer.OnSplash(p.SplashCenter, p.SplashRadius) != null) o.Notes.Add("불길");
} }

View File

@ -36,7 +36,7 @@ namespace TKS
/// <summary> /// <summary>
/// 투사체(PD 2026-11-05 #1087 ⑥ + 기믹 #1097): 첫 유닛 또는 막는 기믹. 목책·강 = 통과(pass) · 덤불·바위·성문·성벽 = 막힘(gimmick 에 담고 null) · /// 투사체(PD 2026-11-05 #1087 ⑥ + 기믹 #1097): 첫 유닛 또는 막는 기믹. 목책·강 = 통과(pass) · 덤불·바위·성문·성벽 = 막힘(gimmick 에 담고 null) ·
/// 폭약 수레 = 맞으면 파괴(gimmick 에 담고 null) · 안개 안 유닛 = 사선 판정 제외(통과). /// 폭약 수레 = 맞으면 파괴(gimmick 에 담고 null) · 안개 안 유닛도 정상 피격(피해만 ×p1 · PD 2026-11-06 개정).
/// </summary> /// </summary>
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel, out GimmickInstance gimmick) 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); var u = UnitOf(h.collider);
if (u == null || u == shooter || !u.IsAlive) continue; if (u == null || u == shooter || !u.IsAlive) continue;
if (passFaction.HasValue && u.Faction == passFaction.Value) continue; if (passFaction.HasValue && u.Faction == passFaction.Value) continue;
if (GimmickLayer.IsFogged(u)) continue;
travel = h.distance; travel = h.distance;
return u; return u;
} }

View File

@ -190,9 +190,10 @@ namespace TKS
IEnumerator BridgePushShot(BattleManager bm, Camera cam, CameraController cc) IEnumerator BridgePushShot(BattleManager bm, Camera cam, CameraController cc)
{ {
yield return Stage(bm); yield return Stage(bm);
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, 0.8f), "보병"); var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-0.6f, 0.8f), "보병");
bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.15f, 3f), "다리 위 적"); var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.35f, 3f), "다리 위 적");
yield return AimCapture(bm, cam, cc, u, new Vector2(0f, 3.2f), new Vector2(0f, 2.2f), "aim_9-11_bridge_push"); 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");
} }
/// <summary>AI 전술: 적 보병이 아군을 함정 쪽으로 튕겨낸다(함정 발동 순간 캡처).</summary> /// <summary>AI 전술: 적 보병이 아군을 함정 쪽으로 튕겨낸다(함정 발동 순간 캡처).</summary>

View File

@ -47,14 +47,17 @@ namespace TKS
public struct ForcedMove public struct ForcedMove
{ {
public Unit Unit; public Unit Unit;
public Vector2 Dir; public Vector2 From;
public float Intended; public Vector2 To;
public float Actual;
} }
/// <summary>이번 행동에서 밀림·튕김이 막힌 기록(다리 → 강 판정).</summary> /// <summary>이번 행동의 강제 이동(밀림·튕김·반작용·넉백) 구간 — 강가·다리에서 밀려 강에 들어가면 빠짐(PD 2026-11-06 개정).</summary>
public readonly List<ForcedMove> Forced = new List<ForcedMove>(); 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 struct Affect
{ {
public Unit Unit; public Unit Unit;
@ -115,7 +118,6 @@ namespace TKS
if (def == null) continue; if (def == null) continue;
Spawn(def, p.Clone(), false); Spawn(def, p.Clone(), false);
} }
RebuildRivers();
ResolveOverlaps(); ResolveOverlaps();
} }
@ -178,6 +180,12 @@ namespace TKS
states.Clear(); states.Clear();
splashTemplate = null; splashTemplate = null;
Forced.Clear(); Forced.Clear();
fogHoles.Clear();
foreach (var m in fogMasks)
{
if (m != null) Destroy(m);
}
fogMasks.Clear();
Outcome = null; Outcome = null;
if (Preview != null) Preview.Hide(); if (Preview != null) Preview.Hide();
} }
@ -231,9 +239,7 @@ namespace TKS
else if (k == "p3" || k == "spawn_on_splash") p.pOver3 = v; else if (k == "p3" || k == "spawn_on_splash") p.pOver3 = v;
} }
} }
var g = Spawn(def, p, false); return Spawn(def, p, false);
if (def.zone == GimmickZone.River || def.id == GimmickIds.Bridge) RebuildRivers();
return g;
} }
void BuildObject(GimmickInstance g) void BuildObject(GimmickInstance g)
@ -283,82 +289,6 @@ namespace TKS
g.Colliders.Add(col); g.Colliders.Add(col);
} }
/// <summary>강 콜라이더 = 강 사각형에서 다리가 겹친 구간(강 가로축 투영)을 뺀 조각들. 원·다각형 강은 다리 없이 통째로.</summary>
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>();
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<Vector2>();
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<Vector2> 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) public static GimmickInstance InstOf(Collider2D c)
@ -399,6 +329,12 @@ namespace TKS
return l != null && l.Fogged(u); 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) public static bool Skips(Unit u)
{ {
var l = Current; var l = Current;
@ -439,6 +375,53 @@ namespace TKS
return false; 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> /// <summary>숨은 함정: origin → dir 로 dist 안에서 중심이 처음 함정에 드는 거리(+0.06 = 안으로 살짝) · 없으면 dist. 조준 미리보기(아군)는 모른다.</summary>
public float TrapStop(Unit actor, Vector2 origin, Vector2 dir, float dist) public float TrapStop(Unit actor, Vector2 origin, Vector2 dir, float dist)
{ {
@ -463,7 +446,7 @@ namespace TKS
bool any = false; bool any = false;
foreach (var g in items) 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; if (Combat.SegmentPointDistance(origin, origin + dir * budget, g.Shape.Center) > g.Shape.BoundingRadius + 0.01f) continue;
any = true; any = true;
break; break;
@ -481,6 +464,7 @@ namespace TKS
if (!g.ZoneActive || !g.Shape.Contains(p)) continue; if (!g.ZoneActive || !g.Shape.Contains(p)) continue;
if (g.Zone == GimmickZone.Slow) m *= Mathf.Max(1f, g.P(1, 1f)); 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.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; float cost = step * m;
if (used + cost >= budget) if (used + cost >= budget)
@ -523,17 +507,17 @@ namespace TKS
return 1f + add; return 1f + add;
} }
/// <summary>안개 안 유닛 = 사선 표적·화살 판정에서 빠진다(안에서 쏘기는 가능).</summary> /// <summary>안개 안 유닛(걷힌 자리 제외) — 받는 원거리 피해 ×p1(PD 2026-11-06 개정 · 표적·사선은 정상).</summary>
public bool Fogged(Unit u) public bool Fogged(Unit u)
{ {
return u != null && ZoneAt(u.Position, GimmickZone.Fog) != null; return u != null && FogAt(u.Position) != null;
} }
/// <summary>광역 중심이 안개면 범위 × p1.</summary> /// <summary>원거리 피해(화살 · 책사 중심·광역) 배율 = 안개 안이면 p1.</summary>
public float FogSplashMult(Vector2 c) public float RangedTakenMult(Unit d)
{ {
var g = ZoneAt(c, GimmickZone.Fog); var f = d != null ? FogAt(d.Position) : null;
return g != null ? g.P(1, 1f) : 1f; return f != null ? Mathf.Max(0f, f.P(1, 1f)) : 1f;
} }
/// <summary>광역이 중심 → p 사이에서 막히는가(성문·성벽·바위 = 투사체 막힘 + 책사 제거 불가).</summary> /// <summary>광역이 중심 → p 사이에서 막히는가(성문·성벽·바위 = 투사체 막힘 + 책사 제거 불가).</summary>
@ -554,7 +538,7 @@ namespace TKS
public float KnockMult(Unit e) public float KnockMult(Unit e)
{ {
if (e == null) return 1f; 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); var ice = ZoneAt(e.Position, GimmickZone.Slide);
return ice != null ? Mathf.Max(0f, ice.P(2, 1f)) : 1f; 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<Unit>();
foreach (var f in Forced) foreach (var f in Forced)
{ {
var u = f.Unit; var u = f.Unit;
if (u == null || !u.IsAlive || f.Intended - f.Actual < 0.05f || !OnBridge(u.Position)) continue; if (u == null || !u.IsAlive || fell.Contains(u) || RiverAt(f.From) != null) continue;
var river = RiverAhead(u, f.Dir, f.Intended - f.Actual + 0.1f); var river = RiverEntered(f.From, f.To);
if (river == null) continue; if (river == null) continue;
fell.Add(u);
PctDamage(u, river.P(1, 0f), "강에 빠짐"); PctDamage(u, river.P(1, 0f), "강에 빠짐");
Stun(u, Mathf.Max(1, Mathf.RoundToInt(river.P(2, 1f))), "강에 빠짐"); Stun(u, Mathf.Max(1, Mathf.RoundToInt(river.P(2, 1f))), "강에 빠짐");
o.Notes.Add(u.DisplayName + " 강에 빠짐"); o.Notes.Add(u.DisplayName + " 강에 빠짐");
@ -873,21 +860,24 @@ namespace TKS
return s.Contains(end); return s.Contains(end);
} }
GimmickInstance RiverAhead(Unit u, Vector2 dir, float dist) /// <summary>선분 a→b 가 강물(다리 제외)에 들어가는 첫 강 · 없으면 null.</summary>
GimmickInstance RiverEntered(Vector2 a, Vector2 b)
{ {
Physics2D.SyncTransforms(); float len = Vector2.Distance(a, b);
foreach (var h in Physics2D.CircleCastAll(u.Position, u.Radius * Combat.CastRadiusScale, dir, dist)) 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); var r = RiverAt(Vector2.Lerp(a, b, (float)i / n));
if (g != null && g.Zone == GimmickZone.River && g.BlocksMove) return g; if (r != null) return r;
} }
return null; 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; if (u == null || items.Count == 0 || (to - from).sqrMagnitude < 1e-6f) return;
Forced.Add(new ForcedMove { Unit = u, Dir = dir.normalized, Intended = intended, Actual = actual }); Forced.Add(new ForcedMove { Unit = u, From = from, To = to });
} }
// ---------- 가상 실행(적 AI) 스냅숏 ---------- // ---------- 가상 실행(적 AI) 스냅숏 ----------
@ -903,11 +893,12 @@ namespace TKS
public readonly List<Vector2> Center = new List<Vector2>(); public readonly List<Vector2> Center = new List<Vector2>();
public readonly Dictionary<Unit, GimmickUnitState> States = new Dictionary<Unit, GimmickUnitState>(); public readonly Dictionary<Unit, GimmickUnitState> States = new Dictionary<Unit, GimmickUnitState>();
public GimmickInstance Template; public GimmickInstance Template;
public int FogHoles;
} }
public Snapshot Take() 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) foreach (var g in items)
{ {
s.Refs.Add(g); s.Refs.Add(g);
@ -945,6 +936,7 @@ namespace TKS
g.SetCollidersEnabled(g.BlocksMove); g.SetCollidersEnabled(g.BlocksMove);
} }
splashTemplate = s.Template; splashTemplate = s.Template;
if (fogHoles.Count > s.FogHoles) fogHoles.RemoveRange(s.FogHoles, fogHoles.Count - s.FogHoles);
foreach (var kv in s.States) foreach (var kv in s.States)
{ {
GimmickUnitState cur; GimmickUnitState cur;

View File

@ -51,6 +51,7 @@ namespace TKS
case GimmickZone.Slide: return new Color(0.55f, 0.9f, 1f, 0.75f); 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.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.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); 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("강에 빠짐"); if (n.Contains("강에 빠짐")) events.Add("강에 빠짐");
if (n.Contains("안개 걷힘")) events.Add("안개 걷힘");
int k = n.IndexOf("자기 −"); int k = n.IndexOf("자기 −");
if (k >= 0) events.Add(n.Substring(k)); // 튕김 자기 피해(bounce_pct · cav_self_pct) if (k >= 0) events.Add(n.Substring(k)); // 튕김 자기 피해(bounce_pct · cav_self_pct)
} }
@ -193,7 +195,7 @@ namespace TKS
int start = -1; int start = -1;
for (int k = 0; k <= n; k++) 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 && start < 0) start = k;
if ((!inside || k == n) && start >= 0) if ((!inside || k == n) && start >= 0)
{ {
@ -210,6 +212,15 @@ namespace TKS
} }
} }
/// <summary>띠를 그릴 자리: 강 = 다리 구간 제외 · 안개 = 걷힌 자리 제외.</summary>
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 static Material DashMaterial

View File

@ -109,6 +109,7 @@ namespace TKS
sr.transform.localRotation = Quaternion.Euler(0f, 0f, s.Type == GimmickShapeType.Rect ? s.Rot : 0f); sr.transform.localRotation = Quaternion.Euler(0f, 0f, s.Type == GimmickShapeType.Rect ? s.Rot : 0f);
fill = sr; fill = sr;
if (art != null) baseColor = Color.white; 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); 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);

View File

@ -321,22 +321,45 @@ namespace TKS.Tests
} }
[UnityTest] [UnityTest]
public IEnumerator Fog_TargetExcluded_SplashCenterInFogShrinks() public IEnumerator Fog_RangedDamageXp1_TargetableNormally_StrategistSplashClearsFog()
{ {
var d = Def("fog"); var d = Def("fog");
yield return Fresh(); 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 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 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); var plan = bm.Resolver.PreviewShot(archer, hidden.Position, false);
Assert.IsNotNull(plan); Assert.AreEqual(hidden, plan.Target, "표적·사선은 정상");
Assert.IsNull(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(); var o = new ActionOutcome();
yield return Act(archer, hidden.Position, o); yield return Act(archer, hidden.Position, o);
Assert.IsFalse(o.Fired, "쏘지 않고 이동"); Assert.AreEqual(hidden, o.ShotHit, "안개 속 적도 화살에 맞는다");
Assert.AreEqual(hidden.MaxHp, hidden.Hp); Assert.Less(hidden.Hp, hidden.MaxHp);
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); 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] [UnityTest]
@ -388,15 +411,25 @@ namespace TKS.Tests
} }
[UnityTest] [UnityTest]
public IEnumerator RiverBridge_BlocksMove_ArrowPasses_KnockedOffBridgeFalls() public IEnumerator River_LikeSwamp_WalkInOut_PushedInFromBankOrBridgeFalls_ArrowPasses()
{ {
var river = Def("river"); var river = Def("river");
yield return Fresh(); yield return Fresh();
L.Spawn("river", GimmickShape.Box(new Vector2(0f, 3f), new Vector2(20f, 2f))); 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))); 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), "보병"); 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); Vector2 reach = bm.Resolver.ReachPoint(walker, new Vector2(-5f, 4.5f));
Assert.AreEqual(Contact(walker, 2f), walker.Position.y, 0.08f, "강 = 통과 불가"); 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 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), "건너편 적"); 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); yield return Act(archer, across.Position, oa);
Assert.AreEqual(across, oa.ShotHit, "투사체는 강 통과"); 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; onBridge.MaxHp = onBridge.Hp = 5000;
onFoot.Position = new Vector2(-8f, -8f);
var o = new ActionOutcome(); var o = new ActionOutcome();
yield return Act(cav, new Vector2(0f, 5.6f), o); yield return Act(cav, new Vector2(0f, 4.4f), o);
Assert.Contains(onBridge, o.Attacked, "다리 = 통행 가능(관통)"); Assert.Contains(onBridge, o.Attacked, "다리 = 통행(관통)");
var s = L.StateOf(onBridge); Assert.AreEqual(Mathf.RoundToInt(river.p2), L.StateOf(onBridge).Stun, "다리에서 옆으로 밀려 강 = 빠짐");
Assert.IsNotNull(s);
Assert.AreEqual(Mathf.RoundToInt(river.p2), s.Stun, "다리에서 옆으로 밀려 강 = p2 턴 행동 불가");
Assert.IsTrue(o.Notes.Exists(n => n.Contains("강에 빠짐")));
yield return L.TurnStart(Faction.Enemy); yield return L.TurnStart(Faction.Enemy);
Assert.IsTrue(GimmickLayer.Skips(onBridge), "그 진영 턴을 쉰다"); Assert.IsTrue(GimmickLayer.Skips(onBridge), "그 진영 턴을 쉰다");
} }
@ -504,7 +547,8 @@ namespace TKS.Tests
if (id == "9-11") if (id == "9-11")
{ {
var river = items.Find(g => g.Zone == GimmickZone.River); 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) foreach (var u in bm.Units)
{ {

View File

@ -48,7 +48,7 @@ Unity.exe -projectPath <proj> -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 <dir>`. - **스킬(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 <dir>`.
- **아웃게임 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 <dir>`(사본 · 10장). - **아웃게임 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 <dir>`(사본 · 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` 는 표에서 미사용(처리기 유지). - **영웅 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 <dir>`(사본). - **지형 기믹 레이어(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 <dir>`(사본).
- **병종 규칙 개정 + 예측 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`. - **병종 규칙 개정 + 예측 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 <dir>`. - 아웃게임 코드 = `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 <dir>`.
- **챕터·스테이지 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`(없으면 길 위 균등). - **챕터·스테이지 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`(없으면 길 위 균등).