diff --git a/Assets/Data/OutGame/economy.csv b/Assets/Data/OutGame/economy.csv
index 4c3b589c..c1f1b259 100644
--- a/Assets/Data/OutGame/economy.csv
+++ b/Assets/Data/OutGame/economy.csv
@@ -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
diff --git a/Assets/Resources/OutGame/EconomyConfig.asset b/Assets/Resources/OutGame/EconomyConfig.asset
index 0c31e985..01ad80e3 100644
--- a/Assets/Resources/OutGame/EconomyConfig.asset
+++ b/Assets/Resources/OutGame/EconomyConfig.asset
@@ -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
diff --git a/Assets/Resources/OutGame/SkillInfoDb.asset b/Assets/Resources/OutGame/SkillInfoDb.asset
index a1bbbc22..5af81b78 100644
--- a/Assets/Resources/OutGame/SkillInfoDb.asset
+++ b/Assets/Resources/OutGame/SkillInfoDb.asset
@@ -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
diff --git a/Assets/Scripts/Editor/DataImporter.cs b/Assets/Scripts/Editor/DataImporter.cs
index e2f04ec6..5ba5dc72 100644
--- a/Assets/Scripts/Editor/DataImporter.cs
+++ b/Assets/Scripts/Editor/DataImporter.cs
@@ -87,6 +87,23 @@ namespace TKS.EditorTools
EditorApplication.EnterPlaymode();
}
+ ///
+ /// 스킬 화면 검증 캡처(GUI 에디터 · 사본 프로젝트 권장 · 세이브 = 메모리 전용 · PD #1093 B):
+ /// Unity.exe -projectPath <copy> -executeMethod TKS.EditorTools.DataImporter.CaptureSkills -skillDir <dir>
+ /// 스킬 레벨업 · 스킬 버튼 · 액티브 발동 · 패시브 표시 PNG 4장 + log · 끝나면 스스로 종료.
+ ///
+ 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();
diff --git a/Assets/Scripts/OutGame/Data/EconomyConfig.cs b/Assets/Scripts/OutGame/Data/EconomyConfig.cs
index ac43e21d..f489d026 100644
--- a/Assets/Scripts/OutGame/Data/EconomyConfig.cs
+++ b/Assets/Scripts/OutGame/Data/EconomyConfig.cs
@@ -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"); } }
/// 중복 영웅 → 조각(희귀도 1~3).
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",
};
}
}
diff --git a/Assets/Scripts/OutGame/Data/SkillInfoDb.cs b/Assets/Scripts/OutGame/Data/SkillInfoDb.cs
index 27c83339..1676307a 100644
--- a/Assets/Scripts/OutGame/Data/SkillInfoDb.cs
+++ b/Assets/Scripts/OutGame/Data/SkillInfoDb.cs
@@ -5,7 +5,8 @@ using UnityEngine;
namespace TKS.OutGame
{
///
- /// 스킬 표시 정보(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(스킬 레벨 표 · 스킬 레벨업 비용).
///
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;
}
+ /// 그 레벨이 되는 비용(skill_levels.csv lv 행 · 없으면 null = 최고).
+ 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;
+ }
+ }
+
/// 표시용 설명 — 끝의 [p1 … ] 개발 메모를 뺀다.
public static string DisplayDesc(Skill s)
{
diff --git a/Assets/Scripts/OutGame/HeroRules.cs b/Assets/Scripts/OutGame/HeroRules.cs
index 47678581..dac4b461 100644
--- a/Assets/Scripts/OutGame/HeroRules.cs
+++ b/Assets/Scripts/OutGame/HeroRules.cs
@@ -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();
foreach (var id in e.StartHeroes)
diff --git a/Assets/Scripts/OutGame/OutGameSession.cs b/Assets/Scripts/OutGame/OutGameSession.cs
index 071992fa..11f93dd1 100644
--- a/Assets/Scripts/OutGame/OutGameSession.cs
+++ b/Assets/Scripts/OutGame/OutGameSession.cs
@@ -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;
}
diff --git a/Assets/Scripts/OutGame/OutGameTables.cs b/Assets/Scripts/OutGame/OutGameTables.cs
index 0acfe33e..fa525c2a 100644
--- a/Assets/Scripts/OutGame/OutGameTables.cs
+++ b/Assets/Scripts/OutGame/OutGameTables.cs
@@ -212,7 +212,7 @@ namespace TKS.OutGame
{
var t = ScriptableObject.CreateInstance();
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();
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;
}
diff --git a/Assets/Scripts/OutGame/SaveData.cs b/Assets/Scripts/OutGame/SaveData.cs
index 49d7a615..96fb2e8c 100644
--- a/Assets/Scripts/OutGame/SaveData.cs
+++ b/Assets/Scripts/OutGame/SaveData.cs
@@ -129,6 +129,9 @@ namespace TKS.OutGame
public string day = "";
public List items = new List();
public int refreshes;
+ [Tooltip("스킬서 임시 판매(PD #1093 B) — 구매한 날(yyyyMMdd) · 그날 구매 수")]
+ public string bookDay = "";
+ public int bookBought;
}
[Serializable]
diff --git a/Assets/Scripts/OutGame/UI/HeroScreens.cs b/Assets/Scripts/OutGame/UI/HeroScreens.cs
index d5e9e8b0..d8cc4aa8 100644
--- a/Assets/Scripts/OutGame/UI/HeroScreens.cs
+++ b/Assets/Scripts/OutGame/UI/HeroScreens.cs
@@ -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, "스킬 레벨업\nB 단계(준비 중)", 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" + (h.isSoldier ? "병사 = 스킬 없음" : "스킬서 " + S.Save.inv.books.skill) + "";
+ skillBtn.GetComponent().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칸");
}
+ /// 스킬 레벨업(PD #1093 B): 액티브·패시브 중 고르기 → 스킬서 + 골드(skill_levels.csv) · 상한 = 등급 + ★3 액티브 +1.
+ 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();
+ 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 ").Append(why).Append("");
+ 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);
diff --git a/Assets/Scripts/OutGame/UI/TradeScreen.cs b/Assets/Scripts/OutGame/UI/TradeScreen.cs
index dc99c9a9..be74b561 100644
--- a/Assets/Scripts/OutGame/UI/TradeScreen.cs
+++ b/Assets/Scripts/OutGame/UI/TradeScreen.cs
@@ -44,9 +44,24 @@ namespace TKS.OutGame
get { return resultRoot != null && resultRoot.activeSelf; }
}
+ /// 장비 상점 칸 수(스킬서 임시 판매 칸 제외).
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";
+
+ /// 스킬서 판매 칸이 있는가(PD #1093 B).
+ 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);
+ }
+
+ /// 스킬서 임시 판매(PD #1093 B · 하루 skill_book_shop_daily 개 · 골드 skill_book_price · 정식 획득처 = 사당 던전(C) · PD 확인 대상).
+ 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;
}
/// 그 장비를 지금 등급 칸에서 기다리는(아직 안 채운) 보유 영웅 수.
diff --git a/Assets/Scripts/Runtime/ActionResolver.cs b/Assets/Scripts/Runtime/ActionResolver.cs
index 4f129267..3de8caa3 100644
--- a/Assets/Scripts/Runtime/ActionResolver.cs
+++ b/Assets/Scripts/Runtime/ActionResolver.cs
@@ -26,6 +26,22 @@ namespace TKS
public List Notes = new List();
/// 실제로 지나간 경로(시작 포함). 창병 견제 판정용.
public List Path = new List();
+
+ // ---------- 스킬(PD #1093 B) ----------
+ /// 이 행동에 걸린 예약 액티브(next_action) · null = 없음.
+ public SkillDef SkillUsed;
+ /// 추가 이동 횟수(기본 1 · 인덕 extra_move_count) · 추가 이동에서 공격 가능(extra_move_attack).
+ public int BonusMoves = 1;
+ public bool BonusAttack;
+ /// 이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정).
+ public List Displaced = new List();
+ }
+
+ public struct Displacement
+ {
+ public Unit Unit;
+ public Vector2 From;
+ public Vector2 To;
}
///
@@ -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).
///
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();
- 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();
+ 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
}
}
+ /// 삼첨도(spear_hit_two): 밀며 전진 끝에 창병·밀린 적과 닿은 다른 적까지 합쳐 최대 n 기에 동시 피해(반격 없음).
+ 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();
+ 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();
+ 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)
+ /// false = 위진소요의 추가 관통(다시 반복·복귀하지 않음 · exclude = 앞서 관통한 적 제외).
+ IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o, bool first, List exclude = null)
{
float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); // 유닛은 무시 · 벽만
travel = StopBeforeUnclearedAllies(u, origin, dir, travel); // 지나칠 수 없는 아군 직전에서 멈춤
var dists = new List();
var enemies = moveOnly ? new List() : 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;
/// 책사: 중심 주변 광역 대상(아군 포함 · 중심·쏜 책사 제외 · 가까운 순).
public readonly List Splash = new List();
+ /// 책사 광역 반경(화공 배율 반영).
+ public float SplashRadius;
+ /// 백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음).
+ 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
///
/// 투사체 1발: 첫 피격 유닛에 사격 피해(아군이면 오사 · 상성 적용 · 반격 없음 · 명중 보상 없음 · 밀지 않으므로 규칙 ⑤ 없음)
- /// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음).
+ /// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음 · 화공·팔문 간파 반영).
///
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
/// 사격 피해 적용 + 집계(상대 = Attacked · 같은 편 = FriendlyHit 오사).
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);
}
- /// 부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계.
- void ImpactExchange(Unit a, Unit d, ActionOutcome o)
+ /// 부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계. 반환 = 준 피해.
+ 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;
}
- /// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio.
- IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o)
+ ///
+ /// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio × 밀기 배율(청룡도 지기·호치·함진영) × 대상 밀림 배율(거구)
+ /// / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio × 자기 튕김 배율(인덕·호치 = 0). impactDmg = 함진영 충돌 추가 피해 기준.
+ ///
+ 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 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();
}
- /// 부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다.
- IEnumerator Push(Unit target, Vector2 n, float force, Unit pusher, ActionOutcome o)
+ /// 부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다. onEnd(true) = 다 못 가고 막힘.
+ IEnumerator Push(Unit target, Vector2 n, float force, Unit pusher, ActionOutcome o, System.Action 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);
}
- /// 규칙 ⑤: 밀린 아군이 상대 유닛과 닿아 있으면 그 자리에서 공격 1회(근접 · 자기 행동 소모 없음).
- IEnumerator PushedAllyAttack(Unit ally, ActionOutcome o)
+ ///
+ /// 규칙 ⑤: 밀린 아군이 상대 유닛과 닿아 있으면 그 자리에서 공격(근접 · 자기 행동 소모 없음) — 기본 1회 ·
+ /// 민 유닛의 군령(pushed_ally_attack_count = n 회) · 대장군(pushed_ally_attack_on_push = +n 회).
+ ///
+ 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);
+ }
}
- /// 밀린 아군의 근접 공격 1회(반격 받음 · 결과 요약에는 집계하지 않고 Notes 에 기록). 원거리 병종도 근접 패널티를 받는다.
+ /// 밀린 아군의 근접 공격 1회(반격 받음 · 결과 요약에는 집계하지 않고 Notes 에 기록 · 도원의 맹약 = 피해 +%). 원거리 병종도 근접 패널티를 받는다.
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 : ""));
diff --git a/Assets/Scripts/Runtime/BattleHUD.cs b/Assets/Scripts/Runtime/BattleHUD.cs
index df879ead..6bf4fa58 100644
--- a/Assets/Scripts/Runtime/BattleHUD.cs
+++ b/Assets/Scripts/Runtime/BattleHUD.cs
@@ -5,9 +5,16 @@ using UnityEngine.UI;
namespace TKS
{
- /// 화면 HUD(uGUI · 코드 생성): 스테이지·턴·유닛 수·안내·선택 유닛 정보·미니맵·스킬 아이콘(추후)·결과 오버레이.
+ /// 화면 HUD(uGUI · 코드 생성): 스테이지·턴·유닛 수·안내·선택 유닛 정보·미니맵·스킬 버튼(PD #1093 B)·스킬 발동 한 줄·결과 오버레이.
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 minimapDots = new Dictionary();
@@ -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 : ""; }
+ }
+
+ /// 화면 점이 스킬 버튼 위면 탭 처리(선택 유닛 액티브 예약·취소·즉시 발동) 후 true — 슬링샷 입력은 건너뛴다.
+ public bool TrySkillTap(Vector2 screen)
+ {
+ if (!SkillButtonVisible || battle == null) return false;
+ if (!RectTransformUtility.RectangleContainsScreenPoint(skillPanel.rectTransform, screen, uiCam)) return false;
+ PressSkill();
+ return true;
+ }
+
+ /// 스킬 버튼 누름(탭 · 테스트 · 캡처 공용).
+ 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;
+ }
+
/// 화면 점이 미니맵 안이면 해당 월드 좌표를 돌려준다.
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);
diff --git a/Assets/Scripts/Runtime/BattleManager.cs b/Assets/Scripts/Runtime/BattleManager.cs
index 63a3ad0b..ea5b9695 100644
--- a/Assets/Scripts/Runtime/BattleManager.cs
+++ b/Assets/Scripts/Runtime/BattleManager.cs
@@ -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;
+ /// ★5 패시브 배율(evolve.csv skill_unlock passive_x · 그 아래 ★ = 1).
+ public float passiveMult = 1f;
+ /// 무장(영웅) — 참장 대상.
+ public bool general;
}
/// 턴 진행·유닛 관리·승패 판정. 아군은 모든 유닛이 행동하면 자동으로 적군 턴이 된다(버튼 없음).
@@ -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; }
+ /// 스킬 상태·시한 효과(PD #1093 B) · 수치 질의 = SkillMods.
+ public SkillSystem Skills { get; private set; }
public string LastMessage = "";
readonly List units = new List();
@@ -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());
}
+ /// 즉시형 액티브 연출(장판 일갈 밀기 · 변진 이동 등) — 아군 턴에만 · 끝나면 다시 아군 턴(행동 소모 없음).
+ 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
/// 창병 대기: 상대 유닛이 지나간 경로가 대기 중인 창병 반경을 스치면 피해(창병마다 1회).
public IEnumerator ApplyOverwatch(Unit mover, List 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);
+ }
+
+ ///
+ /// 창병 대기 견제(스킬 반영 · PD #1093 B): 반경 = SkillMods.OverwatchRadius(후계·백마의 창·청룡언월) · 지나간 적 1기당 1회(기본) × 견제 횟수(삼대 노신 = n 회 타격) ·
+ /// 「견제 무제한」(청룡언월) = 행동 유닛 경로가 반경을 스친 구간마다 + 그 행동으로 밀리거나 넉백된 상대 유닛도 타격 · 피해 × 교변 · 쓰러뜨리면 하북 명장 회복.
+ ///
+ 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();
+ 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);
}
}
+ /// 하북 명장: 견제로 적을 쓰러뜨리면 최대 HP × p 회복.
+ 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();
}
+ /// 테스트·캡처: 턴 번호 지정(스킬 쿨·선봉 1턴 확인).
+ 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)
{
diff --git a/Assets/Scripts/Runtime/Combat.cs b/Assets/Scripts/Runtime/Combat.cs
index 1c06ec8d..44774edc 100644
--- a/Assets/Scripts/Runtime/Combat.cs
+++ b/Assets/Scripts/Runtime/Combat.cs
@@ -15,19 +15,22 @@ namespace TKS
// ---------- 피해 공식 ----------
///
- /// 피해 = (공격 × 상성 × 크리 − 방어 × 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).
///
- 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)
+ ///
+ /// 반격 피해 = 반격하는 쪽(defender) 피해 × 반격 배율(무성·진중 등 · 기본 counterRatio) × 받는 쪽(attacker) 반격 피해 배율(군율 · 금범적).
+ /// defenderPushed = 반격하는 유닛이 이 교환에서 밀렸다(알까기·밀며 전진·관통 넉백) — 진중(밀렸을 때만) 조건. 0 = 반격 없음.
+ ///
+ 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));
}
+ /// 견제 피해 = 근접 피해 × overwatchDamageRatio × 교변(overwatch_dmg_mult).
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
///
/// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외).
/// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist).
+ /// passFaction = 그 진영 유닛은 통과(백발백중 arrow_pass_allies · 기본 null).
///
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;
}
diff --git a/Assets/Scripts/Runtime/EnemyAI.cs b/Assets/Scripts/Runtime/EnemyAI.cs
index 0bd7a187..3cc90fa9 100644
--- a/Assets/Scripts/Runtime/EnemyAI.cs
+++ b/Assets/Scripts/Runtime/EnemyAI.cs
@@ -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];
diff --git a/Assets/Scripts/Runtime/RangeIndicator.cs b/Assets/Scripts/Runtime/RangeIndicator.cs
index d5f0a5c9..4ddd4aad 100644
--- a/Assets/Scripts/Runtime/RangeIndicator.cs
+++ b/Assets/Scripts/Runtime/RangeIndicator.cs
@@ -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;
}
diff --git a/Assets/Scripts/Runtime/Skills.meta b/Assets/Scripts/Runtime/Skills.meta
new file mode 100644
index 00000000..ff153c7e
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 7daafd22d5ac3a64aa327fffd860c149
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/Runtime/Skills/SkillCaptureDriver.cs b/Assets/Scripts/Runtime/Skills/SkillCaptureDriver.cs
new file mode 100644
index 00000000..d33f30d7
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillCaptureDriver.cs
@@ -0,0 +1,187 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using UnityEngine;
+
+namespace TKS.OutGame
+{
+ ///
+ /// 스킬 화면 검증 캡처(에디터 DataImporter.CaptureSkills 가 SessionState 로 출력 폴더를 넘기면 생성 · 세이브 = 메모리 전용 신규 유저 · PD #1093 B).
+ /// ① 스킬 레벨업(영웅 상세 → 팝업 · 액티브 Lv+1 뒤) ② 스킬 버튼(전투 · 관우 선택) ③ 액티브 발동(장비 장판 일갈 · 이름 글자 + 플래시 + 밀기)
+ /// ④ 패시브 표시(조운 관통 → 상산의 창 HUD 한 줄). 캡처를 위해 적 1기씩을 아군 앞에 옮겨 둔다(그 밖은 실제 처리).
+ ///
+ 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 log = new List();
+ 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();
+ 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();
+ 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
+ }
+ }
+}
diff --git a/Assets/Scripts/Runtime/Skills/SkillCaptureDriver.cs.meta b/Assets/Scripts/Runtime/Skills/SkillCaptureDriver.cs.meta
new file mode 100644
index 00000000..7533c506
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillCaptureDriver.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9feab08c83b313f46bd52dea2f5ff769
\ No newline at end of file
diff --git a/Assets/Scripts/Runtime/Skills/SkillDef.cs b/Assets/Scripts/Runtime/Skills/SkillDef.cs
new file mode 100644
index 00000000..9de4d714
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillDef.cs
@@ -0,0 +1,169 @@
+using System.Collections.Generic;
+using TKS.OutGame;
+using UnityEngine;
+
+namespace TKS
+{
+ public enum SkillTrigger
+ {
+ /// 액티브 · 다음 슬링샷 1회에 붙는다(예약 · 행동 전 다시 탭 = 취소 · 쿨 소모 없음).
+ NextAction,
+ /// 액티브 · 즉시 발동(행동 소모 없음).
+ Instant,
+ /// 패시브 · 상시.
+ Always
+ }
+
+ /// 스킬 효과 1개(effect_key 1개 + 값). 키가 하나뿐인 행은 p2·p3 = 그 키의 조건·보조값.
+ 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,
+ };
+ }
+ }
+
+ /// skills.csv 1행의 전투용 정의(SkillInfoDb.Skill → effect_key 를 키 규약대로 풀어 둠).
+ 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 Effects = new List();
+
+ 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;
+ }
+
+ /// 레벨 계수 = 1 + lv_scale·(lv−1) (lv 는 1~lv_max 로 자름).
+ public float LevelFactor(int lv)
+ {
+ return 1f + LvScale * (Mathf.Clamp(lv, 1, LvMax) - 1);
+ }
+
+ /// 레벨·★ 배율(패시브 ★5 = evolve.csv passive_x)을 적용한 효과 목록.
+ public List ScaledEffects(int lv, float starMult)
+ {
+ float f = LevelFactor(lv) * (IsActive ? 1f : starMult);
+ var list = new List();
+ 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;
+ }
+ }
+
+ /// 스킬·영웅 표 조회(Resources/OutGame 의 SkillInfoDb · HeroDb = 임포트 자산 · 전투 시작마다 다시 읽음).
+ public static class SkillCatalog
+ {
+ static Dictionary 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();
+ 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 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;
+ }
+
+ /// 적 무장 → heroes.csv 영웅(아트 id 가 같으면 · 없으면 이름이 같은 영웅) · 병사 유닛 제외 · 없으면 null.
+ 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;
+ }
+ }
+}
diff --git a/Assets/Scripts/Runtime/Skills/SkillDef.cs.meta b/Assets/Scripts/Runtime/Skills/SkillDef.cs.meta
new file mode 100644
index 00000000..416b2ba1
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillDef.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 18b39b6c930d65c468093779c77e4777
\ No newline at end of file
diff --git a/Assets/Scripts/Runtime/Skills/SkillKeys.cs b/Assets/Scripts/Runtime/Skills/SkillKeys.cs
new file mode 100644
index 00000000..0d27b274
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillKeys.cs
@@ -0,0 +1,163 @@
+using System.Collections.Generic;
+
+namespace TKS
+{
+ /// 스킬 수치가 레벨(lv_scale)·★5(패시브 배율)로 커지는 방식.
+ public enum SkillScale
+ {
+ /// 값 × 계수(비율·가산·거리·반경 · 기획서 §5 「수치 × (1 + lv_scale·(lv−1))」).
+ Value,
+ /// 배율(×k)은 1 을 넘는 몫만 키운다: 1 + (k − 1) × 계수 (×2 가 Lv5 에 ×2.8 이 되지 않게).
+ Mult,
+ /// 횟수 = 반올림(값 × 계수).
+ Count,
+ /// 켜고 끄는 값·치환 값(커지지 않음).
+ Fixed
+ }
+
+ ///
+ /// effect_key 51종 처리기 목록(시스템 기획 §5 · skills.csv) — 키 이름 · 레벨 계수 적용 방식 · 같은 편 전원에게 걸리는가.
+ /// 이 표는 「처리기의 뜻」이고 수치는 전부 skills.csv(SkillInfoDb)에서 온다(C45). 표에 없는 키는 임포터가 오류로 막는다.
+ /// p 규약: 키가 여럿이면 p1~p3 = 키 순서 · 키 하나면 p1 = 값 · p2/p3 = 그 키의 조건·보조값(desc_ko [ ] 메모).
+ ///
+ 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;
+ /// p2(키 하나인 행의 보조값)도 같은 계수로 키우는가(절대 가산 · 최대치 · 충돌 추가 피해).
+ public bool ScaleA2;
+ /// 같은 편 전원에게 걸리는 효과(주인 = 같은 편 · 살아 있을 때).
+ 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 All = new Dictionary
+ {
+ { 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);
+ }
+
+ /// 레벨·★ 계수 factor 를 적용한 값.
+ 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;
+ }
+ }
+}
diff --git a/Assets/Scripts/Runtime/Skills/SkillKeys.cs.meta b/Assets/Scripts/Runtime/Skills/SkillKeys.cs.meta
new file mode 100644
index 00000000..8624b8e5
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillKeys.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 65ac45ce344197a4b9bb6dd51d5b8493
\ No newline at end of file
diff --git a/Assets/Scripts/Runtime/Skills/SkillMods.cs b/Assets/Scripts/Runtime/Skills/SkillMods.cs
new file mode 100644
index 00000000..d19f2713
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillMods.cs
@@ -0,0 +1,373 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace TKS
+{
+ ///
+ /// 스킬 수치 질의 — ActionResolver · Combat · EnemyAI · BattleManager 가 수치를 읽을 때 부른다(시스템 기획 §5 · PD #1093 B).
+ /// Get(unit, key) = 그 유닛에게 지금 걸린 효과(자기 패시브 · 같은 편 패시브(팀 키) · 장착된 예약 액티브 · 시한 효과).
+ /// 스킬 시스템이 없거나 효과가 없으면 중립값(합 0 · 곱 1)이라 기존 규칙 그대로다.
+ /// note = 실제 적용(피해·밀기) 때만 true → 패시브 발동 HUD 한 줄.
+ ///
+ public static class SkillMods
+ {
+ static readonly List Empty = new List();
+
+ public static List Get(Unit u, string key)
+ {
+ var sys = SkillSystem.Current;
+ if (sys == null || u == null) return Empty;
+ List 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();
+ 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();
+ 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();
+ 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;
+ }
+
+ /// Π(1 + 값) · 0 아래로 내려가지 않음(받는 피해 −% 류).
+ 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;
+ }
+
+ /// 횟수 = 걸린 효과 중 최댓값 · 없으면 fallback.
+ 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;
+ }
+
+ // ---------- 슬링 거리 · 사거리 · 광역 ----------
+
+ /// 슬링 최대 거리 = 기본 × (1 + 비율 합) + 절대 가산(적토마 p2) — 동남풍(이번 턴 아군 전원) 포함.
+ 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);
+ }
+
+ /// 궁병·책사 사거리 = (기본 + range_add + 대도독 team_range_add) × range_mult.
+ public static float Range(Unit u, bool note = false)
+ {
+ if (u == null) return 0f;
+ float r = u.AttackRange + Sum(u, SkillKeys.RangeAdd, note) + Sum(u, SkillKeys.TeamRangeAdd, note);
+ 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);
+ }
+
+ /// 광역 비율 = 화공 치환값(여럿이면 큰 값) · 없으면 자산 값.
+ 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;
+ }
+
+ /// 광역 아군 비율 = 팔문 간파 치환값(여럿이면 작은 값) · 없으면 자산 값.
+ 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);
+ }
+
+ // ---------- 피해 ----------
+
+ /// 상성 하한(대부 · 0 = 없음).
+ 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;
+ }
+
+ /// 공격력 배율 = 1 + 「공 +%」 합(노익장·맹장 HP 조건 · 선봉 1턴 · 고육계 · 복수 · 패자 · 팔백 돌격).
+ 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;
+ }
+
+ /// 주는 피해 배율 = (1 + 알까기 피해 %) × 무장 대상 배율(참장 = 영웅·보스 · 파로 = 보스만).
+ 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);
+ }
+
+ /// 받는 피해 배율 = 수성·노장 × 간웅(이번 적 턴) × 철벽(반경) × 적 궁병 피해 감소.
+ 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;
+ }
+
+ /// 반격 배율 = 무성·무언의 규율(치환) · 진중(p2 1 = 밀렸을 때만).
+ 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));
+ }
+
+ /// 함진영: 밀린 적이 벽·유닛에 닿으면 추가 피해 비율(push_mult 행의 p2).
+ 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));
+ }
+
+ /// 밀리는 거리 배율(거구 −50 %).
+ 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));
+ }
+
+ /// 관통 피해 배율 · 키 하나인 행의 p2 = 최소 관통 수(만인지적 2기 이상).
+ 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);
+ }
+
+ // ---------- 견제 ----------
+
+ /// 견제 반경 = (기본 + 후계 등 패시브 가산 + 이번 대기의 액티브 가산) × 액티브 배율(청룡언월).
+ 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);
+ }
+ }
+}
diff --git a/Assets/Scripts/Runtime/Skills/SkillMods.cs.meta b/Assets/Scripts/Runtime/Skills/SkillMods.cs.meta
new file mode 100644
index 00000000..b83c3d57
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillMods.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7410190a7c798be4baf6e370a7e3461f
\ No newline at end of file
diff --git a/Assets/Scripts/Runtime/Skills/SkillRules.cs b/Assets/Scripts/Runtime/Skills/SkillRules.cs
new file mode 100644
index 00000000..76e0d6b2
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillRules.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Globalization;
+using UnityEngine;
+
+namespace TKS.OutGame
+{
+ ///
+ /// 스킬 성장 규칙(시스템 기획 §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 단계).
+ ///
+ public static class SkillRules
+ {
+ const string ActiveCapToken = "active_lv+";
+ const string PassiveMultToken = "passive_x";
+
+ /// ★ star 까지 해금된 액티브 상한 보정 합(evolve.csv skill_unlock).
+ 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;
+ }
+
+ /// ★ star 까지 해금된 패시브 효과 배율(곱 · 없으면 1).
+ 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;
+ }
+
+ /// 스킬 i(0 = 액티브 · 1 = 패시브) 레벨 상한 = 등급 순서 + (액티브면 ★ 보정) · 최대 lv_max.
+ 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;
+ }
+
+ /// 레벨업 가능하면 null · 아니면 이유.
+ 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;
+ }
+
+ /// 출전 칸에 스킬(id · 레벨 = 상한 안 · ★ 패시브 배율 · 무장)을 채운다.
+ 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;
+ }
+ }
+}
diff --git a/Assets/Scripts/Runtime/Skills/SkillRules.cs.meta b/Assets/Scripts/Runtime/Skills/SkillRules.cs.meta
new file mode 100644
index 00000000..c9c452be
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillRules.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ebc0a493bed15e643ab085730236eb9f
\ No newline at end of file
diff --git a/Assets/Scripts/Runtime/Skills/SkillSystem.cs b/Assets/Scripts/Runtime/Skills/SkillSystem.cs
new file mode 100644
index 00000000..4d48ac10
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillSystem.cs
@@ -0,0 +1,730 @@
+using System.Collections;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace TKS
+{
+ /// 유닛 1기의 스킬 상태(액티브 쿨·예약 · 패시브 · 행동 뒤에도 남는 효과).
+ public class UnitSkills
+ {
+ public Unit Unit;
+ public string HeroId = "";
+ /// 무장(영웅·보스·엘리트 장수) — 참장·파로(dmg_vs_general_mult) 대상 판정.
+ public bool IsGeneral;
+
+ public SkillDef Active;
+ public int ActiveLv = 1;
+ public List ActiveEffects;
+ public SkillDef Passive;
+ public int PassiveLv = 1;
+ public float PassiveMult = 1f;
+ public List PassiveEffects;
+
+ /// 액티브를 쓸 수 있는 첫 아군 턴(전투 시작 = 1 · 사용 턴 + 쿨).
+ public int ReadyTurn = 1;
+ /// next_action 예약(다음 슬링샷 1회) · 행동 전 다시 탭 = 취소.
+ public bool Reserved;
+ /// 예약한 효과가 지금 걸려 있다(그 유닛의 행동 처리·조준 미리보기 동안만).
+ public bool Armed;
+
+ /// 추가 이동: 지금 것 다음에 더 남은 횟수 · 추가 이동에서 공격 가능.
+ public int BonusMovesLeft;
+ public bool BonusAttack;
+ /// 대기(견제) 중 액티브로 얻은 반경 배율·가산·무제한(그 적 턴까지).
+ public float OwMult = 1f;
+ public float OwAdd;
+ public bool OwUnlimited;
+ /// 전투 중 관통한 적 누적(장료 팔백 돌격).
+ public int PierceHits;
+
+ public bool HasActive
+ {
+ get { return Active != null && ActiveEffects != null; }
+ }
+
+ public int CooldownLeft(int turn)
+ {
+ return Mathf.Max(0, ReadyTurn - turn);
+ }
+ }
+
+ /// 스킬 효과 1건이 어느 유닛·스킬에서 왔는가(SkillMods 질의 결과).
+ public struct SkillHit
+ {
+ public UnitSkills Owner;
+ public SkillDef Def;
+ public SkillEffect E;
+ public bool Passive;
+ }
+
+ ///
+ /// 전투 1판의 스킬 처리(PD #1093 B · 시스템 기획 §5): 유닛별 스킬 상태 · 즉시형 액티브의 시한 효과(이번 아군 턴 / 이번 적 턴) ·
+ /// 행동 전후 훅(예약 효과 장착·쿨 소모) · 피해 적용(호위 분담) · HUD 한 줄 기록. 수치 질의는 SkillMods.
+ ///
+ public class SkillSystem
+ {
+ public static SkillSystem Current;
+
+ public class Timed
+ {
+ public UnitSkills Owner;
+ public Faction Faction;
+ public SkillDef Def;
+ public SkillEffect E;
+ /// true = 이번 적 턴에만 걸림(다음 아군 턴 시작에 사라짐) · false = 이번 아군 턴(적 턴 시작에 사라짐).
+ public bool EnemyWindow;
+ /// 대상 고정(장판 일갈 = 밀어낸 적) · null = 키 규칙대로.
+ public HashSet Targets;
+ }
+
+ public struct LogLine
+ {
+ public string Text;
+ public float Time;
+ }
+
+ readonly BattleManager battle;
+ readonly Dictionary map = new Dictionary();
+ readonly List entries = new List();
+ public readonly List TimedEffects = new List();
+ public readonly List Log = new List();
+ readonly HashSet noted = new HashSet();
+ readonly int[] startCount = new int[2];
+
+ /// 장합 변진: 목표 지점 탭 대기 중인 유닛.
+ public Unit PendingTeleport { get; private set; }
+
+ public SkillSystem(BattleManager b)
+ {
+ battle = b;
+ }
+
+ public BattleManager Battle
+ {
+ get { return battle; }
+ }
+
+ public IReadOnlyList 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;
+ }
+
+ /// 스킬 부착(액티브·패시브 id · 레벨 · ★5 패시브 배율 · 무장 여부). 최대 HP 패시브(hp_max_pct)는 바로 반영.
+ 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;
+ }
+
+ /// heroes.csv 영웅 id 로 부착(테스트·도구용).
+ 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);
+ }
+
+ /// 출전 칸(편성 → 스킬 id·레벨·★ 배율이 채워져 있음).
+ 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);
+ }
+
+ /// 적 무장(보스·엘리트) = heroes.csv 에 같은 아트·이름 영웅이 있으면 그 패시브만(Lv1 · 액티브 AI 사용은 2차 · PD 확인 대상).
+ 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;
+ }
+
+ /// 전투 시작(배치 뒤): 출전 인원 기록(패자 · team_atk_pct_full) · 쿨 = 바로 사용 가능 · 시한 효과 비움.
+ 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();
+ foreach (var kv in map)
+ if (kv.Key == null) dead.Add(kv.Key);
+ foreach (var k in dead) map.Remove(k);
+ }
+
+ // ---------- 턴 ----------
+
+ /// 적 턴 시작: 이번 아군 턴 효과(동남풍 · 고육계)와 쓰지 않은 예약·추가 이동 정리.
+ 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;
+ }
+ }
+
+ /// 아군 턴 시작: 이번 적 턴 효과(호위 · 철벽 · 간웅 · 일갈 · 안개)와 아군 견제 강화 정리.
+ 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;
+ }
+
+ /// HUD 스킬 버튼 탭: next_action = 예약/취소 · instant = 즉시 발동(변진 = 목표 지점 탭 대기). 반환 = 안내 문구.
+ 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) ----------
+
+ /// 행동 시작: 예약한 next_action 효과를 장착(이동만인 추가 이동은 제외) · 발동 연출.
+ 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);
+ }
+
+ /// 행동 끝: 예약 소모 · 쿨(아군 턴 기준) · 견제 강화 이월.
+ 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();
+ }
+
+ /// 조준 미리보기 동안만 예약 효과를 장착(사거리 · 사선 · 광역 표시). 반환 = 복원값.
+ 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;
+ }
+
+ /// 행동 뒤 추가 이동을 (또) 주는가 — 처음 명중 = 추가 이동 n 회(인덕) · 추가 이동 뒤 = 남은 횟수.
+ 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;
+ }
+
+ // ---------- 적 턴: 교전 반응 · 교전 범위 ----------
+
+ /// 이번 적 턴에 교전 반응이 없는가(장판 일갈 공포 · 태평요술 안개).
+ 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;
+ }
+
+ /// 상대 진영 패시브(신산)가 actingFaction 의 교전 범위에 더하는 값.
+ 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;
+ }
+
+ // ---------- 피해 적용(호위) ----------
+
+ /// 피해 적용 — 관평 호위(이번 적 턴 반경 안 같은 편 피해 일부를 관평이 대신) 반영. 반환 = d 가 실제 받은 피해.
+ 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;
+ }
+
+ /// 스킬 시스템이 없어도 쓰는 피해 적용.
+ 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;
+ }
+
+ // ---------- 즉시형 액티브 ----------
+
+ /// 즉시 발동(행동 소모 없음): 효과마다 처리 → 쿨.
+ 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 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);
+ }
+
+ /// 장판 일갈: 반경(p2) 안 상대 전원을 바깥으로 p1 밀고 이번 적 턴 교전 반응 없음.
+ IEnumerator PushFear(UnitSkills s, SkillEffect e)
+ {
+ var u = s.Unit;
+ float radius = e.HasA2 ? e.A2 : 0f;
+ var targets = new HashSet();
+ 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;
+ }
+
+ /// 목표 지점이 반경 안 빈 곳인가(벽·유닛과 겹치지 않음 · 전장 안).
+ 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() != null) return false;
+ var o = c.GetComponent();
+ if (o != null && o != u && o.IsAlive) return false;
+ }
+ return true;
+ }
+
+ /// 변진 대기 중 화면 탭 → 처리했으면 true(유효 = 이동 · 아니면 취소).
+ 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 기록 ----------
+
+ /// 액티브 발동 = 유닛 위 스킬 이름 + 플래시 + HUD 한 줄.
+ 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 + "」");
+ }
+
+ /// 패시브 발동 HUD 한 줄(같은 행동 안에서는 스킬마다 1번).
+ 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);
+ }
+
+ /// 최근 seconds 초 안의 기록(최신이 마지막).
+ public List RecentLog(float seconds, int max)
+ {
+ var res = new List();
+ 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;
+ }
+ }
+
+ /// 스킬 연출(이름 글자 · 플래시).
+ 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);
+ }
+ }
+}
diff --git a/Assets/Scripts/Runtime/Skills/SkillSystem.cs.meta b/Assets/Scripts/Runtime/Skills/SkillSystem.cs.meta
new file mode 100644
index 00000000..e3d5f3a0
--- /dev/null
+++ b/Assets/Scripts/Runtime/Skills/SkillSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2b67581e954b6cc4ca0ea5d96b1690a4
\ No newline at end of file
diff --git a/Assets/Scripts/Runtime/SlingshotInput.cs b/Assets/Scripts/Runtime/SlingshotInput.cs
index aaf06bf3..3053aa44 100644
--- a/Assets/Scripts/Runtime/SlingshotInput.cs
+++ b/Assets/Scripts/Runtime/SlingshotInput.cs
@@ -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 highlighted = new List();
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);
}
+ /// 변진 목표 지점 대기 중이면 반경 원 표시.
+ 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;
diff --git a/Assets/Scripts/Runtime/UnitView.cs b/Assets/Scripts/Runtime/UnitView.cs
index b1d396d7..396c7522 100644
--- a/Assets/Scripts/Runtime/UnitView.cs
+++ b/Assets/Scripts/Runtime/UnitView.cs
@@ -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();
}
+ /// 예약 표시 중인가(테스트·캡처 확인용).
+ public bool SkillBadgeVisible
+ {
+ get { return skillBadge != null && skillBadge.gameObject.activeSelf; }
+ }
+
/// 보는 방향(위 = back · 아래 = front)에 맞는 클립 이름. 그 보기에 없으면 다른 보기 → 대기.
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)
diff --git a/Assets/Tests/PlayMode/SkillTests.cs b/Assets/Tests/PlayMode/SkillTests.cs
new file mode 100644
index 00000000..291186f8
--- /dev/null
+++ b/Assets/Tests/PlayMode/SkillTests.cs
@@ -0,0 +1,1089 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using NUnit.Framework;
+using TKS.OutGame;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using UnityEngine.TestTools;
+
+namespace TKS.Tests
+{
+ ///
+ /// 스킬 시스템(PD #1093 B · 시스템 기획 §5·§3-5) — effect_key 51종 처리기(수치·조건·레벨 계수) · 쿨·예약·취소 · 즉시형 · ★3/★5 · 적 무장 패시브 · 스킬 레벨업·스킬서.
+ /// 기대값은 전부 skills.csv(SkillCatalog)·BalanceConfig·표에서 계산한다(C45 · 테스트에 수치 상수 없음 · 스킬 id 만 지정).
+ /// 결정 피해 비교 = 같은 시드의 난수로 Combat.Damage 를 부른다.
+ ///
+ public class SkillTests
+ {
+ const int Seed = 7;
+ static readonly DateTime T0 = new DateTime(2026, 11, 5, 3, 0, 0, DateTimeKind.Utc);
+
+ BattleManager bm;
+ BalanceConfig cfg;
+
+ [UnitySetUp]
+ public IEnumerator SetUp()
+ {
+ WorldProgress.Reset();
+ SaveStore.UseMemory(true);
+ SceneManager.LoadScene("Battle");
+ yield return null;
+ yield return null;
+ bm = BattleManager.Instance;
+ Assert.IsNotNull(bm);
+ var wm = WorldMapScreen.Instance;
+ if (wm != null && wm.HasData) wm.EnterStage(0, 0);
+ yield return null;
+ cfg = bm.Config;
+ Time.timeScale = 20f;
+ }
+
+ [UnityTearDown]
+ public IEnumerator TearDown()
+ {
+ Time.timeScale = 1f;
+ yield return null;
+ }
+
+ IEnumerator Fresh()
+ {
+ bm.StopAllCoroutines();
+ bm.ClearUnits();
+ bm.ForcePhase(BattlePhase.PlayerTurn);
+ bm.SetTurnForTest(1);
+ yield return null;
+ }
+
+ // ---------- 도우미 ----------
+
+ static OutGameData Db
+ {
+ get { return OutGameData.Default; }
+ }
+
+ static SkillEffect Eff(string skillId, string key, int lv = 1, float star = 1f)
+ {
+ var d = SkillCatalog.Get(skillId);
+ Assert.IsNotNull(d, skillId);
+ foreach (var e in d.ScaledEffects(lv, star))
+ if (e.Key == key) return e;
+ Assert.Fail(skillId + " 에 " + key + " 없음");
+ return null;
+ }
+
+ static int Cd(string skillId)
+ {
+ return SkillCatalog.Get(skillId).Cooldown;
+ }
+
+ Unit Spawn(UnitType t, Faction f, float x, float y, string name, bool boss = false)
+ {
+ return bm.SpawnUnit(t, f, new Vector2(x, y), name, 1f, 1f, boss);
+ }
+
+ UnitSkills Give(Unit u, string heroId, int alv = 1, int plv = 1, float pm = 1f)
+ {
+ var s = bm.Skills.AttachHero(u, heroId, alv, plv, pm);
+ Assert.IsNotNull(s, heroId);
+ return s;
+ }
+
+ void Reserve(Unit u)
+ {
+ bm.Skills.Of(u).Reserved = true;
+ }
+
+ void Arm(Unit u, bool on)
+ {
+ bm.Skills.Of(u).Armed = on;
+ }
+
+ IEnumerator Act(Unit u, Vector2 target, ActionOutcome o, bool moveOnly = false)
+ {
+ yield return bm.ResolveForTest(u, target, moveOnly, o);
+ }
+
+ static float CastContact(Unit mover, Unit other)
+ {
+ return mover.Radius * Combat.CastRadiusScale + other.Radius;
+ }
+
+ static float Gap(Unit a, Unit b)
+ {
+ return Vector2.Distance(a.Position, b.Position) - a.Radius - b.Radius;
+ }
+
+ int Dmg(Unit a, Unit d, bool melee = true, float scale = 1f)
+ {
+ return Combat.Damage(cfg, a, d, false, new System.Random(Seed), melee, scale);
+ }
+
+ /// 피해 공식 기대값(같은 시드 난수) = (공 × 상성 − 방 × 계수) × 배율 × 분산.
+ int Expect(float atk, float matchup, float def, float scale)
+ {
+ var r = new System.Random(Seed);
+ float v = 1f + ((float)r.NextDouble() * 2f - 1f) * cfg.damageVariance;
+ return Mathf.Max(1, Mathf.RoundToInt((atk * matchup - def * cfg.defenseFactor) * scale * v));
+ }
+
+ IEnumerator WaitPhase(BattlePhase p, float sec = 10f)
+ {
+ float t0 = Time.realtimeSinceStartup;
+ while (bm.Phase != p && Time.realtimeSinceStartup - t0 < sec) yield return null;
+ Assert.AreEqual(p, bm.Phase);
+ }
+
+ // ---------- 표 · 임포터 · 레벨 계수 ----------
+
+ [Test]
+ public void Import_EffectKeys_All51_Handled_PColumns_Parsed()
+ {
+ var keys = new HashSet();
+ foreach (var s in Db.Skills.skills)
+ foreach (var k in s.EffectKeys)
+ {
+ Assert.IsTrue(SkillKeys.Known(k), s.id + " " + k);
+ keys.Add(k);
+ }
+ CollectionAssert.AreEquivalent(SkillKeys.All.Keys, keys, "표의 effect_key 종류 = 처리기 목록(51종 전부 사용)");
+
+ // 자산 = CSV(effect_key · p1~p3 · lv_scale · needs_impl)
+ var dir = Path.Combine(Application.dataPath, OutGameTables.Folder);
+ var texts = new string[OutGameTables.Files.Length];
+ for (int i = 0; i < texts.Length; i++) texts[i] = File.ReadAllText(Path.Combine(dir, OutGameTables.Files[i]), System.Text.Encoding.UTF8);
+ var r = OutGameTables.Parse(texts);
+ try
+ {
+ Assert.AreEqual(0, r.errors.Count, string.Join("\n", r.errors.ToArray()));
+ foreach (var s in r.data.Skills.skills)
+ {
+ var a = Db.Skills.Get(s.id);
+ Assert.AreEqual(s.effectKey, a.effectKey, s.id);
+ Assert.AreEqual(s.pMask, a.pMask, s.id);
+ Assert.AreEqual(s.p1, a.p1, 1e-5f, s.id);
+ Assert.AreEqual(s.p2, a.p2, 1e-5f, s.id);
+ Assert.AreEqual(s.p3, a.p3, 1e-5f, s.id);
+ Assert.AreEqual(s.lvScale, a.lvScale, 1e-5f, s.id);
+ Assert.AreEqual(s.needsImpl, a.needsImpl, s.id);
+ var d = SkillCatalog.Get(s.id);
+ Assert.AreEqual(s.EffectKeys.Length, d.Effects.Count, s.id);
+ if (s.EffectKeys.Length > 1)
+ for (int i = 0; i < d.Effects.Count; i++) Assert.AreEqual(s.P(i), d.Effects[i].P, 1e-5f, s.id + " p" + (i + 1) + " = 키 순서");
+ }
+ }
+ finally
+ {
+ OutGameTables.Destroy(r);
+ }
+
+ // 처리기 없는 키 = 임포트 오류
+ int si = Array.IndexOf(OutGameTables.Files, "skills.csv");
+ var broken = (string[])texts.Clone();
+ broken[si] = broken[si].Replace("counter_ratio", "counter_ratio_x");
+ var rb = OutGameTables.Parse(broken);
+ try
+ {
+ Assert.IsTrue(rb.errors.Exists(e => e.Contains("처리기 없음")), "모르는 effect_key → 오류");
+ }
+ finally
+ {
+ OutGameTables.Destroy(rb);
+ }
+ }
+
+ [Test]
+ public void LevelScale_Value_Mult_Count_Fixed_AndStar5Passive()
+ {
+ var cr = SkillCatalog.Get("guanyu_p");
+ float raw = cr.Effects[0].P;
+ Assert.AreEqual(raw * (1f + cr.LvScale * 2f), Eff("guanyu_p", SkillKeys.CounterRatio, 3).P, 1e-4f, "값 × (1 + lv_scale·(lv−1))");
+ float star = SkillRules.PassiveMult(Db, Db.Evolve.MaxStar);
+ Assert.Greater(star, 1f, "★5 패시브 배율(evolve.csv)");
+ Assert.AreEqual(raw * star, Eff("guanyu_p", SkillKeys.CounterRatio, 1, star).P, 1e-4f, "★5 = 패시브 ×배율");
+
+ var pm = SkillCatalog.Get("zhoucang_a");
+ float praw = pm.Effects[0].P;
+ Assert.AreEqual(1f + (praw - 1f) * (1f + pm.LvScale * 4f), Eff("zhoucang_a", SkillKeys.PushMult, 5).P, 1e-4f, "배율 = 1 을 넘는 몫만 키움");
+ Assert.AreEqual(praw, Eff("zhoucang_a", SkillKeys.PushMult, 1, star).P, 1e-4f, "액티브에는 ★5 패시브 배율 없음");
+
+ var em = SkillCatalog.Get("liubei_a");
+ float craw = em.First(SkillKeys.ExtraMoveCount).P;
+ Assert.AreEqual(Mathf.Floor(craw * (1f + em.LvScale * 4f) + 0.5f), Eff("liubei_a", SkillKeys.ExtraMoveCount, 5).P, 1e-4f, "횟수 = 반올림");
+ Assert.AreEqual(SkillCatalog.Get("guanyu_a").First(SkillKeys.OverwatchUnlimited).P, Eff("guanyu_a", SkillKeys.OverwatchUnlimited, 5).P, 1e-4f, "켜기 값은 그대로");
+
+ var lb = SkillCatalog.Get("lvbu_p").Effects[0];
+ var lb3 = Eff("lvbu_p", SkillKeys.SlingDistPct, 3);
+ Assert.AreEqual(lb.A2 * (1f + SkillCatalog.Get("lvbu_p").LvScale * 2f), lb3.A2, 1e-4f, "절대 가산(p2)도 레벨 계수");
+ var hg = SkillCatalog.Get("huanggai_a").Effects[0];
+ Assert.AreEqual(hg.A2, Eff("huanggai_a", SkillKeys.HpCostAtk, 5).A2, 1e-4f, "HP 소모(p2)는 커지지 않음");
+ }
+
+ // ---------- 보병 ----------
+
+ [UnityTest]
+ public IEnumerator Infantry_Rende_NoSelfBounce_ExtraMoves_CooldownFromTable()
+ {
+ yield return Fresh();
+ var inf = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "유비");
+ Give(inf, "liubei");
+ Reserve(inf);
+ var foe = Spawn(UnitType.Spear, Faction.Enemy, 0f, 0f, "적창병");
+ float contactY = -3f + (3f - CastContact(inf, foe));
+ var o = new ActionOutcome();
+ yield return Act(inf, foe.Position, o);
+ Assert.AreEqual("liubei_a", o.SkillUsed != null ? o.SkillUsed.Id : null, "예약 = 다음 행동에 발동");
+ Assert.AreEqual(contactY, inf.Position.y, 0.1f, "self_bounce_mult 0 = 자신은 튕기지 않음");
+ Assert.IsTrue(o.BonusMove);
+ int n = Mathf.RoundToInt(Eff("liubei_a", SkillKeys.ExtraMoveCount).P);
+ Assert.AreEqual(n, o.BonusMoves, "extra_move_count = 추가 이동 n 회");
+ var s = bm.Skills.Of(inf);
+ Assert.IsFalse(s.Reserved);
+ Assert.AreEqual(bm.TurnNumber + Cd("liubei_a"), s.ReadyTurn, "쿨 = 사용 턴 + cooldown");
+ // 추가 이동 n 회: 첫 명중 → 1 회 부여 + 남은 n−1
+ Assert.IsTrue(bm.Skills.NextBonus(inf, false, o));
+ for (int i = 1; i < n; i++) Assert.IsTrue(bm.Skills.NextBonus(inf, true, new ActionOutcome()), "남은 추가 이동 " + i);
+ Assert.IsFalse(bm.Skills.NextBonus(inf, true, new ActionOutcome()), "n 회 뒤 끝");
+ }
+
+ [UnityTest]
+ public IEnumerator Infantry_PushMult_KnockbackTaken_DmgPct_ExtraMoveAttack()
+ {
+ yield return Fresh();
+ // 청룡도 지기(push_mult): 밀리는 거리 = 남은 힘 × collisionPushRatio × p
+ var a = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "주창");
+ Give(a, "zhoucang");
+ Reserve(a);
+ var foe = Spawn(UnitType.Spear, Faction.Enemy, 0f, 0f, "적");
+ float remaining = CastContact(a, foe);
+ var o = new ActionOutcome();
+ yield return Act(a, foe.Position, o);
+ Assert.AreEqual(remaining * cfg.collisionPushRatio * Eff("zhoucang_a", SkillKeys.PushMult).P, foe.Position.y, 0.12f, "push_mult");
+
+ // 거구(knockback_taken_pct): 부딪힌 쪽이 밀리는 거리 × (1 + p)
+ yield return Fresh();
+ var b = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "보병");
+ var xu = Spawn(UnitType.Spear, Faction.Enemy, 0f, 0f, "허저");
+ Give(xu, "xuchu");
+ float rem2 = CastContact(b, xu);
+ var o2 = new ActionOutcome();
+ yield return Act(b, xu.Position, o2);
+ Assert.AreEqual(rem2 * cfg.collisionPushRatio * (1f + Eff("xuchu_p", SkillKeys.KnockbackTakenPct).P), xu.Position.y, 0.12f, "knockback_taken_pct");
+
+ // 강동의 호랑이(dmg_pct · extra_move_attack): 피해 × (1 + p) · 명중 시 추가 이동에서 공격 가능
+ yield return Fresh();
+ var sj = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "손견");
+ var plain = Spawn(UnitType.Infantry, Faction.Player, 6f, -3f, "보병");
+ var d = Spawn(UnitType.Spear, Faction.Enemy, 0f, 0f, "적");
+ Give(sj, "sunjian");
+ Arm(sj, true);
+ Assert.AreEqual(Expect(plain.Atk, cfg.Matchup(UnitType.Infantry, UnitType.Spear), d.Def, 1f + Eff("sunjian_a", SkillKeys.DmgPct).P), Dmg(sj, d), 1, "dmg_pct");
+ Arm(sj, false);
+ Assert.AreEqual(Dmg(plain, d), Dmg(sj, d), "예약 효과는 행동 중에만");
+ Reserve(sj);
+ var o3 = new ActionOutcome();
+ yield return Act(sj, d.Position, o3);
+ Assert.IsTrue(o3.BonusMove && o3.BonusAttack, "extra_move_attack");
+ Assert.IsTrue(bm.Skills.NextBonus(sj, false, o3));
+ Assert.IsTrue(bm.Skills.BonusCanAttack(sj), "추가 이동 = 공격 가능");
+ }
+
+ [UnityTest]
+ public IEnumerator Infantry_Yujin_PushedAllyAttacksTwice_Liubei_PushedAttackBonus_Gaoshun_Collision()
+ {
+ yield return Fresh();
+ // 군령(pushed_ally_attack_count): 알까기로 밀린 아군이 적과 닿으면 n 회 공격
+ var a = Spawn(UnitType.Infantry, Faction.Player, -3f, -2f, "우금");
+ var b = Spawn(UnitType.Spear, Faction.Player, 0f, -2f, "창병B");
+ Give(a, "yujin");
+ Reserve(a);
+ float pushX = CastContact(a, b);
+ var foe = Spawn(UnitType.Infantry, Faction.Enemy, pushX, -2f + b.Radius + 0.45f + 0.05f, "적");
+ foe.MaxHp = foe.Hp = 10000;
+ var o = new ActionOutcome();
+ yield return Act(a, new Vector2(0f, -2f), o);
+ Assert.AreEqual(Mathf.RoundToInt(Eff("yujin_a", SkillKeys.PushedAllyAttackCount).P), o.Notes.Count, "pushed_ally_attack_count");
+
+ // 도원의 맹약(pushed_ally_attack_pct · 같은 편 전원): 밀린 아군 공격 피해 × (1 + p)
+ yield return Fresh();
+ var lb = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "유비");
+ Give(lb, "liubei");
+ var ally = Spawn(UnitType.Infantry, Faction.Player, 0f, -2f, "보병");
+ var en = Spawn(UnitType.Infantry, Faction.Enemy, 6f, 6f, "적");
+ float p = Eff("liubei_p", SkillKeys.PushedAllyAttackPct).P;
+ Assert.AreEqual(p, SkillMods.Sum(ally, SkillKeys.PushedAllyAttackPct), 1e-5f, "같은 편 = 적용");
+ Assert.AreEqual(0f, SkillMods.Sum(en, SkillKeys.PushedAllyAttackPct), 1e-5f, "상대 = 미적용");
+
+ // 함진영(push_mult p2): 밀린 적이 벽·유닛에 막히면 알까기 피해 × p2 추가
+ yield return Fresh();
+ var gs = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "고순");
+ Give(gs, "gaoshun");
+ Reserve(gs);
+ var f1 = Spawn(UnitType.Spear, Faction.Enemy, 0f, 0f, "적1");
+ var f2 = Spawn(UnitType.Spear, Faction.Enemy, 0f, f1.Radius * 2f + 0.01f, "적2");
+ f1.MaxHp = f1.Hp = 10000;
+ var o2 = new ActionOutcome();
+ yield return Act(gs, f1.Position, o2);
+ float bonus = Eff("gaoshun_a", SkillKeys.PushMult).A2;
+ int total = f1.MaxHp - f1.Hp;
+ Assert.AreEqual(total, o2.DamageDealt, "충돌 추가 피해도 집계");
+ int impact = Mathf.RoundToInt(total / (1f + bonus));
+ Assert.AreEqual(Mathf.Max(1, Mathf.RoundToInt(impact * bonus)), total - impact, 2, "막혀서 충돌 = 알까기 피해 × p2");
+ }
+
+ // ---------- 기병 ----------
+
+ [UnityTest]
+ public IEnumerator Cavalry_NoCounter_ReturnOnMiss_Knockback_Repeat_PerHit_MinPierce()
+ {
+ yield return Fresh();
+ // 천하무쌍(pierce_dmg_mult · pierce_no_counter)
+ var lv = Spawn(UnitType.Cavalry, Faction.Player, 0f, -4f, "여포");
+ Give(lv, "lvbu");
+ Reserve(lv);
+ var e1 = Spawn(UnitType.Spear, Faction.Enemy, 0f, -1.5f, "적창병");
+ e1.MaxHp = e1.Hp = 10000;
+ var o = new ActionOutcome();
+ yield return Act(lv, new Vector2(0f, 1.5f), o);
+ Assert.Less(e1.Hp, e1.MaxHp);
+ Assert.AreEqual(0, o.CounterTaken, "pierce_no_counter = 살아남은 근접 적도 반격 없음");
+ Arm(lv, true);
+ Assert.AreEqual(Eff("lvbu_a", SkillKeys.PierceDmgMult).P, SkillMods.PierceDmgMult(lv, 1), 1e-5f, "pierce_dmg_mult");
+ Arm(lv, false);
+
+ // 단기구주(pierce_return_on_miss) + 상산의 창(pierce_knockback_mult)
+ yield return Fresh();
+ var zy = Spawn(UnitType.Cavalry, Faction.Player, 0f, -4f, "조운");
+ Give(zy, "zhaoyun");
+ Reserve(zy);
+ var e2 = Spawn(UnitType.Infantry, Faction.Enemy, 0.3f, -1.5f, "적");
+ e2.MaxHp = e2.Hp = 10000;
+ var o2 = new ActionOutcome();
+ yield return Act(zy, new Vector2(0f, 2f), o2);
+ Assert.IsTrue(o2.Returned, "빗나가도 출발점 복귀");
+ Assert.Less(Vector2.Distance(zy.Position, new Vector2(0f, -4f)), 0.05f);
+ Assert.AreEqual(0, o2.CounterTaken, "단기구주 = 반격 없음");
+ var disp = o2.Displaced.Find(x => x.Unit == e2);
+ Assert.IsNotNull(disp.Unit);
+ Assert.AreEqual(cfg.cavalryKnockback * Eff("zhaoyun_p", SkillKeys.PierceKnockbackMult).P, Vector2.Distance(disp.From, disp.To), 0.05f, "pierce_knockback_mult");
+
+ // 위진소요(pierce_repeat) + 팔백 돌격(pierce_dmg_pct_per_hit)
+ yield return Fresh();
+ var zl = Spawn(UnitType.Cavalry, Faction.Player, 0f, -7f, "장료");
+ Give(zl, "zhangliao");
+ Reserve(zl);
+ var t1 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -4f, "적1");
+ var t2 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -1.2f, "적2");
+ t1.MaxHp = t1.Hp = t2.MaxHp = t2.Hp = 10000;
+ var o3 = new ActionOutcome();
+ yield return Act(zl, t1.Position, o3);
+ Assert.Less(t2.Hp, t2.MaxHp, "명중 → 같은 방향 한 번 더 관통");
+ Assert.IsFalse(o3.Returned, "복귀 대신 추가 관통");
+ Assert.AreEqual(t1.MaxHp - t1.Hp, o3.DamageDealt - (t2.MaxHp - t2.Hp), "앞서 관통한 적은 다시 맞지 않음");
+ var ps = Eff("zhangliao_p", SkillKeys.PierceDmgPctPerHit);
+ int hits = bm.Skills.Of(zl).PierceHits;
+ Assert.AreEqual(2, hits, "관통한 적 누적");
+ Assert.AreEqual(1f + Mathf.Min(ps.P * hits, ps.A2), SkillMods.AttackMult(zl), 1e-4f, "pierce_dmg_pct_per_hit(최대 p2)");
+
+ // 만인지적(pierce_dmg_mult p2 = 최소 관통 수)
+ yield return Fresh();
+ var zf = Spawn(UnitType.Cavalry, Faction.Player, 0f, -4f, "장비");
+ Give(zf, "zhangfei");
+ var mp = Eff("zhangfei_p", SkillKeys.PierceDmgMult);
+ int need = Mathf.RoundToInt(mp.A2);
+ Assert.AreEqual(1f, SkillMods.PierceDmgMult(zf, need - 1), 1e-5f, "최소 관통 수 미만");
+ Assert.AreEqual(mp.P, SkillMods.PierceDmgMult(zf, need), 1e-5f, "최소 관통 수 이상");
+ }
+
+ // ---------- 창병 · 견제 ----------
+
+ int StrikeHi(Unit spear, Unit mover)
+ {
+ int one = Combat.OverwatchDamage(cfg, spear, mover, new System.Random(Seed));
+ return Mathf.CeilToInt(one / (1f - cfg.damageVariance) * (1f + cfg.damageVariance)) + 1;
+ }
+
+ int StrikeLo(Unit spear, Unit mover)
+ {
+ int one = Combat.OverwatchDamage(cfg, spear, mover, new System.Random(Seed));
+ return Mathf.FloorToInt(one / (1f + cfg.damageVariance) * (1f - cfg.damageVariance)) - 1;
+ }
+
+ [UnityTest]
+ public IEnumerator Spear_OverwatchSkills_Radius_Unlimited_Count_DmgMult_HealOnKill()
+ {
+ yield return Fresh();
+ // 청룡언월(overwatch_radius_mult · overwatch_unlimited): 명중 → 대기 반경 × p · 반경을 스친 구간마다 + 밀린 적도
+ var gy = Spawn(UnitType.Spear, Faction.Player, 0f, -3f, "관우");
+ Give(gy, "guanyu");
+ Reserve(gy);
+ var foe = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 0f, "적");
+ var o = new ActionOutcome();
+ yield return Act(gy, foe.Position, o);
+ Assert.IsTrue(o.Overwatch && gy.Overwatch);
+ float r = SkillMods.OverwatchRadius(gy);
+ Assert.AreEqual(gy.AttackRange * Eff("guanyu_a", SkillKeys.OverwatchRadiusMult).P, r, 1e-4f, "overwatch_radius_mult");
+ Assert.IsTrue(SkillMods.OverwatchUnlimited(gy));
+ var mover = Spawn(UnitType.Infantry, Faction.Enemy, gy.Position.x + r + 3f, gy.Position.y, "이동 적");
+ mover.MaxHp = mover.Hp = 10000;
+ var path = new ActionOutcome();
+ Vector2 c = gy.Position;
+ path.Path.AddRange(new[] { c + new Vector2(r + 2f, 0.3f), c + new Vector2(0.5f, 0.3f), c + new Vector2(r + 2f, 0.6f), c + new Vector2(0.5f, 0.6f) });
+ yield return bm.ApplyOverwatch(mover, path);
+ int drop = mover.MaxHp - mover.Hp;
+ Assert.GreaterOrEqual(drop, StrikeLo(gy, mover) * 2, "반경을 스친 구간 2 번 = 견제 2 회(무제한)");
+ // 밀린 적(Displaced)도 견제
+ var pushedFoe = Spawn(UnitType.Infantry, Faction.Enemy, c.x - r - 3f, c.y, "밀린 적");
+ pushedFoe.MaxHp = pushedFoe.Hp = 10000;
+ var o2 = new ActionOutcome();
+ o2.Path.Add(mover.Position);
+ o2.Displaced.Add(new Displacement { Unit = pushedFoe, From = c + new Vector2(-r - 1f, 0f), To = c + new Vector2(-0.5f, 0f) });
+ yield return bm.ApplyOverwatch(mover, o2);
+ Assert.Less(pushedFoe.Hp, pushedFoe.MaxHp, "무제한 = 밀려서 반경을 지난 적도 견제");
+
+ // 후계(overwatch_radius_add) · 삼대 노신(overwatch_count) · 교변(overwatch_dmg_mult)
+ yield return Fresh();
+ var gp = Spawn(UnitType.Spear, Faction.Player, -6f, -6f, "관평");
+ Give(gp, "guanping");
+ gp.SetOverwatch(true);
+ Assert.AreEqual(gp.AttackRange + Eff("guanping_p", SkillKeys.OverwatchRadiusAdd).P, SkillMods.OverwatchRadius(gp), 1e-4f, "overwatch_radius_add");
+
+ var cp = Spawn(UnitType.Spear, Faction.Player, 4f, -6f, "정보");
+ Give(cp, "chengpu");
+ cp.SetOverwatch(true);
+ var m2 = Spawn(UnitType.Infantry, Faction.Enemy, 4f, 2f, "적");
+ m2.MaxHp = m2.Hp = 10000;
+ var po = new ActionOutcome();
+ po.Path.AddRange(new[] { new Vector2(3f, -3f), new Vector2(3f, -8f) });
+ yield return bm.ApplyOverwatch(m2, po);
+ int strikes = Mathf.RoundToInt(Eff("chengpu_p", SkillKeys.OverwatchCount).P);
+ Assert.GreaterOrEqual(m2.MaxHp - m2.Hp, StrikeLo(cp, m2) * strikes, "overwatch_count = 지나간 적 1기당 n 회");
+ Assert.LessOrEqual(m2.MaxHp - m2.Hp, StrikeHi(cp, m2) * strikes);
+
+ var zh = Spawn(UnitType.Spear, Faction.Player, 8f, 8f, "장합");
+ Give(zh, "zhanghe");
+ var plainSpear = Spawn(UnitType.Spear, Faction.Player, -8f, 8f, "창병");
+ int baseOw = Combat.OverwatchDamage(cfg, plainSpear, m2, new System.Random(Seed));
+ int zhOw = Combat.OverwatchDamage(cfg, zh, m2, new System.Random(Seed));
+ Assert.AreEqual(baseOw * Eff("zhanghe_p", SkillKeys.OverwatchDmgMult).P, zhOw, 1.01f, "overwatch_dmg_mult");
+
+ // 하북 명장(heal_on_overwatch_kill): 견제로 쓰러뜨리면 최대 HP × p 회복
+ yield return Fresh();
+ var yl = Spawn(UnitType.Spear, Faction.Player, 0f, -3f, "안량");
+ Give(yl, "yanliang");
+ yl.SetOverwatch(true);
+ yl.Hp = yl.MaxHp / 2;
+ int hp0 = yl.Hp;
+ var weak = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 1f, "적");
+ weak.Hp = 1;
+ var ko = new ActionOutcome();
+ ko.Path.AddRange(new[] { new Vector2(-1f, -1.5f), new Vector2(1f, -1.5f) });
+ yield return bm.ApplyOverwatch(weak, ko);
+ Assert.IsFalse(weak.IsAlive);
+ Assert.AreEqual(Mathf.Min(yl.MaxHp, hp0 + Mathf.Max(1, Mathf.RoundToInt(yl.MaxHp * Eff("yanliang_p", SkillKeys.HealOnOverwatchKill).P))), yl.Hp, "heal_on_overwatch_kill");
+ }
+
+ [UnityTest]
+ public IEnumerator Spear_HitTwo_OnPushAllyAttacks_ChengpuAdvance()
+ {
+ yield return Fresh();
+ // 삼첨도(spear_hit_two): 밀며 전진 끝에 닿은 적까지 최대 n 기 동시 피해
+ var jl = Spawn(UnitType.Spear, Faction.Player, 0f, -4f, "기령");
+ Give(jl, "jiling");
+ Reserve(jl);
+ var f1 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -1f, "적1");
+ var f2 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -1f + f1.Radius * 2f + 0.03f, "적2");
+ f1.MaxHp = f1.Hp = f2.MaxHp = f2.Hp = 10000;
+ var o = new ActionOutcome();
+ yield return Act(jl, new Vector2(0f, -1f), o);
+ Assert.Less(f1.Hp, f1.MaxHp);
+ Assert.Less(f2.Hp, f2.MaxHp, "spear_hit_two = 닿은 둘째 적도 피해");
+ Assert.Contains(f2, o.Attacked);
+
+ // 대장군(pushed_ally_attack_on_push): 밀고 간 아군의 공격 +n 회
+ yield return Fresh();
+ var jp = Spawn(UnitType.Spear, Faction.Player, 0f, -4f, "기령");
+ Give(jp, "jiling");
+ var ally = Spawn(UnitType.Infantry, Faction.Player, 0f, -2f, "아군");
+ var foe = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 1.5f, "적");
+ foe.MaxHp = foe.Hp = 10000;
+ ally.MaxHp = ally.Hp = 10000;
+ var o2 = new ActionOutcome();
+ yield return Act(jp, new Vector2(0f, 0f), o2);
+ Assert.AreEqual(1 + Mathf.RoundToInt(Eff("jiling_p", SkillKeys.PushedAllyAttackOnPush).P), o2.Notes.Count, "pushed_ally_attack_on_push");
+
+ // 철척사모(push_mult · 창병): 밀며 전진 거리 × p
+ yield return Fresh();
+ var ch = Spawn(UnitType.Spear, Faction.Player, 0f, -3f, "정보");
+ Give(ch, "chengpu");
+ Reserve(ch);
+ var t = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 0f, "적");
+ t.MaxHp = t.Hp = 10000;
+ float rem = CastContact(ch, t);
+ var o3 = new ActionOutcome();
+ yield return Act(ch, t.Position, o3);
+ Assert.AreEqual(rem * Eff("chengpu_a", SkillKeys.PushMult).P, t.Position.y, 0.12f, "push_mult(창병 = 밀며 전진 거리)");
+ }
+
+ // ---------- 궁병 · 책사 ----------
+
+ [UnityTest]
+ public IEnumerator Ranged_Range_TeamRange_MeleePenaltyOff_PassAllies_CritAlways()
+ {
+ yield return Fresh();
+ var ts = Spawn(UnitType.Archer, Faction.Player, -6f, -8f, "태사자");
+ Give(ts, "taishici");
+ Arm(ts, true);
+ Assert.AreEqual(ts.AttackRange * Eff("taishici_a", SkillKeys.RangeMult).P, SkillMods.Range(ts), 1e-4f, "range_mult");
+ Arm(ts, false);
+ var xs = Spawn(UnitType.Strategist, Faction.Player, -3f, -8f, "서서");
+ Give(xs, "xushu");
+ Assert.AreEqual(xs.AttackRange + Eff("xushu_p", SkillKeys.RangeAdd).P, SkillMods.Range(xs), 1e-4f, "range_add");
+
+ var zy = Spawn(UnitType.Strategist, Faction.Player, 0f, -8f, "주유");
+ Give(zy, "zhouyu");
+ var archer = Spawn(UnitType.Archer, Faction.Player, 3f, -8f, "궁병");
+ var inf = Spawn(UnitType.Infantry, Faction.Player, 6f, -8f, "보병");
+ var enemyArcher = Spawn(UnitType.Archer, Faction.Enemy, 6f, 8f, "적궁병");
+ float tr = Eff("zhouyu_p", SkillKeys.TeamRangeAdd).P;
+ Assert.AreEqual(archer.AttackRange + tr, SkillMods.Range(archer), 1e-4f, "team_range_add = 아군 궁병");
+ Assert.AreEqual(0f, SkillMods.Sum(inf, SkillKeys.TeamRangeAdd), 1e-5f, "보병 제외");
+ Assert.AreEqual(enemyArcher.AttackRange, SkillMods.Range(enemyArcher), 1e-4f, "상대 제외");
+
+ // 돌파(melee_penalty_off): 근접 전투 패널티 없음(주는·받는 피해)
+ var foe = Spawn(UnitType.Infantry, Faction.Enemy, 8f, 8f, "적보병");
+ float m = cfg.Matchup(UnitType.Archer, UnitType.Infantry);
+ Assert.AreEqual(Expect(ts.Atk, m, foe.Def, 1f), Dmg(ts, foe, true), 1, "주는 근접 피해 패널티 없음");
+ Assert.AreEqual(Expect(archer.Atk, m, foe.Def, cfg.archerMeleeDealtRatio), Dmg(archer, foe, true), 1, "일반 궁병은 패널티");
+ float m2 = cfg.Matchup(UnitType.Infantry, UnitType.Archer);
+ Assert.AreEqual(Expect(foe.Atk, m2, ts.Def, 1f), Dmg(foe, ts, true), 1, "받는 근접 피해 패널티 없음");
+
+ // 백발백중(arrow_pass_allies · arrow_crit_always): 사선의 아군 통과 · 목표점 명중 없이도 크리
+ yield return Fresh();
+ var hz = Spawn(UnitType.Archer, Faction.Player, 0f, -4f, "황충");
+ Give(hz, "huangzhong");
+ Reserve(hz);
+ var block = Spawn(UnitType.Infantry, Faction.Player, 0f, -2.6f, "아군");
+ var target = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -0.6f, "적");
+ target.MaxHp = target.Hp = 10000;
+ var o = new ActionOutcome();
+ yield return Act(hz, new Vector2(0f, -3.2f), o);
+ Assert.AreEqual(block.MaxHp, block.Hp, "arrow_pass_allies = 사선의 아군 무피해");
+ Assert.Less(target.Hp, target.MaxHp);
+ Assert.IsTrue(o.Crit, "arrow_crit_always = 목표점 명중 없이 크리");
+ }
+
+ [UnityTest]
+ public IEnumerator Strategist_SplashRadiusRatio_AllyZero_Ganning_Teleport()
+ {
+ yield return Fresh();
+ // 화공(splash_radius_mult · splash_ratio)
+ var zy = Spawn(UnitType.Strategist, Faction.Player, 0f, -4f, "주유");
+ Give(zy, "zhouyu");
+ Reserve(zy);
+ var c = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -1f, "중심");
+ float mult = Eff("zhouyu_a", SkillKeys.SplashRadiusMult).P;
+ float far = cfg.strategistSplashRadius * (1f + mult) * 0.5f + 0.45f; // 기본 반경 밖 · 화공 반경 안
+ var side = Spawn(UnitType.Infantry, Faction.Enemy, far, -1f, "곁");
+ var plan = bm.Resolver.PreviewShot(zy, c.Position, false);
+ Assert.IsNotNull(plan);
+ Assert.AreEqual(cfg.strategistSplashRadius * mult, plan.SplashRadius, 1e-4f, "splash_radius_mult");
+ Assert.Contains(side, plan.Splash, "커진 광역에 들어옴");
+ Arm(zy, true);
+ Assert.AreEqual(Eff("zhouyu_a", SkillKeys.SplashRatio).P, SkillMods.SplashRatio(zy, cfg), 1e-4f, "splash_ratio");
+ Arm(zy, false);
+ Assert.AreEqual(cfg.strategistSplashRatio, SkillMods.SplashRatio(zy, cfg), 1e-4f, "예약 없으면 자산 값");
+
+ // 팔문 간파(splash_ally_ratio): 광역의 아군 피해 = 0
+ yield return Fresh();
+ var xs = Spawn(UnitType.Strategist, Faction.Player, 0f, -4f, "서서");
+ Give(xs, "xushu");
+ Reserve(xs);
+ var en = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -0.6f, "적");
+ en.MaxHp = en.Hp = 10000;
+ var ally = Spawn(UnitType.Infantry, Faction.Player, 1.0f, -0.6f, "아군");
+ var o = new ActionOutcome();
+ yield return Act(xs, en.Position, o);
+ Assert.Less(en.Hp, en.MaxHp);
+ Assert.AreEqual(ally.MaxHp, ally.Hp, "splash_ally_ratio = 0");
+
+ // 백기습영(teleport · arrow_crit_always): 사거리 밖이어도 목표점까지 이동 후 사격 · 크리
+ yield return Fresh();
+ var gn = Spawn(UnitType.Archer, Faction.Player, 0f, -9f, "감녕");
+ Give(gn, "ganning");
+ var far2 = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 0.5f, "먼 적");
+ far2.MaxHp = far2.Hp = 10000;
+ Assert.Greater(Gap(gn, far2), SkillMods.Range(gn) + gn.ArcherMoveAllow, "출발점에서는 사거리 밖");
+ Reserve(gn);
+ Vector2 to = gn.Position + Vector2.up * SkillMods.SlingMax(gn);
+ var o2 = new ActionOutcome();
+ yield return Act(gn, to, o2);
+ Assert.Greater(gn.Position.y, -9f + SkillMods.SlingMax(gn) - 0.3f, "목표점까지 이동");
+ Assert.IsTrue(o2.Fired && far2.Hp < far2.MaxHp, "이동 후 사격");
+ Assert.IsTrue(o2.Crit, "명중 시 크리");
+ }
+
+ // ---------- 피해 · 반격 · 공격력 ----------
+
+ [UnityTest]
+ public IEnumerator Damage_MatchupFloor_HpBelow_Turn1_General_Taken_Archer_Counter_Death_Full_HpMax()
+ {
+ yield return Fresh();
+ var cav = Spawn(UnitType.Cavalry, Faction.Enemy, 6f, 8f, "적기병");
+ var xh = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "서황");
+ var plain = Spawn(UnitType.Infantry, Faction.Player, -3f, -8f, "보병");
+ Give(xh, "xuhuang");
+ Arm(xh, true);
+ float floor = Eff("xuhuang_a", SkillKeys.MatchupFloor).P;
+ Assert.AreEqual(Expect(xh.Atk, Mathf.Max(cfg.Matchup(UnitType.Infantry, UnitType.Cavalry), floor), cav.Def, 1f), Dmg(xh, cav), 1, "matchup_floor");
+ Arm(xh, false);
+ Assert.AreEqual(Dmg(plain, cav), Dmg(xh, cav));
+
+ // 노익장(dmg_pct_hp_below): HP 임계 이하 공 +p
+ var hz = Spawn(UnitType.Archer, Faction.Player, 0f, -8f, "황충");
+ Give(hz, "huangzhong");
+ var hb = Eff("huangzhong_p", SkillKeys.DmgPctHpBelow);
+ Assert.AreEqual(1f, SkillMods.AttackMult(hz), 1e-5f, "HP 가득 = 없음");
+ hz.Hp = Mathf.FloorToInt(hz.MaxHp * hb.A2);
+ Assert.AreEqual(1f + hb.P, SkillMods.AttackMult(hz), 1e-5f, "dmg_pct_hp_below");
+
+ // 선봉(dmg_pct_turn1)
+ var hx = Spawn(UnitType.Infantry, Faction.Player, 3f, -8f, "화웅");
+ Give(hx, "huaxiong");
+ bm.SetTurnForTest(1);
+ Assert.AreEqual(1f + Eff("huaxiong_p", SkillKeys.DmgPctTurn1).P, SkillMods.AttackMult(hx), 1e-5f, "dmg_pct_turn1");
+ bm.SetTurnForTest(2);
+ Assert.AreEqual(1f, SkillMods.AttackMult(hx), 1e-5f, "2턴 = 없음");
+
+ // 파로(dmg_vs_general_mult p2 1 = 보스만) · 참장(p2 0 = 무장 전체)
+ var sj = Spawn(UnitType.Infantry, Faction.Player, 6f, -8f, "손견");
+ Give(sj, "sunjian");
+ var boss = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "보스", true);
+ var gen = Spawn(UnitType.Infantry, Faction.Enemy, 3f, 8f, "장수");
+ bm.Skills.MarkGeneral(gen);
+ float im = cfg.Matchup(UnitType.Infantry, UnitType.Infantry);
+ Assert.AreEqual(Expect(sj.Atk, im, boss.Def, Eff("sunjian_p", SkillKeys.DmgVsGeneralMult).P), Dmg(sj, boss), 1, "보스 = 배율");
+ Assert.AreEqual(Expect(sj.Atk, im, gen.Def, 1f), Dmg(sj, gen), 1, "보스만 = 일반 장수 제외");
+ Arm(hx, true);
+ Assert.AreEqual(Expect(hx.Atk, im, gen.Def, Eff("huaxiong_a", SkillKeys.DmgVsGeneralMult).P), Dmg(hx, gen), 1, "참장 = 무장 전체");
+ Arm(hx, false);
+
+ // 수성(dmg_taken_pct) · 창천이사(dmg_taken_from_archer_pct)
+ var cr = Spawn(UnitType.Infantry, Faction.Player, -6f, -5f, "조인");
+ Give(cr, "caoren");
+ Assert.AreEqual(Expect(gen.Atk, im, cr.Def, 1f + Eff("caoren_p", SkillKeys.DmgTakenPct).P), Dmg(gen, cr), 1, "dmg_taken_pct");
+ var zj = Spawn(UnitType.Infantry, Faction.Player, -3f, -5f, "장각");
+ Give(zj, "zhangjiao");
+ var ea = Spawn(UnitType.Archer, Faction.Enemy, -3f, 8f, "적궁병");
+ float am = cfg.Matchup(UnitType.Archer, UnitType.Infantry);
+ Assert.AreEqual(Expect(ea.Atk, am, zj.Def, 1f + Eff("zhangjiao_p", SkillKeys.DmgTakenFromArcherPct).P), Dmg(ea, zj, false), 1, "적 궁병 피해 −");
+ Assert.AreEqual(Expect(gen.Atk, im, zj.Def, 1f), Dmg(gen, zj), 1, "궁병 아님 = 그대로");
+
+ // 무성(counter_ratio) · 진중(p2 1 = 밀렸을 때만) · 군율(counter_taken_pct) · 금범적(−1 = 반격 없음)
+ var gy = Spawn(UnitType.Spear, Faction.Player, 0f, -5f, "관우");
+ Give(gy, "guanyu");
+ int baseGy = Combat.Damage(cfg, gy, gen, false, new System.Random(Seed), true);
+ Assert.AreEqual(Mathf.Max(1, Mathf.RoundToInt(baseGy * Eff("guanyu_p", SkillKeys.CounterRatio).P)), Combat.CounterDamage(cfg, gy, gen, new System.Random(Seed)), "counter_ratio");
+ var yj = Spawn(UnitType.Infantry, Faction.Player, 3f, -5f, "우금");
+ Give(yj, "yujin");
+ int baseYj = Combat.Damage(cfg, yj, gen, false, new System.Random(Seed), true);
+ Assert.AreEqual(Mathf.Max(1, Mathf.RoundToInt(baseYj * cfg.counterRatio)), Combat.CounterDamage(cfg, yj, gen, new System.Random(Seed), false), "안 밀림 = 기본 반격");
+ Assert.AreEqual(Mathf.Max(1, Mathf.RoundToInt(baseYj * Eff("yujin_p", SkillKeys.CounterRatio).P)), Combat.CounterDamage(cfg, yj, gen, new System.Random(Seed), true), "밀렸을 때 = 반격 배율");
+ int baseC = Combat.Damage(cfg, gen, xh, false, new System.Random(Seed), true);
+ Assert.AreEqual(Mathf.Max(1, Mathf.RoundToInt(baseC * cfg.counterRatio * (1f + Eff("xuhuang_p", SkillKeys.CounterTakenPct).P))), Combat.CounterDamage(cfg, gen, xh, new System.Random(Seed)), "counter_taken_pct");
+ var gn = Spawn(UnitType.Archer, Faction.Player, 6f, -5f, "감녕");
+ Give(gn, "ganning");
+ Assert.AreEqual(0, Combat.CounterDamage(cfg, gen, gn, new System.Random(Seed)), "금범적 = 근접 반격 없음");
+
+ // 복수(on_ally_death_atk) · 패자(team_atk_pct_full · 출전 인원 조건)
+ yield return Fresh();
+ var wc = Spawn(UnitType.Cavalry, Faction.Player, 0f, -8f, "문추");
+ Give(wc, "wenchou");
+ var dead = Spawn(UnitType.Infantry, Faction.Player, 3f, -8f, "전사자");
+ Assert.AreEqual(1f, SkillMods.AttackMult(wc), 1e-5f);
+ dead.TakeDamage(dead.Hp);
+ Assert.AreEqual(1f + Eff("wenchou_p", SkillKeys.OnAllyDeathAtk).P, SkillMods.AttackMult(wc), 1e-5f, "on_ally_death_atk");
+
+ yield return Fresh();
+ var cc = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "조조");
+ Give(cc, "caocao");
+ var fe = Eff("caocao_p", SkillKeys.TeamAtkPctFull);
+ int needN = Mathf.RoundToInt(fe.A2);
+ var mates = new List();
+ for (int i = 1; i < needN; i++) mates.Add(Spawn(UnitType.Infantry, Faction.Player, -6f + i * 2.5f, -8f, "아군" + i));
+ bm.Skills.BeginBattle();
+ Assert.AreEqual(1f + fe.P, SkillMods.AttackMult(mates[0]), 1e-5f, "출전 n 인 = 아군 전원 공 +p");
+ mates[0].gameObject.SetActive(false);
+ bm.ClearUnits();
+ yield return null;
+ var cc2 = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "조조");
+ Give(cc2, "caocao");
+ var mate = Spawn(UnitType.Infantry, Faction.Player, 0f, -8f, "아군");
+ bm.Skills.BeginBattle();
+ Assert.AreEqual(1f, SkillMods.AttackMult(mate), 1e-5f, "인원 미달 = 없음");
+
+ // 산채 체력(hp_max_pct)
+ var zc = Spawn(UnitType.Infantry, Faction.Player, 6f, -8f, "주창");
+ int baseHp = zc.MaxHp;
+ Give(zc, "zhoucang");
+ Assert.AreEqual(Mathf.RoundToInt(baseHp * (1f + Eff("zhoucang_p", SkillKeys.HpMaxPct).P)), zc.MaxHp, "hp_max_pct");
+ Assert.AreEqual(zc.MaxHp, zc.Hp);
+ }
+
+ // ---------- 즉시형(needs_impl 처리기 포함) ----------
+
+ [UnityTest]
+ public IEnumerator Instant_PushFear_Fog_Guard_Aura_TeamTaken_HpCost_SlingTeam_Aggro()
+ {
+ yield return Fresh();
+ // 장판 일갈(aoe_push_fear): 반경 p2 안 적을 바깥으로 p1 · 이번 적 턴 교전 반응 없음
+ var zf = Spawn(UnitType.Cavalry, Faction.Player, 0f, -3f, "장비");
+ Give(zf, "zhangfei");
+ var pf = Eff("zhangfei_a", SkillKeys.AoePushFear);
+ var near = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -3f + 1.5f, "가까운 적");
+ var farE = Spawn(UnitType.Infantry, Faction.Enemy, 0f, -3f + pf.A2 + 3f, "먼 적");
+ float y0 = near.Position.y;
+ bm.Sling.Select(zf);
+ Assert.IsNotNull(bm.Hud.PressSkill());
+ yield return WaitPhase(BattlePhase.PlayerTurn);
+ Assert.AreEqual(y0 + pf.P, near.Position.y, 0.08f, "바깥으로 p1 밀기");
+ Assert.AreEqual(bm.TurnNumber + Cd("zhangfei_a"), bm.Skills.Of(zf).ReadyTurn, "즉시형도 쿨");
+ Assert.IsFalse(zf.ActedThisTurn, "행동 소모 없음");
+ Assert.IsFalse(bm.Skills.Suppressed(near), "아군 턴에는 해당 없음");
+ bm.ForcePhase(BattlePhase.EnemyTurn);
+ Assert.IsTrue(bm.Skills.Suppressed(near), "이번 적 턴 교전 반응 없음");
+ Assert.IsFalse(bm.Skills.Suppressed(farE), "반경 밖 적은 정상");
+ bm.ForcePhase(BattlePhase.PlayerTurn);
+
+ // 태평요술(enemy_aggro_off_turn): 이번 적 턴 적 전원
+ var zj = Spawn(UnitType.Infantry, Faction.Player, 4f, -6f, "장각");
+ Give(zj, "zhangjiao");
+ bm.Sling.Select(zj);
+ bm.Hud.PressSkill();
+ yield return WaitPhase(BattlePhase.PlayerTurn);
+ bm.ForcePhase(BattlePhase.EnemyTurn);
+ Assert.IsTrue(bm.Skills.Suppressed(farE), "안개 = 적 전원");
+ bm.ForcePhase(BattlePhase.PlayerTurn);
+ // 적 턴을 실제로 돌리면 아무도 움직이지 않고 · 끝나면 효과가 사라진다
+ zf.MarkActed();
+ zj.MarkActed();
+ Vector2 fp = farE.Position;
+ yield return bm.RunEnemyTurn();
+ Assert.AreEqual(fp, farE.Position, "교전 반응 없음 = 행동 안 함");
+ Assert.IsFalse(bm.Skills.Suppressed(farE), "다음 아군 턴 = 해제");
+
+ // 호위(guard_share) · 철벽(dmg_taken_aura) · 간웅(team_dmg_taken_pct_turn)
+ yield return Fresh();
+ var gp = Spawn(UnitType.Spear, Faction.Player, 0f, -3f, "관평");
+ Give(gp, "guanping");
+ var gs = Eff("guanping_a", SkillKeys.GuardShare);
+ var mate = Spawn(UnitType.Infantry, Faction.Player, gs.A2 * 0.8f, -3f, "아군");
+ mate.MaxHp = mate.Hp = gp.MaxHp = gp.Hp = 10000;
+ Spawn(UnitType.Infantry, Faction.Enemy, 0f, 9f, "먼 적"); // 적이 없으면 즉시 발동 뒤 승리 판정
+ bm.Sling.Select(gp);
+ bm.Hud.PressSkill();
+ yield return WaitPhase(BattlePhase.PlayerTurn);
+ int share = Mathf.RoundToInt(100 * Mathf.Clamp01(gs.P));
+ Assert.AreEqual(100, SkillSystem.Deal(mate, 100), "아군 턴 = 분담 없음");
+ bm.ForcePhase(BattlePhase.EnemyTurn);
+ int mh = mate.Hp, gh = gp.Hp;
+ Assert.AreEqual(100 - share, SkillSystem.Deal(mate, 100), "guard_share = 받는 피해 일부를 관평이");
+ Assert.AreEqual(gh - share, gp.Hp);
+ Assert.AreEqual(mh - (100 - share), mate.Hp);
+ bm.ForcePhase(BattlePhase.PlayerTurn);
+
+ yield return Fresh();
+ var cr = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "조인");
+ Give(cr, "caoren");
+ var au = Eff("caoren_a", SkillKeys.DmgTakenAura);
+ var inR = Spawn(UnitType.Infantry, Faction.Player, au.A2 * 0.8f, -3f, "곁");
+ var outR = Spawn(UnitType.Infantry, Faction.Player, au.A2 + 3f, -3f, "먼 아군");
+ var hitter = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "적");
+ bm.Sling.Select(cr);
+ bm.Hud.PressSkill();
+ yield return WaitPhase(BattlePhase.PlayerTurn);
+ Assert.AreEqual(1f, SkillMods.TakenMult(inR, hitter), 1e-5f, "아군 턴 = 아직");
+ bm.ForcePhase(BattlePhase.EnemyTurn);
+ Assert.AreEqual(1f + au.P, SkillMods.TakenMult(inR, hitter), 1e-5f, "dmg_taken_aura 반경 안");
+ Assert.AreEqual(1f, SkillMods.TakenMult(outR, hitter), 1e-5f, "반경 밖");
+ bm.ForcePhase(BattlePhase.PlayerTurn);
+
+ var cc = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "조조");
+ Give(cc, "caocao");
+ bm.Sling.Select(cc);
+ bm.Hud.PressSkill();
+ yield return WaitPhase(BattlePhase.PlayerTurn);
+ bm.ForcePhase(BattlePhase.EnemyTurn);
+ Assert.AreEqual(1f + Eff("caocao_a", SkillKeys.TeamDmgTakenPctTurn).P, SkillMods.TakenMult(outR, hitter), 1e-5f, "team_dmg_taken_pct_turn");
+ bm.ForcePhase(BattlePhase.PlayerTurn);
+
+ // 고육계(hp_cost_atk): HP p2 소모 · 이번 턴 공 +p1 · 적 턴 시작에 끝
+ yield return Fresh();
+ var hg = Spawn(UnitType.Infantry, Faction.Player, 0f, -3f, "황개");
+ Give(hg, "huanggai");
+ var hc = Eff("huanggai_a", SkillKeys.HpCostAtk);
+ Spawn(UnitType.Infantry, Faction.Enemy, 0f, 9f, "먼 적");
+ int hp0 = hg.Hp;
+ bm.Sling.Select(hg);
+ bm.Hud.PressSkill();
+ yield return WaitPhase(BattlePhase.PlayerTurn);
+ Assert.AreEqual(Mathf.Max(1, hp0 - Mathf.RoundToInt(hg.MaxHp * hc.A2)), hg.Hp, "HP 소모");
+ Assert.AreEqual(1f + hc.P, SkillMods.AttackMult(hg), 1e-5f, "이번 턴 공 +");
+ bm.Skills.OnEnemyTurnStart();
+ Assert.AreEqual(1f, SkillMods.AttackMult(hg), 1e-5f, "적 턴 시작 = 끝");
+
+ // 동남풍(sling_dist_pct · p3 1 = 아군 전원 · 이번 턴) · 적토마(p2 절대 가산) · 신산(enemy_aggro_add)
+ yield return Fresh();
+ var zg = Spawn(UnitType.Strategist, Faction.Player, 0f, -6f, "제갈량");
+ Give(zg, "zhugeliang");
+ var lb = Spawn(UnitType.Cavalry, Faction.Player, 4f, -6f, "여포");
+ Give(lb, "lvbu");
+ var foot = Spawn(UnitType.Infantry, Faction.Player, -4f, -6f, "보병");
+ var en = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "적");
+ var lbp = Eff("lvbu_p", SkillKeys.SlingDistPct);
+ Assert.AreEqual(lb.SlingMaxDistance * (1f + lbp.P) + lbp.A2, SkillMods.SlingMax(lb), 1e-4f, "적토마 = 절대 가산");
+ Assert.AreEqual(Eff("zhugeliang_p", SkillKeys.EnemyAggroAdd).P, bm.Skills.AggroAdd(Faction.Enemy), 1e-5f, "신산 = 적 교전 범위 +p");
+ Assert.AreEqual(0f, bm.Skills.AggroAdd(Faction.Player), 1e-5f);
+ bm.Sling.Select(zg);
+ bm.Hud.PressSkill();
+ yield return WaitPhase(BattlePhase.PlayerTurn);
+ var dn = Eff("zhugeliang_a", SkillKeys.SlingDistPct);
+ Assert.AreEqual(foot.SlingMaxDistance * (1f + dn.P) + dn.A2, SkillMods.SlingMax(foot), 1e-4f, "동남풍 = 아군 전원");
+ Assert.AreEqual(en.SlingMaxDistance, SkillMods.SlingMax(en), 1e-4f, "상대 제외");
+ bm.Skills.OnEnemyTurnStart();
+ Assert.AreEqual(foot.SlingMaxDistance, SkillMods.SlingMax(foot), 1e-4f, "이번 턴만");
+ }
+
+ [UnityTest]
+ public IEnumerator Instant_Teleport_TapInsideRadius_NoActionUsed_OutsideCancels()
+ {
+ yield return Fresh();
+ var zh = Spawn(UnitType.Spear, Faction.Player, 0f, -5f, "장합");
+ Give(zh, "zhanghe");
+ Spawn(UnitType.Infantry, Faction.Enemy, 0f, 8f, "적");
+ float r = Eff("zhanghe_a", SkillKeys.Teleport).P;
+ bm.Sling.Select(zh);
+ bm.Hud.PressSkill();
+ Assert.AreEqual(zh, bm.Skills.PendingTeleport, "변진 = 지점 탭 대기");
+ yield return null;
+ Assert.IsTrue(bm.Sling.TeleportAreaVisible, "반경 원 표시");
+ string msg;
+ Assert.IsTrue(bm.Skills.TryTeleportTap(zh.Position + new Vector2(r + 1f, 0f), out msg));
+ Assert.IsNull(bm.Skills.PendingTeleport);
+ Assert.AreEqual(new Vector2(0f, -5f), zh.Position, "반경 밖 = 취소");
+ Assert.AreEqual(SkillSystem.ActiveState.Ready, bm.Skills.StateOf(zh), "취소 = 쿨 소모 없음");
+ bm.Hud.PressSkill();
+ Vector2 dest = zh.Position + new Vector2(r * 0.7f, 0f);
+ Assert.IsTrue(bm.Skills.TryTeleportTap(dest, out msg));
+ yield return WaitPhase(BattlePhase.PlayerTurn);
+ Assert.Less(Vector2.Distance(dest, zh.Position), 0.01f, "teleport = 반경 안 빈 곳으로 이동");
+ Assert.IsFalse(zh.ActedThisTurn, "행동 소모 없음");
+ Assert.AreEqual(bm.TurnNumber + Cd("zhanghe_a"), bm.Skills.Of(zh).ReadyTurn);
+ }
+
+ // ---------- 쿨 · 예약 · 취소 · HUD ----------
+
+ [UnityTest]
+ public IEnumerator Active_Reserve_Cancel_UseCooldown_HudButton_PassiveLog()
+ {
+ yield return Fresh();
+ var gy = Spawn(UnitType.Spear, Faction.Player, 0f, -3f, "관우");
+ Give(gy, "guanyu");
+ var other = Spawn(UnitType.Infantry, Faction.Player, -6f, -8f, "보병");
+ var foe = Spawn(UnitType.Infantry, Faction.Enemy, 0f, 0f, "적");
+ foe.MaxHp = foe.Hp = 10000;
+ bm.Sling.Select(gy);
+ yield return null;
+ Assert.IsTrue(bm.Hud.SkillButtonVisible, "유닛 선택 → 스킬 버튼");
+ StringAssert.Contains(SkillCatalog.Get("guanyu_a").NameKo, bm.Hud.SkillButtonText, "이름");
+ Assert.AreEqual(SkillSystem.ActiveState.Ready, bm.Skills.StateOf(gy), "전투 시작 = 사용 가능");
+
+ bm.Hud.PressSkill();
+ Assert.IsTrue(bm.Skills.Of(gy).Reserved, "탭 = 예약");
+ yield return null;
+ Assert.IsTrue(gy.View.SkillBadgeVisible, "예약 표시");
+ bm.Hud.PressSkill();
+ Assert.IsFalse(bm.Skills.Of(gy).Reserved, "다시 탭 = 취소");
+ Assert.AreEqual(1, bm.Skills.Of(gy).ReadyTurn, "취소 = 쿨 소모 없음");
+
+ // 예약 후 쓰지 않고 적 턴 = 예약만 사라짐
+ bm.Hud.PressSkill();
+ bm.Skills.OnEnemyTurnStart();
+ Assert.IsFalse(bm.Skills.Of(gy).Reserved);
+ Assert.AreEqual(1, bm.Skills.Of(gy).ReadyTurn);
+
+ // 예약 → 실제 행동(RequestPlayerAction) → 쿨
+ bm.Sling.Select(gy);
+ bm.Hud.PressSkill();
+ Assert.IsTrue(bm.RequestPlayerAction(gy, foe.Position));
+ yield return WaitPhase(BattlePhase.PlayerTurn);
+ int cd = Cd("guanyu_a");
+ Assert.AreEqual(1 + cd, bm.Skills.Of(gy).ReadyTurn, "사용한 턴 + 쿨");
+ StringAssert.Contains(SkillCatalog.Get("guanyu_a").NameKo, bm.LastMessage, "행동 문구에 스킬 이름");
+ StringAssert.Contains("액티브", string.Join(" ", bm.Skills.RecentLog(30f, 8).ToArray()), "HUD 액티브 한 줄");
+ gy.ResetForNewTurn();
+ bm.SetTurnForTest(cd);
+ Assert.AreEqual(SkillSystem.ActiveState.Cooldown, bm.Skills.StateOf(gy), "쿨 중 = 사용 불가");
+ Assert.AreEqual(1, bm.Skills.Of(gy).CooldownLeft(bm.TurnNumber));
+ bm.Hud.PressSkill();
+ Assert.IsFalse(bm.Skills.Of(gy).Reserved, "쿨 중 탭 = 예약 안 됨");
+ bm.SetTurnForTest(1 + cd);
+ Assert.AreEqual(SkillSystem.ActiveState.Ready, bm.Skills.StateOf(gy), "N 턴 뒤 재사용");
+
+ // 행동 끝난 유닛 = next_action 예약 불가 · 적 유닛 = 버튼은 패시브만
+ gy.MarkActed();
+ Assert.AreEqual(SkillSystem.ActiveState.Acted, bm.Skills.StateOf(gy));
+ var eb = Spawn(UnitType.Infantry, Faction.Enemy, 6f, 6f, "적 무장");
+ Give(eb, "caoren");
+ Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(eb), "적 = 액티브 사용 없음");
+
+ // 패시브 발동 = HUD 한 줄
+ Combat.Damage(cfg, foe, eb, false, new System.Random(Seed), true);
+ StringAssert.Contains(SkillCatalog.Get("caoren_p").NameKo, bm.Skills.Log[bm.Skills.Log.Count - 1].Text, "패시브 발동 한 줄");
+ StringAssert.Contains("패시브", bm.Skills.Log[bm.Skills.Log.Count - 1].Text);
+ bm.Sling.Select(eb);
+ yield return null;
+ StringAssert.Contains("패시브", bm.Hud.SkillButtonText);
+ }
+
+ // ---------- ★3 · ★5 · 편성 · 적 무장 ----------
+
+ [UnityTest]
+ public IEnumerator Star3ActiveCap_Star5PassiveMult_Lineup_EnemyGeneralPassiveOnly()
+ {
+ var db = Db;
+ int capStar = 0, multStar = 0;
+ foreach (var r in db.Evolve.rows)
+ {
+ if (capStar == 0 && SkillRules.ActiveCapBonus(db, r.star) > 0) capStar = r.star;
+ if (multStar == 0 && SkillRules.PassiveMult(db, r.star) > 1f) multStar = r.star;
+ }
+ Assert.Greater(capStar, 1, "evolve.csv 액티브 상한 해금 ★");
+ Assert.Greater(multStar, 1, "evolve.csv 패시브 배율 ★");
+ var save = HeroRules.NewSave(db, cfg.playerSpawnPoints.Length, T0);
+ var h = db.Heroes.Get(db.Economy.StartHeroes[0]);
+ var hs = save.Hero(h.id);
+ hs.star = capStar - 1;
+ int capBefore = SkillRules.Cap(db, h, hs, 0);
+ hs.star = capStar;
+ Assert.AreEqual(Mathf.Min(capBefore + SkillRules.ActiveCapBonus(db, capStar), db.Skills.Get(h.skillActive).lvMax), SkillRules.Cap(db, h, hs, 0), "★3 = 액티브 상한 +");
+ Assert.AreEqual(HeroRules.SkillCap(db, hs), SkillRules.Cap(db, h, hs, 1), "패시브 상한 = 등급");
+
+ var e = new LineupEntry();
+ hs.star = multStar - 1;
+ SkillRules.FillLineup(db, save, h, e);
+ Assert.AreEqual(1f, e.passiveMult, 1e-5f);
+ hs.star = multStar;
+ SkillRules.FillLineup(db, save, h, e);
+ Assert.AreEqual(SkillRules.PassiveMult(db, multStar), e.passiveMult, 1e-5f, "★5 = 패시브 배율");
+ Assert.AreEqual(h.skillActive, e.skillActive);
+ Assert.IsTrue(e.general);
+
+ // 편성 → 전투 유닛에 스킬(시작 영웅 = 액티브·패시브) · ★5 패시브 수치 ×
+ yield return Fresh();
+ var u = Spawn(h.type, Faction.Player, 0f, -6f, h.nameKo);
+ bm.Skills.AttachLineup(u, e);
+ var s = bm.Skills.Of(u);
+ Assert.AreEqual(h.skillActive, s.Active.Id);
+ var raw = SkillCatalog.Get(h.skillPassive).Effects[0];
+ SkillKeys.Info info = SkillKeys.All[raw.Key];
+ Assert.AreEqual(SkillKeys.Apply(info.Main, raw.P, e.passiveMult), s.PassiveEffects[0].P, 1e-4f, "★5 패시브 효과 적용");
+
+ // 적 무장(heroes.csv 에 있는 보스·엘리트) = 패시브만
+ int idx = -1;
+ for (int i = 0; i < cfg.stages.Length && idx < 0; i++)
+ foreach (var en in cfg.stages[i].enemies)
+ if ((en.isBoss || en.isGeneral) && SkillCatalog.HeroForEnemy(en.art, en.name) != null)
+ {
+ idx = i;
+ break;
+ }
+ Assert.GreaterOrEqual(idx, 0, "영웅 표에 있는 적 무장 스테이지");
+ bm.StartStage(idx);
+ yield return null;
+ int withPassive = 0;
+ foreach (var x in bm.Units)
+ {
+ if (x == null || x.Faction != Faction.Enemy) continue;
+ var es = bm.Skills.Of(x);
+ if (es == null || es.Passive == null) continue;
+ withPassive++;
+ Assert.IsNull(es.Active, "적 = 액티브 없음(AI 사용은 2차)");
+ Assert.AreEqual(SkillSystem.ActiveState.None, bm.Skills.StateOf(x));
+ Assert.IsTrue(es.IsGeneral);
+ }
+ Assert.Greater(withPassive, 0, "적 무장 패시브 부착");
+ int playersWithActive = 0;
+ foreach (var x in bm.Units)
+ if (x != null && x.Faction == Faction.Player && bm.Skills.Of(x) != null && bm.Skills.Of(x).Active != null) playersWithActive++;
+ Assert.AreEqual(bm.Deployed.Count, playersWithActive, "출전 영웅 = 액티브 부착");
+ }
+
+ // ---------- 스킬 레벨업 · 스킬서 ----------
+
+ [Test]
+ public void SkillLevelUp_BooksGold_Cap_Max_BookShopDaily()
+ {
+ var db = Db;
+ var save = HeroRules.NewSave(db, Resources.Load("BalanceConfig").playerSpawnPoints.Length, T0);
+ Assert.AreEqual(db.Economy.StartSkillBooks, save.inv.books.skill, "신규 유저 스킬서");
+ var h = db.Heroes.Get(db.Economy.StartHeroes[0]);
+ var hs = save.Hero(h.id);
+ int cap = SkillRules.Cap(db, h, hs, 1);
+ Assert.AreEqual(1, SkillRules.Level(hs, 1));
+ if (cap <= 1) StringAssert.Contains("상한", SkillRules.CanLevelUp(db, save, h, hs, 1), "상한 = 등급");
+
+ var cost = db.Skills.CostTo(2);
+ int books0 = save.inv.books.skill, gold0 = save.gold;
+ hs.grade = db.Promotion.Get(hs.grade).nextGrade; // 승급 → 상한 +1
+ Assert.IsNull(SkillRules.LevelUp(db, save, h, hs, 1));
+ Assert.AreEqual(2, SkillRules.Level(hs, 1));
+ Assert.AreEqual(books0 - cost.books, save.inv.books.skill, "스킬서 소모");
+ Assert.AreEqual(gold0 - cost.gold, save.gold, "골드 소모");
+
+ save.inv.books.skill = 0;
+ hs.grade = db.Promotion.Get(hs.grade).nextGrade;
+ StringAssert.Contains("스킬서", SkillRules.CanLevelUp(db, save, h, hs, 1), "스킬서 부족");
+ hs.skillLv[0] = db.Skills.Get(h.skillActive).lvMax;
+ Assert.AreEqual("최고 레벨", SkillRules.CanLevelUp(db, save, h, hs, 0));
+
+ // 스킬서 임시 판매: 하루 n 개 · 골드 · 다음 날 다시
+ var e = db.Economy;
+ save.gold = e.SkillBookPrice * (e.SkillBookShopDaily + 2);
+ int g = save.gold;
+ for (int i = 0; i < e.SkillBookShopDaily; i++) Assert.IsNull(SkillRules.BuyBook(db, save, T0), "구매 " + i);
+ Assert.IsNotNull(SkillRules.BuyBook(db, save, T0), "하루 한도");
+ Assert.AreEqual(e.SkillBookShopDaily, save.inv.books.skill);
+ Assert.AreEqual(g - e.SkillBookPrice * e.SkillBookShopDaily, save.gold);
+ Assert.IsNull(SkillRules.BuyBook(db, save, T0.AddDays(1)), "다음 날 다시");
+ }
+
+ [UnityTest]
+ public IEnumerator HeroDetail_SkillUpButton_OpensModal_LevelsUp_ShopHasBookCell()
+ {
+ var og = OutGameRoot.Instance;
+ Assert.IsNotNull(og);
+ og.ShowLobby();
+ var id = og.Session.Db.Economy.StartHeroes[0];
+ og.ShowHero(id);
+ yield return null;
+ og.HeroDetail.OnSkillUp();
+ Assert.IsTrue(og.ModalVisible, "스킬 레벨업 = 고르기 팝업");
+ og.CloseModal();
+ var h = og.Session.Def(id);
+ var hs = og.Session.Save.Hero(id);
+ int lv0 = SkillRules.Level(hs, 0);
+ if (SkillRules.CanLevelUp(og.Session.Db, og.Session.Save, h, hs, 0) == null)
+ {
+ Assert.IsNull(SkillRules.LevelUp(og.Session.Db, og.Session.Save, h, hs, 0));
+ og.Session.Commit();
+ Assert.AreEqual(lv0 + 1, og.Session.Save.Hero(id).skillLv[0], "세이브 반영");
+ }
+ og.Trade.OpenShop();
+ yield return null;
+ Assert.IsTrue(og.Trade.SkillBookCellVisible, "거래 상점 = 스킬서 칸");
+ Assert.AreEqual(og.Session.Db.Economy.ShopSlots, og.Trade.ShopCardCount, "장비 칸 수는 그대로");
+ }
+ }
+}
diff --git a/Assets/Tests/PlayMode/SkillTests.cs.meta b/Assets/Tests/PlayMode/SkillTests.cs.meta
new file mode 100644
index 00000000..65829023
--- /dev/null
+++ b/Assets/Tests/PlayMode/SkillTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fddad1befff291c4b94bad9d444f79da
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 8379786b..d8cba7ae 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,6 +45,7 @@ Unity.exe -projectPath -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 `.
- 아웃게임 코드 = `Assets/Scripts/OutGame/**`(asmref → TKS.Runtime) — `OutGameSession`(표 + 세이브 · 바꾸는 동작은 즉시 저장) · 규칙 `HeroRules`·`EconomyRules`·`GachaRules` · 화면 `UI/`(로비·영웅·편성·거래·결과 패널 · `OutGameRoot` 가 전환). 세이브 = `persistentDataPath/save.json`(테스트·검증 캡처는 `SaveStore.UseMemory` / SessionState `TKS_SAVE_MEMORY` 로 메모리 전용). 출전 = `BattleManager.LineupProvider`(편성 프리셋 → `playerSpawnPoints` 자리 0~2 전열 · 3~4 후열). 화면 캡처 = `Unity.exe -projectPath <사본> -executeMethod TKS.EditorTools.DataImporter.CaptureOutGame -outgameDir `.
- **챕터·스테이지 SOT = `Assets/Data/Stages/*.csv`(utf-8-sig · 2026-11-05 145 스테이지 · 기획서 #1086 §4 전사)** — `stages.csv`(chapter·stage·title·type normal|elite|boss·place·faction·병종 4 수·generals `id:병종:true|false`·bg_theme·note) · `chapters.csv`(장 제목·연도·**월드맵 맵 id**·AI 출발값) · `generals.csv`(무장 id → 이름·배율 덮어쓰기) · `factions.csv`(세력 코드 → 이름·병사 아트 접두). 고친 뒤 메뉴 `TKS/Import Stages (스테이지 임포트)`(`StageImporter` · 형식 오류면 자산 미변경) → `BalanceConfig.chapters·stages`. 적 좌표 = `BalanceConfig.formation` 규칙(전위·중군·후군·장수 줄 · `StageTable.Enemies`) · 장수 배율 = `generals.csv` 가 비면 `boss/eliteGeneral*Mult`. 월드맵 노드 = 스테이지 표(챕터별 수 · 보스 = type boss) · 노드 자리 = `ChapterMap/Data/<맵>.json` `slots_N`(없으면 길 위 균등).
- 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 22종 이상 전부 통과 확인.
@@ -70,5 +71,6 @@ Unity.exe -projectPath -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
- `Assets/Scripts/OutGame/` — 아웃게임(세이브·로비·영웅·편성·거래·보상 · 위 「코드 규칙」)
- `Assets/Data/Stages/` — 챕터·스테이지 표 CSV 4장(위 「코드 규칙」) · 해석·배치 = `Runtime/StageTable`
- `Assets/Resources/Units//` — 유닛 아트 프레임 + 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`)