책사 병종 + 궁병 화살 투사체(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:
깃 관리자 2026-11-05 13:06:32 +09:00
parent 2d0fc4fab6
commit 6992d992df
18 changed files with 902 additions and 230 deletions

View File

@ -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,좁은 산길,

1 chapter stage title type place faction infantry spear cavalry archer strategist generals bg_theme note
2 1 1 탁현 외곽 — 황건 척후 normal 탁현 hgj 3 0 0 0 0 마을 어귀 튜토리얼(조준·이동)
3 1 2 누상촌 약탈대 normal 누상촌 hgj 3 1 0 0 0 마을 튜토리얼(기병 관통)
4 1 3 대흥산 길목 normal 대흥산 hgj 3 2 0 0 0 산길 튜토리얼(창병 견제)
5 1 4 대흥산 — 등무 elite 대흥산 hgj 3 2 0 1 0 hgj_boss_dengmao:spear:false 산기슭
6 1 5 대흥산 전투 — 정원지 boss 대흥산 hgj 4 2 0 1 0 hgj_boss_chengyuanzhi:infantry:true 산기슭 평지
7 1 6 청주 포위망 normal 청주 hgj 4 2 0 2 0 성 밖 들판
8 1 7 청주 산골 매복 normal 청주 hgj 3 2 1 2 0 협곡 일반(적 기병 첫 등장)
9 1 8 영천 — 불길 속 황건 elite 영천 hgj 3 2 1 2 0 hgj_boss_zhangbao:archer:false 불탄 들판
10 1 9 광종 — 황건 본진 전위 elite 광종 hgj 4 2 1 2 0 hgj_boss_zhangliang:infantry:false 진영 앞 평원
11 1 10 광종 결전 — 장각 boss 광종 hgj 4 2 1 2 0 hgj_boss_zhangjiao:infantry:true 황건 본진
12 2 1 산조 진영 — 동탁군 척후 normal 산조 dz 3 0 2 1 0 군영
13 2 2 사수관 가는 길 normal 사수관 동쪽 dz 3 2 1 1 0 산길
14 2 3 사수관 앞 — 호진 elite 사수관 dz 3 2 1 2 0 dz_boss_huzhen:cavalry:false 관문 앞 들판
15 2 4 손견 진영 야습 normal 사수관 dz 3 2 2 1 0 밤 군영
16 2 5 사수관 — 화웅 boss 사수관 dz 4 2 1 2 0 dz_boss_huaxiong:infantry:true 관문 앞
17 2 6 사수관 함락 — 잔병 normal 사수관 dz 3 3 1 2 0 관문 안쪽
18 2 7 호로관 가는 길 — 서량 기병 normal 호로관 동쪽 dz 2 2 3 2 0 들판
19 2 8 호로관 앞 — 방열이 쓰러진 자리 normal 호로관 dz 2 2 4 1 0 관문 앞
20 2 9 호로관 측면 — 적토마 돌격대 normal 호로관 dz 2 3 4 1 0 관문 측면
21 2 10 호로관 — 여포 boss 호로관 dz 3 2 3 2 0 dz_boss_lvbu:cavalry:true 관문 앞
22 3 1 평원 출진 — 원소군 척후 normal 평원 ys 3 2 1 2 0 들판
23 3 2 반하 들판 normal 반하 ys 3 2 2 1 0 강변 들판
24 3 3 반하 다리 — 문추의 추격 elite 반하 ys 2 2 3 1 0 ys_boss_wenchou:cavalry:false 다리·강
25 3 4 반하 — 엄강의 패전 normal 반하 ys 2 2 0 5 0 강변
26 3 5 반하 — 국의 boss 반하 ys 2 2 0 4 0 ys_boss_quyi:archer:true 강변 진영
27 3 6 북해 가는 길 — 황건 잔당 normal 북해 서쪽 hgj 4 2 0 2 0 들판
28 3 7 북해 포위망 바깥 normal 북해 hgj 4 2 1 2 0 성 밖
29 3 8 태사자의 돌파로 normal 북해 hgj 4 3 1 2 0 성 밖
30 3 9 북해 성문 — 관해 친위 normal 북해 hgj 5 2 1 2 0 성문
31 3 10 북해 — 관해 boss 북해 hgj 4 2 1 2 0 hgj_boss_guanhai:infantry:true 성문 앞
32 3 11 서주 가는 길 — 불타는 마을 normal 서주 북쪽 cc 3 2 2 2 0 불탄 마을
33 3 12 서주 외곽 — 조조 포위진 normal 서주 cc 3 3 2 2 0 들판
34 3 13 서주 남쪽 — 피난민 행렬 normal 서주 cc 3 3 3 2 0 들판
35 3 14 조조 진영 외곽 normal 서주 cc 3 3 2 3 0 군영
36 3 15 서주 — 우금 boss 서주 cc 3 3 2 2 0 cc_boss_yujin:infantry:true 군영 앞
37 4 1 우이 들판 — 원술군 선봉 normal 우이 ysu 3 3 1 2 0 들판
38 4 2 회음 강변 — 군량 수송대 normal 회음 ysu 4 2 0 2 0 강변
39 4 3 우이 — 순정 elite 우이 ysu 3 3 1 2 0 ysu_boss_xunzheng:spear:false 들판
40 4 4 우이 진영 야습 normal 우이 ysu 3 3 2 2 0 밤 군영
41 4 5 우이 — 기령 boss 우이 ysu 3 3 2 2 0 ysu_boss_jiling:spear:true 강변 들판
42 4 6 서주성의 밤 — 조표의 배신 normal 서주성 lb 2 2 4 2 0 밤 성안
43 4 7 소패 — 빼앗긴 말 normal 소패 lb 3 2 3 2 0 성 밖
44 4 8 소패 탈출 — 여포군 추격 normal 소패 서쪽 lb 2 2 4 2 0 들판
45 4 9 소패 구원 — 조성 elite 소패 lb 3 2 2 3 0 dz_boss_caoxing:archer:false 성 밖
46 4 10 소패 — 고순 boss 소패 lb 5 2 1 2 0 dz_boss_gaoshun:infantry:true 성문 앞
47 4 11 서주성 — 진등의 계책 normal 서주성 lb 3 3 2 2 0 성 밖
48 4 12 하비 외곽 — 진궁 elite 하비 lb 3 2 2 3 0 dz_boss_chengong:archer:false 들판
49 4 13 하비 수공 normal 하비 lb 3 3 2 3 0 물에 잠긴 성
50 4 14 하비 성문 — 장료 elite 하비 lb 3 2 3 2 0 dz_boss_zhangliao:cavalry:false 성문
51 4 15 하비 — 여포 boss 하비(백문루) lb 3 2 3 3 0 dz_boss_lvbu:cavalry:true 성안 누각 앞
52 5 1 허도 근교 — 사냥터의 도적 normal 허도 sj 3 2 2 2 0 숲 세력 추정(기획서 미기재 · 도적 = 7장 산적 선례)
53 5 2 허도 출진 — 원술 차단 normal 허도 동쪽 ysu 3 2 2 2 0 들판 세력 추정(기획서 미기재 · 원술 차단 출진)
54 5 3 서주 남쪽 — 원술군 선봉 normal 서주 남쪽 ysu 3 3 2 2 0 들판
55 5 4 원술 친위대 normal 서주 남쪽 ysu 3 3 2 3 0 들판
56 5 5 서주 남쪽 — 기령 boss 서주 남쪽 ysu 3 3 2 2 0 ysu_boss_jiling:spear:true 들판
57 5 6 원술의 몰락 — 패잔병 normal 수춘 북쪽 ysu 4 3 0 2 0 황무지
58 5 7 서주성 앞 — 차주의 복병 normal 서주성 cc 3 3 2 2 0 성문 밖 밤
59 5 8 서주성 — 차주 elite 서주성 cc 3 3 2 2 0 cc_boss_chezhou:infantry:false 성문
60 5 9 서주 외곽 — 조조 토벌군 normal 서주 cc 3 3 2 3 0 들판
61 5 10 서주 — 유대·왕충 boss 서주 cc 3 3 2 2 0 cc_boss_liudai:spear:true;cc_boss_wangzhong:infantry:true 들판 진영 보스(2인)
62 5 11 소패 야습 normal 소패 cc 3 3 3 2 0 밤 군영
63 5 12 소패 패주로 normal 소패 북쪽 cc 2 3 4 2 0 들판
64 5 13 하비 — 고립된 관우 normal 하비 cc 3 3 3 3 0 성 밖
65 5 14 하비 성 밖 — 하후돈의 유인 elite 하비 cc 3 3 3 2 0 cc_boss_xiahoudun:cavalry:false 성 밖 들판
66 5 15 토산 — 서황·허저 boss 토산 cc 3 3 2 3 0 cc_boss_xuhuang:infantry:true;cc_boss_xuchu:infantry:true 산 보스(2인)
67 6 1 백마 포위망 normal 백마 ys 3 3 2 2 0 성 밖 들판
68 6 2 백마 — 쓰러진 송헌·위속 normal 백마 ys 3 3 2 2 0 들판
69 6 3 백마 들판 — 안량 본진 앞 normal 백마 ys 3 3 3 2 0 들판 진영
70 6 4 백마 — 측면 기병 normal 백마 ys 2 3 4 2 0 들판
71 6 5 백마 — 안량 boss 백마 ys 3 3 3 2 0 ys_boss_yanliang:spear:true 진영 깃발 아래
72 6 6 연진 — 원소군 도하 normal 연진 ys 3 3 3 2 0 강변 나루
73 6 7 연진 — 버려진 군량과 말 normal 연진 ys 4 3 3 1 0 들판
74 6 8 연진 — 장료·서황의 패퇴 normal 연진 ys 2 3 4 2 0 들판
75 6 9 연진 강변 — 문추 친위 normal 연진 ys 3 3 4 2 0 강변
76 6 10 연진 — 문추 boss 연진 ys 3 3 4 2 0 ys_boss_wenchou:cavalry:true 강변 들판
77 6 11 동령관 — 공수 elite 동령관 cc 4 3 2 2 0 cc_boss_kongxiu:infantry:false 관문
78 6 12 낙양 외곽 — 맹탄 elite 낙양 cc 3 3 3 2 0 cc_boss_mengtan:cavalry:false 성 밖
79 6 13 낙양 성문 — 한복 elite 낙양 cc 3 3 2 4 0 cc_boss_hanfu:archer:false 성문
80 6 14 사수관 — 진국사 매복 normal 사수관(진국사) cc 5 3 1 2 0 절간
81 6 15 사수관 — 변희 boss 사수관 cc 4 3 2 2 0 cc_boss_bianxi:infantry:true 절 마당
82 6 16 형양 — 왕식의 연회 normal 형양 cc 4 3 2 3 0 밤 성안
83 6 17 형양 — 불붙은 객관 normal 형양 cc 3 3 2 4 0 불타는 성안
84 6 18 형양 성문 — 왕식 elite 형양 cc 3 3 3 3 0 cc_boss_wangzhi:archer:false 성문
85 6 19 활주 나루 — 진기 elite 활주 cc 3 4 2 3 0 cc_boss_qinqi:spear:false 나루
86 6 20 황하 나루 — 하후돈 boss 활주 cc 3 3 4 2 0 cc_boss_xiahoudun:cavalry:true 강변 나루
87 7 1 황하 남쪽 — 순찰대 normal 고성 북쪽 cc 3 3 3 2 0 들판
88 7 2 와우산 — 주창의 산채 normal 와우산 sj 5 3 0 2 0 산채
89 7 3 고성 — 의심하는 장비 normal 고성 cc 3 3 3 2 0 성 밖
90 7 4 고성 외곽 — 채양 본대 normal 고성 cc 3 3 4 2 0 들판
91 7 5 고성 — 채양 boss 고성 cc 3 3 3 3 0 cc_boss_caiyang:cavalry:true 성 밖 들판
92 7 6 하북 경계 — 유비의 탈출 normal 관정장 북쪽 ys 3 3 3 3 0 들판
93 7 7 관정장 — 관평 normal 관정장 ys 3 3 4 2 0 마을
94 7 8 와우산 — 배원소 elite 와우산 sj 4 3 2 2 0 hgj_boss_peiyuanshao:infantry:false 산채
95 7 9 양산 — 조조의 추격 elite 양산 cc 3 3 3 3 0 cc_boss_xuchu:infantry:false;cc_boss_zhanghe:spear:false 산골
96 7 10 양산 — 고람 boss 양산 cc 3 4 3 3 0 cc_boss_gaolan:spear:true 산길
97 8 1 형주 가는 길 — 추격 잔당 normal 완 남쪽 cc 3 3 3 2 0 들판
98 8 2 강하 — 반란군 선봉 normal 강하 jzr 4 3 2 2 0 강변
99 8 3 강하 산채 — 반란군 궁병 normal 강하 jzr 3 2 2 4 0 산채
100 8 4 강하 — 진손 elite 강하 jzr 3 3 3 2 0 jz_boss_chensun:spear:false 들판
101 8 5 강하 — 장무 boss 강하 jzr 3 3 3 3 0 jz_boss_zhangwu:cavalry:true 강변 들판
102 8 6 양양 연회 — 채모의 복병 elite 양양 jz 4 3 2 3 0 jz_boss_caimao:archer:false 밤 성안
103 8 7 단계 — 적로의 도약 normal 단계 jz 3 3 4 2 0 개울가
104 8 8 신야 — 여광 elite 신야 cc 3 3 3 2 0 cc_boss_lvkuang:cavalry:false 들판
105 8 9 신야 — 여상 elite 신야 cc 3 3 3 2 0 cc_boss_lvxiang:cavalry:false 들판
106 8 10 신야 — 조인 · 팔문금쇄진 boss 신야 cc 4 3 3 2 0 cc_boss_caoren:infantry:true 진형(바닥 문양)
107 8 11 번성 — 빈 성 normal 번성 cc 3 3 3 3 0 성문
108 8 12 번성 방위 — 조조군 반격 normal 번성 cc 3 4 3 3 0 성벽
109 8 13 융중 가는 길 — 눈보라 속 산적 normal 융중 cc 4 3 2 3 0 눈 덮인 산길
110 8 14 박망파 — 하후돈 선봉 normal 박망파 cc 3 3 4 2 0 갈대숲
111 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장 인물표)
112 9 1 신야 화공 — 조홍 elite 신야 cc 3 3 3 3 0 cc_boss_caohong:infantry:false 불타는 성안
113 9 2 백하 — 관우의 수공 normal 백하 cc 4 3 3 2 0 강변
114 9 3 박릉 나루 — 장비 매복 normal 박릉 나루 cc 3 3 3 3 0 나루
115 9 4 번성 철수 — 백성 행렬 normal 번성 cc 3 3 4 2 0 강 건너 들판
116 9 5 양양 성문 — 문빙 boss 양양 jz 3 4 2 3 0 jz_boss_wenpin:spear:true 성문
117 9 6 당양 — 호표기의 추격 normal 당양 cc 2 3 6 2 0 들판
118 9 7 장판 — 순우도 elite 장판 cc 3 3 4 2 0 cc_boss_chunyudao:spear:false 들판
119 9 8 장판 — 하후은 elite 장판 cc 3 3 4 2 0 cc_boss_xiahouen:cavalry:false 들판
120 9 9 장판 — 안명과 하북 4장 elite 장판 cc 2 3 4 2 0 cc_boss_yanming:spear:false 들판 미배치 = 하북 4장(병사 아트 + 이름표) · 병종 미정(PD 결정 대기)
121 9 10 장판파 — 장합 boss 장판 cc 3 4 4 2 0 cc_boss_zhanghe:spear:true 들판·언덕
122 9 11 장판교 — 장비의 일갈 elite 장판교 cc 3 3 4 3 0 cc_boss_xiahoujie:cavalry:false 다리
123 9 12 부서진 장판교 normal 장판교 cc 3 3 5 2 0 강변
124 9 13 한진 가는 길 — 조조 철기 normal 한진 서쪽 cc 2 3 5 2 0 들판
125 9 14 한진 — 관우·유기의 수군 normal 한진 cc 3 3 4 3 0 나루
126 9 15 한진 나루 — 조조 boss 한진 cc 3 3 4 3 0 cc_boss_caocao:infantry:true 나루 언덕
127 10 1 하구 — 조조군 정찰선 normal 하구 cc 3 2 2 3 0 강변 나루
128 10 2 장강 — 형주 수군 경계선 normal 번구 jz 3 3 0 4 0 배 위
129 10 3 삼강구 — 채훈 elite 삼강구 jz 3 3 0 4 0 jz_boss_caixun:archer:false 배 위
130 10 4 삼강구 — 형주 수군 본대 normal 삼강구 jz 4 3 0 4 0 배 위
131 10 5 삼강구 — 채모·장윤 boss 삼강구 jz 3 3 0 4 0 jz_boss_caimao:archer:true;jz_boss_zhangyun:infantry:true 기함 갑판 보스(2인)
132 10 6 초선차전 — 안개 속 화살비 normal 오림 앞 강 cc 2 2 0 8 0 안개 낀 강
133 10 7 장강 북안 — 감택의 밀서 normal 오림 cc 3 3 3 3 0 강 북안
134 10 8 연환선 — 사슬로 묶인 함대 normal 오림 cc 4 3 0 4 0 연환선 갑판
135 10 9 오림 수채 앞 — 순찰 선단 normal 오림 cc 3 3 2 4 0 수채
136 10 10 연환선단 — 우금·모개 boss 오림 cc 3 3 2 4 0 cc_boss_yujin:infantry:true;cc_boss_maojie:archer:true 연환선 갑판 보스(2인)
137 10 11 동남풍 — 화선 출격 normal 적벽 cc 3 3 0 5 0 강 위
138 10 12 적벽 — 불타는 연환선 normal 적벽 cc 4 3 2 3 0 불타는 갑판
139 10 13 수채 — 감녕과 채중 elite 오림 cc 4 3 2 3 0 jz_boss_caizhong:infantry:false 불타는 수채
140 10 14 수채 본진 — 불길 속 친위대 normal 오림 cc 3 3 4 3 0 불타는 수채
141 10 15 적벽 — 장료 boss 오림 cc 3 3 4 3 0 dz_boss_zhangliao:cavalry:true 불타는 강변
142 10 16 오림 — 조운의 매복 elite 오림 서쪽 cc 3 3 4 2 0 cc_boss_xuhuang:infantry:false;cc_boss_zhanghe:spear:false 숲길
143 10 17 호로구 — 장비의 매복 elite 호로구 cc 3 3 4 2 0 cc_boss_xuchu:infantry:false 골짜기
144 10 18 화용도 — 진흙길 normal 화용도 cc 4 3 3 2 0 진흙 습지
145 10 19 화용도 — 마지막 호위대 normal 화용도 cc 3 3 4 3 0 진흙 습지
146 10 20 화용도 — 조조 boss 화용도 cc 3 3 4 3 0 cc_boss_caocao:infantry:true 좁은 산길

View File

@ -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

View File

@ -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;

View File

@ -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 : ""));
}
}
}

View File

@ -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;

View File

@ -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("보병 알까기: 부딪힌 유닛(아군·적군)이 밀려나는 거리 = 충돌 시 남은 슬링샷 거리 × 비율 · 창병은 비율 없이 남은 거리만큼 밀며 함께 전진")]

View File

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

View File

@ -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 += " · 제자리 복귀";

View File

@ -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)
{

View File

@ -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;
}
}
}

View File

@ -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));

View File

@ -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);

View File

@ -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();
}

View File

@ -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) })

View File

@ -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/&lt;ArtId&gt;/ 아트 폴더 · 비면 도형.</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>();

View File

@ -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) · 적 병사 아트 = &lt;세력&gt;_&lt;키&gt;.</summary>
public static bool IsMelee(UnitType t)
{
return !IsRanged(t);
}
/// <summary>데이터·아트 id 용 영문 키(infantry·cavalry·archer·spear·strategist) · 적 병사 아트 = &lt;세력&gt;_&lt;키&gt;.</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";
}

View File

@ -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>

View File

@ -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()
{