diff --git a/docs/roadmap.md b/docs/roadmap.md index 7c78059..a94c392 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -20,11 +20,12 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, class and terrain affinity rules, ally/enemy roster tabs, unit detail stat panels, weapon/armor/accessory equipment growth, terrain-cost movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, right-click tactical menu, turn ending/reset, resonance bond tracking, and grayscale acted-unit feedback - Second battle expansion with a larger scrollable map, mixed enemy AI behaviors, minimap navigation, camp progression, and save persistence - Third battle expansion path toward Guangzong with a new battlefield, story bridge, campaign step persistence, and title continue support -- Flow verification script from title through the third battle victory and camp save state +- Fourth battle Guangzong main camp confrontation with Zhang Jue, expanded enemy formation, campaign persistence, and anti-Dong Zhuo setup +- Flow verification script from title through the fourth battle victory and camp save state ## Next Steps -1. Add the next Guangzong main battle and named Yellow Turban commanders +1. Start the anti-Dong Zhuo chapter with a story bridge and first coalition-era battle 2. Broaden camp events so bonds, supplies, and equipment choices matter between battles 3. Apply treasure equipment effects to damage, defense, recovery, and command rules 4. Add more distinctive victory/defeat branches and optional objectives diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 7037248..75b26ea 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -302,6 +302,18 @@ try { throw new Error(`Expected bean supply to be consumed: ${JSON.stringify(campStateAfterSupply.campaign?.inventory)}`); } + const goldBeforeMerchant = campStateAfterSupply.campaign?.gold ?? 0; + await page.mouse.click(702, 386); + await page.waitForTimeout(220); + + const campStateAfterMerchant = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + (campStateAfterMerchant.campaign?.inventory?.['콩'] ?? 0) !== 1 || + campStateAfterMerchant.campaign?.gold !== goldBeforeMerchant - 40 + ) { + throw new Error(`Expected merchant purchase to add bean and spend gold: ${JSON.stringify({ goldBeforeMerchant, campStateAfterMerchant })}`); + } + await page.mouse.click(830, 38); await page.waitForTimeout(120); await page.mouse.click(974, 482); @@ -358,7 +370,7 @@ try { return activeScenes.includes('StoryScene'); }); - for (let i = 0; i < 14; i += 1) { + for (let i = 0; i < 24; i += 1) { const isSecondBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit'; @@ -369,7 +381,7 @@ try { } await page.keyboard.press('Space'); - await page.waitForTimeout(220); + await page.waitForTimeout(320); } await page.waitForFunction(() => { @@ -430,7 +442,7 @@ try { return activeScenes.includes('StoryScene'); }); - for (let i = 0; i < 14; i += 1) { + for (let i = 0; i < 24; i += 1) { const isThirdBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'third-battle-guangzong-road'; @@ -441,7 +453,7 @@ try { } await page.keyboard.press('Space'); - await page.waitForTimeout(220); + await page.waitForTimeout(320); } await page.waitForFunction(() => { @@ -490,6 +502,78 @@ try { throw new Error(`Expected campaign save to persist third battle victory: ${JSON.stringify(campaignSaveAfterThirdBattle)}`); } + await page.mouse.click(1120, 38); + await page.waitForTimeout(160); + const thirdCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!thirdCampSortieState?.sortieVisible) { + throw new Error(`Expected third camp sortie preparation overlay: ${JSON.stringify(thirdCampSortieState)}`); + } + await page.mouse.click(938, 596); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('StoryScene'); + }); + + for (let i = 0; i < 26; i += 1) { + const isFourthBattleActive = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'fourth-battle-guangzong-camp'; + }); + + if (isFourthBattleActive) { + 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 === 'fourth-battle-guangzong-camp' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-fourth-battle.png', fullPage: true }); + + const fourthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const fourthEnemies = fourthBattleState.units.filter((unit) => unit.faction === 'enemy'); + const fourthEnemyBehaviors = new Set(fourthEnemies.map((unit) => unit.ai)); + if ( + fourthBattleState.camera?.mapWidth !== 24 || + fourthBattleState.camera?.mapHeight !== 20 || + fourthBattleState.victoryConditionLabel !== '장각 격파' || + fourthEnemies.length < 12 || + !fourthEnemyBehaviors.has('aggressive') || + !fourthEnemyBehaviors.has('guard') || + !fourthEnemyBehaviors.has('hold') + ) { + throw new Error(`Expected fourth battle map, objective, and mixed enemy AI: ${JSON.stringify(fourthBattleState)}`); + } + + 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 campaignSaveAfterFourthBattle = await page.evaluate(() => { + const raw = window.localStorage.getItem('heros-web:campaign-state'); + return raw ? JSON.parse(raw) : undefined; + }); + if ( + !campaignSaveAfterFourthBattle || + campaignSaveAfterFourthBattle.step !== 'fourth-camp' || + campaignSaveAfterFourthBattle.latestBattleId !== 'fourth-battle-guangzong-camp' || + !campaignSaveAfterFourthBattle.battleHistory?.['fourth-battle-guangzong-camp'] + ) { + throw new Error(`Expected campaign save to persist fourth battle victory: ${JSON.stringify(campaignSaveAfterFourthBattle)}`); + } + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -501,7 +585,7 @@ try { return activeScenes.includes('CampScene'); }); - console.log(`Verified title-to-third-battle flow, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-fourth-battle flow, result states, and debug API at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { diff --git a/src/assets/images/battle/fourth-battle-map.svg b/src/assets/images/battle/fourth-battle-map.svg new file mode 100644 index 0000000..a4e0310 --- /dev/null +++ b/src/assets/images/battle/fourth-battle-map.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index a25a72e..e33c066 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -8,6 +8,10 @@ import { secondBattleUnits, secondBattleVictoryPages, thirdBattleBonds, + fourthBattleBonds, + fourthBattleMap, + fourthBattleUnits, + fourthBattleVictoryPages, thirdBattleMap, thirdBattleUnits, thirdBattleVictoryPages, @@ -17,7 +21,11 @@ import { type UnitData } from './scenario'; -export type BattleScenarioId = 'first-battle-zhuo-commandery' | 'second-battle-yellow-turban-pursuit' | 'third-battle-guangzong-road'; +export type BattleScenarioId = + | 'first-battle-zhuo-commandery' + | 'second-battle-yellow-turban-pursuit' + | 'third-battle-guangzong-road' + | 'fourth-battle-guangzong-camp'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -215,12 +223,66 @@ export const thirdBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const fourthBattleScenario: BattleScenarioDefinition = { + id: 'fourth-battle-guangzong-camp', + title: '광종 본영전', + victoryConditionLabel: '장각 격파', + defeatConditionLabel: '유비 퇴각', + openingObjectiveLines: [ + '황건 본영의 중심에는 장각이 있습니다. 장각을 격파하면 광종의 황건 세력은 무너집니다.', + '요새와 숲의 궁병이 진입로를 막고 있습니다. 측면의 기병과 중앙 수비병을 나누어 상대하십시오.', + '16턴 이내에 승리하면 황건적 토벌의 공적이 크게 오릅니다.' + ], + map: fourthBattleMap, + units: fourthBattleUnits, + bonds: fourthBattleBonds, + mapTextureKey: 'battle-map-fourth', + leaderUnitId: 'guangzong-main-leader-zhang-jue', + quickVictoryTurnLimit: 16, + baseVictoryGold: 700, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '장각 격파', + rewardGold: 420, + unitId: 'guangzong-main-leader-zhang-jue' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 180, + unitId: 'liu-bei' + }, + { + id: 'fort', + kind: 'secure-terrain', + label: '광종 본영 확보', + rewardGold: 260, + terrain: 'fort' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '16턴 이내 승리', + rewardGold: 220, + maxTurn: 16 + } + ], + defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], + itemRewards: ['콩 2', '상처약 1', '탁주 1', '황건 토벌 공적 +1'], + victoryPages: fourthBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { 'first-battle-zhuo-commandery': firstBattleScenario, 'second-battle-yellow-turban-pursuit': secondBattleScenario, - 'third-battle-guangzong-road': thirdBattleScenario + 'third-battle-guangzong-road': thirdBattleScenario, + 'fourth-battle-guangzong-camp': fourthBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 7737bd4..50a60ab 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -1,10 +1,13 @@ import type { CampaignStep } from '../state/campaignState'; -import { defaultBattleScenario, secondBattleScenario, thirdBattleScenario, type BattleScenarioId } from './battles'; +import { defaultBattleScenario, fourthBattleScenario, secondBattleScenario, thirdBattleScenario, type BattleScenarioId } from './battles'; import { firstBattleVictoryPages, + fourthBattleIntroPages, + fourthBattleVictoryPages, secondBattleIntroPages, secondBattleVictoryPages, thirdBattleIntroPages, + thirdBattleVictoryPages, type StoryPage } from './scenario'; @@ -43,12 +46,22 @@ const sortieFlows: Record = { }, [thirdBattleScenario.id]: { afterBattleId: thirdBattleScenario.id, + eyebrow: '다음 전장', + title: fourthBattleScenario.title, + description: '광종 구원로를 연 의용군은 이제 황건 본영을 향합니다. 장각을 격파해 황건적 토벌의 큰 고비를 넘겨야 합니다.', + rewardHint: `예상 보상: ${fourthBattleScenario.title} 개방`, + nextBattleId: fourthBattleScenario.id, + campaignStep: 'fourth-battle', + pages: [...thirdBattleVictoryPages, ...fourthBattleIntroPages] + }, + [fourthBattleScenario.id]: { + afterBattleId: fourthBattleScenario.id, eyebrow: '다음 장 준비', - title: '광종 본전', - description: '광종 구원로는 열렸지만 황건 본대는 아직 건재합니다. 다음 작업에서 광종 본전과 더 큰 전장 목표를 이어 붙일 수 있습니다.', - rewardHint: '다음 장: 광종 본전 준비 중', + title: '반동탁의 흐름', + description: '황건적 토벌로 이름을 알린 세 형제 앞에 조정의 새로운 혼란이 다가옵니다. 다음 큰 줄기는 반동탁 흐름으로 이어집니다.', + rewardHint: '다음 장: 반동탁 흐름 준비 중', pages: [], - unavailableNotice: '광종 본전은 다음 장으로 이어집니다. 군영에서 성장 상태를 정비하세요.' + unavailableNotice: '반동탁 흐름은 다음 장으로 이어집니다. 군영에서 성장 상태를 정비하세요.' } }; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 1d9325e..e9c5839 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -311,6 +311,69 @@ export const thirdBattleVictoryPages: StoryPage[] = [ } ]; +export const fourthBattleIntroPages: StoryPage[] = [ + { + id: 'fourth-guangzong-camp', + bgm: 'militia-theme', + chapter: '광종 본영', + background: 'story-militia', + text: '전령의 길이 끊기자 황건 본대는 광종 들판에 진을 굳혔다. 노란 깃발이 겹겹이 서고, 장각의 이름이 병사들 사이에 낮게 울렸다.' + }, + { + id: 'fourth-liu-bei-resolve', + bgm: 'battle-prep', + chapter: '의용군의 결의', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '장각을 치는 일은 한 사람을 꺾는 일이 아니다. 두려움에 몰린 백성들이 다시 길을 찾게 하는 일이다.' + }, + { + id: 'fourth-guan-yu-line', + bgm: 'battle-prep', + chapter: '본영 돌파', + background: 'story-three-heroes', + speaker: '관우', + portrait: 'guanYu', + text: '적은 요새와 숲 사이에 궁병을 숨겼습니다. 전열을 흐트러뜨리지 않고 본영의 깃발까지 밀고 들어가야 합니다.' + }, + { + id: 'fourth-zhang-fei-roar', + bgm: 'battle-prep', + chapter: '황건적 토벌', + background: 'story-sortie', + speaker: '장비', + portrait: 'zhangFei', + text: '좋소! 광종의 누런 깃발을 오늘 모두 뽑아 버립시다. 뒤돌아볼 틈 없이 밀고 들어가겠소!' + } +]; + +export const fourthBattleVictoryPages: StoryPage[] = [ + { + id: 'fourth-victory-yellow-flags-fall', + bgm: 'militia-theme', + chapter: '황건적 토벌', + background: 'story-militia', + text: '광종의 들판에서 황건의 본영이 무너졌다. 피난민들은 오래 닫았던 문을 열고, 쓰러진 깃발 너머로 관군과 의용군을 바라보았다.' + }, + { + id: 'fourth-victory-liu-bei', + bgm: 'militia-theme', + chapter: '이름이 퍼지다', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '난은 잠시 가라앉았지만, 천하가 바로 선 것은 아니다. 오늘의 공이 교만이 되지 않도록 마음을 다잡자.' + }, + { + id: 'fourth-victory-next-era', + bgm: 'battle-prep', + chapter: '새로운 혼란', + background: 'story-sortie', + text: '황건의 불길이 잦아들 무렵, 조정 안에서는 또 다른 그림자가 자라났다. 세 형제의 길은 이제 반동탁의 흐름으로 이어진다.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -390,6 +453,33 @@ export const thirdBattleMap: BattleMap = { ] }; +export const fourthBattleMap: BattleMap = { + width: 24, + height: 20, + terrain: [ + ['forest', 'forest', 'hill', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'hill', 'plain', 'plain', 'fort', 'fort', 'fort', 'plain', 'hill', 'hill', 'forest', 'forest', 'plain', 'plain'], + ['forest', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'hill', 'plain', 'plain', 'fort', 'fort', 'fort', 'plain', 'plain', 'hill', 'forest', 'forest', 'plain', 'plain', 'hill'], + ['hill', 'plain', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'fort', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'hill'], + ['plain', 'plain', 'forest', 'forest', 'plain', 'road', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain'], + ['plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'river', 'river', 'road', 'road', 'plain', 'hill', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain'], + ['plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'road', 'plain', 'village', 'plain', 'plain', 'forest', 'forest', 'plain'], + ['plain', 'forest', 'plain', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'river', 'river', 'plain', 'plain', 'road', 'plain', 'village', 'plain', 'forest', 'forest', 'plain', 'plain'], + ['plain', 'forest', 'plain', 'road', 'road', 'plain', 'hill', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'hill', 'plain'], + ['plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain'], + ['plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'village', 'plain', 'plain', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain'], + ['road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'village', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'plain'], + ['plain', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'forest', 'plain', 'plain'], + ['plain', 'road', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'hill'], + ['hill', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'hill', 'hill'], + ['hill', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'hill'], + ['plain', 'camp', 'road', 'road', 'plain', 'hill', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain'], + ['camp', 'camp', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain'], + ['plain', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'plain'], + ['plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain'], + ['plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain'] + ] +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -1194,6 +1284,270 @@ export const thirdBattleUnits: UnitData[] = [ } ]; +const fourthBattleAllyPositions: Record = { + 'liu-bei': { x: 2, y: 16 }, + 'guan-yu': { x: 3, y: 16 }, + 'zhang-fei': { x: 2, y: 17 } +}; + +export const fourthBattleUnits: UnitData[] = [ + ...firstBattleUnits + .filter((unit) => unit.faction === 'ally') + .map((unit) => placeScenarioUnit(unit, fourthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), + { + id: 'guangzong-main-vanguard-a', + name: '황건 선봉', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 4, + exp: 24, + hp: 28, + maxHp: 28, + attack: 9, + move: 3, + stats: { might: 63, intelligence: 36, leadership: 48, agility: 53, luck: 45 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 24 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 10 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 6, + y: 14 + }, + { + id: 'guangzong-main-vanguard-b', + name: '황건 선봉', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 4, + exp: 24, + hp: 28, + maxHp: 28, + attack: 9, + move: 3, + stats: { might: 63, intelligence: 36, leadership: 48, agility: 53, luck: 45 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 24 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 10 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 8, + y: 12 + }, + { + id: 'guangzong-main-bandit-a', + name: '숲길 돌격병', + faction: 'enemy', + className: '도적', + classKey: 'bandit', + level: 4, + exp: 26, + hp: 29, + maxHp: 29, + attack: 10, + move: 3, + stats: { might: 66, intelligence: 34, leadership: 44, agility: 61, luck: 48 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 24 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 10 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 5, + y: 10 + }, + { + id: 'guangzong-main-archer-a', + name: '황건 궁병', + faction: 'enemy', + className: '궁병', + classKey: 'archer', + level: 4, + exp: 26, + hp: 24, + maxHp: 24, + attack: 10, + move: 3, + stats: { might: 60, intelligence: 48, leadership: 50, agility: 62, luck: 50 }, + equipment: { + weapon: { itemId: 'short-bow', level: 1, exp: 26 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 10 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 9, + y: 10 + }, + { + id: 'guangzong-main-cavalry-a', + name: '황건 기병', + faction: 'enemy', + className: '경기병', + classKey: 'cavalry', + level: 5, + exp: 28, + hp: 32, + maxHp: 32, + attack: 11, + move: 5, + stats: { might: 70, intelligence: 38, leadership: 52, agility: 72, luck: 51 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 28 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 12 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 12, + y: 9 + }, + { + id: 'guangzong-main-guard-a', + name: '본영 수비병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 5, + exp: 28, + hp: 31, + maxHp: 31, + attack: 10, + move: 3, + stats: { might: 68, intelligence: 38, leadership: 54, agility: 55, luck: 49 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 28 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 12 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 14, + y: 7 + }, + { + id: 'guangzong-main-archer-b', + name: '본영 궁병', + faction: 'enemy', + className: '궁병', + classKey: 'archer', + level: 5, + exp: 30, + hp: 25, + maxHp: 25, + attack: 10, + move: 3, + stats: { might: 61, intelligence: 50, leadership: 51, agility: 63, luck: 51 }, + equipment: { + weapon: { itemId: 'short-bow', level: 1, exp: 30 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 12 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 16, + y: 6 + }, + { + id: 'guangzong-main-cavalry-b', + name: '황건 기병', + faction: 'enemy', + className: '경기병', + classKey: 'cavalry', + level: 5, + exp: 30, + hp: 33, + maxHp: 33, + attack: 11, + move: 5, + stats: { might: 71, intelligence: 38, leadership: 53, agility: 73, luck: 51 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 30 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 12 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 18, + y: 10 + }, + { + id: 'guangzong-main-guard-b', + name: '본영 수비병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 5, + exp: 30, + hp: 32, + maxHp: 32, + attack: 10, + move: 3, + stats: { might: 69, intelligence: 39, leadership: 55, agility: 55, luck: 50 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 30 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 12 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 18, + y: 4 + }, + { + id: 'guangzong-main-guard-c', + name: '본영 수비병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 5, + exp: 30, + hp: 32, + maxHp: 32, + attack: 10, + move: 3, + stats: { might: 69, intelligence: 39, leadership: 55, agility: 55, luck: 50 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 30 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 12 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 14, + y: 3 + }, + { + id: 'guangzong-main-archer-c', + name: '망루 궁병', + faction: 'enemy', + className: '궁병', + classKey: 'archer', + level: 5, + exp: 32, + hp: 26, + maxHp: 26, + attack: 11, + move: 3, + stats: { might: 62, intelligence: 52, leadership: 52, agility: 64, luck: 52 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 6 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 12 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 16, + y: 1 + }, + { + id: 'guangzong-main-leader-zhang-jue', + name: '장각', + faction: 'enemy', + className: '황건 대현량사', + classKey: 'rebelLeader', + level: 6, + exp: 40, + hp: 48, + maxHp: 48, + attack: 13, + move: 3, + stats: { might: 74, intelligence: 78, leadership: 82, agility: 58, luck: 64 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 2, exp: 16 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 4 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 15, + y: 1 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -1223,6 +1577,7 @@ export const firstBattleBonds: BattleBond[] = [ export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); +export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData { return { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 6bd2c8d..8bb4697 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser'; 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 thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg'; import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png'; @@ -64,6 +65,7 @@ export class BootScene extends Phaser.Scene { this.load.image('battle-map-first', firstBattleMapUrl); this.load.image('battle-map-second', secondBattleMapUrl); this.load.image('battle-map-third', thirdBattleMapUrl); + this.load.image('battle-map-fourth', fourthBattleMapUrl); 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 67ce459..c3fde42 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -25,6 +25,14 @@ type CampDialogue = { bondId: string; rewardExp: number; lines: string[]; + choices: CampDialogueChoice[]; +}; + +type CampDialogueChoice = { + id: string; + label: string; + response: string; + rewardExp: number; }; type CampTabButtonView = { @@ -41,6 +49,13 @@ type CampSupplyDefinition = { bondExp: number; }; +type MerchantItemDefinition = { + label: string; + title: string; + description: string; + price: number; +}; + type SortieChecklistItem = { label: string; complete: boolean; @@ -73,11 +88,39 @@ const campSupplies: CampSupplyDefinition[] = [ description: '선택 장수의 병력을 8 회복하고 연결된 공명 경험치를 2 올립니다.', healHp: 8, bondExp: 2 + }, + { + label: '상처약', + title: '상처약', + description: '선택 장수의 병력을 22 회복합니다.', + healHp: 22, + bondExp: 0 } ]; const campSupplyByLabel = new Map(campSupplies.map((supply) => [supply.label, supply])); +const merchantItems: MerchantItemDefinition[] = [ + { + label: '콩', + title: '콩', + description: '값싸고 가벼운 회복 도구입니다.', + price: 40 + }, + { + label: '탁주', + title: '탁주', + description: '병력 회복과 작은 공명 상승에 유용합니다.', + price: 70 + }, + { + label: '상처약', + title: '상처약', + description: '큰 부상을 빠르게 회복합니다.', + price: 110 + } +]; + const campDialogues: CampDialogue[] = [ { id: 'liu-guan-after-first-battle', @@ -89,6 +132,20 @@ const campDialogues: CampDialogue[] = [ '유비: 운장, 오늘 그대가 앞을 막아 주지 않았다면 백성들을 지키지 못했을 것이오.', '관우: 형님의 뜻이 분명했기에 칼을 들 수 있었습니다. 다음 싸움에서도 길을 열겠습니다.', '유비: 우리의 맹세가 전장에서 더 단단해지는구려.' + ], + choices: [ + { + id: 'trust-vanguard', + label: '앞길을 맡긴다', + response: '관우는 말없이 고개를 숙였다. 다음 전장에서도 형님의 길을 열겠다는 결의가 깊어졌다.', + rewardExp: 6 + }, + { + id: 'protect-people', + label: '백성 보호를 당부한다', + response: '관우는 칼끝보다 백성의 숨결을 먼저 살피겠다고 답했다. 두 사람의 뜻이 한층 가까워졌다.', + rewardExp: 4 + } ] }, { @@ -101,6 +158,20 @@ const campDialogues: CampDialogue[] = [ '장비: 형님, 다음엔 제가 먼저 뛰쳐나가 두령 놈을 단번에 꺾겠습니다!', '유비: 익덕의 용맹은 든든하나, 백성이 뒤에 있음을 잊지 말아야 하오.', '장비: 하하! 형님 말이라면 참아 보겠습니다. 대신 싸울 때는 크게 치겠습니다.' + ], + choices: [ + { + id: 'temper-courage', + label: '용맹을 다독인다', + response: '장비는 답답하다는 듯 웃었지만, 형님의 말에 맞춰 창을 거두는 법도 익히겠다고 했다.', + rewardExp: 5 + }, + { + id: 'lead-charge', + label: '선봉을 약속한다', + response: '장비의 눈빛이 밝아졌다. 믿고 맡긴다는 말은 그에게 무엇보다 큰 격려였다.', + rewardExp: 7 + } ] }, { @@ -113,6 +184,20 @@ const campDialogues: CampDialogue[] = [ '관우: 익덕, 적이 흩어질 때 너무 깊이 들어가면 형님이 걱정하신다.', '장비: 운장 형님이 옆을 받쳐 준다면 걱정할 일이 없지 않습니까?', '관우: 좋다. 다음 전장에서는 서로의 빈틈을 먼저 살피자.' + ], + choices: [ + { + id: 'cover-flanks', + label: '서로의 측면을 맡긴다', + response: '칼과 창이 같은 호흡으로 움직일 길을 찾았다. 두 맹장의 공명이 단단해졌다.', + rewardExp: 6 + }, + { + id: 'compete-boldly', + label: '누가 먼저 베는지 겨룬다', + response: '장비가 크게 웃고 관우도 미소를 감추지 못했다. 경쟁심마저 전장의 힘이 되었다.', + rewardExp: 4 + } ] } ]; @@ -613,7 +698,7 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setStrokeStyle(1, palette.gold, 0.58); this.track(this.add.text(x + 24, y + 22, '장수 대화', this.textStyle(24, '#f2e3bf', true))); - this.track(this.add.text(x + 24, y + 56, '대화를 보면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6'))); + this.track(this.add.text(x + 24, y + 56, '출진 전 대화에서 선택지를 고르면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6'))); campDialogues.forEach((dialogue, index) => { const completed = this.completedCampDialogues().includes(dialogue.id); @@ -629,7 +714,8 @@ export class CampScene extends Phaser.Scene { this.render(); }); this.track(this.add.text(x + 38, rowY + 8, dialogue.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true))); - this.track(this.add.text(x + 38, rowY + 29, `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}`, this.textStyle(12, '#9fb0bf'))); + const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp)); + this.track(this.add.text(x + 38, rowY + 29, `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(12, '#9fb0bf'))); }); this.renderSelectedDialogue(x + 372, y + 96, 416, 300); @@ -651,19 +737,29 @@ export class CampScene extends Phaser.Scene { }) ); - const buttonLabel = completed ? '대화 완료' : `대화 보기 공명 +${dialogue.rewardExp}`; - const button = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, completed ? 0x17231d : 0x1a2630, 0.96)); - button.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.76); - if (!completed) { + if (completed) { + const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96)); + done.setStrokeStyle(1, palette.green, 0.76); + const text = this.track(this.add.text(x + width / 2, y + height - 34, '대화 완료', this.textStyle(15, '#a8ffd0', true))); + text.setOrigin(0.5); + return; + } + + dialogue.choices.forEach((choice, index) => { + const choiceY = y + height - 80 + index * 42; + const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96)); + button.setStrokeStyle(1, palette.gold, 0.76); button.setInteractive({ useHandCursor: true }); - button.on('pointerdown', () => this.completeDialogue(dialogue)); - } - const text = this.track(this.add.text(x + width / 2, y + height - 34, buttonLabel, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true))); - text.setOrigin(0.5); - if (!completed) { + button.on('pointerover', () => button.setFillStyle(0x283947, 0.98)); + button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96)); + button.on('pointerdown', () => this.completeDialogue(dialogue, choice)); + + const label = `${choice.label} 공명 +${dialogue.rewardExp + choice.rewardExp}`; + const text = this.track(this.add.text(x + width / 2, choiceY, label, this.textStyle(14, '#f2e3bf', true))); + text.setOrigin(0.5); text.setInteractive({ useHandCursor: true }); - text.on('pointerdown', () => this.completeDialogue(dialogue)); - } + text.on('pointerdown', () => this.completeDialogue(dialogue, choice)); + }); } private renderBondList(x: number, y: number, width: number) { @@ -695,30 +791,70 @@ export class CampScene extends Phaser.Scene { this.renderSupplyUseRow(supply, x + 390, y + 94 + index * 74, 390); }); - this.track(this.add.text(x + 24, y + 202, '전리품과 명성', this.textStyle(19, '#f2e3bf', true))); + this.renderMerchantPanel(x + 24, y + 202, 342); + + this.track(this.add.text(x + 390, y + 330, '전리품과 명성', this.textStyle(19, '#f2e3bf', true))); const trophies = this.nonConsumableInventoryLabels(); if (trophies.length === 0) { - this.track(this.add.text(x + 24, y + 236, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf'))); + this.track(this.add.text(x + 390, y + 364, '소모품 외 전리품 없음', this.textStyle(13, '#9fb0bf'))); } else { trophies.forEach((reward, index) => { - this.renderSupplyBox(reward, x + 24 + index * 154, y + 232); + this.renderSupplyBox(reward, x + 390 + index * 128, y + 360); }); } + } - this.track(this.add.text(x + 24, y + 306, '부대 장비 요약', this.textStyle(19, '#f2e3bf', true))); - this.currentUnits() - .filter((unit) => unit.faction === 'ally') - .forEach((unit, index) => { - const rowY = y + 342 + index * 30; - this.track(this.add.text(x + 24, rowY, unit.name, this.textStyle(14, '#f2e3bf', true))); - equipmentSlots.forEach((slot, slotIndex) => { - const state = unit.equipment[slot]; - const item = getItem(state.itemId); - const slotX = x + 104 + slotIndex * 220; - this.track(this.add.text(slotX, rowY - 2, `${equipmentSlotLabels[slot]} ${item.name} Lv${state.level}`, this.textStyle(11, '#d4dce6'))); - this.drawBar(slotX, rowY + 17, 176, 6, state.exp / equipmentExpToNext(state.level), item.rank === 'treasure' ? palette.gold : palette.blue); - }); - }); + private renderMerchantPanel(x: number, y: number, width: number) { + const campaign = this.campaign ?? getCampaignState(); + const bg = this.track(this.add.rectangle(x, y, width, 210, 0x0d141c, 0.92)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.gold, 0.48); + this.track(this.add.text(x + 16, y + 10, `군영 상인 · 군자금 ${campaign.gold}`, this.textStyle(17, '#f2e3bf', true))); + + merchantItems.forEach((item, index) => { + this.renderMerchantItem(item, x + 16, y + 44 + index * 52, width - 32); + }); + } + + private renderMerchantItem(item: MerchantItemDefinition, x: number, y: number, width: number) { + const campaign = this.campaign ?? getCampaignState(); + const canBuy = campaign.gold >= item.price; + const bg = this.track(this.add.rectangle(x, y, width, 40, canBuy ? 0x151f2a : 0x111820, canBuy ? 0.94 : 0.76)); + bg.setOrigin(0); + bg.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.58 : 0.36); + this.track(this.add.text(x + 10, y + 6, item.title, this.textStyle(13, canBuy ? '#f2e3bf' : '#7f8994', true))); + this.track(this.add.text(x + 10, y + 23, `${item.price}금`, this.textStyle(11, canBuy ? '#d8b15f' : '#7f8994', true))); + + const button = this.track(this.add.rectangle(x + width - 42, y + 20, 60, 26, canBuy ? 0x1a2630 : 0x121922, canBuy ? 0.96 : 0.72)); + button.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.72 : 0.4); + const text = this.track(this.add.text(x + width - 42, y + 20, '구매', this.textStyle(12, canBuy ? '#f2e3bf' : '#7f8994', true))); + text.setOrigin(0.5); + + if (canBuy) { + const action = () => this.buyMerchantItem(item); + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(0x283947, 0.98)); + button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96)); + button.on('pointerdown', action); + text.setInteractive({ useHandCursor: true }); + text.on('pointerdown', action); + } + } + + private buyMerchantItem(item: MerchantItemDefinition) { + const campaign = this.campaign ?? getCampaignState(); + if (campaign.gold < item.price) { + this.showCampNotice('군자금이 부족합니다.'); + return; + } + + campaign.gold -= item.price; + campaign.inventory[item.label] = (campaign.inventory[item.label] ?? 0) + 1; + this.campaign = saveCampaignState(campaign); + this.report = this.campaign.firstBattleReport ?? this.report; + soundDirector.playSelect(); + this.showCampNotice(`${item.title} 구입 · 군자금 -${item.price}`); + this.render(); } private renderSupplyBox(label: string, x: number, y: number) { @@ -932,8 +1068,9 @@ export class CampScene extends Phaser.Scene { return bonuses.join(' / ') || '특수 보정 없음'; } - private completeDialogue(dialogue: CampDialogue) { - const updated = applyCampBondExp(dialogue.id, dialogue.bondId, dialogue.rewardExp); + private completeDialogue(dialogue: CampDialogue, choice: CampDialogueChoice) { + const rewardExp = dialogue.rewardExp + choice.rewardExp; + const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp); if (updated) { this.report = updated; this.campaign = getCampaignState(); @@ -941,27 +1078,32 @@ export class CampScene extends Phaser.Scene { this.report.completedCampDialogues.push(dialogue.id); const bond = this.report.bonds.find((candidate) => candidate.id === dialogue.bondId); if (bond) { - bond.battleExp += dialogue.rewardExp; - bond.exp += dialogue.rewardExp; + bond.battleExp += rewardExp; + bond.exp += rewardExp; } } soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 }); - this.showDialogueReward(dialogue); + this.showDialogueReward(dialogue, choice, rewardExp); this.render(); } - private showDialogueReward(dialogue: CampDialogue) { - this.showCampNotice(`${dialogue.title} 완료 · 공명 +${dialogue.rewardExp}`); + private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) { + this.showCampNotice(`${choice.label} · 공명 +${rewardExp}`); + this.time.delayedCall(120, () => this.showCampNotice(choice.response)); } private showCampNotice(message: string) { this.dialogueObjects.forEach((object) => object.destroy()); this.dialogueObjects = []; - const box = this.add.rectangle(this.scale.width / 2, 104, 440, 52, 0x101820, 0.96); + const box = this.add.rectangle(this.scale.width / 2, 104, 720, 58, 0x101820, 0.96); box.setStrokeStyle(2, palette.gold, 0.84); box.setDepth(30); - const text = this.add.text(this.scale.width / 2, 104, message, this.textStyle(17, '#f2e3bf', true)); + const text = this.add.text(this.scale.width / 2, 104, message, { + ...this.textStyle(16, '#f2e3bf', true), + align: 'center', + wordWrap: { width: 670, useAdvancedWrap: true } + }); text.setOrigin(0.5); text.setDepth(31); this.dialogueObjects.push(box, text); diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index af2c1c6..b0cfcc4 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 { secondBattleScenario, thirdBattleScenario } from '../data/battles'; +import { fourthBattleScenario, secondBattleScenario, thirdBattleScenario } from '../data/battles'; import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState'; import { palette } from '../ui/palette'; @@ -299,7 +299,7 @@ export class TitleScene extends Phaser.Scene { soundDirector.playSelect(); const campaign = loadCampaignState(); - if (campaign.step === 'first-camp' || campaign.step === 'second-camp' || campaign.step === 'third-camp') { + if (campaign.step === 'first-camp' || campaign.step === 'second-camp' || campaign.step === 'third-camp' || campaign.step === 'fourth-camp') { this.scene.start('CampScene'); return; } @@ -319,6 +319,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'fourth-battle') { + this.scene.start('BattleScene', { battleId: fourthBattleScenario.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 4acecf0..d730230 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -45,7 +45,9 @@ export type CampaignStep = | 'second-battle' | 'second-camp' | 'third-battle' - | 'third-camp'; + | 'third-camp' + | 'fourth-battle' + | 'fourth-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; @@ -98,7 +100,8 @@ export const campaignSaveSlotCount = 3; const campaignBattleSteps: Record = { 'first-battle-zhuo-commandery': { victory: 'first-camp', retry: 'first-battle' }, 'second-battle-yellow-turban-pursuit': { victory: 'second-camp', retry: 'second-battle' }, - 'third-battle-guangzong-road': { victory: 'third-camp', retry: 'third-battle' } + 'third-battle-guangzong-road': { victory: 'third-camp', retry: 'third-battle' }, + 'fourth-battle-guangzong-camp': { victory: 'fourth-camp', retry: 'fourth-battle' } }; export type CampaignSaveSlotSummary = {