스킬 시스템(PD #1093 세션 B) — skills.csv effect_key 51종 처리기 · 액티브(예약/즉시)·패시브 · HUD 스킬 버튼 · 적 무장 패시브 · 스킬 레벨업·스킬서

- 데이터: SkillInfoDb(= 스킬 DB) 에 effect_key·p1~p3(pMask)·lv_scale·needs_impl 추가 · 임포터 검증(trigger · 키 51종 · p 칸 · skill_levels 연속 · lv_max) · skill_levels = levels(비용 표)
- 처리기 Runtime/Skills: SkillKeys(51종 · 레벨 계수 = 값×계수 / 배율은 1 넘는 몫 / 횟수 반올림 / 켜기·치환 고정 · 팀 키) · SkillDef·SkillCatalog · SkillSystem(유닛별 쿨·예약 · 즉시형 시한 효과 이번 아군 턴/이번 적 턴 · 행동 훅 · 호위 분담 · HUD 한 줄) · SkillMods(전투 코드 수치 질의) · SkillRules(상한 = 등급 + ★3 액티브 +1 · ★5 패시브 ×1.5 = evolve.csv · 레벨업 · 스킬서 판매)
- 전투 연결: ActionResolver(밀기·튕김·추가 이동 n·추가 이동 공격·충돌 추가 피해·밀며 전진 ×·삼첨도·관통 피해/넉백/반격 없음/빗나가도 복귀/한 번 더 관통·사거리·사선 통과·크리·광역 반경/비율/아군 0·백기습영 이동 후 사격·군령/대장군 공격 횟수) · Combat(상성 하한·공 %·주는/받는 피해·근접 패널티 해제·반격 배율/받는 반격·견제 배율) · BattleManager(편성·적 무장 부착 · 견제 반경/횟수/무제한/회복 · 교전 반응 정지 · 교전 범위 · 즉시형 실행) · EnemyAI·RangeIndicator(슬링·사거리 스킬 반영)
- UI: HUD 스킬 버튼(이름·Lv·쿨·사용 가능/불가·패시브) · 탭 = 예약/취소/즉시 · 조준 중 2손가락 탭 · 발동 = 이름 글자 + 플래시 · 스킬 한 줄 · 예약 배지 · 변진 반경 원 · 영웅 상세 스킬 레벨업 팝업 · 거래 상점 스킬서 칸(임시)
- 표: economy.csv start_skill_books 10 · skill_book_shop_daily 3 · skill_book_price 300(임시 · PD 확인)
- 테스트 73/73(기존 56 + SkillTests 17 · 51종 전부) · console error 0 · 캡처 = DataImporter.CaptureSkills

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-11-05 23:31:27 +09:00
parent d32334786d
commit 85aed38ce5
36 changed files with 4147 additions and 166 deletions

View File

@ -30,3 +30,6 @@ shop_slots,6
shop_grades,white;green
shop_price_mult,5
shop_refresh_gem,20
start_skill_books,10
skill_book_shop_daily,3
skill_book_price,300

1 key value
30 shop_grades white;green
31 shop_price_mult 5
32 shop_refresh_gem 20
33 start_skill_books 10
34 skill_book_shop_daily 3
35 skill_book_price 300

View File

@ -76,3 +76,9 @@ MonoBehaviour:
value: 5
- key: shop_refresh_gem
value: 20
- key: start_skill_books
value: 10
- key: skill_book_shop_daily
value: 3
- key: skill_book_price
value: 300

View File

@ -25,6 +25,13 @@ MonoBehaviour:
\uD295\uAE30\uC9C0 \uC54A\uACE0(\uC790\uAE30 \uD295\uAE40 0) \uCD94\uAC00 \uC774\uB3D9\uC774
2\uD68C [p1 \uCD94\uAC00 \uC774\uB3D9 \uD69F\uC218 \xB7 p2 \uC790\uAE30 \uD295\uAE40
\uBC30\uC728]"
effectKey: extra_move_count;self_bounce_mult
p1: 2
p2: 0
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: liubei_p
heroId: liubei
kind: passive
@ -35,6 +42,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uC544\uAD70\uC774 \uBC00\uB824\uC11C \uD558\uB294 \uACF5\uACA9 \uD53C\uD574
+50 %"
effectKey: pushed_ally_attack_pct
p1: 0.5
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: guanyu_a
heroId: guanyu
kind: active
@ -45,6 +59,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uBA85\uC911 \uC2DC \uACAC\uC81C \uBC18\uACBD
\xD72 \xB7 \uADF8 \uC801 \uD134 \uB3D9\uC548 \uACAC\uC81C \uD69F\uC218 \uBB34\uC81C\uD55C"
effectKey: overwatch_radius_mult;overwatch_unlimited
p1: 2
p2: 1
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: guanyu_p
heroId: guanyu
kind: passive
@ -55,6 +76,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uBC18\uACA9 \uD53C\uD574 0.5 \u2192 1.0 \uBC30 [p1 \uBC18\uACA9 \uBC30\uC728(\uAE30\uBCF8
0.5)]"
effectKey: counter_ratio
p1: 1
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: zhangfei_a
heroId: zhangfei
kind: active
@ -67,6 +95,13 @@ MonoBehaviour:
\uBC14\uAE65\uC73C\uB85C 0.8 \uBC00\uACE0 \xB7 \uC774\uBC88 \uC801 \uD134 \uADF8
\uC801\uB4E4\uC740 \uAD50\uC804 \uBC18\uC751 \uC5C6\uC74C [p1 \uBC00\uAE30
\uAC70\uB9AC \xB7 p2 \uBC18\uACBD]"
effectKey: aoe_push_fear
p1: 0.8
p2: 4
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 1
- id: zhangfei_p
heroId: zhangfei
kind: passive
@ -77,6 +112,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uAD00\uD1B5\uC73C\uB85C 2\uAE30 \uC774\uC0C1 \uB9DE\uD788\uBA74 \uD53C\uD574
+30 % [p2 \uCD5C\uC18C \uAD00\uD1B5 \uC218 \xB7 \uD0A4 \uADFC\uC0AC]"
effectKey: pierce_dmg_mult
p1: 1.3
p2: 2
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: zhaoyun_a
heroId: zhaoyun
kind: active
@ -87,6 +129,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uAD00\uD1B5 \uC911 \uBC18\uACA9\uC744 \uBC1B\uC9C0
\uC54A\uACE0 \xB7 \uBE57\uB098\uAC00\uB3C4 \uCD9C\uBC1C\uC810 \uBCF5\uADC0"
effectKey: pierce_no_counter;pierce_return_on_miss
p1: 1
p2: 1
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: zhaoyun_p
heroId: zhaoyun
kind: passive
@ -97,6 +146,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uAD00\uD1B5 \uD53C\uD574\uB97C \uC900 \uC801 \uBC00\uAE30 0.35 \u2192
0.7 [0.35\u21920.7 = \xD72]"
effectKey: pierce_knockback_mult
p1: 2
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: huangzhong_a
heroId: huangzhong
kind: active
@ -107,6 +163,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uD654\uC0B4\uC774 \uC544\uAD70\uC744 \uD1B5\uACFC(\uC0AC\uC120)\uD558\uACE0
\uBA85\uC911 \uC5EC\uBD80\uC640 \uBB34\uAD00\uD558\uAC8C \uD06C\uB9AC"
effectKey: arrow_pass_allies;arrow_crit_always
p1: 1
p2: 1
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 1
- id: huangzhong_p
heroId: huangzhong
kind: passive
@ -117,6 +180,13 @@ MonoBehaviour:
lvMax: 5
descKo: "HP 50 % \uC774\uD558\uC77C \uB54C \uACF5 +40 % [p1 \uACF5 \uC99D\uAC00
\xB7 p2 HP \uC784\uACC4]"
effectKey: dmg_pct_hp_below
p1: 0.4
p2: 0.5
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: zhugeliang_a
heroId: zhugeliang
kind: active
@ -128,6 +198,13 @@ MonoBehaviour:
descKo: "\uC989\uC2DC: \uC774\uBC88 \uD134 \uC544\uAD70 \uC804\uC6D0 \uC2AC\uB9C1
\uAC70\uB9AC +30 % [p1 \uBE44\uC728 \xB7 p2 \uC808\uB300 \uAC00\uC0B0 \xB7
p3 1=\uC544\uAD70 \uC804\uC6D0]"
effectKey: sling_dist_pct
p1: 0.3
p2: 0
p3: 1
pMask: 7
lvScale: 0.1
needsImpl: 0
- id: zhugeliang_p
heroId: zhugeliang
kind: passive
@ -138,6 +215,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uC801 \uAD50\uC804 \uBC94\uC704 \u22121.5(\uC801\uC774 \uB354 \uB2A6\uAC8C
\uC6C0\uC9C1\uC778\uB2E4)"
effectKey: enemy_aggro_add
p1: -1.5
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: zhoucang_a
heroId: zhoucang
kind: active
@ -147,6 +231,13 @@ MonoBehaviour:
cooldown: 3
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uC54C\uAE4C\uAE30 \uBC00\uAE30 \xD72"
effectKey: push_mult
p1: 2
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: zhoucang_p
heroId: zhoucang
kind: passive
@ -156,6 +247,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uCD5C\uB300 HP +20 %"
effectKey: hp_max_pct
p1: 0.2
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: guanping_a
heroId: guanping
kind: active
@ -168,6 +266,13 @@ MonoBehaviour:
\uC544\uAD70\uC774 \uBC1B\uB294 \uD53C\uD574\uC758 50 % \uB97C \uAD00\uD3C9\uC774
\uB300\uC2E0 \uBC1B\uC74C [p1 \uB300\uC2E0 \uBC1B\uB294 \uBE44\uC728 \xB7 p2
\uBC18\uACBD]"
effectKey: guard_share
p1: 0.5
p2: 1.5
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 1
- id: guanping_p
heroId: guanping
kind: passive
@ -177,6 +282,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uACAC\uC81C \uBC18\uACBD +0.5"
effectKey: overwatch_radius_add
p1: 0.5
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: xushu_a
heroId: xushu
kind: active
@ -187,6 +299,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uAD11\uC5ED \uD53C\uD574\uAC00 \uC544\uAD70\uC5D0\uAC8C
0(\uAD11\uC5ED)"
effectKey: splash_ally_ratio
p1: 0
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 1
- id: xushu_p
heroId: xushu
kind: passive
@ -196,6 +315,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uC0AC\uAC70\uB9AC +0.5"
effectKey: range_add
p1: 0.5
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: taishici_a
heroId: taishici
kind: active
@ -205,6 +331,13 @@ MonoBehaviour:
cooldown: 3
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uC0AC\uAC70\uB9AC \xD71.5"
effectKey: range_mult
p1: 1.5
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: taishici_p
heroId: taishici
kind: passive
@ -215,6 +348,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uAD81\uC218 \uADFC\uC811 \uD328\uB110\uD2F0 \uC5C6\uC74C(\uD53C\uD574
100 % \xB7 \uD53C\uACA9 100 %)"
effectKey: melee_penalty_off
p1: 1
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: caocao_a
heroId: caocao
kind: active
@ -225,6 +365,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uC989\uC2DC: \uC774\uBC88 \uC801 \uD134 \uC544\uAD70 \uC804\uC6D0 \uBC1B\uB294
\uD53C\uD574 \u221230 %"
effectKey: team_dmg_taken_pct_turn
p1: -0.3
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: caocao_p
heroId: caocao
kind: passive
@ -235,6 +382,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uCD9C\uC804 5\uC778\uC77C \uB54C \uC544\uAD70 \uC804\uC6D0 \uACF5 +10
% [p2 \uCD9C\uC804 \uC778\uC6D0 \uC870\uAC74]"
effectKey: team_atk_pct_full
p1: 0.1
p2: 5
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: xiahoudun_a
heroId: xiahoudun
kind: active
@ -245,6 +399,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uAD00\uD1B5 \uD53C\uD574 +50 % \xB7 \uBC18\uACA9
\uBC1B\uC9C0 \uC54A\uC74C"
effectKey: pierce_dmg_mult;pierce_no_counter
p1: 1.5
p2: 1
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: xiahoudun_p
heroId: xiahoudun
kind: passive
@ -255,6 +416,13 @@ MonoBehaviour:
lvMax: 5
descKo: "HP 30 % \uC774\uD558\uC77C \uB54C \uACF5 +50 % [p1 \uACF5 \uC99D\uAC00
\xB7 p2 HP \uC784\uACC4]"
effectKey: dmg_pct_hp_below
p1: 0.5
p2: 0.3
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: xuchu_a
heroId: xuchu
kind: active
@ -265,6 +433,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uC790\uAE30 \uD295\uAE40 0 \xB7 \uBC00\uAE30
\xD71.5 \xB7 \uCD94\uAC00 \uC774\uB3D9\uC5D0\uC11C \uACF5\uACA9 \uAC00\uB2A5"
effectKey: self_bounce_mult;push_mult;extra_move_attack
p1: 0
p2: 1.5
p3: 1
pMask: 7
lvScale: 0.1
needsImpl: 0
- id: xuchu_p
heroId: xuchu
kind: passive
@ -274,6 +449,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uBC00\uB9AC\uB294 \uAC70\uB9AC \u221250 %"
effectKey: knockback_taken_pct
p1: -0.5
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: zhangliao_a
heroId: zhangliao
kind: active
@ -285,6 +467,13 @@ MonoBehaviour:
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uBA85\uC911 \uC2DC \uBCF5\uADC0 \uB300\uC2E0
\uBAA9\uD45C\uC810\uC5D0\uC11C \uAC19\uC740 \uBC29\uD5A5\uC73C\uB85C \uD55C
\uBC88 \uB354 \uAD00\uD1B5 [p1 \uCD94\uAC00 \uAD00\uD1B5 \uD69F\uC218]"
effectKey: pierce_repeat
p1: 1
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: zhangliao_p
heroId: zhangliao
kind: passive
@ -295,6 +484,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uAD00\uD1B5\uD55C \uC801 1\uAE30\uB2F9 \uACF5 +5 %(\uCD5C\uB300 +25
% \xB7 \uC804\uD22C \uC911 \uB204\uC801) [p1 1\uAE30\uB2F9 \xB7 p2 \uCD5C\uB300]"
effectKey: pierce_dmg_pct_per_hit
p1: 0.05
p2: 0.25
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: xuhuang_a
heroId: xuhuang
kind: active
@ -305,6 +501,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uC0C1\uC131 \uBD88\uB9AC(\uAE30\uBCD1 0.7)\uB97C
1.0 \uC73C\uB85C"
effectKey: matchup_floor
p1: 1
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: xuhuang_p
heroId: xuhuang
kind: passive
@ -314,6 +517,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uBC1B\uB294 \uBC18\uACA9 \uD53C\uD574 \u221230 %"
effectKey: counter_taken_pct
p1: -0.3
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: zhanghe_a
heroId: zhanghe
kind: active
@ -324,6 +534,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uC989\uC2DC \u2605: \uBC18\uACBD 3 \uC548 \uBE48 \uC9C0\uC810\uC73C\uB85C
\uC774\uB3D9(\uD589\uB3D9 \uC18C\uBAA8 \uC5C6\uC74C) [p1 \uBC18\uACBD]"
effectKey: teleport
p1: 3
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 1
- id: zhanghe_p
heroId: zhanghe
kind: passive
@ -333,6 +550,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uACAC\uC81C \uD53C\uD574 \xD71.5"
effectKey: overwatch_dmg_mult
p1: 1.5
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: caoren_a
heroId: caoren
kind: active
@ -344,6 +568,13 @@ MonoBehaviour:
descKo: "\uC989\uC2DC \u2605: \uC774\uBC88 \uC801 \uD134 \uBC18\uACBD 2 \uC548
\uC544\uAD70 \uC804\uC6D0 \uBC1B\uB294 \uD53C\uD574 \u221240 % [p1 \uBC1B\uB294
\uD53C\uD574 \xB7 p2 \uBC18\uACBD]"
effectKey: dmg_taken_aura
p1: -0.4
p2: 2
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 1
- id: caoren_p
heroId: caoren
kind: passive
@ -354,6 +585,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uBC29 +50 % [\uBC29\uC5B4 \uD0A4 \uC5C6\uC74C \xB7 \uD0A4 \uADFC\uC0AC
= \uBC29 \xD71.5 \u2192 \uBC1B\uB294 \uD53C\uD574 \xF71.5 \uD658\uC0B0(\uCD94\uC815)]"
effectKey: dmg_taken_pct
p1: -0.33
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: yujin_a
heroId: yujin
kind: active
@ -364,6 +602,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uC54C\uAE4C\uAE30\uB85C \uBC00\uB9B0 \uC544\uAD70\uC758
\uACF5\uACA9 2\uD68C"
effectKey: pushed_ally_attack_count
p1: 2
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: yujin_p
heroId: yujin
kind: passive
@ -374,6 +619,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uBC00\uB838\uC744 \uB54C \uBC18\uACA9 1.0 \uBC30 [p2 1=\uBC00\uB838\uC744
\uB54C\uB9CC \xB7 \uD0A4 \uADFC\uC0AC]"
effectKey: counter_ratio
p1: 1
p2: 1
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: sunjian_a
heroId: sunjian
kind: active
@ -384,6 +636,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uC54C\uAE4C\uAE30 \uD53C\uD574 +50 % \xB7
\uCD94\uAC00 \uC774\uB3D9\uC5D0\uC11C \uACF5\uACA9 \uAC00\uB2A5"
effectKey: dmg_pct;extra_move_attack
p1: 0.5
p2: 1
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: sunjian_p
heroId: sunjian
kind: passive
@ -394,6 +653,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uBCF4\uC2A4\uC5D0\uAC8C \uC8FC\uB294 \uD53C\uD574 +20 % [p2 1=\uBCF4\uC2A4\uB9CC(0=\uBB34\uC7A5
\uC804\uCCB4) \xB7 \uD0A4 \uADFC\uC0AC]"
effectKey: dmg_vs_general_mult
p1: 1.2
p2: 1
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: zhouyu_a
heroId: zhouyu
kind: active
@ -404,6 +670,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uAD11\uC5ED \uBC18\uACBD \xD72 \xB7 \uAD11\uC5ED
\uBE44\uC728 1.0(\uAD11\uC5ED)"
effectKey: splash_radius_mult;splash_ratio
p1: 2
p2: 1
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 1
- id: zhouyu_p
heroId: zhouyu
kind: passive
@ -414,6 +687,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uC544\uAD70 \uAD81\uBCD1\xB7\uCC45\uC0AC \uC0AC\uAC70\uB9AC +0.5 [\uB300\uC0C1
= \uAD81\uBCD1\xB7\uCC45\uC0AC]"
effectKey: team_range_add
p1: 0.5
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: huanggai_a
heroId: huanggai
kind: active
@ -424,6 +704,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uC989\uC2DC \u2605: \uC790\uAE30 HP 30 % \uC18C\uBAA8 \xB7 \uC774\uBC88
\uD134 \uACF5 +60 % [p1 \uACF5 \uC99D\uAC00 \xB7 p2 HP \uC18C\uBAA8]"
effectKey: hp_cost_atk
p1: 0.6
p2: 0.3
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 1
- id: huanggai_p
heroId: huanggai
kind: passive
@ -433,6 +720,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uBC1B\uB294 \uD53C\uD574 \u221215 %"
effectKey: dmg_taken_pct
p1: -0.15
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: ganning_a
heroId: ganning
kind: active
@ -446,6 +740,13 @@ MonoBehaviour:
\uC2DC \uD06C\uB9AC [\uC804\uC6A9 \uD0A4 \uC5C6\uC74C \xB7 \uD0A4 \uADFC\uC0AC
= \uC774\uB3D9(p1 0=\uBAA9\uD45C\uC810 \xB7 \uBC18\uACBD \uC81C\uD55C \uC5C6\uC74C)
\uD6C4 \uC0AC\uACA9 + \uD06C\uB9AC]"
effectKey: teleport;arrow_crit_always
p1: 0
p2: 1
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 1
- id: ganning_p
heroId: ganning
kind: passive
@ -456,6 +757,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uADFC\uC811 \uBC18\uACA9\uC744 \uBC1B\uC9C0 \uC54A\uC74C [\uADFC\uC811
\uBC18\uACA9 \uD55C\uC815 \xB7 \uD0A4 \uADFC\uC0AC]"
effectKey: counter_taken_pct
p1: -1
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: chengpu_a
heroId: chengpu
kind: active
@ -466,6 +774,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uBC00\uBA70 \uC804\uC9C4 \uAC70\uB9AC \xD71.5
[\uBC00\uBA70 \uC804\uC9C4 \uAC70\uB9AC \uBC30\uC728 \xB7 \uD0A4 \uADFC\uC0AC]"
effectKey: push_mult
p1: 1.5
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: chengpu_p
heroId: chengpu
kind: passive
@ -475,6 +790,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uACAC\uC81C \uD69F\uC218 2\uD68C"
effectKey: overwatch_count
p1: 2
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: lvbu_a
heroId: lvbu
kind: active
@ -485,6 +807,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uAD00\uD1B5 \uD53C\uD574 \xD72 \xB7 \uBC18\uACA9
\uBC1B\uC9C0 \uC54A\uC74C"
effectKey: pierce_dmg_mult;pierce_no_counter
p1: 2
p2: 1
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: lvbu_p
heroId: lvbu
kind: passive
@ -495,6 +824,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uC2AC\uB9C1 \uAC70\uB9AC +1.5 [\uC808\uB300 \uAC00\uC0B0 \uD0A4 \uC5C6\uC74C
\xB7 p2 \uC808\uB300 \uAC00\uC0B0 \xB7 \uD0A4 \uADFC\uC0AC]"
effectKey: sling_dist_pct
p1: 0
p2: 1.5
p3: 0
pMask: 7
lvScale: 0.1
needsImpl: 0
- id: zhangjiao_a
heroId: zhangjiao
kind: active
@ -506,6 +842,13 @@ MonoBehaviour:
descKo: "\uC989\uC2DC \u2605: \uC774\uBC88 \uC801 \uD134 \uC801 \uC804\uC6D0
\uAD50\uC804 \uBC18\uC751 \uC5C6\uC74C(\uC548\uAC1C) [\uB300\uC0C1 = \uC801
\uC804\uC6D0]"
effectKey: enemy_aggro_off_turn
p1: 1
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 1
- id: zhangjiao_p
heroId: zhangjiao
kind: passive
@ -515,6 +858,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uC801 \uAD81\uBCD1\uC5D0\uAC8C \uBC1B\uB294 \uD53C\uD574 \u221230 %"
effectKey: dmg_taken_from_archer_pct
p1: -0.3
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: huaxiong_a
heroId: huaxiong
kind: active
@ -525,6 +875,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uC54C\uAE4C\uAE30 \uB300\uC0C1\uC774 \uBB34\uC7A5(\uC601\uC6C5\xB7\uBCF4\uC2A4)\uC774\uBA74
\uD53C\uD574 \xD72 [p2 0=\uBB34\uC7A5 \uC804\uCCB4(\uC601\uC6C5\xB7\uBCF4\uC2A4)]"
effectKey: dmg_vs_general_mult
p1: 2
p2: 0
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: huaxiong_p
heroId: huaxiong
kind: passive
@ -534,6 +891,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "1\uD134\uC9F8 \uACF5 +30 %"
effectKey: dmg_pct_turn1
p1: 0.3
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: yanliang_a
heroId: yanliang
kind: active
@ -544,6 +908,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uBA85\uC911 \uC2DC \uACAC\uC81C \uBC18\uACBD
+1.5"
effectKey: overwatch_radius_add
p1: 1.5
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: yanliang_p
heroId: yanliang
kind: passive
@ -554,6 +925,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uACAC\uC81C\uB85C \uC801\uC744 \uC4F0\uB7EC\uB728\uB9AC\uBA74 HP 20
% \uD68C\uBCF5"
effectKey: heal_on_overwatch_kill
p1: 0.2
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: wenchou_a
heroId: wenchou
kind: active
@ -564,6 +942,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uAD00\uD1B5 \uBC00\uAE30 \xD72(\uACBD\uB85C\uB97C
\uBE44\uC6B4\uB2E4)"
effectKey: pierce_knockback_mult
p1: 2
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: wenchou_p
heroId: wenchou
kind: passive
@ -574,6 +959,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uC544\uAD70 1\uAE30 \uC804\uC0AC\uB9C8\uB2E4 \uACF5 +20 %(\uC911\uCCA9)
[\uC911\uCCA9]"
effectKey: on_ally_death_atk
p1: 0.2
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: jiling_a
heroId: jiling
kind: active
@ -585,6 +977,13 @@ MonoBehaviour:
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uBC00\uBA70 \uC804\uC9C4 \uB05D\uC5D0 \uB2FF\uC740
\uC801 2\uAE30\uAE4C\uC9C0 \uB3D9\uC2DC \uD53C\uD574 [p1 \uB3D9\uC2DC \uD53C\uD574
\uCD5C\uB300 \uC218]"
effectKey: spear_hit_two
p1: 2
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: jiling_p
heroId: jiling
kind: passive
@ -595,6 +994,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uBC00\uACE0 \uAC04 \uC544\uAD70\uB3C4 \uADF8 \uC790\uB9AC\uC5D0\uC11C
\uACF5\uACA9 1\uD68C"
effectKey: pushed_ally_attack_on_push
p1: 1
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: gaoshun_a
heroId: gaoshun
kind: active
@ -606,6 +1012,13 @@ MonoBehaviour:
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uBC00\uAE30 \xD72 \xB7 \uBC00\uB9B0 \uC801\uC774
\uBCBD\xB7\uC720\uB2DB\uC5D0 \uB2FF\uC73C\uBA74 +30 % \uD53C\uD574 [p2 \uBCBD\xB7\uC720\uB2DB
\uCDA9\uB3CC \uCD94\uAC00 \uD53C\uD574 \xB7 \uCDA9\uB3CC \uD0A4 \uC5C6\uC74C]"
effectKey: push_mult
p1: 2
p2: 0.3
p3: 0
pMask: 3
lvScale: 0.1
needsImpl: 0
- id: gaoshun_p
heroId: gaoshun
kind: passive
@ -615,6 +1028,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uBC18\uACA9 1.0 \uBC30"
effectKey: counter_ratio
p1: 1
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: gongsunzan_a
heroId: gongsunzan
kind: active
@ -625,6 +1045,13 @@ MonoBehaviour:
lvMax: 5
descKo: "\uB2E4\uC74C \uD589\uB3D9: \uAD00\uD1B5\uC73C\uB85C \uB9DE\uD78C \uC801
\uC804\uBD80 \uBC00\uAE30 \xD72"
effectKey: pierce_knockback_mult
p1: 2
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
- id: gongsunzan_p
heroId: gongsunzan
kind: passive
@ -634,6 +1061,13 @@ MonoBehaviour:
cooldown: 0
lvMax: 5
descKo: "\uC801 \uAD81\uBCD1\uC5D0\uAC8C \uBC1B\uB294 \uD53C\uD574 \u221230 %"
effectKey: dmg_taken_from_archer_pct
p1: -0.3
p2: 0
p3: 0
pMask: 1
lvScale: 0.1
needsImpl: 0
levels:
- lv: 1
books: 0

View File

@ -87,6 +87,23 @@ namespace TKS.EditorTools
EditorApplication.EnterPlaymode();
}
/// <summary>
/// 스킬 화면 검증 캡처(GUI 에디터 · 사본 프로젝트 권장 · 세이브 = 메모리 전용 · PD #1093 B):
/// Unity.exe -projectPath &lt;copy&gt; -executeMethod TKS.EditorTools.DataImporter.CaptureSkills -skillDir &lt;dir&gt;
/// 스킬 레벨업 · 스킬 버튼 · 액티브 발동 · 패시브 표시 PNG 4장 + log · 끝나면 스스로 종료.
/// </summary>
public static void CaptureSkills()
{
string dir = GetArg("-skillDir");
if (string.IsNullOrEmpty(dir)) dir = Path.Combine(Directory.GetCurrentDirectory(), "Logs", "skill_capture");
ImportAll();
EditorSceneManager.OpenScene(ProjectSetup.ScenePath, OpenSceneMode.Single);
SessionState.SetBool(SaveStore.MemoryFlagKey, true);
SessionState.SetString(SkillCaptureDriver.DirKey, dir);
Debug.Log("[TKS] 스킬 캡처 시작 → " + dir);
EditorApplication.EnterPlaymode();
}
static string GetArg(string name)
{
var args = System.Environment.GetCommandLineArgs();

View File

@ -37,6 +37,10 @@ namespace TKS.OutGame
public string[] ShopGrades { get { return values.List("shop_grades"); } }
public float ShopPriceMult { get { return values.F("shop_price_mult"); } }
public int ShopRefreshGem { get { return values.I("shop_refresh_gem"); } }
// 스킬(PD #1093 B) — 신규 유저 스킬서 · 거래 상점 스킬서 임시 판매(획득처 사당 던전 = C 단계 · PD 확인 대상)
public int StartSkillBooks { get { return values.I("start_skill_books"); } }
public int SkillBookShopDaily { get { return values.I("skill_book_shop_daily"); } }
public int SkillBookPrice { get { return values.I("skill_book_price"); } }
/// <summary>중복 영웅 → 조각(희귀도 1~3).</summary>
public int ShardDup(int rarity)
@ -54,6 +58,7 @@ namespace TKS.OutGame
"gear_bonus_pct", "power_hp_div", "power_atk_w", "power_def_w",
"gold_per_stage", "first_clear_gold_mult", "boss_gear_drop_pct",
"shop_slots", "shop_grades", "shop_price_mult", "shop_refresh_gem",
"start_skill_books", "skill_book_shop_daily", "skill_book_price",
};
}
}

View File

@ -5,7 +5,8 @@ using UnityEngine;
namespace TKS.OutGame
{
/// <summary>
/// 스킬 표시 정보(skills.csv 이름·desc·쿨 + skill_levels.csv) — A 단계는 영웅 화면 표시만. 효과 처리기·effect_key·p1~p3 는 B 단계(스킬 세션)가 같은 CSV 로 만든다.
/// 스킬 표(skills.csv 전 열 + skill_levels.csv) = 스킬 DB. 영웅 화면 표시(A) + 전투 처리기(B · Runtime/Skills 의 SkillCatalog 가 effect_key·p1~p3·lv_scale 을 읽음).
/// skill_levels.csv(lv · books · gold) = levels(스킬 레벨 표 · 스킬 레벨업 비용).
/// </summary>
public class SkillInfoDb : ScriptableObject
{
@ -23,6 +24,32 @@ namespace TKS.OutGame
public int lvMax;
[Tooltip("desc_ko (끝 [ ] 메모 포함 원문)")]
public string descKo;
[Tooltip("effect_key 원문(효과 여럿 = ; 로 나열 · p1~p3 = 키 순서)")]
public string effectKey;
public float p1;
public float p2;
public float p3;
[Tooltip("p 칸이 비어 있지 않은가(bit0 = p1 · bit1 = p2 · bit2 = p3)")]
public int pMask;
[Tooltip("수치 × (1 + lv_scale·(lv−1))")]
public float lvScale;
[Tooltip("신규 처리기(반경 안 유닛 · HP 소모 · 이동 · 교전 반응 정지)")]
public bool needsImpl;
public string[] EffectKeys
{
get { return string.IsNullOrEmpty(effectKey) ? new string[0] : effectKey.Split(';'); }
}
public bool HasP(int i)
{
return (pMask & (1 << i)) != 0;
}
public float P(int i)
{
return i == 0 ? p1 : i == 1 ? p2 : p3;
}
}
[Serializable]
@ -44,6 +71,24 @@ namespace TKS.OutGame
return null;
}
/// <summary>그 레벨이 되는 비용(skill_levels.csv lv 행 · 없으면 null = 최고).</summary>
public LevelCost CostTo(int lv)
{
foreach (var c in levels)
if (c.lv == lv) return c;
return null;
}
public int MaxLevel
{
get
{
int m = 1;
foreach (var c in levels) m = Mathf.Max(m, c.lv);
return m;
}
}
/// <summary>표시용 설명 — 끝의 [p1 … ] 개발 메모를 뺀다.</summary>
public static string DisplayDesc(Skill s)
{

View File

@ -88,6 +88,7 @@ namespace TKS.OutGame
{
var e = db.Economy;
var d = new SaveData { gold = e.StartGold, gem = e.StartGem, stamina = e.StartStamina, staminaAt = SaveStore.Iso(nowUtc), chapterMax = 1 };
d.inv.books.skill = e.StartSkillBooks; // 신규 유저 스킬서(PD #1093 B)
int gearSlots = GearSlotCount(db);
var lineup = new List<string>();
foreach (var id in e.StartHeroes)

View File

@ -192,11 +192,13 @@ namespace TKS.OutGame
if (h == null || !Save.Owns(id)) continue;
var st = StatsOf(id);
float g = st.Growth;
res.Add(new LineupEntry
var entry = new LineupEntry
{
slot = i, id = h.id, name = h.nameKo, type = h.type, art = h.art,
hpMult = h.hpMult * g, atkMult = h.atkMult * g, defMult = h.defMult * g,
});
};
SkillRules.FillLineup(Db, Save, h, entry); // 스킬 id·레벨·★ 패시브 배율(PD #1093 B)
res.Add(entry);
}
return res;
}

View File

@ -212,7 +212,7 @@ namespace TKS.OutGame
{
var t = ScriptableObject.CreateInstance<SkillInfoDb>();
const string f = "skills.csv";
if (Cols(rows, f, e, "id", "hero_id", "kind", "name_ko", "hanja", "trigger", "effect_key", "cooldown", "lv_max", "desc_ko"))
if (Cols(rows, f, e, "id", "hero_id", "kind", "name_ko", "hanja", "trigger", "effect_key", "p1", "p2", "p3", "cooldown", "lv_max", "lv_scale", "desc_ko", "needs_impl"))
{
var seen = new HashSet<string>();
foreach (var r in rows)
@ -221,9 +221,39 @@ namespace TKS.OutGame
{
id = Get(r, "id"), heroId = Get(r, "hero_id"), kind = Get(r, "kind"), nameKo = Get(r, "name_ko"), hanja = Get(r, "hanja"),
trigger = Get(r, "trigger"), cooldown = Int(r, "cooldown", f, e), lvMax = Int(r, "lv_max", f, e), descKo = Get(r, "desc_ko"),
effectKey = Get(r, "effect_key"), lvScale = Float(r, "lv_scale", f, e),
};
if (!Unique(seen, s.id, f, r, e)) continue;
if (s.kind != "active" && s.kind != "passive") e.Add(f + " 행 " + Get(r, "#") + ": kind 「" + s.kind + "」 ≠ active|passive");
string at = f + " 행 " + Get(r, "#") + " " + s.id;
if (s.kind != "active" && s.kind != "passive") e.Add(at + ": kind 「" + s.kind + "」 ≠ active|passive");
// 스킬 처리기 열(B 단계) — trigger · effect_key 51종 · p 칸 · needs_impl
bool active = s.kind == "active";
if (active ? (s.trigger != "next_action" && s.trigger != "instant") : s.trigger != "always")
e.Add(at + ": trigger 「" + s.trigger + "」 (active = next_action|instant · passive = always)");
var ni = Get(r, "needs_impl");
if (ni != "0" && ni != "1") e.Add(at + ": needs_impl ≠ 0|1");
s.needsImpl = ni == "1";
var cols = new[] { "p1", "p2", "p3" };
var ps = new float[3];
for (int i = 0; i < 3; i++)
{
var v = Get(r, cols[i]);
if (v.Length == 0) continue;
if (!float.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out ps[i])) e.Add(at + ": " + cols[i] + " 숫자 아님(「" + v + "」)");
else s.pMask |= 1 << i;
}
s.p1 = ps[0];
s.p2 = ps[1];
s.p3 = ps[2];
var keys = s.EffectKeys;
if (keys.Length == 0 || keys.Length > 3) e.Add(at + ": effect_key 수 " + keys.Length + " (1~3)");
for (int i = 0; i < keys.Length; i++)
{
if (!SkillKeys.Known(keys[i])) e.Add(at + ": effect_key 「" + keys[i] + "」 처리기 없음");
if (i < 3 && !s.HasP(i) && (keys.Length > 1 || i == 0)) e.Add(at + ": effect_key 「" + keys[i] + "」 의 p" + (i + 1) + " 비었음");
}
if (active && s.cooldown < 1) e.Add(at + ": 액티브 cooldown < 1");
if (s.lvMax < 1) e.Add(at + ": lv_max < 1");
t.skills.Add(s);
}
}
@ -231,6 +261,10 @@ namespace TKS.OutGame
if (Cols(lvRows, g, e, "lv", "books", "gold"))
foreach (var r in lvRows)
t.levels.Add(new SkillInfoDb.LevelCost { lv = Int(r, "lv", g, e), books = Int(r, "books", g, e), gold = Int(r, "gold", g, e) });
for (int i = 0; i < t.levels.Count; i++)
if (t.levels[i].lv != i + 1) e.Add(g + ": lv 가 1부터 연속이 아님(" + (i + 1) + "번째 = " + t.levels[i].lv + ")");
foreach (var s in t.skills)
if (s.lvMax > t.levels.Count) e.Add(f + ": " + s.id + " lv_max " + s.lvMax + " > skill_levels.csv 행 " + t.levels.Count);
return t;
}

View File

@ -129,6 +129,9 @@ namespace TKS.OutGame
public string day = "";
public List<ShopItem> items = new List<ShopItem>();
public int refreshes;
[Tooltip("스킬서 임시 판매(PD #1093 B) — 구매한 날(yyyyMMdd) · 그날 구매 수")]
public string bookDay = "";
public int bookBought;
}
[Serializable]

View File

@ -217,8 +217,7 @@ namespace TKS.OutGame
float bw = (1032f - 40f) / 3f;
UiKit.Btn("Detail", btnRow, "상세정보", 36, UiKit.Dark, UiKit.White, new Vector2(0f, 0.5f), new Vector2(0f, 0.5f), Vector2.zero, new Vector2(bw, 96f), ShowStats);
UiKit.Btn("Codex", btnRow, "도감", 36, UiKit.Dark, UiKit.White, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(bw, 96f), () => ShowCodex(Root));
skillBtn = UiKit.Btn("SkillUp", btnRow, "스킬 레벨업\n<size=22>B 단계(준비 중)</size>", 32, UiKit.Red, UiKit.White, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), Vector2.zero, new Vector2(bw, 96f), null);
skillBtn.interactable = false;
skillBtn = UiKit.Btn("SkillUp", btnRow, "스킬 레벨업", 32, UiKit.Red, UiKit.White, new Vector2(1f, 0.5f), new Vector2(1f, 0.5f), Vector2.zero, new Vector2(bw, 96f), OnSkillUp);
}
void Step(int d)
@ -275,9 +274,9 @@ namespace TKS.OutGame
starText.text = h.isSoldier ? UiKit.Stars(h.rarity, 3) : UiKit.Stars(hs.star, db.Evolve.MaxStar);
var skills = new[] { db.Skills.Get(h.skillActive), db.Skills.Get(h.skillPassive) };
int cap = HeroRules.SkillCap(db, hs);
for (int i = 0; i < 2; i++)
{
int cap = SkillRules.Cap(db, h, hs, i); // 등급 + ★3 액티브 +1(PD #1093 B)
var sk = skills[i];
if (sk == null)
{
@ -290,8 +289,11 @@ namespace TKS.OutGame
skillName[i].text = sk.nameKo + (string.IsNullOrEmpty(sk.hanja) ? "" : "(" + sk.hanja + ")") + " · " + (sk.kind == "active" ? "액티브" : "패시브");
skillDesc[i].text = SkillInfoDb.DisplayDesc(sk);
int slv = hs != null && hs.skillLv != null && hs.skillLv.Length > i ? hs.skillLv[i] : 1;
skillChip[i].text = "Lv " + slv + "/" + cap + (sk.kind == "active" ? " · 쿨 " + sk.cooldown + "턴" : "");
skillChip[i].text = "Lv " + slv + "/" + cap + (sk.kind == "active" ? " · 쿨 " + sk.cooldown + "턴" : (hs != null && SkillRules.PassiveMult(db, hs.star) > 1f ? " · ★×" + SkillRules.PassiveMult(db, hs.star).ToString("0.##") : ""));
}
bool canSkill = !h.isSoldier && hs != null && (SkillRules.CanLevelUp(db, S.Save, h, hs, 0) == null || SkillRules.CanLevelUp(db, S.Save, h, hs, 1) == null);
UiKit.LabelOf(skillBtn).text = "스킬 레벨업\n<size=22>" + (h.isSoldier ? "병사 = 스킬 없음" : "스킬서 " + S.Save.inv.books.skill) + "</size>";
skillBtn.GetComponent<Image>().color = canSkill ? UiKit.Red : UiKit.Disabled;
int lvCap = HeroRules.LevelCap(db, S.Save);
var row = db.Levels.Get(lv.lv);
@ -366,6 +368,43 @@ namespace TKS.OutGame
Root.Toast(why ?? h.nameKo + " 승급 → " + UiKit.GradeKo(S.Save.Hero(id).grade) + " 등급 · 새 장비 6칸");
}
/// <summary>스킬 레벨업(PD #1093 B): 액티브·패시브 중 고르기 → 스킬서 + 골드(skill_levels.csv) · 상한 = 등급 + ★3 액티브 +1.</summary>
public void OnSkillUp()
{
var h = S.Def(id);
var hs = S.Save.Hero(id);
if (h == null || hs == null || h.isSoldier)
{
Root.Toast("병사 유닛은 스킬이 없습니다");
return;
}
var db = S.Db;
var buttons = new List<OutGameRoot.ModalButton>();
var body = new System.Text.StringBuilder("보유 스킬서 " + S.Save.inv.books.skill + " · 골드 " + S.Save.gold.ToString("N0") + "\n");
for (int i = 0; i < 2; i++)
{
int k = i;
var sk = SkillRules.Skill(db, h, i);
if (sk == null) continue;
int lv = SkillRules.Level(hs, i);
var cost = db.Skills.CostTo(lv + 1);
var why = SkillRules.CanLevelUp(db, S.Save, h, hs, i);
body.Append("\n").Append(i == 0 ? "액티브 " : "패시브 ").Append(sk.nameKo).Append(" Lv ").Append(lv).Append(" / ").Append(SkillRules.Cap(db, h, hs, i));
if (cost != null && lv < sk.lvMax) body.Append(" → 스킬서 ").Append(cost.books).Append(" · 골드 ").Append(cost.gold.ToString("N0"));
if (why != null) body.Append("\n <color=#a03a22>").Append(why).Append("</color>");
buttons.Add(new OutGameRoot.ModalButton((i == 0 ? "액티브" : "패시브") + " Lv+1", why == null ? UiKit.Red : UiKit.Disabled, () =>
{
var r = SkillRules.LevelUp(S.Db, S.Save, h, hs, k);
if (r == null) S.Commit();
Refresh();
if (r == null) OnSkillUp(); // 이어서 올릴 수 있게 다시 연다
Root.Toast(r ?? sk.nameKo + " Lv " + SkillRules.Level(hs, k) + "!");
}));
}
buttons.Add(new OutGameRoot.ModalButton("닫기", UiKit.Dark, null));
Root.Modal("스킬 레벨업 — " + h.nameKo, body.ToString(), buttons.ToArray());
}
void OnEvolve()
{
var why = S.Evolve(id);

View File

@ -44,9 +44,24 @@ namespace TKS.OutGame
get { return resultRoot != null && resultRoot.activeSelf; }
}
/// <summary>장비 상점 칸 수(스킬서 임시 판매 칸 제외).</summary>
public int ShopCardCount
{
get { return shopGrid.childCount; }
get
{
int n = 0;
foreach (Transform c in shopGrid)
if (c.name != SkillBookCellName) n++;
return n;
}
}
public const string SkillBookCellName = "SkillBook";
/// <summary>스킬서 판매 칸이 있는가(PD #1093 B).</summary>
public bool SkillBookCellVisible
{
get { return shopGrid != null && shopGrid.Find(SkillBookCellName) != null; }
}
public override void Build()
@ -306,6 +321,30 @@ namespace TKS.OutGame
});
b.interactable = !it.sold;
}
AddSkillBookCell(d);
}
/// <summary>스킬서 임시 판매(PD #1093 B · 하루 skill_book_shop_daily 개 · 골드 skill_book_price · 정식 획득처 = 사당 던전(C) · PD 확인 대상).</summary>
void AddSkillBookCell(SaveData d)
{
var e = S.Db.Economy;
int left = Mathf.Max(0, e.SkillBookShopDaily - SkillRules.BooksBoughtToday(d, S.Now));
var cell = UiKit.Cell(shopGrid, SkillBookCellName, left > 0 ? UiKit.Paper : new Color(0.86f, 0.84f, 0.8f), null);
UiKit.Border(cell, UiKit.Line, 2f);
var strip = UiKit.TopBand("Grade", cell.transform, 0f, 64f);
UiKit.Paint(strip, UiKit.Purple);
UiKit.FillLabel(strip, "스킬서 · 임시 판매", 28, UiKit.White);
UiKit.Label(cell.transform, "스킬서", 40, UiKit.Ink, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -86f), new Vector2(310f, 70f));
UiKit.Label(cell.transform, "보유 " + d.inv.books.skill + " · 오늘 " + left + "/" + e.SkillBookShopDaily, 24, UiKit.InkSoft, TextAnchor.MiddleCenter, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -160f), new Vector2(310f, 44f), false);
UiKit.Label(cell.transform, "골드 " + e.SkillBookPrice.ToString("N0"), 36, UiKit.Coin, TextAnchor.MiddleCenter, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 120f), new Vector2(310f, 56f));
var b = UiKit.Btn("BuyBook", cell.transform, left > 0 ? "구매" : "품절", 34, left > 0 ? UiKit.Red : UiKit.Disabled, UiKit.White, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 20f), new Vector2(260f, 84f), () =>
{
var why = SkillRules.BuyBook(S.Db, S.Save, S.Now);
if (why == null) S.Commit();
Root.Toast(why ?? "스킬서 +1 (보유 " + S.Save.inv.books.skill + ")");
Refresh();
});
b.interactable = left > 0;
}
/// <summary>그 장비를 지금 등급 칸에서 기다리는(아직 안 채운) 보유 영웅 수.</summary>

View File

@ -26,6 +26,22 @@ namespace TKS
public List<string> Notes = new List<string>();
/// <summary>실제로 지나간 경로(시작 포함). 창병 견제 판정용.</summary>
public List<Vector2> Path = new List<Vector2>();
// ---------- 스킬(PD #1093 B) ----------
/// <summary>이 행동에 걸린 예약 액티브(next_action) · null = 없음.</summary>
public SkillDef SkillUsed;
/// <summary>추가 이동 횟수(기본 1 · 인덕 extra_move_count) · 추가 이동에서 공격 가능(extra_move_attack).</summary>
public int BonusMoves = 1;
public bool BonusAttack;
/// <summary>이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정).</summary>
public List<Displacement> Displaced = new List<Displacement>();
}
public struct Displacement
{
public Unit Unit;
public Vector2 From;
public Vector2 To;
}
/// <summary>
@ -38,6 +54,7 @@ namespace TKS
/// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage)
/// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤)
/// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음)
/// 스킬(PD #1093 B): 수치는 SkillMods 질의(밀기·튕김·관통·사거리·사선·광역·피해) · 예약 액티브는 Resolve 앞뒤에서 장착·소모(SkillSystem).
/// </summary>
public class ActionResolver
{
@ -61,6 +78,8 @@ namespace TKS
if (dist < 0.001f || !u.IsAlive) yield break;
Vector2 dir = delta / dist;
var skills = SkillSystem.Current;
if (skills != null) skills.BeginAction(u, moveOnly, o); // 예약 액티브 장착 + 발동 연출(PD #1093 B)
// 슬링샷 돌진 중 = 병종 돌진 포즈 1프레임 고정 · 끝나면 대기 (PD 2026-11-05 #1084)
if (u.View != null) u.View.SetCharging(true);
switch (u.Type)
@ -72,7 +91,7 @@ namespace TKS
yield return SpearRoutine(u, origin, dir, dist, target, moveOnly, o);
break;
case UnitType.Cavalry:
yield return CavalryRoutine(u, origin, dir, dist, target, moveOnly, o);
yield return CavalryRoutine(u, origin, dir, dist, target, moveOnly, o, true);
break;
case UnitType.Archer:
case UnitType.Strategist:
@ -80,6 +99,7 @@ namespace TKS
break;
}
if (u.View != null) u.View.SetCharging(false);
if (skills != null) skills.EndAction(u, o); // 예약 소모 · 쿨 · 견제 강화 이월
}
// ---------- 미리보기(드라이런) ----------
@ -95,36 +115,45 @@ namespace TKS
if (dist < 0.001f) return result;
Vector2 dir = delta / dist;
float hitDist;
switch (u.Type)
var skills = SkillSystem.Current;
bool prev = skills != null && skills.PreviewBegin(u);
try
{
case UnitType.Infantry:
case UnitType.Spear:
switch (u.Type)
{
var first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
if (first != null && first.Faction != u.Faction) result.Add(first);
break;
}
case UnitType.Cavalry:
{
float travel = Combat.FreeTravel(u, origin, dir, dist, false, false);
travel = StopBeforeUnclearedAllies(u, origin, dir, travel);
var dists = new List<float>();
result.AddRange(Combat.EnemiesOnPath(u, origin, dir, travel, dists));
break;
}
case UnitType.Archer:
case UnitType.Strategist:
{
var p = PlanShot(u, origin, dir, target);
if (!p.Fires) break;
if (p.Hit != null && p.Hit.Faction != u.Faction) result.Add(p.Hit);
foreach (var s in p.Splash)
case UnitType.Infantry:
case UnitType.Spear:
{
if (s.Faction != u.Faction) result.Add(s);
var first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
if (first != null && first.Faction != u.Faction) result.Add(first);
break;
}
case UnitType.Cavalry:
{
float travel = Combat.FreeTravel(u, origin, dir, dist, false, false);
travel = StopBeforeUnclearedAllies(u, origin, dir, travel);
var dists = new List<float>();
result.AddRange(Combat.EnemiesOnPath(u, origin, dir, travel, dists));
break;
}
case UnitType.Archer:
case UnitType.Strategist:
{
var p = PlanShot(u, origin, dir, target);
if (!p.Fires) break;
if (p.Hit != null && p.Hit.Faction != u.Faction) result.Add(p.Hit);
foreach (var s in p.Splash)
{
if (s.Faction != u.Faction) result.Add(s);
}
break;
}
break;
}
}
finally
{
if (skills != null) skills.PreviewEnd(u, prev);
}
return result;
}
@ -135,7 +164,16 @@ namespace TKS
Vector2 delta = target - u.Position;
float dist = delta.magnitude;
if (dist < 0.001f) return null;
return PlanShot(u, u.Position, delta / dist, target);
var skills = SkillSystem.Current;
bool prev = skills != null && skills.PreviewBegin(u);
try
{
return PlanShot(u, u.Position, delta / dist, target);
}
finally
{
if (skills != null) skills.PreviewEnd(u, prev);
}
}
// ---------- 공통 질의 ----------
@ -178,9 +216,10 @@ namespace TKS
bool enemy = first.Faction != u.Faction;
bool isFinal = enemy && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first;
int impact = 0;
if (enemy)
{
ImpactExchange(u, first, o); // 부딪힌 순간 피해·반격을 즉시 주고받는다(연출 대기 없음)
impact = ImpactExchange(u, first, o); // 부딪힌 순간 피해·반격을 즉시 주고받는다(연출 대기 없음)
if (!u.IsAlive) yield break;
}
else
@ -191,10 +230,15 @@ 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);
if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o);
yield return Bounce(u, first, n, remaining, o, enemy ? impact : 0);
if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o, u);
if (isFinal && u.IsAlive) o.BonusMove = true;
if (isFinal && u.IsAlive)
{
o.BonusMove = true;
o.BonusMoves = Mathf.Max(1, SkillMods.Count(u, SkillKeys.ExtraMoveCount, 1)); // 인덕: 추가 이동 n 회
o.BonusAttack = SkillMods.Has(u, SkillKeys.ExtraMoveAttack); // 호치·강동의 호랑이: 추가 이동에서 공격
}
}
// ---------- 창병: 밀며 함께 전진 ----------
@ -231,13 +275,16 @@ namespace TKS
o.MovedOnly = true;
}
if (first.IsAlive && remaining > 0.01f)
// 밀며 전진 거리 = 남은 거리 × 철척사모(push_mult) × 대상 거구(knockback_taken_pct)
float advance = remaining * SkillMods.PushMult(u, true) * SkillMods.KnockbackTakenMult(first, true);
if (first.IsAlive && advance > 0.01f)
{
// 튕기지 않고 앞 유닛을 밀며 함께 전진 · 앞 유닛이 다른 유닛·벽에 막히면 둘 다 멈춤 · 접촉 간격 유지
float travel = Combat.FreeTravel(first, first.Position, dir, remaining, true, true, u);
float travel = Combat.FreeTravel(first, first.Position, dir, advance, true, true, u);
if (travel > 0.01f)
{
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));
@ -245,11 +292,13 @@ namespace TKS
first.Position = firstEnd;
u.Position = spearEnd;
o.Path.Add(spearEnd);
o.Displaced.Add(new Displacement { Unit = first, From = firstStart, To = firstEnd });
battle.NotifyChanged();
}
o.Pushed.Add(first);
if (!enemy) yield return PushedAllyAttack(first, o);
if (!enemy) yield return PushedAllyAttack(first, o, u);
}
if (enemy && u.IsAlive) SpearHitMore(u, first, o);
if (isFinal && u.IsAlive)
{
@ -258,19 +307,62 @@ namespace TKS
}
}
/// <summary>삼첨도(spear_hit_two): 밀며 전진 끝에 창병·밀린 적과 닿은 다른 적까지 합쳐 최대 n 기에 동시 피해(반격 없음).</summary>
void SpearHitMore(Unit u, Unit first, ActionOutcome o)
{
int n = SkillMods.Count(u, SkillKeys.SpearHitTwo, 1);
if (n <= 1) return;
Physics2D.SyncTransforms();
var extra = new List<Unit>();
foreach (var near in new[] { u, first })
{
if (near == null || !near.IsAlive) continue;
foreach (var c in Physics2D.OverlapCircleAll(near.Position, near.Radius + Combat.ContactTolerance + 0.5f))
{
var e = c.GetComponent<Unit>();
if (e == null || e == first || e == u || !e.IsAlive || e.Faction == u.Faction || extra.Contains(e)) continue;
float gap = Vector2.Distance(near.Position, e.Position) - near.Radius - e.Radius;
if (gap <= Combat.ContactTolerance) extra.Add(e);
}
}
extra.Sort((a, b) => Vector2.Distance(u.Position, a.Position).CompareTo(Vector2.Distance(u.Position, b.Position)));
for (int i = 0; i < extra.Count && i < n - 1; i++)
{
var e = extra[i];
int dmg = SkillSystem.Deal(e, Combat.Damage(cfg, u, e, false, rng, true));
o.Attacked.Add(e);
o.DamageDealt += dmg;
Fx.Popup(e.Position, dmg.ToString(), Color.white);
}
battle.NotifyChanged();
}
// ---------- 기병: 관통 ----------
IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
/// <param name="first">false = 위진소요의 추가 관통(다시 반복·복귀하지 않음 · exclude = 앞서 관통한 적 제외).</param>
IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o, bool first, List<Unit> exclude = null)
{
float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); // 유닛은 무시 · 벽만
travel = StopBeforeUnclearedAllies(u, origin, dir, travel); // 지나칠 수 없는 아군 직전에서 멈춤
var dists = new List<float>();
var enemies = moveOnly ? new List<Unit>() : Combat.EnemiesOnPath(u, origin, dir, travel, dists);
if (exclude != null)
{
for (int i = enemies.Count - 1; i >= 0; i--)
{
if (!exclude.Contains(enemies[i])) continue;
enemies.RemoveAt(i);
dists.RemoveAt(i);
}
}
Unit finalHit = moveOnly ? null : Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance);
bool willReturn = finalHit != null && enemies.Contains(finalHit);
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)));
Vector2 end = origin + dir * travel;
if (!willReturn) end = BackOffFromEnemies(u, origin, dir, travel);
if (!willReturn && repeat <= 0) end = BackOffFromEnemies(u, origin, dir, travel);
if (enemies.Count == 0)
{
@ -279,6 +371,7 @@ namespace TKS
yield break;
}
float dmgMult = SkillMods.PierceDmgMult(u, enemies.Count, true); // 만인지적(2기 이상) · 발시담정 · 천하무쌍
Face(u, dir);
if (u.View != null) u.View.PlayAttack();
int idx = 0;
@ -287,7 +380,7 @@ namespace TKS
{
while (idx < enemies.Count && travelled >= dists[idx] - 0.01f)
{
PierceHit(u, enemies[idx], dir, o);
PierceHit(u, enemies[idx], dir, o, dmgMult);
hit.Add(enemies[idx]);
idx++;
}
@ -298,23 +391,39 @@ namespace TKS
{
if (dists[idx] <= travel + 0.01f)
{
PierceHit(u, enemies[idx], dir, o);
PierceHit(u, enemies[idx], dir, o, dmgMult);
hit.Add(enemies[idx]);
}
}
yield return Fx.Wait(0.15f);
// 살아남은 근접 적의 반격
foreach (var e in hit)
// 살아남은 근접 적의 반격(단기구주·발시담정·천하무쌍 = 반격 없음)
if (!SkillMods.Has(u, SkillKeys.PierceNoCounter))
{
if (!u.IsAlive) break;
if (!e.IsAlive || !UnitTypeInfo.IsMelee(e.Type)) continue;
int c = Combat.CounterDamage(cfg, e, u, rng);
u.TakeDamage(c);
o.CounterTaken += c;
Fx.Popup(u.Position, c.ToString(), new Color(1f, 0.45f, 0.45f));
battle.NotifyChanged();
yield return Fx.Wait(0.12f);
foreach (var e in hit)
{
if (!u.IsAlive) break;
if (!e.IsAlive || !UnitTypeInfo.IsMelee(e.Type)) continue;
int c = Combat.CounterDamage(cfg, e, u, rng, true);
if (c <= 0) continue;
c = SkillSystem.Deal(u, c);
o.CounterTaken += c;
Fx.Popup(u.Position, c.ToString(), new Color(1f, 0.45f, 0.45f));
battle.NotifyChanged();
yield return Fx.Wait(0.12f);
}
}
if (repeat > 0 && u.IsAlive)
{
// 위진소요: 목표점에서 같은 방향으로 같은 거리만큼 한 번 더 관통(앞서 관통한 적 제외 · 그 뒤 복귀 없음)
for (int i = 0; i < repeat && u.IsAlive; i++)
{
yield return Fx.Wait(0.1f);
Vector2 from = u.Position;
yield return CavalryRoutine(u, from, dir, dist, from + dir * dist, false, o, false, hit);
}
yield break;
}
if (willReturn && u.IsAlive)
@ -325,21 +434,26 @@ namespace TKS
}
}
void PierceHit(Unit cav, Unit e, Vector2 dir, ActionOutcome o)
void PierceHit(Unit cav, Unit e, Vector2 dir, ActionOutcome o, float dmgMult)
{
if (!e.IsAlive) return;
int dmg = Combat.Damage(cfg, cav, e, false, rng, true);
e.TakeDamage(dmg);
int dmg = SkillSystem.Deal(e, Combat.Damage(cfg, cav, e, false, rng, true, dmgMult));
o.Attacked.Add(e);
o.DamageDealt += dmg;
Fx.Popup(e.Position, dmg.ToString(), Color.white);
// 옆으로 밀어내기
var skills = SkillSystem.Current;
var cs = skills != null ? skills.Of(cav) : null;
if (cs != null) cs.PierceHits++; // 팔백 돌격(전투 중 누적)
// 옆으로 밀어내기(상산의 창·연진 돌격·백마의종 = 넉백 배율 · 거구 = 밀리는 거리 −)
Vector2 side = new Vector2(-dir.y, dir.x);
float sign = Vector2.Dot(e.Position - cav.Position, side) >= 0f ? 1f : -1f;
if (e.IsAlive)
{
Vector2 to = battle.Board.ClampInside(e.Position + side * sign * cfg.cavalryKnockback, e.Radius);
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);
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 });
}
battle.NotifyChanged();
}
@ -413,34 +527,49 @@ namespace TKS
public bool Crit;
/// <summary>책사: 중심 주변 광역 대상(아군 포함 · 중심·쏜 책사 제외 · 가까운 순).</summary>
public readonly List<Unit> Splash = new List<Unit>();
/// <summary>책사 광역 반경(화공 배율 반영).</summary>
public float SplashRadius;
/// <summary>백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음).</summary>
public float Teleport;
public Vector2 Origin;
}
ShotPlan PlanShot(Unit u, Vector2 origin, Vector2 dir, Vector2 target)
{
var p = new ShotPlan { From = origin, To = origin };
var p = new ShotPlan { From = origin, To = origin, Origin = origin, SplashRadius = SkillMods.SplashRadius(u, cfg) };
// 백기습영(teleport · 다음 행동): 사거리와 무관하게 목표점까지 이동한 뒤 그 자리에서 사격
if (SkillMods.Has(u, SkillKeys.Teleport))
{
p.Teleport = Combat.FreeTravel(u, origin, dir, Vector2.Distance(origin, target), true, true);
origin += dir * p.Teleport;
p.From = p.To = origin;
}
float range = SkillMods.Range(u);
// 조준 방향으로 사거리 + 접근 허용 거리 안의 첫 적(타겟 선정 = #1080 그대로). hitDist = 그 적 가장자리에 닿기까지의 이동 거리(= 가장자리 간격).
float hitDist;
p.Target = Combat.FirstEnemyOnPath(u, origin, dir, u.AttackRange + u.ArcherMoveAllow, out hitDist);
p.Target = Combat.FirstEnemyOnPath(u, origin, dir, range + u.ArcherMoveAllow, out hitDist);
if (p.Target == null) return p;
// 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한)
float need = Mathf.Clamp(hitDist - u.AttackRange, 0f, u.ArcherMoveAllow);
float need = Mathf.Clamp(hitDist - range, 0f, 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 > u.AttackRange + 0.05f) return p; // 아군·벽에 막혀 사거리에 못 들어감
if (hitDist - p.Approach > range + 0.05f) return p; // 아군·벽에 막혀 사거리에 못 들어감
// 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음)
// 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과)
p.Fires = true;
Vector2 aim = p.Target.Position - p.From;
Vector2 adir = aim.sqrMagnitude > 1e-6f ? aim.normalized : dir;
float travel;
p.Hit = Combat.FirstUnitOnArrow(u, p.From, adir, u.AttackRange + u.Radius, cfg.arrowRadius, out 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.To = p.From + adir * (p.Hit != null ? travel + cfg.arrowRadius : travel);
p.Crit = p.Hit == p.Target && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == p.Target;
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, cfg.strategistSplashRadius, p.Hit, u));
p.Splash.AddRange(Combat.UnitsInCircle(p.Hit.Position, p.SplashRadius, p.Hit, u));
}
return p;
}
@ -448,10 +577,15 @@ namespace TKS
IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{
var p = moveOnly ? null : PlanShot(u, origin, dir, target);
if (p != null && p.Teleport > 0.01f)
{
yield return Move(u, p.Origin + dir * p.Teleport, o); // 백기습영: 목표점까지 이동 후 사격
if (p.Target == null) o.MovedOnly = true;
}
if (p == null || p.Target == null)
{
// 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지)
yield return MoveFree(u, origin, dir, dist, o);
if (p == null || p.Teleport <= 0.01f) yield return MoveFree(u, origin, dir, dist, o);
yield break;
}
@ -467,7 +601,7 @@ namespace TKS
/// <summary>
/// 투사체 1발: 첫 피격 유닛에 사격 피해(아군이면 오사 · 상성 적용 · 반격 없음 · 명중 보상 없음 · 밀지 않으므로 규칙 ⑤ 없음)
/// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음).
/// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음 · 화공·팔문 간파 반영).
/// </summary>
IEnumerator Shoot(Unit u, ShotPlan p, ActionOutcome o)
{
@ -486,11 +620,13 @@ namespace TKS
ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o);
if (strategist)
{
Fx.Burst(center, cfg.strategistSplashRadius, Fx.SplashColor);
Fx.Burst(center, p.SplashRadius, Fx.SplashColor);
float splashRatio = SkillMods.SplashRatio(u, cfg, true);
float allyRatio = SkillMods.SplashAllyRatio(u, cfg, true);
foreach (var s in p.Splash)
{
if (!s.IsAlive) continue;
float ratio = cfg.strategistSplashRatio * (s.Faction == u.Faction ? cfg.strategistAllyDamageRatio : 1f);
float ratio = splashRatio * (s.Faction == u.Faction ? allyRatio : 1f);
int dmg = Combat.SplashDamage(cfg, u, s, ratio, rng);
if (dmg > 0) ShotDamage(u, s, dmg, false, o);
}
@ -502,7 +638,7 @@ namespace TKS
/// <summary>사격 피해 적용 + 집계(상대 = Attacked · 같은 편 = FriendlyHit 오사).</summary>
void ShotDamage(Unit a, Unit d, int dmg, bool crit, ActionOutcome o)
{
d.TakeDamage(dmg);
dmg = SkillSystem.Deal(d, dmg);
if (d.Faction == a.Faction)
{
o.FriendlyHit.Add(d);
@ -548,36 +684,55 @@ namespace TKS
o.Path.Add(to);
}
/// <summary>부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계.</summary>
void ImpactExchange(Unit a, Unit d, ActionOutcome o)
/// <summary>부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계. 반환 = 준 피해.</summary>
int ImpactExchange(Unit a, Unit d, ActionOutcome o)
{
if (a.View != null) a.View.PlayAttack();
int dmg = Combat.Damage(cfg, a, d, false, rng, true);
d.TakeDamage(dmg);
int dmg = SkillSystem.Deal(d, Combat.Damage(cfg, a, d, false, rng, true));
o.Attacked.Add(d);
o.DamageDealt += dmg;
Fx.Popup(d.Position, dmg.ToString(), Color.white);
if (d.IsAlive && a.IsAlive)
{
int c = Combat.CounterDamage(cfg, d, a, rng);
if (d.View != null) d.View.PlayAttack();
a.TakeDamage(c);
o.CounterTaken += c;
Fx.Popup(a.Position, c.ToString(), new Color(1f, 0.45f, 0.45f));
int c = Combat.CounterDamage(cfg, d, a, rng, true); // 부딪혀 밀리는 쪽의 반격(진중 조건)
if (c > 0)
{
if (d.View != null) d.View.PlayAttack();
c = SkillSystem.Deal(a, c);
o.CounterTaken += c;
Fx.Popup(a.Position, c.ToString(), new Color(1f, 0.45f, 0.45f));
}
}
battle.NotifyChanged();
return dmg;
}
/// <summary>알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio.</summary>
IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o)
/// <summary>
/// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio × 밀기 배율(청룡도 지기·호치·함진영) × 대상 밀림 배율(거구)
/// / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio × 자기 튕김 배율(인덕·호치 = 0). impactDmg = 함진영 충돌 추가 피해 기준.
/// </summary>
IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o, int impactDmg)
{
bool struckDone = true;
if (struck != null && struck.IsAlive && remaining * cfg.collisionPushRatio >= 0.01f)
float force = struck != null ? remaining * cfg.collisionPushRatio * SkillMods.PushMult(striker, true) * SkillMods.KnockbackTakenMult(struck, true) : 0f;
if (struck != null && struck.IsAlive && force >= 0.01f)
{
struckDone = false;
Fx.Run(Track(Push(struck, n, remaining * cfg.collisionPushRatio, striker, o), () => struckDone = true));
float bonus = impactDmg > 0 ? SkillMods.PushCollisionBonus(striker) : 0f;
System.Action<bool> onEnd = null;
if (bonus > 0f)
{
onEnd = blocked =>
{
if (!blocked || !struck.IsAlive) return;
int extra = SkillSystem.Deal(struck, Mathf.Max(1, Mathf.RoundToInt(impactDmg * bonus)));
o.DamageDealt += extra;
Fx.Popup(struck.Position, extra + " 충돌", new Color(1f, 0.75f, 0.3f));
};
}
Fx.Run(Track(Push(struck, n, force, striker, o, onEnd), () => struckDone = true));
}
float self = remaining * cfg.collisionSelfBounceRatio;
float self = remaining * cfg.collisionSelfBounceRatio * SkillMods.SelfBounceMult(striker, true);
if (self >= 0.01f && striker.IsAlive)
{
float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck);
@ -599,41 +754,60 @@ namespace TKS
onDone();
}
/// <summary>부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다.</summary>
IEnumerator Push(Unit target, Vector2 n, float force, Unit pusher, ActionOutcome o)
/// <summary>부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다. onEnd(true) = 다 못 가고 막힘.</summary>
IEnumerator Push(Unit target, Vector2 n, float force, Unit pusher, ActionOutcome o, System.Action<bool> onEnd = null)
{
if (target == null || !target.IsAlive || force < 0.01f) yield break;
float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher);
float moved = 0f;
if (slide > 0.01f)
{
Face(target, n); // 밀린 방향을 본다(PD #1082)
Vector2 start = target.Position;
Vector2 end = battle.Board.ClampInside(target.Position + n * slide, target.Radius);
yield return Fx.MoveAlong(target.transform, target.Position, end, cfg.moveSpeed, null);
target.Position = end;
moved = Vector2.Distance(start, end);
o.Displaced.Add(new Displacement { Unit = target, From = start, To = end });
}
o.Pushed.Add(target);
battle.NotifyChanged();
if (onEnd != null) onEnd(moved < force - Combat.Epsilon * 2f);
}
/// <summary>규칙 ⑤: 밀린 아군이 상대 유닛과 닿아 있으면 그 자리에서 공격 1회(근접 · 자기 행동 소모 없음).</summary>
IEnumerator PushedAllyAttack(Unit ally, ActionOutcome o)
/// <summary>
/// 규칙 ⑤: 밀린 아군이 상대 유닛과 닿아 있으면 그 자리에서 공격(근접 · 자기 행동 소모 없음) — 기본 1회 ·
/// 민 유닛의 군령(pushed_ally_attack_count = n 회) · 대장군(pushed_ally_attack_on_push = +n 회).
/// </summary>
IEnumerator PushedAllyAttack(Unit ally, ActionOutcome o, Unit pusher)
{
if (ally == null || !ally.IsAlive) yield break;
Unit foe = Combat.TouchingOpponent(ally);
if (foe == null) yield break;
yield return Fx.Wait(0.1f);
Face(ally, foe.Position - ally.Position);
yield return PushedAttack(ally, foe, o);
if (Combat.TouchingOpponent(ally) == null) yield break;
int times = 1;
if (pusher != null)
{
times = Mathf.Max(1, SkillMods.Count(pusher, SkillKeys.PushedAllyAttackCount, 1));
times += Mathf.Max(0, SkillMods.Count(pusher, SkillKeys.PushedAllyAttackOnPush, 0, true));
}
for (int i = 0; i < times; i++)
{
if (!ally.IsAlive) yield break;
Unit foe = Combat.TouchingOpponent(ally);
if (foe == null) yield break;
yield return Fx.Wait(0.1f);
Face(ally, foe.Position - ally.Position);
yield return PushedAttack(ally, foe, o);
}
}
/// <summary>밀린 아군의 근접 공격 1회(반격 받음 · 결과 요약에는 집계하지 않고 Notes 에 기록). 원거리 병종도 근접 패널티를 받는다.</summary>
/// <summary>밀린 아군의 근접 공격 1회(반격 받음 · 결과 요약에는 집계하지 않고 Notes 에 기록 · 도원의 맹약 = 피해 +%). 원거리 병종도 근접 패널티를 받는다.</summary>
IEnumerator PushedAttack(Unit a, Unit d, ActionOutcome o)
{
if (a.View != null) a.View.PlayAttack();
yield return Fx.Lunge(a, d.Position);
int dmg = Combat.Damage(cfg, a, d, false, rng, true);
d.TakeDamage(dmg);
float scale = 1f + SkillMods.Sum(a, SkillKeys.PushedAllyAttackPct, true);
int dmg = SkillSystem.Deal(d, Combat.Damage(cfg, a, d, false, rng, true, scale));
Fx.Popup(d.Position, dmg.ToString(), Color.white);
battle.NotifyChanged();
yield return Fx.Wait(0.2f);
@ -641,13 +815,16 @@ namespace TKS
int counter = 0;
if (d.IsAlive && a.IsAlive)
{
counter = Combat.CounterDamage(cfg, d, a, rng);
if (d.View != null) d.View.PlayAttack();
yield return Fx.Lunge(d, a.Position);
a.TakeDamage(counter);
Fx.Popup(a.Position, counter.ToString(), new Color(1f, 0.45f, 0.45f));
battle.NotifyChanged();
yield return Fx.Wait(0.2f);
counter = Combat.CounterDamage(cfg, d, a, rng, false);
if (counter > 0)
{
if (d.View != null) d.View.PlayAttack();
yield return Fx.Lunge(d, a.Position);
counter = SkillSystem.Deal(a, counter);
Fx.Popup(a.Position, counter.ToString(), new Color(1f, 0.45f, 0.45f));
battle.NotifyChanged();
yield return Fx.Wait(0.2f);
}
}
o.Notes.Add("밀린 " + a.DisplayName + " → " + d.DisplayName + " 공격 " + dmg + (counter > 0 ? " · 반격 " + counter : ""));

View File

@ -5,9 +5,16 @@ using UnityEngine.UI;
namespace TKS
{
/// <summary>화면 HUD(uGUI · 코드 생성): 스테이지·턴·유닛 수·안내·선택 유닛 정보·미니맵·스킬 아이콘(추후)·결과 오버레이.</summary>
/// <summary>화면 HUD(uGUI · 코드 생성): 스테이지·턴·유닛 수·안내·선택 유닛 정보·미니맵·스킬 버튼(PD #1093 B)·스킬 발동 한 줄·결과 오버레이.</summary>
public class BattleHUD : MonoBehaviour
{
static readonly Color SkillReadyColor = new Color(0.72f, 0.2f, 0.14f, 0.94f);
static readonly Color SkillReservedColor = new Color(0.96f, 0.76f, 0.22f, 0.97f);
static readonly Color SkillOffColor = new Color(0.3f, 0.3f, 0.33f, 0.88f);
static readonly Color SkillPassiveOnlyColor = new Color(0.18f, 0.2f, 0.26f, 0.85f);
static readonly Color SkillLogColor = new Color(0.6f, 1f, 0.85f);
const float SkillLogSeconds = 3f;
BattleManager battle;
Camera uiCam;
CameraController camCtrl;
@ -22,6 +29,12 @@ namespace TKS
Text resultSub;
Unit selected;
Image skillPanel;
Text skillName;
Text skillState;
Text skillPassive;
Text skillLog;
Image minimapBg;
Image minimapView;
readonly Dictionary<Unit, Image> minimapDots = new Dictionary<Unit, Image>();
@ -66,12 +79,26 @@ namespace TKS
hintText.color = new Color(0.85f, 0.85f, 0.85f);
unitText = MakeText("UnitInfo", new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(30f, 110f), new Vector2(760f, 170f), 32, TextAnchor.LowerLeft);
// 스킬 아이콘(초기 단계 미구현 · 자리만)
var skill = MakeImage("SkillIcon", transform, new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(-36f, 110f), new Vector2(170f, 170f), new Color(0.3f, 0.3f, 0.33f, 0.8f));
var skillLabel = MakeText("Label", new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero, 30, TextAnchor.MiddleCenter);
skillLabel.transform.SetParent(skill.transform, false);
skillLabel.text = "스킬\n(추후)";
skillLabel.color = new Color(0.75f, 0.75f, 0.75f);
// 스킬 버튼(PD #1093 B · 유닛 선택 시 · 탭 = 예약/취소 · 즉시 발동) — 이름 · 쿨 · 사용 가능/불가 · 패시브 한 줄
skillPanel = MakeImage("SkillButton", transform, new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(-30f, 110f), new Vector2(270f, 230f), SkillOffColor);
skillName = MakeText("Name", new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -14f), new Vector2(256f, 60f), 38, TextAnchor.UpperCenter);
skillName.transform.SetParent(skillPanel.transform, false);
skillName.horizontalOverflow = HorizontalWrapMode.Wrap;
skillName.resizeTextForBestFit = true;
skillName.resizeTextMinSize = 22;
skillName.resizeTextMaxSize = 38;
skillState = MakeText("State", new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0f, 4f), new Vector2(256f, 80f), 26, TextAnchor.MiddleCenter);
skillState.transform.SetParent(skillPanel.transform, false);
skillState.horizontalOverflow = HorizontalWrapMode.Wrap;
skillPassive = MakeText("Passive", new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 12f), new Vector2(256f, 40f), 23, TextAnchor.LowerCenter);
skillPassive.transform.SetParent(skillPanel.transform, false);
skillPassive.horizontalOverflow = HorizontalWrapMode.Wrap;
skillPassive.color = new Color(0.8f, 0.9f, 1f);
skillPanel.gameObject.SetActive(false);
// 스킬 발동 한 줄(액티브·패시브 · 최근 몇 초)
skillLog = MakeText("SkillLog", new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(30f, 292f), new Vector2(1020f, 80f), 28, TextAnchor.LowerLeft);
skillLog.color = SkillLogColor;
// 미니맵 (전장 비율 · 탭하면 그 지점으로 카메라 이동)
Vector2 bs = battle.Config.boardSize;
@ -136,6 +163,97 @@ namespace TKS
selected = u;
}
public bool SkillButtonVisible
{
get { return skillPanel != null && skillPanel.gameObject.activeSelf; }
}
public string SkillButtonText
{
get { return skillPanel != null ? skillName.text + " | " + skillState.text + " | " + skillPassive.text : ""; }
}
public string SkillLogText
{
get { return skillLog != null ? skillLog.text : ""; }
}
/// <summary>화면 점이 스킬 버튼 위면 탭 처리(선택 유닛 액티브 예약·취소·즉시 발동) 후 true — 슬링샷 입력은 건너뛴다.</summary>
public bool TrySkillTap(Vector2 screen)
{
if (!SkillButtonVisible || battle == null) return false;
if (!RectTransformUtility.RectangleContainsScreenPoint(skillPanel.rectTransform, screen, uiCam)) return false;
PressSkill();
return true;
}
/// <summary>스킬 버튼 누름(탭 · 테스트 · 캡처 공용).</summary>
public string PressSkill()
{
if (battle == null || battle.Skills == null || selected == null) return null;
var msg = battle.Skills.TapActive(selected);
if (!string.IsNullOrEmpty(msg)) battle.LastMessage = msg;
if (selected.View != null) selected.View.Refresh();
return msg;
}
void RefreshSkill()
{
var sys = battle.Skills;
var lines = sys != null ? sys.RecentLog(SkillLogSeconds, 2) : null;
skillLog.text = lines != null ? string.Join("\n", lines.ToArray()) : "";
var s = sys != null && selected != null && selected.IsAlive ? sys.Of(selected) : null;
bool show = s != null && (s.Active != null || s.Passive != null);
if (skillPanel.gameObject.activeSelf != show) skillPanel.gameObject.SetActive(show);
if (!show) return;
skillPassive.text = s.Passive != null ? "패 · " + s.Passive.NameKo + (s.PassiveMult > 1f ? " ★" : "") : "";
if (s.Active == null || selected.Faction != Faction.Player)
{
skillName.text = s.Active != null ? s.Active.NameKo : "패시브";
skillState.text = selected.Faction != Faction.Player ? "적 무장 · 패시브만" : "액티브 없음";
skillPanel.color = SkillPassiveOnlyColor;
return;
}
skillName.text = s.Active.NameKo + " Lv" + s.ActiveLv;
var st = sys.StateOf(selected);
string kind = s.Active.Trigger == SkillTrigger.Instant ? "즉시" : "다음 행동";
switch (st)
{
case SkillSystem.ActiveState.Ready:
skillState.text = kind + " · 사용 가능\n탭 = " + (s.Active.Trigger == SkillTrigger.Instant ? "발동" : "예약");
skillPanel.color = SkillReadyColor;
break;
case SkillSystem.ActiveState.Reserved:
skillState.text = "예약됨\n다시 탭 = 취소";
skillPanel.color = SkillReservedColor;
break;
case SkillSystem.ActiveState.Targeting:
skillState.text = "지점 탭 대기\n다시 탭 = 취소";
skillPanel.color = SkillReservedColor;
break;
case SkillSystem.ActiveState.Cooldown:
skillState.text = "쿨 " + s.CooldownLeft(battle.TurnNumber) + "턴 · 사용 불가";
skillPanel.color = SkillOffColor;
break;
case SkillSystem.ActiveState.Acted:
skillState.text = kind + " · 행동 완료";
skillPanel.color = SkillOffColor;
break;
case SkillSystem.ActiveState.BonusMove:
skillState.text = "추가 이동 중 · 사용 불가";
skillPanel.color = SkillOffColor;
break;
default:
skillState.text = kind + " · 쿨 " + s.Active.Cooldown + "턴";
skillPanel.color = SkillOffColor;
break;
}
bool dark = st == SkillSystem.ActiveState.Reserved || st == SkillSystem.ActiveState.Targeting;
skillName.color = skillState.color = dark ? new Color(0.15f, 0.1f, 0.05f) : Color.white;
}
/// <summary>화면 점이 미니맵 안이면 해당 월드 좌표를 돌려준다.</summary>
public bool TryMinimapTap(Vector2 screen, out Vector2 world)
{
@ -179,7 +297,11 @@ namespace TKS
}
messageText.text = battle.LastMessage;
if (battle.Phase == BattlePhase.PlayerTurn)
if (battle.Skills != null && battle.Skills.PendingTeleport != null)
{
hintText.text = "변진: 반경 안 빈 곳을 탭하면 이동(행동 소모 없음) · 다른 곳 탭 = 취소";
}
else if (battle.Phase == BattlePhase.PlayerTurn)
{
hintText.text = battle.OnlyBonusMovesRemain()
? "추가 이동: 유닛을 당겨 이동 · 빈 곳을 탭하면 건너뜀"
@ -196,10 +318,10 @@ namespace TKS
string side = u.Faction == Faction.Player ? "아군" : "적군";
string line1 = side + " · " + u.DisplayName + " · " + UnitTypeInfo.Korean(u.Type) + (u.IsBoss ? " (장수)" : "");
string line2 = "HP " + u.Hp + "/" + u.MaxHp + " 공 " + u.Atk + " 방 " + u.Def;
string line3 = "슬링 " + u.SlingMaxDistance.ToString("0.#");
if (UnitTypeInfo.IsRanged(u.Type)) line3 += " 사거리 " + u.AttackRange.ToString("0.#") + " (접근 +" + u.ArcherMoveAllow.ToString("0.#") + ")";
if (u.Type == UnitType.Strategist) line3 += " 광역 " + battle.Config.strategistSplashRadius.ToString("0.#");
if (u.Type == UnitType.Spear) line3 += " 견제 반경 " + u.AttackRange.ToString("0.#");
string line3 = "슬링 " + SkillMods.SlingMax(u).ToString("0.#");
if (UnitTypeInfo.IsRanged(u.Type)) line3 += " 사거리 " + SkillMods.Range(u).ToString("0.#") + " (접근 +" + u.ArcherMoveAllow.ToString("0.#") + ")";
if (u.Type == UnitType.Strategist) line3 += " 광역 " + SkillMods.SplashRadius(u, battle.Config).ToString("0.#");
if (u.Type == UnitType.Spear) line3 += " 견제 반경 " + SkillMods.OverwatchRadius(u).ToString("0.#");
string state = "";
if (u.Faction == Faction.Player)
{
@ -213,6 +335,7 @@ namespace TKS
{
unitText.text = "유닛을 선택하세요";
}
RefreshSkill();
bool result = battle.Phase == BattlePhase.StageClear || battle.Phase == BattlePhase.Defeat;
if (resultOverlay.activeSelf != result) resultOverlay.SetActive(result);

View File

@ -27,6 +27,16 @@ namespace TKS
public float hpMult = 1f;
public float atkMult = 1f;
public float defMult = 1f;
// 스킬(PD #1093 B · 편성 → SkillRules.FillLineup) — 병사 유닛 = 비어 있음
public string skillActive = "";
public string skillPassive = "";
public int activeLv = 1;
public int passiveLv = 1;
/// <summary>★5 패시브 배율(evolve.csv skill_unlock passive_x · 그 아래 ★ = 1).</summary>
public float passiveMult = 1f;
/// <summary>무장(영웅) — 참장 대상.</summary>
public bool general;
}
/// <summary>턴 진행·유닛 관리·승패 판정. 아군은 모든 유닛이 행동하면 자동으로 적군 턴이 된다(버튼 없음).</summary>
@ -44,6 +54,8 @@ namespace TKS
public int TurnNumber { get; private set; }
public System.Random Rng { get; private set; }
public ActionResolver Resolver { get; private set; }
/// <summary>스킬 상태·시한 효과(PD #1093 B) · 수치 질의 = SkillMods.</summary>
public SkillSystem Skills { get; private set; }
public string LastMessage = "";
readonly List<Unit> units = new List<Unit>();
@ -83,12 +95,15 @@ namespace TKS
Rng = new System.Random(Environment.TickCount);
Fx.Runner = this;
Phase = BattlePhase.Setup;
Skills = new SkillSystem(this);
SkillSystem.Current = Skills;
}
void OnDestroy()
{
if (Instance == this) Instance = null;
if (Fx.Runner == this) Fx.Runner = null;
if (SkillSystem.Current == Skills) SkillSystem.Current = null;
}
public void Configure(BalanceConfig cfg, Board board)
@ -160,6 +175,7 @@ namespace TKS
Destroy(units[i].gameObject);
}
units.Clear();
if (Skills != null) Skills.Clear();
}
// ---------- 스테이지 ----------
@ -174,6 +190,7 @@ namespace TKS
// 아군 = 편성 프리셋(자리 0~2 전열 · 3~4 후열 → playerSpawnPoints) · 방어 배율은 Unit.Init 에 없어 생성 뒤 적용
deployed.Clear();
SkillCatalog.Invalidate(); // 임포트로 바뀐 스킬 표를 전투마다 다시 읽음
var lineup = LineupProvider != null ? LineupProvider() : null;
if (lineup != null)
{
@ -182,15 +199,18 @@ namespace TKS
if (h == null || h.slot < 0 || h.slot >= Config.playerSpawnPoints.Length) continue;
var u = SpawnUnit(h.type, Faction.Player, Config.playerSpawnPoints[h.slot], h.name, h.hpMult, h.atkMult, false, h.art);
u.Def = Mathf.Max(0, Mathf.RoundToInt(Config.GetStats(h.type).def * h.defMult));
Skills.AttachLineup(u, h); // 액티브·패시브(레벨 · ★5) · 산채 체력은 여기서 최대 HP 반영
deployed.Add(h);
}
}
foreach (var e in stage.enemies)
{
SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss, e.art);
var u = SpawnUnit(e.type, Faction.Enemy, e.position, e.name, e.hpMult, e.atkMult, e.isBoss, e.art);
Skills.AttachEnemy(u, e); // 적 무장 = 패시브만(액티브 AI 사용은 2차)
}
TurnNumber = 1;
Skills.BeginBattle();
LastMessage = stage.title + " — 아군 차례";
if (StageStarted != null) StageStarted();
SetPhase(BattlePhase.PlayerTurn);
@ -235,7 +255,7 @@ namespace TKS
public bool AnyEnemyWithinArcherReach(Unit u)
{
if (u == null) return false;
float reach = u.AttackRange + u.ArcherMoveAllow;
float reach = SkillMods.Range(u) + u.ArcherMoveAllow;
Faction other = u.Faction == Faction.Player ? Faction.Enemy : Faction.Player;
foreach (var e in AliveUnits(other))
{
@ -256,13 +276,19 @@ namespace TKS
IEnumerator PlayerActionRoutine(Unit u, Vector2 target)
{
bool moveOnly = u.BonusMoveAvailable;
bool wasBonus = u.BonusMoveAvailable;
bool moveOnly = wasBonus && !Skills.BonusCanAttack(u); // 호치·강동의 호랑이 = 추가 이동에서 공격 가능
var o = new ActionOutcome();
yield return Resolver.Resolve(u, target, moveOnly, o);
yield return ApplyOverwatch(u, o.Path);
yield return ApplyOverwatch(u, o);
if (u.IsAlive && o.BonusMove) u.GrantBonusMove();
else u.MarkActed();
// 추가 이동 = 첫 명중 n 회(인덕 extra_move_count) · 추가 이동 뒤에는 남은 횟수만
if (u.IsAlive && Skills.NextBonus(u, wasBonus, o)) u.GrantBonusMove();
else
{
u.MarkActed();
Skills.ClearBonus(u);
}
LastMessage = Describe(u, o, moveOnly);
NotifyChanged();
@ -305,12 +331,31 @@ namespace TKS
if (!OnlyBonusMovesRemain()) return;
foreach (var u in AliveUnits(Faction.Player))
{
if (u.BonusMoveAvailable) u.MarkActed();
if (!u.BonusMoveAvailable) continue;
u.MarkActed();
Skills.ClearBonus(u);
}
SetPhase(BattlePhase.Resolving);
StartCoroutine(EnemyTurnRoutine());
}
/// <summary>즉시형 액티브 연출(장판 일갈 밀기 · 변진 이동 등) — 아군 턴에만 · 끝나면 다시 아군 턴(행동 소모 없음).</summary>
public bool RunSkillRoutine(IEnumerator routine)
{
if (Phase != BattlePhase.PlayerTurn || routine == null) return false;
SetPhase(BattlePhase.Resolving);
StartCoroutine(SkillRoutine(routine));
return true;
}
IEnumerator SkillRoutine(IEnumerator routine)
{
yield return routine;
NotifyChanged();
if (CheckBattleEnd()) yield break;
SetPhase(BattlePhase.PlayerTurn);
}
// ---------- 적군 턴 ----------
public Coroutine RunEnemyTurn()
@ -320,6 +365,7 @@ namespace TKS
IEnumerator EnemyTurnRoutine()
{
Skills.OnEnemyTurnStart(); // 이번 아군 턴 효과(동남풍·고육계) · 쓰지 않은 예약 정리
SetPhase(BattlePhase.EnemyTurn);
LastMessage = "적군 차례";
NotifyChanged();
@ -328,12 +374,13 @@ namespace TKS
var enemies = AliveUnits(Faction.Enemy);
float accuracy = Stage.aiAccuracy;
float aggro = Stage.aiAggroRange;
float aggro = Mathf.Max(0f, Stage.aiAggroRange + Skills.AggroAdd(Faction.Enemy)); // 신산 = 적 교전 범위 −
int acted = 0;
for (int i = 0; i < enemies.Count; i++)
{
var e = enemies[i];
if (!e.IsAlive) continue;
if (Skills.Suppressed(e)) continue; // 장판 일갈·태평요술 = 이번 적 턴 교전 반응 없음
var players = AliveUnits(Faction.Player);
if (players.Count == 0) break;
@ -344,7 +391,7 @@ namespace TKS
yield return Fx.Wait(0.3f); // 카메라가 따라올 시간
var o = new ActionOutcome();
yield return Resolver.Resolve(e, target.Value, false, o);
yield return ApplyOverwatch(e, o.Path);
yield return ApplyOverwatch(e, o);
LastMessage = Describe(e, o, false);
NotifyChanged();
acted++;
@ -354,6 +401,7 @@ namespace TKS
if (acted == 0) yield return Fx.Wait(0.3f);
foreach (var u in AliveUnits(Faction.Player)) u.ResetForNewTurn();
Skills.OnPlayerTurnStart(); // 이번 적 턴 효과(호위·철벽·간웅·일갈·안개) · 견제 강화 정리
TurnNumber++;
LastMessage = TurnNumber + "번째 턴 — 아군 차례" + (acted == 0 ? " (적은 교전 범위 밖에서 대기)" : "");
SetPhase(BattlePhase.PlayerTurn);
@ -362,35 +410,79 @@ namespace TKS
/// <summary>창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).</summary>
public IEnumerator ApplyOverwatch(Unit mover, List<Vector2> path)
{
if (mover == null || path == null || path.Count < 2) yield break;
var o = new ActionOutcome();
if (path != null) o.Path.AddRange(path);
yield return ApplyOverwatch(mover, o);
}
/// <summary>
/// 창병 대기 견제(스킬 반영 · PD #1093 B): 반경 = SkillMods.OverwatchRadius(후계·백마의 창·청룡언월) · 지나간 적 1기당 1회(기본) × 견제 횟수(삼대 노신 = n 회 타격) ·
/// 「견제 무제한」(청룡언월) = 행동 유닛 경로가 반경을 스친 구간마다 + 그 행동으로 밀리거나 넉백된 상대 유닛도 타격 · 피해 × 교변 · 쓰러뜨리면 하북 명장 회복.
/// </summary>
public IEnumerator ApplyOverwatch(Unit mover, ActionOutcome o)
{
if (mover == null || o == null) yield break;
var path = o.Path;
Faction other = mover.Faction == Faction.Player ? Faction.Enemy : Faction.Player;
foreach (var spear in AliveUnits(other))
{
if (spear.Type != UnitType.Spear || !spear.Overwatch) continue;
if (!mover.IsAlive) yield break;
bool triggered = false;
for (int i = 0; i < path.Count - 1; i++)
if (!mover.IsAlive && o.Displaced.Count == 0) yield break;
float radius = SkillMods.OverwatchRadius(spear);
bool unlimited = SkillMods.OverwatchUnlimited(spear);
var victims = new List<Unit>();
int passes = 0;
for (int i = 0; path != null && i < path.Count - 1; i++)
{
Vector2 a = path[i];
Vector2 b = path[i + 1];
if (Vector2.Distance(a, b) < 0.05f) continue;
if (Combat.SegmentPointDistance(a, b, spear.Position) <= spear.AttackRange + mover.Radius)
if (Combat.SegmentPointDistance(a, b, spear.Position) > radius + mover.Radius) continue;
passes++;
if (!unlimited) break;
}
for (int i = 0; i < passes; i++) victims.Add(mover);
if (unlimited)
{
foreach (var d in o.Displaced)
{
triggered = true;
break;
if (d.Unit == null || d.Unit == mover || d.Unit.Faction == spear.Faction) continue;
if (Vector2.Distance(d.From, d.To) < 0.05f) continue;
if (Combat.SegmentPointDistance(d.From, d.To, spear.Position) <= radius + d.Unit.Radius) victims.Add(d.Unit);
}
}
if (victims.Count == 0) continue;
int strikes = Mathf.Max(1, SkillMods.Count(spear, SkillKeys.OverwatchCount, 1, true));
foreach (var v in victims)
{
for (int k = 0; k < strikes; k++)
{
if (!v.IsAlive || !spear.IsAlive) break;
int dmg = Combat.OverwatchDamage(Config, spear, v, Rng);
if (spear.View != null) spear.View.PlayAttack();
yield return Fx.Lunge(spear, v.Position);
dmg = SkillSystem.Deal(v, dmg);
Fx.Popup(v.Position, dmg + " 견제", new Color(0.6f, 0.85f, 1f));
if (!v.IsAlive) HealOnOverwatchKill(spear);
NotifyChanged();
yield return Fx.Wait(0.2f);
}
}
if (!triggered) continue;
int dmg = Combat.OverwatchDamage(Config, spear, mover, Rng);
if (spear.View != null) spear.View.PlayAttack();
yield return Fx.Lunge(spear, mover.Position);
mover.TakeDamage(dmg);
Fx.Popup(mover.Position, dmg + " 견제", new Color(0.6f, 0.85f, 1f));
NotifyChanged();
yield return Fx.Wait(0.2f);
}
}
/// <summary>하북 명장: 견제로 적을 쓰러뜨리면 최대 HP × p 회복.</summary>
void HealOnOverwatchKill(Unit spear)
{
float pct = SkillMods.Sum(spear, SkillKeys.HealOnOverwatchKill, true);
if (pct <= 0f || !spear.IsAlive) return;
int heal = Mathf.Max(1, Mathf.RoundToInt(spear.MaxHp * pct));
int before = spear.Hp;
spear.Hp = Mathf.Min(spear.MaxHp, spear.Hp + heal);
if (spear.View != null) spear.View.Refresh();
if (spear.Hp > before) Fx.Popup(spear.Position, "+" + (spear.Hp - before), SkillFx.HealColor);
}
// ---------- 승패 ----------
public bool CheckBattleEnd()
@ -427,9 +519,15 @@ namespace TKS
NotifyChanged();
}
/// <summary>테스트·캡처: 턴 번호 지정(스킬 쿨·선봉 1턴 확인).</summary>
public void SetTurnForTest(int turn)
{
TurnNumber = Mathf.Max(1, turn);
}
static string Describe(Unit u, ActionOutcome o, bool moveOnly)
{
string who = u.DisplayName + "(" + UnitTypeInfo.Korean(u.Type) + ")";
string who = u.DisplayName + "(" + UnitTypeInfo.Korean(u.Type) + ")" + (o.SkillUsed != null ? " 「" + o.SkillUsed.NameKo + "」" : "");
string s;
if (o.Attacked.Count == 0)
{

View File

@ -15,19 +15,22 @@ namespace TKS
// ---------- 피해 공식 ----------
/// <summary>
/// 피해 = (공격 × 상성 × 크리 − 방어 × defenseFactor) × 궁병 근접 배율 × 난수.
/// 피해 = (공격 × 스킬 공 배율 × 상성(스킬 하한) × 크리 − 방어 × defenseFactor) × 궁병 근접 배율 × scale × 스킬 주는·받는 피해 배율 × 난수.
/// melee = 근접 전투(돌진·반격·관통·견제·밀려 닿은 공격). 원거리 병종(궁병·책사)이 근접 유닛(보병·기병·창병)과 근접 전투하면
/// 주는 피해 × archerMeleeDealtRatio · 받는 피해 × archerMeleeTakenRatio (PD 2026-11-04 #1080 · 책사 #1087 ⑤ 동일) · 원거리 사격은 패널티 없음.
/// 주는 피해 × archerMeleeDealtRatio · 받는 피해 × archerMeleeTakenRatio (PD 2026-11-04 #1080 · 책사 #1087 ⑤ 동일 · 돌파 melee_penalty_off 면 없음) · 원거리 사격은 패널티 없음.
/// scale = 호출 쪽 배율(관통 피해 배율 · 밀린 아군 공격 % 등) · 스킬 수치 = SkillMods(PD #1093 B).
/// </summary>
public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee)
public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee, float scale = 1f)
{
float mult = cfg.Matchup(attacker.Type, defender.Type);
float raw = attacker.Atk * mult * (crit ? cfg.critMultiplier : 1f) - defender.Def * cfg.defenseFactor;
float mult = Mathf.Max(cfg.Matchup(attacker.Type, defender.Type), SkillMods.MatchupFloor(attacker, true));
float atk = attacker.Atk * SkillMods.AttackMult(attacker, true);
float raw = atk * mult * (crit ? cfg.critMultiplier : 1f) - defender.Def * cfg.defenseFactor;
if (melee)
{
if (UnitTypeInfo.IsRanged(attacker.Type) && UnitTypeInfo.IsMelee(defender.Type)) raw *= cfg.archerMeleeDealtRatio;
if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type)) raw *= cfg.archerMeleeTakenRatio;
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 *= 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));
}
@ -40,16 +43,24 @@ namespace TKS
return Mathf.Max(1, Mathf.RoundToInt(baseDmg * ratio));
}
public static int CounterDamage(BalanceConfig cfg, Unit defender, Unit attacker, System.Random rng)
/// <summary>
/// 반격 피해 = 반격하는 쪽(defender) 피해 × 반격 배율(무성·진중 등 · 기본 counterRatio) × 받는 쪽(attacker) 반격 피해 배율(군율 · 금범적).
/// defenderPushed = 반격하는 유닛이 이 교환에서 밀렸다(알까기·밀며 전진·관통 넉백) — 진중(밀렸을 때만) 조건. 0 = 반격 없음.
/// </summary>
public static int CounterDamage(BalanceConfig cfg, Unit defender, Unit attacker, System.Random rng, bool defenderPushed = false)
{
float taken = SkillMods.PctProduct(attacker, SkillKeys.CounterTakenPct, true);
if (taken <= 0f) return 0;
float ratio = SkillMods.CounterRatio(defender, cfg.counterRatio, defenderPushed, true);
int baseDmg = Damage(cfg, defender, attacker, false, rng, true);
return Mathf.Max(1, Mathf.RoundToInt(baseDmg * cfg.counterRatio));
return Mathf.Max(1, Mathf.RoundToInt(baseDmg * ratio * taken));
}
/// <summary>견제 피해 = 근접 피해 × overwatchDamageRatio × 교변(overwatch_dmg_mult).</summary>
public static int OverwatchDamage(BalanceConfig cfg, Unit spear, Unit mover, System.Random rng)
{
int baseDmg = Damage(cfg, spear, mover, false, rng, true);
return Mathf.Max(1, Mathf.RoundToInt(baseDmg * cfg.overwatchDamageRatio));
return Mathf.Max(1, Mathf.RoundToInt(baseDmg * cfg.overwatchDamageRatio * SkillMods.Product(spear, SkillKeys.OverwatchDmgMult, true)));
}
// ---------- 기하 질의 ----------
@ -98,8 +109,14 @@ namespace TKS
/// <summary>
/// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외).
/// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist).
/// passFaction = 그 진영 유닛은 통과(백발백중 arrow_pass_allies · 기본 null).
/// </summary>
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, out float travel)
{
return FirstUnitOnArrow(shooter, from, dir, dist, arrowRadius, null, out travel);
}
public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, Faction? passFaction, out float travel)
{
travel = dist;
Physics2D.SyncTransforms();
@ -114,6 +131,7 @@ namespace TKS
}
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;
}

View File

@ -32,15 +32,16 @@ namespace TKS
Vector2 toT = nearest.Position - e.Position;
float dist = toT.magnitude;
Vector2 dir = dist > 0.001f ? toT / dist : Vector2.up;
float sling = SkillMods.SlingMax(e); // 적 무장 패시브(적토마 등 · PD #1093 B) 반영
Vector2 target;
if (dist <= e.SlingMaxDistance)
if (dist <= sling)
{
target = nearest.Position; // 최종 목표점 명중 시도
}
else
{
target = e.Position + dir * e.SlingMaxDistance; // 접근(궁병은 사거리 안이면 이동 후 사격)
target = e.Position + dir * sling; // 접근(궁병은 사거리 안이면 이동 후 사격)
}
if (rng.NextDouble() > accuracy)
@ -50,11 +51,11 @@ namespace TKS
float sn = Mathf.Sin(ang);
Vector2 rd = new Vector2(dir.x * cs - dir.y * sn, dir.x * sn + dir.y * cs);
float len = (target - e.Position).magnitude * (0.75f + 0.5f * (float)rng.NextDouble());
target = e.Position + rd * Mathf.Min(len, e.SlingMaxDistance);
target = e.Position + rd * Mathf.Min(len, sling);
}
Vector2 v = target - e.Position;
if (v.magnitude > e.SlingMaxDistance) v = v.normalized * e.SlingMaxDistance;
if (v.magnitude > sling) v = v.normalized * sling;
return board.ClampInside(e.Position + v, e.Radius);
}
@ -68,7 +69,7 @@ namespace TKS
Unit pick = null;
int pickTier = int.MaxValue;
float pickD = float.MaxValue;
float reach = e.AttackRange + e.ArcherMoveAllow;
float reach = SkillMods.Range(e) + e.ArcherMoveAllow;
for (int i = 0; i < targets.Count; i++)
{
var t = targets[i];

View File

@ -64,14 +64,14 @@ namespace TKS
Hide();
return;
}
float r = u.SlingMaxDistance;
float r = SkillMods.SlingMax(u); // 스킬(동남풍·적토마) 반영 · 없으면 슬링샷 최대 거리
ring.transform.localScale = new Vector3(r * 2f, r * 2f, 1f);
ring.gameObject.SetActive(true);
bool archer = UnitTypeInfo.IsRanged(u.Type); // 궁병·책사(#1087 ⑤ 같은 사거리 규칙)
if (archer)
{
float ro = r * (1f + ratio);
float ro = SkillMods.Range(u) * (1f + ratio); // 사거리 스킬(단기·대도독) 반영 · 없으면 = 슬링 × (1 + 비율)
donut.transform.localScale = new Vector3(ro * 2f, ro * 2f, 1f);
donut.color = DonutColor;
}

View File

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

View File

@ -0,0 +1,187 @@
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
namespace TKS.OutGame
{
/// <summary>
/// 스킬 화면 검증 캡처(에디터 DataImporter.CaptureSkills 가 SessionState 로 출력 폴더를 넘기면 생성 · 세이브 = 메모리 전용 신규 유저 · PD #1093 B).
/// ① 스킬 레벨업(영웅 상세 → 팝업 · 액티브 Lv+1 뒤) ② 스킬 버튼(전투 · 관우 선택) ③ 액티브 발동(장비 장판 일갈 · 이름 글자 + 플래시 + 밀기)
/// ④ 패시브 표시(조운 관통 → 상산의 창 HUD 한 줄). 캡처를 위해 적 1기씩을 아군 앞에 옮겨 둔다(그 밖은 실제 처리).
/// </summary>
public class SkillCaptureDriver : MonoBehaviour
{
public const string DirKey = "TKS_SKILL_CAPTURE_DIR";
public string OutputDir;
public int Width = 720;
public int Height = 1280;
readonly List<string> log = new List<string>();
int shot = 1;
#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("SkillCaptureDriver").AddComponent<SkillCaptureDriver>();
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 s = root.Session;
Log("memoryOnly=" + SaveStore.MemoryOnly + " newUser=" + s.IsNewUser + " books=" + s.Save.inv.books.skill + " gold=" + s.Save.gold);
// ① 스킬 레벨업: 첫 시작 영웅(★3 = 액티브 상한 +1) 액티브 Lv1 → 2
var id = s.Db.Economy.StartHeroes[0];
var h = s.Def(id);
var hs = s.Save.Hero(id);
root.ShowHero(id);
yield return Wait();
Log("skill cap active=" + SkillRules.Cap(s.Db, h, hs, 0) + " passive=" + SkillRules.Cap(s.Db, h, hs, 1) + " star=" + hs.star + " grade=" + hs.grade);
Log("levelup active → " + (SkillRules.LevelUp(s.Db, s.Save, h, hs, 0) ?? "ok") + " lv=" + hs.skillLv[0] + " books=" + s.Save.inv.books.skill + " gold=" + s.Save.gold);
s.Commit();
root.HeroDetail.Refresh();
root.HeroDetail.OnSkillUp();
yield return Wait();
yield return Shot(cam, "skill_levelup_" + id);
root.CloseModal();
// 전투 1-1
root.ShowLobby();
root.OpenWorldMap();
yield return Wait();
var wm = root.Map;
wm.OpenPopup(0);
yield return Wait();
wm.ConfirmPopup();
var bm = root.Battle;
float t0 = Time.realtimeSinceStartup;
while (bm.Phase != BattlePhase.PlayerTurn && Time.realtimeSinceStartup - t0 < 10f) yield return null;
var byHero = new Dictionary<string, Unit>();
foreach (var u in bm.Units)
{
if (u == null || u.Faction != Faction.Player) continue;
var us = bm.Skills.Of(u);
Log(" " + u.DisplayName + " " + u.Type + " active=" + (us != null && us.Active != null ? us.Active.Id + " Lv" + us.ActiveLv : "-") + " passive=" + (us != null && us.Passive != null ? us.Passive.Id + " ×" + us.PassiveMult : "-") + " hp" + u.MaxHp + " @" + u.Position);
if (us != null) byHero[us.HeroId] = u;
}
// ② 스킬 버튼: 관우 선택(다음 행동형 · 사용 가능)
Unit gy;
if (byHero.TryGetValue("guanyu", out gy))
{
bm.Sling.Select(gy);
yield return Wait();
Log("button: " + bm.Hud.SkillButtonText);
yield return Shot(cam, "skill_button_guanyu");
}
// ③ 액티브 발동: 장비 장판 일갈(즉시 · 반경 안 적 밀기 + 이번 적 턴 교전 반응 없음)
Unit zf;
if (byHero.TryGetValue("zhangfei", out zf))
{
var foe = Nearest(bm, zf);
if (foe != null) foe.Position = zf.Position + new Vector2(0f, 2f);
Vector2 before = foe != null ? foe.Position : Vector2.zero;
bm.Sling.Select(zf);
yield return null;
Log("press: " + bm.Hud.PressSkill());
yield return new WaitForSecondsRealtime(0.18f);
yield return Shot(cam, "active_zhangfei");
t0 = Time.realtimeSinceStartup;
while (bm.Phase != BattlePhase.PlayerTurn && Time.realtimeSinceStartup - t0 < 10f) yield return null;
Log("pushed " + (foe != null ? foe.DisplayName + " " + before + "→" + foe.Position : "-") + " readyTurn=" + bm.Skills.Of(zf).ReadyTurn + " acted=" + zf.ActedThisTurn);
}
// ④ 패시브 표시: 조운 관통(상산의 창 = 넉백 ×) → HUD 한 줄
Unit zy;
if (byHero.TryGetValue("zhaoyun", out zy))
{
var foe = Nearest(bm, zy);
if (foe != null)
{
foe.Position = zy.Position + new Vector2(0f, 3.5f);
bm.RequestPlayerAction(zy, foe.Position);
t0 = Time.realtimeSinceStartup;
while (bm.Phase != BattlePhase.PlayerTurn && Time.realtimeSinceStartup - t0 < 10f) yield return null;
}
bm.Sling.Select(zy);
yield return null;
Log("log: " + string.Join(" / ", bm.Skills.RecentLog(30f, 8).ToArray()));
Log("msg: " + bm.LastMessage);
yield return Shot(cam, "passive_zhaoyun");
}
Finish(0);
}
static Unit Nearest(BattleManager bm, Unit from)
{
Unit best = null;
float bd = float.MaxValue;
foreach (var e in bm.AliveUnits(Faction.Enemy))
{
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.3f);
}
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("[SkillCapture] " + s);
try
{
File.WriteAllLines(Path.Combine(OutputDir, "capture_log.txt"), log.ToArray());
}
catch
{
}
}
void Finish(int code)
{
Log("finish code=" + code);
#if UNITY_EDITOR
UnityEditor.EditorApplication.Exit(code);
#else
Application.Quit(code);
#endif
}
}
}

View File

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

View File

@ -0,0 +1,169 @@
using System.Collections.Generic;
using TKS.OutGame;
using UnityEngine;
namespace TKS
{
public enum SkillTrigger
{
/// <summary>액티브 · 다음 슬링샷 1회에 붙는다(예약 · 행동 전 다시 탭 = 취소 · 쿨 소모 없음).</summary>
NextAction,
/// <summary>액티브 · 즉시 발동(행동 소모 없음).</summary>
Instant,
/// <summary>패시브 · 상시.</summary>
Always
}
/// <summary>스킬 효과 1개(effect_key 1개 + 값). 키가 하나뿐인 행은 p2·p3 = 그 키의 조건·보조값.</summary>
public class SkillEffect
{
public string Key;
public float P;
public float A2;
public float A3;
public bool HasA2;
public bool HasA3;
public SkillEffect Scaled(float factor)
{
SkillKeys.Info info;
if (!SkillKeys.All.TryGetValue(Key, out info)) return this;
return new SkillEffect
{
Key = Key,
P = SkillKeys.Apply(info.Main, P, factor),
A2 = info.ScaleA2 && HasA2 ? A2 * factor : A2,
A3 = A3,
HasA2 = HasA2,
HasA3 = HasA3,
};
}
}
/// <summary>skills.csv 1행의 전투용 정의(SkillInfoDb.Skill → effect_key 를 키 규약대로 풀어 둠).</summary>
public class SkillDef
{
public string Id;
public string HeroId;
public string NameKo;
public bool IsActive;
public SkillTrigger Trigger;
public int Cooldown;
public int LvMax;
public float LvScale;
public bool NeedsImpl;
public readonly List<SkillEffect> Effects = new List<SkillEffect>();
public static SkillDef From(SkillInfoDb.Skill s)
{
var d = new SkillDef
{
Id = s.id,
HeroId = s.heroId,
NameKo = s.nameKo,
IsActive = s.kind == "active",
Trigger = s.trigger == "next_action" ? SkillTrigger.NextAction : s.trigger == "instant" ? SkillTrigger.Instant : SkillTrigger.Always,
Cooldown = s.cooldown,
LvMax = Mathf.Max(1, s.lvMax),
LvScale = s.lvScale,
NeedsImpl = s.needsImpl,
};
var keys = s.EffectKeys;
for (int i = 0; i < keys.Length; i++)
{
if (keys.Length > 1) d.Effects.Add(new SkillEffect { Key = keys[i], P = s.P(i) });
else d.Effects.Add(new SkillEffect { Key = keys[i], P = s.p1, A2 = s.p2, A3 = s.p3, HasA2 = s.HasP(1), HasA3 = s.HasP(2) });
}
return d;
}
/// <summary>레벨 계수 = 1 + lv_scale·(lv−1) (lv 는 1~lv_max 로 자름).</summary>
public float LevelFactor(int lv)
{
return 1f + LvScale * (Mathf.Clamp(lv, 1, LvMax) - 1);
}
/// <summary>레벨·★ 배율(패시브 ★5 = evolve.csv passive_x)을 적용한 효과 목록.</summary>
public List<SkillEffect> ScaledEffects(int lv, float starMult)
{
float f = LevelFactor(lv) * (IsActive ? 1f : starMult);
var list = new List<SkillEffect>();
foreach (var e in Effects) list.Add(e.Scaled(f));
return list;
}
public SkillEffect First(string key)
{
foreach (var e in Effects)
if (e.Key == key) return e;
return null;
}
}
/// <summary>스킬·영웅 표 조회(Resources/OutGame 의 SkillInfoDb · HeroDb = 임포트 자산 · 전투 시작마다 다시 읽음).</summary>
public static class SkillCatalog
{
static Dictionary<string, SkillDef> defs;
static SkillInfoDb source;
static int sourceCount;
public static void Invalidate()
{
defs = null;
}
static void Ensure()
{
var db = OutGameData.Default;
var s = db != null ? db.Skills : null;
if (defs != null && s == source && s != null && s.skills.Count == sourceCount) return;
defs = new Dictionary<string, SkillDef>();
source = s;
sourceCount = s != null ? s.skills.Count : 0;
if (s == null) return;
foreach (var row in s.skills)
{
if (string.IsNullOrEmpty(row.id) || defs.ContainsKey(row.id)) continue;
defs[row.id] = SkillDef.From(row);
}
}
public static SkillDef Get(string id)
{
if (string.IsNullOrEmpty(id)) return null;
Ensure();
SkillDef d;
return defs.TryGetValue(id, out d) ? d : null;
}
public static IEnumerable<SkillDef> All
{
get
{
Ensure();
return defs.Values;
}
}
public static HeroDb.Hero Hero(string id)
{
var db = OutGameData.Default;
return db != null && db.Heroes != null ? db.Heroes.Get(id) : null;
}
/// <summary>적 무장 → heroes.csv 영웅(아트 id 가 같으면 · 없으면 이름이 같은 영웅) · 병사 유닛 제외 · 없으면 null.</summary>
public static HeroDb.Hero HeroForEnemy(string art, string name)
{
var db = OutGameData.Default;
if (db == null || db.Heroes == null) return null;
HeroDb.Hero byName = null;
foreach (var h in db.Heroes.heroes)
{
if (h.isSoldier) continue;
if (!string.IsNullOrEmpty(art) && h.art == art) return h;
if (byName == null && !string.IsNullOrEmpty(name) && h.nameKo == name) byName = h;
}
return byName;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 18b39b6c930d65c468093779c77e4777

View File

@ -0,0 +1,163 @@
using System.Collections.Generic;
namespace TKS
{
/// <summary>스킬 수치가 레벨(lv_scale)·★5(패시브 배율)로 커지는 방식.</summary>
public enum SkillScale
{
/// <summary>값 × 계수(비율·가산·거리·반경 · 기획서 §5 「수치 × (1 + lv_scale·(lv−1))」).</summary>
Value,
/// <summary>배율(×k)은 1 을 넘는 몫만 키운다: 1 + (k − 1) × 계수 (×2 가 Lv5 에 ×2.8 이 되지 않게).</summary>
Mult,
/// <summary>횟수 = 반올림(값 × 계수).</summary>
Count,
/// <summary>켜고 끄는 값·치환 값(커지지 않음).</summary>
Fixed
}
/// <summary>
/// effect_key 51종 처리기 목록(시스템 기획 §5 · skills.csv) — 키 이름 · 레벨 계수 적용 방식 · 같은 편 전원에게 걸리는가.
/// 이 표는 「처리기의 뜻」이고 수치는 전부 skills.csv(SkillInfoDb)에서 온다(C45). 표에 없는 키는 임포터가 오류로 막는다.
/// p 규약: 키가 여럿이면 p1~p3 = 키 순서 · 키 하나면 p1 = 값 · p2/p3 = 그 키의 조건·보조값(desc_ko [ ] 메모).
/// </summary>
public static class SkillKeys
{
public const string SlingDistPct = "sling_dist_pct";
public const string PushMult = "push_mult";
public const string SelfBounceMult = "self_bounce_mult";
public const string ExtraMoveCount = "extra_move_count";
public const string ExtraMoveAttack = "extra_move_attack";
public const string OverwatchRadiusMult = "overwatch_radius_mult";
public const string OverwatchUnlimited = "overwatch_unlimited";
public const string OverwatchRadiusAdd = "overwatch_radius_add";
public const string OverwatchCount = "overwatch_count";
public const string OverwatchDmgMult = "overwatch_dmg_mult";
public const string CounterRatio = "counter_ratio";
public const string CounterTakenPct = "counter_taken_pct";
public const string PierceNoCounter = "pierce_no_counter";
public const string PierceReturnOnMiss = "pierce_return_on_miss";
public const string PierceKnockbackMult = "pierce_knockback_mult";
public const string PierceDmgPctPerHit = "pierce_dmg_pct_per_hit";
public const string PierceRepeat = "pierce_repeat";
public const string PierceDmgMult = "pierce_dmg_mult";
public const string ArrowCritAlways = "arrow_crit_always";
public const string ArrowPassAllies = "arrow_pass_allies";
public const string RangeMult = "range_mult";
public const string RangeAdd = "range_add";
public const string MeleePenaltyOff = "melee_penalty_off";
public const string SplashRadiusMult = "splash_radius_mult";
public const string SplashRatio = "splash_ratio";
public const string SplashAllyRatio = "splash_ally_ratio";
public const string MatchupFloor = "matchup_floor";
public const string DmgPct = "dmg_pct";
public const string DmgPctHpBelow = "dmg_pct_hp_below";
public const string DmgPctTurn1 = "dmg_pct_turn1";
public const string DmgVsGeneralMult = "dmg_vs_general_mult";
public const string DmgTakenPct = "dmg_taken_pct";
public const string DmgTakenAura = "dmg_taken_aura";
public const string DmgTakenFromArcherPct = "dmg_taken_from_archer_pct";
public const string PushedAllyAttackPct = "pushed_ally_attack_pct";
public const string PushedAllyAttackCount = "pushed_ally_attack_count";
public const string KnockbackTakenPct = "knockback_taken_pct";
public const string HpMaxPct = "hp_max_pct";
public const string HealOnOverwatchKill = "heal_on_overwatch_kill";
public const string HpCostAtk = "hp_cost_atk";
public const string AoePushFear = "aoe_push_fear";
public const string EnemyAggroAdd = "enemy_aggro_add";
public const string EnemyAggroOffTurn = "enemy_aggro_off_turn";
public const string TeamDmgTakenPctTurn = "team_dmg_taken_pct_turn";
public const string GuardShare = "guard_share";
public const string Teleport = "teleport";
public const string OnAllyDeathAtk = "on_ally_death_atk";
public const string TeamAtkPctFull = "team_atk_pct_full";
public const string SpearHitTwo = "spear_hit_two";
public const string PushedAllyAttackOnPush = "pushed_ally_attack_on_push";
public const string TeamRangeAdd = "team_range_add";
public class Info
{
public SkillScale Main;
/// <summary>p2(키 하나인 행의 보조값)도 같은 계수로 키우는가(절대 가산 · 최대치 · 충돌 추가 피해).</summary>
public bool ScaleA2;
/// <summary>같은 편 전원에게 걸리는 효과(주인 = 같은 편 · 살아 있을 때).</summary>
public bool Team;
}
static Info I(SkillScale m, bool a2 = false, bool team = false)
{
return new Info { Main = m, ScaleA2 = a2, Team = team };
}
public static readonly Dictionary<string, Info> All = new Dictionary<string, Info>
{
{ SlingDistPct, I(SkillScale.Value, true) }, // p1 비율 · p2 절대 가산 · p3 1 = 아군 전원
{ PushMult, I(SkillScale.Mult, true) }, // p2 = 밀린 적이 벽·유닛에 닿으면 추가 피해 비율(고순)
{ SelfBounceMult, I(SkillScale.Value) },
{ ExtraMoveCount, I(SkillScale.Count) },
{ ExtraMoveAttack, I(SkillScale.Fixed) },
{ OverwatchRadiusMult, I(SkillScale.Mult) },
{ OverwatchUnlimited, I(SkillScale.Fixed) },
{ OverwatchRadiusAdd, I(SkillScale.Value) },
{ OverwatchCount, I(SkillScale.Count) },
{ OverwatchDmgMult, I(SkillScale.Mult) },
{ CounterRatio, I(SkillScale.Value) }, // p2 1 = 밀렸을 때만
{ CounterTakenPct, I(SkillScale.Value) },
{ PierceNoCounter, I(SkillScale.Fixed) },
{ PierceReturnOnMiss, I(SkillScale.Fixed) },
{ PierceKnockbackMult, I(SkillScale.Mult) },
{ PierceDmgPctPerHit, I(SkillScale.Value, true) }, // p2 = 최대
{ PierceRepeat, I(SkillScale.Count) },
{ PierceDmgMult, I(SkillScale.Mult) }, // p2 = 최소 관통 수(키 하나일 때)
{ ArrowCritAlways, I(SkillScale.Fixed) },
{ ArrowPassAllies, I(SkillScale.Fixed) },
{ RangeMult, I(SkillScale.Mult) },
{ RangeAdd, I(SkillScale.Value) },
{ MeleePenaltyOff, I(SkillScale.Fixed) },
{ SplashRadiusMult, I(SkillScale.Mult) },
{ SplashRatio, I(SkillScale.Value) },
{ SplashAllyRatio, I(SkillScale.Value) },
{ MatchupFloor, I(SkillScale.Fixed) },
{ DmgPct, I(SkillScale.Value) },
{ DmgPctHpBelow, I(SkillScale.Value) }, // p2 = HP 임계
{ DmgPctTurn1, I(SkillScale.Value) },
{ DmgVsGeneralMult, I(SkillScale.Mult) }, // p2 1 = 보스만 · 0 = 무장 전체
{ DmgTakenPct, I(SkillScale.Value) },
{ DmgTakenAura, I(SkillScale.Value, false, true) }, // p2 = 반경
{ DmgTakenFromArcherPct, I(SkillScale.Value) },
{ PushedAllyAttackPct, I(SkillScale.Value, false, true) },
{ PushedAllyAttackCount, I(SkillScale.Count) },
{ KnockbackTakenPct, I(SkillScale.Value) },
{ HpMaxPct, I(SkillScale.Value) },
{ HealOnOverwatchKill, I(SkillScale.Value) },
{ HpCostAtk, I(SkillScale.Value) }, // p2 = HP 소모(커지지 않음)
{ AoePushFear, I(SkillScale.Value) }, // p2 = 반경
{ EnemyAggroAdd, I(SkillScale.Value) },
{ EnemyAggroOffTurn, I(SkillScale.Fixed) },
{ TeamDmgTakenPctTurn, I(SkillScale.Value, false, true) },
{ GuardShare, I(SkillScale.Value) }, // p2 = 반경
{ Teleport, I(SkillScale.Value) }, // p1 = 반경(0 = 슬링 목표점)
{ OnAllyDeathAtk, I(SkillScale.Value) },
{ TeamAtkPctFull, I(SkillScale.Value, false, true) }, // p2 = 출전 인원 조건
{ SpearHitTwo, I(SkillScale.Count) },
{ PushedAllyAttackOnPush, I(SkillScale.Count) },
{ TeamRangeAdd, I(SkillScale.Value, false, true) }, // 대상 = 궁병·책사
};
public static bool Known(string key)
{
return key != null && All.ContainsKey(key);
}
/// <summary>레벨·★ 계수 factor 를 적용한 값.</summary>
public static float Apply(SkillScale mode, float p, float factor)
{
switch (mode)
{
case SkillScale.Value: return p * factor;
case SkillScale.Mult: return 1f + (p - 1f) * factor;
case SkillScale.Count: return UnityEngine.Mathf.Floor(p * factor + 0.5f);
}
return p;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 65ac45ce344197a4b9bb6dd51d5b8493

View File

@ -0,0 +1,373 @@
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>
/// 스킬 수치 질의 — ActionResolver · Combat · EnemyAI · BattleManager 가 수치를 읽을 때 부른다(시스템 기획 §5 · PD #1093 B).
/// Get(unit, key) = 그 유닛에게 지금 걸린 효과(자기 패시브 · 같은 편 패시브(팀 키) · 장착된 예약 액티브 · 시한 효과).
/// 스킬 시스템이 없거나 효과가 없으면 중립값(합 0 · 곱 1)이라 기존 규칙 그대로다.
/// note = 실제 적용(피해·밀기) 때만 true → 패시브 발동 HUD 한 줄.
/// </summary>
public static class SkillMods
{
static readonly List<SkillHit> Empty = new List<SkillHit>();
public static List<SkillHit> Get(Unit u, string key)
{
var sys = SkillSystem.Current;
if (sys == null || u == null) return Empty;
List<SkillHit> list = null;
foreach (var s in sys.Entries)
{
var src = s.Unit;
if (src == null) continue;
if (s.PassiveEffects != null && src.IsAlive)
{
foreach (var e in s.PassiveEffects)
{
if (e.Key != key || !Applies(src, u, e)) continue;
if (list == null) list = new List<SkillHit>();
list.Add(new SkillHit { Owner = s, Def = s.Passive, E = e, Passive = true });
}
}
if (s.Armed && src == u && s.ActiveEffects != null)
{
foreach (var e in s.ActiveEffects)
{
if (e.Key != key) continue;
if (list == null) list = new List<SkillHit>();
list.Add(new SkillHit { Owner = s, Def = s.Active, E = e, Passive = false });
}
}
}
foreach (var t in sys.TimedEffects)
{
if (t.E.Key != key || !sys.WindowActive(t) || !TimedApplies(t, u)) continue;
if (list == null) list = new List<SkillHit>();
list.Add(new SkillHit { Owner = t.Owner, Def = t.Def, E = t.E, Passive = false });
}
return list ?? Empty;
}
static bool IsTeam(SkillEffect e)
{
SkillKeys.Info info;
if (e.Key == SkillKeys.SlingDistPct) return e.HasA3 && e.A3 >= 0.5f; // p3 1 = 아군 전원
return SkillKeys.All.TryGetValue(e.Key, out info) && info.Team;
}
static bool Applies(Unit src, Unit u, SkillEffect e)
{
if (!IsTeam(e)) return src == u;
if (src.Faction != u.Faction) return false;
if (e.Key == SkillKeys.TeamRangeAdd) return UnitTypeInfo.IsRanged(u.Type);
if (e.Key == SkillKeys.DmgTakenAura) return Vector2.Distance(src.Position, u.Position) <= (e.HasA2 ? e.A2 : 0f) + u.Radius;
return true;
}
static bool TimedApplies(SkillSystem.Timed t, Unit u)
{
if (t.Targets != null) return t.Targets.Contains(u);
var src = t.Owner != null ? t.Owner.Unit : null;
if (src == null) return false;
if (!IsTeam(t.E)) return src == u;
if (u.Faction != t.Faction) return false;
if (t.E.Key == SkillKeys.DmgTakenAura)
return src.IsAlive && Vector2.Distance(src.Position, u.Position) <= (t.E.HasA2 ? t.E.A2 : 0f) + u.Radius;
return true;
}
static void Note(SkillHit h, bool note)
{
if (!note || !h.Passive || SkillSystem.Current == null) return;
SkillSystem.Current.NotePassive(h.Owner, h.Def);
}
// ---------- 일반 ----------
public static float Sum(Unit u, string key, bool note = false)
{
float s = 0f;
foreach (var h in Get(u, key))
{
s += h.E.P;
Note(h, note);
}
return s;
}
public static float Product(Unit u, string key, bool note = false)
{
float p = 1f;
foreach (var h in Get(u, key))
{
p *= h.E.P;
Note(h, note);
}
return p;
}
/// <summary>Π(1 + 값) · 0 아래로 내려가지 않음(받는 피해 −% 류).</summary>
public static float PctProduct(Unit u, string key, bool note = false)
{
float p = 1f;
foreach (var h in Get(u, key))
{
p *= Mathf.Max(0f, 1f + h.E.P);
Note(h, note);
}
return p;
}
public static bool Has(Unit u, string key, bool note = false)
{
var l = Get(u, key);
foreach (var h in l) Note(h, note);
return l.Count > 0;
}
/// <summary>횟수 = 걸린 효과 중 최댓값 · 없으면 fallback.</summary>
public static int Count(Unit u, string key, int fallback, bool note = false)
{
var l = Get(u, key);
if (l.Count == 0) return fallback;
int n = int.MinValue;
foreach (var h in l)
{
n = Mathf.Max(n, Mathf.RoundToInt(h.E.P));
Note(h, note);
}
return n;
}
// ---------- 슬링 거리 · 사거리 · 광역 ----------
/// <summary>슬링 최대 거리 = 기본 × (1 + 비율 합) + 절대 가산(적토마 p2) — 동남풍(이번 턴 아군 전원) 포함.</summary>
public static float SlingMax(Unit u, bool note = false)
{
if (u == null) return 0f;
float pct = 0f;
float add = 0f;
foreach (var h in Get(u, SkillKeys.SlingDistPct))
{
pct += h.E.P;
if (h.E.HasA2) add += h.E.A2;
Note(h, note);
}
return Mathf.Max(0.1f, u.SlingMaxDistance * (1f + pct) + add);
}
/// <summary>궁병·책사 사거리 = (기본 + range_add + 대도독 team_range_add) × range_mult.</summary>
public static float Range(Unit u, bool note = false)
{
if (u == null) return 0f;
float r = u.AttackRange + Sum(u, SkillKeys.RangeAdd, note) + Sum(u, SkillKeys.TeamRangeAdd, note);
return Mathf.Max(0f, r * Product(u, SkillKeys.RangeMult, note));
}
public static float SplashRadius(Unit u, BalanceConfig cfg, bool note = false)
{
return cfg.strategistSplashRadius * Product(u, SkillKeys.SplashRadiusMult, note);
}
/// <summary>광역 비율 = 화공 치환값(여럿이면 큰 값) · 없으면 자산 값.</summary>
public static float SplashRatio(Unit u, BalanceConfig cfg, bool note = false)
{
var l = Get(u, SkillKeys.SplashRatio);
if (l.Count == 0) return cfg.strategistSplashRatio;
float r = float.MinValue;
foreach (var h in l)
{
r = Mathf.Max(r, h.E.P);
Note(h, note);
}
return r;
}
/// <summary>광역 아군 비율 = 팔문 간파 치환값(여럿이면 작은 값) · 없으면 자산 값.</summary>
public static float SplashAllyRatio(Unit u, BalanceConfig cfg, bool note = false)
{
var l = Get(u, SkillKeys.SplashAllyRatio);
if (l.Count == 0) return cfg.strategistAllyDamageRatio;
float r = float.MaxValue;
foreach (var h in l)
{
r = Mathf.Min(r, h.E.P);
Note(h, note);
}
return Mathf.Max(0f, r);
}
// ---------- 피해 ----------
/// <summary>상성 하한(대부 · 0 = 없음).</summary>
public static float MatchupFloor(Unit a, bool note = false)
{
float f = 0f;
foreach (var h in Get(a, SkillKeys.MatchupFloor))
{
f = Mathf.Max(f, h.E.P);
Note(h, note);
}
return f;
}
/// <summary>공격력 배율 = 1 + 「공 +%」 합(노익장·맹장 HP 조건 · 선봉 1턴 · 고육계 · 복수 · 패자 · 팔백 돌격).</summary>
public static float AttackMult(Unit a, bool note = false)
{
var sys = SkillSystem.Current;
if (sys == null || a == null) return 1f;
float pct = 0f;
foreach (var h in Get(a, SkillKeys.DmgPctHpBelow))
{
float th = h.E.HasA2 ? h.E.A2 : 0f;
if (a.Hp > a.MaxHp * th) continue;
pct += h.E.P;
Note(h, note);
}
if (sys.Battle.TurnNumber == 1) pct += Sum(a, SkillKeys.DmgPctTurn1, note);
pct += Sum(a, SkillKeys.HpCostAtk, note);
var deaths = Get(a, SkillKeys.OnAllyDeathAtk);
if (deaths.Count > 0)
{
int dead = DeadAllies(a);
if (dead > 0)
foreach (var h in deaths)
{
pct += h.E.P * dead;
Note(h, note);
}
}
foreach (var h in Get(a, SkillKeys.TeamAtkPctFull))
{
int need = h.E.HasA2 ? Mathf.RoundToInt(h.E.A2) : 0;
if (sys.StartCount(a.Faction) < need) continue;
pct += h.E.P;
Note(h, note);
}
foreach (var h in Get(a, SkillKeys.PierceDmgPctPerHit))
{
var s = sys.Of(a);
int hits = s != null ? s.PierceHits : 0;
if (hits <= 0) continue;
float v = h.E.P * hits;
if (h.E.HasA2) v = Mathf.Min(v, h.E.A2);
pct += v;
Note(h, note);
}
return Mathf.Max(0f, 1f + pct);
}
static int DeadAllies(Unit a)
{
var sys = SkillSystem.Current;
if (sys == null) return 0;
int n = 0;
foreach (var u in sys.Battle.Units)
if (u != null && u != a && u.Faction == a.Faction && !u.IsAlive) n++;
return n;
}
/// <summary>주는 피해 배율 = (1 + 알까기 피해 %) × 무장 대상 배율(참장 = 영웅·보스 · 파로 = 보스만).</summary>
public static float DealtMult(Unit a, Unit d, bool note = false)
{
var sys = SkillSystem.Current;
float m = 1f + Sum(a, SkillKeys.DmgPct, note);
foreach (var h in Get(a, SkillKeys.DmgVsGeneralMult))
{
bool bossOnly = h.E.HasA2 && h.E.A2 >= 0.5f;
bool ok = bossOnly ? d.IsBoss : (sys != null && sys.IsGeneral(d));
if (!ok) continue;
m *= h.E.P;
Note(h, note);
}
return Mathf.Max(0f, m);
}
/// <summary>받는 피해 배율 = 수성·노장 × 간웅(이번 적 턴) × 철벽(반경) × 적 궁병 피해 감소.</summary>
public static float TakenMult(Unit d, Unit a, bool note = false)
{
float m = PctProduct(d, SkillKeys.DmgTakenPct, note) * PctProduct(d, SkillKeys.TeamDmgTakenPctTurn, note) * PctProduct(d, SkillKeys.DmgTakenAura, note);
if (a != null && a.Type == UnitType.Archer) m *= PctProduct(d, SkillKeys.DmgTakenFromArcherPct, note);
return m;
}
/// <summary>반격 배율 = 무성·무언의 규율(치환) · 진중(p2 1 = 밀렸을 때만).</summary>
public static float CounterRatio(Unit counterer, float baseRatio, bool pushed, bool note = false)
{
float r = baseRatio;
foreach (var h in Get(counterer, SkillKeys.CounterRatio))
{
if (h.E.HasA2 && h.E.A2 >= 0.5f && !pushed) continue;
if (h.E.P <= r) continue;
r = h.E.P;
Note(h, note);
}
return r;
}
// ---------- 밀기 · 튕김 · 관통 ----------
public static float PushMult(Unit actor, bool note = false)
{
return Mathf.Max(0f, Product(actor, SkillKeys.PushMult, note));
}
/// <summary>함진영: 밀린 적이 벽·유닛에 닿으면 추가 피해 비율(push_mult 행의 p2).</summary>
public static float PushCollisionBonus(Unit actor)
{
float b = 0f;
foreach (var h in Get(actor, SkillKeys.PushMult))
if (h.E.HasA2) b += h.E.A2;
return b;
}
public static float SelfBounceMult(Unit actor, bool note = false)
{
return Mathf.Max(0f, Product(actor, SkillKeys.SelfBounceMult, note));
}
/// <summary>밀리는 거리 배율(거구 −50 %).</summary>
public static float KnockbackTakenMult(Unit target, bool note = false)
{
return PctProduct(target, SkillKeys.KnockbackTakenPct, note);
}
public static float PierceKnockbackMult(Unit actor, bool note = false)
{
return Mathf.Max(0f, Product(actor, SkillKeys.PierceKnockbackMult, note));
}
/// <summary>관통 피해 배율 · 키 하나인 행의 p2 = 최소 관통 수(만인지적 2기 이상).</summary>
public static float PierceDmgMult(Unit actor, int pierced, bool note = false)
{
float m = 1f;
foreach (var h in Get(actor, SkillKeys.PierceDmgMult))
{
if (h.E.HasA2 && pierced < Mathf.RoundToInt(h.E.A2)) continue;
m *= h.E.P;
Note(h, note);
}
return Mathf.Max(0f, m);
}
// ---------- 견제 ----------
/// <summary>견제 반경 = (기본 + 후계 등 패시브 가산 + 이번 대기의 액티브 가산) × 액티브 배율(청룡언월).</summary>
public static float OverwatchRadius(Unit spear, bool note = false)
{
if (spear == null) return 0f;
var s = SkillSystem.Current != null ? SkillSystem.Current.Of(spear) : null;
float add = Sum(spear, SkillKeys.OverwatchRadiusAdd, note) + (s != null ? s.OwAdd : 0f);
float mult = s != null ? s.OwMult : 1f;
return Mathf.Max(0f, (spear.AttackRange + add) * mult);
}
public static bool OverwatchUnlimited(Unit spear)
{
var s = SkillSystem.Current != null ? SkillSystem.Current.Of(spear) : null;
return (s != null && s.OwUnlimited) || Has(spear, SkillKeys.OverwatchUnlimited);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7410190a7c798be4baf6e370a7e3461f

View File

@ -0,0 +1,137 @@
using System;
using System.Globalization;
using UnityEngine;
namespace TKS.OutGame
{
/// <summary>
/// 스킬 성장 규칙(시스템 기획 §3-5·§5 · PD #1093 B): 레벨 1~lv_max · 상한 = 승급 등급(HeroRules.SkillCap) + ★ 보정(evolve.csv skill_unlock 「active_lv+n」 = 액티브 상한 +n) ·
/// ★ 패시브 배율(「passive_x1.5」) · 레벨업 비용 = skill_levels.csv(스킬서 + 골드) · 스킬서 임시 판매(economy.csv skill_book_* · 획득처 사당 던전은 C 단계).
/// </summary>
public static class SkillRules
{
const string ActiveCapToken = "active_lv+";
const string PassiveMultToken = "passive_x";
/// <summary>★ star 까지 해금된 액티브 상한 보정 합(evolve.csv skill_unlock).</summary>
public static int ActiveCapBonus(OutGameData db, int star)
{
int add = 0;
if (db == null || db.Evolve == null) return 0;
foreach (var r in db.Evolve.rows)
{
if (r.star > star || string.IsNullOrEmpty(r.skillUnlock) || !r.skillUnlock.StartsWith(ActiveCapToken)) continue;
int n;
if (int.TryParse(r.skillUnlock.Substring(ActiveCapToken.Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out n)) add += n;
}
return add;
}
/// <summary>★ star 까지 해금된 패시브 효과 배율(곱 · 없으면 1).</summary>
public static float PassiveMult(OutGameData db, int star)
{
float m = 1f;
if (db == null || db.Evolve == null) return m;
foreach (var r in db.Evolve.rows)
{
if (r.star > star || string.IsNullOrEmpty(r.skillUnlock) || !r.skillUnlock.StartsWith(PassiveMultToken)) continue;
float f;
if (float.TryParse(r.skillUnlock.Substring(PassiveMultToken.Length), NumberStyles.Float, CultureInfo.InvariantCulture, out f)) m *= f;
}
return m;
}
/// <summary>스킬 i(0 = 액티브 · 1 = 패시브) 레벨 상한 = 등급 순서 + (액티브면 ★ 보정) · 최대 lv_max.</summary>
public static int Cap(OutGameData db, HeroDb.Hero h, HeroState s, int i)
{
var sk = Skill(db, h, i);
if (sk == null || s == null) return 1;
int cap = HeroRules.SkillCap(db, s) + (i == 0 ? ActiveCapBonus(db, s.star) : 0);
return Mathf.Clamp(cap, 1, Mathf.Min(sk.lvMax, db.Skills.MaxLevel));
}
public static SkillInfoDb.Skill Skill(OutGameData db, HeroDb.Hero h, int i)
{
if (db == null || db.Skills == null || h == null) return null;
return db.Skills.Get(i == 0 ? h.skillActive : h.skillPassive);
}
public static int Level(HeroState s, int i)
{
return s != null && s.skillLv != null && s.skillLv.Length > i ? Mathf.Max(1, s.skillLv[i]) : 1;
}
/// <summary>레벨업 가능하면 null · 아니면 이유.</summary>
public static string CanLevelUp(OutGameData db, SaveData d, HeroDb.Hero h, HeroState s, int i)
{
if (h == null || s == null || h.isSoldier) return "병사 유닛은 스킬 없음";
var sk = Skill(db, h, i);
if (sk == null) return "스킬 없음";
int lv = Level(s, i);
int cap = Cap(db, h, s, i);
if (lv >= sk.lvMax) return "최고 레벨";
if (lv >= cap) return "상한 Lv " + cap + " — 승급" + (i == 0 ? "·★3" : "") + "으로 해금";
var c = db.Skills.CostTo(lv + 1);
if (c == null) return "비용 표 없음";
if (d.inv.books.skill < c.books) return "스킬서 " + d.inv.books.skill + " / " + c.books;
if (d.gold < c.gold) return "골드 " + d.gold.ToString("N0") + " / " + c.gold.ToString("N0");
return null;
}
public static string LevelUp(OutGameData db, SaveData d, HeroDb.Hero h, HeroState s, int i)
{
var why = CanLevelUp(db, d, h, s, i);
if (why != null) return why;
var c = db.Skills.CostTo(Level(s, i) + 1);
d.inv.books.skill -= c.books;
d.gold -= c.gold;
if (s.skillLv == null || s.skillLv.Length < 2) s.skillLv = new[] { 1, 1 };
s.skillLv[i] = Level(s, i) + 1;
return null;
}
/// <summary>출전 칸에 스킬(id · 레벨 = 상한 안 · ★ 패시브 배율 · 무장)을 채운다.</summary>
public static void FillLineup(OutGameData db, SaveData d, HeroDb.Hero h, LineupEntry e)
{
if (h == null || e == null || h.isSoldier) return;
var s = d.Hero(h.id);
e.general = true;
e.skillActive = h.skillActive;
e.skillPassive = h.skillPassive;
e.activeLv = Mathf.Min(Level(s, 0), Cap(db, h, s, 0));
e.passiveLv = Mathf.Min(Level(s, 1), Cap(db, h, s, 1));
e.passiveMult = PassiveMult(db, s != null ? s.star : h.rarity);
}
// ---------- 스킬서 임시 판매(거래 > 상점 · PD 확인 대상) ----------
public static int BooksBoughtToday(SaveData d, DateTime nowUtc)
{
return d.shop.bookDay == EconomyRules.DayKey(nowUtc) ? d.shop.bookBought : 0;
}
public static string CanBuyBook(OutGameData db, SaveData d, DateTime nowUtc)
{
var e = db.Economy;
if (BooksBoughtToday(d, nowUtc) >= e.SkillBookShopDaily) return "오늘 구매 " + e.SkillBookShopDaily + "회 완료";
if (d.gold < e.SkillBookPrice) return "골드 " + d.gold.ToString("N0") + " / " + e.SkillBookPrice.ToString("N0");
return null;
}
public static string BuyBook(OutGameData db, SaveData d, DateTime nowUtc)
{
var why = CanBuyBook(db, d, nowUtc);
if (why != null) return why;
var day = EconomyRules.DayKey(nowUtc);
if (d.shop.bookDay != day)
{
d.shop.bookDay = day;
d.shop.bookBought = 0;
}
d.shop.bookBought++;
d.gold -= db.Economy.SkillBookPrice;
d.inv.books.skill++;
return null;
}
}
}

View File

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

View File

@ -0,0 +1,730 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>유닛 1기의 스킬 상태(액티브 쿨·예약 · 패시브 · 행동 뒤에도 남는 효과).</summary>
public class UnitSkills
{
public Unit Unit;
public string HeroId = "";
/// <summary>무장(영웅·보스·엘리트 장수) — 참장·파로(dmg_vs_general_mult) 대상 판정.</summary>
public bool IsGeneral;
public SkillDef Active;
public int ActiveLv = 1;
public List<SkillEffect> ActiveEffects;
public SkillDef Passive;
public int PassiveLv = 1;
public float PassiveMult = 1f;
public List<SkillEffect> PassiveEffects;
/// <summary>액티브를 쓸 수 있는 첫 아군 턴(전투 시작 = 1 · 사용 턴 + 쿨).</summary>
public int ReadyTurn = 1;
/// <summary>next_action 예약(다음 슬링샷 1회) · 행동 전 다시 탭 = 취소.</summary>
public bool Reserved;
/// <summary>예약한 효과가 지금 걸려 있다(그 유닛의 행동 처리·조준 미리보기 동안만).</summary>
public bool Armed;
/// <summary>추가 이동: 지금 것 다음에 더 남은 횟수 · 추가 이동에서 공격 가능.</summary>
public int BonusMovesLeft;
public bool BonusAttack;
/// <summary>대기(견제) 중 액티브로 얻은 반경 배율·가산·무제한(그 적 턴까지).</summary>
public float OwMult = 1f;
public float OwAdd;
public bool OwUnlimited;
/// <summary>전투 중 관통한 적 누적(장료 팔백 돌격).</summary>
public int PierceHits;
public bool HasActive
{
get { return Active != null && ActiveEffects != null; }
}
public int CooldownLeft(int turn)
{
return Mathf.Max(0, ReadyTurn - turn);
}
}
/// <summary>스킬 효과 1건이 어느 유닛·스킬에서 왔는가(SkillMods 질의 결과).</summary>
public struct SkillHit
{
public UnitSkills Owner;
public SkillDef Def;
public SkillEffect E;
public bool Passive;
}
/// <summary>
/// 전투 1판의 스킬 처리(PD #1093 B · 시스템 기획 §5): 유닛별 스킬 상태 · 즉시형 액티브의 시한 효과(이번 아군 턴 / 이번 적 턴) ·
/// 행동 전후 훅(예약 효과 장착·쿨 소모) · 피해 적용(호위 분담) · HUD 한 줄 기록. 수치 질의는 SkillMods.
/// </summary>
public class SkillSystem
{
public static SkillSystem Current;
public class Timed
{
public UnitSkills Owner;
public Faction Faction;
public SkillDef Def;
public SkillEffect E;
/// <summary>true = 이번 적 턴에만 걸림(다음 아군 턴 시작에 사라짐) · false = 이번 아군 턴(적 턴 시작에 사라짐).</summary>
public bool EnemyWindow;
/// <summary>대상 고정(장판 일갈 = 밀어낸 적) · null = 키 규칙대로.</summary>
public HashSet<Unit> Targets;
}
public struct LogLine
{
public string Text;
public float Time;
}
readonly BattleManager battle;
readonly Dictionary<Unit, UnitSkills> map = new Dictionary<Unit, UnitSkills>();
readonly List<UnitSkills> entries = new List<UnitSkills>();
public readonly List<Timed> TimedEffects = new List<Timed>();
public readonly List<LogLine> Log = new List<LogLine>();
readonly HashSet<string> noted = new HashSet<string>();
readonly int[] startCount = new int[2];
/// <summary>장합 변진: 목표 지점 탭 대기 중인 유닛.</summary>
public Unit PendingTeleport { get; private set; }
public SkillSystem(BattleManager b)
{
battle = b;
}
public BattleManager Battle
{
get { return battle; }
}
public IReadOnlyList<UnitSkills> Entries
{
get { return entries; }
}
// ---------- 부착 ----------
public UnitSkills Of(Unit u)
{
if (u == null) return null;
UnitSkills s;
return map.TryGetValue(u, out s) ? s : null;
}
UnitSkills Ensure(Unit u)
{
var s = Of(u);
if (s != null) return s;
s = new UnitSkills { Unit = u };
map[u] = s;
entries.Add(s);
return s;
}
/// <summary>스킬 부착(액티브·패시브 id · 레벨 · ★5 패시브 배율 · 무장 여부). 최대 HP 패시브(hp_max_pct)는 바로 반영.</summary>
public UnitSkills Attach(Unit u, string activeId, int activeLv, string passiveId, int passiveLv, float passiveMult, bool general)
{
if (u == null) return null;
var s = Ensure(u);
s.IsGeneral = general;
s.Active = SkillCatalog.Get(activeId);
s.ActiveLv = Mathf.Max(1, activeLv);
s.ActiveEffects = s.Active != null ? s.Active.ScaledEffects(s.ActiveLv, 1f) : null;
s.Passive = SkillCatalog.Get(passiveId);
s.PassiveLv = Mathf.Max(1, passiveLv);
s.PassiveMult = passiveMult > 0f ? passiveMult : 1f;
s.PassiveEffects = s.Passive != null ? s.Passive.ScaledEffects(s.PassiveLv, s.PassiveMult) : null;
if (s.Passive != null) s.HeroId = s.Passive.HeroId;
else if (s.Active != null) s.HeroId = s.Active.HeroId;
float hp = SkillMods.Sum(u, SkillKeys.HpMaxPct);
if (Mathf.Abs(hp) > 1e-4f)
{
u.MaxHp = Mathf.Max(1, Mathf.RoundToInt(u.MaxHp * (1f + hp)));
u.Hp = u.MaxHp;
if (u.View != null) u.View.Refresh();
}
return s;
}
/// <summary>heroes.csv 영웅 id 로 부착(테스트·도구용).</summary>
public UnitSkills AttachHero(Unit u, string heroId, int activeLv = 1, int passiveLv = 1, float passiveMult = 1f, bool general = true)
{
var h = SkillCatalog.Hero(heroId);
if (h == null) return null;
return Attach(u, h.skillActive, activeLv, h.skillPassive, passiveLv, passiveMult, general && !h.isSoldier);
}
/// <summary>출전 칸(편성 → 스킬 id·레벨·★ 배율이 채워져 있음).</summary>
public UnitSkills AttachLineup(Unit u, LineupEntry e)
{
if (e == null) return null;
if (string.IsNullOrEmpty(e.skillActive) && string.IsNullOrEmpty(e.skillPassive))
{
if (e.general) Ensure(u).IsGeneral = true;
return Of(u);
}
return Attach(u, e.skillActive, e.activeLv, e.skillPassive, e.passiveLv, e.passiveMult, e.general);
}
/// <summary>적 무장(보스·엘리트) = heroes.csv 에 같은 아트·이름 영웅이 있으면 그 패시브만(Lv1 · 액티브 AI 사용은 2차 · PD 확인 대상).</summary>
public UnitSkills AttachEnemy(Unit u, BalanceConfig.EnemySpawn e)
{
if (u == null || e == null || !(e.isBoss || e.isGeneral)) return null;
var h = SkillCatalog.HeroForEnemy(e.art, e.name);
if (h == null)
{
var s = Ensure(u);
s.IsGeneral = true;
return s;
}
return Attach(u, null, 1, h.skillPassive, 1, 1f, true);
}
public void MarkGeneral(Unit u)
{
if (u != null) Ensure(u).IsGeneral = true;
}
public bool IsGeneral(Unit u)
{
if (u == null) return false;
if (u.IsBoss) return true;
var s = Of(u);
return s != null && s.IsGeneral;
}
public void Clear()
{
map.Clear();
entries.Clear();
TimedEffects.Clear();
noted.Clear();
PendingTeleport = null;
startCount[0] = startCount[1] = 0;
}
/// <summary>전투 시작(배치 뒤): 출전 인원 기록(패자 · team_atk_pct_full) · 쿨 = 바로 사용 가능 · 시한 효과 비움.</summary>
public void BeginBattle()
{
Prune();
TimedEffects.Clear();
noted.Clear();
PendingTeleport = null;
startCount[0] = battle.AliveCount(Faction.Player);
startCount[1] = battle.AliveCount(Faction.Enemy);
foreach (var s in entries)
{
s.ReadyTurn = 1;
s.Reserved = false;
s.Armed = false;
s.BonusMovesLeft = 0;
s.BonusAttack = false;
s.OwMult = 1f;
s.OwAdd = 0f;
s.OwUnlimited = false;
s.PierceHits = 0;
}
}
public int StartCount(Faction f)
{
return startCount[(int)f];
}
void Prune()
{
for (int i = entries.Count - 1; i >= 0; i--)
{
if (entries[i].Unit != null) continue;
entries.RemoveAt(i);
}
var dead = new List<Unit>();
foreach (var kv in map)
if (kv.Key == null) dead.Add(kv.Key);
foreach (var k in dead) map.Remove(k);
}
// ---------- 턴 ----------
/// <summary>적 턴 시작: 이번 아군 턴 효과(동남풍 · 고육계)와 쓰지 않은 예약·추가 이동 정리.</summary>
public void OnEnemyTurnStart()
{
TimedEffects.RemoveAll(t => !t.EnemyWindow);
PendingTeleport = null;
foreach (var s in entries)
{
s.Reserved = false;
s.Armed = false;
s.BonusMovesLeft = 0;
s.BonusAttack = false;
}
}
/// <summary>아군 턴 시작: 이번 적 턴 효과(호위 · 철벽 · 간웅 · 일갈 · 안개)와 아군 견제 강화 정리.</summary>
public void OnPlayerTurnStart()
{
TimedEffects.RemoveAll(t => t.EnemyWindow);
foreach (var s in entries)
{
if (s.Unit == null || s.Unit.Faction != Faction.Player) continue;
s.OwMult = 1f;
s.OwAdd = 0f;
s.OwUnlimited = false;
}
}
public bool WindowActive(Timed t)
{
return !t.EnemyWindow || battle.Phase == BattlePhase.EnemyTurn;
}
// ---------- 액티브: 상태 · 탭 ----------
public enum ActiveState
{
None,
Ready,
Reserved,
Cooldown,
Acted,
BonusMove,
NotTurn,
Targeting
}
public ActiveState StateOf(Unit u)
{
var s = Of(u);
if (u == null || s == null || !s.HasActive || u.Faction != Faction.Player || !u.IsAlive) return ActiveState.None;
if (PendingTeleport == u) return ActiveState.Targeting;
if (s.Reserved) return ActiveState.Reserved;
if (battle.Phase != BattlePhase.PlayerTurn) return ActiveState.NotTurn;
if (s.CooldownLeft(battle.TurnNumber) > 0) return ActiveState.Cooldown;
if (s.Active.Trigger == SkillTrigger.NextAction)
{
if (u.BonusMoveAvailable) return ActiveState.BonusMove;
if (!battle.CanAct(u)) return ActiveState.Acted;
}
return ActiveState.Ready;
}
/// <summary>HUD 스킬 버튼 탭: next_action = 예약/취소 · instant = 즉시 발동(변진 = 목표 지점 탭 대기). 반환 = 안내 문구.</summary>
public string TapActive(Unit u)
{
var s = Of(u);
var st = StateOf(u);
switch (st)
{
case ActiveState.None: return null;
case ActiveState.Targeting:
PendingTeleport = null;
return s.Active.NameKo + " 취소";
case ActiveState.Reserved:
s.Reserved = false;
if (u.View != null) u.View.Refresh();
return s.Active.NameKo + " 예약 취소(쿨 소모 없음)";
case ActiveState.NotTurn: return "아군 턴에만 쓸 수 있습니다";
case ActiveState.Cooldown: return s.Active.NameKo + " — 쿨 " + s.CooldownLeft(battle.TurnNumber) + "턴";
case ActiveState.Acted: return s.Active.NameKo + " — 이미 행동한 유닛";
case ActiveState.BonusMove: return s.Active.NameKo + " — 추가 이동 중에는 예약할 수 없음";
}
if (s.Active.Trigger == SkillTrigger.NextAction)
{
s.Reserved = true;
if (u.View != null) u.View.Refresh();
return s.Active.NameKo + " 예약 — 다음 슬링샷에 발동 · 다시 탭 = 취소";
}
if (s.Active.First(SkillKeys.Teleport) != null)
{
PendingTeleport = u;
return s.Active.NameKo + " — 반경 " + TeleportRadius(s).ToString("0.#") + " 안 빈 곳을 탭(다른 곳 = 취소)";
}
battle.RunSkillRoutine(Instant(s));
return s.Active.NameKo + " 발동!";
}
// ---------- 행동 훅(ActionResolver.Resolve) ----------
/// <summary>행동 시작: 예약한 next_action 효과를 장착(이동만인 추가 이동은 제외) · 발동 연출.</summary>
public void BeginAction(Unit u, bool moveOnly, ActionOutcome o)
{
noted.Clear();
var s = Of(u);
if (s == null || !s.Reserved || !s.HasActive || moveOnly) return;
s.Armed = true;
o.SkillUsed = s.Active;
Announce(s, s.Active);
}
/// <summary>행동 끝: 예약 소모 · 쿨(아군 턴 기준) · 견제 강화 이월.</summary>
public void EndAction(Unit u, ActionOutcome o)
{
var s = Of(u);
if (s == null || !s.Armed) return;
if (o.Overwatch)
{
foreach (var e in s.ActiveEffects)
{
if (e.Key == SkillKeys.OverwatchRadiusMult) s.OwMult *= e.P;
else if (e.Key == SkillKeys.OverwatchRadiusAdd) s.OwAdd += e.P;
else if (e.Key == SkillKeys.OverwatchUnlimited) s.OwUnlimited = true;
}
}
s.Armed = false;
s.Reserved = false;
s.ReadyTurn = battle.TurnNumber + s.Active.Cooldown;
if (u.View != null) u.View.Refresh();
}
/// <summary>조준 미리보기 동안만 예약 효과를 장착(사거리 · 사선 · 광역 표시). 반환 = 복원값.</summary>
public bool PreviewBegin(Unit u)
{
var s = Of(u);
if (s == null) return false;
bool prev = s.Armed;
if (s.Reserved && !u.BonusMoveAvailable) s.Armed = true;
return prev;
}
public void PreviewEnd(Unit u, bool prev)
{
var s = Of(u);
if (s != null) s.Armed = prev;
}
// ---------- 추가 이동 ----------
public bool BonusCanAttack(Unit u)
{
var s = Of(u);
return s != null && s.BonusAttack;
}
/// <summary>행동 뒤 추가 이동을 (또) 주는가 — 처음 명중 = 추가 이동 n 회(인덕) · 추가 이동 뒤 = 남은 횟수.</summary>
public bool NextBonus(Unit u, bool wasBonus, ActionOutcome o)
{
var s = Of(u);
if (!wasBonus)
{
if (!o.BonusMove) return false;
if (s != null)
{
s.BonusMovesLeft = Mathf.Max(0, o.BonusMoves - 1);
s.BonusAttack = o.BonusAttack;
}
return true;
}
if (s != null && s.BonusMovesLeft > 0)
{
s.BonusMovesLeft--;
return true;
}
ClearBonus(u);
return false;
}
public void ClearBonus(Unit u)
{
var s = Of(u);
if (s == null) return;
s.BonusMovesLeft = 0;
s.BonusAttack = false;
}
// ---------- 적 턴: 교전 반응 · 교전 범위 ----------
/// <summary>이번 적 턴에 교전 반응이 없는가(장판 일갈 공포 · 태평요술 안개).</summary>
public bool Suppressed(Unit e)
{
if (e == null) return false;
foreach (var t in TimedEffects)
{
if (!WindowActive(t)) continue;
if (t.E.Key == SkillKeys.AoePushFear && t.Targets != null && t.Targets.Contains(e)) return true;
if (t.E.Key == SkillKeys.EnemyAggroOffTurn && t.Faction != e.Faction) return true;
}
return false;
}
/// <summary>상대 진영 패시브(신산)가 actingFaction 의 교전 범위에 더하는 값.</summary>
public float AggroAdd(Faction actingFaction)
{
float add = 0f;
foreach (var s in entries)
{
if (s.Unit == null || !s.Unit.IsAlive || s.Unit.Faction == actingFaction || s.PassiveEffects == null) continue;
foreach (var e in s.PassiveEffects)
{
if (e.Key != SkillKeys.EnemyAggroAdd) continue;
add += e.P;
NotePassive(s, s.Passive);
}
}
return add;
}
// ---------- 피해 적용(호위) ----------
/// <summary>피해 적용 — 관평 호위(이번 적 턴 반경 안 같은 편 피해 일부를 관평이 대신) 반영. 반환 = d 가 실제 받은 피해.</summary>
public int Hit(Unit d, int dmg)
{
if (d == null || !d.IsAlive || dmg <= 0) return 0;
foreach (var t in TimedEffects)
{
if (t.E.Key != SkillKeys.GuardShare || !WindowActive(t)) continue;
var g = t.Owner.Unit;
if (g == null || !g.IsAlive || g == d || g.Faction != d.Faction) continue;
float r = t.E.HasA2 ? t.E.A2 : 0f;
if (Vector2.Distance(g.Position, d.Position) > r + d.Radius) continue;
int share = Mathf.Clamp(Mathf.RoundToInt(dmg * Mathf.Clamp01(t.E.P)), 0, dmg);
if (share <= 0) continue;
g.TakeDamage(share);
Fx.Popup(g.Position, share + " 호위", SkillFx.GuardColor);
dmg -= share;
break;
}
d.TakeDamage(dmg);
return dmg;
}
/// <summary>스킬 시스템이 없어도 쓰는 피해 적용.</summary>
public static int Deal(Unit d, int dmg)
{
if (Current != null) return Current.Hit(d, dmg);
if (d == null) return 0;
d.TakeDamage(dmg);
return dmg;
}
// ---------- 즉시형 액티브 ----------
/// <summary>즉시 발동(행동 소모 없음): 효과마다 처리 → 쿨.</summary>
public IEnumerator Instant(UnitSkills s)
{
var u = s.Unit;
Announce(s, s.Active);
s.ReadyTurn = battle.TurnNumber + s.Active.Cooldown;
foreach (var e in s.ActiveEffects)
{
switch (e.Key)
{
case SkillKeys.AoePushFear:
yield return PushFear(s, e);
break;
case SkillKeys.HpCostAtk:
PayHp(u, e.HasA2 ? e.A2 : 0f);
AddTimed(s, e, false, null);
break;
case SkillKeys.GuardShare:
case SkillKeys.TeamDmgTakenPctTurn:
case SkillKeys.DmgTakenAura:
case SkillKeys.EnemyAggroOffTurn:
AddTimed(s, e, true, null);
break;
case SkillKeys.Teleport:
break; // 목표 지점 탭(TryTeleport)
default:
AddTimed(s, e, false, null); // 동남풍(sling_dist_pct) 등 = 이번 아군 턴
break;
}
}
yield return Fx.Wait(0.25f);
battle.NotifyChanged();
}
void AddTimed(UnitSkills s, SkillEffect e, bool enemyWindow, HashSet<Unit> targets)
{
TimedEffects.Add(new Timed { Owner = s, Faction = s.Unit.Faction, Def = s.Active, E = e, EnemyWindow = enemyWindow, Targets = targets });
}
static void PayHp(Unit u, float ratio)
{
int cost = Mathf.RoundToInt(u.MaxHp * Mathf.Max(0f, ratio));
if (cost <= 0) return;
u.Hp = Mathf.Max(1, u.Hp - cost); // 스스로 쓰러지지는 않는다
if (u.View != null)
{
u.View.OnHit();
u.View.Refresh();
}
Fx.Popup(u.Position, "HP −" + cost, SkillFx.CostColor);
}
/// <summary>장판 일갈: 반경(p2) 안 상대 전원을 바깥으로 p1 밀고 이번 적 턴 교전 반응 없음.</summary>
IEnumerator PushFear(UnitSkills s, SkillEffect e)
{
var u = s.Unit;
float radius = e.HasA2 ? e.A2 : 0f;
var targets = new HashSet<Unit>();
int running = 0;
Faction other = u.Faction == Faction.Player ? Faction.Enemy : Faction.Player;
foreach (var t in battle.AliveUnits(other))
{
if (Vector2.Distance(u.Position, t.Position) > radius + t.Radius) continue;
targets.Add(t);
Vector2 dir = t.Position - u.Position;
dir = dir.sqrMagnitude > 1e-6f ? dir.normalized : Vector2.up;
float force = e.P * SkillMods.KnockbackTakenMult(t, true);
float slide = Combat.FreeTravel(t, t.Position, dir, force, true, true, u);
if (slide <= 0.01f) continue;
Vector2 end = battle.Board.ClampInside(t.Position + dir * slide, t.Radius);
running++;
var unit = t;
if (unit.View != null) unit.View.SetFacing(dir);
Fx.Run(Track(Fx.MoveAlong(unit.transform, unit.Position, end, battle.Config.moveSpeed, null), () =>
{
unit.Position = end;
running--;
}));
}
Fx.Burst(u.Position, radius, SkillFx.FearColor);
while (running > 0) yield return null;
var t2 = new Timed { Owner = s, Faction = u.Faction, Def = s.Active, E = e, EnemyWindow = true, Targets = targets };
TimedEffects.Add(t2);
battle.NotifyChanged();
}
static IEnumerator Track(IEnumerator inner, System.Action onDone)
{
yield return inner;
onDone();
}
// ---------- 변진(장합): 목표 지점 탭 ----------
public static float TeleportRadius(UnitSkills s)
{
if (s == null || s.ActiveEffects == null) return 0f;
foreach (var e in s.ActiveEffects)
if (e.Key == SkillKeys.Teleport) return e.P;
return 0f;
}
/// <summary>목표 지점이 반경 안 빈 곳인가(벽·유닛과 겹치지 않음 · 전장 안).</summary>
public bool TeleportValid(Unit u, Vector2 w)
{
var s = Of(u);
if (s == null) return false;
if (Vector2.Distance(u.Position, w) > TeleportRadius(s) + 1e-3f) return false;
if ((battle.Board.ClampInside(w, u.Radius) - w).sqrMagnitude > 1e-4f) return false;
Physics2D.SyncTransforms();
foreach (var c in Physics2D.OverlapCircleAll(w, u.Radius))
{
if (c.GetComponent<BoardWall>() != null) return false;
var o = c.GetComponent<Unit>();
if (o != null && o != u && o.IsAlive) return false;
}
return true;
}
/// <summary>변진 대기 중 화면 탭 → 처리했으면 true(유효 = 이동 · 아니면 취소).</summary>
public bool TryTeleportTap(Vector2 w, out string message)
{
message = null;
var u = PendingTeleport;
if (u == null) return false;
PendingTeleport = null;
var s = Of(u);
if (s == null || !u.IsAlive || battle.Phase != BattlePhase.PlayerTurn) return true;
if (!TeleportValid(u, w))
{
message = s.Active.NameKo + " 취소 — 반경 안 빈 곳이 아님";
return true;
}
battle.RunSkillRoutine(TeleportRoutine(s, w));
message = s.Active.NameKo + " 발동!";
return true;
}
public IEnumerator TeleportRoutine(UnitSkills s, Vector2 w)
{
var u = s.Unit;
Announce(s, s.Active);
s.ReadyTurn = battle.TurnNumber + s.Active.Cooldown;
if (u.View != null) u.View.SetFacing(w - u.Position);
yield return Fx.MoveAlong(u.transform, u.Position, w, battle.Config.moveSpeed * 2f, null);
u.Position = w;
battle.NotifyChanged();
}
// ---------- 연출 · HUD 기록 ----------
/// <summary>액티브 발동 = 유닛 위 스킬 이름 + 플래시 + HUD 한 줄.</summary>
public void Announce(UnitSkills s, SkillDef def)
{
if (s == null || def == null) return;
if (s.Unit != null) SkillFx.Announce(s.Unit, def.NameKo);
AddLog("액티브 · " + (s.Unit != null ? s.Unit.DisplayName : "") + " 「" + def.NameKo + "」");
}
/// <summary>패시브 발동 HUD 한 줄(같은 행동 안에서는 스킬마다 1번).</summary>
public void NotePassive(UnitSkills s, SkillDef def)
{
if (s == null || def == null || s.Unit == null) return;
string key = s.Unit.GetInstanceID() + "/" + def.Id;
if (!noted.Add(key)) return;
AddLog("패시브 · " + s.Unit.DisplayName + " 「" + def.NameKo + "」");
}
public void AddLog(string text)
{
Log.Add(new LogLine { Text = text, Time = Time.unscaledTime });
if (Log.Count > 8) Log.RemoveAt(0);
}
/// <summary>최근 seconds 초 안의 기록(최신이 마지막).</summary>
public List<string> RecentLog(float seconds, int max)
{
var res = new List<string>();
float now = Time.unscaledTime;
for (int i = Log.Count - 1; i >= 0 && res.Count < max; i--)
if (now - Log[i].Time <= seconds) res.Insert(0, Log[i].Text);
return res;
}
}
/// <summary>스킬 연출(이름 글자 · 플래시).</summary>
public static class SkillFx
{
public static readonly Color NameColor = new Color(1f, 0.85f, 0.3f);
public static readonly Color FlashColor = new Color(1f, 0.9f, 0.45f, 0.55f);
public static readonly Color GuardColor = new Color(0.55f, 0.85f, 1f);
public static readonly Color FearColor = new Color(1f, 0.4f, 0.25f, 0.4f);
public static readonly Color CostColor = new Color(1f, 0.55f, 0.35f);
public static readonly Color HealColor = new Color(0.45f, 1f, 0.5f);
public static void Announce(Unit u, string name)
{
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));
}
static IEnumerator NameRoutine(Vector2 pos, string text)
{
var tm = TextUtil.CreateWorldText(null, text, 0.55f, NameColor, 21);
tm.gameObject.name = "SkillName";
var tr = tm.transform;
float t = 0f;
const float dur = 1.1f;
while (t < dur)
{
t += Time.deltaTime;
float k = Mathf.Clamp01(t / dur);
tr.position = new Vector3(pos.x, pos.y + 0.4f * k, 0f);
var c = NameColor;
c.a = k < 0.7f ? 1f : 1f - (k - 0.7f) / 0.3f;
tm.color = c;
yield return null;
}
Object.Destroy(tm.gameObject);
}
}
}

View File

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

View File

@ -38,6 +38,7 @@ namespace TKS
LineRenderer shotLine; // 궁병·책사 투사체 경로(쏘는 위치 → 첫 피격 지점 · PD 2026-11-05 #1087 ⑥)
SpriteRenderer targetRing;
SpriteRenderer splashArea; // 책사 광역 원(중심 = 첫 피격 유닛 · #1087 ⑤)
SpriteRenderer teleportArea; // 변진(장합) 목표 지점 반경(PD #1093 B)
readonly List<Unit> highlighted = new List<Unit>();
public Unit Selected
@ -89,6 +90,8 @@ namespace TKS
targetRing.enabled = false;
splashArea = ProceduralSprites.NewSprite(transform, "SplashArea", ProceduralSprites.Circle, SplashAreaColor, 1f, -8);
splashArea.gameObject.SetActive(false);
teleportArea = ProceduralSprites.NewSprite(transform, "TeleportArea", ProceduralSprites.Circle, new Color(1f, 0.85f, 0.3f, 0.18f), 1f, -8);
teleportArea.gameObject.SetActive(false);
var rgo = new GameObject("RangeIndicator");
rgo.transform.SetParent(transform, false);
@ -140,10 +143,41 @@ namespace TKS
}
if (Input.GetMouseButtonUp(0)) OnRelease(screen);
// 조준 중(첫 손가락으로 당기는 중) 다른 손가락으로 스킬 버튼 탭 = 예약/취소(PD #1093 B)
if (dragging && hud != null && Input.touchCount > 1)
{
for (int i = 1; i < Input.touchCount; i++)
{
var t = Input.GetTouch(i);
if (t.phase == TouchPhase.Began && hud.TrySkillTap(t.position) && selected != null && previewValid) ShowPreview(selected, currentTarget, null);
}
}
RefreshTeleportArea();
float wheel = Input.mouseScrollDelta.y;
if (Mathf.Abs(wheel) > 0.01f && camCtrl != null) camCtrl.Zoom(1f - wheel * 0.1f);
}
/// <summary>변진 목표 지점 대기 중이면 반경 원 표시.</summary>
void RefreshTeleportArea()
{
var sys = battle.Skills;
var u = sys != null ? sys.PendingTeleport : null;
bool on = u != null && u.IsAlive;
if (on)
{
float d = SkillSystem.TeleportRadius(sys.Of(u)) * 2f;
teleportArea.transform.position = new Vector3(u.Position.x, u.Position.y, 0f);
teleportArea.transform.localScale = new Vector3(d, d, 1f);
}
if (teleportArea.gameObject.activeSelf != on) teleportArea.gameObject.SetActive(on);
}
public bool TeleportAreaVisible
{
get { return teleportArea != null && teleportArea.gameObject.activeSelf; }
}
void OnPress(Vector2 screen)
{
lastScreen = screen;
@ -153,6 +187,15 @@ namespace TKS
emptyPress = false;
previewValid = false;
// 스킬 버튼(HUD) 탭 = 예약/취소/즉시 발동 · 변진 대기 중 = 목표 지점 탭(PD #1093 B)
if (hud != null && hud.TrySkillTap(screen)) return;
string tpMsg;
if (battle.Skills != null && battle.Skills.TryTeleportTap(ScreenToWorld(screen), out tpMsg))
{
if (!string.IsNullOrEmpty(tpMsg)) battle.LastMessage = tpMsg;
return;
}
Vector2 mapWorld;
if (hud != null && camCtrl != null && hud.TryMinimapTap(screen, out mapWorld))
{
@ -199,7 +242,8 @@ namespace TKS
previewValid = false;
return;
}
if (mag > selected.SlingMaxDistance) launch = launch / mag * selected.SlingMaxDistance;
float sling = SkillMods.SlingMax(selected); // 동남풍·적토마(PD #1093 B)
if (mag > sling) launch = launch / mag * sling;
currentTarget = battle.Board.ClampInside(selected.Position + launch, selected.Radius);
previewValid = true;
ShowPreview(selected, currentTarget, w);
@ -318,7 +362,7 @@ namespace TKS
if (friendly) anyFriendly = true;
else foes++;
}
float d = battle.Config.strategistSplashRadius * 2f;
float d = shot.SplashRadius * 2f; // 화공 = 반경 ×(PD #1093 B)
splashArea.transform.position = new Vector3(shot.Hit.Position.x, shot.Hit.Position.y, 0f);
splashArea.transform.localScale = new Vector3(d, d, 1f);
splashArea.color = anyFriendly ? SplashAreaFriendlyColor : SplashAreaColor;

View File

@ -32,6 +32,7 @@ namespace TKS
TextMesh mark;
TextMesh nameLabel;
TextMesh badge;
TextMesh skillBadge; // 예약한 액티브(next_action) 이름(PD #1093 B)
Color baseColor;
Coroutine flash;
bool aiming;
@ -160,10 +161,19 @@ namespace TKS
badge = TextUtil.CreateWorldText(transform, "추가 이동", 0.22f, new Color(1f, 0.95f, 0.4f), 5);
badge.transform.localPosition = new Vector3(0f, topY + 0.24f, 0f);
badge.gameObject.SetActive(false);
skillBadge = TextUtil.CreateWorldText(transform, "", 0.24f, SkillFx.NameColor, 5);
skillBadge.transform.localPosition = new Vector3(0f, topY + 0.5f, 0f);
skillBadge.gameObject.SetActive(false);
Refresh();
}
/// <summary>예약 표시 중인가(테스트·캡처 확인용).</summary>
public bool SkillBadgeVisible
{
get { return skillBadge != null && skillBadge.gameObject.activeSelf; }
}
/// <summary>보는 방향(위 = back · 아래 = front)에 맞는 클립 이름. 그 보기에 없으면 다른 보기 → 대기.</summary>
string ClipName(string suffix)
{
@ -191,8 +201,18 @@ namespace TKS
mark.color = dim ? new Color(1f, 1f, 1f, 0.6f) : Color.white;
bool ow = unit.Overwatch && unit.Type == UnitType.Spear && unit.IsAlive;
if (ow)
{
float d = SkillMods.OverwatchRadius(unit) * 2f; // 후계·백마의 창·청룡언월 반영(PD #1093 B)
overwatchArea.transform.localScale = new Vector3(d, d, 1f);
}
if (overwatchArea.gameObject.activeSelf != ow) overwatchArea.gameObject.SetActive(ow);
badge.gameObject.SetActive(unit.BonusMoveAvailable && unit.IsAlive);
var sk = SkillSystem.Current != null ? SkillSystem.Current.Of(unit) : null;
bool reserved = sk != null && sk.Reserved && sk.Active != null && unit.IsAlive;
if (reserved) skillBadge.text = "★" + sk.Active.NameKo;
if (skillBadge.gameObject.activeSelf != reserved) skillBadge.gameObject.SetActive(reserved);
}
public void SetSelected(bool on)

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -45,6 +45,7 @@ Unity.exe -projectPath <proj> -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
- 전투 수치 SOT = `Assets/Resources/BalanceConfig.asset`(Inspector 조정 · 최초 시드만 `Assets/Scripts/Editor/BalanceDefaults.cs`). 코드·테스트에 수치를 직접 적지 않는다(C45).
- **아웃게임 SOT = `Assets/Data/OutGame/*.csv`(12장 · 기획 `공유/기획팀_산출물/삼국지슬링샷/tables/` 사본 · utf-8-sig · 2026-11-05 #1093 A)** — 영웅·병사(`heroes.csv` · 구 `BalanceConfig.heroes[]` 대체) · 레벨·승급·진화·장비·뽑기·보상·재화·신규 유저 초기값(`economy.csv` start_*). 고친 뒤 메뉴 `TKS/Import OutGame Data (아웃게임 데이터 임포트)`(`Editor/DataImporter` · 오류 시 자산 불변) → `Resources/OutGame/` 9 자산. 표를 바꾸면 기획 `tables/` 도 같이.
- **스킬(2026-11-05 #1093 B)** — SOT = `Assets/Data/OutGame/skills.csv`(effect_key `;` 복수 · p1~p3 · lv_scale · needs_impl) + `skill_levels.csv` → 임포트 자산 `SkillInfoDb`(effect 열 포함 · 표시 + 전투 공용). 처리기 = `Runtime/Skills/`: `SkillKeys`(51종 · 레벨 계수 방식 · 팀 키 · 표에 없는 키 = 임포트 오류) · `SkillSystem`(유닛별 쿨·예약 · 즉시형 시한 효과 · 행동 훅 · 호위 피해 분담 · HUD 한 줄) · `SkillMods`(전투 코드가 수치 질의) · `SkillRules`(상한 = 등급 + ★3 · ★5 패시브 배율 = evolve.csv skill_unlock · 레벨업 · 스킬서 판매). 전투 코드에 스킬 수치를 직접 쓰지 않고 `SkillMods` 로 읽는다. 테스트 = `Tests/PlayMode/SkillTests`. 캡처 = `-executeMethod TKS.EditorTools.DataImporter.CaptureSkills -skillDir <dir>`.
- 아웃게임 코드 = `Assets/Scripts/OutGame/**`(asmref → TKS.Runtime) — `OutGameSession`(표 + 세이브 · 바꾸는 동작은 즉시 저장) · 규칙 `HeroRules`·`EconomyRules`·`GachaRules` · 화면 `UI/`(로비·영웅·편성·거래·결과 패널 · `OutGameRoot` 가 전환). 세이브 = `persistentDataPath/save.json`(테스트·검증 캡처는 `SaveStore.UseMemory` / SessionState `TKS_SAVE_MEMORY` 로 메모리 전용). 출전 = `BattleManager.LineupProvider`(편성 프리셋 → `playerSpawnPoints` 자리 0~2 전열 · 3~4 후열). 화면 캡처 = `Unity.exe -projectPath <사본> -executeMethod TKS.EditorTools.DataImporter.CaptureOutGame -outgameDir <dir>`.
- **챕터·스테이지 SOT = `Assets/Data/Stages/*.csv`(utf-8-sig · 2026-11-05 145 스테이지 · 기획서 #1086 §4 전사)** — `stages.csv`(chapter·stage·title·type normal|elite|boss·place·faction·병종 4 수·generals `id:병종:true|false`·bg_theme·note) · `chapters.csv`(장 제목·연도·**월드맵 맵 id**·AI 출발값) · `generals.csv`(무장 id → 이름·배율 덮어쓰기) · `factions.csv`(세력 코드 → 이름·병사 아트 접두). 고친 뒤 메뉴 `TKS/Import Stages (스테이지 임포트)`(`StageImporter` · 형식 오류면 자산 미변경) → `BalanceConfig.chapters·stages`. 적 좌표 = `BalanceConfig.formation` 규칙(전위·중군·후군·장수 줄 · `StageTable.Enemies`) · 장수 배율 = `generals.csv` 가 비면 `boss/eliteGeneral*Mult`. 월드맵 노드 = 스테이지 표(챕터별 수 · 보스 = type boss) · 노드 자리 = `ChapterMap/Data/<맵>.json` `slots_N`(없으면 길 위 균등).
- 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 22종 이상 전부 통과 확인.
@ -70,5 +71,6 @@ Unity.exe -projectPath <proj> -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
- `Assets/Scripts/OutGame/` — 아웃게임(세이브·로비·영웅·편성·거래·보상 · 위 「코드 규칙」)
- `Assets/Data/Stages/` — 챕터·스테이지 표 CSV 4장(위 「코드 규칙」) · 해석·배치 = `Runtime/StageTable`
- `Assets/Resources/Units/<art>/` — 유닛 아트 프레임 + meta.json(위 절)
- `Assets/Tests/PlayMode/` — `BattleFlowTests` · `OutGameTests`
- `Assets/Scripts/Runtime/Skills/` — 스킬 처리기(위 「코드 규칙」 스킬)
- `Assets/Tests/PlayMode/` — `BattleFlowTests` · `OutGameTests` · `SkillTests`
- `.claude/skills/` — Unity CLI 에이전트 스킬(`unity skill install claude-code --local` 산출물 · 재설치는 `unity skill refresh`)