From 87b200d7721c96b699399ed90532171512c6613d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 00:45:22 +0900 Subject: [PATCH] Add Xuzhou rescue sortie flow --- docs/roadmap.md | 14 +- scripts/verify-flow.mjs | 111 +++++- .../images/battle/seventh-battle-map.svg | 115 ++++++ src/game/data/battles.ts | 63 +++- src/game/data/campaignFlow.ts | 19 +- src/game/data/scenario.ts | 348 ++++++++++++++++++ src/game/scenes/BattleScene.ts | 35 +- src/game/scenes/BootScene.ts | 2 + src/game/scenes/CampScene.ts | 236 +++++++++++- src/game/scenes/TitleScene.ts | 10 +- src/game/state/campaignState.ts | 19 +- 11 files changed, 929 insertions(+), 43 deletions(-) create mode 100644 src/assets/images/battle/seventh-battle-map.svg 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+))$/);