diff --git a/docs/roadmap.md b/docs/roadmap.md index 8690cad..c6f5057 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,13 +2,14 @@ ## Initial Goal -Build a small complete tactical RPG loop: +Build a small complete tactical RPG loop that can grow into a longer Romance of the Three Kingdoms campaign: 1. Title screen 2. Prologue story 3. First battle briefing 4. First battle 5. Battle result and save point +6. Repeatable story -> camp -> battle expansion path ## Current Slice @@ -17,15 +18,17 @@ Build a small complete tactical RPG loop: - Cinematic prologue pages with high-resolution story backgrounds, character portraits, non-black scene transitions, and scenario data - File-based original soft stereo orchestral BGM loops for title, prologue, oath, militia, and battle-prep scenes - First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, class and terrain affinity rules, ally/enemy roster tabs, unit detail stat panels, weapon/armor/accessory equipment growth, terrain-cost movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, right-click tactical menu, turn ending/reset, resonance bond tracking, and grayscale acted-unit feedback -- Flow verification script from title to first battle +- Second battle expansion with a larger scrollable map, mixed enemy AI behaviors, minimap navigation, camp progression, and save persistence +- Third battle expansion path toward Guangzong with a new battlefield, story bridge, campaign step persistence, and title continue support +- Flow verification script from title through the third battle victory and camp save state ## Next Steps -1. Implement attack range, damage, defeat, victory checks, and real resonance assist attacks -2. Apply treasure equipment effects to damage, defense, recovery, and command rules -3. Add strategy and item inventories with usable battle effects -4. Add a simple enemy AI turn -5. Add battle result scene and local save/load +1. Add the next Guangzong main battle and named Yellow Turban commanders +2. Broaden camp events so bonds, supplies, and equipment choices matter between battles +3. Apply treasure equipment effects to damage, defense, recovery, and command rules +4. Add more distinctive victory/defeat branches and optional objectives +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 49aaac6..4b6872e 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -418,6 +418,78 @@ try { throw new Error(`Expected campaign save to persist second battle victory: ${JSON.stringify(campaignSaveAfterSecondBattle)}`); } + await page.mouse.click(1120, 38); + await page.waitForTimeout(160); + const secondCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!secondCampSortieState?.sortieVisible) { + throw new Error(`Expected second camp sortie preparation overlay: ${JSON.stringify(secondCampSortieState)}`); + } + await page.mouse.click(938, 596); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('StoryScene'); + }); + + for (let i = 0; i < 14; i += 1) { + const isThirdBattleActive = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'third-battle-guangzong-road'; + }); + + if (isThirdBattleActive) { + break; + } + + await page.keyboard.press('Space'); + await page.waitForTimeout(220); + } + + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return state?.scene === 'BattleScene' && state?.battleId === 'third-battle-guangzong-road' && state?.battleOutcome === null && state?.phase === 'idle'; + }); + await page.screenshot({ path: 'dist/verification-third-battle.png', fullPage: true }); + + const thirdBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const thirdEnemies = thirdBattleState.units.filter((unit) => unit.faction === 'enemy'); + const thirdEnemyBehaviors = new Set(thirdEnemies.map((unit) => unit.ai)); + if ( + thirdBattleState.camera?.mapWidth !== 24 || + thirdBattleState.camera?.mapHeight !== 20 || + thirdBattleState.victoryConditionLabel !== '전령 마원 격파' || + thirdEnemies.length < 10 || + !thirdEnemyBehaviors.has('aggressive') || + !thirdEnemyBehaviors.has('guard') || + !thirdEnemyBehaviors.has('hold') + ) { + throw new Error(`Expected third battle map, objective, and mixed enemy AI: ${JSON.stringify(thirdBattleState)}`); + } + + 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 campaignSaveAfterThirdBattle = await page.evaluate(() => { + const raw = window.localStorage.getItem('heros-web:campaign-state'); + return raw ? JSON.parse(raw) : undefined; + }); + if ( + !campaignSaveAfterThirdBattle || + campaignSaveAfterThirdBattle.step !== 'third-camp' || + campaignSaveAfterThirdBattle.latestBattleId !== 'third-battle-guangzong-road' || + !campaignSaveAfterThirdBattle.battleHistory?.['third-battle-guangzong-road'] + ) { + throw new Error(`Expected campaign save to persist third battle victory: ${JSON.stringify(campaignSaveAfterThirdBattle)}`); + } + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -429,7 +501,7 @@ try { return activeScenes.includes('CampScene'); }); - console.log(`Verified title-to-second-battle flow, result states, and debug API at ${targetUrl}`); + console.log(`Verified title-to-third-battle flow, result states, and debug API at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { diff --git a/src/assets/images/battle/third-battle-map.svg b/src/assets/images/battle/third-battle-map.svg new file mode 100644 index 0000000..98d3633 --- /dev/null +++ b/src/assets/images/battle/third-battle-map.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 9ea837c..a25a72e 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -7,13 +7,17 @@ import { secondBattleMap, secondBattleUnits, secondBattleVictoryPages, + thirdBattleBonds, + thirdBattleMap, + thirdBattleUnits, + thirdBattleVictoryPages, type BattleBond, type BattleMap, type StoryPage, type UnitData } from './scenario'; -export type BattleScenarioId = 'first-battle-zhuo-commandery' | 'second-battle-yellow-turban-pursuit'; +export type BattleScenarioId = 'first-battle-zhuo-commandery' | 'second-battle-yellow-turban-pursuit' | 'third-battle-guangzong-road'; export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; @@ -158,11 +162,65 @@ export const secondBattleScenario: BattleScenarioDefinition = { nextCampScene: 'CampScene' }; +export const thirdBattleScenario: BattleScenarioDefinition = { + id: 'third-battle-guangzong-road', + title: '광종 구원로', + victoryConditionLabel: '전령 마원 격파', + defeatConditionLabel: '유비 퇴각', + openingObjectiveLines: [ + '황건 전령 마원이 광종 본대로 향하고 있습니다. 마원을 격파하면 적의 연락망이 끊어집니다.', + '강가 길목과 요새 주변의 궁병을 방치하면 전선이 오래 묶입니다. 지형 보정을 살펴 진군하십시오.', + '14턴 이내에 승리하면 광종 구원로 확보 명성이 오릅니다.' + ], + map: thirdBattleMap, + units: thirdBattleUnits, + bonds: thirdBattleBonds, + mapTextureKey: 'battle-map-third', + leaderUnitId: 'guangzong-leader-ma-yuan', + quickVictoryTurnLimit: 14, + baseVictoryGold: 520, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '전령 마원 격파', + rewardGold: 320, + unitId: 'guangzong-leader-ma-yuan' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 140, + unitId: 'liu-bei' + }, + { + id: 'fort', + kind: 'secure-terrain', + label: '강가 요새 확보', + rewardGold: 220, + terrain: 'fort' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '14턴 이내 승리', + rewardGold: 180, + maxTurn: 14 + } + ], + defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], + itemRewards: ['콩 2', '상처약 1', '의용군 명성 +3'], + victoryPages: thirdBattleVictoryPages, + nextCampScene: 'CampScene' +}; + export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; export const battleScenarios: Record = { 'first-battle-zhuo-commandery': firstBattleScenario, - 'second-battle-yellow-turban-pursuit': secondBattleScenario + 'second-battle-yellow-turban-pursuit': secondBattleScenario, + 'third-battle-guangzong-road': thirdBattleScenario }; export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 4c23b25..1d9325e 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -248,6 +248,69 @@ export const secondBattleVictoryPages: StoryPage[] = [ } ]; +export const thirdBattleIntroPages: StoryPage[] = [ + { + id: 'third-guangzong-report', + bgm: 'militia-theme', + chapter: '광종의 급보', + background: 'story-militia', + text: '탁현 북쪽의 소란을 잠재우자, 장터에는 더 큰 소식이 밀려왔다. 광종 방면에서 황건 본대가 관군을 압박하고 있다는 전갈이었다.' + }, + { + id: 'third-liu-bei-decision', + bgm: 'battle-prep', + chapter: '넓어지는 길', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '작은 마을만 지키고 물러선다면 난세는 끝나지 않는다. 광종으로 향하는 길을 열어, 더 많은 백성이 버틸 시간을 만들어야 한다.' + }, + { + id: 'third-guan-yu-bridge', + bgm: 'battle-prep', + chapter: '강가의 길목', + background: 'story-three-heroes', + speaker: '관우', + portrait: 'guanYu', + text: '적 전령이 강가 요새를 거쳐 본대로 향한다면, 그 길을 끊어야 합니다. 좁은 다리와 숲길을 먼저 장악하겠습니다.' + }, + { + id: 'third-zhang-fei-vanguard', + bgm: 'battle-prep', + chapter: '전초전', + background: 'story-sortie', + speaker: '장비', + portrait: 'zhangFei', + text: '전령이든 두령이든 내 앞길에 서면 다 똑같소! 형님, 명만 내리시오. 길목을 시원하게 열어 버리겠소!' + } +]; + +export const thirdBattleVictoryPages: StoryPage[] = [ + { + id: 'third-victory-road', + bgm: 'militia-theme', + chapter: '열린 길목', + background: 'story-sortie', + text: '황건 전령의 깃발이 꺾이자 광종으로 향하는 길목에 잠시 숨통이 트였다. 흩어진 백성들은 의용군의 이름을 기억하기 시작했다.' + }, + { + id: 'third-victory-liu-bei', + bgm: 'militia-theme', + chapter: '큰 싸움의 그림자', + background: 'story-liu-bei', + speaker: '유비', + portrait: 'liuBei', + text: '우리가 막은 것은 작은 전갈 하나일 뿐이다. 그러나 작은 길 하나가 열리면, 다음 전장에서도 백성을 구할 길이 생긴다.' + }, + { + id: 'third-victory-next', + bgm: 'battle-prep', + chapter: '광종으로', + background: 'story-militia', + text: '세 형제는 병사를 추스르고 광종 방면으로 눈을 돌렸다. 황건의 큰 물결은 아직 꺾이지 않았다.' + } +]; + export const firstBattleMap: BattleMap = { width: 20, height: 18, @@ -300,6 +363,33 @@ export const secondBattleMap: BattleMap = { ] }; +export const thirdBattleMap: BattleMap = { + width: 24, + height: 20, + terrain: [ + ['forest', 'forest', 'hill', 'plain', 'plain', 'road', 'plain', 'forest', 'hill', 'plain', 'river', 'river', 'plain', 'forest', 'forest', 'hill', 'hill', 'plain', 'fort', 'fort', 'plain', 'forest', 'forest', 'hill'], + ['forest', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'forest', 'plain', 'hill', 'plain', 'fort', 'plain', 'plain', 'plain', 'forest', 'forest'], + ['hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'road', 'road', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'forest'], + ['plain', 'plain', 'forest', 'road', 'plain', 'plain', 'forest', 'plain', 'hill', 'road', 'river', 'river', 'road', 'road', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain'], + ['plain', 'forest', 'forest', 'road', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'river', 'river', 'plain', 'road', 'plain', 'plain', 'hill', 'cliff', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain'], + ['plain', 'plain', 'forest', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'plain', 'river', 'river', 'road', 'plain', 'village', 'plain', 'hill', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain'], + ['plain', 'plain', 'plain', 'plain', 'road', 'plain', 'hill', 'hill', 'plain', 'road', 'plain', 'river', 'river', 'road', 'plain', 'village', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'forest', 'plain'], + ['plain', 'forest', 'plain', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain'], + ['forest', 'forest', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain'], + ['forest', 'plain', 'plain', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain'], + ['plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain'], + ['plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'village', 'plain', 'plain', 'plain', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain'], + ['hill', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'village', 'plain', 'forest', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain'], + ['hill', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain'], + ['plain', 'road', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain'], + ['camp', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'hill'], + ['camp', 'camp', 'road', 'plain', 'forest', 'plain', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'hill'], + ['plain', 'camp', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'hill', 'plain', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain'], + ['plain', 'plain', 'camp', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'plain'], + ['plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'plain'] + ] +}; + export const firstBattleUnits: UnitData[] = [ { id: 'liu-bei', @@ -840,6 +930,270 @@ export const secondBattleUnits: UnitData[] = [ } ]; +const thirdBattleAllyPositions: Record = { + 'liu-bei': { x: 2, y: 17 }, + 'guan-yu': { x: 3, y: 17 }, + 'zhang-fei': { x: 2, y: 18 } +}; + +export const thirdBattleUnits: UnitData[] = [ + ...firstBattleUnits + .filter((unit) => unit.faction === 'ally') + .map((unit) => placeScenarioUnit(unit, thirdBattleAllyPositions[unit.id] ?? { x: unit.x, y: unit.y })), + { + id: 'guangzong-scout-a', + name: '황건 척후병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 3, + exp: 18, + hp: 25, + maxHp: 25, + attack: 8, + move: 3, + stats: { might: 58, intelligence: 36, leadership: 45, agility: 52, luck: 43 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 8 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 6, + y: 15 + }, + { + id: 'guangzong-bandit-a', + name: '숲길 복병', + faction: 'enemy', + className: '도적', + classKey: 'bandit', + level: 3, + exp: 20, + hp: 26, + maxHp: 26, + attack: 9, + move: 3, + stats: { might: 62, intelligence: 34, leadership: 42, agility: 58, luck: 46 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 8 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 8, + y: 13 + }, + { + id: 'guangzong-archer-a', + name: '강가 궁병', + faction: 'enemy', + className: '궁병', + classKey: 'archer', + level: 3, + exp: 20, + hp: 22, + maxHp: 22, + attack: 9, + move: 3, + stats: { might: 57, intelligence: 45, leadership: 47, agility: 60, luck: 48 }, + equipment: { + weapon: { itemId: 'short-bow', level: 1, exp: 20 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 8 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 11, + y: 14 + }, + { + id: 'guangzong-cavalry-a', + name: '황건 기병', + faction: 'enemy', + className: '경기병', + classKey: 'cavalry', + level: 4, + exp: 22, + hp: 29, + maxHp: 29, + attack: 10, + move: 5, + stats: { might: 66, intelligence: 36, leadership: 50, agility: 68, luck: 48 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 22 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 9 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 12, + y: 11 + }, + { + id: 'guangzong-raider-a', + name: '황건 장정', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 3, + exp: 18, + hp: 26, + maxHp: 26, + attack: 8, + move: 3, + stats: { might: 59, intelligence: 35, leadership: 46, agility: 51, luck: 44 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 8 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 10, + y: 9 + }, + { + id: 'guangzong-raider-b', + name: '황건 장정', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 3, + exp: 18, + hp: 26, + maxHp: 26, + attack: 8, + move: 3, + stats: { might: 59, intelligence: 35, leadership: 46, agility: 51, luck: 44 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 18 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 8 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 14, + y: 10 + }, + { + id: 'guangzong-archer-b', + name: '요새 궁병', + faction: 'enemy', + className: '궁병', + classKey: 'archer', + level: 4, + exp: 24, + hp: 23, + maxHp: 23, + attack: 9, + move: 3, + stats: { might: 58, intelligence: 47, leadership: 48, agility: 61, luck: 49 }, + equipment: { + weapon: { itemId: 'short-bow', level: 1, exp: 24 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 9 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 15, + y: 7 + }, + { + id: 'guangzong-cavalry-b', + name: '전령 기병', + faction: 'enemy', + className: '경기병', + classKey: 'cavalry', + level: 4, + exp: 25, + hp: 30, + maxHp: 30, + attack: 10, + move: 5, + stats: { might: 67, intelligence: 38, leadership: 51, agility: 70, luck: 50 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 25 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 10 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 17, + y: 6 + }, + { + id: 'guangzong-guard-a', + name: '요새 수비병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 4, + exp: 22, + hp: 28, + maxHp: 28, + attack: 9, + move: 3, + stats: { might: 62, intelligence: 37, leadership: 50, agility: 52, luck: 46 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 22 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 10 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 18, + y: 4 + }, + { + id: 'guangzong-guard-b', + name: '요새 수비병', + faction: 'enemy', + className: '황건병', + classKey: 'yellowTurban', + level: 4, + exp: 22, + hp: 28, + maxHp: 28, + attack: 9, + move: 3, + stats: { might: 62, intelligence: 37, leadership: 50, agility: 52, luck: 46 }, + equipment: { + weapon: { itemId: 'yellow-turban-saber', level: 1, exp: 22 }, + armor: { itemId: 'rebel-vest', level: 1, exp: 10 }, + accessory: { itemId: 'yellow-scarf-charm', level: 1, exp: 0 } + }, + x: 20, + y: 4 + }, + { + id: 'guangzong-fort-archer', + name: '망루 궁병', + faction: 'enemy', + className: '궁병', + classKey: 'archer', + level: 4, + exp: 25, + hp: 24, + maxHp: 24, + attack: 10, + move: 3, + stats: { might: 60, intelligence: 49, leadership: 49, agility: 61, luck: 50 }, + equipment: { + weapon: { itemId: 'short-bow', level: 1, exp: 25 }, + armor: { itemId: 'cloth-armor', level: 1, exp: 10 }, + accessory: { itemId: 'wind-quiver', level: 1, exp: 0 } + }, + x: 19, + y: 2 + }, + { + id: 'guangzong-leader-ma-yuan', + name: '전령 마원', + faction: 'enemy', + className: '두령', + classKey: 'rebelLeader', + level: 5, + exp: 30, + hp: 42, + maxHp: 42, + attack: 12, + move: 3, + stats: { might: 72, intelligence: 50, leadership: 68, agility: 56, luck: 54 }, + equipment: { + weapon: { itemId: 'leader-axe', level: 2, exp: 8 }, + armor: { itemId: 'lamellar-armor', level: 1, exp: 18 }, + accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } + }, + x: 20, + y: 2 + } +]; + export const firstBattleBonds: BattleBond[] = [ { id: 'liu-bei__guan-yu', @@ -868,6 +1222,7 @@ export const firstBattleBonds: BattleBond[] = [ ]; export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); +export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario); function placeScenarioUnit(unit: UnitData, position: { x: number; y: number }): UnitData { return { diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 4c42961..df72327 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -51,6 +51,17 @@ const unitTexture: Record = { 'pursuit-leader-han-seok': 'unit-rebel-leader' }; +const unitTextureByClass: Partial> = { + lord: 'unit-liu-bei', + infantry: 'unit-guan-yu', + spearman: 'unit-zhang-fei', + archer: 'unit-rebel-archer', + cavalry: 'unit-rebel-cavalry', + bandit: 'unit-rebel', + yellowTurban: 'unit-rebel', + rebelLeader: 'unit-rebel-leader' +}; + const unitFrameRows: Record = { south: 0, east: 1, @@ -806,7 +817,7 @@ export class BattleScene extends Phaser.Scene { private drawUnits() { battleUnits.forEach((unit) => { const sizeRatio = unit.faction === 'ally' ? 1.16 : 1.1; - const textureBase = unitTexture[unit.id] ?? 'unit-rebel'; + const textureBase = unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel'; const sprite = this.add.sprite(this.tileCenterX(unit.x), this.tileCenterY(unit.y), textureBase, this.unitFrameIndex('south')); sprite.setName(`unit-${unit.id}`); sprite.setDisplaySize(this.layout.tileSize * sizeRatio, this.layout.tileSize * sizeRatio); diff --git a/src/game/scenes/BootScene.ts b/src/game/scenes/BootScene.ts index e992b61..6bd2c8d 100644 --- a/src/game/scenes/BootScene.ts +++ b/src/game/scenes/BootScene.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png'; import secondBattleMapUrl from '../../assets/images/battle/second-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'; import zhangFeiPortraitUrl from '../../assets/images/portraits/zhang-fei.png'; @@ -62,6 +63,7 @@ export class BootScene extends Phaser.Scene { this.load.image('story-sortie', storySortieUrl); this.load.image('battle-map-first', firstBattleMapUrl); this.load.image('battle-map-second', secondBattleMapUrl); + this.load.image('battle-map-third', thirdBattleMapUrl); 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 b6e6c3e..39f6c77 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -2,8 +2,8 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; import { getUnitClass } from '../data/battleRules'; -import { defaultBattleScenario, secondBattleScenario } from '../data/battles'; -import { firstBattleBonds, firstBattleUnits, secondBattleIntroPages, type PortraitKey, type UnitData } from '../data/scenario'; +import { defaultBattleScenario, secondBattleScenario, thirdBattleScenario } from '../data/battles'; +import { firstBattleBonds, firstBattleUnits, secondBattleIntroPages, thirdBattleIntroPages, type PortraitKey, type UnitData } from '../data/scenario'; import { applyCampBondExp, getCampaignState, @@ -398,17 +398,30 @@ export class CampScene extends Phaser.Scene { bg.setStrokeStyle(1, palette.gold, 0.4); const completedDialogues = this.completedCampDialogues().length; const inventory = this.inventoryLabels().join(', '); - const reward = this.campaign?.latestBattleId === secondBattleScenario.id ? '예상 보상: 다음 장 개방' : '예상 보상: 군자금, 소모품, 의용군 명성'; + const reward = + this.campaign?.latestBattleId === thirdBattleScenario.id + ? '다음 장: 광종 본전 준비 중' + : this.campaign?.latestBattleId === secondBattleScenario.id + ? `예상 보상: ${thirdBattleScenario.title} 개방` + : '예상 보상: 군자금, 소모품, 의용군 명성'; 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}/${campDialogues.length} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1); } private nextSortieBriefing() { + if (this.campaign?.latestBattleId === thirdBattleScenario.id) { + return { + eyebrow: '다음 장 준비', + title: '광종 본전', + description: '광종 구원로는 열렸지만 황건 본대는 아직 건재합니다. 다음 작업에서 광종 본전과 더 큰 전장 목표를 이어 붙일 수 있습니다.' + }; + } + if (this.campaign?.latestBattleId === secondBattleScenario.id) { return { - eyebrow: '다음 이야기', - title: '넓어지는 전장', - description: '황건 잔당 추격을 마친 의용군은 탁현을 넘어 더 큰 난세의 흐름을 마주합니다. 다음 장으로 향하기 전 전투 결과를 정리합니다.' + eyebrow: '다음 전장', + title: thirdBattleScenario.title, + description: '탁현을 넘어 광종으로 향하는 길목에서 황건 전령이 관군의 움직임을 본대로 전하려 합니다. 강가 요새와 좁은 길을 돌파해야 합니다.' }; } @@ -471,8 +484,19 @@ export class CampScene extends Phaser.Scene { } private startVictoryStory() { + if (this.campaign?.latestBattleId === thirdBattleScenario.id) { + this.hideSortiePrep(); + this.showCampNotice('광종 본전은 다음 장으로 이어집니다. 군영에서 성장 상태를 정비하세요.'); + return; + } + if (this.campaign?.latestBattleId === secondBattleScenario.id) { - this.scene.start('StoryScene', { pages: secondBattleScenario.victoryPages, nextScene: 'TitleScene' }); + markCampaignStep('third-battle'); + this.scene.start('StoryScene', { + pages: [...secondBattleScenario.victoryPages, ...thirdBattleIntroPages], + nextScene: 'BattleScene', + nextSceneData: { battleId: thirdBattleScenario.id } + }); return; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 5799b45..af2c1c6 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -1,7 +1,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { firstBattleVictoryPages } from '../data/scenario'; -import { secondBattleScenario } from '../data/battles'; +import { secondBattleScenario, thirdBattleScenario } from '../data/battles'; import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState'; import { palette } from '../ui/palette'; @@ -299,7 +299,7 @@ export class TitleScene extends Phaser.Scene { soundDirector.playSelect(); const campaign = loadCampaignState(); - if (campaign.step === 'first-camp' || campaign.step === 'second-camp') { + if (campaign.step === 'first-camp' || campaign.step === 'second-camp' || campaign.step === 'third-camp') { this.scene.start('CampScene'); return; } @@ -314,6 +314,11 @@ export class TitleScene extends Phaser.Scene { return; } + if (campaign.step === 'third-battle') { + this.scene.start('BattleScene', { battleId: thirdBattleScenario.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 e2f1428..4acecf0 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -36,7 +36,16 @@ export type FirstBattleReport = { createdAt: string; }; -export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story' | 'second-battle' | 'second-camp'; +export type CampaignStep = + | 'new' + | 'prologue' + | 'first-battle' + | 'first-camp' + | 'first-victory-story' + | 'second-battle' + | 'second-camp' + | 'third-battle' + | 'third-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; @@ -86,6 +95,12 @@ export type CampaignState = { export const campaignStorageKey = 'heros-web:campaign-state'; export const campaignSaveSlotCount = 3; +const campaignBattleSteps: Record = { + 'first-battle-zhuo-commandery': { victory: 'first-camp', retry: 'first-battle' }, + 'second-battle-yellow-turban-pursuit': { victory: 'second-camp', retry: 'second-battle' }, + 'third-battle-guangzong-road': { victory: 'third-camp', retry: 'third-battle' } +}; + export type CampaignSaveSlotSummary = { slot: number; exists: boolean; @@ -191,8 +206,9 @@ export function setFirstBattleReport(report: FirstBattleReport) { reportClone.completedCampDialogues = completedCampDialogues; state.firstBattleReport = reportClone; - const victoryStep: CampaignStep = battleId === 'second-battle-yellow-turban-pursuit' ? 'second-camp' : 'first-camp'; - const retryStep: CampaignStep = battleId === 'second-battle-yellow-turban-pursuit' ? 'second-battle' : 'first-battle'; + const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery']; + const victoryStep: CampaignStep = stepRule.victory; + const retryStep: CampaignStep = stepRule.retry; state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep; state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold); state.roster = reportClone.units.filter((unit) => unit.faction === 'ally').map(cloneUnit);