지형 기믹 레이어(PD #1097 세션 D) + 병종 규칙 개정 2건 · 예측 시뮬레이션 적 AI(PD #1098)

- 데이터: Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv + Assets/Data/AI/ai_weights.csv(기획 tables/ 사본) → 메뉴 TKS/Import Gimmicks(검증 · 오류 시 자산 불변) → Resources/Gimmicks/GimmickDb(16)·StageGimmickTable(16 + 팁 3) · Resources/AI/AiWeights(32)
- 레이어: GimmickLayer(전투 시작 = 스테이지 배치 · 배경 위 유닛 아래 · 원/사각/다각 · 색 도형 + 글자 · 내구 핍 · 강 콜라이더 = 다리 구간 제외 · 겹친 유닛 보정) · 장애물 = 병종별 내구 피해·튕김·창 목책 밀기·바위 굴림·기병 정지 · 투사체 pass/block/destroy · 폭약 광역(아군 포함)+밀기+불 · 책사 광역 덤불 제거 · 강 낙하
- 지대: 경로 스윕(독 1→2→3 % · 늪 ×2 · 불 10 % · 빙판 미끄러짐 · 고지 · 안개 · 군기 · 함정) · 양 진영 턴 시작 훅
- 규칙 개정: 보병 튕김 중 다른 적과 연쇄 1회 · 창병 반작용(spearRecoilRatio · 밀며 전진 폐기) · 기존 테스트 기대값 갱신
- 적 AI: Runtime/AI(Sim 가상 실행 · TacticalAI 후보·점수·난이도·턴 운영 · 1부 보스 액티브) · 조준선 = 가상 실행 예상 궤적·기믹 반응·지대 띠 · TKS/Gimmick Painter · 캡처 GimmickImporter.Capture
- 검증: 컴파일 0 · 플레이모드 113/113(신규 26) · console error 0 · 적 10기 턴 결정 62 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-11-06 01:20:07 +09:00
parent cf7fe6b8a2
commit 98f675dfda
72 changed files with 6521 additions and 100 deletions

8
Assets/Data/AI.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d4cb149693e67824da30b1dc58dea379
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,33 @@
key,value,desc_ko
dmg_player,1.0,플레이어 유닛에게 준 피해 1당
kill_base,50,처치 1기당 기본
kill_hp_ratio,30,처치한 유닛의 행동 전 HP 비율 × 값
dmg_own,-0.8,자기·아군(적 측) 피해 1당(반격·오사·견제·기믹 포함)
gimmick_dest_bonus,30,밀림·튕김 목적지 기믹(함정·독늪·불·강·폭약 반경)에 플레이어 유닛이 들어가면 피해 환산 + 값(자기 편이면 −)
chain,20,보병 연쇄 교환 1건당
support_reach,15,창병이 민 아군(적 측)이 다음 턴 닿을 수 있는 플레이어 유닛 수 × 값
pierce_hit,10,기병 관통 적중 1기당
cav_return,15,기병 복귀 성공
cav_stranded,-20,기병 관통 후 복귀 실패(적진 잔류)
archer_cover,15,궁병 종료 위치가 목책 뒤(플레이어 근접 유닛과의 직선에 목책)
high_ground,10,궁·책 종료 위치가 고지
cav_line,-15,궁·책 종료 위치가 플레이어 기병 관통선 위
splash_hit,12,책사 광역 적중 1기당(플레이어)
route_open,10,덤불 제거로 열리는 아군(적 측) 사선·돌격로 1개당
zone_end,-1.0,종료 위치 해로운 지대(독늪·불·늪) 피해 환산 × 값
banner,20,종료 위치가 군기 점령 반경
skill_waste,-5,액티브를 썼는데 플레이어 피해 0
approach,2,(구현 추가) 가장 가까운 플레이어 유닛과의 거리 단축 1당 · 칠 대상이 없을 때 전진
boss_accuracy,0.2,보스 최고 점수 선택 확률 가산
mode_accuracy,0.9,투기장 최고 점수 선택 확률
noise_power,0.1,최고가 아닐 때 파워 ±비율
top_n,3,최고가 아닐 때 무작위로 고르는 상위 후보 수
dirs,24,방위 수(적 유닛 8기 이하)
dirs_many,12,방위 수(적 유닛 8기 초과)
many_units,8,방위 축소 기준 적 유닛 수
power_1,0.35,파워 1단(슬링 최대 비율)
power_2,0.6,파워 2단
power_3,1.0,파워 3단
budget_ms,50,유닛 1기 평가 예산(ms) · 넘으면 그때까지 최고
boss_active,1,적 보스 액티브 사용(1 = 사용 · 0 = 끔)
active_max_chapter,10,적 보스 액티브를 쓰는 마지막 장(1부 = 1~10장)
1 key value desc_ko
2 dmg_player 1.0 플레이어 유닛에게 준 피해 1당
3 kill_base 50 처치 1기당 기본
4 kill_hp_ratio 30 처치한 유닛의 행동 전 HP 비율 × 값
5 dmg_own -0.8 자기·아군(적 측) 피해 1당(반격·오사·견제·기믹 포함)
6 gimmick_dest_bonus 30 밀림·튕김 목적지 기믹(함정·독늪·불·강·폭약 반경)에 플레이어 유닛이 들어가면 피해 환산 + 값(자기 편이면 −)
7 chain 20 보병 연쇄 교환 1건당
8 support_reach 15 창병이 민 아군(적 측)이 다음 턴 닿을 수 있는 플레이어 유닛 수 × 값
9 pierce_hit 10 기병 관통 적중 1기당
10 cav_return 15 기병 복귀 성공
11 cav_stranded -20 기병 관통 후 복귀 실패(적진 잔류)
12 archer_cover 15 궁병 종료 위치가 목책 뒤(플레이어 근접 유닛과의 직선에 목책)
13 high_ground 10 궁·책 종료 위치가 고지
14 cav_line -15 궁·책 종료 위치가 플레이어 기병 관통선 위
15 splash_hit 12 책사 광역 적중 1기당(플레이어)
16 route_open 10 덤불 제거로 열리는 아군(적 측) 사선·돌격로 1개당
17 zone_end -1.0 종료 위치 해로운 지대(독늪·불·늪) 피해 환산 × 값
18 banner 20 종료 위치가 군기 점령 반경
19 skill_waste -5 액티브를 썼는데 플레이어 피해 0
20 approach 2 (구현 추가) 가장 가까운 플레이어 유닛과의 거리 단축 1당 · 칠 대상이 없을 때 전진
21 boss_accuracy 0.2 보스 최고 점수 선택 확률 가산
22 mode_accuracy 0.9 투기장 최고 점수 선택 확률
23 noise_power 0.1 최고가 아닐 때 파워 ±비율
24 top_n 3 최고가 아닐 때 무작위로 고르는 상위 후보 수
25 dirs 24 방위 수(적 유닛 8기 이하)
26 dirs_many 12 방위 수(적 유닛 8기 초과)
27 many_units 8 방위 축소 기준 적 유닛 수
28 power_1 0.35 파워 1단(슬링 최대 비율)
29 power_2 0.6 파워 2단
30 power_3 1.0 파워 3단
31 budget_ms 50 유닛 1기 평가 예산(ms) · 넘으면 그때까지 최고
32 boss_active 1 적 보스 액티브 사용(1 = 사용 · 0 = 끔)
33 active_max_chapter 10 적 보스 액티브를 쓰는 마지막 장(1부 = 1~10장)

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: cd768849686ca324c865e83967aa3208
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1ae249671f3bfac4ab79dea4d12edcd4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,17 @@
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
palisade,목책,block,1,3,2,1,1,0.5,0.05,0,pass,0,,,,,gim_palisade,gim_palisade_broken,내구 3 · 보 2 피해·튕김 · 창 1 피해·0.5 밀기 · 기 1 피해·튕김·자기 HP 5 % · 궁책 파괴 불가 · 화살·광역 통과
poison_marsh,독늪,zone,0,0,0,0,0,0,0,0,pass,0,poison,3,0.01,0.01,gim_poison,,지나면 중독 p1 턴 · 매 턴 최대 HP p2 + 턴마다 p3 누적(1 → 2 → 3 %) · 재진입 = 지속 갱신 [p1 턴 · p2 첫 턴 비율 · p3 턴당 증가]
swamp,늪지,zone,0,0,0,0,0,0,0,0,pass,0,slow,2.0,0,,gim_swamp,,늪 안 구간 이동량 ×p1 소모(= 반감) · 기병 늪 안 밀기 0 [p1 이동 비용 배율]
burning_debris,불타는 잔해,zone,0,0,0,0,0,0,0,0,pass,0,burn,0.1,0,0,gim_burning,gim_burning_out,지나면 최대 HP p1 피해(행동당 1회) · 위에서 멈추면 매 턴 p1 · p2 턴 뒤 소화(0 = 영구) · p3 1 = 책사 광역 자리에 생성 [p1 비율 · p2 지속 턴 · p3 spawn_on_splash]
thorn_bush,가시덤불,block,0,0,0,0,0,0,0.05,0.05,block,1,,,,,gim_thorn,,통과 불가 · 부딪히면 튕김 + 최대 HP 5 %(전 병종) · 물리 파괴 불가 · 책사 광역에 들어가면 제거 · 화살 막힘
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 광역 배율]
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 폭]
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
2 palisade 목책 block 1 3 2 1 1 0.5 0.05 0 pass 0 gim_palisade gim_palisade_broken 내구 3 · 보 2 피해·튕김 · 창 1 피해·0.5 밀기 · 기 1 피해·튕김·자기 HP 5 % · 궁책 파괴 불가 · 화살·광역 통과
3 poison_marsh 독늪 zone 0 0 0 0 0 0 0 0 pass 0 poison 3 0.01 0.01 gim_poison 지나면 중독 p1 턴 · 매 턴 최대 HP p2 + 턴마다 p3 누적(1 → 2 → 3 %) · 재진입 = 지속 갱신 [p1 턴 · p2 첫 턴 비율 · p3 턴당 증가]
4 swamp 늪지 zone 0 0 0 0 0 0 0 0 pass 0 slow 2.0 0 gim_swamp 늪 안 구간 이동량 ×p1 소모(= 반감) · 기병 늪 안 밀기 0 [p1 이동 비용 배율]
5 burning_debris 불타는 잔해 zone 0 0 0 0 0 0 0 0 pass 0 burn 0.1 0 0 gim_burning gim_burning_out 지나면 최대 HP p1 피해(행동당 1회) · 위에서 멈추면 매 턴 p1 · p2 턴 뒤 소화(0 = 영구) · p3 1 = 책사 광역 자리에 생성 [p1 비율 · p2 지속 턴 · p3 spawn_on_splash]
6 thorn_bush 가시덤불 block 0 0 0 0 0 0 0.05 0.05 block 1 gim_thorn 통과 불가 · 부딪히면 튕김 + 최대 HP 5 %(전 병종) · 물리 파괴 불가 · 책사 광역에 들어가면 제거 · 화살 막힘
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 광역 배율]
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 0 0 0 0 0 0 0 0 pass 0 river 0.3 1 gim_river 통과 불가(투사체 통과) · 다리 위에서 밀려 강에 빠지면 최대 HP p1 + p2 턴 행동 불가 · 다리 = bridge 레코드가 겹친 구간만 통행 [p1 피해 · p2 턴]
16 bridge 다리 zone 0 0 0 0 0 0 0 0 pass 0 1.2 gim_bridge 강 위 통로(폭 p1) · 다리 위에서 옆으로 밀리면 river 판정 [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

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5aec5258324f30a4a99e4f857044b030
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,17 @@
stage,gimmick_id,shape,cx,cy,w_or_r,h,rot,points,hp_override,params
1-3,thorn_bush,rect,-6,0,3,1,0,,,
1-3,thorn_bush,rect,6,0,3,1,0,,,
1-3,boulder,circle,0,2,0.8,,0,,,
2-5,gate,rect,0,6,4,0.8,0,,,
2-5,wall,rect,-8,6,12,0.8,0,,,
2-5,wall,rect,8,6,12,0.8,0,,,
2-5,high_ground,rect,0,9,20,3,0,,,
4-13,swamp,rect,0,0,20,6,0,,,
4-13,poison_marsh,circle,-4,2,2,,0,,,
4-13,poison_marsh,circle,4,2,2,,0,,,
8-15,burning_debris,rect,0,0,20,32,0,,,spawn_on_splash=1;burn_turns=3
8-15,powder_cart,circle,-3,4,0.6,,0,,,
8-15,powder_cart,circle,3,4,0.6,,0,,,
9-11,river,rect,0,3,20,2,0,,,
9-11,bridge,rect,0,3,1.2,2,0,,,
9-11,fog,circle,0,8,4,,0,,,
1 stage gimmick_id shape cx cy w_or_r h rot points hp_override params
2 1-3 thorn_bush rect -6 0 3 1 0
3 1-3 thorn_bush rect 6 0 3 1 0
4 1-3 boulder circle 0 2 0.8 0
5 2-5 gate rect 0 6 4 0.8 0
6 2-5 wall rect -8 6 12 0.8 0
7 2-5 wall rect 8 6 12 0.8 0
8 2-5 high_ground rect 0 9 20 3 0
9 4-13 swamp rect 0 0 20 6 0
10 4-13 poison_marsh circle -4 2 2 0
11 4-13 poison_marsh circle 4 2 2 0
12 8-15 burning_debris rect 0 0 20 32 0 spawn_on_splash=1;burn_turns=3
13 8-15 powder_cart circle -3 4 0.6 0
14 8-15 powder_cart circle 3 4 0.6 0
15 9-11 river rect 0 3 20 2 0
16 9-11 bridge rect 0 3 1.2 2 0
17 9-11 fog circle 0 8 4 0

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3bed85c558aa0bf4787f2c896fbab028
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,4 @@
stage,tip_ko
1-1,보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다
1-2,보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다
1-3,보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다
1 stage tip_ko
2 1-1 보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다
3 1-2 보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다
4 1-3 보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0907d8a68de35eb4595b90013fded822
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Assets/Resources/AI.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a7135fa18486d994cb10bb47ac8d7629
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,130 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 659ab9685f0a38143a4e480430ce70a3, type: 3}
m_Name: AiWeights
m_EditorClassIdentifier: TKS.Runtime::TKS.AiWeights
entries:
- key: dmg_player
value: 1
desc: "\uD50C\uB808\uC774\uC5B4 \uC720\uB2DB\uC5D0\uAC8C \uC900 \uD53C\uD574
1\uB2F9"
- key: kill_base
value: 50
desc: "\uCC98\uCE58 1\uAE30\uB2F9 \uAE30\uBCF8"
- key: kill_hp_ratio
value: 30
desc: "\uCC98\uCE58\uD55C \uC720\uB2DB\uC758 \uD589\uB3D9 \uC804 HP \uBE44\uC728
\xD7 \uAC12"
- key: dmg_own
value: -0.8
desc: "\uC790\uAE30\xB7\uC544\uAD70(\uC801 \uCE21) \uD53C\uD574 1\uB2F9(\uBC18\uACA9\xB7\uC624\uC0AC\xB7\uACAC\uC81C\xB7\uAE30\uBBF9
\uD3EC\uD568)"
- key: gimmick_dest_bonus
value: 30
desc: "\uBC00\uB9BC\xB7\uD295\uAE40 \uBAA9\uC801\uC9C0 \uAE30\uBBF9(\uD568\uC815\xB7\uB3C5\uB2AA\xB7\uBD88\xB7\uAC15\xB7\uD3ED\uC57D
\uBC18\uACBD)\uC5D0 \uD50C\uB808\uC774\uC5B4 \uC720\uB2DB\uC774 \uB4E4\uC5B4\uAC00\uBA74
\uD53C\uD574 \uD658\uC0B0 + \uAC12(\uC790\uAE30 \uD3B8\uC774\uBA74 \u2212)"
- key: chain
value: 20
desc: "\uBCF4\uBCD1 \uC5F0\uC1C4 \uAD50\uD658 1\uAC74\uB2F9"
- key: support_reach
value: 15
desc: "\uCC3D\uBCD1\uC774 \uBBFC \uC544\uAD70(\uC801 \uCE21)\uC774 \uB2E4\uC74C
\uD134 \uB2FF\uC744 \uC218 \uC788\uB294 \uD50C\uB808\uC774\uC5B4 \uC720\uB2DB
\uC218 \xD7 \uAC12"
- key: pierce_hit
value: 10
desc: "\uAE30\uBCD1 \uAD00\uD1B5 \uC801\uC911 1\uAE30\uB2F9"
- key: cav_return
value: 15
desc: "\uAE30\uBCD1 \uBCF5\uADC0 \uC131\uACF5"
- key: cav_stranded
value: -20
desc: "\uAE30\uBCD1 \uAD00\uD1B5 \uD6C4 \uBCF5\uADC0 \uC2E4\uD328(\uC801\uC9C4
\uC794\uB958)"
- key: archer_cover
value: 15
desc: "\uAD81\uBCD1 \uC885\uB8CC \uC704\uCE58\uAC00 \uBAA9\uCC45 \uB4A4(\uD50C\uB808\uC774\uC5B4
\uADFC\uC811 \uC720\uB2DB\uACFC\uC758 \uC9C1\uC120\uC5D0 \uBAA9\uCC45)"
- key: high_ground
value: 10
desc: "\uAD81\xB7\uCC45 \uC885\uB8CC \uC704\uCE58\uAC00 \uACE0\uC9C0"
- key: cav_line
value: -15
desc: "\uAD81\xB7\uCC45 \uC885\uB8CC \uC704\uCE58\uAC00 \uD50C\uB808\uC774\uC5B4
\uAE30\uBCD1 \uAD00\uD1B5\uC120 \uC704"
- key: splash_hit
value: 12
desc: "\uCC45\uC0AC \uAD11\uC5ED \uC801\uC911 1\uAE30\uB2F9(\uD50C\uB808\uC774\uC5B4)"
- key: route_open
value: 10
desc: "\uB364\uBD88 \uC81C\uAC70\uB85C \uC5F4\uB9AC\uB294 \uC544\uAD70(\uC801
\uCE21) \uC0AC\uC120\xB7\uB3CC\uACA9\uB85C 1\uAC1C\uB2F9"
- key: zone_end
value: -1
desc: "\uC885\uB8CC \uC704\uCE58 \uD574\uB85C\uC6B4 \uC9C0\uB300(\uB3C5\uB2AA\xB7\uBD88\xB7\uB2AA)
\uD53C\uD574 \uD658\uC0B0 \xD7 \uAC12"
- key: banner
value: 20
desc: "\uC885\uB8CC \uC704\uCE58\uAC00 \uAD70\uAE30 \uC810\uB839 \uBC18\uACBD"
- key: skill_waste
value: -5
desc: "\uC561\uD2F0\uBE0C\uB97C \uC37C\uB294\uB370 \uD50C\uB808\uC774\uC5B4 \uD53C\uD574
0"
- key: approach
value: 2
desc: "(\uAD6C\uD604 \uCD94\uAC00) \uAC00\uC7A5 \uAC00\uAE4C\uC6B4 \uD50C\uB808\uC774\uC5B4
\uC720\uB2DB\uACFC\uC758 \uAC70\uB9AC \uB2E8\uCD95 1\uB2F9 \xB7 \uCE60 \uB300\uC0C1\uC774
\uC5C6\uC744 \uB54C \uC804\uC9C4"
- key: boss_accuracy
value: 0.2
desc: "\uBCF4\uC2A4 \uCD5C\uACE0 \uC810\uC218 \uC120\uD0DD \uD655\uB960 \uAC00\uC0B0"
- key: mode_accuracy
value: 0.9
desc: "\uD22C\uAE30\uC7A5 \uCD5C\uACE0 \uC810\uC218 \uC120\uD0DD \uD655\uB960"
- key: noise_power
value: 0.1
desc: "\uCD5C\uACE0\uAC00 \uC544\uB2D0 \uB54C \uD30C\uC6CC \xB1\uBE44\uC728"
- key: top_n
value: 3
desc: "\uCD5C\uACE0\uAC00 \uC544\uB2D0 \uB54C \uBB34\uC791\uC704\uB85C \uACE0\uB974\uB294
\uC0C1\uC704 \uD6C4\uBCF4 \uC218"
- key: dirs
value: 24
desc: "\uBC29\uC704 \uC218(\uC801 \uC720\uB2DB 8\uAE30 \uC774\uD558)"
- key: dirs_many
value: 12
desc: "\uBC29\uC704 \uC218(\uC801 \uC720\uB2DB 8\uAE30 \uCD08\uACFC)"
- key: many_units
value: 8
desc: "\uBC29\uC704 \uCD95\uC18C \uAE30\uC900 \uC801 \uC720\uB2DB \uC218"
- key: power_1
value: 0.35
desc: "\uD30C\uC6CC 1\uB2E8(\uC2AC\uB9C1 \uCD5C\uB300 \uBE44\uC728)"
- key: power_2
value: 0.6
desc: "\uD30C\uC6CC 2\uB2E8"
- key: power_3
value: 1
desc: "\uD30C\uC6CC 3\uB2E8"
- key: budget_ms
value: 50
desc: "\uC720\uB2DB 1\uAE30 \uD3C9\uAC00 \uC608\uC0B0(ms) \xB7 \uB118\uC73C\uBA74
\uADF8\uB54C\uAE4C\uC9C0 \uCD5C\uACE0"
- key: boss_active
value: 1
desc: "\uC801 \uBCF4\uC2A4 \uC561\uD2F0\uBE0C \uC0AC\uC6A9(1 = \uC0AC\uC6A9 \xB7
0 = \uB054)"
- key: active_max_chapter
value: 10
desc: "\uC801 \uBCF4\uC2A4 \uC561\uD2F0\uBE0C\uB97C \uC4F0\uB294 \uB9C8\uC9C0\uB9C9
\uC7A5(1\uBD80 = 1~10\uC7A5)"

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5b4c5518e6ce1084a98920a59a09fb11
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f328cb936677bf04cb80fb3785f5ad75
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,381 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9acfdb0ffd0c7604b8ea57748aed0456, type: 3}
m_Name: GimmickDb
m_EditorClassIdentifier: TKS.Runtime::TKS.GimmickDb
defs:
- id: palisade
nameKo: "\uBAA9\uCC45"
kind: 0
durable: 1
hp: 3
dmgInf: 2
dmgSpear: 1
dmgCav: 1
spearPush: 0.5
cavSelfPct: 0.05
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 0
p1: NaN
p2: NaN
p3: NaN
art: gim_palisade
artBroken: gim_palisade_broken
descKo: "\uB0B4\uAD6C 3 \xB7 \uBCF4 2 \uD53C\uD574\xB7\uD295\uAE40 \xB7 \uCC3D
1 \uD53C\uD574\xB70.5 \uBC00\uAE30 \xB7 \uAE30 1 \uD53C\uD574\xB7\uD295\uAE40\xB7\uC790\uAE30
HP 5 % \xB7 \uAD81\uCC45 \uD30C\uAD34 \uBD88\uAC00 \xB7 \uD654\uC0B4\xB7\uAD11\uC5ED
\uD1B5\uACFC"
- id: poison_marsh
nameKo: "\uB3C5\uB2AA"
kind: 1
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 1
p1: 3
p2: 0.01
p3: 0.01
art: gim_poison
artBroken:
descKo: "\uC9C0\uB098\uBA74 \uC911\uB3C5 p1 \uD134 \xB7 \uB9E4 \uD134 \uCD5C\uB300
HP p2 + \uD134\uB9C8\uB2E4 p3 \uB204\uC801(1 \u2192 2 \u2192 3 %) \xB7 \uC7AC\uC9C4\uC785
= \uC9C0\uC18D \uAC31\uC2E0 [p1 \uD134 \xB7 p2 \uCCAB \uD134 \uBE44\uC728 \xB7
p3 \uD134\uB2F9 \uC99D\uAC00]"
- id: swamp
nameKo: "\uB2AA\uC9C0"
kind: 1
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 2
p1: 2
p2: 0
p3: NaN
art: gim_swamp
artBroken:
descKo: "\uB2AA \uC548 \uAD6C\uAC04 \uC774\uB3D9\uB7C9 \xD7p1 \uC18C\uBAA8(=
\uBC18\uAC10) \xB7 \uAE30\uBCD1 \uB2AA \uC548 \uBC00\uAE30 0 [p1 \uC774\uB3D9
\uBE44\uC6A9 \uBC30\uC728]"
- id: burning_debris
nameKo: "\uBD88\uD0C0\uB294 \uC794\uD574"
kind: 1
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 3
p1: 0.1
p2: 0
p3: 0
art: gim_burning
artBroken: gim_burning_out
descKo: "\uC9C0\uB098\uBA74 \uCD5C\uB300 HP p1 \uD53C\uD574(\uD589\uB3D9\uB2F9
1\uD68C) \xB7 \uC704\uC5D0\uC11C \uBA48\uCD94\uBA74 \uB9E4 \uD134 p1 \xB7 p2
\uD134 \uB4A4 \uC18C\uD654(0 = \uC601\uAD6C) \xB7 p3 1 = \uCC45\uC0AC \uAD11\uC5ED
\uC790\uB9AC\uC5D0 \uC0DD\uC131 [p1 \uBE44\uC728 \xB7 p2 \uC9C0\uC18D \uD134
\xB7 p3 spawn_on_splash]"
- id: thorn_bush
nameKo: "\uAC00\uC2DC\uB364\uBD88"
kind: 0
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0.05
bouncePct: 0.05
projectile: 1
strategistClear: 1
zone: 0
p1: NaN
p2: NaN
p3: NaN
art: gim_thorn
artBroken:
descKo: "\uD1B5\uACFC \uBD88\uAC00 \xB7 \uBD80\uB52A\uD788\uBA74 \uD295\uAE40
+ \uCD5C\uB300 HP 5 %(\uC804 \uBCD1\uC885) \xB7 \uBB3C\uB9AC \uD30C\uAD34 \uBD88\uAC00
\xB7 \uCC45\uC0AC \uAD11\uC5ED\uC5D0 \uB4E4\uC5B4\uAC00\uBA74 \uC81C\uAC70
\xB7 \uD654\uC0B4 \uB9C9\uD798"
- id: ice
nameKo: "\uBE59\uD310"
kind: 1
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 4
p1: 3
p2: 1.5
p3: NaN
art: gim_ice
artBroken:
descKo: "\uC815\uC9C0 \uC9C0\uC810\uC774 \uBE59\uD310\uC774\uBA74 \uAC19\uC740
\uBC29\uD5A5\uC73C\uB85C \uBE59\uD310 \uB05D\uAE4C\uC9C0(\uCD5C\uB300 p1) \uBBF8\uB044\uB7EC\uC9D0
\xB7 \uCDA9\uB3CC \uADDC\uCE59 \uADF8\uB300\uB85C \xB7 \uAE30\uBCD1 \uBE59\uD310
\uC704 \uBC00\uAE30 \xD7p2 [p1 \uCD5C\uB300 \uAC70\uB9AC \xB7 p2 \uAE30\uBCD1
\uBC00\uAE30 \uBC30\uC728]"
- id: boulder
nameKo: "\uBC14\uC704"
kind: 0
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0.5
cavSelfPct: 0.05
bouncePct: 0
projectile: 1
strategistClear: 0
zone: 0
p1: 1
p2: 1
p3: NaN
art: gim_boulder
artBroken:
descKo: "\uBCF4\xB7\uCC3D\uC774 \uBC00\uBA74 \uBC14\uC704 \uC774\uB3D9(\uB0A8\uC740
\uAC70\uB9AC \xD7 spear_push) \xB7 \uBC00\uB9B0 \uBC14\uC704\uAC00 \uC801\uC5D0
\uB2FF\uC73C\uBA74 \uACF5 \xD7p1 \uD53C\uD574 + \uBC00\uAE30 p2 \xB7 \uAE30\uBCD1
\uCDA9\uB3CC \uC815\uC9C0\xB75 % \xB7 \uD22C\uC0AC\uCCB4 \uB9C9\uD798 \xB7
\uD30C\uAD34 \uBD88\uAC00 [p1 \uD53C\uD574 \uBC30\uC728 \xB7 p2 \uBC00\uAE30]"
- id: high_ground
nameKo: "\uACE0\uC9C0"
kind: 1
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 5
p1: 1
p2: 0.15
p3: 1.5
art: gim_high
artBroken:
descKo: "\uC704\uC5D0 \uC120 \uAD81\xB7\uCC45 \uC0AC\uAC70\uB9AC +p1 \xB7 \uC544\uB798\uC5D0\uC11C
\uC624\uB294 \uADFC\uC811 \uD53C\uD574 \u2212p2 \xB7 \uC624\uB974\uB294 \uACBD\uB85C
\uC774\uB3D9\uB7C9 \xD7p3 [p1 \uC0AC\uAC70\uB9AC \xB7 p2 \uD53C\uD574 \uAC10\uC18C
\xB7 p3 \uC774\uB3D9 \uBE44\uC6A9]"
- id: fog
nameKo: "\uC548\uAC1C"
kind: 1
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 6
p1: 0.5
p2: 0
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]"
- id: powder_cart
nameKo: "\uD3ED\uC57D \uC218\uB808"
kind: 0
durable: 1
hp: 1
dmgInf: 1
dmgSpear: 1
dmgCav: 1
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 2
strategistClear: 1
zone: 0
p1: 2
p2: 1.5
p3: 1
art: gim_cart
artBroken: gim_cart_broken
descKo: "\uB0B4\uAD6C 1 \xB7 \uC5B4\uB5A4 \uACF5\uACA9\xB7\uD654\uC0B4\uC5D0\uB3C4
\uD30C\uAD34 \xB7 \uD30C\uAD34 \uC2DC \uBC18\uACBD p1 \uAD11\uC5ED \uACF5 \xD7p2(\uC544\uAD70
\uD3EC\uD568) + \uBC00\uAE30 p3 \xB7 \uBD88\uD0C0\uB294 \uC794\uD574 \uC0DD\uC131
[p1 \uBC18\uACBD \xB7 p2 \uD53C\uD574 \uBC30\uC728 \xB7 p3 \uBC00\uAE30]"
- id: banner
nameKo: "\uAD70\uAE30"
kind: 1
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 7
p1: 1.5
p2: 0.1
p3: NaN
art: gim_banner
artBroken:
descKo: "\uBC18\uACBD p1 \uC548\uC5D0 \uD55C\uCABD \uD3B8\uB9CC \uC788\uC73C\uBA74
\uD134 \uC2DC\uC791 \uC2DC \uC810\uB839 \u2192 \uC810\uB839 \uCE21 \uC804\uC6D0
\uACF5 +p2 \xB7 \uC591\uCABD \uC788\uC73C\uBA74 \uC911\uB9BD [p1 \uBC18\uACBD
\xB7 p2 \uACF5 \uC99D\uAC00]"
- id: gate
nameKo: "\uC131\uBB38"
kind: 0
durable: 1
hp: 6
dmgInf: 2
dmgSpear: 1
dmgCav: 1
spearPush: 0
cavSelfPct: 0.1
bouncePct: 0
projectile: 1
strategistClear: 0
zone: 0
p1: NaN
p2: NaN
p3: NaN
art: gim_gate
artBroken: gim_gate_broken
descKo: "\uB0B4\uAD6C 6 \xB7 \uBCF4 2 \xB7 \uCC3D 1(\uBC00\uAE30 0) \xB7 \uAE30
1 + \uC790\uAE30 HP 10 % \xB7 \uD654\uC0B4\xB7\uAD11\uC5ED \uB9C9\uD798 \xB7
\uD30C\uAD34 \uC2DC \uD1B5\uB85C \uC5F4\uB9BC(\uC131\uBCBD \uAD6C\uAC04\uC740
wall)"
- id: wall
nameKo: "\uC131\uBCBD"
kind: 0
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0.05
bouncePct: 0
projectile: 1
strategistClear: 0
zone: 0
p1: NaN
p2: NaN
p3: NaN
art: gim_wall
artBroken:
descKo: "\uD30C\uAD34 \uBD88\uAC00 \xB7 \uBD80\uB52A\uD788\uBA74 \uD295\uAE40(\uAE30\uBCD1
5 %) \xB7 \uD22C\uC0AC\uCCB4 \uB9C9\uD798 \xB7 \uC131\uBB38\uACFC \uC9DD"
- id: river
nameKo: "\uAC15"
kind: 0
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 9
p1: 0.3
p2: 1
p3: NaN
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]"
- id: bridge
nameKo: "\uB2E4\uB9AC"
kind: 1
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 0
p1: 1.2
p2: NaN
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]"
- id: pit_trap
nameKo: "\uD568\uC815"
kind: 1
durable: 0
hp: 0
dmgInf: 0
dmgSpear: 0
dmgCav: 0
spearPush: 0
cavSelfPct: 0
bouncePct: 0
projectile: 0
strategistClear: 0
zone: 8
p1: 1
p2: NaN
p3: NaN
art: gim_trap
artBroken: gim_trap_open
descKo: "\uC228\uAE40(\uC544\uAD70 \uC870\uC900\uC120 \uBBF8\uD45C\uC2DC \xB7
\uC801 AI \uB294 \uC778\uC9C0) \xB7 \uBC1F\uC73C\uBA74 \uADF8 \uC790\uB9AC
\uC815\uC9C0 + p1 \uD134 \uD589\uB3D9 \uBD88\uAC00 \xB7 \uBC1C\uB3D9 \uD6C4
\uC81C\uAC70 [p1 \uD134]"

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 38882c8f5f9a1ca4d8e9c00e9f5b74e3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,309 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1f8e3e401b33e1d46bf1084189a5f061, type: 3}
m_Name: StageGimmickTable
m_EditorClassIdentifier: TKS.Runtime::TKS.StageGimmickTable
rows:
- stage: 1-3
chapter: 1
stageNo: 3
gimmickId: thorn_bush
shape: 1
cx: -6
cy: 0
wOrR: 3
h: 1
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 2
- stage: 1-3
chapter: 1
stageNo: 3
gimmickId: thorn_bush
shape: 1
cx: 6
cy: 0
wOrR: 3
h: 1
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 3
- stage: 1-3
chapter: 1
stageNo: 3
gimmickId: boulder
shape: 0
cx: 0
cy: 2
wOrR: 0.8
h: 0
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 4
- stage: 2-5
chapter: 2
stageNo: 5
gimmickId: gate
shape: 1
cx: 0
cy: 6
wOrR: 4
h: 0.8
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 5
- stage: 2-5
chapter: 2
stageNo: 5
gimmickId: wall
shape: 1
cx: -8
cy: 6
wOrR: 12
h: 0.8
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 6
- stage: 2-5
chapter: 2
stageNo: 5
gimmickId: wall
shape: 1
cx: 8
cy: 6
wOrR: 12
h: 0.8
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 7
- stage: 2-5
chapter: 2
stageNo: 5
gimmickId: high_ground
shape: 1
cx: 0
cy: 9
wOrR: 20
h: 3
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 8
- stage: 4-13
chapter: 4
stageNo: 13
gimmickId: swamp
shape: 1
cx: 0
cy: 0
wOrR: 20
h: 6
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 9
- stage: 4-13
chapter: 4
stageNo: 13
gimmickId: poison_marsh
shape: 0
cx: -4
cy: 2
wOrR: 2
h: 0
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 10
- stage: 4-13
chapter: 4
stageNo: 13
gimmickId: poison_marsh
shape: 0
cx: 4
cy: 2
wOrR: 2
h: 0
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 11
- stage: 8-15
chapter: 8
stageNo: 15
gimmickId: burning_debris
shape: 1
cx: 0
cy: 0
wOrR: 20
h: 32
rot: 0
points: []
hpOverride: -1
paramsRaw: spawn_on_splash=1;burn_turns=3
pOver1: NaN
pOver2: 3
pOver3: 1
line: 12
- stage: 8-15
chapter: 8
stageNo: 15
gimmickId: powder_cart
shape: 0
cx: -3
cy: 4
wOrR: 0.6
h: 0
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 13
- stage: 8-15
chapter: 8
stageNo: 15
gimmickId: powder_cart
shape: 0
cx: 3
cy: 4
wOrR: 0.6
h: 0
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 14
- stage: 9-11
chapter: 9
stageNo: 11
gimmickId: river
shape: 1
cx: 0
cy: 3
wOrR: 20
h: 2
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 15
- stage: 9-11
chapter: 9
stageNo: 11
gimmickId: bridge
shape: 1
cx: 0
cy: 3
wOrR: 1.2
h: 2
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 16
- stage: 9-11
chapter: 9
stageNo: 11
gimmickId: fog
shape: 0
cx: 0
cy: 8
wOrR: 4
h: 0
rot: 0
points: []
hpOverride: -1
paramsRaw:
pOver1: NaN
pOver2: NaN
pOver3: NaN
line: 17
tips:
- stage: 1-1
chapter: 1
stageNo: 1
text: "\uBCF4\uBCD1\uC740 \uD295\uACA8\uB0B4\uACE0 \uCC3D\uBCD1\uC740 \uBC00\uCCD0\uB0B4\uACE0
\uBB3C\uB7EC\uB09C\uB2E4 \xB7 \uAE30\uBCD1\uC740 \uAFF0\uB6AB\uACE0 \uB3CC\uC544\uC628\uB2E4
\xB7 \uAD81\uBCD1\uC740 \uBAA9\uCC45 \uB108\uBA38\uB97C \uC3DC\uB2E4 \xB7 \uCC45\uC0AC\uB294
\uBB49\uCE5C \uC801\uC744 \uD0DC\uC6B0\uACE0 \uB364\uBD88\uC744 \uCE58\uC6B4\uB2E4"
- stage: 1-2
chapter: 1
stageNo: 2
text: "\uBCF4\uBCD1\uC740 \uD295\uACA8\uB0B4\uACE0 \uCC3D\uBCD1\uC740 \uBC00\uCCD0\uB0B4\uACE0
\uBB3C\uB7EC\uB09C\uB2E4 \xB7 \uAE30\uBCD1\uC740 \uAFF0\uB6AB\uACE0 \uB3CC\uC544\uC628\uB2E4
\xB7 \uAD81\uBCD1\uC740 \uBAA9\uCC45 \uB108\uBA38\uB97C \uC3DC\uB2E4 \xB7 \uCC45\uC0AC\uB294
\uBB49\uCE5C \uC801\uC744 \uD0DC\uC6B0\uACE0 \uB364\uBD88\uC744 \uCE58\uC6B4\uB2E4"
- stage: 1-3
chapter: 1
stageNo: 3
text: "\uBCF4\uBCD1\uC740 \uD295\uACA8\uB0B4\uACE0 \uCC3D\uBCD1\uC740 \uBC00\uCCD0\uB0B4\uACE0
\uBB3C\uB7EC\uB09C\uB2E4 \xB7 \uAE30\uBCD1\uC740 \uAFF0\uB6AB\uACE0 \uB3CC\uC544\uC628\uB2E4
\xB7 \uAD81\uBCD1\uC740 \uBAA9\uCC45 \uB108\uBA38\uB97C \uC3DC\uB2E4 \xB7 \uCC45\uC0AC\uB294
\uBB49\uCE5C \uC801\uC744 \uD0DC\uC6B0\uACE0 \uB364\uBD88\uC744 \uCE58\uC6B4\uB2E4"

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 64e563179d8b37943b222efb3b1b1943
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -54,6 +54,7 @@ namespace TKS.EditorTools
c.strategistAllyDamageRatio = 1f; // 광역 아군 피해 = 적과 동일(PD 확인 대상) c.strategistAllyDamageRatio = 1f; // 광역 아군 피해 = 적과 동일(PD 확인 대상)
c.collisionPushRatio = 1f; // PD 2026-11-04 #1080: 보병 알까기 밀어내기 = 남은 거리 그대로 c.collisionPushRatio = 1f; // PD 2026-11-04 #1080: 보병 알까기 밀어내기 = 남은 거리 그대로
c.collisionSelfBounceRatio = 0.5f; // PD 2026-11-04 #1081: 보병 자신도 반대로 튕김(남은 거리의 절반 · 손맛 조정 대상) c.collisionSelfBounceRatio = 0.5f; // PD 2026-11-04 #1081: 보병 자신도 반대로 튕김(남은 거리의 절반 · 손맛 조정 대상)
c.spearRecoilRatio = 0.5f; // PD 2026-11-06 #1098: 창병 밀치기 반작용(밀린 거리의 절반 · 함께 전진 폐기)
c.unitArtHeight = 1.5f; c.unitArtHeight = 1.5f;
c.boardArt = "battlefield_01"; // 원화 세션 1차 납품(2026-11-04) · Resources/Board/ c.boardArt = "battlefield_01"; // 원화 세션 1차 납품(2026-11-04) · Resources/Board/
c.minDragDistance = 0.35f; c.minDragDistance = 0.35f;

View File

@ -0,0 +1,125 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace TKS.EditorTools
{
/// <summary>
/// 지형 기믹 표(Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv · 기획 tables/ 사본 · PD #1097) + 적 AI 가중치(Assets/Data/AI/ai_weights.csv · PD #1098)
/// → Resources/Gimmicks/GimmickDb · StageGimmickTable · Resources/AI/AiWeights. 메뉴 TKS/Import Gimmicks 또는
/// CLI: Unity.exe -batchmode -nographics -projectPath &lt;proj&gt; -executeMethod TKS.EditorTools.GimmickImporter.Import -quit
/// 형식·참조 오류(정의 id · 스테이지 · 모양 · params · 가중치 키)가 하나라도 있으면 자산을 건드리지 않는다.
/// </summary>
public static class GimmickImporter
{
public const string AssetFolder = "Assets/Resources/Gimmicks";
public const string AiFolder = "Assets/Resources/AI";
[MenuItem("TKS/Import Gimmicks (기믹 임포트)")]
public static void Import()
{
ImportAll();
}
public static string[] ReadFiles()
{
var dir = Path.Combine(Application.dataPath, GimmickTables.Folder);
var texts = new string[GimmickTables.Files.Length];
for (int i = 0; i < texts.Length; i++)
{
var p = Path.Combine(dir, GimmickTables.Files[i]);
texts[i] = File.Exists(p) ? File.ReadAllText(p, Encoding.UTF8) : null;
}
return texts;
}
public static bool ImportAll()
{
var cfg = AssetDatabase.LoadAssetAtPath<BalanceConfig>(ProjectSetup.ConfigPath);
System.Func<int, int, bool> exists = null;
if (cfg != null && cfg.stages != null && cfg.stages.Length > 0)
{
exists = (ch, st) =>
{
foreach (var s in cfg.stages)
{
if (s.chapter == ch && s.stage == st) return true;
}
return false;
};
}
var r = GimmickTables.Parse(ReadFiles(), exists);
var aiPath = Path.Combine(Application.dataPath, AiWeights.CsvPath);
var aiEntries = AiWeights.Parse(File.Exists(aiPath) ? File.ReadAllText(aiPath, Encoding.UTF8) : null, r.errors);
if (r.errors.Count > 0)
{
foreach (var e in r.errors) Debug.LogError("[TKS] 기믹 표 오류 · " + e);
Debug.LogError("[TKS] 기믹 임포트 중단(오류 " + r.errors.Count + ") · 자산 변경 없음");
return false;
}
EnsureFolder("Assets/Resources");
EnsureFolder(AssetFolder);
EnsureFolder(AiFolder);
var db = Load<GimmickDb>(AssetFolder + "/GimmickDb.asset");
db.defs = new List<GimmickDef>(r.defs);
EditorUtility.SetDirty(db);
var table = Load<StageGimmickTable>(AssetFolder + "/StageGimmickTable.asset");
table.rows = new List<GimmickPlacement>(r.rows);
table.tips = new List<StageGimmickTable.Tip>(r.tips);
EditorUtility.SetDirty(table);
var w = Load<AiWeights>(AiFolder + "/AiWeights.asset");
w.entries = aiEntries;
EditorUtility.SetDirty(w);
AssetDatabase.SaveAssets();
GimmickTables.Invalidate();
AiWeights.Invalidate();
Debug.Log("[TKS] 기믹 임포트 완료 · 정의 " + r.defs.Count + " · 배치 " + r.rows.Count + " · 팁 " + r.tips.Count + " · AI 가중치 " + aiEntries.Count + " · " + AssetFolder);
return true;
}
static T Load<T>(string path) where T : ScriptableObject
{
var a = AssetDatabase.LoadAssetAtPath<T>(path);
if (a != null) return a;
a = ScriptableObject.CreateInstance<T>();
AssetDatabase.CreateAsset(a, path);
return a;
}
static void EnsureFolder(string path)
{
if (AssetDatabase.IsValidFolder(path)) return;
var parent = Path.GetDirectoryName(path).Replace('\\', '/');
AssetDatabase.CreateFolder(parent, Path.GetFileName(path));
}
/// <summary>
/// 기믹 검증 캡처(GUI 에디터 · 사본 프로젝트 권장 · 세이브 = 메모리 전용 · PD #1097):
/// Unity.exe -projectPath &lt;copy&gt; -executeMethod TKS.EditorTools.GimmickImporter.Capture -gimmickDir &lt;dir&gt;
/// 샘플 5 스테이지 1턴씩 + 조준선 반응 2장 + AI 함정 쪽 튕겨내기 1장 PNG + log · 끝나면 스스로 종료.
/// </summary>
public static void Capture()
{
string dir = GetArg("-gimmickDir");
if (string.IsNullOrEmpty(dir)) dir = Path.Combine(Directory.GetCurrentDirectory(), "Logs", "gimmick_capture");
ImportAll();
EditorSceneManager.OpenScene(ProjectSetup.ScenePath, OpenSceneMode.Single);
SessionState.SetBool(OutGame.SaveStore.MemoryFlagKey, true);
SessionState.SetString(GimmickCaptureDriver.DirKey, dir);
Debug.Log("[TKS] 기믹 캡처 시작 → " + dir);
EditorApplication.EnterPlaymode();
}
static string GetArg(string name)
{
var args = System.Environment.GetCommandLineArgs();
for (int i = 0; i < args.Length - 1; i++)
if (args[i] == name) return args[i + 1];
return null;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f97d9c5e09776874cb64cb1784e67e6e

View File

@ -0,0 +1,294 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
namespace TKS.EditorTools
{
/// <summary>
/// 지형 기믹 배치 도구(PD #1097 §1 「어떤 맵이든 올려서 배치」) — 메뉴 TKS/Gimmick Painter.
/// 스테이지 고르기 → 불러오기(stage_gimmicks.csv 의 그 스테이지 행) → 기믹·모양 고르고 「그리기」 → 씬 뷰에서 드래그(원 = 중심→반지름 · 사각 = 모서리→모서리) →
/// 저장(그 스테이지 행만 바꾸고 다른 스테이지 행은 그대로 · utf-8-sig) → 자동 임포트(Play 중이면 레이어 다시 만듦). 표를 바꿨으면 기획 tables/ 에도 같은 파일을 둔다.
/// </summary>
public class GimmickPainter : EditorWindow
{
const string Header = "stage,gimmick_id,shape,cx,cy,w_or_r,h,rot,points,hp_override,params";
int stageIdx;
int gimmickIdx;
GimmickShapeType shape = GimmickShapeType.Rect;
float rot;
string param = "";
bool painting;
int selected = -1;
Vector2 scroll;
readonly List<GimmickPlacement> rows = new List<GimmickPlacement>();
string loadedStage = "";
bool dragging;
Vector2 dragStart, dragNow;
[MenuItem("TKS/Gimmick Painter (기믹 배치)")]
public static void Open()
{
GetWindow<GimmickPainter>("기믹 배치");
}
void OnEnable()
{
SceneView.duringSceneGui += OnScene;
}
void OnDisable()
{
SceneView.duringSceneGui -= OnScene;
}
static BalanceConfig Cfg
{
get { return AssetDatabase.LoadAssetAtPath<BalanceConfig>(ProjectSetup.ConfigPath); }
}
string CurrentStage
{
get
{
var cfg = Cfg;
if (cfg == null || cfg.stages.Length == 0) return "";
var s = cfg.stages[Mathf.Clamp(stageIdx, 0, cfg.stages.Length - 1)];
return s.chapter + "-" + s.stage;
}
}
void OnGUI()
{
var cfg = Cfg;
var db = GimmickTables.Db;
if (cfg == null || db == null)
{
EditorGUILayout.HelpBox("BalanceConfig 또는 GimmickDb 가 없습니다 — TKS/Import Stages · TKS/Import Gimmicks 먼저.", MessageType.Warning);
return;
}
var stageNames = new string[cfg.stages.Length];
for (int i = 0; i < stageNames.Length; i++) stageNames[i] = cfg.stages[i].chapter + "-" + cfg.stages[i].stage + " " + cfg.stages[i].title;
if (Application.isPlaying && BattleManager.Instance != null && GUILayout.Button("지금 전투 스테이지로"))
{
var st = BattleManager.Instance.Stage;
for (int i = 0; i < cfg.stages.Length; i++)
if (cfg.stages[i].chapter == st.chapter && cfg.stages[i].stage == st.stage) stageIdx = i;
}
stageIdx = EditorGUILayout.Popup("스테이지", Mathf.Clamp(stageIdx, 0, stageNames.Length - 1), stageNames);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("불러오기")) LoadRows();
GUI.enabled = loadedStage == CurrentStage && loadedStage.Length > 0;
if (GUILayout.Button("저장 + 임포트")) SaveRows();
GUI.enabled = true;
EditorGUILayout.EndHorizontal();
if (loadedStage != CurrentStage) EditorGUILayout.HelpBox("이 스테이지를 불러온 뒤 그리세요(불러온 = " + (loadedStage.Length > 0 ? loadedStage : "없음") + ").", MessageType.Info);
var ids = new string[db.defs.Count];
for (int i = 0; i < ids.Length; i++) ids[i] = db.defs[i].id + " · " + db.defs[i].nameKo;
gimmickIdx = EditorGUILayout.Popup("기믹", Mathf.Clamp(gimmickIdx, 0, ids.Length - 1), ids);
shape = (GimmickShapeType)EditorGUILayout.Popup("모양", shape == GimmickShapeType.Circle ? 0 : 1, new[] { "circle", "rect" }) == 0 ? GimmickShapeType.Circle : GimmickShapeType.Rect;
if (shape == GimmickShapeType.Rect) rot = EditorGUILayout.FloatField("회전(도)", rot);
param = EditorGUILayout.TextField("params", param);
painting = GUILayout.Toggle(painting, painting ? "그리기 중 — 씬 뷰에서 드래그 (다시 누르면 끔)" : "그리기", "Button");
scroll = EditorGUILayout.BeginScrollView(scroll);
for (int i = 0; i < rows.Count; i++)
{
var r = rows[i];
EditorGUILayout.BeginHorizontal();
if (GUILayout.Toggle(selected == i, "", GUILayout.Width(18))) selected = i;
EditorGUILayout.LabelField(r.gimmickId + " " + r.shape + " (" + GimmickTables.F(r.cx) + ", " + GimmickTables.F(r.cy) + ") " + GimmickTables.F(r.wOrR) + (r.shape == GimmickShapeType.Rect ? "×" + GimmickTables.F(r.h) : "") + (r.paramsRaw.Length > 0 ? " · " + r.paramsRaw : ""));
if (GUILayout.Button("삭제", GUILayout.Width(44)))
{
rows.RemoveAt(i);
selected = -1;
SceneView.RepaintAll();
break;
}
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.EndScrollView();
}
void LoadRows()
{
rows.Clear();
selected = -1;
var texts = GimmickImporter.ReadFiles();
var r = GimmickTables.Parse(texts);
string st = CurrentStage;
foreach (var p in r.rows)
{
if (p.stage == st) rows.Add(p);
}
loadedStage = st;
if (r.errors.Count > 0) Debug.LogWarning("[TKS] 기믹 배치 표 경고 " + r.errors.Count + "건(첫 줄: " + r.errors[0] + ")");
SceneView.RepaintAll();
}
void SaveRows()
{
string path = Path.Combine(Application.dataPath, GimmickTables.Folder, GimmickTables.Files[1]);
var lines = File.Exists(path) ? File.ReadAllLines(path, Encoding.UTF8) : new string[0];
var outLines = new List<string> { Header };
bool emitted = false;
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i].TrimStart('');
if (line.Trim().Length == 0) continue;
string first = line.Split(',')[0].Trim();
if (first == loadedStage)
{
if (!emitted) foreach (var r in rows) outLines.Add(Format(r));
emitted = true;
continue;
}
outLines.Add(line);
}
if (!emitted) foreach (var r in rows) outLines.Add(Format(r));
File.WriteAllText(path, string.Join("\n", outLines.ToArray()) + "\n", new UTF8Encoding(true));
AssetDatabase.Refresh();
Debug.Log("[TKS] 기믹 배치 저장 · " + loadedStage + " " + rows.Count + "행 · " + path);
if (GimmickImporter.ImportAll() && Application.isPlaying && BattleManager.Instance != null && GimmickLayer.Current != null) GimmickLayer.Current.Build(BattleManager.Instance.Stage);
}
static string Format(GimmickPlacement r)
{
string shp = r.shape == GimmickShapeType.Circle ? "circle" : r.shape == GimmickShapeType.Rect ? "rect" : "poly";
return string.Join(",", new[]
{
r.stage, r.gimmickId, shp,
r.shape == GimmickShapeType.Poly ? "" : GimmickTables.F(r.cx),
r.shape == GimmickShapeType.Poly ? "" : GimmickTables.F(r.cy),
r.shape == GimmickShapeType.Poly ? "" : GimmickTables.F(r.wOrR),
r.shape == GimmickShapeType.Rect ? GimmickTables.F(r.h) : "",
GimmickTables.F(r.rot),
r.shape == GimmickShapeType.Poly ? GimmickTables.FormatPoints(r.points) : "",
r.hpOverride > 0 ? r.hpOverride.ToString() : "",
r.paramsRaw ?? ""
});
}
static Vector2 MouseWorld(Event e)
{
var ray = HandleUtility.GUIPointToWorldRay(e.mousePosition);
if (Mathf.Abs(ray.direction.z) < 1e-5f) return ray.origin;
float t = -ray.origin.z / ray.direction.z;
var p = ray.origin + ray.direction * t;
return new Vector2(Mathf.Round(p.x * 10f) / 10f, Mathf.Round(p.y * 10f) / 10f);
}
void OnScene(SceneView view)
{
var cfg = Cfg;
if (cfg != null)
{
Vector2 b = cfg.boardSize;
Handles.color = new Color(1f, 0.85f, 0.4f, 0.8f);
Handles.DrawWireCube(Vector3.zero, new Vector3(b.x, b.y, 0f));
}
var db = GimmickTables.Db;
for (int i = 0; i < rows.Count; i++) DrawRow(rows[i], db, i == selected);
if (!painting || db == null || loadedStage != CurrentStage) return;
var e = Event.current;
int id = GUIUtility.GetControlID(FocusType.Passive);
if (e.type == EventType.Layout) HandleUtility.AddDefaultControl(id);
if (e.type == EventType.MouseDown && e.button == 0 && !e.alt)
{
dragging = true;
dragStart = dragNow = MouseWorld(e);
GUIUtility.hotControl = id;
e.Use();
}
else if (e.type == EventType.MouseDrag && dragging)
{
dragNow = MouseWorld(e);
e.Use();
view.Repaint();
}
else if (e.type == EventType.MouseUp && dragging)
{
dragging = false;
GUIUtility.hotControl = 0;
dragNow = MouseWorld(e);
AddRow(db);
e.Use();
Repaint();
}
if (dragging)
{
var tmp = MakeRow(db);
if (tmp != null) DrawRow(tmp, db, true);
}
}
GimmickPlacement MakeRow(GimmickDb db)
{
if (db == null || db.defs.Count == 0) return null;
var def = db.defs[Mathf.Clamp(gimmickIdx, 0, db.defs.Count - 1)];
var p = new GimmickPlacement { stage = loadedStage, gimmickId = def.id, shape = shape, rot = shape == GimmickShapeType.Rect ? rot : 0f, paramsRaw = param ?? "" };
var parts = loadedStage.Split('-');
int.TryParse(parts[0], out p.chapter);
if (parts.Length > 1) int.TryParse(parts[1], out p.stageNo);
if (shape == GimmickShapeType.Circle)
{
p.cx = dragStart.x;
p.cy = dragStart.y;
p.wOrR = Mathf.Max(0.2f, Mathf.Round(Vector2.Distance(dragStart, dragNow) * 10f) / 10f);
}
else
{
Vector2 c = (dragStart + dragNow) * 0.5f;
p.cx = Mathf.Round(c.x * 10f) / 10f;
p.cy = Mathf.Round(c.y * 10f) / 10f;
p.wOrR = Mathf.Max(0.2f, Mathf.Abs(dragNow.x - dragStart.x));
p.h = Mathf.Max(0.2f, Mathf.Abs(dragNow.y - dragStart.y));
}
return p;
}
void AddRow(GimmickDb db)
{
var p = MakeRow(db);
if (p == null) return;
rows.Add(p);
selected = rows.Count - 1;
}
static void DrawRow(GimmickPlacement r, GimmickDb db, bool sel)
{
var def = db != null ? db.Get(r.gimmickId) : null;
Color c = def != null ? GimmickView.ColorOf(def) : Color.magenta;
c.a = Mathf.Max(c.a, 0.35f) * (sel ? 1f : 0.7f);
Handles.color = c;
var s = GimmickShape.From(r);
switch (s.Type)
{
case GimmickShapeType.Circle:
Handles.DrawSolidDisc(new Vector3(s.Center.x, s.Center.y, 0f), Vector3.forward, s.R);
break;
case GimmickShapeType.Rect:
{
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;
var v = new Vector3[] { s.Center - hx - hy, s.Center + hx - hy, s.Center + hx + hy, s.Center - hx + hy };
Handles.DrawSolidRectangleWithOutline(v, c, sel ? Color.yellow : Color.black);
break;
}
default:
if (s.Points != null && s.Points.Length >= 3)
{
var v = new Vector3[s.Points.Length];
for (int i = 0; i < v.Length; i++) v[i] = s.Points[i];
Handles.DrawAAConvexPolygon(v);
}
break;
}
Handles.Label(new Vector3(s.Center.x, s.Center.y, 0f), def != null ? def.nameKo : r.gimmickId);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3eb049b6ace5cb34c919b6b4ced9b347

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 38487f2bf2e40824a9e921ca527d179e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
namespace TKS
{
/// <summary>
/// 적 AI 점수 가중치(PD #1098 · 전술규칙_적AI_기획서 §2-3 · SOT = Assets/Data/AI/ai_weights.csv = 기획 tables/ 사본) →
/// Resources/AI/AiWeights(메뉴 TKS/Import Gimmicks 가 함께 임포트). 코드는 키 이름만 안다(C45).
/// </summary>
public class AiWeights : ScriptableObject
{
public const string CsvPath = "Data/AI/ai_weights.csv"; // Assets/ 아래
public const string ResourcePath = "AI/AiWeights";
/// <summary>코드가 읽는 키(표에 없으면 임포트 오류).</summary>
public static readonly string[] Keys =
{
"dmg_player", "kill_base", "kill_hp_ratio", "dmg_own", "gimmick_dest_bonus", "chain", "support_reach", "pierce_hit", "cav_return", "cav_stranded",
"archer_cover", "high_ground", "cav_line", "splash_hit", "route_open", "zone_end", "banner", "skill_waste", "approach", "boss_accuracy", "mode_accuracy",
"noise_power", "top_n", "dirs", "dirs_many", "many_units", "power_1", "power_2", "power_3", "budget_ms", "boss_active", "active_max_chapter"
};
[Serializable]
public class Entry
{
public string key;
public float value;
public string desc;
}
public List<Entry> entries = new List<Entry>();
Dictionary<string, float> index;
public float this[string key]
{
get
{
if (index == null)
{
index = new Dictionary<string, float>();
foreach (var e in entries) index[e.key] = e.value;
}
float v;
if (index.TryGetValue(key, out v)) return v;
Debug.LogError("[TKS] ai_weights.csv 에 키 없음: " + key);
return 0f;
}
}
static AiWeights cached;
public static AiWeights Load()
{
if (cached == null) cached = Resources.Load<AiWeights>(ResourcePath);
return cached;
}
public static void Invalidate()
{
cached = null;
}
/// <summary>CSV(key,value,desc_ko) 해석 · 키 누락·숫자 아님 = errors.</summary>
public static List<Entry> Parse(string text, List<string> errors)
{
var list = new List<Entry>();
var seen = new HashSet<string>();
foreach (var r in StageTable.ReadCsv(text, "ai_weights.csv", errors))
{
string k, v, d;
r.TryGetValue("key", out k);
r.TryGetValue("value", out v);
r.TryGetValue("desc_ko", out d);
float f;
if (string.IsNullOrEmpty(k) || !float.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out f))
{
errors.Add("ai_weights.csv 줄 " + r["#"] + ": key·value 형식(「" + k + "」 · 「" + v + "」)");
continue;
}
if (!seen.Add(k)) errors.Add("ai_weights.csv 줄 " + r["#"] + ": key 중복 「" + k + "」");
list.Add(new Entry { key = k, value = f, desc = d ?? "" });
}
foreach (var k in Keys)
{
if (!seen.Contains(k)) errors.Add("ai_weights.csv: 키 없음 「" + k + "」");
}
return list;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 659ab9685f0a38143a4e480430ce70a3

View File

@ -0,0 +1,142 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>
/// 가상 실행(dry-run · PD #1098 적 AI 예측 시뮬레이션): Active 동안 ActionResolver 가 연출 없이 한 프레임 안에 끝난다 —
/// Fx 이동·대기·투사체 = 즉시 · 팝업·광역 터짐 = 없음 · 유닛 표시(UnitView) 갱신 없음 · 피해 난수 = 분산 0 · 전투 이벤트 카운터 없음.
/// 상태는 Snapshot 으로 떠 두고 Restore 로 되돌린다(유닛 · 스킬 상태 · 기믹 레이어).
/// </summary>
public static class Sim
{
public static bool Active;
/// <summary>코루틴을 이 프레임 안에서 끝까지 돌린다(중첩 IEnumerator 포함 · null/기타 yield = 즉시 진행).</summary>
public static void Run(IEnumerator routine)
{
if (routine == null) return;
var stack = new Stack<IEnumerator>();
stack.Push(routine);
int guard = 0;
while (stack.Count > 0)
{
if (++guard > 200000)
{
Debug.LogError("[TKS] 가상 실행 루프 상한 초과 — 중단");
return;
}
var top = stack.Peek();
if (!top.MoveNext())
{
stack.Pop();
continue;
}
var cur = top.Current as IEnumerator;
if (cur != null) stack.Push(cur);
}
}
public class UnitSnap
{
public Unit U;
public Vector2 Pos;
public int Hp, MaxHp, Atk, Def;
public bool Acted, Bonus, Overwatch, Collider, Active;
}
public class SkillSnap
{
public UnitSkills S;
public int ReadyTurn, BonusMovesLeft, PierceHits;
public bool Reserved, Armed, BonusAttack, OwUnlimited;
public float OwMult, OwAdd;
}
public class Snapshot
{
public readonly List<UnitSnap> Units = new List<UnitSnap>();
public readonly List<SkillSnap> Skills = new List<SkillSnap>();
public List<SkillSystem.Timed> Timed;
public int LogCount;
public GimmickLayer.Snapshot Gimmicks;
public string Message;
}
public static Snapshot Take(BattleManager bm)
{
var s = new Snapshot { Message = bm.LastMessage };
foreach (var u in bm.Units)
{
if (u == null) continue;
s.Units.Add(new UnitSnap
{
U = u, Pos = u.Position, Hp = u.Hp, MaxHp = u.MaxHp, Atk = u.Atk, Def = u.Def, Acted = u.ActedThisTurn, Bonus = u.BonusMoveAvailable,
Overwatch = u.Overwatch, Collider = u.Collider != null && u.Collider.enabled, Active = u.gameObject.activeSelf
});
}
var sys = bm.Skills;
if (sys != null)
{
foreach (var e in sys.Entries)
{
s.Skills.Add(new SkillSnap
{
S = e, ReadyTurn = e.ReadyTurn, BonusMovesLeft = e.BonusMovesLeft, PierceHits = e.PierceHits, Reserved = e.Reserved, Armed = e.Armed,
BonusAttack = e.BonusAttack, OwUnlimited = e.OwUnlimited, OwMult = e.OwMult, OwAdd = e.OwAdd
});
}
s.Timed = new List<SkillSystem.Timed>(sys.TimedEffects);
s.LogCount = sys.Log.Count;
}
if (GimmickLayer.Current != null) s.Gimmicks = GimmickLayer.Current.Take();
return s;
}
public static void Restore(BattleManager bm, Snapshot s)
{
foreach (var x in s.Units)
{
var u = x.U;
if (u == null) continue;
u.Position = x.Pos;
u.Hp = x.Hp;
u.MaxHp = x.MaxHp;
u.Atk = x.Atk;
u.Def = x.Def;
u.ActedThisTurn = x.Acted;
u.BonusMoveAvailable = x.Bonus;
u.Overwatch = x.Overwatch;
if (u.Collider != null) u.Collider.enabled = x.Collider;
if (u.gameObject.activeSelf != x.Active) u.gameObject.SetActive(x.Active);
}
var sys = bm.Skills;
if (sys != null)
{
foreach (var k in s.Skills)
{
var e = k.S;
e.ReadyTurn = k.ReadyTurn;
e.BonusMovesLeft = k.BonusMovesLeft;
e.PierceHits = k.PierceHits;
e.Reserved = k.Reserved;
e.Armed = k.Armed;
e.BonusAttack = k.BonusAttack;
e.OwUnlimited = k.OwUnlimited;
e.OwMult = k.OwMult;
e.OwAdd = k.OwAdd;
}
if (s.Timed != null)
{
sys.TimedEffects.Clear();
sys.TimedEffects.AddRange(s.Timed);
}
if (sys.Log.Count > s.LogCount) sys.Log.RemoveRange(s.LogCount, sys.Log.Count - s.LogCount);
}
if (GimmickLayer.Current != null && s.Gimmicks != null) GimmickLayer.Current.Restore(s.Gimmicks);
bm.LastMessage = s.Message;
Physics2D.SyncTransforms();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 056fcbc68cba91c498bffad3000a8607

View File

@ -0,0 +1,477 @@
using System.Collections.Generic;
using System.Diagnostics;
using UnityEngine;
namespace TKS
{
/// <summary>적 유닛 1기의 행동 후보 1건(가상 실행 결과 점수 포함).</summary>
public class AiPlan
{
public Unit Unit;
public Vector2 Target;
public bool UseSkill;
public float Score;
/// <summary>0 = 서포터(창병 밀기 · 책사 덤불 제거 · 이득 있는 버프) · 1 = 돌격(보·기·창) · 2 = 사격(궁·책).</summary>
public int Group;
public string Why = "";
public int Candidates;
public float Ms;
}
/// <summary>
/// 적 AI 예측 시뮬레이션(PD 2026-11-06 #1098 · 전술규칙_적AI_기획서 §2): 후보 행동을 실제 규칙(ActionResolver · 기믹 · 스킬 · 견제)으로 가상 실행(Sim)해
/// ai_weights.csv 점수가 가장 높은 것을 고른다. 후보 = 방위(유닛 ≤ many_units = dirs · 넘으면 dirs_many) × 파워 3단 × 액티브 사용 여부 ·
/// 궁·책 = 사거리 안 표적 + (책사) 덤불 중심 + 방위 이동 · 유닛 1기 예산 budget_ms · 난이도 = aiAccuracy(보스 +boss_accuracy · 투기장 mode_accuracy)로
/// 최고 선택 · 아니면 상위 top_n 무작위 + 파워 ±noise_power · 턴 운영 = 서포터 → 돌격 → 사격 순, 1기 실행마다 재평가(BattleManager).
/// </summary>
public static class TacticalAI
{
static readonly List<float> lastEvalMs = new List<float>();
/// <summary>최근 평가 시간(ms · 성능 테스트용).</summary>
public static IReadOnlyList<float> LastEvalMs
{
get { return lastEvalMs; }
}
public static bool Available
{
get { return AiWeights.Load() != null; }
}
/// <summary>이 적 보스가 액티브를 쓰는가(1부 보스만 · 다음 행동형만 · ai_weights boss_active · active_max_chapter).</summary>
public static bool BossUsesActive(BalanceConfig.StageDef stage, BalanceConfig.EnemySpawn e)
{
var w = AiWeights.Load();
if (w == null || stage == null || e == null || !e.isBoss) return false;
return w["boss_active"] >= 0.5f && stage.chapter >= 1 && stage.chapter <= Mathf.RoundToInt(w["active_max_chapter"]);
}
/// <summary>
/// 다음에 움직일 적 1기와 그 행동: 남은 유닛 전원 평가 → 그룹(서포터 → 돌격 → 사격) 순 · 같은 그룹은 점수 높은 순 → 그 유닛의 후보에서 난이도대로 고른다.
/// 교전 범위 밖(가장 가까운 아군이 aggro 보다 멂)은 대기 → null 이면 이번 턴 끝.
/// </summary>
public static AiPlan Next(BattleManager bm, List<Unit> pending, float accuracy, float aggro, System.Random rng)
{
lastEvalMs.Clear();
var w = AiWeights.Load();
if (w == null) return null;
var players = bm.AliveUnits(Faction.Player);
if (players.Count == 0) return null;
AiPlan best = null;
List<AiPlan> bestList = null;
int enemyCount = bm.AliveCount(Faction.Enemy);
foreach (var u in pending)
{
if (u == null || !u.IsAlive) continue;
if (NearestDist(u, players) > aggro) continue;
List<AiPlan> list;
var top = Evaluate(bm, u, enemyCount, out list);
if (top == null) continue;
if (best == null || top.Group < best.Group || (top.Group == best.Group && top.Score > best.Score))
{
best = top;
bestList = list;
}
}
if (best == null) return null;
return Choose(bm, best.Unit, bestList, accuracy, rng, w);
}
static float NearestDist(Unit u, List<Unit> players)
{
float best = float.MaxValue;
foreach (var p in players)
{
if (p.IsAlive) best = Mathf.Min(best, Vector2.Distance(u.Position, p.Position));
}
return best;
}
/// <summary>난이도: 최고 점수를 accuracy(+보스 · 투기장 = mode) 확률로 · 아니면 상위 top_n 중 무작위 + 파워 ±noise.</summary>
static AiPlan Choose(BattleManager bm, Unit u, List<AiPlan> list, float accuracy, System.Random rng, AiWeights w)
{
float acc = accuracy + (u.IsBoss ? w["boss_accuracy"] : 0f);
if (bm.Mode != null && bm.Mode.kind == OutGame.ArenaRules.Arena) acc = w["mode_accuracy"];
if (list.Count == 1 || rng.NextDouble() < acc) return list[0];
int n = Mathf.Clamp(Mathf.RoundToInt(w["top_n"]), 1, list.Count);
var pick = list[rng.Next(n)];
float noise = w["noise_power"];
Vector2 v = pick.Target - u.Position;
float k = 1f + ((float)rng.NextDouble() * 2f - 1f) * noise;
var copy = new AiPlan { Unit = pick.Unit, UseSkill = pick.UseSkill, Score = pick.Score, Group = pick.Group, Why = pick.Why + " · 오차", Candidates = pick.Candidates, Ms = pick.Ms };
float max = SkillMods.SlingMax(u);
v *= k;
if (v.magnitude > max) v = v.normalized * max;
copy.Target = bm.Board.ClampInside(u.Position + v, u.Radius);
return copy;
}
/// <summary>유닛 1기 평가: 후보마다 가상 실행 → 점수 내림차순 목록(list[0] = 최고).</summary>
public static AiPlan Evaluate(BattleManager bm, Unit u, int enemyCount, out List<AiPlan> list)
{
list = new List<AiPlan>();
var w = AiWeights.Load();
if (w == null || u == null || !u.IsAlive) return null;
var sw = Stopwatch.StartNew();
float budget = Mathf.Max(5f, w["budget_ms"]);
var cands = Candidates(bm, u, enemyCount, w);
var skills = bm.Skills != null ? bm.Skills.Of(u) : null;
bool canSkill = skills != null && skills.HasActive && skills.Active.Trigger == SkillTrigger.NextAction && skills.CooldownLeft(bm.TurnNumber) <= 0;
var resolver = new ActionResolver(bm.Config, bm, new System.Random(12345));
var snap = Sim.Take(bm);
int done = 0;
foreach (var t in cands)
{
for (int s = 0; s < (canSkill ? 2 : 1); s++)
{
if (sw.Elapsed.TotalMilliseconds > budget && list.Count > 0) break;
var plan = Simulate(bm, resolver, u, t, s == 1, snap, w);
done++;
if (plan != null) list.Add(plan);
}
}
list.Sort((a, b) => b.Score.CompareTo(a.Score));
float ms = (float)sw.Elapsed.TotalMilliseconds;
lastEvalMs.Add(ms);
if (list.Count == 0) return null;
list[0].Candidates = done;
list[0].Ms = ms;
return list[0];
}
/// <summary>후보 목표점: 방위 × 파워 3단 · 궁·책은 사거리(+접근) 안 표적 · 책사는 덤불 중심까지.</summary>
static List<Vector2> Candidates(BattleManager bm, Unit u, int enemyCount, AiWeights w)
{
var res = new List<Vector2>();
float sling = SkillMods.SlingMax(u);
int dirs = Mathf.Max(4, Mathf.RoundToInt(enemyCount > w["many_units"] ? w["dirs_many"] : w["dirs"]));
float[] powers = { w["power_1"], w["power_2"], w["power_3"] };
if (UnitTypeInfo.IsRanged(u.Type))
{
float reach = SkillMods.Range(u) + u.ArcherMoveAllow;
foreach (var p in bm.AliveUnits(Faction.Player))
{
float gap = Vector2.Distance(u.Position, p.Position) - u.Radius * Combat.CastRadiusScale - p.Radius;
if (gap <= reach + 0.5f) res.Add(p.Position); // 표적 = 그 유닛 중심(목표점 명중 크리)
}
var layer = GimmickLayer.Current;
if (u.Type == UnitType.Strategist && layer != null)
{
foreach (var g in layer.Items)
{
if (!g.BlocksMove || !g.Def.strategistClear) continue;
if (Vector2.Distance(u.Position, g.Shape.Center) - g.Shape.BoundingRadius <= reach) res.Add(g.Shape.Center);
}
}
dirs = Mathf.Max(4, dirs / 2); // 이동 후보(엄폐·고지 자리 잡기)
}
for (int i = 0; i < dirs; i++)
{
float a = i * Mathf.PI * 2f / dirs;
Vector2 d = new Vector2(Mathf.Cos(a), Mathf.Sin(a));
foreach (var k in powers) res.Add(bm.Board.ClampInside(u.Position + d * sling * k, u.Radius));
}
return res;
}
static AiPlan Simulate(BattleManager bm, ActionResolver resolver, Unit u, Vector2 target, bool useSkill, Sim.Snapshot snap, AiWeights w)
{
var o = new ActionOutcome();
float score;
string why;
Sim.Active = true;
try
{
if (useSkill) bm.Skills.Of(u).Reserved = true;
var before = Before(bm, snap);
Sim.Run(resolver.Resolve(u, target, false, o));
Sim.Run(bm.ApplyOverwatch(u, o));
score = Score(bm, u, o, useSkill, before, snap, w, out why);
}
finally
{
Sim.Active = false;
Sim.Restore(bm, snap);
}
int group = UnitTypeInfo.IsRanged(u.Type) ? 2 : 1;
if (why.Contains("서포트") || why.Contains("덤불")) group = 0;
return new AiPlan { Unit = u, Target = target, UseSkill = useSkill, Score = score, Group = group, Why = why };
}
class Pre
{
public readonly Dictionary<Unit, Sim.UnitSnap> Units = new Dictionary<Unit, Sim.UnitSnap>();
public readonly HashSet<GimmickInstance> Removed = new HashSet<GimmickInstance>();
}
static Pre Before(BattleManager bm, Sim.Snapshot snap)
{
var p = new Pre();
foreach (var x in snap.Units) p.Units[x.U] = x;
var layer = GimmickLayer.Current;
if (layer != null)
{
foreach (var g in layer.Items)
{
if (g.Removed || g.Broken) p.Removed.Add(g);
}
}
return p;
}
/// <summary>점수(기획서 §2-3) — 가상 실행 직후 상태 vs 실행 전.</summary>
static float Score(BattleManager bm, Unit u, ActionOutcome o, bool useSkill, Pre pre, Sim.Snapshot snap, AiWeights w, out string why)
{
var parts = new List<string>();
float s = 0f;
float playerDmg = 0f;
int kills = 0;
foreach (var kv in pre.Units)
{
var x = kv.Value;
var v = x.U;
if (v == null || x.Hp <= 0) continue;
int dmg = Mathf.Max(0, x.Hp - v.Hp);
if (v.Faction == Faction.Player)
{
playerDmg += dmg;
s += w["dmg_player"] * dmg;
if (v.Hp <= 0)
{
kills++;
s += w["kill_base"] + w["kill_hp_ratio"] * (x.MaxHp > 0 ? (float)x.Hp / x.MaxHp : 0f);
}
}
else s += w["dmg_own"] * dmg;
}
if (playerDmg > 0f) parts.Add("피해 " + playerDmg);
if (kills > 0) parts.Add("처치 " + kills);
var layer = GimmickLayer.Current;
if (layer != null)
{
foreach (var a in layer.Affected)
{
if (a.Unit == null) continue;
float val = w["gimmick_dest_bonus"] + a.Value;
s += a.Unit.Faction == Faction.Player ? val : -val;
if (a.Unit.Faction == Faction.Player) parts.Add("기믹 " + a.Kind);
}
// 플레이어 유닛을 폭약 수레 반경으로 밀어 넣음
foreach (var d in o.Displaced)
{
if (d.Unit == null || d.Unit.Faction != Faction.Player || !d.Unit.IsAlive) continue;
foreach (var g in layer.Items)
{
if (!g.BlocksMove || g.Def.projectile != GimmickProjectile.Destroy) continue;
float r = g.P(1, 0f);
if (Vector2.Distance(d.Unit.Position, g.Shape.Center) <= r && Vector2.Distance(d.From, g.Shape.Center) > r)
{
s += w["gimmick_dest_bonus"];
parts.Add("수레 반경");
}
}
}
}
if (o.Chains > 0)
{
s += w["chain"] * o.Chains;
parts.Add("연쇄 " + o.Chains);
}
if (u.Type == UnitType.Spear)
{
var seen = new HashSet<Unit>();
int reach = 0;
foreach (var d in o.Displaced)
{
var a = d.Unit;
if (a == null || a == u || a.Faction != u.Faction || !a.IsAlive || !seen.Add(a)) continue;
if (Vector2.Distance(d.From, d.To) < 0.1f) continue;
foreach (var p in bm.AliveUnits(Faction.Player))
{
float before = Vector2.Distance(d.From, p.Position) - a.Radius - p.Radius;
float after = Vector2.Distance(a.Position, p.Position) - a.Radius - p.Radius;
if (after <= SkillMods.SlingMax(a) && after < before) reach++;
}
}
if (reach > 0)
{
s += w["support_reach"] * reach;
parts.Add("서포트 " + reach);
}
}
if (u.Type == UnitType.Cavalry)
{
var hit = new HashSet<Unit>();
foreach (var a in o.Attacked)
{
if (a != null && a.Faction == Faction.Player) hit.Add(a);
}
if (hit.Count > 0)
{
s += w["pierce_hit"] * hit.Count + (o.Returned ? w["cav_return"] : w["cav_stranded"]);
parts.Add("관통 " + hit.Count + (o.Returned ? " · 복귀" : " · 잔류"));
}
}
if (u.IsAlive && UnitTypeInfo.IsRanged(u.Type))
{
if (layer != null && Covered(u, bm, layer))
{
s += w["archer_cover"];
parts.Add("목책 엄폐");
}
if (layer != null && layer.ZoneAt(u.Position, GimmickZone.High) != null)
{
s += w["high_ground"];
parts.Add("고지");
}
if (OnCavalryLine(u, bm, layer))
{
s += w["cav_line"];
parts.Add("기병 관통선");
}
}
if (u.Type == UnitType.Strategist)
{
var hit = new HashSet<Unit>();
foreach (var a in o.Attacked)
{
if (a != null && a.Faction == Faction.Player) hit.Add(a);
}
if (hit.Count > 0)
{
s += w["splash_hit"] * hit.Count;
parts.Add("광역 " + hit.Count);
}
if (layer != null)
{
int routes = OpenedRoutes(bm, u, layer, pre);
if (routes > 0)
{
s += w["route_open"] * routes;
parts.Add("덤불 제거 " + routes);
}
}
}
if (u.IsAlive && layer != null)
{
var burn = layer.ZoneAt(u.Position, GimmickZone.Burn);
if (burn != null) s += w["zone_end"] * burn.P(1, 0f) * u.MaxHp;
foreach (var g in layer.Items)
{
if (g.Zone != GimmickZone.Capture || !g.ZoneActive) continue;
float r = g.P(1, 0f);
if (Vector2.Distance(u.Position, g.Shape.Center) - u.Radius > r) continue;
bool foe = false;
foreach (var p in bm.AliveUnits(Faction.Player))
{
if (Vector2.Distance(p.Position, g.Shape.Center) - p.Radius <= r) foe = true;
}
if (!foe && g.Owner != (int)u.Faction)
{
s += w["banner"];
parts.Add("군기");
}
}
}
if (useSkill && playerDmg <= 0f) s += w["skill_waste"];
if (useSkill) parts.Add("액티브");
if (playerDmg <= 0f && kills == 0 && u.IsAlive)
{
var start = pre.Units[u].Pos;
float before = float.MaxValue, after = float.MaxValue;
foreach (var p in bm.AliveUnits(Faction.Player))
{
before = Mathf.Min(before, Vector2.Distance(start, p.Position));
after = Mathf.Min(after, Vector2.Distance(u.Position, p.Position));
}
if (before < float.MaxValue)
{
float gain = before - after;
if (UnitTypeInfo.IsRanged(u.Type)) gain = Mathf.Min(gain, Mathf.Max(0f, before - SkillMods.Range(u))); // 원거리 = 사거리까지만 다가간다
s += w["approach"] * gain;
}
}
why = string.Join(" · ", parts.ToArray());
return s;
}
/// <summary>궁·책 종료 위치와 플레이어 근접 유닛 사이 직선에 목책(투사체 통과 · 이동 막힘)이 있는가.</summary>
static bool Covered(Unit u, BattleManager bm, GimmickLayer layer)
{
foreach (var p in bm.AliveUnits(Faction.Player))
{
if (!UnitTypeInfo.IsMelee(p.Type)) continue;
if (Vector2.Distance(p.Position, u.Position) > SkillMods.SlingMax(p) + 2f) continue;
foreach (var g in layer.Items)
{
if (!g.BlocksMove || g.Def.projectile != GimmickProjectile.Pass || g.Zone == GimmickZone.River) continue;
if (g.Shape.SegmentTouches(u.Position, p.Position)) return true;
}
}
return false;
}
/// <summary>플레이어 기병의 관통 사정(슬링 + 반지름) 안이고 사이에 장애물이 없으면 관통선 위.</summary>
static bool OnCavalryLine(Unit u, BattleManager bm, GimmickLayer layer)
{
foreach (var c in bm.AliveUnits(Faction.Player))
{
if (c.Type != UnitType.Cavalry) continue;
if (Vector2.Distance(c.Position, u.Position) > SkillMods.SlingMax(c) + u.Radius) continue;
bool blocked = false;
if (layer != null)
{
foreach (var g in layer.Items)
{
if (g.BlocksMove && g.Shape.SegmentTouches(c.Position, u.Position)) blocked = true;
}
}
if (!blocked) return true;
}
return false;
}
/// <summary>이번 가상 실행으로 지워진 덤불 때문에 새로 열린 (적 측 유닛 → 플레이어 유닛) 직선 수(사거리·슬링 안 · 다른 장애물 없음).</summary>
static int OpenedRoutes(BattleManager bm, Unit shooter, GimmickLayer layer, Pre pre)
{
var cleared = new List<GimmickInstance>();
foreach (var g in layer.Items)
{
if (g.Removed && !pre.Removed.Contains(g) && g.Def.strategistClear) cleared.Add(g);
}
if (cleared.Count == 0) return 0;
int n = 0;
foreach (var a in bm.AliveUnits(shooter.Faction))
{
if (a == shooter) continue;
float reach = UnitTypeInfo.IsRanged(a.Type) ? SkillMods.Range(a) + a.ArcherMoveAllow : SkillMods.SlingMax(a);
foreach (var p in bm.AliveUnits(Faction.Player))
{
if (Vector2.Distance(a.Position, p.Position) - a.Radius - p.Radius > reach) continue;
bool wasBlocked = false;
foreach (var g in cleared)
{
if (g.Shape.SegmentTouches(a.Position, p.Position, a.Radius * 0.8f)) wasBlocked = true;
}
if (!wasBlocked) continue;
bool stillBlocked = false;
foreach (var g in layer.Items)
{
if (g.BlocksMove && g.Shape.SegmentTouches(a.Position, p.Position, a.Radius * 0.8f)) stillBlocked = true;
}
if (!stillBlocked) n++;
}
}
return n;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 66c2037730580514da6d7f1172bbbc71

View File

@ -33,8 +33,10 @@ namespace TKS
/// <summary>추가 이동 횟수(기본 1 · 인덕 extra_move_count) · 추가 이동에서 공격 가능(extra_move_attack).</summary> /// <summary>추가 이동 횟수(기본 1 · 인덕 extra_move_count) · 추가 이동에서 공격 가능(extra_move_attack).</summary>
public int BonusMoves = 1; public int BonusMoves = 1;
public bool BonusAttack; public bool BonusAttack;
/// <summary>이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정).</summary> /// <summary>이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정 · 기믹 지대 스윕).</summary>
public List<Displacement> Displaced = new List<Displacement>(); public List<Displacement> Displaced = new List<Displacement>();
/// <summary>보병 연쇄 교환 수(튕겨나는 도중 다른 적과 부딪힘 · PD 2026-11-06 #1098).</summary>
public int Chains;
} }
public struct Displacement public struct Displacement
@ -46,17 +48,20 @@ namespace TKS
/// <summary> /// <summary>
/// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (PD 2026-11-04 #1080 충돌 규칙). /// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (PD 2026-11-04 #1080 충돌 규칙).
/// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 멈춤 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회 /// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 반대로 튕김 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회 ·
/// 적과 싸운 뒤 튕겨나는 도중 다른 적과 부딪히면 그 적과도 피해·반격 교환 + 다시 튕김(연쇄 1회 · 아군이면 밀기만 · PD 2026-11-06 #1098)
/// ② 기병: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 · 목표점 명중 시 제자리 복귀 /// ② 기병: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 · 목표점 명중 시 제자리 복귀
/// ③ 창병: 첫 유닛에 부딪히면 튕기지 않고 남은 거리만큼 밀며 함께 전진 · 적이면 피해(반격 받음) · 아군이면 밀기만 · 목표점 명중 시 대기(견제) /// ③ 창병: 첫 유닛에 부딪히면 그 유닛을 남은 거리만큼 밀치고 자신은 그 × spearRecoilRatio 만큼 반대로 밀림(반작용 · 함께 전진 폐기 · PD #1098) ·
/// 적이면 피해(반격 받음) · 아군이면 밀기만 · 목표점 명중 시 대기(견제)
/// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ ×(1+비율) 안이면 들어올 만큼만 다가가 사격 · /// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ ×(1+비율) 안이면 들어올 만큼만 다가가 사격 ·
/// 더 멀면 목표점까지 이동(쏘지 않음) · 사격 = 타겟 방향 화살 투사체 → 경로 위 첫 유닛(아군 포함)이 맞음 · 관통 없음 · 반격 없음 · /// 더 멀면 목표점까지 이동(쏘지 않음) · 사격 = 타겟 방향 화살 투사체 → 경로 위 첫 유닛(아군 포함)이 맞음 · 관통 없음 · 반격 없음 ·
/// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage) /// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage)
/// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤) /// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤)
/// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음) /// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음)
/// 스킬(PD #1093 B): 수치는 SkillMods 질의(밀기·튕김·관통·사거리·사선·광역·피해) · 예약 액티브는 Resolve 앞뒤에서 장착·소모(SkillSystem). /// 스킬(PD #1093 B): 수치는 SkillMods 질의(밀기·튕김·관통·사거리·사선·광역·피해) · 예약 액티브는 Resolve 앞뒤에서 장착·소모(SkillSystem).
/// 지형 기믹(PD #1097): 충돌·투사체·미끄러짐·지대 = Gimmicks/ActionResolver.Gimmicks.cs · 가상 실행(적 AI) = Sim.Active 동안 연출 없이 즉시.
/// </summary> /// </summary>
public class ActionResolver public partial class ActionResolver
{ {
readonly BalanceConfig cfg; readonly BalanceConfig cfg;
readonly BattleManager battle; readonly BattleManager battle;
@ -72,12 +77,14 @@ namespace TKS
public IEnumerator Resolve(Unit u, Vector2 target, bool moveOnly, ActionOutcome o) public IEnumerator Resolve(Unit u, Vector2 target, bool moveOnly, ActionOutcome o)
{ {
Vector2 origin = u.Position; Vector2 origin = u.Position;
target = ReachPoint(u, target); // 늪·고지 이동 비용 = 실제 도달점(근접 병종 · PD #1097)
Vector2 delta = target - origin; Vector2 delta = target - origin;
float dist = delta.magnitude; float dist = delta.magnitude;
o.Path.Add(origin); o.Path.Add(origin);
if (dist < 0.001f || !u.IsAlive) yield break; if (dist < 0.001f || !u.IsAlive) yield break;
Vector2 dir = delta / dist; Vector2 dir = delta / dist;
GimmickBegin(o);
var skills = SkillSystem.Current; var skills = SkillSystem.Current;
if (skills != null) skills.BeginAction(u, moveOnly, o); // 예약 액티브 장착 + 발동 연출(PD #1093 B) if (skills != null) skills.BeginAction(u, moveOnly, o); // 예약 액티브 장착 + 발동 연출(PD #1093 B)
// 슬링샷 돌진 중 = 병종 돌진 포즈 1프레임 고정 · 끝나면 대기 (PD 2026-11-05 #1084) // 슬링샷 돌진 중 = 병종 돌진 포즈 1프레임 고정 · 끝나면 대기 (PD 2026-11-05 #1084)
@ -98,6 +105,7 @@ namespace TKS
yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o); yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o);
break; break;
} }
yield return GimmickAfter(u, moveOnly, o, !(UnitTypeInfo.IsRanged(u.Type) && o.Fired)); // 빙판 · 지대 스윕 · 강(PD #1097)
if (u.View != null) u.View.SetCharging(false); if (u.View != null) u.View.SetCharging(false);
if (skills != null) skills.EndAction(u, o); // 예약 소모 · 쿨 · 견제 강화 이월 if (skills != null) skills.EndAction(u, o); // 예약 소모 · 쿨 · 견제 강화 이월
} }
@ -110,6 +118,7 @@ namespace TKS
var result = new List<Unit>(); var result = new List<Unit>();
if (moveOnly) return result; if (moveOnly) return result;
Vector2 origin = u.Position; Vector2 origin = u.Position;
target = ReachPoint(u, target);
Vector2 delta = target - origin; Vector2 delta = target - origin;
float dist = delta.magnitude; float dist = delta.magnitude;
if (dist < 0.001f) return result; if (dist < 0.001f) return result;
@ -117,6 +126,7 @@ namespace TKS
float hitDist; float hitDist;
var skills = SkillSystem.Current; var skills = SkillSystem.Current;
bool prev = skills != null && skills.PreviewBegin(u); bool prev = skills != null && skills.PreviewBegin(u);
GimmickLayer.PreviewMode = true; // 아군 조준 = 숨은 함정을 모른다(PD #1097)
try try
{ {
switch (u.Type) switch (u.Type)
@ -152,6 +162,7 @@ namespace TKS
} }
finally finally
{ {
GimmickLayer.PreviewMode = false;
if (skills != null) skills.PreviewEnd(u, prev); if (skills != null) skills.PreviewEnd(u, prev);
} }
return result; return result;
@ -166,12 +177,14 @@ namespace TKS
if (dist < 0.001f) return null; if (dist < 0.001f) return null;
var skills = SkillSystem.Current; var skills = SkillSystem.Current;
bool prev = skills != null && skills.PreviewBegin(u); bool prev = skills != null && skills.PreviewBegin(u);
GimmickLayer.PreviewMode = true;
try try
{ {
return PlanShot(u, u.Position, delta / dist, target); return PlanShot(u, u.Position, delta / dist, target);
} }
finally finally
{ {
GimmickLayer.PreviewMode = false;
if (skills != null) skills.PreviewEnd(u, prev); if (skills != null) skills.PreviewEnd(u, prev);
} }
} }
@ -204,6 +217,14 @@ namespace TKS
float hitDist; float hitDist;
Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist); Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
float gDist;
Vector2 gNormal;
var gim = BlockAhead(u, origin, dir, dist, first != null ? hitDist : dist, out gDist, out gNormal);
if (gim != null)
{
yield return HitGimmick(u, gim, origin, dir, dist, gDist, gNormal, o); // 장애물 기믹이 먼저(PD #1097)
yield break;
}
if (first == null) if (first == null)
{ {
yield return MoveFree(u, origin, dir, dist, o); yield return MoveFree(u, origin, dir, dist, o);
@ -230,7 +251,7 @@ namespace TKS
// 알까기: 부딪힌 순간 양쪽이 동시에 튕긴다 — 상대 = 접촉 방향 × 남은 힘 × collisionPushRatio · 자신 = 반대 방향 × 남은 힘 × collisionSelfBounceRatio // 알까기: 부딪힌 순간 양쪽이 동시에 튕긴다 — 상대 = 접촉 방향 × 남은 힘 × collisionPushRatio · 자신 = 반대 방향 × 남은 힘 × collisionSelfBounceRatio
Vector2 n = first.Position - contact; Vector2 n = first.Position - contact;
n = n.sqrMagnitude > 1e-6f ? n.normalized : dir; n = n.sqrMagnitude > 1e-6f ? n.normalized : dir;
yield return Bounce(u, first, n, remaining, o, enemy ? impact : 0); yield return Bounce(u, first, n, remaining, o, enemy ? impact : 0, enemy); // 적과 싸운 뒤 튕김 = 연쇄 1회(PD #1098)
if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o, u); if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o, u);
if (isFinal && u.IsAlive) if (isFinal && u.IsAlive)
@ -241,7 +262,7 @@ namespace TKS
} }
} }
// ---------- 창병: 밀며 함께 전진 ---------- // ---------- 창병: 밀치기 + 반작용(PD 2026-11-06 #1098 · 밀며 함께 전진 폐기) ----------
IEnumerator SpearRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) IEnumerator SpearRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{ {
@ -253,6 +274,14 @@ namespace TKS
float hitDist; float hitDist;
Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist); Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
float gDist;
Vector2 gNormal;
var gim = BlockAhead(u, origin, dir, dist, first != null ? hitDist : dist, out gDist, out gNormal);
if (gim != null)
{
yield return HitGimmick(u, gim, origin, dir, dist, gDist, gNormal, o); // 장애물 기믹이 먼저(PD #1097)
yield break;
}
if (first == null) if (first == null)
{ {
yield return MoveFree(u, origin, dir, dist, o); yield return MoveFree(u, origin, dir, dist, o);
@ -275,27 +304,29 @@ namespace TKS
o.MovedOnly = true; o.MovedOnly = true;
} }
// 밀며 전진 거리 = 남은 거리 × 철척사모(push_mult) × 대상 거구(knockback_taken_pct) // 밀치기 거리 = 남은 거리 × 철척사모(push_mult) × 대상 거구(knockback_taken_pct) · 상대만 밀리고 창병은 그 × spearRecoilRatio 만큼 반대로(반작용 · PD #1098)
float advance = remaining * SkillMods.PushMult(u, true) * SkillMods.KnockbackTakenMult(first, true); float push = remaining * SkillMods.PushMult(u, true) * SkillMods.KnockbackTakenMult(first, true);
if (first.IsAlive && advance > 0.01f) if (first.IsAlive && push > 0.01f)
{ {
// 튕기지 않고 앞 유닛을 밀며 함께 전진 · 앞 유닛이 다른 유닛·벽에 막히면 둘 다 멈춤 · 접촉 간격 유지 bool pushedDone = false;
float travel = Combat.FreeTravel(first, first.Position, dir, advance, true, true, u); Fx.Run(Track(Push(first, dir, push, u, o), () => pushedDone = true));
if (travel > 0.01f) float recoil = Reach(u, u.Position, -dir, push * cfg.spearRecoilRatio);
if (recoil > 0.01f && u.IsAlive)
{ {
Face(first, dir); // 밀린 유닛은 밀린 방향을 본다(PD #1082) // 반작용: 벽·유닛·장애물에 닿으면 정지 · 피해 없음 · 보는 방향은 유지(밀친 쪽을 본 채 물러남)
Vector2 firstStart = first.Position; float back = Combat.FreeTravel(u, u.Position, -dir, recoil, true, true, first);
Vector2 firstEnd = first.Position + dir * travel; NoteForced(u, -dir, recoil, back);
Vector2 spearEnd = contact + dir * travel; if (back > 0.01f)
Fx.Run(Fx.MoveAlong(first.transform, first.Position, firstEnd, cfg.moveSpeed, null)); {
yield return Fx.MoveAlong(u.transform, contact, spearEnd, cfg.moveSpeed, null); Vector2 start = u.Position;
first.Position = firstEnd; Vector2 end = battle.Board.ClampInside(start - dir * back, u.Radius);
u.Position = spearEnd; yield return Fx.MoveAlong(u.transform, start, end, cfg.moveSpeed, null);
o.Path.Add(spearEnd); u.Position = end;
o.Displaced.Add(new Displacement { Unit = first, From = firstStart, To = firstEnd }); o.Path.Add(end);
battle.NotifyChanged();
} }
o.Pushed.Add(first); }
while (!pushedDone) yield return null;
battle.NotifyChanged();
if (!enemy) yield return PushedAllyAttack(first, o, u); if (!enemy) yield return PushedAllyAttack(first, o, u);
} }
if (enemy && u.IsAlive) SpearHitMore(u, first, o); if (enemy && u.IsAlive) SpearHitMore(u, first, o);
@ -342,8 +373,11 @@ namespace TKS
/// <param name="first">false = 위진소요의 추가 관통(다시 반복·복귀하지 않음 · exclude = 앞서 관통한 적 제외).</param> /// <param name="first">false = 위진소요의 추가 관통(다시 반복·복귀하지 않음 · exclude = 앞서 관통한 적 제외).</param>
IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o, bool first, List<Unit> exclude = null) IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o, bool first, List<Unit> exclude = null)
{ {
float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); // 유닛은 무시 · 벽만 float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); // 유닛은 무시 · 벽·장애물 기믹만
travel = StopBeforeUnclearedAllies(u, origin, dir, travel); // 지나칠 수 없는 아군 직전에서 멈춤 travel = StopBeforeUnclearedAllies(u, origin, dir, travel); // 지나칠 수 없는 아군 직전에서 멈춤
float gDist;
Vector2 gNormal;
var gim = BlockAhead(u, origin, dir, dist, travel + 0.05f, out gDist, out gNormal); // 장애물 = 관통 불가 → 거기서 정지 · 복귀 없음(PD #1097)
var dists = new List<float>(); var dists = new List<float>();
var enemies = moveOnly ? new List<Unit>() : Combat.EnemiesOnPath(u, origin, dir, travel, dists); var enemies = moveOnly ? new List<Unit>() : Combat.EnemiesOnPath(u, origin, dir, travel, dists);
if (exclude != null) if (exclude != null)
@ -358,8 +392,8 @@ namespace TKS
Unit finalHit = moveOnly ? null : Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance); Unit finalHit = moveOnly ? null : Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance);
bool hitFinal = first && finalHit != null && enemies.Contains(finalHit); bool hitFinal = first && finalHit != null && enemies.Contains(finalHit);
// 단기구주: 빗나가도(관통한 적이 있으면) 출발점 복귀 · 위진소요: 명중 시 복귀 대신 같은 방향 한 번 더 관통 // 단기구주: 빗나가도(관통한 적이 있으면) 출발점 복귀 · 위진소요: 명중 시 복귀 대신 같은 방향 한 번 더 관통
int repeat = hitFinal ? SkillMods.Count(u, SkillKeys.PierceRepeat, 0) : 0; int repeat = hitFinal && gim == null ? SkillMods.Count(u, SkillKeys.PierceRepeat, 0) : 0;
bool willReturn = first && repeat <= 0 && (hitFinal || (enemies.Count > 0 && SkillMods.Has(u, SkillKeys.PierceReturnOnMiss))); bool willReturn = gim == null && first && repeat <= 0 && (hitFinal || (enemies.Count > 0 && SkillMods.Has(u, SkillKeys.PierceReturnOnMiss)));
Vector2 end = origin + dir * travel; Vector2 end = origin + dir * travel;
if (!willReturn && repeat <= 0) end = BackOffFromEnemies(u, origin, dir, travel); if (!willReturn && repeat <= 0) end = BackOffFromEnemies(u, origin, dir, travel);
@ -368,6 +402,7 @@ namespace TKS
{ {
yield return Move(u, end, o); yield return Move(u, end, o);
o.MovedOnly = true; o.MovedOnly = true;
if (gim != null && u.IsAlive) yield return CavalryHitsGimmick(u, gim, Mathf.Max(0f, dist - travel), gNormal, o);
yield break; yield break;
} }
@ -414,6 +449,12 @@ namespace TKS
} }
} }
if (gim != null && u.IsAlive)
{
yield return CavalryHitsGimmick(u, gim, Mathf.Max(0f, dist - travel), gNormal, o);
yield break;
}
if (repeat > 0 && u.IsAlive) if (repeat > 0 && u.IsAlive)
{ {
// 위진소요: 목표점에서 같은 방향으로 같은 거리만큼 한 번 더 관통(앞서 관통한 적 제외 · 그 뒤 복귀 없음) // 위진소요: 목표점에서 같은 방향으로 같은 거리만큼 한 번 더 관통(앞서 관통한 적 제외 · 그 뒤 복귀 없음)
@ -451,7 +492,7 @@ namespace TKS
{ {
float kb = cfg.cavalryKnockback * SkillMods.PierceKnockbackMult(cav, true) * SkillMods.KnockbackTakenMult(e, true); float kb = cfg.cavalryKnockback * SkillMods.PierceKnockbackMult(cav, true) * SkillMods.KnockbackTakenMult(e, true);
Vector2 from = e.Position; Vector2 from = e.Position;
Vector2 to = battle.Board.ClampInside(e.Position + side * sign * kb, e.Radius); Vector2 to = KnockTo(e, side * sign, kb); // 늪 0 · 빙판 × · 장애물·함정에서 멈춤 · 다리 → 강(PD #1097)
Fx.Run(Fx.MoveAlong(e.transform, e.Position, to, cfg.moveSpeed * 0.6f, null)); Fx.Run(Fx.MoveAlong(e.transform, e.Position, to, cfg.moveSpeed * 0.6f, null));
o.Displaced.Add(new Displacement { Unit = e, From = from, To = to }); o.Displaced.Add(new Displacement { Unit = e, From = from, To = to });
} }
@ -532,6 +573,13 @@ namespace TKS
/// <summary>백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음).</summary> /// <summary>백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음).</summary>
public float Teleport; public float Teleport;
public Vector2 Origin; public Vector2 Origin;
/// <summary>투사체를 막거나 맞은 기믹(덤불·바위·성문·성벽 = 막힘 · 폭약 수레 = 파괴 · PD #1097) · 유닛을 맞혔으면 null.</summary>
public GimmickInstance HitGimmick;
/// <summary>책사 광역이 터진다(중심 = 첫 피격 유닛 · 유닛 없이 덤불·수레에 막혔으면 그 자리).</summary>
public bool HasSplash;
public Vector2 SplashCenter;
/// <summary>책사 광역에 들어 지워질 기믹(덤불 제거 · 수레 파괴).</summary>
public readonly List<GimmickInstance> ClearGimmicks = new List<GimmickInstance>();
} }
ShotPlan PlanShot(Unit u, Vector2 origin, Vector2 dir, Vector2 target) ShotPlan PlanShot(Unit u, Vector2 origin, Vector2 dir, Vector2 target)
@ -550,28 +598,32 @@ namespace TKS
p.Target = Combat.FirstEnemyOnPath(u, origin, dir, range + u.ArcherMoveAllow, out hitDist); p.Target = Combat.FirstEnemyOnPath(u, origin, dir, range + u.ArcherMoveAllow, out hitDist);
if (p.Target == null) return p; if (p.Target == null) return p;
// 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한) // 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한 · 늪·고지 이동 비용 반영 PD #1097)
float need = Mathf.Clamp(hitDist - range, 0f, u.ArcherMoveAllow); float need = Mathf.Clamp(hitDist - range, 0f, Reach(u, origin, dir, u.ArcherMoveAllow));
p.Approach = need > 0.01f ? Combat.FreeTravel(u, origin, dir, need, true) : 0f; p.Approach = need > 0.01f ? Combat.FreeTravel(u, origin, dir, need, true) : 0f;
p.From = origin + dir * p.Approach; p.From = origin + dir * p.Approach;
p.To = p.From; p.To = p.From;
if (hitDist - p.Approach > range + 0.05f) return p; // 아군·벽에 막혀 사거리에 못 들어감 if (hitDist - p.Approach > range + 0.05f) return p; // 아군·벽에 막혀 사거리에 못 들어감
Aim(u, p, dir, target, range);
return p;
}
// 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과) /// <summary>
/// 사격 위치(p.From)에서 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과) ·
/// 막는 기믹(덤불·바위·성문·성벽) · 파괴되는 기믹(폭약 수레) · 안개 안 유닛 제외 · 책사 = 광역 계획(AimSplash).
/// </summary>
void Aim(Unit u, ShotPlan p, Vector2 dir, Vector2 target, float range)
{
p.Fires = true; p.Fires = true;
Vector2 aim = p.Target.Position - p.From; Vector2 aim = p.Target.Position - p.From;
Vector2 adir = aim.sqrMagnitude > 1e-6f ? aim.normalized : dir; Vector2 adir = aim.sqrMagnitude > 1e-6f ? aim.normalized : dir;
float travel; float travel;
Faction? pass = SkillMods.Has(u, SkillKeys.ArrowPassAllies) ? u.Faction : (Faction?)null; Faction? pass = SkillMods.Has(u, SkillKeys.ArrowPassAllies) ? u.Faction : (Faction?)null;
p.Hit = Combat.FirstUnitOnArrow(u, p.From, adir, range + u.Radius, cfg.arrowRadius, pass, out travel); p.Hit = Combat.FirstUnitOnArrow(u, p.From, adir, range + u.Radius, cfg.arrowRadius, pass, out travel, out p.HitGimmick);
p.To = p.From + adir * (p.Hit != null ? travel + cfg.arrowRadius : travel); p.To = p.From + adir * (p.Hit != null ? travel + cfg.arrowRadius : travel);
if (SkillMods.Has(u, SkillKeys.ArrowCritAlways)) p.Crit = p.Hit != null && p.Hit.Faction != u.Faction; // 백발백중·백기습영: 명중 여부와 무관하게 크리 if (SkillMods.Has(u, SkillKeys.ArrowCritAlways)) p.Crit = p.Hit != null && p.Hit.Faction != u.Faction; // 백발백중·백기습영: 명중 여부와 무관하게 크리
else p.Crit = p.Hit == p.Target && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == p.Target; else p.Crit = p.Hit == p.Target && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == p.Target;
if (p.Hit != null && u.Type == UnitType.Strategist) if (u.Type == UnitType.Strategist) AimSplash(u, p);
{
p.Splash.AddRange(Combat.UnitsInCircle(p.Hit.Position, p.SplashRadius, p.Hit, u));
}
return p;
} }
IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
@ -584,12 +636,16 @@ namespace TKS
} }
if (p == null || p.Target == null) if (p == null || p.Target == null)
{ {
// 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지) // 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지) · 장애물 = 튕김 · 늪·고지 = 이동 비용(PD #1097)
if (p == null || p.Teleport <= 0.01f) yield return MoveFree(u, origin, dir, dist, o); if (p == null || p.Teleport <= 0.01f) yield return MoveOrHit(u, origin, dir, Reach(u, origin, dir, dist), o);
yield break; yield break;
} }
if (p.Approach > 0.01f) yield return Move(u, p.From, o); if (p.Approach > 0.01f)
{
yield return Move(u, p.From, o);
if (p.Fires) yield return SlideBeforeShot(u, dir, p, target, o); // 빙판 = 사격 전 미끄러짐 · 사거리 이탈 = 취소(PD #1097 §9 ③)
}
if (!p.Fires) if (!p.Fires)
{ {
o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감 o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감
@ -611,16 +667,18 @@ namespace TKS
o.ShotHit = p.Hit; o.ShotHit = p.Hit;
if (u.View != null) u.View.PlayAttack(); if (u.View != null) u.View.PlayAttack();
yield return Fx.Arrow(p.From, p.To, strategist ? Fx.StrategistShotColor : Fx.ArrowColor); yield return Fx.Arrow(p.From, p.To, strategist ? Fx.StrategistShotColor : Fx.ArrowColor);
if (p.Hit == null) if (p.Hit == null && p.HitGimmick == null)
{ {
yield return Fx.Wait(0.1f); // 빗나감(벽 · 비행 끝) yield return Fx.Wait(0.1f); // 빗나감(벽 · 비행 끝)
yield break; yield break;
} }
Vector2 center = p.Hit.Position; Vector2 center = p.HasSplash ? p.SplashCenter : p.To;
ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o); if (p.Hit != null) ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o);
if (strategist) else yield return ArrowHitsGimmick(u, p, o); // 덤불·성문 막힘 · 수레 파괴(PD #1097)
if (strategist && p.HasSplash)
{ {
yield return SplashGimmicks(u, p, o); // 덤불 제거 · 수레 파괴 · 화공 불(PD #1097)
Fx.Burst(center, p.SplashRadius, Fx.SplashColor); Fx.Burst(center, p.SplashRadius, Fx.SplashColor);
float splashRatio = SkillMods.SplashRatio(u, cfg, true); float splashRatio = SkillMods.SplashRatio(u, cfg, true);
float allyRatio = SkillMods.SplashAllyRatio(u, cfg, true); float allyRatio = SkillMods.SplashAllyRatio(u, cfg, true);
@ -712,8 +770,9 @@ namespace TKS
/// <summary> /// <summary>
/// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio × 밀기 배율(청룡도 지기·호치·함진영) × 대상 밀림 배율(거구) /// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio × 밀기 배율(청룡도 지기·호치·함진영) × 대상 밀림 배율(거구)
/// / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio × 자기 튕김 배율(인덕·호치 = 0). impactDmg = 함진영 충돌 추가 피해 기준. /// / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio × 자기 튕김 배율(인덕·호치 = 0). impactDmg = 함진영 충돌 추가 피해 기준.
/// chain = 적과 싸운 뒤의 튕김(PD #1098): 튕겨나는 길에 다른 적이 있으면 그 적과 피해·반격 교환 → 그 적 기준으로 다시 튕김(연쇄 1회) · 아군이면 밀기만.
/// </summary> /// </summary>
IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o, int impactDmg) IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o, int impactDmg, bool chain = false)
{ {
bool struckDone = true; bool struckDone = true;
float force = struck != null ? remaining * cfg.collisionPushRatio * SkillMods.PushMult(striker, true) * SkillMods.KnockbackTakenMult(struck, true) : 0f; float force = struck != null ? remaining * cfg.collisionPushRatio * SkillMods.PushMult(striker, true) * SkillMods.KnockbackTakenMult(struck, true) : 0f;
@ -734,10 +793,40 @@ namespace TKS
} }
Fx.Run(Track(Push(struck, n, force, striker, o, onEnd), () => struckDone = true)); Fx.Run(Track(Push(struck, n, force, striker, o, onEnd), () => struckDone = true));
} }
float self = remaining * cfg.collisionSelfBounceRatio * SkillMods.SelfBounceMult(striker, true); float self = Reach(striker, striker.Position, -n, remaining * cfg.collisionSelfBounceRatio * SkillMods.SelfBounceMult(striker, true));
if (self >= 0.01f && striker.IsAlive && chain)
{
// 연쇄(PD #1098): 튕겨나는 길의 첫 유닛(벽·장애물보다 앞) — 적 = 피해·반격 교환 후 다시 튕김 · 아군 = 밀기만(밀린 아군은 규칙 ⑤)
float h2;
Unit second = Combat.FirstUnitOnPathExcept(striker, striker.Position, -n, self, struck, out h2);
if (second != null && h2 <= Combat.FreeTravel(striker, striker.Position, -n, self, false, false) + Combat.Epsilon)
{
Vector2 c2 = striker.Position - n * h2;
yield return MoveStraight(striker, c2, o);
float rem2 = Mathf.Max(0f, self - h2);
Vector2 n2 = second.Position - c2;
n2 = n2.sqrMagnitude > 1e-6f ? n2.normalized : -n;
if (second.Faction != striker.Faction)
{
o.Chains++;
o.Notes.Add("연쇄 → " + second.DisplayName);
int imp2 = ImpactExchange(striker, second, o);
if (striker.IsAlive) yield return Bounce(striker, second, n2, rem2, o, imp2, false);
}
else
{
float f2 = rem2 * cfg.collisionPushRatio * SkillMods.KnockbackTakenMult(second, true);
if (f2 >= 0.01f) yield return Push(second, n2, f2, striker, o);
if (second.IsAlive) yield return PushedAllyAttack(second, o, striker);
}
while (!struckDone) yield return null;
yield break;
}
}
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);
if (back > 0.01f) if (back > 0.01f)
{ {
Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082) Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082)
@ -761,7 +850,9 @@ namespace TKS
{ {
if (target == null || !target.IsAlive || force < 0.01f) yield break; if (target == null || !target.IsAlive || force < 0.01f) yield break;
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)
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); // 막힘 기록 → 다리 위에서 강 쪽이면 강에 빠짐
float moved = 0f; float moved = 0f;
if (slide > 0.01f) if (slide > 0.01f)
{ {

View File

@ -146,6 +146,8 @@ namespace TKS
public float collisionPushRatio = 1f; public float collisionPushRatio = 1f;
[Tooltip("보병 알까기: 부딪힌 보병 자신이 반대 방향으로 튕겨나는 거리 = 충돌 시 남은 슬링샷 거리 × 비율 (PD #1081 「자신도 튕겨나야」)")] [Tooltip("보병 알까기: 부딪힌 보병 자신이 반대 방향으로 튕겨나는 거리 = 충돌 시 남은 슬링샷 거리 × 비율 (PD #1081 「자신도 튕겨나야」)")]
public float collisionSelfBounceRatio = 0.5f; public float collisionSelfBounceRatio = 0.5f;
[Tooltip("창병 밀치기 반작용(PD 2026-11-06 #1098): 상대가 밀리는 거리(남은 거리 × 밀기 배율) × 비율만큼 창병이 반대로 밀린다(벽·유닛에 닿으면 정지 · 피해 없음) · 「밀며 함께 전진」 폐기")]
public float spearRecoilRatio = 0.5f;
[Header("표시")] [Header("표시")]
[Tooltip("유닛 아트(스프라이트) 세로 크기 · 월드 단위 · 장수는 반지름 배율만큼 커진다")] [Tooltip("유닛 아트(스프라이트) 세로 크기 · 월드 단위 · 장수는 반지름 배율만큼 커진다")]

View File

@ -38,7 +38,7 @@ namespace TKS
public static void Raise(string key, int n = 1) public static void Raise(string key, int n = 1)
{ {
if (n <= 0 || Raised == null) return; if (n <= 0 || Raised == null || Sim.Active) return; // 가상 실행(적 AI 예측)은 세지 않는다
Raised(key, n); Raised(key, n);
} }
} }

View File

@ -141,9 +141,16 @@ namespace TKS
public void NotifyChanged() public void NotifyChanged()
{ {
if (Sim.Active) return; // 가상 실행(적 AI 예측) 중 = HUD 갱신 없음
if (Changed != null) Changed(); if (Changed != null) Changed();
} }
/// <summary>지형 기믹 레이어(PD #1097 · 전투마다 스테이지 배치로 다시 만든다).</summary>
public GimmickLayer Gimmicks
{
get { return GimmickLayer.Current != null && GimmickLayer.Current.Battle == this ? GimmickLayer.Current : null; }
}
void SetPhase(BattlePhase p) void SetPhase(BattlePhase p)
{ {
if (Phase == p) return; if (Phase == p) return;
@ -202,6 +209,7 @@ namespace TKS
} }
units.Clear(); units.Clear();
if (Skills != null) Skills.Clear(); if (Skills != null) Skills.Clear();
if (Gimmicks != null) Gimmicks.Clear();
} }
// ---------- 스테이지 ---------- // ---------- 스테이지 ----------
@ -247,12 +255,15 @@ namespace TKS
foreach (var e in stage.enemies) foreach (var e in stage.enemies)
{ {
var u = SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss, e.art); var u = SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss, e.art);
Skills.AttachEnemy(u, e); // 적 무장 = 패시브만(액티브 AI 사용은 2차) Skills.AttachEnemy(u, e, TacticalAI.BossUsesActive(stage, e)); // 적 무장 = 패시브 · 1부 보스 = 액티브도(적 AI 가 예약해 씀 · PD #1098)
} }
GimmickLayer.Ensure(this).Build(stage); // 지형 기믹 레이어(배경 위 · 유닛 아래 · PD #1097)
TurnNumber = 1; TurnNumber = 1;
Skills.BeginBattle(); Skills.BeginBattle();
LastMessage = stage.title + " — 아군 차례"; LastMessage = stage.title + " — 아군 차례";
string tip = GimmickTables.Table != null ? GimmickTables.Table.TipFor(stage.chapter, stage.stage) : "";
if (tip.Length > 0) LastMessage += " · " + tip; // 1장 튜토리얼 문구(tutorial_tips.csv · PD #1098 §3)
if (StageStarted != null) StageStarted(); if (StageStarted != null) StageStarted();
SetPhase(BattlePhase.PlayerTurn); SetPhase(BattlePhase.PlayerTurn);
} }
@ -419,27 +430,61 @@ namespace TKS
LastMessage = "적군 차례"; LastMessage = "적군 차례";
NotifyChanged(); NotifyChanged();
foreach (var e in AliveUnits(Faction.Enemy)) e.ResetForNewTurn(); foreach (var e in AliveUnits(Faction.Enemy)) e.ResetForNewTurn();
if (Gimmicks != null)
{
yield return Gimmicks.TurnStart(Faction.Enemy); // 군기 · 중독 · 불 · 행동 불가(PD #1097)
if (CheckBattleEnd()) yield break;
}
yield return Fx.Wait(0.4f); yield return Fx.Wait(0.4f);
var enemies = AliveUnits(Faction.Enemy);
float accuracy = Stage.aiAccuracy; float accuracy = Stage.aiAccuracy;
float aggro = Mathf.Max(0f, Stage.aiAggroRange + Skills.AggroAdd(Faction.Enemy)); // 신산 = 적 교전 범위 − float aggro = Mathf.Max(0f, Stage.aiAggroRange + Skills.AggroAdd(Faction.Enemy)); // 신산 = 적 교전 범위 −
int acted = 0; int acted = 0;
for (int i = 0; i < enemies.Count; i++) // 예측 시뮬레이션 AI(PD #1098): 남은 적 전원 평가 → 서포터 → 돌격 → 사격 · 1기 실행마다 재평가 · 교전 범위 밖 = 대기
var pending = new List<Unit>();
foreach (var e in AliveUnits(Faction.Enemy))
{ {
var e = enemies[i];
if (!e.IsAlive) continue;
if (Skills.Suppressed(e)) continue; // 장판 일갈·태평요술 = 이번 적 턴 교전 반응 없음 if (Skills.Suppressed(e)) continue; // 장판 일갈·태평요술 = 이번 적 턴 교전 반응 없음
if (GimmickLayer.Skips(e)) continue; // 함정·강 = 이번 턴 행동 불가
pending.Add(e);
}
bool tactical = TacticalAI.Available;
while (pending.Count > 0)
{
pending.RemoveAll(x => x == null || !x.IsAlive);
var players = AliveUnits(Faction.Player); var players = AliveUnits(Faction.Player);
if (players.Count == 0) break; if (players.Count == 0 || pending.Count == 0) break;
Vector2? target = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config); Unit e;
if (!target.HasValue) continue; // 교전 범위 밖 → 자리 지킴 Vector2 target;
bool useSkill = false;
if (tactical)
{
var plan = TacticalAI.Next(this, pending, accuracy, aggro, Rng);
if (plan == null) break; // 남은 유닛 = 교전 범위 밖 → 자리 지킴
e = plan.Unit;
target = plan.Target;
useSkill = plan.UseSkill;
LastAiPlan = plan;
}
else
{
e = pending[0];
Vector2? aim = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config); // 가중치 자산이 없으면 기존 조준
if (!aim.HasValue)
{
pending.RemoveAt(0);
continue;
}
target = aim.Value;
}
pending.Remove(e);
if (UnitActing != null) UnitActing(e); if (UnitActing != null) UnitActing(e);
yield return Fx.Wait(0.3f); // 카메라가 따라올 시간 yield return Fx.Wait(0.3f); // 카메라가 따라올 시간
if (useSkill && Skills.Of(e) != null) Skills.Of(e).Reserved = true; // 적 보스 액티브(다음 행동형 · 1부 보스만)
var o = new ActionOutcome(); var o = new ActionOutcome();
yield return Resolver.Resolve(e, target.Value, false, o); yield return Resolver.Resolve(e, target, false, o);
yield return ApplyOverwatch(e, o); yield return ApplyOverwatch(e, o);
LastMessage = Describe(e, o, false); LastMessage = Describe(e, o, false);
NotifyChanged(); NotifyChanged();
@ -458,10 +503,25 @@ namespace TKS
foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn(); foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn();
Skills.OnPlayerTurnStart(); // 이번 적 턴 효과(호위·철벽·간웅·일갈·안개) · 견제 강화 정리 Skills.OnPlayerTurnStart(); // 이번 적 턴 효과(호위·철벽·간웅·일갈·안개) · 견제 강화 정리
TurnNumber++; TurnNumber++;
if (Gimmicks != null)
{
yield return Gimmicks.TurnStart(Faction.Player); // 군기 · 불 지속 · 중독 · 불 · 행동 불가(PD #1097)
if (CheckBattleEnd()) yield break;
}
LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : ""); LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : "");
if (AliveCount(Faction.Player) > 0 && AllPlayersActed())
{
LastMessage = TurnNumber + "번째 턴 — 아군 전원 행동 불가";
yield return Fx.Wait(0.4f);
yield return EnemyTurnRoutine(); // 함정·강으로 아군 전원이 쉬는 턴
yield break;
}
SetPhase(BattlePhase.PlayerTurn); SetPhase(BattlePhase.PlayerTurn);
} }
/// <summary>마지막 적 AI 결정(디버그 · 테스트 · 캡처).</summary>
public AiPlan LastAiPlan;
/// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary> /// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary>
public IEnumerator ApplyOverwatch(Unit mover, List<Vector2> path) public IEnumerator ApplyOverwatch(Unit mover, List<Vector2> path)
{ {

View File

@ -3,8 +3,8 @@ using UnityEngine;
namespace TKS namespace TKS
{ {
/// <summary>피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중).</summary> /// <summary>피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중). 지형 기믹 질의 = Gimmicks/Combat.Gimmicks.cs.</summary>
public static class Combat public static partial class Combat
{ {
/// <summary>경로 질의에 쓰는 원 반지름 배율(살짝 작게 캐스트해 스침을 관대하게).</summary> /// <summary>경로 질의에 쓰는 원 반지름 배율(살짝 작게 캐스트해 스침을 관대하게).</summary>
public const float CastRadiusScale = 0.9f; public const float CastRadiusScale = 0.9f;
@ -23,16 +23,16 @@ namespace TKS
public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee, float scale = 1f) public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee, float scale = 1f)
{ {
float mult = Mathf.Max(cfg.Matchup(attacker.Type, defender.Type), SkillMods.MatchupFloor(attacker, true)); float mult = Mathf.Max(cfg.Matchup(attacker.Type, defender.Type), SkillMods.MatchupFloor(attacker, true));
float atk = attacker.Atk * SkillMods.AttackMult(attacker, true); float atk = attacker.Atk * SkillMods.AttackMult(attacker, true) * GimmickLayer.AttackMultOf(attacker); // 군기 점령(PD #1097)
float raw = atk * mult * (crit ? SkillMods.CritMult(attacker, cfg) : 1f) - defender.Def * cfg.defenseFactor; // 보물 크리 % 포함(PD #1093 C) float raw = atk * mult * (crit ? SkillMods.CritMult(attacker, cfg) : 1f) - defender.Def * cfg.defenseFactor; // 보물 크리 % 포함(PD #1093 C)
if (melee) if (melee)
{ {
if (UnitTypeInfo.IsRanged(attacker.Type) && UnitTypeInfo.IsMelee(defender.Type) && !SkillMods.Has(attacker, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeDealtRatio; if (UnitTypeInfo.IsRanged(attacker.Type) && UnitTypeInfo.IsMelee(defender.Type) && !SkillMods.Has(attacker, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeDealtRatio;
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 *= scale * SkillMods.DealtMult(attacker, defender, true) * SkillMods.TakenMult(defender, attacker, true); raw *= scale * SkillMods.DealtMult(attacker, defender, true) * SkillMods.TakenMult(defender, attacker, true);
float variance = 1f + ((float)rng.NextDouble() * 2f - 1f) * cfg.damageVariance; return Mathf.Max(1, Mathf.RoundToInt(raw * Variance(cfg, rng))); // 가상 실행 = 분산 0(PD #1098)
return Mathf.Max(1, Mathf.RoundToInt(raw * variance));
} }
/// <summary>책사 광역 피해(PD 2026-11-05 #1087 ⑤) = 대상별 사격 피해(상성 적용 · 크리 없음) × ratio · ratio ≤ 0 이면 0.</summary> /// <summary>책사 광역 피해(PD 2026-11-05 #1087 ⑤) = 대상별 사격 피해(상성 적용 · 크리 없음) × ratio · ratio ≤ 0 이면 0.</summary>
@ -86,6 +86,7 @@ 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;
} }
@ -106,6 +107,20 @@ namespace TKS
return null; return null;
} }
/// <summary>경로 위 가장 가까운 유닛(except 제외) — 보병 연쇄 튕김(PD 2026-11-06 #1098 · 방금 부딪힌 유닛은 뺀다).</summary>
public static Unit FirstUnitOnPathExcept(Unit actor, Vector2 origin, Vector2 dir, float dist, Unit except, out float hitDist)
{
hitDist = 0f;
foreach (var h in Cast(actor, origin, dir, dist))
{
var u = UnitOf(h.collider);
if (u == null || u == actor || u == except || !u.IsAlive) continue;
hitDist = h.distance;
return u;
}
return null;
}
/// <summary> /// <summary>
/// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외). /// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외).
/// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist). /// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist).
@ -118,24 +133,8 @@ namespace TKS
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel) public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel)
{ {
travel = dist; GimmickInstance g;
Physics2D.SyncTransforms(); return FirstUnitOnArrow(shooter, from, dir, dist, arrowRadius, passFaction, out travel, out g); // 기믹 pass/block/destroy · 안개(Combat.Gimmicks.cs)
var hits = Physics2D.CircleCastAll(from, arrowRadius, dir, dist);
System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
foreach (var h in hits)
{
if (h.collider.GetComponent<BoardWall>() != null)
{
travel = h.distance;
return null;
}
var u = UnitOf(h.collider);
if (u == null || u == shooter || !u.IsAlive) continue;
if (passFaction.HasValue && u.Faction == passFaction.Value) continue;
travel = h.distance;
return u;
}
return null;
} }
/// <summary>점 center 반경 radius 원에 몸(원)이 닿는 살아 있는 유닛(가까운 순 · 진영 불문 · exclude 둘 제외) — 책사 광역(보스는 반지름이 커서 더 잘 닿는다).</summary> /// <summary>점 center 반경 radius 원에 몸(원)이 닿는 살아 있는 유닛(가까운 순 · 진영 불문 · exclude 둘 제외) — 책사 광역(보스는 반지름이 커서 더 잘 닿는다).</summary>
@ -189,13 +188,16 @@ namespace TKS
return list; return list;
} }
/// <summary>벽·(옵션) 유닛에 막히기 전까지 실제로 나아갈 수 있는 거리. ignore = 밀어 주는 유닛처럼 출발 지점에서 닿아 있어도 무시할 유닛.</summary> /// <summary>
/// 벽·장애물 기믹·(옵션) 유닛에 막히기 전까지 실제로 나아갈 수 있는 거리. ignore = 밀어 주는 유닛처럼 출발 지점에서 닿아 있어도 무시할 유닛.
/// 숨은 함정에 중심이 들면 그 자리에서 멈춘다(PD #1097 · 아군 조준 미리보기는 함정을 모른다).
/// </summary>
public static float FreeTravel(Unit actor, Vector2 origin, Vector2 dir, float dist, bool enemiesBlock, bool friendsBlock = true, Unit ignore = null) public static float FreeTravel(Unit actor, Vector2 origin, Vector2 dir, float dist, bool enemiesBlock, bool friendsBlock = true, Unit ignore = null)
{ {
float min = dist; float min = TrapStop(actor, origin, dir, dist);
foreach (var h in Cast(actor, origin, dir, dist)) foreach (var h in Cast(actor, origin, dir, dist))
{ {
if (h.collider.GetComponent<BoardWall>() != null) if (h.collider.GetComponent<BoardWall>() != null || GimmickLayer.BlocksMove(h.collider))
{ {
min = Mathf.Min(min, h.distance); min = Mathf.Min(min, h.distance);
continue; continue;

View File

@ -4,19 +4,25 @@ using UnityEngine;
namespace TKS namespace TKS
{ {
/// <summary>간단한 연출(이동 트윈·돌진·화살·피해 숫자). 코루틴 실행자는 BattleManager.</summary> /// <summary>간단한 연출(이동 트윈·돌진·화살·피해 숫자). 코루틴 실행자는 BattleManager. 가상 실행(Sim.Active · 적 AI 예측) 중 = 이동·대기 즉시 · 팝업·터짐 없음.</summary>
public static class Fx public static class Fx
{ {
public static MonoBehaviour Runner; public static MonoBehaviour Runner;
public static Coroutine Run(IEnumerator routine) public static Coroutine Run(IEnumerator routine)
{ {
if (Sim.Active)
{
Sim.Run(routine); // 가상 실행 = 그 자리에서 끝까지(병렬 밀기 등)
return null;
}
if (Runner == null) return null; if (Runner == null) return null;
return Runner.StartCoroutine(routine); return Runner.StartCoroutine(routine);
} }
public static IEnumerator Wait(float seconds) public static IEnumerator Wait(float seconds)
{ {
if (Sim.Active) yield break;
float t = 0f; float t = 0f;
while (t < seconds) while (t < seconds)
{ {
@ -30,6 +36,12 @@ namespace TKS
{ {
float total = Vector2.Distance(from, to); float total = Vector2.Distance(from, to);
if (total < 1e-4f) yield break; if (total < 1e-4f) yield break;
if (Sim.Active)
{
tr.position = new Vector3(to.x, to.y, 0f);
if (onDistance != null) onDistance(total);
yield break;
}
float duration = Mathf.Max(0.12f, total / Mathf.Max(0.1f, speed)); float duration = Mathf.Max(0.12f, total / Mathf.Max(0.1f, speed));
float el = 0f; float el = 0f;
while (el < duration) while (el < duration)
@ -49,6 +61,7 @@ namespace TKS
/// <summary>근접 공격 돌진(짧게 찌르고 복귀).</summary> /// <summary>근접 공격 돌진(짧게 찌르고 복귀).</summary>
public static IEnumerator Lunge(Unit a, Vector2 toward) public static IEnumerator Lunge(Unit a, Vector2 toward)
{ {
if (Sim.Active) yield break;
Vector2 start = a.Position; Vector2 start = a.Position;
Vector2 dir = (toward - start); Vector2 dir = (toward - start);
if (dir.sqrMagnitude < 1e-4f) yield break; if (dir.sqrMagnitude < 1e-4f) yield break;
@ -73,6 +86,7 @@ namespace TKS
/// <summary>투사체(가늘고 긴 사각형) 날리기 — 궁병 화살 · 책사 계략(색만 다름).</summary> /// <summary>투사체(가늘고 긴 사각형) 날리기 — 궁병 화살 · 책사 계략(색만 다름).</summary>
public static IEnumerator Arrow(Vector2 from, Vector2 to, Color color) public static IEnumerator Arrow(Vector2 from, Vector2 to, Color color)
{ {
if (Sim.Active) yield break;
var go = new GameObject("Arrow"); var go = new GameObject("Arrow");
var sr = go.AddComponent<SpriteRenderer>(); var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = ProceduralSprites.Square; sr.sprite = ProceduralSprites.Square;
@ -97,6 +111,7 @@ namespace TKS
/// <summary>광역 터짐(반지름 radius 원이 짧게 커지며 사라짐 · 책사 광역).</summary> /// <summary>광역 터짐(반지름 radius 원이 짧게 커지며 사라짐 · 책사 광역).</summary>
public static void Burst(Vector2 center, float radius, Color color) public static void Burst(Vector2 center, float radius, Color color)
{ {
if (Sim.Active) return;
Run(BurstRoutine(center, radius, color)); Run(BurstRoutine(center, radius, color));
} }
@ -122,6 +137,7 @@ namespace TKS
public static void Popup(Vector2 pos, string text, Color color) public static void Popup(Vector2 pos, string text, Color color)
{ {
if (Sim.Active) return;
Run(PopupRoutine(pos, text, color)); Run(PopupRoutine(pos, text, color));
} }

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4c04a047e2d9a1c44bea58d6a6fbf8a8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,452 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>
/// 지형 기믹 충돌 반응(PD #1097 · 지형기믹_기획서 §2~§4 · 병종×기믹 표):
/// 보병 = 내구 피해 + 튕김(알까기 자기 튕김 재사용 · bounce_pct = 자기 HP 피해) · 바위는 굴림 / 창병 = 내구 피해 + spear_push 만큼 밀고 정지(밀린 끝 유닛도 밀림) · 바위 굴림 /
/// 기병 = 관통 불가 → 거기서 정지(복귀 없음) · 내구 피해 + 튕김 + 자기 HP(cav_self_pct) · 바위 = 정지 / 궁·책 이동 = 피해 없이 튕김 ·
/// 투사체 = pass·block·destroy · 책사 광역 = 덤불 제거·수레 파괴·(화공) 불 생성 · 폭약 = 광역 파이프라인(아군 포함 + 밀기 + 불) ·
/// 행동 끝 = 빙판 미끄러짐(행동 유닛 = 병종 루틴 재실행 · 밀린 유닛 = 밀기) → 지대 스윕(독·불·함정) → 다리 → 강.
/// </summary>
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; }
}
/// <summary>근접 병종 슬링샷 도달점(늪 ×p1 · 고지 오르기 ×p3) — Resolve·조준 미리보기 공용. 원거리 병종은 접근·이동 단계에서 따로 계산.</summary>
public Vector2 ReachPoint(Unit u, Vector2 target)
{
if (!Gim || u == null || UnitTypeInfo.IsRanged(u.Type)) return target;
Vector2 d = target - u.Position;
float dist = d.magnitude;
if (dist < 0.001f) return target;
return u.Position + d / dist * Layer.CostReach(u, u.Position, d / dist, dist);
}
static float Reach(Unit u, Vector2 origin, Vector2 dir, float budget)
{
return Gim ? Layer.CostReach(u, origin, dir, budget) : budget;
}
static void NoteForced(Unit u, Vector2 dir, float intended, float actual)
{
if (Gim) Layer.NoteForced(u, dir, intended, actual);
}
void GimmickBegin(ActionOutcome o)
{
if (Layer == null) return;
Layer.Outcome = o;
Layer.Forced.Clear();
Layer.Affected.Clear();
}
/// <summary>경로 위 첫 장애물 기믹 — limit(첫 유닛 접촉) · 전장 벽 · 숨은 함정보다 앞일 때만.</summary>
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;
}
/// <summary>궁·책 등 이동만 하는 병종: 유닛 앞의 장애물이면 부딪힘(튕김) · 아니면 그대로 이동.</summary>
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);
}
/// <summary>보병·창병·궁·책 이동이 장애물 기믹에 부딪힘.</summary>
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;
bool pusher = u.Type == UnitType.Infantry || u.Type == UnitType.Spear;
if (pusher && g.Rolls)
{
yield return RollBoulder(u, g, dir, remaining * d.spearPush, o); // 보·창 = 바위를 남은 거리 × spear_push 만큼 굴린다 · 자신은 정지
yield break;
}
int dmg = d.DamageBy(u.Type);
if (dmg > 0) yield return DamageGimmick(g, dmg, 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, 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) · 면 바깥으로
}
/// <summary>기병 관통이 장애물에 막힘(복귀 없음): 내구 피해 → 바위 = 정지 + cav_self_pct · 그 밖 = 튕김 + max(cav_self_pct, bounce_pct).</summary>
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);
}
/// <summary>
/// 폭약 수레 폭발(광역 파이프라인 재사용): 반경 p1 안 유닛 전원(아군 포함 · 성문·성벽 너머 제외) 피해 = 터뜨린 유닛 공 × p2 · 바깥으로 p3 밀기 ·
/// 반경 안 다른 수레 연쇄 · 그 자리 불 지대(반경 p1 · 지속 = 스테이지 화공 배치 burn_turns · 없으면 정의 p2).
/// </summary>
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, Combat.GimmickDamage(cfg, by, v, mult, rng));
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<GimmickInstance>(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);
}
/// <summary>바위 굴림: 바위 원을 dir 로 dist 만큼(벽·다른 장애물·유닛에서 멈춤) · 상대 유닛에 닿으면 공 × p1 피해 + p2 밀기.</summary>
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<BoardWall>() != null || GimmickLayer.BlocksMove(h.collider))
{
travel = h.distance;
break;
}
var v = h.collider.GetComponent<Unit>();
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, Combat.GimmickDamage(cfg, pusher, victim, g.P(1, 1f), rng));
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();
}
/// <summary>목책 밀기(창병): spear_push 만큼(벽·다른 장애물에서 멈춤) · 새 자리와 겹칠 유닛은 먼저 그만큼 밀고 · 다 못 밀리면 목책도 그만큼만.</summary>
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<BoardWall>() != null || GimmickLayer.BlocksMove(h.collider)) min = Mathf.Min(min, h.distance);
}
return Mathf.Max(0f, min - Combat.Epsilon);
}
/// <summary>기병 관통 옆 밀기: 늪 안 = 0 · 빙판 위 = ×p2 · 장애물·함정에서 멈춤 · 다리 위에서 강 쪽이면 강 판정.</summary>
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, dir, kb, free);
return e.Position + dir * free;
}
// ---------- 투사체 · 책사 광역 ----------
/// <summary>책사 광역 계획: 중심 = 첫 피격 유닛 · 유닛이 없고 제거 가능 기믹(덤불·수레)에 막혔으면 그 자리 · 반경 × 안개 · 성문·성벽 너머 제외 · 지울 기믹.</summary>
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) * (Gim ? Layer.FogSplashMult(p.SplashCenter) : 1f);
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));
}
/// <summary>투사체가 기믹에 막힘: 수레 = 파괴(폭발) · 그 밖 = 막힘(피해 없음).</summary>
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));
}
}
/// <summary>책사 광역의 기믹: 덤불 제거 · 수레 파괴(폭발) · 화공 스테이지 = 그 자리 불.</summary>
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.OnSplash(p.SplashCenter, p.SplashRadius) != null) o.Notes.Add("불길");
}
/// <summary>빙판: 사격 전 이동이 빙판에서 끝나면 미끄러짐 → 사거리 밖이면 사격 취소(PD #1097 §9 ③ 제안) · 아니면 새 자리에서 다시 조준.</summary>
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);
}
// ---------- 행동 끝 ----------
/// <summary>행동 끝(기믹): 행동 유닛 미끄러짐(행동당 1회 · 최대 p1) → 밀린 유닛 미끄러짐 → 지대 스윕(독·불·함정) → 다리 → 강.</summary>
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<Unit>();
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<Vector2> 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;
}
/// <summary>행동 유닛 미끄러짐 = 같은 방향으로 병종 루틴을 미끄러질 거리만큼 다시(충돌 규칙 그대로) · 명중 보상·복귀·견제는 원래 행동 것만.</summary>
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, false, new List<Unit>(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);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 09ebc6412623b864ebdbad94df388f89

View File

@ -0,0 +1,98 @@
using UnityEngine;
namespace TKS
{
/// <summary>지형 기믹 질의(PD #1097) — 장애물 콜라이더 · 투사체 pass/block/destroy · 안개 · 기믹 피해 공식.</summary>
public static partial class Combat
{
/// <summary>전장 가장자리 벽까지만(기믹·유닛 무시) 나아갈 수 있는 거리.</summary>
public static float WallTravel(Unit actor, Vector2 origin, Vector2 dir, float dist)
{
float min = dist;
foreach (var h in Cast(actor, origin, dir, dist))
{
if (h.collider.GetComponent<BoardWall>() != null) min = Mathf.Min(min, h.distance);
}
return Mathf.Max(0f, min - Epsilon);
}
/// <summary>경로(원 캐스트) 위 첫 장애물 기믹. hitDist = 닿기까지 중심 이동 거리 · normal = 닿은 면의 바깥 방향(튕김 방향).</summary>
public static GimmickInstance FirstGimmickOnPath(Unit actor, Vector2 origin, Vector2 dir, float dist, out float hitDist, out Vector2 normal)
{
hitDist = 0f;
normal = -dir;
if (GimmickLayer.Current == null || !GimmickLayer.Current.HasAny) return null;
foreach (var h in Cast(actor, origin, dir, dist))
{
var g = GimmickLayer.InstOf(h.collider);
if (g == null || !g.BlocksMove) continue;
hitDist = h.distance;
normal = h.normal.sqrMagnitude > 1e-6f ? h.normal.normalized : -dir;
if (Vector2.Dot(normal, dir) > 0f) normal = -dir; // 출발부터 겹친 경우(법선 미정)
return g;
}
return null;
}
/// <summary>
/// 투사체(PD 2026-11-05 #1087 ⑥ + 기믹 #1097): 첫 유닛 또는 막는 기믹. 목책·강 = 통과(pass) · 덤불·바위·성문·성벽 = 막힘(gimmick 에 담고 null) ·
/// 폭약 수레 = 맞으면 파괴(gimmick 에 담고 null) · 안개 안 유닛 = 사선 판정 제외(통과).
/// </summary>
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel, out GimmickInstance gimmick)
{
travel = dist;
gimmick = null;
Physics2D.SyncTransforms();
var hits = Physics2D.CircleCastAll(from, arrowRadius, dir, dist);
System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
foreach (var h in hits)
{
if (h.collider.GetComponent<BoardWall>() != null)
{
travel = h.distance;
return null;
}
var g = GimmickLayer.InstOf(h.collider);
if (g != null)
{
if (!g.BlocksMove || g.Def.projectile == GimmickProjectile.Pass) continue;
travel = h.distance;
gimmick = g;
return null;
}
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;
}
return null;
}
/// <summary>
/// 기믹 피해(바위 굴림 · 폭약 폭발) = (공격 × 스킬 공 배율 × 군기 × mult − 방어 × defenseFactor) × 난수 · 상성·크리 없음(「공 ×p」 · 해석 = PD 확인 대상).
/// </summary>
public static int GimmickDamage(BalanceConfig cfg, Unit attacker, Unit defender, float mult, System.Random rng)
{
if (attacker == null || defender == null) return 0;
float atk = attacker.Atk * SkillMods.AttackMult(attacker, true) * GimmickLayer.AttackMultOf(attacker);
float raw = atk * mult - defender.Def * cfg.defenseFactor;
return Mathf.Max(1, Mathf.RoundToInt(raw * Variance(cfg, rng)));
}
/// <summary>피해 난수(가상 실행 = 분산 0 · 적 AI 예측 · PD #1098).</summary>
public static float Variance(BalanceConfig cfg, System.Random rng)
{
if (Sim.Active || rng == null) return 1f;
return 1f + ((float)rng.NextDouble() * 2f - 1f) * cfg.damageVariance;
}
/// <summary>함정 정지(숨은 함정에 중심이 들면 그 자리) — FreeTravel 이 쓴다.</summary>
static float TrapStop(Unit actor, Vector2 origin, Vector2 dir, float dist)
{
var l = GimmickLayer.Current;
return l == null || !l.HasAny ? dist : l.TrapStop(actor, origin, dir, dist);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3be7ffd206cf91f458c0a84090c1363d

View File

@ -0,0 +1,10 @@
using UnityEngine;
namespace TKS
{
/// <summary>장애물 기믹 콜라이더 표식(캐스트 질의에서 어떤 기믹인지 찾는다 · BoardWall 과 같은 역할).</summary>
public class GimmickBody : MonoBehaviour
{
public GimmickInstance Inst;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 31660f844b158db42880d31b9625a704

View File

@ -0,0 +1,284 @@
using System.Collections;
using System.Collections.Generic;
using System.IO;
using TKS.OutGame;
using UnityEngine;
namespace TKS
{
/// <summary>
/// 지형 기믹·적 AI 검증 캡처(에디터 GimmickImporter.Capture 가 SessionState 로 출력 폴더를 넘기면 생성 · 세이브 = 메모리 전용 · PD #1097 · #1098).
/// ① 샘플 5 스테이지(1-3 · 2-5 · 4-13 · 8-15 · 9-11) 시작 + 1턴(아군 = 가까운 적 쪽 슬링 · 적 = 예측 AI) 뒤 ② 조준선 반응 2장(1-3 바위·덤불 · 9-11 다리 밀기)
/// ③ AI 가 아군을 함정 쪽으로 튕겨내는 장면 1장. 카메라는 기믹 쪽으로 · 끝나면 스스로 종료.
/// </summary>
public class GimmickCaptureDriver : MonoBehaviour
{
public const string DirKey = "TKS_GIMMICK_CAPTURE_DIR";
public string OutputDir;
public int Width = 720;
public int Height = 1280;
readonly List<string> log = new List<string>();
int shot = 1;
static readonly string[] Samples = { "1-3", "2-5", "4-13", "8-15", "9-11" };
#if UNITY_EDITOR
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void AutoSpawn()
{
string dir = UnityEditor.SessionState.GetString(DirKey, "");
if (string.IsNullOrEmpty(dir)) return;
UnityEditor.SessionState.EraseString(DirKey);
var d = new GameObject("GimmickCaptureDriver").AddComponent<GimmickCaptureDriver>();
d.OutputDir = dir;
}
#endif
IEnumerator Start()
{
Directory.CreateDirectory(OutputDir);
yield return null;
yield return null;
var root = OutGameRoot.Instance;
var cam = Camera.main;
if (root == null || cam == null)
{
Log("FAIL: OutGameRoot 또는 카메라 없음");
Finish(1);
yield break;
}
var bm = root.Battle;
var camCtrl = cam.GetComponent<CameraController>();
Log("gimmickDb=" + (GimmickTables.Db != null ? GimmickTables.Db.defs.Count : -1) + " table=" + (GimmickTables.Table != null ? GimmickTables.Table.rows.Count : -1) + " aiWeights=" + (AiWeights.Load() != null));
foreach (var st in Samples)
{
var parts = st.Split('-');
int ch = int.Parse(parts[0]), no = int.Parse(parts[1]);
root.OpenWorldMap();
yield return null;
root.Map.EnterStage(ch - 1, no - 1);
float t0 = Time.realtimeSinceStartup;
while (bm.Phase != BattlePhase.PlayerTurn && Time.realtimeSinceStartup - t0 < 10f) yield return null;
var layer = bm.Gimmicks;
Log(st + " 「" + bm.Stage.title + "」 기믹 " + (layer != null ? layer.Items.Count : 0) + " · 아군 " + bm.AliveCount(Faction.Player) + " · 적 " + bm.AliveCount(Faction.Enemy) + " · msg=" + bm.LastMessage);
if (layer != null) foreach (var g in layer.Items) Log(" " + g.Id + " " + g.Shape.Type + " @" + g.Shape.Center + (g.Template ? " (생성 규칙)" : "") + (g.MaxHp > 0 ? " hp" + g.Hp : ""));
Vector2 focus = Focus(bm, layer);
if (camCtrl != null)
{
camCtrl.SetViewHeight(26f); // 기믹 전체가 보이게
camCtrl.SnapTo(focus);
}
yield return Wait();
yield return Shot(cam, "stage_" + st + "_start");
if (st == "1-3" || st == "9-11")
{
if (st == "1-3") yield return ThornAimShot(bm, cam, camCtrl);
else yield return BridgePushShot(bm, cam, camCtrl);
root.OpenWorldMap(); // 조준 장면용으로 비운 전장을 다시 그 스테이지로
yield return null;
root.Map.EnterStage(ch - 1, no - 1);
t0 = Time.realtimeSinceStartup;
while (bm.Phase != BattlePhase.PlayerTurn && Time.realtimeSinceStartup - t0 < 10f) yield return null;
layer = bm.Gimmicks;
}
if (camCtrl != null) camCtrl.SetViewHeight(bm.Config.cameraViewHeight);
yield return PlayOneTurn(bm);
if (camCtrl != null)
{
camCtrl.SetViewHeight(26f);
camCtrl.SnapTo(Focus(bm, layer));
}
yield return Wait();
Log(st + " 1턴 뒤 · 턴 " + bm.TurnNumber + " · 아군 " + bm.AliveCount(Faction.Player) + " · 적 " + bm.AliveCount(Faction.Enemy) + " · AI=" + (bm.LastAiPlan != null ? bm.LastAiPlan.Unit.DisplayName + " 「" + bm.LastAiPlan.Why + "」 " + bm.LastAiPlan.Score.ToString("0.#") : "-") + " · msg=" + bm.LastMessage);
yield return Shot(cam, "stage_" + st + "_turn1");
}
yield return TrapShot(bm, cam, camCtrl);
Finish(0);
}
static Vector2 Focus(BattleManager bm, GimmickLayer layer)
{
Vector2 sum = Vector2.zero;
int n = 0;
if (layer != null)
{
foreach (var g in layer.Items)
{
if (g.Template || g.Shape.BoundingRadius > 12f) continue;
sum += g.Shape.Center;
n++;
}
}
if (n == 0) return new Vector2(0f, -4f);
Vector2 c = sum / n;
return new Vector2(c.x, Mathf.Min(c.y, 2f) - 3f);
}
/// <summary>아군 전원이 가까운 적 쪽으로 슬링(최대 거리) → 적 턴(예측 AI)까지 끝나기를 기다린다.</summary>
IEnumerator PlayOneTurn(BattleManager bm)
{
Time.timeScale = 3f;
int turn = bm.TurnNumber;
float t0 = Time.realtimeSinceStartup;
while (bm.TurnNumber == turn && Time.realtimeSinceStartup - t0 < 90f)
{
if (bm.Phase == BattlePhase.StageClear || bm.Phase == BattlePhase.Defeat) break;
if (bm.Phase == BattlePhase.PlayerTurn)
{
Unit next = null;
foreach (var u in bm.AliveUnits(Faction.Player))
{
if (bm.CanAct(u)) { next = u; break; }
}
if (next == null) bm.OnEmptyTap();
else
{
var foe = Nearest(bm, next);
Vector2 d = foe != null ? foe.Position - next.Position : Vector2.up;
float len = Mathf.Min(d.magnitude, SkillMods.SlingMax(next));
var target = bm.Board.ClampInside(next.Position + d.normalized * len, next.Radius);
bm.RequestPlayerAction(next, target);
}
}
yield return null;
}
Time.timeScale = 1f;
}
/// <summary>조준 장면용 전장: 유닛을 비우고 그 스테이지 기믹만 다시 깐다.</summary>
static IEnumerator Stage(BattleManager bm)
{
bm.StopAllCoroutines();
var stage = bm.Stage;
bm.ClearUnits();
bm.ForcePhase(BattlePhase.PlayerTurn);
yield return null;
GimmickLayer.Ensure(bm).Build(stage);
}
IEnumerator AimCapture(BattleManager bm, Camera cam, CameraController cc, Unit u, Vector2 target, Vector2 focus, string name)
{
bm.Sling.Select(u);
bm.Sling.ShowPreview(u, target, u.Position - (target - u.Position).normalized * 1.2f);
if (cc != null)
{
cc.SetViewHeight(13f);
cc.SnapTo(focus);
}
yield return Wait();
var pv = bm.Gimmicks.Preview;
Log(name + " · 글자=" + string.Join(" / ", pv.LastIcons.ToArray()) + " · 끊김=" + pv.LastCut + " · 궤적=" + pv.LastTracks + " · 띠=" + pv.LastBandColors.Count);
yield return Shot(cam, name);
bm.Sling.HidePreview();
bm.Sling.Select(null);
}
/// <summary>1-3: 보병이 가시덤불 쪽으로 당긴 조준(덤불 지점 붉게 끊김 · 튕김 · 자기 피해 글자 · 튕겨날 궤적).</summary>
IEnumerator ThornAimShot(BattleManager bm, Camera cam, CameraController cc)
{
yield return Stage(bm);
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(6f, -3.2f), "보병");
bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(6f, 2.4f), "덤불 뒤 적");
yield return AimCapture(bm, cam, cc, u, new Vector2(6f, 1.3f), new Vector2(6f, -0.8f), "aim_1-3_thorn");
}
/// <summary>9-11: 다리 위 적을 아군 보병이 비스듬히 밀어 강에 빠뜨리는 조준(밀림 궤적 · 강에 빠짐 글자).</summary>
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");
}
/// <summary>AI 전술: 적 보병이 아군을 함정 쪽으로 튕겨낸다(함정 발동 순간 캡처).</summary>
IEnumerator TrapShot(BattleManager bm, Camera cam, CameraController cc)
{
bm.StopAllCoroutines();
bm.ClearUnits();
bm.ForcePhase(BattlePhase.PlayerTurn);
yield return null;
var layer = GimmickLayer.Ensure(bm);
var trap = layer.Spawn("pit_trap", GimmickShape.Circle(new Vector2(0f, 3.4f), 0.6f));
var enemy = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -1.5f), "적보병");
var victim = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, 1.2f), "아군창병");
var other = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-4f, -1.5f), "아군보병");
bm.LastMessage = "AI 전술 — 적 보병이 아군을 함정 쪽으로 튕겨낸다";
if (cc != null)
{
cc.SetViewHeight(13f);
cc.SnapTo(new Vector2(0f, 1f));
}
yield return Wait();
List<AiPlan> list;
var best = TacticalAI.Evaluate(bm, enemy, 1, out list);
Log("trap AI: " + (best != null ? best.Why + " · 점수 " + best.Score.ToString("0.#") + " · 후보 " + best.Candidates + " · " + best.Ms.ToString("0.0") + "ms" : "없음"));
Time.timeScale = 1f;
var o = new ActionOutcome();
Fx.Run(bm.ResolveForTest(enemy, best != null ? best.Target : victim.Position, false, o));
float t0 = Time.realtimeSinceStartup;
while (!trap.Revealed && Time.realtimeSinceStartup - t0 < 6f) yield return null;
yield return new WaitForSecondsRealtime(0.12f);
Log("trap revealed=" + trap.Revealed + " victim=" + victim.Position + " stun=" + (layer.StateOf(victim) != null ? layer.StateOf(victim).Stun : 0) + " other=" + other.IsAlive);
yield return Shot(cam, "ai_trap_bounce");
yield return new WaitForSecondsRealtime(1f);
}
static Unit Nearest(BattleManager bm, Unit from)
{
Unit best = null;
float bd = float.MaxValue;
foreach (var e in bm.AliveUnits(from.Faction == Faction.Player ? Faction.Enemy : Faction.Player))
{
float d = Vector2.Distance(e.Position, from.Position);
if (d < bd)
{
bd = d;
best = e;
}
}
return best;
}
static IEnumerator Wait()
{
yield return null;
yield return new WaitForSecondsRealtime(0.35f);
}
IEnumerator Shot(Camera cam, string name)
{
string file = Path.Combine(OutputDir, shot.ToString("00") + "_" + name + ".png");
shot++;
yield return CaptureUtil.Capture(cam, Width, Height, file);
Log("shot " + file);
}
void Log(string s)
{
log.Add(s);
Debug.Log("[GimmickCapture] " + s);
try
{
File.WriteAllLines(Path.Combine(OutputDir, "capture_log.txt"), log.ToArray());
}
catch
{
}
}
void Finish(int code)
{
Log("finish code=" + code);
#if UNITY_EDITOR
UnityEditor.EditorApplication.Exit(code);
#else
Application.Quit(code);
#endif
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 85b5cf0772320f4408726c98222609db

View File

@ -0,0 +1,434 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
namespace TKS
{
public enum GimmickKind
{
Block = 0, // 장애물: 이동·(설정에 따라) 투사체를 막는 콜라이더 + 내구도
Zone = 1 // 지대: 겹치면 효과(경로 스윕 판정)
}
public enum GimmickProjectile
{
Pass = 0, // 투사체 통과(목책 · 강)
Block = 1, // 투사체 막힘(덤불 · 바위 · 성문 · 성벽)
Destroy = 2 // 맞으면 파괴(폭약 수레)
}
public enum GimmickZone
{
None = 0,
Poison = 1, // 독늪
Slow = 2, // 늪지
Burn = 3, // 불타는 잔해
Slide = 4, // 빙판
High = 5, // 고지
Fog = 6, // 안개
Capture = 7, // 군기
Trap = 8, // 함정
River = 9 // 강(장애물 레코드의 효과)
}
public enum GimmickShapeType
{
Circle = 0,
Rect = 1,
Poly = 2
}
/// <summary>기믹 정의 1행(gimmicks.csv · SOT = 기획 tables/ · C45 — 코드에는 열 이름과 뜻만).</summary>
[Serializable]
public class GimmickDef
{
public string id;
public string nameKo;
public GimmickKind kind;
public bool durable;
public int hp;
public int dmgInf;
public int dmgSpear;
public int dmgCav;
public float spearPush;
public float cavSelfPct;
public float bouncePct;
public GimmickProjectile projectile;
public bool strategistClear;
public GimmickZone zone;
[Tooltip("빈 칸 = NaN (desc_ko 끝 [ ] 메모가 뜻)")]
public float p1 = float.NaN;
public float p2 = float.NaN;
public float p3 = float.NaN;
public string art = "";
public string artBroken = "";
public string descKo = "";
/// <summary>병종이 부딪혀 주는 내구 피해(궁·책 = 0 · 내구 없는 기믹 = 0).</summary>
public int DamageBy(UnitType t)
{
if (!durable) return 0;
switch (t)
{
case UnitType.Infantry: return dmgInf;
case UnitType.Spear: return dmgSpear;
case UnitType.Cavalry: return dmgCav;
}
return 0;
}
}
/// <summary>스테이지 배치 1행(stage_gimmicks.csv). 좌표 = 전장 월드(가운데 0,0 · 아군 아래).</summary>
[Serializable]
public class GimmickPlacement
{
public string stage = "";
public int chapter;
public int stageNo;
public string gimmickId = "";
public GimmickShapeType shape;
public float cx;
public float cy;
[Tooltip("circle = 반지름 · rect = 가로")]
public float wOrR;
public float h;
[Tooltip("rect 회전(도 · 반시계)")]
public float rot;
[Tooltip("poly 꼭짓점(월드) · x:y;x:y;…")]
public Vector2[] points = new Vector2[0];
[Tooltip("-1 = 정의 hp")]
public int hpOverride = -1;
public string paramsRaw = "";
public float pOver1 = float.NaN;
public float pOver2 = float.NaN;
public float pOver3 = float.NaN;
public int line;
public GimmickPlacement Clone()
{
var c = (GimmickPlacement)MemberwiseClone();
c.points = points != null ? (Vector2[])points.Clone() : new Vector2[0];
return c;
}
}
/// <summary>
/// 기믹 표(Assets/Data/Gimmicks/*.csv · utf-8-sig) 해석·검증(PD #1097). 형식·참조 오류는 errors 에 모은다(하나라도 있으면 임포터가 자산을 바꾸지 않는다).
/// 기믹 id 중 동작이 특별한 것(바위 굴림 · 다리 · 불타는 잔해 생성) = GimmickIds — 수치는 모두 CSV.
/// </summary>
public static class GimmickTables
{
public const string Folder = "Data/Gimmicks"; // Assets/ 아래
/// <summary>정의 · 배치 · 스테이지 시작 팁(1장 튜토리얼 문구 · PD #1098 §3).</summary>
public static readonly string[] Files = { "gimmicks.csv", "stage_gimmicks.csv", "tutorial_tips.csv" };
public const string ResourcesFolder = "Gimmicks/";
/// <summary>배치 params 에 쓸 수 있는 키(p1~p3 = 정의 p 덮어쓰기 · 별칭 = 불 burn_turns→p2 · spawn_on_splash→p3 · 빙판 slide_max→p1).</summary>
public static readonly string[] ParamKeys = { "p1", "p2", "p3", "burn_turns", "spawn_on_splash", "slide_max" };
public class Result
{
public readonly List<GimmickDef> defs = new List<GimmickDef>();
public readonly List<GimmickPlacement> rows = new List<GimmickPlacement>();
public readonly List<StageGimmickTable.Tip> tips = new List<StageGimmickTable.Tip>();
public readonly List<string> errors = new List<string>();
}
static GimmickDb db;
static StageGimmickTable table;
/// <summary>임포트한 자산(없으면 null · 전투 시작마다 다시 읽으려면 Invalidate).</summary>
public static GimmickDb Db
{
get
{
if (db == null) db = Resources.Load<GimmickDb>(ResourcesFolder + "GimmickDb");
return db;
}
}
public static StageGimmickTable Table
{
get
{
if (table == null) table = Resources.Load<StageGimmickTable>(ResourcesFolder + "StageGimmickTable");
return table;
}
}
public static void Invalidate()
{
db = null;
table = null;
}
/// <summary>texts = Files 순서 · stageExists(장, 번호) = 스테이지 표에 있는가(null = 검사 생략).</summary>
public static Result Parse(string[] texts, Func<int, int, bool> stageExists = null)
{
var r = new Result();
var ids = new HashSet<string>();
foreach (var row in StageTable.ReadCsv(texts.Length > 0 ? texts[0] : null, "gimmicks.csv", r.errors))
{
string at = "gimmicks.csv 줄 " + Get(row, "#");
var d = new GimmickDef
{
id = Get(row, "id"),
nameKo = Get(row, "name_ko"),
art = Get(row, "art"),
artBroken = Get(row, "art_broken"),
descKo = Get(row, "desc_ko")
};
if (d.id.Length == 0) r.errors.Add(at + ": id 비어 있음");
else if (!ids.Add(d.id)) r.errors.Add(at + ": id 중복 「" + d.id + "」");
switch (Get(row, "kind"))
{
case "block": d.kind = GimmickKind.Block; break;
case "zone": d.kind = GimmickKind.Zone; break;
default: r.errors.Add(at + ": kind 는 block|zone(「" + Get(row, "kind") + "」)"); break;
}
d.durable = Bool(row, "durable", r.errors, at);
d.hp = Int(row, "hp", r.errors, at);
d.dmgInf = Int(row, "dmg_inf", r.errors, at);
d.dmgSpear = Int(row, "dmg_spear", r.errors, at);
d.dmgCav = Int(row, "dmg_cav", r.errors, at);
d.spearPush = Float(row, "spear_push", r.errors, at);
d.cavSelfPct = Float(row, "cav_self_pct", r.errors, at);
d.bouncePct = Float(row, "bounce_pct", r.errors, at);
switch (Get(row, "projectile"))
{
case "pass": d.projectile = GimmickProjectile.Pass; break;
case "block": d.projectile = GimmickProjectile.Block; break;
case "destroy": d.projectile = GimmickProjectile.Destroy; break;
default: r.errors.Add(at + ": projectile 는 pass|block|destroy(「" + Get(row, "projectile") + "」)"); break;
}
d.strategistClear = Bool(row, "strategist_clear", r.errors, at);
string z = Get(row, "zone_effect");
if (!TryZone(z, out d.zone)) r.errors.Add(at + ": zone_effect 알 수 없음(「" + z + "」)");
d.p1 = OptFloat(row, "p1", r.errors, at);
d.p2 = OptFloat(row, "p2", r.errors, at);
d.p3 = OptFloat(row, "p3", r.errors, at);
if (d.durable && d.hp <= 0) r.errors.Add(at + ": durable 1 인데 hp ≤ 0");
if (d.kind == GimmickKind.Zone && d.durable) r.errors.Add(at + ": 지대(zone)는 내구도가 없다");
r.defs.Add(d);
}
foreach (var row in StageTable.ReadCsv(texts.Length > 1 ? texts[1] : null, "stage_gimmicks.csv", r.errors))
{
string at = "stage_gimmicks.csv 줄 " + Get(row, "#");
var p = new GimmickPlacement { stage = Get(row, "stage"), gimmickId = Get(row, "gimmick_id"), paramsRaw = Get(row, "params") };
int.TryParse(Get(row, "#"), NumberStyles.Integer, CultureInfo.InvariantCulture, out p.line);
var parts = p.stage.Split('-');
if (parts.Length != 2 || !int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out p.chapter) ||
!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out p.stageNo) || p.chapter <= 0 || p.stageNo <= 0)
{
r.errors.Add(at + ": stage 는 「장-번호」(「" + p.stage + "」)");
}
else if (stageExists != null && !stageExists(p.chapter, p.stageNo))
{
r.errors.Add(at + ": 스테이지 표에 없는 스테이지 「" + p.stage + "」");
}
GimmickDef def = null;
foreach (var d in r.defs)
{
if (d.id == p.gimmickId) def = d;
}
if (def == null) r.errors.Add(at + ": gimmicks.csv 에 없는 gimmick_id 「" + p.gimmickId + "」");
switch (Get(row, "shape"))
{
case "circle": p.shape = GimmickShapeType.Circle; break;
case "rect": p.shape = GimmickShapeType.Rect; break;
case "poly": p.shape = GimmickShapeType.Poly; break;
default: r.errors.Add(at + ": shape 는 circle|rect|poly(「" + Get(row, "shape") + "」)"); break;
}
if (p.shape != GimmickShapeType.Poly)
{
p.cx = Float(row, "cx", r.errors, at);
p.cy = Float(row, "cy", r.errors, at);
p.wOrR = Float(row, "w_or_r", r.errors, at);
if (p.wOrR <= 0f) r.errors.Add(at + ": w_or_r ≤ 0");
}
if (p.shape == GimmickShapeType.Rect)
{
p.h = Float(row, "h", r.errors, at);
if (p.h <= 0f) r.errors.Add(at + ": rect h ≤ 0");
}
float rot = OptFloat(row, "rot", r.errors, at);
p.rot = float.IsNaN(rot) ? 0f : rot;
if (p.shape == GimmickShapeType.Poly)
{
p.points = ParsePoints(Get(row, "points"), r.errors, at);
if (p.points.Length < 3) r.errors.Add(at + ": poly 꼭짓점 3개 이상(x:y;x:y;x:y)");
else
{
Vector2 c = Vector2.zero;
foreach (var v in p.points) c += v;
c /= p.points.Length;
p.cx = c.x;
p.cy = c.y;
}
}
string hpo = Get(row, "hp_override");
if (hpo.Length > 0)
{
if (!int.TryParse(hpo, NumberStyles.Integer, CultureInfo.InvariantCulture, out p.hpOverride) || p.hpOverride <= 0)
r.errors.Add(at + ": hp_override 양의 정수(「" + hpo + "」)");
}
ParseParams(p, def, r.errors, at);
r.rows.Add(p);
}
if (texts.Length > 2 && !string.IsNullOrEmpty(texts[2]))
{
foreach (var row in StageTable.ReadCsv(texts[2], "tutorial_tips.csv", r.errors))
{
string at = "tutorial_tips.csv 줄 " + Get(row, "#");
var t = new StageGimmickTable.Tip { stage = Get(row, "stage"), text = Get(row, "tip_ko") };
var parts = t.stage.Split('-');
if (parts.Length != 2 || !int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out t.chapter) ||
!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out t.stageNo))
r.errors.Add(at + ": stage 는 「장-번호」(「" + t.stage + "」)");
else if (stageExists != null && !stageExists(t.chapter, t.stageNo)) r.errors.Add(at + ": 스테이지 표에 없는 스테이지 「" + t.stage + "」");
if (t.text.Length == 0) r.errors.Add(at + ": tip_ko 비어 있음");
r.tips.Add(t);
}
}
return r;
}
static void ParseParams(GimmickPlacement p, GimmickDef def, List<string> errors, string at)
{
if (string.IsNullOrEmpty(p.paramsRaw)) return;
foreach (var kv in p.paramsRaw.Split(';'))
{
if (kv.Trim().Length == 0) continue;
var f = kv.Split('=');
float v;
if (f.Length != 2 || !float.TryParse(f[1].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out v))
{
errors.Add(at + ": params 형식 「키=숫자;…」(「" + kv + "」)");
continue;
}
switch (f[0].Trim())
{
case "p1":
case "slide_max":
p.pOver1 = v;
break;
case "p2":
case "burn_turns":
p.pOver2 = v;
break;
case "p3":
case "spawn_on_splash":
p.pOver3 = v;
break;
default:
errors.Add(at + ": params 키 「" + f[0].Trim() + "」 는 " + string.Join("·", ParamKeys) + " 중 하나");
break;
}
}
if (def != null && p.paramsRaw.Contains("spawn_on_splash") && def.zone != GimmickZone.Burn) errors.Add(at + ": spawn_on_splash 는 불(burn) 기믹만");
}
public static Vector2[] ParsePoints(string s, List<string> errors, string at)
{
var list = new List<Vector2>();
if (string.IsNullOrEmpty(s)) return list.ToArray();
foreach (var part in s.Split(';'))
{
if (part.Trim().Length == 0) continue;
var xy = part.Split(':');
float x, y;
if (xy.Length != 2 || !float.TryParse(xy[0].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out x) ||
!float.TryParse(xy[1].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out y))
{
errors.Add(at + ": points 형식 x:y;x:y(「" + part + "」)");
continue;
}
list.Add(new Vector2(x, y));
}
return list.ToArray();
}
public static string FormatPoints(Vector2[] pts)
{
if (pts == null || pts.Length == 0) return "";
var s = new string[pts.Length];
for (int i = 0; i < pts.Length; i++) s[i] = F(pts[i].x) + ":" + F(pts[i].y);
return string.Join(";", s);
}
public static string F(float v)
{
return Mathf.Approximately(v, Mathf.Round(v)) ? Mathf.RoundToInt(v).ToString(CultureInfo.InvariantCulture) : v.ToString("0.###", CultureInfo.InvariantCulture);
}
public static bool TryZone(string s, out GimmickZone z)
{
switch (s)
{
case "": z = GimmickZone.None; return true;
case "poison": z = GimmickZone.Poison; return true;
case "slow": z = GimmickZone.Slow; return true;
case "burn": z = GimmickZone.Burn; return true;
case "slide": z = GimmickZone.Slide; return true;
case "high": z = GimmickZone.High; return true;
case "fog": z = GimmickZone.Fog; return true;
case "capture": z = GimmickZone.Capture; return true;
case "trap": z = GimmickZone.Trap; return true;
case "river": z = GimmickZone.River; return true;
}
z = GimmickZone.None;
return false;
}
static string Get(Dictionary<string, string> r, string key)
{
string v;
return r.TryGetValue(key, out v) ? v : "";
}
static int Int(Dictionary<string, string> r, string key, List<string> errors, string at)
{
int v;
if (int.TryParse(Get(r, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) return v;
errors.Add(at + ": " + key + " 정수 아님(「" + Get(r, key) + "」)");
return 0;
}
static bool Bool(Dictionary<string, string> r, string key, List<string> errors, string at)
{
string s = Get(r, key);
if (s == "1") return true;
if (s == "0") return false;
errors.Add(at + ": " + key + " 는 0|1(「" + s + "」)");
return false;
}
static float Float(Dictionary<string, string> r, string key, List<string> errors, string at)
{
float v;
if (float.TryParse(Get(r, key), NumberStyles.Float, CultureInfo.InvariantCulture, out v)) return v;
errors.Add(at + ": " + key + " 숫자 아님(「" + Get(r, key) + "」)");
return 0f;
}
static float OptFloat(Dictionary<string, string> r, string key, List<string> errors, string at)
{
return Get(r, key).Length == 0 ? float.NaN : Float(r, key, errors, at);
}
}
/// <summary>동작이 id 로 정해지는 기믹(수치는 CSV · 새 기믹이 같은 동작을 쓰면 여기 한 줄).</summary>
public static class GimmickIds
{
/// <summary>보·창이 밀면 굴러가는 바위(남은 거리 × spear_push · 적에 닿으면 공 ×p1 + 밀기 p2).</summary>
public const string Boulder = "boulder";
/// <summary>강 위 통로(겹친 강 구간은 콜라이더에서 뺀다 · 다리 위에서 옆으로 밀리면 강 판정).</summary>
public const string Bridge = "bridge";
/// <summary>폭약 수레 폭발 · 책사 광역(spawn_on_splash)이 만드는 불 지대의 정의.</summary>
public const string BurningDebris = "burning_debris";
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 645600176fecaa047908a4a1acebef2d

View File

@ -0,0 +1,21 @@
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>기믹 정의 표(Resources/Gimmicks/GimmickDb · 메뉴 TKS/Import Gimmicks).</summary>
public class GimmickDb : ScriptableObject
{
public List<GimmickDef> defs = new List<GimmickDef>();
public GimmickDef Get(string id)
{
if (string.IsNullOrEmpty(id)) return null;
for (int i = 0; i < defs.Count; i++)
{
if (defs[i].id == id) return defs[i];
}
return null;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9acfdb0ffd0c7604b8ea57748aed0456

View File

@ -0,0 +1,96 @@
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>전장에 놓인 기믹 1개(정의 + 배치 + 전투 중 상태). 시각 = GimmickView · 콜라이더 = 장애물만.</summary>
public class GimmickInstance
{
public GimmickDef Def;
public GimmickPlacement Src;
public GimmickShape Shape;
public int Hp;
public int MaxHp;
/// <summary>내구 0 = 잔해(통과 가능 · art_broken).</summary>
public bool Broken;
/// <summary>제거됨(책사 광역 덤불 · 발동한 함정).</summary>
public bool Removed;
/// <summary>함정이 드러났다(발동).</summary>
public bool Revealed;
/// <summary>불 지대 남은 라운드(−1 = 영구) · 0 이 되면 Out.</summary>
public int BurnLeft = -1;
/// <summary>불이 꺼짐(잔해만 남음 · 효과 없음).</summary>
public bool Out;
/// <summary>군기 점령 진영(−1 = 중립 · 0 = 아군 · 1 = 적군).</summary>
public int Owner = -1;
/// <summary>전투 중 생긴 기믹(폭발·광역 불).</summary>
public bool Spawned;
/// <summary>배치만 있고 지대는 만들지 않는 생성 규칙(불 spawn_on_splash).</summary>
public bool Template;
public GameObject Go;
public readonly List<Collider2D> Colliders = new List<Collider2D>();
public GimmickView View;
public string Id
{
get { return Def != null ? Def.id : ""; }
}
public GimmickZone Zone
{
get { return Def.zone; }
}
public bool IsBlock
{
get { return Def.kind == GimmickKind.Block; }
}
/// <summary>이동을 막는다(장애물 · 부서지거나 제거되지 않음).</summary>
public bool BlocksMove
{
get { return IsBlock && !Broken && !Removed; }
}
/// <summary>지대 효과가 살아 있다.</summary>
public bool ZoneActive
{
get { return Def.kind == GimmickKind.Zone && !Removed && !Out && !Template; }
}
public bool Rolls
{
get { return Def.id == GimmickIds.Boulder; }
}
public bool IsBridge
{
get { return Def.id == GimmickIds.Bridge; }
}
/// <summary>정의 p(i) · 배치 params 가 있으면 그 값 · 둘 다 비면 fallback.</summary>
public float P(int i, float fallback = 0f)
{
float o = i == 1 ? Src.pOver1 : (i == 2 ? Src.pOver2 : Src.pOver3);
if (!float.IsNaN(o)) return o;
float d = i == 1 ? Def.p1 : (i == 2 ? Def.p2 : Def.p3);
return float.IsNaN(d) ? fallback : d;
}
public void SetCollidersEnabled(bool on)
{
foreach (var c in Colliders)
{
if (c != null) c.enabled = on;
}
}
/// <summary>모양·표시·콜라이더를 d 만큼 옮긴다(목책 밀기 · 바위 굴림).</summary>
public void MoveBy(Vector2 d)
{
Shape = Shape.Translated(d);
if (Go != null) Go.transform.position = new Vector3(Shape.Center.x, Shape.Center.y, 0f);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d1257de6310ea7d4aa26f8cc4d71c6f6

View File

@ -0,0 +1,978 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>유닛 1기의 기믹 상태(중독 · 행동 불가 · 상태 글자).</summary>
public class GimmickUnitState
{
public int PoisonTurns;
public int PoisonTick;
public float PoisonPct;
public float PoisonStep;
/// <summary>남은 행동 불가 턴(함정 · 강) — 그 진영 턴 시작에 1 소모하고 그 턴을 쉰다.</summary>
public int Stun;
/// <summary>이번 턴 행동 불가(턴 시작에 정함).</summary>
public bool Skip;
public TextMesh Badge;
public GimmickUnitState Copy()
{
return (GimmickUnitState)MemberwiseClone();
}
}
/// <summary>
/// 지형 기믹 레이어(PD #1097 · 기획서 지형기믹_기획서.md) — 스테이지와 독립된 레이어. 전투 시작마다 그 스테이지의 배치(stage_gimmicks.csv)로
/// 장애물(콜라이더 + 내구)·지대(모양 · 경로 스윕)를 만든다(배경 위 · 유닛 아래). 전투 코드는 이 클래스의 질의만 부른다:
/// 이동 막기(Combat.FreeTravel) · 함정 정지 · 늪·고지 이동 비용 · 고지 사거리·근접 피해 · 군기 공격 · 안개 표적 · 광역 막힘 · 턴 시작(중독·불·행동 불가·군기).
/// 충돌 반응(튕김·밀기·파괴·폭발·미끄러짐)은 ActionResolver.Gimmicks.cs.
/// </summary>
public class GimmickLayer : MonoBehaviour
{
public static GimmickLayer Current { get; private set; }
/// <summary>조준 미리보기 계산 중(아군 = 숨은 함정을 모른다).</summary>
public static bool PreviewMode;
BattleManager battle;
readonly List<GimmickInstance> items = new List<GimmickInstance>();
readonly Dictionary<Unit, GimmickUnitState> states = new Dictionary<Unit, GimmickUnitState>();
GimmickInstance splashTemplate;
/// <summary>지금 처리 중인 행동의 결과(기믹 효과가 Displaced·Notes 를 더한다).</summary>
public ActionOutcome Outcome;
public struct ForcedMove
{
public Unit Unit;
public Vector2 Dir;
public float Intended;
public float Actual;
}
/// <summary>이번 행동에서 밀림·튕김이 막힌 기록(다리 → 강 판정).</summary>
public readonly List<ForcedMove> Forced = new List<ForcedMove>();
public struct Affect
{
public Unit Unit;
public GimmickZone Kind;
/// <summary>앞으로 받을 피해 환산(중독 = 남은 턴 합계 · 그 밖 = 0 · 즉시 피해는 HP 로 따로 센다).</summary>
public float Value;
}
/// <summary>이번 행동에서 지대 효과를 받은 유닛(독·불·함정·강) — 적 AI 점수(목적지 기믹 가치 · PD #1098).</summary>
public readonly List<Affect> Affected = new List<Affect>();
public GimmickPreview Preview { get; private set; }
public IReadOnlyList<GimmickInstance> Items
{
get { return items; }
}
public BattleManager Battle
{
get { return battle; }
}
public bool HasAny
{
get { return items.Count > 0; }
}
public static GimmickLayer Ensure(BattleManager bm)
{
if (Current != null && Current.battle == bm) return Current;
var go = new GameObject("GimmickLayer");
var layer = go.AddComponent<GimmickLayer>();
layer.battle = bm;
layer.Preview = new GimmickPreview(layer);
Current = layer;
return layer;
}
void OnDestroy()
{
if (Current == this) Current = null;
}
// ---------- 생성 ----------
/// <summary>전투 시작: 그 스테이지(장-번호)의 배치를 만든다(정의·배치 = 임포트 자산 · 없으면 빈 레이어).</summary>
public void Build(BalanceConfig.StageDef stage)
{
Clear();
GimmickTables.Invalidate();
var db = GimmickTables.Db;
var table = GimmickTables.Table;
if (db == null || table == null || stage == null) return;
foreach (var p in table.For(stage.chapter, stage.stage))
{
var def = db.Get(p.gimmickId);
if (def == null) continue;
Spawn(def, p.Clone(), false);
}
RebuildRivers();
ResolveOverlaps();
}
/// <summary>배치(편성 규칙 좌표)가 장애물 기믹과 겹친 유닛은 가장 가까운 빈 자리로 옮긴다(겹친 채면 캐스트가 거리 0 으로 막힘).</summary>
public int ResolveOverlaps()
{
if (battle == null) return 0;
Physics2D.SyncTransforms();
int moved = 0;
foreach (var u in battle.Units)
{
if (u == null || !u.IsAlive || Free(u, u.Position)) continue;
bool done = false;
for (float r = 0.2f; r <= 4f && !done; r += 0.2f)
{
for (int k = 0; k < 16 && !done; k++)
{
float a = k * Mathf.PI / 8f;
Vector2 p = battle.Board.ClampInside(u.Position + new Vector2(Mathf.Cos(a), Mathf.Sin(a)) * r, u.Radius);
if (!Free(u, p)) continue;
u.Position = p;
done = true;
moved++;
}
}
}
if (moved > 0) Physics2D.SyncTransforms();
return moved;
}
bool Free(Unit u, Vector2 p)
{
foreach (var g in items)
{
if (g.BlocksMove && g.Shape.SignedDistance(p) < u.Radius + 0.05f) return false;
}
foreach (var o in battle.Units)
{
if (o == null || o == u || !o.IsAlive) continue;
if (Vector2.Distance(o.Position, p) < o.Radius + u.Radius + 0.02f) return false;
}
return true;
}
public void Clear()
{
foreach (var g in items)
{
if (g.Go != null)
{
g.Go.SetActive(false);
Destroy(g.Go);
}
}
items.Clear();
foreach (var kv in states)
{
if (kv.Value.Badge != null) Destroy(kv.Value.Badge.gameObject);
}
states.Clear();
splashTemplate = null;
Forced.Clear();
Outcome = null;
if (Preview != null) Preview.Hide();
}
/// <summary>정의 + 배치로 1개 생성(전투 중 생성 · 테스트 · 페인터 공용). 불 spawn_on_splash 배치는 지대 없이 생성 규칙으로만 둔다.</summary>
public GimmickInstance Spawn(GimmickDef def, GimmickPlacement src, bool spawned)
{
var g = new GimmickInstance { Def = def, Src = src, Shape = GimmickShape.From(src), Spawned = spawned };
g.MaxHp = def.durable ? (src.hpOverride > 0 ? src.hpOverride : def.hp) : 0;
g.Hp = g.MaxHp;
if (def.zone == GimmickZone.Burn)
{
float turns = g.P(2, 0f);
g.BurnLeft = turns > 0f ? Mathf.RoundToInt(turns) : -1;
if (!spawned && g.P(3, 0f) >= 0.5f)
{
g.Template = true; // 8-15 화공: 책사 광역이 떨어진 자리에 생긴다
splashTemplate = g;
}
}
items.Add(g);
if (!g.Template && !Sim.Active) BuildObject(g);
return g;
}
/// <summary>테스트·페인터: id 와 모양으로 생성(params = 「키=값;…」).</summary>
public GimmickInstance Spawn(string id, GimmickShape shape, string param = null, int hpOverride = -1)
{
var def = GimmickTables.Db != null ? GimmickTables.Db.Get(id) : null;
if (def == null)
{
Debug.LogError("[TKS] 기믹 정의 없음: " + id + " (TKS/Import Gimmicks)");
return null;
}
var p = new GimmickPlacement
{
stage = "", gimmickId = id, shape = shape.Type, cx = shape.Center.x, cy = shape.Center.y,
wOrR = shape.Type == GimmickShapeType.Circle ? shape.R : shape.Size.x, h = shape.Size.y, rot = shape.Rot,
points = shape.Points != null ? (Vector2[])shape.Points.Clone() : new Vector2[0], hpOverride = hpOverride, paramsRaw = param ?? ""
};
if (!string.IsNullOrEmpty(param))
{
foreach (var kv in param.Split(';'))
{
var f = kv.Split('=');
float v;
if (f.Length != 2 || !float.TryParse(f[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v)) continue;
string k = f[0].Trim();
if (k == "p1" || k == "slide_max") p.pOver1 = v;
else if (k == "p2" || k == "burn_turns") p.pOver2 = v;
else if (k == "p3" || k == "spawn_on_splash") p.pOver3 = v;
}
}
var g = Spawn(def, p, false);
if (def.zone == GimmickZone.River || def.id == GimmickIds.Bridge) RebuildRivers();
return g;
}
void BuildObject(GimmickInstance g)
{
g.Go = new GameObject("G_" + g.Def.id);
g.Go.transform.SetParent(transform, false);
g.Go.transform.position = new Vector3(g.Shape.Center.x, g.Shape.Center.y, 0f);
if (g.IsBlock && g.Zone != GimmickZone.River) AddCollider(g, g.Shape, g.Go.transform);
g.View = new GimmickView(g);
}
static void AddCollider(GimmickInstance g, GimmickShape s, Transform parent)
{
var cgo = new GameObject("Collider");
cgo.transform.SetParent(parent, false);
cgo.transform.position = new Vector3(s.Center.x, s.Center.y, 0f);
Collider2D col;
switch (s.Type)
{
case GimmickShapeType.Circle:
{
var c = cgo.AddComponent<CircleCollider2D>();
c.radius = s.R;
col = c;
break;
}
case GimmickShapeType.Rect:
{
cgo.transform.rotation = Quaternion.Euler(0f, 0f, s.Rot);
var b = cgo.AddComponent<BoxCollider2D>();
b.size = s.Size;
col = b;
break;
}
default:
{
var pc = cgo.AddComponent<PolygonCollider2D>();
var pts = new Vector2[s.Points.Length];
for (int i = 0; i < pts.Length; i++) pts[i] = s.Points[i] - s.Center;
pc.points = pts;
col = pc;
break;
}
}
col.isTrigger = false;
cgo.AddComponent<GimmickBody>().Inst = g;
g.Colliders.Add(col);
}
/// <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)
{
if (c == null) return null;
var b = c.GetComponent<GimmickBody>();
return b != null ? b.Inst : null;
}
/// <summary>이 콜라이더가 이동을 막는 기믹인가(부서진 잔해·제거된 덤불 = 아님).</summary>
public static bool BlocksMove(Collider2D c)
{
var g = InstOf(c);
return g != null && g.BlocksMove;
}
public static float AttackMultOf(Unit a)
{
var l = Current;
return l == null ? 1f : l.AttackMult(a);
}
public static float MeleeTakenMultOf(Unit a, Unit d)
{
var l = Current;
return l == null ? 1f : l.MeleeTakenMult(a, d);
}
public static float RangeBonusOf(Unit u)
{
var l = Current;
return l == null ? 0f : l.RangeBonus(u);
}
public static bool IsFogged(Unit u)
{
var l = Current;
return l != null && l.Fogged(u);
}
public static bool Skips(Unit u)
{
var l = Current;
if (l == null || u == null) return false;
GimmickUnitState s;
return l.states.TryGetValue(u, out s) && s.Skip;
}
// ---------- 위치 질의 ----------
public GimmickUnitState StateOf(Unit u, bool create = false)
{
GimmickUnitState s;
if (u == null) return null;
if (!states.TryGetValue(u, out s) && create)
{
s = new GimmickUnitState();
states[u] = s;
}
return s;
}
public GimmickInstance ZoneAt(Vector2 p, GimmickZone z)
{
foreach (var g in items)
{
if (g.ZoneActive && g.Zone == z && g.Shape.Contains(p)) return g;
}
return null;
}
public bool OnBridge(Vector2 p)
{
foreach (var g in items)
{
if (g.IsBridge && !g.Removed && g.Shape.Contains(p)) return true;
}
return false;
}
/// <summary>숨은 함정: origin → dir 로 dist 안에서 중심이 처음 함정에 드는 거리(+0.06 = 안으로 살짝) · 없으면 dist. 조준 미리보기(아군)는 모른다.</summary>
public float TrapStop(Unit actor, Vector2 origin, Vector2 dir, float dist)
{
if (PreviewMode) return dist;
float best = dist;
foreach (var g in items)
{
if (g.Zone != GimmickZone.Trap || !g.ZoneActive || g.Shape.Contains(origin)) continue;
float t = g.Shape.EnterDistance(origin, dir, dist);
if (t >= 0f) best = Mathf.Min(best, t + 0.06f);
}
return best;
}
/// <summary>
/// 이동 비용(늪 = 안 구간 ×p1 · 고지 = 밖에서 오르는 구간 ×p3): budget(슬링샷 이동량)으로 실제로 갈 수 있는 거리.
/// 늪에서 멈추면 다음 행동도 늪 안 구간만큼 반감(출발점이 늪이어도 같은 계산).
/// </summary>
public float CostReach(Unit u, Vector2 origin, Vector2 dir, float budget)
{
if (budget <= 0f) return 0f;
bool any = false;
foreach (var g in items)
{
if (!g.ZoneActive || (g.Zone != GimmickZone.Slow && g.Zone != GimmickZone.High)) continue;
if (Combat.SegmentPointDistance(origin, origin + dir * budget, g.Shape.Center) > g.Shape.BoundingRadius + 0.01f) continue;
any = true;
break;
}
if (!any) return budget;
const float step = GimmickShape.SweepStep;
float used = 0f, t = 0f;
int guard = 0;
while (used < budget - 1e-4f && guard++ < 4000)
{
Vector2 p = origin + dir * (t + step * 0.5f);
float m = 1f;
foreach (var g in items)
{
if (!g.ZoneActive || !g.Shape.Contains(p)) continue;
if (g.Zone == GimmickZone.Slow) m *= Mathf.Max(1f, g.P(1, 1f));
else if (g.Zone == GimmickZone.High && !g.Shape.Contains(origin)) m *= Mathf.Max(1f, g.P(3, 1f));
}
float cost = step * m;
if (used + cost >= budget)
{
t += (budget - used) / m;
return t;
}
used += cost;
t += step;
}
return t;
}
/// <summary>고지 위의 궁·책 사거리 +p1.</summary>
public float RangeBonus(Unit u)
{
if (u == null || !UnitTypeInfo.IsRanged(u.Type)) return 0f;
var g = ZoneAt(u.Position, GimmickZone.High);
return g != null ? g.P(1, 0f) : 0f;
}
/// <summary>고지 위 유닛이 아래(고지 밖)에서 오는 근접 피해 × (1 − p2).</summary>
public float MeleeTakenMult(Unit a, Unit d)
{
if (a == null || d == null) return 1f;
var g = ZoneAt(d.Position, GimmickZone.High);
if (g == null || ZoneAt(a.Position, GimmickZone.High) != null) return 1f;
return Mathf.Max(0f, 1f - g.P(2, 0f));
}
/// <summary>군기 점령 측 전원 공 × (1 + Σp2).</summary>
public float AttackMult(Unit a)
{
if (a == null) return 1f;
float add = 0f;
foreach (var g in items)
{
if (g.Zone == GimmickZone.Capture && g.ZoneActive && g.Owner == (int)a.Faction) add += g.P(2, 0f);
}
return 1f + add;
}
/// <summary>안개 안 유닛 = 사선 표적·화살 판정에서 빠진다(안에서 쏘기는 가능).</summary>
public bool Fogged(Unit u)
{
return u != null && ZoneAt(u.Position, GimmickZone.Fog) != null;
}
/// <summary>광역 중심이 안개면 범위 × p1.</summary>
public float FogSplashMult(Vector2 c)
{
var g = ZoneAt(c, GimmickZone.Fog);
return g != null ? g.P(1, 1f) : 1f;
}
/// <summary>광역이 중심 → p 사이에서 막히는가(성문·성벽·바위 = 투사체 막힘 + 책사 제거 불가).</summary>
public bool SplashBlocked(Vector2 c, Vector2 p)
{
if (items.Count == 0 || (p - c).sqrMagnitude < 1e-4f) return false;
Physics2D.SyncTransforms();
foreach (var h in Physics2D.LinecastAll(c, p))
{
var g = InstOf(h.collider);
if (g == null || !g.BlocksMove) continue;
if (g.Def.projectile == GimmickProjectile.Block && !g.Def.strategistClear && !g.Shape.Contains(c)) return true;
}
return false;
}
/// <summary>기병 관통 옆 밀기 배율: 늪 안 = 0 · 빙판 위 = ×p2.</summary>
public float KnockMult(Unit e)
{
if (e == null) return 1f;
if (ZoneAt(e.Position, GimmickZone.Slow) != null) return 0f;
var ice = ZoneAt(e.Position, GimmickZone.Slide);
return ice != null ? Mathf.Max(0f, ice.P(2, 1f)) : 1f;
}
/// <summary>정지점이 빙판이면 같은 방향으로 빙판이 끝나는 곳까지(최대 p1) 미끄러질 거리 · 아니면 0.</summary>
public float SlideDistance(Vector2 pos, Vector2 dir)
{
var ice = ZoneAt(pos, GimmickZone.Slide);
if (ice == null || dir.sqrMagnitude < 1e-6f) return 0f;
float max = Mathf.Max(0f, ice.P(1, 0f));
return Mathf.Min(max, ice.Shape.ExitDistance(pos, dir.normalized, max));
}
/// <summary>책사 광역 반경 안의 제거 가능한 장애물(strategist_clear = 덤불 제거 · 폭약 수레 파괴).</summary>
public List<GimmickInstance> ClearableIn(Vector2 c, float r)
{
var res = new List<GimmickInstance>();
foreach (var g in items)
{
if (!g.BlocksMove || !g.Def.strategistClear) continue;
if (g.Shape.SignedDistance(c) <= r) res.Add(g);
}
return res;
}
/// <summary>책사 광역이 떨어진 자리에 불(스테이지에 spawn_on_splash 불 배치가 있을 때 · 반경 = 광역 반경 · 지속 = 그 배치 burn_turns).</summary>
public GimmickInstance OnSplash(Vector2 c, float r)
{
if (splashTemplate == null) return null;
return SpawnBurning(c, r, splashTemplate.P(2, 0f));
}
/// <summary>폭약 폭발 자리 불(반경 = 폭발 반경 · 지속 = 스테이지 화공 배치 burn_turns · 없으면 정의 p2 · 0 = 영구).</summary>
public GimmickInstance SpawnFire(Vector2 c, float r)
{
float turns = 0f;
if (splashTemplate != null) turns = splashTemplate.P(2, 0f);
else
{
var def = GimmickTables.Db != null ? GimmickTables.Db.Get(GimmickIds.BurningDebris) : null;
if (def != null && !float.IsNaN(def.p2)) turns = def.p2;
}
return SpawnBurning(c, r, turns);
}
public GimmickInstance SpawnBurning(Vector2 c, float r, float turns)
{
var db = GimmickTables.Db;
var def = db != null ? db.Get(GimmickIds.BurningDebris) : null;
if (def == null) return null;
var p = new GimmickPlacement { gimmickId = def.id, shape = GimmickShapeType.Circle, cx = c.x, cy = c.y, wOrR = Mathf.Max(0.3f, r), pOver2 = turns, pOver3 = 0f };
return Spawn(def, p, true);
}
public void Remove(GimmickInstance g)
{
if (g == null || g.Removed) return;
g.Removed = true;
g.SetCollidersEnabled(false);
if (!Sim.Active && g.View != null) g.View.Vanish(g.Zone == GimmickZone.Trap);
}
/// <summary>내구 피해(흔들림 · 핍) → 0 이면 잔해. 반환 = 이번에 부서졌다.</summary>
public bool Hit(GimmickInstance g, int dmg)
{
if (g == null || !g.BlocksMove || !g.Def.durable || dmg <= 0) return false;
g.Hp = Mathf.Max(0, g.Hp - dmg);
bool broke = g.Hp <= 0;
if (broke)
{
g.Broken = true;
g.SetCollidersEnabled(false);
}
if (!Sim.Active && g.View != null)
{
g.View.Shake();
if (broke) g.View.Shatter();
else g.View.Refresh();
Fx.Popup(g.Shape.Center, broke ? g.Def.nameKo + " 파괴" : g.Def.nameKo + " −" + dmg, new Color(1f, 0.8f, 0.4f));
}
return broke;
}
// ---------- 상태(중독 · 행동 불가) ----------
public void Poison(Unit u, GimmickInstance g)
{
var s = StateOf(u, true);
if (s.PoisonTurns <= 0) s.PoisonTick = 0; // 재진입 = 지속만 갱신(중첩 없음)
s.PoisonTurns = Mathf.Max(1, Mathf.RoundToInt(g.P(1, 1f)));
s.PoisonPct = g.P(2, 0f);
s.PoisonStep = g.P(3, 0f);
Text(u, "중독", new Color(0.75f, 0.45f, 1f));
RefreshBadge(u);
}
public void Stun(Unit u, int turns, string why)
{
if (u == null || turns <= 0) return;
var s = StateOf(u, true);
s.Stun = Mathf.Max(s.Stun, turns);
Text(u, why, new Color(1f, 0.85f, 0.3f));
RefreshBadge(u);
}
/// <summary>최대 HP 비율 피해(지대 · 튕김 자기 피해) — 반격·사망과 같은 경로(SkillSystem.Deal). 반환 = 준 피해.</summary>
public int PctDamage(Unit u, float pct, string label)
{
if (u == null || !u.IsAlive || pct <= 0f) return 0;
int dmg = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * pct));
dmg = SkillSystem.Deal(u, dmg);
Text(u, (string.IsNullOrEmpty(label) ? "" : label + " ") + "−" + dmg, new Color(1f, 0.55f, 0.3f));
return dmg;
}
public void Text(Unit u, string text, Color c)
{
if (Sim.Active || u == null) return;
Fx.Popup(u.Position + new Vector2(0f, 0.35f), text, c);
}
void RefreshBadge(Unit u)
{
if (Sim.Active || u == null) return;
var s = StateOf(u);
if (s == null) return;
string t = "";
if (s.PoisonTurns > 0) t += "독" + s.PoisonTurns;
if (s.Stun > 0 || s.Skip) t += (t.Length > 0 ? " " : "") + "행동불가";
if (s.Badge == null)
{
if (t.Length == 0) return;
s.Badge = TextUtil.CreateWorldText(u.transform, t, 0.24f, new Color(0.85f, 0.6f, 1f), 6);
s.Badge.gameObject.name = "GimmickStatus";
float top = u.View != null && u.View.HasArt ? -u.Radius * 0.55f + u.ArtHeight + 0.12f : u.Radius + 0.22f;
s.Badge.transform.localPosition = new Vector3(0f, top + 0.78f, 0f);
}
s.Badge.text = t;
s.Badge.gameObject.SetActive(t.Length > 0 && u.IsAlive);
}
public string BadgeText(Unit u)
{
var s = StateOf(u);
return s != null && s.Badge != null && s.Badge.gameObject.activeSelf ? s.Badge.text : "";
}
// ---------- 턴 시작 ----------
/// <summary>
/// side 진영 턴 시작: 군기 점령 판정 → (아군 턴 = 라운드 시작) 불 지속 −1 → 그 진영 유닛의 중독(최대 HP p2 + p3 × 지난 턴 수) ·
/// 불 위에서 멈춘 유닛(p1) · 행동 불가(남은 턴 1 소모 · 아군 = 행동 끝 표시).
/// </summary>
public IEnumerator TurnStart(Faction side)
{
if (items.Count == 0 && states.Count == 0) yield break;
bool any = false;
foreach (var g in items)
{
if (g.Zone != GimmickZone.Capture || !g.ZoneActive) continue;
float r = g.P(1, 0f);
bool p = false, e = false;
foreach (var u in battle.Units)
{
if (u == null || !u.IsAlive) continue;
if (Vector2.Distance(u.Position, g.Shape.Center) - u.Radius > r) continue;
if (u.Faction == Faction.Player) p = true;
else e = true;
}
int owner = p && e ? -1 : p ? (int)Faction.Player : e ? (int)Faction.Enemy : g.Owner;
if (owner != g.Owner)
{
g.Owner = owner;
if (!Sim.Active && g.View != null)
{
g.View.Refresh();
Fx.Popup(g.Shape.Center, owner < 0 ? "군기 중립" : owner == 0 ? "아군 군기 점령" : "적군 군기 점령", new Color(1f, 0.9f, 0.4f));
}
any = true;
}
}
if (side == Faction.Player)
{
foreach (var g in items)
{
if (g.Zone != GimmickZone.Burn || !g.ZoneActive || g.BurnLeft <= 0) continue;
g.BurnLeft--;
if (g.BurnLeft == 0) g.Out = true;
if (!Sim.Active && g.View != null) g.View.Refresh();
}
}
foreach (var u in battle.AliveUnits(side))
{
var s = StateOf(u);
if (s != null && s.PoisonTurns > 0)
{
PctDamage(u, s.PoisonPct + s.PoisonStep * s.PoisonTick, "중독");
s.PoisonTick++;
s.PoisonTurns--;
any = true;
}
if (u.IsAlive)
{
var burn = ZoneAt(u.Position, GimmickZone.Burn);
if (burn != null)
{
PctDamage(u, burn.P(1, 0f), "불");
any = true;
}
}
if (s != null)
{
s.Skip = false;
if (s.Stun > 0 && u.IsAlive)
{
s.Stun--;
s.Skip = true;
if (side == Faction.Player) u.MarkActed();
Text(u, "행동 불가", new Color(1f, 0.85f, 0.3f));
any = true;
}
RefreshBadge(u);
}
}
if (any)
{
battle.NotifyChanged();
yield return Fx.Wait(0.35f);
}
}
// ---------- 행동 뒤 지대(경로 스윕) ----------
/// <summary>
/// 이 행동에서 움직인 모든 유닛(행동 유닛 경로 + 밀림·넉백·튕김 구간)의 중심 경로가 지대와 겹치면 효과:
/// 독늪 = 중독(재진입 갱신) · 불 = 최대 HP p1(행동당 1회) · 함정 = 그 자리 정지(이동 계산에서 이미 멈춤) + p1 턴 행동 불가 + 제거.
/// 그 뒤 다리 위에서 옆으로 밀려 강에 막힌 유닛 = 최대 HP p1 + p2 턴 행동 불가.
/// </summary>
public void ApplyZones(Unit mover, ActionOutcome o)
{
if (items.Count == 0 || o == null) return;
var segs = new Dictionary<Unit, List<Vector2>>();
if (mover != null && o.Path.Count > 0) segs[mover] = new List<Vector2>(o.Path);
foreach (var d in o.Displaced)
{
if (d.Unit == null) continue;
List<Vector2> l;
if (!segs.TryGetValue(d.Unit, out l))
{
l = new List<Vector2>();
segs[d.Unit] = l;
}
l.Add(d.From);
l.Add(d.To);
l.Add(new Vector2(float.NaN, float.NaN)); // 구간 끊김
}
foreach (var kv in segs)
{
var u = kv.Key;
var pts = kv.Value;
bool burned = false;
foreach (var g in items)
{
if (!g.ZoneActive || !u.IsAlive) continue;
if (g.Zone != GimmickZone.Poison && g.Zone != GimmickZone.Burn && g.Zone != GimmickZone.Trap) continue;
if (!Touches(g.Shape, pts, u.Position)) continue;
switch (g.Zone)
{
case GimmickZone.Poison:
{
Poison(u, g);
var s = StateOf(u);
float future = 0f;
for (int k = 0; k < s.PoisonTurns; k++) future += (s.PoisonPct + s.PoisonStep * (s.PoisonTick + k)) * u.MaxHp;
Affected.Add(new Affect { Unit = u, Kind = g.Zone, Value = future });
break;
}
case GimmickZone.Burn:
if (burned) break;
burned = true;
PctDamage(u, g.P(1, 0f), "불");
Affected.Add(new Affect { Unit = u, Kind = g.Zone });
break;
case GimmickZone.Trap:
Remove(g);
g.Revealed = true;
Stun(u, Mathf.Max(1, Mathf.RoundToInt(g.P(1, 1f))), "함정!");
o.Notes.Add(u.DisplayName + " 함정");
Affected.Add(new Affect { Unit = u, Kind = g.Zone });
break;
}
}
}
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 (river == null) continue;
PctDamage(u, river.P(1, 0f), "강에 빠짐");
Stun(u, Mathf.Max(1, Mathf.RoundToInt(river.P(2, 1f))), "강에 빠짐");
o.Notes.Add(u.DisplayName + " 강에 빠짐");
Affected.Add(new Affect { Unit = u, Kind = GimmickZone.River });
}
Forced.Clear();
}
static bool Touches(GimmickShape s, List<Vector2> pts, Vector2 end)
{
if (pts.Count == 1) return s.Contains(pts[0]);
for (int i = 0; i + 1 < pts.Count; i++)
{
if (float.IsNaN(pts[i].x) || float.IsNaN(pts[i + 1].x)) continue;
if (s.SegmentTouches(pts[i], pts[i + 1])) return true;
}
return s.Contains(end);
}
GimmickInstance RiverAhead(Unit u, Vector2 dir, float dist)
{
Physics2D.SyncTransforms();
foreach (var h in Physics2D.CircleCastAll(u.Position, u.Radius * Combat.CastRadiusScale, dir, dist))
{
var g = InstOf(h.collider);
if (g != null && g.Zone == GimmickZone.River && g.BlocksMove) return g;
}
return null;
}
public void NoteForced(Unit u, Vector2 dir, float intended, float actual)
{
if (u == null || items.Count == 0 || dir.sqrMagnitude < 1e-6f) return;
Forced.Add(new ForcedMove { Unit = u, Dir = dir.normalized, Intended = intended, Actual = actual });
}
// ---------- 가상 실행(적 AI) 스냅숏 ----------
public class Snapshot
{
public int Count;
public readonly List<GimmickInstance> Refs = new List<GimmickInstance>();
public readonly List<int> Hp = new List<int>();
public readonly List<int> Flags = new List<int>();
public readonly List<int> BurnLeft = new List<int>();
public readonly List<int> Owner = new List<int>();
public readonly List<Vector2> Center = new List<Vector2>();
public readonly Dictionary<Unit, GimmickUnitState> States = new Dictionary<Unit, GimmickUnitState>();
public GimmickInstance Template;
}
public Snapshot Take()
{
var s = new Snapshot { Count = items.Count, Template = splashTemplate };
foreach (var g in items)
{
s.Refs.Add(g);
s.Hp.Add(g.Hp);
s.Flags.Add((g.Broken ? 1 : 0) | (g.Removed ? 2 : 0) | (g.Revealed ? 4 : 0) | (g.Out ? 8 : 0));
s.BurnLeft.Add(g.BurnLeft);
s.Owner.Add(g.Owner);
s.Center.Add(g.Shape.Center);
}
foreach (var kv in states) s.States[kv.Key] = kv.Value.Copy();
return s;
}
public void Restore(Snapshot s)
{
if (s == null) return;
for (int i = items.Count - 1; i >= s.Count; i--)
{
if (items[i].Go != null) Destroy(items[i].Go);
items.RemoveAt(i);
}
for (int i = 0; i < s.Count; i++)
{
var g = s.Refs[i];
items[i] = g;
g.Hp = s.Hp[i];
int f = s.Flags[i];
g.Broken = (f & 1) != 0;
g.Removed = (f & 2) != 0;
g.Revealed = (f & 4) != 0;
g.Out = (f & 8) != 0;
g.BurnLeft = s.BurnLeft[i];
g.Owner = s.Owner[i];
if ((g.Shape.Center - s.Center[i]).sqrMagnitude > 1e-8f) g.MoveBy(s.Center[i] - g.Shape.Center);
g.SetCollidersEnabled(g.BlocksMove);
}
splashTemplate = s.Template;
foreach (var kv in s.States)
{
GimmickUnitState cur;
if (states.TryGetValue(kv.Key, out cur))
{
var badge = cur.Badge;
states[kv.Key] = kv.Value.Copy();
states[kv.Key].Badge = badge;
}
else states[kv.Key] = kv.Value.Copy();
}
var extra = new List<Unit>();
foreach (var kv in states)
{
if (!s.States.ContainsKey(kv.Key)) extra.Add(kv.Key);
}
foreach (var u in extra)
{
if (states[u].Badge == null) states.Remove(u);
else
{
var b = states[u].Badge;
states[u] = new GimmickUnitState { Badge = b };
}
}
Forced.Clear();
Affected.Clear();
Outcome = null;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 297969a5821fa9e428e5c0b0920c81eb

View File

@ -0,0 +1,316 @@
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>
/// 조준 미리보기(PD #1097 §6 + #1098 §3): 이 슬링샷을 가상 실행(Sim · 연출 없음 · 숨은 함정 모름)해 그 결과를 그린다 —
/// 행동 유닛 실제 경로(튕김·연쇄·창병 반작용·빙판 미끄러짐 = 점선) · 밀릴 유닛 궤적(상대 = 주황 · 같은 편 = 하늘 점선) ·
/// 장애물에 닿는 지점 = 붉게 끊김 + 결과 글자(내구 −n · 파괴 · 굴림 · 밀림 · 폭발 · 튕김) · 지나는 지대 = 색 띠(늪 갈색 · 독 보라 · 불 주황 · 빙판 하늘 · 고지 황토 · 안개 흰색).
/// </summary>
public class GimmickPreview
{
static readonly Color MoverColor = new Color(1f, 0.95f, 0.5f, 0.95f);
static readonly Color FoeTrackColor = new Color(1f, 0.6f, 0.15f, 0.9f);
static readonly Color AllyTrackColor = new Color(0.45f, 0.8f, 1f, 0.9f);
static readonly Color CutColor = new Color(1f, 0.15f, 0.15f, 0.9f);
static readonly Color IconColor = new Color(1f, 0.85f, 0.35f);
readonly GimmickLayer layer;
readonly Transform root;
readonly List<LineRenderer> dashes = new List<LineRenderer>();
readonly List<LineRenderer> bands = new List<LineRenderer>();
readonly List<TextMesh> icons = new List<TextMesh>();
LineRenderer cut;
static Material dashMaterial;
static Material solidMaterial;
int usedDash, usedBand, usedIcon;
/// <summary>마지막 미리보기 결과(테스트 확인용).</summary>
public readonly List<string> LastIcons = new List<string>();
public readonly List<Color> LastBandColors = new List<Color>();
public int LastTracks;
public bool LastCut;
public Vector2 LastMoverEnd;
public GimmickPreview(GimmickLayer l)
{
layer = l;
var go = new GameObject("AimPreview");
go.transform.SetParent(l.transform, false);
root = go.transform;
}
public static Color BandColor(GimmickZone z)
{
switch (z)
{
case GimmickZone.Slow: return new Color(0.55f, 0.38f, 0.15f, 0.75f);
case GimmickZone.Poison: return new Color(0.7f, 0.35f, 0.95f, 0.75f);
case GimmickZone.Burn: return new Color(1f, 0.5f, 0.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.Fog: return new Color(0.95f, 0.95f, 1f, 0.6f);
}
return new Color(1f, 1f, 1f, 0f);
}
public bool Visible
{
get { return usedDash + usedBand + usedIcon > 0 || (cut != null && cut.enabled); }
}
public void Hide()
{
usedDash = usedBand = usedIcon = 0;
Flush();
if (cut != null) cut.enabled = false;
}
/// <summary>조준 중 1회(드래그 프레임마다). shot = 궁·책 사격 계획(있으면 막힘·폭발 글자).</summary>
public void Show(Unit u, Vector2 target, bool moveOnly, ActionResolver.ShotPlan shot)
{
usedDash = usedBand = usedIcon = 0;
LastIcons.Clear();
LastBandColors.Clear();
LastTracks = 0;
LastCut = false;
if (cut != null) cut.enabled = false;
var bm = layer.Battle;
if (u == null || bm == null || !u.IsAlive)
{
Flush();
return;
}
// 가상 실행(연출 없음 · 숨은 함정 모름 · 상태는 되돌린다)
var snap = Sim.Take(bm);
var gBefore = new List<KeyValuePair<GimmickInstance, int>>();
var gPos = new List<Vector2>();
foreach (var g in layer.Items)
{
gBefore.Add(new KeyValuePair<GimmickInstance, int>(g, g.Hp * 4 + (g.Broken ? 1 : 0) + (g.Removed ? 2 : 0)));
gPos.Add(g.Shape.Center);
}
var o = new ActionOutcome();
var events = new List<string>();
var moverPath = new List<Vector2>();
var tracks = new List<KeyValuePair<Unit, Displacement>>();
Sim.Active = true;
GimmickLayer.PreviewMode = true;
try
{
var resolver = new ActionResolver(bm.Config, bm, new System.Random(7));
Sim.Run(resolver.Resolve(u, target, moveOnly, o));
moverPath.AddRange(o.Path);
foreach (var d in o.Displaced) tracks.Add(new KeyValuePair<Unit, Displacement>(d.Unit, d));
for (int i = 0; i < gBefore.Count && i < layer.Items.Count; i++)
{
var g = gBefore[i].Key;
int before = gBefore[i].Value;
int beforeHp = before / 4;
if (g.Broken && (before & 1) == 0) events.Add(g.Def.projectile == GimmickProjectile.Destroy ? g.Def.nameKo + " 폭발" : g.Def.nameKo + " 파괴");
else if (g.Hp < beforeHp) events.Add(g.Def.nameKo + " −" + (beforeHp - g.Hp));
if (g.Removed && (before & 2) == 0 && g.Zone != GimmickZone.Trap) events.Add(g.Def.nameKo + " 제거");
if ((g.Shape.Center - gPos[i]).sqrMagnitude > 1e-4f) events.Add(g.Def.nameKo + (g.Rolls ? " 굴림" : " 밀림"));
}
if (o.Chains > 0) events.Add("연쇄 " + o.Chains);
foreach (var n in o.Notes)
{
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)
}
}
finally
{
Sim.Active = false;
GimmickLayer.PreviewMode = false;
Sim.Restore(bm, snap);
}
// 행동 유닛 = 첫 접촉 뒤 궤적(튕김·연쇄·반작용·미끄러짐)만 점선(첫 구간은 조준선) · 첫 구간이 장애물에서 끝났으면 그 뒤(조준점까지)를 붉게 끊는다
if (moverPath.Count >= 2)
{
LastMoverEnd = moverPath[moverPath.Count - 1];
if (moverPath.Count > 2)
{
Dash(moverPath.GetRange(1, moverPath.Count - 1), MoverColor);
LastTracks++;
}
Vector2 aimEnd = bm.Resolver.ReachPoint(u, target);
Vector2 firstEnd = moverPath[1];
if (!UnitTypeInfo.IsRanged(u.Type) && Vector2.Distance(firstEnd, aimEnd) > 0.15f && BlockNear(firstEnd, u.Radius))
{
Cut(firstEnd, aimEnd);
if (o.Path.Count > 2 && !events.Contains("미끄러짐")) events.Add("튕김");
}
Bands(moverPath, u.Faction == Faction.Player);
}
foreach (var kv in tracks)
{
if (kv.Key == null || Vector2.Distance(kv.Value.From, kv.Value.To) < 0.05f) continue;
Dash(new List<Vector2> { kv.Value.From, kv.Value.To }, kv.Key.Faction == u.Faction ? AllyTrackColor : FoeTrackColor);
LastTracks++;
}
if (shot != null && shot.HitGimmick != null && shot.Hit == null)
{
string t = shot.HitGimmick.Def.projectile == GimmickProjectile.Destroy ? shot.HitGimmick.Def.nameKo + " 폭발" : shot.HitGimmick.Def.nameKo + "에 막힘";
if (!events.Contains(t)) events.Add(t);
}
if (events.Count > 0)
{
Vector2 at = shot != null && shot.Fires ? shot.To : (moverPath.Count >= 2 ? moverPath[1] : u.Position);
Icon(at, string.Join(" · ", events.ToArray()));
}
Flush();
}
/// <summary>그 지점에서 장애물 콜라이더(강 = 다리 구간을 뺀 조각)에 닿아 있는가.</summary>
static bool BlockNear(Vector2 p, float r)
{
Physics2D.SyncTransforms();
foreach (var c in Physics2D.OverlapCircleAll(p, r * Combat.CastRadiusScale + 0.08f))
{
if (GimmickLayer.BlocksMove(c)) return true;
}
return false;
}
void Bands(List<Vector2> path, bool player)
{
foreach (var g in layer.Items)
{
if (!g.ZoneActive || g.Zone == GimmickZone.Trap || g.Zone == GimmickZone.Capture || g.Zone == GimmickZone.None) continue;
Color c = BandColor(g.Zone);
for (int i = 0; i + 1 < path.Count; i++)
{
Vector2 a = path[i], b = path[i + 1];
float len = Vector2.Distance(a, b);
if (len < 0.05f) continue;
int n = Mathf.Max(1, Mathf.CeilToInt(len / GimmickShape.SweepStep));
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));
if (inside && start < 0) start = k;
if ((!inside || k == n) && start >= 0)
{
int end = inside ? k : k - 1;
if (end > start)
{
Band(Vector2.Lerp(a, b, (float)start / n), Vector2.Lerp(a, b, (float)end / n), c);
LastBandColors.Add(c);
}
start = -1;
}
}
}
}
}
// ---------- 그리기(풀) ----------
static Material DashMaterial
{
get
{
if (dashMaterial == null)
{
var tex = new Texture2D(8, 2, TextureFormat.RGBA32, false);
var px = new Color[16];
for (int y = 0; y < 2; y++)
for (int x = 0; x < 8; x++)
px[y * 8 + x] = x < 5 ? Color.white : new Color(1f, 1f, 1f, 0f);
tex.SetPixels(px);
tex.wrapMode = TextureWrapMode.Repeat;
tex.filterMode = FilterMode.Point;
tex.Apply();
tex.hideFlags = HideFlags.DontSave;
dashMaterial = new Material(Shader.Find("Sprites/Default")) { mainTexture = tex };
dashMaterial.mainTextureScale = new Vector2(4f, 1f);
dashMaterial.hideFlags = HideFlags.DontSave;
}
return dashMaterial;
}
}
static Material SolidMaterial
{
get
{
if (solidMaterial == null) solidMaterial = new Material(Shader.Find("Sprites/Default")) { hideFlags = HideFlags.DontSave };
return solidMaterial;
}
}
LineRenderer NewLine(string name, Material m, float width, int order, bool tile)
{
var go = new GameObject(name);
go.transform.SetParent(root, false);
var lr = go.AddComponent<LineRenderer>();
lr.useWorldSpace = true;
lr.sharedMaterial = m;
lr.startWidth = lr.endWidth = width;
lr.sortingOrder = order;
lr.numCapVertices = tile ? 0 : 3;
lr.textureMode = tile ? LineTextureMode.Tile : LineTextureMode.Stretch;
return lr;
}
void Dash(List<Vector2> pts, Color c)
{
if (usedDash >= dashes.Count) dashes.Add(NewLine("Track", DashMaterial, 0.06f, 9, true));
var lr = dashes[usedDash++];
lr.positionCount = pts.Count;
for (int i = 0; i < pts.Count; i++) lr.SetPosition(i, new Vector3(pts[i].x, pts[i].y, 0f));
lr.startColor = lr.endColor = c;
lr.enabled = true;
}
void Band(Vector2 a, Vector2 b, Color c)
{
if (usedBand >= bands.Count) bands.Add(NewLine("ZoneBand", SolidMaterial, 0.22f, 8, false));
var lr = bands[usedBand++];
lr.positionCount = 2;
lr.SetPosition(0, new Vector3(a.x, a.y, 0f));
lr.SetPosition(1, new Vector3(b.x, b.y, 0f));
lr.startColor = lr.endColor = c;
lr.enabled = true;
}
void Cut(Vector2 a, Vector2 b)
{
if (cut == null) cut = NewLine("BlockedCut", SolidMaterial, 0.13f, 11, false); // 조준선(10) 위
cut.positionCount = 2;
cut.SetPosition(0, new Vector3(a.x, a.y, 0f));
cut.SetPosition(1, new Vector3(b.x, b.y, 0f));
cut.startColor = cut.endColor = CutColor;
cut.enabled = true;
LastCut = true;
}
void Icon(Vector2 at, string text)
{
if (usedIcon >= icons.Count)
{
var tm = TextUtil.CreateWorldText(root, "", 0.3f, IconColor, 12, TextAnchor.MiddleLeft);
tm.gameObject.name = "PreviewIcon";
icons.Add(tm);
}
var t = icons[usedIcon++];
t.text = text;
t.transform.position = new Vector3(at.x + 0.4f, at.y - 0.3f, 0f); // 접점 오른쪽 아래(기믹 이름·조준선과 겹치지 않게)
t.gameObject.SetActive(true);
LastIcons.Add(text);
}
void Flush()
{
for (int i = usedDash; i < dashes.Count; i++) dashes[i].enabled = false;
for (int i = usedBand; i < bands.Count; i++) bands[i].enabled = false;
for (int i = usedIcon; i < icons.Count; i++) icons[i].gameObject.SetActive(false);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 08f2e7173a4b32c4d8744429c48520a1

View File

@ -0,0 +1,192 @@
using UnityEngine;
namespace TKS
{
/// <summary>
/// 기믹 모양(circle · rect(회전) · poly) 기하 — 지대 겹침(경로 스윕) · 거리 · 진입/이탈 거리. 장애물 콜라이더도 이 모양으로 만든다.
/// 경로 스윕은 0.05 간격 표본(전장 20×32 · 슬링 최대 6.5 → 한 행동 130 표본 × 기믹 수 · 충분히 가볍다).
/// </summary>
public struct GimmickShape
{
public const float SweepStep = 0.05f;
public GimmickShapeType Type;
public Vector2 Center;
/// <summary>circle 반지름.</summary>
public float R;
/// <summary>rect 가로·세로.</summary>
public Vector2 Size;
/// <summary>rect 회전(도 · 반시계).</summary>
public float Rot;
/// <summary>poly 꼭짓점(월드 · Center 기준 상대 좌표가 아니다).</summary>
public Vector2[] Points;
public static GimmickShape From(GimmickPlacement p)
{
var s = new GimmickShape { Type = p.shape, Center = new Vector2(p.cx, p.cy), R = p.wOrR, Size = new Vector2(p.wOrR, p.h), Rot = p.rot };
if (p.shape == GimmickShapeType.Poly) s.Points = p.points != null ? (Vector2[])p.points.Clone() : new Vector2[0];
return s;
}
public static GimmickShape Circle(Vector2 c, float r)
{
return new GimmickShape { Type = GimmickShapeType.Circle, Center = c, R = r };
}
public static GimmickShape Box(Vector2 c, Vector2 size, float rot = 0f)
{
return new GimmickShape { Type = GimmickShapeType.Rect, Center = c, Size = size, Rot = rot };
}
public GimmickShape Translated(Vector2 d)
{
var s = this;
s.Center += d;
if (Type == GimmickShapeType.Poly && Points != null)
{
s.Points = new Vector2[Points.Length];
for (int i = 0; i < Points.Length; i++) s.Points[i] = Points[i] + d;
}
return s;
}
/// <summary>가장 바깥 점까지의 거리(대략 검사용).</summary>
public float BoundingRadius
{
get
{
switch (Type)
{
case GimmickShapeType.Circle: return R;
case GimmickShapeType.Rect: return Size.magnitude * 0.5f;
}
float m = 0f;
if (Points != null) foreach (var p in Points) m = Mathf.Max(m, Vector2.Distance(p, Center));
return m;
}
}
public Vector2 ToLocal(Vector2 q)
{
Vector2 d = q - Center;
float a = -Rot * Mathf.Deg2Rad;
float c = Mathf.Cos(a), s = Mathf.Sin(a);
return new Vector2(d.x * c - d.y * s, d.x * s + d.y * c);
}
public Vector2 LocalAxisX
{
get
{
float a = Rot * Mathf.Deg2Rad;
return new Vector2(Mathf.Cos(a), Mathf.Sin(a));
}
}
/// <summary>점 q 까지 부호 거리(안 = 음수 · 경계 = 0).</summary>
public float SignedDistance(Vector2 q)
{
switch (Type)
{
case GimmickShapeType.Circle:
return Vector2.Distance(q, Center) - R;
case GimmickShapeType.Rect:
{
Vector2 l = ToLocal(q);
Vector2 d = new Vector2(Mathf.Abs(l.x) - Size.x * 0.5f, Mathf.Abs(l.y) - Size.y * 0.5f);
Vector2 o = new Vector2(Mathf.Max(d.x, 0f), Mathf.Max(d.y, 0f));
return o.magnitude + Mathf.Min(Mathf.Max(d.x, d.y), 0f);
}
}
if (Points == null || Points.Length < 3) return float.MaxValue;
float min = float.MaxValue;
bool inside = false;
for (int i = 0, j = Points.Length - 1; i < Points.Length; j = i++)
{
Vector2 a = Points[i], b = Points[j];
min = Mathf.Min(min, Combat.SegmentPointDistance(a, b, q));
if ((a.y > q.y) != (b.y > q.y) && q.x < (b.x - a.x) * (q.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
}
return inside ? -min : min;
}
public bool Contains(Vector2 q)
{
return SignedDistance(q) <= 0f;
}
/// <summary>선분 ab 가 모양과 겹치는가(중심 경로 · 여유 tol).</summary>
public bool SegmentTouches(Vector2 a, Vector2 b, float tol = 0f)
{
float len = Vector2.Distance(a, b);
if (SegmentPointDistanceToCenter(a, b) > BoundingRadius + tol + 0.01f) return false;
int n = Mathf.Max(1, Mathf.CeilToInt(len / SweepStep));
for (int i = 0; i <= n; i++)
{
if (SignedDistance(Vector2.Lerp(a, b, (float)i / n)) <= tol) return true;
}
return false;
}
/// <summary>선분 ab 중 모양 안에 든 길이.</summary>
public float InsideLength(Vector2 a, Vector2 b)
{
float len = Vector2.Distance(a, b);
if (len < 1e-4f || SegmentPointDistanceToCenter(a, b) > BoundingRadius + 0.01f) return 0f;
int n = Mathf.Max(1, Mathf.CeilToInt(len / SweepStep));
float step = len / n;
float inside = 0f;
for (int i = 0; i < n; i++)
{
if (Contains(Vector2.Lerp(a, b, (i + 0.5f) / n))) inside += step;
}
return inside;
}
/// <summary>origin 에서 dir 로 dist 안에 중심이 처음 모양 안에 드는 거리(출발점이 이미 안이면 0 · 없으면 −1).</summary>
public float EnterDistance(Vector2 origin, Vector2 dir, float dist)
{
if (Contains(origin)) return 0f;
if (SegmentPointDistanceToCenter(origin, origin + dir * dist) > BoundingRadius + 0.01f) return -1f;
int n = Mathf.Max(1, Mathf.CeilToInt(dist / SweepStep));
float prev = 0f;
for (int i = 1; i <= n; i++)
{
float t = dist * i / n;
if (!Contains(origin + dir * t))
{
prev = t;
continue;
}
// 이분법으로 경계를 좁힌다
float lo = prev, hi = t;
for (int k = 0; k < 8; k++)
{
float m = (lo + hi) * 0.5f;
if (Contains(origin + dir * m)) hi = m;
else lo = m;
}
return hi;
}
return -1f;
}
/// <summary>origin(모양 안)에서 dir 로 나가는 거리(max 까지 · 출발점이 밖이면 0).</summary>
public float ExitDistance(Vector2 origin, Vector2 dir, float max)
{
if (!Contains(origin)) return 0f;
int n = Mathf.Max(1, Mathf.CeilToInt(max / SweepStep));
for (int i = 1; i <= n; i++)
{
float t = max * i / n;
if (!Contains(origin + dir * t)) return t;
}
return max;
}
float SegmentPointDistanceToCenter(Vector2 a, Vector2 b)
{
return Combat.SegmentPointDistance(a, b, Center);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 88a4d45c42d9ad942a2092b6378de92f

View File

@ -0,0 +1,342 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>
/// 기믹 표시(PD #1097 §6): 스프라이트(Resources/Gimmicks/Art/&lt;art&gt;)가 있으면 모양에 맞춰 늘리고, 없으면 색 도형 + 이름 글자.
/// 장애물 = 내구 핍 · 피격 흔들림 · 파괴 파편 → 잔해(art_broken) · 함정 = 발동 전 숨김 · 불 = 꺼지면 회색 · 군기 = 점령 진영 색.
/// 정렬 = 배경(−20~−16) 위 · 유닛(−6~) 아래.
/// </summary>
public class GimmickView
{
public const int ZoneOrder = -15;
public const int RiverOrder = -14;
public const int BridgeOrder = -13;
public const int BlockOrder = -12;
public const int LabelOrder = -11;
public const string ArtFolder = "Gimmicks/Art/";
static Material polyMaterial;
readonly GimmickInstance g;
readonly Transform root;
Transform body;
Renderer fill;
TextMesh label;
readonly List<SpriteRenderer> pips = new List<SpriteRenderer>();
Color baseColor;
Coroutine shake;
public GimmickView(GimmickInstance inst)
{
g = inst;
root = inst.Go.transform;
Build();
}
public Transform Body
{
get { return body; }
}
public bool Visible
{
get { return body != null && body.gameObject.activeSelf; }
}
public string LabelText
{
get { return label != null ? label.text : ""; }
}
public int PipCount
{
get { return pips.Count; }
}
public static Color ColorOf(GimmickDef d)
{
switch (d.zone)
{
case GimmickZone.Poison: return new Color(0.55f, 0.25f, 0.75f, 0.45f);
case GimmickZone.Slow: return new Color(0.42f, 0.32f, 0.16f, 0.5f);
case GimmickZone.Burn: return new Color(1f, 0.45f, 0.1f, 0.5f);
case GimmickZone.Slide: return new Color(0.6f, 0.88f, 1f, 0.5f);
case GimmickZone.High: return new Color(0.78f, 0.66f, 0.4f, 0.38f);
case GimmickZone.Fog: return new Color(0.92f, 0.92f, 0.96f, 0.42f);
case GimmickZone.Capture: return new Color(0.75f, 0.75f, 0.75f, 0.3f);
case GimmickZone.Trap: return new Color(0.3f, 0.18f, 0.08f, 0.85f);
case GimmickZone.River: return new Color(0.2f, 0.45f, 0.85f, 0.9f);
}
if (d.id == GimmickIds.Bridge) return new Color(0.62f, 0.46f, 0.26f, 1f);
if (d.id == GimmickIds.Boulder) return new Color(0.52f, 0.52f, 0.55f, 1f);
if (d.projectile == GimmickProjectile.Destroy) return new Color(0.78f, 0.22f, 0.15f, 1f);
if (d.strategistClear) return new Color(0.2f, 0.42f, 0.18f, 1f); // 가시덤불
if (d.projectile == GimmickProjectile.Pass) return new Color(0.58f, 0.4f, 0.2f, 1f); // 목책
if (d.durable) return new Color(0.42f, 0.27f, 0.14f, 1f); // 성문
return new Color(0.46f, 0.46f, 0.43f, 1f); // 성벽
}
static int OrderOf(GimmickInstance i)
{
if (i.Zone == GimmickZone.River) return RiverOrder;
if (i.IsBridge) return BridgeOrder;
return i.IsBlock ? BlockOrder : ZoneOrder;
}
void Build()
{
var bgo = new GameObject("Body");
bgo.transform.SetParent(root, false);
body = bgo.transform;
baseColor = ColorOf(g.Def);
int order = OrderOf(g);
var s = g.Shape;
var art = string.IsNullOrEmpty(g.Def.art) ? null : Resources.Load<Sprite>(ArtFolder + g.Def.art);
if (s.Type == GimmickShapeType.Poly)
{
fill = MakePoly(body, s, baseColor, order);
}
else
{
Vector2 size = s.Type == GimmickShapeType.Circle ? new Vector2(s.R * 2f, s.R * 2f) : s.Size;
var sr = ProceduralSprites.NewSprite(body, "Fill", art != null ? art : (s.Type == GimmickShapeType.Circle ? ProceduralSprites.Circle : ProceduralSprites.Square), art != null ? Color.white : baseColor, 1f, order);
Vector2 native = sr.sprite.bounds.size;
sr.transform.localScale = new Vector3(size.x / Mathf.Max(0.001f, native.x), size.y / Mathf.Max(0.001f, native.y), 1f);
sr.transform.localRotation = Quaternion.Euler(0f, 0f, s.Type == GimmickShapeType.Rect ? s.Rot : 0f);
fill = sr;
if (art != null) baseColor = Color.white;
}
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);
label = TextUtil.CreateWorldText(body, g.Def.nameKo, h, new Color(1f, 1f, 1f, g.IsBlock ? 0.95f : 0.75f), LabelOrder);
label.gameObject.name = "Label";
if (g.Zone == GimmickZone.River && s.Type == GimmickShapeType.Rect) label.transform.localPosition = (Vector3)(s.LocalAxisX * s.Size.x * 0.3f); // 다리 글자와 겹치지 않게
if (g.Def.durable && g.MaxHp > 0)
{
float top = s.Type == GimmickShapeType.Rect ? Mathf.Abs(s.Size.y) * 0.5f + 0.18f : s.BoundingRadius + 0.18f;
const float w = 0.16f, gap = 0.05f;
float total = g.MaxHp * w + (g.MaxHp - 1) * gap;
for (int i = 0; i < g.MaxHp; i++)
{
var p = ProceduralSprites.NewSprite(body, "Pip", ProceduralSprites.Square, Color.white, 1f, LabelOrder);
p.transform.localPosition = new Vector3(-total * 0.5f + w * 0.5f + i * (w + gap), top, 0f);
p.transform.localScale = new Vector3(w, 0.1f, 1f);
pips.Add(p);
}
}
if (g.Zone == GimmickZone.Trap) body.gameObject.SetActive(false); // 함정 = 숨김(아군 조준선 미표시 · 적 AI 는 인지)
Refresh();
}
static Renderer MakePoly(Transform parent, GimmickShape s, Color color, int order)
{
var go = new GameObject("Fill");
go.transform.SetParent(parent, false);
var mf = go.AddComponent<MeshFilter>();
var mr = go.AddComponent<MeshRenderer>();
if (polyMaterial == null) polyMaterial = new Material(Shader.Find("Sprites/Default"));
mr.sharedMaterial = polyMaterial;
mr.sortingOrder = order;
var mesh = new Mesh();
var verts = new Vector3[s.Points.Length];
var cols = new Color[s.Points.Length];
for (int i = 0; i < verts.Length; i++)
{
verts[i] = s.Points[i] - s.Center;
cols[i] = color;
}
mesh.vertices = verts;
mesh.colors = cols;
mesh.triangles = Triangulate(s.Points);
mf.sharedMesh = mesh;
return mr;
}
/// <summary>귀 자르기 삼각 분할(단순 다각형).</summary>
public static int[] Triangulate(Vector2[] pts)
{
var tris = new List<int>();
var idx = new List<int>();
float area = 0f;
for (int i = 0, j = pts.Length - 1; i < pts.Length; j = i++) area += pts[j].x * pts[i].y - pts[i].x * pts[j].y;
for (int i = 0; i < pts.Length; i++) idx.Add(area > 0f ? i : pts.Length - 1 - i);
int guard = 0;
while (idx.Count > 3 && guard++ < 1000)
{
bool cut = false;
for (int i = 0; i < idx.Count; i++)
{
int a = idx[(i + idx.Count - 1) % idx.Count], b = idx[i], c = idx[(i + 1) % idx.Count];
Vector2 pa = pts[a], pb = pts[b], pc = pts[c];
if ((pb.x - pa.x) * (pc.y - pa.y) - (pb.y - pa.y) * (pc.x - pa.x) <= 0f) continue;
bool inside = false;
foreach (int k in idx)
{
if (k == a || k == b || k == c) continue;
if (InTri(pts[k], pa, pb, pc)) { inside = true; break; }
}
if (inside) continue;
tris.Add(a); tris.Add(b); tris.Add(c);
idx.RemoveAt(i);
cut = true;
break;
}
if (!cut) break;
}
if (idx.Count == 3) { tris.Add(idx[0]); tris.Add(idx[1]); tris.Add(idx[2]); }
return tris.ToArray();
}
static bool InTri(Vector2 p, Vector2 a, Vector2 b, Vector2 c)
{
float d1 = (p.x - b.x) * (a.y - b.y) - (a.x - b.x) * (p.y - b.y);
float d2 = (p.x - c.x) * (b.y - c.y) - (b.x - c.x) * (p.y - c.y);
float d3 = (p.x - a.x) * (c.y - a.y) - (c.x - a.x) * (p.y - a.y);
bool neg = d1 < 0 || d2 < 0 || d3 < 0, pos = d1 > 0 || d2 > 0 || d3 > 0;
return !(neg && pos);
}
/// <summary>상태(내구 · 잔해 · 군기 · 불 꺼짐 · 함정 노출)를 그림에 반영.</summary>
public void Refresh()
{
if (body == null) return;
Color c = baseColor;
string text = g.Def.nameKo;
if (g.Broken)
{
c = Color.Lerp(baseColor, new Color(0.2f, 0.18f, 0.15f, 0.55f), 0.6f);
text = g.Def.nameKo + " 잔해";
var broken = string.IsNullOrEmpty(g.Def.artBroken) ? null : Resources.Load<Sprite>(ArtFolder + g.Def.artBroken);
var sr = fill as SpriteRenderer;
if (broken != null && sr != null)
{
sr.sprite = broken;
c = Color.white;
}
}
if (g.Out)
{
c = new Color(0.35f, 0.33f, 0.3f, 0.35f);
text = "꺼진 잔해";
}
if (g.Zone == GimmickZone.Capture)
{
c = g.Owner == (int)Faction.Player ? new Color(0.25f, 0.55f, 0.95f, 0.35f) : g.Owner == (int)Faction.Enemy ? new Color(0.9f, 0.3f, 0.25f, 0.35f) : baseColor;
text = g.Owner < 0 ? g.Def.nameKo : g.Def.nameKo + (g.Owner == (int)Faction.Player ? "(아군)" : "(적군)");
}
if (g.Zone == GimmickZone.Burn && g.BurnLeft > 0) text = g.Def.nameKo + " " + g.BurnLeft;
SetColor(c);
if (label != null) label.text = text;
for (int i = 0; i < pips.Count; i++)
{
pips[i].color = i < g.Hp ? new Color(1f, 0.85f, 0.3f) : new Color(0.15f, 0.15f, 0.15f, 0.8f);
pips[i].gameObject.SetActive(!g.Broken);
}
}
void SetColor(Color c)
{
var sr = fill as SpriteRenderer;
if (sr != null) sr.color = c;
var mr = fill as MeshRenderer;
if (mr != null)
{
var mf = mr.GetComponent<MeshFilter>();
if (mf != null && mf.sharedMesh != null)
{
var cols = new Color[mf.sharedMesh.vertexCount];
for (int i = 0; i < cols.Length; i++) cols[i] = c;
mf.sharedMesh.colors = cols;
}
}
}
/// <summary>피격 흔들림.</summary>
public void Shake()
{
if (body == null || Fx.Runner == null) return;
if (shake != null) Fx.Runner.StopCoroutine(shake);
shake = Fx.Run(ShakeRoutine());
}
IEnumerator ShakeRoutine()
{
float t = 0f;
const float dur = 0.25f;
while (t < dur && body != null)
{
t += Time.deltaTime;
float k = 1f - t / dur;
body.localPosition = new Vector3(Mathf.Sin(t * 70f) * 0.09f * k, Mathf.Cos(t * 55f) * 0.05f * k, 0f);
yield return null;
}
if (body != null) body.localPosition = Vector3.zero;
shake = null;
}
/// <summary>파괴 파편(작은 조각이 흩어지며 사라짐) → 잔해 표시.</summary>
public void Shatter()
{
Fx.Run(ShatterRoutine(g.Shape.Center, baseColor));
Refresh();
}
static IEnumerator ShatterRoutine(Vector2 c, Color color)
{
var bits = new List<SpriteRenderer>();
var vel = new List<Vector2>();
for (int i = 0; i < 10; i++)
{
var sr = ProceduralSprites.NewSprite(null, "Debris", ProceduralSprites.Square, new Color(color.r, color.g, color.b, 1f), 0.16f, 7);
sr.transform.position = new Vector3(c.x, c.y, 0f);
float a = i * Mathf.PI * 2f / 10f + Random.value * 0.4f;
vel.Add(new Vector2(Mathf.Cos(a), Mathf.Sin(a)) * (1.5f + Random.value * 1.5f));
bits.Add(sr);
}
float t = 0f;
const float dur = 0.5f;
while (t < dur)
{
t += Time.deltaTime;
for (int i = 0; i < bits.Count; i++)
{
bits[i].transform.position += (Vector3)(vel[i] * Time.deltaTime);
var col = bits[i].color;
col.a = 1f - t / dur;
bits[i].color = col;
}
yield return null;
}
foreach (var b in bits) Object.Destroy(b.gameObject);
}
/// <summary>함정 발동(드러났다 사라짐) · 덤불 제거(흐려지며 사라짐).</summary>
public void Vanish(bool reveal)
{
if (body == null) return;
if (reveal) body.gameObject.SetActive(true);
Refresh();
Fx.Run(VanishRoutine(reveal ? 0.7f : 0.35f));
}
IEnumerator VanishRoutine(float dur)
{
float t = 0f;
while (t < dur && body != null)
{
t += Time.deltaTime;
var c = baseColor;
c.a = baseColor.a * (1f - t / dur);
SetColor(c);
yield return null;
}
if (body != null) body.gameObject.SetActive(false);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b65a87233ccc60e48b4267a11d41d734

View File

@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>스테이지 배치 표(Resources/Gimmicks/StageGimmickTable · 메뉴 TKS/Import Gimmicks) + 스테이지 시작 팁(tutorial_tips.csv · PD #1098 §3).</summary>
public class StageGimmickTable : ScriptableObject
{
[Serializable]
public class Tip
{
public string stage = "";
public int chapter;
public int stageNo;
public string text = "";
}
public List<GimmickPlacement> rows = new List<GimmickPlacement>();
public List<Tip> tips = new List<Tip>();
public List<GimmickPlacement> For(int chapter, int stageNo)
{
var res = new List<GimmickPlacement>();
if (chapter <= 0 || stageNo <= 0) return res;
foreach (var r in rows)
{
if (r.chapter == chapter && r.stageNo == stageNo) res.Add(r);
}
return res;
}
public string TipFor(int chapter, int stageNo)
{
foreach (var t in tips)
{
if (t.chapter == chapter && t.stageNo == stageNo) return t.text;
}
return "";
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1f8e3e401b33e1d46bf1084189a5f061

View File

@ -181,11 +181,11 @@ namespace TKS
return Mathf.Max(0.1f, u.SlingMaxDistance * (1f + pct) + add); return Mathf.Max(0.1f, u.SlingMaxDistance * (1f + pct) + add);
} }
/// <summary>궁병·책사 사거리 = (기본 + range_add + 대도독 team_range_add) × range_mult.</summary> /// <summary>궁병·책사 사거리 = (기본 + range_add + 대도독 team_range_add + 고지 p1(지형 기믹 PD #1097)) × range_mult.</summary>
public static float Range(Unit u, bool note = false) public static float Range(Unit u, bool note = false)
{ {
if (u == null) return 0f; if (u == null) return 0f;
float r = u.AttackRange + Sum(u, SkillKeys.RangeAdd, note) + Sum(u, SkillKeys.TeamRangeAdd, note); float r = u.AttackRange + Sum(u, SkillKeys.RangeAdd, note) + Sum(u, SkillKeys.TeamRangeAdd, note) + GimmickLayer.RangeBonusOf(u);
return Mathf.Max(0f, r * Product(u, SkillKeys.RangeMult, note)); return Mathf.Max(0f, r * Product(u, SkillKeys.RangeMult, note));
} }

View File

@ -190,8 +190,11 @@ namespace TKS
return Attach(u, e.skillActive, e.activeLv, e.skillPassive, e.passiveLv, e.passiveMult, e.general); return Attach(u, e.skillActive, e.activeLv, e.skillPassive, e.passiveLv, e.passiveMult, e.general);
} }
/// <summary>적 무장(보스·엘리트) = heroes.csv 에 같은 아트·이름 영웅이 있으면 그 패시브만(Lv1 · 액티브 AI 사용은 2차 · PD 확인 대상).</summary> /// <summary>
public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e) /// 적 무장(보스·엘리트) = heroes.csv 에 같은 아트·이름 영웅이 있으면 그 패시브(Lv1) · withActive = 액티브도(적 AI 가 다음 행동형만 예약해 씀 ·
/// 1부 보스만 = ai_weights.csv boss_active·active_max_chapter · PD #1098 확인 대상).
/// </summary>
public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e, bool withActive = false)
{ {
if (u == null || e == null || !(e.isBoss || e.isGeneral)) return null; if (u == null || e == null || !(e.isBoss || e.isGeneral)) return null;
var h = SkillCatalog.HeroForEnemy(e.art, e.name); var h = SkillCatalog.HeroForEnemy(e.art, e.name);
@ -201,7 +204,7 @@ namespace TKS
s.IsGeneral = true; s.IsGeneral = true;
return s; return s;
} }
return Attach(u, null, 1, h.skillPassive, 1, 1f, true); return Attach(u, withActive ? h.skillActive : null, 1, h.skillPassive, 1, 1f, true);
} }
public void MarkGeneral(Unit u) public void MarkGeneral(Unit u)
@ -794,6 +797,7 @@ namespace TKS
public static void Announce(Unit u, string name) public static void Announce(Unit u, string name)
{ {
if (Sim.Active) return;
Fx.Burst(u.Position, u.Radius * 2.2f, FlashColor); Fx.Burst(u.Position, u.Radius * 2.2f, FlashColor);
Fx.Run(NameRoutine(u.Position + new Vector2(0f, u.ArtHeight > 0f ? u.ArtHeight * 0.6f : u.Radius + 0.6f), name)); Fx.Run(NameRoutine(u.Position + new Vector2(0f, u.ArtHeight > 0f ? u.ArtHeight * 0.6f : u.Radius + 0.6f), name));
} }

View File

@ -296,6 +296,8 @@ namespace TKS
/// <summary>조준 표시(자동 플레이 캡처에서도 호출). 반환 = 공격 예정 적 수.</summary> /// <summary>조준 표시(자동 플레이 캡처에서도 호출). 반환 = 공격 예정 적 수.</summary>
public int ShowPreview(Unit u, Vector2 target, Vector2? pointer) public int ShowPreview(Unit u, Vector2 target, Vector2? pointer)
{ {
Vector2 aim = target;
target = battle.Resolver.ReachPoint(u, target); // 늪·고지 = 실제 정지점(PD #1097)
aimLine.enabled = true; aimLine.enabled = true;
aimLine.SetPosition(0, new Vector3(u.Position.x, u.Position.y, 0f)); aimLine.SetPosition(0, new Vector3(u.Position.x, u.Position.y, 0f));
aimLine.SetPosition(1, new Vector3(target.x, target.y, 0f)); aimLine.SetPosition(1, new Vector3(target.x, target.y, 0f));
@ -318,13 +320,15 @@ namespace TKS
targetRing.transform.localScale = finalHit != null ? new Vector3(0.9f, 0.9f, 1f) : new Vector3(0.7f, 0.7f, 1f); targetRing.transform.localScale = finalHit != null ? new Vector3(0.9f, 0.9f, 1f) : new Vector3(0.7f, 0.7f, 1f);
ClearHighlights(); ClearHighlights();
var shot = battle.Resolver.PreviewShot(u, target, moveOnly); var shot = battle.Resolver.PreviewShot(u, aim, moveOnly);
var gl = battle.Gimmicks;
if (gl != null) gl.Preview.Show(u, aim, moveOnly, shot); // 예상 궤적(튕김·밀림·연쇄·반작용) · 기믹 반응 · 지대 띠(PD #1097 · #1098)
if (shot != null) if (shot != null)
{ {
return ShowShot(u, shot); return ShowShot(u, shot);
} }
HideShot(); HideShot();
foreach (var e in battle.Resolver.PredictTargets(u, target, moveOnly)) Highlight(e, false); foreach (var e in battle.Resolver.PredictTargets(u, aim, moveOnly)) Highlight(e, false);
return highlighted.Count; return highlighted.Count;
} }
@ -351,7 +355,7 @@ namespace TKS
if (!friendlyCenter) foes++; if (!friendlyCenter) foes++;
} }
bool splash = u.Type == UnitType.Strategist && shot.Hit != null; bool splash = u.Type == UnitType.Strategist && shot.HasSplash; // 덤불·수레에 막혀도 그 자리 광역(PD #1097)
if (splash) if (splash)
{ {
bool anyFriendly = friendlyCenter; bool anyFriendly = friendlyCenter;
@ -362,8 +366,8 @@ namespace TKS
if (friendly) anyFriendly = true; if (friendly) anyFriendly = true;
else foes++; else foes++;
} }
float d = shot.SplashRadius * 2f; // 화공 = 반경 ×(PD #1093 B) float d = shot.SplashRadius * 2f; // 화공 = 반경 ×(PD #1093 B) · 안개 중심 = ×p1
splashArea.transform.position = new Vector3(shot.Hit.Position.x, shot.Hit.Position.y, 0f); splashArea.transform.position = new Vector3(shot.SplashCenter.x, shot.SplashCenter.y, 0f);
splashArea.transform.localScale = new Vector3(d, d, 1f); splashArea.transform.localScale = new Vector3(d, d, 1f);
splashArea.color = anyFriendly ? SplashAreaFriendlyColor : SplashAreaColor; splashArea.color = anyFriendly ? SplashAreaFriendlyColor : SplashAreaColor;
} }
@ -391,6 +395,7 @@ namespace TKS
targetRing.enabled = false; targetRing.enabled = false;
HideShot(); HideShot();
ClearHighlights(); ClearHighlights();
if (battle != null && battle.Gimmicks != null) battle.Gimmicks.Preview.Hide();
} }
void ClearHighlights() void ClearHighlights()

View File

@ -71,6 +71,7 @@ namespace TKS
/// </summary> /// </summary>
public void SetFacing(Vector2 dir) public void SetFacing(Vector2 dir)
{ {
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
bool viewChanged = false; bool viewChanged = false;
if (dir.y > FacingEps && !facingBack) if (dir.y > FacingEps && !facingBack)
{ {
@ -186,6 +187,7 @@ namespace TKS
public void Refresh() public void Refresh()
{ {
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (unit == null || hpFill == null) return; if (unit == null || hpFill == null) return;
float ratio = unit.MaxHp > 0 ? Mathf.Clamp01((float)unit.Hp / unit.MaxHp) : 0f; float ratio = unit.MaxHp > 0 ? Mathf.Clamp01((float)unit.Hp / unit.MaxHp) : 0f;
hpFill.transform.localScale = new Vector3(HpBarWidth * ratio, 0.12f, 1f); hpFill.transform.localScale = new Vector3(HpBarWidth * ratio, 0.12f, 1f);
@ -248,6 +250,7 @@ namespace TKS
/// <summary>조준(당기는) 중 = 출발 자세(ready · 1프레임) · 끝나면 대기 (PD 2026-11-05 #1084).</summary> /// <summary>조준(당기는) 중 = 출발 자세(ready · 1프레임) · 끝나면 대기 (PD 2026-11-05 #1084).</summary>
public void SetAiming(bool on) public void SetAiming(bool on)
{ {
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (anim == null || aiming == on) return; if (anim == null || aiming == on) return;
aiming = on; aiming = on;
if (on) anim.Play(ClipName("ready"), true); if (on) anim.Play(ClipName("ready"), true);
@ -257,6 +260,7 @@ namespace TKS
/// <summary>슬링샷 돌진(이동) 중 = 병종 돌진 포즈(action · 1프레임 고정) · 멈추면 대기 (PD 2026-11-05 #1084).</summary> /// <summary>슬링샷 돌진(이동) 중 = 병종 돌진 포즈(action · 1프레임 고정) · 멈추면 대기 (PD 2026-11-05 #1084).</summary>
public void SetCharging(bool on) public void SetCharging(bool on)
{ {
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (anim == null || charging == on) return; if (anim == null || charging == on) return;
charging = on; charging = on;
aiming = false; aiming = false;
@ -272,6 +276,7 @@ namespace TKS
/// <summary>공격 자세(1 프레임) 잠깐 → 대기. 돌진 중이면 이미 돌진 포즈라 그대로 둔다.</summary> /// <summary>공격 자세(1 프레임) 잠깐 → 대기. 돌진 중이면 이미 돌진 포즈라 그대로 둔다.</summary>
public void PlayAttack() public void PlayAttack()
{ {
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (anim == null || !isActiveAndEnabled || charging) return; if (anim == null || !isActiveAndEnabled || charging) return;
aiming = false; aiming = false;
anim.Play(ClipName("action"), false, AttackHold, true); anim.Play(ClipName("action"), false, AttackHold, true);
@ -280,6 +285,7 @@ namespace TKS
/// <summary>승리 동작(앞모습 · 끝 프레임 유지).</summary> /// <summary>승리 동작(앞모습 · 끝 프레임 유지).</summary>
public void PlayVictory() public void PlayVictory()
{ {
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (anim == null || !isActiveAndEnabled || !unit.IsAlive) return; if (anim == null || !isActiveAndEnabled || !unit.IsAlive) return;
aiming = false; aiming = false;
anim.Play(ClipName("victory"), false); anim.Play(ClipName("victory"), false);
@ -287,6 +293,7 @@ namespace TKS
public void OnHit() public void OnHit()
{ {
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (!isActiveAndEnabled) return; if (!isActiveAndEnabled) return;
if (flash != null) StopCoroutine(flash); if (flash != null) StopCoroutine(flash);
flash = StartCoroutine(FlashRoutine()); flash = StartCoroutine(FlashRoutine());
@ -315,6 +322,7 @@ namespace TKS
public void PlayDeath() public void PlayDeath()
{ {
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
if (!isActiveAndEnabled) return; if (!isActiveAndEnabled) return;
StartCoroutine(DeathRoutine()); StartCoroutine(DeathRoutine());
} }

View File

@ -586,9 +586,10 @@ namespace TKS.Tests
yield return bm.ResolveForTest(spear, foe.Position, false, o); yield return bm.ResolveForTest(spear, foe.Position, false, o);
Assert.Contains(foe, o.Attacked); Assert.Contains(foe, o.Attacked);
Assert.Less(foe.Hp, foe.MaxHp, "부딪힌 적에게 피해"); Assert.Less(foe.Hp, foe.MaxHp, "부딪힌 적에게 피해");
Assert.Contains(foe, o.Pushed, "튕기지 않고 밀며 함께 전진"); Assert.Contains(foe, o.Pushed, "부딪힌 상대를 밀친다");
Assert.Greater(foe.Position.y, 0.5f, "적이 남은 거리만큼 앞으로 밀림"); Assert.Greater(foe.Position.y, 0.5f, "적이 남은 거리만큼 앞으로 밀림");
Assert.AreEqual(CastContact(spear, foe), foe.Position.y - spear.Position.y, 0.1f, "창병은 접촉 간격을 유지하며 따라감"); float remaining = CastContact(spear, foe); // 당긴 3 − 접촉까지(3 − 접촉 거리)
Assert.AreEqual(-CastContact(spear, foe) - remaining * cfg.spearRecoilRatio, spear.Position.y, 0.1f, "창병 = 밀친 거리 × spearRecoilRatio 만큼 반대로(PD #1098 · 함께 전진 폐기)");
Assert.IsTrue(o.Overwatch && spear.Overwatch, "최종 목표점 명중 → 대기(견제)"); Assert.IsTrue(o.Overwatch && spear.Overwatch, "최종 목표점 명중 → 대기(견제)");
int hp = foe.Hp; int hp = foe.Hp;
@ -615,9 +616,11 @@ namespace TKS.Tests
yield return bm.ResolveForTest(spear, new Vector2(0f, 0f), false, o); yield return bm.ResolveForTest(spear, new Vector2(0f, 0f), false, o);
Assert.AreEqual(0, o.Attacked.Count, "아군에 부딪힌 창병 자신은 공격하지 않음"); Assert.AreEqual(0, o.Attacked.Count, "아군에 부딪힌 창병 자신은 공격하지 않음");
Assert.Contains(ally, o.Pushed); Assert.Contains(ally, o.Pushed);
Assert.Greater(ally.Position.y, 0.4f, "아군을 밀며 함께 전진(적에 닿을 때까지)"); Assert.Greater(ally.Position.y, 0.4f, "아군을 밀쳐 적진으로(적에 닿을 때까지)");
Assert.LessOrEqual(Gap(ally, foe), Combat.ContactTolerance + 0.05f, "밀린 아군이 적과 닿음"); Assert.LessOrEqual(Gap(ally, foe), Combat.ContactTolerance + 0.05f, "밀린 아군이 적과 닿음");
Assert.AreEqual(CastContact(spear, ally), ally.Position.y - spear.Position.y, 0.1f, "창병은 아군 바로 뒤까지 따라옴"); float contactY = -2f - CastContact(spear, ally);
float push = 4f - (contactY + 4f); // 남은 거리 = 당긴 4 − 접촉까지
Assert.AreEqual(contactY - push * cfg.spearRecoilRatio, spear.Position.y, 0.1f, "창병 반작용(PD #1098) = 밀친 거리 × 비율만큼 뒤로");
Assert.Less(foe.Hp, foe.MaxHp, "밀린 아군이 적을 공격(규칙 ⑤)"); Assert.Less(foe.Hp, foe.MaxHp, "밀린 아군이 적을 공격(규칙 ⑤)");
Assert.AreEqual(spear.MaxHp, spear.Hp, "창병은 피해 없음"); Assert.AreEqual(spear.MaxHp, spear.Hp, "창병은 피해 없음");
Assert.AreEqual(1, o.Notes.Count); Assert.AreEqual(1, o.Notes.Count);

View File

@ -0,0 +1,600 @@
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using NUnit.Framework;
using TKS.OutGame;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
namespace TKS.Tests
{
/// <summary>
/// 지형 기믹 레이어(PD #1097 · 지형기믹_기획서) — 기믹 16 × 핵심 병종 조합 · 샘플 5 스테이지 로드 · 임포트 검증 · 조준 미리보기 ·
/// 병종 규칙 개정 2건(보병 연쇄 · 창병 반작용 · PD #1098). 수치 = 임포트 자산(GimmickDb · BalanceConfig)에서 읽는다(C45).
/// </summary>
public class GimmickTests
{
BattleManager bm;
BalanceConfig cfg;
GimmickLayer L;
[UnitySetUp]
public IEnumerator SetUp()
{
WorldProgress.Reset();
SaveStore.UseMemory(true);
SceneManager.LoadScene("Battle");
yield return null;
yield return null;
bm = BattleManager.Instance;
Assert.IsNotNull(bm);
var wm = WorldMapScreen.Instance;
if (wm != null && wm.HasData) wm.EnterStage(0, 0);
yield return null;
cfg = bm.Config;
Time.timeScale = 20f;
}
[UnityTearDown]
public IEnumerator TearDown()
{
Time.timeScale = 1f;
yield return null;
}
IEnumerator Fresh()
{
bm.StopAllCoroutines();
bm.ClearUnits();
bm.ForcePhase(BattlePhase.PlayerTurn);
L = GimmickLayer.Ensure(bm);
L.Clear();
yield return null;
}
static GimmickDef Def(string id)
{
var d = GimmickTables.Db.Get(id);
Assert.IsNotNull(d, "GimmickDb 에 " + id + " (TKS/Import Gimmicks)");
return d;
}
static int Pct(Unit u, float pct)
{
return Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * pct));
}
float Contact(Unit mover, float obstacleEdgeY)
{
return obstacleEdgeY - mover.Radius * Combat.CastRadiusScale;
}
IEnumerator Act(Unit u, Vector2 target, ActionOutcome o, bool moveOnly = false)
{
yield return bm.ResolveForTest(u, target, moveOnly, o);
}
// ---------- 목책 ----------
[UnityTest]
public IEnumerator Palisade_Infantry2HitsBreak_Bounce_ArrowPasses_SpearShoves_CavalrySelfDamage()
{
var d = Def("palisade");
yield return Fresh();
var p = L.Spawn("palisade", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.4f)));
Assert.AreEqual(d.hp, p.Hp);
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병");
var o = new ActionOutcome();
yield return Act(inf, new Vector2(0f, 1.5f), o);
Assert.AreEqual(d.hp - d.dmgInf, p.Hp, "보병 = dmg_inf 피해");
float contact = Contact(inf, -0.2f);
float remaining = 4.5f - (contact + 3f);
Assert.AreEqual(contact - remaining * cfg.collisionSelfBounceRatio, inf.Position.y, 0.12f, "알까기 자기 튕김(남은 거리 × 비율)");
Assert.AreEqual(inf.MaxHp, inf.Hp, "목책 bounce_pct 0 = 자기 피해 없음");
yield return Act(inf, inf.Position + new Vector2(0f, 4.5f), new ActionOutcome());
Assert.IsTrue(p.Broken, "보병 2회 = 파괴(잔해)");
Assert.IsFalse(p.BlocksMove);
yield return Act(inf, inf.Position + new Vector2(0f, 4.5f), new ActionOutcome());
Assert.Greater(inf.Position.y, 0.5f, "잔해 = 통과 가능");
// 화살 = 목책 통과
yield return Fresh();
L.Spawn("palisade", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.4f)));
var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.5f), "적");
var oa = new ActionOutcome();
yield return Act(archer, foe.Position, oa);
Assert.AreEqual(foe, oa.ShotHit, "화살은 목책을 넘어간다");
Assert.Less(foe.Hp, foe.MaxHp);
// 창병 = 1 피해 + spear_push 밀기(밀린 끝 유닛도 밀림) · 정지
yield return Fresh();
var p2 = L.Spawn("palisade", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.4f)));
var spear = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, -3f), "창병");
var behind = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0.8f), "목책 뒤 적");
yield return Act(spear, new Vector2(0f, 1.5f), new ActionOutcome());
Assert.AreEqual(d.hp - d.dmgSpear, p2.Hp);
Assert.AreEqual(d.spearPush, p2.Shape.Center.y, 0.05f, "목책이 spear_push 만큼 밀림");
Assert.GreaterOrEqual(behind.Position.y - behind.Radius, p2.Shape.Center.y + 0.2f - 0.03f, "밀린 끝 유닛도 밀림(겹치지 않음)");
Assert.AreEqual(Contact(spear, -0.2f), spear.Position.y, 0.08f, "창병은 튕기지 않고 정지");
// 기병 = 1 피해 + 튕김 + 자기 HP cav_self_pct · 복귀 없음
yield return Fresh();
var p3 = L.Spawn("palisade", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.4f)));
var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -4f), "기병");
var oc = new ActionOutcome();
yield return Act(cav, new Vector2(0f, 2.5f), oc);
Assert.AreEqual(d.hp - d.dmgCav, p3.Hp);
Assert.AreEqual(cav.MaxHp - Pct(cav, Mathf.Max(d.cavSelfPct, d.bouncePct)), cav.Hp, "기병 자기 피해 5 %");
Assert.IsFalse(oc.Returned, "막히면 복귀 없음");
Assert.Less(cav.Position.y, Contact(cav, -0.2f) - 0.3f, "튕김");
}
// ---------- 가시덤불 ----------
[UnityTest]
public IEnumerator ThornBush_ArrowBlocked_BounceSelf5Pct_StrategistSplashClears()
{
var d = Def("thorn_bush");
yield return Fresh();
var t = L.Spawn("thorn_bush", GimmickShape.Box(Vector2.zero, new Vector2(3f, 0.6f)));
var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.5f), "적");
var o = new ActionOutcome();
yield return Act(archer, foe.Position, o);
Assert.IsNull(o.ShotHit, "화살은 덤불에 막힌다");
Assert.AreEqual(foe.MaxHp, foe.Hp);
Assert.IsTrue(t.BlocksMove);
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(1f, -3f), "보병");
yield return Act(inf, new Vector2(1f, 1.5f), new ActionOutcome());
Assert.AreEqual(inf.MaxHp - Pct(inf, d.bouncePct), inf.Hp, "덤불 = 튕김 + 최대 HP bounce_pct");
Assert.Less(inf.Position.y, Contact(inf, -0.3f) - 0.3f, "튕김");
var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(-1f, -3f), "책사");
var foe2 = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(-1f, 1.2f), "적2");
var os = new ActionOutcome();
yield return Act(st, foe2.Position, os);
Assert.IsTrue(t.Removed, "책사 광역 = 덤불 제거");
Assert.IsFalse(t.BlocksMove);
var o2 = new ActionOutcome();
archer.ResetForNewTurn();
yield return Act(archer, foe.Position, o2);
Assert.AreEqual(foe, o2.ShotHit, "제거 뒤 사선이 열림");
}
// ---------- 바위 ----------
[UnityTest]
public IEnumerator Boulder_InfantryRollsIntoEnemy_DamageAndPush_CavalryStops()
{
var d = Def("boulder");
yield return Fresh();
var b = L.Spawn("boulder", GimmickShape.Circle(Vector2.zero, 0.8f));
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.8f), "적");
yield return Act(inf, new Vector2(0f, 1.5f), new ActionOutcome());
Assert.Greater(b.Shape.Center.y, 0.3f, "보병이 밀면 바위가 구른다");
Assert.Less(foe.Hp, foe.MaxHp, "구른 바위가 적에게 피해(공 × p1)");
Assert.Greater(foe.Position.y, 1.8f + d.p2 * 0.6f, "그리고 p2 만큼 밀기");
Assert.AreEqual(Contact(inf, -0.8f), inf.Position.y, 0.08f, "보병은 정지");
yield return Fresh();
L.Spawn("boulder", GimmickShape.Circle(Vector2.zero, 0.8f));
var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -4f), "기병");
yield return Act(cav, new Vector2(0f, 2.5f), new ActionOutcome());
Assert.AreEqual(cav.MaxHp - Pct(cav, d.cavSelfPct), cav.Hp, "기병 = 충돌 정지 + cav_self_pct");
Assert.AreEqual(Contact(cav, -0.8f), cav.Position.y, 0.08f, "튕기지 않음");
}
// ---------- 폭약 수레 ----------
[UnityTest]
public IEnumerator PowderCart_ArrowDestroys_ExplosionHitsAlliesToo_PushAndFire()
{
var d = Def("powder_cart");
yield return Fresh();
var cart = L.Spawn("powder_cart", GimmickShape.Circle(Vector2.zero, 0.6f));
var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.4f), "적");
var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(1.3f, 0f), "아군");
var o = new ActionOutcome();
yield return Act(archer, foe.Position, o);
Assert.IsTrue(cart.Broken, "어떤 피격에도 파괴");
Assert.Less(foe.Hp, foe.MaxHp, "폭발 = 반경 p1 광역");
Assert.Less(ally.Hp, ally.MaxHp, "아군 포함");
Assert.Contains(ally, o.FriendlyHit);
Assert.Greater(ally.Position.x, 1.3f + d.p3 * 0.6f, "바깥으로 p3 밀기");
bool fire = false;
foreach (var g in L.Items) fire |= g.Spawned && g.Zone == GimmickZone.Burn && g.ZoneActive;
Assert.IsTrue(fire, "불타는 잔해 생성");
Assert.Less(archer.Position.y, -2.9f, "쏜 궁병은 반경 밖");
Assert.AreEqual(archer.MaxHp, archer.Hp);
}
// ---------- 독늪 · 늪지 · 불 ----------
[UnityTest]
public IEnumerator PoisonMarsh_1_2_3Pct_ThenEnds_ReentryRefreshes()
{
var d = Def("poison_marsh");
yield return Fresh();
L.Spawn("poison_marsh", GimmickShape.Circle(Vector2.zero, 1f));
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병");
u.MaxHp = u.Hp = 1000;
yield return Act(u, new Vector2(0f, 1.4f), new ActionOutcome(), true);
var s = L.StateOf(u);
Assert.IsNotNull(s);
Assert.AreEqual(Mathf.RoundToInt(d.p1), s.PoisonTurns, "지나면 중독 p1 턴");
Assert.AreEqual(1000, u.Hp, "지나는 순간은 피해 없음");
int hp = 1000;
for (int k = 0; k < 3; k++)
{
yield return L.TurnStart(Faction.Player);
int expect = Mathf.Max(1, Mathf.RoundToInt(1000 * (d.p2 + d.p3 * k)));
Assert.AreEqual(hp - expect, u.Hp, "턴 " + (k + 1) + " = 최대 HP " + (d.p2 + d.p3 * k) * 100f + " %");
hp = u.Hp;
}
yield return L.TurnStart(Faction.Player);
Assert.AreEqual(hp, u.Hp, "p1 턴 뒤 끝");
Assert.AreEqual(0, s.PoisonTurns);
}
[UnityTest]
public IEnumerator Swamp_HalvesInsideSegment_PreviewBandAndStopPoint()
{
var d = Def("swamp");
yield return Fresh();
L.Spawn("swamp", GimmickShape.Box(Vector2.zero, new Vector2(20f, 6f)));
var a = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -2f), "늪 안");
yield return Act(a, new Vector2(0f, 2f), new ActionOutcome());
Assert.AreEqual(-2f + 4f / d.p1, a.Position.y, 0.1f, "늪 안 구간 이동량 ×p1 = 반감");
var b = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(4f, -6f), "늪 밖");
Vector2 reach = bm.Resolver.ReachPoint(b, new Vector2(4f, -2f));
Assert.AreEqual(-3f + 1f / d.p1, reach.y, 0.1f, "밖 3 → 안 1 × 1/p1");
bm.Sling.ShowPreview(b, new Vector2(4f, -2f), null);
Assert.Contains(GimmickPreview.BandColor(GimmickZone.Slow), L.Preview.LastBandColors, "조준선 = 늪 구간 색 띠");
bm.Sling.HidePreview();
yield return Act(b, new Vector2(4f, -2f), new ActionOutcome());
Assert.AreEqual(reach.y, b.Position.y, 0.08f, "미리보기 정지점 = 실제 정지점");
}
[UnityTest]
public IEnumerator BurningDebris_10PctOncePerAction_StandingEveryTurn()
{
var d = Def("burning_debris");
yield return Fresh();
L.Spawn("burning_debris", GimmickShape.Circle(Vector2.zero, 1f));
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병");
yield return Act(u, new Vector2(0f, 1.6f), new ActionOutcome(), true);
Assert.AreEqual(u.MaxHp - Pct(u, d.p1), u.Hp, "지나면 최대 HP p1(행동당 1회)");
var v = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0.2f, 0f), "불 위");
yield return L.TurnStart(Faction.Player);
Assert.AreEqual(v.MaxHp - Pct(v, d.p1), v.Hp, "위에서 멈추면 매 턴 p1");
Assert.AreEqual(u.MaxHp - Pct(u, d.p1), u.Hp, "밖은 그대로");
}
// ---------- 빙판 · 고지 · 안개 · 군기 ----------
[UnityTest]
public IEnumerator Ice_SlidesToEdgeMaxP1_CollisionRulesApply()
{
var d = Def("ice");
yield return Fresh();
L.Spawn("ice", GimmickShape.Box(Vector2.zero, new Vector2(6f, 6f)));
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-1f, -4f), "보병");
var o = new ActionOutcome();
yield return Act(u, new Vector2(-1f, -1f), o);
Assert.AreEqual(-1f + d.p1, u.Position.y, 0.12f, "정지점이 빙판 = 같은 방향 최대 p1 미끄러짐");
Assert.IsTrue(o.Notes.Exists(n => n.Contains("미끄러짐")));
yield return Fresh();
L.Spawn("ice", GimmickShape.Box(Vector2.zero, new Vector2(6f, 6f)));
var w = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -4f), "보병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1f), "적");
var o2 = new ActionOutcome();
yield return Act(w, new Vector2(0f, -1f), o2);
Assert.Contains(foe, o2.Attacked, "미끄러지다 적에 닿으면 알까기(충돌 규칙 그대로)");
Assert.Less(foe.Hp, foe.MaxHp);
Assert.IsFalse(o2.BonusMove, "미끄러짐은 명중 보상 없음");
}
[UnityTest]
public IEnumerator HighGround_RangeAdd_MeleeTakenCut_ClimbCost()
{
var d = Def("high_ground");
yield return Fresh();
L.Spawn("high_ground", GimmickShape.Box(new Vector2(0f, 5f), new Vector2(20f, 3f)));
var a = bm.SpawnUnit(UnitType.Archer, Faction.Enemy, new Vector2(0f, 5f), "고지 궁병");
var b = bm.SpawnUnit(UnitType.Archer, Faction.Enemy, new Vector2(3f, 0f), "평지 궁병");
Assert.AreEqual(a.AttackRange + d.p1, SkillMods.Range(a), 1e-4f, "고지 궁·책 사거리 +p1");
Assert.AreEqual(b.AttackRange, SkillMods.Range(b), 1e-4f);
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, 2f), "보병");
Assert.AreEqual(1f - d.p2, GimmickLayer.MeleeTakenMultOf(inf, a), 1e-4f, "아래에서 오는 근접 피해 −p2");
Assert.AreEqual(1f, GimmickLayer.MeleeTakenMultOf(inf, b), 1e-4f);
Vector2 reach = bm.Resolver.ReachPoint(inf, new Vector2(0f, 6f));
Assert.AreEqual(3.5f + (4f - 1.5f) / d.p3, reach.y, 0.1f, "오르는 구간 이동량 ×p3(밖 1.5 → 안 남은 2.5 ÷ p3)");
}
[UnityTest]
public IEnumerator Fog_TargetExcluded_SplashCenterInFogShrinks()
{
var d = Def("fog");
yield return Fresh();
L.Spawn("fog", GimmickShape.Circle(new Vector2(0f, 1.5f), 1.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 plan = bm.Resolver.PreviewShot(archer, hidden.Position, false);
Assert.IsNotNull(plan);
Assert.IsNull(plan.Target, "안개 안 유닛 = 사선 표적 제외");
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);
}
[UnityTest]
public IEnumerator Banner_CaptureAtTurnStart_AtkBonus_NeutralWhenContested()
{
var d = Def("banner");
yield return Fresh();
var g = L.Spawn("banner", GimmickShape.Circle(Vector2.zero, d.p1));
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0.5f, 0f), "아군");
var e = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 8f), "적");
yield return L.TurnStart(Faction.Player);
Assert.AreEqual((int)Faction.Player, g.Owner, "반경 안에 한쪽만 = 점령");
Assert.AreEqual(1f + d.p2, GimmickLayer.AttackMultOf(p), 1e-4f, "점령 측 공 +p2");
Assert.AreEqual(1f, GimmickLayer.AttackMultOf(e), 1e-4f);
e.Position = new Vector2(-0.6f, -0.9f);
yield return L.TurnStart(Faction.Enemy);
Assert.AreEqual(-1, g.Owner, "양쪽 = 중립");
Assert.AreEqual(1f, GimmickLayer.AttackMultOf(p), 1e-4f);
}
// ---------- 성문 · 성벽 · 강 · 다리 · 함정 ----------
[UnityTest]
public IEnumerator Gate_Hp6_Infantry3Hits_ArrowBlocked_WallUnbreakable()
{
var d = Def("gate");
yield return Fresh();
var gate = L.Spawn("gate", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.8f)));
var wall = L.Spawn("wall", GimmickShape.Box(new Vector2(7f, 0f), new Vector2(6f, 0.8f)));
Assert.AreEqual(d.hp, gate.Hp);
var archer = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -3f), "궁병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 1.3f), "적");
var oa = new ActionOutcome();
yield return Act(archer, foe.Position, oa);
Assert.IsNull(oa.ShotHit, "화살 막힘");
Assert.AreEqual(d.hp, gate.Hp, "화살로는 부서지지 않음");
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(1.5f, -3f), "보병");
int hits = 0;
while (!gate.Broken && hits < 6)
{
yield return Act(inf, inf.Position + new Vector2(0f, 4.5f), new ActionOutcome());
hits++;
}
Assert.AreEqual(Mathf.CeilToInt((float)d.hp / d.dmgInf), hits, "보병 2 피해 × 3회 = 파괴");
var w = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(7f, -3f), "벽 보병");
yield return Act(w, new Vector2(7f, 1.5f), new ActionOutcome());
Assert.IsTrue(wall.BlocksMove, "성벽 = 파괴 불가");
Assert.Less(w.Position.y, -0.4f, "성벽에 막혀 튕김");
}
[UnityTest]
public IEnumerator RiverBridge_BlocksMove_ArrowPasses_KnockedOffBridgeFalls()
{
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)));
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, "강 = 통과 불가");
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 oa = new ActionOutcome();
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), "다리 위 적");
onBridge.MaxHp = onBridge.Hp = 5000;
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 L.TurnStart(Faction.Enemy);
Assert.IsTrue(GimmickLayer.Skips(onBridge), "그 진영 턴을 쉰다");
}
[UnityTest]
public IEnumerator PitTrap_HiddenFromPreview_StopsMover_StunsNextTurn_Removed()
{
var d = Def("pit_trap");
yield return Fresh();
var trap = L.Spawn("pit_trap", GimmickShape.Circle(Vector2.zero, 0.6f));
Assert.IsFalse(trap.View.Visible, "숨김");
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병");
bm.Sling.ShowPreview(u, new Vector2(0f, 1.5f), null);
Assert.Greater(L.Preview.LastMoverEnd.y, 1f, "아군 조준선은 함정을 모른다(그대로 통과해 그림)");
bm.Sling.HidePreview();
Assert.IsFalse(trap.Removed, "미리보기는 상태를 바꾸지 않는다");
var o = new ActionOutcome();
yield return Act(u, new Vector2(0f, 1.5f), o);
Assert.AreEqual(-0.6f, u.Position.y, 0.1f, "밟으면 그 자리 정지");
Assert.IsTrue(trap.Removed && trap.Revealed, "발동 후 제거");
Assert.AreEqual(Mathf.RoundToInt(d.p1), L.StateOf(u).Stun);
u.ResetForNewTurn();
yield return L.TurnStart(Faction.Player);
Assert.IsTrue(u.ActedThisTurn, "다음 턴 행동 불가");
u.ResetForNewTurn();
yield return L.TurnStart(Faction.Player);
Assert.IsFalse(u.ActedThisTurn, "그다음 턴 = 정상");
}
// ---------- 조준 미리보기 ----------
[UnityTest]
public IEnumerator AimPreview_BlockCutRed_ResultIcon_TracksForPushAndRecoil()
{
yield return Fresh();
var p = L.Spawn("palisade", GimmickShape.Box(Vector2.zero, new Vector2(4f, 0.4f)));
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병");
bm.Sling.ShowPreview(inf, new Vector2(0f, 1.5f), null);
Assert.IsTrue(L.Preview.LastCut, "장애물 지점에서 붉게 끊김");
Assert.IsTrue(L.Preview.LastIcons.Exists(t => t.Contains("목책 −")), "결과 글자(내구 −n)");
Assert.IsTrue(L.Preview.LastIcons.Exists(t => t.Contains("튕김")), "튕김 표시");
Assert.AreEqual(p.MaxHp, p.Hp, "미리보기는 상태를 바꾸지 않는다");
Assert.AreEqual(-3f, inf.Position.y, 1e-4f);
bm.Sling.HidePreview();
Assert.IsFalse(L.Preview.Visible);
// 창병 반작용 · 밀릴 상대 궤적(PD #1098 §3)
yield return Fresh();
var spear = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, -3f), "창병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0f), "적");
bm.Sling.ShowPreview(spear, foe.Position, null);
Assert.GreaterOrEqual(L.Preview.LastTracks, 2, "행동 유닛 경로(반작용 포함) + 밀릴 상대 궤적");
Assert.Less(L.Preview.LastMoverEnd.y, -0.9f, "창병 반작용 = 접촉점보다 뒤");
bm.Sling.HidePreview();
Assert.AreEqual(foe.MaxHp, foe.Hp);
}
// ---------- 샘플 5 스테이지 · 임포트 ----------
[UnityTest]
public IEnumerator SampleStages_5_LoadPlacements_RiverCutAtBridge_TemplateFire()
{
var table = GimmickTables.Table;
Assert.IsNotNull(table);
foreach (var id in new[] { "1-3", "2-5", "4-13", "8-15", "9-11" })
{
var parts = id.Split('-');
int ch = int.Parse(parts[0]), no = int.Parse(parts[1]);
int idx = -1;
for (int i = 0; i < cfg.stages.Length; i++)
if (cfg.stages[i].chapter == ch && cfg.stages[i].stage == no) idx = i;
Assert.GreaterOrEqual(idx, 0, id + " 스테이지 표에 있음");
bm.StartStage(idx);
yield return null;
var layer = bm.Gimmicks;
Assert.IsNotNull(layer);
Assert.AreEqual(table.For(ch, no).Count, layer.Items.Count, id + " 배치 행 수");
foreach (var g in layer.Items)
{
if (g.IsBlock && !g.Template) Assert.Greater(g.Colliders.Count, 0, id + " " + g.Id + " 콜라이더");
}
var items = new List<GimmickInstance>(layer.Items);
if (id == "8-15")
{
Assert.IsTrue(items.Exists(g => g.Template), "8-15 화공 = 책사 광역 자리 불(생성 규칙)");
Assert.IsNotNull(layer.OnSplash(new Vector2(0f, 6f), 1.5f), "광역 자리 불 생성");
}
if (id == "9-11")
{
var river = items.Find(g => g.Zone == GimmickZone.River);
Assert.AreEqual(2, river.Colliders.Count, "강 = 다리 구간을 뺀 2 조각");
}
foreach (var u in bm.Units)
{
if (u == null || !u.IsAlive) continue;
foreach (var g in items)
if (g.BlocksMove) Assert.GreaterOrEqual(g.Shape.SignedDistance(u.Position), u.Radius, id + " " + u.DisplayName + " 이 " + g.Id + " 과 겹치지 않음(배치 보정)");
}
}
}
[Test]
public void Import_Tables_Valid_And_ErrorsReported()
{
var dir = Path.Combine(Application.dataPath, GimmickTables.Folder);
var texts = new string[GimmickTables.Files.Length];
for (int i = 0; i < texts.Length; i++) texts[i] = File.ReadAllText(Path.Combine(dir, GimmickTables.Files[i]), Encoding.UTF8);
var r = GimmickTables.Parse(texts);
Assert.AreEqual(0, r.errors.Count, r.errors.Count > 0 ? r.errors[0] : "");
Assert.AreEqual(16, r.defs.Count, "gimmicks.csv 16 행(PD 6 + 제안 8 + 보조 2)");
Assert.AreEqual(GimmickTables.Db.defs.Count, r.defs.Count, "자산 = CSV");
Assert.AreEqual(GimmickTables.Table.rows.Count, r.rows.Count);
var bad = (string[])texts.Clone();
bad[1] = texts[1].TrimEnd('\n', '\r') + "\n1-3,nothing,rect,0,0,1,1,0,,,\n1-3,palisade,hex,0,0,1,1,0,,,\n99-1,palisade,rect,0,0,1,1,0,,,\n1-3,ice,rect,0,0,1,1,0,,,foo=1\n";
var cfgLocal = Resources.Load<BalanceConfig>("BalanceConfig");
var rb = GimmickTables.Parse(bad, (c, s) =>
{
foreach (var st in cfgLocal.stages)
if (st.chapter == c && st.stage == s) return true;
return false;
});
Assert.IsTrue(rb.errors.Exists(e => e.Contains("gimmick_id")), "없는 기믹 id");
Assert.IsTrue(rb.errors.Exists(e => e.Contains("shape")), "모양 오류");
Assert.IsTrue(rb.errors.Exists(e => e.Contains("스테이지 표에 없는")), "없는 스테이지");
Assert.IsTrue(rb.errors.Exists(e => e.Contains("params 키")), "params 키");
var w = new List<string>();
AiWeights.Parse("key,value,desc_ko\ndmg_player,1,x\n", w);
Assert.IsTrue(w.Exists(e => e.Contains("kill_base")), "가중치 키 누락 = 오류");
}
// ---------- 병종 규칙 개정(PD 2026-11-06 #1098) ----------
[UnityTest]
public IEnumerator Rule_InfantryBounceChain_OnceWithOtherEnemy_AllyOnlyPushed()
{
yield return Fresh();
var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병");
var a = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.6f, 0f), "적A");
float cc = inf.Radius * Combat.CastRadiusScale + a.Radius;
float cy = -Mathf.Sqrt(cc * cc - 0.36f);
Vector2 contact = new Vector2(0f, cy);
Vector2 n = (a.Position - contact).normalized;
var b = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, contact - n * (cc + 0.6f), "적B");
a.MaxHp = a.Hp = b.MaxHp = b.Hp = 5000;
var o = new ActionOutcome();
yield return Act(inf, new Vector2(0f, 1.5f), o);
Assert.Contains(a, o.Attacked);
Assert.AreEqual(1, o.Chains, "튕겨나는 도중 다른 적 = 연쇄 1회");
Assert.Contains(b, o.Attacked);
Assert.Less(b.Hp, b.MaxHp, "연쇄 = 피해·반격 교환");
yield return Fresh();
var inf2 = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "보병");
var a2 = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0.6f, 0f), "적A");
var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, contact - n * (cc + 0.6f), "아군");
a2.MaxHp = a2.Hp = 5000;
Vector2 before = ally.Position;
var o2 = new ActionOutcome();
yield return Act(inf2, new Vector2(0f, 1.5f), o2);
Assert.AreEqual(0, o2.Chains, "아군과 부딪히면 연쇄 없음");
Assert.AreEqual(ally.MaxHp, ally.Hp, "아군 = 밀기만");
Assert.Greater(Vector2.Distance(before, ally.Position), 0.1f, "아군이 밀림");
}
[UnityTest]
public IEnumerator Rule_SpearPushesTarget_RecoilsHalf_NoAdvanceTogether()
{
yield return Fresh();
var spear = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, -3f), "창병");
var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0f), "적");
foe.MaxHp = foe.Hp = 5000;
float cc = spear.Radius * Combat.CastRadiusScale + foe.Radius;
var o = new ActionOutcome();
yield return Act(spear, foe.Position, o);
float push = cc; // 남은 거리 = 당긴 3 − 접촉까지(3 − cc)
Assert.AreEqual(push, foe.Position.y, 0.08f, "상대 = 남은 거리만큼 밀림");
Assert.AreEqual(-cc - push * cfg.spearRecoilRatio, spear.Position.y, 0.08f, "창병 = 그 × spearRecoilRatio 만큼 반대로(함께 전진 없음)");
Assert.Less(foe.Hp, foe.MaxHp, "접촉 순간 공격");
Assert.IsTrue(o.Overwatch, "명중 = 견제 유지");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0e186203f94130a489d5cc674599a19c

View File

@ -1004,8 +1004,10 @@ namespace TKS.Tests
var es = bm.Skills.Of(x); var es = bm.Skills.Of(x);
if (es == null || es.Passive == null) continue; if (es == null || es.Passive == null) continue;
withPassive++; withPassive++;
Assert.IsNull(es.Active, "적 = 액티브 없음(AI 사용은 2차)"); // PD #1098 확정: 적 액티브 = 1부(1~10장) 보스만(적 AI 가 다음 행동형을 예약해 씀) · 엘리트 무장 = 패시브만
Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(x)); if (x.IsBoss && TacticalAI.BossUsesActive(bm.Stage, new BalanceConfig.EnemySpawn { isBoss = true })) Assert.IsNotNull(es.Active, "1부 보스 = 액티브도");
else Assert.IsNull(es.Active, "엘리트 무장 = 액티브 없음");
Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(x), "적 액티브는 HUD 버튼 상태 없음(AI 전용)");
Assert.IsTrue(es.IsGeneral); Assert.IsTrue(es.IsGeneral);
} }
Assert.Greater(withPassive, 0, "적 무장 패시브 부착"); Assert.Greater(withPassive, 0, "적 무장 패시브 부착");

View File

@ -0,0 +1,199 @@
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using NUnit.Framework;
using TKS.OutGame;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
namespace TKS.Tests
{
/// <summary>
/// 적 AI 예측 시뮬레이션(PD #1098 · 전술규칙_적AI_기획서 §2) — 전술 항목이 점수에 반영되는지(함정 쪽 튕기기 · 창병 아군 밀어 전진 · 기병 일렬 휩쓸기 ·
/// 궁병 목책 엄폐 · 책사 덤불 제거) · 가상 실행이 상태를 되돌리는지 · 성능(적 10기 턴 결정 ≤ 1.5 초). 가중치 = ai_weights.csv(AiWeights 자산).
/// </summary>
public class TacticalAITests
{
BattleManager bm;
GimmickLayer L;
AiWeights W;
[UnitySetUp]
public IEnumerator SetUp()
{
WorldProgress.Reset();
SaveStore.UseMemory(true);
SceneManager.LoadScene("Battle");
yield return null;
yield return null;
bm = BattleManager.Instance;
var wm = WorldMapScreen.Instance;
if (wm != null && wm.HasData) wm.EnterStage(0, 0);
yield return null;
W = AiWeights.Load();
Assert.IsNotNull(W, "AiWeights 자산(TKS/Import Gimmicks)");
Time.timeScale = 20f;
}
[UnityTearDown]
public IEnumerator TearDown()
{
Time.timeScale = 1f;
yield return null;
}
IEnumerator Fresh()
{
bm.StopAllCoroutines();
bm.ClearUnits();
bm.ForcePhase(BattlePhase.EnemyTurn);
L = GimmickLayer.Ensure(bm);
L.Clear();
yield return null;
}
AiPlan Best(Unit e)
{
List<AiPlan> list;
var p = TacticalAI.Evaluate(bm, e, bm.AliveCount(Faction.Enemy), out list);
Assert.IsNotNull(p, "후보 있음");
return p;
}
[UnityTest]
public IEnumerator Infantry_BouncesPlayerIntoTrap_AndSimRestoresState()
{
yield return Fresh();
var trap = L.Spawn("pit_trap", GimmickShape.Circle(new Vector2(0f, 3.4f), 0.6f));
var e = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -1.5f), "적보병");
var victim = bm.SpawnUnit(UnitType.Spear, Faction.Player, new Vector2(0f, 1.2f), "아군창병");
var other = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-4f, -1.5f), "아군보병");
Vector2 v0 = victim.Position, e0 = e.Position;
int hv = victim.Hp, he = e.Hp;
var best = Best(e);
Assert.IsTrue(best.Why.Contains("기믹 Trap"), "함정 쪽으로 튕겨내는 수가 최고 점수(" + best.Why + ")");
Assert.AreEqual(v0, victim.Position, "가상 실행 뒤 위치 복원");
Assert.AreEqual(e0, e.Position);
Assert.AreEqual(hv, victim.Hp, "HP 복원");
Assert.AreEqual(he, e.Hp);
Assert.IsFalse(trap.Removed, "기믹 상태 복원");
var vs = L.StateOf(victim);
Assert.IsTrue(vs == null || vs.Stun == 0, "행동 불가 복원");
var o = new ActionOutcome();
yield return bm.ResolveForTest(e, best.Target, false, o);
Assert.IsTrue(trap.Revealed, "실제 실행 = 함정 발동");
Assert.Greater(L.StateOf(victim).Stun, 0);
Assert.IsTrue(other.IsAlive);
}
[UnityTest]
public IEnumerator Spear_PushesOwnAllyTowardPlayers_SupportScore()
{
yield return Fresh();
var spear = bm.SpawnUnit(UnitType.Spear, Faction.Enemy, new Vector2(0f, 4f), "적창병");
var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 2.5f), "적보병");
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -4f), "아군");
var best = Best(spear);
Assert.IsTrue(best.Why.Contains("서포트"), "창병 = 아군을 밀어 적진에 빨리 닿게(" + best.Why + ")");
Assert.AreEqual(0, best.Group, "서포터 그룹 = 먼저 움직인다");
Assert.IsTrue(p.IsAlive && ally.IsAlive);
}
[UnityTest]
public IEnumerator Cavalry_SweepsLinedUpPlayers()
{
yield return Fresh();
var cav = bm.SpawnUnit(UnitType.Cavalry, Faction.Enemy, new Vector2(0f, 4f), "적기병");
for (int i = 0; i < 3; i++)
{
var u = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, 1.5f - i * 1.5f), "줄" + i);
u.MaxHp = u.Hp = 5000;
}
bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(5f, 3f), "따로");
var best = Best(cav);
int n = 0;
var m = System.Text.RegularExpressions.Regex.Match(best.Why, "관통 (\\d+)");
if (m.Success) n = int.Parse(m.Groups[1].Value);
Assert.GreaterOrEqual(n, 2, "일렬로 선 적을 한 번에 꿰뚫는 수(" + best.Why + ")");
}
[UnityTest]
public IEnumerator Archer_BehindPalisade_CoverScore()
{
yield return Fresh();
var a = bm.SpawnUnit(UnitType.Archer, Faction.Enemy, new Vector2(0f, 3f), "적궁병");
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -0.8f), "아군");
p.MaxHp = p.Hp = 5000;
float open = Best(a).Score;
L.Spawn("palisade", GimmickShape.Box(new Vector2(0f, 1.6f), new Vector2(6f, 0.4f)));
var best = Best(a);
Assert.IsTrue(best.Why.Contains("목책 엄폐"), "목책 뒤 사격(" + best.Why + ")");
Assert.AreEqual(open + W["archer_cover"], best.Score, 1.5f, "엄폐 가중치만큼 점수 +");
}
[UnityTest]
public IEnumerator Strategist_ClearsThorn_OpensAllyRoute()
{
yield return Fresh();
var st = bm.SpawnUnit(UnitType.Strategist, Faction.Enemy, new Vector2(0f, 3.5f), "적책사");
L.Spawn("thorn_bush", GimmickShape.Box(Vector2.zero, new Vector2(3f, 0.6f)));
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -0.9f), "아군");
p.MaxHp = p.Hp = 5000;
bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(1.6f, 1.9f), "적보병");
var best = Best(st);
Assert.IsTrue(best.Why.Contains("덤불 제거"), "책사 = 덤불을 지워 아군 돌격로를 연다(" + best.Why + ")");
Assert.AreEqual(0, best.Group, "서포터 그룹");
}
[UnityTest]
public IEnumerator Performance_10Enemies_TurnDecisions_Under1500ms()
{
yield return Fresh();
var pending = new List<Unit>();
var types = new[] { UnitType.Infantry, UnitType.Spear, UnitType.Cavalry, UnitType.Archer, UnitType.Strategist };
for (int i = 0; i < 10; i++)
pending.Add(bm.SpawnUnit(types[i % 5], Faction.Enemy, new Vector2(-8f + (i % 5) * 4f, 2f + (i / 5) * 2.5f), "적" + i));
for (int i = 0; i < 5; i++) bm.SpawnUnit(types[i], Faction.Player, new Vector2(-8f + i * 4f, -3f), "아군" + i);
L.Spawn("palisade", GimmickShape.Box(new Vector2(-4f, 0f), new Vector2(4f, 0.4f)));
L.Spawn("swamp", GimmickShape.Box(new Vector2(4f, 0f), new Vector2(6f, 2f)));
L.Spawn("pit_trap", GimmickShape.Circle(new Vector2(0f, -1f), 0.6f));
var sw = Stopwatch.StartNew();
int decisions = 0;
var rng = new System.Random(3);
while (pending.Count > 0)
{
var plan = TacticalAI.Next(bm, pending, 0.6f, 30f, rng);
if (plan == null) break;
pending.Remove(plan.Unit);
decisions++;
}
sw.Stop();
UnityEngine.Debug.Log("[TacticalAITests] 적 10기 턴 결정 " + decisions + "회 · " + sw.ElapsedMilliseconds + " ms");
Assert.AreEqual(10, decisions);
Assert.LessOrEqual(sw.ElapsedMilliseconds, 1500, "적 10기 턴(1기마다 재평가) ≤ 1.5 초");
}
[UnityTest]
public IEnumerator EnemyTurn_UsesTacticalAI_AndBossActiveGate()
{
yield return Fresh();
var p = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "아군");
var e = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 2f), "적");
p.MarkActed();
bm.LastAiPlan = null;
yield return bm.RunEnemyTurn();
Assert.IsNotNull(bm.LastAiPlan, "적 턴 = 예측 AI 결정");
Assert.AreEqual(e, bm.LastAiPlan.Unit);
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase);
var boss = new BalanceConfig.EnemySpawn { isBoss = true };
var ch1 = new BalanceConfig.StageDef { chapter = 1 };
var ch11 = new BalanceConfig.StageDef { chapter = Mathf.RoundToInt(W["active_max_chapter"]) + 1 };
Assert.IsTrue(TacticalAI.BossUsesActive(ch1, boss), "1부 보스 = 액티브 사용");
Assert.IsFalse(TacticalAI.BossUsesActive(ch11, boss), "2부 = 아직");
Assert.IsFalse(TacticalAI.BossUsesActive(ch1, new BalanceConfig.EnemySpawn { isGeneral = true }), "보스만");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 41a7f217aa8155648a181188a8c323f2

View File

@ -48,6 +48,8 @@ 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>`(사본).
- **병종 규칙 개정 + 예측 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`(없으면 길 위 균등).
- 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 22종 이상 전부 통과 확인. - 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 22종 이상 전부 통과 확인.
@ -74,5 +76,7 @@ Unity.exe -projectPath <proj> -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
- `Assets/Data/Stages/` — 챕터·스테이지 표 CSV 4장(위 「코드 규칙」) · 해석·배치 = `Runtime/StageTable` - `Assets/Data/Stages/` — 챕터·스테이지 표 CSV 4장(위 「코드 규칙」) · 해석·배치 = `Runtime/StageTable`
- `Assets/Resources/Units/<art>/` — 유닛 아트 프레임 + meta.json(위 절) - `Assets/Resources/Units/<art>/` — 유닛 아트 프레임 + meta.json(위 절)
- `Assets/Scripts/Runtime/Skills/` — 스킬 처리기(위 「코드 규칙」 스킬) - `Assets/Scripts/Runtime/Skills/` — 스킬 처리기(위 「코드 규칙」 스킬)
- `Assets/Tests/PlayMode/` — `BattleFlowTests` · `OutGameTests` · `SkillTests` · `OutGameCTests` - `Assets/Scripts/Runtime/Gimmicks/` — 지형 기믹 레이어(위 「코드 규칙」) · `Assets/Scripts/Runtime/AI/` — 예측 시뮬레이션 적 AI(`Sim` · `TacticalAI` · `AiWeights`)
- `Assets/Data/Gimmicks/` · `Assets/Data/AI/` — 기믹 표 3장 · AI 가중치 표
- `Assets/Tests/PlayMode/` — `BattleFlowTests` · `OutGameTests` · `SkillTests` · `OutGameCTests` · `GimmickTests` · `TacticalAITests`
- `.claude/skills/` — Unity CLI 에이전트 스킬(`unity skill install claude-code --local` 산출물 · 재설치는 `unity skill refresh`) - `.claude/skills/` — Unity CLI 에이전트 스킬(`unity skill install claude-code --local` 산출물 · 재설치는 `unity skill refresh`)