책사 병종 + 궁병 화살 투사체(PD #1087 ⑤⑥) — 궁병 사격 = 타겟 방향 화살(arrowRadius 0.1 원 캐스트 · 비행 = 사거리 · 경로 위 첫 유닛 피격 · 아군 포함 · 관통 없음 · 첫 피격 = 타겟 + 목표점 명중 시 크리 · 아군 피격 = 같은 공식 · 반격·명중 보상 없음 · 타겟 없으면 쏘지 않음 유지) · UnitType.Strategist(책 · 65/24/3 · 슬링 4.0 · 반지름 0.42 · 상성 5×5 = 궁병과 같은 값 · 근접 패널티 동일) = ArcherRoutine 재사용 + 중심(첫 피격) 반경 strategistSplashRadius 1.5 안 다른 유닛 전원 × strategistSplashRatio 0.6(크리 없음 · 상성 대상별) · 같은 편 × strategistAllyDamageRatio 1.0 · 쏜 책사 제외 · 조준 UI = 화살 경로선 + 첫 피격 강조(아군 붉게) + 책사 광역 원·휩쓸릴 아군 붉게 · 적 AI = 사선이 빈 대상 우선 · 책사는 광역에 자기 편 없는 대상 먼저 · 없으면 그냥 쏨 · 연출 = 투사체 1종(책사 보라) + 광역 터짐 · BalanceConfig 4필드 + BalanceDefaults 시드 · 재시드(추가만) · stages.csv strategist 열(전부 0 · 비거나 없으면 0) + 중군 배치(궁 다음) · 테스트 40/40(신규 9) · console error 0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2d0fc4fab6
commit
6992d992df
|
|
@ -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,좁은 산길,
|
||||
|
|
|
|||
|
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Unit> Attacked = new List<Unit>();
|
||||
/// <summary>궁병·책사 사격에 맞은 같은 편(오사 · 책사 광역 포함 · PD 2026-11-05 #1087 ⑤⑥).</summary>
|
||||
public List<Unit> FriendlyHit = new List<Unit>();
|
||||
public int FriendlyDamage;
|
||||
/// <summary>충돌로 밀려난 유닛(아군·적군 · PD 2026-11-04 #1080).</summary>
|
||||
public List<Unit> Pushed = new List<Unit>();
|
||||
/// <summary>밀린 아군이 적과 닿아 얻은 공격 등 부가 설명(HUD 문구).</summary>
|
||||
|
|
@ -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회(조작마다 · 자기 행동은 소모하지 않음)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>조작 중 표시용: 궁병·책사의 사격 계획(화살 경로선 · 첫 피격 유닛 · 책사 광역). 원거리 병종이 아니거나 이동만이면 null.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------- 공통 질의 ----------
|
||||
|
||||
/// <summary>경로 위 첫 유닛. 벽이 그보다 먼저 있으면 null.</summary>
|
||||
|
|
@ -369,35 +393,127 @@ namespace TKS
|
|||
return origin;
|
||||
}
|
||||
|
||||
// ---------- 궁병 ----------
|
||||
// ---------- 궁병 · 책사: 투사체 사격 (PD 2026-11-05 #1087 ⑤⑥) ----------
|
||||
|
||||
/// <summary>궁병·책사 사격 계획 — 조준 미리보기(PreviewShot)와 실제 행동(ArcherRoutine)이 같은 계산을 쓴다.</summary>
|
||||
public class ShotPlan
|
||||
{
|
||||
/// <summary>복도 탐색으로 고른 적(조준 방향 사거리 + 접근 허용 안의 첫 적) · null = 쏘지 않고 목표점까지 이동.</summary>
|
||||
public Unit Target;
|
||||
/// <summary>사격 전에 다가가는 거리(사거리 안 = 0).</summary>
|
||||
public float Approach;
|
||||
/// <summary>사거리에 들어와 쏜다(false = 아군·벽에 막혀 사거리에 못 들어감 → 이동만).</summary>
|
||||
public bool Fires;
|
||||
public Vector2 From;
|
||||
/// <summary>투사체가 멈춘 지점(첫 피격 유닛에 닿은 곳 · 빗나가면 비행 끝).</summary>
|
||||
public Vector2 To;
|
||||
/// <summary>투사체가 처음 맞히는 유닛(아군 포함 · 관통 없음) = 책사 광역의 중심.</summary>
|
||||
public Unit Hit;
|
||||
/// <summary>첫 피격 = 타겟이고 최종 목표점 명중 → 크리티컬.</summary>
|
||||
public bool Crit;
|
||||
/// <summary>책사: 중심 주변 광역 대상(아군 포함 · 중심·쏜 책사 제외 · 가까운 순).</summary>
|
||||
public readonly List<Unit> Splash = new List<Unit>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 투사체 1발: 첫 피격 유닛에 사격 피해(아군이면 오사 · 상성 적용 · 반격 없음 · 명중 보상 없음 · 밀지 않으므로 규칙 ⑤ 없음)
|
||||
/// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>사격 피해 적용 + 집계(상대 = Attacked · 같은 편 = FriendlyHit 오사).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>공격 1회. byActor = 행동 유닛의 공격(결과 요약에 집계) · false = 밀린 아군의 공격(Notes 에 기록).</summary>
|
||||
IEnumerator Attack(Unit a, Unit d, bool ranged, bool crit, ActionOutcome o, bool byActor)
|
||||
/// <summary>밀린 아군의 근접 공격 1회(반격 받음 · 결과 요약에는 집계하지 않고 Notes 에 기록). 원거리 병종도 근접 패널티를 받는다.</summary>
|
||||
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 : ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("보병 알까기: 부딪힌 유닛(아군·적군)이 밀려나는 거리 = 충돌 시 남은 슬링샷 거리 × 비율 · 창병은 비율 없이 남은 거리만큼 밀며 함께 전진")]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ namespace TKS
|
|||
return Phase == BattlePhase.PlayerTurn && u != null && u.Faction == Faction.Player && u.IsAlive && !u.ActedThisTurn;
|
||||
}
|
||||
|
||||
/// <summary>궁병 공격 가능 범위(사거리 + 접근 허용 거리 · 가장자리 간격 기준) 안에 상대 유닛이 하나라도 있는가 — 도넛 색 표시용.</summary>
|
||||
/// <summary>궁병·책사 공격 가능 범위(사거리 + 접근 허용 거리 · 가장자리 간격 기준) 안에 상대 유닛이 하나라도 있는가 — 도넛 색 표시용.</summary>
|
||||
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<string>();
|
||||
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 += " · 제자리 복귀";
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ namespace TKS
|
|||
|
||||
/// <summary>
|
||||
/// 피해 = (공격 × 상성 × 크리 − 방어 × defenseFactor) × 궁병 근접 배율 × 난수.
|
||||
/// melee = 근접 전투(돌진·반격·관통·견제·밀려 닿은 공격). 궁병이 근접 유닛(보병·기병·창병)과 근접 전투하면
|
||||
/// 주는 피해 × archerMeleeDealtRatio · 받는 피해 × archerMeleeTakenRatio (PD 2026-11-04 #1080) · 원거리 사격은 패널티 없음.
|
||||
/// melee = 근접 전투(돌진·반격·관통·견제·밀려 닿은 공격). 원거리 병종(궁병·책사)이 근접 유닛(보병·기병·창병)과 근접 전투하면
|
||||
/// 주는 피해 × archerMeleeDealtRatio · 받는 피해 × archerMeleeTakenRatio (PD 2026-11-04 #1080 · 책사 #1087 ⑤ 동일) · 원거리 사격은 패널티 없음.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>책사 광역 피해(PD 2026-11-05 #1087 ⑤) = 대상별 사격 피해(상성 적용 · 크리 없음) × ratio · ratio ≤ 0 이면 0.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외).
|
||||
/// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist).
|
||||
/// </summary>
|
||||
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<BoardWall>() != 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;
|
||||
}
|
||||
|
||||
/// <summary>점 center 반경 radius 원에 몸(원)이 닿는 살아 있는 유닛(가까운 순 · 진영 불문 · exclude 둘 제외) — 책사 광역(보스는 반지름이 커서 더 잘 닿는다).</summary>
|
||||
public static List<Unit> UnitsInCircle(Vector2 center, float radius, Unit excludeA, Unit excludeB)
|
||||
{
|
||||
Physics2D.SyncTransforms();
|
||||
var list = new List<Unit>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>u 와 가장자리가 닿아 있는(ContactTolerance 이내) 가장 가까운 상대 진영 유닛.</summary>
|
||||
public static Unit TouchingOpponent(Unit u)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ using UnityEngine;
|
|||
|
||||
namespace TKS
|
||||
{
|
||||
/// <summary>적군 슬링샷 결정: 교전 범위 안의 가장 가까운 아군을 향해 당기고, 정확도에 따라 흔들린다. 범위 밖이면 대기(null).</summary>
|
||||
/// <summary>
|
||||
/// 적군 슬링샷 결정: 교전 범위 안의 가장 가까운 아군을 향해 당기고, 정확도에 따라 흔들린다. 범위 밖이면 대기(null).
|
||||
/// 궁병·책사(PD 2026-11-05 #1087 ⑤⑥ · cfg 를 넘기면): 공격 가능 범위 안에서 사선이 빈 대상 우선 · 책사는 맞을 자리 광역에 자기 편이 없는 대상을 그보다 먼저 · 없으면 가장 가까운 대상(그냥 쏜다).
|
||||
/// </summary>
|
||||
public static class EnemyAI
|
||||
{
|
||||
public static Vector2? Decide(Unit e, IReadOnlyList<Unit> targets, float accuracy, float aggroRange, System.Random rng, Board board)
|
||||
public static Vector2? Decide(Unit e, IReadOnlyList<Unit> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 원거리 대상 고르기: 공격 가능 범위(사거리 + 접근 허용 · 가장자리 간격) 안의 대상만 본다.
|
||||
/// 등급 0 = 사선이 빔(화살이 처음 맞힐 유닛 = 그 대상) + (책사) 맞을 자리 광역에 자기 편 없음 · 1 = 사선만 빔(책사) · 2 = 막힘 → 등급 낮은 순 · 같으면 가까운 순.
|
||||
/// 범위 안에 아무도 없으면 가장 가까운 대상(접근).
|
||||
/// </summary>
|
||||
static Unit PickRangedTarget(Unit e, IReadOnlyList<Unit> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
/// <summary>화살(가늘고 긴 사각형) 날리기.</summary>
|
||||
public static IEnumerator Arrow(Vector2 from, Vector2 to)
|
||||
{
|
||||
return Arrow(from, to, ArrowColor);
|
||||
}
|
||||
|
||||
/// <summary>투사체(가늘고 긴 사각형) 날리기 — 궁병 화살 · 책사 계략(색만 다름).</summary>
|
||||
public static IEnumerator Arrow(Vector2 from, Vector2 to, Color color)
|
||||
{
|
||||
var go = new GameObject("Arrow");
|
||||
var sr = go.AddComponent<SpriteRenderer>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>광역 터짐(반지름 radius 원이 짧게 커지며 사라짐 · 책사 광역).</summary>
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ namespace TKS
|
|||
/// <summary>
|
||||
/// 선택(터치)한 유닛의 사거리 표시 (PD 2026-11-04).
|
||||
/// · 모든 병종: 슬링샷 최대 거리 = 얇은 반투명 원 (아군·적군 모두)
|
||||
/// · 궁병: 추가로 슬링샷 범위 ~ 슬링샷 범위 × (1 + archerRangeRatio) 까지를 채워진 반투명 붉은 도넛으로 (실제 공격 가능 범위)
|
||||
/// · 궁병·책사: 추가로 슬링샷 범위 ~ 슬링샷 범위 × (1 + archerRangeRatio) 까지를 채워진 반투명 붉은 도넛으로 (실제 공격 가능 범위)
|
||||
/// — 그 범위 안에 적이 하나도 없으면 회색.
|
||||
/// </summary>
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<Unit> highlighted = new List<Unit>();
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>책사 광역 원의 지름(월드) = strategistSplashRadius × 2 · 중심 = SplashCenter.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 궁병·책사 조준(PD 2026-11-05 #1087 ⑤⑥): 화살 경로선(쏘는 위치 → 첫 피격 지점) + 첫 피격 유닛 강조(같은 편이면 붉게)
|
||||
/// · 책사 = 맞을 자리 광역 원 + 휩쓸릴 유닛 강조(같은 편 붉게). 반환 = 맞을 상대 유닛 수.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ namespace TKS
|
|||
{
|
||||
/// <summary>
|
||||
/// 스테이지 표(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)와 데이터 검증 테스트가 같이 쓴다.
|
||||
/// </summary>
|
||||
|
|
@ -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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 병사 → 후군 경비(창·기·보 우선 · 총수 × rearShare 내림) · 전위(보·창·기 우선 · 총수 × frontShare 올림) · 나머지 = 중군(궁 가운데 → 보·창 → 기병 양 끝).
|
||||
/// 병사 → 후군 경비(창·기·보 우선 · 총수 × rearShare 내림) · 전위(보·창·기 우선 · 총수 × frontShare 올림) · 나머지 = 중군(궁 가운데 → 책사 → 보·창 → 기병 양 끝).
|
||||
/// 대마다 maxPerRow 넘으면 고르게 여러 줄(적 쪽으로 rowGap · 줄마다 엇갈림). 장수 = commandY 줄(보스 → 엘리트 순 가운데부터).
|
||||
/// </summary>
|
||||
public static List<BalanceConfig.EnemySpawn> 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<BalanceConfig.EnemySpawn>();
|
||||
foreach (var band in new[] { new KeyValuePair<float, List<UnitType>>(F.frontY, front), new KeyValuePair<float, List<UnitType>>(F.middleY, middle), new KeyValuePair<float, List<UnitType>>(F.rearY, rear) })
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ namespace TKS
|
|||
public int Def;
|
||||
public float Radius;
|
||||
public float SlingMaxDistance;
|
||||
/// <summary>궁병 = 사거리 = 슬링샷 최대 거리(적 가장자리까지의 간격 기준 · 제자리 사격) · 창병 = 대기(견제) 반경.</summary>
|
||||
/// <summary>궁병·책사 = 사거리 = 슬링샷 최대 거리(적 가장자리까지의 간격 기준 · 제자리 사격) · 창병 = 대기(견제) 반경.</summary>
|
||||
public float AttackRange;
|
||||
/// <summary>궁병이 사거리 밖(~ 사거리 × (1 + archerRangeRatio))의 적에게 다가갈 수 있는 최대 거리. 다른 병종 = 0.</summary>
|
||||
/// <summary>궁병·책사가 사거리 밖(~ 사거리 × (1 + archerRangeRatio))의 적에게 다가갈 수 있는 최대 거리. 다른 병종 = 0.</summary>
|
||||
public float ArcherMoveAllow;
|
||||
/// <summary>Resources/Units/<ArtId>/ 아트 폴더 · 비면 도형.</summary>
|
||||
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<CircleCollider2D>();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
namespace TKS
|
||||
{
|
||||
/// <summary>병종. 보병 > 창병 > 기병 > 보병 상성, 궁병은 모두에게 약하나 반격을 받지 않는다.</summary>
|
||||
/// <summary>병종. 보병 > 창병 > 기병 > 보병 상성, 원거리(궁병·책사)는 모두에게 약하나 반격을 받지 않는다.</summary>
|
||||
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)
|
||||
/// <summary>원거리 병종(궁병·책사) — 투사체 사격 · 반격 없음 · 근접 전투 패널티.</summary>
|
||||
public static bool IsRanged(UnitType t)
|
||||
{
|
||||
return t != UnitType.Archer;
|
||||
return t == UnitType.Archer || t == UnitType.Strategist;
|
||||
}
|
||||
|
||||
/// <summary>데이터·아트 id 용 영문 키(infantry·cavalry·archer·spear) · 적 병사 아트 = <세력>_<키>.</summary>
|
||||
public static bool IsMelee(UnitType t)
|
||||
{
|
||||
return !IsRanged(t);
|
||||
}
|
||||
|
||||
/// <summary>데이터·아트 id 용 영문 키(infantry·cavalry·archer·spear·strategist) · 적 병사 아트 = <세력>_<키>.</summary>
|
||||
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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>공격 예정 표시 링 · friendly = 궁병·책사 사격에 맞을 같은 편(붉게 · PD 2026-11-05 #1087 ⑤⑥).</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>오사 경고(붉은 링) 중인가.</summary>
|
||||
public bool HighlightFriendly
|
||||
{
|
||||
get { return highlightFriendly; }
|
||||
}
|
||||
|
||||
/// <summary>조준(당기는) 중 = 출발 자세(ready · 1프레임) · 끝나면 대기 (PD 2026-11-05 #1084).</summary>
|
||||
|
|
|
|||
|
|
@ -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 ⑤⑥) ----------
|
||||
|
||||
/// <summary>사격 피해가 공식 범위 안인지(공 × 상성 × 크리 − 방 × 계수 · 난수 ± damageVariance · 그 뒤 × ratio · 반올림 여유 1).</summary>
|
||||
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>("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("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
/// <summary>stages.csv 를 다시 쓴다(dropKey 열 빼기 · setRow 행의 setKey 칸 바꾸기 · 모든 칸 따옴표).</summary>
|
||||
static string RebuildStages(string csv, string dropKey, int setRow, string setKey, string setValue)
|
||||
{
|
||||
var errs = new List<string>();
|
||||
var rows = StageTable.ReadCsv(csv, "stages.csv", errs);
|
||||
Assert.IsEmpty(errs);
|
||||
var head = new List<string>(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<string>();
|
||||
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>("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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue