From 7f80600d0c056dc222d9400be4218c99b2193538 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 06:59:33 +0900 Subject: [PATCH] Add Red Cliffs vanguard chapter --- docs/roadmap.md | 5 +- scripts/verify-flow.mjs | 153 +++++- .../images/battle/twenty-first-battle-map.svg | 94 ++++ src/game/data/battles.ts | 73 ++- src/game/data/campaignFlow.ts | 26 +- src/game/data/scenario.ts | 506 ++++++++++++++++++ src/game/scenes/BattleScene.ts | 38 +- src/game/scenes/BootScene.ts | 2 + src/game/scenes/CampScene.ts | 169 +++++- src/game/scenes/TitleScene.ts | 9 +- src/game/state/campaignState.ts | 7 +- 11 files changed, 1058 insertions(+), 24 deletions(-) create mode 100644 src/assets/images/battle/twenty-first-battle-map.svg diff --git a/docs/roadmap.md b/docs/roadmap.md index 8ef05d4..9a74374 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,15 +42,16 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Eighteenth battle Bowang ambush as the first Red Cliffs preparation battle, with a new large battlefield, Xiahou Dun's vanguard, mixed enemy AI, Zhuge Liang sortie recommendation, and post-battle camp events - Nineteenth battle Changban refuge route, extending Red Cliffs preparation with a larger refugee-road battlefield, Cao Chun's cavalry pursuit, Zhao Yun survival pressure, and a sortie choice that highlights growing roster tradeoffs - Twentieth battle Jiangdong envoy route, shifting sortie choice toward protecting Zhuge Liang and Sun Qian while opening the diplomatic path to Sun Quan and Zhou Yu +- Twenty-first battle Red Cliffs vanguard route, opening the first allied river-line clash with Cai Mao, six-officer sortie pressure, and post-battle fire-attack preparation events - Camp progress timeline tab that summarizes Liu Bei's long campaign arc, completed battles, current chapter, latest battle, and next major chapter - Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer - Officer collection support in camp, including full roster status, selected/reserve/recommended markers, and post-battle reserve training growth for benched officers - Treasure equipment effects wired into battle previews and resolution, including named weapon damage bonuses, defensive treasure mitigation, support recovery bonuses, turn-start charm recovery, and equipment-specific growth bonuses -- Flow verification script from title through the twentieth battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, Bowang/Changban/Jiangdong camp states, campaign timeline state, and camp save state +- Flow verification script from title through the twenty-first battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, Bowang/Changban/Jiangdong/Red Cliffs camp states, campaign timeline state, and camp save state ## Next Steps -1. Continue the Red Cliffs preparation chapter into the Sun Quan/Zhou Yu parley and the first Red Cliffs confrontation +1. Continue the Red Cliffs preparation chapter into Huang Gai's bitter-flesh plan and the main fire-attack confrontation 2. Add more recruitable officers as the campaign moves toward Red Cliffs, Jing Province, Yi Province, and Shu Han 3. Expand reserve training into explicit drill choices, class practice, and bond-focused camp assignments 4. Add a dedicated treasure/equipment management view so players can compare special effects and growth diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 64a9403..e3fa600 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1736,7 +1736,7 @@ try { if ( progressTabState?.activeTab !== 'progress' || progressTabState.campaignProgress?.completedKnown !== 17 || - progressTabState.campaignProgress?.totalKnown !== 20 || + progressTabState.campaignProgress?.totalKnown !== 21 || progressTabState.campaignProgress?.activeChapter?.title !== '적벽대전' || progressTabState.campaignProgress?.latestBattleTitle !== '융중 방문로' || progressTabState.campaignProgress?.nextBattleTitle !== '박망파 매복전' @@ -1888,7 +1888,7 @@ try { if ( postBowangProgressState?.activeTab !== 'progress' || postBowangProgressState.campaignProgress?.completedKnown !== 18 || - postBowangProgressState.campaignProgress?.totalKnown !== 20 || + postBowangProgressState.campaignProgress?.totalKnown !== 21 || postBowangProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postBowangProgressState.campaignProgress?.latestBattleTitle !== '박망파 매복전' || postBowangProgressState.campaignProgress?.nextBattleTitle !== '장판파 피난로' @@ -2026,7 +2026,7 @@ try { if ( postChangbanProgressState?.activeTab !== 'progress' || postChangbanProgressState.campaignProgress?.completedKnown !== 19 || - postChangbanProgressState.campaignProgress?.totalKnown !== 20 || + postChangbanProgressState.campaignProgress?.totalKnown !== 21 || postChangbanProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postChangbanProgressState.campaignProgress?.latestBattleTitle !== '장판파 피난로' || postChangbanProgressState.campaignProgress?.nextBattleTitle !== '강동 사절로' @@ -2163,15 +2163,156 @@ try { const postJiangdongProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( postJiangdongProgressState?.activeTab !== 'progress' || - postJiangdongProgressState.campaignProgress?.completedKnown !== postJiangdongProgressState.campaignProgress?.totalKnown || + postJiangdongProgressState.campaignProgress?.completedKnown !== 20 || + postJiangdongProgressState.campaignProgress?.totalKnown !== 21 || postJiangdongProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postJiangdongProgressState.campaignProgress?.latestBattleTitle !== '강동 사절로' || - postJiangdongProgressState.campaignProgress?.nextBattleTitle !== '준비 중' + postJiangdongProgressState.campaignProgress?.nextBattleTitle !== '적벽 전초전' ) { throw new Error(`Expected post-Jiangdong progress tab to keep Red Cliffs active while first Red Cliffs battle is pending: ${JSON.stringify(postJiangdongProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-jiangdong-progress.png', fullPage: true }); + await page.mouse.click(1120, 38); + await page.waitForTimeout(180); + const redCliffsSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + !redCliffsSortieState?.sortieVisible || + !redCliffsSortieState.sortiePlan?.objectiveLine?.includes('적벽 전초전') || + !redCliffsSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || + !redCliffsSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || + !redCliffsSortieState.sortieRoster?.some((unit) => unit.id === 'guan-yu' && unit.recommended) || + !redCliffsSortieState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.recruited && unit.recommended) || + redCliffsSortieState.sortieRoster?.length < 8 || + redCliffsSortieState.sortiePlan?.maxCount !== 6 + ) { + throw new Error(`Expected twentieth camp sortie prep to target Red Cliffs vanguard with Zhuge Liang, Zhao Yun, Guan Yu, and Sun Qian recommendations: ${JSON.stringify(redCliffsSortieState)}`); + } + assertSortieTacticalRoster(redCliffsSortieState, [ + 'liu-bei', + 'guan-yu', + 'zhang-fei', + 'jian-yong', + 'mi-zhu', + 'sun-qian', + 'zhao-yun', + 'zhuge-liang' + ]); + + const redCliffsPriorityUnits = ['zhuge-liang', 'zhao-yun', 'guan-yu', 'sun-qian']; + for (const unitId of redCliffsPriorityUnits) { + const currentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!currentSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)) { + const removable = currentSortieState.sortieRoster?.find( + (unit) => unit.selected && unit.id !== 'liu-bei' && !redCliffsPriorityUnits.includes(unit.id) + ); + if (removable) { + await clickSortieRosterUnit(page, removable.id); + } + await clickSortieRosterUnit(page, unitId); + } + } + + const redCliffsSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + !redCliffsPriorityUnits.every((unitId) => + redCliffsSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) + ) || + redCliffsSortieReadyState.sortiePlan?.selectedCount !== 6 || + redCliffsSortieReadyState.sortiePlan?.recommendedSelectedCount < 5 + ) { + throw new Error(`Expected Red Cliffs sortie to deploy priority officers while preserving six-officer choice pressure: ${JSON.stringify(redCliffsSortieReadyState)}`); + } + await page.screenshot({ path: 'dist/verification-redcliffs-sortie.png', fullPage: true }); + + await page.mouse.click(1068, 646); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('StoryScene'); + }); + await page.screenshot({ path: 'dist/verification-redcliffs-story.png', fullPage: true }); + + for (let i = 0; i < 34; i += 1) { + const enteredTwentyFirstBattle = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'twenty-first-battle-red-cliffs-vanguard'; + }); + if (enteredTwentyFirstBattle) { + 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 === 'twenty-first-battle-red-cliffs-vanguard' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-twenty-first-battle.png', fullPage: true }); + + const twentyFirstBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const twentyFirstEnemies = twentyFirstBattleState.units.filter((unit) => unit.faction === 'enemy'); + const twentyFirstAllies = twentyFirstBattleState.units.filter((unit) => unit.faction === 'ally'); + const twentyFirstEnemyBehaviors = new Set(twentyFirstEnemies.map((unit) => unit.ai)); + if ( + twentyFirstBattleState.camera?.mapWidth !== 42 || + twentyFirstBattleState.camera?.mapHeight !== 30 || + twentyFirstBattleState.victoryConditionLabel !== '채모 퇴각' || + twentyFirstEnemies.length < 17 || + !twentyFirstEnemyBehaviors.has('aggressive') || + !twentyFirstEnemyBehaviors.has('guard') || + !twentyFirstEnemyBehaviors.has('hold') || + !twentyFirstEnemies.some((unit) => unit.id === 'redcliff-vanguard-leader-cai-mao') || + !twentyFirstAllies.some((unit) => unit.id === 'zhuge-liang') || + !twentyFirstAllies.some((unit) => unit.id === 'zhao-yun') || + !twentyFirstAllies.some((unit) => unit.id === 'sun-qian') + ) { + throw new Error(`Expected twenty-first battle to use Red Cliffs vanguard map, Cai Mao objective, mixed AI, and selected allied officers: ${JSON.stringify(twentyFirstBattleState)}`); + } + + 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 twentyFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + twentyFirstCampState?.campBattleId !== 'twenty-first-battle-red-cliffs-vanguard' || + twentyFirstCampState.campTitle !== '적벽 전초전 후 군영' || + twentyFirstCampState.availableDialogueIds?.length !== 3 || + !twentyFirstCampState.availableDialogueIds.every((id) => id.endsWith('red-cliffs-vanguard')) || + twentyFirstCampState.availableVisitIds?.length !== 2 || + !twentyFirstCampState.report?.bonds?.some((bond) => bond.id === 'liu-bei__zhuge-liang') || + !twentyFirstCampState.report?.bonds?.some((bond) => bond.id === 'sun-qian__zhuge-liang') || + twentyFirstCampState.rosterCollection?.total < 8 || + twentyFirstCampState.rosterCollection?.reserveTrainingCount < 2 || + twentyFirstCampState.rosterCollection?.reserveTrainingExp < 12 + ) { + throw new Error(`Expected twenty-first camp to expose Red Cliffs dialogue/visit sets, alliance bonds, and reserve growth: ${JSON.stringify(twentyFirstCampState)}`); + } + await page.screenshot({ path: 'dist/verification-redcliffs-camp.png', fullPage: true }); + + await page.mouse.click(966, 38); + await page.waitForTimeout(180); + const postRedCliffsProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + postRedCliffsProgressState?.activeTab !== 'progress' || + postRedCliffsProgressState.campaignProgress?.completedKnown !== 21 || + postRedCliffsProgressState.campaignProgress?.totalKnown !== 21 || + postRedCliffsProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || + postRedCliffsProgressState.campaignProgress?.latestBattleTitle !== '적벽 전초전' || + postRedCliffsProgressState.campaignProgress?.nextBattleTitle !== '준비 중' + ) { + throw new Error(`Expected post-Red Cliffs vanguard progress tab to complete known battles and keep fire attack preparation pending: ${JSON.stringify(postRedCliffsProgressState?.campaignProgress)}`); + } + await page.screenshot({ path: 'dist/verification-post-redcliffs-progress.png', fullPage: true }); + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -2183,7 +2324,7 @@ try { return activeScenes.includes('CampScene'); }); - console.log(`Verified title-to-twentieth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-twenty-first-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { diff --git a/src/assets/images/battle/twenty-first-battle-map.svg b/src/assets/images/battle/twenty-first-battle-map.svg new file mode 100644 index 0000000..44d78f2 --- /dev/null +++ b/src/assets/images/battle/twenty-first-battle-map.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index ea8f810..018d38a 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -39,6 +39,10 @@ import { thirteenthBattleMap, thirteenthBattleUnits, thirteenthBattleVictoryPages, + twentyFirstBattleBonds, + twentyFirstBattleMap, + twentyFirstBattleUnits, + twentyFirstBattleVictoryPages, twentiethBattleBonds, twentiethBattleMap, twentiethBattleUnits, @@ -105,7 +109,8 @@ export type BattleScenarioId = | 'seventeenth-battle-wolong-visit-road' | 'eighteenth-battle-bowang-ambush' | 'nineteenth-battle-changban-refuge' - | 'twentieth-battle-jiangdong-envoy'; + | 'twentieth-battle-jiangdong-envoy' + | 'twenty-first-battle-red-cliffs-vanguard'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -1225,6 +1230,69 @@ export const twentiethBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const twentyFirstBattleScenario: BattleScenarioDefinition = { + id: 'twenty-first-battle-red-cliffs-vanguard', + title: '적벽 전초전', + victoryConditionLabel: '채모 퇴각', + defeatConditionLabel: '유비 또는 제갈량 퇴각', + openingObjectiveLines: [ + '강동 회담이 결전으로 기울자 조조군 수군 선봉이 적벽 포구를 시험합니다. 첫 전선을 빼앗기면 손유 동맹의 사기가 크게 흔들립니다.', + '강 위의 전장은 강줄기와 배다리가 길을 나눕니다. 기동 장수로 강안 기병을 끊고, 전열 장수로 포구 수비대를 밀어내야 합니다.', + '채모를 퇴각시키고 제갈량을 지키십시오. 강동 포구를 확보하면 화공을 논할 발판이 마련됩니다.' + ], + map: twentyFirstBattleMap, + units: twentyFirstBattleUnits, + bonds: twentyFirstBattleBonds, + mapTextureKey: 'battle-map-twenty-first', + leaderUnitId: 'redcliff-vanguard-leader-cai-mao', + quickVictoryTurnLimit: 30, + baseVictoryGold: 2540, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '채모 퇴각', + rewardGold: 1660, + unitId: 'redcliff-vanguard-leader-cai-mao' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 520, + unitId: 'liu-bei' + }, + { + id: 'zhuge-liang', + kind: 'keep-unit-alive', + label: '제갈량 생존', + rewardGold: 800, + unitId: 'zhuge-liang' + }, + { + id: 'red-cliffs-port', + kind: 'secure-terrain', + label: '강동 포구 확보', + rewardGold: 900, + terrain: 'fort' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '30턴 이내 승리', + rewardGold: 680, + maxTurn: 30 + } + ], + defeatConditions: [ + { kind: 'unit-defeated', unitId: 'liu-bei' }, + { kind: 'unit-defeated', unitId: 'zhuge-liang' } + ], + itemRewards: ['콩 8', '상처약 6', '탁주 3', '화공 논의 +1', '동맹 군의 기록 +1'], + victoryPages: twentyFirstBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { @@ -1247,7 +1315,8 @@ export const battleScenarios: Record 'seventeenth-battle-wolong-visit-road': seventeenthBattleScenario, 'eighteenth-battle-bowang-ambush': eighteenthBattleScenario, 'nineteenth-battle-changban-refuge': nineteenthBattleScenario, - 'twentieth-battle-jiangdong-envoy': twentiethBattleScenario + 'twentieth-battle-jiangdong-envoy': twentiethBattleScenario, + 'twenty-first-battle-red-cliffs-vanguard': twentyFirstBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 7a8394e..0c8b129 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -17,6 +17,7 @@ import { seventeenthBattleScenario, tenthBattleScenario, thirteenthBattleScenario, + twentyFirstBattleScenario, twentiethBattleScenario, twelfthBattleScenario, thirdBattleScenario, @@ -56,6 +57,8 @@ import { tenthBattleVictoryPages, thirteenthBattleIntroPages, thirteenthBattleVictoryPages, + twentyFirstBattleIntroPages, + twentyFirstBattleVictoryPages, twentiethBattleIntroPages, twentiethBattleVictoryPages, twelfthBattleIntroPages, @@ -277,13 +280,24 @@ const sortieFlows: Record = { }, [twentiethBattleScenario.id]: { afterBattleId: twentiethBattleScenario.id, - eyebrow: '다음 장 준비', - title: '적벽 첫 전투 준비', + eyebrow: '다음 전장', + title: twentyFirstBattleScenario.title, description: - '강동으로 향하는 길이 열렸습니다. 이제 제갈량과 손건은 손권과 주유를 설득해야 하고, 유비군은 장강에서 조조의 대군을 맞을 준비를 시작합니다.', - rewardHint: '다음 장: 강동 회담과 적벽대전 첫 전투 준비 중', - pages: twentiethBattleVictoryPages, - unavailableNotice: '강동 회담과 적벽대전 첫 전투는 다음 작업에서 이어집니다. 군영에서 사절 문서, 화공 준비, 출전 후보를 정비하십시오.' + '강동으로 향하는 길이 열렸습니다. 제갈량과 손건이 손권과 주유를 설득한 뒤, 조조군 수군 선봉을 상대로 손유 동맹의 첫 전선을 세워야 합니다.', + rewardHint: `예상 보상: ${twentyFirstBattleScenario.title} 개방 / 화공 논의 진전`, + nextBattleId: twentyFirstBattleScenario.id, + campaignStep: 'twenty-first-battle', + pages: [...twentiethBattleVictoryPages, ...twentyFirstBattleIntroPages] + }, + [twentyFirstBattleScenario.id]: { + afterBattleId: twentyFirstBattleScenario.id, + eyebrow: '다음 장 준비', + title: '적벽 화공 준비', + description: + '조조군 수군 선봉을 밀어내며 손유 동맹의 첫 전선이 세워졌습니다. 이제 주유와 황개, 제갈량의 계책이 맞물려 본격적인 화공을 준비해야 합니다.', + rewardHint: '다음 장: 황개 고육계와 적벽 화공 준비 중', + pages: twentyFirstBattleVictoryPages, + unavailableNotice: '황개 고육계와 적벽 화공 장은 다음 작업에서 이어집니다. 군영에서 화공 논의, 공명, 보급을 정비하십시오.' } }; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 22c5f3c..c5d8836 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1323,6 +1323,68 @@ export const twentiethBattleVictoryPages: StoryPage[] = [ } ]; +export const twentyFirstBattleIntroPages: StoryPage[] = [ + { + id: 'twenty-first-sun-quan-council', + bgm: 'story-dark', + chapter: '강동 회담', + background: 'story-liu-bei', + speaker: '제갈량', + text: '손권의 장막 안에서는 항복과 결전의 말이 맞섰습니다. 제갈량은 조조의 대군이 강을 건너면 강동도, 유비군도 설 곳을 잃는다는 사실을 차분히 짚었습니다.' + }, + { + id: 'twenty-first-zhou-yu-decision', + bgm: 'battle-prep', + chapter: '주유의 결단', + background: 'story-three-heroes', + speaker: '주유', + text: '조조가 강 위에서 싸우는 법을 모른다면, 장강은 우리 편이 될 것입니다. 손권께서 결심하셨으니, 이제 말이 아니라 배와 불길로 답해야 합니다.' + }, + { + id: 'twenty-first-liu-bei-alliance', + bgm: 'militia-theme', + chapter: '손유 동맹', + background: 'story-militia', + speaker: '유비', + portrait: 'liuBei', + text: '강동이 함께 선다면 조조의 군세도 처음으로 멈출 수 있소. 우리는 작은 군이지만, 장강의 바람과 백성을 지키는 뜻을 함께 세웁시다.' + }, + { + id: 'twenty-first-red-cliffs-sortie', + bgm: 'battle-prep', + chapter: '적벽 전초전', + background: 'story-sortie', + speaker: '제갈량', + text: '조조군 수군 선봉이 강가 포구를 시험하고 있습니다. 채모의 선봉을 물리쳐야 강동 수군과 합류할 첫 자리를 지킬 수 있습니다.' + } +]; + +export const twentyFirstBattleVictoryPages: StoryPage[] = [ + { + id: 'twenty-first-victory-river-line', + bgm: 'militia-theme', + chapter: '강 위의 첫 승리', + background: 'story-sortie', + text: '채모의 선봉이 물러서자 장강의 물결 위에 손유 동맹의 첫 전선이 세워졌습니다. 작은 승리였지만 조조군이 강 위에서 무적이 아님을 모두가 보았습니다.' + }, + { + id: 'twenty-first-zhou-yu-fire-plan', + bgm: 'battle-prep', + chapter: '화공의 실마리', + background: 'story-militia', + speaker: '주유', + text: '조조군의 배는 많지만 서로를 믿지 못하고, 사슬로 묶으면 바람 앞에서 약점이 됩니다. 아직 때가 무르익지는 않았지만 불길의 길은 보이기 시작했습니다.' + }, + { + id: 'twenty-first-zhuge-wind', + bgm: 'story-dark', + chapter: '바람을 기다리다', + background: 'story-three-heroes', + speaker: '제갈량', + text: '다음 싸움은 힘으로만 밀어붙일 수 없습니다. 황개의 결단, 주유의 수군, 그리고 바람의 때가 맞아야 조조의 대군을 무너뜨릴 수 있습니다.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -1588,6 +1650,12 @@ export const twentiethBattleMap: BattleMap = { terrain: createTwentiethBattleTerrain() }; +export const twentyFirstBattleMap: BattleMap = { + width: 42, + height: 30, + terrain: createTwentyFirstBattleTerrain() +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -7686,6 +7754,384 @@ export const twentiethBattleUnits: UnitData[] = [ } ]; +const twentyFirstBattleAllyPositions: Record = { + 'liu-bei': { x: 4, y: 24 }, + 'guan-yu': { x: 6, y: 22 }, + 'zhang-fei': { x: 6, y: 25 }, + 'jian-yong': { x: 3, y: 26 }, + 'mi-zhu': { x: 5, y: 27 }, + 'sun-qian': { x: 8, y: 24 }, + 'zhao-yun': { x: 8, y: 21 }, + 'zhuge-liang': { x: 6, y: 23 } +}; + +export const twentyFirstBattleUnits: UnitData[] = [ + ...[ + ...firstBattleUnits.filter((unit) => unit.faction === 'ally'), + ...xuzhouRecruitUnits, + ...caoBreakRecruitUnits, + ...liuBiaoRecruitUnits, + ...zhugeRecruitUnits + ].map((unit) => placeScenarioUnit(unit, twentyFirstBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), + { + id: 'redcliff-vanguard-scout-a', + name: '조조 수군 척후', + faction: 'enemy', + className: '수군 척후', + classKey: 'bandit', + level: 26, + exp: 38, + hp: 74, + maxHp: 74, + attack: 29, + move: 4, + stats: { might: 106, intelligence: 78, leadership: 84, agility: 104, luck: 72 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 3, exp: 52 }, + armor: { itemId: 'rebel-vest', level: 3, exp: 28 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 14, + y: 22 + }, + { + id: 'redcliff-vanguard-scout-b', + name: '조조 수군 척후', + faction: 'enemy', + className: '강변 척후', + classKey: 'bandit', + level: 26, + exp: 38, + hp: 74, + maxHp: 74, + attack: 29, + move: 4, + stats: { might: 106, intelligence: 78, leadership: 84, agility: 104, luck: 72 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 3, exp: 52 }, + armor: { itemId: 'rebel-vest', level: 3, exp: 28 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 15, + y: 26 + }, + { + id: 'redcliff-vanguard-marine-a', + name: '조조 수군 보병', + faction: 'enemy', + className: '갑판 보병', + classKey: 'yellowTurban', + level: 26, + exp: 38, + hp: 96, + maxHp: 96, + attack: 31, + move: 3, + stats: { might: 116, intelligence: 80, leadership: 98, agility: 86, luck: 72 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 58 }, + armor: { itemId: 'lamellar-armor', level: 3, exp: 34 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 19, + y: 20 + }, + { + id: 'redcliff-vanguard-marine-b', + name: '조조 수군 보병', + faction: 'enemy', + className: '갑판 보병', + classKey: 'yellowTurban', + level: 26, + exp: 38, + hp: 96, + maxHp: 96, + attack: 31, + move: 3, + stats: { might: 116, intelligence: 80, leadership: 98, agility: 86, luck: 72 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 58 }, + armor: { itemId: 'lamellar-armor', level: 3, exp: 34 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 21, + y: 23 + }, + { + id: 'redcliff-vanguard-marine-c', + name: '조조 수군 보병', + faction: 'enemy', + className: '포구 보병', + classKey: 'yellowTurban', + level: 27, + exp: 40, + hp: 100, + maxHp: 100, + attack: 32, + move: 3, + stats: { might: 118, intelligence: 82, leadership: 100, agility: 88, luck: 73 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 64 }, + armor: { itemId: 'reinforced-lamellar', level: 3, exp: 38 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 27, + y: 18 + }, + { + id: 'redcliff-vanguard-marine-d', + name: '조조 수군 보병', + faction: 'enemy', + className: '포구 보병', + classKey: 'yellowTurban', + level: 27, + exp: 40, + hp: 100, + maxHp: 100, + attack: 32, + move: 3, + stats: { might: 118, intelligence: 82, leadership: 100, agility: 88, luck: 73 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 64 }, + armor: { itemId: 'reinforced-lamellar', level: 3, exp: 38 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 29, + y: 22 + }, + { + id: 'redcliff-vanguard-archer-a', + name: '조조 선상 궁병', + faction: 'enemy', + className: '선상 궁병', + classKey: 'archer', + level: 26, + exp: 38, + hp: 72, + maxHp: 72, + attack: 31, + move: 3, + stats: { might: 102, intelligence: 92, leadership: 90, agility: 100, luck: 74 }, + equipment: { + weapon: { itemId: 'short-bow', level: 3, exp: 58 }, + armor: { itemId: 'cloth-armor', level: 3, exp: 32 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 20, + y: 16 + }, + { + id: 'redcliff-vanguard-archer-b', + name: '조조 선상 궁병', + faction: 'enemy', + className: '선상 궁병', + classKey: 'archer', + level: 26, + exp: 38, + hp: 72, + maxHp: 72, + attack: 31, + move: 3, + stats: { might: 102, intelligence: 92, leadership: 90, agility: 100, luck: 74 }, + equipment: { + weapon: { itemId: 'short-bow', level: 3, exp: 58 }, + armor: { itemId: 'cloth-armor', level: 3, exp: 32 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 25, + y: 15 + }, + { + id: 'redcliff-vanguard-archer-c', + name: '조조 선상 궁병', + faction: 'enemy', + className: '강북 궁병', + classKey: 'archer', + level: 27, + exp: 40, + hp: 74, + maxHp: 74, + attack: 32, + move: 3, + stats: { might: 104, intelligence: 94, leadership: 92, agility: 102, luck: 74 }, + equipment: { + weapon: { itemId: 'short-bow', level: 4, exp: 2 }, + armor: { itemId: 'cloth-armor', level: 3, exp: 36 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 33, + y: 17 + }, + { + id: 'redcliff-vanguard-cavalry-a', + name: '조조 강안 기병', + faction: 'enemy', + className: '강안 기병', + classKey: 'cavalry', + level: 27, + exp: 40, + hp: 102, + maxHp: 102, + attack: 34, + move: 5, + stats: { might: 120, intelligence: 78, leadership: 96, agility: 114, luck: 74 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 4, exp: 2 }, + armor: { itemId: 'lamellar-armor', level: 3, exp: 42 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 17, + y: 25 + }, + { + id: 'redcliff-vanguard-cavalry-b', + name: '조조 강안 기병', + faction: 'enemy', + className: '강안 기병', + classKey: 'cavalry', + level: 27, + exp: 40, + hp: 102, + maxHp: 102, + attack: 34, + move: 5, + stats: { might: 120, intelligence: 78, leadership: 96, agility: 114, luck: 74 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 4, exp: 2 }, + armor: { itemId: 'lamellar-armor', level: 3, exp: 42 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 23, + y: 26 + }, + { + id: 'redcliff-vanguard-cavalry-c', + name: '조조 강안 기병', + faction: 'enemy', + className: '강안 기병', + classKey: 'cavalry', + level: 28, + exp: 42, + hp: 106, + maxHp: 106, + attack: 35, + move: 5, + stats: { might: 122, intelligence: 80, leadership: 98, agility: 116, luck: 75 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 4, exp: 8 }, + armor: { itemId: 'reinforced-lamellar', level: 3, exp: 46 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 31, + y: 24 + }, + { + id: 'redcliff-vanguard-guard-a', + name: '채모 친위', + faction: 'enemy', + className: '수군 친위', + classKey: 'yellowTurban', + level: 28, + exp: 42, + hp: 108, + maxHp: 108, + attack: 34, + move: 3, + stats: { might: 122, intelligence: 84, leadership: 106, agility: 90, luck: 75 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 4, exp: 10 }, + armor: { itemId: 'reinforced-lamellar', level: 3, exp: 50 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 35, + y: 18 + }, + { + id: 'redcliff-vanguard-guard-b', + name: '채모 친위', + faction: 'enemy', + className: '수군 친위', + classKey: 'yellowTurban', + level: 28, + exp: 42, + hp: 108, + maxHp: 108, + attack: 34, + move: 3, + stats: { might: 122, intelligence: 84, leadership: 106, agility: 90, luck: 75 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 4, exp: 10 }, + armor: { itemId: 'reinforced-lamellar', level: 3, exp: 50 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 36, + y: 21 + }, + { + id: 'redcliff-vanguard-strategist-a', + name: '수군 군리', + faction: 'enemy', + className: '수군 군리', + classKey: 'archer', + level: 27, + exp: 40, + hp: 76, + maxHp: 76, + attack: 31, + move: 3, + stats: { might: 80, intelligence: 100, leadership: 94, agility: 88, luck: 76 }, + equipment: { + weapon: { itemId: 'short-bow', level: 4, exp: 6 }, + armor: { itemId: 'cloth-armor', level: 3, exp: 42 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 34, + y: 15 + }, + { + id: 'redcliff-vanguard-strategist-b', + name: '수군 군리', + faction: 'enemy', + className: '수군 군리', + classKey: 'archer', + level: 27, + exp: 40, + hp: 76, + maxHp: 76, + attack: 31, + move: 3, + stats: { might: 80, intelligence: 100, leadership: 94, agility: 88, luck: 76 }, + equipment: { + weapon: { itemId: 'short-bow', level: 4, exp: 6 }, + armor: { itemId: 'cloth-armor', level: 3, exp: 42 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 37, + y: 16 + }, + { + id: 'redcliff-vanguard-leader-cai-mao', + name: '채모', + faction: 'enemy', + className: '조조 수군 선봉장', + classKey: 'rebelLeader', + level: 29, + exp: 46, + hp: 140, + maxHp: 140, + attack: 36, + move: 4, + stats: { might: 124, intelligence: 88, leadership: 116, agility: 92, luck: 78 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 4, exp: 14 }, + armor: { itemId: 'reinforced-lamellar', level: 4, exp: 4 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 39, + y: 19 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -7838,6 +8284,7 @@ export const eighteenthBattleBonds: BattleBond[] = [...sixteenthBattleBonds, ... ); export const nineteenthBattleBonds: BattleBond[] = [...eighteenthBattleBonds].map(cloneBattleBondForScenario); export const twentiethBattleBonds: BattleBond[] = [...nineteenthBattleBonds].map(cloneBattleBondForScenario); +export const twentyFirstBattleBonds: BattleBond[] = [...twentiethBattleBonds].map(cloneBattleBondForScenario); function createEighthBattleTerrain(): TerrainType[][] { return Array.from({ length: 22 }, (_, y) => @@ -8458,6 +8905,65 @@ function createTwentiethBattleTerrain(): TerrainType[][] { ); } +function createTwentyFirstBattleTerrain(): TerrainType[][] { + return Array.from({ length: 30 }, (_, y) => + Array.from({ length: 42 }, (_, x): TerrainType => { + if ((x <= 4 && y >= 23 && y <= 28) || (x >= 5 && x <= 8 && y >= 25 && y <= 29)) { + return 'camp'; + } + if ((x >= 35 && x <= 41 && y >= 16 && y <= 23) || (x >= 32 && x <= 35 && y >= 17 && y <= 20)) { + return 'fort'; + } + if ((x >= 13 && x <= 16 && y >= 21 && y <= 23) || (x >= 29 && x <= 31 && y >= 17 && y <= 19)) { + return 'village'; + } + if ( + (y === 24 && x >= 3 && x <= 16) || + (y === 21 && x >= 15 && x <= 27) || + (y === 18 && x >= 27 && x <= 38) || + (x === 35 && y >= 16 && y <= 24) + ) { + return 'road'; + } + if ( + (x >= 6 && x <= 17 && y === 27 - Math.floor(x / 2)) || + (x >= 18 && x <= 33 && y === Math.floor(x / 2) + 8) || + (x >= 8 && x <= 14 && y === 25) + ) { + return 'road'; + } + if ((x === 20 || x === 21) && y >= 0 && y <= 29) { + return 'river'; + } + if ((x === 28 || x === 29) && y >= 0 && y <= 16) { + return 'river'; + } + if ((x === 24 || x === 25) && y >= 22 && y <= 29) { + return 'river'; + } + if ( + (x >= 5 && x <= 15 && y >= 9 && y <= 18) || + (x >= 16 && x <= 20 && y >= 23 && y <= 29) || + (x <= 9 && y <= 8) || + (x >= 31 && x <= 40 && y <= 9) + ) { + return 'forest'; + } + if ( + (x >= 10 && x <= 18 && y >= 2 && y <= 6) || + (x >= 27 && x <= 35 && y >= 24 && y <= 29) || + (x >= 33 && x <= 41 && y >= 3 && y <= 7) + ) { + return 'hill'; + } + if ((x <= 1 && y <= 14) || (x >= 40 && y <= 12) || (x >= 40 && y >= 24)) { + return 'cliff'; + } + return 'plain'; + }) + ); +} + function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData { return { ...cloneUnitForScenario(unit), diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 0fb2f78..9f03c43 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -205,7 +205,24 @@ const unitTexture: Record = { 'envoy-road-guard-a': 'unit-rebel', 'envoy-road-guard-b': 'unit-rebel', 'envoy-road-strategist-a': 'unit-rebel-archer', - 'envoy-road-leader-wen-pin': 'unit-rebel-leader' + 'envoy-road-leader-wen-pin': 'unit-rebel-leader', + 'redcliff-vanguard-scout-a': 'unit-rebel', + 'redcliff-vanguard-scout-b': 'unit-rebel', + 'redcliff-vanguard-marine-a': 'unit-rebel', + 'redcliff-vanguard-marine-b': 'unit-rebel', + 'redcliff-vanguard-marine-c': 'unit-rebel', + 'redcliff-vanguard-marine-d': 'unit-rebel', + 'redcliff-vanguard-archer-a': 'unit-rebel-archer', + 'redcliff-vanguard-archer-b': 'unit-rebel-archer', + 'redcliff-vanguard-archer-c': 'unit-rebel-archer', + 'redcliff-vanguard-cavalry-a': 'unit-rebel-cavalry', + 'redcliff-vanguard-cavalry-b': 'unit-rebel-cavalry', + 'redcliff-vanguard-cavalry-c': 'unit-rebel-cavalry', + 'redcliff-vanguard-guard-a': 'unit-rebel', + 'redcliff-vanguard-guard-b': 'unit-rebel', + 'redcliff-vanguard-strategist-a': 'unit-rebel-archer', + 'redcliff-vanguard-strategist-b': 'unit-rebel-archer', + 'redcliff-vanguard-leader-cai-mao': 'unit-rebel-leader' }; const unitTextureByClass: Partial> = { @@ -866,7 +883,24 @@ const enemyAiByUnitId: Record = { 'envoy-road-guard-a': 'guard', 'envoy-road-guard-b': 'guard', 'envoy-road-strategist-a': 'hold', - 'envoy-road-leader-wen-pin': 'guard' + 'envoy-road-leader-wen-pin': 'guard', + 'redcliff-vanguard-scout-a': 'aggressive', + 'redcliff-vanguard-scout-b': 'aggressive', + 'redcliff-vanguard-marine-a': 'guard', + 'redcliff-vanguard-marine-b': 'guard', + 'redcliff-vanguard-marine-c': 'guard', + 'redcliff-vanguard-marine-d': 'guard', + 'redcliff-vanguard-archer-a': 'hold', + 'redcliff-vanguard-archer-b': 'hold', + 'redcliff-vanguard-archer-c': 'hold', + 'redcliff-vanguard-cavalry-a': 'aggressive', + 'redcliff-vanguard-cavalry-b': 'aggressive', + 'redcliff-vanguard-cavalry-c': 'aggressive', + 'redcliff-vanguard-guard-a': 'guard', + 'redcliff-vanguard-guard-b': 'guard', + 'redcliff-vanguard-strategist-a': 'hold', + 'redcliff-vanguard-strategist-b': 'hold', + 'redcliff-vanguard-leader-cai-mao': 'guard' }; const defaultEnemyAiByClass: Partial> = { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index ca22a0a..2eeb834 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -17,6 +17,7 @@ import seventeenthBattleMapUrl from '../../assets/images/battle/seventeenth-batt import tenthBattleMapUrl from '../../assets/images/battle/tenth-battle-map.svg'; import thirteenthBattleMapUrl from '../../assets/images/battle/thirteenth-battle-map.svg'; import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg'; +import twentyFirstBattleMapUrl from '../../assets/images/battle/twenty-first-battle-map.svg'; import twentiethBattleMapUrl from '../../assets/images/battle/twentieth-battle-map.svg'; import twelfthBattleMapUrl from '../../assets/images/battle/twelfth-battle-map.svg'; import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png'; @@ -98,6 +99,7 @@ export class BootScene extends Phaser.Scene { this.load.image('battle-map-eighteenth', eighteenthBattleMapUrl); this.load.image('battle-map-nineteenth', nineteenthBattleMapUrl); this.load.image('battle-map-twentieth', twentiethBattleMapUrl); + this.load.image('battle-map-twenty-first', twentyFirstBattleMapUrl); 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 340a086..c8ebd17 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -281,7 +281,12 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [ title: '적벽대전', period: '강동의 바람', description: '제갈량의 계책과 강동의 힘이 맞물려 조조의 대군을 막아서는 큰 전환 장입니다.', - battleIds: ['eighteenth-battle-bowang-ambush', 'nineteenth-battle-changban-refuge', 'twentieth-battle-jiangdong-envoy'], + battleIds: [ + 'eighteenth-battle-bowang-ambush', + 'nineteenth-battle-changban-refuge', + 'twentieth-battle-jiangdong-envoy', + 'twenty-first-battle-red-cliffs-vanguard' + ], nextHints: ['강동 사절', '화공 준비', '조조 남하'] }, { @@ -322,7 +327,8 @@ const campBattleIds = { seventeenth: 'seventeenth-battle-wolong-visit-road', eighteenth: 'eighteenth-battle-bowang-ambush', nineteenth: 'nineteenth-battle-changban-refuge', - twentieth: 'twentieth-battle-jiangdong-envoy' + twentieth: 'twentieth-battle-jiangdong-envoy', + twentyFirst: 'twenty-first-battle-red-cliffs-vanguard' } as const; const requiredSortieUnitIds = new Set(['liu-bei']); @@ -537,6 +543,18 @@ const sortieRulesByBattleId: Partial