diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index dec54b4..b358a64 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -265,6 +265,21 @@ try { throw new Error(`Expected campaign slot 1 to persist progress: ${JSON.stringify(campaignSlotAfterDialogue)}`); } + await page.mouse.click(1120, 38); + await page.waitForTimeout(180); + const campStateWithSortiePrep = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!campStateWithSortiePrep?.sortieVisible) { + throw new Error(`Expected sortie preparation overlay from next story button: ${JSON.stringify(campStateWithSortiePrep)}`); + } + await page.screenshot({ path: 'dist/verification-camp-sortie.png', fullPage: true }); + + await page.mouse.click(792, 596); + await page.waitForTimeout(140); + const campStateAfterSortieClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (campStateAfterSortieClose?.sortieVisible) { + throw new Error(`Expected sortie preparation overlay to close: ${JSON.stringify(campStateAfterSortieClose)}`); + } + 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 da659ec..226d033 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -40,6 +40,12 @@ type CampSupplyDefinition = { bondExp: number; }; +type SortieChecklistItem = { + label: string; + complete: boolean; + detail: string; +}; + const portraitKeys: Record = { liuBei: 'portrait-liu-bei', guanYu: 'portrait-guan-yu', @@ -118,7 +124,9 @@ export class CampScene extends Phaser.Scene { private selectedDialogueId = campDialogues[0].id; private contentObjects: Phaser.GameObjects.GameObject[] = []; private dialogueObjects: Phaser.GameObjects.GameObject[] = []; + private sortieObjects: Phaser.GameObjects.GameObject[] = []; private tabButtons: CampTabButtonView[] = []; + private visitedTabs = new Set(); constructor() { super('CampScene'); @@ -127,7 +135,9 @@ export class CampScene extends Phaser.Scene { create() { this.contentObjects = []; this.dialogueObjects = []; + this.sortieObjects = []; this.tabButtons = []; + this.visitedTabs = new Set(['status']); this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei'; @@ -197,8 +207,7 @@ export class CampScene extends Phaser.Scene { }); this.addCommandButton('다음 이야기', 1120, 38, 142, () => { soundDirector.playSelect(); - markCampaignStep('first-victory-story'); - this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); + this.showSortiePrep(); }); } @@ -259,6 +268,7 @@ export class CampScene extends Phaser.Scene { private render() { this.clearContent(); + this.visitedTabs.add(this.activeTab); this.updateTabButtons(); this.renderUnitColumn(); this.renderReportPanel(); @@ -276,6 +286,196 @@ export class CampScene extends Phaser.Scene { this.renderStatusPanel(); } + private showSortiePrep() { + this.hideSortiePrep(); + this.campaign = getCampaignState(); + this.report = this.campaign.firstBattleReport ?? this.report; + + const depth = 40; + const width = 900; + const height = 560; + const x = Math.floor((this.scale.width - width) / 2); + const y = Math.floor((this.scale.height - height) / 2); + + const shade = this.trackSortie(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.68)); + shade.setOrigin(0); + shade.setDepth(depth); + shade.setInteractive(); + + const panel = this.trackSortie(this.add.rectangle(x, y, width, height, 0x101820, 0.98)); + panel.setOrigin(0); + panel.setDepth(depth + 1); + panel.setStrokeStyle(3, palette.gold, 0.92); + + const title = this.trackSortie(this.add.text(x + 34, y + 28, '출진 준비', this.textStyle(30, '#f2e3bf', true))); + title.setDepth(depth + 2); + const subtitle = this.trackSortie( + this.add.text(x + 34, y + 70, '다음 전장으로 향하기 전 부대 상태와 준비 항목을 확인합니다.', this.textStyle(15, '#d4dce6')) + ); + subtitle.setDepth(depth + 2); + + this.renderSortieBriefing(x + 34, y + 112, 396, 152, depth + 2); + this.renderSortieChecklist(x + 462, y + 112, 404, 170, depth + 2); + this.renderSortieUnitSummary(x + 34, y + 292, 832, 148, depth + 2); + this.renderSortieRewardHint(x + 34, y + 462, 548, 52, depth + 2); + + this.addSortieButton('군영으로', x + width - 298, y + height - 44, 124, () => { + soundDirector.playSelect(); + this.hideSortiePrep(); + }, depth + 3); + this.addSortieButton('출진', x + width - 152, y + height - 44, 124, () => { + soundDirector.playSelect(); + this.startVictoryStory(); + }, depth + 3); + } + + private renderSortieBriefing(x: number, y: number, width: number, height: number, depth: number) { + const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.blue, 0.48); + + this.trackSortie(this.add.text(x + 18, y + 14, '다음 전장', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 18, y + 48, '황건 잔당 추격', this.textStyle(23, '#d8b15f', true))).setDepth(depth + 1); + this.trackSortie( + this.add.text(x + 18, y + 84, '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 첫 승리의 기세를 몰아 추격에 나섭니다.', { + ...this.textStyle(14, '#d4dce6'), + wordWrap: { width: width - 36, useAdvancedWrap: true }, + lineSpacing: 4 + }) + ).setDepth(depth + 1); + } + + private renderSortieChecklist(x: number, y: number, width: number, height: number, depth: number) { + const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.gold, 0.5); + this.trackSortie(this.add.text(x + 18, y + 14, '준비 체크', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1); + + this.sortieChecklist().forEach((item, index) => { + const rowY = y + 42 + index * 27; + const color = item.complete ? '#a8ffd0' : '#ffdf7b'; + const status = item.complete ? '완료' : '주의'; + this.trackSortie(this.add.text(x + 18, rowY, status, this.textStyle(13, color, true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 70, rowY, item.label, this.textStyle(14, '#d4dce6', true))).setDepth(depth + 1); + const detail = this.trackSortie(this.add.text(x + 190, rowY, item.detail, this.textStyle(12, item.complete ? '#9fb0bf' : '#d8b15f'))); + detail.setDepth(depth + 1); + }); + } + + private renderSortieUnitSummary(x: number, y: number, width: number, height: number, depth: number) { + const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.blue, 0.48); + this.trackSortie(this.add.text(x + 18, y + 14, '출진 부대', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1); + + this.currentUnits() + .filter((unit) => unit.faction === 'ally') + .forEach((unit, index) => { + const rowY = y + 52 + index * 30; + this.trackSortie(this.add.text(x + 18, rowY, `${unit.name} Lv ${unit.level}`, this.textStyle(14, '#f2e3bf', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 132, rowY, unit.className, this.textStyle(13, '#9fb0bf', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 236, rowY, `병력 ${unit.hp}/${unit.maxHp}`, this.textStyle(13, '#d4dce6', true))).setDepth(depth + 1); + this.drawSortieBar(x + 338, rowY + 6, 128, 7, unit.hp / unit.maxHp, palette.green, depth + 1); + + const equipment = equipmentSlots + .map((slot) => { + const state = unit.equipment[slot]; + return `${equipmentSlotLabels[slot]} Lv${state.level}`; + }) + .join(' '); + this.trackSortie(this.add.text(x + 496, rowY, equipment, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1); + }); + } + + private renderSortieRewardHint(x: number, y: number, width: number, height: number, depth: number) { + const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.9)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.gold, 0.4); + const completedDialogues = this.completedCampDialogues().length; + const inventory = this.inventoryLabels().join(', '); + this.trackSortie(this.add.text(x + 16, y + 10, `예상 보상: 군자금, 소모품, 의용군 명성`, 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 sortieChecklist(): SortieChecklistItem[] { + const units = this.currentUnits().filter((unit) => unit.faction === 'ally'); + const liuBei = units.find((unit) => unit.id === 'liu-bei'); + const injured = units.filter((unit) => unit.hp < unit.maxHp); + const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0); + const completedDialogues = this.completedCampDialogues().length; + return [ + { + label: '유비 생존', + complete: Boolean(liuBei && liuBei.hp > 0), + detail: liuBei ? `병력 ${liuBei.hp}/${liuBei.maxHp}` : '유비 없음' + }, + { + label: '부상 장수 확인', + complete: injured.length === 0, + detail: injured.length === 0 ? '전원 완전한 병력' : `${injured.map((unit) => unit.name).join(', ')} 회복 권장` + }, + { + label: '소모품 보유', + complete: supplyCount > 0, + detail: supplyCount > 0 ? `보유 ${supplyCount}` : '정비 탭에서 보급 확인' + }, + { + label: '공명 대화', + complete: completedDialogues >= campDialogues.length, + detail: `${completedDialogues}/${campDialogues.length} 완료` + }, + { + label: '장비 상태', + complete: this.visitedTabs.has('status'), + detail: this.visitedTabs.has('status') ? '장수 탭 확인됨' : '장수 탭 확인 권장' + } + ]; + } + + private addSortieButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) { + const bg = this.trackSortie(this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96)); + bg.setDepth(depth); + bg.setStrokeStyle(1, label === '출진' ? palette.gold : palette.blue, 0.78); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); + bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.96)); + bg.on('pointerdown', action); + + const text = this.trackSortie(this.add.text(x, y, label, this.textStyle(15, '#f2e3bf', true))); + text.setOrigin(0.5); + text.setDepth(depth + 1); + text.setInteractive({ useHandCursor: true }); + text.on('pointerdown', action); + } + + private startVictoryStory() { + markCampaignStep('first-victory-story'); + this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); + } + + private hideSortiePrep() { + this.sortieObjects.forEach((object) => object.destroy()); + this.sortieObjects = []; + } + + private trackSortie(object: T) { + this.sortieObjects.push(object); + return object; + } + + private drawSortieBar(x: number, y: number, width: number, height: number, ratio: number, color: number, depth: number) { + const track = this.trackSortie(this.add.rectangle(x, y, width, height, 0x070b10, 0.86)); + track.setOrigin(0); + track.setDepth(depth); + const fill = this.trackSortie(this.add.rectangle(x, y, Math.max(2, width * Phaser.Math.Clamp(ratio, 0, 1)), height, color, 0.96)); + fill.setOrigin(0); + fill.setDepth(depth + 1); + } + private renderUnitColumn() { const allies = this.currentUnits().filter((unit) => unit.faction === 'ally'); allies.forEach((unit, index) => { @@ -838,6 +1038,7 @@ export class CampScene extends Phaser.Scene { return { scene: this.scene.key, activeTab: this.activeTab, + sortieVisible: this.sortieObjects.length > 0, selectedUnitId: this.selectedUnitId, selectedDialogueId: this.selectedDialogueId, campaign: this.campaign