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

View File

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

View File

@ -38,7 +38,7 @@ namespace TKS
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);
}
}

View File

@ -141,9 +141,16 @@ namespace TKS
public void NotifyChanged()
{
if (Sim.Active) return; // 가상 실행(적 AI 예측) 중 = HUD 갱신 없음
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)
{
if (Phase == p) return;
@ -202,6 +209,7 @@ namespace TKS
}
units.Clear();
if (Skills != null) Skills.Clear();
if (Gimmicks != null) Gimmicks.Clear();
}
// ---------- 스테이지 ----------
@ -247,12 +255,15 @@ namespace TKS
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);
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;
Skills.BeginBattle();
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();
SetPhase(BattlePhase.PlayerTurn);
}
@ -419,27 +430,61 @@ namespace TKS
LastMessage = "적군 차례";
NotifyChanged();
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);
var enemies = AliveUnits(Faction.Enemy);
float accuracy = Stage.aiAccuracy;
float aggro = Mathf.Max(0f, Stage.aiAggroRange + Skills.AggroAdd(Faction.Enemy)); // 신산 = 적 교전 범위 −
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 (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);
if (players.Count == 0) break;
if (players.Count == 0 || pending.Count == 0) break;
Vector2? target = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config);
if (!target.HasValue) continue; // 교전 범위 밖 → 자리 지킴
Unit e;
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);
yield return Fx.Wait(0.3f); // 카메라가 따라올 시간
if (useSkill && Skills.Of(e) != null) Skills.Of(e).Reserved = true; // 적 보스 액티브(다음 행동형 · 1부 보스만)
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);
LastMessage = Describe(e, o, false);
NotifyChanged();
@ -458,10 +503,25 @@ namespace TKS
foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn();
Skills.OnPlayerTurnStart(); // 이번 적 턴 효과(호위·철벽·간웅·일갈·안개) · 견제 강화 정리
TurnNumber++;
if (Gimmicks != null)
{
yield return Gimmicks.TurnStart(Faction.Player); // 군기 · 불 지속 · 중독 · 불 · 행동 불가(PD #1097)
if (CheckBattleEnd()) yield break;
}
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);
}
/// <summary>마지막 적 AI 결정(디버그 · 테스트 · 캡처).</summary>
public AiPlan LastAiPlan;
/// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary>
public IEnumerator ApplyOverwatch(Unit mover, List<Vector2> path)
{

View File

@ -3,8 +3,8 @@ using UnityEngine;
namespace TKS
{
/// <summary>피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중).</summary>
public static class Combat
/// <summary>피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중). 지형 기믹 질의 = Gimmicks/Combat.Gimmicks.cs.</summary>
public static partial class Combat
{
/// <summary>경로 질의에 쓰는 원 반지름 배율(살짝 작게 캐스트해 스침을 관대하게).</summary>
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)
{
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)
if (melee)
{
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;
raw *= GimmickLayer.MeleeTakenMultOf(attacker, defender); // 고지 위 = 아래에서 오는 근접 피해 −(PD #1097)
}
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));
return Mathf.Max(1, Mathf.RoundToInt(raw * Variance(cfg, rng))); // 가상 실행 = 분산 0(PD #1098)
}
/// <summary>책사 광역 피해(PD 2026-11-05 #1087 ⑤) = 대상별 사격 피해(상성 적용 · 크리 없음) × ratio · ratio ≤ 0 이면 0.</summary>
@ -86,6 +86,7 @@ namespace TKS
{
var u = UnitOf(h.collider);
if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue;
if (GimmickLayer.IsFogged(u)) continue; // 안개 안 = 사선 표적 제외(PD #1097)
hitDist = h.distance;
return u;
}
@ -106,6 +107,20 @@ namespace TKS
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>
/// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 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)
{
travel = dist;
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 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;
GimmickInstance g;
return FirstUnitOnArrow(shooter, from, dir, dist, arrowRadius, passFaction, out travel, out g); // 기믹 pass/block/destroy · 안개(Combat.Gimmicks.cs)
}
/// <summary>점 center 반경 radius 원에 몸(원)이 닿는 살아 있는 유닛(가까운 순 · 진영 불문 · exclude 둘 제외) — 책사 광역(보스는 반지름이 커서 더 잘 닿는다).</summary>
@ -189,13 +188,16 @@ namespace TKS
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)
{
float min = dist;
float min = TrapStop(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);
continue;

View File

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

View File

@ -190,8 +190,11 @@ namespace TKS
return Attach(u, e.skillActive, e.activeLv, e.skillPassive, e.passiveLv, e.passiveMult, e.general);
}
/// <summary>적 무장(보스·엘리트) = heroes.csv 에 같은 아트·이름 영웅이 있으면 그 패시브만(Lv1 · 액티브 AI 사용은 2차 · PD 확인 대상).</summary>
public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e)
/// <summary>
/// 적 무장(보스·엘리트) = 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;
var h = SkillCatalog.HeroForEnemy(e.art, e.name);
@ -201,7 +204,7 @@ namespace TKS
s.IsGeneral = true;
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)
@ -794,6 +797,7 @@ namespace TKS
public static void Announce(Unit u, string name)
{
if (Sim.Active) return;
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));
}

View File

@ -296,6 +296,8 @@ namespace TKS
/// <summary>조준 표시(자동 플레이 캡처에서도 호출). 반환 = 공격 예정 적 수.</summary>
public int ShowPreview(Unit u, Vector2 target, Vector2? pointer)
{
Vector2 aim = target;
target = battle.Resolver.ReachPoint(u, target); // 늪·고지 = 실제 정지점(PD #1097)
aimLine.enabled = true;
aimLine.SetPosition(0, new Vector3(u.Position.x, u.Position.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);
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)
{
return ShowShot(u, shot);
}
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;
}
@ -351,7 +355,7 @@ namespace TKS
if (!friendlyCenter) foes++;
}
bool splash = u.Type == UnitType.Strategist && shot.Hit != null;
bool splash = u.Type == UnitType.Strategist && shot.HasSplash; // 덤불·수레에 막혀도 그 자리 광역(PD #1097)
if (splash)
{
bool anyFriendly = friendlyCenter;
@ -362,8 +366,8 @@ namespace TKS
if (friendly) anyFriendly = true;
else foes++;
}
float d = shot.SplashRadius * 2f; // 화공 = 반경 ×(PD #1093 B)
splashArea.transform.position = new Vector3(shot.Hit.Position.x, shot.Hit.Position.y, 0f);
float d = shot.SplashRadius * 2f; // 화공 = 반경 ×(PD #1093 B) · 안개 중심 = ×p1
splashArea.transform.position = new Vector3(shot.SplashCenter.x, shot.SplashCenter.y, 0f);
splashArea.transform.localScale = new Vector3(d, d, 1f);
splashArea.color = anyFriendly ? SplashAreaFriendlyColor : SplashAreaColor;
}
@ -391,6 +395,7 @@ namespace TKS
targetRing.enabled = false;
HideShot();
ClearHighlights();
if (battle != null && battle.Gimmicks != null) battle.Gimmicks.Preview.Hide();
}
void ClearHighlights()

View File

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

View File

@ -586,9 +586,10 @@ namespace TKS.Tests
yield return bm.ResolveForTest(spear, foe.Position, false, o);
Assert.Contains(foe, o.Attacked);
Assert.Less(foe.Hp, foe.MaxHp, "부딪힌 적에게 피해");
Assert.Contains(foe, o.Pushed, "튕기지 않고 밀며 함께 전진");
Assert.Contains(foe, o.Pushed, "부딪힌 상대를 밀친다");
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, "최종 목표점 명중 → 대기(견제)");
int hp = foe.Hp;
@ -615,9 +616,11 @@ namespace TKS.Tests
yield return bm.ResolveForTest(spear, new Vector2(0f, 0f), false, o);
Assert.AreEqual(0, o.Attacked.Count, "아군에 부딪힌 창병 자신은 공격하지 않음");
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.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.AreEqual(spear.MaxHp, spear.Hp, "창병은 피해 없음");
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);
if (es == null || es.Passive == null) continue;
withPassive++;
Assert.IsNull(es.Active, "적 = 액티브 없음(AI 사용은 2차)");
Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(x));
// PD #1098 확정: 적 액티브 = 1부(1~10장) 보스만(적 AI 가 다음 행동형을 예약해 씀) · 엘리트 무장 = 패시브만
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.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>`.
- **아웃게임 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` 는 표에서 미사용(처리기 유지).
- **지형 기믹 레이어(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>`.
- **챕터·스테이지 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종 이상 전부 통과 확인.
@ -74,5 +76,7 @@ Unity.exe -projectPath <proj> -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
- `Assets/Data/Stages/` — 챕터·스테이지 표 CSV 4장(위 「코드 규칙」) · 해석·배치 = `Runtime/StageTable`
- `Assets/Resources/Units/<art>/` — 유닛 아트 프레임 + meta.json(위 절)
- `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`)