스테이지 데이터 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; using UnityEngine;
namespace TKS.EditorTools namespace TKS.EditorTools
@ -7,6 +6,7 @@ namespace TKS.EditorTools
/// BalanceConfig 자산의 최초 1회 시드 값(프로토타입 초안). 이후 수치 조정은 자산(Inspector)에서 한다 — 코드가 SOT 가 아니다. /// BalanceConfig 자산의 최초 1회 시드 값(프로토타입 초안). 이후 수치 조정은 자산(Inspector)에서 한다 — 코드가 SOT 가 아니다.
/// 전장 = 가로 20 × 세로 32 (x −10~10 · y −16~16) · 화면에는 세로 18 만 보여 스크롤한다. 아군은 아래(y −13) · 적은 전위/중군/후군으로 흩어 배치. /// 전장 = 가로 20 × 세로 32 (x −10~10 · y −16~16) · 화면에는 세로 18 만 보여 스크롤한다. 아군은 아래(y −13) · 적은 전위/중군/후군으로 흩어 배치.
/// 아트 id = Resources/Units/&lt;id&gt;/ (아군 = 스켈레탈 세션 리그 프레임 liubei·guanyu·zhangfei · 적 = 일러 세션 납품 이름 &lt;세력&gt;_&lt;병종&gt; · 없으면 도형). /// 아트 id = Resources/Units/&lt;id&gt;/ (아군 = 스켈레탈 세션 리그 프레임 liubei·guanyu·zhangfei · 적 = 일러 세션 납품 이름 &lt;세력&gt;_&lt;병종&gt; · 없으면 도형).
/// 챕터·스테이지는 시드하지 않는다 — Assets/Data/Stages/*.csv 가 SOT(StageImporter · 2026-11-05 145 스테이지). 여기는 배치 규칙·장수 배율 출발값만.
/// </summary> /// </summary>
public static class BalanceDefaults public static class BalanceDefaults
{ {
@ -70,52 +70,20 @@ namespace TKS.EditorTools
Hero("황충", UnitType.Archer, 1.0f, 1.25f, "huangzhong"), // 원화 세션 재디자인(2026-11-04 #1081) 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, frontY = -5.5f, middleY = 0.5f, rearY = 7.5f, commandY = 11f,
// 전위 colGap = 2.6f, rowGap = 1.8f, maxPerRow = 5, rowStagger = 0.5f,
E("hgj", "황건적 보병", UnitType.Infantry, -3f, -6f), frontShare = 0.4f, rearShare = 0.15f,
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)),
}; };
// 장수 배율 출발값(밸런스 세션 조정) · 보스 = 구 시드 보스 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) 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 }; 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) if (created || overwrite)
{ {
BalanceDefaults.Apply(cfg); BalanceDefaults.Apply(cfg);
StageImporter.ImportInto(cfg); // 챕터·스테이지 = Assets/Data/Stages/*.csv
EditorUtility.SetDirty(cfg); EditorUtility.SetDirty(cfg);
} }
return 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 namespace TKS
{ {
public enum StageKind
{
Normal = 0,
Elite = 1, // 이름 있는 무장이 나오는 일반 스테이지(무장 isBoss = false · 배율만 보정)
Boss = 2 // 5·10·15·20
}
/// <summary> /// <summary>
/// 밸런스·스테이지 데이터의 단일 출처(SOT). 코드에는 수치를 두지 않고 이 자산만 읽는다. /// 밸런스·스테이지 데이터의 단일 출처(SOT). 코드에는 수치를 두지 않고 이 자산만 읽는다.
/// 기본값은 에디터 셋업(ProjectSetup)이 최초 1회 시드한다. /// 기본값은 에디터 셋업(ProjectSetup)이 최초 1회 시드한다.
@ -58,6 +65,50 @@ namespace TKS
[Tooltip("이 거리 안에 아군이 들어와야 적이 움직인다(넓은 전장에서 부대별 교전)")] [Tooltip("이 거리 안에 아군이 들어와야 적이 움직인다(넓은 전장에서 부대별 교전)")]
public float aiAggroRange = 11f; public float aiAggroRange = 11f;
public List<EnemySpawn> enemies = new List<EnemySpawn>(); 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("병종 기본 능력치")] [Header("병종 기본 능력치")]
@ -119,9 +170,19 @@ namespace TKS
[Header("아군 영웅 (최대 5)")] [Header("아군 영웅 (최대 5)")]
public HeroDef[] heroes = new HeroDef[0]; 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]; 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) public UnitStats GetStats(UnitType t)
{ {
for (int i = 0; i < unitStats.Length; i++) for (int i = 0; i < unitStats.Length; i++)
@ -131,6 +192,17 @@ namespace TKS
throw new InvalidOperationException("BalanceConfig 에 " + t + " 능력치가 없습니다."); 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) public float Matchup(UnitType attacker, UnitType defender)
{ {
int idx = (int)attacker * UnitTypeInfo.Count + (int)defender; int idx = (int)attacker * UnitTypeInfo.Count + (int)defender;

View File

@ -49,7 +49,13 @@ namespace TKS
es.AddComponent<StandaloneInputModule>(); 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); 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 = 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); turnText.color = new Color(1f, 0.95f, 0.6f);
@ -157,7 +163,9 @@ namespace TKS
void Refresh() void Refresh()
{ {
var stage = battle.Stage; 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); countText.text = "아군 " + battle.AliveCount(Faction.Player) + " · 적 " + battle.AliveCount(Faction.Enemy);
switch (battle.Phase) 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; 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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace TKS namespace TKS
{ {
// ---------- 데이터(Resources/ChapterMap/Data/world.json · 원화 세션 chap_install.py 생성 · 좌표 = 맵 px · 왼쪽 위 원점 · y 아래로) ---------- // ---------- 데이터(Resources/ChapterMap/Data/world.json · 원화 세션 chap_install.py 생성 · 좌표 = 맵 px · 왼쪽 위 원점 · y 아래로) ----------
// 맵 그림·길 = world.json(맵 id 별) · 노드 자리 = Data/<맵>.json 의 slots_N · 게임 챕터·스테이지·노드 수 = BalanceConfig.chapters·stages(스테이지 표 CSV)
[Serializable] [Serializable]
public class WorldKitItem public class WorldKitItem
@ -36,8 +39,7 @@ namespace TKS
public float[] roadX; public float[] roadX;
public float[] roadY; public float[] roadY;
public WorldLandmark[] landmarks; public WorldLandmark[] landmarks;
public int nodes; // 스테이지 수(임시 · 기획 세션이 확정) // world.json 의 nodes·stageLink(구 임시 연결 spec/stage_plan.json) 는 읽지 않는다 — 노드 = 스테이지 표(2026-11-05 145 스테이지)
public int stageLink; // 이 챕터 노드가 쓰는 BalanceConfig.stages 인덱스(임시)
} }
[Serializable] [Serializable]
@ -56,6 +58,52 @@ namespace TKS
public WorldChapter[] chapters; public WorldChapter[] chapters;
public const string Path = "ChapterMap/Data/world"; 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() public static WorldData Load()
{ {
@ -141,7 +189,8 @@ namespace TKS
/// <summary> /// <summary>
/// 게임 시작 화면 = 챕터 월드맵(스크롤 · 상하좌우로 끌기). 길 위 깃발 노드를 누르면 스테이지 팝업 → 출전 = 전투 시작 · /// 게임 시작 화면 = 챕터 월드맵(스크롤 · 상하좌우로 끌기). 길 위 깃발 노드를 누르면 스테이지 팝업 → 출전 = 전투 시작 ·
/// 전투 결과를 탭하면 월드맵으로 돌아와 다음 노드가 열린다. 그림·좌표·부품 배치 = world.json · 전투 데이터 = BalanceConfig(C45). /// 전투 결과를 탭하면 월드맵으로 돌아와 다음 노드가 열린다. 그림·길·부품 배치 = world.json · 노드 자리 = anchors slots_N(없으면 길 위 균등) ·
/// 챕터·노드·전투 = BalanceConfig.chapters·stages(스테이지 표 · C45) · 보스 노드 = type boss.
/// </summary> /// </summary>
public class WorldMapScreen : MonoBehaviour public class WorldMapScreen : MonoBehaviour
{ {
@ -156,6 +205,8 @@ namespace TKS
BattleHUD hud; BattleHUD hud;
SlingshotInput sling; SlingshotInput sling;
WorldData data; WorldData data;
BalanceConfig cfg;
readonly List<int> nodeStages = new List<int>(); // 현재 챕터 노드 → stages 인덱스
GameObject root; GameObject root;
CanvasGroup group; CanvasGroup group;
@ -183,14 +234,50 @@ namespace TKS
int entryChapter = -1; int entryChapter = -1;
int entryNode = -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 IsVisible { get { return root != null && root.activeSelf; } }
public bool PopupVisible { get { return popup != null && popup.activeSelf; } } public bool PopupVisible { get { return popup != null && popup.activeSelf; } }
public int Chapter { get { return chapter; } } 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 WorldData Data { get { return data; } }
public Sprite MapSprite { get { return mapImage != null ? mapImage.sprite : null; } } public Sprite MapSprite { get { return mapImage != null ? mapImage.sprite : null; } }
public int NodeCount { get { return nodeButtons.Count; } } 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() void Awake()
{ {
@ -208,9 +295,10 @@ namespace TKS
hud = h; hud = h;
sling = s; sling = s;
data = WorldData.Load(); data = WorldData.Load();
cfg = b != null ? b.Config : null;
if (!HasData) if (!HasData)
{ {
Debug.LogWarning("[TKS] 월드맵 데이터 없음(" + WorldData.Path + ") → 바로 전투"); Debug.LogWarning("[TKS] 월드맵 데이터 없음(" + WorldData.Path + " 또는 BalanceConfig.chapters — TKS/Import Stages) → 바로 전투");
return; return;
} }
battle.ResultHandler = OnBattleResult; battle.ResultHandler = OnBattleResult;
@ -282,34 +370,42 @@ namespace TKS
public void ShowChapter(int ch) public void ShowChapter(int ch)
{ {
if (!HasData) return; if (!HasData) return;
chapter = Mathf.Clamp(ch, 0, data.chapters.Length - 1); chapter = Mathf.Clamp(ch, 0, cfg.chapters.Length - 1);
ClosePopup(); ClosePopup();
var c = data.chapters[chapter]; var def = cfg.chapters[chapter];
var c = MapOf(chapter);
foreach (var go in spawned) foreach (var go in spawned)
if (go != null) Destroy(go); if (go != null) Destroy(go);
spawned.Clear(); spawned.Clear();
nodeButtons.Clear(); nodeButtons.Clear();
cursor = null; 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); var sp = Resources.Load<Sprite>(MapRoot + c.map);
if (sp == null) Debug.LogWarning("[TKS] 맵 그림 없음: " + MapRoot + c.map); if (sp == null) Debug.LogWarning("[TKS] 맵 그림 없음: " + MapRoot + c.map);
mapImage.sprite = sp; mapImage.sprite = sp;
mapImage.color = sp != null ? Color.white : new Color(0.55f, 0.7f, 0.45f); 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; 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); 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; int cleared = ChapterUnlocked(chapter) ? WorldProgress.Cleared(chapter) : 0;
// 길 점(노드 사이) // 길 점(노드 사이 · 노드 자리를 길에 투영한 호 길이 구간)
float spacing = Mathf.Max(8f, data.dotSpacing); float spacing = Mathf.Max(8f, data.dotSpacing);
for (int i = 0; i + 1 < slots.Length; i++) for (int i = 0; i + 1 < slots.Length; i++)
{ {
float l0 = total * i / (slots.Length - 1) + 44f; float l0 = Mathf.Min(len[i], len[i + 1]) + 44f;
float l1 = total * (i + 1) / (slots.Length - 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)); int m = Mathf.Max(0, Mathf.FloorToInt((l1 - l0) / spacing));
for (int j = 0; j <= m; j++) for (int j = 0; j <= m; j++)
{ {
@ -332,7 +428,9 @@ namespace TKS
// 노드 // 노드
for (int i = 0; i < slots.Length; i++) 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); var st = StateOf(chapter, i);
string key = (boss ? "flag_boss_" : "flag_") + (st == NodeState.Clear ? "clear" : st == NodeState.Open ? "open" : "lock"); 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); if (boss) Piece("frame", slots[i] + V(data.flagFrame), false);
@ -346,7 +444,7 @@ namespace TKS
var kit = data.Kit(key); var kit = data.Kit(key);
if (st != NodeState.Lock && kit != null) 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; t.raycastTarget = false;
} }
if (st == NodeState.Clear) if (st == NodeState.Clear)
@ -392,8 +490,8 @@ namespace TKS
public bool ChapterUnlocked(int ch) public bool ChapterUnlocked(int ch)
{ {
if (!HasData || ch < 0 || ch >= data.chapters.Length) return false; if (!HasData || ch < 0 || ch >= cfg.chapters.Length) return false;
return ch == 0 || WorldProgress.Cleared(ch - 1) >= data.chapters[ch - 1].nodes; return ch == 0 || WorldProgress.Cleared(ch - 1) >= NodesIn(ch - 1);
} }
// ---------- 팝업 ---------- // ---------- 팝업 ----------
@ -407,21 +505,18 @@ namespace TKS
public void OpenPopup(int node) public void OpenPopup(int node)
{ {
if (!HasData) return; if (!HasData) return;
var c = data.chapters[chapter]; int si = StageIndexOf(chapter, node);
if (node < 0 || node >= c.nodes) return; if (si < 0) return;
popupNode = node; popupNode = node;
var st = StateOf(chapter, node); var st = StateOf(chapter, node);
var stage = StageFor(c); var def = cfg.chapters[chapter];
bool boss = node == c.nodes - 1; var stage = cfg.stages[si];
popupTitle.text = c.no + "-" + (node + 1) + (boss ? " 보스" : "") + "\n" + c.ko; 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(); var body = new System.Text.StringBuilder();
if (stage != null) body.Append(stage.title);
{ if (!string.IsNullOrEmpty(stage.place)) body.Append("\n<size=26>").Append(stage.place).Append(string.IsNullOrEmpty(stage.bgTheme) ? "" : " · " + stage.bgTheme).Append("</size>");
body.Append(stage.title); body.Append("\n\n").Append(EnemySummary(stage));
if (!string.IsNullOrEmpty(stage.subtitle)) body.Append(" · ").Append(stage.subtitle);
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잠긴 스테이지 — 앞 스테이지를 먼저 깨세요"); if (st == NodeState.Lock) body.Append("\n\n잠긴 스테이지 — 앞 스테이지를 먼저 깨세요");
popupBody.text = body.ToString(); popupBody.text = body.ToString();
popupGo.interactable = st != NodeState.Lock; popupGo.interactable = st != NodeState.Lock;
@ -450,14 +545,16 @@ namespace TKS
battle.StartStage(0); battle.StartStage(0);
return; 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; entryChapter = chapter;
entryNode = node; entryNode = node;
ClosePopup(); ClosePopup();
root.SetActive(false); root.SetActive(false);
if (hud != null) hud.gameObject.SetActive(true); if (hud != null) hud.gameObject.SetActive(true);
if (sling != null) sling.enabled = true; if (sling != null) sling.enabled = true;
battle.StartStage(data.chapters[chapter].stageLink); battle.StartStage(si);
} }
void OnBattleResult(bool win) void OnBattleResult(bool win)
@ -466,27 +563,24 @@ namespace TKS
if (win && entryNode >= 0) if (win && entryNode >= 0)
{ {
WorldProgress.MarkCleared(ch, entryNode); 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; entryChapter = entryNode = -1;
Show(ch); Show(ch);
} }
BalanceConfig.StageDef StageFor(WorldChapter c) /// <summary>적 수·병종 + 장수(보스 · 엘리트) 이름.</summary>
{
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)];
}
static string EnemySummary(BalanceConfig.StageDef stage) static string EnemySummary(BalanceConfig.StageDef stage)
{ {
var counts = new int[UnitTypeInfo.Count]; 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) foreach (var e in stage.enemies)
{ {
counts[(int)e.type]++; 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 + "기 · "); var sb = new System.Text.StringBuilder("적 " + stage.enemies.Count + "기 · ");
bool first = true; bool first = true;
@ -497,7 +591,8 @@ namespace TKS
sb.Append(UnitTypeInfo.Korean((UnitType)t)).Append(" ").Append(counts[t]); sb.Append(UnitTypeInfo.Korean((UnitType)t)).Append(" ").Append(counts[t]);
first = false; 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(); return sb.ToString();
} }

View File

@ -1,5 +1,6 @@
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using NUnit.Framework; using NUnit.Framework;
using UnityEngine; using UnityEngine;
using UnityEngine.SceneManagement; using UnityEngine.SceneManagement;
@ -508,13 +509,15 @@ namespace TKS.Tests
Assert.AreEqual(bm.AliveCount(Faction.Enemy), enemyArt, "스테이지 1 황건적은 전원 원화(hgj_*)로 표시"); Assert.AreEqual(bm.AliveCount(Faction.Enemy), enemyArt, "스테이지 1 황건적은 전원 원화(hgj_*)로 표시");
Assert.IsTrue(bm.Board.HasArt, "전장 배경 = Resources/Board/" + cfg.boardArt); Assert.IsTrue(bm.Board.HasArt, "전장 배경 = Resources/Board/" + cfg.boardArt);
// 원화 2차 납품(2026-11-04): 동탁군 보·창·기·궁 4종 반영 · 여포(dz_boss_lvbu)는 보류 → 도형 // 원화 2차 납품(2026-11-04): 동탁군 보·창·기·궁 4종 반영 → 스테이지 표의 첫 동탁군 스테이지(2장)
bm.StartStage(1); int dzStage = System.Array.FindIndex(cfg.stages, s => s.faction == "dz");
Assert.GreaterOrEqual(dzStage, 0, "동탁군 스테이지");
bm.StartStage(dzStage);
yield return null; yield return null;
foreach (var e in bm.AliveUnits(Faction.Enemy)) foreach (var e in bm.AliveUnits(Faction.Enemy))
{ {
if (e.IsBoss) continue; if (e.IsBoss) continue;
Assert.IsTrue(e.View.HasArt, "스테이지 2 동탁군 병종 4종은 원화(" + e.ArtId + ")"); Assert.IsTrue(e.View.HasArt, "동탁군 병종 4종은 원화(" + e.ArtId + ")");
} }
bm.StartStage(0); bm.StartStage(0);
yield return null; yield return null;
@ -577,7 +580,7 @@ namespace TKS.Tests
Assert.AreEqual(BattlePhase.Setup, b.Phase, "월드맵 표시 중 = 전투 없음"); Assert.AreEqual(BattlePhase.Setup, b.Phase, "월드맵 표시 중 = 전투 없음");
Assert.AreEqual(0, b.Units.Count, "월드맵 표시 중 = 유닛 없음"); Assert.AreEqual(0, b.Units.Count, "월드맵 표시 중 = 유닛 없음");
Assert.IsNotNull(wm.MapSprite, "챕터 1 맵 그림"); 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.Open, wm.StateOf(0, 0), "1-1 = 도전 가능");
Assert.AreEqual(NodeState.Lock, wm.StateOf(0, 1), "1-2 = 잠김"); Assert.AreEqual(NodeState.Lock, wm.StateOf(0, 1), "1-2 = 잠김");
Assert.IsFalse(wm.ChapterUnlocked(1), "2장은 1장 보스를 깨야 열림"); Assert.IsFalse(wm.ChapterUnlocked(1), "2장은 1장 보스를 깨야 열림");
@ -600,7 +603,8 @@ namespace TKS.Tests
yield return null; yield return null;
Assert.IsFalse(wm.IsVisible, "출전 → 월드맵 닫힘"); Assert.IsFalse(wm.IsVisible, "출전 → 월드맵 닫힘");
Assert.AreEqual(BattlePhase.PlayerTurn, bm.Phase); 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), "팝업 노드의 전투가 시작"); Assert.AreEqual(stage.enemies.Count, bm.AliveCount(Faction.Enemy), "팝업 노드의 전투가 시작");
bm.ForcePhase(BattlePhase.StageClear); 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[0], sp.rect.width, 0.5f, c.id + " 맵 가로 = world.json mapSize");
Assert.AreEqual(d.mapSize[1], sp.rect.height, 0.5f, c.id + " 맵 세로"); Assert.AreEqual(d.mapSize[1], sp.rect.height, 0.5f, c.id + " 맵 세로");
Assert.GreaterOrEqual(c.roadX.Length, 2, 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) foreach (var k in d.kit)
Assert.IsNotNull(Resources.Load<Sprite>("ChapterMap/UI/" + k.name), "UI 부품 " + k.name); Assert.IsNotNull(Resources.Load<Sprite>("ChapterMap/UI/" + k.name), "UI 부품 " + k.name);
@ -656,12 +658,15 @@ namespace TKS.Tests
foreach (var p in poses) foreach (var p in poses)
Assert.IsTrue(a.Clips.ContainsKey(v + "_" + p), h.art + " " + v + "_" + p); Assert.IsTrue(a.Clips.ContainsKey(v + "_" + p), h.art + " " + v + "_" + p);
} }
// 병사 = 납품 세력(hgj·dz·cc)은 반드시 원화 · 장수(<세력>_boss_<병음>) = 납품된 것만 원화(나머지는 도형 대체 · StageData_MissingArt 테스트)
string[] delivered = { "hgj_", "dz_", "cc_" }; string[] delivered = { "hgj_", "dz_", "cc_" };
foreach (var s in cfg.stages) foreach (var s in cfg.stages)
foreach (var e in s.enemies) foreach (var e in s.enemies)
{ {
bool general = e.art.Contains("_boss_");
bool want = false; bool want = false;
foreach (var f in delivered) want |= e.art.StartsWith(f); foreach (var f in delivered) want |= e.art.StartsWith(f);
if (general) want = Resources.Load<TextAsset>("Units/" + e.art + "/meta") != null;
if (!want) continue; if (!want) continue;
var a = UnitArt.Load(e.art); var a = UnitArt.Load(e.art);
Assert.IsNotNull(a, "적 원화 " + e.art); Assert.IsNotNull(a, "적 원화 " + e.art);
@ -684,5 +689,158 @@ namespace TKS.Tests
Assert.AreEqual("front_idle", u.View.Anim.Current, "아래를 보면 앞모습"); Assert.AreEqual("front_idle", u.View.Anim.Current, "아래를 보면 앞모습");
Assert.IsTrue(u.View.Anim.Renderer.flipX, "왼쪽 = 좌우 반전"); 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종 이상 전부 통과 확인. - 규칙 해석 변경 = `Assets/Scripts/Runtime/ActionResolver.cs`(병종 4종) · `BattleManager.cs`(턴·승패·창병 대기) · `Combat.cs`(피해 공식·경로 질의). 변경 시 `Assets/Tests/PlayMode/BattleFlowTests.cs` 에 테스트 추가 후 22종 이상 전부 통과 확인.
- 콜라이더 질의 전 `Physics2D.SyncTransforms()` · 유닛 제거는 `SetActive(false)` 후 `Destroy` · 자식 렌더러가 있는 표시물은 GameObject 통째로 끈다. - 콜라이더 질의 전 `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)` · 원거리 사격은 패널티 없음. - **궁병 규칙(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 와 동일) ## 구조 (README 와 동일)
- `Assets/Scripts/Runtime/` — `GameBootstrap`(씬 구성) · `BattleManager` · `ActionResolver` · `Combat` · `SlingshotInput`(조작·스크롤·줌) · `RangeIndicator`(사거리 원·궁병 도넛) · `CameraController`(넓은 전장 카메라) · `EnemyAI`(교전 범위) · `BattleHUD`(HUD·미니맵) · `Unit`/`UnitView` · `UnitArt`(아트 로더·`FrameAnimator`) · `Board` · `Fx` · `AutoPlayDriver` - `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/Resources/Units/<art>/` — 유닛 아트 프레임 + meta.json(위 절)
- `Assets/Tests/PlayMode/` — `BattleFlowTests` - `Assets/Tests/PlayMode/` — `BattleFlowTests`
- `.claude/skills/` — Unity CLI 에이전트 스킬(`unity skill install claude-code --local` 산출물 · 재설치는 `unity skill refresh`) - `.claude/skills/` — Unity CLI 에이전트 스킬(`unity skill install claude-code --local` 산출물 · 재설치는 `unity skill refresh`)