From 64af9c1f682b8a6ac5f0e0339e7e3782ab829e6b Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 05:09:14 +0900 Subject: [PATCH] Add Wolong recruitment chapter --- docs/roadmap.md | 10 +- scripts/verify-flow.mjs | 96 +++- .../images/battle/seventeenth-battle-map.svg | 78 +++ src/game/data/battleItems.ts | 9 + src/game/data/battles.ts | 63 ++- src/game/data/campaignFlow.ts | 26 +- src/game/data/scenario.ts | 505 ++++++++++++++++++ src/game/scenes/BattleScene.ts | 42 +- src/game/scenes/BootScene.ts | 27 + src/game/scenes/CampScene.ts | 183 ++++++- src/game/scenes/TitleScene.ts | 9 +- src/game/state/campaignState.ts | 7 +- 12 files changed, 1033 insertions(+), 22 deletions(-) create mode 100644 src/assets/images/battle/seventeenth-battle-map.svg diff --git a/docs/roadmap.md b/docs/roadmap.md index c41ccd8..d108f0e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -37,15 +37,17 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Fifteenth battle Yuan Shao refuge road, pushing Liu Bei through Cao Cao's pursuit into Yuan Shao's camp while preserving the six-officer sortie roster - Sixteenth battle Liu Biao refuge road, adding Zhao Yun as a recruitable cavalry officer and forcing a seven-officer roster into a six-officer sortie choice - 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 - 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 sixteenth battle victory, recruit sortie selection, Liu Biao visit rewards, and camp save state +- Flow verification script from title through the seventeenth battle victory, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, and camp save state ## Next Steps -1. Build the Zhuge Liang recruitment chapter using the Wolong clues gathered from Liu Biao camp visits -2. Add a chapter/progress view so long campaign arcs are easier to read between battles +1. Add a chapter/progress view so long campaign arcs are easier to read between battles +2. Start the Red Cliffs preparation chapter with Zhuge Liang's strategic role visible in sortie and camp choices 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 +4. Add more recruitable officers as the campaign moves toward Red Cliffs, Jing Province, Yi Province, and Shu Han 5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending ## Content Direction diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 11e284e..e26f711 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1636,6 +1636,100 @@ try { } await page.screenshot({ path: 'dist/verification-liu-biao-visits.png', fullPage: true }); + await page.mouse.click(1120, 38); + await page.waitForTimeout(160); + const sixteenthCampWolongSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + !sixteenthCampWolongSortieState?.sortieVisible || + !sixteenthCampWolongSortieState.sortiePlan?.objectiveLine?.includes('융중 방문로') || + !sixteenthCampWolongSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || + !sixteenthCampWolongSortieState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.recruited && unit.recommended) || + sixteenthCampWolongSortieState.sortieRoster?.length < 7 || + sixteenthCampWolongSortieState.sortiePlan?.maxCount !== 6 + ) { + throw new Error(`Expected sixteenth camp sortie prep to target Wolong visit road with expanded selectable roster: ${JSON.stringify(sixteenthCampWolongSortieState)}`); + } + assertSortieTacticalRoster(sixteenthCampWolongSortieState, [ + 'liu-bei', + 'guan-yu', + 'zhang-fei', + 'jian-yong', + 'mi-zhu', + 'sun-qian', + 'zhao-yun' + ]); + await page.screenshot({ path: 'dist/verification-wolong-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-wolong-story.png', fullPage: true }); + + for (let i = 0; i < 24; i += 1) { + const enteredSeventeenthBattle = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'seventeenth-battle-wolong-visit-road'; + }); + if (enteredSeventeenthBattle) { + 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 === 'seventeenth-battle-wolong-visit-road' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-seventeenth-battle.png', fullPage: true }); + + const seventeenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const seventeenthEnemies = seventeenthBattleState.units.filter((unit) => unit.faction === 'enemy'); + const seventeenthAllies = seventeenthBattleState.units.filter((unit) => unit.faction === 'ally'); + const seventeenthEnemyBehaviors = new Set(seventeenthEnemies.map((unit) => unit.ai)); + if ( + seventeenthBattleState.camera?.mapWidth !== 34 || + seventeenthBattleState.camera?.mapHeight !== 26 || + seventeenthBattleState.victoryConditionLabel !== '채순 격파' || + seventeenthEnemies.length < 15 || + !seventeenthEnemyBehaviors.has('aggressive') || + !seventeenthEnemyBehaviors.has('guard') || + !seventeenthEnemyBehaviors.has('hold') || + !seventeenthEnemies.some((unit) => unit.id === 'wolong-road-leader-cai-xun') || + !seventeenthAllies.some((unit) => unit.id === 'zhao-yun') || + seventeenthAllies.some((unit) => unit.id === 'zhuge-liang') + ) { + throw new Error(`Expected seventeenth battle to use Wolong visit map, Cai Xun objective, mixed AI, and pre-Zhuge sortie: ${JSON.stringify(seventeenthBattleState)}`); + } + + 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 seventeenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + seventeenthCampState?.campBattleId !== 'seventeenth-battle-wolong-visit-road' || + seventeenthCampState.campTitle !== '와룡 출려 후 군영' || + seventeenthCampState.availableDialogueIds?.length !== 3 || + !seventeenthCampState.availableDialogueIds.every((id) => id.endsWith('wolong-visit')) || + seventeenthCampState.availableVisitIds?.length !== 2 || + !seventeenthCampState.campaign?.roster?.some((unit) => unit.id === 'zhuge-liang') || + !seventeenthCampState.report?.bonds?.some((bond) => bond.id === 'liu-bei__zhuge-liang') || + !seventeenthCampState.report?.bonds?.some((bond) => bond.id === 'guan-yu__zhuge-liang') + ) { + throw new Error(`Expected seventeenth camp to recruit Zhuge Liang and expose Wolong dialogue/visit sets: ${JSON.stringify(seventeenthCampState)}`); + } + await page.screenshot({ path: 'dist/verification-zhuge-recruit-camp.png', fullPage: true }); + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -1647,7 +1741,7 @@ try { return activeScenes.includes('CampScene'); }); - console.log(`Verified title-to-sixteenth-battle flow, recruit sortie selection, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-seventeenth-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/seventeenth-battle-map.svg b/src/assets/images/battle/seventeenth-battle-map.svg new file mode 100644 index 0000000..33792ea --- /dev/null +++ b/src/assets/images/battle/seventeenth-battle-map.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battleItems.ts b/src/game/data/battleItems.ts index fede1fc..c8e5c1e 100644 --- a/src/game/data/battleItems.ts +++ b/src/game/data/battleItems.ts @@ -76,6 +76,15 @@ export const itemCatalog: Record = { attackBonus: 6, effects: ['단독으로 돌파할 때 피해 보너스'] }, + 'white-feather-fan': { + id: 'white-feather-fan', + name: '백우선', + slot: 'weapon', + rank: 'treasure', + description: '제갈량이 책략을 펼칠 때 드는 흰 깃털 부채. 전장의 바람과 마음을 읽는다.', + strategyBonus: 5, + effects: ['책략 피해와 공명 지원 경험치 보정'] + }, 'yellow-turban-saber': { id: 'yellow-turban-saber', name: '황건도', diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 590fb6a..785f891 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -60,6 +60,10 @@ import { sixteenthBattleMap, sixteenthBattleUnits, sixteenthBattleVictoryPages, + seventeenthBattleBonds, + seventeenthBattleMap, + seventeenthBattleUnits, + seventeenthBattleVictoryPages, thirdBattleMap, thirdBattleUnits, thirdBattleVictoryPages, @@ -85,7 +89,8 @@ export type BattleScenarioId = | 'thirteenth-battle-xiapi-final' | 'fourteenth-battle-cao-break' | 'fifteenth-battle-yuan-refuge-road' - | 'sixteenth-battle-liu-biao-refuge'; + | 'sixteenth-battle-liu-biao-refuge' + | 'seventeenth-battle-wolong-visit-road'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -972,6 +977,59 @@ export const sixteenthBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const seventeenthBattleScenario: BattleScenarioDefinition = { + id: 'seventeenth-battle-wolong-visit-road', + title: '융중 방문로', + victoryConditionLabel: '채순 격파', + defeatConditionLabel: '유비 퇴각', + openingObjectiveLines: [ + '형주에서 모은 와룡의 단서를 따라 유비군은 융중으로 향합니다. 그러나 산길에는 채씨 가병이 먼저 길목을 잡고 있습니다.', + '숲과 언덕, 좁은 물길이 많은 전장입니다. 조운의 기동력과 책사들의 후원을 살려 산길의 궁병과 감시병을 끊어 내십시오.', + '채순을 격파하고 융중 초가로 향하는 길을 확보하십시오. 26턴 안에 승리하면 제갈량 영입의 명분과 보급을 더 단단히 챙길 수 있습니다.' + ], + map: seventeenthBattleMap, + units: seventeenthBattleUnits, + bonds: seventeenthBattleBonds, + mapTextureKey: 'battle-map-seventeenth', + leaderUnitId: 'wolong-road-leader-cai-xun', + quickVictoryTurnLimit: 26, + baseVictoryGold: 1960, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '채순 격파', + rewardGold: 1260, + unitId: 'wolong-road-leader-cai-xun' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 440, + unitId: 'liu-bei' + }, + { + id: 'longzhong-cottage', + kind: 'secure-terrain', + label: '융중 초가 길 확보', + rewardGold: 720, + terrain: 'village' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '26턴 이내 승리', + rewardGold: 560, + maxTurn: 26 + } + ], + defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], + itemRewards: ['콩 6', '상처약 4', '탁주 2', '와룡의 서찰 +1'], + victoryPages: seventeenthBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { @@ -990,7 +1048,8 @@ export const battleScenarios: Record 'thirteenth-battle-xiapi-final': thirteenthBattleScenario, 'fourteenth-battle-cao-break': fourteenthBattleScenario, 'fifteenth-battle-yuan-refuge-road': fifteenthBattleScenario, - 'sixteenth-battle-liu-biao-refuge': sixteenthBattleScenario + 'sixteenth-battle-liu-biao-refuge': sixteenthBattleScenario, + 'seventeenth-battle-wolong-visit-road': seventeenthBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 4652850..73bd2c5 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -12,6 +12,7 @@ import { seventhBattleScenario, sixthBattleScenario, sixteenthBattleScenario, + seventeenthBattleScenario, tenthBattleScenario, thirteenthBattleScenario, twelfthBattleScenario, @@ -42,6 +43,8 @@ import { sixthBattleVictoryPages, sixteenthBattleIntroPages, sixteenthBattleVictoryPages, + seventeenthBattleIntroPages, + seventeenthBattleVictoryPages, tenthBattleIntroPages, tenthBattleVictoryPages, thirteenthBattleIntroPages, @@ -221,13 +224,24 @@ const sortieFlows: Record = { }, [sixteenthBattleScenario.id]: { afterBattleId: sixteenthBattleScenario.id, - eyebrow: '다음 장 준비', - title: '제갈량을 향한 길', + eyebrow: '다음 전장', + title: seventeenthBattleScenario.title, description: - '유표에게 의탁한 유비군은 형주에서 숨을 돌리지만, 손님으로 머무는 것만으로 천하의 길을 열 수는 없습니다. 다음 흐름은 와룡의 이름을 찾아가는 제갈량 영입 장으로 이어집니다.', - rewardHint: '다음 장: 제갈량 영입 준비 중', - pages: sixteenthBattleVictoryPages, - unavailableNotice: '제갈량 영입 장은 다음 작업에서 이어집니다. 군영에서 새로 합류한 조운과 공명도를 다지고 출전 구성을 정비하십시오.' + '유표에게 의탁한 유비군은 형주에서 숨을 돌리지만, 손님으로 머무는 것만으로 천하의 길을 열 수는 없습니다. 형주에서 모은 와룡의 단서를 따라 융중으로 향해야 합니다.', + rewardHint: `예상 보상: ${seventeenthBattleScenario.title} 개방 / 제갈량 영입`, + nextBattleId: seventeenthBattleScenario.id, + campaignStep: 'seventeenth-battle', + pages: [...sixteenthBattleVictoryPages, ...seventeenthBattleIntroPages] + }, + [seventeenthBattleScenario.id]: { + afterBattleId: seventeenthBattleScenario.id, + eyebrow: '다음 장 준비', + title: '적벽으로 향하는 바람', + description: + '제갈량이 합류하며 유비군은 비로소 천하의 큰 판을 바라보게 됩니다. 다음 흐름은 형주의 불안과 강동의 바람이 맞물리는 적벽대전 준비로 이어집니다.', + rewardHint: '다음 장: 적벽대전 준비 중', + pages: seventeenthBattleVictoryPages, + unavailableNotice: '적벽대전 장은 다음 작업에서 이어집니다. 군영에서 제갈량과의 공명도를 다지고 출전 구성을 정비하십시오.' } }; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index a95009e..c434174 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1069,6 +1069,70 @@ export const sixteenthBattleVictoryPages: StoryPage[] = [ } ]; +export const seventeenthBattleIntroPages: StoryPage[] = [ + { + id: 'seventeenth-wolong-clue-gathered', + bgm: 'story-dark', + chapter: '와룡의 단서', + background: 'story-militia', + speaker: '손건', + text: '형주 선비들의 말이 하나로 모입니다. 융중의 초가에 머무는 제갈공명, 사람들은 그를 와룡이라 부릅니다. 주공께서 직접 찾아가 예를 다한다면 길이 열릴지도 모릅니다.' + }, + { + id: 'seventeenth-liu-bei-third-visit', + bgm: 'militia-theme', + chapter: '삼고초려', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '한 번 찾아가 만나지 못했다 하여 뜻이 얕아지는 것은 아니오. 천하를 구할 계책을 구하려면, 내가 먼저 몸을 낮추어 그 문 앞에 서야 하오.' + }, + { + id: 'seventeenth-road-to-longzhong', + bgm: 'battle-prep', + chapter: '융중 방문로', + background: 'story-three-heroes', + speaker: '조운', + text: '융중으로 향하는 산길에 수상한 가병들이 숨어 있습니다. 주공의 방문이 형주 안의 권신들에게 알려진 듯합니다. 길을 먼저 정리하겠습니다.' + }, + { + id: 'seventeenth-longzhong-sortie', + bgm: 'battle-prep', + chapter: '와룡을 찾아', + background: 'story-sortie', + speaker: '장비', + portrait: 'zhangFei', + text: '군사 하나 만나러 가는데 길목마다 잔챙이들이 성가시게 하는군요. 그래도 형님 뜻이라면 길부터 시원하게 열어 보겠습니다.' + } +]; + +export const seventeenthBattleVictoryPages: StoryPage[] = [ + { + id: 'seventeenth-victory-longzhong-road', + bgm: 'militia-theme', + chapter: '열린 초가 길', + background: 'story-sortie', + text: '융중으로 향하는 길목의 가병들이 흩어지고, 산길은 조용해졌다. 유비는 갑옷의 먼지를 털지도 않은 채 제갈량의 초가 앞에 다시 섰다.' + }, + { + id: 'seventeenth-zhuge-liang-counsel', + bgm: 'story-dark', + chapter: '천하삼분의 말', + background: 'story-liu-bei', + speaker: '제갈량', + text: '한실을 일으키려면 먼저 형주와 익주를 얻어 근본을 세우고, 때를 기다려 북벌의 길을 여십시오. 주공의 뜻이 백성에게 닿는다면, 저는 그 길에 지혜를 보태겠습니다.' + }, + { + id: 'seventeenth-zhuge-liang-joins', + bgm: 'battle-prep', + chapter: '와룡 출려', + background: 'story-three-heroes', + speaker: '유비', + portrait: 'liuBei', + text: '공명 선생이 함께한다면 우리 군은 비로소 길을 보는 눈을 얻은 것이오. 이제 형주의 머무름은 다음 큰 전장을 준비하는 시간이 될 것이오.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -1310,6 +1374,12 @@ export const sixteenthBattleMap: BattleMap = { terrain: createSixteenthBattleTerrain() }; +export const seventeenthBattleMap: BattleMap = { + width: 34, + height: 26, + terrain: createSeventeenthBattleTerrain() +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -3242,6 +3312,30 @@ export const liuBiaoRecruitUnits: UnitData[] = [ } ]; +export const zhugeRecruitUnits: UnitData[] = [ + { + id: 'zhuge-liang', + name: '제갈량', + faction: 'ally', + className: '군사', + classKey: 'strategist', + level: 8, + exp: 0, + hp: 28, + maxHp: 28, + attack: 8, + move: 4, + stats: { might: 34, intelligence: 100, leadership: 88, agility: 74, luck: 86 }, + equipment: { + weapon: { itemId: 'white-feather-fan', level: 1, exp: 0 }, + armor: { itemId: 'oath-robe', level: 1, exp: 0 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 7, + y: 20 + } +]; + const eighthBattleAllyPositions: Record = { 'liu-bei': { x: 2, y: 17 }, 'guan-yu': { x: 3, y: 17 }, @@ -5937,6 +6031,340 @@ export const sixteenthBattleUnits: UnitData[] = [ } ]; +const seventeenthBattleAllyPositions: Record = { + 'liu-bei': { x: 3, y: 21 }, + 'guan-yu': { x: 4, y: 20 }, + 'zhang-fei': { x: 4, y: 22 }, + 'jian-yong': { x: 5, y: 21 }, + 'mi-zhu': { x: 3, y: 23 }, + 'sun-qian': { x: 5, y: 23 }, + 'zhao-yun': { x: 6, y: 22 } +}; + +export const seventeenthBattleUnits: UnitData[] = [ + ...[ + ...firstBattleUnits.filter((unit) => unit.faction === 'ally'), + ...xuzhouRecruitUnits, + ...caoBreakRecruitUnits, + ...liuBiaoRecruitUnits + ].map((unit) => placeScenarioUnit(unit, seventeenthBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), + { + id: 'wolong-road-scout-a', + name: '산길 척후', + faction: 'enemy', + className: '형주 척후', + classKey: 'bandit', + level: 18, + exp: 16, + hp: 58, + maxHp: 58, + attack: 22, + move: 4, + stats: { might: 94, intelligence: 66, leadership: 72, agility: 92, luck: 64 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 82 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 38 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 10, + y: 20 + }, + { + id: 'wolong-road-scout-b', + name: '산길 척후', + faction: 'enemy', + className: '형주 척후', + classKey: 'bandit', + level: 18, + exp: 16, + hp: 58, + maxHp: 58, + attack: 22, + move: 4, + stats: { might: 94, intelligence: 66, leadership: 72, agility: 92, luck: 64 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 2, exp: 82 }, + armor: { itemId: 'rebel-vest', level: 2, exp: 38 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 12, + y: 23 + }, + { + id: 'wolong-road-infantry-a', + name: '형주 가병', + faction: 'enemy', + className: '산길 보병', + classKey: 'yellowTurban', + level: 18, + exp: 18, + hp: 70, + maxHp: 70, + attack: 24, + move: 3, + stats: { might: 100, intelligence: 68, leadership: 86, agility: 74, luck: 64 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 2, exp: 86 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 42 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 15, + y: 18 + }, + { + id: 'wolong-road-infantry-b', + name: '형주 가병', + faction: 'enemy', + className: '산길 보병', + classKey: 'yellowTurban', + level: 18, + exp: 18, + hp: 70, + maxHp: 70, + attack: 24, + move: 3, + stats: { might: 100, intelligence: 68, leadership: 86, agility: 74, luck: 64 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 2, exp: 86 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 42 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 17, + y: 20 + }, + { + id: 'wolong-road-infantry-c', + name: '형주 가병', + faction: 'enemy', + className: '초가 수비병', + classKey: 'yellowTurban', + level: 19, + exp: 20, + hp: 74, + maxHp: 74, + attack: 25, + move: 3, + stats: { might: 102, intelligence: 70, leadership: 88, agility: 74, luck: 65 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 2, exp: 90 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 46 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 26, + y: 11 + }, + { + id: 'wolong-road-infantry-d', + name: '형주 가병', + faction: 'enemy', + className: '초가 수비병', + classKey: 'yellowTurban', + level: 19, + exp: 20, + hp: 74, + maxHp: 74, + attack: 25, + move: 3, + stats: { might: 102, intelligence: 70, leadership: 88, agility: 74, luck: 65 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 2, exp: 90 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 46 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 27, + y: 15 + }, + { + id: 'wolong-road-archer-a', + name: '형주 궁병', + faction: 'enemy', + className: '숲길 궁병', + classKey: 'archer', + level: 18, + exp: 18, + hp: 56, + maxHp: 56, + attack: 23, + move: 3, + stats: { might: 88, intelligence: 80, leadership: 78, agility: 88, luck: 65 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 84 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 40 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 16, + y: 14 + }, + { + id: 'wolong-road-archer-b', + name: '형주 궁병', + faction: 'enemy', + className: '숲길 궁병', + classKey: 'archer', + level: 18, + exp: 18, + hp: 56, + maxHp: 56, + attack: 23, + move: 3, + stats: { might: 88, intelligence: 80, leadership: 78, agility: 88, luck: 65 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 84 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 40 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 22, + y: 10 + }, + { + id: 'wolong-road-archer-c', + name: '형주 궁병', + faction: 'enemy', + className: '초가 궁병', + classKey: 'archer', + level: 19, + exp: 20, + hp: 58, + maxHp: 58, + attack: 24, + move: 3, + stats: { might: 90, intelligence: 82, leadership: 80, agility: 88, luck: 66 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 88 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 42 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 29, + y: 13 + }, + { + id: 'wolong-road-cavalry-a', + name: '형주 기병', + faction: 'enemy', + className: '산길 기병', + classKey: 'cavalry', + level: 19, + exp: 20, + hp: 76, + maxHp: 76, + attack: 26, + move: 5, + stats: { might: 104, intelligence: 64, leadership: 82, agility: 96, luck: 66 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 2, exp: 90 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 44 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 13, + y: 19 + }, + { + id: 'wolong-road-cavalry-b', + name: '형주 기병', + faction: 'enemy', + className: '산길 기병', + classKey: 'cavalry', + level: 19, + exp: 20, + hp: 76, + maxHp: 76, + attack: 26, + move: 5, + stats: { might: 104, intelligence: 64, leadership: 82, agility: 96, luck: 66 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 2, exp: 90 }, + armor: { itemId: 'lamellar-armor', level: 2, exp: 44 }, + accessory: { itemId: 'bravery-token', level: 1, exp: 0 } + }, + x: 20, + y: 18 + }, + { + id: 'wolong-road-guard-a', + name: '채씨 가병', + faction: 'enemy', + className: '초가 감시병', + classKey: 'yellowTurban', + level: 19, + exp: 22, + hp: 78, + maxHp: 78, + attack: 25, + move: 3, + stats: { might: 102, intelligence: 74, leadership: 90, agility: 75, luck: 65 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 2, exp: 92 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 48 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 30, + y: 14 + }, + { + id: 'wolong-road-guard-b', + name: '채씨 가병', + faction: 'enemy', + className: '초가 감시병', + classKey: 'yellowTurban', + level: 19, + exp: 22, + hp: 78, + maxHp: 78, + attack: 25, + move: 3, + stats: { might: 102, intelligence: 74, leadership: 90, agility: 75, luck: 65 }, + equipment: { + weapon: { itemId: 'iron-spear', level: 2, exp: 92 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 48 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 31, + y: 12 + }, + { + id: 'wolong-road-strategist-a', + name: '형주 문객', + faction: 'enemy', + className: '채씨 문객', + classKey: 'archer', + level: 19, + exp: 22, + hp: 60, + maxHp: 60, + attack: 24, + move: 3, + stats: { might: 74, intelligence: 92, leadership: 82, agility: 80, luck: 68 }, + equipment: { + weapon: { itemId: 'short-bow', level: 2, exp: 90 }, + armor: { itemId: 'cloth-armor', level: 2, exp: 44 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 28, + y: 12 + }, + { + id: 'wolong-road-leader-cai-xun', + name: '채순', + faction: 'enemy', + className: '채씨 가병장', + classKey: 'rebelLeader', + level: 20, + exp: 24, + hp: 104, + maxHp: 104, + attack: 28, + move: 4, + stats: { might: 108, intelligence: 76, leadership: 98, agility: 82, luck: 66 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 3, exp: 32 }, + armor: { itemId: 'reinforced-lamellar', level: 2, exp: 52 }, + accessory: { itemId: 'war-manual', level: 1, exp: 0 } + }, + x: 31, + y: 14 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -6021,6 +6449,33 @@ export const liuBiaoRecruitBonds: BattleBond[] = [ } ]; +export const zhugeRecruitBonds: BattleBond[] = [ + { + id: 'liu-bei__zhuge-liang', + unitIds: ['liu-bei', 'zhuge-liang'], + title: '군신의 만남', + level: 58, + exp: 0, + description: '유비의 뜻과 제갈량의 계책이 만나 천하를 향한 큰 그림을 열기 시작합니다.' + }, + { + id: 'zhao-yun__zhuge-liang', + unitIds: ['zhao-yun', 'zhuge-liang'], + title: '창과 책략', + level: 36, + exp: 0, + description: '조운의 기동력과 제갈량의 판단은 빠른 구원과 정확한 돌파를 함께 만듭니다.' + }, + { + id: 'guan-yu__zhuge-liang', + unitIds: ['guan-yu', 'zhuge-liang'], + title: '의와 계책', + level: 34, + exp: 0, + description: '관우의 의로운 전열과 제갈량의 큰 계책이 서로를 인정하며 군의 중심을 단단히 세웁니다.' + } +]; + export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); @@ -6040,6 +6495,7 @@ export const fifteenthBattleBonds: BattleBond[] = fourteenthBattleBonds.map(clon export const sixteenthBattleBonds: BattleBond[] = [...fifteenthBattleBonds, ...liuBiaoRecruitBonds].map( cloneBattleBondForScenario ); +export const seventeenthBattleBonds: BattleBond[] = sixteenthBattleBonds.map(cloneBattleBondForScenario); function createEighthBattleTerrain(): TerrainType[][] { return Array.from({ length: 22 }, (_, y) => @@ -6448,6 +6904,55 @@ function createSixteenthBattleTerrain(): TerrainType[][] { ); } +function createSeventeenthBattleTerrain(): TerrainType[][] { + return Array.from({ length: 26 }, (_, y) => + Array.from({ length: 34 }, (_, x): TerrainType => { + if ((x <= 2 && y >= 20 && y <= 24) || (x === 3 && y >= 22 && y <= 24)) { + return 'camp'; + } + if ((x >= 29 && x <= 33 && y >= 10 && y <= 15) || (x >= 27 && x <= 30 && y >= 8 && y <= 10)) { + return 'fort'; + } + if ((x >= 24 && x <= 26 && y >= 11 && y <= 13) || (x >= 12 && x <= 14 && y >= 18 && y <= 20)) { + return 'village'; + } + if ((x === 18 || x === 19) && y >= 4 && y <= 23) { + return 'river'; + } + if ( + (y === 21 && x >= 2 && x <= 15) || + (y === 18 && x >= 13 && x <= 24) || + (y === 13 && x >= 22 && x <= 32) || + (x === 29 && y >= 10 && y <= 18) + ) { + return 'road'; + } + if ((x >= 5 && x <= 15 && y === 25 - x) || (x >= 15 && x <= 29 && y === x - 11)) { + return 'road'; + } + if ( + (x <= 10 && y <= 9) || + (x >= 4 && x <= 15 && y >= 10 && y <= 17) || + (x >= 20 && x <= 28 && y >= 16 && y <= 24) || + (x >= 25 && y <= 7) + ) { + return 'forest'; + } + if ( + (x >= 8 && x <= 15 && y >= 2 && y <= 7) || + (x >= 15 && x <= 23 && y >= 20 && y <= 25) || + (x >= 27 && x <= 33 && y >= 1 && y <= 6) + ) { + return 'hill'; + } + if ((x >= 32 && y <= 9) || (x >= 32 && y >= 17) || (x <= 1 && y <= 6)) { + 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 92c7050..a1646d5 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -28,6 +28,7 @@ const unitTexture: Record = { 'guan-yu': 'unit-guan-yu', 'zhang-fei': 'unit-zhang-fei', 'zhao-yun': 'unit-rebel-cavalry', + 'zhuge-liang': 'unit-liu-bei', 'rebel-a': 'unit-rebel', 'rebel-b': 'unit-rebel', 'rebel-c': 'unit-rebel-archer', @@ -139,7 +140,22 @@ const unitTexture: Record = { 'liu-biao-road-guard-a': 'unit-rebel', 'liu-biao-road-guard-b': 'unit-rebel', 'liu-biao-road-strategist-a': 'unit-rebel-archer', - 'liu-biao-road-leader-cao-ren': 'unit-rebel-leader' + 'liu-biao-road-leader-cao-ren': 'unit-rebel-leader', + 'wolong-road-scout-a': 'unit-rebel', + 'wolong-road-scout-b': 'unit-rebel', + 'wolong-road-infantry-a': 'unit-rebel', + 'wolong-road-infantry-b': 'unit-rebel', + 'wolong-road-infantry-c': 'unit-rebel', + 'wolong-road-infantry-d': 'unit-rebel', + 'wolong-road-archer-a': 'unit-rebel-archer', + 'wolong-road-archer-b': 'unit-rebel-archer', + 'wolong-road-archer-c': 'unit-rebel-archer', + 'wolong-road-cavalry-a': 'unit-rebel-cavalry', + 'wolong-road-cavalry-b': 'unit-rebel-cavalry', + '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' }; const unitTextureByClass: Partial> = { @@ -553,7 +569,9 @@ const unitStrategyIds: Record = { 'guan-yu': ['fireTactic'], 'zhang-fei': ['roar'], 'jian-yong': ['aid', 'encourage', 'fireTactic'], - 'mi-zhu': ['aid', 'encourage'] + 'mi-zhu': ['aid', 'encourage'], + 'sun-qian': ['aid', 'encourage', 'fireTactic'], + 'zhuge-liang': ['aid', 'encourage', 'fireTactic'] }; const initialItemStocks: Record> = { @@ -563,7 +581,8 @@ const initialItemStocks: Record> = { 'jian-yong': { bean: 1 }, 'mi-zhu': { bean: 2, salve: 1 }, 'sun-qian': { bean: 1, salve: 1 }, - 'zhao-yun': { bean: 1, wine: 1 } + 'zhao-yun': { bean: 1, wine: 1 }, + 'zhuge-liang': { bean: 1, wine: 1 } }; const attackRangeByClass: Partial> = { @@ -727,7 +746,22 @@ const enemyAiByUnitId: Record = { 'liu-biao-road-guard-a': 'guard', 'liu-biao-road-guard-b': 'guard', 'liu-biao-road-strategist-a': 'hold', - 'liu-biao-road-leader-cao-ren': 'guard' + 'liu-biao-road-leader-cao-ren': 'guard', + 'wolong-road-scout-a': 'aggressive', + 'wolong-road-scout-b': 'aggressive', + 'wolong-road-infantry-a': 'guard', + 'wolong-road-infantry-b': 'guard', + 'wolong-road-infantry-c': 'guard', + 'wolong-road-infantry-d': 'guard', + 'wolong-road-archer-a': 'hold', + 'wolong-road-archer-b': 'hold', + 'wolong-road-archer-c': 'hold', + 'wolong-road-cavalry-a': 'aggressive', + 'wolong-road-cavalry-b': 'aggressive', + 'wolong-road-guard-a': 'guard', + 'wolong-road-guard-b': 'guard', + 'wolong-road-strategist-a': 'hold', + 'wolong-road-leader-cai-xun': 'guard' }; const defaultEnemyAiByClass: Partial> = { diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index 840969c..2107baa 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -11,6 +11,7 @@ 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 sixteenthBattleMapUrl from '../../assets/images/battle/sixteenth-battle-map.svg'; +import seventeenthBattleMapUrl from '../../assets/images/battle/seventeenth-battle-map.svg'; import tenthBattleMapUrl from '../../assets/images/battle/tenth-battle-map.svg'; import thirteenthBattleMapUrl from '../../assets/images/battle/thirteenth-battle-map.svg'; import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg'; @@ -90,6 +91,7 @@ export class BootScene extends Phaser.Scene { this.load.image('battle-map-fourteenth', fourteenthBattleMapUrl); this.load.image('battle-map-fifteenth', fifteenthBattleMapUrl); this.load.image('battle-map-sixteenth', sixteenthBattleMapUrl); + this.load.image('battle-map-seventeenth', seventeenthBattleMapUrl); this.load.image('portrait-liu-bei', liuBeiPortraitUrl); this.load.image('portrait-guan-yu', guanYuPortraitUrl); this.load.image('portrait-zhang-fei', zhangFeiPortraitUrl); @@ -132,6 +134,7 @@ export class BootScene extends Phaser.Scene { this.createItemIcon('item-green-dragon-glaive', (graphics) => this.drawPolearmIcon(graphics, 0x5fbf8f, 0xd8b15f)); this.createItemIcon('item-serpent-spear', (graphics) => this.drawSpearIcon(graphics, 0xe0e8ef, 0xb86b55)); this.createItemIcon('item-sky-piercer-halberd', (graphics) => this.drawPolearmIcon(graphics, 0xd8dfe6, 0xd95f4f)); + this.createItemIcon('item-white-feather-fan', (graphics) => this.drawFanIcon(graphics, 0xf3f0df, 0x6e8eb8)); this.createItemIcon('item-yellow-turban-saber', (graphics) => this.drawSaberIcon(graphics, 0xd8b15f, 0x9c6b2f)); this.createItemIcon('item-short-bow', (graphics) => this.drawBowIcon(graphics, 0xc58a4c, 0xe8dfca)); this.createItemIcon('item-leader-axe', (graphics) => this.drawAxeIcon(graphics, 0xd8dfe6, 0x8d5a3a)); @@ -258,6 +261,30 @@ export class BootScene extends Phaser.Scene { graphics.fillTriangle(16, 5, 9, 9, 15, 14); } + private drawFanIcon(graphics: Phaser.GameObjects.Graphics, featherColor: number, ribColor: number) { + graphics.lineStyle(2, ribColor, 1); + graphics.beginPath(); + graphics.moveTo(16, 25); + graphics.lineTo(5, 8); + graphics.moveTo(16, 25); + graphics.lineTo(10, 4); + graphics.moveTo(16, 25); + graphics.lineTo(16, 3); + graphics.moveTo(16, 25); + graphics.lineTo(22, 4); + graphics.moveTo(16, 25); + graphics.lineTo(27, 8); + graphics.strokePath(); + graphics.fillStyle(featherColor, 0.96); + graphics.fillEllipse(5, 9, 7, 15); + graphics.fillEllipse(10, 6, 7, 17); + graphics.fillEllipse(16, 5, 8, 18); + graphics.fillEllipse(22, 6, 7, 17); + graphics.fillEllipse(27, 9, 7, 15); + graphics.fillStyle(0xd8b15f, 1); + graphics.fillCircle(16, 25, 3); + } + private drawArmorIcon(graphics: Phaser.GameObjects.Graphics, baseColor: number, accentColor: number) { graphics.fillStyle(baseColor, 1); graphics.fillTriangle(14, 4, 24, 11, 20, 24); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 4aaeae1..7104705 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -13,6 +13,8 @@ import { liuBiaoRecruitUnits, xuzhouRecruitBonds, xuzhouRecruitUnits, + zhugeRecruitBonds, + zhugeRecruitUnits, type PortraitKey, type UnitData } from '../data/scenario'; @@ -218,7 +220,8 @@ const campBattleIds = { thirteenth: 'thirteenth-battle-xiapi-final', fourteenth: 'fourteenth-battle-cao-break', fifteenth: 'fifteenth-battle-yuan-refuge-road', - sixteenth: 'sixteenth-battle-liu-biao-refuge' + sixteenth: 'sixteenth-battle-liu-biao-refuge', + seventeenth: 'seventeenth-battle-wolong-visit-road' } as const; const requiredSortieUnitIds = new Set(['liu-bei']); @@ -385,6 +388,18 @@ const sortieRulesByBattleId: Partial= visits.length && visits.length > 0 ? '#a8ffd0' : '#d4dce6', true))); - const insightCount = this.inventoryAmount('와룡 소문') + this.inventoryAmount('민심 기록'); + const insightCount = this.wolongClueCount(); this.track(this.add.text(x + 14, y + 66, `제갈량 단서 ${insightCount} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true))); } @@ -3586,6 +3761,10 @@ export class CampScene extends Phaser.Scene { return this.campaign?.inventory[label] ?? 0; } + private wolongClueCount() { + return this.inventoryAmount('와룡 소문') + this.inventoryAmount('민심 기록'); + } + private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) { const track = this.track(this.add.rectangle(x, y, width, height, 0x070b10, 0.86)); track.setOrigin(0); diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 02b55b7..01c379c 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -13,6 +13,7 @@ import { seventhBattleScenario, sixthBattleScenario, sixteenthBattleScenario, + seventeenthBattleScenario, tenthBattleScenario, thirteenthBattleScenario, twelfthBattleScenario, @@ -331,7 +332,8 @@ export class TitleScene extends Phaser.Scene { campaign.step === 'thirteenth-camp' || campaign.step === 'fourteenth-camp' || campaign.step === 'fifteenth-camp' || - campaign.step === 'sixteenth-camp' + campaign.step === 'sixteenth-camp' || + campaign.step === 'seventeenth-camp' ) { this.scene.start('CampScene'); return; @@ -417,6 +419,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'seventeenth-battle') { + this.scene.start('BattleScene', { battleId: seventeenthBattleScenario.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 b92c981..177683d 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -72,7 +72,9 @@ export type CampaignStep = | 'fifteenth-battle' | 'fifteenth-camp' | 'sixteenth-battle' - | 'sixteenth-camp'; + | 'sixteenth-camp' + | 'seventeenth-battle' + | 'seventeenth-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; @@ -140,7 +142,8 @@ const campaignBattleSteps: Record