diff --git a/docs/roadmap.md b/docs/roadmap.md
index 10d7f0f..243f491 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -22,16 +22,18 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of
- Third battle expansion path toward Guangzong with a new battlefield, story bridge, campaign step persistence, and title continue support
- Fourth battle Guangzong main camp confrontation with Zhang Jue, expanded enemy formation, campaign persistence, and anti-Dong Zhuo setup
- Fifth battle Sishui Gate vanguard path starting the anti-Dong Zhuo chapter, with campaign persistence and title continue support
-- Battle-specific camp conversations with selectable bond-growth choices from the first camp through the Gongsun Zan camp
+- Battle-specific camp conversations with selectable bond-growth choices from the first camp through the Xu Province camp
- Sixth battle Jieqiao relief route under Gongsun Zan, with a story bridge toward Tao Qian and Xu Province
-- Flow verification script from title through the sixth battle victory and camp save state
+- Seventh battle Xu Province rescue route against Cao Cao's vanguard, with Tao Qian entrusting Xu Province as the next endpoint
+- Sortie preparation selection in camp so Liu Bei is required and optional allied officers can be deployed or benched before battle
+- Flow verification script from title through the seventh battle victory, sortie selection, and camp save state
## Next Steps
-1. Add the Tao Qian/Xu Province rescue battle and first Xu Province camp
-2. Turn story-only endpoint bridges into a more explicit chapter selection/progress view
-3. Apply treasure equipment effects to damage, defense, recovery, and command rules
-4. Add more distinctive victory/defeat branches and optional objectives
+1. Add the Tao Qian handoff story and first Xu Province governance camp
+2. Introduce recruitable allied officers and battle sortie limits so the selection screen becomes a stronger strategic choice
+3. Turn story-only endpoint bridges into a more explicit chapter selection/progress view
+4. Apply treasure equipment effects to damage, defense, recovery, and command rules
5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending
## Content Direction
diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs
index 5d967e6..fa3687d 100644
--- a/scripts/verify-flow.mjs
+++ b/scripts/verify-flow.mjs
@@ -749,16 +749,119 @@ try {
await page.waitForTimeout(160);
const sixthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (!sixthCampSortieState?.sortieVisible) {
- throw new Error(`Expected sixth camp story bridge overlay: ${JSON.stringify(sixthCampSortieState)}`);
+ throw new Error(`Expected sixth camp sortie preparation overlay: ${JSON.stringify(sixthCampSortieState)}`);
+ }
+ if (!sixthCampSortieState.selectedSortieUnitIds?.includes('liu-bei') || sixthCampSortieState.selectedSortieUnitIds.length < 3) {
+ throw new Error(`Expected sortie prep to default to the core brother roster: ${JSON.stringify(sixthCampSortieState)}`);
+ }
+
+ await page.mouse.click(254, 450);
+ await page.waitForTimeout(220);
+ const sixthCampAfterSortieToggle = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ if (
+ !sixthCampAfterSortieToggle?.sortieVisible ||
+ sixthCampAfterSortieToggle.selectedSortieUnitIds?.includes('guan-yu') ||
+ !sixthCampAfterSortieToggle.selectedSortieUnitIds?.includes('liu-bei') ||
+ !sixthCampAfterSortieToggle.selectedSortieUnitIds?.includes('zhang-fei')
+ ) {
+ throw new Error(`Expected sortie selection toggle to bench Guan Yu while keeping Liu Bei/Zhang Fei: ${JSON.stringify(sixthCampAfterSortieToggle)}`);
}
await page.mouse.click(938, 596);
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('StoryScene');
});
- await page.screenshot({ path: 'dist/verification-xuzhou-bridge-story.png', fullPage: true });
+ await page.screenshot({ path: 'dist/verification-seventh-bridge-story.png', fullPage: true });
- for (let i = 0; i < 4; i += 1) {
+ for (let i = 0; i < 30; i += 1) {
+ const isSeventhBattleActive = await page.evaluate(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.battleId === 'seventh-battle-xuzhou-rescue';
+ });
+
+ if (isSeventhBattleActive) {
+ break;
+ }
+
+ await page.keyboard.press('Space');
+ await page.waitForTimeout(320);
+ }
+ await page.waitForFunction(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.scene === 'BattleScene' && state?.battleId === 'seventh-battle-xuzhou-rescue' && state?.battleOutcome === null && state?.phase === 'idle';
+ });
+ await page.screenshot({ path: 'dist/verification-seventh-battle.png', fullPage: true });
+
+ const seventhBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
+ const seventhEnemies = seventhBattleState.units.filter((unit) => unit.faction === 'enemy');
+ const seventhAllies = seventhBattleState.units.filter((unit) => unit.faction === 'ally');
+ const seventhEnemyBehaviors = new Set(seventhEnemies.map((unit) => unit.ai));
+ if (
+ seventhBattleState.camera?.mapWidth !== 24 ||
+ seventhBattleState.camera?.mapHeight !== 20 ||
+ seventhBattleState.victoryConditionLabel !== '하후돈 격파' ||
+ seventhEnemies.length < 12 ||
+ !seventhEnemyBehaviors.has('aggressive') ||
+ !seventhEnemyBehaviors.has('guard') ||
+ !seventhEnemyBehaviors.has('hold') ||
+ seventhAllies.some((unit) => unit.id === 'guan-yu') ||
+ !seventhAllies.some((unit) => unit.id === 'liu-bei') ||
+ !seventhAllies.some((unit) => unit.id === 'zhang-fei')
+ ) {
+ throw new Error(`Expected seventh battle map, objective, mixed AI, and saved sortie selection: ${JSON.stringify(seventhBattleState)}`);
+ }
+
+ await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
+ await page.waitForFunction(() => {
+ const state = window.__HEROS_DEBUG__?.battle();
+ return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true;
+ });
+
+ await page.mouse.click(738, 642);
+ await page.waitForFunction(() => {
+ const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
+ return activeScenes.includes('CampScene');
+ });
+
+ const campaignSaveAfterSeventhBattle = await page.evaluate(() => {
+ const raw = window.localStorage.getItem('heros-web:campaign-state');
+ return raw ? JSON.parse(raw) : undefined;
+ });
+ if (
+ !campaignSaveAfterSeventhBattle ||
+ campaignSaveAfterSeventhBattle.step !== 'seventh-camp' ||
+ campaignSaveAfterSeventhBattle.latestBattleId !== 'seventh-battle-xuzhou-rescue' ||
+ !campaignSaveAfterSeventhBattle.battleHistory?.['seventh-battle-xuzhou-rescue'] ||
+ !campaignSaveAfterSeventhBattle.roster?.some((unit) => unit.id === 'guan-yu')
+ ) {
+ throw new Error(`Expected campaign save to persist seventh battle victory and preserve benched roster: ${JSON.stringify(campaignSaveAfterSeventhBattle)}`);
+ }
+
+ const seventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ if (
+ seventhCampState?.campBattleId !== 'seventh-battle-xuzhou-rescue' ||
+ seventhCampState.campTitle !== '서주 인수 준비 군영' ||
+ seventhCampState.availableDialogueIds?.length !== 3 ||
+ !seventhCampState.availableDialogueIds.every((id) => id.endsWith('xuzhou')) ||
+ !seventhCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu')
+ ) {
+ throw new Error(`Expected seventh camp to expose Xu Province dialogue set and preserved roster: ${JSON.stringify(seventhCampState)}`);
+ }
+
+ await page.mouse.click(1120, 38);
+ await page.waitForTimeout(160);
+ const seventhCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
+ if (!seventhCampSortieState?.sortieVisible) {
+ throw new Error(`Expected seventh camp story bridge overlay: ${JSON.stringify(seventhCampSortieState)}`);
+ }
+ await page.mouse.click(938, 596);
+ await page.waitForFunction(() => {
+ const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
+ return activeScenes.includes('StoryScene');
+ });
+ await page.screenshot({ path: 'dist/verification-xuzhou-entrust-story.png', fullPage: true });
+
+ for (let i = 0; i < 8; i += 1) {
const returnedToCamp = await page.evaluate(() => {
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return activeScenes.includes('CampScene');
@@ -785,7 +888,7 @@ try {
return activeScenes.includes('CampScene');
});
- console.log(`Verified title-to-sixth-battle flow, result states, and debug API at ${targetUrl}`);
+ console.log(`Verified title-to-seventh-battle flow, sortie selection, result states, and debug API at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
diff --git a/src/assets/images/battle/seventh-battle-map.svg b/src/assets/images/battle/seventh-battle-map.svg
new file mode 100644
index 0000000..90a435c
--- /dev/null
+++ b/src/assets/images/battle/seventh-battle-map.svg
@@ -0,0 +1,115 @@
+
diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts
index 995028e..4e1f9c1 100644
--- a/src/game/data/battles.ts
+++ b/src/game/data/battles.ts
@@ -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 = {
@@ -400,7 +458,8 @@ export const battleScenarios: Record
'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];
diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts
index b53c4e5..5288e95 100644
--- a/src/game/data/campaignFlow.ts
+++ b/src/game/data/campaignFlow.ts
@@ -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 = {
},
[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: '도겸의 서주 인수 흐름은 다음 장으로 이어집니다. 군영에서 성장 상태와 출전 구성을 정비하세요.'
}
};
diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts
index 8afe13f..e1e723b 100644
--- a/src/game/data/scenario.ts
+++ b/src/game/data/scenario.ts
@@ -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 = {
+ '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 {
diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts
index df72327..6e894be 100644
--- a/src/game/scenes/BattleScene.ts
+++ b/src/game/scenes/BattleScene.ts
@@ -48,7 +48,19 @@ const unitTexture: Record = {
'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> = {
@@ -493,7 +505,19 @@ const enemyAiByUnitId: Record = {
'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> = {
@@ -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);
diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts
index 44f59e2..b71d198 100644
--- a/src/game/scenes/BootScene.ts
+++ b/src/game/scenes/BootScene.ts
@@ -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);
diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts
index 8114b8d..3ecefc4 100644
--- a/src/game/scenes/CampScene.ts
+++ b/src/game/scenes/CampScene.ts
@@ -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();
+ 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,
diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts
index 086b1fa..8078c75 100644
--- a/src/game/scenes/TitleScene.ts
+++ b/src/game/scenes/TitleScene.ts
@@ -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;
diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts
index da2f654..c88747c 100644
--- a/src/game/state/campaignState.ts
+++ b/src/game/state/campaignState.ts
@@ -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;
+ selectedSortieUnitIds: string[];
completedCampDialogues: string[];
battleHistory: Record;
latestBattleId?: string;
@@ -107,7 +110,8 @@ const campaignBattleSteps: Record 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, rewards: string[],
}, { ...inventory });
}
+function mergeRosterProgress(currentRoster: UnitData[], deployedUnits: UnitData[]) {
+ const merged = new Map();
+ 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+))$/);