From 6eacee48fc0afd574dc6047c3e1e28a6eab10f59 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 02:09:38 +0900 Subject: [PATCH] Add Xuzhou breakout chapter --- docs/roadmap.md | 5 +- scripts/verify-flow.mjs | 87 ++++- src/assets/images/battle/tenth-battle-map.svg | 97 +++++ src/game/data/battles.ts | 63 +++- src/game/data/campaignFlow.ts | 23 +- src/game/data/scenario.ts | 354 ++++++++++++++++++ src/game/scenes/BattleScene.ts | 13 +- src/game/scenes/BootScene.ts | 2 + src/game/scenes/CampScene.ts | 95 ++++- src/game/scenes/TitleScene.ts | 9 +- src/game/state/campaignState.ts | 7 +- 11 files changed, 739 insertions(+), 16 deletions(-) create mode 100644 src/assets/images/battle/tenth-battle-map.svg diff --git a/docs/roadmap.md b/docs/roadmap.md index 5b9f529..ca8f05d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -29,12 +29,13 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - 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 - 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 - 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 ninth battle victory, recruit sortie selection, and camp save state +- Flow verification script from title through the tenth battle victory, recruit sortie selection, and camp save state ## Next Steps -1. Continue into Zhang Fei's mistake and the full loss of Xu Province to Lu Bu +1. Continue into Liu Bei's temporary service under Cao Cao and the tension of surviving in Xu/Hu Du politics 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 7f11550..360b823 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1017,6 +1017,91 @@ try { throw new Error(`Expected ninth camp to expose Xuzhou gate dialogue set and preserve recruits: ${JSON.stringify(ninthCampState)}`); } + await page.mouse.click(1120, 38); + await page.waitForTimeout(160); + const ninthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!ninthCampSortieState?.sortieVisible) { + throw new Error(`Expected ninth camp sortie prep overlay: ${JSON.stringify(ninthCampSortieState)}`); + } + if (!ninthCampSortieState.selectedSortieUnitIds?.includes('guan-yu')) { + await page.mouse.click(256, 420); + await page.waitForTimeout(120); + } + const ninthCampGuanYuSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!ninthCampGuanYuSortieState.selectedSortieUnitIds?.includes('guan-yu')) { + throw new Error(`Expected Guan Yu to be selectable again for Xuzhou breakout: ${JSON.stringify(ninthCampGuanYuSortieState)}`); + } + assertSortieTacticalRoster(ninthCampGuanYuSortieState, ['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-xuzhou-loss-story.png', fullPage: true }); + + for (let i = 0; i < 14; i += 1) { + const enteredTenthBattle = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'tenth-battle-xuzhou-breakout'; + }); + if (enteredTenthBattle) { + 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 === 'tenth-battle-xuzhou-breakout' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-tenth-battle.png', fullPage: true }); + + const tenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const tenthEnemies = tenthBattleState.units.filter((unit) => unit.faction === 'enemy'); + const tenthAllies = tenthBattleState.units.filter((unit) => unit.faction === 'ally'); + const tenthEnemyBehaviors = new Set(tenthEnemies.map((unit) => unit.ai)); + if ( + tenthBattleState.camera?.mapWidth !== 28 || + tenthBattleState.camera?.mapHeight !== 22 || + tenthBattleState.victoryConditionLabel !== '송헌 격파' || + tenthEnemies.length < 11 || + !tenthEnemyBehaviors.has('aggressive') || + !tenthEnemyBehaviors.has('guard') || + !tenthEnemyBehaviors.has('hold') || + !tenthAllies.some((unit) => unit.id === 'liu-bei') || + !tenthAllies.some((unit) => unit.id === 'guan-yu') || + !tenthAllies.some((unit) => unit.id === 'zhang-fei') || + !tenthAllies.some((unit) => unit.id === 'jian-yong') || + !tenthAllies.some((unit) => unit.id === 'mi-zhu') + ) { + throw new Error(`Expected tenth battle to use breakout map, mixed AI, and selected full Xu Province sortie: ${JSON.stringify(tenthBattleState)}`); + } + + 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 tenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + tenthCampState?.campBattleId !== 'tenth-battle-xuzhou-breakout' || + tenthCampState.campTitle !== '서주 상실 후 군영' || + tenthCampState.availableDialogueIds?.length !== 3 || + !tenthCampState.availableDialogueIds.every((id) => id.endsWith('xuzhou-loss')) || + !tenthCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') || + !tenthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || + !tenthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') + ) { + throw new Error(`Expected tenth camp to expose Xuzhou loss dialogue set and preserve full roster: ${JSON.stringify(tenthCampState)}`); + } + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -1028,7 +1113,7 @@ try { return activeScenes.includes('CampScene'); }); - console.log(`Verified title-to-ninth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-tenth-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/tenth-battle-map.svg b/src/assets/images/battle/tenth-battle-map.svg new file mode 100644 index 0000000..474173d --- /dev/null +++ b/src/assets/images/battle/tenth-battle-map.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 42d2758..0bcf028 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -11,6 +11,10 @@ import { ninthBattleMap, ninthBattleUnits, ninthBattleVictoryPages, + tenthBattleBonds, + tenthBattleMap, + tenthBattleUnits, + tenthBattleVictoryPages, fifthBattleBonds, fifthBattleMap, fifthBattleUnits, @@ -50,7 +54,8 @@ export type BattleScenarioId = | 'sixth-battle-jieqiao-relief' | 'seventh-battle-xuzhou-rescue' | 'eighth-battle-xiaopei-supply-road' - | 'ninth-battle-xuzhou-gate-night-raid'; + | 'ninth-battle-xuzhou-gate-night-raid' + | 'tenth-battle-xuzhou-breakout'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -566,6 +571,59 @@ export const ninthBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const tenthBattleScenario: BattleScenarioDefinition = { + id: 'tenth-battle-xuzhou-breakout', + title: '서주 탈출전', + victoryConditionLabel: '송헌 격파', + defeatConditionLabel: '유비 퇴각', + openingObjectiveLines: [ + '서주는 여포에게 넘어갔고, 유비군은 성 밖으로 빠져나갈 길을 열어야 합니다. 송헌을 격파하면 추격대의 지휘가 무너집니다.', + '여포군 기병은 길을 따라 빠르게 압박하고, 성내 배반병은 숲과 마을 근처에서 퇴로를 끊으려 합니다.', + '서쪽 진영과 마을을 확보한 채 20턴 안에 돌파하면 조조 의탁으로 이어질 병력과 보급을 더 많이 보존합니다.' + ], + map: tenthBattleMap, + units: tenthBattleUnits, + bonds: tenthBattleBonds, + mapTextureKey: 'battle-map-tenth', + leaderUnitId: 'xuzhou-escape-leader-song-xian', + quickVictoryTurnLimit: 20, + baseVictoryGold: 1240, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '송헌 격파', + rewardGold: 760, + unitId: 'xuzhou-escape-leader-song-xian' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 300, + unitId: 'liu-bei' + }, + { + id: 'village', + kind: 'secure-terrain', + label: '피난 마을 확보', + rewardGold: 420, + terrain: 'village' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '20턴 이내 돌파', + rewardGold: 340, + maxTurn: 20 + } + ], + defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], + itemRewards: ['콩 4', '상처약 2', '탁주 1', '서주 피난민 보호 +1'], + victoryPages: tenthBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { @@ -577,7 +635,8 @@ export const battleScenarios: Record 'sixth-battle-jieqiao-relief': sixthBattleScenario, 'seventh-battle-xuzhou-rescue': seventhBattleScenario, 'eighth-battle-xiaopei-supply-road': eighthBattleScenario, - 'ninth-battle-xuzhou-gate-night-raid': ninthBattleScenario + 'ninth-battle-xuzhou-gate-night-raid': ninthBattleScenario, + 'tenth-battle-xuzhou-breakout': tenthBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 0d1d422..f165cdf 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -8,6 +8,7 @@ import { secondBattleScenario, seventhBattleScenario, sixthBattleScenario, + tenthBattleScenario, thirdBattleScenario, type BattleScenarioId } from './battles'; @@ -27,6 +28,8 @@ import { seventhBattleVictoryPages, sixthBattleIntroPages, sixthBattleVictoryPages, + tenthBattleIntroPages, + tenthBattleVictoryPages, thirdBattleIntroPages, thirdBattleVictoryPages, type StoryPage @@ -127,12 +130,22 @@ const sortieFlows: Record = { }, [ninthBattleScenario.id]: { afterBattleId: ninthBattleScenario.id, + eyebrow: '다음 전장', + title: tenthBattleScenario.title, + description: '장비의 성급함과 여포의 야심이 맞물려 서주는 무너집니다. 성을 되찾기보다 포위망을 뚫고 다음 길을 찾는 전투가 됩니다.', + rewardHint: `예상 보상: ${tenthBattleScenario.title} 개방`, + nextBattleId: tenthBattleScenario.id, + campaignStep: 'tenth-battle', + pages: [...ninthBattleVictoryPages, ...tenthBattleIntroPages] + }, + [tenthBattleScenario.id]: { + afterBattleId: tenthBattleScenario.id, eyebrow: '다음 장 준비', - title: '서주 상실의 전조', - description: '성문은 지켰지만 여포와 서주 내부의 불씨는 더 커졌습니다. 다음 흐름은 장비의 실책과 서주 상실로 이어집니다.', - rewardHint: '다음 장: 장비의 실책과 서주 상실 준비 중', - pages: ninthBattleVictoryPages, - unavailableNotice: '서주 상실 전투는 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.' + title: '조조 의탁의 길', + description: '서주를 잃은 유비군은 살아남은 병력과 백성을 추슬러 허도로 향할 길을 찾습니다. 다음 흐름은 조조 의탁과 그 안의 긴장으로 이어집니다.', + rewardHint: '다음 장: 조조 의탁 준비 중', + pages: tenthBattleVictoryPages, + unavailableNotice: '조조 의탁 장은 다음 작업에서 이어집니다. 군영에서 성장과 출전 구성을 정비하십시오.' } }; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index f367653..3b216d3 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -649,6 +649,61 @@ export const ninthBattleVictoryPages: StoryPage[] = [ } ]; +export const tenthBattleIntroPages: StoryPage[] = [ + { + id: 'tenth-zhang-fei-fault', + bgm: 'story-dark', + chapter: '술잔이 부른 균열', + background: 'story-liu-bei', + speaker: '장비', + portrait: 'zhangFei', + text: '조표의 잔당을 다그치던 장비는 분노를 이기지 못했다. 말 한마디, 술 한잔, 그리고 성 안의 오래된 원한이 한밤중에 다시 타올랐다.' + }, + { + id: 'tenth-lu-bu-takes-xuzhou', + bgm: 'story-dark', + chapter: '빼앗긴 서주', + background: 'story-militia', + text: '여포는 성문이 흔들린 틈을 놓치지 않았다. 서주 곳곳의 수비대가 갈라졌고, 유비가 돌아왔을 때 성루의 깃발은 이미 바뀌어 있었다.' + }, + { + id: 'tenth-brothers-regroup', + bgm: 'militia-theme', + chapter: '무너진 성 앞에서', + background: 'story-three-heroes', + speaker: '관우', + portrait: 'guanYu', + text: '형님, 지금 성을 되찾으려 들면 백성과 군졸을 모두 잃습니다. 익덕을 꾸짖는 일은 살아나간 뒤에 해도 늦지 않습니다.' + }, + { + id: 'tenth-xuzhou-escape', + bgm: 'battle-prep', + chapter: '서주 탈출', + background: 'story-sortie', + speaker: '유비', + portrait: 'liuBei', + text: '서주는 오늘 내 손에서 떠났다. 그러나 백성과 형제와 뜻까지 잃을 수는 없다. 포위망을 뚫고 다음 길을 찾자.' + } +]; + +export const tenthBattleVictoryPages: StoryPage[] = [ + { + id: 'tenth-victory-breakout', + bgm: 'militia-theme', + chapter: '잃고도 남은 것', + background: 'story-militia', + text: '유비군은 여포군 선봉을 뚫고 서주 외곽으로 물러났다. 성은 빼앗겼으나, 세 형제와 새로 모인 장수들의 전열은 아직 흩어지지 않았다.' + }, + { + id: 'tenth-cao-cao-shadow', + bgm: 'story-dark', + chapter: '허도로 향하는 그림자', + background: 'story-liu-bei', + speaker: '간옹', + text: '현덕, 이제 서주 안에서 버티는 길은 막혔네. 조조의 휘하로 들어가는 일은 마음에 걸리지만, 살아남아 다시 뜻을 세우려면 그 길을 지나야 할 걸세.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -848,6 +903,12 @@ export const ninthBattleMap: BattleMap = { terrain: createNinthBattleTerrain() }; +export const tenthBattleMap: BattleMap = { + width: 28, + height: 22, + terrain: createTenthBattleTerrain() +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -3201,6 +3262,251 @@ export const ninthBattleUnits: UnitData[] = [ } ]; +const tenthBattleAllyPositions: Record = { + 'liu-bei': { x: 3, y: 14 }, + 'guan-yu': { x: 4, y: 15 }, + 'zhang-fei': { x: 3, y: 16 }, + 'jian-yong': { x: 5, y: 17 }, + 'mi-zhu': { x: 4, y: 18 } +}; + +export const tenthBattleUnits: UnitData[] = [ + ...[...firstBattleUnits.filter((unit) => unit.faction === 'ally'), ...xuzhouRecruitUnits].map((unit) => + placeScenarioUnit(unit, tenthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y }) + ), + { + id: 'xuzhou-escape-cavalry-a', + name: '여포군 추격기병', + faction: 'enemy', + className: '여포군 기병', + classKey: 'cavalry', + level: 11, + exp: 64, + hp: 52, + maxHp: 52, + attack: 18, + move: 5, + stats: { might: 91, intelligence: 52, leadership: 70, agility: 92, luck: 58 }, + 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: 21, + y: 5 + }, + { + id: 'xuzhou-escape-cavalry-b', + name: '여포군 추격기병', + faction: 'enemy', + className: '여포군 기병', + classKey: 'cavalry', + level: 11, + exp: 66, + hp: 53, + maxHp: 53, + attack: 18, + move: 5, + stats: { might: 92, intelligence: 52, leadership: 70, agility: 93, luck: 58 }, + 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: 23, + y: 9 + }, + { + id: 'xuzhou-escape-cavalry-c', + name: '여포군 추격기병', + faction: 'enemy', + className: '여포군 기병', + classKey: 'cavalry', + level: 11, + exp: 66, + hp: 53, + maxHp: 53, + attack: 18, + move: 5, + stats: { might: 92, intelligence: 53, leadership: 71, agility: 93, luck: 59 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 46 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 20 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 22, + y: 16 + }, + { + id: 'xuzhou-escape-guard-a', + name: '서주 점거병', + faction: 'enemy', + className: '여포군 보병', + classKey: 'yellowTurban', + level: 10, + exp: 62, + hp: 47, + maxHp: 47, + attack: 15, + move: 3, + stats: { might: 82, intelligence: 54, leadership: 69, agility: 63, luck: 55 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 36 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 18 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 12, + y: 9 + }, + { + id: 'xuzhou-escape-guard-b', + name: '서주 점거병', + faction: 'enemy', + className: '여포군 보병', + classKey: 'yellowTurban', + level: 10, + exp: 62, + hp: 48, + maxHp: 48, + attack: 15, + move: 3, + stats: { might: 83, intelligence: 54, leadership: 70, agility: 64, luck: 55 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 38 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 18 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 13, + y: 13 + }, + { + id: 'xuzhou-escape-guard-c', + name: '서주 점거병', + faction: 'enemy', + className: '여포군 보병', + classKey: 'yellowTurban', + level: 10, + exp: 64, + hp: 48, + maxHp: 48, + attack: 15, + move: 3, + stats: { might: 84, intelligence: 55, leadership: 70, agility: 64, luck: 56 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 40 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 18 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 15, + y: 16 + }, + { + id: 'xuzhou-escape-archer-a', + name: '여포군 궁병', + faction: 'enemy', + className: '여포군 궁병', + classKey: 'archer', + level: 10, + exp: 62, + hp: 38, + maxHp: 38, + attack: 16, + move: 3, + stats: { might: 74, intelligence: 65, leadership: 63, agility: 74, luck: 56 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 38 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 16 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 17, + y: 7 + }, + { + id: 'xuzhou-escape-archer-b', + name: '여포군 궁병', + faction: 'enemy', + className: '여포군 궁병', + classKey: 'archer', + level: 10, + exp: 64, + hp: 39, + maxHp: 39, + attack: 16, + move: 3, + stats: { might: 75, intelligence: 66, leadership: 64, agility: 75, luck: 56 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 40 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 16 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 18, + y: 14 + }, + { + id: 'xuzhou-escape-raider-a', + name: '성내 배반병', + faction: 'enemy', + className: '성내 배반병', + classKey: 'bandit', + level: 10, + exp: 62, + hp: 43, + maxHp: 43, + attack: 16, + move: 4, + stats: { might: 84, intelligence: 52, leadership: 61, agility: 76, luck: 55 }, + 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: 8, + y: 10 + }, + { + id: 'xuzhou-escape-raider-b', + name: '성내 배반병', + faction: 'enemy', + className: '성내 배반병', + classKey: 'bandit', + level: 10, + exp: 64, + hp: 44, + maxHp: 44, + attack: 16, + move: 4, + stats: { might: 85, intelligence: 52, leadership: 62, agility: 77, luck: 55 }, + 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: 10, + y: 17 + }, + { + id: 'xuzhou-escape-leader-song-xian', + name: '송헌', + faction: 'enemy', + className: '여포군 선봉장', + classKey: 'rebelLeader', + level: 11, + exp: 70, + hp: 68, + maxHp: 68, + attack: 18, + move: 4, + stats: { might: 88, intelligence: 60, leadership: 84, agility: 72, luck: 58 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 2, exp: 42 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 22 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 20, + y: 11 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -3255,6 +3561,7 @@ export const sixthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBo export const seventhBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const eighthBattleBonds: BattleBond[] = [...firstBattleBonds, ...xuzhouRecruitBonds].map(cloneBattleBondForScenario); export const ninthBattleBonds: BattleBond[] = eighthBattleBonds.map(cloneBattleBondForScenario); +export const tenthBattleBonds: BattleBond[] = ninthBattleBonds.map(cloneBattleBondForScenario); function createEighthBattleTerrain(): TerrainType[][] { return Array.from({ length: 22 }, (_, y) => @@ -3317,6 +3624,53 @@ function createNinthBattleTerrain(): TerrainType[][] { ); } +function createTenthBattleTerrain(): 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 >= 17 && y <= 20)) { + return 'camp'; + } + if ((x >= 18 && x <= 25 && y >= 5 && y <= 12) || (x >= 20 && x <= 26 && y >= 13 && y <= 17)) { + return 'fort'; + } + if ((x >= 6 && x <= 8 && y >= 9 && y <= 11) || (x >= 13 && x <= 14 && y >= 16 && y <= 18)) { + return 'village'; + } + if ((x === 15 || x === 16) && y >= 2 && y <= 21) { + return 'river'; + } + if ( + (y === 15 && x >= 2 && x <= 26) || + (x === 6 && y >= 7 && y <= 20) || + (x === 20 && y >= 4 && y <= 18) || + (x + y === 28 && x >= 9 && x <= 20) + ) { + return 'road'; + } + if ( + (x <= 5 && y <= 6) || + (x >= 7 && x <= 12 && y >= 2 && y <= 7) || + (x >= 2 && x <= 9 && y >= 8 && y <= 13) || + (x >= 8 && x <= 13 && y >= 18 && y <= 21) + ) { + return 'forest'; + } + if ( + (x >= 21 && y <= 4) || + (x >= 24 && y >= 18) || + (x >= 10 && x <= 13 && y >= 11 && y <= 14) || + (x <= 3 && y >= 7 && y <= 10) + ) { + return 'hill'; + } + if ((x >= 25 && y >= 6 && y <= 10) || (x >= 18 && x <= 20 && 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 8a17c8c..e5252d1 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -545,7 +545,18 @@ const enemyAiByUnitId: Record = { 'lubu-cavalry-a': 'aggressive', 'lubu-cavalry-b': 'aggressive', 'lubu-cavalry-c': 'aggressive', - 'xuzhou-leader-cao-bao': 'guard' + 'xuzhou-leader-cao-bao': 'guard', + 'xuzhou-escape-cavalry-a': 'aggressive', + 'xuzhou-escape-cavalry-b': 'aggressive', + 'xuzhou-escape-cavalry-c': 'aggressive', + 'xuzhou-escape-guard-a': 'guard', + 'xuzhou-escape-guard-b': 'guard', + 'xuzhou-escape-guard-c': 'guard', + 'xuzhou-escape-archer-a': 'hold', + 'xuzhou-escape-archer-b': 'hold', + 'xuzhou-escape-raider-a': 'aggressive', + 'xuzhou-escape-raider-b': 'aggressive', + 'xuzhou-escape-leader-song-xian': 'guard' }; const defaultEnemyAiByClass: Partial> = { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 5433ba4..235f4cc 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -7,6 +7,7 @@ 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'; +import tenthBattleMapUrl from '../../assets/images/battle/tenth-battle-map.svg'; import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg'; import guanYuPortraitUrl from '../../assets/images/portraits/guan-yu.png'; import liuBeiPortraitUrl from '../../assets/images/portraits/liu-bei.png'; @@ -76,6 +77,7 @@ export class BootScene extends Phaser.Scene { this.load.image('battle-map-seventh', seventhBattleMapUrl); this.load.image('battle-map-eighth', eighthBattleMapUrl); this.load.image('battle-map-ninth', ninthBattleMapUrl); + this.load.image('battle-map-tenth', tenthBattleMapUrl); 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 923a2de..39c3bea 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -139,7 +139,8 @@ const campBattleIds = { sixth: 'sixth-battle-jieqiao-relief', seventh: 'seventh-battle-xuzhou-rescue', eighth: 'eighth-battle-xiaopei-supply-road', - ninth: 'ninth-battle-xuzhou-gate-night-raid' + ninth: 'ninth-battle-xuzhou-gate-night-raid', + tenth: 'tenth-battle-xuzhou-breakout' } as const; const requiredSortieUnitIds = new Set(['liu-bei']); @@ -928,6 +929,87 @@ const campDialogues: CampDialogue[] = [ rewardExp: 7 } ] + }, + { + id: 'liu-zhang-after-xuzhou-loss', + title: '잃은 성 앞에서', + availableAfterBattleIds: [campBattleIds.tenth], + unitIds: ['liu-bei', 'zhang-fei'], + bondId: 'liu-bei__zhang-fei', + rewardExp: 24, + lines: [ + '장비: 형님, 서주를 잃은 죄는 모두 제게 있습니다. 제 목을 베어 군율을 세우십시오.', + '유비: 익덕의 목을 베면 서주가 돌아오느냐. 살아서 다시 지키는 것이 네 벌이다.', + '장비: 다시는 술과 분노가 창끝보다 먼저 나가지 않게 하겠습니다.' + ], + choices: [ + { + id: 'forgive-and-command', + label: '용서하되 군율을 맡긴다', + response: '장비는 꾸지람보다 무거운 신뢰를 받고, 다음 전장에서는 먼저 자신을 다스리겠다고 맹세했다.', + rewardExp: 11 + }, + { + id: 'make-amends-through-action', + label: '공으로 갚게 한다', + response: '장비는 서주를 잃은 분노를 다음 싸움의 방패로 삼겠다고 답했다.', + rewardExp: 9 + } + ] + }, + { + id: 'liu-guan-after-xuzhou-loss', + title: '흩어지지 않는 전열', + availableAfterBattleIds: [campBattleIds.tenth], + unitIds: ['liu-bei', 'guan-yu'], + bondId: 'liu-bei__guan-yu', + rewardExp: 22, + lines: [ + '관우: 성은 잃었으나 형님의 깃발 아래 모인 마음은 아직 남았습니다.', + '유비: 운장, 내가 의를 앞세워 사람을 들였고 그 의가 백성을 흔들었다.', + '관우: 의는 버릴 것이 아니라 더 단단히 세울 것입니다. 이번 패배가 그 기둥이 되게 하십시오.' + ], + choices: [ + { + id: 'hold-the-banner', + label: '깃발을 다시 세운다', + response: '관우는 흩어진 병사들이 볼 수 있도록 유비군의 깃발을 다시 높이 세웠다.', + rewardExp: 10 + }, + { + id: 'protect-the-people', + label: '피난민을 먼저 모은다', + response: '관우는 패전의 행군에서도 백성을 버리지 않는 것이 형님의 길이라며 고개를 끄덕였다.', + rewardExp: 9 + } + ] + }, + { + id: 'liu-jian-yong-after-xuzhou-loss', + title: '허도로 가는 말', + availableAfterBattleIds: [campBattleIds.tenth], + unitIds: ['liu-bei', 'jian-yong'], + bondId: 'liu-bei__jian-yong', + rewardExp: 20, + lines: [ + '간옹: 현덕, 조조에게 몸을 의탁하는 일은 달갑지 않겠지. 하지만 지금은 그 그늘을 지나야 다음 길이 열린다네.', + '유비: 조조의 그늘이 너무 짙어 내 뜻까지 가리면 어찌하오?', + '간옹: 그때를 대비해 사람과 말을 잃지 말아야지. 뜻도 살아 있는 사람에게 붙는 법이네.' + ], + choices: [ + { + id: 'send-envoys', + label: '먼저 사자를 보낸다', + response: '간옹은 허도로 향할 말을 고르고, 유비군이 굴복이 아닌 임시 의탁임을 전할 문장을 다듬었다.', + rewardExp: 9 + }, + { + id: 'hide-strength', + label: '병력 규모를 감춘다', + response: '간옹은 조조가 보는 숫자와 실제로 보존할 숫자를 나누어 생각해야 한다고 조언했다.', + rewardExp: 8 + } + ] } ]; @@ -1006,7 +1088,13 @@ export class CampScene extends Phaser.Scene { private ensureCurrentCampRecruitment() { const battleId = this.currentCampBattleId(); - if ((battleId !== campBattleIds.seventh && battleId !== campBattleIds.eighth && battleId !== campBattleIds.ninth) || !this.campaign) { + if ( + (battleId !== campBattleIds.seventh && + battleId !== campBattleIds.eighth && + battleId !== campBattleIds.ninth && + battleId !== campBattleIds.tenth) || + !this.campaign + ) { return; } @@ -1026,6 +1114,9 @@ export class CampScene extends Phaser.Scene { private currentCampTitle() { const battleId = this.currentCampBattleId(); + if (battleId === campBattleIds.tenth) { + return '서주 상실 후 군영'; + } if (battleId === campBattleIds.ninth) { return '서주 성문 군영'; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index d83e5f9..7a0f18d 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -9,6 +9,7 @@ import { secondBattleScenario, seventhBattleScenario, sixthBattleScenario, + tenthBattleScenario, thirdBattleScenario } from '../data/battles'; import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState'; @@ -317,7 +318,8 @@ export class TitleScene extends Phaser.Scene { campaign.step === 'sixth-camp' || campaign.step === 'seventh-camp' || campaign.step === 'eighth-camp' || - campaign.step === 'ninth-camp' + campaign.step === 'ninth-camp' || + campaign.step === 'tenth-camp' ) { this.scene.start('CampScene'); return; @@ -368,6 +370,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'tenth-battle') { + this.scene.start('BattleScene', { battleId: tenthBattleScenario.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 1096f4d..91f1180 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -57,7 +57,9 @@ export type CampaignStep = | 'eighth-battle' | 'eighth-camp' | 'ninth-battle' - | 'ninth-camp'; + | 'ninth-camp' + | 'tenth-battle' + | 'tenth-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; @@ -117,7 +119,8 @@ const campaignBattleSteps: Record