- 데이터: 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:
parent
cf7fe6b8a2
commit
98f675dfda
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d4cb149693e67824da30b1dc58dea379
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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장)
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cd768849686ca324c865e83967aa3208
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1ae249671f3bfac4ab79dea4d12edcd4
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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 턴]
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5aec5258324f30a4a99e4f857044b030
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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,,,
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3bed85c558aa0bf4787f2c896fbab028
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
stage,tip_ko
|
||||||
|
1-1,보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다
|
||||||
|
1-2,보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다
|
||||||
|
1-3,보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0907d8a68de35eb4595b90013fded822
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a7135fa18486d994cb10bb47ac8d7629
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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)"
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5b4c5518e6ce1084a98920a59a09fb11
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f328cb936677bf04cb80fb3785f5ad75
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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]"
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 38882c8f5f9a1ca4d8e9c00e9f5b74e3
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 64e563179d8b37943b222efb3b1b1943
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -54,6 +54,7 @@ namespace TKS.EditorTools
|
||||||
c.strategistAllyDamageRatio = 1f; // 광역 아군 피해 = 적과 동일(PD 확인 대상)
|
c.strategistAllyDamageRatio = 1f; // 광역 아군 피해 = 적과 동일(PD 확인 대상)
|
||||||
c.collisionPushRatio = 1f; // PD 2026-11-04 #1080: 보병 알까기 밀어내기 = 남은 거리 그대로
|
c.collisionPushRatio = 1f; // PD 2026-11-04 #1080: 보병 알까기 밀어내기 = 남은 거리 그대로
|
||||||
c.collisionSelfBounceRatio = 0.5f; // PD 2026-11-04 #1081: 보병 자신도 반대로 튕김(남은 거리의 절반 · 손맛 조정 대상)
|
c.collisionSelfBounceRatio = 0.5f; // PD 2026-11-04 #1081: 보병 자신도 반대로 튕김(남은 거리의 절반 · 손맛 조정 대상)
|
||||||
|
c.spearRecoilRatio = 0.5f; // PD 2026-11-06 #1098: 창병 밀치기 반작용(밀린 거리의 절반 · 함께 전진 폐기)
|
||||||
c.unitArtHeight = 1.5f;
|
c.unitArtHeight = 1.5f;
|
||||||
c.boardArt = "battlefield_01"; // 원화 세션 1차 납품(2026-11-04) · Resources/Board/
|
c.boardArt = "battlefield_01"; // 원화 세션 1차 납품(2026-11-04) · Resources/Board/
|
||||||
c.minDragDistance = 0.35f;
|
c.minDragDistance = 0.35f;
|
||||||
|
|
|
||||||
|
|
@ -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 <proj> -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 <copy> -executeMethod TKS.EditorTools.GimmickImporter.Capture -gimmickDir <dir>
|
||||||
|
/// 샘플 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f97d9c5e09776874cb64cb1784e67e6e
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3eb049b6ace5cb34c919b6b4ced9b347
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 38487f2bf2e40824a9e921ca527d179e
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 659ab9685f0a38143a4e480430ce70a3
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 056fcbc68cba91c498bffad3000a8607
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 66c2037730580514da6d7f1172bbbc71
|
||||||
|
|
@ -33,8 +33,10 @@ namespace TKS
|
||||||
/// <summary>추가 이동 횟수(기본 1 · 인덕 extra_move_count) · 추가 이동에서 공격 가능(extra_move_attack).</summary>
|
/// <summary>추가 이동 횟수(기본 1 · 인덕 extra_move_count) · 추가 이동에서 공격 가능(extra_move_attack).</summary>
|
||||||
public int BonusMoves = 1;
|
public int BonusMoves = 1;
|
||||||
public bool BonusAttack;
|
public bool BonusAttack;
|
||||||
/// <summary>이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정).</summary>
|
/// <summary>이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정 · 기믹 지대 스윕).</summary>
|
||||||
public List<Displacement> Displaced = new List<Displacement>();
|
public List<Displacement> Displaced = new List<Displacement>();
|
||||||
|
/// <summary>보병 연쇄 교환 수(튕겨나는 도중 다른 적과 부딪힘 · PD 2026-11-06 #1098).</summary>
|
||||||
|
public int Chains;
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Displacement
|
public struct Displacement
|
||||||
|
|
@ -46,17 +48,20 @@ namespace TKS
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (PD 2026-11-04 #1080 충돌 규칙).
|
/// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (PD 2026-11-04 #1080 충돌 규칙).
|
||||||
/// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 멈춤 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회
|
/// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 반대로 튕김 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회 ·
|
||||||
|
/// 적과 싸운 뒤 튕겨나는 도중 다른 적과 부딪히면 그 적과도 피해·반격 교환 + 다시 튕김(연쇄 1회 · 아군이면 밀기만 · PD 2026-11-06 #1098)
|
||||||
/// ② 기병: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 · 목표점 명중 시 제자리 복귀
|
/// ② 기병: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 · 목표점 명중 시 제자리 복귀
|
||||||
/// ③ 창병: 첫 유닛에 부딪히면 튕기지 않고 남은 거리만큼 밀며 함께 전진 · 적이면 피해(반격 받음) · 아군이면 밀기만 · 목표점 명중 시 대기(견제)
|
/// ③ 창병: 첫 유닛에 부딪히면 그 유닛을 남은 거리만큼 밀치고 자신은 그 × spearRecoilRatio 만큼 반대로 밀림(반작용 · 함께 전진 폐기 · PD #1098) ·
|
||||||
|
/// 적이면 피해(반격 받음) · 아군이면 밀기만 · 목표점 명중 시 대기(견제)
|
||||||
/// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ ×(1+비율) 안이면 들어올 만큼만 다가가 사격 ·
|
/// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ ×(1+비율) 안이면 들어올 만큼만 다가가 사격 ·
|
||||||
/// 더 멀면 목표점까지 이동(쏘지 않음) · 사격 = 타겟 방향 화살 투사체 → 경로 위 첫 유닛(아군 포함)이 맞음 · 관통 없음 · 반격 없음 ·
|
/// 더 멀면 목표점까지 이동(쏘지 않음) · 사격 = 타겟 방향 화살 투사체 → 경로 위 첫 유닛(아군 포함)이 맞음 · 관통 없음 · 반격 없음 ·
|
||||||
/// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage)
|
/// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage)
|
||||||
/// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤)
|
/// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤)
|
||||||
/// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음)
|
/// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음)
|
||||||
/// 스킬(PD #1093 B): 수치는 SkillMods 질의(밀기·튕김·관통·사거리·사선·광역·피해) · 예약 액티브는 Resolve 앞뒤에서 장착·소모(SkillSystem).
|
/// 스킬(PD #1093 B): 수치는 SkillMods 질의(밀기·튕김·관통·사거리·사선·광역·피해) · 예약 액티브는 Resolve 앞뒤에서 장착·소모(SkillSystem).
|
||||||
|
/// 지형 기믹(PD #1097): 충돌·투사체·미끄러짐·지대 = Gimmicks/ActionResolver.Gimmicks.cs · 가상 실행(적 AI) = Sim.Active 동안 연출 없이 즉시.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ActionResolver
|
public partial class ActionResolver
|
||||||
{
|
{
|
||||||
readonly BalanceConfig cfg;
|
readonly BalanceConfig cfg;
|
||||||
readonly BattleManager battle;
|
readonly BattleManager battle;
|
||||||
|
|
@ -72,12 +77,14 @@ namespace TKS
|
||||||
public IEnumerator Resolve(Unit u, Vector2 target, bool moveOnly, ActionOutcome o)
|
public IEnumerator Resolve(Unit u, Vector2 target, bool moveOnly, ActionOutcome o)
|
||||||
{
|
{
|
||||||
Vector2 origin = u.Position;
|
Vector2 origin = u.Position;
|
||||||
|
target = ReachPoint(u, target); // 늪·고지 이동 비용 = 실제 도달점(근접 병종 · PD #1097)
|
||||||
Vector2 delta = target - origin;
|
Vector2 delta = target - origin;
|
||||||
float dist = delta.magnitude;
|
float dist = delta.magnitude;
|
||||||
o.Path.Add(origin);
|
o.Path.Add(origin);
|
||||||
if (dist < 0.001f || !u.IsAlive) yield break;
|
if (dist < 0.001f || !u.IsAlive) yield break;
|
||||||
Vector2 dir = delta / dist;
|
Vector2 dir = delta / dist;
|
||||||
|
|
||||||
|
GimmickBegin(o);
|
||||||
var skills = SkillSystem.Current;
|
var skills = SkillSystem.Current;
|
||||||
if (skills != null) skills.BeginAction(u, moveOnly, o); // 예약 액티브 장착 + 발동 연출(PD #1093 B)
|
if (skills != null) skills.BeginAction(u, moveOnly, o); // 예약 액티브 장착 + 발동 연출(PD #1093 B)
|
||||||
// 슬링샷 돌진 중 = 병종 돌진 포즈 1프레임 고정 · 끝나면 대기 (PD 2026-11-05 #1084)
|
// 슬링샷 돌진 중 = 병종 돌진 포즈 1프레임 고정 · 끝나면 대기 (PD 2026-11-05 #1084)
|
||||||
|
|
@ -98,6 +105,7 @@ namespace TKS
|
||||||
yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o);
|
yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
yield return GimmickAfter(u, moveOnly, o, !(UnitTypeInfo.IsRanged(u.Type) && o.Fired)); // 빙판 · 지대 스윕 · 강(PD #1097)
|
||||||
if (u.View != null) u.View.SetCharging(false);
|
if (u.View != null) u.View.SetCharging(false);
|
||||||
if (skills != null) skills.EndAction(u, o); // 예약 소모 · 쿨 · 견제 강화 이월
|
if (skills != null) skills.EndAction(u, o); // 예약 소모 · 쿨 · 견제 강화 이월
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +118,7 @@ namespace TKS
|
||||||
var result = new List<Unit>();
|
var result = new List<Unit>();
|
||||||
if (moveOnly) return result;
|
if (moveOnly) return result;
|
||||||
Vector2 origin = u.Position;
|
Vector2 origin = u.Position;
|
||||||
|
target = ReachPoint(u, target);
|
||||||
Vector2 delta = target - origin;
|
Vector2 delta = target - origin;
|
||||||
float dist = delta.magnitude;
|
float dist = delta.magnitude;
|
||||||
if (dist < 0.001f) return result;
|
if (dist < 0.001f) return result;
|
||||||
|
|
@ -117,6 +126,7 @@ namespace TKS
|
||||||
float hitDist;
|
float hitDist;
|
||||||
var skills = SkillSystem.Current;
|
var skills = SkillSystem.Current;
|
||||||
bool prev = skills != null && skills.PreviewBegin(u);
|
bool prev = skills != null && skills.PreviewBegin(u);
|
||||||
|
GimmickLayer.PreviewMode = true; // 아군 조준 = 숨은 함정을 모른다(PD #1097)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
switch (u.Type)
|
switch (u.Type)
|
||||||
|
|
@ -152,6 +162,7 @@ namespace TKS
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
GimmickLayer.PreviewMode = false;
|
||||||
if (skills != null) skills.PreviewEnd(u, prev);
|
if (skills != null) skills.PreviewEnd(u, prev);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -166,12 +177,14 @@ namespace TKS
|
||||||
if (dist < 0.001f) return null;
|
if (dist < 0.001f) return null;
|
||||||
var skills = SkillSystem.Current;
|
var skills = SkillSystem.Current;
|
||||||
bool prev = skills != null && skills.PreviewBegin(u);
|
bool prev = skills != null && skills.PreviewBegin(u);
|
||||||
|
GimmickLayer.PreviewMode = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return PlanShot(u, u.Position, delta / dist, target);
|
return PlanShot(u, u.Position, delta / dist, target);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
GimmickLayer.PreviewMode = false;
|
||||||
if (skills != null) skills.PreviewEnd(u, prev);
|
if (skills != null) skills.PreviewEnd(u, prev);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +217,14 @@ namespace TKS
|
||||||
|
|
||||||
float hitDist;
|
float hitDist;
|
||||||
Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
|
Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
|
||||||
|
float gDist;
|
||||||
|
Vector2 gNormal;
|
||||||
|
var gim = BlockAhead(u, origin, dir, dist, first != null ? hitDist : dist, out gDist, out gNormal);
|
||||||
|
if (gim != null)
|
||||||
|
{
|
||||||
|
yield return HitGimmick(u, gim, origin, dir, dist, gDist, gNormal, o); // 장애물 기믹이 먼저(PD #1097)
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
if (first == null)
|
if (first == null)
|
||||||
{
|
{
|
||||||
yield return MoveFree(u, origin, dir, dist, o);
|
yield return MoveFree(u, origin, dir, dist, o);
|
||||||
|
|
@ -230,7 +251,7 @@ namespace TKS
|
||||||
// 알까기: 부딪힌 순간 양쪽이 동시에 튕긴다 — 상대 = 접촉 방향 × 남은 힘 × collisionPushRatio · 자신 = 반대 방향 × 남은 힘 × collisionSelfBounceRatio
|
// 알까기: 부딪힌 순간 양쪽이 동시에 튕긴다 — 상대 = 접촉 방향 × 남은 힘 × collisionPushRatio · 자신 = 반대 방향 × 남은 힘 × collisionSelfBounceRatio
|
||||||
Vector2 n = first.Position - contact;
|
Vector2 n = first.Position - contact;
|
||||||
n = n.sqrMagnitude > 1e-6f ? n.normalized : dir;
|
n = n.sqrMagnitude > 1e-6f ? n.normalized : dir;
|
||||||
yield return Bounce(u, first, n, remaining, o, enemy ? impact : 0);
|
yield return Bounce(u, first, n, remaining, o, enemy ? impact : 0, enemy); // 적과 싸운 뒤 튕김 = 연쇄 1회(PD #1098)
|
||||||
if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o, u);
|
if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o, u);
|
||||||
|
|
||||||
if (isFinal && u.IsAlive)
|
if (isFinal && u.IsAlive)
|
||||||
|
|
@ -241,7 +262,7 @@ namespace TKS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 창병: 밀며 함께 전진 ----------
|
// ---------- 창병: 밀치기 + 반작용(PD 2026-11-06 #1098 · 밀며 함께 전진 폐기) ----------
|
||||||
|
|
||||||
IEnumerator SpearRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
|
IEnumerator SpearRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
|
||||||
{
|
{
|
||||||
|
|
@ -253,6 +274,14 @@ namespace TKS
|
||||||
|
|
||||||
float hitDist;
|
float hitDist;
|
||||||
Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
|
Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
|
||||||
|
float gDist;
|
||||||
|
Vector2 gNormal;
|
||||||
|
var gim = BlockAhead(u, origin, dir, dist, first != null ? hitDist : dist, out gDist, out gNormal);
|
||||||
|
if (gim != null)
|
||||||
|
{
|
||||||
|
yield return HitGimmick(u, gim, origin, dir, dist, gDist, gNormal, o); // 장애물 기믹이 먼저(PD #1097)
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
if (first == null)
|
if (first == null)
|
||||||
{
|
{
|
||||||
yield return MoveFree(u, origin, dir, dist, o);
|
yield return MoveFree(u, origin, dir, dist, o);
|
||||||
|
|
@ -275,27 +304,29 @@ namespace TKS
|
||||||
o.MovedOnly = true;
|
o.MovedOnly = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 밀며 전진 거리 = 남은 거리 × 철척사모(push_mult) × 대상 거구(knockback_taken_pct)
|
// 밀치기 거리 = 남은 거리 × 철척사모(push_mult) × 대상 거구(knockback_taken_pct) · 상대만 밀리고 창병은 그 × spearRecoilRatio 만큼 반대로(반작용 · PD #1098)
|
||||||
float advance = remaining * SkillMods.PushMult(u, true) * SkillMods.KnockbackTakenMult(first, true);
|
float push = remaining * SkillMods.PushMult(u, true) * SkillMods.KnockbackTakenMult(first, true);
|
||||||
if (first.IsAlive && advance > 0.01f)
|
if (first.IsAlive && push > 0.01f)
|
||||||
{
|
{
|
||||||
// 튕기지 않고 앞 유닛을 밀며 함께 전진 · 앞 유닛이 다른 유닛·벽에 막히면 둘 다 멈춤 · 접촉 간격 유지
|
bool pushedDone = false;
|
||||||
float travel = Combat.FreeTravel(first, first.Position, dir, advance, true, true, u);
|
Fx.Run(Track(Push(first, dir, push, u, o), () => pushedDone = true));
|
||||||
if (travel > 0.01f)
|
float recoil = Reach(u, u.Position, -dir, push * cfg.spearRecoilRatio);
|
||||||
|
if (recoil > 0.01f && u.IsAlive)
|
||||||
{
|
{
|
||||||
Face(first, dir); // 밀린 유닛은 밀린 방향을 본다(PD #1082)
|
// 반작용: 벽·유닛·장애물에 닿으면 정지 · 피해 없음 · 보는 방향은 유지(밀친 쪽을 본 채 물러남)
|
||||||
Vector2 firstStart = first.Position;
|
float back = Combat.FreeTravel(u, u.Position, -dir, recoil, true, true, first);
|
||||||
Vector2 firstEnd = first.Position + dir * travel;
|
NoteForced(u, -dir, recoil, back);
|
||||||
Vector2 spearEnd = contact + dir * travel;
|
if (back > 0.01f)
|
||||||
Fx.Run(Fx.MoveAlong(first.transform, first.Position, firstEnd, cfg.moveSpeed, null));
|
{
|
||||||
yield return Fx.MoveAlong(u.transform, contact, spearEnd, cfg.moveSpeed, null);
|
Vector2 start = u.Position;
|
||||||
first.Position = firstEnd;
|
Vector2 end = battle.Board.ClampInside(start - dir * back, u.Radius);
|
||||||
u.Position = spearEnd;
|
yield return Fx.MoveAlong(u.transform, start, end, cfg.moveSpeed, null);
|
||||||
o.Path.Add(spearEnd);
|
u.Position = end;
|
||||||
o.Displaced.Add(new Displacement { Unit = first, From = firstStart, To = firstEnd });
|
o.Path.Add(end);
|
||||||
battle.NotifyChanged();
|
}
|
||||||
}
|
}
|
||||||
o.Pushed.Add(first);
|
while (!pushedDone) yield return null;
|
||||||
|
battle.NotifyChanged();
|
||||||
if (!enemy) yield return PushedAllyAttack(first, o, u);
|
if (!enemy) yield return PushedAllyAttack(first, o, u);
|
||||||
}
|
}
|
||||||
if (enemy && u.IsAlive) SpearHitMore(u, first, o);
|
if (enemy && u.IsAlive) SpearHitMore(u, first, o);
|
||||||
|
|
@ -342,8 +373,11 @@ namespace TKS
|
||||||
/// <param name="first">false = 위진소요의 추가 관통(다시 반복·복귀하지 않음 · exclude = 앞서 관통한 적 제외).</param>
|
/// <param name="first">false = 위진소요의 추가 관통(다시 반복·복귀하지 않음 · exclude = 앞서 관통한 적 제외).</param>
|
||||||
IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o, bool first, List<Unit> exclude = null)
|
IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o, bool first, List<Unit> exclude = null)
|
||||||
{
|
{
|
||||||
float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); // 유닛은 무시 · 벽만
|
float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); // 유닛은 무시 · 벽·장애물 기믹만
|
||||||
travel = StopBeforeUnclearedAllies(u, origin, dir, travel); // 지나칠 수 없는 아군 직전에서 멈춤
|
travel = StopBeforeUnclearedAllies(u, origin, dir, travel); // 지나칠 수 없는 아군 직전에서 멈춤
|
||||||
|
float gDist;
|
||||||
|
Vector2 gNormal;
|
||||||
|
var gim = BlockAhead(u, origin, dir, dist, travel + 0.05f, out gDist, out gNormal); // 장애물 = 관통 불가 → 거기서 정지 · 복귀 없음(PD #1097)
|
||||||
var dists = new List<float>();
|
var dists = new List<float>();
|
||||||
var enemies = moveOnly ? new List<Unit>() : Combat.EnemiesOnPath(u, origin, dir, travel, dists);
|
var enemies = moveOnly ? new List<Unit>() : Combat.EnemiesOnPath(u, origin, dir, travel, dists);
|
||||||
if (exclude != null)
|
if (exclude != null)
|
||||||
|
|
@ -358,8 +392,8 @@ namespace TKS
|
||||||
Unit finalHit = moveOnly ? null : Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance);
|
Unit finalHit = moveOnly ? null : Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance);
|
||||||
bool hitFinal = first && finalHit != null && enemies.Contains(finalHit);
|
bool hitFinal = first && finalHit != null && enemies.Contains(finalHit);
|
||||||
// 단기구주: 빗나가도(관통한 적이 있으면) 출발점 복귀 · 위진소요: 명중 시 복귀 대신 같은 방향 한 번 더 관통
|
// 단기구주: 빗나가도(관통한 적이 있으면) 출발점 복귀 · 위진소요: 명중 시 복귀 대신 같은 방향 한 번 더 관통
|
||||||
int repeat = hitFinal ? SkillMods.Count(u, SkillKeys.PierceRepeat, 0) : 0;
|
int repeat = hitFinal && gim == null ? SkillMods.Count(u, SkillKeys.PierceRepeat, 0) : 0;
|
||||||
bool willReturn = first && repeat <= 0 && (hitFinal || (enemies.Count > 0 && SkillMods.Has(u, SkillKeys.PierceReturnOnMiss)));
|
bool willReturn = gim == null && first && repeat <= 0 && (hitFinal || (enemies.Count > 0 && SkillMods.Has(u, SkillKeys.PierceReturnOnMiss)));
|
||||||
|
|
||||||
Vector2 end = origin + dir * travel;
|
Vector2 end = origin + dir * travel;
|
||||||
if (!willReturn && repeat <= 0) end = BackOffFromEnemies(u, origin, dir, travel);
|
if (!willReturn && repeat <= 0) end = BackOffFromEnemies(u, origin, dir, travel);
|
||||||
|
|
@ -368,6 +402,7 @@ namespace TKS
|
||||||
{
|
{
|
||||||
yield return Move(u, end, o);
|
yield return Move(u, end, o);
|
||||||
o.MovedOnly = true;
|
o.MovedOnly = true;
|
||||||
|
if (gim != null && u.IsAlive) yield return CavalryHitsGimmick(u, gim, Mathf.Max(0f, dist - travel), gNormal, o);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,6 +449,12 @@ namespace TKS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gim != null && u.IsAlive)
|
||||||
|
{
|
||||||
|
yield return CavalryHitsGimmick(u, gim, Mathf.Max(0f, dist - travel), gNormal, o);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
if (repeat > 0 && u.IsAlive)
|
if (repeat > 0 && u.IsAlive)
|
||||||
{
|
{
|
||||||
// 위진소요: 목표점에서 같은 방향으로 같은 거리만큼 한 번 더 관통(앞서 관통한 적 제외 · 그 뒤 복귀 없음)
|
// 위진소요: 목표점에서 같은 방향으로 같은 거리만큼 한 번 더 관통(앞서 관통한 적 제외 · 그 뒤 복귀 없음)
|
||||||
|
|
@ -451,7 +492,7 @@ namespace TKS
|
||||||
{
|
{
|
||||||
float kb = cfg.cavalryKnockback * SkillMods.PierceKnockbackMult(cav, true) * SkillMods.KnockbackTakenMult(e, true);
|
float kb = cfg.cavalryKnockback * SkillMods.PierceKnockbackMult(cav, true) * SkillMods.KnockbackTakenMult(e, true);
|
||||||
Vector2 from = e.Position;
|
Vector2 from = e.Position;
|
||||||
Vector2 to = battle.Board.ClampInside(e.Position + side * sign * kb, e.Radius);
|
Vector2 to = KnockTo(e, side * sign, kb); // 늪 0 · 빙판 × · 장애물·함정에서 멈춤 · 다리 → 강(PD #1097)
|
||||||
Fx.Run(Fx.MoveAlong(e.transform, e.Position, to, cfg.moveSpeed * 0.6f, null));
|
Fx.Run(Fx.MoveAlong(e.transform, e.Position, to, cfg.moveSpeed * 0.6f, null));
|
||||||
o.Displaced.Add(new Displacement { Unit = e, From = from, To = to });
|
o.Displaced.Add(new Displacement { Unit = e, From = from, To = to });
|
||||||
}
|
}
|
||||||
|
|
@ -532,6 +573,13 @@ namespace TKS
|
||||||
/// <summary>백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음).</summary>
|
/// <summary>백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음).</summary>
|
||||||
public float Teleport;
|
public float Teleport;
|
||||||
public Vector2 Origin;
|
public Vector2 Origin;
|
||||||
|
/// <summary>투사체를 막거나 맞은 기믹(덤불·바위·성문·성벽 = 막힘 · 폭약 수레 = 파괴 · PD #1097) · 유닛을 맞혔으면 null.</summary>
|
||||||
|
public GimmickInstance HitGimmick;
|
||||||
|
/// <summary>책사 광역이 터진다(중심 = 첫 피격 유닛 · 유닛 없이 덤불·수레에 막혔으면 그 자리).</summary>
|
||||||
|
public bool HasSplash;
|
||||||
|
public Vector2 SplashCenter;
|
||||||
|
/// <summary>책사 광역에 들어 지워질 기믹(덤불 제거 · 수레 파괴).</summary>
|
||||||
|
public readonly List<GimmickInstance> ClearGimmicks = new List<GimmickInstance>();
|
||||||
}
|
}
|
||||||
|
|
||||||
ShotPlan PlanShot(Unit u, Vector2 origin, Vector2 dir, Vector2 target)
|
ShotPlan PlanShot(Unit u, Vector2 origin, Vector2 dir, Vector2 target)
|
||||||
|
|
@ -550,28 +598,32 @@ namespace TKS
|
||||||
p.Target = Combat.FirstEnemyOnPath(u, origin, dir, range + u.ArcherMoveAllow, out hitDist);
|
p.Target = Combat.FirstEnemyOnPath(u, origin, dir, range + u.ArcherMoveAllow, out hitDist);
|
||||||
if (p.Target == null) return p;
|
if (p.Target == null) return p;
|
||||||
|
|
||||||
// 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한)
|
// 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한 · 늪·고지 이동 비용 반영 PD #1097)
|
||||||
float need = Mathf.Clamp(hitDist - range, 0f, u.ArcherMoveAllow);
|
float need = Mathf.Clamp(hitDist - range, 0f, Reach(u, origin, dir, u.ArcherMoveAllow));
|
||||||
p.Approach = need > 0.01f ? Combat.FreeTravel(u, origin, dir, need, true) : 0f;
|
p.Approach = need > 0.01f ? Combat.FreeTravel(u, origin, dir, need, true) : 0f;
|
||||||
p.From = origin + dir * p.Approach;
|
p.From = origin + dir * p.Approach;
|
||||||
p.To = p.From;
|
p.To = p.From;
|
||||||
if (hitDist - p.Approach > range + 0.05f) return p; // 아군·벽에 막혀 사거리에 못 들어감
|
if (hitDist - p.Approach > range + 0.05f) return p; // 아군·벽에 막혀 사거리에 못 들어감
|
||||||
|
Aim(u, p, dir, target, range);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
// 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과)
|
/// <summary>
|
||||||
|
/// 사격 위치(p.From)에서 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과) ·
|
||||||
|
/// 막는 기믹(덤불·바위·성문·성벽) · 파괴되는 기믹(폭약 수레) · 안개 안 유닛 제외 · 책사 = 광역 계획(AimSplash).
|
||||||
|
/// </summary>
|
||||||
|
void Aim(Unit u, ShotPlan p, Vector2 dir, Vector2 target, float range)
|
||||||
|
{
|
||||||
p.Fires = true;
|
p.Fires = true;
|
||||||
Vector2 aim = p.Target.Position - p.From;
|
Vector2 aim = p.Target.Position - p.From;
|
||||||
Vector2 adir = aim.sqrMagnitude > 1e-6f ? aim.normalized : dir;
|
Vector2 adir = aim.sqrMagnitude > 1e-6f ? aim.normalized : dir;
|
||||||
float travel;
|
float travel;
|
||||||
Faction? pass = SkillMods.Has(u, SkillKeys.ArrowPassAllies) ? u.Faction : (Faction?)null;
|
Faction? pass = SkillMods.Has(u, SkillKeys.ArrowPassAllies) ? u.Faction : (Faction?)null;
|
||||||
p.Hit = Combat.FirstUnitOnArrow(u, p.From, adir, range + u.Radius, cfg.arrowRadius, pass, out travel);
|
p.Hit = Combat.FirstUnitOnArrow(u, p.From, adir, range + u.Radius, cfg.arrowRadius, pass, out travel, out p.HitGimmick);
|
||||||
p.To = p.From + adir * (p.Hit != null ? travel + cfg.arrowRadius : travel);
|
p.To = p.From + adir * (p.Hit != null ? travel + cfg.arrowRadius : travel);
|
||||||
if (SkillMods.Has(u, SkillKeys.ArrowCritAlways)) p.Crit = p.Hit != null && p.Hit.Faction != u.Faction; // 백발백중·백기습영: 명중 여부와 무관하게 크리
|
if (SkillMods.Has(u, SkillKeys.ArrowCritAlways)) p.Crit = p.Hit != null && p.Hit.Faction != u.Faction; // 백발백중·백기습영: 명중 여부와 무관하게 크리
|
||||||
else p.Crit = p.Hit == p.Target && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == p.Target;
|
else p.Crit = p.Hit == p.Target && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == p.Target;
|
||||||
if (p.Hit != null && u.Type == UnitType.Strategist)
|
if (u.Type == UnitType.Strategist) AimSplash(u, p);
|
||||||
{
|
|
||||||
p.Splash.AddRange(Combat.UnitsInCircle(p.Hit.Position, p.SplashRadius, p.Hit, u));
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
|
IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
|
||||||
|
|
@ -584,12 +636,16 @@ namespace TKS
|
||||||
}
|
}
|
||||||
if (p == null || p.Target == null)
|
if (p == null || p.Target == null)
|
||||||
{
|
{
|
||||||
// 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지)
|
// 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지) · 장애물 = 튕김 · 늪·고지 = 이동 비용(PD #1097)
|
||||||
if (p == null || p.Teleport <= 0.01f) yield return MoveFree(u, origin, dir, dist, o);
|
if (p == null || p.Teleport <= 0.01f) yield return MoveOrHit(u, origin, dir, Reach(u, origin, dir, dist), o);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p.Approach > 0.01f) yield return Move(u, p.From, o);
|
if (p.Approach > 0.01f)
|
||||||
|
{
|
||||||
|
yield return Move(u, p.From, o);
|
||||||
|
if (p.Fires) yield return SlideBeforeShot(u, dir, p, target, o); // 빙판 = 사격 전 미끄러짐 · 사거리 이탈 = 취소(PD #1097 §9 ③)
|
||||||
|
}
|
||||||
if (!p.Fires)
|
if (!p.Fires)
|
||||||
{
|
{
|
||||||
o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감
|
o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감
|
||||||
|
|
@ -611,16 +667,18 @@ namespace TKS
|
||||||
o.ShotHit = p.Hit;
|
o.ShotHit = p.Hit;
|
||||||
if (u.View != null) u.View.PlayAttack();
|
if (u.View != null) u.View.PlayAttack();
|
||||||
yield return Fx.Arrow(p.From, p.To, strategist ? Fx.StrategistShotColor : Fx.ArrowColor);
|
yield return Fx.Arrow(p.From, p.To, strategist ? Fx.StrategistShotColor : Fx.ArrowColor);
|
||||||
if (p.Hit == null)
|
if (p.Hit == null && p.HitGimmick == null)
|
||||||
{
|
{
|
||||||
yield return Fx.Wait(0.1f); // 빗나감(벽 · 비행 끝)
|
yield return Fx.Wait(0.1f); // 빗나감(벽 · 비행 끝)
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
Vector2 center = p.Hit.Position;
|
Vector2 center = p.HasSplash ? p.SplashCenter : p.To;
|
||||||
ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o);
|
if (p.Hit != null) ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o);
|
||||||
if (strategist)
|
else yield return ArrowHitsGimmick(u, p, o); // 덤불·성문 막힘 · 수레 파괴(PD #1097)
|
||||||
|
if (strategist && p.HasSplash)
|
||||||
{
|
{
|
||||||
|
yield return SplashGimmicks(u, p, o); // 덤불 제거 · 수레 파괴 · 화공 불(PD #1097)
|
||||||
Fx.Burst(center, p.SplashRadius, Fx.SplashColor);
|
Fx.Burst(center, p.SplashRadius, Fx.SplashColor);
|
||||||
float splashRatio = SkillMods.SplashRatio(u, cfg, true);
|
float splashRatio = SkillMods.SplashRatio(u, cfg, true);
|
||||||
float allyRatio = SkillMods.SplashAllyRatio(u, cfg, true);
|
float allyRatio = SkillMods.SplashAllyRatio(u, cfg, true);
|
||||||
|
|
@ -712,8 +770,9 @@ namespace TKS
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio × 밀기 배율(청룡도 지기·호치·함진영) × 대상 밀림 배율(거구)
|
/// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio × 밀기 배율(청룡도 지기·호치·함진영) × 대상 밀림 배율(거구)
|
||||||
/// / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio × 자기 튕김 배율(인덕·호치 = 0). impactDmg = 함진영 충돌 추가 피해 기준.
|
/// / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio × 자기 튕김 배율(인덕·호치 = 0). impactDmg = 함진영 충돌 추가 피해 기준.
|
||||||
|
/// chain = 적과 싸운 뒤의 튕김(PD #1098): 튕겨나는 길에 다른 적이 있으면 그 적과 피해·반격 교환 → 그 적 기준으로 다시 튕김(연쇄 1회) · 아군이면 밀기만.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o, int impactDmg)
|
IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o, int impactDmg, bool chain = false)
|
||||||
{
|
{
|
||||||
bool struckDone = true;
|
bool struckDone = true;
|
||||||
float force = struck != null ? remaining * cfg.collisionPushRatio * SkillMods.PushMult(striker, true) * SkillMods.KnockbackTakenMult(struck, true) : 0f;
|
float force = struck != null ? remaining * cfg.collisionPushRatio * SkillMods.PushMult(striker, true) * SkillMods.KnockbackTakenMult(struck, true) : 0f;
|
||||||
|
|
@ -734,10 +793,40 @@ namespace TKS
|
||||||
}
|
}
|
||||||
Fx.Run(Track(Push(struck, n, force, striker, o, onEnd), () => struckDone = true));
|
Fx.Run(Track(Push(struck, n, force, striker, o, onEnd), () => struckDone = true));
|
||||||
}
|
}
|
||||||
float self = remaining * cfg.collisionSelfBounceRatio * SkillMods.SelfBounceMult(striker, true);
|
float self = Reach(striker, striker.Position, -n, remaining * cfg.collisionSelfBounceRatio * SkillMods.SelfBounceMult(striker, true));
|
||||||
|
if (self >= 0.01f && striker.IsAlive && chain)
|
||||||
|
{
|
||||||
|
// 연쇄(PD #1098): 튕겨나는 길의 첫 유닛(벽·장애물보다 앞) — 적 = 피해·반격 교환 후 다시 튕김 · 아군 = 밀기만(밀린 아군은 규칙 ⑤)
|
||||||
|
float h2;
|
||||||
|
Unit second = Combat.FirstUnitOnPathExcept(striker, striker.Position, -n, self, struck, out h2);
|
||||||
|
if (second != null && h2 <= Combat.FreeTravel(striker, striker.Position, -n, self, false, false) + Combat.Epsilon)
|
||||||
|
{
|
||||||
|
Vector2 c2 = striker.Position - n * h2;
|
||||||
|
yield return MoveStraight(striker, c2, o);
|
||||||
|
float rem2 = Mathf.Max(0f, self - h2);
|
||||||
|
Vector2 n2 = second.Position - c2;
|
||||||
|
n2 = n2.sqrMagnitude > 1e-6f ? n2.normalized : -n;
|
||||||
|
if (second.Faction != striker.Faction)
|
||||||
|
{
|
||||||
|
o.Chains++;
|
||||||
|
o.Notes.Add("연쇄 → " + second.DisplayName);
|
||||||
|
int imp2 = ImpactExchange(striker, second, o);
|
||||||
|
if (striker.IsAlive) yield return Bounce(striker, second, n2, rem2, o, imp2, false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float f2 = rem2 * cfg.collisionPushRatio * SkillMods.KnockbackTakenMult(second, true);
|
||||||
|
if (f2 >= 0.01f) yield return Push(second, n2, f2, striker, o);
|
||||||
|
if (second.IsAlive) yield return PushedAllyAttack(second, o, striker);
|
||||||
|
}
|
||||||
|
while (!struckDone) yield return null;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (self >= 0.01f && striker.IsAlive)
|
if (self >= 0.01f && striker.IsAlive)
|
||||||
{
|
{
|
||||||
float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck);
|
float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck);
|
||||||
|
NoteForced(striker, -n, self, back);
|
||||||
if (back > 0.01f)
|
if (back > 0.01f)
|
||||||
{
|
{
|
||||||
Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082)
|
Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082)
|
||||||
|
|
@ -761,7 +850,9 @@ namespace TKS
|
||||||
{
|
{
|
||||||
if (target == null || !target.IsAlive || force < 0.01f) yield break;
|
if (target == null || !target.IsAlive || force < 0.01f) yield break;
|
||||||
if (pusher != null && pusher.Faction != target.Faction) BattleEvents.Raise(BattleEvents.Pushes, pusher); // 보물 조건 효과(PD #1093 C)
|
if (pusher != null && pusher.Faction != target.Faction) BattleEvents.Raise(BattleEvents.Pushes, pusher); // 보물 조건 효과(PD #1093 C)
|
||||||
|
force = Reach(target, target.Position, n, force); // 늪 = 밀리는 거리도 이동 비용(PD #1097)
|
||||||
float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher);
|
float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher);
|
||||||
|
NoteForced(target, n, force, slide); // 막힘 기록 → 다리 위에서 강 쪽이면 강에 빠짐
|
||||||
float moved = 0f;
|
float moved = 0f;
|
||||||
if (slide > 0.01f)
|
if (slide > 0.01f)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,8 @@ namespace TKS
|
||||||
public float collisionPushRatio = 1f;
|
public float collisionPushRatio = 1f;
|
||||||
[Tooltip("보병 알까기: 부딪힌 보병 자신이 반대 방향으로 튕겨나는 거리 = 충돌 시 남은 슬링샷 거리 × 비율 (PD #1081 「자신도 튕겨나야」)")]
|
[Tooltip("보병 알까기: 부딪힌 보병 자신이 반대 방향으로 튕겨나는 거리 = 충돌 시 남은 슬링샷 거리 × 비율 (PD #1081 「자신도 튕겨나야」)")]
|
||||||
public float collisionSelfBounceRatio = 0.5f;
|
public float collisionSelfBounceRatio = 0.5f;
|
||||||
|
[Tooltip("창병 밀치기 반작용(PD 2026-11-06 #1098): 상대가 밀리는 거리(남은 거리 × 밀기 배율) × 비율만큼 창병이 반대로 밀린다(벽·유닛에 닿으면 정지 · 피해 없음) · 「밀며 함께 전진」 폐기")]
|
||||||
|
public float spearRecoilRatio = 0.5f;
|
||||||
|
|
||||||
[Header("표시")]
|
[Header("표시")]
|
||||||
[Tooltip("유닛 아트(스프라이트) 세로 크기 · 월드 단위 · 장수는 반지름 배율만큼 커진다")]
|
[Tooltip("유닛 아트(스프라이트) 세로 크기 · 월드 단위 · 장수는 반지름 배율만큼 커진다")]
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ namespace TKS
|
||||||
|
|
||||||
public static void Raise(string key, int n = 1)
|
public static void Raise(string key, int n = 1)
|
||||||
{
|
{
|
||||||
if (n <= 0 || Raised == null) return;
|
if (n <= 0 || Raised == null || Sim.Active) return; // 가상 실행(적 AI 예측)은 세지 않는다
|
||||||
Raised(key, n);
|
Raised(key, n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,9 +141,16 @@ namespace TKS
|
||||||
|
|
||||||
public void NotifyChanged()
|
public void NotifyChanged()
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return; // 가상 실행(적 AI 예측) 중 = HUD 갱신 없음
|
||||||
if (Changed != null) Changed();
|
if (Changed != null) Changed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>지형 기믹 레이어(PD #1097 · 전투마다 스테이지 배치로 다시 만든다).</summary>
|
||||||
|
public GimmickLayer Gimmicks
|
||||||
|
{
|
||||||
|
get { return GimmickLayer.Current != null && GimmickLayer.Current.Battle == this ? GimmickLayer.Current : null; }
|
||||||
|
}
|
||||||
|
|
||||||
void SetPhase(BattlePhase p)
|
void SetPhase(BattlePhase p)
|
||||||
{
|
{
|
||||||
if (Phase == p) return;
|
if (Phase == p) return;
|
||||||
|
|
@ -202,6 +209,7 @@ namespace TKS
|
||||||
}
|
}
|
||||||
units.Clear();
|
units.Clear();
|
||||||
if (Skills != null) Skills.Clear();
|
if (Skills != null) Skills.Clear();
|
||||||
|
if (Gimmicks != null) Gimmicks.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 스테이지 ----------
|
// ---------- 스테이지 ----------
|
||||||
|
|
@ -247,12 +255,15 @@ namespace TKS
|
||||||
foreach (var e in stage.enemies)
|
foreach (var e in stage.enemies)
|
||||||
{
|
{
|
||||||
var u = SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss, e.art);
|
var u = SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss, e.art);
|
||||||
Skills.AttachEnemy(u, e); // 적 무장 = 패시브만(액티브 AI 사용은 2차)
|
Skills.AttachEnemy(u, e, TacticalAI.BossUsesActive(stage, e)); // 적 무장 = 패시브 · 1부 보스 = 액티브도(적 AI 가 예약해 씀 · PD #1098)
|
||||||
}
|
}
|
||||||
|
GimmickLayer.Ensure(this).Build(stage); // 지형 기믹 레이어(배경 위 · 유닛 아래 · PD #1097)
|
||||||
|
|
||||||
TurnNumber = 1;
|
TurnNumber = 1;
|
||||||
Skills.BeginBattle();
|
Skills.BeginBattle();
|
||||||
LastMessage = stage.title + " — 아군 차례";
|
LastMessage = stage.title + " — 아군 차례";
|
||||||
|
string tip = GimmickTables.Table != null ? GimmickTables.Table.TipFor(stage.chapter, stage.stage) : "";
|
||||||
|
if (tip.Length > 0) LastMessage += " · " + tip; // 1장 튜토리얼 문구(tutorial_tips.csv · PD #1098 §3)
|
||||||
if (StageStarted != null) StageStarted();
|
if (StageStarted != null) StageStarted();
|
||||||
SetPhase(BattlePhase.PlayerTurn);
|
SetPhase(BattlePhase.PlayerTurn);
|
||||||
}
|
}
|
||||||
|
|
@ -419,27 +430,61 @@ namespace TKS
|
||||||
LastMessage = "적군 차례";
|
LastMessage = "적군 차례";
|
||||||
NotifyChanged();
|
NotifyChanged();
|
||||||
foreach (var e in AliveUnits(Faction.Enemy)) e.ResetForNewTurn();
|
foreach (var e in AliveUnits(Faction.Enemy)) e.ResetForNewTurn();
|
||||||
|
if (Gimmicks != null)
|
||||||
|
{
|
||||||
|
yield return Gimmicks.TurnStart(Faction.Enemy); // 군기 · 중독 · 불 · 행동 불가(PD #1097)
|
||||||
|
if (CheckBattleEnd()) yield break;
|
||||||
|
}
|
||||||
yield return Fx.Wait(0.4f);
|
yield return Fx.Wait(0.4f);
|
||||||
|
|
||||||
var enemies = AliveUnits(Faction.Enemy);
|
|
||||||
float accuracy = Stage.aiAccuracy;
|
float accuracy = Stage.aiAccuracy;
|
||||||
float aggro = Mathf.Max(0f, Stage.aiAggroRange + Skills.AggroAdd(Faction.Enemy)); // 신산 = 적 교전 범위 −
|
float aggro = Mathf.Max(0f, Stage.aiAggroRange + Skills.AggroAdd(Faction.Enemy)); // 신산 = 적 교전 범위 −
|
||||||
int acted = 0;
|
int acted = 0;
|
||||||
for (int i = 0; i < enemies.Count; i++)
|
// 예측 시뮬레이션 AI(PD #1098): 남은 적 전원 평가 → 서포터 → 돌격 → 사격 · 1기 실행마다 재평가 · 교전 범위 밖 = 대기
|
||||||
|
var pending = new List<Unit>();
|
||||||
|
foreach (var e in AliveUnits(Faction.Enemy))
|
||||||
{
|
{
|
||||||
var e = enemies[i];
|
|
||||||
if (!e.IsAlive) continue;
|
|
||||||
if (Skills.Suppressed(e)) continue; // 장판 일갈·태평요술 = 이번 적 턴 교전 반응 없음
|
if (Skills.Suppressed(e)) continue; // 장판 일갈·태평요술 = 이번 적 턴 교전 반응 없음
|
||||||
|
if (GimmickLayer.Skips(e)) continue; // 함정·강 = 이번 턴 행동 불가
|
||||||
|
pending.Add(e);
|
||||||
|
}
|
||||||
|
bool tactical = TacticalAI.Available;
|
||||||
|
while (pending.Count > 0)
|
||||||
|
{
|
||||||
|
pending.RemoveAll(x => x == null || !x.IsAlive);
|
||||||
var players = AliveUnits(Faction.Player);
|
var players = AliveUnits(Faction.Player);
|
||||||
if (players.Count == 0) break;
|
if (players.Count == 0 || pending.Count == 0) break;
|
||||||
|
|
||||||
Vector2? target = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config);
|
Unit e;
|
||||||
if (!target.HasValue) continue; // 교전 범위 밖 → 자리 지킴
|
Vector2 target;
|
||||||
|
bool useSkill = false;
|
||||||
|
if (tactical)
|
||||||
|
{
|
||||||
|
var plan = TacticalAI.Next(this, pending, accuracy, aggro, Rng);
|
||||||
|
if (plan == null) break; // 남은 유닛 = 교전 범위 밖 → 자리 지킴
|
||||||
|
e = plan.Unit;
|
||||||
|
target = plan.Target;
|
||||||
|
useSkill = plan.UseSkill;
|
||||||
|
LastAiPlan = plan;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e = pending[0];
|
||||||
|
Vector2? aim = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config); // 가중치 자산이 없으면 기존 조준
|
||||||
|
if (!aim.HasValue)
|
||||||
|
{
|
||||||
|
pending.RemoveAt(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
target = aim.Value;
|
||||||
|
}
|
||||||
|
pending.Remove(e);
|
||||||
|
|
||||||
if (UnitActing != null) UnitActing(e);
|
if (UnitActing != null) UnitActing(e);
|
||||||
yield return Fx.Wait(0.3f); // 카메라가 따라올 시간
|
yield return Fx.Wait(0.3f); // 카메라가 따라올 시간
|
||||||
|
if (useSkill && Skills.Of(e) != null) Skills.Of(e).Reserved = true; // 적 보스 액티브(다음 행동형 · 1부 보스만)
|
||||||
var o = new ActionOutcome();
|
var o = new ActionOutcome();
|
||||||
yield return Resolver.Resolve(e, target.Value, false, o);
|
yield return Resolver.Resolve(e, target, false, o);
|
||||||
yield return ApplyOverwatch(e, o);
|
yield return ApplyOverwatch(e, o);
|
||||||
LastMessage = Describe(e, o, false);
|
LastMessage = Describe(e, o, false);
|
||||||
NotifyChanged();
|
NotifyChanged();
|
||||||
|
|
@ -458,10 +503,25 @@ namespace TKS
|
||||||
foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn();
|
foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn();
|
||||||
Skills.OnPlayerTurnStart(); // 이번 적 턴 효과(호위·철벽·간웅·일갈·안개) · 견제 강화 정리
|
Skills.OnPlayerTurnStart(); // 이번 적 턴 효과(호위·철벽·간웅·일갈·안개) · 견제 강화 정리
|
||||||
TurnNumber++;
|
TurnNumber++;
|
||||||
|
if (Gimmicks != null)
|
||||||
|
{
|
||||||
|
yield return Gimmicks.TurnStart(Faction.Player); // 군기 · 불 지속 · 중독 · 불 · 행동 불가(PD #1097)
|
||||||
|
if (CheckBattleEnd()) yield break;
|
||||||
|
}
|
||||||
LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : "");
|
LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : "");
|
||||||
|
if (AliveCount(Faction.Player) > 0 && AllPlayersActed())
|
||||||
|
{
|
||||||
|
LastMessage = TurnNumber + "번째 턴 — 아군 전원 행동 불가";
|
||||||
|
yield return Fx.Wait(0.4f);
|
||||||
|
yield return EnemyTurnRoutine(); // 함정·강으로 아군 전원이 쉬는 턴
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
SetPhase(BattlePhase.PlayerTurn);
|
SetPhase(BattlePhase.PlayerTurn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>마지막 적 AI 결정(디버그 · 테스트 · 캡처).</summary>
|
||||||
|
public AiPlan LastAiPlan;
|
||||||
|
|
||||||
/// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary>
|
/// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary>
|
||||||
public IEnumerator ApplyOverwatch(Unit mover, List<Vector2> path)
|
public IEnumerator ApplyOverwatch(Unit mover, List<Vector2> path)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ using UnityEngine;
|
||||||
|
|
||||||
namespace TKS
|
namespace TKS
|
||||||
{
|
{
|
||||||
/// <summary>피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중).</summary>
|
/// <summary>피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중). 지형 기믹 질의 = Gimmicks/Combat.Gimmicks.cs.</summary>
|
||||||
public static class Combat
|
public static partial class Combat
|
||||||
{
|
{
|
||||||
/// <summary>경로 질의에 쓰는 원 반지름 배율(살짝 작게 캐스트해 스침을 관대하게).</summary>
|
/// <summary>경로 질의에 쓰는 원 반지름 배율(살짝 작게 캐스트해 스침을 관대하게).</summary>
|
||||||
public const float CastRadiusScale = 0.9f;
|
public const float CastRadiusScale = 0.9f;
|
||||||
|
|
@ -23,16 +23,16 @@ namespace TKS
|
||||||
public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee, float scale = 1f)
|
public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee, float scale = 1f)
|
||||||
{
|
{
|
||||||
float mult = Mathf.Max(cfg.Matchup(attacker.Type, defender.Type), SkillMods.MatchupFloor(attacker, true));
|
float mult = Mathf.Max(cfg.Matchup(attacker.Type, defender.Type), SkillMods.MatchupFloor(attacker, true));
|
||||||
float atk = attacker.Atk * SkillMods.AttackMult(attacker, true);
|
float atk = attacker.Atk * SkillMods.AttackMult(attacker, true) * GimmickLayer.AttackMultOf(attacker); // 군기 점령(PD #1097)
|
||||||
float raw = atk * mult * (crit ? SkillMods.CritMult(attacker, cfg) : 1f) - defender.Def * cfg.defenseFactor; // 보물 크리 % 포함(PD #1093 C)
|
float raw = atk * mult * (crit ? SkillMods.CritMult(attacker, cfg) : 1f) - defender.Def * cfg.defenseFactor; // 보물 크리 % 포함(PD #1093 C)
|
||||||
if (melee)
|
if (melee)
|
||||||
{
|
{
|
||||||
if (UnitTypeInfo.IsRanged(attacker.Type) && UnitTypeInfo.IsMelee(defender.Type) && !SkillMods.Has(attacker, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeDealtRatio;
|
if (UnitTypeInfo.IsRanged(attacker.Type) && UnitTypeInfo.IsMelee(defender.Type) && !SkillMods.Has(attacker, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeDealtRatio;
|
||||||
if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type) && !SkillMods.Has(defender, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeTakenRatio;
|
if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type) && !SkillMods.Has(defender, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeTakenRatio;
|
||||||
|
raw *= GimmickLayer.MeleeTakenMultOf(attacker, defender); // 고지 위 = 아래에서 오는 근접 피해 −(PD #1097)
|
||||||
}
|
}
|
||||||
raw *= scale * SkillMods.DealtMult(attacker, defender, true) * SkillMods.TakenMult(defender, attacker, true);
|
raw *= scale * SkillMods.DealtMult(attacker, defender, true) * SkillMods.TakenMult(defender, attacker, true);
|
||||||
float variance = 1f + ((float)rng.NextDouble() * 2f - 1f) * cfg.damageVariance;
|
return Mathf.Max(1, Mathf.RoundToInt(raw * Variance(cfg, rng))); // 가상 실행 = 분산 0(PD #1098)
|
||||||
return Mathf.Max(1, Mathf.RoundToInt(raw * variance));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>책사 광역 피해(PD 2026-11-05 #1087 ⑤) = 대상별 사격 피해(상성 적용 · 크리 없음) × ratio · ratio ≤ 0 이면 0.</summary>
|
/// <summary>책사 광역 피해(PD 2026-11-05 #1087 ⑤) = 대상별 사격 피해(상성 적용 · 크리 없음) × ratio · ratio ≤ 0 이면 0.</summary>
|
||||||
|
|
@ -86,6 +86,7 @@ namespace TKS
|
||||||
{
|
{
|
||||||
var u = UnitOf(h.collider);
|
var u = UnitOf(h.collider);
|
||||||
if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue;
|
if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue;
|
||||||
|
if (GimmickLayer.IsFogged(u)) continue; // 안개 안 = 사선 표적 제외(PD #1097)
|
||||||
hitDist = h.distance;
|
hitDist = h.distance;
|
||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +107,20 @@ namespace TKS
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>경로 위 가장 가까운 유닛(except 제외) — 보병 연쇄 튕김(PD 2026-11-06 #1098 · 방금 부딪힌 유닛은 뺀다).</summary>
|
||||||
|
public static Unit FirstUnitOnPathExcept(Unit actor, Vector2 origin, Vector2 dir, float dist, Unit except, out float hitDist)
|
||||||
|
{
|
||||||
|
hitDist = 0f;
|
||||||
|
foreach (var h in Cast(actor, origin, dir, dist))
|
||||||
|
{
|
||||||
|
var u = UnitOf(h.collider);
|
||||||
|
if (u == null || u == actor || u == except || !u.IsAlive) continue;
|
||||||
|
hitDist = h.distance;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외).
|
/// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외).
|
||||||
/// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist).
|
/// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist).
|
||||||
|
|
@ -118,24 +133,8 @@ namespace TKS
|
||||||
|
|
||||||
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel)
|
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel)
|
||||||
{
|
{
|
||||||
travel = dist;
|
GimmickInstance g;
|
||||||
Physics2D.SyncTransforms();
|
return FirstUnitOnArrow(shooter, from, dir, dist, arrowRadius, passFaction, out travel, out g); // 기믹 pass/block/destroy · 안개(Combat.Gimmicks.cs)
|
||||||
var hits = Physics2D.CircleCastAll(from, arrowRadius, dir, dist);
|
|
||||||
System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
|
|
||||||
foreach (var h in hits)
|
|
||||||
{
|
|
||||||
if (h.collider.GetComponent<BoardWall>() != null)
|
|
||||||
{
|
|
||||||
travel = h.distance;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var u = UnitOf(h.collider);
|
|
||||||
if (u == null || u == shooter || !u.IsAlive) continue;
|
|
||||||
if (passFaction.HasValue && u.Faction == passFaction.Value) continue;
|
|
||||||
travel = h.distance;
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>점 center 반경 radius 원에 몸(원)이 닿는 살아 있는 유닛(가까운 순 · 진영 불문 · exclude 둘 제외) — 책사 광역(보스는 반지름이 커서 더 잘 닿는다).</summary>
|
/// <summary>점 center 반경 radius 원에 몸(원)이 닿는 살아 있는 유닛(가까운 순 · 진영 불문 · exclude 둘 제외) — 책사 광역(보스는 반지름이 커서 더 잘 닿는다).</summary>
|
||||||
|
|
@ -189,13 +188,16 @@ namespace TKS
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>벽·(옵션) 유닛에 막히기 전까지 실제로 나아갈 수 있는 거리. ignore = 밀어 주는 유닛처럼 출발 지점에서 닿아 있어도 무시할 유닛.</summary>
|
/// <summary>
|
||||||
|
/// 벽·장애물 기믹·(옵션) 유닛에 막히기 전까지 실제로 나아갈 수 있는 거리. ignore = 밀어 주는 유닛처럼 출발 지점에서 닿아 있어도 무시할 유닛.
|
||||||
|
/// 숨은 함정에 중심이 들면 그 자리에서 멈춘다(PD #1097 · 아군 조준 미리보기는 함정을 모른다).
|
||||||
|
/// </summary>
|
||||||
public static float FreeTravel(Unit actor, Vector2 origin, Vector2 dir, float dist, bool enemiesBlock, bool friendsBlock = true, Unit ignore = null)
|
public static float FreeTravel(Unit actor, Vector2 origin, Vector2 dir, float dist, bool enemiesBlock, bool friendsBlock = true, Unit ignore = null)
|
||||||
{
|
{
|
||||||
float min = dist;
|
float min = TrapStop(actor, origin, dir, dist);
|
||||||
foreach (var h in Cast(actor, origin, dir, dist))
|
foreach (var h in Cast(actor, origin, dir, dist))
|
||||||
{
|
{
|
||||||
if (h.collider.GetComponent<BoardWall>() != null)
|
if (h.collider.GetComponent<BoardWall>() != null || GimmickLayer.BlocksMove(h.collider))
|
||||||
{
|
{
|
||||||
min = Mathf.Min(min, h.distance);
|
min = Mathf.Min(min, h.distance);
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,25 @@ using UnityEngine;
|
||||||
|
|
||||||
namespace TKS
|
namespace TKS
|
||||||
{
|
{
|
||||||
/// <summary>간단한 연출(이동 트윈·돌진·화살·피해 숫자). 코루틴 실행자는 BattleManager.</summary>
|
/// <summary>간단한 연출(이동 트윈·돌진·화살·피해 숫자). 코루틴 실행자는 BattleManager. 가상 실행(Sim.Active · 적 AI 예측) 중 = 이동·대기 즉시 · 팝업·터짐 없음.</summary>
|
||||||
public static class Fx
|
public static class Fx
|
||||||
{
|
{
|
||||||
public static MonoBehaviour Runner;
|
public static MonoBehaviour Runner;
|
||||||
|
|
||||||
public static Coroutine Run(IEnumerator routine)
|
public static Coroutine Run(IEnumerator routine)
|
||||||
{
|
{
|
||||||
|
if (Sim.Active)
|
||||||
|
{
|
||||||
|
Sim.Run(routine); // 가상 실행 = 그 자리에서 끝까지(병렬 밀기 등)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (Runner == null) return null;
|
if (Runner == null) return null;
|
||||||
return Runner.StartCoroutine(routine);
|
return Runner.StartCoroutine(routine);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IEnumerator Wait(float seconds)
|
public static IEnumerator Wait(float seconds)
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) yield break;
|
||||||
float t = 0f;
|
float t = 0f;
|
||||||
while (t < seconds)
|
while (t < seconds)
|
||||||
{
|
{
|
||||||
|
|
@ -30,6 +36,12 @@ namespace TKS
|
||||||
{
|
{
|
||||||
float total = Vector2.Distance(from, to);
|
float total = Vector2.Distance(from, to);
|
||||||
if (total < 1e-4f) yield break;
|
if (total < 1e-4f) yield break;
|
||||||
|
if (Sim.Active)
|
||||||
|
{
|
||||||
|
tr.position = new Vector3(to.x, to.y, 0f);
|
||||||
|
if (onDistance != null) onDistance(total);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
float duration = Mathf.Max(0.12f, total / Mathf.Max(0.1f, speed));
|
float duration = Mathf.Max(0.12f, total / Mathf.Max(0.1f, speed));
|
||||||
float el = 0f;
|
float el = 0f;
|
||||||
while (el < duration)
|
while (el < duration)
|
||||||
|
|
@ -49,6 +61,7 @@ namespace TKS
|
||||||
/// <summary>근접 공격 돌진(짧게 찌르고 복귀).</summary>
|
/// <summary>근접 공격 돌진(짧게 찌르고 복귀).</summary>
|
||||||
public static IEnumerator Lunge(Unit a, Vector2 toward)
|
public static IEnumerator Lunge(Unit a, Vector2 toward)
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) yield break;
|
||||||
Vector2 start = a.Position;
|
Vector2 start = a.Position;
|
||||||
Vector2 dir = (toward - start);
|
Vector2 dir = (toward - start);
|
||||||
if (dir.sqrMagnitude < 1e-4f) yield break;
|
if (dir.sqrMagnitude < 1e-4f) yield break;
|
||||||
|
|
@ -73,6 +86,7 @@ namespace TKS
|
||||||
/// <summary>투사체(가늘고 긴 사각형) 날리기 — 궁병 화살 · 책사 계략(색만 다름).</summary>
|
/// <summary>투사체(가늘고 긴 사각형) 날리기 — 궁병 화살 · 책사 계략(색만 다름).</summary>
|
||||||
public static IEnumerator Arrow(Vector2 from, Vector2 to, Color color)
|
public static IEnumerator Arrow(Vector2 from, Vector2 to, Color color)
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) yield break;
|
||||||
var go = new GameObject("Arrow");
|
var go = new GameObject("Arrow");
|
||||||
var sr = go.AddComponent<SpriteRenderer>();
|
var sr = go.AddComponent<SpriteRenderer>();
|
||||||
sr.sprite = ProceduralSprites.Square;
|
sr.sprite = ProceduralSprites.Square;
|
||||||
|
|
@ -97,6 +111,7 @@ namespace TKS
|
||||||
/// <summary>광역 터짐(반지름 radius 원이 짧게 커지며 사라짐 · 책사 광역).</summary>
|
/// <summary>광역 터짐(반지름 radius 원이 짧게 커지며 사라짐 · 책사 광역).</summary>
|
||||||
public static void Burst(Vector2 center, float radius, Color color)
|
public static void Burst(Vector2 center, float radius, Color color)
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return;
|
||||||
Run(BurstRoutine(center, radius, color));
|
Run(BurstRoutine(center, radius, color));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,6 +137,7 @@ namespace TKS
|
||||||
|
|
||||||
public static void Popup(Vector2 pos, string text, Color color)
|
public static void Popup(Vector2 pos, string text, Color color)
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return;
|
||||||
Run(PopupRoutine(pos, text, color));
|
Run(PopupRoutine(pos, text, color));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4c04a047e2d9a1c44bea58d6a6fbf8a8
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 09ebc6412623b864ebdbad94df388f89
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3be7ffd206cf91f458c0a84090c1363d
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace TKS
|
||||||
|
{
|
||||||
|
/// <summary>장애물 기믹 콜라이더 표식(캐스트 질의에서 어떤 기믹인지 찾는다 · BoardWall 과 같은 역할).</summary>
|
||||||
|
public class GimmickBody : MonoBehaviour
|
||||||
|
{
|
||||||
|
public GimmickInstance Inst;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 31660f844b158db42880d31b9625a704
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 85b5cf0772320f4408726c98222609db
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 645600176fecaa047908a4a1acebef2d
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9acfdb0ffd0c7604b8ea57748aed0456
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d1257de6310ea7d4aa26f8cc4d71c6f6
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 297969a5821fa9e428e5c0b0920c81eb
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 08f2e7173a4b32c4d8744429c48520a1
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 88a4d45c42d9ad942a2092b6378de92f
|
||||||
|
|
@ -0,0 +1,342 @@
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace TKS
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 기믹 표시(PD #1097 §6): 스프라이트(Resources/Gimmicks/Art/<art>)가 있으면 모양에 맞춰 늘리고, 없으면 색 도형 + 이름 글자.
|
||||||
|
/// 장애물 = 내구 핍 · 피격 흔들림 · 파괴 파편 → 잔해(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b65a87233ccc60e48b4267a11d41d734
|
||||||
|
|
@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1f8e3e401b33e1d46bf1084189a5f061
|
||||||
|
|
@ -181,11 +181,11 @@ namespace TKS
|
||||||
return Mathf.Max(0.1f, u.SlingMaxDistance * (1f + pct) + add);
|
return Mathf.Max(0.1f, u.SlingMaxDistance * (1f + pct) + add);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>궁병·책사 사거리 = (기본 + range_add + 대도독 team_range_add) × range_mult.</summary>
|
/// <summary>궁병·책사 사거리 = (기본 + range_add + 대도독 team_range_add + 고지 p1(지형 기믹 PD #1097)) × range_mult.</summary>
|
||||||
public static float Range(Unit u, bool note = false)
|
public static float Range(Unit u, bool note = false)
|
||||||
{
|
{
|
||||||
if (u == null) return 0f;
|
if (u == null) return 0f;
|
||||||
float r = u.AttackRange + Sum(u, SkillKeys.RangeAdd, note) + Sum(u, SkillKeys.TeamRangeAdd, note);
|
float r = u.AttackRange + Sum(u, SkillKeys.RangeAdd, note) + Sum(u, SkillKeys.TeamRangeAdd, note) + GimmickLayer.RangeBonusOf(u);
|
||||||
return Mathf.Max(0f, r * Product(u, SkillKeys.RangeMult, note));
|
return Mathf.Max(0f, r * Product(u, SkillKeys.RangeMult, note));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,8 +190,11 @@ namespace TKS
|
||||||
return Attach(u, e.skillActive, e.activeLv, e.skillPassive, e.passiveLv, e.passiveMult, e.general);
|
return Attach(u, e.skillActive, e.activeLv, e.skillPassive, e.passiveLv, e.passiveMult, e.general);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>적 무장(보스·엘리트) = heroes.csv 에 같은 아트·이름 영웅이 있으면 그 패시브만(Lv1 · 액티브 AI 사용은 2차 · PD 확인 대상).</summary>
|
/// <summary>
|
||||||
public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e)
|
/// 적 무장(보스·엘리트) = heroes.csv 에 같은 아트·이름 영웅이 있으면 그 패시브(Lv1) · withActive = 액티브도(적 AI 가 다음 행동형만 예약해 씀 ·
|
||||||
|
/// 1부 보스만 = ai_weights.csv boss_active·active_max_chapter · PD #1098 확인 대상).
|
||||||
|
/// </summary>
|
||||||
|
public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e, bool withActive = false)
|
||||||
{
|
{
|
||||||
if (u == null || e == null || !(e.isBoss || e.isGeneral)) return null;
|
if (u == null || e == null || !(e.isBoss || e.isGeneral)) return null;
|
||||||
var h = SkillCatalog.HeroForEnemy(e.art, e.name);
|
var h = SkillCatalog.HeroForEnemy(e.art, e.name);
|
||||||
|
|
@ -201,7 +204,7 @@ namespace TKS
|
||||||
s.IsGeneral = true;
|
s.IsGeneral = true;
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
return Attach(u, null, 1, h.skillPassive, 1, 1f, true);
|
return Attach(u, withActive ? h.skillActive : null, 1, h.skillPassive, 1, 1f, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MarkGeneral(Unit u)
|
public void MarkGeneral(Unit u)
|
||||||
|
|
@ -794,6 +797,7 @@ namespace TKS
|
||||||
|
|
||||||
public static void Announce(Unit u, string name)
|
public static void Announce(Unit u, string name)
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return;
|
||||||
Fx.Burst(u.Position, u.Radius * 2.2f, FlashColor);
|
Fx.Burst(u.Position, u.Radius * 2.2f, FlashColor);
|
||||||
Fx.Run(NameRoutine(u.Position + new Vector2(0f, u.ArtHeight > 0f ? u.ArtHeight * 0.6f : u.Radius + 0.6f), name));
|
Fx.Run(NameRoutine(u.Position + new Vector2(0f, u.ArtHeight > 0f ? u.ArtHeight * 0.6f : u.Radius + 0.6f), name));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,8 @@ namespace TKS
|
||||||
/// <summary>조준 표시(자동 플레이 캡처에서도 호출). 반환 = 공격 예정 적 수.</summary>
|
/// <summary>조준 표시(자동 플레이 캡처에서도 호출). 반환 = 공격 예정 적 수.</summary>
|
||||||
public int ShowPreview(Unit u, Vector2 target, Vector2? pointer)
|
public int ShowPreview(Unit u, Vector2 target, Vector2? pointer)
|
||||||
{
|
{
|
||||||
|
Vector2 aim = target;
|
||||||
|
target = battle.Resolver.ReachPoint(u, target); // 늪·고지 = 실제 정지점(PD #1097)
|
||||||
aimLine.enabled = true;
|
aimLine.enabled = true;
|
||||||
aimLine.SetPosition(0, new Vector3(u.Position.x, u.Position.y, 0f));
|
aimLine.SetPosition(0, new Vector3(u.Position.x, u.Position.y, 0f));
|
||||||
aimLine.SetPosition(1, new Vector3(target.x, target.y, 0f));
|
aimLine.SetPosition(1, new Vector3(target.x, target.y, 0f));
|
||||||
|
|
@ -318,13 +320,15 @@ namespace TKS
|
||||||
targetRing.transform.localScale = finalHit != null ? new Vector3(0.9f, 0.9f, 1f) : new Vector3(0.7f, 0.7f, 1f);
|
targetRing.transform.localScale = finalHit != null ? new Vector3(0.9f, 0.9f, 1f) : new Vector3(0.7f, 0.7f, 1f);
|
||||||
|
|
||||||
ClearHighlights();
|
ClearHighlights();
|
||||||
var shot = battle.Resolver.PreviewShot(u, target, moveOnly);
|
var shot = battle.Resolver.PreviewShot(u, aim, moveOnly);
|
||||||
|
var gl = battle.Gimmicks;
|
||||||
|
if (gl != null) gl.Preview.Show(u, aim, moveOnly, shot); // 예상 궤적(튕김·밀림·연쇄·반작용) · 기믹 반응 · 지대 띠(PD #1097 · #1098)
|
||||||
if (shot != null)
|
if (shot != null)
|
||||||
{
|
{
|
||||||
return ShowShot(u, shot);
|
return ShowShot(u, shot);
|
||||||
}
|
}
|
||||||
HideShot();
|
HideShot();
|
||||||
foreach (var e in battle.Resolver.PredictTargets(u, target, moveOnly)) Highlight(e, false);
|
foreach (var e in battle.Resolver.PredictTargets(u, aim, moveOnly)) Highlight(e, false);
|
||||||
return highlighted.Count;
|
return highlighted.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,7 +355,7 @@ namespace TKS
|
||||||
if (!friendlyCenter) foes++;
|
if (!friendlyCenter) foes++;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool splash = u.Type == UnitType.Strategist && shot.Hit != null;
|
bool splash = u.Type == UnitType.Strategist && shot.HasSplash; // 덤불·수레에 막혀도 그 자리 광역(PD #1097)
|
||||||
if (splash)
|
if (splash)
|
||||||
{
|
{
|
||||||
bool anyFriendly = friendlyCenter;
|
bool anyFriendly = friendlyCenter;
|
||||||
|
|
@ -362,8 +366,8 @@ namespace TKS
|
||||||
if (friendly) anyFriendly = true;
|
if (friendly) anyFriendly = true;
|
||||||
else foes++;
|
else foes++;
|
||||||
}
|
}
|
||||||
float d = shot.SplashRadius * 2f; // 화공 = 반경 ×(PD #1093 B)
|
float d = shot.SplashRadius * 2f; // 화공 = 반경 ×(PD #1093 B) · 안개 중심 = ×p1
|
||||||
splashArea.transform.position = new Vector3(shot.Hit.Position.x, shot.Hit.Position.y, 0f);
|
splashArea.transform.position = new Vector3(shot.SplashCenter.x, shot.SplashCenter.y, 0f);
|
||||||
splashArea.transform.localScale = new Vector3(d, d, 1f);
|
splashArea.transform.localScale = new Vector3(d, d, 1f);
|
||||||
splashArea.color = anyFriendly ? SplashAreaFriendlyColor : SplashAreaColor;
|
splashArea.color = anyFriendly ? SplashAreaFriendlyColor : SplashAreaColor;
|
||||||
}
|
}
|
||||||
|
|
@ -391,6 +395,7 @@ namespace TKS
|
||||||
targetRing.enabled = false;
|
targetRing.enabled = false;
|
||||||
HideShot();
|
HideShot();
|
||||||
ClearHighlights();
|
ClearHighlights();
|
||||||
|
if (battle != null && battle.Gimmicks != null) battle.Gimmicks.Preview.Hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClearHighlights()
|
void ClearHighlights()
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ namespace TKS
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SetFacing(Vector2 dir)
|
public void SetFacing(Vector2 dir)
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
|
||||||
bool viewChanged = false;
|
bool viewChanged = false;
|
||||||
if (dir.y > FacingEps && !facingBack)
|
if (dir.y > FacingEps && !facingBack)
|
||||||
{
|
{
|
||||||
|
|
@ -186,6 +187,7 @@ namespace TKS
|
||||||
|
|
||||||
public void Refresh()
|
public void Refresh()
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
|
||||||
if (unit == null || hpFill == null) return;
|
if (unit == null || hpFill == null) return;
|
||||||
float ratio = unit.MaxHp > 0 ? Mathf.Clamp01((float)unit.Hp / unit.MaxHp) : 0f;
|
float ratio = unit.MaxHp > 0 ? Mathf.Clamp01((float)unit.Hp / unit.MaxHp) : 0f;
|
||||||
hpFill.transform.localScale = new Vector3(HpBarWidth * ratio, 0.12f, 1f);
|
hpFill.transform.localScale = new Vector3(HpBarWidth * ratio, 0.12f, 1f);
|
||||||
|
|
@ -248,6 +250,7 @@ namespace TKS
|
||||||
/// <summary>조준(당기는) 중 = 출발 자세(ready · 1프레임) · 끝나면 대기 (PD 2026-11-05 #1084).</summary>
|
/// <summary>조준(당기는) 중 = 출발 자세(ready · 1프레임) · 끝나면 대기 (PD 2026-11-05 #1084).</summary>
|
||||||
public void SetAiming(bool on)
|
public void SetAiming(bool on)
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
|
||||||
if (anim == null || aiming == on) return;
|
if (anim == null || aiming == on) return;
|
||||||
aiming = on;
|
aiming = on;
|
||||||
if (on) anim.Play(ClipName("ready"), true);
|
if (on) anim.Play(ClipName("ready"), true);
|
||||||
|
|
@ -257,6 +260,7 @@ namespace TKS
|
||||||
/// <summary>슬링샷 돌진(이동) 중 = 병종 돌진 포즈(action · 1프레임 고정) · 멈추면 대기 (PD 2026-11-05 #1084).</summary>
|
/// <summary>슬링샷 돌진(이동) 중 = 병종 돌진 포즈(action · 1프레임 고정) · 멈추면 대기 (PD 2026-11-05 #1084).</summary>
|
||||||
public void SetCharging(bool on)
|
public void SetCharging(bool on)
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
|
||||||
if (anim == null || charging == on) return;
|
if (anim == null || charging == on) return;
|
||||||
charging = on;
|
charging = on;
|
||||||
aiming = false;
|
aiming = false;
|
||||||
|
|
@ -272,6 +276,7 @@ namespace TKS
|
||||||
/// <summary>공격 자세(1 프레임) 잠깐 → 대기. 돌진 중이면 이미 돌진 포즈라 그대로 둔다.</summary>
|
/// <summary>공격 자세(1 프레임) 잠깐 → 대기. 돌진 중이면 이미 돌진 포즈라 그대로 둔다.</summary>
|
||||||
public void PlayAttack()
|
public void PlayAttack()
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
|
||||||
if (anim == null || !isActiveAndEnabled || charging) return;
|
if (anim == null || !isActiveAndEnabled || charging) return;
|
||||||
aiming = false;
|
aiming = false;
|
||||||
anim.Play(ClipName("action"), false, AttackHold, true);
|
anim.Play(ClipName("action"), false, AttackHold, true);
|
||||||
|
|
@ -280,6 +285,7 @@ namespace TKS
|
||||||
/// <summary>승리 동작(앞모습 · 끝 프레임 유지).</summary>
|
/// <summary>승리 동작(앞모습 · 끝 프레임 유지).</summary>
|
||||||
public void PlayVictory()
|
public void PlayVictory()
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
|
||||||
if (anim == null || !isActiveAndEnabled || !unit.IsAlive) return;
|
if (anim == null || !isActiveAndEnabled || !unit.IsAlive) return;
|
||||||
aiming = false;
|
aiming = false;
|
||||||
anim.Play(ClipName("victory"), false);
|
anim.Play(ClipName("victory"), false);
|
||||||
|
|
@ -287,6 +293,7 @@ namespace TKS
|
||||||
|
|
||||||
public void OnHit()
|
public void OnHit()
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
|
||||||
if (!isActiveAndEnabled) return;
|
if (!isActiveAndEnabled) return;
|
||||||
if (flash != null) StopCoroutine(flash);
|
if (flash != null) StopCoroutine(flash);
|
||||||
flash = StartCoroutine(FlashRoutine());
|
flash = StartCoroutine(FlashRoutine());
|
||||||
|
|
@ -315,6 +322,7 @@ namespace TKS
|
||||||
|
|
||||||
public void PlayDeath()
|
public void PlayDeath()
|
||||||
{
|
{
|
||||||
|
if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로
|
||||||
if (!isActiveAndEnabled) return;
|
if (!isActiveAndEnabled) return;
|
||||||
StartCoroutine(DeathRoutine());
|
StartCoroutine(DeathRoutine());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -586,9 +586,10 @@ namespace TKS.Tests
|
||||||
yield return bm.ResolveForTest(spear, foe.Position, false, o);
|
yield return bm.ResolveForTest(spear, foe.Position, false, o);
|
||||||
Assert.Contains(foe, o.Attacked);
|
Assert.Contains(foe, o.Attacked);
|
||||||
Assert.Less(foe.Hp, foe.MaxHp, "부딪힌 적에게 피해");
|
Assert.Less(foe.Hp, foe.MaxHp, "부딪힌 적에게 피해");
|
||||||
Assert.Contains(foe, o.Pushed, "튕기지 않고 밀며 함께 전진");
|
Assert.Contains(foe, o.Pushed, "부딪힌 상대를 밀친다");
|
||||||
Assert.Greater(foe.Position.y, 0.5f, "적이 남은 거리만큼 앞으로 밀림");
|
Assert.Greater(foe.Position.y, 0.5f, "적이 남은 거리만큼 앞으로 밀림");
|
||||||
Assert.AreEqual(CastContact(spear, foe), foe.Position.y - spear.Position.y, 0.1f, "창병은 접촉 간격을 유지하며 따라감");
|
float remaining = CastContact(spear, foe); // 당긴 3 − 접촉까지(3 − 접촉 거리)
|
||||||
|
Assert.AreEqual(-CastContact(spear, foe) - remaining * cfg.spearRecoilRatio, spear.Position.y, 0.1f, "창병 = 밀친 거리 × spearRecoilRatio 만큼 반대로(PD #1098 · 함께 전진 폐기)");
|
||||||
Assert.IsTrue(o.Overwatch && spear.Overwatch, "최종 목표점 명중 → 대기(견제)");
|
Assert.IsTrue(o.Overwatch && spear.Overwatch, "최종 목표점 명중 → 대기(견제)");
|
||||||
|
|
||||||
int hp = foe.Hp;
|
int hp = foe.Hp;
|
||||||
|
|
@ -615,9 +616,11 @@ namespace TKS.Tests
|
||||||
yield return bm.ResolveForTest(spear, new Vector2(0f, 0f), false, o);
|
yield return bm.ResolveForTest(spear, new Vector2(0f, 0f), false, o);
|
||||||
Assert.AreEqual(0, o.Attacked.Count, "아군에 부딪힌 창병 자신은 공격하지 않음");
|
Assert.AreEqual(0, o.Attacked.Count, "아군에 부딪힌 창병 자신은 공격하지 않음");
|
||||||
Assert.Contains(ally, o.Pushed);
|
Assert.Contains(ally, o.Pushed);
|
||||||
Assert.Greater(ally.Position.y, 0.4f, "아군을 밀며 함께 전진(적에 닿을 때까지)");
|
Assert.Greater(ally.Position.y, 0.4f, "아군을 밀쳐 적진으로(적에 닿을 때까지)");
|
||||||
Assert.LessOrEqual(Gap(ally, foe), Combat.ContactTolerance + 0.05f, "밀린 아군이 적과 닿음");
|
Assert.LessOrEqual(Gap(ally, foe), Combat.ContactTolerance + 0.05f, "밀린 아군이 적과 닿음");
|
||||||
Assert.AreEqual(CastContact(spear, ally), ally.Position.y - spear.Position.y, 0.1f, "창병은 아군 바로 뒤까지 따라옴");
|
float contactY = -2f - CastContact(spear, ally);
|
||||||
|
float push = 4f - (contactY + 4f); // 남은 거리 = 당긴 4 − 접촉까지
|
||||||
|
Assert.AreEqual(contactY - push * cfg.spearRecoilRatio, spear.Position.y, 0.1f, "창병 반작용(PD #1098) = 밀친 거리 × 비율만큼 뒤로");
|
||||||
Assert.Less(foe.Hp, foe.MaxHp, "밀린 아군이 적을 공격(규칙 ⑤)");
|
Assert.Less(foe.Hp, foe.MaxHp, "밀린 아군이 적을 공격(규칙 ⑤)");
|
||||||
Assert.AreEqual(spear.MaxHp, spear.Hp, "창병은 피해 없음");
|
Assert.AreEqual(spear.MaxHp, spear.Hp, "창병은 피해 없음");
|
||||||
Assert.AreEqual(1, o.Notes.Count);
|
Assert.AreEqual(1, o.Notes.Count);
|
||||||
|
|
|
||||||
|
|
@ -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, "명중 = 견제 유지");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0e186203f94130a489d5cc674599a19c
|
||||||
|
|
@ -1004,8 +1004,10 @@ namespace TKS.Tests
|
||||||
var es = bm.Skills.Of(x);
|
var es = bm.Skills.Of(x);
|
||||||
if (es == null || es.Passive == null) continue;
|
if (es == null || es.Passive == null) continue;
|
||||||
withPassive++;
|
withPassive++;
|
||||||
Assert.IsNull(es.Active, "적 = 액티브 없음(AI 사용은 2차)");
|
// PD #1098 확정: 적 액티브 = 1부(1~10장) 보스만(적 AI 가 다음 행동형을 예약해 씀) · 엘리트 무장 = 패시브만
|
||||||
Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(x));
|
if (x.IsBoss && TacticalAI.BossUsesActive(bm.Stage, new BalanceConfig.EnemySpawn { isBoss = true })) Assert.IsNotNull(es.Active, "1부 보스 = 액티브도");
|
||||||
|
else Assert.IsNull(es.Active, "엘리트 무장 = 액티브 없음");
|
||||||
|
Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(x), "적 액티브는 HUD 버튼 상태 없음(AI 전용)");
|
||||||
Assert.IsTrue(es.IsGeneral);
|
Assert.IsTrue(es.IsGeneral);
|
||||||
}
|
}
|
||||||
Assert.Greater(withPassive, 0, "적 무장 패시브 부착");
|
Assert.Greater(withPassive, 0, "적 무장 패시브 부착");
|
||||||
|
|
|
||||||
|
|
@ -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 }), "보스만");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 41a7f217aa8155648a181188a8c323f2
|
||||||
|
|
@ -48,6 +48,8 @@ Unity.exe -projectPath <proj> -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
|
||||||
- **스킬(2026-11-05 #1093 B)** — SOT = `Assets/Data/OutGame/skills.csv`(effect_key `;` 복수 · p1~p3 · lv_scale · needs_impl) + `skill_levels.csv` → 임포트 자산 `SkillInfoDb`(effect 열 포함 · 표시 + 전투 공용). 처리기 = `Runtime/Skills/`: `SkillKeys`(51종 · 레벨 계수 방식 · 팀 키 · 표에 없는 키 = 임포트 오류) · `SkillSystem`(유닛별 쿨·예약 · 즉시형 시한 효과 · 행동 훅 · 호위 피해 분담 · HUD 한 줄) · `SkillMods`(전투 코드가 수치 질의) · `SkillRules`(상한 = 등급 + ★3 · ★5 패시브 배율 = evolve.csv skill_unlock · 레벨업 · 스킬서 판매). 전투 코드에 스킬 수치를 직접 쓰지 않고 `SkillMods` 로 읽는다. 테스트 = `Tests/PlayMode/SkillTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureSkills -skillDir <dir>`.
|
- **스킬(2026-11-05 #1093 B)** — SOT = `Assets/Data/OutGame/skills.csv`(effect_key `;` 복수 · p1~p3 · lv_scale · needs_impl) + `skill_levels.csv` → 임포트 자산 `SkillInfoDb`(effect 열 포함 · 표시 + 전투 공용). 처리기 = `Runtime/Skills/`: `SkillKeys`(51종 · 레벨 계수 방식 · 팀 키 · 표에 없는 키 = 임포트 오류) · `SkillSystem`(유닛별 쿨·예약 · 즉시형 시한 효과 · 행동 훅 · 호위 피해 분담 · HUD 한 줄) · `SkillMods`(전투 코드가 수치 질의) · `SkillRules`(상한 = 등급 + ★3 · ★5 패시브 배율 = evolve.csv skill_unlock · 레벨업 · 스킬서 판매). 전투 코드에 스킬 수치를 직접 쓰지 않고 `SkillMods` 로 읽는다. 테스트 = `Tests/PlayMode/SkillTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureSkills -skillDir <dir>`.
|
||||||
- **아웃게임 2차(2026-11-05~06 #1093 C)** — 표 10장 추가(`training` · `training_ranks` · `patrol` · `dev_tree` · `treasures` · `treasure_sets` · `chests`(+ iron/buy 열) · `tower` · `dungeons`(+ reward_amount) · `arena`(+ 상점 키)) + `economy.csv` C 키 24 → 임포트 자산 18(A 9 + C 9 · `OutGameTables.C.cs` 검증). 코드 = `OutGame/Domestic/`(`TrainingRules` 훈련 · `PatrolRules` 순찰 · `DevRules` 개발 효과 키 → 적용 지점 · `DomesticScreen`) · `OutGame/Treasure/`(`TreasureRules` 보유·세트·강화·조건 카운터·상자 · `TreasureScreen`) · `OutGame/Arena/`(`ArenaRules` 탑·던전·투기장·적장 · `ArenaScreen`). 스탯 = §3-2 × (1 + 훈련(병종·항목)) × (1 + 보물(항목)) = `HeroRules.Compute(…, save)` · 보물 crit/range/splash = `BattleManager.SideEffectsProvider` → `SkillMods.Get` 진영 효과(`side_crit_pct` · `range_mult` · `splash_radius_mult`) · 조건 효과 카운터 = `Runtime/BattleEvents.Raise`(전투 코드 1줄씩 · 아군만) · 연무장 전투 = `BattleManager.StartMode(BattleMode)`(적 편성 · 제한 턴 · 결과 탭 = `onExit`) → `OutGameSession.OnModeEnd`. 테스트 = `Tests/PlayMode/OutGameCTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureC -cDir <dir>`(사본 · 10장).
|
- **아웃게임 2차(2026-11-05~06 #1093 C)** — 표 10장 추가(`training` · `training_ranks` · `patrol` · `dev_tree` · `treasures` · `treasure_sets` · `chests`(+ iron/buy 열) · `tower` · `dungeons`(+ reward_amount) · `arena`(+ 상점 키)) + `economy.csv` C 키 24 → 임포트 자산 18(A 9 + C 9 · `OutGameTables.C.cs` 검증). 코드 = `OutGame/Domestic/`(`TrainingRules` 훈련 · `PatrolRules` 순찰 · `DevRules` 개발 효과 키 → 적용 지점 · `DomesticScreen`) · `OutGame/Treasure/`(`TreasureRules` 보유·세트·강화·조건 카운터·상자 · `TreasureScreen`) · `OutGame/Arena/`(`ArenaRules` 탑·던전·투기장·적장 · `ArenaScreen`). 스탯 = §3-2 × (1 + 훈련(병종·항목)) × (1 + 보물(항목)) = `HeroRules.Compute(…, save)` · 보물 crit/range/splash = `BattleManager.SideEffectsProvider` → `SkillMods.Get` 진영 효과(`side_crit_pct` · `range_mult` · `splash_radius_mult`) · 조건 효과 카운터 = `Runtime/BattleEvents.Raise`(전투 코드 1줄씩 · 아군만) · 연무장 전투 = `BattleManager.StartMode(BattleMode)`(적 편성 · 제한 턴 · 결과 탭 = `onExit`) → `OutGameSession.OnModeEnd`. 테스트 = `Tests/PlayMode/OutGameCTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureC -cDir <dir>`(사본 · 10장).
|
||||||
- **영웅 56명(PD #1096 · 2026-11-06)** — heroes 71행(영웅 56 + 병사 15) · skills 112 · hero_gear 1344 · 신규 effect_key 4(`team_dmg_pct_turn` · `cooldown_add` · `charm` · `heal_self_pct`) + 기존 키 조건 열(받는 피해 p2 = HP 임계/2 보스/3 근접 · p3 = 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) = `SkillMods`·`SkillSystem` · 대장군 `pushed_ally_attack_on_push` 는 표에서 미사용(처리기 유지).
|
- **영웅 56명(PD #1096 · 2026-11-06)** — heroes 71행(영웅 56 + 병사 15) · skills 112 · hero_gear 1344 · 신규 effect_key 4(`team_dmg_pct_turn` · `cooldown_add` · `charm` · `heal_self_pct`) + 기존 키 조건 열(받는 피해 p2 = HP 임계/2 보스/3 근접 · p3 = 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) = `SkillMods`·`SkillSystem` · 대장군 `pushed_ally_attack_on_push` 는 표에서 미사용(처리기 유지).
|
||||||
|
- **지형 기믹 레이어(PD #1097 · 2026-11-06 세션 D)** — SOT = `Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv`(기획 `tables/` 사본 · utf-8-sig) → 메뉴 `TKS/Import Gimmicks (기믹 임포트)`(`Editor/GimmickImporter` · 오류 시 자산 불변 · `ai_weights.csv` 도 함께) → `Resources/Gimmicks/GimmickDb`·`StageGimmickTable` · `Resources/AI/AiWeights`. 전투 시작 = `GimmickLayer.Build(stage)`(장-번호 배치 · 배경 위 · 유닛 아래 · 장애물 = 콜라이더 + `GimmickBody` · 지대 = 모양 스윕 · 강 콜라이더 = 다리 구간을 뺀 조각 · 겹친 유닛은 빈 자리로). 전투 코드는 질의만: `Combat.FreeTravel`(장애물 = 벽 · 숨은 함정 정지) · `FirstUnitOnArrow`(pass/block/destroy · 안개 제외) · `Combat.Damage`(군기 공 · 고지 근접 피격) · `SkillMods.Range`(고지 사거리) · `BattleManager` 턴 시작 `TurnStart`(군기·중독·불·행동 불가). 충돌 반응·폭발·굴림·미끄러짐·지대 = `Gimmicks/ActionResolver.Gimmicks.cs`(partial). 배치 도구 = `TKS/Gimmick Painter (기믹 배치)`. 테스트 = `Tests/PlayMode/GimmickTests` · 캡처 = `-executeMethod TKS.EditorTools.GimmickImporter.Capture -gimmickDir <dir>`(사본).
|
||||||
|
- **병종 규칙 개정 + 예측 AI(PD #1098 · 2026-11-06)** — 보병 = 적과 싸운 뒤 튕겨나는 길의 다른 적과 연쇄 교환 1회(`ActionOutcome.Chains`) · 창병 = 상대만 밀리고 자신은 × `spearRecoilRatio` 반대로(밀며 전진 폐기). 적 턴 = `Runtime/AI/TacticalAI`(후보 방위 × 파워 3 × 액티브 → `Sim` 가상 실행(연출 없음 · 분산 0 · 상태 복원) → `ai_weights.csv` 점수 · 서포터 → 돌격 → 사격 · 1기마다 재평가) · 가중치 자산이 없으면 `EnemyAI.Decide`(기존 조준) · 적 액티브 = 1부 보스만(`boss_active`). 가상 실행 중(`Sim.Active`)에는 `Fx`·`UnitView`·`BattleEvents`·HUD 갱신이 꺼진다 — 새 연출 코드도 `Sim.Active` 면 건너뛸 것. 조준 미리보기도 같은 가상 실행(`GimmickPreview` · 숨은 함정 모름). 테스트 = `Tests/PlayMode/TacticalAITests`.
|
||||||
- 아웃게임 코드 = `Assets/Scripts/OutGame/**`(asmref → TKS.Runtime) — `OutGameSession`(표 + 세이브 · 바꾸는 동작은 즉시 저장) · 규칙 `HeroRules`·`EconomyRules`·`GachaRules` · 화면 `UI/`(로비·영웅·편성·거래·결과 패널 · `OutGameRoot` 가 전환). 세이브 = `persistentDataPath/save.json`(테스트·검증 캡처는 `SaveStore.UseMemory` / SessionState `TKS_SAVE_MEMORY` 로 메모리 전용). 출전 = `BattleManager.LineupProvider`(편성 프리셋 → `playerSpawnPoints` 자리 0~2 전열 · 3~4 후열). 화면 캡처 = `Unity.exe -projectPath <사본> -executeMethod TKS.EditorTools.DataImporter.CaptureOutGame -outgameDir <dir>`.
|
- 아웃게임 코드 = `Assets/Scripts/OutGame/**`(asmref → TKS.Runtime) — `OutGameSession`(표 + 세이브 · 바꾸는 동작은 즉시 저장) · 규칙 `HeroRules`·`EconomyRules`·`GachaRules` · 화면 `UI/`(로비·영웅·편성·거래·결과 패널 · `OutGameRoot` 가 전환). 세이브 = `persistentDataPath/save.json`(테스트·검증 캡처는 `SaveStore.UseMemory` / SessionState `TKS_SAVE_MEMORY` 로 메모리 전용). 출전 = `BattleManager.LineupProvider`(편성 프리셋 → `playerSpawnPoints` 자리 0~2 전열 · 3~4 후열). 화면 캡처 = `Unity.exe -projectPath <사본> -executeMethod TKS.EditorTools.DataImporter.CaptureOutGame -outgameDir <dir>`.
|
||||||
- **챕터·스테이지 SOT = `Assets/Data/Stages/*.csv`(utf-8-sig · 2026-11-05 145 스테이지 · 기획서 #1086 §4 전사)** — `stages.csv`(chapter·stage·title·type normal|elite|boss·place·faction·병종 4 수·generals `id:병종:true|false`·bg_theme·note) · `chapters.csv`(장 제목·연도·**월드맵 맵 id**·AI 출발값) · `generals.csv`(무장 id → 이름·배율 덮어쓰기) · `factions.csv`(세력 코드 → 이름·병사 아트 접두). 고친 뒤 메뉴 `TKS/Import Stages (스테이지 임포트)`(`StageImporter` · 형식 오류면 자산 미변경) → `BalanceConfig.chapters·stages`. 적 좌표 = `BalanceConfig.formation` 규칙(전위·중군·후군·장수 줄 · `StageTable.Enemies`) · 장수 배율 = `generals.csv` 가 비면 `boss/eliteGeneral*Mult`. 월드맵 노드 = 스테이지 표(챕터별 수 · 보스 = type boss) · 노드 자리 = `ChapterMap/Data/<맵>.json` `slots_N`(없으면 길 위 균등).
|
- **챕터·스테이지 SOT = `Assets/Data/Stages/*.csv`(utf-8-sig · 2026-11-05 145 스테이지 · 기획서 #1086 §4 전사)** — `stages.csv`(chapter·stage·title·type normal|elite|boss·place·faction·병종 4 수·generals `id:병종:true|false`·bg_theme·note) · `chapters.csv`(장 제목·연도·**월드맵 맵 id**·AI 출발값) · `generals.csv`(무장 id → 이름·배율 덮어쓰기) · `factions.csv`(세력 코드 → 이름·병사 아트 접두). 고친 뒤 메뉴 `TKS/Import Stages (스테이지 임포트)`(`StageImporter` · 형식 오류면 자산 미변경) → `BalanceConfig.chapters·stages`. 적 좌표 = `BalanceConfig.formation` 규칙(전위·중군·후군·장수 줄 · `StageTable.Enemies`) · 장수 배율 = `generals.csv` 가 비면 `boss/eliteGeneral*Mult`. 월드맵 노드 = 스테이지 표(챕터별 수 · 보스 = type boss) · 노드 자리 = `ChapterMap/Data/<맵>.json` `slots_N`(없으면 길 위 균등).
|
||||||
- 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 22종 이상 전부 통과 확인.
|
- 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 22종 이상 전부 통과 확인.
|
||||||
|
|
@ -74,5 +76,7 @@ Unity.exe -projectPath <proj> -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
|
||||||
- `Assets/Data/Stages/` — 챕터·스테이지 표 CSV 4장(위 「코드 규칙」) · 해석·배치 = `Runtime/StageTable`
|
- `Assets/Data/Stages/` — 챕터·스테이지 표 CSV 4장(위 「코드 규칙」) · 해석·배치 = `Runtime/StageTable`
|
||||||
- `Assets/Resources/Units/<art>/` — 유닛 아트 프레임 + meta.json(위 절)
|
- `Assets/Resources/Units/<art>/` — 유닛 아트 프레임 + meta.json(위 절)
|
||||||
- `Assets/Scripts/Runtime/Skills/` — 스킬 처리기(위 「코드 규칙」 스킬)
|
- `Assets/Scripts/Runtime/Skills/` — 스킬 처리기(위 「코드 규칙」 스킬)
|
||||||
- `Assets/Tests/PlayMode/` — `BattleFlowTests` · `OutGameTests` · `SkillTests` · `OutGameCTests`
|
- `Assets/Scripts/Runtime/Gimmicks/` — 지형 기믹 레이어(위 「코드 규칙」) · `Assets/Scripts/Runtime/AI/` — 예측 시뮬레이션 적 AI(`Sim` · `TacticalAI` · `AiWeights`)
|
||||||
|
- `Assets/Data/Gimmicks/` · `Assets/Data/AI/` — 기믹 표 3장 · AI 가중치 표
|
||||||
|
- `Assets/Tests/PlayMode/` — `BattleFlowTests` · `OutGameTests` · `SkillTests` · `OutGameCTests` · `GimmickTests` · `TacticalAITests`
|
||||||
- `.claude/skills/` — Unity CLI 에이전트 스킬(`unity skill install claude-code --local` 산출물 · 재설치는 `unity skill refresh`)
|
- `.claude/skills/` — Unity CLI 에이전트 스킬(`unity skill install claude-code --local` 산출물 · 재설치는 `unity skill refresh`)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue