From 056fb3422654397aa7c8456e76d786214e5c6b1d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 01:42:44 +0900 Subject: [PATCH] Add Xuzhou gate night raid battle --- docs/roadmap.md | 5 +- scripts/verify-flow.mjs | 85 ++++- src/assets/images/battle/ninth-battle-map.svg | 88 +++++ src/game/data/battles.ts | 63 +++- src/game/data/campaignFlow.ts | 23 +- src/game/data/scenario.ts | 315 ++++++++++++++++++ src/game/scenes/BattleScene.ts | 12 +- src/game/scenes/BootScene.ts | 2 + src/game/scenes/CampScene.ts | 89 ++++- src/game/scenes/TitleScene.ts | 9 +- src/game/state/campaignState.ts | 7 +- 11 files changed, 682 insertions(+), 16 deletions(-) create mode 100644 src/assets/images/battle/ninth-battle-map.svg diff --git a/docs/roadmap.md b/docs/roadmap.md index 47745fb..c3d967a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -28,11 +28,12 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Sortie preparation selection in camp so Liu Bei is required and optional allied officers can be deployed or benched before battle - Xu Province camp recruitment for Jian Yong and Mi Zhu, including new strategist/quartermaster roles, bond dialogue, and selectable sortie participation - Eighth battle Xiaopei supply-road defense, where the expanded Xu Province roster can be selected into battle and Lu Bu's refuge arc is foreshadowed -- Flow verification script from title through the eighth battle victory, recruit sortie selection, and camp save state +- Ninth battle Xuzhou gate night raid, opening Lu Bu's refuge and the internal instability that leads toward the loss of Xu Province +- Flow verification script from title through the ninth battle victory, recruit sortie selection, and camp save state ## Next Steps -1. Continue into the Lu Bu refuge and Xu Province loss chapter +1. Continue into Zhang Fei's mistake and the full loss of Xu Province to Lu Bu 2. Add a chapter/progress view so long campaign arcs are easier to read between battles 3. Apply treasure equipment effects to damage, defense, recovery, and command rules 4. Add more recruitable officers as the campaign moves toward Cao Cao, Yuan Shao, Liu Biao, and Zhuge Liang diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 619ede5..9645db0 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -930,6 +930,89 @@ try { throw new Error(`Expected eighth camp to expose Xiaopei dialogue set and preserve recruits: ${JSON.stringify(eighthCampState)}`); } + await page.mouse.click(1120, 38); + await page.waitForTimeout(160); + const eighthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!eighthCampSortieState?.sortieVisible) { + throw new Error(`Expected eighth camp sortie prep overlay: ${JSON.stringify(eighthCampSortieState)}`); + } + if (!eighthCampSortieState.selectedSortieUnitIds?.includes('mi-zhu')) { + await page.mouse.click(256, 516); + await page.waitForTimeout(120); + } + const eighthCampMiZhuSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!eighthCampMiZhuSortieState.selectedSortieUnitIds?.includes('mi-zhu')) { + throw new Error(`Expected Mi Zhu to be selectable for ninth battle sortie: ${JSON.stringify(eighthCampMiZhuSortieState)}`); + } + 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-lubu-refuge-story.png', fullPage: true }); + + for (let i = 0; i < 10; i += 1) { + const enteredNinthBattle = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'ninth-battle-xuzhou-gate-night-raid'; + }); + if (enteredNinthBattle) { + 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 === 'ninth-battle-xuzhou-gate-night-raid' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-ninth-battle.png', fullPage: true }); + + const ninthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const ninthEnemies = ninthBattleState.units.filter((unit) => unit.faction === 'enemy'); + const ninthAllies = ninthBattleState.units.filter((unit) => unit.faction === 'ally'); + const ninthEnemyBehaviors = new Set(ninthEnemies.map((unit) => unit.ai)); + if ( + ninthBattleState.camera?.mapWidth !== 26 || + ninthBattleState.camera?.mapHeight !== 22 || + ninthBattleState.victoryConditionLabel !== '조표 격파' || + ninthEnemies.length < 10 || + !ninthEnemyBehaviors.has('aggressive') || + !ninthEnemyBehaviors.has('guard') || + !ninthEnemyBehaviors.has('hold') || + ninthAllies.some((unit) => unit.id === 'guan-yu') || + !ninthAllies.some((unit) => unit.id === 'liu-bei') || + !ninthAllies.some((unit) => unit.id === 'zhang-fei') || + !ninthAllies.some((unit) => unit.id === 'jian-yong') || + !ninthAllies.some((unit) => unit.id === 'mi-zhu') + ) { + throw new Error(`Expected ninth battle to use gate map, mixed AI, and selected recruit sortie: ${JSON.stringify(ninthBattleState)}`); + } + + 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 ninthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + ninthCampState?.campBattleId !== 'ninth-battle-xuzhou-gate-night-raid' || + ninthCampState.campTitle !== '서주 성문 군영' || + ninthCampState.availableDialogueIds?.length !== 3 || + !ninthCampState.availableDialogueIds.every((id) => id.endsWith('xuzhou-gate')) || + !ninthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || + !ninthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') + ) { + throw new Error(`Expected ninth camp to expose Xuzhou gate dialogue set and preserve recruits: ${JSON.stringify(ninthCampState)}`); + } + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -941,7 +1024,7 @@ try { return activeScenes.includes('CampScene'); }); - console.log(`Verified title-to-eighth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-ninth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { diff --git a/src/assets/images/battle/ninth-battle-map.svg b/src/assets/images/battle/ninth-battle-map.svg new file mode 100644 index 0000000..b405ddf --- /dev/null +++ b/src/assets/images/battle/ninth-battle-map.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 5f13a56..42d2758 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -7,6 +7,10 @@ import { firstBattleMap, firstBattleUnits, firstBattleVictoryPages, + ninthBattleBonds, + ninthBattleMap, + ninthBattleUnits, + ninthBattleVictoryPages, fifthBattleBonds, fifthBattleMap, fifthBattleUnits, @@ -45,7 +49,8 @@ export type BattleScenarioId = | 'fifth-battle-sishui-vanguard' | 'sixth-battle-jieqiao-relief' | 'seventh-battle-xuzhou-rescue' - | 'eighth-battle-xiaopei-supply-road'; + | 'eighth-battle-xiaopei-supply-road' + | 'ninth-battle-xuzhou-gate-night-raid'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -508,6 +513,59 @@ export const eighthBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const ninthBattleScenario: BattleScenarioDefinition = { + id: 'ninth-battle-xuzhou-gate-night-raid', + title: '서주 성문 야습', + victoryConditionLabel: '조표 격파', + defeatConditionLabel: '유비 퇴각', + openingObjectiveLines: [ + '조표가 성문 수비병을 움직이고 여포군 기병이 밤길을 타고 접근합니다. 조표를 격파하면 성문을 다시 장악할 수 있습니다.', + '성벽 궁병은 높은 곳을 지키고, 여포군 기병은 길을 따라 빠르게 파고듭니다. 마을과 성문 지형을 이용해 전열을 끊어야 합니다.', + '18턴 이내 성문을 안정시키면 서주 내부 불안을 잠시나마 눌러 둘 수 있습니다.' + ], + map: ninthBattleMap, + units: ninthBattleUnits, + bonds: ninthBattleBonds, + mapTextureKey: 'battle-map-ninth', + leaderUnitId: 'xuzhou-leader-cao-bao', + quickVictoryTurnLimit: 18, + baseVictoryGold: 1160, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '조표 격파', + rewardGold: 700, + unitId: 'xuzhou-leader-cao-bao' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 280, + unitId: 'liu-bei' + }, + { + id: 'fort', + kind: 'secure-terrain', + label: '서주 성문 확보', + rewardGold: 400, + terrain: 'fort' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '18턴 이내 승리', + rewardGold: 320, + maxTurn: 18 + } + ], + defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], + itemRewards: ['콩 3', '상처약 2', '탁주 1', '서주 성문 수비 +1'], + victoryPages: ninthBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { @@ -518,7 +576,8 @@ export const battleScenarios: Record 'fifth-battle-sishui-vanguard': fifthBattleScenario, 'sixth-battle-jieqiao-relief': sixthBattleScenario, 'seventh-battle-xuzhou-rescue': seventhBattleScenario, - 'eighth-battle-xiaopei-supply-road': eighthBattleScenario + 'eighth-battle-xiaopei-supply-road': eighthBattleScenario, + 'ninth-battle-xuzhou-gate-night-raid': ninthBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 9ec2056..0d1d422 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -4,6 +4,7 @@ import { eighthBattleScenario, fifthBattleScenario, fourthBattleScenario, + ninthBattleScenario, secondBattleScenario, seventhBattleScenario, sixthBattleScenario, @@ -18,6 +19,8 @@ import { firstBattleVictoryPages, fourthBattleIntroPages, fourthBattleVictoryPages, + ninthBattleIntroPages, + ninthBattleVictoryPages, secondBattleIntroPages, secondBattleVictoryPages, seventhBattleIntroPages, @@ -114,12 +117,22 @@ const sortieFlows: Record = { }, [eighthBattleScenario.id]: { afterBattleId: eighthBattleScenario.id, + eyebrow: '다음 전장', + title: ninthBattleScenario.title, + description: '여포를 의탁시킨 뒤 서주 성문 안팎의 불안이 터집니다. 조표의 야습을 막아 성문을 지켜내야 합니다.', + rewardHint: `예상 보상: ${ninthBattleScenario.title} 개방`, + nextBattleId: ninthBattleScenario.id, + campaignStep: 'ninth-battle', + pages: [...eighthBattleVictoryPages, ...ninthBattleIntroPages] + }, + [ninthBattleScenario.id]: { + afterBattleId: ninthBattleScenario.id, eyebrow: '다음 장 준비', - title: '여포의 의탁', - description: '소패 보급로를 지켜냈지만 여포가 몸을 의탁하겠다는 전갈이 도착합니다. 받아들이는 선택은 곧 서주 상실의 갈등으로 이어질 것입니다.', - rewardHint: '다음 장: 여포 의탁과 서주 내부 불안 준비 중', - pages: eighthBattleVictoryPages, - unavailableNotice: '여포 의탁 이후의 서주 상실 흐름은 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.' + title: '서주 상실의 전조', + description: '성문은 지켰지만 여포와 서주 내부의 불씨는 더 커졌습니다. 다음 흐름은 장비의 실책과 서주 상실로 이어집니다.', + rewardHint: '다음 장: 장비의 실책과 서주 상실 준비 중', + pages: ninthBattleVictoryPages, + unavailableNotice: '서주 상실 전투는 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.' } }; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 073a2de..f367653 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -594,6 +594,61 @@ export const eighthBattleVictoryPages: StoryPage[] = [ } ]; +export const ninthBattleIntroPages: StoryPage[] = [ + { + id: 'ninth-lu-bu-refuge', + bgm: 'militia-theme', + chapter: '여포의 의탁', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '여포가 몸을 의탁하겠다 청했다. 난세에 갈 곳 없는 이를 내치는 것은 쉽지 않으나, 서주 백성의 불안을 모른 척할 수도 없다.' + }, + { + id: 'ninth-zhang-fei-suspicion', + bgm: 'battle-prep', + chapter: '성문 안의 불씨', + background: 'story-three-heroes', + speaker: '장비', + portrait: 'zhangFei', + text: '여포 같은 자를 성 안에 들이면 반드시 화근이 됩니다. 도겸의 옛 무장들까지 술렁이니, 성문부터 단단히 지켜야 합니다.' + }, + { + id: 'ninth-cao-bao-mutiny', + bgm: 'battle-prep', + chapter: '조표의 야습', + background: 'story-sortie', + text: '밤이 깊자 조표가 일부 성문 수비병을 움직였다. 바깥의 여포군 기병은 불빛을 신호로 삼아 성문을 향해 몰려들었다.' + }, + { + id: 'ninth-xuzhou-gate', + bgm: 'battle-prep', + chapter: '서주 성문 방위', + background: 'story-militia', + speaker: '간옹', + text: '현덕, 이 싸움은 이겨도 상처가 남을 걸세. 하지만 성문이 오늘 밤 무너지면 내일의 선택도 사라진다네.' + } +]; + +export const ninthBattleVictoryPages: StoryPage[] = [ + { + id: 'ninth-victory-gate-held', + bgm: 'militia-theme', + chapter: '지켜낸 밤', + background: 'story-militia', + text: '조표의 야습은 물러났고 성문은 다시 닫혔다. 그러나 성 안의 의심과 두려움은 이미 선명한 금이 되어 남았다.' + }, + { + id: 'ninth-lu-bu-pretext', + bgm: 'battle-prep', + chapter: '더 깊어진 그림자', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '여포는 서주의 혼란을 핑계 삼아 더 깊이 발을 들이려 했다. 오늘 밤 성문은 지켰으나, 서주를 잃을 싸움은 이제 시작되고 있었다.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -787,6 +842,12 @@ export const eighthBattleMap: BattleMap = { terrain: createEighthBattleTerrain() }; +export const ninthBattleMap: BattleMap = { + width: 26, + height: 22, + terrain: createNinthBattleTerrain() +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -2916,6 +2977,230 @@ export const eighthBattleUnits: UnitData[] = [ } ]; +const ninthBattleAllyPositions: Record = { + 'liu-bei': { x: 3, y: 17 }, + 'guan-yu': { x: 4, y: 17 }, + 'zhang-fei': { x: 3, y: 18 }, + 'jian-yong': { x: 5, y: 18 }, + 'mi-zhu': { x: 4, y: 19 } +}; + +export const ninthBattleUnits: UnitData[] = [ + ...[...firstBattleUnits.filter((unit) => unit.faction === 'ally'), ...xuzhouRecruitUnits].map((unit) => + placeScenarioUnit(unit, ninthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y }) + ), + { + id: 'xuzhou-gate-traitor-a', + name: '성문 배반병', + faction: 'enemy', + className: '서주 배반병', + classKey: 'bandit', + level: 9, + exp: 56, + hp: 40, + maxHp: 40, + attack: 15, + move: 4, + stats: { might: 80, intelligence: 48, leadership: 58, agility: 74, luck: 52 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 30 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 14 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 11, + y: 12 + }, + { + id: 'xuzhou-gate-traitor-b', + name: '성문 배반병', + faction: 'enemy', + className: '서주 배반병', + classKey: 'bandit', + level: 9, + exp: 58, + hp: 41, + maxHp: 41, + attack: 15, + move: 4, + stats: { might: 81, intelligence: 49, leadership: 59, agility: 74, luck: 52 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 32 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 14 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 12, + y: 14 + }, + { + id: 'xuzhou-gate-infantry-a', + name: '성문 수비병', + faction: 'enemy', + className: '조표 휘하 보병', + classKey: 'yellowTurban', + level: 9, + exp: 56, + hp: 44, + maxHp: 44, + attack: 14, + move: 3, + stats: { might: 80, intelligence: 52, leadership: 68, agility: 62, luck: 54 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 30 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 14 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 17, + y: 10 + }, + { + id: 'xuzhou-gate-infantry-b', + name: '성문 수비병', + faction: 'enemy', + className: '조표 휘하 보병', + classKey: 'yellowTurban', + level: 9, + exp: 58, + hp: 45, + maxHp: 45, + attack: 14, + move: 3, + stats: { might: 81, intelligence: 52, leadership: 68, agility: 63, luck: 54 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 32 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 14 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 18, + y: 12 + }, + { + id: 'xuzhou-gate-archer-a', + name: '성벽 궁병', + faction: 'enemy', + className: '성벽 궁병', + classKey: 'archer', + level: 9, + exp: 58, + hp: 36, + maxHp: 36, + attack: 15, + move: 3, + stats: { might: 72, intelligence: 64, leadership: 62, agility: 73, luck: 55 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 32 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 14 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 20, + y: 7 + }, + { + id: 'xuzhou-gate-archer-b', + name: '성벽 궁병', + faction: 'enemy', + className: '성벽 궁병', + classKey: 'archer', + level: 9, + exp: 60, + hp: 37, + maxHp: 37, + attack: 15, + move: 3, + stats: { might: 73, intelligence: 64, leadership: 63, agility: 74, luck: 55 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 34 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 14 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 22, + y: 13 + }, + { + id: 'lubu-cavalry-a', + name: '여포군 기병', + faction: 'enemy', + className: '여포군 기병', + classKey: 'cavalry', + level: 10, + exp: 62, + hp: 49, + maxHp: 49, + attack: 17, + move: 5, + stats: { might: 88, intelligence: 50, leadership: 68, agility: 90, luck: 57 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 36 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 18 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 23, + y: 5 + }, + { + id: 'lubu-cavalry-b', + name: '여포군 기병', + faction: 'enemy', + className: '여포군 기병', + classKey: 'cavalry', + level: 10, + exp: 64, + hp: 50, + maxHp: 50, + attack: 17, + move: 5, + stats: { might: 89, intelligence: 50, leadership: 68, agility: 91, luck: 57 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 38 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 18 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 24, + y: 16 + }, + { + id: 'lubu-cavalry-c', + name: '여포군 기병', + faction: 'enemy', + className: '여포군 기병', + classKey: 'cavalry', + level: 10, + exp: 66, + hp: 50, + maxHp: 50, + attack: 18, + move: 5, + stats: { might: 90, intelligence: 52, leadership: 70, agility: 91, luck: 58 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 40 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 18 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 21, + y: 10 + }, + { + id: 'xuzhou-leader-cao-bao', + name: '조표', + faction: 'enemy', + className: '서주 성문장', + classKey: 'rebelLeader', + level: 10, + exp: 68, + hp: 64, + maxHp: 64, + attack: 17, + move: 4, + stats: { might: 84, intelligence: 60, leadership: 82, agility: 66, luck: 56 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 2, exp: 38 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 20 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 20, + y: 10 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -2969,6 +3254,7 @@ export const fifthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBo export const sixthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario); +export const ninthBattleBonds: BattleBond[] = eighthBattleBonds.map(cloneBattleBondForScenario); function createEighthBattleTerrain(): TerrainType[][] { return Array.from({ length: 22 }, (_, y) => @@ -3002,6 +3288,35 @@ function createEighthBattleTerrain(): TerrainType[][] { ); } +function createNinthBattleTerrain(): TerrainType[][] { + return Array.from({ length: 22 }, (_, y) => + Array.from({ length: 26 }, (_, x): TerrainType => { + if ((x >= 1 && x <= 4 && y >= 16 && y <= 20) || (x >= 5 && x <= 7 && y >= 18 && y <= 20)) { + return 'camp'; + } + if ((x >= 19 && x <= 24 && y >= 3 && y <= 8) || (x >= 18 && x <= 24 && y >= 12 && y <= 16)) { + return 'fort'; + } + if ((x >= 20 && x <= 22 && y >= 9 && y <= 11) || (x >= 11 && x <= 12 && y >= 13 && y <= 15)) { + return 'village'; + } + if ((x === 14 || x === 15) && y >= 0 && y <= 20) { + return 'river'; + } + if ((y === 10 && x >= 2 && x <= 24) || (x === 8 && y >= 5 && y <= 20) || (x === 18 && y >= 2 && y <= 18) || (x + y === 25 && x >= 7 && x <= 17)) { + return 'road'; + } + if ((x <= 5 && y <= 5) || (x >= 2 && x <= 7 && y >= 7 && y <= 12) || (x >= 8 && x <= 13 && y >= 1 && y <= 5) || (x >= 4 && x <= 10 && y >= 14 && y <= 17)) { + return 'forest'; + } + if ((x >= 21 && y <= 2) || (x >= 22 && y >= 17) || (x >= 10 && x <= 13 && y >= 16) || (x <= 3 && y >= 6 && y <= 9)) { + return 'hill'; + } + return 'plain'; + }) + ); +} + function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData { return { ...cloneUnitForScenario(unit), diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 0b3ce39..8a17c8c 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -535,7 +535,17 @@ const enemyAiByUnitId: Record = { 'xiaopei-guard-b': 'aggressive', 'xiaopei-cavalry-a': 'aggressive', 'xiaopei-cavalry-b': 'aggressive', - 'xiaopei-leader-gao-shun': 'guard' + 'xiaopei-leader-gao-shun': 'guard', + 'xuzhou-gate-traitor-a': 'aggressive', + 'xuzhou-gate-traitor-b': 'aggressive', + 'xuzhou-gate-infantry-a': 'guard', + 'xuzhou-gate-infantry-b': 'guard', + 'xuzhou-gate-archer-a': 'hold', + 'xuzhou-gate-archer-b': 'hold', + 'lubu-cavalry-a': 'aggressive', + 'lubu-cavalry-b': 'aggressive', + 'lubu-cavalry-c': 'aggressive', + 'xuzhou-leader-cao-bao': 'guard' }; const defaultEnemyAiByClass: Partial> = { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 805f063..5433ba4 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -3,6 +3,7 @@ import eighthBattleMapUrl from '../../assets/images/battle/eighth-battle-map.svg 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 ninthBattleMapUrl from '../../assets/images/battle/ninth-battle-map.svg'; import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg'; import seventhBattleMapUrl from '../../assets/images/battle/seventh-battle-map.svg'; import sixthBattleMapUrl from '../../assets/images/battle/sixth-battle-map.svg'; @@ -74,6 +75,7 @@ export class BootScene extends Phaser.Scene { this.load.image('battle-map-sixth', sixthBattleMapUrl); this.load.image('battle-map-seventh', seventhBattleMapUrl); this.load.image('battle-map-eighth', eighthBattleMapUrl); + this.load.image('battle-map-ninth', ninthBattleMapUrl); 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 172097b..40738e1 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -131,7 +131,8 @@ const campBattleIds = { fifth: 'fifth-battle-sishui-vanguard', sixth: 'sixth-battle-jieqiao-relief', seventh: 'seventh-battle-xuzhou-rescue', - eighth: 'eighth-battle-xiaopei-supply-road' + eighth: 'eighth-battle-xiaopei-supply-road', + ninth: 'ninth-battle-xuzhou-gate-night-raid' } as const; const requiredSortieUnitIds = new Set(['liu-bei']); @@ -839,6 +840,87 @@ const campDialogues: CampDialogue[] = [ rewardExp: 9 } ] + }, + { + id: 'liu-zhang-after-xuzhou-gate', + title: '성문 뒤의 후회', + availableAfterBattleIds: [campBattleIds.ninth], + unitIds: ['liu-bei', 'zhang-fei'], + bondId: 'liu-bei__zhang-fei', + rewardExp: 22, + lines: [ + '장비: 형님, 성문은 지켰지만 조표 같은 자들이 움직인 것 자체가 제 불찰입니다.', + '유비: 익덕, 성급함은 네 강점이자 약점이다. 하지만 후회로 멈추면 다음 성문도 지키지 못한다.', + '장비: 다음에는 술도, 분노도, 먼저 앞세우지 않겠습니다.' + ], + choices: [ + { + id: 'trust-reform', + label: '다시 맡기되 군율을 세운다', + response: '장비는 꾸짖음보다 믿음을 받은 것에 더 무겁게 고개를 숙였다.', + rewardExp: 10 + }, + { + id: 'share-command', + label: '관우와 함께 성문을 맡긴다', + response: '장비는 운장 형님과 함께라면 이번 실수를 씻어 보이겠다고 답했다.', + rewardExp: 8 + } + ] + }, + { + id: 'liu-jian-yong-after-xuzhou-gate', + title: '의와 현실 사이', + availableAfterBattleIds: [campBattleIds.ninth], + unitIds: ['liu-bei', 'jian-yong'], + bondId: 'liu-bei__jian-yong', + rewardExp: 22, + lines: [ + '간옹: 현덕, 여포를 들인 일은 이제 말 한마디로 되돌릴 수 없네. 사람들은 이미 네 의와 그의 칼을 함께 보고 있어.', + '유비: 의로 시작한 일이 백성을 위태롭게 한다면, 나는 무엇을 지켜야 하오?', + '간옹: 그래서 더더욱 눈을 뜨고 있어야지. 의도 잠들면 남의 칼집이 된다네.' + ], + choices: [ + { + id: 'watch-lu-bu', + label: '여포의 움직임을 감시한다', + response: '간옹은 마침내 현실적인 지시가 나왔다며 사람을 붙이겠다고 했다.', + rewardExp: 10 + }, + { + id: 'protect-civilians-first', + label: '백성 피난로를 먼저 정한다', + response: '간옹은 서주를 잃더라도 사람을 잃지 않겠다는 말에 한숨 섞인 미소를 지었다.', + rewardExp: 8 + } + ] + }, + { + id: 'liu-mi-after-xuzhou-gate', + title: '성문과 창고', + availableAfterBattleIds: [campBattleIds.ninth], + unitIds: ['liu-bei', 'mi-zhu'], + bondId: 'liu-bei__mi-zhu', + rewardExp: 18, + lines: [ + '미축: 성문이 흔들리면 창고도 오래 버티지 못합니다. 군량을 지키는 길과 성문을 지키는 길은 다르지 않습니다.', + '유비: 그렇다면 창고의 문서와 수레를 성문 수비와 함께 움직입시다. 길이 끊겨도 백성에게 갈 몫은 남겨 두어야 하오.', + '미축: 그 뜻이면 충분합니다. 다음 혼란에 대비하겠습니다.' + ], + choices: [ + { + id: 'move-grain-carts', + label: '군량 수레를 재배치한다', + response: '미축은 군량 수레를 성문 안쪽 길목으로 옮겨 비상시에 대비했다.', + rewardExp: 8 + }, + { + id: 'guard-ledgers', + label: '창고 장부를 보호한다', + response: '관우는 장부를 지키는 일도 전열을 지키는 일이라며 병사를 배치했다.', + rewardExp: 7 + } + ] } ]; @@ -917,7 +999,7 @@ export class CampScene extends Phaser.Scene { private ensureCurrentCampRecruitment() { const battleId = this.currentCampBattleId(); - if ((battleId !== campBattleIds.seventh && battleId !== campBattleIds.eighth) || !this.campaign) { + if ((battleId !== campBattleIds.seventh && battleId !== campBattleIds.eighth && battleId !== campBattleIds.ninth) || !this.campaign) { return; } @@ -937,6 +1019,9 @@ export class CampScene extends Phaser.Scene { private currentCampTitle() { const battleId = this.currentCampBattleId(); + if (battleId === campBattleIds.ninth) { + return '서주 성문 군영'; + } if (battleId === campBattleIds.eighth) { return '소패 방위 군영'; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 5481720..d83e5f9 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -5,6 +5,7 @@ import { eighthBattleScenario, fifthBattleScenario, fourthBattleScenario, + ninthBattleScenario, secondBattleScenario, seventhBattleScenario, sixthBattleScenario, @@ -315,7 +316,8 @@ export class TitleScene extends Phaser.Scene { campaign.step === 'fifth-camp' || campaign.step === 'sixth-camp' || campaign.step === 'seventh-camp' || - campaign.step === 'eighth-camp' + campaign.step === 'eighth-camp' || + campaign.step === 'ninth-camp' ) { this.scene.start('CampScene'); return; @@ -361,6 +363,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'ninth-battle') { + this.scene.start('BattleScene', { battleId: ninthBattleScenario.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 2418238..1096f4d 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -55,7 +55,9 @@ export type CampaignStep = | 'seventh-battle' | 'seventh-camp' | 'eighth-battle' - | 'eighth-camp'; + | 'eighth-camp' + | 'ninth-battle' + | 'ninth-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; @@ -114,7 +116,8 @@ const campaignBattleSteps: Record