스테이지 데이터 145(챕터 1~10 · 기획서 #1086 §4) = CSV SOT + 임포터 + 월드맵 연결 — Assets/Data/Stages/{stages,chapters,generals,factions}.csv(utf-8-sig) · 메뉴 TKS/Import Stages(StageImporter · 형식 오류면 자산 미변경) → BalanceConfig.chapters·stages · 적 좌표 = formation 규칙(전위·중군·후군·장수 줄 · StageTable.Enemies) · 보스 isBoss = type boss 스테이지만 · 엘리트 = 배율 보정 · 아트 없는 무장·세력 = 도형 · 월드맵 = 챕터·노드 수·보스 노드를 스테이지 표에서(10·10·15·15·15·20·10·15·15·20) · 노드 자리 = anchors slots_N · 3·7장 = 맵 1 임시 · BalanceDefaults 구 4 스테이지 시드 삭제 · 테스트 31/31(신규 4: 145행·보스=5의 배수·병종/아트 id · 자산=CSV·배치가 전장 안·겹침 없음 · 도형 대체 · 월드맵 노드/해금) · console error 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-11-05 12:17:19 +09:00
parent d2dfa76aac
commit 2d0fc4fab6
23 changed files with 13990 additions and 243 deletions

8
Assets/Data.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 58f16644f478cba44a713a05b718b341
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Assets/Data/Stages.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5ba406b8d996345478d7ebc6ce5239d9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,11 @@
chapter,title,subtitle,years,map,ai_accuracy,ai_aggro,note
1,도원결의,황건적의 난,184,ch01,0.5,11,
2,반동탁연합,사수관·호로관,189–190,ch02,0.6,11,
3,군웅할거,반하·북해·서주,191–194,ch01,0.6,11,맵 신규 전(월드맵 보강) 임시 = 맵 1 재사용
4,여포와 서주,우이·소패·하비,195–198,ch03,0.6,11,맵 3(서주) · 우이·회음 장소 추가 예정
5,의대조,원술 토벌과 서주,199–200,ch03,0.6,11,맵 3 재사용(권장안) · 허도·망탕산·토산 추가 예정
6,관우 천리행,백마·연진·오관,200,ch04,0.6,11,맵 4 · 백마·연진 추가 예정
7,고성 재회,여남과 양산,200–201,ch01,0.6,11,맵 신규 전(월드맵 보강) 임시 = 맵 1 재사용
8,형주의 객장,서서와 와룡,201–208,ch05,0.6,11,맵 5 · 강하·양양·단계·번성 추가 예정
9,장판파,백성과 함께,208,ch06,0.6,11,맵 6 · 신야·백하 추가 예정
10,적벽대전,,208,ch07,0.65,12,맵 7(적벽)
1 chapter title subtitle years map ai_accuracy ai_aggro note
2 1 도원결의 황건적의 난 184 ch01 0.5 11
3 2 반동탁연합 사수관·호로관 189–190 ch02 0.6 11
4 3 군웅할거 반하·북해·서주 191–194 ch01 0.6 11 맵 신규 전(월드맵 보강) 임시 = 맵 1 재사용
5 4 여포와 서주 우이·소패·하비 195–198 ch03 0.6 11 맵 3(서주) · 우이·회음 장소 추가 예정
6 5 의대조 원술 토벌과 서주 199–200 ch03 0.6 11 맵 3 재사용(권장안) · 허도·망탕산·토산 추가 예정
7 6 관우 천리행 백마·연진·오관 200 ch04 0.6 11 맵 4 · 백마·연진 추가 예정
8 7 고성 재회 여남과 양산 200–201 ch01 0.6 11 맵 신규 전(월드맵 보강) 임시 = 맵 1 재사용
9 8 형주의 객장 서서와 와룡 201–208 ch05 0.6 11 맵 5 · 강하·양양·단계·번성 추가 예정
10 9 장판파 백성과 함께 208 ch06 0.6 11 맵 6 · 신야·백하 추가 예정
11 10 적벽대전 208 ch07 0.65 12 맵 7(적벽)

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: ef30932e4ea39ec4ebf3ab952d296731
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,10 @@
code,name,art,note
hgj,황건적,hgj,1장 · 3장 북해 잔당
dz,동탁군,dz,2장
lb,여포군,dz,4장 · D3 = 동탁군 아트 재사용(베리에이션 lb_ 납품 시 art 교체)
ys,원소군,ys,cc 기반 베리에이션(미납품 → 도형)
ysu,원술군,ysu,cc 기반 베리에이션(미납품 → 도형)
jz,형주군,jz,cc 기반 베리에이션(미납품 → 도형)
jzr,형주 반란군,jz,8장 2~5 · 장무·진손 id 접두 jz
sj,산적,hgj,7장 2·8 = 황건 아트 재사용(기획서)
cc,조조군,cc,
1 code name art note
2 hgj 황건적 hgj 1장 · 3장 북해 잔당
3 dz 동탁군 dz 2장
4 lb 여포군 dz 4장 · D3 = 동탁군 아트 재사용(베리에이션 lb_ 납품 시 art 교체)
5 ys 원소군 ys cc 기반 베리에이션(미납품 → 도형)
6 ysu 원술군 ysu cc 기반 베리에이션(미납품 → 도형)
7 jz 형주군 jz cc 기반 베리에이션(미납품 → 도형)
8 jzr 형주 반란군 jz 8장 2~5 · 장무·진손 id 접두 jz
9 sj 산적 hgj 7장 2·8 = 황건 아트 재사용(기획서)
10 cc 조조군 cc

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 471bde1166de62349a709883947c1093
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,54 @@
id,name,hanja,hp_mult,atk_mult,note
cc_boss_bianxi,변희,卞喜,,,
cc_boss_caiyang,채양,蔡陽,,,
cc_boss_caocao,조조,曹操,1.5,1.3,현행 보스 값 승계
cc_boss_caohong,조홍,曹洪,,,
cc_boss_caoren,조인,曹仁,,,
cc_boss_chezhou,차주,車冑,,,
cc_boss_chunyudao,순우도,淳于導,,,
cc_boss_gaolan,고람,高覽,,,
cc_boss_hanfu,한복,韓福,,,
cc_boss_kongxiu,공수,孔秀,,,
cc_boss_lidian,이전,李典,,,
cc_boss_liudai,유대,劉岱,,,
cc_boss_lvkuang,여광,呂曠,,,
cc_boss_lvxiang,여상,呂翔,,,
cc_boss_maojie,모개,毛玠,,,
cc_boss_mengtan,맹탄,孟坦,,,
cc_boss_qinqi,진기,秦琪,,,
cc_boss_wangzhi,왕식,王植,,,
cc_boss_wangzhong,왕충,王忠,,,
cc_boss_xiahoudun,하후돈,夏侯惇,,,
cc_boss_xiahouen,하후은,夏侯恩,,,
cc_boss_xiahoujie,하후걸,夏侯傑,,,
cc_boss_xuchu,허저,許褚,,,
cc_boss_xuhuang,서황,徐晃,,,
cc_boss_yanming,안명,晏明,,,
cc_boss_yujin,우금,于禁,,,
cc_boss_zhanghe,장합,張郃,,,
dz_boss_caoxing,조성,曹性,,,
dz_boss_chengong,진궁,陳宮,,,
dz_boss_gaoshun,고순,高順,,,
dz_boss_huaxiong,화웅,華雄,,,
dz_boss_huzhen,호진,胡軫,,,
dz_boss_lvbu,여포,呂布,1.6,1.5,현행 보스 값 승계
dz_boss_zhangliao,장료,張遼,,,
hgj_boss_chengyuanzhi,정원지,程遠志,,,
hgj_boss_dengmao,등무,鄧茂,,,
hgj_boss_guanhai,관해,管亥,,,
hgj_boss_peiyuanshao,배원소,裴元紹,,,
hgj_boss_zhangbao,장보,張寶,,,
hgj_boss_zhangjiao,장각,張角,1.3,1.2,현행 보스 값 승계
hgj_boss_zhangliang,장량,張梁,,,
jz_boss_caimao,채모,蔡瑁,,,
jz_boss_caixun,채훈,蔡壎,,,
jz_boss_caizhong,채중,蔡中,,,
jz_boss_chensun,진손,陳孫,,,
jz_boss_wenpin,문빙,文聘,,,
jz_boss_zhangwu,장무,張武,,,
jz_boss_zhangyun,장윤,張允,,,
ys_boss_quyi,국의,麴義,,,
ys_boss_wenchou,문추,文醜,,,
ys_boss_yanliang,안량,顏良,,,
ysu_boss_jiling,기령,紀靈,,,
ysu_boss_xunzheng,순정,荀正,,,
1 id name hanja hp_mult atk_mult note
2 cc_boss_bianxi 변희 卞喜
3 cc_boss_caiyang 채양 蔡陽
4 cc_boss_caocao 조조 曹操 1.5 1.3 현행 보스 값 승계
5 cc_boss_caohong 조홍 曹洪
6 cc_boss_caoren 조인 曹仁
7 cc_boss_chezhou 차주 車冑
8 cc_boss_chunyudao 순우도 淳于導
9 cc_boss_gaolan 고람 高覽
10 cc_boss_hanfu 한복 韓福
11 cc_boss_kongxiu 공수 孔秀
12 cc_boss_lidian 이전 李典
13 cc_boss_liudai 유대 劉岱
14 cc_boss_lvkuang 여광 呂曠
15 cc_boss_lvxiang 여상 呂翔
16 cc_boss_maojie 모개 毛玠
17 cc_boss_mengtan 맹탄 孟坦
18 cc_boss_qinqi 진기 秦琪
19 cc_boss_wangzhi 왕식 王植
20 cc_boss_wangzhong 왕충 王忠
21 cc_boss_xiahoudun 하후돈 夏侯惇
22 cc_boss_xiahouen 하후은 夏侯恩
23 cc_boss_xiahoujie 하후걸 夏侯傑
24 cc_boss_xuchu 허저 許褚
25 cc_boss_xuhuang 서황 徐晃
26 cc_boss_yanming 안명 晏明
27 cc_boss_yujin 우금 于禁
28 cc_boss_zhanghe 장합 張郃
29 dz_boss_caoxing 조성 曹性
30 dz_boss_chengong 진궁 陳宮
31 dz_boss_gaoshun 고순 高順
32 dz_boss_huaxiong 화웅 華雄
33 dz_boss_huzhen 호진 胡軫
34 dz_boss_lvbu 여포 呂布 1.6 1.5 현행 보스 값 승계
35 dz_boss_zhangliao 장료 張遼
36 hgj_boss_chengyuanzhi 정원지 程遠志
37 hgj_boss_dengmao 등무 鄧茂
38 hgj_boss_guanhai 관해 管亥
39 hgj_boss_peiyuanshao 배원소 裴元紹
40 hgj_boss_zhangbao 장보 張寶
41 hgj_boss_zhangjiao 장각 張角 1.3 1.2 현행 보스 값 승계
42 hgj_boss_zhangliang 장량 張梁
43 jz_boss_caimao 채모 蔡瑁
44 jz_boss_caixun 채훈 蔡壎
45 jz_boss_caizhong 채중 蔡中
46 jz_boss_chensun 진손 陳孫
47 jz_boss_wenpin 문빙 文聘
48 jz_boss_zhangwu 장무 張武
49 jz_boss_zhangyun 장윤 張允
50 ys_boss_quyi 국의 麴義
51 ys_boss_wenchou 문추 文醜
52 ys_boss_yanliang 안량 顏良
53 ysu_boss_jiling 기령 紀靈
54 ysu_boss_xunzheng 순정 荀正

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 65e6abbca07bd2748b6a004d841fb426
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 64046371a1ba18e4d8b2878a72967d2a
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,3 @@
using System.Collections.Generic;
using UnityEngine;
namespace TKS.EditorTools
@ -7,6 +6,7 @@ namespace TKS.EditorTools
/// BalanceConfig 자산의 최초 1회 시드 값(프로토타입 초안). 이후 수치 조정은 자산(Inspector)에서 한다 — 코드가 SOT 가 아니다.
/// 전장 = 가로 20 × 세로 32 (x −10~10 · y −16~16) · 화면에는 세로 18 만 보여 스크롤한다. 아군은 아래(y −13) · 적은 전위/중군/후군으로 흩어 배치.
/// 아트 id = Resources/Units/&lt;id&gt;/ (아군 = 스켈레탈 세션 리그 프레임 liubei·guanyu·zhangfei · 적 = 일러 세션 납품 이름 &lt;세력&gt;_&lt;병종&gt; · 없으면 도형).
/// 챕터·스테이지는 시드하지 않는다 — Assets/Data/Stages/*.csv 가 SOT(StageImporter · 2026-11-05 145 스테이지). 여기는 배치 규칙·장수 배율 출발값만.
/// </summary>
public static class BalanceDefaults
{
@ -70,52 +70,20 @@ namespace TKS.EditorTools
Hero("황충", UnitType.Archer, 1.0f, 1.25f, "huangzhong"), // 원화 세션 재디자인(2026-11-04 #1081)
};
c.stages = new[]
// 적 배치 규칙 = 구 시드 4 스테이지(황건적의 난·호로관·적벽·오장원)의 전위 y −6~−4.5 · 중군 −2~5 · 후군 7~9 · 장수 10~13 · 가로 간격 2.5~3 을 규칙화
c.formation = new BalanceConfig.FormationRule
{
Stage("황건적의 난", "184년", 0.5f, 11f,
// 전위
E("hgj", "황건적 보병", UnitType.Infantry, -3f, -6f),
E("hgj", "황건적 보병", UnitType.Infantry, 3f, -6f),
E("hgj", "황건적 창병", UnitType.Spear, 0f, -4.5f),
// 중군
E("hgj", "황건적 궁병", UnitType.Archer, -4f, 1f),
E("hgj", "황건적 궁병", UnitType.Archer, 4f, 1f),
E("hgj", "황건적 보병", UnitType.Infantry, 0f, 2.5f),
// 후군
E("hgj", "황건적 창병", UnitType.Spear, -2.5f, 8f),
Boss("hgj", "zhangjiao", "장각", UnitType.Infantry, 0f, 10f, 1.3f, 1.2f)),
Stage("호로관 전투", "190년", 0.6f, 11f,
E("dz", "동탁군 창병", UnitType.Spear, -2.5f, -5f),
E("dz", "동탁군 창병", UnitType.Spear, 2.5f, -5f),
E("dz", "동탁군 기병", UnitType.Cavalry, -6f, -1f),
E("dz", "동탁군 기병", UnitType.Cavalry, 6f, -1f),
E("dz", "동탁군 보병", UnitType.Infantry, 0f, 1f),
E("dz", "동탁군 궁병", UnitType.Archer, -3f, 3f),
E("dz", "동탁군 궁병", UnitType.Archer, 3f, 3f),
E("dz", "동탁군 기병", UnitType.Cavalry, 0f, 7f),
Boss("dz", "lvbu", "여포", UnitType.Cavalry, 0f, 11f, 1.6f, 1.5f)),
Stage("적벽대전", "208년", 0.65f, 12f,
E("cc", "조조군 보병", UnitType.Infantry, -5f, -6f),
E("cc", "조조군 보병", UnitType.Infantry, 0f, -5f),
E("cc", "조조군 보병", UnitType.Infantry, 5f, -6f),
E("cc", "조조군 기병", UnitType.Cavalry, -3f, 0f),
E("cc", "조조군 기병", UnitType.Cavalry, 3f, 0f),
E("cc", "조조군 궁병", UnitType.Archer, -4f, 5f),
E("cc", "조조군 궁병", UnitType.Archer, 4f, 5f),
E("cc", "조조군 창병", UnitType.Spear, 0f, 7f),
Boss("cc", "caocao", "조조", UnitType.Infantry, 0f, 12f, 1.5f, 1.3f)),
Stage("오장원 전투", "234년", 0.7f, 12f,
E("wei", "위군 창병", UnitType.Spear, -3f, -6f),
E("wei", "위군 창병", UnitType.Spear, 3f, -6f),
E("wei", "위군 보병", UnitType.Infantry, -7f, -2f),
E("wei", "위군 보병", UnitType.Infantry, 7f, -2f),
E("wei", "위군 기병", UnitType.Cavalry, -2f, 2f),
E("wei", "위군 기병", UnitType.Cavalry, 2f, 2f),
E("wei", "위군 궁병", UnitType.Archer, -4f, 7f),
E("wei", "위군 궁병", UnitType.Archer, 4f, 7f),
E("wei", "위군 창병", UnitType.Spear, 0f, 9f),
Boss("wei", "simayi", "사마의", UnitType.Infantry, 0f, 13f, 1.6f, 1.3f)),
frontY = -5.5f, middleY = 0.5f, rearY = 7.5f, commandY = 11f,
colGap = 2.6f, rowGap = 1.8f, maxPerRow = 5, rowStagger = 0.5f,
frontShare = 0.4f, rearShare = 0.15f,
};
// 장수 배율 출발값(밸런스 세션 조정) · 보스 = 구 시드 보스 4인(1.3~1.6 / 1.2~1.5)의 가운데 값 · 장각·여포·조조는 generals.csv 에 구 값 승계
c.bossGeneralHpMult = 1.5f;
c.bossGeneralAtkMult = 1.3f;
c.eliteGeneralHpMult = 1.25f;
c.eliteGeneralAtkMult = 1.15f;
c.chapters = new BalanceConfig.ChapterDef[0];
c.stages = new BalanceConfig.StageDef[0];
}
static BalanceConfig.UnitStats Stats(UnitType t, int hp, int atk, int def, float sling, float range, float radius)
@ -128,32 +96,5 @@ namespace TKS.EditorTools
return new BalanceConfig.HeroDef { name = name, type = t, hpMult = hpMult, atkMult = atkMult, art = art };
}
/// <summary>적 병사 아트 id = &lt;세력 접두&gt;_&lt;병종&gt; (일러 세션 납품 이름 규칙 · 예 hgj_infantry).</summary>
static string ArtKey(UnitType t)
{
switch (t)
{
case UnitType.Infantry: return "infantry";
case UnitType.Cavalry: return "cavalry";
case UnitType.Archer: return "archer";
case UnitType.Spear: return "spear";
}
return "unit";
}
static BalanceConfig.EnemySpawn E(string faction, string name, UnitType t, float x, float y)
{
return new BalanceConfig.EnemySpawn { name = name, type = t, position = new Vector2(x, y), hpMult = 1f, atkMult = 1f, isBoss = false, art = faction + "_" + ArtKey(t) };
}
static BalanceConfig.EnemySpawn Boss(string faction, string roman, string name, UnitType t, float x, float y, float hpMult, float atkMult)
{
return new BalanceConfig.EnemySpawn { name = name, type = t, position = new Vector2(x, y), hpMult = hpMult, atkMult = atkMult, isBoss = true, art = faction + "_boss_" + roman };
}
static BalanceConfig.StageDef Stage(string title, string subtitle, float accuracy, float aggro, params BalanceConfig.EnemySpawn[] enemies)
{
return new BalanceConfig.StageDef { title = title, subtitle = subtitle, aiAccuracy = accuracy, aiAggroRange = aggro, enemies = new List<BalanceConfig.EnemySpawn>(enemies) };
}
}
}

View File

@ -60,6 +60,7 @@ namespace TKS.EditorTools
if (created || overwrite)
{
BalanceDefaults.Apply(cfg);
StageImporter.ImportInto(cfg); // 챕터·스테이지 = Assets/Data/Stages/*.csv
EditorUtility.SetDirty(cfg);
}
return cfg;

View File

@ -0,0 +1,50 @@
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
namespace TKS.EditorTools
{
/// <summary>
/// 스테이지 표(Assets/Data/Stages/*.csv) → BalanceConfig.chapters·stages. 메뉴 TKS/Import Stages 또는
/// CLI: Unity.exe -batchmode -nographics -projectPath &lt;proj&gt; -executeMethod TKS.EditorTools.StageImporter.Import -quit
/// 형식 오류가 하나라도 있으면 자산을 건드리지 않는다(콘솔 error 로 행 번호 보고).
/// </summary>
public static class StageImporter
{
[MenuItem("TKS/Import Stages (스테이지 임포트)")]
public static void Import()
{
var cfg = ProjectSetup.EnsureConfig(false);
if (!ImportInto(cfg)) return;
AssetDatabase.SaveAssets();
Debug.Log("[TKS] 스테이지 임포트 완료 · 챕터 " + cfg.chapters.Length + " · 스테이지 " + cfg.stages.Length + " · " + ProjectSetup.ConfigPath);
}
public static string[] ReadFiles()
{
var dir = Path.Combine(Application.dataPath, StageTable.Folder);
var texts = new string[StageTable.Files.Length];
for (int i = 0; i < texts.Length; i++)
{
var p = Path.Combine(dir, StageTable.Files[i]);
texts[i] = File.Exists(p) ? File.ReadAllText(p, Encoding.UTF8) : null;
}
return texts;
}
public static bool ImportInto(BalanceConfig cfg)
{
var t = StageTable.Parse(ReadFiles());
if (t.errors.Count > 0)
{
foreach (var e in t.errors) Debug.LogError("[TKS] 스테이지 표 오류 · " + e);
Debug.LogError("[TKS] 스테이지 임포트 중단(오류 " + t.errors.Count + ") · 자산 변경 없음");
return false;
}
StageTable.Apply(cfg, t);
EditorUtility.SetDirty(cfg);
return true;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 42dc6c9bd5b09554d8387821016f247c

View File

@ -4,6 +4,13 @@ using UnityEngine;
namespace TKS
{
public enum StageKind
{
Normal = 0,
Elite = 1, // 이름 있는 무장이 나오는 일반 스테이지(무장 isBoss = false · 배율만 보정)
Boss = 2 // 5·10·15·20
}
/// <summary>
/// 밸런스·스테이지 데이터의 단일 출처(SOT). 코드에는 수치를 두지 않고 이 자산만 읽는다.
/// 기본값은 에디터 셋업(ProjectSetup)이 최초 1회 시드한다.
@ -58,6 +65,50 @@ namespace TKS
[Tooltip("이 거리 안에 아군이 들어와야 적이 움직인다(넓은 전장에서 부대별 교전)")]
public float aiAggroRange = 11f;
public List<EnemySpawn> enemies = new List<EnemySpawn>();
[Header("스테이지 표(Assets/Data/Stages/stages.csv · TKS/Import Stages)")]
public int chapter;
[Tooltip("챕터 안 번호(1부터)")]
public int stage;
public StageKind kind;
public string place;
[Tooltip("factions.csv 세력 코드")]
public string faction;
public string bgTheme;
public string note;
}
[Serializable]
public class ChapterDef
{
public int no;
public string title;
public string subtitle;
public string years;
[Tooltip("Resources/ChapterMap/Maps/<map> 맵 그림 · Data/<map>.json 노드 자리(slots_N)")]
public string map;
}
/// <summary>적 배치 규칙(스테이지 표의 병사 수 → 전위·중군·후군 좌표 · 임포트 때 적용).</summary>
[Serializable]
public class FormationRule
{
[Tooltip("전위(보병·창병 우선) 첫 줄 y")]
public float frontY = -5.5f;
[Tooltip("중군(궁병 가운데 · 기병 양 끝) 첫 줄 y")]
public float middleY = 0.5f;
[Tooltip("후군 경비(창병·기병 우선) 줄 y")]
public float rearY = 7.5f;
[Tooltip("장수(보스·엘리트) 줄 y · 보스가 가운데")]
public float commandY = 11f;
public float colGap = 2.6f;
[Tooltip("같은 대가 여러 줄일 때 다음 줄 간격(적 쪽으로)")]
public float rowGap = 1.8f;
public int maxPerRow = 5;
[Tooltip("여러 줄일 때 줄마다 좌우로 엇갈리는 양(× colGap)")]
public float rowStagger = 0.5f;
[Range(0f, 1f)] public float frontShare = 0.4f;
[Range(0f, 1f)] public float rearShare = 0.15f;
}
[Header("병종 기본 능력치")]
@ -119,9 +170,19 @@ namespace TKS
[Header("아군 영웅 (최대 5)")]
public HeroDef[] heroes = new HeroDef[0];
[Header("스테이지")]
[Header("스테이지 (Assets/Data/Stages/*.csv → 메뉴 TKS/Import Stages · 직접 고치면 다음 임포트에 덮임)")]
public ChapterDef[] chapters = new ChapterDef[0];
public StageDef[] stages = new StageDef[0];
[Header("스테이지 임포트 규칙 (값을 바꾸면 다시 임포트)")]
public FormationRule formation = new FormationRule();
[Tooltip("보스 스테이지 장수 배율(generals.csv hp_mult·atk_mult 가 비었을 때) · isBoss 라 bossHpMult 가 더 곱해진다")]
public float bossGeneralHpMult = 1.5f;
public float bossGeneralAtkMult = 1.3f;
[Tooltip("엘리트 장수 배율(isBoss 아님 · generals.csv 가 비었을 때)")]
public float eliteGeneralHpMult = 1.25f;
public float eliteGeneralAtkMult = 1.15f;
public UnitStats GetStats(UnitType t)
{
for (int i = 0; i < unitStats.Length; i++)
@ -131,6 +192,17 @@ namespace TKS
throw new InvalidOperationException("BalanceConfig 에 " + t + " 능력치가 없습니다.");
}
/// <summary>챕터 no 의 스테이지 인덱스(stages 순서 = 표 순서).</summary>
public List<int> StagesOfChapter(int no)
{
var res = new List<int>();
for (int i = 0; i < stages.Length; i++)
{
if (stages[i].chapter == no) res.Add(i);
}
return res;
}
public float Matchup(UnitType attacker, UnitType defender)
{
int idx = (int)attacker * UnitTypeInfo.Count + (int)defender;

View File

@ -49,7 +49,13 @@ namespace TKS
es.AddComponent<StandaloneInputModule>();
}
stageText = MakeText("Stage", new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(30f, -28f), new Vector2(760f, 70f), 40, TextAnchor.UpperLeft);
stageText = MakeText("Stage", new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(30f, -28f), new Vector2(700f, 70f), 40, TextAnchor.UpperLeft);
// 스테이지 표 제목(최대 ~20자)이 오른쪽 위 적 수와 겹치지 않게 칸 안에서 글자 크기를 줄인다
stageText.horizontalOverflow = HorizontalWrapMode.Wrap;
stageText.verticalOverflow = VerticalWrapMode.Truncate;
stageText.resizeTextForBestFit = true;
stageText.resizeTextMinSize = 24;
stageText.resizeTextMaxSize = 40;
countText = MakeText("Count", new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-30f, -28f), new Vector2(520f, 70f), 36, TextAnchor.UpperRight);
turnText = MakeText("Turn", new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -92f), new Vector2(700f, 70f), 46, TextAnchor.UpperCenter);
turnText.color = new Color(1f, 0.95f, 0.6f);
@ -157,7 +163,9 @@ namespace TKS
void Refresh()
{
var stage = battle.Stage;
stageText.text = "제" + (battle.StageIndex + 1) + "전투 · " + stage.title + (string.IsNullOrEmpty(stage.subtitle) ? "" : " " + stage.subtitle);
stageText.text = stage.chapter > 0
? stage.chapter + "-" + stage.stage + " · " + stage.title
: "제" + (battle.StageIndex + 1) + "전투 · " + stage.title + (string.IsNullOrEmpty(stage.subtitle) ? "" : " " + stage.subtitle);
countText.text = "아군 " + battle.AliveCount(Faction.Player) + " · 적 " + battle.AliveCount(Faction.Enemy);
switch (battle.Phase)

View File

@ -0,0 +1,413 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;
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
/// 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>
public static class StageTable
{
public const string Folder = "Data/Stages"; // Assets/ 아래
public static readonly string[] Files = { "stages.csv", "chapters.csv", "generals.csv", "factions.csv" };
public class GeneralRef
{
public string id;
public UnitType type;
public bool boss;
}
public class StageRow
{
public int line;
public int chapter;
public int stage;
public string title;
public StageKind kind;
public string place;
public string faction;
public int[] soldiers = new int[UnitTypeInfo.Count]; // UnitType 순서
public List<GeneralRef> generals = new List<GeneralRef>();
public string bgTheme;
public string note;
public int SoldierCount
{
get
{
int n = 0;
foreach (var c in soldiers) n += c;
return n;
}
}
}
public class ChapterRow
{
public int no;
public string title;
public string subtitle;
public string years;
public string map;
public float aiAccuracy;
public float aiAggro;
}
public class GeneralInfo
{
public string id;
public string name;
public float hpMult = float.NaN; // 비면 역할(보스·엘리트) 기본 배율
public float atkMult = float.NaN;
}
public class FactionInfo
{
public string code;
public string name;
public string art;
}
public class Tables
{
public readonly List<StageRow> stages = new List<StageRow>();
public readonly List<ChapterRow> chapters = new List<ChapterRow>();
public readonly Dictionary<string, GeneralInfo> generals = new Dictionary<string, GeneralInfo>();
public readonly Dictionary<string, FactionInfo> factions = new Dictionary<string, FactionInfo>();
public readonly List<string> errors = new List<string>();
}
// ---------- 해석 ----------
/// <summary>texts = Files 순서의 CSV 내용. 형식 오류는 errors 에 모은다(하나라도 있으면 반영하지 않는다).</summary>
public static Tables Parse(string[] texts)
{
var t = new Tables();
foreach (var r in ReadCsv(texts[3], "factions.csv", t.errors))
{
var f = new FactionInfo { code = Get(r, "code"), name = Get(r, "name"), art = Get(r, "art") };
if (string.IsNullOrEmpty(f.code) || string.IsNullOrEmpty(f.art)) t.errors.Add("factions.csv 행 " + Get(r, "#") + ": code·art 필수");
else t.factions[f.code] = f;
}
foreach (var r in ReadCsv(texts[2], "generals.csv", t.errors))
{
var g = new GeneralInfo { id = Get(r, "id"), name = Get(r, "name") };
g.hpMult = OptFloat(r, "hp_mult", t.errors, "generals.csv");
g.atkMult = OptFloat(r, "atk_mult", t.errors, "generals.csv");
if (string.IsNullOrEmpty(g.id) || string.IsNullOrEmpty(g.name)) t.errors.Add("generals.csv 행 " + Get(r, "#") + ": id·name 필수");
else t.generals[g.id] = g;
}
foreach (var r in ReadCsv(texts[1], "chapters.csv", t.errors))
{
t.chapters.Add(new ChapterRow
{
no = Int(r, "chapter", t.errors, "chapters.csv"),
title = Get(r, "title"),
subtitle = Get(r, "subtitle"),
years = Get(r, "years"),
map = Get(r, "map"),
aiAccuracy = Float(r, "ai_accuracy", t.errors, "chapters.csv"),
aiAggro = Float(r, "ai_aggro", t.errors, "chapters.csv"),
});
}
foreach (var r in ReadCsv(texts[0], "stages.csv", t.errors))
{
string at = "stages.csv 행 " + Get(r, "#");
var s = new StageRow
{
line = Int(r, "#", t.errors, at),
chapter = Int(r, "chapter", t.errors, at),
stage = Int(r, "stage", t.errors, at),
title = Get(r, "title"),
place = Get(r, "place"),
faction = Get(r, "faction"),
bgTheme = Get(r, "bg_theme"),
note = Get(r, "note"),
};
at += " (" + s.chapter + "-" + s.stage + ")";
switch (Get(r, "type"))
{
case "normal": s.kind = StageKind.Normal; break;
case "elite": s.kind = StageKind.Elite; break;
case "boss": s.kind = StageKind.Boss; break;
default: t.errors.Add(at + ": type = normal|elite|boss (값 「" + Get(r, "type") + "」)"); break;
}
for (int i = 0; i < UnitTypeInfo.Count; i++)
{
int n = Int(r, UnitTypeInfo.Key((UnitType)i), t.errors, at);
if (n < 0) t.errors.Add(at + ": 병사 수 음수");
s.soldiers[i] = Mathf.Max(0, n);
}
if (!t.factions.ContainsKey(s.faction)) t.errors.Add(at + ": faction 「" + s.faction + "」 이 factions.csv 에 없음");
foreach (var part in Get(r, "generals").Split(';'))
{
var g = part.Trim();
if (g.Length == 0) continue;
var f = g.Split(':');
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 + "」)");
continue;
}
var gr = new GeneralRef { id = f[0].Trim(), type = ut, boss = f[2].Trim() == "true" };
if (!t.generals.ContainsKey(gr.id)) t.errors.Add(at + ": 무장 「" + gr.id + "」 이 generals.csv 에 없음");
if (gr.boss && s.kind != StageKind.Boss) t.errors.Add(at + ": 보스 무장은 type = boss 스테이지에만");
s.generals.Add(gr);
}
t.stages.Add(s);
}
// 챕터 안 번호 = 1 부터 빈칸 없이 · 챕터는 chapters.csv 에 있어야 한다
var seen = new HashSet<long>();
foreach (var s in t.stages)
{
if (!seen.Add(s.chapter * 1000L + s.stage)) t.errors.Add("stages.csv: " + s.chapter + "-" + s.stage + " 중복");
if (t.chapters.Find(c => c.no == s.chapter) == null) t.errors.Add("stages.csv: 챕터 " + s.chapter + " 이 chapters.csv 에 없음");
}
foreach (var c in t.chapters)
{
int n = t.stages.FindAll(s => s.chapter == c.no).Count;
for (int k = 1; k <= n; k++)
if (!seen.Contains(c.no * 1000L + k)) t.errors.Add("stages.csv: 챕터 " + c.no + " 의 " + k + "번 스테이지 없음(1부터 빈칸 없이)");
}
return t;
}
// ---------- 반영 ----------
/// <summary>chapters·stages 를 표 순서(챕터 → 스테이지)로 덮어쓴다. 적 좌표 = cfg.formation 규칙.</summary>
public static void Apply(BalanceConfig cfg, Tables t)
{
var chapters = new List<ChapterRow>(t.chapters);
chapters.Sort((a, b) => a.no.CompareTo(b.no));
var rows = new List<StageRow>(t.stages);
rows.Sort((a, b) => a.chapter != b.chapter ? a.chapter.CompareTo(b.chapter) : a.stage.CompareTo(b.stage));
cfg.chapters = new BalanceConfig.ChapterDef[chapters.Count];
for (int i = 0; i < chapters.Count; i++)
{
var c = chapters[i];
cfg.chapters[i] = new BalanceConfig.ChapterDef { no = c.no, title = c.title, subtitle = c.subtitle, years = c.years, map = c.map };
}
cfg.stages = new BalanceConfig.StageDef[rows.Count];
for (int i = 0; i < rows.Count; i++)
{
var r = rows[i];
var c = chapters.Find(x => x.no == r.chapter);
cfg.stages[i] = new BalanceConfig.StageDef
{
title = r.title,
subtitle = string.IsNullOrEmpty(c.years) ? "" : c.years + "년",
aiAccuracy = c.aiAccuracy,
aiAggroRange = c.aiAggro,
enemies = Enemies(cfg, r, t),
chapter = r.chapter,
stage = r.stage,
kind = r.kind,
place = r.place,
faction = r.faction,
bgTheme = r.bgTheme,
note = r.note,
};
}
}
/// <summary>
/// 병사 → 후군 경비(창·기·보 우선 · 총수 × rearShare 내림) · 전위(보·창·기 우선 · 총수 × frontShare 올림) · 나머지 = 중군(궁 가운데 → 보·창 → 기병 양 끝).
/// 대마다 maxPerRow 넘으면 고르게 여러 줄(적 쪽으로 rowGap · 줄마다 엇갈림). 장수 = commandY 줄(보스 → 엘리트 순 가운데부터).
/// </summary>
public static List<BalanceConfig.EnemySpawn> Enemies(BalanceConfig cfg, StageRow r, Tables t)
{
var F = cfg.formation;
var fac = t.factions[r.faction];
var pool = (int[])r.soldiers.Clone();
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 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) })
{
var pos = Block(F, band.Key, band.Value.Count);
for (int i = 0; i < band.Value.Count; i++)
{
var ut = band.Value[i];
res.Add(new BalanceConfig.EnemySpawn
{
name = fac.name + " " + UnitTypeInfo.Korean(ut),
type = ut,
position = pos[i],
hpMult = 1f,
atkMult = 1f,
isBoss = false,
art = fac.art + "_" + UnitTypeInfo.Key(ut),
});
}
}
var gens = new List<GeneralRef>(r.generals);
gens.Sort((a, b) => b.boss.CompareTo(a.boss)); // 보스 먼저(가운데)
var gpos = Block(F, F.commandY, gens.Count);
for (int i = 0; i < gens.Count; i++)
{
var g = gens[i];
var info = t.generals[g.id];
res.Add(new BalanceConfig.EnemySpawn
{
name = info.name,
type = g.type,
position = gpos[i],
hpMult = !float.IsNaN(info.hpMult) ? info.hpMult : g.boss ? cfg.bossGeneralHpMult : cfg.eliteGeneralHpMult,
atkMult = !float.IsNaN(info.atkMult) ? info.atkMult : g.boss ? cfg.bossGeneralAtkMult : cfg.eliteGeneralAtkMult,
isBoss = g.boss,
art = g.id,
});
}
return res;
}
/// <summary>pool 에서 우선순위 병종 순으로 n 기를 꺼낸다.</summary>
static List<UnitType> Take(int[] pool, int n, params UnitType[] order)
{
var res = new List<UnitType>();
foreach (var ut in order)
{
while (res.Count < n && pool[(int)ut] > 0)
{
pool[(int)ut]--;
res.Add(ut);
}
}
return res;
}
/// <summary>n 자리 = 줄마다 가운데부터 바깥으로(같은 거리면 왼쪽 먼저). 줄 수 = ceil(n / maxPerRow) · 줄마다 고르게.</summary>
static List<Vector2> Block(BalanceConfig.FormationRule F, float y0, int n)
{
var res = new List<Vector2>();
if (n <= 0) return res;
int per = Mathf.Max(1, F.maxPerRow);
int rows = (n + per - 1) / per;
int left = n;
for (int row = 0; row < rows; row++)
{
int k = (left + (rows - row) - 1) / (rows - row);
left -= k;
float shift = rows > 1 ? (row % 2 == 0 ? -0.5f : 0.5f) * F.rowStagger * F.colGap : 0f;
var xs = new List<float>();
for (int j = 0; j < k; j++) xs.Add((j - (k - 1) * 0.5f) * F.colGap);
xs.Sort((a, b) => Mathf.Abs(a) != Mathf.Abs(b) ? Mathf.Abs(a).CompareTo(Mathf.Abs(b)) : a.CompareTo(b));
foreach (var x in xs) res.Add(new Vector2(x + shift, y0 + row * F.rowGap));
}
return res;
}
// ---------- CSV ----------
/// <summary>머리줄 = 열 이름 · 따옴표("…" · "" = ") 지원 · 빈 줄 건너뜀 · 각 행에 "#" = 파일 줄 번호.</summary>
public static List<Dictionary<string, string>> ReadCsv(string text, string file, List<string> errors)
{
var res = new List<Dictionary<string, string>>();
if (string.IsNullOrEmpty(text))
{
errors.Add(file + ": 비어 있음");
return res;
}
if (text[0] == '') text = text.Substring(1);
var records = Split(text);
if (records.Count == 0) return res;
var head = records[0].Value;
for (int i = 1; i < records.Count; i++)
{
var cells = records[i].Value;
if (cells.Count == 1 && cells[0].Trim().Length == 0) continue;
if (cells.Count != head.Count)
{
errors.Add(file + " 줄 " + records[i].Key + ": 칸 수 " + cells.Count + " ≠ 머리줄 " + head.Count);
continue;
}
var d = new Dictionary<string, string> { { "#", records[i].Key.ToString(CultureInfo.InvariantCulture) } };
for (int c = 0; c < head.Count; c++) d[head[c].Trim()] = cells[c].Trim();
res.Add(d);
}
return res;
}
static List<KeyValuePair<int, List<string>>> Split(string text)
{
var rows = new List<KeyValuePair<int, List<string>>>();
var cells = new List<string>();
var sb = new StringBuilder();
bool quoted = false;
int line = 1, start = 1;
for (int i = 0; i < text.Length; i++)
{
char ch = text[i];
if (quoted)
{
if (ch == '"' && i + 1 < text.Length && text[i + 1] == '"') { sb.Append('"'); i++; }
else if (ch == '"') quoted = false;
else
{
if (ch == '\n') line++;
sb.Append(ch);
}
continue;
}
if (ch == '"') quoted = true;
else if (ch == ',') { cells.Add(sb.ToString()); sb.Length = 0; }
else if (ch == '\r') { }
else if (ch == '\n')
{
cells.Add(sb.ToString());
sb.Length = 0;
rows.Add(new KeyValuePair<int, List<string>>(start, cells));
cells = new List<string>();
line++;
start = line;
}
else sb.Append(ch);
}
if (sb.Length > 0 || cells.Count > 0)
{
cells.Add(sb.ToString());
rows.Add(new KeyValuePair<int, List<string>>(start, cells));
}
return rows;
}
static string Get(Dictionary<string, string> r, string key)
{
string v;
return r.TryGetValue(key, out v) ? v : "";
}
static int Int(Dictionary<string, string> r, string key, List<string> errors, string at)
{
int v;
if (int.TryParse(Get(r, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) return v;
errors.Add(at + ": " + key + " 정수 아님(「" + Get(r, key) + "」)");
return 0;
}
static float Float(Dictionary<string, string> r, string key, List<string> errors, string at)
{
float v;
if (float.TryParse(Get(r, key), NumberStyles.Float, CultureInfo.InvariantCulture, out v)) return v;
errors.Add(at + " 행 " + Get(r, "#") + ": " + key + " 숫자 아님(「" + Get(r, key) + "」)");
return 0f;
}
static float OptFloat(Dictionary<string, string> r, string key, List<string> errors, string at)
{
return Get(r, key).Length == 0 ? float.NaN : Float(r, key, errors, at);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 39939f824eda1494eadd2b2ab38c602a

View File

@ -47,5 +47,32 @@ namespace TKS
{
return t != UnitType.Archer;
}
/// <summary>데이터·아트 id 용 영문 키(infantry·cavalry·archer·spear) · 적 병사 아트 = &lt;세력&gt;_&lt;키&gt;.</summary>
public static string Key(UnitType t)
{
switch (t)
{
case UnitType.Infantry: return "infantry";
case UnitType.Cavalry: return "cavalry";
case UnitType.Archer: return "archer";
case UnitType.Spear: return "spear";
}
return "unit";
}
public static bool TryParseKey(string key, out UnitType t)
{
for (int i = 0; i < Count; i++)
{
if (Key((UnitType)i) == key)
{
t = (UnitType)i;
return true;
}
}
t = UnitType.Infantry;
return false;
}
}
}

View File

@ -1,11 +1,14 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.UI;
namespace TKS
{
// ---------- 데이터(Resources/ChapterMap/Data/world.json · 원화 세션 chap_install.py 생성 · 좌표 = 맵 px · 왼쪽 위 원점 · y 아래로) ----------
// 맵 그림·길 = world.json(맵 id 별) · 노드 자리 = Data/<맵>.json 의 slots_N · 게임 챕터·스테이지·노드 수 = BalanceConfig.chapters·stages(스테이지 표 CSV)
[Serializable]
public class WorldKitItem
@ -36,8 +39,7 @@ namespace TKS
public float[] roadX;
public float[] roadY;
public WorldLandmark[] landmarks;
public int nodes; // 스테이지 수(임시 · 기획 세션이 확정)
public int stageLink; // 이 챕터 노드가 쓰는 BalanceConfig.stages 인덱스(임시)
// world.json 의 nodes·stageLink(구 임시 연결 spec/stage_plan.json) 는 읽지 않는다 — 노드 = 스테이지 표(2026-11-05 145 스테이지)
}
[Serializable]
@ -56,6 +58,52 @@ namespace TKS
public WorldChapter[] chapters;
public const string Path = "ChapterMap/Data/world";
public const string DataRoot = "ChapterMap/Data/";
public WorldChapter FindMap(string map)
{
if (chapters != null)
foreach (var c in chapters)
if (c.map == map) return c;
return null;
}
/// <summary>Data/&lt;map&gt;.json(원화 세션 anchors) 의 slots_n(맵 px · 첫 칸 = 출발). 없으면 null.</summary>
public static Vector2[] AnchorSlots(string map, int n)
{
var t = Resources.Load<TextAsset>(DataRoot + map);
if (t == null || n <= 0) return null;
var m = Regex.Match(t.text, "\"slots_" + n + "\"\\s*:\\s*\\[((?:\\s*\\[[^\\]]*\\]\\s*,?)*)\\s*\\]");
if (!m.Success) return null;
var nums = Regex.Matches(m.Groups[1].Value, @"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?");
if (nums.Count != n * 2) return null;
var res = new Vector2[n];
for (int i = 0; i < n; i++)
res[i] = new Vector2(float.Parse(nums[2 * i].Value, CultureInfo.InvariantCulture), float.Parse(nums[2 * i + 1].Value, CultureInfo.InvariantCulture));
return res;
}
/// <summary>점 p 에 가장 가까운 길 위 지점의 호 길이.</summary>
public static float ArcOf(WorldChapter c, float[] acc, Vector2 p)
{
int m = acc.Length;
if (m == 1) return 0f;
float best = float.MaxValue, res = 0f;
for (int i = 1; i < m; i++)
{
var a = new Vector2(c.roadX[i - 1], c.roadY[i - 1]);
var b = new Vector2(c.roadX[i], c.roadY[i]);
var ab = b - a;
float t = Mathf.Clamp01(Vector2.Dot(p - a, ab) / Mathf.Max(ab.sqrMagnitude, 1e-6f));
float d = (a + ab * t - p).sqrMagnitude;
if (d < best)
{
best = d;
res = acc[i - 1] + (acc[i] - acc[i - 1]) * t;
}
}
return res;
}
public static WorldData Load()
{
@ -141,7 +189,8 @@ namespace TKS
/// <summary>
/// 게임 시작 화면 = 챕터 월드맵(스크롤 · 상하좌우로 끌기). 길 위 깃발 노드를 누르면 스테이지 팝업 → 출전 = 전투 시작 ·
/// 전투 결과를 탭하면 월드맵으로 돌아와 다음 노드가 열린다. 그림·좌표·부품 배치 = world.json · 전투 데이터 = BalanceConfig(C45).
/// 전투 결과를 탭하면 월드맵으로 돌아와 다음 노드가 열린다. 그림·길·부품 배치 = world.json · 노드 자리 = anchors slots_N(없으면 길 위 균등) ·
/// 챕터·노드·전투 = BalanceConfig.chapters·stages(스테이지 표 · C45) · 보스 노드 = type boss.
/// </summary>
public class WorldMapScreen : MonoBehaviour
{
@ -156,6 +205,8 @@ namespace TKS
BattleHUD hud;
SlingshotInput sling;
WorldData data;
BalanceConfig cfg;
readonly List<int> nodeStages = new List<int>(); // 현재 챕터 노드 → stages 인덱스
GameObject root;
CanvasGroup group;
@ -183,14 +234,50 @@ namespace TKS
int entryChapter = -1;
int entryNode = -1;
public bool HasData { get { return data != null && data.chapters != null && data.chapters.Length > 0; } }
public bool HasData
{
get { return data != null && data.chapters != null && data.chapters.Length > 0 && cfg != null && cfg.chapters != null && cfg.chapters.Length > 0 && cfg.stages.Length > 0; }
}
public bool IsVisible { get { return root != null && root.activeSelf; } }
public bool PopupVisible { get { return popup != null && popup.activeSelf; } }
public int Chapter { get { return chapter; } }
public int ChapterCount { get { return HasData ? data.chapters.Length : 0; } }
public int ChapterCount { get { return HasData ? cfg.chapters.Length : 0; } }
public WorldData Data { get { return data; } }
public Sprite MapSprite { get { return mapImage != null ? mapImage.sprite : null; } }
public int NodeCount { get { return nodeButtons.Count; } }
public int BossNodeCount { get; private set; }
/// <summary>챕터(0부터)의 노드 수 = 스테이지 표의 그 챕터 스테이지 수.</summary>
public int NodesIn(int ch)
{
return HasData && ch >= 0 && ch < cfg.chapters.Length ? cfg.StagesOfChapter(cfg.chapters[ch].no).Count : 0;
}
/// <summary>챕터(0부터) · 노드(0부터) → BalanceConfig.stages 인덱스(없으면 −1).</summary>
public int StageIndexOf(int ch, int node)
{
if (!HasData || ch < 0 || ch >= cfg.chapters.Length) return -1;
var list = cfg.StagesOfChapter(cfg.chapters[ch].no);
return node >= 0 && node < list.Count ? list[node] : -1;
}
/// <summary>챕터(0부터)의 맵 길(world.json · chapters.csv map 열). 없으면 첫 맵.</summary>
public WorldChapter MapOf(int ch)
{
if (!HasData) return null;
var wc = ch >= 0 && ch < cfg.chapters.Length ? data.FindMap(cfg.chapters[ch].map) : null;
return wc ?? data.chapters[0];
}
/// <summary>노드 자리(맵 px) = anchors slots_N · 그 N 이 없으면 길 위 균등.</summary>
public Vector2[] NodeSlots(int ch, out bool fromAnchors)
{
int n = NodesIn(ch);
var wc = MapOf(ch);
var s = wc != null ? WorldData.AnchorSlots(wc.map, n) : null;
fromAnchors = s != null;
return s ?? (wc != null ? WorldData.Slots(wc, n) : new Vector2[0]);
}
void Awake()
{
@ -208,9 +295,10 @@ namespace TKS
hud = h;
sling = s;
data = WorldData.Load();
cfg = b != null ? b.Config : null;
if (!HasData)
{
Debug.LogWarning("[TKS] 월드맵 데이터 없음(" + WorldData.Path + ") → 바로 전투");
Debug.LogWarning("[TKS] 월드맵 데이터 없음(" + WorldData.Path + " 또는 BalanceConfig.chapters — TKS/Import Stages) → 바로 전투");
return;
}
battle.ResultHandler = OnBattleResult;
@ -282,34 +370,42 @@ namespace TKS
public void ShowChapter(int ch)
{
if (!HasData) return;
chapter = Mathf.Clamp(ch, 0, data.chapters.Length - 1);
chapter = Mathf.Clamp(ch, 0, cfg.chapters.Length - 1);
ClosePopup();
var c = data.chapters[chapter];
var def = cfg.chapters[chapter];
var c = MapOf(chapter);
foreach (var go in spawned)
if (go != null) Destroy(go);
spawned.Clear();
nodeButtons.Clear();
cursor = null;
nodeStages.Clear();
nodeStages.AddRange(cfg.StagesOfChapter(def.no));
BossNodeCount = 0;
if (c.map != def.map) Debug.LogWarning("[TKS] 챕터 " + def.no + " 맵 「" + def.map + "」 이 world.json 에 없음 → " + c.map);
var sp = Resources.Load<Sprite>(MapRoot + c.map);
if (sp == null) Debug.LogWarning("[TKS] 맵 그림 없음: " + MapRoot + c.map);
mapImage.sprite = sp;
mapImage.color = sp != null ? Color.white : new Color(0.55f, 0.7f, 0.45f);
bannerText.text = "제" + c.no + "장 " + c.ko;
bannerText.text = "제" + def.no + "장 " + def.title;
prevButton.interactable = chapter > 0;
nextButton.interactable = chapter < data.chapters.Length - 1;
nextButton.interactable = chapter < cfg.chapters.Length - 1;
var slots = WorldData.Slots(c, c.nodes);
bool fromAnchors;
var slots = NodeSlots(chapter, out fromAnchors);
var acc = WorldData.Arc(c);
float total = acc[acc.Length - 1];
var len = new float[slots.Length];
for (int i = 0; i < slots.Length; i++) len[i] = WorldData.ArcOf(c, acc, slots[i]);
int cleared = ChapterUnlocked(chapter) ? WorldProgress.Cleared(chapter) : 0;
// 길 점(노드 사이)
// 길 점(노드 사이 · 노드 자리를 길에 투영한 호 길이 구간)
float spacing = Mathf.Max(8f, data.dotSpacing);
for (int i = 0; i + 1 < slots.Length; i++)
{
float l0 = total * i / (slots.Length - 1) + 44f;
float l1 = total * (i + 1) / (slots.Length - 1) - 44f;
float l0 = Mathf.Min(len[i], len[i + 1]) + 44f;
float l1 = Mathf.Max(len[i], len[i + 1]) - 44f;
if (l1 <= l0) continue;
int m = Mathf.Max(0, Mathf.FloorToInt((l1 - l0) / spacing));
for (int j = 0; j <= m; j++)
{
@ -332,7 +428,9 @@ namespace TKS
// 노드
for (int i = 0; i < slots.Length; i++)
{
bool boss = i == slots.Length - 1;
var stage = cfg.stages[nodeStages[i]];
bool boss = stage.kind == StageKind.Boss;
if (boss) BossNodeCount++;
var st = StateOf(chapter, i);
string key = (boss ? "flag_boss_" : "flag_") + (st == NodeState.Clear ? "clear" : st == NodeState.Open ? "open" : "lock");
if (boss) Piece("frame", slots[i] + V(data.flagFrame), false);
@ -346,7 +444,7 @@ namespace TKS
var kit = data.Kit(key);
if (st != NodeState.Lock && kit != null)
{
var t = Label(img.transform, c.no + "-" + (i + 1), boss ? 38 : (i + 1 >= 10 ? 26 : 32), Paper, new Vector2(kit.numberCenter[0], 1f - kit.numberCenter[1]));
var t = Label(img.transform, def.no + "-" + stage.stage, boss ? 38 : (def.no >= 10 || stage.stage >= 10 ? 26 : 32), Paper, new Vector2(kit.numberCenter[0], 1f - kit.numberCenter[1]));
t.raycastTarget = false;
}
if (st == NodeState.Clear)
@ -392,8 +490,8 @@ namespace TKS
public bool ChapterUnlocked(int ch)
{
if (!HasData || ch < 0 || ch >= data.chapters.Length) return false;
return ch == 0 || WorldProgress.Cleared(ch - 1) >= data.chapters[ch - 1].nodes;
if (!HasData || ch < 0 || ch >= cfg.chapters.Length) return false;
return ch == 0 || WorldProgress.Cleared(ch - 1) >= NodesIn(ch - 1);
}
// ---------- 팝업 ----------
@ -407,21 +505,18 @@ namespace TKS
public void OpenPopup(int node)
{
if (!HasData) return;
var c = data.chapters[chapter];
if (node < 0 || node >= c.nodes) return;
int si = StageIndexOf(chapter, node);
if (si < 0) return;
popupNode = node;
var st = StateOf(chapter, node);
var stage = StageFor(c);
bool boss = node == c.nodes - 1;
popupTitle.text = c.no + "-" + (node + 1) + (boss ? " 보스" : "") + "\n" + c.ko;
var def = cfg.chapters[chapter];
var stage = cfg.stages[si];
string tag = stage.kind == StageKind.Boss ? " 보스" : stage.kind == StageKind.Elite ? " 엘리트" : "";
popupTitle.text = def.no + "-" + stage.stage + tag + "\n제" + def.no + "장 " + def.title;
var body = new System.Text.StringBuilder();
if (stage != null)
{
body.Append(stage.title);
if (!string.IsNullOrEmpty(stage.subtitle)) body.Append(" · ").Append(stage.subtitle);
if (!string.IsNullOrEmpty(stage.place)) body.Append("\n<size=26>").Append(stage.place).Append(string.IsNullOrEmpty(stage.bgTheme) ? "" : " · " + stage.bgTheme).Append("</size>");
body.Append("\n\n").Append(EnemySummary(stage));
}
body.Append("\n\n<size=24>(임시 구성 · 전투 = 스테이지 ").Append(c.stageLink + 1).Append(" · 기획 확정 후 교체)</size>");
if (st == NodeState.Lock) body.Append("\n\n잠긴 스테이지 — 앞 스테이지를 먼저 깨세요");
popupBody.text = body.ToString();
popupGo.interactable = st != NodeState.Lock;
@ -450,14 +545,16 @@ namespace TKS
battle.StartStage(0);
return;
}
chapter = Mathf.Clamp(ch, 0, data.chapters.Length - 1);
chapter = Mathf.Clamp(ch, 0, cfg.chapters.Length - 1);
int si = StageIndexOf(chapter, node);
if (si < 0) return;
entryChapter = chapter;
entryNode = node;
ClosePopup();
root.SetActive(false);
if (hud != null) hud.gameObject.SetActive(true);
if (sling != null) sling.enabled = true;
battle.StartStage(data.chapters[chapter].stageLink);
battle.StartStage(si);
}
void OnBattleResult(bool win)
@ -466,27 +563,24 @@ namespace TKS
if (win && entryNode >= 0)
{
WorldProgress.MarkCleared(ch, entryNode);
// 챕터 보스를 깨면 다음 챕터 맵으로
if (entryNode == data.chapters[ch].nodes - 1 && ch + 1 < data.chapters.Length) ch++;
// 챕터 마지막 스테이지를 깨면 다음 챕터 맵으로
if (entryNode == NodesIn(ch) - 1 && ch + 1 < cfg.chapters.Length) ch++;
}
entryChapter = entryNode = -1;
Show(ch);
}
BalanceConfig.StageDef StageFor(WorldChapter c)
{
if (battle == null || battle.Config == null || battle.Config.stages.Length == 0) return null;
return battle.Config.stages[Mathf.Clamp(c.stageLink, 0, battle.Config.stages.Length - 1)];
}
/// <summary>적 수·병종 + 장수(보스 · 엘리트) 이름.</summary>
static string EnemySummary(BalanceConfig.StageDef stage)
{
var counts = new int[UnitTypeInfo.Count];
string bossName = null;
var bosses = new List<string>();
var elites = new List<string>();
foreach (var e in stage.enemies)
{
counts[(int)e.type]++;
if (e.isBoss) bossName = e.name;
if (e.isBoss) bosses.Add(e.name);
else if (e.art.Contains("_boss_")) elites.Add(e.name);
}
var sb = new System.Text.StringBuilder("적 " + stage.enemies.Count + "기 · ");
bool first = true;
@ -497,7 +591,8 @@ namespace TKS
sb.Append(UnitTypeInfo.Korean((UnitType)t)).Append(" ").Append(counts[t]);
first = false;
}
if (bossName != null) sb.Append("\n보스 ").Append(bossName);
if (bosses.Count > 0) sb.Append("\n보스 ").Append(string.Join(" · ", bosses.ToArray()));
if (elites.Count > 0) sb.Append("\n장수 ").Append(string.Join(" · ", elites.ToArray()));
return sb.ToString();
}

View File

@ -1,5 +1,6 @@
using System.Collections;
using System.Collections.Generic;
using System.IO;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.SceneManagement;
@ -508,13 +509,15 @@ namespace TKS.Tests
Assert.AreEqual(bm.AliveCount(Faction.Enemy), enemyArt, "스테이지 1 황건적은 전원 원화(hgj_*)로 표시");
Assert.IsTrue(bm.Board.HasArt, "전장 배경 = Resources/Board/" + cfg.boardArt);
// 원화 2차 납품(2026-11-04): 동탁군 보·창·기·궁 4종 반영 · 여포(dz_boss_lvbu)는 보류 → 도형
bm.StartStage(1);
// 원화 2차 납품(2026-11-04): 동탁군 보·창·기·궁 4종 반영 → 스테이지 표의 첫 동탁군 스테이지(2장)
int dzStage = System.Array.FindIndex(cfg.stages, s => s.faction == "dz");
Assert.GreaterOrEqual(dzStage, 0, "동탁군 스테이지");
bm.StartStage(dzStage);
yield return null;
foreach (var e in bm.AliveUnits(Faction.Enemy))
{
if (e.IsBoss) continue;
Assert.IsTrue(e.View.HasArt, "스테이지 2 동탁군 병종 4종은 원화(" + e.ArtId + ")");
Assert.IsTrue(e.View.HasArt, "동탁군 병종 4종은 원화(" + e.ArtId + ")");
}
bm.StartStage(0);
yield return null;
@ -577,7 +580,7 @@ namespace TKS.Tests
Assert.AreEqual(BattlePhase.Setup, b.Phase, "월드맵 표시 중 = 전투 없음");
Assert.AreEqual(0, b.Units.Count, "월드맵 표시 중 = 유닛 없음");
Assert.IsNotNull(wm.MapSprite, "챕터 1 맵 그림");
Assert.AreEqual(wm.Data.chapters[0].nodes, wm.NodeCount, "노드 수 = world.json nodes");
Assert.AreEqual(wm.NodesIn(0), wm.NodeCount, "노드 수 = 스테이지 표 1장 스테이지 수");
Assert.AreEqual(NodeState.Open, wm.StateOf(0, 0), "1-1 = 도전 가능");
Assert.AreEqual(NodeState.Lock, wm.StateOf(0, 1), "1-2 = 잠김");
Assert.IsFalse(wm.ChapterUnlocked(1), "2장은 1장 보스를 깨야 열림");
@ -600,7 +603,8 @@ namespace TKS.Tests
yield return null;
Assert.IsFalse(wm.IsVisible, "출전 → 월드맵 닫힘");
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase);
var stage = cfg.stages[wm.Data.chapters[0].stageLink];
Assert.AreEqual(wm.StageIndexOf(0, 0), bm.StageIndex, "1-1 노드 = 스테이지 표 1-1 전투");
var stage = cfg.stages[wm.StageIndexOf(0, 0)];
Assert.AreEqual(stage.enemies.Count, bm.AliveCount(Faction.Enemy), "팝업 노드의 전투가 시작");
bm.ForcePhase(BattlePhase.StageClear);
@ -634,8 +638,6 @@ namespace TKS.Tests
Assert.AreEqual(d.mapSize[0], sp.rect.width, 0.5f, c.id + " 맵 가로 = world.json mapSize");
Assert.AreEqual(d.mapSize[1], sp.rect.height, 0.5f, c.id + " 맵 세로");
Assert.GreaterOrEqual(c.roadX.Length, 2, c.id + " 길");
foreach (var s in WorldData.Slots(c, c.nodes))
Assert.IsTrue(s.x >= 0f && s.x <= d.mapSize[0] && s.y >= 0f && s.y <= d.mapSize[1], c.id + " 노드가 맵 안");
}
foreach (var k in d.kit)
Assert.IsNotNull(Resources.Load<Sprite>("ChapterMap/UI/" + k.name), "UI 부품 " + k.name);
@ -656,12 +658,15 @@ namespace TKS.Tests
foreach (var p in poses)
Assert.IsTrue(a.Clips.ContainsKey(v + "_" + p), h.art + " " + v + "_" + p);
}
// 병사 = 납품 세력(hgj·dz·cc)은 반드시 원화 · 장수(<세력>_boss_<병음>) = 납품된 것만 원화(나머지는 도형 대체 · StageData_MissingArt 테스트)
string[] delivered = { "hgj_", "dz_", "cc_" };
foreach (var s in cfg.stages)
foreach (var e in s.enemies)
{
bool general = e.art.Contains("_boss_");
bool want = false;
foreach (var f in delivered) want |= e.art.StartsWith(f);
if (general) want = Resources.Load<TextAsset>("Units/" + e.art + "/meta") != null;
if (!want) continue;
var a = UnitArt.Load(e.art);
Assert.IsNotNull(a, "적 원화 " + e.art);
@ -684,5 +689,158 @@ namespace TKS.Tests
Assert.AreEqual("front_idle", u.View.Anim.Current, "아래를 보면 앞모습");
Assert.IsTrue(u.View.Anim.Renderer.flipX, "왼쪽 = 좌우 반전");
}
// ---------- 스테이지 표(Assets/Data/Stages/*.csv · 145 스테이지 · 2026-11-05 #1086) ----------
static StageTable.Tables ReadStageTable()
{
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);
return StageTable.Parse(texts);
}
[Test]
public void StageTable_Csv_145Rows_BossEveryFifth_ValidUnitAndArtIds()
{
var t = ReadStageTable();
Assert.IsEmpty(t.errors, "형식 오류(병종 id · 세력 · 무장 id · 번호): " + string.Join(" / ", t.errors.ToArray()));
Assert.AreEqual(145, t.stages.Count, "오픈 스펙 = 챕터 1~10 · 145 스테이지(기획서 #1086 · D1 = A)");
var artPrefixes = new HashSet<string>();
foreach (var f in t.factions.Values) artPrefixes.Add(f.art);
foreach (var f in t.factions.Values) artPrefixes.Add(f.code);
var idRule = new System.Text.RegularExpressions.Regex("^([a-z]+)_boss_[a-z]+$");
foreach (var s in t.stages)
{
string at = s.chapter + "-" + s.stage;
Assert.AreEqual(s.stage % 5 == 0, s.kind == StageKind.Boss, at + " 보스 = 5의 배수 스테이지(기획서 §1 원칙 1)");
Assert.Greater(s.SoldierCount, 0, at + " 병사");
int bosses = s.generals.FindAll(x => x.boss).Count;
if (s.kind == StageKind.Boss) Assert.GreaterOrEqual(bosses, 1, at + " 보스 스테이지에 보스 무장");
if (s.kind == StageKind.Elite) Assert.GreaterOrEqual(s.generals.Count, 1, at + " 엘리트 스테이지에 무장");
if (s.kind == StageKind.Normal) Assert.AreEqual(0, s.generals.Count, at + " 일반 스테이지 = 무장 없음");
foreach (var g in s.generals)
{
var m = idRule.Match(g.id);
Assert.IsTrue(m.Success, at + " 무장 아트 id 규칙 <세력>_boss_<병음>: " + g.id);
Assert.IsTrue(artPrefixes.Contains(m.Groups[1].Value), at + " 무장 id 세력 접두: " + g.id);
}
}
}
[Test]
public void StageTable_ImportedConfig_MatchesCsv_FormationFitsBoard()
{
var c = Resources.Load<BalanceConfig>("BalanceConfig");
var t = ReadStageTable();
Assert.AreEqual(t.stages.Count, c.stages.Length, "CSV 를 고쳤으면 TKS/Import Stages 로 다시 임포트");
Assert.AreEqual(t.chapters.Count, c.chapters.Length);
float halfW = c.boardSize.x / 2f, halfH = c.boardSize.y / 2f;
float playerFront = float.MinValue;
foreach (var p in c.playerSpawnPoints) playerFront = Mathf.Max(playerFront, p.y);
for (int i = 0; i < c.stages.Length; i++)
{
var s = c.stages[i];
var row = t.stages.Find(x => x.chapter == s.chapter && x.stage == s.stage);
string at = s.chapter + "-" + s.stage;
Assert.IsNotNull(row, at + " CSV 행");
Assert.AreEqual(row.kind, s.kind, at);
Assert.AreEqual(row.title, s.title, at);
Assert.AreEqual(row.SoldierCount + row.generals.Count, s.enemies.Count, at + " 적 수 = 병사 + 무장");
var counts = new int[UnitTypeInfo.Count];
int bosses = 0;
foreach (var e in s.enemies)
{
if (!e.art.Contains("_boss_")) counts[(int)e.type]++;
if (e.isBoss) bosses++;
Assert.IsFalse(string.IsNullOrEmpty(e.art), at + " 아트 id");
float r = c.GetStats(e.type).radius * (e.isBoss ? c.bossRadiusMult : 1f);
Assert.IsTrue(Mathf.Abs(e.position.x) <= halfW - r && e.position.y <= halfH - r, at + " " + e.name + " 전장 안 " + e.position);
Assert.Greater(e.position.y, playerFront + 2f, at + " " + e.name + " 아군 출발선보다 앞(적 쪽)");
}
for (int k = 0; k < UnitTypeInfo.Count; k++) Assert.AreEqual(row.soldiers[k], counts[k], at + " " + UnitTypeInfo.Korean((UnitType)k) + " 수");
Assert.AreEqual(row.generals.FindAll(g => g.boss).Count, bosses, at + " 보스 수");
for (int a = 0; a < s.enemies.Count; a++)
for (int b = a + 1; b < s.enemies.Count; b++)
{
var ea = s.enemies[a];
var eb = s.enemies[b];
float ra = c.GetStats(ea.type).radius * (ea.isBoss ? c.bossRadiusMult : 1f);
float rb = c.GetStats(eb.type).radius * (eb.isBoss ? c.bossRadiusMult : 1f);
Assert.Greater(Vector2.Distance(ea.position, eb.position), ra + rb, at + " " + ea.name + "·" + eb.name + " 겹침");
}
}
}
[UnityTest]
public IEnumerator StageData_MissingArt_FallsBackToShapes()
{
// 아트가 아직 없는 무장(보스 신규 25 · 엘리트 25)·세력(원소·원술·형주 베리에이션) = 도형으로 정상 전투
int idx = -1;
for (int i = 0; i < cfg.stages.Length && idx < 0; i++)
foreach (var e in cfg.stages[i].enemies)
if (e.isBoss && UnitArt.Load(e.art) == null) { idx = i; break; }
Assert.GreaterOrEqual(idx, 0, "아트 없는 보스 스테이지");
bm.StartStage(idx);
yield return null;
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase);
Assert.AreEqual(cfg.stages[idx].enemies.Count, bm.AliveCount(Faction.Enemy));
int shapes = 0;
foreach (var e in bm.AliveUnits(Faction.Enemy))
{
bool has = UnitArt.Load(e.ArtId) != null;
Assert.AreEqual(has, e.View.HasArt, e.DisplayName + " 아트 있으면 원화 · 없으면 도형");
if (!has) shapes++;
}
Assert.Greater(shapes, 0, "도형 대체 유닛");
bm.StartStage(0);
yield return null;
}
[UnityTest]
public IEnumerator WorldMap_ChaptersFromStageTable_AnchorSlotsBossNodesUnlock()
{
var wm = WorldMapScreen.Instance;
Assert.AreEqual(cfg.chapters.Length, wm.ChapterCount, "챕터 = chapters.csv");
int total = 0;
for (int ch = 0; ch < wm.ChapterCount; ch++)
{
var def = cfg.chapters[ch];
var stages = cfg.StagesOfChapter(def.no);
Assert.AreEqual(def.map, wm.MapOf(ch).map, "제" + def.no + "장 맵 = chapters.csv map");
Assert.IsNotNull(Resources.Load<Sprite>("ChapterMap/Maps/" + def.map), def.map + " 맵 그림");
bool fromAnchors;
var slots = wm.NodeSlots(ch, out fromAnchors);
Assert.IsTrue(fromAnchors, "제" + def.no + "장 노드 자리 = anchors slots_" + stages.Count);
Assert.AreEqual(stages.Count, slots.Length);
foreach (var p in slots)
Assert.IsTrue(p.x >= 0f && p.x <= wm.Data.mapSize[0] && p.y >= 0f && p.y <= wm.Data.mapSize[1], def.map + " 노드가 맵 안");
wm.Show(ch);
yield return null;
Assert.AreEqual(stages.Count, wm.NodeCount, "제" + def.no + "장 노드 수 = 스테이지 수");
int bosses = 0;
foreach (var si in stages)
if (cfg.stages[si].kind == StageKind.Boss) bosses++;
Assert.AreEqual(bosses, wm.BossNodeCount, "제" + def.no + "장 보스 노드");
total += stages.Count;
}
Assert.AreEqual(cfg.stages.Length, total, "모든 스테이지가 어느 챕터 노드");
// 해금 = 앞 스테이지 클리어 · 챕터 마지막 노드를 깨면 다음 챕터 열림 · 마지막 노드 클리어 → 다음 챕터 맵으로
Assert.IsFalse(wm.ChapterUnlocked(1));
for (int n = 0; n < wm.NodesIn(0) - 1; n++) WorldProgress.MarkCleared(0, n);
Assert.AreEqual(NodeState.Open, wm.StateOf(0, wm.NodesIn(0) - 1));
Assert.IsFalse(wm.ChapterUnlocked(1), "보스 전 = 2장 잠김");
wm.EnterStage(0, wm.NodesIn(0) - 1);
yield return null;
Assert.AreEqual(wm.StageIndexOf(0, wm.NodesIn(0) - 1), bm.StageIndex);
Assert.AreEqual(StageKind.Boss, bm.Stage.kind, "1장 마지막 = 보스");
bm.ForcePhase(BattlePhase.StageClear);
bm.ContinueAfterResult();
yield return null;
Assert.IsTrue(wm.ChapterUnlocked(1), "1장 보스 클리어 → 2장 열림");
Assert.AreEqual(1, wm.Chapter, "다음 챕터 맵으로");
Assert.AreEqual(NodeState.Open, wm.StateOf(1, 0));
}
}
}

View File

@ -43,7 +43,8 @@ Unity.exe -projectPath <proj> -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
## 코드 규칙
- 수치·영웅·스테이지 SOT = `Assets/Resources/BalanceConfig.asset`(Inspector 조정 · 최초 시드만 `Assets/Scripts/Editor/BalanceDefaults.cs`). 코드·테스트에 수치를 직접 적지 않는다(C45).
- 수치·영웅 SOT = `Assets/Resources/BalanceConfig.asset`(Inspector 조정 · 최초 시드만 `Assets/Scripts/Editor/BalanceDefaults.cs`). 코드·테스트에 수치를 직접 적지 않는다(C45).
- **챕터·스테이지 SOT = `Assets/Data/Stages/*.csv`(utf-8-sig · 2026-11-05 145 스테이지 · 기획서 #1086 §4 전사)** — `stages.csv`(chapter·stage·title·type normal|elite|boss·place·faction·병종 4 수·generals `id:병종:true|false`·bg_theme·note) · `chapters.csv`(장 제목·연도·**월드맵 맵 id**·AI 출발값) · `generals.csv`(무장 id → 이름·배율 덮어쓰기) · `factions.csv`(세력 코드 → 이름·병사 아트 접두). 고친 뒤 메뉴 `TKS/Import Stages (스테이지 임포트)`(`StageImporter` · 형식 오류면 자산 미변경) → `BalanceConfig.chapters·stages`. 적 좌표 = `BalanceConfig.formation` 규칙(전위·중군·후군·장수 줄 · `StageTable.Enemies`) · 장수 배율 = `generals.csv` 가 비면 `boss/eliteGeneral*Mult`. 월드맵 노드 = 스테이지 표(챕터별 수 · 보스 = type boss) · 노드 자리 = `ChapterMap/Data/<맵>.json` `slots_N`(없으면 길 위 균등).
- 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 22종 이상 전부 통과 확인.
- 콜라이더 질의 전 `Physics2D.SyncTransforms()` · 유닛 제거는 `SetActive(false)` 후 `Destroy` · 자식 렌더러가 있는 표시물은 GameObject 통째로 끈다.
- **궁병 규칙(PD 2026-11-04 · #1080)**: 사거리 = `slingMaxDistance`(얇은 원). 조준 방향 사거리 안의 적(가장자리 간격 기준) = **제자리 사격** · 사거리 밖 ~ ×(1 + `archerRangeRatio`)(붉은 도넛) 안의 적 = 들어올 만큼만(최대 `ArcherMoveAllow` = 슬링 × 비율) 다가가 사격 · 더 멀면 **목표점까지 이동만**(#1079). 반격 없음 · 목표점 명중 크리. **근접 전투**(돌진·반격·관통·견제·밀려 닿은 공격)에서 궁병은 근접 유닛 상대 주는 피해 × `archerMeleeDealtRatio`(0.5) · 받는 피해 × `archerMeleeTakenRatio`(1.5) — `Combat.Damage(…, melee)` · 원거리 사격은 패널티 없음.
@ -63,7 +64,8 @@ Unity.exe -projectPath <proj> -executeMethod TKS.EditorTools.ProjectSetup.AutoPl
## 구조 (README 와 동일)
- `Assets/Scripts/Runtime/` — `GameBootstrap`(씬 구성) · `BattleManager` · `ActionResolver` · `Combat` · `SlingshotInput`(조작·스크롤·줌) · `RangeIndicator`(사거리 원·궁병 도넛) · `CameraController`(넓은 전장 카메라) · `EnemyAI`(교전 범위) · `BattleHUD`(HUD·미니맵) · `Unit`/`UnitView` · `UnitArt`(아트 로더·`FrameAnimator`) · `Board` · `Fx` · `AutoPlayDriver`
- `Assets/Scripts/Editor/` — `ProjectSetup`(CLI 진입점·씬 생성) · `BalanceDefaults`(최초 시드) · `UnitArtImporter`(아트 임포트 설정)
- `Assets/Scripts/Editor/` — `ProjectSetup`(CLI 진입점·씬 생성) · `BalanceDefaults`(최초 시드) · `StageImporter`(스테이지 표 → 자산) · `UnitArtImporter`(아트 임포트 설정)
- `Assets/Data/Stages/` — 챕터·스테이지 표 CSV 4장(위 「코드 규칙」) · 해석·배치 = `Runtime/StageTable`
- `Assets/Resources/Units/<art>/` — 유닛 아트 프레임 + meta.json(위 절)
- `Assets/Tests/PlayMode/` — `BattleFlowTests`
- `.claude/skills/` — Unity CLI 에이전트 스킬(`unity skill install claude-code --local` 산출물 · 재설치는 `unity skill refresh`)