diff --git a/AGENTS.md b/AGENTS.md index 1d5fcd8..edcfc29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,4 @@ - The game targets normal operation on PC/desktop browsers; do not prioritize mobile layout or mobile-specific work. - 이미지는 KOEI 삼국지 스타일을 활용하되, 실제 저작권 원본 이미지나 로고를 복제하지 않고 프로젝트 고유 자산으로 제작한다. - 캠페인 큰 줄기는 유비의 삼국지 서사를 따른다: 황건적 토벌 → 반동탁 흐름 → 공손찬 의탁 → 도겸에게 서주 인수 → 여포에게 서주 상실 → 조조 의탁 → 조조 이탈 → 원소 의탁 → 유표 의탁 → 제갈량 영입 → 적벽대전 → 형주·익주 확보 → 촉한 건국. +- 게임이 진행되며 무장이 추가될수록 출전 전에 함께할 무장을 선택하는 편성 재미가 드러나도록 구현한다. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4b8ac6b..259bb2d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -39,13 +39,14 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Liu Biao camp visit events with one-time local choices, persisted completion, bond/gold/item rewards, and Wolong clue preparation before Zhuge Liang's recruitment arc - Seventeenth battle Wolong visit road, gated by Wolong clue visits, opening the Longzhong approach and recruiting Zhuge Liang into the roster after victory - Post-Wolong camp dialogue and visit events, including Zhuge Liang bond choices and Red Cliffs preparation clues +- Eighteenth battle Bowang ambush as the first Red Cliffs preparation battle, with a new large battlefield, Xiahou Dun's vanguard, mixed enemy AI, Zhuge Liang sortie recommendation, and post-battle camp events - Camp progress timeline tab that summarizes Liu Bei's long campaign arc, completed battles, current chapter, latest battle, and next major chapter - Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer -- Flow verification script from title through the seventeenth battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, campaign timeline state, and camp save state +- Flow verification script from title through the eighteenth battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, Bowang camp state, campaign timeline state, and camp save state ## Next Steps -1. Start the Red Cliffs preparation chapter with Zhuge Liang's strategic role visible in sortie and camp choices +1. Continue the Red Cliffs preparation chapter into Changban and the Jiangdong envoy route 2. Add a richer officer collection and reserve growth view so benched recruitable officers still feel valuable 3. Apply treasure equipment effects to damage, defense, recovery, and command rules 4. Add more recruitable officers as the campaign moves toward Red Cliffs, Jing Province, Yi Province, and Shu Han diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 3183d5c..8b7fa21 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1735,15 +1735,139 @@ try { const progressTabState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( progressTabState?.activeTab !== 'progress' || - progressTabState.campaignProgress?.completedKnown !== progressTabState.campaignProgress?.totalKnown || + progressTabState.campaignProgress?.completedKnown !== 17 || + progressTabState.campaignProgress?.totalKnown !== 18 || progressTabState.campaignProgress?.activeChapter?.title !== '적벽대전' || progressTabState.campaignProgress?.latestBattleTitle !== '융중 방문로' || - progressTabState.campaignProgress?.nextBattleTitle !== '준비 중' + progressTabState.campaignProgress?.nextBattleTitle !== '박망파 매복전' ) { - throw new Error(`Expected progress tab to summarize completed known campaign and point toward Red Cliffs: ${JSON.stringify(progressTabState?.campaignProgress)}`); + throw new Error(`Expected progress tab to summarize Wolong completion and point toward Bowang ambush: ${JSON.stringify(progressTabState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-campaign-progress.png', fullPage: true }); + await page.mouse.click(1120, 38); + await page.waitForTimeout(160); + const seventeenthCampBowangSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + !seventeenthCampBowangSortieState?.sortieVisible || + !seventeenthCampBowangSortieState.sortiePlan?.objectiveLine?.includes('박망파 매복전') || + !seventeenthCampBowangSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || + seventeenthCampBowangSortieState.sortieRoster?.length < 8 || + seventeenthCampBowangSortieState.sortiePlan?.maxCount !== 6 + ) { + throw new Error(`Expected seventeenth camp sortie prep to target Bowang ambush with Zhuge Liang as a selectable recruit: ${JSON.stringify(seventeenthCampBowangSortieState)}`); + } + assertSortieTacticalRoster(seventeenthCampBowangSortieState, [ + 'liu-bei', + 'guan-yu', + 'zhang-fei', + 'jian-yong', + 'mi-zhu', + 'sun-qian', + 'zhao-yun', + 'zhuge-liang' + ]); + if (!seventeenthCampBowangSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.selected)) { + const selectedReserveCandidate = ['jian-yong', 'mi-zhu'].find((unitId) => + seventeenthCampBowangSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) + ); + if (selectedReserveCandidate) { + await clickSortieRosterUnit(page, selectedReserveCandidate); + } + await clickSortieRosterUnit(page, 'zhuge-liang'); + } + const bowangSortieWithZhugeState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + !bowangSortieWithZhugeState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.selected) || + bowangSortieWithZhugeState.sortiePlan?.selectedCount !== 6 || + bowangSortieWithZhugeState.sortiePlan?.recommendedSelectedCount < 4 + ) { + throw new Error(`Expected Zhuge Liang to be selectable and deployed for Bowang ambush: ${JSON.stringify(bowangSortieWithZhugeState)}`); + } + await page.screenshot({ path: 'dist/verification-bowang-sortie.png', fullPage: true }); + + 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-bowang-story.png', fullPage: true }); + + for (let i = 0; i < 26; i += 1) { + const enteredEighteenthBattle = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'eighteenth-battle-bowang-ambush'; + }); + if (enteredEighteenthBattle) { + 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 === 'eighteenth-battle-bowang-ambush' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-eighteenth-battle.png', fullPage: true }); + + const eighteenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const eighteenthEnemies = eighteenthBattleState.units.filter((unit) => unit.faction === 'enemy'); + const eighteenthAllies = eighteenthBattleState.units.filter((unit) => unit.faction === 'ally'); + const eighteenthEnemyBehaviors = new Set(eighteenthEnemies.map((unit) => unit.ai)); + if ( + eighteenthBattleState.camera?.mapWidth !== 36 || + eighteenthBattleState.camera?.mapHeight !== 26 || + eighteenthBattleState.victoryConditionLabel !== '하후돈 퇴각' || + eighteenthEnemies.length < 16 || + !eighteenthEnemyBehaviors.has('aggressive') || + !eighteenthEnemyBehaviors.has('guard') || + !eighteenthEnemyBehaviors.has('hold') || + !eighteenthEnemies.some((unit) => unit.id === 'bowang-leader-xiahou-dun') || + !eighteenthAllies.some((unit) => unit.id === 'zhuge-liang') + ) { + throw new Error(`Expected eighteenth battle to use Bowang map, Xiahou Dun objective, mixed AI, and deployed Zhuge Liang: ${JSON.stringify(eighteenthBattleState)}`); + } + + 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 eighteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + eighteenthCampState?.campBattleId !== 'eighteenth-battle-bowang-ambush' || + eighteenthCampState.campTitle !== '박망파 승전 후 군영' || + eighteenthCampState.availableDialogueIds?.length !== 3 || + !eighteenthCampState.availableDialogueIds.every((id) => id.endsWith('bowang-ambush')) || + eighteenthCampState.availableVisitIds?.length !== 2 || + !eighteenthCampState.report?.bonds?.some((bond) => bond.id === 'zhang-fei__zhuge-liang') || + !eighteenthCampState.report?.bonds?.some((bond) => bond.id === 'sun-qian__zhuge-liang') + ) { + throw new Error(`Expected eighteenth camp to expose Bowang dialogue/visit sets and Zhuge bond expansion: ${JSON.stringify(eighteenthCampState)}`); + } + await page.screenshot({ path: 'dist/verification-bowang-camp.png', fullPage: true }); + + await page.mouse.click(966, 38); + await page.waitForTimeout(180); + const postBowangProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + postBowangProgressState?.activeTab !== 'progress' || + postBowangProgressState.campaignProgress?.completedKnown !== postBowangProgressState.campaignProgress?.totalKnown || + postBowangProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || + postBowangProgressState.campaignProgress?.latestBattleTitle !== '박망파 매복전' || + postBowangProgressState.campaignProgress?.nextBattleTitle !== '준비 중' + ) { + throw new Error(`Expected post-Bowang progress tab to keep Red Cliffs active while next scenario is pending: ${JSON.stringify(postBowangProgressState?.campaignProgress)}`); + } + await page.screenshot({ path: 'dist/verification-post-bowang-progress.png', fullPage: true }); + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -1755,7 +1879,7 @@ try { return activeScenes.includes('CampScene'); }); - console.log(`Verified title-to-seventeenth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-eighteenth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { @@ -1871,7 +1995,7 @@ async function clickSortieRosterUnit(page, unitId) { throw new Error(`Cannot click sortie unit ${unitId}; unit is missing from roster: ${JSON.stringify(state.sortieRoster)}`); } - const rowGap = state.sortieRoster.length > 5 ? 38 : state.sortieRoster.length > 4 ? 43 : 48; + const rowGap = state.sortieRoster.length > 7 ? 30 : state.sortieRoster.length > 5 ? 38 : state.sortieRoster.length > 4 ? 43 : 48; await page.mouse.click(254, 314 + 50 + index * rowGap); await page.waitForTimeout(120); } diff --git a/src/assets/images/battle/eighteenth-battle-map.svg b/src/assets/images/battle/eighteenth-battle-map.svg new file mode 100644 index 0000000..de447bd --- /dev/null +++ b/src/assets/images/battle/eighteenth-battle-map.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 785f891..20914ec 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -12,6 +12,10 @@ import { eighthBattleMap, eighthBattleUnits, eighthBattleVictoryPages, + eighteenthBattleBonds, + eighteenthBattleMap, + eighteenthBattleUnits, + eighteenthBattleVictoryPages, eleventhBattleBonds, eleventhBattleMap, eleventhBattleUnits, @@ -90,7 +94,8 @@ export type BattleScenarioId = | 'fourteenth-battle-cao-break' | 'fifteenth-battle-yuan-refuge-road' | 'sixteenth-battle-liu-biao-refuge' - | 'seventeenth-battle-wolong-visit-road'; + | 'seventeenth-battle-wolong-visit-road' + | 'eighteenth-battle-bowang-ambush'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -1030,6 +1035,59 @@ export const seventeenthBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const eighteenthBattleScenario: BattleScenarioDefinition = { + id: 'eighteenth-battle-bowang-ambush', + title: '박망파 매복전', + victoryConditionLabel: '하후돈 퇴각', + defeatConditionLabel: '유비 퇴각', + openingObjectiveLines: [ + '조조군 선봉 하후돈이 신야로 향합니다. 제갈량은 박망파의 숲길과 좁은 물길을 이용해 적의 속도를 끊으려 합니다.', + '숲길 궁병과 기병이 동시에 압박하지만, 강줄기와 매복 지형을 이용하면 조조군 전열을 갈라낼 수 있습니다. 제갈량의 책략과 조운의 기동을 함께 활용하십시오.', + '하후돈을 퇴각시키고 박망파의 길목을 지키십시오. 27턴 안에 승리하면 강동과 손잡을 시간을 더 벌 수 있습니다.' + ], + map: eighteenthBattleMap, + units: eighteenthBattleUnits, + bonds: eighteenthBattleBonds, + mapTextureKey: 'battle-map-eighteenth', + leaderUnitId: 'bowang-leader-xiahou-dun', + quickVictoryTurnLimit: 27, + baseVictoryGold: 2080, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '하후돈 퇴각', + rewardGold: 1340, + unitId: 'bowang-leader-xiahou-dun' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 460, + unitId: 'liu-bei' + }, + { + id: 'ambush-village', + kind: 'secure-terrain', + label: '박망파 매복지 확보', + rewardGold: 760, + terrain: 'village' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '27턴 이내 승리', + rewardGold: 600, + maxTurn: 27 + } + ], + defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], + itemRewards: ['콩 7', '상처약 4', '탁주 2', '강동 풍문 +1'], + victoryPages: eighteenthBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { @@ -1049,7 +1107,8 @@ export const battleScenarios: Record 'fourteenth-battle-cao-break': fourteenthBattleScenario, 'fifteenth-battle-yuan-refuge-road': fifteenthBattleScenario, 'sixteenth-battle-liu-biao-refuge': sixteenthBattleScenario, - 'seventeenth-battle-wolong-visit-road': seventeenthBattleScenario + 'seventeenth-battle-wolong-visit-road': seventeenthBattleScenario, + 'eighteenth-battle-bowang-ambush': eighteenthBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 73bd2c5..7629828 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, + eighteenthBattleScenario, eleventhBattleScenario, fifthBattleScenario, fifteenthBattleScenario, @@ -22,6 +23,8 @@ import { import { eighthBattleIntroPages, eighthBattleVictoryPages, + eighteenthBattleIntroPages, + eighteenthBattleVictoryPages, eleventhBattleIntroPages, eleventhBattleVictoryPages, fifthBattleIntroPages, @@ -235,13 +238,24 @@ const sortieFlows: Record = { }, [seventeenthBattleScenario.id]: { afterBattleId: seventeenthBattleScenario.id, - eyebrow: '다음 장 준비', - title: '적벽으로 향하는 바람', + eyebrow: '다음 전장', + title: eighteenthBattleScenario.title, description: - '제갈량이 합류하며 유비군은 비로소 천하의 큰 판을 바라보게 됩니다. 다음 흐름은 형주의 불안과 강동의 바람이 맞물리는 적벽대전 준비로 이어집니다.', - rewardHint: '다음 장: 적벽대전 준비 중', - pages: seventeenthBattleVictoryPages, - unavailableNotice: '적벽대전 장은 다음 작업에서 이어집니다. 군영에서 제갈량과의 공명도를 다지고 출전 구성을 정비하십시오.' + '제갈량이 합류하며 유비군은 비로소 천하의 큰 판을 바라보게 됩니다. 조조군 선봉이 신야로 다가오자, 공명은 박망파의 숲길에서 첫 계책을 펼치려 합니다.', + rewardHint: `예상 보상: ${eighteenthBattleScenario.title} 개방 / 제갈량 책략 실전`, + nextBattleId: eighteenthBattleScenario.id, + campaignStep: 'eighteenth-battle', + pages: [...seventeenthBattleVictoryPages, ...eighteenthBattleIntroPages] + }, + [eighteenthBattleScenario.id]: { + afterBattleId: eighteenthBattleScenario.id, + eyebrow: '다음 장 준비', + title: '강동으로 부는 바람', + description: + '박망파에서 조조군 선봉을 늦춘 유비군은 더 큰 남하를 막기 위해 강동과 손잡을 길을 찾아야 합니다. 다음 흐름은 장판파와 강동 사절, 그리고 적벽대전으로 이어집니다.', + rewardHint: '다음 장: 장판파와 강동 사절 준비 중', + pages: eighteenthBattleVictoryPages, + unavailableNotice: '장판파와 강동 사절 장은 다음 작업에서 이어집니다. 군영에서 제갈량의 책략과 장수들의 출전 구성을 정비하십시오.' } }; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index c434174..5169475 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1133,6 +1133,70 @@ export const seventeenthBattleVictoryPages: StoryPage[] = [ } ]; +export const eighteenthBattleIntroPages: StoryPage[] = [ + { + id: 'eighteenth-cao-vanguard-moves', + bgm: 'story-dark', + chapter: '조조 남하의 그림자', + background: 'story-militia', + speaker: '손건', + text: '조조가 형주를 향해 병력을 움직이고 있습니다. 유표의 병세와 형주의 불안이 겹치자, 신야의 작은 군영에도 큰 물결이 밀려옵니다.' + }, + { + id: 'eighteenth-zhuge-first-plan', + bgm: 'battle-prep', + chapter: '박망파의 계책', + background: 'story-liu-bei', + speaker: '제갈량', + text: '하후돈의 선봉은 빠르나 숲과 좁은 길을 가볍게 여길 것입니다. 길을 내어 주는 척하다가 박망파의 숲길에서 불과 매복으로 그 기세를 꺾겠습니다.' + }, + { + id: 'eighteenth-generals-doubt', + bgm: 'militia-theme', + chapter: '새 군사의 첫 명', + background: 'story-three-heroes', + speaker: '관우', + portrait: 'guanYu', + text: '군사의 말이 이치에 맞다 하여도, 전장은 말과 다릅니다. 그러나 형님께서 맡기셨다면 우리 또한 그 계책을 전장에서 증명하겠습니다.' + }, + { + id: 'eighteenth-bowang-sortie', + bgm: 'battle-prep', + chapter: '박망파 매복전', + background: 'story-sortie', + speaker: '유비', + portrait: 'liuBei', + text: '오늘 싸움은 적을 많이 베는 것보다 조조군의 발을 묶는 것이 중요하오. 공명의 계책을 중심에 두고, 숲길과 불길 사이에서 전열을 흩트리지 맙시다.' + } +]; + +export const eighteenthBattleVictoryPages: StoryPage[] = [ + { + id: 'eighteenth-victory-bowang-fire', + bgm: 'militia-theme', + chapter: '숲길의 불빛', + background: 'story-sortie', + text: '박망파의 숲길에 불빛이 번지고, 하후돈의 선봉은 좁은 길에서 서로의 퇴로를 막았다. 유비군은 큰 병력 없이도 조조군의 진격을 늦추었다.' + }, + { + id: 'eighteenth-zhuge-trust-earned', + bgm: 'battle-prep', + chapter: '계책을 믿다', + background: 'story-liu-bei', + speaker: '장비', + portrait: 'zhangFei', + text: '처음에는 군사 양반이 종이 위에서만 싸우는 줄 알았습니다. 그런데 오늘 보니, 종이 위의 길이 정말 전장의 길이 되더군요.' + }, + { + id: 'eighteenth-red-cliffs-wind', + bgm: 'story-dark', + chapter: '강동으로 부는 바람', + background: 'story-three-heroes', + speaker: '제갈량', + text: '조조의 큰 군세는 여기서 멈추지 않을 것입니다. 이제 강동과 손을 잡을 길을 열어야 합니다. 바람은 아직 멀리 있으나, 방향은 보이기 시작했습니다.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -1380,6 +1444,12 @@ export const seventeenthBattleMap: BattleMap = { terrain: createSeventeenthBattleTerrain() }; +export const eighteenthBattleMap: BattleMap = { + width: 36, + height: 26, + terrain: createEighteenthBattleTerrain() +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -6365,6 +6435,363 @@ export const seventeenthBattleUnits: UnitData[] = [ } ]; +const eighteenthBattleAllyPositions: Record = { + 'liu-bei': { x: 3, y: 18 }, + 'guan-yu': { x: 4, y: 17 }, + 'zhang-fei': { x: 4, y: 19 }, + 'jian-yong': { x: 5, y: 18 }, + 'mi-zhu': { x: 3, y: 20 }, + 'sun-qian': { x: 5, y: 20 }, + 'zhao-yun': { x: 6, y: 17 }, + 'zhuge-liang': { x: 6, y: 19 } +}; + +export const eighteenthBattleUnits: UnitData[] = [ + ...[ + ...firstBattleUnits.filter((unit) => unit.faction === 'ally'), + ...xuzhouRecruitUnits, + ...caoBreakRecruitUnits, + ...liuBiaoRecruitUnits, + ...zhugeRecruitUnits + ].map((unit) => placeScenarioUnit(unit, eighteenthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), + { + id: 'bowang-scout-a', + name: '조조군 척후', + faction: 'enemy', + className: '선봉 척후', + classKey: 'bandit', + level: 20, + exp: 24, + hp: 62, + maxHp: 62, + attack: 24, + move: 4, + stats: { might: 98, intelligence: 70, leadership: 76, agility: 94, luck: 66 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 3, exp: 10 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 62 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 12, + y: 18 + }, + { + id: 'bowang-scout-b', + name: '조조군 척후', + faction: 'enemy', + className: '선봉 척후', + classKey: 'bandit', + level: 20, + exp: 24, + hp: 62, + maxHp: 62, + attack: 24, + move: 4, + stats: { might: 98, intelligence: 70, leadership: 76, agility: 94, luck: 66 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 3, exp: 10 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 62 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 14, + y: 20 + }, + { + id: 'bowang-infantry-a', + name: '조조군 보병', + faction: 'enemy', + className: '박망 보병', + classKey: 'yellowTurban', + level: 20, + exp: 26, + hp: 76, + maxHp: 76, + attack: 26, + move: 3, + stats: { might: 104, intelligence: 72, leadership: 88, agility: 76, luck: 66 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 12 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 66 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 17, + y: 17 + }, + { + id: 'bowang-infantry-b', + name: '조조군 보병', + faction: 'enemy', + className: '박망 보병', + classKey: 'yellowTurban', + level: 20, + exp: 26, + hp: 76, + maxHp: 76, + attack: 26, + move: 3, + stats: { might: 104, intelligence: 72, leadership: 88, agility: 76, luck: 66 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 12 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 66 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 18, + y: 19 + }, + { + id: 'bowang-infantry-c', + name: '조조군 보병', + faction: 'enemy', + className: '길목 보병', + classKey: 'yellowTurban', + level: 21, + exp: 28, + hp: 80, + maxHp: 80, + attack: 27, + move: 3, + stats: { might: 106, intelligence: 74, leadership: 90, agility: 78, luck: 67 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 16 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 68 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 25, + y: 14 + }, + { + id: 'bowang-infantry-d', + name: '조조군 보병', + faction: 'enemy', + className: '길목 보병', + classKey: 'yellowTurban', + level: 21, + exp: 28, + hp: 80, + maxHp: 80, + attack: 27, + move: 3, + stats: { might: 106, intelligence: 74, leadership: 90, agility: 78, luck: 67 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 16 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 68 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 26, + y: 17 + }, + { + id: 'bowang-archer-a', + name: '조조군 궁병', + faction: 'enemy', + className: '숲길 궁병', + classKey: 'archer', + level: 20, + exp: 24, + hp: 60, + maxHp: 60, + attack: 25, + move: 3, + stats: { might: 92, intelligence: 82, leadership: 82, agility: 90, luck: 68 }, + equipment: { + weapon: { itemId: 'short-bow', level: 3, exp: 10 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 58 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 19, + y: 14 + }, + { + id: 'bowang-archer-b', + name: '조조군 궁병', + faction: 'enemy', + className: '숲길 궁병', + classKey: 'archer', + level: 20, + exp: 24, + hp: 60, + maxHp: 60, + attack: 25, + move: 3, + stats: { might: 92, intelligence: 82, leadership: 82, agility: 90, luck: 68 }, + equipment: { + weapon: { itemId: 'short-bow', level: 3, exp: 10 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 58 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 23, + y: 12 + }, + { + id: 'bowang-archer-c', + name: '조조군 궁병', + faction: 'enemy', + className: '후방 궁병', + classKey: 'archer', + level: 21, + exp: 26, + hp: 62, + maxHp: 62, + attack: 26, + move: 3, + stats: { might: 94, intelligence: 84, leadership: 84, agility: 90, luck: 68 }, + equipment: { + weapon: { itemId: 'short-bow', level: 3, exp: 14 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 62 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 29, + y: 15 + }, + { + id: 'bowang-cavalry-a', + name: '조조군 기병', + faction: 'enemy', + className: '추격 기병', + classKey: 'cavalry', + level: 21, + exp: 28, + hp: 82, + maxHp: 82, + attack: 28, + move: 5, + stats: { might: 108, intelligence: 70, leadership: 86, agility: 98, luck: 68 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 18 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 68 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 15, + y: 16 + }, + { + id: 'bowang-cavalry-b', + name: '조조군 기병', + faction: 'enemy', + className: '추격 기병', + classKey: 'cavalry', + level: 21, + exp: 28, + hp: 82, + maxHp: 82, + attack: 28, + move: 5, + stats: { might: 108, intelligence: 70, leadership: 86, agility: 98, luck: 68 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 18 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 68 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 21, + y: 18 + }, + { + id: 'bowang-cavalry-c', + name: '조조군 기병', + faction: 'enemy', + className: '후속 기병', + classKey: 'cavalry', + level: 22, + exp: 30, + hp: 86, + maxHp: 86, + attack: 29, + move: 5, + stats: { might: 110, intelligence: 72, leadership: 88, agility: 100, luck: 68 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 20 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 70 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 28, + y: 18 + }, + { + id: 'bowang-guard-a', + name: '조조군 정예', + faction: 'enemy', + className: '하후돈 친위', + classKey: 'yellowTurban', + level: 22, + exp: 30, + hp: 88, + maxHp: 88, + attack: 29, + move: 3, + stats: { might: 110, intelligence: 76, leadership: 96, agility: 80, luck: 68 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 24 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 74 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 31, + y: 13 + }, + { + id: 'bowang-guard-b', + name: '조조군 정예', + faction: 'enemy', + className: '하후돈 친위', + classKey: 'yellowTurban', + level: 22, + exp: 30, + hp: 88, + maxHp: 88, + attack: 29, + move: 3, + stats: { might: 110, intelligence: 76, leadership: 96, agility: 80, luck: 68 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 3, exp: 24 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 74 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 32, + y: 16 + }, + { + id: 'bowang-strategist-a', + name: '조조군 군리', + faction: 'enemy', + className: '군리', + classKey: 'archer', + level: 21, + exp: 28, + hp: 64, + maxHp: 64, + attack: 26, + move: 3, + stats: { might: 76, intelligence: 94, leadership: 86, agility: 82, luck: 70 }, + equipment: { + weapon: { itemId: 'short-bow', level: 3, exp: 18 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 64 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 30, + y: 12 + }, + { + id: 'bowang-leader-xiahou-dun', + name: '하후돈', + faction: 'enemy', + className: '조조군 선봉장', + classKey: 'rebelLeader', + level: 23, + exp: 34, + hp: 118, + maxHp: 118, + attack: 31, + move: 4, + stats: { might: 116, intelligence: 78, leadership: 104, agility: 86, luck: 72 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 3, exp: 44 }, + armor: { itemId: 'reinforced-lamellar', level: 3, exp: 12 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 33, + y: 15 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -6473,6 +6900,22 @@ export const zhugeRecruitBonds: BattleBond[] = [ level: 34, exp: 0, description: '관우의 의로운 전열과 제갈량의 큰 계책이 서로를 인정하며 군의 중심을 단단히 세웁니다.' + }, + { + id: 'zhang-fei__zhuge-liang', + unitIds: ['zhang-fei', 'zhuge-liang'], + title: '호령과 계책', + level: 30, + exp: 0, + description: '장비의 거친 돌파와 제갈량의 매복 판단이 맞물리면 적의 전열을 크게 흔듭니다.' + }, + { + id: 'sun-qian__zhuge-liang', + unitIds: ['sun-qian', 'zhuge-liang'], + title: '문서와 대계', + level: 32, + exp: 0, + description: '손건의 외교 문서와 제갈량의 형세 판단은 강동과의 접점을 여는 힘이 됩니다.' } ]; @@ -6496,6 +6939,9 @@ export const sixteenthBattleBonds: BattleBond[] = [...fifteenthBattleBonds, ...l cloneBattleBondForScenario ); export const seventeenthBattleBonds: BattleBond[] = sixteenthBattleBonds.map(cloneBattleBondForScenario); +export const eighteenthBattleBonds: BattleBond[] = [...sixteenthBattleBonds, ...zhugeRecruitBonds].map( + cloneBattleBondForScenario +); function createEighthBattleTerrain(): TerrainType[][] { return Array.from({ length: 22 }, (_, y) => @@ -6953,6 +7399,57 @@ function createSeventeenthBattleTerrain(): TerrainType[][] { ); } +function createEighteenthBattleTerrain(): TerrainType[][] { + return Array.from({ length: 26 }, (_, y) => + Array.from({ length: 36 }, (_, x): TerrainType => { + if ((x <= 3 && y >= 16 && y <= 22) || (x <= 1 && y >= 13 && y <= 15)) { + return 'camp'; + } + if ((x >= 31 && x <= 35 && y >= 12 && y <= 18) || (x >= 29 && x <= 32 && y >= 10 && y <= 12)) { + return 'fort'; + } + if ((x >= 9 && x <= 11 && y >= 18 && y <= 20) || (x >= 24 && x <= 26 && y >= 13 && y <= 15)) { + return 'village'; + } + if ((x === 18 || x === 19) && y >= 1 && y <= 23) { + return 'river'; + } + if ((x === 27 || x === 28) && y >= 4 && y <= 21) { + return 'river'; + } + if ( + (y === 18 && x >= 2 && x <= 19) || + (y === 15 && x >= 18 && x <= 33) || + (x === 33 && y >= 12 && y <= 19) + ) { + return 'road'; + } + if ((x >= 5 && x <= 17 && y === 23 - Math.floor(x / 2)) || (x >= 17 && x <= 31 && y === Math.floor(x / 2) + 4)) { + return 'road'; + } + if ( + (x >= 6 && x <= 17 && y >= 8 && y <= 17) || + (x >= 20 && x <= 29 && y >= 16 && y <= 24) || + (x <= 8 && y <= 8) || + (x >= 25 && x <= 35 && y <= 8) + ) { + return 'forest'; + } + if ( + (x >= 9 && x <= 16 && y >= 2 && y <= 6) || + (x >= 13 && x <= 23 && y >= 21 && y <= 25) || + (x >= 30 && x <= 35 && y >= 2 && y <= 6) + ) { + return 'hill'; + } + if ((x <= 1 && y <= 8) || (x >= 34 && y <= 10) || (x >= 34 && y >= 19)) { + 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 a1646d5..b911d78 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -155,7 +155,23 @@ const unitTexture: Record = { 'wolong-road-guard-a': 'unit-rebel', 'wolong-road-guard-b': 'unit-rebel', 'wolong-road-strategist-a': 'unit-rebel-archer', - 'wolong-road-leader-cai-xun': 'unit-rebel-leader' + 'wolong-road-leader-cai-xun': 'unit-rebel-leader', + 'bowang-scout-a': 'unit-rebel', + 'bowang-scout-b': 'unit-rebel', + 'bowang-infantry-a': 'unit-rebel', + 'bowang-infantry-b': 'unit-rebel', + 'bowang-infantry-c': 'unit-rebel', + 'bowang-infantry-d': 'unit-rebel', + 'bowang-archer-a': 'unit-rebel-archer', + 'bowang-archer-b': 'unit-rebel-archer', + 'bowang-archer-c': 'unit-rebel-archer', + 'bowang-cavalry-a': 'unit-rebel-cavalry', + 'bowang-cavalry-b': 'unit-rebel-cavalry', + 'bowang-cavalry-c': 'unit-rebel-cavalry', + 'bowang-guard-a': 'unit-rebel', + 'bowang-guard-b': 'unit-rebel', + 'bowang-strategist-a': 'unit-rebel-archer', + 'bowang-leader-xiahou-dun': 'unit-rebel-leader' }; const unitTextureByClass: Partial> = { @@ -761,7 +777,23 @@ const enemyAiByUnitId: Record = { 'wolong-road-guard-a': 'guard', 'wolong-road-guard-b': 'guard', 'wolong-road-strategist-a': 'hold', - 'wolong-road-leader-cai-xun': 'guard' + 'wolong-road-leader-cai-xun': 'guard', + 'bowang-scout-a': 'aggressive', + 'bowang-scout-b': 'aggressive', + 'bowang-infantry-a': 'guard', + 'bowang-infantry-b': 'guard', + 'bowang-infantry-c': 'guard', + 'bowang-infantry-d': 'guard', + 'bowang-archer-a': 'hold', + 'bowang-archer-b': 'hold', + 'bowang-archer-c': 'hold', + 'bowang-cavalry-a': 'aggressive', + 'bowang-cavalry-b': 'aggressive', + 'bowang-cavalry-c': 'aggressive', + 'bowang-guard-a': 'guard', + 'bowang-guard-b': 'guard', + 'bowang-strategist-a': 'hold', + 'bowang-leader-xiahou-dun': 'guard' }; const defaultEnemyAiByClass: Partial> = { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 2107baa..1c9b80a 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 eighteenthBattleMapUrl from '../../assets/images/battle/eighteenth-battle-map.svg'; import eleventhBattleMapUrl from '../../assets/images/battle/eleventh-battle-map.svg'; import fifthBattleMapUrl from '../../assets/images/battle/fifth-battle-map.svg'; import fifteenthBattleMapUrl from '../../assets/images/battle/fifteenth-battle-map.svg'; @@ -92,6 +93,7 @@ export class BootScene extends Phaser.Scene { this.load.image('battle-map-fifteenth', fifteenthBattleMapUrl); this.load.image('battle-map-sixteenth', sixteenthBattleMapUrl); this.load.image('battle-map-seventeenth', seventeenthBattleMapUrl); + this.load.image('battle-map-eighteenth', eighteenthBattleMapUrl); 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 c91f79b..ba0e3ec 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -281,7 +281,7 @@ const campaignTimelineChapters: CampaignTimelineChapter[] = [ title: '적벽대전', period: '강동의 바람', description: '제갈량의 계책과 강동의 힘이 맞물려 조조의 대군을 막아서는 큰 전환 장입니다.', - battleIds: [], + battleIds: ['eighteenth-battle-bowang-ambush'], nextHints: ['강동 사절', '화공 준비', '조조 남하'] }, { @@ -319,7 +319,8 @@ const campBattleIds = { fourteenth: 'fourteenth-battle-cao-break', fifteenth: 'fifteenth-battle-yuan-refuge-road', sixteenth: 'sixteenth-battle-liu-biao-refuge', - seventeenth: 'seventeenth-battle-wolong-visit-road' + seventeenth: 'seventeenth-battle-wolong-visit-road', + eighteenth: 'eighteenth-battle-bowang-ambush' } as const; const requiredSortieUnitIds = new Set(['liu-bei']); @@ -498,6 +499,18 @@ const sortieRulesByBattleId: Partial 5 ? 38 : allies.length > 4 ? 43 : 48; - const rowHeight = rowGap - 6; + const rowGap = allies.length > 7 ? 30 : allies.length > 5 ? 38 : allies.length > 4 ? 43 : 48; + const rowHeight = allies.length > 7 ? 24 : rowGap - 6; this.trackSortie( this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${maxUnits}`, this.textStyle(18, '#f2e3bf', true)) ).setDepth(depth + 1); @@ -2871,11 +3027,22 @@ export class CampScene extends Phaser.Scene { const completedDialogues = this.completedAvailableDialogues().length; const availableVisits = this.availableCampVisits(); const completedVisits = this.completedAvailableVisits().length; - const inventory = this.inventoryLabels().join(', '); - const selectedNames = this.selectedSortieUnits().map((unit) => unit.name).join(', '); + const inventoryLabels = this.inventoryLabels(); + const selectedUnits = this.selectedSortieUnits(); + const selectedPreview = selectedUnits.slice(0, 3).map((unit) => unit.name).join(', '); + const selectedSummary = selectedUnits.length > 0 ? `${selectedUnits.length}명 (${selectedPreview}${selectedUnits.length > 3 ? ' 외' : ''})` : '없음'; + const inventorySummary = + inventoryLabels.length > 0 ? `${inventoryLabels.slice(0, 4).join(', ')}${inventoryLabels.length > 4 ? ' 외' : ''}` : '없음'; const reward = getSortieFlow(this.campaign?.latestBattleId).rewardHint; 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}/${availableDialogues.length} · 방문 ${completedVisits}/${availableVisits.length} · 출전 ${selectedNames || '없음'} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1); + this.trackSortie( + this.add.text( + x + 16, + y + 30, + `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 방문 ${completedVisits}/${availableVisits.length} · 출전 ${selectedSummary} · 보유 ${inventorySummary}`, + this.textStyle(12, '#d4dce6') + ) + ).setDepth(depth + 1); } private nextSortieBriefing() { @@ -3206,6 +3373,17 @@ export class CampScene extends Phaser.Scene { } private activeTimelineChapterIndex(completedIds = this.completedBattleIds()) { + const latestBattleId = this.campaign?.latestBattleId ?? this.report?.battleId; + const nextBattleId = getSortieFlow(this.campaign?.latestBattleId).nextBattleId; + if (!nextBattleId && latestBattleId) { + const latestChapterIndex = campaignTimelineChapters.findIndex((chapter) => + chapter.battleIds.includes(latestBattleId as BattleScenarioId) + ); + if (latestChapterIndex >= 0) { + return latestChapterIndex; + } + } + const index = campaignTimelineChapters.findIndex((chapter) => { if (chapter.battleIds.length === 0) { return true; diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 01c379c..5bf63f8 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, + eighteenthBattleScenario, eleventhBattleScenario, fifthBattleScenario, fifteenthBattleScenario, @@ -333,7 +334,8 @@ export class TitleScene extends Phaser.Scene { campaign.step === 'fourteenth-camp' || campaign.step === 'fifteenth-camp' || campaign.step === 'sixteenth-camp' || - campaign.step === 'seventeenth-camp' + campaign.step === 'seventeenth-camp' || + campaign.step === 'eighteenth-camp' ) { this.scene.start('CampScene'); return; @@ -424,6 +426,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'eighteenth-battle') { + this.scene.start('BattleScene', { battleId: eighteenthBattleScenario.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 177683d..600bf55 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -74,7 +74,9 @@ export type CampaignStep = | 'sixteenth-battle' | 'sixteenth-camp' | 'seventeenth-battle' - | 'seventeenth-camp'; + | 'seventeenth-camp' + | 'eighteenth-battle' + | 'eighteenth-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; @@ -143,7 +145,8 @@ const campaignBattleSteps: Record