Add Xuzhou rescue sortie flow

This commit is contained in:
2026-06-23 00:45:22 +09:00
parent 84a5606a73
commit 87b200d772
11 changed files with 929 additions and 43 deletions

View File

@@ -0,0 +1,115 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2400 2000" width="2400" height="2000">
<defs>
<filter id="grain">
<feTurbulence type="fractalNoise" baseFrequency="0.018" numOctaves="4" seed="277" />
<feColorMatrix type="saturate" values="0.58" />
<feBlend mode="multiply" in2="SourceGraphic" />
</filter>
<pattern id="grass" width="84" height="84" patternUnits="userSpaceOnUse">
<rect width="84" height="84" fill="#5f744b" />
<path d="M10 22h25M46 55h24M22 66l17-12M58 16l14 10" stroke="#b2bd75" stroke-width="3" opacity="0.24" />
<path d="M8 58l14-22M38 30l12-16M66 72l8-23" stroke="#324b2e" stroke-width="2" opacity="0.36" />
<circle cx="18" cy="40" r="3" fill="#7b8d55" opacity="0.42" />
<circle cx="61" cy="36" r="4" fill="#3f5d35" opacity="0.32" />
</pattern>
<pattern id="earth" width="96" height="96" patternUnits="userSpaceOnUse">
<rect width="96" height="96" fill="#9d794b" />
<path d="M4 23c33-17 60 10 90-7M0 63c36-18 68 14 94-4" stroke="#cda36a" stroke-width="7" opacity="0.24" />
<path d="M20 80l14-23M58 38l23-14M72 76l13-8" stroke="#57432d" stroke-width="3" opacity="0.34" />
</pattern>
<pattern id="forest" width="120" height="104" patternUnits="userSpaceOnUse">
<rect width="120" height="104" fill="#30482d" />
<circle cx="24" cy="38" r="28" fill="#203722" />
<circle cx="58" cy="30" r="34" fill="#567345" />
<circle cx="91" cy="46" r="30" fill="#2a4428" />
<circle cx="50" cy="76" r="27" fill="#67814d" />
<path d="M12 58c30 17 70 15 100-6M34 24c22 18 52 16 70-6" stroke="#142615" stroke-width="5" opacity="0.36" />
</pattern>
<pattern id="hill" width="126" height="92" patternUnits="userSpaceOnUse">
<rect width="126" height="92" fill="#77704a" />
<path d="M-12 78c45-58 86-78 126-62c35 15 54 42 72 76" fill="#57553c" opacity="0.72" />
<path d="M18 72c34-38 68-48 102-32" fill="none" stroke="#a89b65" stroke-width="10" opacity="0.28" />
<path d="M38 34l28 19M78 22l20 20" stroke="#3a3a2a" stroke-width="4" opacity="0.32" />
</pattern>
<linearGradient id="river" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#538eaa" />
<stop offset="0.52" stop-color="#275b77" />
<stop offset="1" stop-color="#18384f" />
</linearGradient>
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#86735d" />
<stop offset="1" stop-color="#46382a" />
</linearGradient>
<linearGradient id="roof" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d49a3f" />
<stop offset="1" stop-color="#7b5120" />
</linearGradient>
</defs>
<rect width="2400" height="2000" fill="url(#grass)" />
<g filter="url(#grain)">
<path d="M-80 1700C236 1454 432 1280 610 1060C760 876 940 760 1140 646C1350 526 1564 336 1800 86" fill="none" stroke="#a68051" stroke-width="252" stroke-linecap="round" opacity="0.98" />
<path d="M-80 1700C236 1454 432 1280 610 1060C760 876 940 760 1140 646C1350 526 1564 336 1800 86" fill="none" stroke="url(#earth)" stroke-width="170" stroke-linecap="round" opacity="0.94" />
<path d="M-92 760C230 830 450 812 690 690C930 568 1084 506 1304 524C1520 542 1700 444 1910 250" fill="none" stroke="#9d7749" stroke-width="188" stroke-linecap="round" opacity="0.92" />
<path d="M-92 760C230 830 450 812 690 690C930 568 1084 506 1304 524C1520 542 1700 444 1910 250" fill="none" stroke="url(#earth)" stroke-width="120" stroke-linecap="round" opacity="0.88" />
<path d="M250 1940C340 1678 382 1494 318 1296C252 1090 280 918 418 780C582 616 638 422 574 180" fill="none" stroke="#9d7749" stroke-width="164" stroke-linecap="round" opacity="0.88" />
<path d="M250 1940C340 1678 382 1494 318 1296C252 1090 280 918 418 780C582 616 638 422 574 180" fill="none" stroke="url(#earth)" stroke-width="96" stroke-linecap="round" opacity="0.84" />
<path d="M1288 -30c-72 242-44 420 80 548c154 160 198 334 138 546c-46 160-14 304 116 442c108 116 144 248 92 430" fill="none" stroke="#173c52" stroke-width="264" stroke-linecap="round" opacity="0.96" />
<path d="M1288 -30c-72 242-44 420 80 548c154 160 198 334 138 546c-46 160-14 304 116 442c108 116 144 248 92 430" fill="none" stroke="url(#river)" stroke-width="178" stroke-linecap="round" opacity="0.98" />
<path d="M1328 142c32 122 44 234-10 356M1478 766c92 104 112 226 50 370M1650 1510c58 96 78 188 44 292" fill="none" stroke="#b5e6ee" stroke-width="14" opacity="0.34" />
<path d="M-40 48c270-86 532-52 760 98c44 150-44 290-250 402c-238-10-412-90-520-238Z" fill="url(#forest)" opacity="0.84" />
<path d="M568 258c250-104 490-78 720 86c14 154-88 272-308 348c-242-32-384-170-412-434Z" fill="url(#forest)" opacity="0.82" />
<path d="M38 548c318-118 610-66 860 156c-80 222-344 350-778 364C-4 922-28 748 38 548Z" fill="url(#forest)" opacity="0.84" />
<path d="M1740 454c276-96 486-54 660 112v424c-252 76-492 8-712-196c-34-134-18-248 52-340Z" fill="url(#forest)" opacity="0.82" />
<path d="M640 1534c342-126 646-74 908 142c-70 226-326 340-760 324c-160-116-210-266-148-466Z" fill="url(#forest)" opacity="0.82" />
<path d="M1880 60c180-48 352-20 516 86v260c-176 38-336 2-480-110c-66-78-78-158-36-236Z" fill="url(#hill)" opacity="0.9" />
<path d="M1860 1090c176-48 348-18 516 90v278c-176 40-336 0-484-120c-64-82-76-164-32-248Z" fill="url(#hill)" opacity="0.86" />
<g transform="translate(1560 70)">
<rect x="-36" y="-18" width="520" height="310" rx="18" fill="#2d261d" opacity="0.55" />
<rect x="0" y="0" width="452" height="260" rx="15" fill="url(#wall)" />
<rect x="40" y="50" width="374" height="170" fill="#756044" stroke="#241d15" stroke-width="12" />
<path d="M40 50h374M40 220h374M40 50v170M414 50v170" stroke="#35291e" stroke-width="10" />
<rect x="80" y="102" width="80" height="86" fill="#98714a" />
<rect x="198" y="76" width="90" height="112" fill="#9d7650" />
<rect x="330" y="102" width="58" height="86" fill="#8c6846" />
<path d="M60 102l60-48l62 48M184 76l60-52l70 52M316 102l44-38l54 38" fill="url(#roof)" stroke="#302316" stroke-width="9" />
<path d="M220 188v-60h46v60" fill="#1f1712" />
</g>
<g transform="translate(1712 530)">
<rect x="-16" y="-16" width="308" height="220" rx="16" fill="#2b231a" opacity="0.38" />
<rect x="0" y="0" width="112" height="158" rx="10" fill="#67513a" />
<rect x="136" y="20" width="128" height="158" rx="10" fill="#5f4b36" />
<rect x="34" y="50" width="50" height="76" fill="#98724a" />
<rect x="174" y="72" width="54" height="74" fill="#96704a" />
<path d="M18 50l44-38l50 38M150 72l50-44l66 44" fill="url(#roof)" stroke="#322317" stroke-width="9" />
<path d="M-28 188c110 24 220 22 346-8" stroke="#95a361" stroke-width="24" opacity="0.35" />
</g>
<g transform="translate(66 1426)">
<path d="M0 90c92-74 200-98 326-66c86 80 100 176 42 284c-130 24-248-6-352-84c-36-52-40-96-16-134Z" fill="#4e6263" opacity="0.46" />
<rect x="78" y="104" width="198" height="144" rx="10" fill="#604b36" />
<rect x="120" y="150" width="56" height="74" fill="#98724a" />
<rect x="200" y="142" width="46" height="84" fill="#7a5b3a" />
<path d="M60 104l112-88l124 88" fill="#c7832e" stroke="#2c2118" stroke-width="12" />
<path d="M104 252h190M96 274h168" stroke="#1c1712" stroke-width="10" opacity="0.5" />
<path d="M-10 326h350" stroke="#9f834e" stroke-width="18" opacity="0.46" />
</g>
<g transform="translate(920 1140)" opacity="0.86">
<path d="M-40 86c96-70 192-94 294-74c76 52 96 126 60 222c-96 42-196 36-302-16c-48-42-66-86-52-132Z" fill="#6c744c" />
<path d="M14 124h220M42 168h190" stroke="#3a442c" stroke-width="18" opacity="0.58" />
<path d="M74 54l42 34l-68 34M176 42l54 46l-76 42" fill="#9a713d" stroke="#322317" stroke-width="8" />
</g>
<g opacity="0.2">
<path d="M0 0h2400v2000h-2400Z" fill="none" stroke="#f1d48a" stroke-width="18" />
<path d="M0 100h2400M0 200h2400M0 300h2400M0 400h2400M0 500h2400M0 600h2400M0 700h2400M0 800h2400M0 900h2400M0 1000h2400M0 1100h2400M0 1200h2400M0 1300h2400M0 1400h2400M0 1500h2400M0 1600h2400M0 1700h2400M0 1800h2400M0 1900h2400" stroke="#0c1318" stroke-width="3" />
<path d="M100 0v2000M200 0v2000M300 0v2000M400 0v2000M500 0v2000M600 0v2000M700 0v2000M800 0v2000M900 0v2000M1000 0v2000M1100 0v2000M1200 0v2000M1300 0v2000M1400 0v2000M1500 0v2000M1600 0v2000M1700 0v2000M1800 0v2000M1900 0v2000M2000 0v2000M2100 0v2000M2200 0v2000M2300 0v2000" stroke="#0c1318" stroke-width="3" />
</g>
</g>
<rect width="2400" height="2000" fill="#080a0e" opacity="0.08" />
</svg>

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -11,6 +11,10 @@ import {
secondBattleMap,
secondBattleUnits,
secondBattleVictoryPages,
seventhBattleBonds,
seventhBattleMap,
seventhBattleUnits,
seventhBattleVictoryPages,
sixthBattleBonds,
sixthBattleMap,
sixthBattleUnits,
@@ -35,7 +39,8 @@ export type BattleScenarioId =
| 'third-battle-guangzong-road'
| 'fourth-battle-guangzong-camp'
| 'fifth-battle-sishui-vanguard'
| 'sixth-battle-jieqiao-relief';
| 'sixth-battle-jieqiao-relief'
| 'seventh-battle-xuzhou-rescue';
export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory';
@@ -392,6 +397,59 @@ export const sixthBattleScenario: BattleScenarioDefinition = {
nextCampScene: 'CampScene'
};
export const seventhBattleScenario: BattleScenarioDefinition = {
id: 'seventh-battle-xuzhou-rescue',
title: '서주 구원전',
victoryConditionLabel: '하후돈 격파',
defeatConditionLabel: '유비 퇴각',
openingObjectiveLines: [
'조조군 선봉 하후돈이 서주 피난로와 마을을 압박하고 있습니다. 하후돈을 격파하면 서주 성문까지 길을 열 수 있습니다.',
'강가 궁병은 자리를 지키고, 기병은 길을 따라 빠르게 추격합니다. 마을을 확보하며 선봉을 단계적으로 끊어 내십시오.',
'16턴 이내에 승리하면 도겸과 서주 백성의 신뢰가 크게 오릅니다.'
],
map: seventhBattleMap,
units: seventhBattleUnits,
bonds: seventhBattleBonds,
mapTextureKey: 'battle-map-seventh',
leaderUnitId: 'xuzhou-leader-xiahou-dun',
quickVictoryTurnLimit: 16,
baseVictoryGold: 960,
objectives: [
{
id: 'leader',
kind: 'defeat-leader',
label: '하후돈 격파',
rewardGold: 580,
unitId: 'xuzhou-leader-xiahou-dun'
},
{
id: 'liu-bei',
kind: 'keep-unit-alive',
label: '유비 생존',
rewardGold: 230,
unitId: 'liu-bei'
},
{
id: 'village',
kind: 'secure-terrain',
label: '서주 피난촌 확보',
rewardGold: 330,
terrain: 'village'
},
{
id: 'quick',
kind: 'quick-victory',
label: '16턴 이내 승리',
rewardGold: 270,
maxTurn: 16
}
],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
itemRewards: ['콩 2', '상처약 2', '탁주 1', '서주 민심 +1'],
victoryPages: seventhBattleVictoryPages,
nextCampScene: 'CampScene'
};
export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id;
export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition> = {
@@ -400,7 +458,8 @@ export const battleScenarios: Record<BattleScenarioId, BattleScenarioDefinition>
'third-battle-guangzong-road': thirdBattleScenario,
'fourth-battle-guangzong-camp': fourthBattleScenario,
'fifth-battle-sishui-vanguard': fifthBattleScenario,
'sixth-battle-jieqiao-relief': sixthBattleScenario
'sixth-battle-jieqiao-relief': sixthBattleScenario,
'seventh-battle-xuzhou-rescue': seventhBattleScenario
};
export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId];

View File

@@ -4,6 +4,7 @@ import {
fifthBattleScenario,
fourthBattleScenario,
secondBattleScenario,
seventhBattleScenario,
sixthBattleScenario,
thirdBattleScenario,
type BattleScenarioId
@@ -16,6 +17,8 @@ import {
fourthBattleVictoryPages,
secondBattleIntroPages,
secondBattleVictoryPages,
seventhBattleIntroPages,
seventhBattleVictoryPages,
sixthBattleIntroPages,
sixthBattleVictoryPages,
thirdBattleIntroPages,
@@ -88,12 +91,22 @@ const sortieFlows: Record<string, SortieFlow> = {
},
[sixthBattleScenario.id]: {
afterBattleId: sixthBattleScenario.id,
eyebrow: '다음 전장',
title: seventhBattleScenario.title,
description: '계교 원군로를 열어 공손찬의 신뢰를 얻은 유비군에게 서주의 도겸이 원군을 청합니다. 조조군 선봉을 밀어내고 서주 피난로를 열어야 합니다.',
rewardHint: `예상 보상: ${seventhBattleScenario.title} 개방`,
nextBattleId: seventhBattleScenario.id,
campaignStep: 'seventh-battle',
pages: [...sixthBattleVictoryPages, ...seventhBattleIntroPages]
},
[seventhBattleScenario.id]: {
afterBattleId: seventhBattleScenario.id,
eyebrow: '다음 장 준비',
title: '도겸의 서주',
description: '계교 원군로를 열어 공손찬의 신뢰를 얻은 유비에게 서주의 도겸이 원군을 청합니다. 다음 큰 줄기는 서주 인수로 이어집니다.',
description: '서주 구원전 뒤 도겸은 유비에게 서주의 장래를 맡아 달라 청합니다. 이제 서주 인수와 여포의 그림자가 다음 줄기를 만듭니다.',
rewardHint: '다음 장: 도겸에게 서주 인수 준비 중',
pages: sixthBattleVictoryPages,
unavailableNotice: '도겸 서주 흐름은 다음 장으로 이어집니다. 군영에서 성장 상태 정비하세요.'
pages: seventhBattleVictoryPages,
unavailableNotice: '도겸 서주 인수 흐름은 다음 장으로 이어집니다. 군영에서 성장 상태와 출전 구성을 정비하세요.'
}
};

View File

@@ -484,6 +484,62 @@ export const sixthBattleVictoryPages: StoryPage[] = [
}
];
export const seventhBattleIntroPages: StoryPage[] = [
{
id: 'seventh-xuzhou-envoy-arrives',
bgm: 'militia-theme',
chapter: '도겸의 사신',
background: 'story-militia',
text: '서주의 도겸이 조조군의 압박을 견디지 못하고 원군을 청했다. 공손찬의 허락을 받은 유비는 세 형제와 의용군을 이끌고 남쪽 길로 향했다.'
},
{
id: 'seventh-liu-bei-xuzhou',
bgm: 'battle-prep',
chapter: '서주로 가는 길',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '서주 백성이 위태롭다는데 어찌 머뭇거리겠는가. 도겸 공을 돕고, 백성들이 피난길에서 더 쓰러지지 않게 길을 열자.'
},
{
id: 'seventh-guan-yu-field',
bgm: 'battle-prep',
chapter: '조조군 선봉',
background: 'story-three-heroes',
speaker: '관우',
portrait: 'guanYu',
text: '조조군 선봉은 길과 촌락을 함께 누르고 있습니다. 기병은 길을 따라 빠르게 내려오고, 궁병은 강가와 마을 뒤에서 전열을 끊을 것입니다.'
},
{
id: 'seventh-zhang-fei-charge',
bgm: 'battle-prep',
chapter: '서주 구원전',
background: 'story-sortie',
speaker: '장비',
portrait: 'zhangFei',
text: '서주 백성을 울리는 놈들이라면 이름이 누구든 상관없습니다. 형님, 조조군 선봉부터 박살내고 길을 열어 봅시다!'
}
];
export const seventhBattleVictoryPages: StoryPage[] = [
{
id: 'seventh-victory-xuzhou-road',
bgm: 'militia-theme',
chapter: '서주의 문',
background: 'story-militia',
text: '조조군 선봉이 물러서자 서주 성문 앞 피난민들의 숨통이 트였다. 도겸은 유비의 군기를 맞이하며 깊이 고개를 숙였다.'
},
{
id: 'seventh-tao-qian-entrusts',
bgm: 'battle-prep',
chapter: '도겸의 부탁',
background: 'story-liu-bei',
speaker: '유비',
portrait: 'liuBei',
text: '서주를 맡아 달라는 도겸 공의 청은 너무 무거운 일이다. 그러나 백성이 의지할 곳이 없다면, 외면하는 것도 의가 아니다.'
}
];
export const firstBattleMap: BattleMap = {
width: 20,
height: 18,
@@ -644,6 +700,33 @@ export const sixthBattleMap: BattleMap = {
]
};
export const seventhBattleMap: BattleMap = {
width: 24,
height: 20,
terrain: [
['forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'fort', 'fort', 'plain', 'hill', 'forest', 'forest', 'plain', 'plain'],
['forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'river', 'river', 'road', 'plain', 'fort', 'plain', 'plain', 'plain', 'hill', 'forest', 'plain', 'plain'],
['plain', 'plain', 'forest', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'road', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain'],
['plain', 'forest', 'plain', 'road', 'plain', 'hill', 'forest', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain'],
['plain', 'plain', 'road', 'road', 'plain', 'hill', 'plain', 'plain', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'road', 'road', 'plain', 'village', 'plain', 'forest', 'plain', 'plain', 'hill'],
['plain', 'plain', 'road', 'plain', 'plain', 'plain', 'plain', 'forest', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'road', 'village', 'village', 'plain', 'forest', 'forest', 'plain', 'hill'],
['forest', 'plain', 'road', 'plain', 'hill', 'hill', 'plain', 'forest', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'road', 'plain', 'village', 'plain', 'plain', 'forest', 'plain', 'plain'],
['forest', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'forest', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'],
['plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'road', 'forest', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain'],
['road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'road', 'road', 'road', 'road', 'road', 'road', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'],
['road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'],
['plain', 'road', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'],
['plain', 'road', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain', 'plain'],
['plain', 'road', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'hill', 'plain', 'plain'],
['camp', 'road', 'road', 'plain', 'hill', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain'],
['camp', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain'],
['plain', 'camp', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'plain', 'plain'],
['plain', 'plain', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'plain'],
['plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain'],
['plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain']
]
};
export const firstBattleUnits: UnitData[] = [
{
id: 'liu-bei',
@@ -2219,6 +2302,270 @@ export const sixthBattleUnits: UnitData[] = [
}
];
const seventhBattleAllyPositions: Record<string, { x: number; y: number }> = {
'liu-bei': { x: 2, y: 16 },
'guan-yu': { x: 3, y: 16 },
'zhang-fei': { x: 2, y: 17 }
};
export const seventhBattleUnits: UnitData[] = [
...firstBattleUnits
.filter((unit) => unit.faction === 'ally')
.map((unit) => placeScenarioUnit(unit, seventhBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })),
{
id: 'xuzhou-raider-a',
name: '조조군 척후',
faction: 'enemy',
className: '조조 척후',
classKey: 'bandit',
level: 7,
exp: 42,
hp: 34,
maxHp: 34,
attack: 12,
move: 4,
stats: { might: 74, intelligence: 48, leadership: 54, agility: 70, luck: 52 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 18 },
armor: { itemId: 'rebel-vest', level: 2, exp: 8 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 5,
y: 13
},
{
id: 'xuzhou-infantry-a',
name: '조조 보병',
faction: 'enemy',
className: '조조 보병',
classKey: 'yellowTurban',
level: 7,
exp: 42,
hp: 39,
maxHp: 39,
attack: 12,
move: 3,
stats: { might: 77, intelligence: 48, leadership: 64, agility: 58, luck: 52 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 18 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 8 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 7,
y: 12
},
{
id: 'xuzhou-archer-a',
name: '조조 궁병',
faction: 'enemy',
className: '조조 궁병',
classKey: 'archer',
level: 7,
exp: 44,
hp: 31,
maxHp: 31,
attack: 13,
move: 3,
stats: { might: 69, intelligence: 58, leadership: 58, agility: 68, luck: 52 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 20 },
armor: { itemId: 'cloth-armor', level: 2, exp: 8 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 9,
y: 11
},
{
id: 'xuzhou-cavalry-a',
name: '조조 기병',
faction: 'enemy',
className: '조조 경기병',
classKey: 'cavalry',
level: 8,
exp: 46,
hp: 42,
maxHp: 42,
attack: 14,
move: 5,
stats: { might: 82, intelligence: 46, leadership: 64, agility: 82, luck: 54 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 22 },
armor: { itemId: 'rebel-vest', level: 2, exp: 10 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 11,
y: 9
},
{
id: 'xuzhou-raider-b',
name: '피난로 척후',
faction: 'enemy',
className: '조조 척후',
classKey: 'bandit',
level: 7,
exp: 44,
hp: 35,
maxHp: 35,
attack: 13,
move: 4,
stats: { might: 76, intelligence: 49, leadership: 55, agility: 71, luck: 53 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 20 },
armor: { itemId: 'rebel-vest', level: 2, exp: 8 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 6,
y: 8
},
{
id: 'xuzhou-archer-b',
name: '마을 궁병',
faction: 'enemy',
className: '조조 궁병',
classKey: 'archer',
level: 8,
exp: 46,
hp: 32,
maxHp: 32,
attack: 14,
move: 3,
stats: { might: 70, intelligence: 60, leadership: 59, agility: 69, luck: 53 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 22 },
armor: { itemId: 'cloth-armor', level: 2, exp: 10 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 10,
y: 7
},
{
id: 'xuzhou-cavalry-b',
name: '조조 기병',
faction: 'enemy',
className: '조조 경기병',
classKey: 'cavalry',
level: 8,
exp: 48,
hp: 43,
maxHp: 43,
attack: 14,
move: 5,
stats: { might: 83, intelligence: 47, leadership: 65, agility: 83, luck: 54 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 24 },
armor: { itemId: 'rebel-vest', level: 2, exp: 10 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 14,
y: 8
},
{
id: 'xuzhou-guard-a',
name: '선봉 수비병',
faction: 'enemy',
className: '조조 보병',
classKey: 'yellowTurban',
level: 8,
exp: 48,
hp: 41,
maxHp: 41,
attack: 13,
move: 3,
stats: { might: 80, intelligence: 50, leadership: 67, agility: 60, luck: 53 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 24 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 10 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 15,
y: 5
},
{
id: 'xuzhou-cavalry-c',
name: '측면 기병',
faction: 'enemy',
className: '조조 경기병',
classKey: 'cavalry',
level: 8,
exp: 50,
hp: 44,
maxHp: 44,
attack: 14,
move: 5,
stats: { might: 84, intelligence: 47, leadership: 66, agility: 84, luck: 55 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 26 },
armor: { itemId: 'rebel-vest', level: 2, exp: 12 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 19,
y: 6
},
{
id: 'xuzhou-guard-b',
name: '선봉 수비병',
faction: 'enemy',
className: '조조 보병',
classKey: 'yellowTurban',
level: 8,
exp: 50,
hp: 42,
maxHp: 42,
attack: 13,
move: 3,
stats: { might: 81, intelligence: 50, leadership: 68, agility: 60, luck: 54 },
equipment: {
weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 26 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 10 },
accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 }
},
x: 17,
y: 3
},
{
id: 'xuzhou-archer-c',
name: '서주 길목 궁병',
faction: 'enemy',
className: '조조 궁병',
classKey: 'archer',
level: 8,
exp: 50,
hp: 33,
maxHp: 33,
attack: 14,
move: 3,
stats: { might: 71, intelligence: 61, leadership: 60, agility: 70, luck: 54 },
equipment: {
weapon: { itemId: 'short-bow', level: 2, exp: 26 },
armor: { itemId: 'cloth-armor', level: 2, exp: 10 },
accessory: { itemId: 'wind-quiver', level: 1, exp: 0 }
},
x: 15,
y: 1
},
{
id: 'xuzhou-leader-xiahou-dun',
name: '하후돈',
faction: 'enemy',
className: '조조군 장수',
classKey: 'rebelLeader',
level: 9,
exp: 54,
hp: 58,
maxHp: 58,
attack: 16,
move: 4,
stats: { might: 88, intelligence: 66, leadership: 82, agility: 66, luck: 58 },
equipment: {
weapon: { itemId: 'leader-axe', level: 2, exp: 30 },
armor: { itemId: 'lamellar-armor', level: 2, exp: 16 },
accessory: { itemId: 'war-manual', level: 1, exp: 0 }
},
x: 16,
y: 1
}
];
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -2251,6 +2598,7 @@ export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBo
export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const fifthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const sixthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData {
return {

View File

@@ -48,7 +48,19 @@ const unitTexture: Record<string, string> = {
'pursuit-cavalry-b': 'unit-rebel-cavalry',
'pursuit-guard-a': 'unit-rebel',
'pursuit-guard-b': 'unit-rebel',
'pursuit-leader-han-seok': 'unit-rebel-leader'
'pursuit-leader-han-seok': 'unit-rebel-leader',
'xuzhou-raider-a': 'unit-rebel',
'xuzhou-infantry-a': 'unit-rebel',
'xuzhou-archer-a': 'unit-rebel-archer',
'xuzhou-cavalry-a': 'unit-rebel-cavalry',
'xuzhou-raider-b': 'unit-rebel',
'xuzhou-archer-b': 'unit-rebel-archer',
'xuzhou-cavalry-b': 'unit-rebel-cavalry',
'xuzhou-guard-a': 'unit-rebel',
'xuzhou-cavalry-c': 'unit-rebel-cavalry',
'xuzhou-guard-b': 'unit-rebel',
'xuzhou-archer-c': 'unit-rebel-archer',
'xuzhou-leader-xiahou-dun': 'unit-rebel-leader'
};
const unitTextureByClass: Partial<Record<UnitClassKey, string>> = {
@@ -493,7 +505,19 @@ const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
'pursuit-cavalry-b': 'aggressive',
'pursuit-guard-a': 'guard',
'pursuit-guard-b': 'guard',
'pursuit-leader-han-seok': 'guard'
'pursuit-leader-han-seok': 'guard',
'xuzhou-raider-a': 'aggressive',
'xuzhou-infantry-a': 'guard',
'xuzhou-archer-a': 'hold',
'xuzhou-cavalry-a': 'aggressive',
'xuzhou-raider-b': 'aggressive',
'xuzhou-archer-b': 'hold',
'xuzhou-cavalry-b': 'aggressive',
'xuzhou-guard-a': 'guard',
'xuzhou-cavalry-c': 'aggressive',
'xuzhou-guard-b': 'guard',
'xuzhou-archer-c': 'hold',
'xuzhou-leader-xiahou-dun': 'guard'
};
const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {
@@ -697,7 +721,12 @@ export class BattleScene extends Phaser.Scene {
}
private resetBattleData(campaign?: CampaignState) {
battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map((unit) => this.applyCampaignUnitProgress(unit, campaign)));
const selected = new Set(campaign?.selectedSortieUnitIds ?? []);
const hasSelection = selected.size > 0;
const nextUnits = initialBattleUnits
.filter((unit) => unit.faction === 'enemy' || !hasSelection || selected.has(unit.id) || unit.id === 'liu-bei')
.map((unit) => this.applyCampaignUnitProgress(unit, campaign));
battleUnits.splice(0, battleUnits.length, ...nextUnits);
battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign)));
initialBattleUnits = battleUnits.map(cloneUnitData);
initialBattleBonds = battleBonds.map(cloneBattleBond);

View File

@@ -3,6 +3,7 @@ import fifthBattleMapUrl from '../../assets/images/battle/fifth-battle-map.svg';
import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png';
import fourthBattleMapUrl from '../../assets/images/battle/fourth-battle-map.svg';
import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg';
import seventhBattleMapUrl from '../../assets/images/battle/seventh-battle-map.svg';
import sixthBattleMapUrl from '../../assets/images/battle/sixth-battle-map.svg';
import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg';
import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png';
@@ -70,6 +71,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('battle-map-fourth', fourthBattleMapUrl);
this.load.image('battle-map-fifth', fifthBattleMapUrl);
this.load.image('battle-map-sixth', sixthBattleMapUrl);
this.load.image('battle-map-seventh', seventhBattleMapUrl);
this.load.image('portrait-liu-bei', liuBeiPortraitUrl);
this.load.image('portrait-guan-yu', guanYuPortraitUrl);
this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl);

View File

@@ -128,9 +128,13 @@ const campBattleIds = {
third: 'third-battle-guangzong-road',
fourth: 'fourth-battle-guangzong-camp',
fifth: 'fifth-battle-sishui-vanguard',
sixth: 'sixth-battle-jieqiao-relief'
sixth: 'sixth-battle-jieqiao-relief',
seventh: 'seventh-battle-xuzhou-rescue'
} as const;
const requiredSortieUnitIds = new Set(['liu-bei']);
const maxSortieUnits = 6;
const campDialogues: CampDialogue[] = [
{
id: 'liu-guan-after-first-battle',
@@ -617,6 +621,87 @@ const campDialogues: CampDialogue[] = [
rewardExp: 6
}
]
},
{
id: 'liu-guan-after-xuzhou',
title: '서주의 무게',
availableAfterBattleIds: [campBattleIds.seventh],
unitIds: ['liu-bei', 'guan-yu'],
bondId: 'liu-bei__guan-yu',
rewardExp: 22,
lines: [
'유비: 도겸 공은 서주를 맡아 달라 하나, 내 그릇이 백성을 감당할 수 있을지 두렵소.',
'관우: 형님께서 두려워하시는 까닭은 땅이 아니라 백성을 먼저 보기 때문입니다. 그래서 맡을 수 있는 일입니다.',
'유비: 운장의 말처럼 두려움이 의를 잊지 않게 하는 끈이라면, 그 끈을 놓지 않겠소.'
],
choices: [
{
id: 'accept-responsibility',
label: '책임을 피하지 않는다',
response: '관우는 유비가 명분보다 책임을 먼저 말하는 것을 보고 깊이 고개를 끄덕였다.',
rewardExp: 9
},
{
id: 'listen-to-xuzhou',
label: '서주 사람들의 뜻을 듣는다',
response: '두 사람은 성급히 자리를 받기보다 백성의 마음을 먼저 살피기로 했다.',
rewardExp: 8
}
]
},
{
id: 'liu-zhang-after-xuzhou',
title: '성급함을 다스리다',
availableAfterBattleIds: [campBattleIds.seventh],
unitIds: ['liu-bei', 'zhang-fei'],
bondId: 'liu-bei__zhang-fei',
rewardExp: 22,
lines: [
'장비: 형님, 서주가 우리를 부른다면 시원하게 맡으면 되지 않습니까?',
'유비: 익덕, 기뻐할 일만은 아니오. 성 하나를 맡는다는 것은 그 성의 굶주림과 두려움도 함께 맡는 일이오.',
'장비: 그렇다면 제가 먼저 군량과 길목을 지키겠습니다. 백성이 굶는 일부터 막아야겠지요.'
],
choices: [
{
id: 'guard-supplies',
label: '군량 길목을 맡긴다',
response: '장비는 싸움보다 지키는 일이 더 어렵다는 말을 마음에 새겼다.',
rewardExp: 8
},
{
id: 'calm-the-temper',
label: '분노보다 절제를 당부한다',
response: '유비의 당부에 장비는 주먹을 풀고, 백성을 놀라게 하지 않겠다고 답했다.',
rewardExp: 9
}
]
},
{
id: 'guan-zhang-after-xuzhou',
title: '새 군영의 좌우',
availableAfterBattleIds: [campBattleIds.seventh],
unitIds: ['guan-yu', 'zhang-fei'],
bondId: 'guan-yu__zhang-fei',
rewardExp: 20,
lines: [
'관우: 서주는 넓고 사람의 마음은 아직 흩어져 있다. 우리가 먼저 흔들리면 형님의 짐이 더 무거워질 것이다.',
'장비: 걱정 마시오. 운장 형님이 군율을 세우면, 나는 군심을 붙잡겠습니다.',
'관우: 좋다. 이제 우리는 전장에서뿐 아니라 군영에서도 형님의 좌우가 되어야 한다.'
],
choices: [
{
id: 'divide-camp-duties',
label: '군율과 군심을 나눈다',
response: '관우와 장비는 서로의 강점을 인정하며 새 군영을 안정시키기로 했다.',
rewardExp: 8
},
{
id: 'train-new-recruits',
label: '새 병사를 함께 훈련한다',
response: '두 장수의 훈련 방식은 달랐지만, 병사들은 그 사이에서 빠르게 자리를 잡았다.',
rewardExp: 7
}
]
}
];
@@ -631,6 +716,7 @@ export class CampScene extends Phaser.Scene {
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
private tabButtons: CampTabButtonView[] = [];
private visitedTabs = new Set<CampTab>();
private selectedSortieUnitIds: string[] = [];
constructor() {
super('CampScene');
@@ -645,6 +731,7 @@ export class CampScene extends Phaser.Scene {
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
soundDirector.playMusic('militia-theme');
@@ -702,6 +789,9 @@ export class CampScene extends Phaser.Scene {
private currentCampTitle() {
const battleId = this.currentCampBattleId();
if (battleId === campBattleIds.seventh) {
return '서주 인수 준비 군영';
}
if (battleId === campBattleIds.fifth) {
return '공손찬 진영 군영';
}
@@ -830,6 +920,7 @@ export class CampScene extends Phaser.Scene {
this.hideSortiePrep();
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? this.report;
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
const depth = 40;
const width = 900;
@@ -850,7 +941,7 @@ export class CampScene extends Phaser.Scene {
const title = this.trackSortie(this.add.text(x + 34, y + 28, '출진 준비', this.textStyle(30, '#f2e3bf', true)));
title.setDepth(depth + 2);
const subtitle = this.trackSortie(
this.add.text(x + 34, y + 70, '다음 전장으로 향하기 전 부대 상태와 준비 항목을 확인합니다.', this.textStyle(15, '#d4dce6'))
this.add.text(x + 34, y + 70, '대화, 보급, 상인 정비를 마친 뒤 함께 출전할 무장을 선택합니다.', this.textStyle(15, '#d4dce6'))
);
subtitle.setDepth(depth + 2);
@@ -910,25 +1001,39 @@ export class CampScene extends Phaser.Scene {
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.blue, 0.48);
this.trackSortie(this.add.text(x + 18, y + 14, '출진 부대', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
const allies = this.sortieAllies();
const selectedCount = this.selectedSortieUnitIds.length;
this.trackSortie(
this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${Math.min(maxSortieUnits, allies.length)}`, this.textStyle(18, '#f2e3bf', true))
).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 17, '클릭해서 출전/대기 전환', this.textStyle(12, '#9fb0bf'))).setOrigin(1, 0).setDepth(depth + 1);
this.currentUnits()
.filter((unit) => unit.faction === 'ally')
.forEach((unit, index) => {
const rowY = y + 52 + index * 30;
this.trackSortie(this.add.text(x + 18, rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(14, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 132, rowY, unit.className, this.textStyle(13, '#9fb0bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 236, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, '#d4dce6', true))).setDepth(depth + 1);
this.drawSortieBar(x + 338, rowY + 6, 128, 7, unit.hp / unit.maxHp, palette.green, depth + 1);
allies.forEach((unit, index) => {
const rowY = y + 48 + index * 30;
const selected = this.isSortieSelected(unit.id);
const required = requiredSortieUnitIds.has(unit.id);
const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, 26, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82));
row.setOrigin(0);
row.setDepth(depth + 1);
row.setStrokeStyle(1, selected ? palette.green : 0x53606c, selected ? 0.62 : 0.38);
row.setInteractive({ useHandCursor: !required });
row.on('pointerdown', () => this.toggleSortieUnit(unit.id));
const equipment = equipmentSlots
.map((slot) => {
const state = unit.equipment[slot];
return `${equipmentSlotLabels[slot]} Lv${state.level}`;
})
.join(' ');
this.trackSortie(this.add.text(x + 496, rowY, equipment, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
});
const marker = selected ? '출전' : '대기';
this.trackSortie(this.add.text(x + 30, rowY, required ? '필수' : marker, this.textStyle(12, required ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 84, rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(14, selected ? '#f2e3bf' : '#87919c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 198, rowY, unit.className, this.textStyle(13, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.trackSortie(this.add.text(x + 310, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, selected ? '#d4dce6' : '#77818c', true))).setDepth(depth + 2);
this.drawSortieBar(x + 414, rowY + 6, 102, 7, unit.hp / unit.maxHp, selected ? palette.green : 0x53606c, depth + 2);
const equipment = equipmentSlots
.map((slot) => {
const state = unit.equipment[slot];
return `${equipmentSlotLabels[slot]} Lv${state.level}`;
})
.join(' ');
this.trackSortie(this.add.text(x + 542, rowY, equipment, this.textStyle(12, selected ? '#d4dce6' : '#77818c'))).setDepth(depth + 2);
});
}
private renderSortieRewardHint(x: number, y: number, width: number, height: number, depth: number) {
@@ -939,9 +1044,10 @@ export class CampScene extends Phaser.Scene {
const availableDialogues = this.availableCampDialogues();
const completedDialogues = this.completedAvailableDialogues().length;
const inventory = this.inventoryLabels().join(', ');
const selectedNames = this.selectedSortieUnits().map((unit) => unit.name).join(', ');
const reward = getSortieFlow(this.campaign?.latestBattleId).rewardHint;
this.trackSortie(this.add.text(x + 16, y + 10, reward, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 출전 ${selectedNames || '없음'} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
}
private nextSortieBriefing() {
@@ -960,6 +1066,7 @@ export class CampScene extends Phaser.Scene {
const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0);
const availableDialogues = this.availableCampDialogues();
const completedDialogues = this.completedAvailableDialogues().length;
const selected = this.selectedSortieUnits();
return [
{
label: '유비 생존',
@@ -971,6 +1078,11 @@ export class CampScene extends Phaser.Scene {
complete: injured.length === 0,
detail: injured.length === 0 ? '전원 완전한 병력' : `${injured.map((unit) => unit.name).join(', ')} 회복 권장`
},
{
label: '출전 구성',
complete: selected.some((unit) => unit.id === 'liu-bei') && selected.length > 0,
detail: selected.length > 0 ? selected.map((unit) => unit.name).join(', ') : '출전 무장 선택 필요'
},
{
label: '소모품 보유',
complete: supplyCount > 0,
@@ -1007,6 +1119,10 @@ export class CampScene extends Phaser.Scene {
private startVictoryStory() {
const flow = getSortieFlow(this.campaign?.latestBattleId);
if (!this.ensureSortieSelectionSaved()) {
this.showCampNotice('출전할 무장을 선택하세요. 유비는 반드시 출전해야 합니다.');
return;
}
if (!flow.nextBattleId || !flow.campaignStep || flow.pages.length === 0) {
this.hideSortiePrep();
@@ -1618,6 +1734,84 @@ export class CampScene extends Phaser.Scene {
return this.report?.bonds ?? [];
}
private sortieAllies() {
return this.currentUnits().filter((unit) => unit.faction === 'ally' && unit.hp > 0);
}
private selectedSortieUnits() {
const selected = new Set(this.selectedSortieUnitIds);
return this.sortieAllies().filter((unit) => selected.has(unit.id));
}
private normalizedSortieUnitIds(candidateIds?: string[]) {
const allies = this.sortieAllies();
const allyIds = new Set(allies.map((unit) => unit.id));
const useDefaultSelection = !candidateIds || candidateIds.length === 0;
const candidateSet = new Set((candidateIds ?? []).filter((id) => allyIds.has(id)));
requiredSortieUnitIds.forEach((id) => {
if (allyIds.has(id)) {
candidateSet.add(id);
}
});
if (useDefaultSelection) {
allies.slice(0, maxSortieUnits).forEach((unit) => candidateSet.add(unit.id));
}
const required = allies.filter((unit) => requiredSortieUnitIds.has(unit.id)).map((unit) => unit.id);
const optional = allies
.filter((unit) => !requiredSortieUnitIds.has(unit.id) && candidateSet.has(unit.id))
.slice(0, Math.max(0, maxSortieUnits - required.length))
.map((unit) => unit.id);
return [...new Set([...required, ...optional])].slice(0, maxSortieUnits);
}
private isSortieSelected(unitId: string) {
return this.selectedSortieUnitIds.includes(unitId);
}
private toggleSortieUnit(unitId: string) {
const unit = this.sortieAllies().find((candidate) => candidate.id === unitId);
if (!unit) {
return;
}
if (requiredSortieUnitIds.has(unitId)) {
this.showCampNotice(`${unit.name}는 반드시 출전해야 합니다.`);
return;
}
const selected = new Set(this.selectedSortieUnitIds);
if (selected.has(unitId)) {
selected.delete(unitId);
} else if (selected.size >= maxSortieUnits) {
this.showCampNotice(`이번 전투는 최대 ${maxSortieUnits}명까지 출전할 수 있습니다.`);
return;
} else {
selected.add(unitId);
}
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(Array.from(selected));
this.persistSortieSelection();
soundDirector.playSelect();
this.showSortiePrep();
}
private ensureSortieSelectionSaved() {
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.selectedSortieUnitIds);
if (!this.selectedSortieUnitIds.some((id) => requiredSortieUnitIds.has(id)) || this.selectedSortieUnitIds.length === 0) {
return false;
}
this.persistSortieSelection();
return true;
}
private persistSortieSelection() {
const campaign = this.campaign ?? getCampaignState();
campaign.selectedSortieUnitIds = [...this.selectedSortieUnitIds];
this.campaign = saveCampaignState(campaign);
}
private completedCampDialogues() {
if (this.campaign) {
return this.campaign.completedCampDialogues;
@@ -1684,6 +1878,7 @@ export class CampScene extends Phaser.Scene {
sortieVisible: this.sortieObjects.length > 0,
selectedUnitId: this.selectedUnitId,
selectedDialogueId: this.selectedDialogueId,
selectedSortieUnitIds: [...this.selectedSortieUnitIds],
campBattleId: this.currentCampBattleId(),
campTitle: this.currentCampTitle(),
availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),
@@ -1693,6 +1888,7 @@ export class CampScene extends Phaser.Scene {
step: this.campaign.step,
gold: this.campaign.gold,
inventory: this.campaign.inventory,
selectedSortieUnitIds: this.campaign.selectedSortieUnitIds,
roster: this.campaign.roster.map((unit) => ({
id: unit.id,
name: unit.name,

View File

@@ -1,7 +1,7 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { firstBattleVictoryPages } from '../data/scenario';
import { fifthBattleScenario, fourthBattleScenario, secondBattleScenario, sixthBattleScenario, thirdBattleScenario } from '../data/battles';
import { fifthBattleScenario, fourthBattleScenario, secondBattleScenario, seventhBattleScenario, sixthBattleScenario, thirdBattleScenario } from '../data/battles';
import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState';
import { palette } from '../ui/palette';
@@ -305,7 +305,8 @@ export class TitleScene extends Phaser.Scene {
campaign.step === 'third-camp' ||
campaign.step === 'fourth-camp' ||
campaign.step === 'fifth-camp' ||
campaign.step === 'sixth-camp'
campaign.step === 'sixth-camp' ||
campaign.step === 'seventh-camp'
) {
this.scene.start('CampScene');
return;
@@ -341,6 +342,11 @@ export class TitleScene extends Phaser.Scene {
return;
}
if (campaign.step === 'seventh-battle') {
this.scene.start('BattleScene', { battleId: seventhBattleScenario.id });
return;
}
if (campaign.step === 'first-victory-story') {
this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' });
return;

View File

@@ -51,7 +51,9 @@ export type CampaignStep =
| 'fifth-battle'
| 'fifth-camp'
| 'sixth-battle'
| 'sixth-camp';
| 'sixth-camp'
| 'seventh-battle'
| 'seventh-camp';
export type CampaignUnitProgressSnapshot = {
unitId: string;
@@ -92,6 +94,7 @@ export type CampaignState = {
roster: UnitData[];
bonds: CampBondSnapshot[];
inventory: Record<string, number>;
selectedSortieUnitIds: string[];
completedCampDialogues: string[];
battleHistory: Record<string, CampaignBattleSettlement>;
latestBattleId?: string;
@@ -107,7 +110,8 @@ const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: Campai
'third-battle-guangzong-road': { victory: 'third-camp', retry: 'third-battle' },
'fourth-battle-guangzong-camp': { victory: 'fourth-camp', retry: 'fourth-battle' },
'fifth-battle-sishui-vanguard': { victory: 'fifth-camp', retry: 'fifth-battle' },
'sixth-battle-jieqiao-relief': { victory: 'sixth-camp', retry: 'sixth-battle' }
'sixth-battle-jieqiao-relief': { victory: 'sixth-camp', retry: 'sixth-battle' },
'seventh-battle-xuzhou-rescue': { victory: 'seventh-camp', retry: 'seventh-battle' }
};
export type CampaignSaveSlotSummary = {
@@ -131,6 +135,7 @@ export function createInitialCampaignState(): CampaignState {
roster: [],
bonds: [],
inventory: {},
selectedSortieUnitIds: [],
completedCampDialogues: [],
battleHistory: {}
};
@@ -220,7 +225,7 @@ export function setFirstBattleReport(report: FirstBattleReport) {
const retryStep: CampaignStep = stepRule.retry;
state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep;
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
state.roster = reportClone.units.filter((unit) => unit.faction === 'ally').map(cloneUnit);
state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally'));
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
state.completedCampDialogues = completedCampDialogues;
state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
@@ -283,6 +288,7 @@ function normalizeCampaignState(state: CampaignState): CampaignState {
normalized.bonds = (normalized.bonds ?? []).map(cloneBondSnapshot);
normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues ?? [])];
normalized.inventory = { ...(normalized.inventory ?? {}) };
normalized.selectedSortieUnitIds = [...new Set(normalized.selectedSortieUnitIds ?? [])];
normalized.battleHistory = normalized.battleHistory ?? {};
if (normalized.firstBattleReport) {
normalized.firstBattleReport = cloneReport(normalized.firstBattleReport);
@@ -388,6 +394,13 @@ function applyRewardDelta(inventory: Record<string, number>, rewards: string[],
}, { ...inventory });
}
function mergeRosterProgress(currentRoster: UnitData[], deployedUnits: UnitData[]) {
const merged = new Map<string, UnitData>();
currentRoster.forEach((unit) => merged.set(unit.id, cloneUnit(unit)));
deployedUnits.forEach((unit) => merged.set(unit.id, cloneUnit(unit)));
return Array.from(merged.values());
}
function parseRewardLabel(reward: string) {
const normalized = reward.replace(/\s+/g, ' ').trim();
const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/);