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`)