From 3de598511c797bfa67b827e1a8d448176ca4b12d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 23:00:10 +0900 Subject: [PATCH] Add second battle pursuit chapter --- scripts/verify-flow.mjs | 88 ++++- .../images/battle/second-battle-map.svg | 98 +++++ src/game/data/battles.ts | 62 ++- src/game/data/scenario.ts | 354 ++++++++++++++++++ src/game/scenes/BattleScene.ts | 76 +++- src/game/scenes/BootScene.ts | 2 + src/game/scenes/CampScene.ts | 43 ++- src/game/scenes/StoryScene.ts | 5 +- src/game/scenes/TitleScene.ts | 8 +- src/game/state/campaignState.ts | 11 +- 10 files changed, 703 insertions(+), 44 deletions(-) create mode 100644 src/assets/images/battle/second-battle-map.svg diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index b358a64..df99cfa 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -280,6 +280,74 @@ try { throw new Error(`Expected sortie preparation overlay to close: ${JSON.stringify(campStateAfterSortieClose)}`); } + await page.mouse.click(1120, 38); + await page.waitForTimeout(120); + await page.mouse.click(938, 596); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('StoryScene'); + }); + + for (let i = 0; i < 8; i += 1) { + const isSecondBattleActive = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit'; + }); + + if (isSecondBattleActive) { + break; + } + + await page.keyboard.press('Space'); + await page.waitForTimeout(220); + } + + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-second-battle.png', fullPage: true }); + + const secondBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const secondEnemies = secondBattleState.units.filter((unit) => unit.faction === 'enemy'); + const secondEnemyBehaviors = new Set(secondEnemies.map((unit) => unit.ai)); + if ( + secondBattleState.camera?.mapWidth !== 24 || + secondBattleState.camera?.mapHeight !== 20 || + secondEnemies.length < 10 || + !secondEnemyBehaviors.has('aggressive') || + !secondEnemyBehaviors.has('guard') || + !secondEnemyBehaviors.has('hold') + ) { + throw new Error(`Expected expanded second battle map and mixed enemy AI: ${JSON.stringify(secondBattleState)}`); + } + + 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 campaignSaveAfterSecondBattle = await page.evaluate(() => { + const raw = window.localStorage.getItem('heros-web:campaign-state'); + return raw ? JSON.parse(raw) : undefined; + }); + if ( + !campaignSaveAfterSecondBattle || + campaignSaveAfterSecondBattle.step !== 'second-camp' || + campaignSaveAfterSecondBattle.latestBattleId !== 'second-battle-yellow-turban-pursuit' || + !campaignSaveAfterSecondBattle.battleHistory?.['second-battle-yellow-turban-pursuit'] || + !campaignSaveAfterSecondBattle.completedCampDialogues?.length + ) { + throw new Error(`Expected campaign save to persist second battle victory: ${JSON.stringify(campaignSaveAfterSecondBattle)}`); + } + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -291,25 +359,7 @@ try { return activeScenes.includes('CampScene'); }); - await page.evaluate(() => window.__HEROS_DEBUG__?.goToBattle()); - await page.waitForFunction(() => { - const state = window.__HEROS_DEBUG__?.battle(); - return state?.scene === 'BattleScene' && state?.battleOutcome === null && state?.phase === 'idle'; - }); - - await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('defeat')); - await page.waitForFunction(() => { - const state = window.__HEROS_DEBUG__?.battle(); - return state?.battleOutcome === 'defeat' && state?.phase === 'resolved' && state?.resultVisible === true; - }); - - const defeatState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); - const liuBei = defeatState.units.find((unit) => unit.id === 'liu-bei'); - if (!liuBei || liuBei.hp > 0) { - throw new Error(`Expected Liu Bei to be defeated after forced defeat: ${JSON.stringify(liuBei)}`); - } - - console.log(`Verified title-to-battle flow, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-second-battle flow, result states, and debug API at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { diff --git a/src/assets/images/battle/second-battle-map.svg b/src/assets/images/battle/second-battle-map.svg new file mode 100644 index 0000000..5e9399b --- /dev/null +++ b/src/assets/images/battle/second-battle-map.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index bb9c982..9ea837c 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -3,13 +3,17 @@ import { firstBattleMap, firstBattleUnits, firstBattleVictoryPages, + secondBattleBonds, + secondBattleMap, + secondBattleUnits, + secondBattleVictoryPages, type BattleBond, type BattleMap, type StoryPage, type UnitData } from './scenario'; -export type BattleScenarioId = 'first-battle-zhuo-commandery'; +export type BattleScenarioId = 'first-battle-zhuo-commandery' | 'second-battle-yellow-turban-pursuit'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -101,10 +105,64 @@ export const firstBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const secondBattleScenario: BattleScenarioDefinition = { + id: 'second-battle-yellow-turban-pursuit', + title: '황건 잔당 추격', + victoryConditionLabel: '두령 한석 격파', + defeatConditionLabel: '유비 퇴각', + openingObjectiveLines: [ + '황건 잔당이 북쪽 마을로 달아났습니다. 두령 한석을 격파하면 적의 퇴로가 무너집니다.', + '마을 주변에 적 위협이 남아 있으면 보상이 줄어듭니다. 길목의 습격조를 먼저 정리하십시오.', + '12턴 이내에 승리하면 의용군의 추격전 명성이 오릅니다.' + ], + map: secondBattleMap, + units: secondBattleUnits, + bonds: secondBattleBonds, + mapTextureKey: 'battle-map-second', + leaderUnitId: 'pursuit-leader-han-seok', + quickVictoryTurnLimit: 12, + baseVictoryGold: 420, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '두령 한석 격파', + rewardGold: 260, + unitId: 'pursuit-leader-han-seok' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 120, + unitId: 'liu-bei' + }, + { + id: 'village', + kind: 'secure-terrain', + label: '북쪽 마을 확보', + rewardGold: 180, + terrain: 'village' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '12턴 이내 승리', + rewardGold: 150, + maxTurn: 12 + } + ], + defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], + itemRewards: ['콩 2', '탁주 1', '의용군 명성 +2'], + victoryPages: secondBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { - [firstBattleScenario.id]: firstBattleScenario + 'first-battle-zhuo-commandery': firstBattleScenario, + 'second-battle-yellow-turban-pursuit': secondBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 52db0a7..4c23b25 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -185,6 +185,69 @@ export const firstBattleVictoryPages: StoryPage[] = [ } ]; +export const secondBattleIntroPages: StoryPage[] = [ + { + id: 'second-pursuit-rumor', + bgm: 'militia-theme', + chapter: '잔당의 그림자', + background: 'story-militia', + text: '첫 승리의 기쁨이 채 가시기도 전에, 황건 잔당이 북쪽 길목의 마을을 습격하고 있다는 급보가 도착했다.' + }, + { + id: 'second-liu-bei-command', + bgm: 'battle-prep', + chapter: '추격 명령', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '백성이 다시 칼끝에 몰렸다면 우리가 쉬어 갈 곳은 없다. 길을 따라 추격하되, 마을을 먼저 지켜야 한다.' + }, + { + id: 'second-guan-yu-vow', + bgm: 'battle-prep', + chapter: '좁은 길목', + background: 'story-three-heroes', + speaker: '관우', + portrait: 'guanYu', + text: '적은 숲과 강을 끼고 물러설 것입니다. 제가 앞길을 열겠습니다. 형님께서는 백성을 살피십시오.' + }, + { + id: 'second-zhang-fei-charge', + bgm: 'battle-prep', + chapter: '의용군 출진', + background: 'story-sortie', + speaker: '장비', + portrait: 'zhangFei', + text: '좋다! 숨어 있는 놈들도 모조리 끌어내 주마. 이번에는 도망칠 틈도 주지 않겠다!' + } +]; + +export const secondBattleVictoryPages: StoryPage[] = [ + { + id: 'second-victory-village', + bgm: 'militia-theme', + chapter: '마을의 안도', + background: 'story-militia', + text: '잔당의 불길은 꺼지고, 피난하던 백성들이 하나둘 마을로 돌아왔다. 의용군의 이름은 이제 길목의 장터마다 오르내렸다.' + }, + { + id: 'second-victory-liu-bei', + bgm: 'militia-theme', + chapter: '다음 책임', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '우리가 이긴 것은 힘만으로 된 일이 아니다. 백성을 지키겠다는 뜻을 잊지 않는다면, 더 큰 전장도 견딜 수 있을 것이다.' + }, + { + id: 'second-victory-next', + bgm: 'battle-prep', + chapter: '넓어지는 전장', + background: 'story-sortie', + text: '탁현 인근의 소란은 잦아들었지만, 황건의 난은 아직 천하 곳곳에서 타오르고 있었다. 세 형제의 길은 이제 더 멀리 이어진다.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -210,6 +273,33 @@ export const firstBattleMap: BattleMap = { ] }; +export const secondBattleMap: BattleMap = { + width: 24, + height: 20, + terrain: [ + ['forest', 'forest', 'forest', 'hill', 'plain', 'plain', 'road', 'plain', 'forest', 'hill', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'hill', 'hill', 'plain', 'plain', 'forest', 'forest'], + ['forest', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'river', 'river', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'fort', 'fort', 'plain', 'plain', 'forest'], + ['plain', 'plain', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'fort', 'plain', 'plain', 'plain', 'forest'], + ['plain', 'plain', 'forest', 'forest', 'plain', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'], + ['plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'cliff', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain'], + ['plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'plain', 'village', 'plain', 'forest', 'forest', 'plain'], + ['plain', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'village', 'plain', 'plain', 'forest', 'plain'], + ['plain', 'forest', 'forest', 'plain', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain'], + ['plain', 'forest', 'plain', 'road', 'road', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain'], + ['plain', 'plain', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain'], + ['hill', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'village', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain'], + ['hill', 'hill', 'plain', 'road', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'village', 'village', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain'], + ['cliff', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'village', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain'], + ['cliff', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'], + ['plain', 'plain', 'camp', 'plain', 'plain', 'road', 'road', 'plain', 'road', 'plain', 'forest', 'plain', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain'], + ['plain', 'camp', 'camp', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'forest', 'plain', 'hill'], + ['plain', 'camp', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill', 'hill'], + ['plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill'], + ['plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain'], + ['plain', 'road', 'road', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain'] + ] +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -507,6 +597,249 @@ export const firstBattleUnits: UnitData[] = [ } ]; +const secondBattleAllyPositions: Record = { + 'liu-bei': { x: 2, y: 15 }, + 'guan-yu': { x: 3, y: 15 }, + 'zhang-fei': { x: 2, y: 16 } +}; + +export const secondBattleUnits: UnitData[] = [ + ...firstBattleUnits + .filter((unit) => unit.faction === 'ally') + .map((unit) => placeScenarioUnit(unit, secondBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), + { + id: 'pursuit-raider-a', + name: '황건 추격병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 2, + exp: 12, + hp: 23, + maxHp: 23, + attack: 7, + move: 3, + stats: { might: 55, intelligence: 34, leadership: 43, agility: 48, luck: 42 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 12 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 6 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 7, + y: 12 + }, + { + id: 'pursuit-raider-b', + name: '황건 습격병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 2, + exp: 12, + hp: 23, + maxHp: 23, + attack: 7, + move: 3, + stats: { might: 54, intelligence: 35, leadership: 42, agility: 47, luck: 41 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 12 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 6 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 10, + y: 11 + }, + { + id: 'pursuit-bandit-a', + name: '산길 복병', + faction: 'enemy', + className: '산적', + classKey: 'bandit', + level: 2, + exp: 14, + hp: 24, + maxHp: 24, + attack: 8, + move: 3, + stats: { might: 58, intelligence: 32, leadership: 40, agility: 53, luck: 44 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 13 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 7 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 6, + y: 8 + }, + { + id: 'pursuit-archer-a', + name: '황건 궁병', + faction: 'enemy', + className: '궁병', + classKey: 'archer', + level: 2, + exp: 16, + hp: 20, + maxHp: 20, + attack: 8, + move: 3, + stats: { might: 54, intelligence: 43, leadership: 45, agility: 58, luck: 45 }, + equipment: { + weapon: { itemId: 'short-bow', level: 1, exp: 16 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 7 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 12, + y: 10 + }, + { + id: 'pursuit-cavalry-a', + name: '황건 기병', + faction: 'enemy', + className: '경기병', + classKey: 'cavalry', + level: 3, + exp: 18, + hp: 26, + maxHp: 26, + attack: 9, + move: 5, + stats: { might: 62, intelligence: 35, leadership: 48, agility: 65, luck: 46 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 8 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 15, + y: 7 + }, + { + id: 'pursuit-raider-c', + name: '마을 습격병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 2, + exp: 13, + hp: 24, + maxHp: 24, + attack: 7, + move: 3, + stats: { might: 56, intelligence: 34, leadership: 43, agility: 49, luck: 42 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 13 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 7 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 17, + y: 6 + }, + { + id: 'pursuit-archer-b', + name: '황건 궁병', + faction: 'enemy', + className: '궁병', + classKey: 'archer', + level: 3, + exp: 18, + hp: 21, + maxHp: 21, + attack: 8, + move: 3, + stats: { might: 55, intelligence: 45, leadership: 46, agility: 59, luck: 47 }, + equipment: { + weapon: { itemId: 'short-bow', level: 1, exp: 18 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 8 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 20, + y: 5 + }, + { + id: 'pursuit-cavalry-b', + name: '황건 기병', + faction: 'enemy', + className: '경기병', + classKey: 'cavalry', + level: 3, + exp: 19, + hp: 27, + maxHp: 27, + attack: 9, + move: 5, + stats: { might: 63, intelligence: 35, leadership: 49, agility: 66, luck: 47 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 19 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 8 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 18, + y: 3 + }, + { + id: 'pursuit-guard-a', + name: '한석 친위병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 3, + exp: 16, + hp: 25, + maxHp: 25, + attack: 8, + move: 3, + stats: { might: 58, intelligence: 36, leadership: 48, agility: 50, luck: 44 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 16 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 8 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 19, + y: 2 + }, + { + id: 'pursuit-guard-b', + name: '한석 친위병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 3, + exp: 16, + hp: 25, + maxHp: 25, + attack: 8, + move: 3, + stats: { might: 58, intelligence: 36, leadership: 48, agility: 50, luck: 44 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 16 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 8 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 21, + y: 2 + }, + { + id: 'pursuit-leader-han-seok', + name: '두령 한석', + faction: 'enemy', + className: '두령', + classKey: 'rebelLeader', + level: 4, + exp: 24, + hp: 38, + maxHp: 38, + attack: 11, + move: 3, + stats: { might: 68, intelligence: 48, leadership: 64, agility: 54, luck: 52 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 1, exp: 24 }, + armor: { itemId: 'lamellar-armor', level: 1, exp: 14 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 20, + y: 1 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -533,3 +866,24 @@ export const firstBattleBonds: BattleBond[] = [ description: '둘이 같은 전선을 압박하면 연계 공격의 기초 확률이 오른다.' } ]; + +export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); + +function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData { + return { + ...cloneUnitForScenario(unit), + x: position.x, + y: position.y + }; +} + +function cloneUnitForScenario(unit: UnitData): UnitData { + return JSON.parse(JSON.stringify(unit)) as UnitData; +} + +function cloneBattleBondForScenario(bond: BattleBond): BattleBond { + return { + ...bond, + unitIds: [...bond.unitIds] as [string, string] + }; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index c1f3998..7c883b8 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -18,6 +18,7 @@ import { markCampaignStep, saveCampaignState, setFirstBattleReport, + type CampaignState, type CampaignStep } from '../state/campaignState'; import { palette } from '../ui/palette'; @@ -36,7 +37,18 @@ const unitTexture: Record = { 'rebel-cavalry-a': 'unit-rebel-cavalry', 'rebel-cavalry-b': 'unit-rebel-cavalry', 'rebel-archer-b': 'unit-rebel-archer', - 'rebel-leader': 'unit-rebel-leader' + 'rebel-leader': 'unit-rebel-leader', + 'pursuit-raider-a': 'unit-rebel', + 'pursuit-raider-b': 'unit-rebel', + 'pursuit-bandit-a': 'unit-rebel', + 'pursuit-archer-a': 'unit-rebel-archer', + 'pursuit-cavalry-a': 'unit-rebel-cavalry', + 'pursuit-raider-c': 'unit-rebel', + 'pursuit-archer-b': 'unit-rebel-archer', + 'pursuit-cavalry-b': 'unit-rebel-cavalry', + 'pursuit-guard-a': 'unit-rebel', + 'pursuit-guard-b': 'unit-rebel', + 'pursuit-leader-han-seok': 'unit-rebel-leader' }; const unitFrameRows: Record = { @@ -459,7 +471,18 @@ const enemyAiByUnitId: Record = { 'rebel-cavalry-a': 'aggressive', 'rebel-cavalry-b': 'aggressive', 'rebel-archer-b': 'hold', - 'rebel-leader': 'guard' + 'rebel-leader': 'guard', + 'pursuit-raider-a': 'aggressive', + 'pursuit-raider-b': 'guard', + 'pursuit-bandit-a': 'aggressive', + 'pursuit-archer-a': 'hold', + 'pursuit-cavalry-a': 'aggressive', + 'pursuit-raider-c': 'aggressive', + 'pursuit-archer-b': 'hold', + 'pursuit-cavalry-b': 'aggressive', + 'pursuit-guard-a': 'guard', + 'pursuit-guard-b': 'guard', + 'pursuit-leader-han-seok': 'guard' }; const defaultEnemyAiByClass: Partial> = { @@ -611,8 +634,8 @@ export class BattleScene extends Phaser.Scene { } create() { - this.resetBattleData(); const campaign = getCampaignState(); + this.resetBattleData(campaign); if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') { markCampaignStep('first-battle'); } @@ -662,9 +685,42 @@ export class BattleScene extends Phaser.Scene { this.renderRosterPanel('ally', '행동할 장수를 선택하세요.'); } - private resetBattleData() { - battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map(cloneUnitData)); - battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map(cloneBattleBond)); + private resetBattleData(campaign?: CampaignState) { + battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map((unit) => this.applyCampaignUnitProgress(unit, campaign))); + battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map((bond) => this.applyCampaignBondProgress(bond, campaign))); + initialBattleUnits = battleUnits.map(cloneUnitData); + initialBattleBonds = battleBonds.map(cloneBattleBond); + } + + private applyCampaignUnitProgress(unit: UnitData, campaign?: CampaignState) { + const cloned = cloneUnitData(unit); + const progress = campaign?.roster.find((candidate) => candidate.id === unit.id); + if (!progress || unit.faction !== 'ally') { + return cloned; + } + + return { + ...cloned, + level: progress.level, + exp: progress.exp, + hp: Math.max(1, Math.min(progress.hp, progress.maxHp)), + maxHp: progress.maxHp, + equipment: JSON.parse(JSON.stringify(progress.equipment)) as UnitData['equipment'] + }; + } + + private applyCampaignBondProgress(bond: BattleBond, campaign?: CampaignState) { + const cloned = cloneBattleBond(bond); + const progress = campaign?.bonds.find((candidate) => candidate.id === bond.id); + if (!progress) { + return cloned; + } + + return { + ...cloned, + level: progress.level, + exp: progress.exp + }; } private createLayout(width: number, height: number): BattleLayout { @@ -2054,7 +2110,7 @@ export class BattleScene extends Phaser.Scene { } this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { soundDirector.playSelect(); - this.scene.restart(); + this.scene.restart({ battleId: battleScenario.id }); }, depth + 3); this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { soundDirector.playSelect(); @@ -3149,7 +3205,7 @@ export class BattleScene extends Phaser.Scene { } private showOpeningBattleEvent() { - this.triggerBattleEvent('opening', '첫 전투 목표', battleScenario.openingObjectiveLines); + this.triggerBattleEvent('opening', '전투 목표', battleScenario.openingObjectiveLines); } private triggerBattleEvent(key: string, title: string, lines: string[]) { @@ -6463,8 +6519,8 @@ export class BattleScene extends Phaser.Scene { x: unit.x, y: unit.y, acted: this.actedUnitIds.has(unit.id), - animating: this.unitViews.get(unit.id)?.sprite.anims.isPlaying ?? false, - animationKey: this.unitViews.get(unit.id)?.sprite.anims.currentAnim?.key ?? null + animating: this.unitViews.get(unit.id)?.sprite.anims?.isPlaying ?? false, + animationKey: this.unitViews.get(unit.id)?.sprite.anims?.currentAnim?.key ?? null })), bonds: Array.from(this.bondStates.values()).map((bond) => ({ id: bond.id, diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 083d798..e992b61 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 secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg'; import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png'; import liuBeiPortraitUrl from '../../assets/images/portraits/liu-bei.png'; import zhangFeiPortraitUrl from '../../assets/images/portraits/zhang-fei.png'; @@ -60,6 +61,7 @@ export class BootScene extends Phaser.Scene { this.load.image('story-militia', storyMilitiaUrl); this.load.image('story-sortie', storySortieUrl); this.load.image('battle-map-first', firstBattleMapUrl); + this.load.image('battle-map-second', secondBattleMapUrl); 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 226d033..b6e6c3e 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -2,8 +2,8 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; import { getUnitClass } from '../data/battleRules'; -import { defaultBattleScenario } from '../data/battles'; -import { firstBattleBonds, firstBattleUnits, firstBattleVictoryPages, type PortraitKey, type UnitData } from '../data/scenario'; +import { defaultBattleScenario, secondBattleScenario } from '../data/battles'; +import { firstBattleBonds, firstBattleUnits, secondBattleIntroPages, type PortraitKey, type UnitData } from '../data/scenario'; import { applyCampBondExp, getCampaignState, @@ -335,10 +335,11 @@ export class CampScene extends Phaser.Scene { bg.setDepth(depth); bg.setStrokeStyle(1, palette.blue, 0.48); - this.trackSortie(this.add.text(x + 18, y + 14, '다음 전장', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1); - this.trackSortie(this.add.text(x + 18, y + 48, '황건 잔당 추격', this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1); + const briefing = this.nextSortieBriefing(); + this.trackSortie(this.add.text(x + 18, y + 14, briefing.eyebrow, this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 18, y + 48, briefing.title, this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1); this.trackSortie( - this.add.text(x + 18, y + 84, '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 첫 승리의 기세를 몰아 추격에 나섭니다.', { + this.add.text(x + 18, y + 84, briefing.description, { ...this.textStyle(14, '#d4dce6'), wordWrap: { width: width - 36, useAdvancedWrap: true }, lineSpacing: 4 @@ -397,10 +398,27 @@ export class CampScene extends Phaser.Scene { bg.setStrokeStyle(1, palette.gold, 0.4); const completedDialogues = this.completedCampDialogues().length; const inventory = this.inventoryLabels().join(', '); - this.trackSortie(this.add.text(x + 16, y + 10, `예상 보상: 군자금, 소모품, 의용군 명성`, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1); + const reward = this.campaign?.latestBattleId === secondBattleScenario.id ? '예상 보상: 다음 장 개방' : '예상 보상: 군자금, 소모품, 의용군 명성'; + this.trackSortie(this.add.text(x + 16, y + 10, reward, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1); this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${campDialogues.length} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1); } + private nextSortieBriefing() { + if (this.campaign?.latestBattleId === secondBattleScenario.id) { + return { + eyebrow: '다음 이야기', + title: '넓어지는 전장', + description: '황건 잔당 추격을 마친 의용군은 탁현을 넘어 더 큰 난세의 흐름을 마주합니다. 다음 장으로 향하기 전 전투 결과를 정리합니다.' + }; + } + + return { + eyebrow: '다음 전장', + title: secondBattleScenario.title, + description: '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 첫 승리의 기세를 몰아 추격에 나섭니다.' + }; + } + private sortieChecklist(): SortieChecklistItem[] { const units = this.currentUnits().filter((unit) => unit.faction === 'ally'); const liuBei = units.find((unit) => unit.id === 'liu-bei'); @@ -453,8 +471,17 @@ export class CampScene extends Phaser.Scene { } private startVictoryStory() { - markCampaignStep('first-victory-story'); - this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); + if (this.campaign?.latestBattleId === secondBattleScenario.id) { + this.scene.start('StoryScene', { pages: secondBattleScenario.victoryPages, nextScene: 'TitleScene' }); + return; + } + + markCampaignStep('second-battle'); + this.scene.start('StoryScene', { + pages: secondBattleIntroPages, + nextScene: 'BattleScene', + nextSceneData: { battleId: secondBattleScenario.id } + }); } private hideSortiePrep() { diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 1facd44..2643d01 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -14,12 +14,14 @@ type AlphaObject = Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle | Pha type StorySceneData = { pages?: StoryPage[]; nextScene?: string; + nextSceneData?: Record; }; export class StoryScene extends Phaser.Scene { private pageIndex = 0; private pages: StoryPage[] = prologuePages; private nextScene = 'BattleScene'; + private nextSceneData?: Record; private transitioning = false; private background?: Phaser.GameObjects.Image; private chapterText?: Phaser.GameObjects.Text; @@ -37,6 +39,7 @@ export class StoryScene extends Phaser.Scene { init(data?: StorySceneData) { this.pages = data?.pages?.length ? data.pages : prologuePages; this.nextScene = data?.nextScene ?? 'BattleScene'; + this.nextSceneData = data?.nextSceneData; this.pageIndex = 0; this.transitioning = false; } @@ -223,7 +226,7 @@ export class StoryScene extends Phaser.Scene { } if (this.pageIndex >= this.pages.length - 1) { - this.scene.start(this.nextScene); + this.scene.start(this.nextScene, this.nextSceneData); return; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index da2cf75..5799b45 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { firstBattleVictoryPages } from '../data/scenario'; +import { secondBattleScenario } from '../data/battles'; import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState'; import { palette } from '../ui/palette'; @@ -298,7 +299,7 @@ export class TitleScene extends Phaser.Scene { soundDirector.playSelect(); const campaign = loadCampaignState(); - if (campaign.step === 'first-camp') { + if (campaign.step === 'first-camp' || campaign.step === 'second-camp') { this.scene.start('CampScene'); return; } @@ -308,6 +309,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'second-battle') { + this.scene.start('BattleScene', { battleId: secondBattleScenario.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 3c71a45..e2f1428 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -36,7 +36,7 @@ export type FirstBattleReport = { createdAt: string; }; -export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story'; +export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story' | 'second-battle' | 'second-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; @@ -186,13 +186,18 @@ export function setFirstBattleReport(report: FirstBattleReport) { const reportClone = cloneReport(report); const battleId = reportClone.battleId; const previousSettlement = state.battleHistory[battleId]; + const completedCampDialogues = + battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues]; + reportClone.completedCampDialogues = completedCampDialogues; state.firstBattleReport = reportClone; - state.step = reportClone.outcome === 'victory' ? 'first-camp' : 'first-battle'; + const victoryStep: CampaignStep = battleId === 'second-battle-yellow-turban-pursuit' ? 'second-camp' : 'first-camp'; + const retryStep: CampaignStep = battleId === 'second-battle-yellow-turban-pursuit' ? 'second-battle' : 'first-battle'; + state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep; state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold); state.roster = reportClone.units.filter((unit) => unit.faction === 'ally').map(cloneUnit); state.bonds = reportClone.bonds.map(cloneBondSnapshot); - state.completedCampDialogues = [...reportClone.completedCampDialogues]; + state.completedCampDialogues = completedCampDialogues; state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1); state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1); state.battleHistory[battleId] = createBattleSettlement(reportClone);