From 98f675dfdabd6e46daa3ca2fc54caeaa16c070dd Mon Sep 17 00:00:00 2001 From: swrring Date: Fri, 6 Nov 2026 01:20:07 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A7=80=ED=98=95=20=EA=B8=B0=EB=AF=B9=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4(PD=20#1097=20=EC=84=B8=EC=85=98=20D?= =?UTF-8?q?)=20+=20=EB=B3=91=EC=A2=85=20=EA=B7=9C=EC=B9=99=20=EA=B0=9C?= =?UTF-8?q?=EC=A0=95=202=EA=B1=B4=20=C2=B7=20=EC=98=88=EC=B8=A1=20?= =?UTF-8?q?=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=A0=81=20AI(?= =?UTF-8?q?PD=20#1098)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터: 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 --- Assets/Data/AI.meta | 8 + Assets/Data/AI/ai_weights.csv | 33 + Assets/Data/AI/ai_weights.csv.meta | 7 + Assets/Data/Gimmicks.meta | 8 + Assets/Data/Gimmicks/gimmicks.csv | 17 + Assets/Data/Gimmicks/gimmicks.csv.meta | 7 + Assets/Data/Gimmicks/stage_gimmicks.csv | 17 + Assets/Data/Gimmicks/stage_gimmicks.csv.meta | 7 + Assets/Data/Gimmicks/tutorial_tips.csv | 4 + Assets/Data/Gimmicks/tutorial_tips.csv.meta | 7 + Assets/Resources/AI.meta | 8 + Assets/Resources/AI/AiWeights.asset | 130 +++ Assets/Resources/AI/AiWeights.asset.meta | 8 + Assets/Resources/Gimmicks.meta | 8 + Assets/Resources/Gimmicks/GimmickDb.asset | 381 +++++++ .../Resources/Gimmicks/GimmickDb.asset.meta | 8 + .../Gimmicks/StageGimmickTable.asset | 309 ++++++ .../Gimmicks/StageGimmickTable.asset.meta | 8 + Assets/Scripts/Editor/BalanceDefaults.cs | 1 + Assets/Scripts/Editor/GimmickImporter.cs | 125 +++ Assets/Scripts/Editor/GimmickImporter.cs.meta | 2 + Assets/Scripts/Editor/GimmickPainter.cs | 294 ++++++ Assets/Scripts/Editor/GimmickPainter.cs.meta | 2 + Assets/Scripts/Runtime/AI.meta | 8 + Assets/Scripts/Runtime/AI/AiWeights.cs | 92 ++ Assets/Scripts/Runtime/AI/AiWeights.cs.meta | 2 + Assets/Scripts/Runtime/AI/Sim.cs | 142 +++ Assets/Scripts/Runtime/AI/Sim.cs.meta | 2 + Assets/Scripts/Runtime/AI/TacticalAI.cs | 477 +++++++++ Assets/Scripts/Runtime/AI/TacticalAI.cs.meta | 2 + Assets/Scripts/Runtime/ActionResolver.cs | 183 +++- Assets/Scripts/Runtime/BalanceConfig.cs | 2 + Assets/Scripts/Runtime/BattleEvents.cs | 2 +- Assets/Scripts/Runtime/BattleManager.cs | 78 +- Assets/Scripts/Runtime/Combat.cs | 54 +- Assets/Scripts/Runtime/Fx.cs | 18 +- Assets/Scripts/Runtime/Gimmicks.meta | 8 + .../Gimmicks/ActionResolver.Gimmicks.cs | 452 ++++++++ .../Gimmicks/ActionResolver.Gimmicks.cs.meta | 2 + .../Runtime/Gimmicks/Combat.Gimmicks.cs | 98 ++ .../Runtime/Gimmicks/Combat.Gimmicks.cs.meta | 2 + .../Scripts/Runtime/Gimmicks/GimmickBody.cs | 10 + .../Runtime/Gimmicks/GimmickBody.cs.meta | 2 + .../Runtime/Gimmicks/GimmickCaptureDriver.cs | 284 +++++ .../Gimmicks/GimmickCaptureDriver.cs.meta | 2 + .../Scripts/Runtime/Gimmicks/GimmickData.cs | 434 ++++++++ .../Runtime/Gimmicks/GimmickData.cs.meta | 2 + Assets/Scripts/Runtime/Gimmicks/GimmickDb.cs | 21 + .../Runtime/Gimmicks/GimmickDb.cs.meta | 2 + .../Runtime/Gimmicks/GimmickInstance.cs | 96 ++ .../Runtime/Gimmicks/GimmickInstance.cs.meta | 2 + .../Scripts/Runtime/Gimmicks/GimmickLayer.cs | 978 ++++++++++++++++++ .../Runtime/Gimmicks/GimmickLayer.cs.meta | 2 + .../Runtime/Gimmicks/GimmickPreview.cs | 316 ++++++ .../Runtime/Gimmicks/GimmickPreview.cs.meta | 2 + .../Scripts/Runtime/Gimmicks/GimmickShape.cs | 192 ++++ .../Runtime/Gimmicks/GimmickShape.cs.meta | 2 + .../Scripts/Runtime/Gimmicks/GimmickView.cs | 342 ++++++ .../Runtime/Gimmicks/GimmickView.cs.meta | 2 + .../Runtime/Gimmicks/StageGimmickTable.cs | 42 + .../Gimmicks/StageGimmickTable.cs.meta | 2 + Assets/Scripts/Runtime/Skills/SkillMods.cs | 4 +- Assets/Scripts/Runtime/Skills/SkillSystem.cs | 10 +- Assets/Scripts/Runtime/SlingshotInput.cs | 15 +- Assets/Scripts/Runtime/UnitView.cs | 8 + Assets/Tests/PlayMode/BattleFlowTests.cs | 11 +- Assets/Tests/PlayMode/GimmickTests.cs | 600 +++++++++++ Assets/Tests/PlayMode/GimmickTests.cs.meta | 2 + Assets/Tests/PlayMode/SkillTests.cs | 6 +- Assets/Tests/PlayMode/TacticalAITests.cs | 199 ++++ Assets/Tests/PlayMode/TacticalAITests.cs.meta | 2 + CLAUDE.md | 6 +- 72 files changed, 6521 insertions(+), 100 deletions(-) create mode 100644 Assets/Data/AI.meta create mode 100644 Assets/Data/AI/ai_weights.csv create mode 100644 Assets/Data/AI/ai_weights.csv.meta create mode 100644 Assets/Data/Gimmicks.meta create mode 100644 Assets/Data/Gimmicks/gimmicks.csv create mode 100644 Assets/Data/Gimmicks/gimmicks.csv.meta create mode 100644 Assets/Data/Gimmicks/stage_gimmicks.csv create mode 100644 Assets/Data/Gimmicks/stage_gimmicks.csv.meta create mode 100644 Assets/Data/Gimmicks/tutorial_tips.csv create mode 100644 Assets/Data/Gimmicks/tutorial_tips.csv.meta create mode 100644 Assets/Resources/AI.meta create mode 100644 Assets/Resources/AI/AiWeights.asset create mode 100644 Assets/Resources/AI/AiWeights.asset.meta create mode 100644 Assets/Resources/Gimmicks.meta create mode 100644 Assets/Resources/Gimmicks/GimmickDb.asset create mode 100644 Assets/Resources/Gimmicks/GimmickDb.asset.meta create mode 100644 Assets/Resources/Gimmicks/StageGimmickTable.asset create mode 100644 Assets/Resources/Gimmicks/StageGimmickTable.asset.meta create mode 100644 Assets/Scripts/Editor/GimmickImporter.cs create mode 100644 Assets/Scripts/Editor/GimmickImporter.cs.meta create mode 100644 Assets/Scripts/Editor/GimmickPainter.cs create mode 100644 Assets/Scripts/Editor/GimmickPainter.cs.meta create mode 100644 Assets/Scripts/Runtime/AI.meta create mode 100644 Assets/Scripts/Runtime/AI/AiWeights.cs create mode 100644 Assets/Scripts/Runtime/AI/AiWeights.cs.meta create mode 100644 Assets/Scripts/Runtime/AI/Sim.cs create mode 100644 Assets/Scripts/Runtime/AI/Sim.cs.meta create mode 100644 Assets/Scripts/Runtime/AI/TacticalAI.cs create mode 100644 Assets/Scripts/Runtime/AI/TacticalAI.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickBody.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickBody.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickData.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickData.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickDb.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickDb.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickInstance.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickInstance.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickShape.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickShape.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickView.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/GimmickView.cs.meta create mode 100644 Assets/Scripts/Runtime/Gimmicks/StageGimmickTable.cs create mode 100644 Assets/Scripts/Runtime/Gimmicks/StageGimmickTable.cs.meta create mode 100644 Assets/Tests/PlayMode/GimmickTests.cs create mode 100644 Assets/Tests/PlayMode/GimmickTests.cs.meta create mode 100644 Assets/Tests/PlayMode/TacticalAITests.cs create mode 100644 Assets/Tests/PlayMode/TacticalAITests.cs.meta diff --git a/Assets/Data/AI.meta b/Assets/Data/AI.meta new file mode 100644 index 00000000..c88367c4 --- /dev/null +++ b/Assets/Data/AI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d4cb149693e67824da30b1dc58dea379 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Data/AI/ai_weights.csv b/Assets/Data/AI/ai_weights.csv new file mode 100644 index 00000000..cf5475d9 --- /dev/null +++ b/Assets/Data/AI/ai_weights.csv @@ -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장) diff --git a/Assets/Data/AI/ai_weights.csv.meta b/Assets/Data/AI/ai_weights.csv.meta new file mode 100644 index 00000000..d71fcca2 --- /dev/null +++ b/Assets/Data/AI/ai_weights.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cd768849686ca324c865e83967aa3208 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Data/Gimmicks.meta b/Assets/Data/Gimmicks.meta new file mode 100644 index 00000000..d1181350 --- /dev/null +++ b/Assets/Data/Gimmicks.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1ae249671f3bfac4ab79dea4d12edcd4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Data/Gimmicks/gimmicks.csv b/Assets/Data/Gimmicks/gimmicks.csv new file mode 100644 index 00000000..21bf35fa --- /dev/null +++ b/Assets/Data/Gimmicks/gimmicks.csv @@ -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 턴] diff --git a/Assets/Data/Gimmicks/gimmicks.csv.meta b/Assets/Data/Gimmicks/gimmicks.csv.meta new file mode 100644 index 00000000..dfd76e8a --- /dev/null +++ b/Assets/Data/Gimmicks/gimmicks.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5aec5258324f30a4a99e4f857044b030 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Data/Gimmicks/stage_gimmicks.csv b/Assets/Data/Gimmicks/stage_gimmicks.csv new file mode 100644 index 00000000..4eab05f3 --- /dev/null +++ b/Assets/Data/Gimmicks/stage_gimmicks.csv @@ -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,,, diff --git a/Assets/Data/Gimmicks/stage_gimmicks.csv.meta b/Assets/Data/Gimmicks/stage_gimmicks.csv.meta new file mode 100644 index 00000000..eb128cac --- /dev/null +++ b/Assets/Data/Gimmicks/stage_gimmicks.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3bed85c558aa0bf4787f2c896fbab028 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Data/Gimmicks/tutorial_tips.csv b/Assets/Data/Gimmicks/tutorial_tips.csv new file mode 100644 index 00000000..e8e68ea4 --- /dev/null +++ b/Assets/Data/Gimmicks/tutorial_tips.csv @@ -0,0 +1,4 @@ +stage,tip_ko +1-1,보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다 +1-2,보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다 +1-3,보병은 튕겨내고 창병은 밀쳐내고 물러난다 · 기병은 꿰뚫고 돌아온다 · 궁병은 목책 너머를 쏜다 · 책사는 뭉친 적을 태우고 덤불을 치운다 diff --git a/Assets/Data/Gimmicks/tutorial_tips.csv.meta b/Assets/Data/Gimmicks/tutorial_tips.csv.meta new file mode 100644 index 00000000..67edad97 --- /dev/null +++ b/Assets/Data/Gimmicks/tutorial_tips.csv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0907d8a68de35eb4595b90013fded822 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/AI.meta b/Assets/Resources/AI.meta new file mode 100644 index 00000000..1934523d --- /dev/null +++ b/Assets/Resources/AI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a7135fa18486d994cb10bb47ac8d7629 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/AI/AiWeights.asset b/Assets/Resources/AI/AiWeights.asset new file mode 100644 index 00000000..08fd709f --- /dev/null +++ b/Assets/Resources/AI/AiWeights.asset @@ -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)" diff --git a/Assets/Resources/AI/AiWeights.asset.meta b/Assets/Resources/AI/AiWeights.asset.meta new file mode 100644 index 00000000..e8b91e4d --- /dev/null +++ b/Assets/Resources/AI/AiWeights.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5b4c5518e6ce1084a98920a59a09fb11 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Gimmicks.meta b/Assets/Resources/Gimmicks.meta new file mode 100644 index 00000000..56546230 --- /dev/null +++ b/Assets/Resources/Gimmicks.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f328cb936677bf04cb80fb3785f5ad75 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Gimmicks/GimmickDb.asset b/Assets/Resources/Gimmicks/GimmickDb.asset new file mode 100644 index 00000000..e32d068e --- /dev/null +++ b/Assets/Resources/Gimmicks/GimmickDb.asset @@ -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]" diff --git a/Assets/Resources/Gimmicks/GimmickDb.asset.meta b/Assets/Resources/Gimmicks/GimmickDb.asset.meta new file mode 100644 index 00000000..1769fb13 --- /dev/null +++ b/Assets/Resources/Gimmicks/GimmickDb.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 38882c8f5f9a1ca4d8e9c00e9f5b74e3 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Gimmicks/StageGimmickTable.asset b/Assets/Resources/Gimmicks/StageGimmickTable.asset new file mode 100644 index 00000000..d4f4b496 --- /dev/null +++ b/Assets/Resources/Gimmicks/StageGimmickTable.asset @@ -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" diff --git a/Assets/Resources/Gimmicks/StageGimmickTable.asset.meta b/Assets/Resources/Gimmicks/StageGimmickTable.asset.meta new file mode 100644 index 00000000..7311b5a2 --- /dev/null +++ b/Assets/Resources/Gimmicks/StageGimmickTable.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 64e563179d8b37943b222efb3b1b1943 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/BalanceDefaults.cs b/Assets/Scripts/Editor/BalanceDefaults.cs index 5834d99e..9f770194 100644 --- a/Assets/Scripts/Editor/BalanceDefaults.cs +++ b/Assets/Scripts/Editor/BalanceDefaults.cs @@ -54,6 +54,7 @@ namespace TKS.EditorTools c.strategistAllyDamageRatio = 1f; // 광역 아군 피해 = 적과 동일(PD 확인 대상) c.collisionPushRatio = 1f; // PD 2026-11-04 #1080: 보병 알까기 밀어내기 = 남은 거리 그대로 c.collisionSelfBounceRatio = 0.5f; // PD 2026-11-04 #1081: 보병 자신도 반대로 튕김(남은 거리의 절반 · 손맛 조정 대상) + c.spearRecoilRatio = 0.5f; // PD 2026-11-06 #1098: 창병 밀치기 반작용(밀린 거리의 절반 · 함께 전진 폐기) c.unitArtHeight = 1.5f; c.boardArt = "battlefield_01"; // 원화 세션 1차 납품(2026-11-04) · Resources/Board/ c.minDragDistance = 0.35f; diff --git a/Assets/Scripts/Editor/GimmickImporter.cs b/Assets/Scripts/Editor/GimmickImporter.cs new file mode 100644 index 00000000..59df47a2 --- /dev/null +++ b/Assets/Scripts/Editor/GimmickImporter.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace TKS.EditorTools +{ + /// + /// 지형 기믹 표(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 · 가중치 키)가 하나라도 있으면 자산을 건드리지 않는다. + /// + 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(ProjectSetup.ConfigPath); + System.Func 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(AssetFolder + "/GimmickDb.asset"); + db.defs = new List(r.defs); + EditorUtility.SetDirty(db); + var table = Load(AssetFolder + "/StageGimmickTable.asset"); + table.rows = new List(r.rows); + table.tips = new List(r.tips); + EditorUtility.SetDirty(table); + var w = Load(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(string path) where T : ScriptableObject + { + var a = AssetDatabase.LoadAssetAtPath(path); + if (a != null) return a; + a = ScriptableObject.CreateInstance(); + 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)); + } + + /// + /// 기믹 검증 캡처(GUI 에디터 · 사본 프로젝트 권장 · 세이브 = 메모리 전용 · PD #1097): + /// Unity.exe -projectPath <copy> -executeMethod TKS.EditorTools.GimmickImporter.Capture -gimmickDir <dir> + /// 샘플 5 스테이지 1턴씩 + 조준선 반응 2장 + AI 함정 쪽 튕겨내기 1장 PNG + log · 끝나면 스스로 종료. + /// + 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; + } + } +} diff --git a/Assets/Scripts/Editor/GimmickImporter.cs.meta b/Assets/Scripts/Editor/GimmickImporter.cs.meta new file mode 100644 index 00000000..e8826db6 --- /dev/null +++ b/Assets/Scripts/Editor/GimmickImporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f97d9c5e09776874cb64cb1784e67e6e \ No newline at end of file diff --git a/Assets/Scripts/Editor/GimmickPainter.cs b/Assets/Scripts/Editor/GimmickPainter.cs new file mode 100644 index 00000000..ff5e800f --- /dev/null +++ b/Assets/Scripts/Editor/GimmickPainter.cs @@ -0,0 +1,294 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace TKS.EditorTools +{ + /// + /// 지형 기믹 배치 도구(PD #1097 §1 「어떤 맵이든 올려서 배치」) — 메뉴 TKS/Gimmick Painter. + /// 스테이지 고르기 → 불러오기(stage_gimmicks.csv 의 그 스테이지 행) → 기믹·모양 고르고 「그리기」 → 씬 뷰에서 드래그(원 = 중심→반지름 · 사각 = 모서리→모서리) → + /// 저장(그 스테이지 행만 바꾸고 다른 스테이지 행은 그대로 · utf-8-sig) → 자동 임포트(Play 중이면 레이어 다시 만듦). 표를 바꿨으면 기획 tables/ 에도 같은 파일을 둔다. + /// + 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 rows = new List(); + string loadedStage = ""; + bool dragging; + Vector2 dragStart, dragNow; + + [MenuItem("TKS/Gimmick Painter (기믹 배치)")] + public static void Open() + { + GetWindow("기믹 배치"); + } + + void OnEnable() + { + SceneView.duringSceneGui += OnScene; + } + + void OnDisable() + { + SceneView.duringSceneGui -= OnScene; + } + + static BalanceConfig Cfg + { + get { return AssetDatabase.LoadAssetAtPath(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 { 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); + } + } +} diff --git a/Assets/Scripts/Editor/GimmickPainter.cs.meta b/Assets/Scripts/Editor/GimmickPainter.cs.meta new file mode 100644 index 00000000..b57c7139 --- /dev/null +++ b/Assets/Scripts/Editor/GimmickPainter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3eb049b6ace5cb34c919b6b4ced9b347 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/AI.meta b/Assets/Scripts/Runtime/AI.meta new file mode 100644 index 00000000..8367f07b --- /dev/null +++ b/Assets/Scripts/Runtime/AI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 38487f2bf2e40824a9e921ca527d179e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Runtime/AI/AiWeights.cs b/Assets/Scripts/Runtime/AI/AiWeights.cs new file mode 100644 index 00000000..0a133594 --- /dev/null +++ b/Assets/Scripts/Runtime/AI/AiWeights.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using UnityEngine; + +namespace TKS +{ + /// + /// 적 AI 점수 가중치(PD #1098 · 전술규칙_적AI_기획서 §2-3 · SOT = Assets/Data/AI/ai_weights.csv = 기획 tables/ 사본) → + /// Resources/AI/AiWeights(메뉴 TKS/Import Gimmicks 가 함께 임포트). 코드는 키 이름만 안다(C45). + /// + public class AiWeights : ScriptableObject + { + public const string CsvPath = "Data/AI/ai_weights.csv"; // Assets/ 아래 + public const string ResourcePath = "AI/AiWeights"; + + /// 코드가 읽는 키(표에 없으면 임포트 오류). + 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 entries = new List(); + Dictionary index; + + public float this[string key] + { + get + { + if (index == null) + { + index = new Dictionary(); + 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(ResourcePath); + return cached; + } + + public static void Invalidate() + { + cached = null; + } + + /// CSV(key,value,desc_ko) 해석 · 키 누락·숫자 아님 = errors. + public static List Parse(string text, List errors) + { + var list = new List(); + var seen = new HashSet(); + 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; + } + } +} diff --git a/Assets/Scripts/Runtime/AI/AiWeights.cs.meta b/Assets/Scripts/Runtime/AI/AiWeights.cs.meta new file mode 100644 index 00000000..6fff7361 --- /dev/null +++ b/Assets/Scripts/Runtime/AI/AiWeights.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 659ab9685f0a38143a4e480430ce70a3 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/AI/Sim.cs b/Assets/Scripts/Runtime/AI/Sim.cs new file mode 100644 index 00000000..4905d7e7 --- /dev/null +++ b/Assets/Scripts/Runtime/AI/Sim.cs @@ -0,0 +1,142 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace TKS +{ + /// + /// 가상 실행(dry-run · PD #1098 적 AI 예측 시뮬레이션): Active 동안 ActionResolver 가 연출 없이 한 프레임 안에 끝난다 — + /// Fx 이동·대기·투사체 = 즉시 · 팝업·광역 터짐 = 없음 · 유닛 표시(UnitView) 갱신 없음 · 피해 난수 = 분산 0 · 전투 이벤트 카운터 없음. + /// 상태는 Snapshot 으로 떠 두고 Restore 로 되돌린다(유닛 · 스킬 상태 · 기믹 레이어). + /// + public static class Sim + { + public static bool Active; + + /// 코루틴을 이 프레임 안에서 끝까지 돌린다(중첩 IEnumerator 포함 · null/기타 yield = 즉시 진행). + public static void Run(IEnumerator routine) + { + if (routine == null) return; + var stack = new Stack(); + 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 Units = new List(); + public readonly List Skills = new List(); + public List 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(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(); + } + } +} diff --git a/Assets/Scripts/Runtime/AI/Sim.cs.meta b/Assets/Scripts/Runtime/AI/Sim.cs.meta new file mode 100644 index 00000000..72794e31 --- /dev/null +++ b/Assets/Scripts/Runtime/AI/Sim.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 056fcbc68cba91c498bffad3000a8607 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/AI/TacticalAI.cs b/Assets/Scripts/Runtime/AI/TacticalAI.cs new file mode 100644 index 00000000..17786130 --- /dev/null +++ b/Assets/Scripts/Runtime/AI/TacticalAI.cs @@ -0,0 +1,477 @@ +using System.Collections.Generic; +using System.Diagnostics; +using UnityEngine; + +namespace TKS +{ + /// 적 유닛 1기의 행동 후보 1건(가상 실행 결과 점수 포함). + public class AiPlan + { + public Unit Unit; + public Vector2 Target; + public bool UseSkill; + public float Score; + /// 0 = 서포터(창병 밀기 · 책사 덤불 제거 · 이득 있는 버프) · 1 = 돌격(보·기·창) · 2 = 사격(궁·책). + public int Group; + public string Why = ""; + public int Candidates; + public float Ms; + } + + /// + /// 적 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). + /// + public static class TacticalAI + { + static readonly List lastEvalMs = new List(); + + /// 최근 평가 시간(ms · 성능 테스트용). + public static IReadOnlyList LastEvalMs + { + get { return lastEvalMs; } + } + + public static bool Available + { + get { return AiWeights.Load() != null; } + } + + /// 이 적 보스가 액티브를 쓰는가(1부 보스만 · 다음 행동형만 · ai_weights boss_active · active_max_chapter). + 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"]); + } + + /// + /// 다음에 움직일 적 1기와 그 행동: 남은 유닛 전원 평가 → 그룹(서포터 → 돌격 → 사격) 순 · 같은 그룹은 점수 높은 순 → 그 유닛의 후보에서 난이도대로 고른다. + /// 교전 범위 밖(가장 가까운 아군이 aggro 보다 멂)은 대기 → null 이면 이번 턴 끝. + /// + public static AiPlan Next(BattleManager bm, List 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 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 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 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; + } + + /// 난이도: 최고 점수를 accuracy(+보스 · 투기장 = mode) 확률로 · 아니면 상위 top_n 중 무작위 + 파워 ±noise. + static AiPlan Choose(BattleManager bm, Unit u, List 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; + } + + /// 유닛 1기 평가: 후보마다 가상 실행 → 점수 내림차순 목록(list[0] = 최고). + public static AiPlan Evaluate(BattleManager bm, Unit u, int enemyCount, out List list) + { + list = new List(); + 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]; + } + + /// 후보 목표점: 방위 × 파워 3단 · 궁·책은 사거리(+접근) 안 표적 · 책사는 덤불 중심까지. + static List Candidates(BattleManager bm, Unit u, int enemyCount, AiWeights w) + { + var res = new List(); + 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 Units = new Dictionary(); + public readonly HashSet Removed = new HashSet(); + } + + 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; + } + + /// 점수(기획서 §2-3) — 가상 실행 직후 상태 vs 실행 전. + 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(); + 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(); + 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(); + 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(); + 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; + } + + /// 궁·책 종료 위치와 플레이어 근접 유닛 사이 직선에 목책(투사체 통과 · 이동 막힘)이 있는가. + 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; + } + + /// 플레이어 기병의 관통 사정(슬링 + 반지름) 안이고 사이에 장애물이 없으면 관통선 위. + 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; + } + + /// 이번 가상 실행으로 지워진 덤불 때문에 새로 열린 (적 측 유닛 → 플레이어 유닛) 직선 수(사거리·슬링 안 · 다른 장애물 없음). + static int OpenedRoutes(BattleManager bm, Unit shooter, GimmickLayer layer, Pre pre) + { + var cleared = new List(); + 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; + } + } +} diff --git a/Assets/Scripts/Runtime/AI/TacticalAI.cs.meta b/Assets/Scripts/Runtime/AI/TacticalAI.cs.meta new file mode 100644 index 00000000..9260cde5 --- /dev/null +++ b/Assets/Scripts/Runtime/AI/TacticalAI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 66c2037730580514da6d7f1172bbbc71 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/ActionResolver.cs b/Assets/Scripts/Runtime/ActionResolver.cs index d477371a..80bc8a3e 100644 --- a/Assets/Scripts/Runtime/ActionResolver.cs +++ b/Assets/Scripts/Runtime/ActionResolver.cs @@ -33,8 +33,10 @@ namespace TKS /// 추가 이동 횟수(기본 1 · 인덕 extra_move_count) · 추가 이동에서 공격 가능(extra_move_attack). public int BonusMoves = 1; public bool BonusAttack; - /// 이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정). + /// 이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정 · 기믹 지대 스윕). public List Displaced = new List(); + /// 보병 연쇄 교환 수(튕겨나는 도중 다른 적과 부딪힘 · PD 2026-11-06 #1098). + public int Chains; } public struct Displacement @@ -46,17 +48,20 @@ namespace TKS /// /// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (PD 2026-11-04 #1080 충돌 규칙). - /// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 멈춤 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회 + /// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 반대로 튕김 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회 · + /// 적과 싸운 뒤 튕겨나는 도중 다른 적과 부딪히면 그 적과도 피해·반격 교환 + 다시 튕김(연쇄 1회 · 아군이면 밀기만 · PD 2026-11-06 #1098) /// ② 기병: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 · 목표점 명중 시 제자리 복귀 - /// ③ 창병: 첫 유닛에 부딪히면 튕기지 않고 남은 거리만큼 밀며 함께 전진 · 적이면 피해(반격 받음) · 아군이면 밀기만 · 목표점 명중 시 대기(견제) + /// ③ 창병: 첫 유닛에 부딪히면 그 유닛을 남은 거리만큼 밀치고 자신은 그 × spearRecoilRatio 만큼 반대로 밀림(반작용 · 함께 전진 폐기 · PD #1098) · + /// 적이면 피해(반격 받음) · 아군이면 밀기만 · 목표점 명중 시 대기(견제) /// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ ×(1+비율) 안이면 들어올 만큼만 다가가 사격 · /// 더 멀면 목표점까지 이동(쏘지 않음) · 사격 = 타겟 방향 화살 투사체 → 경로 위 첫 유닛(아군 포함)이 맞음 · 관통 없음 · 반격 없음 · /// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage) /// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤) /// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음) /// 스킬(PD #1093 B): 수치는 SkillMods 질의(밀기·튕김·관통·사거리·사선·광역·피해) · 예약 액티브는 Resolve 앞뒤에서 장착·소모(SkillSystem). + /// 지형 기믹(PD #1097): 충돌·투사체·미끄러짐·지대 = Gimmicks/ActionResolver.Gimmicks.cs · 가상 실행(적 AI) = Sim.Active 동안 연출 없이 즉시. /// - public class ActionResolver + public partial class ActionResolver { readonly BalanceConfig cfg; readonly BattleManager battle; @@ -72,12 +77,14 @@ namespace TKS public IEnumerator Resolve(Unit u, Vector2 target, bool moveOnly, ActionOutcome o) { Vector2 origin = u.Position; + target = ReachPoint(u, target); // 늪·고지 이동 비용 = 실제 도달점(근접 병종 · PD #1097) Vector2 delta = target - origin; float dist = delta.magnitude; o.Path.Add(origin); if (dist < 0.001f || !u.IsAlive) yield break; Vector2 dir = delta / dist; + GimmickBegin(o); var skills = SkillSystem.Current; if (skills != null) skills.BeginAction(u, moveOnly, o); // 예약 액티브 장착 + 발동 연출(PD #1093 B) // 슬링샷 돌진 중 = 병종 돌진 포즈 1프레임 고정 · 끝나면 대기 (PD 2026-11-05 #1084) @@ -98,6 +105,7 @@ namespace TKS yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o); break; } + yield return GimmickAfter(u, moveOnly, o, !(UnitTypeInfo.IsRanged(u.Type) && o.Fired)); // 빙판 · 지대 스윕 · 강(PD #1097) if (u.View != null) u.View.SetCharging(false); if (skills != null) skills.EndAction(u, o); // 예약 소모 · 쿨 · 견제 강화 이월 } @@ -110,6 +118,7 @@ namespace TKS var result = new List(); if (moveOnly) return result; Vector2 origin = u.Position; + target = ReachPoint(u, target); Vector2 delta = target - origin; float dist = delta.magnitude; if (dist < 0.001f) return result; @@ -117,6 +126,7 @@ namespace TKS float hitDist; var skills = SkillSystem.Current; bool prev = skills != null && skills.PreviewBegin(u); + GimmickLayer.PreviewMode = true; // 아군 조준 = 숨은 함정을 모른다(PD #1097) try { switch (u.Type) @@ -152,6 +162,7 @@ namespace TKS } finally { + GimmickLayer.PreviewMode = false; if (skills != null) skills.PreviewEnd(u, prev); } return result; @@ -166,12 +177,14 @@ namespace TKS if (dist < 0.001f) return null; var skills = SkillSystem.Current; bool prev = skills != null && skills.PreviewBegin(u); + GimmickLayer.PreviewMode = true; try { return PlanShot(u, u.Position, delta / dist, target); } finally { + GimmickLayer.PreviewMode = false; if (skills != null) skills.PreviewEnd(u, prev); } } @@ -204,6 +217,14 @@ namespace TKS float hitDist; Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist); + float gDist; + Vector2 gNormal; + var gim = BlockAhead(u, origin, dir, dist, first != null ? hitDist : dist, out gDist, out gNormal); + if (gim != null) + { + yield return HitGimmick(u, gim, origin, dir, dist, gDist, gNormal, o); // 장애물 기믹이 먼저(PD #1097) + yield break; + } if (first == null) { yield return MoveFree(u, origin, dir, dist, o); @@ -230,7 +251,7 @@ namespace TKS // 알까기: 부딪힌 순간 양쪽이 동시에 튕긴다 — 상대 = 접촉 방향 × 남은 힘 × collisionPushRatio · 자신 = 반대 방향 × 남은 힘 × collisionSelfBounceRatio Vector2 n = first.Position - contact; n = n.sqrMagnitude > 1e-6f ? n.normalized : dir; - yield return Bounce(u, first, n, remaining, o, enemy ? impact : 0); + yield return Bounce(u, first, n, remaining, o, enemy ? impact : 0, enemy); // 적과 싸운 뒤 튕김 = 연쇄 1회(PD #1098) if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o, u); if (isFinal && u.IsAlive) @@ -241,7 +262,7 @@ namespace TKS } } - // ---------- 창병: 밀며 함께 전진 ---------- + // ---------- 창병: 밀치기 + 반작용(PD 2026-11-06 #1098 · 밀며 함께 전진 폐기) ---------- IEnumerator SpearRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) { @@ -253,6 +274,14 @@ namespace TKS float hitDist; Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist); + float gDist; + Vector2 gNormal; + var gim = BlockAhead(u, origin, dir, dist, first != null ? hitDist : dist, out gDist, out gNormal); + if (gim != null) + { + yield return HitGimmick(u, gim, origin, dir, dist, gDist, gNormal, o); // 장애물 기믹이 먼저(PD #1097) + yield break; + } if (first == null) { yield return MoveFree(u, origin, dir, dist, o); @@ -275,27 +304,29 @@ namespace TKS o.MovedOnly = true; } - // 밀며 전진 거리 = 남은 거리 × 철척사모(push_mult) × 대상 거구(knockback_taken_pct) - float advance = remaining * SkillMods.PushMult(u, true) * SkillMods.KnockbackTakenMult(first, true); - if (first.IsAlive && advance > 0.01f) + // 밀치기 거리 = 남은 거리 × 철척사모(push_mult) × 대상 거구(knockback_taken_pct) · 상대만 밀리고 창병은 그 × spearRecoilRatio 만큼 반대로(반작용 · PD #1098) + float push = remaining * SkillMods.PushMult(u, true) * SkillMods.KnockbackTakenMult(first, true); + if (first.IsAlive && push > 0.01f) { - // 튕기지 않고 앞 유닛을 밀며 함께 전진 · 앞 유닛이 다른 유닛·벽에 막히면 둘 다 멈춤 · 접촉 간격 유지 - float travel = Combat.FreeTravel(first, first.Position, dir, advance, true, true, u); - if (travel > 0.01f) + bool pushedDone = false; + Fx.Run(Track(Push(first, dir, push, u, o), () => pushedDone = true)); + float recoil = Reach(u, u.Position, -dir, push * cfg.spearRecoilRatio); + if (recoil > 0.01f && u.IsAlive) { - Face(first, dir); // 밀린 유닛은 밀린 방향을 본다(PD #1082) - Vector2 firstStart = first.Position; - Vector2 firstEnd = first.Position + dir * travel; - Vector2 spearEnd = contact + dir * travel; - Fx.Run(Fx.MoveAlong(first.transform, first.Position, firstEnd, cfg.moveSpeed, null)); - yield return Fx.MoveAlong(u.transform, contact, spearEnd, cfg.moveSpeed, null); - first.Position = firstEnd; - u.Position = spearEnd; - o.Path.Add(spearEnd); - o.Displaced.Add(new Displacement { Unit = first, From = firstStart, To = firstEnd }); - battle.NotifyChanged(); + // 반작용: 벽·유닛·장애물에 닿으면 정지 · 피해 없음 · 보는 방향은 유지(밀친 쪽을 본 채 물러남) + float back = Combat.FreeTravel(u, u.Position, -dir, recoil, true, true, first); + NoteForced(u, -dir, recoil, back); + if (back > 0.01f) + { + Vector2 start = u.Position; + Vector2 end = battle.Board.ClampInside(start - dir * back, u.Radius); + yield return Fx.MoveAlong(u.transform, start, end, cfg.moveSpeed, null); + u.Position = end; + o.Path.Add(end); + } } - o.Pushed.Add(first); + while (!pushedDone) yield return null; + battle.NotifyChanged(); if (!enemy) yield return PushedAllyAttack(first, o, u); } if (enemy && u.IsAlive) SpearHitMore(u, first, o); @@ -342,8 +373,11 @@ namespace TKS /// false = 위진소요의 추가 관통(다시 반복·복귀하지 않음 · exclude = 앞서 관통한 적 제외). IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o, bool first, List exclude = null) { - float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); // 유닛은 무시 · 벽만 + float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); // 유닛은 무시 · 벽·장애물 기믹만 travel = StopBeforeUnclearedAllies(u, origin, dir, travel); // 지나칠 수 없는 아군 직전에서 멈춤 + float gDist; + Vector2 gNormal; + var gim = BlockAhead(u, origin, dir, dist, travel + 0.05f, out gDist, out gNormal); // 장애물 = 관통 불가 → 거기서 정지 · 복귀 없음(PD #1097) var dists = new List(); var enemies = moveOnly ? new List() : Combat.EnemiesOnPath(u, origin, dir, travel, dists); if (exclude != null) @@ -358,8 +392,8 @@ namespace TKS Unit finalHit = moveOnly ? null : Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance); bool hitFinal = first && finalHit != null && enemies.Contains(finalHit); // 단기구주: 빗나가도(관통한 적이 있으면) 출발점 복귀 · 위진소요: 명중 시 복귀 대신 같은 방향 한 번 더 관통 - int repeat = hitFinal ? SkillMods.Count(u, SkillKeys.PierceRepeat, 0) : 0; - bool willReturn = first && repeat <= 0 && (hitFinal || (enemies.Count > 0 && SkillMods.Has(u, SkillKeys.PierceReturnOnMiss))); + int repeat = hitFinal && gim == null ? SkillMods.Count(u, SkillKeys.PierceRepeat, 0) : 0; + bool willReturn = gim == null && first && repeat <= 0 && (hitFinal || (enemies.Count > 0 && SkillMods.Has(u, SkillKeys.PierceReturnOnMiss))); Vector2 end = origin + dir * travel; if (!willReturn && repeat <= 0) end = BackOffFromEnemies(u, origin, dir, travel); @@ -368,6 +402,7 @@ namespace TKS { yield return Move(u, end, o); o.MovedOnly = true; + if (gim != null && u.IsAlive) yield return CavalryHitsGimmick(u, gim, Mathf.Max(0f, dist - travel), gNormal, o); yield break; } @@ -414,6 +449,12 @@ namespace TKS } } + if (gim != null && u.IsAlive) + { + yield return CavalryHitsGimmick(u, gim, Mathf.Max(0f, dist - travel), gNormal, o); + yield break; + } + if (repeat > 0 && u.IsAlive) { // 위진소요: 목표점에서 같은 방향으로 같은 거리만큼 한 번 더 관통(앞서 관통한 적 제외 · 그 뒤 복귀 없음) @@ -451,7 +492,7 @@ namespace TKS { float kb = cfg.cavalryKnockback * SkillMods.PierceKnockbackMult(cav, true) * SkillMods.KnockbackTakenMult(e, true); Vector2 from = e.Position; - Vector2 to = battle.Board.ClampInside(e.Position + side * sign * kb, e.Radius); + Vector2 to = KnockTo(e, side * sign, kb); // 늪 0 · 빙판 × · 장애물·함정에서 멈춤 · 다리 → 강(PD #1097) Fx.Run(Fx.MoveAlong(e.transform, e.Position, to, cfg.moveSpeed * 0.6f, null)); o.Displaced.Add(new Displacement { Unit = e, From = from, To = to }); } @@ -532,6 +573,13 @@ namespace TKS /// 백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음). public float Teleport; public Vector2 Origin; + /// 투사체를 막거나 맞은 기믹(덤불·바위·성문·성벽 = 막힘 · 폭약 수레 = 파괴 · PD #1097) · 유닛을 맞혔으면 null. + public GimmickInstance HitGimmick; + /// 책사 광역이 터진다(중심 = 첫 피격 유닛 · 유닛 없이 덤불·수레에 막혔으면 그 자리). + public bool HasSplash; + public Vector2 SplashCenter; + /// 책사 광역에 들어 지워질 기믹(덤불 제거 · 수레 파괴). + public readonly List ClearGimmicks = new List(); } ShotPlan PlanShot(Unit u, Vector2 origin, Vector2 dir, Vector2 target) @@ -550,28 +598,32 @@ namespace TKS p.Target = Combat.FirstEnemyOnPath(u, origin, dir, range + u.ArcherMoveAllow, out hitDist); if (p.Target == null) return p; - // 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한) - float need = Mathf.Clamp(hitDist - range, 0f, u.ArcherMoveAllow); + // 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한 · 늪·고지 이동 비용 반영 PD #1097) + float need = Mathf.Clamp(hitDist - range, 0f, Reach(u, origin, dir, u.ArcherMoveAllow)); p.Approach = need > 0.01f ? Combat.FreeTravel(u, origin, dir, need, true) : 0f; p.From = origin + dir * p.Approach; p.To = p.From; if (hitDist - p.Approach > range + 0.05f) return p; // 아군·벽에 막혀 사거리에 못 들어감 + Aim(u, p, dir, target, range); + return p; + } - // 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과) + /// + /// 사격 위치(p.From)에서 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과) · + /// 막는 기믹(덤불·바위·성문·성벽) · 파괴되는 기믹(폭약 수레) · 안개 안 유닛 제외 · 책사 = 광역 계획(AimSplash). + /// + void Aim(Unit u, ShotPlan p, Vector2 dir, Vector2 target, float range) + { p.Fires = true; Vector2 aim = p.Target.Position - p.From; Vector2 adir = aim.sqrMagnitude > 1e-6f ? aim.normalized : dir; float travel; Faction? pass = SkillMods.Has(u, SkillKeys.ArrowPassAllies) ? u.Faction : (Faction?)null; - p.Hit = Combat.FirstUnitOnArrow(u, p.From, adir, range + u.Radius, cfg.arrowRadius, pass, out travel); + p.Hit = Combat.FirstUnitOnArrow(u, p.From, adir, range + u.Radius, cfg.arrowRadius, pass, out travel, out p.HitGimmick); p.To = p.From + adir * (p.Hit != null ? travel + cfg.arrowRadius : travel); if (SkillMods.Has(u, SkillKeys.ArrowCritAlways)) p.Crit = p.Hit != null && p.Hit.Faction != u.Faction; // 백발백중·백기습영: 명중 여부와 무관하게 크리 else p.Crit = p.Hit == p.Target && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == p.Target; - if (p.Hit != null && u.Type == UnitType.Strategist) - { - p.Splash.AddRange(Combat.UnitsInCircle(p.Hit.Position, p.SplashRadius, p.Hit, u)); - } - return p; + if (u.Type == UnitType.Strategist) AimSplash(u, p); } IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) @@ -584,12 +636,16 @@ namespace TKS } if (p == null || p.Target == null) { - // 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지) - if (p == null || p.Teleport <= 0.01f) yield return MoveFree(u, origin, dir, dist, o); + // 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지) · 장애물 = 튕김 · 늪·고지 = 이동 비용(PD #1097) + if (p == null || p.Teleport <= 0.01f) yield return MoveOrHit(u, origin, dir, Reach(u, origin, dir, dist), o); yield break; } - if (p.Approach > 0.01f) yield return Move(u, p.From, o); + if (p.Approach > 0.01f) + { + yield return Move(u, p.From, o); + if (p.Fires) yield return SlideBeforeShot(u, dir, p, target, o); // 빙판 = 사격 전 미끄러짐 · 사거리 이탈 = 취소(PD #1097 §9 ③) + } if (!p.Fires) { o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감 @@ -611,16 +667,18 @@ namespace TKS o.ShotHit = p.Hit; if (u.View != null) u.View.PlayAttack(); yield return Fx.Arrow(p.From, p.To, strategist ? Fx.StrategistShotColor : Fx.ArrowColor); - if (p.Hit == null) + if (p.Hit == null && p.HitGimmick == null) { yield return Fx.Wait(0.1f); // 빗나감(벽 · 비행 끝) yield break; } - Vector2 center = p.Hit.Position; - ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o); - if (strategist) + Vector2 center = p.HasSplash ? p.SplashCenter : p.To; + if (p.Hit != null) ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o); + else yield return ArrowHitsGimmick(u, p, o); // 덤불·성문 막힘 · 수레 파괴(PD #1097) + if (strategist && p.HasSplash) { + yield return SplashGimmicks(u, p, o); // 덤불 제거 · 수레 파괴 · 화공 불(PD #1097) Fx.Burst(center, p.SplashRadius, Fx.SplashColor); float splashRatio = SkillMods.SplashRatio(u, cfg, true); float allyRatio = SkillMods.SplashAllyRatio(u, cfg, true); @@ -712,8 +770,9 @@ namespace TKS /// /// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio × 밀기 배율(청룡도 지기·호치·함진영) × 대상 밀림 배율(거구) /// / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio × 자기 튕김 배율(인덕·호치 = 0). impactDmg = 함진영 충돌 추가 피해 기준. + /// chain = 적과 싸운 뒤의 튕김(PD #1098): 튕겨나는 길에 다른 적이 있으면 그 적과 피해·반격 교환 → 그 적 기준으로 다시 튕김(연쇄 1회) · 아군이면 밀기만. /// - IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o, int impactDmg) + IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o, int impactDmg, bool chain = false) { bool struckDone = true; float force = struck != null ? remaining * cfg.collisionPushRatio * SkillMods.PushMult(striker, true) * SkillMods.KnockbackTakenMult(struck, true) : 0f; @@ -734,10 +793,40 @@ namespace TKS } Fx.Run(Track(Push(struck, n, force, striker, o, onEnd), () => struckDone = true)); } - float self = remaining * cfg.collisionSelfBounceRatio * SkillMods.SelfBounceMult(striker, true); + float self = Reach(striker, striker.Position, -n, remaining * cfg.collisionSelfBounceRatio * SkillMods.SelfBounceMult(striker, true)); + if (self >= 0.01f && striker.IsAlive && chain) + { + // 연쇄(PD #1098): 튕겨나는 길의 첫 유닛(벽·장애물보다 앞) — 적 = 피해·반격 교환 후 다시 튕김 · 아군 = 밀기만(밀린 아군은 규칙 ⑤) + float h2; + Unit second = Combat.FirstUnitOnPathExcept(striker, striker.Position, -n, self, struck, out h2); + if (second != null && h2 <= Combat.FreeTravel(striker, striker.Position, -n, self, false, false) + Combat.Epsilon) + { + Vector2 c2 = striker.Position - n * h2; + yield return MoveStraight(striker, c2, o); + float rem2 = Mathf.Max(0f, self - h2); + Vector2 n2 = second.Position - c2; + n2 = n2.sqrMagnitude > 1e-6f ? n2.normalized : -n; + if (second.Faction != striker.Faction) + { + o.Chains++; + o.Notes.Add("연쇄 → " + second.DisplayName); + int imp2 = ImpactExchange(striker, second, o); + if (striker.IsAlive) yield return Bounce(striker, second, n2, rem2, o, imp2, false); + } + else + { + float f2 = rem2 * cfg.collisionPushRatio * SkillMods.KnockbackTakenMult(second, true); + if (f2 >= 0.01f) yield return Push(second, n2, f2, striker, o); + if (second.IsAlive) yield return PushedAllyAttack(second, o, striker); + } + while (!struckDone) yield return null; + yield break; + } + } if (self >= 0.01f && striker.IsAlive) { float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck); + NoteForced(striker, -n, self, back); if (back > 0.01f) { Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082) @@ -761,7 +850,9 @@ namespace TKS { if (target == null || !target.IsAlive || force < 0.01f) yield break; if (pusher != null && pusher.Faction != target.Faction) BattleEvents.Raise(BattleEvents.Pushes, pusher); // 보물 조건 효과(PD #1093 C) + force = Reach(target, target.Position, n, force); // 늪 = 밀리는 거리도 이동 비용(PD #1097) float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher); + NoteForced(target, n, force, slide); // 막힘 기록 → 다리 위에서 강 쪽이면 강에 빠짐 float moved = 0f; if (slide > 0.01f) { diff --git a/Assets/Scripts/Runtime/BalanceConfig.cs b/Assets/Scripts/Runtime/BalanceConfig.cs index b3c74740..57d60d48 100644 --- a/Assets/Scripts/Runtime/BalanceConfig.cs +++ b/Assets/Scripts/Runtime/BalanceConfig.cs @@ -146,6 +146,8 @@ namespace TKS public float collisionPushRatio = 1f; [Tooltip("보병 알까기: 부딪힌 보병 자신이 반대 방향으로 튕겨나는 거리 = 충돌 시 남은 슬링샷 거리 × 비율 (PD #1081 「자신도 튕겨나야」)")] public float collisionSelfBounceRatio = 0.5f; + [Tooltip("창병 밀치기 반작용(PD 2026-11-06 #1098): 상대가 밀리는 거리(남은 거리 × 밀기 배율) × 비율만큼 창병이 반대로 밀린다(벽·유닛에 닿으면 정지 · 피해 없음) · 「밀며 함께 전진」 폐기")] + public float spearRecoilRatio = 0.5f; [Header("표시")] [Tooltip("유닛 아트(스프라이트) 세로 크기 · 월드 단위 · 장수는 반지름 배율만큼 커진다")] diff --git a/Assets/Scripts/Runtime/BattleEvents.cs b/Assets/Scripts/Runtime/BattleEvents.cs index eb287c30..583aa2a2 100644 --- a/Assets/Scripts/Runtime/BattleEvents.cs +++ b/Assets/Scripts/Runtime/BattleEvents.cs @@ -38,7 +38,7 @@ namespace TKS public static void Raise(string key, int n = 1) { - if (n <= 0 || Raised == null) return; + if (n <= 0 || Raised == null || Sim.Active) return; // 가상 실행(적 AI 예측)은 세지 않는다 Raised(key, n); } } diff --git a/Assets/Scripts/Runtime/BattleManager.cs b/Assets/Scripts/Runtime/BattleManager.cs index 089eafb7..e980d77a 100644 --- a/Assets/Scripts/Runtime/BattleManager.cs +++ b/Assets/Scripts/Runtime/BattleManager.cs @@ -141,9 +141,16 @@ namespace TKS public void NotifyChanged() { + if (Sim.Active) return; // 가상 실행(적 AI 예측) 중 = HUD 갱신 없음 if (Changed != null) Changed(); } + /// 지형 기믹 레이어(PD #1097 · 전투마다 스테이지 배치로 다시 만든다). + public GimmickLayer Gimmicks + { + get { return GimmickLayer.Current != null && GimmickLayer.Current.Battle == this ? GimmickLayer.Current : null; } + } + void SetPhase(BattlePhase p) { if (Phase == p) return; @@ -202,6 +209,7 @@ namespace TKS } units.Clear(); if (Skills != null) Skills.Clear(); + if (Gimmicks != null) Gimmicks.Clear(); } // ---------- 스테이지 ---------- @@ -247,12 +255,15 @@ namespace TKS foreach (var e in stage.enemies) { var u = SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss, e.art); - Skills.AttachEnemy(u, e); // 적 무장 = 패시브만(액티브 AI 사용은 2차) + Skills.AttachEnemy(u, e, TacticalAI.BossUsesActive(stage, e)); // 적 무장 = 패시브 · 1부 보스 = 액티브도(적 AI 가 예약해 씀 · PD #1098) } + GimmickLayer.Ensure(this).Build(stage); // 지형 기믹 레이어(배경 위 · 유닛 아래 · PD #1097) TurnNumber = 1; Skills.BeginBattle(); LastMessage = stage.title + " — 아군 차례"; + string tip = GimmickTables.Table != null ? GimmickTables.Table.TipFor(stage.chapter, stage.stage) : ""; + if (tip.Length > 0) LastMessage += " · " + tip; // 1장 튜토리얼 문구(tutorial_tips.csv · PD #1098 §3) if (StageStarted != null) StageStarted(); SetPhase(BattlePhase.PlayerTurn); } @@ -419,27 +430,61 @@ namespace TKS LastMessage = "적군 차례"; NotifyChanged(); foreach (var e in AliveUnits(Faction.Enemy)) e.ResetForNewTurn(); + if (Gimmicks != null) + { + yield return Gimmicks.TurnStart(Faction.Enemy); // 군기 · 중독 · 불 · 행동 불가(PD #1097) + if (CheckBattleEnd()) yield break; + } yield return Fx.Wait(0.4f); - var enemies = AliveUnits(Faction.Enemy); float accuracy = Stage.aiAccuracy; float aggro = Mathf.Max(0f, Stage.aiAggroRange + Skills.AggroAdd(Faction.Enemy)); // 신산 = 적 교전 범위 − int acted = 0; - for (int i = 0; i < enemies.Count; i++) + // 예측 시뮬레이션 AI(PD #1098): 남은 적 전원 평가 → 서포터 → 돌격 → 사격 · 1기 실행마다 재평가 · 교전 범위 밖 = 대기 + var pending = new List(); + foreach (var e in AliveUnits(Faction.Enemy)) { - var e = enemies[i]; - if (!e.IsAlive) continue; if (Skills.Suppressed(e)) continue; // 장판 일갈·태평요술 = 이번 적 턴 교전 반응 없음 + if (GimmickLayer.Skips(e)) continue; // 함정·강 = 이번 턴 행동 불가 + pending.Add(e); + } + bool tactical = TacticalAI.Available; + while (pending.Count > 0) + { + pending.RemoveAll(x => x == null || !x.IsAlive); var players = AliveUnits(Faction.Player); - if (players.Count == 0) break; + if (players.Count == 0 || pending.Count == 0) break; - Vector2? target = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config); - if (!target.HasValue) continue; // 교전 범위 밖 → 자리 지킴 + Unit e; + Vector2 target; + bool useSkill = false; + if (tactical) + { + var plan = TacticalAI.Next(this, pending, accuracy, aggro, Rng); + if (plan == null) break; // 남은 유닛 = 교전 범위 밖 → 자리 지킴 + e = plan.Unit; + target = plan.Target; + useSkill = plan.UseSkill; + LastAiPlan = plan; + } + else + { + e = pending[0]; + Vector2? aim = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config); // 가중치 자산이 없으면 기존 조준 + if (!aim.HasValue) + { + pending.RemoveAt(0); + continue; + } + target = aim.Value; + } + pending.Remove(e); if (UnitActing != null) UnitActing(e); yield return Fx.Wait(0.3f); // 카메라가 따라올 시간 + if (useSkill && Skills.Of(e) != null) Skills.Of(e).Reserved = true; // 적 보스 액티브(다음 행동형 · 1부 보스만) var o = new ActionOutcome(); - yield return Resolver.Resolve(e, target.Value, false, o); + yield return Resolver.Resolve(e, target, false, o); yield return ApplyOverwatch(e, o); LastMessage = Describe(e, o, false); NotifyChanged(); @@ -458,10 +503,25 @@ namespace TKS foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn(); Skills.OnPlayerTurnStart(); // 이번 적 턴 효과(호위·철벽·간웅·일갈·안개) · 견제 강화 정리 TurnNumber++; + if (Gimmicks != null) + { + yield return Gimmicks.TurnStart(Faction.Player); // 군기 · 불 지속 · 중독 · 불 · 행동 불가(PD #1097) + if (CheckBattleEnd()) yield break; + } LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : ""); + if (AliveCount(Faction.Player) > 0 && AllPlayersActed()) + { + LastMessage = TurnNumber + "번째 턴 — 아군 전원 행동 불가"; + yield return Fx.Wait(0.4f); + yield return EnemyTurnRoutine(); // 함정·강으로 아군 전원이 쉬는 턴 + yield break; + } SetPhase(BattlePhase.PlayerTurn); } + /// 마지막 적 AI 결정(디버그 · 테스트 · 캡처). + public AiPlan LastAiPlan; + /// 창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회). public IEnumerator ApplyOverwatch(Unit mover, List path) { diff --git a/Assets/Scripts/Runtime/Combat.cs b/Assets/Scripts/Runtime/Combat.cs index cab87e4f..4fde08d0 100644 --- a/Assets/Scripts/Runtime/Combat.cs +++ b/Assets/Scripts/Runtime/Combat.cs @@ -3,8 +3,8 @@ using UnityEngine; namespace TKS { - /// 피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중). - public static class Combat + /// 피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중). 지형 기믹 질의 = Gimmicks/Combat.Gimmicks.cs. + public static partial class Combat { /// 경로 질의에 쓰는 원 반지름 배율(살짝 작게 캐스트해 스침을 관대하게). public const float CastRadiusScale = 0.9f; @@ -23,16 +23,16 @@ namespace TKS public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee, float scale = 1f) { float mult = Mathf.Max(cfg.Matchup(attacker.Type, defender.Type), SkillMods.MatchupFloor(attacker, true)); - float atk = attacker.Atk * SkillMods.AttackMult(attacker, true); + float atk = attacker.Atk * SkillMods.AttackMult(attacker, true) * GimmickLayer.AttackMultOf(attacker); // 군기 점령(PD #1097) float raw = atk * mult * (crit ? SkillMods.CritMult(attacker, cfg) : 1f) - defender.Def * cfg.defenseFactor; // 보물 크리 % 포함(PD #1093 C) if (melee) { if (UnitTypeInfo.IsRanged(attacker.Type) && UnitTypeInfo.IsMelee(defender.Type) && !SkillMods.Has(attacker, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeDealtRatio; if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type) && !SkillMods.Has(defender, SkillKeys.MeleePenaltyOff, true)) raw *= cfg.archerMeleeTakenRatio; + raw *= GimmickLayer.MeleeTakenMultOf(attacker, defender); // 고지 위 = 아래에서 오는 근접 피해 −(PD #1097) } raw *= scale * SkillMods.DealtMult(attacker, defender, true) * SkillMods.TakenMult(defender, attacker, true); - float variance = 1f + ((float)rng.NextDouble() * 2f - 1f) * cfg.damageVariance; - return Mathf.Max(1, Mathf.RoundToInt(raw * variance)); + return Mathf.Max(1, Mathf.RoundToInt(raw * Variance(cfg, rng))); // 가상 실행 = 분산 0(PD #1098) } /// 책사 광역 피해(PD 2026-11-05 #1087 ⑤) = 대상별 사격 피해(상성 적용 · 크리 없음) × ratio · ratio ≤ 0 이면 0. @@ -86,6 +86,7 @@ namespace TKS { var u = UnitOf(h.collider); if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue; + if (GimmickLayer.IsFogged(u)) continue; // 안개 안 = 사선 표적 제외(PD #1097) hitDist = h.distance; return u; } @@ -106,6 +107,20 @@ namespace TKS return null; } + /// 경로 위 가장 가까운 유닛(except 제외) — 보병 연쇄 튕김(PD 2026-11-06 #1098 · 방금 부딪힌 유닛은 뺀다). + 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; + } + /// /// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외). /// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist). @@ -118,24 +133,8 @@ namespace TKS public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel) { - travel = dist; - Physics2D.SyncTransforms(); - var hits = Physics2D.CircleCastAll(from, arrowRadius, dir, dist); - System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance)); - foreach (var h in hits) - { - if (h.collider.GetComponent() != null) - { - travel = h.distance; - return null; - } - var u = UnitOf(h.collider); - if (u == null || u == shooter || !u.IsAlive) continue; - if (passFaction.HasValue && u.Faction == passFaction.Value) continue; - travel = h.distance; - return u; - } - return null; + GimmickInstance g; + return FirstUnitOnArrow(shooter, from, dir, dist, arrowRadius, passFaction, out travel, out g); // 기믹 pass/block/destroy · 안개(Combat.Gimmicks.cs) } /// 점 center 반경 radius 원에 몸(원)이 닿는 살아 있는 유닛(가까운 순 · 진영 불문 · exclude 둘 제외) — 책사 광역(보스는 반지름이 커서 더 잘 닿는다). @@ -189,13 +188,16 @@ namespace TKS return list; } - /// 벽·(옵션) 유닛에 막히기 전까지 실제로 나아갈 수 있는 거리. ignore = 밀어 주는 유닛처럼 출발 지점에서 닿아 있어도 무시할 유닛. + /// + /// 벽·장애물 기믹·(옵션) 유닛에 막히기 전까지 실제로 나아갈 수 있는 거리. ignore = 밀어 주는 유닛처럼 출발 지점에서 닿아 있어도 무시할 유닛. + /// 숨은 함정에 중심이 들면 그 자리에서 멈춘다(PD #1097 · 아군 조준 미리보기는 함정을 모른다). + /// public static float FreeTravel(Unit actor, Vector2 origin, Vector2 dir, float dist, bool enemiesBlock, bool friendsBlock = true, Unit ignore = null) { - float min = dist; + float min = TrapStop(actor, origin, dir, dist); foreach (var h in Cast(actor, origin, dir, dist)) { - if (h.collider.GetComponent() != null) + if (h.collider.GetComponent() != null || GimmickLayer.BlocksMove(h.collider)) { min = Mathf.Min(min, h.distance); continue; diff --git a/Assets/Scripts/Runtime/Fx.cs b/Assets/Scripts/Runtime/Fx.cs index 185a82b8..2b502a64 100644 --- a/Assets/Scripts/Runtime/Fx.cs +++ b/Assets/Scripts/Runtime/Fx.cs @@ -4,19 +4,25 @@ using UnityEngine; namespace TKS { - /// 간단한 연출(이동 트윈·돌진·화살·피해 숫자). 코루틴 실행자는 BattleManager. + /// 간단한 연출(이동 트윈·돌진·화살·피해 숫자). 코루틴 실행자는 BattleManager. 가상 실행(Sim.Active · 적 AI 예측) 중 = 이동·대기 즉시 · 팝업·터짐 없음. public static class Fx { public static MonoBehaviour Runner; public static Coroutine Run(IEnumerator routine) { + if (Sim.Active) + { + Sim.Run(routine); // 가상 실행 = 그 자리에서 끝까지(병렬 밀기 등) + return null; + } if (Runner == null) return null; return Runner.StartCoroutine(routine); } public static IEnumerator Wait(float seconds) { + if (Sim.Active) yield break; float t = 0f; while (t < seconds) { @@ -30,6 +36,12 @@ namespace TKS { float total = Vector2.Distance(from, to); if (total < 1e-4f) yield break; + if (Sim.Active) + { + tr.position = new Vector3(to.x, to.y, 0f); + if (onDistance != null) onDistance(total); + yield break; + } float duration = Mathf.Max(0.12f, total / Mathf.Max(0.1f, speed)); float el = 0f; while (el < duration) @@ -49,6 +61,7 @@ namespace TKS /// 근접 공격 돌진(짧게 찌르고 복귀). public static IEnumerator Lunge(Unit a, Vector2 toward) { + if (Sim.Active) yield break; Vector2 start = a.Position; Vector2 dir = (toward - start); if (dir.sqrMagnitude < 1e-4f) yield break; @@ -73,6 +86,7 @@ namespace TKS /// 투사체(가늘고 긴 사각형) 날리기 — 궁병 화살 · 책사 계략(색만 다름). public static IEnumerator Arrow(Vector2 from, Vector2 to, Color color) { + if (Sim.Active) yield break; var go = new GameObject("Arrow"); var sr = go.AddComponent(); sr.sprite = ProceduralSprites.Square; @@ -97,6 +111,7 @@ namespace TKS /// 광역 터짐(반지름 radius 원이 짧게 커지며 사라짐 · 책사 광역). public static void Burst(Vector2 center, float radius, Color color) { + if (Sim.Active) return; Run(BurstRoutine(center, radius, color)); } @@ -122,6 +137,7 @@ namespace TKS public static void Popup(Vector2 pos, string text, Color color) { + if (Sim.Active) return; Run(PopupRoutine(pos, text, color)); } diff --git a/Assets/Scripts/Runtime/Gimmicks.meta b/Assets/Scripts/Runtime/Gimmicks.meta new file mode 100644 index 00000000..53216b7a --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4c04a047e2d9a1c44bea58d6a6fbf8a8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs b/Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs new file mode 100644 index 00000000..14c21814 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs @@ -0,0 +1,452 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace TKS +{ + /// + /// 지형 기믹 충돌 반응(PD #1097 · 지형기믹_기획서 §2~§4 · 병종×기믹 표): + /// 보병 = 내구 피해 + 튕김(알까기 자기 튕김 재사용 · bounce_pct = 자기 HP 피해) · 바위는 굴림 / 창병 = 내구 피해 + spear_push 만큼 밀고 정지(밀린 끝 유닛도 밀림) · 바위 굴림 / + /// 기병 = 관통 불가 → 거기서 정지(복귀 없음) · 내구 피해 + 튕김 + 자기 HP(cav_self_pct) · 바위 = 정지 / 궁·책 이동 = 피해 없이 튕김 · + /// 투사체 = pass·block·destroy · 책사 광역 = 덤불 제거·수레 파괴·(화공) 불 생성 · 폭약 = 광역 파이프라인(아군 포함 + 밀기 + 불) · + /// 행동 끝 = 빙판 미끄러짐(행동 유닛 = 병종 루틴 재실행 · 밀린 유닛 = 밀기) → 지대 스윕(독·불·함정) → 다리 → 강. + /// + 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; } + } + + /// 근접 병종 슬링샷 도달점(늪 ×p1 · 고지 오르기 ×p3) — Resolve·조준 미리보기 공용. 원거리 병종은 접근·이동 단계에서 따로 계산. + 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(); + } + + /// 경로 위 첫 장애물 기믹 — limit(첫 유닛 접촉) · 전장 벽 · 숨은 함정보다 앞일 때만. + 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; + } + + /// 궁·책 등 이동만 하는 병종: 유닛 앞의 장애물이면 부딪힘(튕김) · 아니면 그대로 이동. + 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); + } + + /// 보병·창병·궁·책 이동이 장애물 기믹에 부딪힘. + 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) · 면 바깥으로 + } + + /// 기병 관통이 장애물에 막힘(복귀 없음): 내구 피해 → 바위 = 정지 + cav_self_pct · 그 밖 = 튕김 + max(cav_self_pct, bounce_pct). + 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); + } + + /// + /// 폭약 수레 폭발(광역 파이프라인 재사용): 반경 p1 안 유닛 전원(아군 포함 · 성문·성벽 너머 제외) 피해 = 터뜨린 유닛 공 × p2 · 바깥으로 p3 밀기 · + /// 반경 안 다른 수레 연쇄 · 그 자리 불 지대(반경 p1 · 지속 = 스테이지 화공 배치 burn_turns · 없으면 정의 p2). + /// + 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(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); + } + + /// 바위 굴림: 바위 원을 dir 로 dist 만큼(벽·다른 장애물·유닛에서 멈춤) · 상대 유닛에 닿으면 공 × p1 피해 + p2 밀기. + 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() != null || GimmickLayer.BlocksMove(h.collider)) + { + travel = h.distance; + break; + } + var v = h.collider.GetComponent(); + 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(); + } + + /// 목책 밀기(창병): spear_push 만큼(벽·다른 장애물에서 멈춤) · 새 자리와 겹칠 유닛은 먼저 그만큼 밀고 · 다 못 밀리면 목책도 그만큼만. + 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() != null || GimmickLayer.BlocksMove(h.collider)) min = Mathf.Min(min, h.distance); + } + return Mathf.Max(0f, min - Combat.Epsilon); + } + + /// 기병 관통 옆 밀기: 늪 안 = 0 · 빙판 위 = ×p2 · 장애물·함정에서 멈춤 · 다리 위에서 강 쪽이면 강 판정. + 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; + } + + // ---------- 투사체 · 책사 광역 ---------- + + /// 책사 광역 계획: 중심 = 첫 피격 유닛 · 유닛이 없고 제거 가능 기믹(덤불·수레)에 막혔으면 그 자리 · 반경 × 안개 · 성문·성벽 너머 제외 · 지울 기믹. + 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)); + } + + /// 투사체가 기믹에 막힘: 수레 = 파괴(폭발) · 그 밖 = 막힘(피해 없음). + 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)); + } + } + + /// 책사 광역의 기믹: 덤불 제거 · 수레 파괴(폭발) · 화공 스테이지 = 그 자리 불. + 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("불길"); + } + + /// 빙판: 사격 전 이동이 빙판에서 끝나면 미끄러짐 → 사거리 밖이면 사격 취소(PD #1097 §9 ③ 제안) · 아니면 새 자리에서 다시 조준. + 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); + } + + // ---------- 행동 끝 ---------- + + /// 행동 끝(기믹): 행동 유닛 미끄러짐(행동당 1회 · 최대 p1) → 밀린 유닛 미끄러짐 → 지대 스윕(독·불·함정) → 다리 → 강. + 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(); + 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 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; + } + + /// 행동 유닛 미끄러짐 = 같은 방향으로 병종 루틴을 미끄러질 거리만큼 다시(충돌 규칙 그대로) · 명중 보상·복귀·견제는 원래 행동 것만. + 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(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); + } + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs.meta b/Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs.meta new file mode 100644 index 00000000..428e73d7 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/ActionResolver.Gimmicks.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 09ebc6412623b864ebdbad94df388f89 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs b/Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs new file mode 100644 index 00000000..e312c1c5 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs @@ -0,0 +1,98 @@ +using UnityEngine; + +namespace TKS +{ + /// 지형 기믹 질의(PD #1097) — 장애물 콜라이더 · 투사체 pass/block/destroy · 안개 · 기믹 피해 공식. + public static partial class Combat + { + /// 전장 가장자리 벽까지만(기믹·유닛 무시) 나아갈 수 있는 거리. + 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() != null) min = Mathf.Min(min, h.distance); + } + return Mathf.Max(0f, min - Epsilon); + } + + /// 경로(원 캐스트) 위 첫 장애물 기믹. hitDist = 닿기까지 중심 이동 거리 · normal = 닿은 면의 바깥 방향(튕김 방향). + 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; + } + + /// + /// 투사체(PD 2026-11-05 #1087 ⑥ + 기믹 #1097): 첫 유닛 또는 막는 기믹. 목책·강 = 통과(pass) · 덤불·바위·성문·성벽 = 막힘(gimmick 에 담고 null) · + /// 폭약 수레 = 맞으면 파괴(gimmick 에 담고 null) · 안개 안 유닛 = 사선 판정 제외(통과). + /// + 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() != 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; + } + + /// + /// 기믹 피해(바위 굴림 · 폭약 폭발) = (공격 × 스킬 공 배율 × 군기 × mult − 방어 × defenseFactor) × 난수 · 상성·크리 없음(「공 ×p」 · 해석 = PD 확인 대상). + /// + 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))); + } + + /// 피해 난수(가상 실행 = 분산 0 · 적 AI 예측 · PD #1098). + 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; + } + + /// 함정 정지(숨은 함정에 중심이 들면 그 자리) — FreeTravel 이 쓴다. + 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); + } + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs.meta b/Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs.meta new file mode 100644 index 00000000..b6b869d0 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/Combat.Gimmicks.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3be7ffd206cf91f458c0a84090c1363d \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickBody.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickBody.cs new file mode 100644 index 00000000..4010dea6 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickBody.cs @@ -0,0 +1,10 @@ +using UnityEngine; + +namespace TKS +{ + /// 장애물 기믹 콜라이더 표식(캐스트 질의에서 어떤 기믹인지 찾는다 · BoardWall 과 같은 역할). + public class GimmickBody : MonoBehaviour + { + public GimmickInstance Inst; + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickBody.cs.meta b/Assets/Scripts/Runtime/Gimmicks/GimmickBody.cs.meta new file mode 100644 index 00000000..8c9f175f --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickBody.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 31660f844b158db42880d31b9625a704 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs new file mode 100644 index 00000000..460e9092 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs @@ -0,0 +1,284 @@ +using System.Collections; +using System.Collections.Generic; +using System.IO; +using TKS.OutGame; +using UnityEngine; + +namespace TKS +{ + /// + /// 지형 기믹·적 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장. 카메라는 기믹 쪽으로 · 끝나면 스스로 종료. + /// + 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 log = new List(); + 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(); + 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(); + 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); + } + + /// 아군 전원이 가까운 적 쪽으로 슬링(최대 거리) → 적 턴(예측 AI)까지 끝나기를 기다린다. + 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; + } + + /// 조준 장면용 전장: 유닛을 비우고 그 스테이지 기믹만 다시 깐다. + 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); + } + + /// 1-3: 보병이 가시덤불 쪽으로 당긴 조준(덤불 지점 붉게 끊김 · 튕김 · 자기 피해 글자 · 튕겨날 궤적). + 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"); + } + + /// 9-11: 다리 위 적을 아군 보병이 비스듬히 밀어 강에 빠뜨리는 조준(밀림 궤적 · 강에 빠짐 글자). + 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"); + } + + /// AI 전술: 적 보병이 아군을 함정 쪽으로 튕겨낸다(함정 발동 순간 캡처). + 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 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 + } + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs.meta b/Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs.meta new file mode 100644 index 00000000..c92e7dd3 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickCaptureDriver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 85b5cf0772320f4408726c98222609db \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickData.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickData.cs new file mode 100644 index 00000000..66995cf5 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickData.cs @@ -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 + } + + /// 기믹 정의 1행(gimmicks.csv · SOT = 기획 tables/ · C45 — 코드에는 열 이름과 뜻만). + [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 = ""; + + /// 병종이 부딪혀 주는 내구 피해(궁·책 = 0 · 내구 없는 기믹 = 0). + 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; + } + } + + /// 스테이지 배치 1행(stage_gimmicks.csv). 좌표 = 전장 월드(가운데 0,0 · 아군 아래). + [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; + } + } + + /// + /// 기믹 표(Assets/Data/Gimmicks/*.csv · utf-8-sig) 해석·검증(PD #1097). 형식·참조 오류는 errors 에 모은다(하나라도 있으면 임포터가 자산을 바꾸지 않는다). + /// 기믹 id 중 동작이 특별한 것(바위 굴림 · 다리 · 불타는 잔해 생성) = GimmickIds — 수치는 모두 CSV. + /// + public static class GimmickTables + { + public const string Folder = "Data/Gimmicks"; // Assets/ 아래 + /// 정의 · 배치 · 스테이지 시작 팁(1장 튜토리얼 문구 · PD #1098 §3). + public static readonly string[] Files = { "gimmicks.csv", "stage_gimmicks.csv", "tutorial_tips.csv" }; + public const string ResourcesFolder = "Gimmicks/"; + + /// 배치 params 에 쓸 수 있는 키(p1~p3 = 정의 p 덮어쓰기 · 별칭 = 불 burn_turns→p2 · spawn_on_splash→p3 · 빙판 slide_max→p1). + public static readonly string[] ParamKeys = { "p1", "p2", "p3", "burn_turns", "spawn_on_splash", "slide_max" }; + + public class Result + { + public readonly List defs = new List(); + public readonly List rows = new List(); + public readonly List tips = new List(); + public readonly List errors = new List(); + } + + static GimmickDb db; + static StageGimmickTable table; + + /// 임포트한 자산(없으면 null · 전투 시작마다 다시 읽으려면 Invalidate). + public static GimmickDb Db + { + get + { + if (db == null) db = Resources.Load(ResourcesFolder + "GimmickDb"); + return db; + } + } + + public static StageGimmickTable Table + { + get + { + if (table == null) table = Resources.Load(ResourcesFolder + "StageGimmickTable"); + return table; + } + } + + public static void Invalidate() + { + db = null; + table = null; + } + + /// texts = Files 순서 · stageExists(장, 번호) = 스테이지 표에 있는가(null = 검사 생략). + public static Result Parse(string[] texts, Func stageExists = null) + { + var r = new Result(); + var ids = new HashSet(); + 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 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 errors, string at) + { + var list = new List(); + 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 r, string key) + { + string v; + return r.TryGetValue(key, out v) ? v : ""; + } + + static int Int(Dictionary r, string key, List 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 r, string key, List 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 r, string key, List 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 r, string key, List errors, string at) + { + return Get(r, key).Length == 0 ? float.NaN : Float(r, key, errors, at); + } + } + + /// 동작이 id 로 정해지는 기믹(수치는 CSV · 새 기믹이 같은 동작을 쓰면 여기 한 줄). + public static class GimmickIds + { + /// 보·창이 밀면 굴러가는 바위(남은 거리 × spear_push · 적에 닿으면 공 ×p1 + 밀기 p2). + public const string Boulder = "boulder"; + /// 강 위 통로(겹친 강 구간은 콜라이더에서 뺀다 · 다리 위에서 옆으로 밀리면 강 판정). + public const string Bridge = "bridge"; + /// 폭약 수레 폭발 · 책사 광역(spawn_on_splash)이 만드는 불 지대의 정의. + public const string BurningDebris = "burning_debris"; + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickData.cs.meta b/Assets/Scripts/Runtime/Gimmicks/GimmickData.cs.meta new file mode 100644 index 00000000..f92ace95 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 645600176fecaa047908a4a1acebef2d \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickDb.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickDb.cs new file mode 100644 index 00000000..ac80a574 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickDb.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace TKS +{ + /// 기믹 정의 표(Resources/Gimmicks/GimmickDb · 메뉴 TKS/Import Gimmicks). + public class GimmickDb : ScriptableObject + { + public List defs = new List(); + + 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; + } + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickDb.cs.meta b/Assets/Scripts/Runtime/Gimmicks/GimmickDb.cs.meta new file mode 100644 index 00000000..d6e6b315 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickDb.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9acfdb0ffd0c7604b8ea57748aed0456 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickInstance.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickInstance.cs new file mode 100644 index 00000000..80e0e67d --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickInstance.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace TKS +{ + /// 전장에 놓인 기믹 1개(정의 + 배치 + 전투 중 상태). 시각 = GimmickView · 콜라이더 = 장애물만. + public class GimmickInstance + { + public GimmickDef Def; + public GimmickPlacement Src; + public GimmickShape Shape; + public int Hp; + public int MaxHp; + /// 내구 0 = 잔해(통과 가능 · art_broken). + public bool Broken; + /// 제거됨(책사 광역 덤불 · 발동한 함정). + public bool Removed; + /// 함정이 드러났다(발동). + public bool Revealed; + /// 불 지대 남은 라운드(−1 = 영구) · 0 이 되면 Out. + public int BurnLeft = -1; + /// 불이 꺼짐(잔해만 남음 · 효과 없음). + public bool Out; + /// 군기 점령 진영(−1 = 중립 · 0 = 아군 · 1 = 적군). + public int Owner = -1; + /// 전투 중 생긴 기믹(폭발·광역 불). + public bool Spawned; + /// 배치만 있고 지대는 만들지 않는 생성 규칙(불 spawn_on_splash). + public bool Template; + + public GameObject Go; + public readonly List Colliders = new List(); + 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; } + } + + /// 이동을 막는다(장애물 · 부서지거나 제거되지 않음). + public bool BlocksMove + { + get { return IsBlock && !Broken && !Removed; } + } + + /// 지대 효과가 살아 있다. + 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; } + } + + /// 정의 p(i) · 배치 params 가 있으면 그 값 · 둘 다 비면 fallback. + 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; + } + } + + /// 모양·표시·콜라이더를 d 만큼 옮긴다(목책 밀기 · 바위 굴림). + public void MoveBy(Vector2 d) + { + Shape = Shape.Translated(d); + if (Go != null) Go.transform.position = new Vector3(Shape.Center.x, Shape.Center.y, 0f); + } + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickInstance.cs.meta b/Assets/Scripts/Runtime/Gimmicks/GimmickInstance.cs.meta new file mode 100644 index 00000000..21a12b48 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickInstance.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d1257de6310ea7d4aa26f8cc4d71c6f6 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs new file mode 100644 index 00000000..6d8d0af4 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs @@ -0,0 +1,978 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace TKS +{ + /// 유닛 1기의 기믹 상태(중독 · 행동 불가 · 상태 글자). + public class GimmickUnitState + { + public int PoisonTurns; + public int PoisonTick; + public float PoisonPct; + public float PoisonStep; + /// 남은 행동 불가 턴(함정 · 강) — 그 진영 턴 시작에 1 소모하고 그 턴을 쉰다. + public int Stun; + /// 이번 턴 행동 불가(턴 시작에 정함). + public bool Skip; + public TextMesh Badge; + + public GimmickUnitState Copy() + { + return (GimmickUnitState)MemberwiseClone(); + } + } + + /// + /// 지형 기믹 레이어(PD #1097 · 기획서 지형기믹_기획서.md) — 스테이지와 독립된 레이어. 전투 시작마다 그 스테이지의 배치(stage_gimmicks.csv)로 + /// 장애물(콜라이더 + 내구)·지대(모양 · 경로 스윕)를 만든다(배경 위 · 유닛 아래). 전투 코드는 이 클래스의 질의만 부른다: + /// 이동 막기(Combat.FreeTravel) · 함정 정지 · 늪·고지 이동 비용 · 고지 사거리·근접 피해 · 군기 공격 · 안개 표적 · 광역 막힘 · 턴 시작(중독·불·행동 불가·군기). + /// 충돌 반응(튕김·밀기·파괴·폭발·미끄러짐)은 ActionResolver.Gimmicks.cs. + /// + public class GimmickLayer : MonoBehaviour + { + public static GimmickLayer Current { get; private set; } + + /// 조준 미리보기 계산 중(아군 = 숨은 함정을 모른다). + public static bool PreviewMode; + + BattleManager battle; + readonly List items = new List(); + readonly Dictionary states = new Dictionary(); + GimmickInstance splashTemplate; + + /// 지금 처리 중인 행동의 결과(기믹 효과가 Displaced·Notes 를 더한다). + public ActionOutcome Outcome; + + public struct ForcedMove + { + public Unit Unit; + public Vector2 Dir; + public float Intended; + public float Actual; + } + + /// 이번 행동에서 밀림·튕김이 막힌 기록(다리 → 강 판정). + public readonly List Forced = new List(); + + public struct Affect + { + public Unit Unit; + public GimmickZone Kind; + /// 앞으로 받을 피해 환산(중독 = 남은 턴 합계 · 그 밖 = 0 · 즉시 피해는 HP 로 따로 센다). + public float Value; + } + + /// 이번 행동에서 지대 효과를 받은 유닛(독·불·함정·강) — 적 AI 점수(목적지 기믹 가치 · PD #1098). + public readonly List Affected = new List(); + + public GimmickPreview Preview { get; private set; } + + public IReadOnlyList 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(); + layer.battle = bm; + layer.Preview = new GimmickPreview(layer); + Current = layer; + return layer; + } + + void OnDestroy() + { + if (Current == this) Current = null; + } + + // ---------- 생성 ---------- + + /// 전투 시작: 그 스테이지(장-번호)의 배치를 만든다(정의·배치 = 임포트 자산 · 없으면 빈 레이어). + 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(); + } + + /// 배치(편성 규칙 좌표)가 장애물 기믹과 겹친 유닛은 가장 가까운 빈 자리로 옮긴다(겹친 채면 캐스트가 거리 0 으로 막힘). + 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(); + } + + /// 정의 + 배치로 1개 생성(전투 중 생성 · 테스트 · 페인터 공용). 불 spawn_on_splash 배치는 지대 없이 생성 규칙으로만 둔다. + 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; + } + + /// 테스트·페인터: id 와 모양으로 생성(params = 「키=값;…」). + 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(); + c.radius = s.R; + col = c; + break; + } + case GimmickShapeType.Rect: + { + cgo.transform.rotation = Quaternion.Euler(0f, 0f, s.Rot); + var b = cgo.AddComponent(); + b.size = s.Size; + col = b; + break; + } + default: + { + var pc = cgo.AddComponent(); + 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().Inst = g; + g.Colliders.Add(col); + } + + /// 강 콜라이더 = 강 사각형에서 다리가 겹친 구간(강 가로축 투영)을 뺀 조각들. 원·다각형 강은 다리 없이 통째로. + void RebuildRivers() + { + if (Sim.Active) return; + foreach (var r in items) + { + if (r.Zone != GimmickZone.River || r.Go == null) continue; + foreach (var c in r.Colliders) + { + if (c == null) continue; + c.gameObject.SetActive(false); // 콜라이더를 즉시 물리 월드에서 제거 + Destroy(c.gameObject); + } + r.Colliders.Clear(); + if (r.Shape.Type != GimmickShapeType.Rect) + { + AddCollider(r, r.Shape, r.Go.transform); + continue; + } + float half = r.Shape.Size.x * 0.5f; + var cuts = new List(); + Vector2 ax = r.Shape.LocalAxisX; + foreach (var b in items) + { + if (!b.IsBridge || b.Removed) continue; + if (r.Shape.SignedDistance(b.Shape.Center) > b.Shape.BoundingRadius) continue; + float lo = float.MaxValue, hi = float.MinValue; + foreach (var corner in Corners(b.Shape)) + { + float x = Vector2.Dot(corner - r.Shape.Center, ax); + lo = Mathf.Min(lo, x); + hi = Mathf.Max(hi, x); + } + cuts.Add(new Vector2(Mathf.Max(lo, -half), Mathf.Min(hi, half))); + } + cuts.Sort((a, b) => a.x.CompareTo(b.x)); + float cur = -half; + var pieces = new List(); + foreach (var c in cuts) + { + if (c.x > cur + 0.01f) pieces.Add(new Vector2(cur, c.x)); + cur = Mathf.Max(cur, c.y); + } + if (half > cur + 0.01f) pieces.Add(new Vector2(cur, half)); + foreach (var pc in pieces) + { + float mid = (pc.x + pc.y) * 0.5f; + var s = GimmickShape.Box(r.Shape.Center + ax * mid, new Vector2(pc.y - pc.x, r.Shape.Size.y), r.Shape.Rot); + AddCollider(r, s, r.Go.transform); + } + } + } + + static IEnumerable Corners(GimmickShape s) + { + if (s.Type == GimmickShapeType.Circle) + { + yield return s.Center + new Vector2(-s.R, -s.R); + yield return s.Center + new Vector2(s.R, s.R); + yield return s.Center + new Vector2(-s.R, s.R); + yield return s.Center + new Vector2(s.R, -s.R); + yield break; + } + if (s.Type == GimmickShapeType.Poly) + { + foreach (var p in s.Points) yield return p; + yield break; + } + Vector2 ax = s.LocalAxisX, ay = new Vector2(-ax.y, ax.x); + Vector2 hx = ax * s.Size.x * 0.5f, hy = ay * s.Size.y * 0.5f; + yield return s.Center + hx + hy; + yield return s.Center + hx - hy; + yield return s.Center - hx + hy; + yield return s.Center - hx - hy; + } + + // ---------- 정적 질의(레이어가 없으면 기본값) ---------- + + public static GimmickInstance InstOf(Collider2D c) + { + if (c == null) return null; + var b = c.GetComponent(); + return b != null ? b.Inst : null; + } + + /// 이 콜라이더가 이동을 막는 기믹인가(부서진 잔해·제거된 덤불 = 아님). + 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; + } + + /// 숨은 함정: origin → dir 로 dist 안에서 중심이 처음 함정에 드는 거리(+0.06 = 안으로 살짝) · 없으면 dist. 조준 미리보기(아군)는 모른다. + 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; + } + + /// + /// 이동 비용(늪 = 안 구간 ×p1 · 고지 = 밖에서 오르는 구간 ×p3): budget(슬링샷 이동량)으로 실제로 갈 수 있는 거리. + /// 늪에서 멈추면 다음 행동도 늪 안 구간만큼 반감(출발점이 늪이어도 같은 계산). + /// + 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; + } + + /// 고지 위의 궁·책 사거리 +p1. + 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; + } + + /// 고지 위 유닛이 아래(고지 밖)에서 오는 근접 피해 × (1 − p2). + 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)); + } + + /// 군기 점령 측 전원 공 × (1 + Σp2). + 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; + } + + /// 안개 안 유닛 = 사선 표적·화살 판정에서 빠진다(안에서 쏘기는 가능). + public bool Fogged(Unit u) + { + return u != null && ZoneAt(u.Position, GimmickZone.Fog) != null; + } + + /// 광역 중심이 안개면 범위 × p1. + public float FogSplashMult(Vector2 c) + { + var g = ZoneAt(c, GimmickZone.Fog); + return g != null ? g.P(1, 1f) : 1f; + } + + /// 광역이 중심 → p 사이에서 막히는가(성문·성벽·바위 = 투사체 막힘 + 책사 제거 불가). + 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; + } + + /// 기병 관통 옆 밀기 배율: 늪 안 = 0 · 빙판 위 = ×p2. + 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; + } + + /// 정지점이 빙판이면 같은 방향으로 빙판이 끝나는 곳까지(최대 p1) 미끄러질 거리 · 아니면 0. + 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)); + } + + /// 책사 광역 반경 안의 제거 가능한 장애물(strategist_clear = 덤불 제거 · 폭약 수레 파괴). + public List ClearableIn(Vector2 c, float r) + { + var res = new List(); + foreach (var g in items) + { + if (!g.BlocksMove || !g.Def.strategistClear) continue; + if (g.Shape.SignedDistance(c) <= r) res.Add(g); + } + return res; + } + + /// 책사 광역이 떨어진 자리에 불(스테이지에 spawn_on_splash 불 배치가 있을 때 · 반경 = 광역 반경 · 지속 = 그 배치 burn_turns). + public GimmickInstance OnSplash(Vector2 c, float r) + { + if (splashTemplate == null) return null; + return SpawnBurning(c, r, splashTemplate.P(2, 0f)); + } + + /// 폭약 폭발 자리 불(반경 = 폭발 반경 · 지속 = 스테이지 화공 배치 burn_turns · 없으면 정의 p2 · 0 = 영구). + 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); + } + + /// 내구 피해(흔들림 · 핍) → 0 이면 잔해. 반환 = 이번에 부서졌다. + 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); + } + + /// 최대 HP 비율 피해(지대 · 튕김 자기 피해) — 반격·사망과 같은 경로(SkillSystem.Deal). 반환 = 준 피해. + 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 : ""; + } + + // ---------- 턴 시작 ---------- + + /// + /// side 진영 턴 시작: 군기 점령 판정 → (아군 턴 = 라운드 시작) 불 지속 −1 → 그 진영 유닛의 중독(최대 HP p2 + p3 × 지난 턴 수) · + /// 불 위에서 멈춘 유닛(p1) · 행동 불가(남은 턴 1 소모 · 아군 = 행동 끝 표시). + /// + 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); + } + } + + // ---------- 행동 뒤 지대(경로 스윕) ---------- + + /// + /// 이 행동에서 움직인 모든 유닛(행동 유닛 경로 + 밀림·넉백·튕김 구간)의 중심 경로가 지대와 겹치면 효과: + /// 독늪 = 중독(재진입 갱신) · 불 = 최대 HP p1(행동당 1회) · 함정 = 그 자리 정지(이동 계산에서 이미 멈춤) + p1 턴 행동 불가 + 제거. + /// 그 뒤 다리 위에서 옆으로 밀려 강에 막힌 유닛 = 최대 HP p1 + p2 턴 행동 불가. + /// + public void ApplyZones(Unit mover, ActionOutcome o) + { + if (items.Count == 0 || o == null) return; + var segs = new Dictionary>(); + if (mover != null && o.Path.Count > 0) segs[mover] = new List(o.Path); + foreach (var d in o.Displaced) + { + if (d.Unit == null) continue; + List l; + if (!segs.TryGetValue(d.Unit, out l)) + { + l = new List(); + 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 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 Refs = new List(); + public readonly List Hp = new List(); + public readonly List Flags = new List(); + public readonly List BurnLeft = new List(); + public readonly List Owner = new List(); + public readonly List Center = new List(); + public readonly Dictionary States = new Dictionary(); + 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(); + 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; + } + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs.meta b/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs.meta new file mode 100644 index 00000000..6f2b7d7d --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickLayer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 297969a5821fa9e428e5c0b0920c81eb \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs new file mode 100644 index 00000000..bdae98ec --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs @@ -0,0 +1,316 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace TKS +{ + /// + /// 조준 미리보기(PD #1097 §6 + #1098 §3): 이 슬링샷을 가상 실행(Sim · 연출 없음 · 숨은 함정 모름)해 그 결과를 그린다 — + /// 행동 유닛 실제 경로(튕김·연쇄·창병 반작용·빙판 미끄러짐 = 점선) · 밀릴 유닛 궤적(상대 = 주황 · 같은 편 = 하늘 점선) · + /// 장애물에 닿는 지점 = 붉게 끊김 + 결과 글자(내구 −n · 파괴 · 굴림 · 밀림 · 폭발 · 튕김) · 지나는 지대 = 색 띠(늪 갈색 · 독 보라 · 불 주황 · 빙판 하늘 · 고지 황토 · 안개 흰색). + /// + 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 dashes = new List(); + readonly List bands = new List(); + readonly List icons = new List(); + LineRenderer cut; + static Material dashMaterial; + static Material solidMaterial; + int usedDash, usedBand, usedIcon; + + /// 마지막 미리보기 결과(테스트 확인용). + public readonly List LastIcons = new List(); + public readonly List LastBandColors = new List(); + 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; + } + + /// 조준 중 1회(드래그 프레임마다). shot = 궁·책 사격 계획(있으면 막힘·폭발 글자). + 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>(); + var gPos = new List(); + foreach (var g in layer.Items) + { + gBefore.Add(new KeyValuePair(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(); + var moverPath = new List(); + var tracks = new List>(); + 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(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 { 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(); + } + + /// 그 지점에서 장애물 콜라이더(강 = 다리 구간을 뺀 조각)에 닿아 있는가. + 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 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(); + 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 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); + } + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs.meta b/Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs.meta new file mode 100644 index 00000000..66a75c72 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickPreview.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 08f2e7173a4b32c4d8744429c48520a1 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickShape.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickShape.cs new file mode 100644 index 00000000..0dafda10 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickShape.cs @@ -0,0 +1,192 @@ +using UnityEngine; + +namespace TKS +{ + /// + /// 기믹 모양(circle · rect(회전) · poly) 기하 — 지대 겹침(경로 스윕) · 거리 · 진입/이탈 거리. 장애물 콜라이더도 이 모양으로 만든다. + /// 경로 스윕은 0.05 간격 표본(전장 20×32 · 슬링 최대 6.5 → 한 행동 130 표본 × 기믹 수 · 충분히 가볍다). + /// + public struct GimmickShape + { + public const float SweepStep = 0.05f; + + public GimmickShapeType Type; + public Vector2 Center; + /// circle 반지름. + public float R; + /// rect 가로·세로. + public Vector2 Size; + /// rect 회전(도 · 반시계). + public float Rot; + /// poly 꼭짓점(월드 · Center 기준 상대 좌표가 아니다). + 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; + } + + /// 가장 바깥 점까지의 거리(대략 검사용). + 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)); + } + } + + /// 점 q 까지 부호 거리(안 = 음수 · 경계 = 0). + 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; + } + + /// 선분 ab 가 모양과 겹치는가(중심 경로 · 여유 tol). + 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; + } + + /// 선분 ab 중 모양 안에 든 길이. + 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; + } + + /// origin 에서 dir 로 dist 안에 중심이 처음 모양 안에 드는 거리(출발점이 이미 안이면 0 · 없으면 −1). + 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; + } + + /// origin(모양 안)에서 dir 로 나가는 거리(max 까지 · 출발점이 밖이면 0). + 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); + } + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickShape.cs.meta b/Assets/Scripts/Runtime/Gimmicks/GimmickShape.cs.meta new file mode 100644 index 00000000..09838401 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickShape.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 88a4d45c42d9ad942a2092b6378de92f \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickView.cs b/Assets/Scripts/Runtime/Gimmicks/GimmickView.cs new file mode 100644 index 00000000..87d7201a --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickView.cs @@ -0,0 +1,342 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace TKS +{ + /// + /// 기믹 표시(PD #1097 §6): 스프라이트(Resources/Gimmicks/Art/<art>)가 있으면 모양에 맞춰 늘리고, 없으면 색 도형 + 이름 글자. + /// 장애물 = 내구 핍 · 피격 흔들림 · 파괴 파편 → 잔해(art_broken) · 함정 = 발동 전 숨김 · 불 = 꺼지면 회색 · 군기 = 점령 진영 색. + /// 정렬 = 배경(−20~−16) 위 · 유닛(−6~) 아래. + /// + 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 pips = new List(); + 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(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(); + var mr = go.AddComponent(); + 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; + } + + /// 귀 자르기 삼각 분할(단순 다각형). + public static int[] Triangulate(Vector2[] pts) + { + var tris = new List(); + var idx = new List(); + 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); + } + + /// 상태(내구 · 잔해 · 군기 · 불 꺼짐 · 함정 노출)를 그림에 반영. + 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(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(); + 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; + } + } + } + + /// 피격 흔들림. + 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; + } + + /// 파괴 파편(작은 조각이 흩어지며 사라짐) → 잔해 표시. + public void Shatter() + { + Fx.Run(ShatterRoutine(g.Shape.Center, baseColor)); + Refresh(); + } + + static IEnumerator ShatterRoutine(Vector2 c, Color color) + { + var bits = new List(); + var vel = new List(); + 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); + } + + /// 함정 발동(드러났다 사라짐) · 덤불 제거(흐려지며 사라짐). + 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); + } + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/GimmickView.cs.meta b/Assets/Scripts/Runtime/Gimmicks/GimmickView.cs.meta new file mode 100644 index 00000000..49ec1c20 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/GimmickView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b65a87233ccc60e48b4267a11d41d734 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Gimmicks/StageGimmickTable.cs b/Assets/Scripts/Runtime/Gimmicks/StageGimmickTable.cs new file mode 100644 index 00000000..e140a613 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/StageGimmickTable.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace TKS +{ + /// 스테이지 배치 표(Resources/Gimmicks/StageGimmickTable · 메뉴 TKS/Import Gimmicks) + 스테이지 시작 팁(tutorial_tips.csv · PD #1098 §3). + public class StageGimmickTable : ScriptableObject + { + [Serializable] + public class Tip + { + public string stage = ""; + public int chapter; + public int stageNo; + public string text = ""; + } + + public List rows = new List(); + public List tips = new List(); + + public List For(int chapter, int stageNo) + { + var res = new List(); + 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 ""; + } + } +} diff --git a/Assets/Scripts/Runtime/Gimmicks/StageGimmickTable.cs.meta b/Assets/Scripts/Runtime/Gimmicks/StageGimmickTable.cs.meta new file mode 100644 index 00000000..a2166c70 --- /dev/null +++ b/Assets/Scripts/Runtime/Gimmicks/StageGimmickTable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1f8e3e401b33e1d46bf1084189a5f061 \ No newline at end of file diff --git a/Assets/Scripts/Runtime/Skills/SkillMods.cs b/Assets/Scripts/Runtime/Skills/SkillMods.cs index 28d41f5a..b62482e2 100644 --- a/Assets/Scripts/Runtime/Skills/SkillMods.cs +++ b/Assets/Scripts/Runtime/Skills/SkillMods.cs @@ -181,11 +181,11 @@ namespace TKS return Mathf.Max(0.1f, u.SlingMaxDistance * (1f + pct) + add); } - /// 궁병·책사 사거리 = (기본 + range_add + 대도독 team_range_add) × range_mult. + /// 궁병·책사 사거리 = (기본 + range_add + 대도독 team_range_add + 고지 p1(지형 기믹 PD #1097)) × range_mult. public static float Range(Unit u, bool note = false) { if (u == null) return 0f; - float r = u.AttackRange + Sum(u, SkillKeys.RangeAdd, note) + Sum(u, SkillKeys.TeamRangeAdd, note); + float r = u.AttackRange + Sum(u, SkillKeys.RangeAdd, note) + Sum(u, SkillKeys.TeamRangeAdd, note) + GimmickLayer.RangeBonusOf(u); return Mathf.Max(0f, r * Product(u, SkillKeys.RangeMult, note)); } diff --git a/Assets/Scripts/Runtime/Skills/SkillSystem.cs b/Assets/Scripts/Runtime/Skills/SkillSystem.cs index b949d4c0..be96b0f4 100644 --- a/Assets/Scripts/Runtime/Skills/SkillSystem.cs +++ b/Assets/Scripts/Runtime/Skills/SkillSystem.cs @@ -190,8 +190,11 @@ namespace TKS return Attach(u, e.skillActive, e.activeLv, e.skillPassive, e.passiveLv, e.passiveMult, e.general); } - /// 적 무장(보스·엘리트) = heroes.csv 에 같은 아트·이름 영웅이 있으면 그 패시브만(Lv1 · 액티브 AI 사용은 2차 · PD 확인 대상). - public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e) + /// + /// 적 무장(보스·엘리트) = heroes.csv 에 같은 아트·이름 영웅이 있으면 그 패시브(Lv1) · withActive = 액티브도(적 AI 가 다음 행동형만 예약해 씀 · + /// 1부 보스만 = ai_weights.csv boss_active·active_max_chapter · PD #1098 확인 대상). + /// + public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e, bool withActive = false) { if (u == null || e == null || !(e.isBoss || e.isGeneral)) return null; var h = SkillCatalog.HeroForEnemy(e.art, e.name); @@ -201,7 +204,7 @@ namespace TKS s.IsGeneral = true; return s; } - return Attach(u, null, 1, h.skillPassive, 1, 1f, true); + return Attach(u, withActive ? h.skillActive : null, 1, h.skillPassive, 1, 1f, true); } public void MarkGeneral(Unit u) @@ -794,6 +797,7 @@ namespace TKS public static void Announce(Unit u, string name) { + if (Sim.Active) return; Fx.Burst(u.Position, u.Radius * 2.2f, FlashColor); Fx.Run(NameRoutine(u.Position + new Vector2(0f, u.ArtHeight > 0f ? u.ArtHeight * 0.6f : u.Radius + 0.6f), name)); } diff --git a/Assets/Scripts/Runtime/SlingshotInput.cs b/Assets/Scripts/Runtime/SlingshotInput.cs index 3053aa44..e3767299 100644 --- a/Assets/Scripts/Runtime/SlingshotInput.cs +++ b/Assets/Scripts/Runtime/SlingshotInput.cs @@ -296,6 +296,8 @@ namespace TKS /// 조준 표시(자동 플레이 캡처에서도 호출). 반환 = 공격 예정 적 수. public int ShowPreview(Unit u, Vector2 target, Vector2? pointer) { + Vector2 aim = target; + target = battle.Resolver.ReachPoint(u, target); // 늪·고지 = 실제 정지점(PD #1097) aimLine.enabled = true; aimLine.SetPosition(0, new Vector3(u.Position.x, u.Position.y, 0f)); aimLine.SetPosition(1, new Vector3(target.x, target.y, 0f)); @@ -318,13 +320,15 @@ namespace TKS targetRing.transform.localScale = finalHit != null ? new Vector3(0.9f, 0.9f, 1f) : new Vector3(0.7f, 0.7f, 1f); ClearHighlights(); - var shot = battle.Resolver.PreviewShot(u, target, moveOnly); + var shot = battle.Resolver.PreviewShot(u, aim, moveOnly); + var gl = battle.Gimmicks; + if (gl != null) gl.Preview.Show(u, aim, moveOnly, shot); // 예상 궤적(튕김·밀림·연쇄·반작용) · 기믹 반응 · 지대 띠(PD #1097 · #1098) if (shot != null) { return ShowShot(u, shot); } HideShot(); - foreach (var e in battle.Resolver.PredictTargets(u, target, moveOnly)) Highlight(e, false); + foreach (var e in battle.Resolver.PredictTargets(u, aim, moveOnly)) Highlight(e, false); return highlighted.Count; } @@ -351,7 +355,7 @@ namespace TKS if (!friendlyCenter) foes++; } - bool splash = u.Type == UnitType.Strategist && shot.Hit != null; + bool splash = u.Type == UnitType.Strategist && shot.HasSplash; // 덤불·수레에 막혀도 그 자리 광역(PD #1097) if (splash) { bool anyFriendly = friendlyCenter; @@ -362,8 +366,8 @@ namespace TKS if (friendly) anyFriendly = true; else foes++; } - float d = shot.SplashRadius * 2f; // 화공 = 반경 ×(PD #1093 B) - splashArea.transform.position = new Vector3(shot.Hit.Position.x, shot.Hit.Position.y, 0f); + float d = shot.SplashRadius * 2f; // 화공 = 반경 ×(PD #1093 B) · 안개 중심 = ×p1 + splashArea.transform.position = new Vector3(shot.SplashCenter.x, shot.SplashCenter.y, 0f); splashArea.transform.localScale = new Vector3(d, d, 1f); splashArea.color = anyFriendly ? SplashAreaFriendlyColor : SplashAreaColor; } @@ -391,6 +395,7 @@ namespace TKS targetRing.enabled = false; HideShot(); ClearHighlights(); + if (battle != null && battle.Gimmicks != null) battle.Gimmicks.Preview.Hide(); } void ClearHighlights() diff --git a/Assets/Scripts/Runtime/UnitView.cs b/Assets/Scripts/Runtime/UnitView.cs index 396c7522..010bc805 100644 --- a/Assets/Scripts/Runtime/UnitView.cs +++ b/Assets/Scripts/Runtime/UnitView.cs @@ -71,6 +71,7 @@ namespace TKS /// public void SetFacing(Vector2 dir) { + if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로 bool viewChanged = false; if (dir.y > FacingEps && !facingBack) { @@ -186,6 +187,7 @@ namespace TKS public void Refresh() { + if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로 if (unit == null || hpFill == null) return; float ratio = unit.MaxHp > 0 ? Mathf.Clamp01((float)unit.Hp / unit.MaxHp) : 0f; hpFill.transform.localScale = new Vector3(HpBarWidth * ratio, 0.12f, 1f); @@ -248,6 +250,7 @@ namespace TKS /// 조준(당기는) 중 = 출발 자세(ready · 1프레임) · 끝나면 대기 (PD 2026-11-05 #1084). public void SetAiming(bool on) { + if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로 if (anim == null || aiming == on) return; aiming = on; if (on) anim.Play(ClipName("ready"), true); @@ -257,6 +260,7 @@ namespace TKS /// 슬링샷 돌진(이동) 중 = 병종 돌진 포즈(action · 1프레임 고정) · 멈추면 대기 (PD 2026-11-05 #1084). public void SetCharging(bool on) { + if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로 if (anim == null || charging == on) return; charging = on; aiming = false; @@ -272,6 +276,7 @@ namespace TKS /// 공격 자세(1 프레임) 잠깐 → 대기. 돌진 중이면 이미 돌진 포즈라 그대로 둔다. public void PlayAttack() { + if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로 if (anim == null || !isActiveAndEnabled || charging) return; aiming = false; anim.Play(ClipName("action"), false, AttackHold, true); @@ -280,6 +285,7 @@ namespace TKS /// 승리 동작(앞모습 · 끝 프레임 유지). public void PlayVictory() { + if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로 if (anim == null || !isActiveAndEnabled || !unit.IsAlive) return; aiming = false; anim.Play(ClipName("victory"), false); @@ -287,6 +293,7 @@ namespace TKS public void OnHit() { + if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로 if (!isActiveAndEnabled) return; if (flash != null) StopCoroutine(flash); flash = StartCoroutine(FlashRoutine()); @@ -315,6 +322,7 @@ namespace TKS public void PlayDeath() { + if (Sim.Active) return; // 가상 실행(적 AI 예측) = 표시 그대로 if (!isActiveAndEnabled) return; StartCoroutine(DeathRoutine()); } diff --git a/Assets/Tests/PlayMode/BattleFlowTests.cs b/Assets/Tests/PlayMode/BattleFlowTests.cs index e76f41d7..4539cfcb 100644 --- a/Assets/Tests/PlayMode/BattleFlowTests.cs +++ b/Assets/Tests/PlayMode/BattleFlowTests.cs @@ -586,9 +586,10 @@ namespace TKS.Tests yield return bm.ResolveForTest(spear, foe.Position, false, o); Assert.Contains(foe, o.Attacked); Assert.Less(foe.Hp, foe.MaxHp, "부딪힌 적에게 피해"); - Assert.Contains(foe, o.Pushed, "튕기지 않고 밀며 함께 전진"); + Assert.Contains(foe, o.Pushed, "부딪힌 상대를 밀친다"); Assert.Greater(foe.Position.y, 0.5f, "적이 남은 거리만큼 앞으로 밀림"); - Assert.AreEqual(CastContact(spear, foe), foe.Position.y - spear.Position.y, 0.1f, "창병은 접촉 간격을 유지하며 따라감"); + float remaining = CastContact(spear, foe); // 당긴 3 − 접촉까지(3 − 접촉 거리) + Assert.AreEqual(-CastContact(spear, foe) - remaining * cfg.spearRecoilRatio, spear.Position.y, 0.1f, "창병 = 밀친 거리 × spearRecoilRatio 만큼 반대로(PD #1098 · 함께 전진 폐기)"); Assert.IsTrue(o.Overwatch && spear.Overwatch, "최종 목표점 명중 → 대기(견제)"); int hp = foe.Hp; @@ -615,9 +616,11 @@ namespace TKS.Tests yield return bm.ResolveForTest(spear, new Vector2(0f, 0f), false, o); Assert.AreEqual(0, o.Attacked.Count, "아군에 부딪힌 창병 자신은 공격하지 않음"); Assert.Contains(ally, o.Pushed); - Assert.Greater(ally.Position.y, 0.4f, "아군을 밀며 함께 전진(적에 닿을 때까지)"); + Assert.Greater(ally.Position.y, 0.4f, "아군을 밀쳐 적진으로(적에 닿을 때까지)"); Assert.LessOrEqual(Gap(ally, foe), Combat.ContactTolerance + 0.05f, "밀린 아군이 적과 닿음"); - Assert.AreEqual(CastContact(spear, ally), ally.Position.y - spear.Position.y, 0.1f, "창병은 아군 바로 뒤까지 따라옴"); + float contactY = -2f - CastContact(spear, ally); + float push = 4f - (contactY + 4f); // 남은 거리 = 당긴 4 − 접촉까지 + Assert.AreEqual(contactY - push * cfg.spearRecoilRatio, spear.Position.y, 0.1f, "창병 반작용(PD #1098) = 밀친 거리 × 비율만큼 뒤로"); Assert.Less(foe.Hp, foe.MaxHp, "밀린 아군이 적을 공격(규칙 ⑤)"); Assert.AreEqual(spear.MaxHp, spear.Hp, "창병은 피해 없음"); Assert.AreEqual(1, o.Notes.Count); diff --git a/Assets/Tests/PlayMode/GimmickTests.cs b/Assets/Tests/PlayMode/GimmickTests.cs new file mode 100644 index 00000000..5a42f5be --- /dev/null +++ b/Assets/Tests/PlayMode/GimmickTests.cs @@ -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 +{ + /// + /// 지형 기믹 레이어(PD #1097 · 지형기믹_기획서) — 기믹 16 × 핵심 병종 조합 · 샘플 5 스테이지 로드 · 임포트 검증 · 조준 미리보기 · + /// 병종 규칙 개정 2건(보병 연쇄 · 창병 반작용 · PD #1098). 수치 = 임포트 자산(GimmickDb · BalanceConfig)에서 읽는다(C45). + /// + 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(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"); + 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(); + 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, "명중 = 견제 유지"); + } + } +} diff --git a/Assets/Tests/PlayMode/GimmickTests.cs.meta b/Assets/Tests/PlayMode/GimmickTests.cs.meta new file mode 100644 index 00000000..b4caf238 --- /dev/null +++ b/Assets/Tests/PlayMode/GimmickTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0e186203f94130a489d5cc674599a19c \ No newline at end of file diff --git a/Assets/Tests/PlayMode/SkillTests.cs b/Assets/Tests/PlayMode/SkillTests.cs index c1f5a9e6..dc166ef2 100644 --- a/Assets/Tests/PlayMode/SkillTests.cs +++ b/Assets/Tests/PlayMode/SkillTests.cs @@ -1004,8 +1004,10 @@ namespace TKS.Tests var es = bm.Skills.Of(x); if (es == null || es.Passive == null) continue; withPassive++; - Assert.IsNull(es.Active, "적 = 액티브 없음(AI 사용은 2차)"); - Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(x)); + // PD #1098 확정: 적 액티브 = 1부(1~10장) 보스만(적 AI 가 다음 행동형을 예약해 씀) · 엘리트 무장 = 패시브만 + if (x.IsBoss && TacticalAI.BossUsesActive(bm.Stage, new BalanceConfig.EnemySpawn { isBoss = true })) Assert.IsNotNull(es.Active, "1부 보스 = 액티브도"); + else Assert.IsNull(es.Active, "엘리트 무장 = 액티브 없음"); + Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(x), "적 액티브는 HUD 버튼 상태 없음(AI 전용)"); Assert.IsTrue(es.IsGeneral); } Assert.Greater(withPassive, 0, "적 무장 패시브 부착"); diff --git a/Assets/Tests/PlayMode/TacticalAITests.cs b/Assets/Tests/PlayMode/TacticalAITests.cs new file mode 100644 index 00000000..87a014c4 --- /dev/null +++ b/Assets/Tests/PlayMode/TacticalAITests.cs @@ -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 +{ + /// + /// 적 AI 예측 시뮬레이션(PD #1098 · 전술규칙_적AI_기획서 §2) — 전술 항목이 점수에 반영되는지(함정 쪽 튕기기 · 창병 아군 밀어 전진 · 기병 일렬 휩쓸기 · + /// 궁병 목책 엄폐 · 책사 덤불 제거) · 가상 실행이 상태를 되돌리는지 · 성능(적 10기 턴 결정 ≤ 1.5 초). 가중치 = ai_weights.csv(AiWeights 자산). + /// + 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 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(); + 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 }), "보스만"); + } + } +} diff --git a/Assets/Tests/PlayMode/TacticalAITests.cs.meta b/Assets/Tests/PlayMode/TacticalAITests.cs.meta new file mode 100644 index 00000000..7811687e --- /dev/null +++ b/Assets/Tests/PlayMode/TacticalAITests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 41a7f217aa8155648a181188a8c323f2 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 61b05fdb..3356f7b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,8 @@ Unity.exe -projectPath -executeMethod TKS.EditorTools.ProjectSetup.AutoPl - **스킬(2026-11-05 #1093 B)** — SOT = `Assets/Data/OutGame/skills.csv`(effect_key `;` 복수 · p1~p3 · lv_scale · needs_impl) + `skill_levels.csv` → 임포트 자산 `SkillInfoDb`(effect 열 포함 · 표시 + 전투 공용). 처리기 = `Runtime/Skills/`: `SkillKeys`(51종 · 레벨 계수 방식 · 팀 키 · 표에 없는 키 = 임포트 오류) · `SkillSystem`(유닛별 쿨·예약 · 즉시형 시한 효과 · 행동 훅 · 호위 피해 분담 · HUD 한 줄) · `SkillMods`(전투 코드가 수치 질의) · `SkillRules`(상한 = 등급 + ★3 · ★5 패시브 배율 = evolve.csv skill_unlock · 레벨업 · 스킬서 판매). 전투 코드에 스킬 수치를 직접 쓰지 않고 `SkillMods` 로 읽는다. 테스트 = `Tests/PlayMode/SkillTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureSkills -skillDir `. - **아웃게임 2차(2026-11-05~06 #1093 C)** — 표 10장 추가(`training` · `training_ranks` · `patrol` · `dev_tree` · `treasures` · `treasure_sets` · `chests`(+ iron/buy 열) · `tower` · `dungeons`(+ reward_amount) · `arena`(+ 상점 키)) + `economy.csv` C 키 24 → 임포트 자산 18(A 9 + C 9 · `OutGameTables.C.cs` 검증). 코드 = `OutGame/Domestic/`(`TrainingRules` 훈련 · `PatrolRules` 순찰 · `DevRules` 개발 효과 키 → 적용 지점 · `DomesticScreen`) · `OutGame/Treasure/`(`TreasureRules` 보유·세트·강화·조건 카운터·상자 · `TreasureScreen`) · `OutGame/Arena/`(`ArenaRules` 탑·던전·투기장·적장 · `ArenaScreen`). 스탯 = §3-2 × (1 + 훈련(병종·항목)) × (1 + 보물(항목)) = `HeroRules.Compute(…, save)` · 보물 crit/range/splash = `BattleManager.SideEffectsProvider` → `SkillMods.Get` 진영 효과(`side_crit_pct` · `range_mult` · `splash_radius_mult`) · 조건 효과 카운터 = `Runtime/BattleEvents.Raise`(전투 코드 1줄씩 · 아군만) · 연무장 전투 = `BattleManager.StartMode(BattleMode)`(적 편성 · 제한 턴 · 결과 탭 = `onExit`) → `OutGameSession.OnModeEnd`. 테스트 = `Tests/PlayMode/OutGameCTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureC -cDir `(사본 · 10장). - **영웅 56명(PD #1096 · 2026-11-06)** — heroes 71행(영웅 56 + 병사 15) · skills 112 · hero_gear 1344 · 신규 effect_key 4(`team_dmg_pct_turn` · `cooldown_add` · `charm` · `heal_self_pct`) + 기존 키 조건 열(받는 피해 p2 = HP 임계/2 보스/3 근접 · p3 = 출전 인원 아군 전원 · 최대 HP·반격 아군 전원 · 피해 % 발동 턴) = `SkillMods`·`SkillSystem` · 대장군 `pushed_ally_attack_on_push` 는 표에서 미사용(처리기 유지). +- **지형 기믹 레이어(PD #1097 · 2026-11-06 세션 D)** — SOT = `Assets/Data/Gimmicks/{gimmicks,stage_gimmicks,tutorial_tips}.csv`(기획 `tables/` 사본 · utf-8-sig) → 메뉴 `TKS/Import Gimmicks (기믹 임포트)`(`Editor/GimmickImporter` · 오류 시 자산 불변 · `ai_weights.csv` 도 함께) → `Resources/Gimmicks/GimmickDb`·`StageGimmickTable` · `Resources/AI/AiWeights`. 전투 시작 = `GimmickLayer.Build(stage)`(장-번호 배치 · 배경 위 · 유닛 아래 · 장애물 = 콜라이더 + `GimmickBody` · 지대 = 모양 스윕 · 강 콜라이더 = 다리 구간을 뺀 조각 · 겹친 유닛은 빈 자리로). 전투 코드는 질의만: `Combat.FreeTravel`(장애물 = 벽 · 숨은 함정 정지) · `FirstUnitOnArrow`(pass/block/destroy · 안개 제외) · `Combat.Damage`(군기 공 · 고지 근접 피격) · `SkillMods.Range`(고지 사거리) · `BattleManager` 턴 시작 `TurnStart`(군기·중독·불·행동 불가). 충돌 반응·폭발·굴림·미끄러짐·지대 = `Gimmicks/ActionResolver.Gimmicks.cs`(partial). 배치 도구 = `TKS/Gimmick Painter (기믹 배치)`. 테스트 = `Tests/PlayMode/GimmickTests` · 캡처 = `-executeMethod TKS.EditorTools.GimmickImporter.Capture -gimmickDir `(사본). +- **병종 규칙 개정 + 예측 AI(PD #1098 · 2026-11-06)** — 보병 = 적과 싸운 뒤 튕겨나는 길의 다른 적과 연쇄 교환 1회(`ActionOutcome.Chains`) · 창병 = 상대만 밀리고 자신은 × `spearRecoilRatio` 반대로(밀며 전진 폐기). 적 턴 = `Runtime/AI/TacticalAI`(후보 방위 × 파워 3 × 액티브 → `Sim` 가상 실행(연출 없음 · 분산 0 · 상태 복원) → `ai_weights.csv` 점수 · 서포터 → 돌격 → 사격 · 1기마다 재평가) · 가중치 자산이 없으면 `EnemyAI.Decide`(기존 조준) · 적 액티브 = 1부 보스만(`boss_active`). 가상 실행 중(`Sim.Active`)에는 `Fx`·`UnitView`·`BattleEvents`·HUD 갱신이 꺼진다 — 새 연출 코드도 `Sim.Active` 면 건너뛸 것. 조준 미리보기도 같은 가상 실행(`GimmickPreview` · 숨은 함정 모름). 테스트 = `Tests/PlayMode/TacticalAITests`. - 아웃게임 코드 = `Assets/Scripts/OutGame/**`(asmref → TKS.Runtime) — `OutGameSession`(표 + 세이브 · 바꾸는 동작은 즉시 저장) · 규칙 `HeroRules`·`EconomyRules`·`GachaRules` · 화면 `UI/`(로비·영웅·편성·거래·결과 패널 · `OutGameRoot` 가 전환). 세이브 = `persistentDataPath/save.json`(테스트·검증 캡처는 `SaveStore.UseMemory` / SessionState `TKS_SAVE_MEMORY` 로 메모리 전용). 출전 = `BattleManager.LineupProvider`(편성 프리셋 → `playerSpawnPoints` 자리 0~2 전열 · 3~4 후열). 화면 캡처 = `Unity.exe -projectPath <사본> -executeMethod TKS.EditorTools.DataImporter.CaptureOutGame -outgameDir `. - **챕터·스테이지 SOT = `Assets/Data/Stages/*.csv`(utf-8-sig · 2026-11-05 145 스테이지 · 기획서 #1086 §4 전사)** — `stages.csv`(chapter·stage·title·type normal|elite|boss·place·faction·병종 4 수·generals `id:병종:true|false`·bg_theme·note) · `chapters.csv`(장 제목·연도·**월드맵 맵 id**·AI 출발값) · `generals.csv`(무장 id → 이름·배율 덮어쓰기) · `factions.csv`(세력 코드 → 이름·병사 아트 접두). 고친 뒤 메뉴 `TKS/Import Stages (스테이지 임포트)`(`StageImporter` · 형식 오류면 자산 미변경) → `BalanceConfig.chapters·stages`. 적 좌표 = `BalanceConfig.formation` 규칙(전위·중군·후군·장수 줄 · `StageTable.Enemies`) · 장수 배율 = `generals.csv` 가 비면 `boss/eliteGeneral*Mult`. 월드맵 노드 = 스테이지 표(챕터별 수 · 보스 = type boss) · 노드 자리 = `ChapterMap/Data/<맵>.json` `slots_N`(없으면 길 위 균등). - 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 22종 이상 전부 통과 확인. @@ -74,5 +76,7 @@ Unity.exe -projectPath -executeMethod TKS.EditorTools.ProjectSetup.AutoPl - `Assets/Data/Stages/` — 챕터·스테이지 표 CSV 4장(위 「코드 규칙」) · 해석·배치 = `Runtime/StageTable` - `Assets/Resources/Units//` — 유닛 아트 프레임 + meta.json(위 절) - `Assets/Scripts/Runtime/Skills/` — 스킬 처리기(위 「코드 규칙」 스킬) -- `Assets/Tests/PlayMode/` — `BattleFlowTests` · `OutGameTests` · `SkillTests` · `OutGameCTests` +- `Assets/Scripts/Runtime/Gimmicks/` — 지형 기믹 레이어(위 「코드 규칙」) · `Assets/Scripts/Runtime/AI/` — 예측 시뮬레이션 적 AI(`Sim` · `TacticalAI` · `AiWeights`) +- `Assets/Data/Gimmicks/` · `Assets/Data/AI/` — 기믹 표 3장 · AI 가중치 표 +- `Assets/Tests/PlayMode/` — `BattleFlowTests` · `OutGameTests` · `SkillTests` · `OutGameCTests` · `GimmickTests` · `TacticalAITests` - `.claude/skills/` — Unity CLI 에이전트 스킬(`unity skill install claude-code --local` 산출물 · 재설치는 `unity skill refresh`)