From 6992d992dfce56192041060de98715ecf912874c Mon Sep 17 00:00:00 2001 From: swrring Date: Thu, 5 Nov 2026 13:06:32 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B1=85=EC=82=AC=20=EB=B3=91=EC=A2=85=20+=20?= =?UTF-8?q?=EA=B6=81=EB=B3=91=20=ED=99=94=EC=82=B4=20=ED=88=AC=EC=82=AC?= =?UTF-8?q?=EC=B2=B4(PD=20#1087=20=E2=91=A4=E2=91=A5)=20=E2=80=94=20?= =?UTF-8?q?=EA=B6=81=EB=B3=91=20=EC=82=AC=EA=B2=A9=20=3D=20=ED=83=80?= =?UTF-8?q?=EA=B2=9F=20=EB=B0=A9=ED=96=A5=20=ED=99=94=EC=82=B4(arrowRadius?= =?UTF-8?q?=200.1=20=EC=9B=90=20=EC=BA=90=EC=8A=A4=ED=8A=B8=20=C2=B7=20?= =?UTF-8?q?=EB=B9=84=ED=96=89=20=3D=20=EC=82=AC=EA=B1=B0=EB=A6=AC=20=C2=B7?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=9C=84=20=EC=B2=AB=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=9B=20=ED=94=BC=EA=B2=A9=20=C2=B7=20=EC=95=84=EA=B5=B0=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=20=C2=B7=20=EA=B4=80=ED=86=B5=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=B2=AB=20=ED=94=BC=EA=B2=A9=20=3D=20?= =?UTF-8?q?=ED=83=80=EA=B2=9F=20+=20=EB=AA=A9=ED=91=9C=EC=A0=90=20?= =?UTF-8?q?=EB=AA=85=EC=A4=91=20=EC=8B=9C=20=ED=81=AC=EB=A6=AC=20=C2=B7=20?= =?UTF-8?q?=EC=95=84=EA=B5=B0=20=ED=94=BC=EA=B2=A9=20=3D=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EA=B3=B5=EC=8B=9D=20=C2=B7=20=EB=B0=98=EA=B2=A9?= =?UTF-8?q?=C2=B7=EB=AA=85=EC=A4=91=20=EB=B3=B4=EC=83=81=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=ED=83=80=EA=B2=9F=20=EC=97=86=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EC=8F=98=EC=A7=80=20=EC=95=8A=EC=9D=8C=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80)=20=C2=B7=20UnitType.Strategist(=EC=B1=85=20=C2=B7=20?= =?UTF-8?q?65/24/3=20=C2=B7=20=EC=8A=AC=EB=A7=81=204.0=20=C2=B7=20?= =?UTF-8?q?=EB=B0=98=EC=A7=80=EB=A6=84=200.42=20=C2=B7=20=EC=83=81?= =?UTF-8?q?=EC=84=B1=205=C3=975=20=3D=20=EA=B6=81=EB=B3=91=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EA=B0=92=20=C2=B7=20=EA=B7=BC=EC=A0=91=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=ED=8B=B0=20=EB=8F=99=EC=9D=BC)=20=3D=20Arche?= =?UTF-8?q?rRoutine=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20+=20=EC=A4=91=EC=8B=AC?= =?UTF-8?q?(=EC=B2=AB=20=ED=94=BC=EA=B2=A9)=20=EB=B0=98=EA=B2=BD=20strateg?= =?UTF-8?q?istSplashRadius=201.5=20=EC=95=88=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=9B=20=EC=A0=84=EC=9B=90=20=C3=97=20strategistSp?= =?UTF-8?q?lashRatio=200.6(=ED=81=AC=EB=A6=AC=20=EC=97=86=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EC=83=81=EC=84=B1=20=EB=8C=80=EC=83=81=EB=B3=84)=20?= =?UTF-8?q?=C2=B7=20=EA=B0=99=EC=9D=80=20=ED=8E=B8=20=C3=97=20strategistAl?= =?UTF-8?q?lyDamageRatio=201.0=20=C2=B7=20=EC=8F=9C=20=EC=B1=85=EC=82=AC?= =?UTF-8?q?=20=EC=A0=9C=EC=99=B8=20=C2=B7=20=EC=A1=B0=EC=A4=80=20UI=20=3D?= =?UTF-8?q?=20=ED=99=94=EC=82=B4=20=EA=B2=BD=EB=A1=9C=EC=84=A0=20+=20?= =?UTF-8?q?=EC=B2=AB=20=ED=94=BC=EA=B2=A9=20=EA=B0=95=EC=A1=B0(=EC=95=84?= =?UTF-8?q?=EA=B5=B0=20=EB=B6=89=EA=B2=8C)=20+=20=EC=B1=85=EC=82=AC=20?= =?UTF-8?q?=EA=B4=91=EC=97=AD=20=EC=9B=90=C2=B7=ED=9C=A9=EC=93=B8=EB=A6=B4?= =?UTF-8?q?=20=EC=95=84=EA=B5=B0=20=EB=B6=89=EA=B2=8C=20=C2=B7=20=EC=A0=81?= =?UTF-8?q?=20AI=20=3D=20=EC=82=AC=EC=84=A0=EC=9D=B4=20=EB=B9=88=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=20=EC=9A=B0=EC=84=A0=20=C2=B7=20=EC=B1=85?= =?UTF-8?q?=EC=82=AC=EB=8A=94=20=EA=B4=91=EC=97=AD=EC=97=90=20=EC=9E=90?= =?UTF-8?q?=EA=B8=B0=20=ED=8E=B8=20=EC=97=86=EB=8A=94=20=EB=8C=80=EC=83=81?= =?UTF-8?q?=20=EB=A8=BC=EC=A0=80=20=C2=B7=20=EC=97=86=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EA=B7=B8=EB=83=A5=20=EC=8F=A8=20=C2=B7=20=EC=97=B0=EC=B6=9C=20?= =?UTF-8?q?=3D=20=ED=88=AC=EC=82=AC=EC=B2=B4=201=EC=A2=85(=EC=B1=85?= =?UTF-8?q?=EC=82=AC=20=EB=B3=B4=EB=9D=BC)=20+=20=EA=B4=91=EC=97=AD=20?= =?UTF-8?q?=ED=84=B0=EC=A7=90=20=C2=B7=20BalanceConfig=204=ED=95=84?= =?UTF-8?q?=EB=93=9C=20+=20BalanceDefaults=20=EC=8B=9C=EB=93=9C=20=C2=B7?= =?UTF-8?q?=20=EC=9E=AC=EC=8B=9C=EB=93=9C(=EC=B6=94=EA=B0=80=EB=A7=8C)=20?= =?UTF-8?q?=C2=B7=20stages.csv=20strategist=20=EC=97=B4(=EC=A0=84=EB=B6=80?= =?UTF-8?q?=200=20=C2=B7=20=EB=B9=84=EA=B1=B0=EB=82=98=20=EC=97=86?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=200)=20+=20=EC=A4=91=EA=B5=B0=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98(=EA=B6=81=20=EB=8B=A4=EC=9D=8C)=20=C2=B7=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=2040/40(=EC=8B=A0=EA=B7=9C=209)=20=C2=B7=20c?= =?UTF-8?q?onsole=20error=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Assets/Data/Stages/stages.csv | 292 +++++++++++------------ Assets/Resources/BalanceConfig.asset | 20 ++ Assets/Scripts/Editor/BalanceDefaults.cs | 10 +- Assets/Scripts/Runtime/ActionResolver.cs | 187 +++++++++++---- Assets/Scripts/Runtime/AutoPlayDriver.cs | 6 +- Assets/Scripts/Runtime/BalanceConfig.cs | 12 +- Assets/Scripts/Runtime/BattleHUD.cs | 3 +- Assets/Scripts/Runtime/BattleManager.cs | 15 +- Assets/Scripts/Runtime/Combat.cs | 56 ++++- Assets/Scripts/Runtime/EnemyAI.cs | 49 +++- Assets/Scripts/Runtime/Fx.cs | 39 ++- Assets/Scripts/Runtime/RangeIndicator.cs | 4 +- Assets/Scripts/Runtime/SlingshotInput.cs | 97 +++++++- Assets/Scripts/Runtime/StageTable.cs | 13 +- Assets/Scripts/Runtime/Unit.cs | 10 +- Assets/Scripts/Runtime/UnitTypes.cs | 28 ++- Assets/Scripts/Runtime/UnitView.cs | 27 ++- Assets/Tests/PlayMode/BattleFlowTests.cs | 264 +++++++++++++++++++- 18 files changed, 902 insertions(+), 230 deletions(-) diff --git a/Assets/Data/Stages/stages.csv b/Assets/Data/Stages/stages.csv index 1132e28b..3077f95d 100644 --- a/Assets/Data/Stages/stages.csv +++ b/Assets/Data/Stages/stages.csv @@ -1,146 +1,146 @@ -chapter,stage,title,type,place,faction,infantry,spear,cavalry,archer,generals,bg_theme,note -1,1,탁현 외곽 — 황건 척후,normal,탁현,hgj,3,0,0,0,,마을 어귀,튜토리얼(조준·이동) -1,2,누상촌 약탈대,normal,누상촌,hgj,3,1,0,0,,마을,튜토리얼(기병 관통) -1,3,대흥산 길목,normal,대흥산,hgj,3,2,0,0,,산길,튜토리얼(창병 견제) -1,4,대흥산 — 등무,elite,대흥산,hgj,3,2,0,1,hgj_boss_dengmao:spear:false,산기슭, -1,5,대흥산 전투 — 정원지,boss,대흥산,hgj,4,2,0,1,hgj_boss_chengyuanzhi:infantry:true,산기슭 평지, -1,6,청주 포위망,normal,청주,hgj,4,2,0,2,,성 밖 들판, -1,7,청주 산골 매복,normal,청주,hgj,3,2,1,2,,협곡,일반(적 기병 첫 등장) -1,8,영천 — 불길 속 황건,elite,영천,hgj,3,2,1,2,hgj_boss_zhangbao:archer:false,불탄 들판, -1,9,광종 — 황건 본진 전위,elite,광종,hgj,4,2,1,2,hgj_boss_zhangliang:infantry:false,진영 앞 평원, -1,10,광종 결전 — 장각,boss,광종,hgj,4,2,1,2,hgj_boss_zhangjiao:infantry:true,황건 본진, -2,1,산조 진영 — 동탁군 척후,normal,산조,dz,3,0,2,1,,군영, -2,2,사수관 가는 길,normal,사수관 동쪽,dz,3,2,1,1,,산길, -2,3,사수관 앞 — 호진,elite,사수관,dz,3,2,1,2,dz_boss_huzhen:cavalry:false,관문 앞 들판, -2,4,손견 진영 야습,normal,사수관,dz,3,2,2,1,,밤 군영, -2,5,사수관 — 화웅,boss,사수관,dz,4,2,1,2,dz_boss_huaxiong:infantry:true,관문 앞, -2,6,사수관 함락 — 잔병,normal,사수관,dz,3,3,1,2,,관문 안쪽, -2,7,호로관 가는 길 — 서량 기병,normal,호로관 동쪽,dz,2,2,3,2,,들판, -2,8,호로관 앞 — 방열이 쓰러진 자리,normal,호로관,dz,2,2,4,1,,관문 앞, -2,9,호로관 측면 — 적토마 돌격대,normal,호로관,dz,2,3,4,1,,관문 측면, -2,10,호로관 — 여포,boss,호로관,dz,3,2,3,2,dz_boss_lvbu:cavalry:true,관문 앞, -3,1,평원 출진 — 원소군 척후,normal,평원,ys,3,2,1,2,,들판, -3,2,반하 들판,normal,반하,ys,3,2,2,1,,강변 들판, -3,3,반하 다리 — 문추의 추격,elite,반하,ys,2,2,3,1,ys_boss_wenchou:cavalry:false,다리·강, -3,4,반하 — 엄강의 패전,normal,반하,ys,2,2,0,5,,강변, -3,5,반하 — 국의,boss,반하,ys,2,2,0,4,ys_boss_quyi:archer:true,강변 진영, -3,6,북해 가는 길 — 황건 잔당,normal,북해 서쪽,hgj,4,2,0,2,,들판, -3,7,북해 포위망 바깥,normal,북해,hgj,4,2,1,2,,성 밖, -3,8,태사자의 돌파로,normal,북해,hgj,4,3,1,2,,성 밖, -3,9,북해 성문 — 관해 친위,normal,북해,hgj,5,2,1,2,,성문, -3,10,북해 — 관해,boss,북해,hgj,4,2,1,2,hgj_boss_guanhai:infantry:true,성문 앞, -3,11,서주 가는 길 — 불타는 마을,normal,서주 북쪽,cc,3,2,2,2,,불탄 마을, -3,12,서주 외곽 — 조조 포위진,normal,서주,cc,3,3,2,2,,들판, -3,13,서주 남쪽 — 피난민 행렬,normal,서주,cc,3,3,3,2,,들판, -3,14,조조 진영 외곽,normal,서주,cc,3,3,2,3,,군영, -3,15,서주 — 우금,boss,서주,cc,3,3,2,2,cc_boss_yujin:infantry:true,군영 앞, -4,1,우이 들판 — 원술군 선봉,normal,우이,ysu,3,3,1,2,,들판, -4,2,회음 강변 — 군량 수송대,normal,회음,ysu,4,2,0,2,,강변, -4,3,우이 — 순정,elite,우이,ysu,3,3,1,2,ysu_boss_xunzheng:spear:false,들판, -4,4,우이 진영 야습,normal,우이,ysu,3,3,2,2,,밤 군영, -4,5,우이 — 기령,boss,우이,ysu,3,3,2,2,ysu_boss_jiling:spear:true,강변 들판, -4,6,서주성의 밤 — 조표의 배신,normal,서주성,lb,2,2,4,2,,밤 성안, -4,7,소패 — 빼앗긴 말,normal,소패,lb,3,2,3,2,,성 밖, -4,8,소패 탈출 — 여포군 추격,normal,소패 서쪽,lb,2,2,4,2,,들판, -4,9,소패 구원 — 조성,elite,소패,lb,3,2,2,3,dz_boss_caoxing:archer:false,성 밖, -4,10,소패 — 고순,boss,소패,lb,5,2,1,2,dz_boss_gaoshun:infantry:true,성문 앞, -4,11,서주성 — 진등의 계책,normal,서주성,lb,3,3,2,2,,성 밖, -4,12,하비 외곽 — 진궁,elite,하비,lb,3,2,2,3,dz_boss_chengong:archer:false,들판, -4,13,하비 수공,normal,하비,lb,3,3,2,3,,물에 잠긴 성, -4,14,하비 성문 — 장료,elite,하비,lb,3,2,3,2,dz_boss_zhangliao:cavalry:false,성문, -4,15,하비 — 여포,boss,하비(백문루),lb,3,2,3,3,dz_boss_lvbu:cavalry:true,성안 누각 앞, -5,1,허도 근교 — 사냥터의 도적,normal,허도,sj,3,2,2,2,,숲,세력 추정(기획서 미기재 · 도적 = 7장 산적 선례) -5,2,허도 출진 — 원술 차단,normal,허도 동쪽,ysu,3,2,2,2,,들판,세력 추정(기획서 미기재 · 원술 차단 출진) -5,3,서주 남쪽 — 원술군 선봉,normal,서주 남쪽,ysu,3,3,2,2,,들판, -5,4,원술 친위대,normal,서주 남쪽,ysu,3,3,2,3,,들판, -5,5,서주 남쪽 — 기령,boss,서주 남쪽,ysu,3,3,2,2,ysu_boss_jiling:spear:true,들판, -5,6,원술의 몰락 — 패잔병,normal,수춘 북쪽,ysu,4,3,0,2,,황무지, -5,7,서주성 앞 — 차주의 복병,normal,서주성,cc,3,3,2,2,,성문 밖 밤, -5,8,서주성 — 차주,elite,서주성,cc,3,3,2,2,cc_boss_chezhou:infantry:false,성문, -5,9,서주 외곽 — 조조 토벌군,normal,서주,cc,3,3,2,3,,들판, -5,10,서주 — 유대·왕충,boss,서주,cc,3,3,2,2,cc_boss_liudai:spear:true;cc_boss_wangzhong:infantry:true,들판 진영,보스(2인) -5,11,소패 야습,normal,소패,cc,3,3,3,2,,밤 군영, -5,12,소패 패주로,normal,소패 북쪽,cc,2,3,4,2,,들판, -5,13,하비 — 고립된 관우,normal,하비,cc,3,3,3,3,,성 밖, -5,14,하비 성 밖 — 하후돈의 유인,elite,하비,cc,3,3,3,2,cc_boss_xiahoudun:cavalry:false,성 밖 들판, -5,15,토산 — 서황·허저,boss,토산,cc,3,3,2,3,cc_boss_xuhuang:infantry:true;cc_boss_xuchu:infantry:true,산,보스(2인) -6,1,백마 포위망,normal,백마,ys,3,3,2,2,,성 밖 들판, -6,2,백마 — 쓰러진 송헌·위속,normal,백마,ys,3,3,2,2,,들판, -6,3,백마 들판 — 안량 본진 앞,normal,백마,ys,3,3,3,2,,들판 진영, -6,4,백마 — 측면 기병,normal,백마,ys,2,3,4,2,,들판, -6,5,백마 — 안량,boss,백마,ys,3,3,3,2,ys_boss_yanliang:spear:true,진영 깃발 아래, -6,6,연진 — 원소군 도하,normal,연진,ys,3,3,3,2,,강변 나루, -6,7,연진 — 버려진 군량과 말,normal,연진,ys,4,3,3,1,,들판, -6,8,연진 — 장료·서황의 패퇴,normal,연진,ys,2,3,4,2,,들판, -6,9,연진 강변 — 문추 친위,normal,연진,ys,3,3,4,2,,강변, -6,10,연진 — 문추,boss,연진,ys,3,3,4,2,ys_boss_wenchou:cavalry:true,강변 들판, -6,11,동령관 — 공수,elite,동령관,cc,4,3,2,2,cc_boss_kongxiu:infantry:false,관문, -6,12,낙양 외곽 — 맹탄,elite,낙양,cc,3,3,3,2,cc_boss_mengtan:cavalry:false,성 밖, -6,13,낙양 성문 — 한복,elite,낙양,cc,3,3,2,4,cc_boss_hanfu:archer:false,성문, -6,14,사수관 — 진국사 매복,normal,사수관(진국사),cc,5,3,1,2,,절간, -6,15,사수관 — 변희,boss,사수관,cc,4,3,2,2,cc_boss_bianxi:infantry:true,절 마당, -6,16,형양 — 왕식의 연회,normal,형양,cc,4,3,2,3,,밤 성안, -6,17,형양 — 불붙은 객관,normal,형양,cc,3,3,2,4,,불타는 성안, -6,18,형양 성문 — 왕식,elite,형양,cc,3,3,3,3,cc_boss_wangzhi:archer:false,성문, -6,19,활주 나루 — 진기,elite,활주,cc,3,4,2,3,cc_boss_qinqi:spear:false,나루, -6,20,황하 나루 — 하후돈,boss,활주,cc,3,3,4,2,cc_boss_xiahoudun:cavalry:true,강변 나루, -7,1,황하 남쪽 — 순찰대,normal,고성 북쪽,cc,3,3,3,2,,들판, -7,2,와우산 — 주창의 산채,normal,와우산,sj,5,3,0,2,,산채, -7,3,고성 — 의심하는 장비,normal,고성,cc,3,3,3,2,,성 밖, -7,4,고성 외곽 — 채양 본대,normal,고성,cc,3,3,4,2,,들판, -7,5,고성 — 채양,boss,고성,cc,3,3,3,3,cc_boss_caiyang:cavalry:true,성 밖 들판, -7,6,하북 경계 — 유비의 탈출,normal,관정장 북쪽,ys,3,3,3,3,,들판, -7,7,관정장 — 관평,normal,관정장,ys,3,3,4,2,,마을, -7,8,와우산 — 배원소,elite,와우산,sj,4,3,2,2,hgj_boss_peiyuanshao:infantry:false,산채, -7,9,양산 — 조조의 추격,elite,양산,cc,3,3,3,3,cc_boss_xuchu:infantry:false;cc_boss_zhanghe:spear:false,산골, -7,10,양산 — 고람,boss,양산,cc,3,4,3,3,cc_boss_gaolan:spear:true,산길, -8,1,형주 가는 길 — 추격 잔당,normal,완 남쪽,cc,3,3,3,2,,들판, -8,2,강하 — 반란군 선봉,normal,강하,jzr,4,3,2,2,,강변, -8,3,강하 산채 — 반란군 궁병,normal,강하,jzr,3,2,2,4,,산채, -8,4,강하 — 진손,elite,강하,jzr,3,3,3,2,jz_boss_chensun:spear:false,들판, -8,5,강하 — 장무,boss,강하,jzr,3,3,3,3,jz_boss_zhangwu:cavalry:true,강변 들판, -8,6,양양 연회 — 채모의 복병,elite,양양,jz,4,3,2,3,jz_boss_caimao:archer:false,밤 성안, -8,7,단계 — 적로의 도약,normal,단계,jz,3,3,4,2,,개울가, -8,8,신야 — 여광,elite,신야,cc,3,3,3,2,cc_boss_lvkuang:cavalry:false,들판, -8,9,신야 — 여상,elite,신야,cc,3,3,3,2,cc_boss_lvxiang:cavalry:false,들판, -8,10,신야 — 조인 · 팔문금쇄진,boss,신야,cc,4,3,3,2,cc_boss_caoren:infantry:true,진형(바닥 문양), -8,11,번성 — 빈 성,normal,번성,cc,3,3,3,3,,성문, -8,12,번성 방위 — 조조군 반격,normal,번성,cc,3,4,3,3,,성벽, -8,13,융중 가는 길 — 눈보라 속 산적,normal,융중,cc,4,3,2,3,,눈 덮인 산길, -8,14,박망파 — 하후돈 선봉,normal,박망파,cc,3,3,4,2,,갈대숲, -8,15,박망파 — 하후돈,boss,박망파,cc,3,3,4,2,cc_boss_xiahoudun:cavalry:true;cc_boss_yujin:infantry:false;cc_boss_lidian:spear:false,불타는 갈대숲,우금·이전 = 엘리트 동행(기획서 8장 인물표) -9,1,신야 화공 — 조홍,elite,신야,cc,3,3,3,3,cc_boss_caohong:infantry:false,불타는 성안, -9,2,백하 — 관우의 수공,normal,백하,cc,4,3,3,2,,강변, -9,3,박릉 나루 — 장비 매복,normal,박릉 나루,cc,3,3,3,3,,나루, -9,4,번성 철수 — 백성 행렬,normal,번성,cc,3,3,4,2,,강 건너 들판, -9,5,양양 성문 — 문빙,boss,양양,jz,3,4,2,3,jz_boss_wenpin:spear:true,성문, -9,6,당양 — 호표기의 추격,normal,당양,cc,2,3,6,2,,들판, -9,7,장판 — 순우도,elite,장판,cc,3,3,4,2,cc_boss_chunyudao:spear:false,들판, -9,8,장판 — 하후은,elite,장판,cc,3,3,4,2,cc_boss_xiahouen:cavalry:false,들판, -9,9,장판 — 안명과 하북 4장,elite,장판,cc,2,3,4,2,cc_boss_yanming:spear:false,들판,미배치 = 하북 4장(병사 아트 + 이름표) · 병종 미정(PD 결정 대기) -9,10,장판파 — 장합,boss,장판,cc,3,4,4,2,cc_boss_zhanghe:spear:true,들판·언덕, -9,11,장판교 — 장비의 일갈,elite,장판교,cc,3,3,4,3,cc_boss_xiahoujie:cavalry:false,다리, -9,12,부서진 장판교,normal,장판교,cc,3,3,5,2,,강변, -9,13,한진 가는 길 — 조조 철기,normal,한진 서쪽,cc,2,3,5,2,,들판, -9,14,한진 — 관우·유기의 수군,normal,한진,cc,3,3,4,3,,나루, -9,15,한진 나루 — 조조,boss,한진,cc,3,3,4,3,cc_boss_caocao:infantry:true,나루 언덕, -10,1,하구 — 조조군 정찰선,normal,하구,cc,3,2,2,3,,강변 나루, -10,2,장강 — 형주 수군 경계선,normal,번구,jz,3,3,0,4,,배 위, -10,3,삼강구 — 채훈,elite,삼강구,jz,3,3,0,4,jz_boss_caixun:archer:false,배 위, -10,4,삼강구 — 형주 수군 본대,normal,삼강구,jz,4,3,0,4,,배 위, -10,5,삼강구 — 채모·장윤,boss,삼강구,jz,3,3,0,4,jz_boss_caimao:archer:true;jz_boss_zhangyun:infantry:true,기함 갑판,보스(2인) -10,6,초선차전 — 안개 속 화살비,normal,오림 앞 강,cc,2,2,0,8,,안개 낀 강, -10,7,장강 북안 — 감택의 밀서,normal,오림,cc,3,3,3,3,,강 북안, -10,8,연환선 — 사슬로 묶인 함대,normal,오림,cc,4,3,0,4,,연환선 갑판, -10,9,오림 수채 앞 — 순찰 선단,normal,오림,cc,3,3,2,4,,수채, -10,10,연환선단 — 우금·모개,boss,오림,cc,3,3,2,4,cc_boss_yujin:infantry:true;cc_boss_maojie:archer:true,연환선 갑판,보스(2인) -10,11,동남풍 — 화선 출격,normal,적벽,cc,3,3,0,5,,강 위, -10,12,적벽 — 불타는 연환선,normal,적벽,cc,4,3,2,3,,불타는 갑판, -10,13,수채 — 감녕과 채중,elite,오림,cc,4,3,2,3,jz_boss_caizhong:infantry:false,불타는 수채, -10,14,수채 본진 — 불길 속 친위대,normal,오림,cc,3,3,4,3,,불타는 수채, -10,15,적벽 — 장료,boss,오림,cc,3,3,4,3,dz_boss_zhangliao:cavalry:true,불타는 강변, -10,16,오림 — 조운의 매복,elite,오림 서쪽,cc,3,3,4,2,cc_boss_xuhuang:infantry:false;cc_boss_zhanghe:spear:false,숲길, -10,17,호로구 — 장비의 매복,elite,호로구,cc,3,3,4,2,cc_boss_xuchu:infantry:false,골짜기, -10,18,화용도 — 진흙길,normal,화용도,cc,4,3,3,2,,진흙 습지, -10,19,화용도 — 마지막 호위대,normal,화용도,cc,3,3,4,3,,진흙 습지, -10,20,화용도 — 조조,boss,화용도,cc,3,3,4,3,cc_boss_caocao:infantry:true,좁은 산길, +chapter,stage,title,type,place,faction,infantry,spear,cavalry,archer,strategist,generals,bg_theme,note +1,1,탁현 외곽 — 황건 척후,normal,탁현,hgj,3,0,0,0,0,,마을 어귀,튜토리얼(조준·이동) +1,2,누상촌 약탈대,normal,누상촌,hgj,3,1,0,0,0,,마을,튜토리얼(기병 관통) +1,3,대흥산 길목,normal,대흥산,hgj,3,2,0,0,0,,산길,튜토리얼(창병 견제) +1,4,대흥산 — 등무,elite,대흥산,hgj,3,2,0,1,0,hgj_boss_dengmao:spear:false,산기슭, +1,5,대흥산 전투 — 정원지,boss,대흥산,hgj,4,2,0,1,0,hgj_boss_chengyuanzhi:infantry:true,산기슭 평지, +1,6,청주 포위망,normal,청주,hgj,4,2,0,2,0,,성 밖 들판, +1,7,청주 산골 매복,normal,청주,hgj,3,2,1,2,0,,협곡,일반(적 기병 첫 등장) +1,8,영천 — 불길 속 황건,elite,영천,hgj,3,2,1,2,0,hgj_boss_zhangbao:archer:false,불탄 들판, +1,9,광종 — 황건 본진 전위,elite,광종,hgj,4,2,1,2,0,hgj_boss_zhangliang:infantry:false,진영 앞 평원, +1,10,광종 결전 — 장각,boss,광종,hgj,4,2,1,2,0,hgj_boss_zhangjiao:infantry:true,황건 본진, +2,1,산조 진영 — 동탁군 척후,normal,산조,dz,3,0,2,1,0,,군영, +2,2,사수관 가는 길,normal,사수관 동쪽,dz,3,2,1,1,0,,산길, +2,3,사수관 앞 — 호진,elite,사수관,dz,3,2,1,2,0,dz_boss_huzhen:cavalry:false,관문 앞 들판, +2,4,손견 진영 야습,normal,사수관,dz,3,2,2,1,0,,밤 군영, +2,5,사수관 — 화웅,boss,사수관,dz,4,2,1,2,0,dz_boss_huaxiong:infantry:true,관문 앞, +2,6,사수관 함락 — 잔병,normal,사수관,dz,3,3,1,2,0,,관문 안쪽, +2,7,호로관 가는 길 — 서량 기병,normal,호로관 동쪽,dz,2,2,3,2,0,,들판, +2,8,호로관 앞 — 방열이 쓰러진 자리,normal,호로관,dz,2,2,4,1,0,,관문 앞, +2,9,호로관 측면 — 적토마 돌격대,normal,호로관,dz,2,3,4,1,0,,관문 측면, +2,10,호로관 — 여포,boss,호로관,dz,3,2,3,2,0,dz_boss_lvbu:cavalry:true,관문 앞, +3,1,평원 출진 — 원소군 척후,normal,평원,ys,3,2,1,2,0,,들판, +3,2,반하 들판,normal,반하,ys,3,2,2,1,0,,강변 들판, +3,3,반하 다리 — 문추의 추격,elite,반하,ys,2,2,3,1,0,ys_boss_wenchou:cavalry:false,다리·강, +3,4,반하 — 엄강의 패전,normal,반하,ys,2,2,0,5,0,,강변, +3,5,반하 — 국의,boss,반하,ys,2,2,0,4,0,ys_boss_quyi:archer:true,강변 진영, +3,6,북해 가는 길 — 황건 잔당,normal,북해 서쪽,hgj,4,2,0,2,0,,들판, +3,7,북해 포위망 바깥,normal,북해,hgj,4,2,1,2,0,,성 밖, +3,8,태사자의 돌파로,normal,북해,hgj,4,3,1,2,0,,성 밖, +3,9,북해 성문 — 관해 친위,normal,북해,hgj,5,2,1,2,0,,성문, +3,10,북해 — 관해,boss,북해,hgj,4,2,1,2,0,hgj_boss_guanhai:infantry:true,성문 앞, +3,11,서주 가는 길 — 불타는 마을,normal,서주 북쪽,cc,3,2,2,2,0,,불탄 마을, +3,12,서주 외곽 — 조조 포위진,normal,서주,cc,3,3,2,2,0,,들판, +3,13,서주 남쪽 — 피난민 행렬,normal,서주,cc,3,3,3,2,0,,들판, +3,14,조조 진영 외곽,normal,서주,cc,3,3,2,3,0,,군영, +3,15,서주 — 우금,boss,서주,cc,3,3,2,2,0,cc_boss_yujin:infantry:true,군영 앞, +4,1,우이 들판 — 원술군 선봉,normal,우이,ysu,3,3,1,2,0,,들판, +4,2,회음 강변 — 군량 수송대,normal,회음,ysu,4,2,0,2,0,,강변, +4,3,우이 — 순정,elite,우이,ysu,3,3,1,2,0,ysu_boss_xunzheng:spear:false,들판, +4,4,우이 진영 야습,normal,우이,ysu,3,3,2,2,0,,밤 군영, +4,5,우이 — 기령,boss,우이,ysu,3,3,2,2,0,ysu_boss_jiling:spear:true,강변 들판, +4,6,서주성의 밤 — 조표의 배신,normal,서주성,lb,2,2,4,2,0,,밤 성안, +4,7,소패 — 빼앗긴 말,normal,소패,lb,3,2,3,2,0,,성 밖, +4,8,소패 탈출 — 여포군 추격,normal,소패 서쪽,lb,2,2,4,2,0,,들판, +4,9,소패 구원 — 조성,elite,소패,lb,3,2,2,3,0,dz_boss_caoxing:archer:false,성 밖, +4,10,소패 — 고순,boss,소패,lb,5,2,1,2,0,dz_boss_gaoshun:infantry:true,성문 앞, +4,11,서주성 — 진등의 계책,normal,서주성,lb,3,3,2,2,0,,성 밖, +4,12,하비 외곽 — 진궁,elite,하비,lb,3,2,2,3,0,dz_boss_chengong:archer:false,들판, +4,13,하비 수공,normal,하비,lb,3,3,2,3,0,,물에 잠긴 성, +4,14,하비 성문 — 장료,elite,하비,lb,3,2,3,2,0,dz_boss_zhangliao:cavalry:false,성문, +4,15,하비 — 여포,boss,하비(백문루),lb,3,2,3,3,0,dz_boss_lvbu:cavalry:true,성안 누각 앞, +5,1,허도 근교 — 사냥터의 도적,normal,허도,sj,3,2,2,2,0,,숲,세력 추정(기획서 미기재 · 도적 = 7장 산적 선례) +5,2,허도 출진 — 원술 차단,normal,허도 동쪽,ysu,3,2,2,2,0,,들판,세력 추정(기획서 미기재 · 원술 차단 출진) +5,3,서주 남쪽 — 원술군 선봉,normal,서주 남쪽,ysu,3,3,2,2,0,,들판, +5,4,원술 친위대,normal,서주 남쪽,ysu,3,3,2,3,0,,들판, +5,5,서주 남쪽 — 기령,boss,서주 남쪽,ysu,3,3,2,2,0,ysu_boss_jiling:spear:true,들판, +5,6,원술의 몰락 — 패잔병,normal,수춘 북쪽,ysu,4,3,0,2,0,,황무지, +5,7,서주성 앞 — 차주의 복병,normal,서주성,cc,3,3,2,2,0,,성문 밖 밤, +5,8,서주성 — 차주,elite,서주성,cc,3,3,2,2,0,cc_boss_chezhou:infantry:false,성문, +5,9,서주 외곽 — 조조 토벌군,normal,서주,cc,3,3,2,3,0,,들판, +5,10,서주 — 유대·왕충,boss,서주,cc,3,3,2,2,0,cc_boss_liudai:spear:true;cc_boss_wangzhong:infantry:true,들판 진영,보스(2인) +5,11,소패 야습,normal,소패,cc,3,3,3,2,0,,밤 군영, +5,12,소패 패주로,normal,소패 북쪽,cc,2,3,4,2,0,,들판, +5,13,하비 — 고립된 관우,normal,하비,cc,3,3,3,3,0,,성 밖, +5,14,하비 성 밖 — 하후돈의 유인,elite,하비,cc,3,3,3,2,0,cc_boss_xiahoudun:cavalry:false,성 밖 들판, +5,15,토산 — 서황·허저,boss,토산,cc,3,3,2,3,0,cc_boss_xuhuang:infantry:true;cc_boss_xuchu:infantry:true,산,보스(2인) +6,1,백마 포위망,normal,백마,ys,3,3,2,2,0,,성 밖 들판, +6,2,백마 — 쓰러진 송헌·위속,normal,백마,ys,3,3,2,2,0,,들판, +6,3,백마 들판 — 안량 본진 앞,normal,백마,ys,3,3,3,2,0,,들판 진영, +6,4,백마 — 측면 기병,normal,백마,ys,2,3,4,2,0,,들판, +6,5,백마 — 안량,boss,백마,ys,3,3,3,2,0,ys_boss_yanliang:spear:true,진영 깃발 아래, +6,6,연진 — 원소군 도하,normal,연진,ys,3,3,3,2,0,,강변 나루, +6,7,연진 — 버려진 군량과 말,normal,연진,ys,4,3,3,1,0,,들판, +6,8,연진 — 장료·서황의 패퇴,normal,연진,ys,2,3,4,2,0,,들판, +6,9,연진 강변 — 문추 친위,normal,연진,ys,3,3,4,2,0,,강변, +6,10,연진 — 문추,boss,연진,ys,3,3,4,2,0,ys_boss_wenchou:cavalry:true,강변 들판, +6,11,동령관 — 공수,elite,동령관,cc,4,3,2,2,0,cc_boss_kongxiu:infantry:false,관문, +6,12,낙양 외곽 — 맹탄,elite,낙양,cc,3,3,3,2,0,cc_boss_mengtan:cavalry:false,성 밖, +6,13,낙양 성문 — 한복,elite,낙양,cc,3,3,2,4,0,cc_boss_hanfu:archer:false,성문, +6,14,사수관 — 진국사 매복,normal,사수관(진국사),cc,5,3,1,2,0,,절간, +6,15,사수관 — 변희,boss,사수관,cc,4,3,2,2,0,cc_boss_bianxi:infantry:true,절 마당, +6,16,형양 — 왕식의 연회,normal,형양,cc,4,3,2,3,0,,밤 성안, +6,17,형양 — 불붙은 객관,normal,형양,cc,3,3,2,4,0,,불타는 성안, +6,18,형양 성문 — 왕식,elite,형양,cc,3,3,3,3,0,cc_boss_wangzhi:archer:false,성문, +6,19,활주 나루 — 진기,elite,활주,cc,3,4,2,3,0,cc_boss_qinqi:spear:false,나루, +6,20,황하 나루 — 하후돈,boss,활주,cc,3,3,4,2,0,cc_boss_xiahoudun:cavalry:true,강변 나루, +7,1,황하 남쪽 — 순찰대,normal,고성 북쪽,cc,3,3,3,2,0,,들판, +7,2,와우산 — 주창의 산채,normal,와우산,sj,5,3,0,2,0,,산채, +7,3,고성 — 의심하는 장비,normal,고성,cc,3,3,3,2,0,,성 밖, +7,4,고성 외곽 — 채양 본대,normal,고성,cc,3,3,4,2,0,,들판, +7,5,고성 — 채양,boss,고성,cc,3,3,3,3,0,cc_boss_caiyang:cavalry:true,성 밖 들판, +7,6,하북 경계 — 유비의 탈출,normal,관정장 북쪽,ys,3,3,3,3,0,,들판, +7,7,관정장 — 관평,normal,관정장,ys,3,3,4,2,0,,마을, +7,8,와우산 — 배원소,elite,와우산,sj,4,3,2,2,0,hgj_boss_peiyuanshao:infantry:false,산채, +7,9,양산 — 조조의 추격,elite,양산,cc,3,3,3,3,0,cc_boss_xuchu:infantry:false;cc_boss_zhanghe:spear:false,산골, +7,10,양산 — 고람,boss,양산,cc,3,4,3,3,0,cc_boss_gaolan:spear:true,산길, +8,1,형주 가는 길 — 추격 잔당,normal,완 남쪽,cc,3,3,3,2,0,,들판, +8,2,강하 — 반란군 선봉,normal,강하,jzr,4,3,2,2,0,,강변, +8,3,강하 산채 — 반란군 궁병,normal,강하,jzr,3,2,2,4,0,,산채, +8,4,강하 — 진손,elite,강하,jzr,3,3,3,2,0,jz_boss_chensun:spear:false,들판, +8,5,강하 — 장무,boss,강하,jzr,3,3,3,3,0,jz_boss_zhangwu:cavalry:true,강변 들판, +8,6,양양 연회 — 채모의 복병,elite,양양,jz,4,3,2,3,0,jz_boss_caimao:archer:false,밤 성안, +8,7,단계 — 적로의 도약,normal,단계,jz,3,3,4,2,0,,개울가, +8,8,신야 — 여광,elite,신야,cc,3,3,3,2,0,cc_boss_lvkuang:cavalry:false,들판, +8,9,신야 — 여상,elite,신야,cc,3,3,3,2,0,cc_boss_lvxiang:cavalry:false,들판, +8,10,신야 — 조인 · 팔문금쇄진,boss,신야,cc,4,3,3,2,0,cc_boss_caoren:infantry:true,진형(바닥 문양), +8,11,번성 — 빈 성,normal,번성,cc,3,3,3,3,0,,성문, +8,12,번성 방위 — 조조군 반격,normal,번성,cc,3,4,3,3,0,,성벽, +8,13,융중 가는 길 — 눈보라 속 산적,normal,융중,cc,4,3,2,3,0,,눈 덮인 산길, +8,14,박망파 — 하후돈 선봉,normal,박망파,cc,3,3,4,2,0,,갈대숲, +8,15,박망파 — 하후돈,boss,박망파,cc,3,3,4,2,0,cc_boss_xiahoudun:cavalry:true;cc_boss_yujin:infantry:false;cc_boss_lidian:spear:false,불타는 갈대숲,우금·이전 = 엘리트 동행(기획서 8장 인물표) +9,1,신야 화공 — 조홍,elite,신야,cc,3,3,3,3,0,cc_boss_caohong:infantry:false,불타는 성안, +9,2,백하 — 관우의 수공,normal,백하,cc,4,3,3,2,0,,강변, +9,3,박릉 나루 — 장비 매복,normal,박릉 나루,cc,3,3,3,3,0,,나루, +9,4,번성 철수 — 백성 행렬,normal,번성,cc,3,3,4,2,0,,강 건너 들판, +9,5,양양 성문 — 문빙,boss,양양,jz,3,4,2,3,0,jz_boss_wenpin:spear:true,성문, +9,6,당양 — 호표기의 추격,normal,당양,cc,2,3,6,2,0,,들판, +9,7,장판 — 순우도,elite,장판,cc,3,3,4,2,0,cc_boss_chunyudao:spear:false,들판, +9,8,장판 — 하후은,elite,장판,cc,3,3,4,2,0,cc_boss_xiahouen:cavalry:false,들판, +9,9,장판 — 안명과 하북 4장,elite,장판,cc,2,3,4,2,0,cc_boss_yanming:spear:false,들판,미배치 = 하북 4장(병사 아트 + 이름표) · 병종 미정(PD 결정 대기) +9,10,장판파 — 장합,boss,장판,cc,3,4,4,2,0,cc_boss_zhanghe:spear:true,들판·언덕, +9,11,장판교 — 장비의 일갈,elite,장판교,cc,3,3,4,3,0,cc_boss_xiahoujie:cavalry:false,다리, +9,12,부서진 장판교,normal,장판교,cc,3,3,5,2,0,,강변, +9,13,한진 가는 길 — 조조 철기,normal,한진 서쪽,cc,2,3,5,2,0,,들판, +9,14,한진 — 관우·유기의 수군,normal,한진,cc,3,3,4,3,0,,나루, +9,15,한진 나루 — 조조,boss,한진,cc,3,3,4,3,0,cc_boss_caocao:infantry:true,나루 언덕, +10,1,하구 — 조조군 정찰선,normal,하구,cc,3,2,2,3,0,,강변 나루, +10,2,장강 — 형주 수군 경계선,normal,번구,jz,3,3,0,4,0,,배 위, +10,3,삼강구 — 채훈,elite,삼강구,jz,3,3,0,4,0,jz_boss_caixun:archer:false,배 위, +10,4,삼강구 — 형주 수군 본대,normal,삼강구,jz,4,3,0,4,0,,배 위, +10,5,삼강구 — 채모·장윤,boss,삼강구,jz,3,3,0,4,0,jz_boss_caimao:archer:true;jz_boss_zhangyun:infantry:true,기함 갑판,보스(2인) +10,6,초선차전 — 안개 속 화살비,normal,오림 앞 강,cc,2,2,0,8,0,,안개 낀 강, +10,7,장강 북안 — 감택의 밀서,normal,오림,cc,3,3,3,3,0,,강 북안, +10,8,연환선 — 사슬로 묶인 함대,normal,오림,cc,4,3,0,4,0,,연환선 갑판, +10,9,오림 수채 앞 — 순찰 선단,normal,오림,cc,3,3,2,4,0,,수채, +10,10,연환선단 — 우금·모개,boss,오림,cc,3,3,2,4,0,cc_boss_yujin:infantry:true;cc_boss_maojie:archer:true,연환선 갑판,보스(2인) +10,11,동남풍 — 화선 출격,normal,적벽,cc,3,3,0,5,0,,강 위, +10,12,적벽 — 불타는 연환선,normal,적벽,cc,4,3,2,3,0,,불타는 갑판, +10,13,수채 — 감녕과 채중,elite,오림,cc,4,3,2,3,0,jz_boss_caizhong:infantry:false,불타는 수채, +10,14,수채 본진 — 불길 속 친위대,normal,오림,cc,3,3,4,3,0,,불타는 수채, +10,15,적벽 — 장료,boss,오림,cc,3,3,4,3,0,dz_boss_zhangliao:cavalry:true,불타는 강변, +10,16,오림 — 조운의 매복,elite,오림 서쪽,cc,3,3,4,2,0,cc_boss_xuhuang:infantry:false;cc_boss_zhanghe:spear:false,숲길, +10,17,호로구 — 장비의 매복,elite,호로구,cc,3,3,4,2,0,cc_boss_xuchu:infantry:false,골짜기, +10,18,화용도 — 진흙길,normal,화용도,cc,4,3,3,2,0,,진흙 습지, +10,19,화용도 — 마지막 호위대,normal,화용도,cc,3,3,4,3,0,,진흙 습지, +10,20,화용도 — 조조,boss,화용도,cc,3,3,4,3,0,cc_boss_caocao:infantry:true,좁은 산길, diff --git a/Assets/Resources/BalanceConfig.asset b/Assets/Resources/BalanceConfig.asset index 9ad2a765..13cbd39b 100644 --- a/Assets/Resources/BalanceConfig.asset +++ b/Assets/Resources/BalanceConfig.asset @@ -41,15 +41,25 @@ MonoBehaviour: slingMaxDistance: 4.5 attackRange: 2.2 radius: 0.45 + - type: 4 + maxHp: 65 + atk: 24 + def: 3 + slingMaxDistance: 4 + attackRange: 0 + radius: 0.42 matchup: - 1 - 0.7 - 1.5 - 1.5 - 1.5 + - 1.5 - 1 - 1.5 - 0.7 + - 1.5 + - 1 - 1 - 1 - 1 @@ -58,6 +68,12 @@ MonoBehaviour: - 1.5 - 1.5 - 1 + - 1.5 + - 1 + - 1 + - 1 + - 1 + - 1 defenseFactor: 0.5 counterRatio: 0.5 critMultiplier: 2 @@ -69,6 +85,10 @@ MonoBehaviour: archerRangeRatio: 0.2 archerMeleeDealtRatio: 0.5 archerMeleeTakenRatio: 1.5 + arrowRadius: 0.1 + strategistSplashRadius: 1.5 + strategistSplashRatio: 0.6 + strategistAllyDamageRatio: 1 collisionPushRatio: 1 collisionSelfBounceRatio: 0.5 unitArtHeight: 1.5 diff --git a/Assets/Scripts/Editor/BalanceDefaults.cs b/Assets/Scripts/Editor/BalanceDefaults.cs index 8c9454af..dcb0f808 100644 --- a/Assets/Scripts/Editor/BalanceDefaults.cs +++ b/Assets/Scripts/Editor/BalanceDefaults.cs @@ -18,9 +18,10 @@ namespace TKS.EditorTools Stats(UnitType.Cavalry, 110, 28, 8, 6.5f, 0f, 0.45f), Stats(UnitType.Archer, 70, 26, 4, 4.0f, 0f, 0.42f), // 궁병 사거리 = slingMaxDistance (attackRange 미사용 · #1080) Stats(UnitType.Spear, 100, 30, 10, 4.5f, 2.2f, 0.45f), + Stats(UnitType.Strategist, 65, 24, 3, 4.0f, 0f, 0.42f), // PD 2026-11-05 #1087 ⑤ 제안값 · 사거리 = slingMaxDistance(궁병과 동일) }; - // 상성: 보병 > 창병 > 기병 > 보병 · 궁병은 모두에게 약함(받는 피해 1.5) + // 상성: 보병 > 창병 > 기병 > 보병 · 원거리(궁병·책사)는 근접 3종에게 약함(받는 피해 1.5) · 원거리가 주는 피해 = 전부 1.0 c.matchup = new float[UnitTypeInfo.Count * UnitTypeInfo.Count]; for (int i = 0; i < c.matchup.Length; i++) c.matchup[i] = 1f; c.SetMatchup(UnitType.Infantry, UnitType.Spear, 1.5f); @@ -32,6 +33,9 @@ namespace TKS.EditorTools c.SetMatchup(UnitType.Infantry, UnitType.Archer, 1.5f); c.SetMatchup(UnitType.Cavalry, UnitType.Archer, 1.5f); c.SetMatchup(UnitType.Spear, UnitType.Archer, 1.5f); + c.SetMatchup(UnitType.Infantry, UnitType.Strategist, 1.5f); // 책사 열 = 궁병 열과 같은 값으로 출발(PD 확인 대상) + c.SetMatchup(UnitType.Cavalry, UnitType.Strategist, 1.5f); + c.SetMatchup(UnitType.Spear, UnitType.Strategist, 1.5f); c.defenseFactor = 0.5f; c.counterRatio = 0.5f; @@ -44,6 +48,10 @@ namespace TKS.EditorTools c.archerRangeRatio = 0.2f; // PD 2026-11-04: 사거리(슬링샷 범위) 밖 +20 % 안의 적에게 다가가 사격 c.archerMeleeDealtRatio = 0.5f; // PD 2026-11-04 #1080: 궁수 근접 전투 피해 50 % c.archerMeleeTakenRatio = 1.5f; // PD 2026-11-04 #1080: 궁수 근접 피격 +50 % + c.arrowRadius = 0.1f; // PD 2026-11-05 #1087 ⑥: 화살 투사체 폭(경로 위 첫 유닛 피격) + c.strategistSplashRadius = 1.5f; // PD 2026-11-05 #1087 ⑤ 제안값: 광역 반경(유닛 지름 약 1.7 개) + c.strategistSplashRatio = 0.6f; // 광역 피해 = 대상별 피해 × 0.6(크리 없음) + c.strategistAllyDamageRatio = 1f; // 광역 아군 피해 = 적과 동일(PD 확인 대상) c.collisionPushRatio = 1f; // PD 2026-11-04 #1080: 보병 알까기 밀어내기 = 남은 거리 그대로 c.collisionSelfBounceRatio = 0.5f; // PD 2026-11-04 #1081: 보병 자신도 반대로 튕김(남은 거리의 절반 · 손맛 조정 대상) c.unitArtHeight = 1.5f; diff --git a/Assets/Scripts/Runtime/ActionResolver.cs b/Assets/Scripts/Runtime/ActionResolver.cs index cef5232a..4f129267 100644 --- a/Assets/Scripts/Runtime/ActionResolver.cs +++ b/Assets/Scripts/Runtime/ActionResolver.cs @@ -10,11 +10,16 @@ namespace TKS public bool BonusMove; // 보병: 최종 목표점 명중 → 추가 이동 public bool Returned; // 기병: 최종 목표점 명중 → 제자리 복귀 public bool Overwatch; // 창병: 최종 목표점 명중 → 대기(견제) - public bool Crit; // 궁병: 최종 목표점 명중 → 크리티컬 + public bool Crit; // 궁병·책사: 첫 피격 = 타겟 + 최종 목표점 명중 → 크리티컬 public bool MovedOnly; // 행동 유닛 자신은 공격하지 않음(이동 · 밀어내기만) + public bool Fired; // 궁병·책사: 투사체를 쐈다(타겟이 없으면 목표점 이동만 · 쏘지 않음) + public Unit ShotHit; // 궁병·책사: 투사체가 처음 맞힌 유닛(아군 포함 · 책사 광역 중심) · null = 안 쏨·빗나감 public int DamageDealt; public int CounterTaken; public List Attacked = new List(); + /// 궁병·책사 사격에 맞은 같은 편(오사 · 책사 광역 포함 · PD 2026-11-05 #1087 ⑤⑥). + public List FriendlyHit = new List(); + public int FriendlyDamage; /// 충돌로 밀려난 유닛(아군·적군 · PD 2026-11-04 #1080). public List Pushed = new List(); /// 밀린 아군이 적과 닿아 얻은 공격 등 부가 설명(HUD 문구). @@ -29,7 +34,9 @@ namespace TKS /// ② 기병: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 · 목표점 명중 시 제자리 복귀 /// ③ 창병: 첫 유닛에 부딪히면 튕기지 않고 남은 거리만큼 밀며 함께 전진 · 적이면 피해(반격 받음) · 아군이면 밀기만 · 목표점 명중 시 대기(견제) /// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ ×(1+비율) 안이면 들어올 만큼만 다가가 사격 · - /// 더 멀면 목표점까지 이동 · 반격 없음 · 목표점 명중 시 크리티컬 · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage) + /// 더 멀면 목표점까지 이동(쏘지 않음) · 사격 = 타겟 방향 화살 투사체 → 경로 위 첫 유닛(아군 포함)이 맞음 · 관통 없음 · 반격 없음 · + /// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage) + /// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤) /// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음) /// public class ActionResolver @@ -68,6 +75,7 @@ namespace TKS yield return CavalryRoutine(u, origin, dir, dist, target, moveOnly, o); break; case UnitType.Archer: + case UnitType.Strategist: yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o); break; } @@ -105,15 +113,31 @@ namespace TKS break; } case UnitType.Archer: + case UnitType.Strategist: { - var first = Combat.FirstEnemyOnPath(u, origin, dir, u.AttackRange + u.ArcherMoveAllow, out hitDist); - if (first != null) result.Add(first); + 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; } } return result; } + /// 조작 중 표시용: 궁병·책사의 사격 계획(화살 경로선 · 첫 피격 유닛 · 책사 광역). 원거리 병종이 아니거나 이동만이면 null. + public ShotPlan PreviewShot(Unit u, Vector2 target, bool moveOnly) + { + if (moveOnly || u == null || !UnitTypeInfo.IsRanged(u.Type)) return null; + Vector2 delta = target - u.Position; + float dist = delta.magnitude; + if (dist < 0.001f) return null; + return PlanShot(u, u.Position, delta / dist, target); + } + // ---------- 공통 질의 ---------- /// 경로 위 첫 유닛. 벽이 그보다 먼저 있으면 null. @@ -369,35 +393,127 @@ namespace TKS return origin; } - // ---------- 궁병 ---------- + // ---------- 궁병 · 책사: 투사체 사격 (PD 2026-11-05 #1087 ⑤⑥) ---------- + + /// 궁병·책사 사격 계획 — 조준 미리보기(PreviewShot)와 실제 행동(ArcherRoutine)이 같은 계산을 쓴다. + public class ShotPlan + { + /// 복도 탐색으로 고른 적(조준 방향 사거리 + 접근 허용 안의 첫 적) · null = 쏘지 않고 목표점까지 이동. + public Unit Target; + /// 사격 전에 다가가는 거리(사거리 안 = 0). + public float Approach; + /// 사거리에 들어와 쏜다(false = 아군·벽에 막혀 사거리에 못 들어감 → 이동만). + public bool Fires; + public Vector2 From; + /// 투사체가 멈춘 지점(첫 피격 유닛에 닿은 곳 · 빗나가면 비행 끝). + public Vector2 To; + /// 투사체가 처음 맞히는 유닛(아군 포함 · 관통 없음) = 책사 광역의 중심. + public Unit Hit; + /// 첫 피격 = 타겟이고 최종 목표점 명중 → 크리티컬. + public bool Crit; + /// 책사: 중심 주변 광역 대상(아군 포함 · 중심·쏜 책사 제외 · 가까운 순). + public readonly List Splash = new List(); + } + + ShotPlan PlanShot(Unit u, Vector2 origin, Vector2 dir, Vector2 target) + { + var p = new ShotPlan { From = origin, To = origin }; + // 조준 방향으로 사거리 + 접근 허용 거리 안의 첫 적(타겟 선정 = #1080 그대로). hitDist = 그 적 가장자리에 닿기까지의 이동 거리(= 가장자리 간격). + float hitDist; + p.Target = Combat.FirstEnemyOnPath(u, origin, dir, u.AttackRange + u.ArcherMoveAllow, out hitDist); + if (p.Target == null) return p; + + // 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한) + float need = Mathf.Clamp(hitDist - u.AttackRange, 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; // 아군·벽에 막혀 사거리에 못 들어감 + + // 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음) + 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); + 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 (p.Hit != null && u.Type == UnitType.Strategist) + { + p.Splash.AddRange(Combat.UnitsInCircle(p.Hit.Position, cfg.strategistSplashRadius, p.Hit, u)); + } + return p; + } IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) { - // 조준 방향으로 사거리 + 접근 허용 거리 안의 첫 적. hitDist = 그 적 가장자리에 닿기까지의 이동 거리(= 가장자리 간격). - float hitDist = 0f; - Unit first = moveOnly ? null : Combat.FirstEnemyOnPath(u, origin, dir, u.AttackRange + u.ArcherMoveAllow, out hitDist); - if (first == null) + var p = moveOnly ? null : PlanShot(u, origin, dir, target); + if (p == null || p.Target == null) { - // 공격 가능 범위에 적이 없으면 목표점까지 이동 (PD 2026-11-04 2차 지시) + // 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지) yield return MoveFree(u, origin, dir, dist, o); yield break; } - // 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한) - float need = Mathf.Clamp(hitDist - u.AttackRange, 0f, u.ArcherMoveAllow); - float freeMove = need > 0.01f ? Combat.FreeTravel(u, origin, dir, need, true) : 0f; - if (freeMove > 0.01f) yield return Move(u, origin + dir * freeMove, o); - - if (hitDist - freeMove <= u.AttackRange + 0.05f) - { - bool crit = Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first; - Face(u, dir); // 제자리 사격도 조준 방향을 본다 - yield return Attack(u, first, true, crit, o, true); - } - else + if (p.Approach > 0.01f) yield return Move(u, p.From, o); + if (!p.Fires) { o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감 + yield break; } + Face(u, p.To - p.From); // 제자리 사격도 쏘는 방향을 본다 + yield return Shoot(u, p, o); + } + + /// + /// 투사체 1발: 첫 피격 유닛에 사격 피해(아군이면 오사 · 상성 적용 · 반격 없음 · 명중 보상 없음 · 밀지 않으므로 규칙 ⑤ 없음) + /// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음). + /// + IEnumerator Shoot(Unit u, ShotPlan p, ActionOutcome o) + { + bool strategist = u.Type == UnitType.Strategist; + o.Fired = true; + o.ShotHit = p.Hit; + if (u.View != null) u.View.PlayAttack(); + yield return Fx.Arrow(p.From, p.To, strategist ? Fx.StrategistShotColor : Fx.ArrowColor); + if (p.Hit == null) + { + yield return Fx.Wait(0.1f); // 빗나감(벽 · 비행 끝) + yield break; + } + + Vector2 center = p.Hit.Position; + ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o); + if (strategist) + { + Fx.Burst(center, cfg.strategistSplashRadius, Fx.SplashColor); + foreach (var s in p.Splash) + { + if (!s.IsAlive) continue; + float ratio = cfg.strategistSplashRatio * (s.Faction == u.Faction ? cfg.strategistAllyDamageRatio : 1f); + int dmg = Combat.SplashDamage(cfg, u, s, ratio, rng); + if (dmg > 0) ShotDamage(u, s, dmg, false, o); + } + } + battle.NotifyChanged(); + yield return Fx.Wait(0.2f); + } + + /// 사격 피해 적용 + 집계(상대 = Attacked · 같은 편 = FriendlyHit 오사). + void ShotDamage(Unit a, Unit d, int dmg, bool crit, ActionOutcome o) + { + d.TakeDamage(dmg); + if (d.Faction == a.Faction) + { + o.FriendlyHit.Add(d); + o.FriendlyDamage += dmg; + Fx.Popup(d.Position, dmg + " 오사", Fx.FriendlyFireColor); + return; + } + o.Attacked.Add(d); + o.DamageDealt += dmg; + o.Crit |= crit; + Fx.Popup(d.Position, crit ? dmg + "!" : dmg.ToString(), crit ? new Color(1f, 0.7f, 0.2f) : Color.white); } // ---------- 공통 ---------- @@ -507,45 +623,34 @@ namespace TKS if (foe == null) yield break; yield return Fx.Wait(0.1f); Face(ally, foe.Position - ally.Position); - yield return Attack(ally, foe, false, false, o, false); + yield return PushedAttack(ally, foe, o); } - /// 공격 1회. byActor = 행동 유닛의 공격(결과 요약에 집계) · false = 밀린 아군의 공격(Notes 에 기록). - IEnumerator Attack(Unit a, Unit d, bool ranged, bool crit, ActionOutcome o, bool byActor) + /// 밀린 아군의 근접 공격 1회(반격 받음 · 결과 요약에는 집계하지 않고 Notes 에 기록). 원거리 병종도 근접 패널티를 받는다. + IEnumerator PushedAttack(Unit a, Unit d, ActionOutcome o) { if (a.View != null) a.View.PlayAttack(); - if (ranged) yield return Fx.Arrow(a.Position, d.Position); - else yield return Fx.Lunge(a, d.Position); + yield return Fx.Lunge(a, d.Position); - int dmg = Combat.Damage(cfg, a, d, crit, rng, !ranged); + int dmg = Combat.Damage(cfg, a, d, false, rng, true); d.TakeDamage(dmg); - if (byActor) - { - o.Attacked.Add(d); - o.DamageDealt += dmg; - o.Crit |= crit; - } - Fx.Popup(d.Position, crit ? dmg + "!" : dmg.ToString(), crit ? new Color(1f, 0.7f, 0.2f) : Color.white); + Fx.Popup(d.Position, dmg.ToString(), Color.white); battle.NotifyChanged(); yield return Fx.Wait(0.2f); int counter = 0; - if (!ranged && d.IsAlive && a.IsAlive) + 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); - if (byActor) o.CounterTaken += counter; Fx.Popup(a.Position, counter.ToString(), new Color(1f, 0.45f, 0.45f)); battle.NotifyChanged(); yield return Fx.Wait(0.2f); } - if (!byActor) - { - o.Notes.Add("밀린 " + a.DisplayName + " → " + d.DisplayName + " 공격 " + dmg + (counter > 0 ? " · 반격 " + counter : "")); - } + o.Notes.Add("밀린 " + a.DisplayName + " → " + d.DisplayName + " 공격 " + dmg + (counter > 0 ? " · 반격 " + counter : "")); } } } diff --git a/Assets/Scripts/Runtime/AutoPlayDriver.cs b/Assets/Scripts/Runtime/AutoPlayDriver.cs index 8e1f4a20..55ff7429 100644 --- a/Assets/Scripts/Runtime/AutoPlayDriver.cs +++ b/Assets/Scripts/Runtime/AutoPlayDriver.cs @@ -90,7 +90,7 @@ namespace TKS var first = FirstActable(bm); if (first != null) { - var tgt = EnemyAI.Decide(first, bm.AliveUnits(Faction.Enemy), 1f, float.PositiveInfinity, bm.Rng, bm.Board); + var tgt = EnemyAI.Decide(first, bm.AliveUnits(Faction.Enemy), 1f, float.PositiveInfinity, bm.Rng, bm.Board, bm.Config); if (tgt.HasValue) { Vector2 pointer = first.Position - (tgt.Value - first.Position) * 0.5f; @@ -108,7 +108,7 @@ namespace TKS // 사거리 표시 캡처: 궁병 선택(얇은 원 + 공격 가능 도넛) · 적 선택(적의 사거리) foreach (var u in bm.AliveUnits(Faction.Player)) { - if (u.Type != UnitType.Archer) continue; + if (!UnitTypeInfo.IsRanged(u.Type)) continue; if (camCtrl != null) camCtrl.FocusOn(u.Position + new Vector2(0f, 2f)); yield return Fx.Wait(0.5f); bm.Sling.Select(u); @@ -143,7 +143,7 @@ namespace TKS bm.OnEmptyTap(); break; } - var target = EnemyAI.Decide(u, bm.AliveUnits(Faction.Enemy), 1f, float.PositiveInfinity, bm.Rng, bm.Board); + var target = EnemyAI.Decide(u, bm.AliveUnits(Faction.Enemy), 1f, float.PositiveInfinity, bm.Rng, bm.Board, bm.Config); if (!target.HasValue) break; if (!bm.RequestPlayerAction(u, target.Value)) break; while (bm.Phase == BattlePhase.Resolving) yield return null; diff --git a/Assets/Scripts/Runtime/BalanceConfig.cs b/Assets/Scripts/Runtime/BalanceConfig.cs index 05e198c5..1844bab1 100644 --- a/Assets/Scripts/Runtime/BalanceConfig.cs +++ b/Assets/Scripts/Runtime/BalanceConfig.cs @@ -114,7 +114,7 @@ namespace TKS [Header("병종 기본 능력치")] public UnitStats[] unitStats = new UnitStats[0]; - [Header("상성 배율 (행 = 공격자, 열 = 피격자 · 순서 보병·기병·궁병·창병)")] + [Header("상성 배율 (행 = 공격자, 열 = 피격자 · 순서 보병·기병·궁병·창병·책사 · 5×5)")] public float[] matchup = new float[UnitTypeInfo.Count * UnitTypeInfo.Count]; [Header("전투 공식")] @@ -139,6 +139,16 @@ namespace TKS public float archerMeleeDealtRatio = 0.5f; [Tooltip("궁병이 근접 유닛에게 근접으로 맞을 때 받는 피해 배율(상성 배율과 곱)")] public float archerMeleeTakenRatio = 1.5f; + [Tooltip("화살 투사체 반지름(PD 2026-11-05 #1087 ⑥) — 타겟 방향 원 캐스트 폭 · 경로 위 첫 유닛(아군 포함)이 맞는다 · 관통 없음 · 책사 투사체도 같음 · 근접 패널티는 책사도 위 두 배율")] + public float arrowRadius = 0.1f; + + [Header("책사 광역 (PD 2026-11-05 · #1087 ⑤)")] + [Tooltip("투사체가 맞힌 중심 유닛 위치에서 이 반경 원에 몸이 닿는 다른 유닛 전원(아군 포함 · 쏜 책사 제외)이 광역 피해")] + public float strategistSplashRadius = 1.5f; + [Tooltip("광역 피해 = 대상별 피해(상성 적용 · 크리 없음) × 비율")] + public float strategistSplashRatio = 0.6f; + [Tooltip("광역에 휩쓸린 같은 편(쏜 책사의 아군)이 받는 배율 · 1 = 적과 동일 · 0 = 아군 무피해")] + public float strategistAllyDamageRatio = 1f; [Header("충돌 (PD 2026-11-04 · #1080)")] [Tooltip("보병 알까기: 부딪힌 유닛(아군·적군)이 밀려나는 거리 = 충돌 시 남은 슬링샷 거리 × 비율 · 창병은 비율 없이 남은 거리만큼 밀며 함께 전진")] diff --git a/Assets/Scripts/Runtime/BattleHUD.cs b/Assets/Scripts/Runtime/BattleHUD.cs index ed415f87..df879ead 100644 --- a/Assets/Scripts/Runtime/BattleHUD.cs +++ b/Assets/Scripts/Runtime/BattleHUD.cs @@ -197,7 +197,8 @@ namespace TKS 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 (u.Type == UnitType.Archer) line3 += " 사거리 " + u.AttackRange.ToString("0.#") + " (접근 +" + u.ArcherMoveAllow.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 state = ""; if (u.Faction == Faction.Player) diff --git a/Assets/Scripts/Runtime/BattleManager.cs b/Assets/Scripts/Runtime/BattleManager.cs index 6e356cb8..cd45b759 100644 --- a/Assets/Scripts/Runtime/BattleManager.cs +++ b/Assets/Scripts/Runtime/BattleManager.cs @@ -200,7 +200,7 @@ namespace TKS return Phase == BattlePhase.PlayerTurn && u != null && u.Faction == Faction.Player && u.IsAlive && !u.ActedThisTurn; } - /// 궁병 공격 가능 범위(사거리 + 접근 허용 거리 · 가장자리 간격 기준) 안에 상대 유닛이 하나라도 있는가 — 도넛 색 표시용. + /// 궁병·책사 공격 가능 범위(사거리 + 접근 허용 거리 · 가장자리 간격 기준) 안에 상대 유닛이 하나라도 있는가 — 도넛 색 표시용. public bool AnyEnemyWithinArcherReach(Unit u) { if (u == null) return false; @@ -306,7 +306,7 @@ namespace TKS var players = AliveUnits(Faction.Player); if (players.Count == 0) break; - Vector2? target = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board); + Vector2? target = EnemyAI.Decide(e, players, accuracy, aggro, Rng, Board, Config); if (!target.HasValue) continue; // 교전 범위 밖 → 자리 지킴 if (UnitActing != null) UnitActing(e); @@ -402,7 +402,7 @@ namespace TKS string s; if (o.Attacked.Count == 0) { - s = who + (moveOnly ? " 추가 이동" : " 이동"); + s = who + (o.Fired ? " 사격" : moveOnly ? " 추가 이동" : " 이동"); } else { @@ -424,6 +424,15 @@ namespace TKS } s += " · " + string.Join(", ", pushed.ToArray()) + " 밀어냄"; } + if (o.FriendlyHit.Count > 0) + { + var hurt = new List(); + foreach (var f in o.FriendlyHit) + { + if (!hurt.Contains(f.DisplayName)) hurt.Add(f.DisplayName); + } + s += " · 아군 오사 " + string.Join(", ", hurt.ToArray()) + " " + o.FriendlyDamage; // PD 2026-11-05 #1087 ⑤⑥ + } foreach (var n in o.Notes) s += " · " + n; if (o.BonusMove) s += " · 추가 이동!"; if (o.Returned) s += " · 제자리 복귀"; diff --git a/Assets/Scripts/Runtime/Combat.cs b/Assets/Scripts/Runtime/Combat.cs index 29653fa6..1c06ec8d 100644 --- a/Assets/Scripts/Runtime/Combat.cs +++ b/Assets/Scripts/Runtime/Combat.cs @@ -16,8 +16,8 @@ namespace TKS /// /// 피해 = (공격 × 상성 × 크리 − 방어 × defenseFactor) × 궁병 근접 배율 × 난수. - /// melee = 근접 전투(돌진·반격·관통·견제·밀려 닿은 공격). 궁병이 근접 유닛(보병·기병·창병)과 근접 전투하면 - /// 주는 피해 × archerMeleeDealtRatio · 받는 피해 × archerMeleeTakenRatio (PD 2026-11-04 #1080) · 원거리 사격은 패널티 없음. + /// melee = 근접 전투(돌진·반격·관통·견제·밀려 닿은 공격). 원거리 병종(궁병·책사)이 근접 유닛(보병·기병·창병)과 근접 전투하면 + /// 주는 피해 × archerMeleeDealtRatio · 받는 피해 × archerMeleeTakenRatio (PD 2026-11-04 #1080 · 책사 #1087 ⑤ 동일) · 원거리 사격은 패널티 없음. /// public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee) { @@ -25,13 +25,21 @@ namespace TKS float raw = attacker.Atk * mult * (crit ? cfg.critMultiplier : 1f) - defender.Def * cfg.defenseFactor; if (melee) { - if (attacker.Type == UnitType.Archer && UnitTypeInfo.IsMelee(defender.Type)) raw *= cfg.archerMeleeDealtRatio; - if (defender.Type == UnitType.Archer && UnitTypeInfo.IsMelee(attacker.Type)) raw *= cfg.archerMeleeTakenRatio; + if (UnitTypeInfo.IsRanged(attacker.Type) && UnitTypeInfo.IsMelee(defender.Type)) raw *= cfg.archerMeleeDealtRatio; + if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type)) raw *= cfg.archerMeleeTakenRatio; } float variance = 1f + ((float)rng.NextDouble() * 2f - 1f) * cfg.damageVariance; return Mathf.Max(1, Mathf.RoundToInt(raw * variance)); } + /// 책사 광역 피해(PD 2026-11-05 #1087 ⑤) = 대상별 사격 피해(상성 적용 · 크리 없음) × ratio · ratio ≤ 0 이면 0. + public static int SplashDamage(BalanceConfig cfg, Unit attacker, Unit defender, float ratio, System.Random rng) + { + if (ratio <= 0f) return 0; + int baseDmg = Damage(cfg, attacker, defender, false, rng, false); + return Mathf.Max(1, Mathf.RoundToInt(baseDmg * ratio)); + } + public static int CounterDamage(BalanceConfig cfg, Unit defender, Unit attacker, System.Random rng) { int baseDmg = Damage(cfg, defender, attacker, false, rng, true); @@ -87,6 +95,46 @@ namespace TKS return null; } + /// + /// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외). + /// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist). + /// + public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, out float travel) + { + travel = dist; + Physics2D.SyncTransforms(); + var hits = Physics2D.CircleCastAll(from, arrowRadius, dir, dist); + System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance)); + foreach (var h in hits) + { + if (h.collider.GetComponent() != null) + { + travel = h.distance; + return null; + } + var u = UnitOf(h.collider); + if (u == null || u == shooter || !u.IsAlive) continue; + travel = h.distance; + return u; + } + return null; + } + + /// 점 center 반경 radius 원에 몸(원)이 닿는 살아 있는 유닛(가까운 순 · 진영 불문 · exclude 둘 제외) — 책사 광역(보스는 반지름이 커서 더 잘 닿는다). + public static List UnitsInCircle(Vector2 center, float radius, Unit excludeA, Unit excludeB) + { + Physics2D.SyncTransforms(); + var list = new List(); + foreach (var c in Physics2D.OverlapCircleAll(center, radius)) + { + var u = UnitOf(c); + if (u == null || u == excludeA || u == excludeB || !u.IsAlive || list.Contains(u)) continue; + list.Add(u); + } + list.Sort((a, b) => Vector2.Distance(center, a.Position).CompareTo(Vector2.Distance(center, b.Position))); + return list; + } + /// u 와 가장자리가 닿아 있는(ContactTolerance 이내) 가장 가까운 상대 진영 유닛. public static Unit TouchingOpponent(Unit u) { diff --git a/Assets/Scripts/Runtime/EnemyAI.cs b/Assets/Scripts/Runtime/EnemyAI.cs index 671b345d..0bd7a187 100644 --- a/Assets/Scripts/Runtime/EnemyAI.cs +++ b/Assets/Scripts/Runtime/EnemyAI.cs @@ -3,10 +3,13 @@ using UnityEngine; namespace TKS { - /// 적군 슬링샷 결정: 교전 범위 안의 가장 가까운 아군을 향해 당기고, 정확도에 따라 흔들린다. 범위 밖이면 대기(null). + /// + /// 적군 슬링샷 결정: 교전 범위 안의 가장 가까운 아군을 향해 당기고, 정확도에 따라 흔들린다. 범위 밖이면 대기(null). + /// 궁병·책사(PD 2026-11-05 #1087 ⑤⑥ · cfg 를 넘기면): 공격 가능 범위 안에서 사선이 빈 대상 우선 · 책사는 맞을 자리 광역에 자기 편이 없는 대상을 그보다 먼저 · 없으면 가장 가까운 대상(그냥 쏜다). + /// public static class EnemyAI { - public static Vector2? Decide(Unit e, IReadOnlyList targets, float accuracy, float aggroRange, System.Random rng, Board board) + public static Vector2? Decide(Unit e, IReadOnlyList targets, float accuracy, float aggroRange, System.Random rng, Board board, BalanceConfig cfg = null) { Unit nearest = null; float best = float.MaxValue; @@ -24,6 +27,8 @@ namespace TKS if (nearest == null) return null; if (best > aggroRange) return null; // 넓은 전장: 교전 범위 밖이면 자리 지킴 + if (cfg != null && UnitTypeInfo.IsRanged(e.Type)) nearest = PickRangedTarget(e, targets, nearest, cfg); + Vector2 toT = nearest.Position - e.Position; float dist = toT.magnitude; Vector2 dir = dist > 0.001f ? toT / dist : Vector2.up; @@ -52,5 +57,45 @@ namespace TKS if (v.magnitude > e.SlingMaxDistance) v = v.normalized * e.SlingMaxDistance; return board.ClampInside(e.Position + v, e.Radius); } + + /// + /// 원거리 대상 고르기: 공격 가능 범위(사거리 + 접근 허용 · 가장자리 간격) 안의 대상만 본다. + /// 등급 0 = 사선이 빔(화살이 처음 맞힐 유닛 = 그 대상) + (책사) 맞을 자리 광역에 자기 편 없음 · 1 = 사선만 빔(책사) · 2 = 막힘 → 등급 낮은 순 · 같으면 가까운 순. + /// 범위 안에 아무도 없으면 가장 가까운 대상(접근). + /// + static Unit PickRangedTarget(Unit e, IReadOnlyList targets, Unit nearest, BalanceConfig cfg) + { + Unit pick = null; + int pickTier = int.MaxValue; + float pickD = float.MaxValue; + float reach = e.AttackRange + e.ArcherMoveAllow; + for (int i = 0; i < targets.Count; i++) + { + var t = targets[i]; + if (!t.IsAlive) continue; + float d = Vector2.Distance(e.Position, t.Position); + if (d - e.Radius * Combat.CastRadiusScale - t.Radius > reach || d < 0.001f) continue; + + float travel; + var first = Combat.FirstUnitOnArrow(e, e.Position, (t.Position - e.Position) / d, d, cfg.arrowRadius, out travel); + bool clear = first == t; + bool safe = true; + if (clear && e.Type == UnitType.Strategist) + { + foreach (var s in Combat.UnitsInCircle(t.Position, cfg.strategistSplashRadius, t, e)) + { + if (s.Faction == e.Faction) safe = false; + } + } + int tier = clear ? (safe ? 0 : 1) : 2; + if (tier < pickTier || (tier == pickTier && d < pickD)) + { + pick = t; + pickTier = tier; + pickD = d; + } + } + return pick ?? nearest; + } } } diff --git a/Assets/Scripts/Runtime/Fx.cs b/Assets/Scripts/Runtime/Fx.cs index 954d1c21..185a82b8 100644 --- a/Assets/Scripts/Runtime/Fx.cs +++ b/Assets/Scripts/Runtime/Fx.cs @@ -59,13 +59,24 @@ namespace TKS a.Position = start; } + public static readonly Color ArrowColor = new Color(1f, 0.95f, 0.75f); + public static readonly Color StrategistShotColor = new Color(0.75f, 0.55f, 1f); + public static readonly Color SplashColor = new Color(0.7f, 0.45f, 1f, 0.45f); + public static readonly Color FriendlyFireColor = new Color(1f, 0.3f, 0.3f); + /// 화살(가늘고 긴 사각형) 날리기. public static IEnumerator Arrow(Vector2 from, Vector2 to) + { + return Arrow(from, to, ArrowColor); + } + + /// 투사체(가늘고 긴 사각형) 날리기 — 궁병 화살 · 책사 계략(색만 다름). + public static IEnumerator Arrow(Vector2 from, Vector2 to, Color color) { var go = new GameObject("Arrow"); var sr = go.AddComponent(); sr.sprite = ProceduralSprites.Square; - sr.color = new Color(1f, 0.95f, 0.75f); + sr.color = color; sr.sortingOrder = 8; Vector2 d = to - from; float ang = Mathf.Atan2(d.y, d.x) * Mathf.Rad2Deg; @@ -83,6 +94,32 @@ namespace TKS UnityEngine.Object.Destroy(go); } + /// 광역 터짐(반지름 radius 원이 짧게 커지며 사라짐 · 책사 광역). + public static void Burst(Vector2 center, float radius, Color color) + { + Run(BurstRoutine(center, radius, color)); + } + + static IEnumerator BurstRoutine(Vector2 center, float radius, Color color) + { + var sr = ProceduralSprites.NewSprite(null, "Burst", ProceduralSprites.Circle, color, radius * 2f, 7); + sr.transform.position = new Vector3(center.x, center.y, 0f); + float t = 0f; + const float dur = 0.35f; + while (t < dur) + { + t += Time.deltaTime; + float k = Mathf.Clamp01(t / dur); + float s = radius * 2f * (0.6f + 0.4f * k); + sr.transform.localScale = new Vector3(s, s, 1f); + var c = color; + c.a = color.a * (1f - k); + sr.color = c; + yield return null; + } + UnityEngine.Object.Destroy(sr.gameObject); + } + public static void Popup(Vector2 pos, string text, Color color) { Run(PopupRoutine(pos, text, color)); diff --git a/Assets/Scripts/Runtime/RangeIndicator.cs b/Assets/Scripts/Runtime/RangeIndicator.cs index c5bbeca9..d5f0a5c9 100644 --- a/Assets/Scripts/Runtime/RangeIndicator.cs +++ b/Assets/Scripts/Runtime/RangeIndicator.cs @@ -5,7 +5,7 @@ namespace TKS /// /// 선택(터치)한 유닛의 사거리 표시 (PD 2026-11-04). /// · 모든 병종: 슬링샷 최대 거리 = 얇은 반투명 원 (아군·적군 모두) - /// · 궁병: 추가로 슬링샷 범위 ~ 슬링샷 범위 × (1 + archerRangeRatio) 까지를 채워진 반투명 붉은 도넛으로 (실제 공격 가능 범위) + /// · 궁병·책사: 추가로 슬링샷 범위 ~ 슬링샷 범위 × (1 + archerRangeRatio) 까지를 채워진 반투명 붉은 도넛으로 (실제 공격 가능 범위) /// — 그 범위 안에 적이 하나도 없으면 회색. /// public class RangeIndicator : MonoBehaviour @@ -68,7 +68,7 @@ namespace TKS ring.transform.localScale = new Vector3(r * 2f, r * 2f, 1f); ring.gameObject.SetActive(true); - bool archer = u.Type == UnitType.Archer; + bool archer = UnitTypeInfo.IsRanged(u.Type); // 궁병·책사(#1087 ⑤ 같은 사거리 규칙) if (archer) { float ro = r * (1f + ratio); diff --git a/Assets/Scripts/Runtime/SlingshotInput.cs b/Assets/Scripts/Runtime/SlingshotInput.cs index ad6648c7..aaf06bf3 100644 --- a/Assets/Scripts/Runtime/SlingshotInput.cs +++ b/Assets/Scripts/Runtime/SlingshotInput.cs @@ -13,6 +13,10 @@ namespace TKS { static readonly Color TargetFreeColor = Color.white; static readonly Color TargetHitColor = new Color(1f, 0.6f, 0.1f); + static readonly Color ShotLineColor = new Color(1f, 0.92f, 0.6f, 0.95f); + static readonly Color ShotLineFriendlyColor = new Color(1f, 0.2f, 0.2f, 0.95f); + static readonly Color SplashAreaColor = new Color(0.7f, 0.45f, 1f, 0.22f); + static readonly Color SplashAreaFriendlyColor = new Color(1f, 0.25f, 0.25f, 0.26f); BattleManager battle; Camera cam; @@ -31,7 +35,9 @@ namespace TKS LineRenderer aimLine; LineRenderer pullLine; + LineRenderer shotLine; // 궁병·책사 투사체 경로(쏘는 위치 → 첫 피격 지점 · PD 2026-11-05 #1087 ⑥) SpriteRenderer targetRing; + SpriteRenderer splashArea; // 책사 광역 원(중심 = 첫 피격 유닛 · #1087 ⑤) readonly List highlighted = new List(); public Unit Selected @@ -39,6 +45,27 @@ namespace TKS get { return selected; } } + public bool ShotLineVisible + { + get { return shotLine != null && shotLine.enabled; } + } + + public bool SplashVisible + { + get { return splashArea != null && splashArea.gameObject.activeSelf; } + } + + /// 책사 광역 원의 지름(월드) = strategistSplashRadius × 2 · 중심 = SplashCenter. + public float SplashDiameter + { + get { return splashArea != null ? splashArea.transform.localScale.x : 0f; } + } + + public Vector2 SplashCenter + { + get { return splashArea != null ? (Vector2)splashArea.transform.position : Vector2.zero; } + } + public RangeIndicator Range { get { return range; } @@ -57,8 +84,11 @@ namespace TKS camCtrl = cc; aimLine = MakeLine("AimLine", 0.08f, new Color(1f, 1f, 1f, 0.9f), 10); pullLine = MakeLine("PullLine", 0.035f, new Color(1f, 1f, 1f, 0.35f), 9); + shotLine = MakeLine("ShotLine", 0.05f, ShotLineColor, 10); targetRing = ProceduralSprites.NewSprite(transform, "TargetRing", ProceduralSprites.Ring, Color.white, 0.7f, 11); targetRing.enabled = false; + splashArea = ProceduralSprites.NewSprite(transform, "SplashArea", ProceduralSprites.Circle, SplashAreaColor, 1f, -8); + splashArea.gameObject.SetActive(false); var rgo = new GameObject("RangeIndicator"); rgo.transform.SetParent(transform, false); @@ -215,7 +245,7 @@ namespace TKS if (range != null) { range.Show(u); - if (u != null && u.Type == UnitType.Archer) range.SetArcherReachable(battle.AnyEnemyWithinArcherReach(u)); + if (u != null && UnitTypeInfo.IsRanged(u.Type)) range.SetArcherReachable(battle.AnyEnemyWithinArcherReach(u)); } } @@ -244,19 +274,78 @@ namespace TKS targetRing.transform.localScale = finalHit != null ? new Vector3(0.9f, 0.9f, 1f) : new Vector3(0.7f, 0.7f, 1f); ClearHighlights(); - foreach (var e in battle.Resolver.PredictTargets(u, target, moveOnly)) + var shot = battle.Resolver.PreviewShot(u, target, moveOnly); + if (shot != null) { - if (e.View != null) e.View.SetHighlight(true); - highlighted.Add(e); + return ShowShot(u, shot); } + HideShot(); + foreach (var e in battle.Resolver.PredictTargets(u, target, moveOnly)) Highlight(e, false); return highlighted.Count; } + /// + /// 궁병·책사 조준(PD 2026-11-05 #1087 ⑤⑥): 화살 경로선(쏘는 위치 → 첫 피격 지점) + 첫 피격 유닛 강조(같은 편이면 붉게) + /// · 책사 = 맞을 자리 광역 원 + 휩쓸릴 유닛 강조(같은 편 붉게). 반환 = 맞을 상대 유닛 수. + /// + int ShowShot(Unit u, ActionResolver.ShotPlan shot) + { + if (!shot.Fires) + { + HideShot(); + return 0; + } + bool friendlyCenter = shot.Hit != null && shot.Hit.Faction == u.Faction; + shotLine.enabled = true; + shotLine.startColor = shotLine.endColor = friendlyCenter ? ShotLineFriendlyColor : ShotLineColor; + shotLine.SetPosition(0, new Vector3(shot.From.x, shot.From.y, 0f)); + shotLine.SetPosition(1, new Vector3(shot.To.x, shot.To.y, 0f)); + int foes = 0; + if (shot.Hit != null) + { + Highlight(shot.Hit, friendlyCenter); + if (!friendlyCenter) foes++; + } + + bool splash = u.Type == UnitType.Strategist && shot.Hit != null; + if (splash) + { + bool anyFriendly = friendlyCenter; + foreach (var s in shot.Splash) + { + bool friendly = s.Faction == u.Faction; + Highlight(s, friendly); + if (friendly) anyFriendly = true; + else foes++; + } + float d = battle.Config.strategistSplashRadius * 2f; + 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; + } + if (splashArea.gameObject.activeSelf != splash) splashArea.gameObject.SetActive(splash); + return foes; + } + + void HideShot() + { + shotLine.enabled = false; + splashArea.gameObject.SetActive(false); + } + + void Highlight(Unit e, bool friendly) + { + if (e == null || highlighted.Contains(e)) return; + if (e.View != null) e.View.SetHighlight(true, friendly); + highlighted.Add(e); + } + public void HidePreview() { aimLine.enabled = false; pullLine.enabled = false; targetRing.enabled = false; + HideShot(); ClearHighlights(); } diff --git a/Assets/Scripts/Runtime/StageTable.cs b/Assets/Scripts/Runtime/StageTable.cs index 70a2f419..a1bcc800 100644 --- a/Assets/Scripts/Runtime/StageTable.cs +++ b/Assets/Scripts/Runtime/StageTable.cs @@ -7,7 +7,8 @@ namespace TKS { /// /// 스테이지 표(Assets/Data/Stages/*.csv · utf-8-sig) 해석 + BalanceConfig 반영. 값의 SOT = CSV(C45) — 코드는 열 이름·형식만 안다. - /// stages.csv = chapter,stage,title,type(normal|elite|boss),place,faction,infantry,spear,cavalry,archer,generals(id:병종:보스여부 ; 구분),bg_theme,note + /// stages.csv = chapter,stage,title,type(normal|elite|boss),place,faction,infantry,spear,cavalry,archer,strategist,generals(id:병종:보스여부 ; 구분),bg_theme,note + /// (strategist = 책사 수 · PD 2026-11-05 #1087 ⑤ · 비거나 열이 없으면 0) /// chapters.csv = chapter,title,subtitle,years,map,ai_accuracy,ai_aggro,note · generals.csv = id,name,hanja,hp_mult,atk_mult,note · factions.csv = code,name,art,note /// 에디터 임포터(TKS/Import Stages)와 데이터 검증 테스트가 같이 쓴다. /// @@ -140,7 +141,9 @@ namespace TKS } for (int i = 0; i < UnitTypeInfo.Count; i++) { - int n = Int(r, UnitTypeInfo.Key((UnitType)i), t.errors, at); + string key = UnitTypeInfo.Key((UnitType)i); + bool optional = (UnitType)i == UnitType.Strategist; // 책사 열(2026-11-05 추가)은 비거나 없으면 0 — 구 4열 표도 그대로 읽힌다 + int n = optional && Get(r, key).Length == 0 ? 0 : Int(r, key, t.errors, at); if (n < 0) t.errors.Add(at + ": 병사 수 음수"); s.soldiers[i] = Mathf.Max(0, n); } @@ -153,7 +156,7 @@ namespace TKS UnitType ut; if (f.Length != 3 || !UnitTypeInfo.TryParseKey(f[1].Trim(), out ut) || (f[2].Trim() != "true" && f[2].Trim() != "false")) { - t.errors.Add(at + ": generals 형식 = id:병종(infantry|spear|cavalry|archer):true|false (값 「" + g + "」)"); + t.errors.Add(at + ": generals 형식 = id:병종(infantry|spear|cavalry|archer|strategist):true|false (값 「" + g + "」)"); continue; } var gr = new GeneralRef { id = f[0].Trim(), type = ut, boss = f[2].Trim() == "true" }; @@ -219,7 +222,7 @@ namespace TKS } /// - /// 병사 → 후군 경비(창·기·보 우선 · 총수 × rearShare 내림) · 전위(보·창·기 우선 · 총수 × frontShare 올림) · 나머지 = 중군(궁 가운데 → 보·창 → 기병 양 끝). + /// 병사 → 후군 경비(창·기·보 우선 · 총수 × rearShare 내림) · 전위(보·창·기 우선 · 총수 × frontShare 올림) · 나머지 = 중군(궁 가운데 → 책사 → 보·창 → 기병 양 끝). /// 대마다 maxPerRow 넘으면 고르게 여러 줄(적 쪽으로 rowGap · 줄마다 엇갈림). 장수 = commandY 줄(보스 → 엘리트 순 가운데부터). /// public static List Enemies(BalanceConfig cfg, StageRow r, Tables t) @@ -230,7 +233,7 @@ namespace TKS int total = r.SoldierCount; var rear = Take(pool, Mathf.FloorToInt(total * F.rearShare), UnitType.Spear, UnitType.Cavalry, UnitType.Infantry); var front = Take(pool, Mathf.CeilToInt(total * F.frontShare), UnitType.Infantry, UnitType.Spear, UnitType.Cavalry); - var middle = Take(pool, total, UnitType.Archer, UnitType.Infantry, UnitType.Spear, UnitType.Cavalry); + var middle = Take(pool, total, UnitType.Archer, UnitType.Strategist, UnitType.Infantry, UnitType.Spear, UnitType.Cavalry); var res = new List(); foreach (var band in new[] { new KeyValuePair>(F.frontY, front), new KeyValuePair>(F.middleY, middle), new KeyValuePair>(F.rearY, rear) }) diff --git a/Assets/Scripts/Runtime/Unit.cs b/Assets/Scripts/Runtime/Unit.cs index 3cd04ac3..205be0e2 100644 --- a/Assets/Scripts/Runtime/Unit.cs +++ b/Assets/Scripts/Runtime/Unit.cs @@ -17,9 +17,9 @@ namespace TKS public int Def; public float Radius; public float SlingMaxDistance; - /// 궁병 = 사거리 = 슬링샷 최대 거리(적 가장자리까지의 간격 기준 · 제자리 사격) · 창병 = 대기(견제) 반경. + /// 궁병·책사 = 사거리 = 슬링샷 최대 거리(적 가장자리까지의 간격 기준 · 제자리 사격) · 창병 = 대기(견제) 반경. public float AttackRange; - /// 궁병이 사거리 밖(~ 사거리 × (1 + archerRangeRatio))의 적에게 다가갈 수 있는 최대 거리. 다른 병종 = 0. + /// 궁병·책사가 사거리 밖(~ 사거리 × (1 + archerRangeRatio))의 적에게 다가갈 수 있는 최대 거리. 다른 병종 = 0. public float ArcherMoveAllow; /// Resources/Units/<ArtId>/ 아트 폴더 · 비면 도형. public string ArtId = ""; @@ -64,8 +64,10 @@ namespace TKS ArtHeight = cfg.unitArtHeight * radiusMult; SlingMaxDistance = s.slingMaxDistance; // PD 2026-11-04 #1080: 궁병 사거리 = 슬링샷 범위(제자리 사격) · 그 밖 +비율 안의 적은 들어올 만큼만 다가가 사격 · 창병은 자산의 대기 반경 - AttackRange = type == UnitType.Archer ? s.slingMaxDistance : s.attackRange; - ArcherMoveAllow = type == UnitType.Archer ? s.slingMaxDistance * cfg.archerRangeRatio : 0f; + // 책사(PD 2026-11-05 #1087 ⑤) = 궁병과 같은 사거리 규칙 + bool ranged = UnitTypeInfo.IsRanged(type); + AttackRange = ranged ? s.slingMaxDistance : s.attackRange; + ArcherMoveAllow = ranged ? s.slingMaxDistance * cfg.archerRangeRatio : 0f; gameObject.name = (faction == Faction.Player ? "P_" : "E_") + displayName; Collider = GetComponent(); diff --git a/Assets/Scripts/Runtime/UnitTypes.cs b/Assets/Scripts/Runtime/UnitTypes.cs index b1d9e54d..9ec4390e 100644 --- a/Assets/Scripts/Runtime/UnitTypes.cs +++ b/Assets/Scripts/Runtime/UnitTypes.cs @@ -1,12 +1,13 @@ namespace TKS { - /// 병종. 보병 > 창병 > 기병 > 보병 상성, 궁병은 모두에게 약하나 반격을 받지 않는다. + /// 병종. 보병 > 창병 > 기병 > 보병 상성, 원거리(궁병·책사)는 모두에게 약하나 반격을 받지 않는다. public enum UnitType { - Infantry = 0, // 보병 - Cavalry = 1, // 기병 - Archer = 2, // 궁병 - Spear = 3 // 창병 + Infantry = 0, // 보병 + Cavalry = 1, // 기병 + Archer = 2, // 궁병 + Spear = 3, // 창병 + Strategist = 4 // 책사(PD 2026-11-05 #1087 ⑤ · 궁병 사격 + 맞은 자리 광역) } public enum Faction @@ -17,7 +18,7 @@ namespace TKS public static class UnitTypeInfo { - public const int Count = 4; + public const int Count = 5; public static string Korean(UnitType t) { @@ -27,6 +28,7 @@ namespace TKS case UnitType.Cavalry: return "기병"; case UnitType.Archer: return "궁병"; case UnitType.Spear: return "창병"; + case UnitType.Strategist: return "책사"; } return "?"; } @@ -39,16 +41,23 @@ namespace TKS case UnitType.Cavalry: return "기"; case UnitType.Archer: return "궁"; case UnitType.Spear: return "창"; + case UnitType.Strategist: return "책"; } return "?"; } - public static bool IsMelee(UnitType t) + /// 원거리 병종(궁병·책사) — 투사체 사격 · 반격 없음 · 근접 전투 패널티. + public static bool IsRanged(UnitType t) { - return t != UnitType.Archer; + return t == UnitType.Archer || t == UnitType.Strategist; } - /// 데이터·아트 id 용 영문 키(infantry·cavalry·archer·spear) · 적 병사 아트 = <세력>_<키>. + public static bool IsMelee(UnitType t) + { + return !IsRanged(t); + } + + /// 데이터·아트 id 용 영문 키(infantry·cavalry·archer·spear·strategist) · 적 병사 아트 = <세력>_<키>. public static string Key(UnitType t) { switch (t) @@ -57,6 +66,7 @@ namespace TKS case UnitType.Cavalry: return "cavalry"; case UnitType.Archer: return "archer"; case UnitType.Spear: return "spear"; + case UnitType.Strategist: return "strategist"; } return "unit"; } diff --git a/Assets/Scripts/Runtime/UnitView.cs b/Assets/Scripts/Runtime/UnitView.cs index 0032c8e8..b1d396d7 100644 --- a/Assets/Scripts/Runtime/UnitView.cs +++ b/Assets/Scripts/Runtime/UnitView.cs @@ -14,6 +14,8 @@ namespace TKS static readonly Color DimColor = new Color(0.3f, 0.3f, 0.32f); static readonly Color ArtDimColor = new Color(0.45f, 0.45f, 0.5f); static readonly Color ArtHitTint = new Color(1f, 0.45f, 0.45f); + static readonly Color HighlightColor = new Color(1f, 0.5f, 0.15f); + static readonly Color FriendlyFireRingColor = new Color(1f, 0.1f, 0.1f); const float HpBarWidth = 1.0f; const float AttackHold = 0.35f; const float HitHold = 0.3f; @@ -33,6 +35,7 @@ namespace TKS Color baseColor; Coroutine flash; bool aiming; + bool highlightFriendly; bool charging; // 슬링샷 돌진(이동) 중 = 돌진 포즈 고정 bool facingBack; // 위를 봄(뒷모습 back_* 세트) · false = 아래를 봄(앞모습 front_*) bool facingLeft; // 왼쪽을 봄(좌우 반전) · false = 오른쪽(기본) @@ -138,7 +141,7 @@ namespace TKS selectRing = ProceduralSprites.NewSprite(transform, "SelectRing", ProceduralSprites.Ring, new Color(1f, 0.95f, 0.4f), d + 0.28f, 1); selectRing.enabled = false; - highlightRing = ProceduralSprites.NewSprite(transform, "HighlightRing", ProceduralSprites.Ring, new Color(1f, 0.5f, 0.15f), d + 0.28f, 1); + highlightRing = ProceduralSprites.NewSprite(transform, "HighlightRing", ProceduralSprites.Ring, HighlightColor, d + 0.28f, 1); highlightRing.enabled = false; // HP 바 = 도형이면 원 위 · 아트면 머리 위(발 = −0.55 R · 세로 ArtHeight) — 얼굴을 가리지 않게(PD #1081 첨부 캡처) @@ -199,7 +202,27 @@ namespace TKS public void SetHighlight(bool on) { - if (highlightRing != null) highlightRing.enabled = on; + SetHighlight(on, false); + } + + /// 공격 예정 표시 링 · friendly = 궁병·책사 사격에 맞을 같은 편(붉게 · PD 2026-11-05 #1087 ⑤⑥). + public void SetHighlight(bool on, bool friendly) + { + if (highlightRing == null) return; + highlightRing.enabled = on; + highlightRing.color = friendly ? FriendlyFireRingColor : HighlightColor; + highlightFriendly = on && friendly; + } + + public bool Highlighted + { + get { return highlightRing != null && highlightRing.enabled; } + } + + /// 오사 경고(붉은 링) 중인가. + public bool HighlightFriendly + { + get { return highlightFriendly; } } /// 조준(당기는) 중 = 출발 자세(ready · 1프레임) · 끝나면 대기 (PD 2026-11-05 #1084). diff --git a/Assets/Tests/PlayMode/BattleFlowTests.cs b/Assets/Tests/PlayMode/BattleFlowTests.cs index 4110105f..ec54e000 100644 --- a/Assets/Tests/PlayMode/BattleFlowTests.cs +++ b/Assets/Tests/PlayMode/BattleFlowTests.cs @@ -257,6 +257,7 @@ namespace TKS.Tests var target = new Vector2(0f, -1f); yield return bm.ResolveForTest(arc, target, false, o); Assert.IsTrue(o.MovedOnly, "공격 가능 범위에 적이 없으면 목표점까지 이동만 (PD 2026-11-04 2차 지시)"); + Assert.IsFalse(o.Fired, "타겟이 없으면 쏘지 않는다(#1087 ⑥ 유지)"); Assert.AreEqual(0, o.Attacked.Count); Assert.AreEqual(foe.MaxHp, foe.Hp); Assert.Less(Vector2.Distance(arc.Position, target), 0.05f, "목표 지점까지 이동"); @@ -275,10 +276,271 @@ namespace TKS.Tests var o = new ActionOutcome(); yield return bm.ResolveForTest(arc, foe.Position, false, o); Assert.Contains(foe, o.Attacked); - Assert.IsTrue(o.Crit, "최종 목표점 명중 → 크리티컬"); + Assert.IsTrue(o.Fired); + Assert.AreEqual(foe, o.ShotHit, "사선이 비면 화살이 타겟을 맞힌다"); + Assert.IsTrue(o.Crit, "첫 피격 = 타겟 + 최종 목표점 명중 → 크리티컬"); Assert.AreEqual(arc.MaxHp, arc.Hp); } + // ---------- 궁병 투사체 · 책사 (PD 2026-11-05 #1087 ⑤⑥) ---------- + + /// 사격 피해가 공식 범위 안인지(공 × 상성 × 크리 − 방 × 계수 · 난수 ± damageVariance · 그 뒤 × ratio · 반올림 여유 1). + void AssertShotDamage(Unit a, Unit d, int dealt, bool crit, float ratio, string msg) + { + float raw = a.Atk * cfg.Matchup(a.Type, d.Type) * (crit ? cfg.critMultiplier : 1f) - d.Def * cfg.defenseFactor; + float lo = Mathf.Max(1f, raw * (1f - cfg.damageVariance)) * ratio - 1f; + float hi = Mathf.Max(1f, raw * (1f + cfg.damageVariance)) * ratio + 1f; + Assert.GreaterOrEqual((float)dealt, lo, msg + " (피해 " + dealt + ")"); + Assert.LessOrEqual((float)dealt, hi, msg + " (피해 " + dealt + ")"); + } + + [UnityTest] + public IEnumerator Archer_Arrow_AllyOnLine_TakesHit_TargetUntouched_NoCrit() + { + yield return Fresh(); + var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병"); + var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3.5f), "아군보병"); + var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -1.5f), "적보병"); + var o = new ActionOutcome(); + yield return bm.ResolveForTest(arc, foe.Position, false, o); // 목표점 = 타겟(명중)이지만 사선을 아군이 막음 + Assert.IsTrue(o.Fired, "타겟(적)이 있으면 쏜다"); + Assert.AreEqual(ally, o.ShotHit, "경로 위 첫 유닛 = 아군이 맞는다"); + Assert.Contains(ally, o.FriendlyHit); + AssertShotDamage(arc, ally, ally.MaxHp - ally.Hp, false, 1f, "아군 피격 = 같은 피해 공식(상성 적용 · 100 %)"); + Assert.AreEqual(foe.MaxHp, foe.Hp, "관통 없음 → 가려진 타겟은 무피해"); + Assert.AreEqual(0, o.Attacked.Count); + Assert.IsFalse(o.Crit, "첫 피격이 타겟이 아니면 크리 없음"); + Assert.AreEqual(arc.MaxHp, arc.Hp, "반격 없음"); + Assert.AreEqual(-3.5f, ally.Position.y, 0.01f, "화살은 밀지 않는다"); + Assert.AreEqual(0, o.Notes.Count, "밀린 아군 공격(규칙 ⑤) 없음"); + Assert.Less(Vector2.Distance(arc.Position, new Vector2(0f, -5f)), 0.01f, "제자리 사격"); + } + + [UnityTest] + public IEnumerator Archer_NoEnemyInReach_DoesNotFire_AllyOnLineUnhurt() + { + yield return Fresh(); + var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병"); + var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3f), "아군보병"); + var far = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 8f), "먼적"); + var o = new ActionOutcome(); + yield return bm.ResolveForTest(arc, new Vector2(0f, -1.5f), false, o); + Assert.IsFalse(o.Fired, "공격 가능 범위에 적이 없으면 쏘지 않는다"); + Assert.IsTrue(o.MovedOnly); + Assert.AreEqual(ally.MaxHp, ally.Hp, "사선 위 아군도 무피해"); + Assert.AreEqual(0, o.FriendlyHit.Count); + Assert.AreEqual(far.MaxHp, far.Hp); + Assert.LessOrEqual(arc.Position.y, ally.Position.y - CastContact(arc, ally) + 0.05f, "아군에 막혀 그 앞에서 멈춤"); + } + + [UnityTest] + public IEnumerator Strategist_Splash_RadiusRatio_AllyRatio_CenterOnlyCrit() + { + yield return Fresh(); + float R = cfg.strategistSplashRadius; + var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(0f, -5f), "책사"); + Vector2 c = new Vector2(0f, -2f); + var center = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, c, "중심적"); + var near = bm.SpawnUnit(UnitType.Spear, Faction.Enemy, c + new Vector2(R * 0.8f, 0f), "광역적"); + var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, c + new Vector2(-R * 0.8f, 0f), "광역아군"); + var far = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, c + new Vector2(R + 0.45f + 0.3f, 0f), "반경밖적"); + Assert.Greater(Vector2.Distance(c, far.Position), R + far.Radius, "전제: 반경 밖(몸이 원에 안 닿음)"); + + var o = new ActionOutcome(); + yield return bm.ResolveForTest(st, center.Position, false, o); + Assert.IsTrue(o.Fired); + Assert.AreEqual(center, o.ShotHit, "사선이 비면 타겟이 광역 중심"); + Assert.IsTrue(o.Crit, "중심 = 타겟 + 목표점 명중 → 크리"); + AssertShotDamage(st, center, center.MaxHp - center.Hp, true, 1f, "중심 피해(크리 ×" + cfg.critMultiplier + ")"); + AssertShotDamage(st, near, near.MaxHp - near.Hp, false, cfg.strategistSplashRatio, "광역 = 대상별 피해 × 광역 비율 · 크리 미적용"); + AssertShotDamage(st, ally, ally.MaxHp - ally.Hp, false, cfg.strategistSplashRatio * cfg.strategistAllyDamageRatio, "범위 안 아군 = × 아군 비율"); + Assert.AreEqual(far.MaxHp, far.Hp, "반경 밖 무피해"); + Assert.Contains(center, o.Attacked); + Assert.Contains(near, o.Attacked); + if (cfg.strategistAllyDamageRatio > 0f) Assert.Contains(ally, o.FriendlyHit); + Assert.AreEqual(st.MaxHp, st.Hp, "반격 없음 · 쏜 책사 자신은 광역 제외"); + Assert.Less(Vector2.Distance(st.Position, new Vector2(0f, -5f)), 0.01f, "제자리 사격"); + } + + [UnityTest] + public IEnumerator Strategist_AllyOnLine_BecomesCenter_SplashesTarget() + { + yield return Fresh(); + var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(0f, -5f), "책사"); + var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3.5f), "아군보병"); + var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -2.4f), "적보병"); + Assert.LessOrEqual(Vector2.Distance(ally.Position, foe.Position), cfg.strategistSplashRadius + foe.Radius, "전제: 적이 아군 중심 광역 안"); + var o = new ActionOutcome(); + yield return bm.ResolveForTest(st, foe.Position, false, o); + Assert.AreEqual(ally, o.ShotHit, "아군이 사선을 막으면 그 아군이 광역 중심"); + AssertShotDamage(st, ally, ally.MaxHp - ally.Hp, false, 1f, "중심 아군 = 사격 피해 100 %(크리 없음)"); + AssertShotDamage(st, foe, foe.MaxHp - foe.Hp, false, cfg.strategistSplashRatio, "타겟은 광역으로만"); + Assert.IsFalse(o.Crit, "첫 피격이 타겟이 아니면 크리 없음"); + Assert.Contains(ally, o.FriendlyHit); + Assert.Contains(foe, o.Attacked); + } + + [Test] + public void Matchup_5x5_StrategistRowAndColumn_SameAsArcher() + { + var c = cfg != null ? cfg : Resources.Load("BalanceConfig"); + Assert.AreEqual(UnitTypeInfo.Count * UnitTypeInfo.Count, c.matchup.Length, "상성 5×5(보·기·궁·창·책)"); + for (int i = 0; i < UnitTypeInfo.Count; i++) + { + var t = (UnitType)i; + Assert.AreEqual(c.Matchup(t, UnitType.Archer), c.Matchup(t, UnitType.Strategist), UnitTypeInfo.Korean(t) + " → 책사 = → 궁병"); + Assert.AreEqual(c.Matchup(UnitType.Archer, t), c.Matchup(UnitType.Strategist, t), "책사 → " + UnitTypeInfo.Korean(t) + " = 궁병 →"); + } + Assert.IsTrue(UnitTypeInfo.IsRanged(UnitType.Strategist) && !UnitTypeInfo.IsMelee(UnitType.Strategist), "책사 = 원거리(반격 없음 · 근접 패널티)"); + Assert.AreEqual("strategist", UnitTypeInfo.Key(UnitType.Strategist), "데이터·아트 키"); + Assert.AreEqual("책", UnitTypeInfo.Mark(UnitType.Strategist), "표시 글자"); + Assert.AreEqual(c.GetStats(UnitType.Archer).slingMaxDistance, c.GetStats(UnitType.Strategist).slingMaxDistance, "슬링 = 사거리 = 궁병과 동일(제안값)"); + } + + [UnityTest] + public IEnumerator Strategist_RangeLikeArcher_Donut_MeleePenalty() + { + yield return Fresh(); + var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(0f, -5f), "책사"); + var inf = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(3f, -5f), "적보병"); + Assert.AreEqual(st.SlingMaxDistance, st.AttackRange, 0.001f, "사거리 = 슬링샷 범위"); + Assert.AreEqual(st.SlingMaxDistance * cfg.archerRangeRatio, st.ArcherMoveAllow, 0.001f, "접근 허용 = 궁병과 같은 비율"); + int ranged = Combat.Damage(cfg, st, inf, false, new System.Random(7), false); + int melee = Combat.Damage(cfg, st, inf, false, new System.Random(7), true); + Assert.AreEqual(ranged * cfg.archerMeleeDealtRatio, melee, 1f, "근접 전투 주는 피해 × 궁병과 같은 비율"); + int takenRanged = Combat.Damage(cfg, inf, st, false, new System.Random(7), false); + int takenMelee = Combat.Damage(cfg, inf, st, false, new System.Random(7), true); + Assert.AreEqual(takenRanged * cfg.archerMeleeTakenRatio, takenMelee, 1f, "근접 피격 × 궁병과 같은 비율"); + bm.Sling.Select(st); + Assert.IsTrue(bm.Sling.Range.DonutVisible, "책사도 공격 가능 범위 도넛"); + bm.Sling.Select(null); + } + + static string QuoteCsv(string v) + { + return "\"" + v.Replace("\"", "\"\"") + "\""; + } + + /// stages.csv 를 다시 쓴다(dropKey 열 빼기 · setRow 행의 setKey 칸 바꾸기 · 모든 칸 따옴표). + static string RebuildStages(string csv, string dropKey, int setRow, string setKey, string setValue) + { + var errs = new List(); + var rows = StageTable.ReadCsv(csv, "stages.csv", errs); + Assert.IsEmpty(errs); + var head = new List(csv.TrimStart('').Split('\n')[0].Trim('\r').Split(',')); + if (dropKey != null) head.Remove(dropKey); + var sb = new System.Text.StringBuilder(string.Join(",", head.ToArray())).Append('\n'); + for (int i = 0; i < rows.Count; i++) + { + var cells = new List(); + foreach (var h in head) cells.Add(QuoteCsv(i == setRow && h == setKey ? setValue : rows[i][h])); + sb.Append(string.Join(",", cells.ToArray())).Append('\n'); + } + return sb.ToString(); + } + + [Test] + public void StageTable_StrategistColumn_DefaultZero_OldTableStillParses_PlacedInMiddle() + { + var dir = Path.Combine(Application.dataPath, StageTable.Folder); + var texts = new string[StageTable.Files.Length]; + for (int i = 0; i < texts.Length; i++) texts[i] = File.ReadAllText(Path.Combine(dir, StageTable.Files[i]), System.Text.Encoding.UTF8); + var t = StageTable.Parse(texts); + Assert.IsEmpty(t.errors); + Assert.IsTrue(texts[0].Split('\n')[0].Contains(",archer,strategist,"), "stages.csv 머리줄에 strategist 열(#1087 ⑤)"); + + // 구 4열 표(strategist 열 없음)도 그대로 읽히고 책사 = 0 + var old = (string[])texts.Clone(); + old[0] = RebuildStages(texts[0], "strategist", -1, null, null); + var t2 = StageTable.Parse(old); + Assert.IsEmpty(t2.errors, "strategist 열이 없어도 오류 없음"); + Assert.AreEqual(t.stages.Count, t2.stages.Count); + for (int i = 0; i < t2.stages.Count; i++) + { + Assert.AreEqual(0, t2.stages[i].soldiers[(int)UnitType.Strategist], "열이 없으면 책사 0"); + for (int k = 0; k < UnitTypeInfo.Count; k++) Assert.AreEqual(t.stages[i].soldiers[k], t2.stages[i].soldiers[k]); + } + + // 책사 수를 넣으면 중군에 배치 · 아트 id = <세력>_strategist + var mod = (string[])texts.Clone(); + mod[0] = RebuildStages(texts[0], null, 0, "strategist", "2"); + var t3 = StageTable.Parse(mod); + Assert.IsEmpty(t3.errors); + var row = t3.stages[0]; + Assert.AreEqual(2, row.soldiers[(int)UnitType.Strategist]); + var c = Resources.Load("BalanceConfig"); + var enemies = StageTable.Enemies(c, row, t3); + var strategists = enemies.FindAll(e => e.type == UnitType.Strategist); + Assert.AreEqual(2, strategists.Count, "책사 2 배치"); + foreach (var e in strategists) + { + Assert.AreEqual(t3.factions[row.faction].art + "_strategist", e.art, "아트 id <세력>_strategist(그림 없으면 도형)"); + Assert.AreEqual(c.formation.middleY, e.position.y, 0.01f, "책사 = 중군 줄"); + } + } + + [UnityTest] + public IEnumerator EnemyAI_Ranged_PrefersClearLine_StrategistAvoidsOwnSideInSplash() + { + yield return Fresh(); + // 적 궁병: 가까운 아군 A 의 사선을 적 보병이 막음 → 사선이 빈 아군 B 를 노린다 + var ea = bm.SpawnUnit(UnitType.Archer, Faction.Enemy, new Vector2(0f, 2f), "적궁병"); + var blocker = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, 0.5f), "적보병"); + var a = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -1f), "아군A"); + var b = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(3f, 0f), "아군B"); + var players = bm.AliveUnits(Faction.Player); + var aimOld = EnemyAI.Decide(ea, players, 1f, 11f, bm.Rng, bm.Board); + Assert.Less(Vector2.Distance(aimOld.Value, a.Position), 0.05f, "cfg 없이 = 가장 가까운 대상(기존)"); + var aim = EnemyAI.Decide(ea, players, 1f, 11f, bm.Rng, bm.Board, cfg); + Assert.Less(Vector2.Distance(aim.Value, b.Position), 0.05f, "사선이 빈 대상 우선"); + Assert.IsTrue(blocker.IsAlive); + + // 적 책사: A 는 사선이 비지만 광역에 적(자기 편)이 섞임 → 광역에 자기 편이 없는 B 를 노린다 + yield return Fresh(); + var es = bm.SpawnUnit(UnitType.Strategist, Faction.Enemy, new Vector2(0f, 2f), "적책사"); + var a2 = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -1f), "아군A"); + var own = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, a2.Position + new Vector2(cfg.strategistSplashRadius * 0.8f, 0f), "적보병"); + var b2 = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(-3f, 0f), "아군B"); + var aim2 = EnemyAI.Decide(es, bm.AliveUnits(Faction.Player), 1f, 11f, bm.Rng, bm.Board, cfg); + Assert.Less(Vector2.Distance(aim2.Value, b2.Position), 0.05f, "광역에 자기 편이 없는 대상 우선"); + Assert.IsTrue(own.IsAlive); + } + + [UnityTest] + public IEnumerator AimPreview_ArcherShotLine_AllyRed_StrategistSplashCircle() + { + yield return Fresh(); + var sling = bm.Sling; + var arc = bm.SpawnUnit(UnitType.Archer, Faction.Player, new Vector2(0f, -5f), "궁병"); + var ally = bm.SpawnUnit(UnitType.Infantry, Faction.Player, new Vector2(0f, -3.5f), "아군보병"); + var foe = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(0f, -1.5f), "적보병"); + yield return null; + int foes = sling.ShowPreview(arc, foe.Position, null); + Assert.IsTrue(sling.ShotLineVisible, "화살 경로선"); + Assert.IsTrue(ally.View.Highlighted && ally.View.HighlightFriendly, "첫 피격 = 아군 → 붉게"); + Assert.IsFalse(foe.View.Highlighted, "가려진 타겟은 공격 예정 아님"); + Assert.AreEqual(0, foes); + Assert.IsFalse(sling.SplashVisible, "궁병은 광역 원 없음"); + sling.HidePreview(); + Assert.IsFalse(sling.ShotLineVisible); + Assert.IsFalse(ally.View.Highlighted); + + float R = cfg.strategistSplashRadius; + var st = bm.SpawnUnit(UnitType.Strategist, Faction.Player, new Vector2(4f, -5f), "책사"); + var foe2 = bm.SpawnUnit(UnitType.Infantry, Faction.Enemy, new Vector2(4f, -2f), "적보병2"); + var ally2 = bm.SpawnUnit(UnitType.Infantry, Faction.Player, foe2.Position + new Vector2(-R * 0.8f, 0f), "아군보병2"); + yield return null; + foes = sling.ShowPreview(st, foe2.Position, null); + Assert.IsTrue(sling.SplashVisible, "책사 = 명중 지점 광역 원"); + Assert.AreEqual(R * 2f, sling.SplashDiameter, 0.01f); + Assert.Less(Vector2.Distance(foe2.Position, sling.SplashCenter), 0.01f, "광역 중심 = 첫 피격 유닛"); + Assert.IsTrue(foe2.View.Highlighted && !foe2.View.HighlightFriendly); + Assert.IsTrue(ally2.View.Highlighted && ally2.View.HighlightFriendly, "광역에 휩쓸릴 아군 붉게"); + Assert.AreEqual(1, foes); + sling.HidePreview(); + Assert.IsFalse(sling.SplashVisible); + } + [UnityTest] public IEnumerator Archer_MeleePenalty_DealsRatio_TakesRatio_RangedUnaffected() {