diff --git a/docs/roadmap.md b/docs/roadmap.md index 62bf2e5..b80550c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -52,15 +52,16 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Twenty-eighth battle Fu Pass entry route, putting Pang Tong into the sortie choice across a 56x38 fortified mountain pass and recruiting Fa Zheng after Gao Pei withdraws - Twenty-ninth battle Luo Castle outer wall route, putting Fa Zheng into the sortie choice across a 58x40 outer-wall battlefield and recruiting Wu Yi after Zhang Ren withdraws - Thirtieth battle Luofeng Slope ambush route, using Wu Yi's first sortie choice and Pang Tong survival pressure across a 60x42 ambush battlefield +- Thirty-first battle Luo Castle main-gate route, turning the siege into a 62x44 castle assault with Zhang Ren's final defense and Yan Yan recruitment after the gate opens - 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 thirtieth battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, Bowang/Changban/Jiangdong/Red Cliffs/Jing Province/Guiyang/Wuling/Changsha/Yi Province/Fu Pass/Luo Castle/Luofeng camp states, campaign timeline state, and camp save state +- Flow verification script from title through the thirty-first battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, Bowang/Changban/Jiangdong/Red Cliffs/Jing Province/Guiyang/Wuling/Changsha/Yi Province/Fu Pass/Luo Castle/Luofeng/Luo main gate camp states, campaign timeline state, and camp save state ## Next Steps -1. Continue the Yi Province arc into Luo Castle proper, with Pang Tong/Fa Zheng/Wu Yi planning pressure and a stronger mix of siege, persuasion, and officer-selection tradeoffs +1. Continue the Yi Province arc into Chengdu pressure and final Yi Province persuasion, using Yan Yan's veteran perspective plus Pang Tong/Fa Zheng/Wu Yi planning pressure 2. Add more recruitable officers as the campaign moves deeper through Yi Province and toward 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 0271ca4..d488222 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -12,12 +12,12 @@ try { browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); - await page.goto(targetUrl, { waitUntil: 'networkidle' }); + await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => window.localStorage.clear()); - await page.reload({ waitUntil: 'networkidle' }); - await page.waitForSelector('canvas'); - await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined); - await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); + await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined, undefined, { timeout: 90000 }); + await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); await page.waitForTimeout(500); const debugBeforeBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); @@ -1736,7 +1736,7 @@ try { if ( progressTabState?.activeTab !== 'progress' || progressTabState.campaignProgress?.completedKnown !== 17 || - progressTabState.campaignProgress?.totalKnown !== 30 || + progressTabState.campaignProgress?.totalKnown !== 31 || 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 !== 30 || + postBowangProgressState.campaignProgress?.totalKnown !== 31 || 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 !== 30 || + postChangbanProgressState.campaignProgress?.totalKnown !== 31 || postChangbanProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postChangbanProgressState.campaignProgress?.latestBattleTitle !== '장판파 피난로' || postChangbanProgressState.campaignProgress?.nextBattleTitle !== '강동 사절로' @@ -2164,7 +2164,7 @@ try { if ( postJiangdongProgressState?.activeTab !== 'progress' || postJiangdongProgressState.campaignProgress?.completedKnown !== 20 || - postJiangdongProgressState.campaignProgress?.totalKnown !== 30 || + postJiangdongProgressState.campaignProgress?.totalKnown !== 31 || postJiangdongProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postJiangdongProgressState.campaignProgress?.latestBattleTitle !== '강동 사절로' || postJiangdongProgressState.campaignProgress?.nextBattleTitle !== '적벽 전초전' @@ -2304,7 +2304,7 @@ try { if ( postRedCliffsProgressState?.activeTab !== 'progress' || postRedCliffsProgressState.campaignProgress?.completedKnown !== 21 || - postRedCliffsProgressState.campaignProgress?.totalKnown !== 30 || + postRedCliffsProgressState.campaignProgress?.totalKnown !== 31 || postRedCliffsProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postRedCliffsProgressState.campaignProgress?.latestBattleTitle !== '적벽 전초전' || postRedCliffsProgressState.campaignProgress?.nextBattleTitle !== '적벽 화공전' @@ -2444,7 +2444,7 @@ try { if ( postFireAttackProgressState?.activeTab !== 'progress' || postFireAttackProgressState.campaignProgress?.completedKnown !== 22 || - postFireAttackProgressState.campaignProgress?.totalKnown !== 30 || + postFireAttackProgressState.campaignProgress?.totalKnown !== 31 || postFireAttackProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postFireAttackProgressState.campaignProgress?.latestBattleTitle !== '적벽 화공전' || postFireAttackProgressState.campaignProgress?.nextBattleTitle !== '형주 남부 진입전' @@ -2587,7 +2587,7 @@ try { if ( postJingzhouProgressState?.activeTab !== 'progress' || postJingzhouProgressState.campaignProgress?.completedKnown !== 23 || - postJingzhouProgressState.campaignProgress?.totalKnown !== 30 || + postJingzhouProgressState.campaignProgress?.totalKnown !== 31 || postJingzhouProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postJingzhouProgressState.campaignProgress?.latestBattleTitle !== '형주 남부 진입전' || postJingzhouProgressState.campaignProgress?.nextBattleTitle !== '계양 설득전' @@ -2729,7 +2729,7 @@ try { if ( postGuiyangProgressState?.activeTab !== 'progress' || postGuiyangProgressState.campaignProgress?.completedKnown !== 24 || - postGuiyangProgressState.campaignProgress?.totalKnown !== 30 || + postGuiyangProgressState.campaignProgress?.totalKnown !== 31 || postGuiyangProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postGuiyangProgressState.campaignProgress?.latestBattleTitle !== '계양 설득전' || postGuiyangProgressState.campaignProgress?.nextBattleTitle !== '무릉 산길 확보전' @@ -2872,7 +2872,7 @@ try { if ( postWulingProgressState?.activeTab !== 'progress' || postWulingProgressState.campaignProgress?.completedKnown !== 25 || - postWulingProgressState.campaignProgress?.totalKnown !== 30 || + postWulingProgressState.campaignProgress?.totalKnown !== 31 || postWulingProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postWulingProgressState.campaignProgress?.latestBattleTitle !== '무릉 산길 확보전' || postWulingProgressState.campaignProgress?.nextBattleTitle !== '장사 노장 대면전' @@ -3021,7 +3021,7 @@ try { if ( postChangshaProgressState?.activeTab !== 'progress' || postChangshaProgressState.campaignProgress?.completedKnown !== 26 || - postChangshaProgressState.campaignProgress?.totalKnown !== 30 || + postChangshaProgressState.campaignProgress?.totalKnown !== 31 || postChangshaProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postChangshaProgressState.campaignProgress?.latestBattleTitle !== '장사 노장 대면전' || postChangshaProgressState.campaignProgress?.nextBattleTitle !== '익주 원군로' @@ -3166,7 +3166,7 @@ try { if ( postYizhouProgressState?.activeTab !== 'progress' || postYizhouProgressState.campaignProgress?.completedKnown !== 27 || - postYizhouProgressState.campaignProgress?.totalKnown !== 30 || + postYizhouProgressState.campaignProgress?.totalKnown !== 31 || postYizhouProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postYizhouProgressState.campaignProgress?.latestBattleTitle !== '익주 원군로' || postYizhouProgressState.campaignProgress?.nextBattleTitle !== '부수관 진입전' @@ -3313,7 +3313,7 @@ try { if ( postFuPassProgressState?.activeTab !== 'progress' || postFuPassProgressState.campaignProgress?.completedKnown !== 28 || - postFuPassProgressState.campaignProgress?.totalKnown !== 30 || + postFuPassProgressState.campaignProgress?.totalKnown !== 31 || postFuPassProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postFuPassProgressState.campaignProgress?.latestBattleTitle !== '부수관 진입전' || postFuPassProgressState.campaignProgress?.nextBattleTitle !== '낙성 외곽전' @@ -3462,7 +3462,7 @@ try { if ( postLuoProgressState?.activeTab !== 'progress' || postLuoProgressState.campaignProgress?.completedKnown !== 29 || - postLuoProgressState.campaignProgress?.totalKnown !== 30 || + postLuoProgressState.campaignProgress?.totalKnown !== 31 || postLuoProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postLuoProgressState.campaignProgress?.latestBattleTitle !== '낙성 외곽전' || postLuoProgressState.campaignProgress?.nextBattleTitle !== '낙봉파 매복전' @@ -3608,27 +3608,186 @@ try { if ( postLuofengProgressState?.activeTab !== 'progress' || postLuofengProgressState.campaignProgress?.completedKnown !== 30 || - postLuofengProgressState.campaignProgress?.totalKnown !== 30 || + postLuofengProgressState.campaignProgress?.totalKnown !== 31 || postLuofengProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postLuofengProgressState.campaignProgress?.latestBattleTitle !== '낙봉파 매복전' || - postLuofengProgressState.campaignProgress?.nextBattleTitle !== '준비 중' + postLuofengProgressState.campaignProgress?.nextBattleTitle !== '낙성 본성 공략' ) { - throw new Error(`Expected post-Luofeng progress tab to complete Luofeng and pause before Luo Castle proper: ${JSON.stringify(postLuofengProgressState?.campaignProgress)}`); + throw new Error(`Expected post-Luofeng progress tab to complete Luofeng and open Luo Castle main gate: ${JSON.stringify(postLuofengProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-luofeng-progress.png', fullPage: true }); - await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); + await page.mouse.click(1120, 38); + await page.waitForTimeout(180); + const luoMainSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + !luoMainSortieState?.sortieVisible || + !luoMainSortieState.sortiePlan?.objectiveLine?.includes('낙성 본성 공략') || + !luoMainSortieState.sortieRoster?.some((unit) => unit.id === 'pang-tong' && unit.recruited && unit.recommended) || + !luoMainSortieState.sortieRoster?.some((unit) => unit.id === 'fa-zheng' && unit.recruited && unit.recommended) || + !luoMainSortieState.sortieRoster?.some((unit) => unit.id === 'wu-yi' && unit.recruited && unit.recommended) || + !luoMainSortieState.sortieRoster?.some((unit) => unit.id === 'huang-zhong' && unit.recruited && unit.recommended) || + !luoMainSortieState.sortieRoster?.some((unit) => unit.id === 'zhang-fei' && unit.recommended) || + luoMainSortieState.sortieRoster?.some((unit) => unit.id === 'yan-yan') || + luoMainSortieState.sortieRoster?.length < 16 || + luoMainSortieState.sortiePlan?.maxCount !== 6 + ) { + throw new Error(`Expected Luofeng camp sortie prep to target Luo Castle main gate with Pang Tong, Fa Zheng, Wu Yi, Huang Zhong, and Zhang Fei recommended, before Yan Yan joins: ${JSON.stringify(luoMainSortieState)}`); + } + assertSortieTacticalRoster(luoMainSortieState, [ + 'liu-bei', + 'guan-yu', + 'zhang-fei', + 'jian-yong', + 'mi-zhu', + 'sun-qian', + 'zhao-yun', + 'zhuge-liang', + 'ma-liang', + 'yi-ji', + 'gong-zhi', + 'huang-zhong', + 'wei-yan', + 'pang-tong', + 'fa-zheng', + 'wu-yi' + ]); + + const luoMainPriorityUnits = ['pang-tong', 'fa-zheng', 'wu-yi', 'huang-zhong', 'zhang-fei']; + for (const unitId of luoMainPriorityUnits) { + 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' && !luoMainPriorityUnits.includes(unit.id) + ); + if (removable) { + await clickSortieRosterUnit(page, removable.id); + } + await clickSortieRosterUnit(page, unitId); + } + } + + const luoMainSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + !luoMainPriorityUnits.every((unitId) => + luoMainSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) + ) || + luoMainSortieReadyState.sortiePlan?.selectedCount !== 6 || + luoMainSortieReadyState.sortiePlan?.recommendedSelectedCount < 6 + ) { + throw new Error(`Expected Luo Castle main sortie to deploy Pang Tong, Fa Zheng, Wu Yi, Huang Zhong, and Zhang Fei while preserving six-officer pressure: ${JSON.stringify(luoMainSortieReadyState)}`); + } + await page.screenshot({ path: 'dist/verification-luo-main-sortie.png', fullPage: true }); + + await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; - return activeScenes.includes('TitleScene'); + return activeScenes.includes('StoryScene'); }); + await page.screenshot({ path: 'dist/verification-luo-main-story.png', fullPage: true }); + + for (let i = 0; i < 50; i += 1) { + const enteredThirtyFirstBattle = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'thirty-first-battle-luo-main-gate'; + }); + if (enteredThirtyFirstBattle) { + 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 === 'thirty-first-battle-luo-main-gate' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-thirty-first-battle.png', fullPage: true }); + + const thirtyFirstBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const thirtyFirstEnemies = thirtyFirstBattleState.units.filter((unit) => unit.faction === 'enemy'); + const thirtyFirstAllies = thirtyFirstBattleState.units.filter((unit) => unit.faction === 'ally'); + const thirtyFirstEnemyBehaviors = new Set(thirtyFirstEnemies.map((unit) => unit.ai)); + if ( + thirtyFirstBattleState.camera?.mapWidth !== 62 || + thirtyFirstBattleState.camera?.mapHeight !== 44 || + thirtyFirstBattleState.victoryConditionLabel !== '장임 본성 격파' || + thirtyFirstEnemies.length < 25 || + !thirtyFirstEnemyBehaviors.has('aggressive') || + !thirtyFirstEnemyBehaviors.has('guard') || + !thirtyFirstEnemyBehaviors.has('hold') || + !thirtyFirstEnemies.some((unit) => unit.id === 'luo-main-leader-zhang-ren') || + !thirtyFirstEnemies.some((unit) => unit.id === 'luo-main-veteran-yan-yan') || + !thirtyFirstAllies.some((unit) => unit.id === 'pang-tong') || + !thirtyFirstAllies.some((unit) => unit.id === 'fa-zheng') || + !thirtyFirstAllies.some((unit) => unit.id === 'wu-yi') || + !thirtyFirstAllies.some((unit) => unit.id === 'huang-zhong') || + !thirtyFirstAllies.some((unit) => unit.id === 'zhang-fei') || + thirtyFirstAllies.some((unit) => unit.id === 'yan-yan') + ) { + throw new Error(`Expected thirty-first battle to use Luo Castle main map, Zhang Ren objective, Yan Yan enemy officer, selected allied officers, and mixed AI: ${JSON.stringify(thirtyFirstBattleState)}`); + } + + 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 thirtyFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + thirtyFirstCampState?.campBattleId !== 'thirty-first-battle-luo-main-gate' || + thirtyFirstCampState.campTitle !== '낙성 본성 공략 후 군영' || + thirtyFirstCampState.availableDialogueIds?.length !== 3 || + !thirtyFirstCampState.availableDialogueIds.every((id) => id.endsWith('luomain')) || + thirtyFirstCampState.availableVisitIds?.length !== 2 || + !thirtyFirstCampState.campaign?.roster?.some((unit) => unit.id === 'yan-yan') || + !thirtyFirstCampState.sortieRoster?.some((unit) => unit.id === 'yan-yan' && unit.recruited) || + thirtyFirstCampState.rosterCollection?.total < 17 + ) { + throw new Error(`Expected thirty-first camp to recruit Yan Yan and expose Luo main dialogue/visit sets: ${JSON.stringify(thirtyFirstCampState)}`); + } + await page.screenshot({ path: 'dist/verification-luo-main-camp.png', fullPage: true }); + + await page.mouse.click(966, 38); + await page.waitForTimeout(180); + const postLuoMainProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + postLuoMainProgressState?.activeTab !== 'progress' || + postLuoMainProgressState.campaignProgress?.completedKnown !== 31 || + postLuoMainProgressState.campaignProgress?.totalKnown !== 31 || + postLuoMainProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || + postLuoMainProgressState.campaignProgress?.latestBattleTitle !== '낙성 본성 공략' || + postLuoMainProgressState.campaignProgress?.nextBattleTitle !== '준비 중' + ) { + throw new Error(`Expected post-Luo-main progress tab to complete Luo Castle proper and pause before Chengdu pressure: ${JSON.stringify(postLuoMainProgressState?.campaignProgress)}`); + } + await page.screenshot({ path: 'dist/verification-post-luo-main-progress.png', fullPage: true }); + + await page.evaluate(() => { + const game = window.__HEROS_GAME__; + game?.scene.stop('CampScene'); + game?.scene.stop('BattleScene'); + game?.scene.stop('StoryScene'); + game?.scene.start('TitleScene'); + }); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('TitleScene') && !activeScenes.includes('CampScene'); + }); + await page.waitForTimeout(300); await page.mouse.click(962, 310); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); - console.log(`Verified title-to-thirtieth-battle flow, recruited officer sortie selection, Ma Liang, Yi Ji, Gong Zhi, Huang Zhong, Wei Yan, Pang Tong, Fa Zheng, Wu Yi joins, Luofeng ambush, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-thirty-first-battle flow, recruited officer sortie selection, Ma Liang, Yi Ji, Gong Zhi, Huang Zhong, Wei Yan, Pang Tong, Fa Zheng, Wu Yi, Yan Yan joins, Luofeng and Luo main gate battles, result states, and debug API at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { diff --git a/src/assets/images/battle/thirty-first-battle-map.svg b/src/assets/images/battle/thirty-first-battle-map.svg new file mode 100644 index 0000000..a570a78 --- /dev/null +++ b/src/assets/images/battle/thirty-first-battle-map.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 4fbac12..2ef6e64 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -43,6 +43,10 @@ import { tenthBattleMap, tenthBattleUnits, tenthBattleVictoryPages, + thirtyFirstBattleBonds, + thirtyFirstBattleMap, + thirtyFirstBattleUnits, + thirtyFirstBattleVictoryPages, thirteenthBattleBonds, thirteenthBattleMap, thirteenthBattleUnits, @@ -155,7 +159,8 @@ export type BattleScenarioId = | 'twenty-seventh-battle-yizhou-relief-road' | 'twenty-eighth-battle-fu-pass-entry' | 'twenty-ninth-battle-luo-outer-wall' - | 'thirtieth-battle-luofeng-ambush'; + | 'thirtieth-battle-luofeng-ambush' + | 'thirty-first-battle-luo-main-gate'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -1905,6 +1910,69 @@ export const thirtiethBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const thirtyFirstBattleScenario: BattleScenarioDefinition = { + id: 'thirty-first-battle-luo-main-gate', + title: '낙성 본성 공략', + victoryConditionLabel: '장임 본성 격파', + defeatConditionLabel: '유비 또는 방통 퇴각', + openingObjectiveLines: [ + '낙성 본성은 높은 성벽과 좁은 성문으로 버티고 있습니다. 성문을 무너뜨리되 백성 피해를 줄여야 다음 익주 진입이 쉬워집니다.', + '엄안이 성 안의 노장으로 버티고 있습니다. 황충, 법정, 오의를 데려가면 성벽 궁병과 항복 설득을 함께 압박하기 좋습니다.', + '유비와 방통을 지키며 장임을 격파하십시오. 낙성이 열리면 익주 심부와 성도 압박으로 나아갑니다.' + ], + map: thirtyFirstBattleMap, + units: thirtyFirstBattleUnits, + bonds: thirtyFirstBattleBonds, + mapTextureKey: 'battle-map-thirty-first', + leaderUnitId: 'luo-main-leader-zhang-ren', + quickVictoryTurnLimit: 42, + baseVictoryGold: 4720, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '장임 본성 격파', + rewardGold: 2920, + unitId: 'luo-main-leader-zhang-ren' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 900, + unitId: 'liu-bei' + }, + { + id: 'pang-tong', + kind: 'keep-unit-alive', + label: '방통 생존', + rewardGold: 1220, + unitId: 'pang-tong' + }, + { + id: 'main-gate', + kind: 'secure-terrain', + label: '낙성 성문 확보', + rewardGold: 1480, + terrain: 'fort' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '42턴 이내 승리', + rewardGold: 1120, + maxTurn: 42 + } + ], + defeatConditions: [ + { kind: 'unit-defeated', unitId: 'liu-bei' }, + { kind: 'unit-defeated', unitId: 'pang-tong' } + ], + itemRewards: ['콩 16', '상처약 12', '탁주 7', '낙성 항복 권고문 +1', '엄안 합류'], + victoryPages: thirtyFirstBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { @@ -1937,7 +2005,8 @@ export const battleScenarios: Record 'twenty-seventh-battle-yizhou-relief-road': twentySeventhBattleScenario, 'twenty-eighth-battle-fu-pass-entry': twentyEighthBattleScenario, 'twenty-ninth-battle-luo-outer-wall': twentyNinthBattleScenario, - 'thirtieth-battle-luofeng-ambush': thirtiethBattleScenario + 'thirtieth-battle-luofeng-ambush': thirtiethBattleScenario, + 'thirty-first-battle-luo-main-gate': thirtyFirstBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 8ab371a..da69ffb 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -17,6 +17,7 @@ import { seventeenthBattleScenario, tenthBattleScenario, thirteenthBattleScenario, + thirtyFirstBattleScenario, thirtiethBattleScenario, twentyEighthBattleScenario, twentyNinthBattleScenario, @@ -66,6 +67,8 @@ import { tenthBattleVictoryPages, thirteenthBattleIntroPages, thirteenthBattleVictoryPages, + thirtyFirstBattleIntroPages, + thirtyFirstBattleVictoryPages, thirtiethBattleIntroPages, thirtiethBattleVictoryPages, twentyEighthBattleIntroPages, @@ -417,13 +420,24 @@ const sortieFlows: Record = { }, [thirtiethBattleScenario.id]: { afterBattleId: thirtiethBattleScenario.id, - eyebrow: '다음 장 준비', - title: '낙성 본성 공략 준비', + eyebrow: '다음 전장', + title: thirtyFirstBattleScenario.title, description: '낙봉파의 매복을 무너뜨리고 방통을 살려 냈습니다. 장임은 낙성 본성으로 물러났고, 익주의 성문과 장수들의 마음을 함께 열어야 할 차례입니다.', - rewardHint: '다음 장: 낙성 본성 공략 준비 중', - pages: thirtiethBattleVictoryPages, - unavailableNotice: '낙성 본성 공략은 다음 작업에서 이어집니다. 군영에서 방통, 법정, 오의의 정보와 출전 조합을 더 다듬어 두십시오.' + rewardHint: `예상 보상: ${thirtyFirstBattleScenario.title} 개방 / 엄안 합류`, + nextBattleId: thirtyFirstBattleScenario.id, + campaignStep: 'thirty-first-battle', + pages: [...thirtiethBattleVictoryPages, ...thirtyFirstBattleIntroPages] + }, + [thirtyFirstBattleScenario.id]: { + afterBattleId: thirtyFirstBattleScenario.id, + eyebrow: '다음 장 준비', + title: '성도 압박 준비', + description: + '낙성 본성이 열리고 엄안이 합류했습니다. 이제 유비군은 익주의 심부를 흔들며 성도까지 이어지는 길을 준비해야 합니다.', + rewardHint: '다음 장: 성도 압박 준비 중', + pages: thirtyFirstBattleVictoryPages, + unavailableNotice: '성도 압박과 익주 최종 설득은 다음 작업에서 이어집니다. 새로 합류한 엄안을 포함해 성벽과 고지 전투 조합을 준비하십시오.' } }; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 049be30..48c9bf7 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1952,6 +1952,68 @@ export const thirtiethBattleVictoryPages: StoryPage[] = [ } ]; +export const thirtyFirstBattleIntroPages: StoryPage[] = [ + { + id: 'thirty-first-luo-main-gate', + bgm: 'story-dark', + chapter: '낙성 본성의 문', + background: 'story-three-heroes', + speaker: '법정', + text: '장임은 낙봉파에서 물러난 뒤 낙성 본성의 문을 모두 닫았습니다. 성벽은 높고, 성 안의 장수들은 아직 유비공의 뜻을 의심하고 있습니다.' + }, + { + id: 'thirty-first-veteran-wall', + bgm: 'battle-prep', + chapter: '노장 엄안', + background: 'story-liu-bei', + speaker: '오의', + text: '성 안에는 엄안이라는 노장이 있습니다. 장임과 뜻을 같이해 버티고 있으나, 백성을 해치지 않겠다는 약속을 보이면 마음이 움직일 수 있습니다.' + }, + { + id: 'thirty-first-siege-choice', + bgm: 'battle-prep', + chapter: '돌파와 설득', + background: 'story-militia', + speaker: '제갈량', + text: '성문을 부수는 힘만으로는 낙성을 얻을 수 없습니다. 방통, 법정, 오의를 함께 쓰면 설득의 길이 열리고, 무장을 더 세우면 짧은 돌파가 가능해집니다.' + }, + { + id: 'thirty-first-sortie', + bgm: 'battle-prep', + chapter: '낙성 본성 공략', + background: 'story-sortie', + speaker: '유비', + portrait: 'liuBei', + text: '성문을 열되 백성의 마음은 닫지 않겠소. 장임의 방어선을 깨고 엄안 같은 장수에게 우리가 지키려는 뜻을 보여 주시오.' + } +]; + +export const thirtyFirstBattleVictoryPages: StoryPage[] = [ + { + id: 'thirty-first-victory-gate-opened', + bgm: 'militia-theme', + chapter: '낙성의 문이 열리다', + background: 'story-sortie', + text: '낙성 본성의 문은 마침내 열렸습니다. 성벽 위의 깃발은 내려갔고, 장임의 마지막 방어선도 유비군의 꾸준한 압박 앞에 무너졌습니다.' + }, + { + id: 'thirty-first-yan-yan-joins', + bgm: 'battle-prep', + chapter: '엄안의 결심', + background: 'story-liu-bei', + speaker: '엄안', + text: '성을 잃고도 백성이 다치지 않았다면, 장수는 패배 속에서도 새 주군을 볼 수 있습니다. 노장의 활이 이제 유비공의 군영을 지키겠습니다.' + }, + { + id: 'thirty-first-next-chengdu-road', + bgm: 'story-dark', + chapter: '성도 압박의 길', + background: 'story-three-heroes', + speaker: '법정', + text: '낙성이 열렸으니 익주의 심장부가 흔들립니다. 이제 유비군은 성도를 향한 길에서 더 많은 항복과 저항을 동시에 맞이하게 될 것입니다.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -2277,6 +2339,12 @@ export const thirtiethBattleMap: BattleMap = { terrain: createThirtiethBattleTerrain() }; +export const thirtyFirstBattleMap: BattleMap = { + width: 62, + height: 44, + terrain: createThirtyFirstBattleTerrain() +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -4422,6 +4490,30 @@ export const luoCastleRecruitUnits: UnitData[] = [ } ]; +export const luoCastleProperRecruitUnits: UnitData[] = [ + { + id: 'yan-yan', + name: '엄안', + faction: 'ally', + className: '익주 노장', + classKey: 'archer', + level: 19, + exp: 0, + hp: 42, + maxHp: 42, + attack: 14, + move: 4, + stats: { might: 78, intelligence: 70, leadership: 88, agility: 68, luck: 74 }, + equipment: { + weapon: { itemId: 'short-bow', level: 3, exp: 18 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 20 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 10, + y: 27 + } +]; + const eighthBattleAllyPositions: Record = { 'liu-bei': { x: 2, y: 17 }, 'guan-yu': { x: 3, y: 17 }, @@ -12782,6 +12874,169 @@ export const thirtiethBattleUnits: UnitData[] = [ } ]; +const thirtyFirstBattleAllyPositions: Record = { + 'liu-bei': { x: 4, y: 39 }, + 'guan-yu': { x: 5, y: 37 }, + 'zhang-fei': { x: 4, y: 41 }, + 'jian-yong': { x: 3, y: 42 }, + 'mi-zhu': { x: 5, y: 42 }, + 'sun-qian': { x: 7, y: 41 }, + 'zhao-yun': { x: 8, y: 38 }, + 'zhuge-liang': { x: 6, y: 37 }, + 'ma-liang': { x: 8, y: 42 }, + 'yi-ji': { x: 9, y: 40 }, + 'gong-zhi': { x: 10, y: 42 }, + 'huang-zhong': { x: 7, y: 39 }, + 'wei-yan': { x: 10, y: 37 }, + 'pang-tong': { x: 9, y: 38 }, + 'fa-zheng': { x: 11, y: 40 }, + 'wu-yi': { x: 12, y: 38 } +}; + +const createThirtyFirstEnemyUnit = ( + id: string, + name: string, + className: string, + classKey: UnitClassKey, + level: number, + hp: number, + attack: number, + x: number, + y: number +): UnitData => { + const equipmentByClass: Record = { + lord: { + weapon: { itemId: 'twin-oath-blades', level: 5, exp: 70 }, + armor: { itemId: 'reinforced-lamellar', level: 5, exp: 62 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + infantry: { + weapon: { itemId: 'iron-spear', level: 5, exp: 74 }, + armor: { itemId: 'reinforced-lamellar', level: 5, exp: 66 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + cavalry: { + weapon: { itemId: 'iron-spear', level: 5, exp: 78 }, + armor: { itemId: 'lamellar-armor', level: 5, exp: 68 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + spearman: { + weapon: { itemId: 'iron-spear', level: 5, exp: 76 }, + armor: { itemId: 'reinforced-lamellar', level: 5, exp: 66 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + archer: { + weapon: { itemId: 'short-bow', level: 5, exp: 72 }, + armor: { itemId: 'cloth-armor', level: 5, exp: 58 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + strategist: { + weapon: { itemId: 'white-feather-fan', level: 5, exp: 64 }, + armor: { itemId: 'cloth-armor', level: 5, exp: 54 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + quartermaster: { + weapon: { itemId: 'white-feather-fan', level: 5, exp: 56 }, + armor: { itemId: 'cloth-armor', level: 5, exp: 48 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + bandit: { + weapon: { itemId: 'yellow-turban-saber', level: 5, exp: 66 }, + armor: { itemId: 'rebel-vest', level: 5, exp: 54 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + yellowTurban: { + weapon: { itemId: 'iron-spear', level: 5, exp: 78 }, + armor: { itemId: 'reinforced-lamellar', level: 5, exp: 68 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + rebelLeader: { + weapon: { itemId: 'leader-axe', level: 5, exp: 82 }, + armor: { itemId: 'reinforced-lamellar', level: 5, exp: 76 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + } + }; + const moveByClass: Record = { + lord: 4, + infantry: 3, + cavalry: 5, + spearman: 3, + archer: 3, + strategist: 4, + quartermaster: 4, + bandit: 4, + yellowTurban: 3, + rebelLeader: 4 + }; + const statLift = Math.max(0, level - 52) * 2; + + return { + id, + name, + faction: 'enemy', + className, + classKey, + level, + exp: Math.min(98, 84 + statLift), + hp, + maxHp: hp, + attack, + move: moveByClass[classKey], + stats: { + might: 148 + statLift + (classKey === 'rebelLeader' ? 34 : classKey === 'cavalry' ? 24 : classKey === 'yellowTurban' ? 20 : 0), + intelligence: 108 + statLift + (classKey === 'strategist' ? 34 : classKey === 'archer' ? 12 : 0), + leadership: 132 + statLift + (classKey === 'rebelLeader' ? 34 : classKey === 'yellowTurban' ? 18 : 0), + agility: 112 + statLift + (classKey === 'bandit' ? 28 : classKey === 'cavalry' ? 26 : classKey === 'archer' ? 18 : 0), + luck: 90 + Math.floor(statLift / 2) + }, + equipment: equipmentByClass[classKey], + x, + y + }; +}; + +export const thirtyFirstBattleUnits: UnitData[] = [ + ...[ + ...firstBattleUnits.filter((unit) => unit.faction === 'ally'), + ...xuzhouRecruitUnits, + ...caoBreakRecruitUnits, + ...liuBiaoRecruitUnits, + ...zhugeRecruitUnits, + ...jingzhouRecruitUnits, + ...guiyangRecruitUnits, + ...wulingRecruitUnits, + ...changshaRecruitUnits, + ...yizhouRecruitUnits, + ...fuPassRecruitUnits, + ...luoCastleRecruitUnits + ].map((unit) => placeScenarioUnit(unit, thirtyFirstBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), + createThirtyFirstEnemyUnit('luo-main-scout-a', '낙성 정찰병', '성밖 정찰병', 'bandit', 53, 148, 55, 18, 39), + createThirtyFirstEnemyUnit('luo-main-scout-b', '낙성 정찰병', '수로 정찰병', 'bandit', 53, 148, 55, 23, 36), + createThirtyFirstEnemyUnit('luo-main-infantry-a', '낙성 보병', '외성 보병', 'yellowTurban', 54, 210, 66, 28, 34), + createThirtyFirstEnemyUnit('luo-main-infantry-b', '낙성 보병', '외성 보병', 'yellowTurban', 54, 210, 66, 33, 32), + createThirtyFirstEnemyUnit('luo-main-infantry-c', '낙성 보병', '성문 보병', 'yellowTurban', 55, 220, 68, 40, 28), + createThirtyFirstEnemyUnit('luo-main-infantry-d', '낙성 보병', '성문 보병', 'yellowTurban', 55, 220, 68, 44, 26), + createThirtyFirstEnemyUnit('luo-main-infantry-e', '낙성 보병', '내성 보병', 'yellowTurban', 56, 230, 70, 51, 23), + createThirtyFirstEnemyUnit('luo-main-archer-a', '낙성 궁병', '외벽 궁병', 'archer', 54, 146, 55, 31, 29), + createThirtyFirstEnemyUnit('luo-main-archer-b', '낙성 궁병', '성벽 궁병', 'archer', 55, 152, 56, 42, 22), + createThirtyFirstEnemyUnit('luo-main-archer-c', '낙성 궁병', '성벽 궁병', 'archer', 55, 152, 56, 49, 18), + createThirtyFirstEnemyUnit('luo-main-archer-d', '낙성 궁병', '내성 궁병', 'archer', 56, 158, 58, 55, 24), + createThirtyFirstEnemyUnit('luo-main-cavalry-a', '낙성 기병', '우익 기병', 'cavalry', 55, 220, 70, 29, 40), + createThirtyFirstEnemyUnit('luo-main-cavalry-b', '낙성 기병', '좌익 기병', 'cavalry', 55, 220, 70, 39, 36), + createThirtyFirstEnemyUnit('luo-main-cavalry-c', '낙성 기병', '내성 기병', 'cavalry', 56, 232, 72, 53, 29), + createThirtyFirstEnemyUnit('luo-main-guard-a', '장임 친위대', '성문 친위대', 'yellowTurban', 56, 236, 72, 48, 21), + createThirtyFirstEnemyUnit('luo-main-guard-b', '장임 친위대', '성문 친위대', 'yellowTurban', 56, 236, 72, 51, 20), + createThirtyFirstEnemyUnit('luo-main-guard-c', '장임 친위대', '내성 친위대', 'yellowTurban', 57, 246, 74, 56, 19), + createThirtyFirstEnemyUnit('luo-main-strategist-a', '낙성 책사', '성문 책사', 'strategist', 55, 150, 56, 45, 18), + createThirtyFirstEnemyUnit('luo-main-strategist-b', '낙성 책사', '내성 책사', 'strategist', 56, 156, 58, 55, 15), + createThirtyFirstEnemyUnit('luo-main-ambusher-a', '낙성 복병', '남벽 복병', 'bandit', 54, 156, 58, 34, 39), + createThirtyFirstEnemyUnit('luo-main-ambusher-b', '낙성 복병', '동문 복병', 'bandit', 54, 156, 58, 48, 32), + createThirtyFirstEnemyUnit('luo-main-ambusher-c', '낙성 복병', '북벽 복병', 'bandit', 55, 164, 60, 57, 11), + createThirtyFirstEnemyUnit('luo-main-veteran-yan-yan', '엄안', '익주 노장', 'archer', 57, 210, 68, 50, 22), + createThirtyFirstEnemyUnit('luo-main-officer-a', '낙성 부장', '성문 부장', 'rebelLeader', 57, 260, 75, 54, 20), + createThirtyFirstEnemyUnit('luo-main-leader-zhang-ren', '장임', '낙성 본성 주장', 'rebelLeader', 58, 286, 78, 58, 18) +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -13106,6 +13361,33 @@ export const luoCastleRecruitBonds: BattleBond[] = [ } ]; +export const luoCastleProperRecruitBonds: BattleBond[] = [ + { + id: 'liu-bei__yan-yan', + unitIds: ['liu-bei', 'yan-yan'], + title: '노장의 항복', + level: 32, + exp: 0, + description: '유비의 명분과 엄안의 노련한 절개가 만나, 익주의 성문과 백성을 함께 지키는 신뢰로 이어집니다.' + }, + { + id: 'huang-zhong__yan-yan', + unitIds: ['huang-zhong', 'yan-yan'], + title: '노장 쌍궁', + level: 30, + exp: 0, + description: '황충의 침착한 활과 엄안의 성벽 방어 경험이 맞물려, 고지와 성문에서 강한 원거리 공명을 만듭니다.' + }, + { + id: 'zhang-fei__yan-yan', + unitIds: ['zhang-fei', 'yan-yan'], + title: '호통과 절개', + level: 28, + exp: 0, + description: '장비의 거친 돌파력과 엄안의 굽히지 않는 절개가 부딪치며, 서로의 강함을 인정하는 관계로 자랍니다.' + } +]; + export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); @@ -13141,6 +13423,7 @@ export const twentySeventhBattleBonds: BattleBond[] = [...twentySixthBattleBonds export const twentyEighthBattleBonds: BattleBond[] = [...twentySeventhBattleBonds, ...yizhouRecruitBonds].map(cloneBattleBondForScenario); export const twentyNinthBattleBonds: BattleBond[] = [...twentyEighthBattleBonds, ...fuPassRecruitBonds].map(cloneBattleBondForScenario); export const thirtiethBattleBonds: BattleBond[] = [...twentyNinthBattleBonds, ...luoCastleRecruitBonds].map(cloneBattleBondForScenario); +export const thirtyFirstBattleBonds: BattleBond[] = [...thirtiethBattleBonds].map(cloneBattleBondForScenario); function createEighthBattleTerrain(): TerrainType[][] { return Array.from({ length: 22 }, (_, y) => @@ -14338,6 +14621,70 @@ function createThirtiethBattleTerrain(): TerrainType[][] { ); } +function createThirtyFirstBattleTerrain(): TerrainType[][] { + return Array.from({ length: 44 }, (_, y) => + Array.from({ length: 62 }, (_, x): TerrainType => { + if (x <= 4 && y >= 37) { + return 'camp'; + } + if ( + (x >= 47 && x <= 60 && y >= 13 && y <= 25) || + (x >= 43 && x <= 52 && y >= 9 && y <= 17) || + (x >= 52 && x <= 60 && y >= 17 && y <= 28) + ) { + return 'fort'; + } + if ( + (x >= 18 && x <= 21 && y >= 35 && y <= 37) || + (x >= 35 && x <= 37 && y >= 29 && y <= 31) || + (x >= 45 && x <= 47 && y >= 24 && y <= 26) + ) { + return 'village'; + } + if ( + (y >= 39 && x >= 4 && x <= 18) || + (x >= 17 && x <= 30 && y >= 35 && y <= 39) || + (x >= 29 && x <= 42 && y >= 30 && y <= 35) || + (x >= 41 && x <= 52 && y >= 24 && y <= 30) || + (x >= 51 && x <= 59 && y >= 19 && y <= 24) || + (x >= 56 && x <= 60 && y >= 18 && y <= 20) + ) { + return 'road'; + } + if ((x === 13 || x === 14) && y >= 0 && y <= 33) { + return 'river'; + } + if ((x === 33 || x === 34) && y >= 4 && y <= 28) { + return 'river'; + } + if ((x === 44 || x === 45) && y >= 31 && y <= 43) { + return 'river'; + } + if ( + (x >= 4 && x <= 20 && y >= 8 && y <= 28) || + (x >= 21 && x <= 32 && y >= 14 && y <= 29) || + (x >= 35 && x <= 49 && y >= 32 && y <= 43) || + (x >= 47 && x <= 61 && y >= 28 && y <= 42) + ) { + return 'forest'; + } + if ( + (x >= 0 && x <= 11 && y <= 15) || + (x >= 21 && x <= 32 && y <= 13) || + (x >= 34 && x <= 50 && y >= 2 && y <= 14) || + (x >= 53 && x <= 61 && y <= 12) || + (x >= 25 && x <= 36 && y >= 36 && y <= 43) + ) { + return 'hill'; + } + if ((x <= 2 && y <= 36) || (x >= 60 && y >= 26) || (x >= 47 && x <= 50 && y <= 9)) { + 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 656ad8e..3127705 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -37,6 +37,7 @@ const unitTexture: Record = { 'pang-tong': 'unit-liu-bei', 'fa-zheng': 'unit-liu-bei', 'wu-yi': 'unit-guan-yu', + 'yan-yan': 'unit-rebel-archer', 'rebel-a': 'unit-rebel', 'rebel-b': 'unit-rebel', 'rebel-c': 'unit-rebel-archer', @@ -401,7 +402,32 @@ const unitTexture: Record = { 'luofeng-ambusher-b': 'unit-rebel', 'luofeng-ambusher-c': 'unit-rebel', 'luofeng-ambusher-d': 'unit-rebel', - 'luofeng-leader-zhang-ren': 'unit-rebel-leader' + 'luofeng-leader-zhang-ren': 'unit-rebel-leader', + 'luo-main-scout-a': 'unit-rebel', + 'luo-main-scout-b': 'unit-rebel', + 'luo-main-infantry-a': 'unit-rebel', + 'luo-main-infantry-b': 'unit-rebel', + 'luo-main-infantry-c': 'unit-rebel', + 'luo-main-infantry-d': 'unit-rebel', + 'luo-main-infantry-e': 'unit-rebel', + 'luo-main-archer-a': 'unit-rebel-archer', + 'luo-main-archer-b': 'unit-rebel-archer', + 'luo-main-archer-c': 'unit-rebel-archer', + 'luo-main-archer-d': 'unit-rebel-archer', + 'luo-main-cavalry-a': 'unit-rebel-cavalry', + 'luo-main-cavalry-b': 'unit-rebel-cavalry', + 'luo-main-cavalry-c': 'unit-rebel-cavalry', + 'luo-main-guard-a': 'unit-rebel', + 'luo-main-guard-b': 'unit-rebel', + 'luo-main-guard-c': 'unit-rebel', + 'luo-main-strategist-a': 'unit-rebel-archer', + 'luo-main-strategist-b': 'unit-rebel-archer', + 'luo-main-ambusher-a': 'unit-rebel', + 'luo-main-ambusher-b': 'unit-rebel', + 'luo-main-ambusher-c': 'unit-rebel', + 'luo-main-veteran-yan-yan': 'unit-rebel-archer', + 'luo-main-officer-a': 'unit-rebel-leader', + 'luo-main-leader-zhang-ren': 'unit-rebel-leader' }; const unitTextureByClass: Partial> = { @@ -1266,7 +1292,32 @@ const enemyAiByUnitId: Record = { 'luofeng-ambusher-b': 'aggressive', 'luofeng-ambusher-c': 'aggressive', 'luofeng-ambusher-d': 'aggressive', - 'luofeng-leader-zhang-ren': 'guard' + 'luofeng-leader-zhang-ren': 'guard', + 'luo-main-scout-a': 'aggressive', + 'luo-main-scout-b': 'aggressive', + 'luo-main-infantry-a': 'guard', + 'luo-main-infantry-b': 'guard', + 'luo-main-infantry-c': 'guard', + 'luo-main-infantry-d': 'guard', + 'luo-main-infantry-e': 'guard', + 'luo-main-archer-a': 'hold', + 'luo-main-archer-b': 'hold', + 'luo-main-archer-c': 'hold', + 'luo-main-archer-d': 'hold', + 'luo-main-cavalry-a': 'aggressive', + 'luo-main-cavalry-b': 'aggressive', + 'luo-main-cavalry-c': 'aggressive', + 'luo-main-guard-a': 'guard', + 'luo-main-guard-b': 'guard', + 'luo-main-guard-c': 'guard', + 'luo-main-strategist-a': 'hold', + 'luo-main-strategist-b': 'hold', + 'luo-main-ambusher-a': 'aggressive', + 'luo-main-ambusher-b': 'aggressive', + 'luo-main-ambusher-c': 'aggressive', + 'luo-main-veteran-yan-yan': 'hold', + 'luo-main-officer-a': 'guard', + 'luo-main-leader-zhang-ren': 'guard' }; const defaultEnemyAiByClass: Partial> = { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 8b09c3e..25165f0 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -16,6 +16,7 @@ import sixteenthBattleMapUrl from '../../assets/images/battle/sixteenth-battle-m import seventeenthBattleMapUrl from '../../assets/images/battle/seventeenth-battle-map.svg'; import tenthBattleMapUrl from '../../assets/images/battle/tenth-battle-map.svg'; import thirteenthBattleMapUrl from '../../assets/images/battle/thirteenth-battle-map.svg'; +import thirtyFirstBattleMapUrl from '../../assets/images/battle/thirty-first-battle-map.svg'; import thirtiethBattleMapUrl from '../../assets/images/battle/thirtieth-battle-map.svg'; import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg'; import twentyEighthBattleMapUrl from '../../assets/images/battle/twenty-eighth-battle-map.svg'; @@ -118,6 +119,7 @@ export class BootScene extends Phaser.Scene { this.load.image('battle-map-twenty-eighth', twentyEighthBattleMapUrl); this.load.image('battle-map-twenty-ninth', twentyNinthBattleMapUrl); this.load.image('battle-map-thirtieth', thirtiethBattleMapUrl); + this.load.image('battle-map-thirty-first', thirtyFirstBattleMapUrl); 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 ff44eae..52dac59 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -19,6 +19,8 @@ import { jingzhouRecruitUnits, liuBiaoRecruitBonds, liuBiaoRecruitUnits, + luoCastleProperRecruitBonds, + luoCastleProperRecruitUnits, luoCastleRecruitBonds, luoCastleRecruitUnits, wulingRecruitBonds, @@ -119,6 +121,8 @@ type SortieUnitTacticalSummary = { terrainLine: string; }; +type SortieTerrainCounts = Partial>; + type SortieRecommendation = { unitId: string; reason: string; @@ -317,7 +321,8 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [ 'twenty-seventh-battle-yizhou-relief-road', 'twenty-eighth-battle-fu-pass-entry', 'twenty-ninth-battle-luo-outer-wall', - 'thirtieth-battle-luofeng-ambush' + 'thirtieth-battle-luofeng-ambush', + 'thirty-first-battle-luo-main-gate' ], nextHints: ['형주 방위', '익주 진입', '방통·법정 합류'] }, @@ -361,7 +366,8 @@ const campBattleIds = { twentySeventh: 'twenty-seventh-battle-yizhou-relief-road', twentyEighth: 'twenty-eighth-battle-fu-pass-entry', twentyNinth: 'twenty-ninth-battle-luo-outer-wall', - thirtieth: 'thirtieth-battle-luofeng-ambush' + thirtieth: 'thirtieth-battle-luofeng-ambush', + thirtyFirst: 'thirty-first-battle-luo-main-gate' } as const; const requiredSortieUnitIds = new Set(['liu-bei']); @@ -696,6 +702,18 @@ const sortieRulesByBattleId: Partial(); private selectedSortieUnitIds: string[] = []; + private terrainCountCache = new Map(); constructor() { super('CampScene'); @@ -4224,6 +4384,41 @@ export class CampScene extends Phaser.Scene { return; } + if (battleId === campBattleIds.thirtyFirst) { + this.campaign = ensureCampaignRosterUnits( + [ + ...xuzhouRecruitUnits, + ...caoBreakRecruitUnits, + ...liuBiaoRecruitUnits, + ...zhugeRecruitUnits, + ...jingzhouRecruitUnits, + ...guiyangRecruitUnits, + ...wulingRecruitUnits, + ...changshaRecruitUnits, + ...yizhouRecruitUnits, + ...fuPassRecruitUnits, + ...luoCastleRecruitUnits, + ...luoCastleProperRecruitUnits + ], + [ + ...xuzhouRecruitBonds, + ...caoBreakRecruitBonds, + ...liuBiaoRecruitBonds, + ...zhugeRecruitBonds, + ...jingzhouRecruitBonds, + ...guiyangRecruitBonds, + ...wulingRecruitBonds, + ...changshaRecruitBonds, + ...yizhouRecruitBonds, + ...fuPassRecruitBonds, + ...luoCastleRecruitBonds, + ...luoCastleProperRecruitBonds + ] + ); + this.report = this.campaign.firstBattleReport ?? this.report; + return; + } + if (battleId === campBattleIds.thirtieth || battleId === campBattleIds.twentyNinth) { this.campaign = ensureCampaignRosterUnits( [ @@ -4465,6 +4660,9 @@ export class CampScene extends Phaser.Scene { private currentCampTitle() { const battleId = this.currentCampBattleId(); + if (battleId === campBattleIds.thirtyFirst) { + return '낙성 본성 공략 후 군영'; + } if (battleId === campBattleIds.thirtieth) { return '낙봉파 매복전 후 군영'; } @@ -4848,7 +5046,7 @@ export class CampScene extends Phaser.Scene { const column = index % 2; const row = Math.floor(index / 2); const rowX = x + 18 + column * 176; - const rowY = y + 218 + row * 28; + const rowY = y + 208 + row * 24; const names = slot.unitNames.length > 0 ? slot.unitNames.join(', ') : '미배치'; const color = slot.unitNames.length > 0 ? '#a8ffd0' : '#9fb0bf'; this.trackSortie(this.add.text(rowX, rowY, slot.label, this.textStyle(12, color, true))).setDepth(depth + 1); @@ -4857,7 +5055,7 @@ export class CampScene extends Phaser.Scene { }); const warning = plan.warnings[0] ?? plan.reserveLine; - this.trackSortie(this.add.text(x + 18, y + height - 28, this.compactText(`조언: ${warning}`, 42), this.textStyle(12, plan.warnings.length > 0 ? '#ffdf7b' : '#a8ffd0', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 18, y + height - 24, this.compactText(`조언: ${warning}`, 42), this.textStyle(12, plan.warnings.length > 0 ? '#ffdf7b' : '#a8ffd0', true))).setDepth(depth + 1); } private renderSortieEquipmentIcons(unit: UnitData, x: number, y: number, selected: boolean, depth: number) { @@ -4947,8 +5145,13 @@ export class CampScene extends Phaser.Scene { return `지형 ${grade}${bestTerrains ? ` · ${bestTerrains}` : ''}`; } - private passableTerrainCounts(scenario: BattleScenarioDefinition) { - const counts = {} as Record; + private passableTerrainCounts(scenario: BattleScenarioDefinition): SortieTerrainCounts { + const cached = this.terrainCountCache.get(scenario.id); + if (cached) { + return cached; + } + + const counts: SortieTerrainCounts = {}; scenario.map.terrain.forEach((row) => { row.forEach((terrain) => { if (terrainRules[terrain].passable === false) { @@ -4957,6 +5160,7 @@ export class CampScene extends Phaser.Scene { counts[terrain] = (counts[terrain] ?? 0) + 1; }); }); + this.terrainCountCache.set(scenario.id, counts); return counts; } @@ -5065,7 +5269,7 @@ export class CampScene extends Phaser.Scene { warnings.push(`아직 ${maxCount - selectedUnits.length}명 더 출전할 수 있습니다.`); } if (missingRecommended.length > 0) { - warnings.push(`추천 무장 ${missingRecommended.map((entry) => this.unitName(entry.unitId)).join(', ')}이 대기 중입니다.`); + warnings.push(`추천 무장 ${missingRecommended.map((entry) => this.unitName(entry.unitId)).join(', ')} 대기 중입니다.`); } if (roleCounts.front <= 0) { warnings.push('전열 병과가 없어 길목을 버티기 어렵습니다.'); diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 07aedb0..29a4a28 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -18,6 +18,7 @@ import { seventeenthBattleScenario, tenthBattleScenario, thirteenthBattleScenario, + thirtyFirstBattleScenario, thirtiethBattleScenario, twentyEighthBattleScenario, twentyFirstBattleScenario, @@ -359,7 +360,8 @@ export class TitleScene extends Phaser.Scene { campaign.step === 'twenty-seventh-camp' || campaign.step === 'twenty-eighth-camp' || campaign.step === 'twenty-ninth-camp' || - campaign.step === 'thirtieth-camp' + campaign.step === 'thirtieth-camp' || + campaign.step === 'thirty-first-camp' ) { this.scene.start('CampScene'); return; @@ -515,6 +517,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'thirty-first-battle') { + this.scene.start('BattleScene', { battleId: thirtyFirstBattleScenario.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 90cff66..ebc9170 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -101,7 +101,9 @@ export type CampaignStep = | 'twenty-ninth-battle' | 'twenty-ninth-camp' | 'thirtieth-battle' - | 'thirtieth-camp'; + | 'thirtieth-camp' + | 'thirty-first-battle' + | 'thirty-first-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; @@ -193,7 +195,8 @@ const campaignBattleSteps: Record