From 683357f6a7933afb45c52a26441ea64c497d93de Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 02:21:23 +0900 Subject: [PATCH] Add Cao Cao refuge road chapter --- docs/roadmap.md | 5 +- scripts/verify-flow.mjs | 79 +++- .../images/battle/eleventh-battle-map.svg | 97 +++++ src/game/data/battles.ts | 63 ++- src/game/data/campaignFlow.ts | 23 +- src/game/data/scenario.ts | 375 ++++++++++++++++++ src/game/scenes/BattleScene.ts | 14 +- src/game/scenes/BootScene.ts | 2 + src/game/scenes/CampScene.ts | 90 ++++- src/game/scenes/TitleScene.ts | 9 +- src/game/state/campaignState.ts | 7 +- 11 files changed, 748 insertions(+), 16 deletions(-) create mode 100644 src/assets/images/battle/eleventh-battle-map.svg diff --git a/docs/roadmap.md b/docs/roadmap.md index ca8f05d..5881af2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,12 +30,13 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - 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 - Ninth battle Xuzhou gate night raid, opening Lu Bu's refuge and the internal instability that leads toward the loss of Xu Province - Tenth battle Xuzhou breakout, covering Zhang Fei's mistake, the loss of Xu Province to Lu Bu, and the retreat toward Cao Cao's shadow +- Eleventh battle Xudu refuge road, opening Liu Bei's uneasy service under Cao Cao through a Yuan Shu remnant test battle - Tactical sortie preparation panel with class role, named equipment, core stats, bond partner, and next-map terrain suitability for each deployable officer -- Flow verification script from title through the tenth battle victory, recruit sortie selection, and camp save state +- Flow verification script from title through the eleventh battle victory, recruit sortie selection, and camp save state ## Next Steps -1. Continue into Liu Bei's temporary service under Cao Cao and the tension of surviving in Xu/Hu Du politics +1. Continue into Liu Bei's tense service under Cao Cao, then build toward the break from Cao Cao 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 360b823..ab45820 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1102,6 +1102,83 @@ try { throw new Error(`Expected tenth camp to expose Xuzhou loss dialogue set and preserve full roster: ${JSON.stringify(tenthCampState)}`); } + await page.mouse.click(1120, 38); + await page.waitForTimeout(160); + const tenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!tenthCampSortieState?.sortieVisible) { + throw new Error(`Expected tenth camp sortie prep overlay: ${JSON.stringify(tenthCampSortieState)}`); + } + assertSortieTacticalRoster(tenthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']); + await page.mouse.click(1068, 646); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('StoryScene'); + }); + await page.screenshot({ path: 'dist/verification-cao-cao-refuge-story.png', fullPage: true }); + + for (let i = 0; i < 16; i += 1) { + const enteredEleventhBattle = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'eleventh-battle-xudu-refuge-road'; + }); + if (enteredEleventhBattle) { + 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 === 'eleventh-battle-xudu-refuge-road' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-eleventh-battle.png', fullPage: true }); + + const eleventhBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const eleventhEnemies = eleventhBattleState.units.filter((unit) => unit.faction === 'enemy'); + const eleventhAllies = eleventhBattleState.units.filter((unit) => unit.faction === 'ally'); + const eleventhEnemyBehaviors = new Set(eleventhEnemies.map((unit) => unit.ai)); + if ( + eleventhBattleState.camera?.mapWidth !== 28 || + eleventhBattleState.camera?.mapHeight !== 22 || + eleventhBattleState.victoryConditionLabel !== '기령 격파' || + eleventhEnemies.length < 12 || + !eleventhEnemyBehaviors.has('aggressive') || + !eleventhEnemyBehaviors.has('guard') || + !eleventhEnemyBehaviors.has('hold') || + !eleventhAllies.some((unit) => unit.id === 'liu-bei') || + !eleventhAllies.some((unit) => unit.id === 'guan-yu') || + !eleventhAllies.some((unit) => unit.id === 'zhang-fei') || + !eleventhAllies.some((unit) => unit.id === 'jian-yong') || + !eleventhAllies.some((unit) => unit.id === 'mi-zhu') + ) { + throw new Error(`Expected eleventh battle to use Xudu road map, mixed AI, and selected Cao Cao refuge sortie: ${JSON.stringify(eleventhBattleState)}`); + } + + 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 eleventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + eleventhCampState?.campBattleId !== 'eleventh-battle-xudu-refuge-road' || + eleventhCampState.campTitle !== '허도 의탁 군영' || + eleventhCampState.availableDialogueIds?.length !== 3 || + !eleventhCampState.availableDialogueIds.every((id) => id.endsWith('cao-cao-refuge')) || + !eleventhCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') || + !eleventhCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || + !eleventhCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') + ) { + throw new Error(`Expected eleventh camp to expose Cao Cao refuge dialogue set and preserve full roster: ${JSON.stringify(eleventhCampState)}`); + } + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -1113,7 +1190,7 @@ try { return activeScenes.includes('CampScene'); }); - console.log(`Verified title-to-tenth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-eleventh-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/eleventh-battle-map.svg b/src/assets/images/battle/eleventh-battle-map.svg new file mode 100644 index 0000000..9b527a3 --- /dev/null +++ b/src/assets/images/battle/eleventh-battle-map.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 0bcf028..54fd956 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -4,6 +4,10 @@ import { eighthBattleMap, eighthBattleUnits, eighthBattleVictoryPages, + eleventhBattleBonds, + eleventhBattleMap, + eleventhBattleUnits, + eleventhBattleVictoryPages, firstBattleMap, firstBattleUnits, firstBattleVictoryPages, @@ -55,7 +59,8 @@ export type BattleScenarioId = | 'seventh-battle-xuzhou-rescue' | 'eighth-battle-xiaopei-supply-road' | 'ninth-battle-xuzhou-gate-night-raid' - | 'tenth-battle-xuzhou-breakout'; + | 'tenth-battle-xuzhou-breakout' + | 'eleventh-battle-xudu-refuge-road'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -624,6 +629,59 @@ export const tenthBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const eleventhBattleScenario: BattleScenarioDefinition = { + id: 'eleventh-battle-xudu-refuge-road', + title: '허도 입성로', + victoryConditionLabel: '기령 격파', + defeatConditionLabel: '유비 퇴각', + openingObjectiveLines: [ + '조조는 유비군을 받아들이는 대신 허도 입성로를 어지럽히는 원술 잔군을 치라는 명을 내립니다. 기령을 격파하면 길이 열립니다.', + '원술군 기병은 길을 따라 돌파하고, 약탈병은 숲과 마을을 끼고 보급을 노립니다. 조조군의 감시 속에서 군율을 증명해야 합니다.', + '마을을 확보하고 18턴 안에 승리하면 조조 의탁 이후에도 병력과 민심을 더 많이 보존합니다.' + ], + map: eleventhBattleMap, + units: eleventhBattleUnits, + bonds: eleventhBattleBonds, + mapTextureKey: 'battle-map-eleventh', + leaderUnitId: 'xudu-road-leader-ji-ling', + quickVictoryTurnLimit: 18, + baseVictoryGold: 1320, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '기령 격파', + rewardGold: 820, + unitId: 'xudu-road-leader-ji-ling' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 320, + unitId: 'liu-bei' + }, + { + id: 'village', + kind: 'secure-terrain', + label: '허도 길목 마을 확보', + rewardGold: 460, + terrain: 'village' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '18턴 이내 승리', + rewardGold: 360, + maxTurn: 18 + } + ], + defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], + itemRewards: ['콩 4', '상처약 3', '탁주 1', '조조군 군문 통행 +1'], + victoryPages: eleventhBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { @@ -636,7 +694,8 @@ export const battleScenarios: Record 'seventh-battle-xuzhou-rescue': seventhBattleScenario, 'eighth-battle-xiaopei-supply-road': eighthBattleScenario, 'ninth-battle-xuzhou-gate-night-raid': ninthBattleScenario, - 'tenth-battle-xuzhou-breakout': tenthBattleScenario + 'tenth-battle-xuzhou-breakout': tenthBattleScenario, + 'eleventh-battle-xudu-refuge-road': eleventhBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index f165cdf..2b87f0c 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -2,6 +2,7 @@ import type { CampaignStep } from '../state/campaignState'; import { defaultBattleScenario, eighthBattleScenario, + eleventhBattleScenario, fifthBattleScenario, fourthBattleScenario, ninthBattleScenario, @@ -15,6 +16,8 @@ import { import { eighthBattleIntroPages, eighthBattleVictoryPages, + eleventhBattleIntroPages, + eleventhBattleVictoryPages, fifthBattleIntroPages, fifthBattleVictoryPages, firstBattleVictoryPages, @@ -140,12 +143,22 @@ const sortieFlows: Record = { }, [tenthBattleScenario.id]: { afterBattleId: tenthBattleScenario.id, + eyebrow: '다음 전장', + title: eleventhBattleScenario.title, + description: '조조는 유비군을 받아들이되 허도 입성로의 원술 잔군을 치게 합니다. 의탁은 시작되지만, 동시에 유비군의 군율과 힘을 재는 시험입니다.', + rewardHint: `예상 보상: ${eleventhBattleScenario.title} 개방`, + nextBattleId: eleventhBattleScenario.id, + campaignStep: 'eleventh-battle', + pages: [...tenthBattleVictoryPages, ...eleventhBattleIntroPages] + }, + [eleventhBattleScenario.id]: { + afterBattleId: eleventhBattleScenario.id, eyebrow: '다음 장 준비', - title: '조조 의탁의 길', - description: '서주를 잃은 유비군은 살아남은 병력과 백성을 추슬러 허도로 향할 길을 찾습니다. 다음 흐름은 조조 의탁과 그 안의 긴장으로 이어집니다.', - rewardHint: '다음 장: 조조 의탁 준비 중', - pages: tenthBattleVictoryPages, - unavailableNotice: '조조 의탁 장은 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.' + title: '조조의 그늘 아래', + description: '허도 입성로를 열어 조조의 장막에 들어섰지만, 유비군은 보호와 감시를 동시에 받게 됩니다. 다음 흐름은 조조 밑에서 기회를 기다리는 장으로 이어집니다.', + rewardHint: '다음 장: 조조 휘하의 긴장 준비 중', + pages: eleventhBattleVictoryPages, + unavailableNotice: '조조 휘하 장은 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.' } }; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 3b216d3..ae999f8 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -704,6 +704,61 @@ export const tenthBattleVictoryPages: StoryPage[] = [ } ]; +export const eleventhBattleIntroPages: StoryPage[] = [ + { + id: 'eleventh-arrive-cao-cao-camp', + bgm: 'story-dark', + chapter: '조조의 장막', + background: 'story-liu-bei', + text: '서주를 잃은 유비군은 허도로 향했다. 조조는 웃으며 맞이했지만, 그 웃음 뒤에는 사람을 재는 날카로운 눈빛이 숨어 있었다.' + }, + { + id: 'eleventh-cao-cao-test', + bgm: 'militia-theme', + chapter: '의탁의 대가', + background: 'story-militia', + speaker: '간옹', + text: '현덕, 조조는 우리에게 머물 곳을 주겠지만 공짜로 주지는 않을 걸세. 허도 입성로에 남은 원술 잔군을 치라는 명은 환대이자 시험이지.' + }, + { + id: 'eleventh-brothers-under-watch', + bgm: 'story-dark', + chapter: '감시 속의 출진', + background: 'story-three-heroes', + speaker: '관우', + portrait: 'guanYu', + text: '형님, 조조의 깃발 아래 싸우더라도 우리의 칼끝은 백성을 향하지 않습니다. 명을 따르되 뜻은 잃지 않겠습니다.' + }, + { + id: 'eleventh-xudu-road-sortie', + bgm: 'battle-prep', + chapter: '허도 입성로', + background: 'story-sortie', + speaker: '유비', + portrait: 'liuBei', + text: '지금은 몸을 낮추어야 할 때다. 그러나 낮춘 몸 안에서도 마음까지 굽힐 수는 없다. 길을 열고, 살아남아 다시 일어서자.' + } +]; + +export const eleventhBattleVictoryPages: StoryPage[] = [ + { + id: 'eleventh-victory-road-cleared', + bgm: 'militia-theme', + chapter: '열린 허도 길', + background: 'story-militia', + text: '원술 잔군은 물러났고 허도로 향하는 길이 열렸다. 조조군의 감시병들은 유비군의 전열과 군율을 조용히 기록했다.' + }, + { + id: 'eleventh-cao-cao-accepts', + bgm: 'story-dark', + chapter: '웃음 속의 칼', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '조조의 장막은 비바람을 피하게 해 주지만, 그 안의 공기는 오래 머물수록 무겁다. 나는 살아남기 위해 들어가되, 나올 날을 잊지 않겠다.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -909,6 +964,12 @@ export const tenthBattleMap: BattleMap = { terrain: createTenthBattleTerrain() }; +export const eleventhBattleMap: BattleMap = { + width: 28, + height: 22, + terrain: createEleventhBattleTerrain() +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -3507,6 +3568,272 @@ export const tenthBattleUnits: UnitData[] = [ } ]; +const eleventhBattleAllyPositions: Record = { + 'liu-bei': { x: 3, y: 15 }, + 'guan-yu': { x: 4, y: 14 }, + 'zhang-fei': { x: 4, y: 16 }, + 'jian-yong': { x: 5, y: 17 }, + 'mi-zhu': { x: 3, y: 18 } +}; + +export const eleventhBattleUnits: UnitData[] = [ + ...[...firstBattleUnits.filter((unit) => unit.faction === 'ally'), ...xuzhouRecruitUnits].map((unit) => + placeScenarioUnit(unit, eleventhBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y }) + ), + { + id: 'xudu-road-raider-a', + name: '원술군 잔병', + faction: 'enemy', + className: '원술군 보병', + classKey: 'yellowTurban', + level: 11, + exp: 68, + hp: 50, + maxHp: 50, + attack: 16, + move: 3, + stats: { might: 84, intelligence: 54, leadership: 70, agility: 64, luck: 56 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 42 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 20 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 11, + y: 12 + }, + { + id: 'xudu-road-raider-b', + name: '원술군 잔병', + faction: 'enemy', + className: '원술군 보병', + classKey: 'yellowTurban', + level: 11, + exp: 70, + hp: 51, + maxHp: 51, + attack: 16, + move: 3, + stats: { might: 85, intelligence: 55, leadership: 71, agility: 65, luck: 56 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 44 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 20 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 13, + y: 16 + }, + { + id: 'xudu-road-raider-c', + name: '원술군 잔병', + faction: 'enemy', + className: '원술군 보병', + classKey: 'yellowTurban', + level: 11, + exp: 70, + hp: 51, + maxHp: 51, + attack: 16, + move: 3, + stats: { might: 85, intelligence: 55, leadership: 71, agility: 65, luck: 56 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 44 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 20 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 17, + y: 11 + }, + { + id: 'xudu-road-bandit-a', + name: '군량 약탈병', + faction: 'enemy', + className: '원술군 약탈병', + classKey: 'bandit', + level: 11, + exp: 68, + hp: 45, + maxHp: 45, + attack: 17, + move: 4, + stats: { might: 86, intelligence: 53, leadership: 62, agility: 78, luck: 56 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 42 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 20 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 8, + y: 8 + }, + { + id: 'xudu-road-bandit-b', + name: '군량 약탈병', + faction: 'enemy', + className: '원술군 약탈병', + classKey: 'bandit', + level: 11, + exp: 70, + hp: 46, + maxHp: 46, + attack: 17, + move: 4, + stats: { might: 87, intelligence: 53, leadership: 63, agility: 79, luck: 56 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 44 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 20 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 9, + y: 18 + }, + { + id: 'xudu-road-archer-a', + name: '원술군 궁병', + faction: 'enemy', + className: '원술군 궁병', + classKey: 'archer', + level: 11, + exp: 68, + hp: 40, + maxHp: 40, + attack: 17, + move: 3, + stats: { might: 76, intelligence: 66, leadership: 64, agility: 76, luck: 57 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 42 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 18 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 16, + y: 6 + }, + { + id: 'xudu-road-archer-b', + name: '원술군 궁병', + faction: 'enemy', + className: '원술군 궁병', + classKey: 'archer', + level: 11, + exp: 70, + hp: 41, + maxHp: 41, + attack: 17, + move: 3, + stats: { might: 77, intelligence: 67, leadership: 65, agility: 77, luck: 57 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 44 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 18 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 20, + y: 15 + }, + { + id: 'xudu-road-cavalry-a', + name: '원술군 기병', + faction: 'enemy', + className: '원술군 기병', + classKey: 'cavalry', + level: 12, + exp: 72, + hp: 55, + maxHp: 55, + attack: 19, + move: 5, + stats: { might: 93, intelligence: 54, leadership: 72, agility: 94, luck: 59 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 48 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 22 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 21, + y: 8 + }, + { + id: 'xudu-road-cavalry-b', + name: '원술군 기병', + faction: 'enemy', + className: '원술군 기병', + classKey: 'cavalry', + level: 12, + exp: 74, + hp: 56, + maxHp: 56, + attack: 19, + move: 5, + stats: { might: 94, intelligence: 54, leadership: 72, agility: 95, luck: 59 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 50 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 22 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 22, + y: 17 + }, + { + id: 'xudu-road-guard-a', + name: '원술군 친위병', + faction: 'enemy', + className: '원술군 창병', + classKey: 'spearman', + level: 12, + exp: 72, + hp: 54, + maxHp: 54, + attack: 18, + move: 3, + stats: { might: 90, intelligence: 56, leadership: 76, agility: 68, luck: 58 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 48 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 22 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 23, + y: 11 + }, + { + id: 'xudu-road-guard-b', + name: '원술군 친위병', + faction: 'enemy', + className: '원술군 창병', + classKey: 'spearman', + level: 12, + exp: 74, + hp: 55, + maxHp: 55, + attack: 18, + move: 3, + stats: { might: 91, intelligence: 56, leadership: 76, agility: 69, luck: 58 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 50 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 22 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 24, + y: 13 + }, + { + id: 'xudu-road-leader-ji-ling', + name: '기령', + faction: 'enemy', + className: '원술군 대장', + classKey: 'rebelLeader', + level: 13, + exp: 80, + hp: 74, + maxHp: 74, + attack: 20, + move: 4, + stats: { might: 94, intelligence: 64, leadership: 88, agility: 74, luck: 60 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 2, exp: 52 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 24 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 24, + y: 10 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -3562,6 +3889,7 @@ export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattle export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario); export const ninthBattleBonds: BattleBond[] = eighthBattleBonds.map(cloneBattleBondForScenario); export const tenthBattleBonds: BattleBond[] = ninthBattleBonds.map(cloneBattleBondForScenario); +export const eleventhBattleBonds: BattleBond[] = tenthBattleBonds.map(cloneBattleBondForScenario); function createEighthBattleTerrain(): TerrainType[][] { return Array.from({ length: 22 }, (_, y) => @@ -3671,6 +3999,53 @@ function createTenthBattleTerrain(): TerrainType[][] { ); } +function createEleventhBattleTerrain(): TerrainType[][] { + return Array.from({ length: 22 }, (_, y) => + Array.from({ length: 28 }, (_, x): TerrainType => { + if ((x <= 4 && y >= 14 && y <= 20) || (x >= 5 && x <= 7 && y >= 16 && y <= 20)) { + return 'camp'; + } + if ((x >= 22 && x <= 26 && y >= 8 && y <= 13) || (x >= 19 && x <= 21 && y >= 5 && y <= 8)) { + return 'fort'; + } + if ((x >= 8 && x <= 10 && y >= 10 && y <= 12) || (x >= 14 && x <= 16 && y >= 15 && y <= 17)) { + return 'village'; + } + if ((x === 17 || x === 18) && y >= 0 && y <= 19) { + return 'river'; + } + if ( + (y === 15 && x >= 2 && x <= 25) || + (x === 8 && y >= 6 && y <= 20) || + (x === 22 && y >= 4 && y <= 17) || + (x + y === 28 && x >= 9 && x <= 21) + ) { + return 'road'; + } + if ( + (x <= 5 && y <= 5) || + (x >= 6 && x <= 13 && y >= 2 && y <= 7) || + (x >= 3 && x <= 8 && y >= 8 && y <= 13) || + (x >= 9 && x <= 14 && y >= 18 && y <= 21) + ) { + return 'forest'; + } + if ( + (x >= 21 && y <= 3) || + (x >= 24 && y >= 16) || + (x >= 11 && x <= 14 && y >= 11 && y <= 14) || + (x <= 4 && y >= 6 && y <= 10) + ) { + return 'hill'; + } + if ((x >= 25 && y >= 4 && y <= 7) || (x >= 19 && x <= 21 && y <= 2)) { + return 'cliff'; + } + return 'plain'; + }) + ); +} + function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData { return { ...cloneUnitForScenario(unit), diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index e5252d1..e1dce78 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -556,7 +556,19 @@ const enemyAiByUnitId: Record = { 'xuzhou-escape-archer-b': 'hold', 'xuzhou-escape-raider-a': 'aggressive', 'xuzhou-escape-raider-b': 'aggressive', - 'xuzhou-escape-leader-song-xian': 'guard' + 'xuzhou-escape-leader-song-xian': 'guard', + 'xudu-road-raider-a': 'guard', + 'xudu-road-raider-b': 'guard', + 'xudu-road-raider-c': 'guard', + 'xudu-road-bandit-a': 'aggressive', + 'xudu-road-bandit-b': 'aggressive', + 'xudu-road-archer-a': 'hold', + 'xudu-road-archer-b': 'hold', + 'xudu-road-cavalry-a': 'aggressive', + 'xudu-road-cavalry-b': 'aggressive', + 'xudu-road-guard-a': 'guard', + 'xudu-road-guard-b': 'guard', + 'xudu-road-leader-ji-ling': 'guard' }; const defaultEnemyAiByClass: Partial> = { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 235f4cc..47f9631 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import eighthBattleMapUrl from '../../assets/images/battle/eighth-battle-map.svg'; +import eleventhBattleMapUrl from '../../assets/images/battle/eleventh-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'; @@ -78,6 +79,7 @@ export class BootScene extends Phaser.Scene { this.load.image('battle-map-eighth', eighthBattleMapUrl); this.load.image('battle-map-ninth', ninthBattleMapUrl); this.load.image('battle-map-tenth', tenthBattleMapUrl); + this.load.image('battle-map-eleventh', eleventhBattleMapUrl); 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 39c3bea..ffd44ea 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -140,7 +140,8 @@ const campBattleIds = { seventh: 'seventh-battle-xuzhou-rescue', eighth: 'eighth-battle-xiaopei-supply-road', ninth: 'ninth-battle-xuzhou-gate-night-raid', - tenth: 'tenth-battle-xuzhou-breakout' + tenth: 'tenth-battle-xuzhou-breakout', + eleventh: 'eleventh-battle-xudu-refuge-road' } as const; const requiredSortieUnitIds = new Set(['liu-bei']); @@ -1010,6 +1011,87 @@ const campDialogues: CampDialogue[] = [ rewardExp: 8 } ] + }, + { + id: 'liu-guan-after-cao-cao-refuge', + title: '남의 깃발 아래', + availableAfterBattleIds: [campBattleIds.eleventh], + unitIds: ['liu-bei', 'guan-yu'], + bondId: 'liu-bei__guan-yu', + rewardExp: 22, + lines: [ + '관우: 조조의 깃발 아래 서 있으니 병사들의 눈빛이 조심스러워졌습니다.', + '유비: 운장, 깃발은 잠시 빌릴 수 있어도 뜻은 빌릴 수 없다. 우리가 지켜야 할 선을 잊지 맙시다.', + '관우: 형님의 뜻이 흔들리지 않는다면, 남의 진영에서도 우리의 칼은 길을 잃지 않을 것입니다.' + ], + choices: [ + { + id: 'keep-oath-visible', + label: '도원의 뜻을 분명히 한다', + response: '관우는 병사들에게 조조의 명을 따르되 유비군의 군율을 잃지 말라고 전했다.', + rewardExp: 10 + }, + { + id: 'watch-cao-cao-camp', + label: '조조 진영의 움직임을 살핀다', + response: '관우는 지나친 경계가 오히려 의심을 부른다며 조용한 감시를 맡았다.', + rewardExp: 9 + } + ] + }, + { + id: 'liu-jian-yong-after-cao-cao-refuge', + title: '웃음 뒤의 문장', + availableAfterBattleIds: [campBattleIds.eleventh], + unitIds: ['liu-bei', 'jian-yong'], + bondId: 'liu-bei__jian-yong', + rewardExp: 20, + lines: [ + '간옹: 조조는 웃으며 맞았지만, 그 웃음은 사람을 묶는 줄이기도 하네.', + '유비: 그 줄을 끊으면 목이 조이고, 그대로 두면 발이 묶일 것이오.', + '간옹: 그래서 말과 문서를 조심해야지. 지금은 충심을 보이되, 떠날 길의 이름을 잊지 않는 때라네.' + ], + choices: [ + { + id: 'write-carefully', + label: '문서를 조심스럽게 정리한다', + response: '간옹은 조조에게 보일 문서와 유비군 내부 문서를 따로 정리했다.', + rewardExp: 9 + }, + { + id: 'hear-rumors', + label: '허도 소문을 모은다', + response: '간옹은 말 많은 술자리와 시장길에서 조조 진영의 분위기를 살피기로 했다.', + rewardExp: 8 + } + ] + }, + { + id: 'liu-mi-after-cao-cao-refuge', + title: '빌린 창고', + availableAfterBattleIds: [campBattleIds.eleventh], + unitIds: ['liu-bei', 'mi-zhu'], + bondId: 'liu-bei__mi-zhu', + rewardExp: 18, + lines: [ + '미축: 조조군의 창고를 빌리더라도 우리 병사의 식량 장부는 따로 남겨 두겠습니다.', + '유비: 군량 하나에도 마음이 묶이는 법이오. 빌린 것은 빌린 것이라 분명히 해야 하오.', + '미축: 그렇다면 보급은 받되, 유비군이 스스로 버틸 양도 함께 모아 두겠습니다.' + ], + choices: [ + { + id: 'separate-ledgers', + label: '장부를 분리한다', + response: '미축은 조조군 보급과 유비군 예비 군량을 따로 기록해 훗날의 발목을 줄였다.', + rewardExp: 8 + }, + { + id: 'save-refugee-grain', + label: '피난민 군량을 남긴다', + response: '미축은 병사 몫을 줄이지 않으면서도 피난민에게 갈 곡식을 별도로 숨겨 두었다.', + rewardExp: 8 + } + ] } ]; @@ -1092,7 +1174,8 @@ export class CampScene extends Phaser.Scene { (battleId !== campBattleIds.seventh && battleId !== campBattleIds.eighth && battleId !== campBattleIds.ninth && - battleId !== campBattleIds.tenth) || + battleId !== campBattleIds.tenth && + battleId !== campBattleIds.eleventh) || !this.campaign ) { return; @@ -1114,6 +1197,9 @@ export class CampScene extends Phaser.Scene { private currentCampTitle() { const battleId = this.currentCampBattleId(); + if (battleId === campBattleIds.eleventh) { + return '허도 의탁 군영'; + } if (battleId === campBattleIds.tenth) { return '서주 상실 후 군영'; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 7a0f18d..26285a9 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -3,6 +3,7 @@ import { soundDirector } from '../audio/SoundDirector'; import { firstBattleVictoryPages } from '../data/scenario'; import { eighthBattleScenario, + eleventhBattleScenario, fifthBattleScenario, fourthBattleScenario, ninthBattleScenario, @@ -319,7 +320,8 @@ export class TitleScene extends Phaser.Scene { campaign.step === 'seventh-camp' || campaign.step === 'eighth-camp' || campaign.step === 'ninth-camp' || - campaign.step === 'tenth-camp' + campaign.step === 'tenth-camp' || + campaign.step === 'eleventh-camp' ) { this.scene.start('CampScene'); return; @@ -375,6 +377,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'eleventh-battle') { + this.scene.start('BattleScene', { battleId: eleventhBattleScenario.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 91f1180..330f445 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -59,7 +59,9 @@ export type CampaignStep = | 'ninth-battle' | 'ninth-camp' | 'tenth-battle' - | 'tenth-camp'; + | 'tenth-camp' + | 'eleventh-battle' + | 'eleventh-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; @@ -120,7 +122,8 @@ const campaignBattleSteps: Record