diff --git a/docs/roadmap.md b/docs/roadmap.md index d108f0e..4b8ac6b 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 +- 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, and camp save state +- 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 ## Next Steps -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 +1. Start the Red Cliffs preparation chapter with Zhuge Liang's strategic role visible in sortie and camp choices +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 5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index e26f711..3183d5c 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -294,7 +294,7 @@ try { }); campScene.render(); }); - await page.mouse.click(956, 38); + await page.mouse.click(888, 38); await page.waitForTimeout(160); await page.screenshot({ path: 'dist/verification-camp-supplies.png', fullPage: true }); await page.mouse.click(1120, 245); @@ -321,7 +321,7 @@ try { throw new Error(`Expected merchant purchase to add bean and spend gold: ${JSON.stringify({ goldBeforeMerchant, campStateAfterMerchant })}`); } - await page.mouse.click(784, 38); + await page.mouse.click(732, 38); await page.waitForTimeout(120); await page.mouse.click(974, 482); await page.waitForTimeout(260); @@ -1613,7 +1613,7 @@ try { throw new Error(`Expected sixteenth camp to expose Liu Biao refuge dialogue/visit sets and preserve Zhao Yun in roster: ${JSON.stringify(sixteenthCampState)}`); } - await page.mouse.click(870, 38); + await page.mouse.click(810, 38); await page.waitForTimeout(160); const sixteenthVisitTabState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -1730,6 +1730,20 @@ try { } await page.screenshot({ path: 'dist/verification-zhuge-recruit-camp.png', fullPage: true }); + await page.mouse.click(966, 38); + await page.waitForTimeout(180); + const progressTabState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + progressTabState?.activeTab !== 'progress' || + progressTabState.campaignProgress?.completedKnown !== progressTabState.campaignProgress?.totalKnown || + progressTabState.campaignProgress?.activeChapter?.title !== '적벽대전' || + progressTabState.campaignProgress?.latestBattleTitle !== '융중 방문로' || + progressTabState.campaignProgress?.nextBattleTitle !== '준비 중' + ) { + throw new Error(`Expected progress tab to summarize completed known campaign and point toward Red Cliffs: ${JSON.stringify(progressTabState?.campaignProgress)}`); + } + await page.screenshot({ path: 'dist/verification-campaign-progress.png', fullPage: true }); + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 7104705..c91f79b 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -31,7 +31,7 @@ import { } from '../state/campaignState'; import { palette } from '../ui/palette'; -type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies'; +type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies' | 'progress'; type CampDialogue = { id: string; @@ -145,6 +145,15 @@ type SortiePlanSummary = { warnings: string[]; }; +type CampaignTimelineChapter = { + id: string; + title: string; + period: string; + description: string; + battleIds: BattleScenarioId[]; + nextHints: string[]; +}; + const portraitKeys: Record = { liuBei: 'portrait-liu-bei', guanYu: 'portrait-guan-yu', @@ -204,6 +213,95 @@ const merchantItems: MerchantItemDefinition[] = [ } ]; +const campaignTimelineChapters: CampaignTimelineChapter[] = [ + { + id: 'yellow-turban', + title: '황건적 토벌', + period: '의용군의 시작', + description: '탁현에서 뜻을 세운 세 형제가 황건적을 토벌하며 이름과 명분을 얻는 초반 장입니다.', + battleIds: [ + 'first-battle-zhuo-commandery', + 'second-battle-yellow-turban-pursuit', + 'third-battle-guangzong-road', + 'fourth-battle-guangzong-camp' + ], + nextHints: ['의용군 명성', '세 형제 공명', '황건 토벌 공적'] + }, + { + id: 'anti-dong-gongsun', + title: '반동탁과 공손찬 의탁', + period: '난세의 큰 흐름', + description: '반동탁 연합의 바깥에서 공손찬 진영의 신뢰를 얻고, 서주 원군 요청으로 이어지는 장입니다.', + battleIds: ['fifth-battle-sishui-vanguard', 'sixth-battle-jieqiao-relief'], + nextHints: ['공손찬 신뢰', '북방 전선', '서주 원군 요청'] + }, + { + id: 'xuzhou-lubu', + title: '서주 인수와 여포의 그림자', + period: '얻고 잃는 땅', + description: '도겸에게 서주를 맡고, 여포를 의탁시킨 뒤 내부 불안과 배신으로 서주를 잃는 장입니다.', + battleIds: [ + 'seventh-battle-xuzhou-rescue', + 'eighth-battle-xiaopei-supply-road', + 'ninth-battle-xuzhou-gate-night-raid', + 'tenth-battle-xuzhou-breakout' + ], + nextHints: ['간옹·미축 합류', '서주 민심', '여포와의 긴장'] + }, + { + id: 'cao-refuge-xiapi', + title: '조조 의탁과 하비 결전', + period: '의탁 속의 경계', + description: '조조에게 몸을 의탁하지만 독자 명분을 잃지 않으며, 하비에서 여포의 운명을 마무리하는 장입니다.', + battleIds: [ + 'eleventh-battle-xudu-refuge-road', + 'twelfth-battle-xiapi-outer-siege', + 'thirteenth-battle-xiapi-final' + ], + nextHints: ['조조군 감시', '하비 포위', '여포 격파'] + }, + { + id: 'break-cao-yuan', + title: '조조 이탈과 원소 의탁', + period: '다시 북쪽으로', + description: '조조의 장막을 벗어나 원소에게 향하지만, 객군의 한계와 다음 의탁의 필요를 확인하는 장입니다.', + battleIds: ['fourteenth-battle-cao-break', 'fifteenth-battle-yuan-refuge-road'], + nextHints: ['손건 합류', '원소 접선', '객군의 불안'] + }, + { + id: 'liu-biao-wolong', + title: '유표 의탁과 제갈량 영입', + period: '와룡을 찾아', + description: '형주에 머물며 조운이 재합류하고, 와룡의 단서를 좇아 제갈량을 얻는 전환점입니다.', + battleIds: ['sixteenth-battle-liu-biao-refuge', 'seventeenth-battle-wolong-visit-road'], + nextHints: ['조운 재합류', '와룡 단서', '천하삼분'] + }, + { + id: 'red-cliffs', + title: '적벽대전', + period: '강동의 바람', + description: '제갈량의 계책과 강동의 힘이 맞물려 조조의 대군을 막아서는 큰 전환 장입니다.', + battleIds: [], + nextHints: ['강동 사절', '화공 준비', '조조 남하'] + }, + { + id: 'jing-yi', + title: '형주·익주 확보', + period: '근본을 세우다', + description: '형주와 익주를 기반으로 삼아 유비군이 유랑군에서 국가의 뿌리로 바뀌는 장입니다.', + battleIds: [], + nextHints: ['형주 방위', '익주 진입', '장수 대거 합류'] + }, + { + id: 'shu-han', + title: '촉한 건국', + period: '한실을 잇는 깃발', + description: '여러 전장을 지나 촉한을 세우고 북벌을 바라보는 장기 목표입니다.', + battleIds: [], + nextHints: ['왕업 선언', '오호대장군', '북벌 준비'] + } +]; + const campBattleIds = { first: defaultBattleScenario.id, second: 'second-battle-yellow-turban-pursuit', @@ -2203,10 +2301,11 @@ export class CampScene extends Phaser.Scene { } private renderStaticButtons() { - this.addTabButton('장수', 'status', 698, 38); - this.addTabButton('대화', 'dialogue', 784, 38); - this.addTabButton('방문', 'visit', 870, 38); - this.addTabButton('정비', 'supplies', 956, 38); + this.addTabButton('장수', 'status', 654, 38); + this.addTabButton('대화', 'dialogue', 732, 38); + this.addTabButton('방문', 'visit', 810, 38); + this.addTabButton('정비', 'supplies', 888, 38); + this.addTabButton('연표', 'progress', 966, 38); this.addCommandButton('저장', 1044, 38, 76, () => { soundDirector.playSelect(); this.campaign = saveCampaignState(getCampaignState()); @@ -2296,6 +2395,11 @@ export class CampScene extends Phaser.Scene { return; } + if (this.activeTab === 'progress') { + this.renderProgressPanel(); + return; + } + this.renderStatusPanel(); } @@ -2972,6 +3076,175 @@ export class CampScene extends Phaser.Scene { ); } + private renderProgressPanel() { + const x = 394; + const y = 120; + const width = 828; + const height = 444; + const progress = this.campaignTimelineProgress(); + const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.9)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.gold, 0.56); + + this.track(this.add.text(x + 24, y + 22, '군영 연표', this.textStyle(24, '#f2e3bf', true))); + this.track(this.add.text(x + 24, y + 56, '유비군의 장기 서사와 현재 위치를 확인합니다.', this.textStyle(14, '#d4dce6'))); + + const statY = y + 82; + this.renderProgressStatCard('완료 전투', `${progress.completedKnown}/${progress.totalKnown}`, x + 24, statY, 176); + this.renderProgressStatCard('현재 장', progress.activeChapter.title, x + 212, statY, 198); + this.renderProgressStatCard('최근 전투', progress.latestBattleTitle, x + 422, statY, 176); + this.renderProgressStatCard('다음 전장', progress.nextBattleTitle, x + 610, statY, 190); + + const completedIds = this.completedBattleIds(); + const listX = x + 24; + const listY = y + 142; + this.track(this.add.text(listX, listY, '큰 흐름', this.textStyle(17, '#f2e3bf', true))); + campaignTimelineChapters.forEach((chapter, index) => { + const status = this.timelineChapterStatus(chapter, index, progress.activeChapterIndex, completedIds); + const rowY = listY + 30 + index * 31; + const active = index === progress.activeChapterIndex; + const complete = status === '완료'; + const row = this.track(this.add.rectangle(listX, rowY, 360, 27, active ? 0x25384a : complete ? 0x17231d : 0x151f2a, active ? 0.96 : 0.84)); + row.setOrigin(0); + row.setStrokeStyle(1, active ? palette.gold : complete ? palette.green : palette.blue, active ? 0.7 : 0.34); + const markerColor = active ? '#fff2b8' : complete ? '#a8ffd0' : '#9fb0bf'; + this.track(this.add.text(listX + 12, rowY + 5, status, this.textStyle(11, markerColor, true))); + this.track(this.add.text(listX + 64, rowY + 4, chapter.title, this.textStyle(13, active ? '#f2e3bf' : '#d4dce6', active))); + const countText = chapter.battleIds.length > 0 + ? `${chapter.battleIds.filter((id) => completedIds.has(id)).length}/${chapter.battleIds.length}` + : '예정'; + const count = this.track(this.add.text(listX + 342, rowY + 5, countText, this.textStyle(11, markerColor, true))); + count.setOrigin(1, 0); + }); + + this.renderProgressChapterDetail(progress.activeChapter, progress.activeChapterIndex, x + 410, listY, 390, 310, completedIds); + } + + private renderProgressStatCard(label: string, value: string, x: number, y: number, width: number) { + const bg = this.track(this.add.rectangle(x, y, width, 44, 0x0d141c, 0.92)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.blue, 0.4); + this.track(this.add.text(x + 12, y + 8, label, this.textStyle(11, '#9fb0bf', true))); + const valueText = this.track(this.add.text(x + width - 12, y + 21, this.compactText(value, width > 190 ? 14 : 11), this.textStyle(14, '#f2e3bf', true))); + valueText.setOrigin(1, 0); + } + + private renderProgressChapterDetail( + chapter: CampaignTimelineChapter, + chapterIndex: number, + x: number, + y: number, + width: number, + height: number, + completedIds: Set + ) { + const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.gold, 0.48); + const status = this.timelineChapterStatus(chapter, chapterIndex, this.campaignTimelineProgress().activeChapterIndex, completedIds); + this.track(this.add.text(x + 18, y + 16, chapter.title, this.textStyle(21, '#f2e3bf', true))); + const statusText = this.track(this.add.text(x + width - 18, y + 19, status, this.textStyle(13, this.timelineStatusColor(status), true))); + statusText.setOrigin(1, 0); + this.track(this.add.text(x + 18, y + 48, chapter.period, this.textStyle(13, '#d8b15f', true))); + this.track( + this.add.text(x + 18, y + 74, chapter.description, { + ...this.textStyle(13, '#d4dce6'), + wordWrap: { width: width - 36, useAdvancedWrap: true }, + lineSpacing: 2 + }) + ); + + const completedCount = chapter.battleIds.filter((id) => completedIds.has(id)).length; + const total = chapter.battleIds.length; + const ratio = total > 0 ? completedCount / total : status === '다음' ? 0.08 : 0; + this.track(this.add.text(x + 18, y + 136, total > 0 ? `전투 진행 ${completedCount}/${total}` : '아직 전투가 편성되지 않은 장입니다.', this.textStyle(13, '#f2e3bf', true))); + this.drawBar(x + 18, y + 162, width - 36, 8, ratio, status === '완료' ? palette.green : palette.gold); + + if (chapter.battleIds.length > 0) { + chapter.battleIds.forEach((battleId, index) => { + const battle = getBattleScenario(battleId); + const complete = completedIds.has(battleId); + const rowY = y + 190 + index * 28; + const row = this.track(this.add.rectangle(x + 18, rowY, width - 36, 24, complete ? 0x17231d : 0x151f2a, 0.86)); + row.setOrigin(0); + row.setStrokeStyle(1, complete ? palette.green : palette.blue, complete ? 0.48 : 0.32); + this.track(this.add.text(x + 30, rowY + 4, complete ? '완료' : '대기', this.textStyle(10, complete ? '#a8ffd0' : '#9fb0bf', true))); + this.track(this.add.text(x + 78, rowY + 3, battle.title, this.textStyle(12, complete ? '#d4dce6' : '#f2e3bf', !complete))); + const objective = this.track(this.add.text(x + width - 30, rowY + 4, battle.victoryConditionLabel, this.textStyle(10, '#9fb0bf', true))); + objective.setOrigin(1, 0); + }); + return; + } + + chapter.nextHints.forEach((hint, index) => { + const rowY = y + 190 + index * 30; + const dot = this.track(this.add.circle(x + 28, rowY + 11, 4, status === '다음' ? palette.gold : palette.blue, status === '다음' ? 0.92 : 0.48)); + dot.setStrokeStyle(1, 0x05070a, 0.6); + this.track(this.add.text(x + 44, rowY + 3, hint, this.textStyle(13, status === '다음' ? '#f2e3bf' : '#9fb0bf', status === '다음'))); + }); + } + + private campaignTimelineProgress() { + const completedIds = this.completedBattleIds(); + const totalKnown = campaignTimelineChapters.reduce((sum, chapter) => sum + chapter.battleIds.length, 0); + const completedKnown = campaignTimelineChapters.reduce( + (sum, chapter) => sum + chapter.battleIds.filter((id) => completedIds.has(id)).length, + 0 + ); + const activeChapterIndex = this.activeTimelineChapterIndex(completedIds); + const activeChapter = campaignTimelineChapters[activeChapterIndex]; + const latestBattleId = this.campaign?.latestBattleId ?? this.report?.battleId; + const nextBattleId = getSortieFlow(this.campaign?.latestBattleId).nextBattleId; + return { + completedKnown, + totalKnown, + activeChapterIndex, + activeChapter, + latestBattleTitle: latestBattleId ? getBattleScenario(latestBattleId).title : '없음', + nextBattleTitle: nextBattleId ? getBattleScenario(nextBattleId).title : '준비 중' + }; + } + + private activeTimelineChapterIndex(completedIds = this.completedBattleIds()) { + const index = campaignTimelineChapters.findIndex((chapter) => { + if (chapter.battleIds.length === 0) { + return true; + } + return chapter.battleIds.some((battleId) => !completedIds.has(battleId)); + }); + return index >= 0 ? index : campaignTimelineChapters.length - 1; + } + + private timelineChapterStatus(chapter: CampaignTimelineChapter, index: number, activeIndex: number, completedIds = this.completedBattleIds()) { + if (index < activeIndex) { + return '완료'; + } + if (index > activeIndex) { + return '예정'; + } + if (chapter.battleIds.length === 0) { + return '다음'; + } + return '진행'; + } + + private timelineStatusColor(status: string) { + if (status === '완료') { + return '#a8ffd0'; + } + if (status === '다음') { + return '#fff2b8'; + } + if (status === '진행') { + return '#d8b15f'; + } + return '#9fb0bf'; + } + + private completedBattleIds() { + return new Set(Object.keys(this.campaign?.battleHistory ?? {})); + } + private renderStatusPanel() { const unit = this.selectedUnit(); if (!unit) { @@ -3827,6 +4100,7 @@ export class CampScene extends Phaser.Scene { }; }), sortiePlan: this.sortiePlanSummary(), + campaignProgress: this.campaignTimelineProgress(), campBattleId: this.currentCampBattleId(), campTitle: this.currentCampTitle(), availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),