diff --git a/docs/roadmap.md b/docs/roadmap.md index bb94d8e..10d7f0f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -23,12 +23,13 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Fourth battle Guangzong main camp confrontation with Zhang Jue, expanded enemy formation, campaign persistence, and anti-Dong Zhuo setup - Fifth battle Sishui Gate vanguard path starting the anti-Dong Zhuo chapter, with campaign persistence and title continue support - Battle-specific camp conversations with selectable bond-growth choices from the first camp through the Gongsun Zan camp -- Flow verification script from title through the fifth battle victory and camp save state +- Sixth battle Jieqiao relief route under Gongsun Zan, with a story bridge toward Tao Qian and Xu Province +- Flow verification script from title through the sixth battle victory and camp save state ## Next Steps -1. Build the Gongsun Zan camp chapter after the Sishui Gate vanguard battle -2. Add the next Gongsun Zan-era battle and the story bridge toward Xu Province +1. Add the Tao Qian/Xu Province rescue battle and first Xu Province camp +2. Turn story-only endpoint bridges into a more explicit chapter selection/progress view 3. Apply treasure equipment effects to damage, defense, recovery, and command rules 4. Add more distinctive victory/defeat branches and optional objectives 5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 57e7b63..5d967e6 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -663,6 +663,117 @@ try { throw new Error(`Expected fifth camp to expose Gongsun Zan/Sishui dialogue set: ${JSON.stringify(fifthCampState)}`); } + await page.mouse.click(1120, 38); + await page.waitForTimeout(160); + const fifthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!fifthCampSortieState?.sortieVisible) { + throw new Error(`Expected fifth camp sortie preparation overlay: ${JSON.stringify(fifthCampSortieState)}`); + } + 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 isSixthBattleActive = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'sixth-battle-jieqiao-relief'; + }); + + if (isSixthBattleActive) { + 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 === 'sixth-battle-jieqiao-relief' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-sixth-battle.png', fullPage: true }); + + const sixthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const sixthEnemies = sixthBattleState.units.filter((unit) => unit.faction === 'enemy'); + const sixthEnemyBehaviors = new Set(sixthEnemies.map((unit) => unit.ai)); + if ( + sixthBattleState.camera?.mapWidth !== 24 || + sixthBattleState.camera?.mapHeight !== 20 || + sixthBattleState.victoryConditionLabel !== '곡의 격파' || + sixthEnemies.length < 12 || + !sixthEnemyBehaviors.has('aggressive') || + !sixthEnemyBehaviors.has('guard') || + !sixthEnemyBehaviors.has('hold') + ) { + throw new Error(`Expected sixth battle map, objective, and mixed enemy AI: ${JSON.stringify(sixthBattleState)}`); + } + + 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 campaignSaveAfterSixthBattle = await page.evaluate(() => { + const raw = window.localStorage.getItem('heros-web:campaign-state'); + return raw ? JSON.parse(raw) : undefined; + }); + if ( + !campaignSaveAfterSixthBattle || + campaignSaveAfterSixthBattle.step !== 'sixth-camp' || + campaignSaveAfterSixthBattle.latestBattleId !== 'sixth-battle-jieqiao-relief' || + !campaignSaveAfterSixthBattle.battleHistory?.['sixth-battle-jieqiao-relief'] + ) { + throw new Error(`Expected campaign save to persist sixth battle victory: ${JSON.stringify(campaignSaveAfterSixthBattle)}`); + } + + const sixthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + sixthCampState?.campBattleId !== 'sixth-battle-jieqiao-relief' || + sixthCampState.campTitle !== '서주 원군 준비 군영' || + sixthCampState.availableDialogueIds?.length !== 3 || + !sixthCampState.availableDialogueIds.every((id) => id.endsWith('jieqiao')) + ) { + throw new Error(`Expected sixth camp to expose Xu Province bridge dialogue set: ${JSON.stringify(sixthCampState)}`); + } + + await page.mouse.click(1120, 38); + await page.waitForTimeout(160); + const sixthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!sixthCampSortieState?.sortieVisible) { + throw new Error(`Expected sixth camp story bridge overlay: ${JSON.stringify(sixthCampSortieState)}`); + } + await page.mouse.click(938, 596); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('StoryScene'); + }); + await page.screenshot({ path: 'dist/verification-xuzhou-bridge-story.png', fullPage: true }); + + for (let i = 0; i < 4; i += 1) { + const returnedToCamp = await page.evaluate(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('CampScene'); + }); + if (returnedToCamp) { + break; + } + await page.keyboard.press('Space'); + await page.waitForTimeout(320); + } + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('CampScene'); + }); + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -674,7 +785,7 @@ try { return activeScenes.includes('CampScene'); }); - console.log(`Verified title-to-fifth-battle flow, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-sixth-battle flow, result states, and debug API at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { diff --git a/src/assets/images/battle/sixth-battle-map.svg b/src/assets/images/battle/sixth-battle-map.svg new file mode 100644 index 0000000..1fd9dff --- /dev/null +++ b/src/assets/images/battle/sixth-battle-map.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 1291d1a..995028e 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -11,6 +11,10 @@ import { secondBattleMap, secondBattleUnits, secondBattleVictoryPages, + sixthBattleBonds, + sixthBattleMap, + sixthBattleUnits, + sixthBattleVictoryPages, thirdBattleBonds, fourthBattleBonds, fourthBattleMap, @@ -30,7 +34,8 @@ export type BattleScenarioId = | 'second-battle-yellow-turban-pursuit' | 'third-battle-guangzong-road' | 'fourth-battle-guangzong-camp' - | 'fifth-battle-sishui-vanguard'; + | 'fifth-battle-sishui-vanguard' + | 'sixth-battle-jieqiao-relief'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -334,6 +339,59 @@ export const fifthBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const sixthBattleScenario: BattleScenarioDefinition = { + id: 'sixth-battle-jieqiao-relief', + title: '계교 원군로', + victoryConditionLabel: '곡의 격파', + defeatConditionLabel: '유비 퇴각', + openingObjectiveLines: [ + '공손찬의 보급로를 원소군 교위 곡의가 끊으려 합니다. 곡의를 격파하면 계교로 향하는 길을 다시 열 수 있습니다.', + '강가의 궁병과 측면 기병이 동시에 압박합니다. 숲길 척후를 먼저 밀어내고 중앙 길을 확보하십시오.', + '15턴 이내에 승리하면 공손찬 진영에서 세 형제의 신뢰가 커집니다.' + ], + map: sixthBattleMap, + units: sixthBattleUnits, + bonds: sixthBattleBonds, + mapTextureKey: 'battle-map-sixth', + leaderUnitId: 'jieqiao-leader-qu-yi', + quickVictoryTurnLimit: 15, + baseVictoryGold: 860, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '곡의 격파', + rewardGold: 520, + unitId: 'jieqiao-leader-qu-yi' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 210, + unitId: 'liu-bei' + }, + { + id: 'village', + kind: 'secure-terrain', + label: '계교 보급촌 확보', + rewardGold: 300, + terrain: 'village' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '15턴 이내 승리', + rewardGold: 250, + maxTurn: 15 + } + ], + defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], + itemRewards: ['콩 2', '상처약 2', '탁주 1', '공손찬 신뢰 +1'], + victoryPages: sixthBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { @@ -341,7 +399,8 @@ export const battleScenarios: Record 'second-battle-yellow-turban-pursuit': secondBattleScenario, 'third-battle-guangzong-road': thirdBattleScenario, 'fourth-battle-guangzong-camp': fourthBattleScenario, - 'fifth-battle-sishui-vanguard': fifthBattleScenario + 'fifth-battle-sishui-vanguard': fifthBattleScenario, + 'sixth-battle-jieqiao-relief': sixthBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 54e0c55..b53c4e5 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -4,6 +4,7 @@ import { fifthBattleScenario, fourthBattleScenario, secondBattleScenario, + sixthBattleScenario, thirdBattleScenario, type BattleScenarioId } from './battles'; @@ -15,6 +16,8 @@ import { fourthBattleVictoryPages, secondBattleIntroPages, secondBattleVictoryPages, + sixthBattleIntroPages, + sixthBattleVictoryPages, thirdBattleIntroPages, thirdBattleVictoryPages, type StoryPage @@ -75,12 +78,22 @@ const sortieFlows: Record = { }, [fifthBattleScenario.id]: { afterBattleId: fifthBattleScenario.id, + eyebrow: '다음 전장', + title: sixthBattleScenario.title, + description: '공손찬의 군영에 의탁한 세 형제는 북방 전선의 보급로를 지키는 임무를 맡습니다. 계교로 향하는 길목에서 원소군 별동대를 상대해야 합니다.', + rewardHint: `예상 보상: ${sixthBattleScenario.title} 개방`, + nextBattleId: sixthBattleScenario.id, + campaignStep: 'sixth-battle', + pages: [...fifthBattleVictoryPages, ...sixthBattleIntroPages] + }, + [sixthBattleScenario.id]: { + afterBattleId: sixthBattleScenario.id, eyebrow: '다음 장 준비', - title: '공손찬 의탁', - description: '사수관 앞 전초전을 넘긴 유비군은 공손찬 진영에서 연합군의 큰 흐름을 살피게 됩니다. 다음 줄기는 공손찬 의탁과 그 뒤의 서주로 이어집니다.', - rewardHint: '다음 장: 공손찬 의탁 준비 중', - pages: fifthBattleVictoryPages, - unavailableNotice: '공손찬 의탁 흐름은 다음 장으로 이어집니다. 군영에서 성장 상태를 정비하세요.' + title: '도겸의 서주', + description: '계교 원군로를 열어 공손찬의 신뢰를 얻은 유비군에게 서주의 도겸이 원군을 청합니다. 다음 큰 줄기는 서주 인수로 이어집니다.', + rewardHint: '다음 장: 도겸에게 서주 인수 준비 중', + pages: sixthBattleVictoryPages, + unavailableNotice: '도겸과 서주의 흐름은 다음 장으로 이어집니다. 군영에서 성장 상태를 정비하세요.' } }; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 6b7ceb8..8afe13f 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -428,6 +428,62 @@ export const fifthBattleVictoryPages: StoryPage[] = [ } ]; +export const sixthBattleIntroPages: StoryPage[] = [ + { + id: 'sixth-gongsun-zan-order', + bgm: 'militia-theme', + chapter: '공손찬의 명', + background: 'story-three-heroes', + text: '사수관 전초전 뒤, 유비군은 공손찬의 군영에 머물며 북방의 정세를 살폈다. 원소군의 별동대가 계교로 향하는 보급로를 위협하자, 공손찬은 세 형제에게 길목을 맡겼다.' + }, + { + id: 'sixth-liu-bei-service', + bgm: 'battle-prep', + chapter: '의탁의 무게', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '남의 깃발 아래에 서더라도 백성을 지키는 뜻은 변하지 않는다. 공손찬 장군의 군영에 머무는 동안에도 우리의 길을 분명히 하자.' + }, + { + id: 'sixth-guan-yu-road', + bgm: 'battle-prep', + chapter: '계교 원군로', + background: 'story-militia', + speaker: '관우', + portrait: 'guanYu', + text: '계교로 향하는 길은 강과 숲이 얽혀 있습니다. 원소군의 궁병과 기병을 나누어 상대해야 길을 열 수 있습니다.' + }, + { + id: 'sixth-zhang-fei-sortie', + bgm: 'battle-prep', + chapter: '북방의 먼지', + background: 'story-sortie', + speaker: '장비', + portrait: 'zhangFei', + text: '원소군이든 누구든 길을 막는 놈은 비켜서게 만들면 됩니다. 형님, 이번엔 제가 먼저 먼지를 일으키겠습니다!' + } +]; + +export const sixthBattleVictoryPages: StoryPage[] = [ + { + id: 'sixth-victory-jieqiao', + bgm: 'militia-theme', + chapter: '계교의 길', + background: 'story-militia', + text: '계교로 향하는 보급로가 열리자 공손찬의 군영에도 한숨 돌릴 여유가 생겼다. 그러나 북방의 전공은 세 형제의 마음을 오래 붙잡아 두지 못했다.' + }, + { + id: 'sixth-xuzhou-envoy', + bgm: 'battle-prep', + chapter: '서주의 사신', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '도겸 공이 서주에서 원군을 청한다면, 백성의 고통을 외면할 수 없소. 공손찬 장군께 허락을 구하고 서주로 향할 준비를 하자.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -561,6 +617,33 @@ export const fifthBattleMap: BattleMap = { ] }; +export const sixthBattleMap: BattleMap = { + width: 24, + height: 20, + terrain: [ + ['forest', 'hill', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'hill', 'hill', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'fort', 'fort', 'plain', 'hill', 'hill', 'forest', 'forest', 'plain'], + ['hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'river', 'river', 'road', 'plain', 'fort', 'plain', 'plain', 'plain', 'hill', 'forest', 'plain', 'plain'], + ['plain', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'road', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain'], + ['plain', 'forest', 'forest', 'plain', 'road', 'plain', 'hill', 'forest', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain'], + ['plain', 'plain', 'forest', 'road', 'road', 'plain', 'hill', 'plain', 'plain', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill'], + ['plain', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'plain', 'forest', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'road', 'plain', 'village', 'plain', 'forest', 'forest', 'plain', 'hill'], + ['forest', 'plain', 'plain', 'road', 'plain', 'hill', 'hill', 'plain', 'forest', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'plain', 'village', 'plain', 'plain', 'forest', 'plain', 'plain'], + ['forest', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'forest', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain'], + ['plain', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'road', 'forest', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain'], + ['plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'road', 'road', 'road', 'road', 'road', 'road', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain'], + ['road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'plain', 'plain'], + ['plain', 'road', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'hill', 'plain', 'plain'], + ['plain', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain'], + ['plain', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'hill', 'plain'], + ['camp', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain'], + ['camp', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain'], + ['plain', 'camp', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'plain'], + ['plain', 'plain', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain'], + ['plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain'], + ['plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain'] + ] +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -1872,6 +1955,270 @@ export const fifthBattleUnits: UnitData[] = [ } ]; +const sixthBattleAllyPositions: Record = { + 'liu-bei': { x: 2, y: 16 }, + 'guan-yu': { x: 3, y: 16 }, + 'zhang-fei': { x: 2, y: 17 } +}; + +export const sixthBattleUnits: UnitData[] = [ + ...firstBattleUnits + .filter((unit) => unit.faction === 'ally') + .map((unit) => placeScenarioUnit(unit, sixthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), + { + id: 'jieqiao-skirmisher-a', + name: '원소군 척후', + faction: 'enemy', + className: '척후병', + classKey: 'bandit', + level: 6, + exp: 36, + hp: 32, + maxHp: 32, + attack: 11, + move: 4, + stats: { might: 72, intelligence: 43, leadership: 50, agility: 68, luck: 50 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 14 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 6 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 5, + y: 12 + }, + { + id: 'jieqiao-infantry-a', + name: '원소 보병', + faction: 'enemy', + className: '원소 보병', + classKey: 'yellowTurban', + level: 6, + exp: 36, + hp: 36, + maxHp: 36, + attack: 11, + move: 3, + stats: { might: 73, intelligence: 42, leadership: 58, agility: 56, luck: 49 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 14 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 6 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 7, + y: 11 + }, + { + id: 'jieqiao-archer-a', + name: '원소 궁병', + faction: 'enemy', + className: '원소 궁병', + classKey: 'archer', + level: 6, + exp: 38, + hp: 29, + maxHp: 29, + attack: 12, + move: 3, + stats: { might: 66, intelligence: 54, leadership: 54, agility: 66, luck: 50 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 16 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 4 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 9, + y: 10 + }, + { + id: 'jieqiao-cavalry-a', + name: '원소 기병', + faction: 'enemy', + className: '원소 경기병', + classKey: 'cavalry', + level: 7, + exp: 40, + hp: 39, + maxHp: 39, + attack: 13, + move: 5, + stats: { might: 78, intelligence: 42, leadership: 60, agility: 78, luck: 52 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 18 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 8 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 12, + y: 9 + }, + { + id: 'jieqiao-skirmisher-b', + name: '강가 척후', + faction: 'enemy', + className: '척후병', + classKey: 'bandit', + level: 6, + exp: 38, + hp: 33, + maxHp: 33, + attack: 12, + move: 4, + stats: { might: 74, intelligence: 44, leadership: 51, agility: 69, luck: 51 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 16 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 6 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 6, + y: 7 + }, + { + id: 'jieqiao-archer-b', + name: '강안 궁병', + faction: 'enemy', + className: '원소 궁병', + classKey: 'archer', + level: 7, + exp: 40, + hp: 30, + maxHp: 30, + attack: 13, + move: 3, + stats: { might: 68, intelligence: 56, leadership: 56, agility: 67, luck: 51 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 18 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 6 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 11, + y: 6 + }, + { + id: 'jieqiao-cavalry-b', + name: '원소 기병', + faction: 'enemy', + className: '원소 경기병', + classKey: 'cavalry', + level: 7, + exp: 42, + hp: 40, + maxHp: 40, + attack: 13, + move: 5, + stats: { might: 79, intelligence: 42, leadership: 61, agility: 79, luck: 52 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 20 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 8 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 15, + y: 8 + }, + { + id: 'jieqiao-guard-a', + name: '계교 수비병', + faction: 'enemy', + className: '원소 보병', + classKey: 'yellowTurban', + level: 7, + exp: 42, + hp: 38, + maxHp: 38, + attack: 12, + move: 3, + stats: { might: 76, intelligence: 45, leadership: 62, agility: 58, luck: 51 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 20 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 8 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 16, + y: 5 + }, + { + id: 'jieqiao-cavalry-c', + name: '측면 기병', + faction: 'enemy', + className: '원소 경기병', + classKey: 'cavalry', + level: 7, + exp: 44, + hp: 41, + maxHp: 41, + attack: 13, + move: 5, + stats: { might: 80, intelligence: 43, leadership: 62, agility: 80, luck: 53 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 22 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 10 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 20, + y: 5 + }, + { + id: 'jieqiao-guard-b', + name: '계교 수비병', + faction: 'enemy', + className: '원소 보병', + classKey: 'yellowTurban', + level: 7, + exp: 44, + hp: 39, + maxHp: 39, + attack: 12, + move: 3, + stats: { might: 77, intelligence: 45, leadership: 63, agility: 58, luck: 52 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 22 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 8 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 18, + y: 3 + }, + { + id: 'jieqiao-archer-c', + name: '계교 궁병', + faction: 'enemy', + className: '원소 궁병', + classKey: 'archer', + level: 7, + exp: 44, + hp: 31, + maxHp: 31, + attack: 13, + move: 3, + stats: { might: 69, intelligence: 57, leadership: 57, agility: 68, luck: 52 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 22 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 8 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 16, + y: 1 + }, + { + id: 'jieqiao-leader-qu-yi', + name: '곡의', + faction: 'enemy', + className: '원소군 교위', + classKey: 'rebelLeader', + level: 8, + exp: 48, + hp: 54, + maxHp: 54, + attack: 15, + move: 3, + stats: { might: 84, intelligence: 62, leadership: 78, agility: 62, luck: 56 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 2, exp: 26 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 14 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 17, + y: 1 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -1903,6 +2250,7 @@ export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleB export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const fifthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); +export const sixthBattleBonds: 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 2236782..44f59e2 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -3,6 +3,7 @@ import fifthBattleMapUrl from '../../assets/images/battle/fifth-battle-map.svg'; import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png'; import fourthBattleMapUrl from '../../assets/images/battle/fourth-battle-map.svg'; import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg'; +import sixthBattleMapUrl from '../../assets/images/battle/sixth-battle-map.svg'; import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg'; import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png'; import liuBeiPortraitUrl from '../../assets/images/portraits/liu-bei.png'; @@ -68,6 +69,7 @@ export class BootScene extends Phaser.Scene { this.load.image('battle-map-third', thirdBattleMapUrl); this.load.image('battle-map-fourth', fourthBattleMapUrl); this.load.image('battle-map-fifth', fifthBattleMapUrl); + this.load.image('battle-map-sixth', sixthBattleMapUrl); 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 18bf2b9..8114b8d 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -127,7 +127,8 @@ const campBattleIds = { second: 'second-battle-yellow-turban-pursuit', third: 'third-battle-guangzong-road', fourth: 'fourth-battle-guangzong-camp', - fifth: 'fifth-battle-sishui-vanguard' + fifth: 'fifth-battle-sishui-vanguard', + sixth: 'sixth-battle-jieqiao-relief' } as const; const campDialogues: CampDialogue[] = [ @@ -535,6 +536,87 @@ const campDialogues: CampDialogue[] = [ rewardExp: 5 } ] + }, + { + id: 'liu-guan-after-jieqiao', + title: '서주로 향하는 뜻', + availableAfterBattleIds: [campBattleIds.sixth], + unitIds: ['liu-bei', 'guan-yu'], + bondId: 'liu-bei__guan-yu', + rewardExp: 20, + lines: [ + '유비: 공손찬 장군의 은혜는 크나, 서주의 백성이 위태롭다는 소식을 들으니 마음이 편치 않소.', + '관우: 의탁은 몸을 잠시 맡기는 일이고, 의리는 마음이 향하는 곳을 따르는 일입니다.', + '유비: 운장의 말이 나를 깨우는구려. 도겸 공의 청을 외면하지 않겠소.' + ], + choices: [ + { + id: 'ask-permission', + label: '공손찬에게 허락을 구한다', + response: '관우는 예를 갖추어 떠나는 것이 유비의 이름을 더 높인다고 답했다.', + rewardExp: 8 + }, + { + id: 'put-people-first', + label: '백성을 먼저 생각한다', + response: '두 사람은 새 주인의 눈치보다 고통받는 백성을 먼저 보겠다고 뜻을 모았다.', + rewardExp: 7 + } + ] + }, + { + id: 'liu-zhang-after-jieqiao', + title: '떠나는 술잔', + availableAfterBattleIds: [campBattleIds.sixth], + unitIds: ['liu-bei', 'zhang-fei'], + bondId: 'liu-bei__zhang-fei', + rewardExp: 20, + lines: [ + '장비: 형님, 북방 군영도 나쁘진 않았지만 서주 백성이 부른다면 가야지요.', + '유비: 익덕은 떠나는 길에서도 성급히 다투지 말아야 하오. 우리는 도움을 주러 가는 것이오.', + '장비: 알겠습니다. 술잔은 비우고, 창은 백성을 괴롭히는 자에게만 겨누겠습니다.' + ], + choices: [ + { + id: 'steady-departure', + label: '차분한 출발을 당부한다', + response: '장비는 떠나는 순간의 소란도 형님의 이름에 흠이 될 수 있음을 받아들였다.', + rewardExp: 7 + }, + { + id: 'promise-xuzhou-vanguard', + label: '서주 선봉을 맡긴다', + response: '장비는 서주 길에서도 가장 앞에서 백성을 지키겠다며 창을 움켜쥐었다.', + rewardExp: 8 + } + ] + }, + { + id: 'guan-zhang-after-jieqiao', + title: '새 길의 두 기둥', + availableAfterBattleIds: [campBattleIds.sixth], + unitIds: ['guan-yu', 'zhang-fei'], + bondId: 'guan-yu__zhang-fei', + rewardExp: 18, + lines: [ + '관우: 익덕, 서주로 가면 우리 힘만으로는 감당하기 어려운 일도 많을 것이다.', + '장비: 그러니 더더욱 형님 곁을 단단히 지켜야지요. 운장 형님이 오른쪽이면 저는 왼쪽입니다.', + '관우: 좋다. 길이 바뀌어도 형님을 받치는 두 기둥은 흔들리지 않는다.' + ], + choices: [ + { + id: 'two-pillars', + label: '좌우의 역할을 정한다', + response: '두 사람은 유비의 좌우에서 전선과 군심을 함께 지키기로 했다.', + rewardExp: 8 + }, + { + id: 'guard-the-oath', + label: '도원 맹세를 되새긴다', + response: '낯선 서주 길 앞에서도 도원의 맹세가 세 사람의 중심이 되었다.', + rewardExp: 6 + } + ] } ]; @@ -623,6 +705,9 @@ export class CampScene extends Phaser.Scene { if (battleId === campBattleIds.fifth) { return '공손찬 진영 군영'; } + if (battleId === campBattleIds.sixth) { + return '서주 원군 준비 군영'; + } if (battleId === campBattleIds.fourth) { return '광종 토벌 후 군영'; } @@ -925,6 +1010,13 @@ export class CampScene extends Phaser.Scene { if (!flow.nextBattleId || !flow.campaignStep || flow.pages.length === 0) { this.hideSortiePrep(); + if (flow.pages.length > 0) { + this.scene.start('StoryScene', { + pages: flow.pages, + nextScene: 'CampScene' + }); + return; + } this.showCampNotice(flow.unavailableNotice ?? '다음 장은 아직 준비 중입니다. 군영에서 성장 상태를 정비하세요.'); return; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index d295f75..086b1fa 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -1,7 +1,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { firstBattleVictoryPages } from '../data/scenario'; -import { fifthBattleScenario, fourthBattleScenario, secondBattleScenario, thirdBattleScenario } from '../data/battles'; +import { fifthBattleScenario, fourthBattleScenario, secondBattleScenario, sixthBattleScenario, thirdBattleScenario } from '../data/battles'; import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState'; import { palette } from '../ui/palette'; @@ -304,7 +304,8 @@ export class TitleScene extends Phaser.Scene { campaign.step === 'second-camp' || campaign.step === 'third-camp' || campaign.step === 'fourth-camp' || - campaign.step === 'fifth-camp' + campaign.step === 'fifth-camp' || + campaign.step === 'sixth-camp' ) { this.scene.start('CampScene'); return; @@ -335,6 +336,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'sixth-battle') { + this.scene.start('BattleScene', { battleId: sixthBattleScenario.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 6d64b36..da2f654 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -49,7 +49,9 @@ export type CampaignStep = | 'fourth-battle' | 'fourth-camp' | 'fifth-battle' - | 'fifth-camp'; + | 'fifth-camp' + | 'sixth-battle' + | 'sixth-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; @@ -104,7 +106,8 @@ const campaignBattleSteps: Record