diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index aa0c8a8..206bc1c 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -91,6 +91,20 @@ try { await clickLegacyUi(page, 962, 240); await waitForStoryReady(page); await setDebugQueryParam(page, 'debugOrderCommandReady', '1'); + const initialStoryProgress = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); + assert( + initialStoryProgress?.pageIndex === 0 && + initialStoryProgress?.totalPages > 1 && + initialStoryProgress?.progressLabel?.startsWith(`1 / ${initialStoryProgress.totalPages}`) && + initialStoryProgress.progressLabel.includes('클릭') && + initialStoryProgress.progressLabel.includes('스페이스') && + initialStoryProgress.progressLabel.includes('다음') && + initialStoryProgress.advanceHint === 'next' && + initialStoryProgress.isLastPage === false && + boundsWithinFhdViewport(initialStoryProgress.progressBounds) && + boundsWithinFhdViewport(initialStoryProgress.progressPanelBounds), + `Expected localized, FHD-safe story progress guidance: ${JSON.stringify(initialStoryProgress)}` + ); await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true }); await assertCanvasPainted(page, 'new game story'); await advanceUntilBattle(page, 'first-battle-zhuo-commandery'); @@ -1870,6 +1884,22 @@ try { assert(!resultProbe.resultTexts.some((text) => text.includes('미달')), `Expected result screen to avoid harsh optional-goal wording: ${JSON.stringify(resultProbe.resultTexts)}`); const firstResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const firstResultPrimaryActions = firstResultState?.resultActions?.filter((action) => action.primary) ?? []; + const firstResultContinueAction = firstResultState?.resultActions?.find((action) => action.kind === 'continue'); + const firstResultRetryAction = firstResultState?.resultActions?.find((action) => action.kind === 'retry'); + const firstResultTitleAction = firstResultState?.resultActions?.find((action) => action.kind === 'title'); + assert( + firstResultPrimaryActions.length === 1 && + firstResultContinueAction?.label === '군영으로' && + firstResultContinueAction.primary === true && + firstResultContinueAction.tone === 'primary' && + firstResultContinueAction.lineWidth >= 2 && + firstResultContinueAction.interactive === true && + boundsWithinFhdViewport(firstResultContinueAction.bounds) && + firstResultRetryAction?.tone === 'secondary' && + firstResultTitleAction?.tone === 'ghost', + `Expected the victory result to expose one clear continue CTA: ${JSON.stringify(firstResultState?.resultActions)}` + ); const firstResultSave = await readCampaignSave(page); const firstSortieOrder = firstResultState?.sortieOperationOrder; const firstSortieOrderResult = firstSortieOrder?.result; @@ -2110,6 +2140,35 @@ try { ), `Expected campaign report debug state to retain first battle unlock reward: ${JSON.stringify(firstCampProbe.state?.report?.campaignRewards)}` ); + assert( + firstCampProbe.state?.campTabs?.length === 6 && + firstCampProbe.state.campTabs.filter((tab) => tab.active).length === 1 && + firstCampProbe.state.campTabs.every( + (tab) => tab.interactive === true && boundsWithinFhdViewport(tab.bounds) + ) && + firstCampProbe.state.campTabs.find((tab) => tab.id === 'status')?.visualState === 'active' && + firstCampProbe.state.campTabs.find((tab) => tab.id === 'status')?.indicatorVisible === true, + `Expected six distinct, FHD-safe camp tabs with one active state: ${JSON.stringify(firstCampProbe.state?.campTabs)}` + ); + const firstCampDialogueTabPoint = await readCampTabControlPoint(page, 'dialogue'); + await page.mouse.move(firstCampDialogueTabPoint.x, firstCampDialogueTabPoint.y); + await page.waitForTimeout(80); + const firstCampTabHoverState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert( + firstCampTabHoverState?.activeTab === 'status' && + firstCampTabHoverState?.campTabs?.find((tab) => tab.id === 'dialogue')?.visualState === 'hover' && + firstCampTabHoverState.campTabs.find((tab) => tab.id === 'dialogue')?.indicatorVisible === true && + firstCampTabHoverState.campTabs.find((tab) => tab.id === 'dialogue')?.lineWidth >= 2, + `Expected camp tab hover to add emphasis without changing the active tab: ${JSON.stringify(firstCampTabHoverState?.campTabs)}` + ); + await page.mouse.move(4, 4); + await page.waitForTimeout(80); + const firstCampTabResetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert( + firstCampTabResetState?.campTabs?.find((tab) => tab.id === 'dialogue')?.visualState === 'idle' && + firstCampTabResetState.campTabs.find((tab) => tab.id === 'dialogue')?.indicatorVisible === false, + `Expected camp tab hover styling to reset on pointerout: ${JSON.stringify(firstCampTabResetState?.campTabs)}` + ); const firstCampEvaluation = firstCampProbe.state?.reportFormationEvaluation; const firstCampHistory = firstCampProbe.state?.reportFormationHistory; @@ -2664,7 +2723,11 @@ try { firstSortieBriefingState?.stagedSortiePrep === true && firstSortieBriefingState?.sortiePrepStep === 'briefing' && firstSortieBriefingState?.sortiePrimaryAction?.kind === 'next' && - firstSortieBriefingState?.sortiePrimaryAction?.nextStep === 'formation', + firstSortieBriefingState?.sortiePrimaryAction?.nextStep === 'formation' && + firstSortieBriefingState?.sortiePrimaryAction?.primary === true && + firstSortieBriefingState?.sortiePrimaryAction?.interactive === true && + firstSortieBriefingState?.sortiePrimaryAction?.lineWidth >= 2 && + boundsWithinFhdViewport(firstSortieBriefingState?.sortiePrimaryAction?.bounds), `Expected the first sortie to open at the staged battlefield briefing: ${JSON.stringify(firstSortieBriefingState)}` ); await advanceSortiePrepStep(page, 'formation'); @@ -8019,6 +8082,16 @@ function isPositiveBounds(bounds) { ); } +function boundsWithinFhdViewport(bounds, tolerance = 1) { + return Boolean( + isPositiveBounds(bounds) && + bounds.x >= -tolerance && + bounds.y >= -tolerance && + bounds.x + bounds.width <= 1920 + tolerance && + bounds.y + bounds.height <= 1080 + tolerance + ); +} + function isBoundsInside(inner, outer, tolerance = 0.01) { return ( isPositiveBounds(inner) && diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 7df30cc..3ad6a31 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1604,8 +1604,14 @@ type SortieOrderHudSnapshot = { type ResultButtonView = { background: Phaser.GameObjects.Rectangle; label: Phaser.GameObjects.Text; + tone: ResultButtonTone; + kind: ResultButtonKind; + hovered: boolean; }; +type ResultButtonTone = 'primary' | 'secondary' | 'utility' | 'ghost'; +type ResultButtonKind = 'continue' | 'retry' | 'title' | 'detail'; + type ThreatTile = { x: number; y: number; @@ -3521,6 +3527,7 @@ export class BattleScene extends Phaser.Scene { private resultFormationReviewFeedback = ''; private resultPresetOverwriteConfirmId?: CampaignSortiePresetId; private resultFormationReviewButton?: ResultButtonView; + private resultActionButtons: ResultButtonView[] = []; private resultFormationReviewView?: ResultFormationReviewView; private resultSortieOrderObjects: Phaser.GameObjects.GameObject[] = []; private resultSortieOrderVisible = false; @@ -12012,7 +12019,7 @@ export class BattleScene extends Phaser.Scene { return; } void startLazyScene(this, 'CampScene'); - }, depth + 3); + }, depth + 3, 'primary', 'continue'); } this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { soundDirector.playSelect(); @@ -12027,11 +12034,11 @@ export class BattleScene extends Phaser.Scene { ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) : undefined }); - }, depth + 3); + }, depth + 3, outcome === 'defeat' ? 'primary' : 'secondary', 'retry'); this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { soundDirector.playSelect(); this.scene.start('TitleScene'); - }, depth + 3); + }, depth + 3, 'ghost', 'title'); void this.playResultSettlementAnimation(animationToken); } @@ -12047,6 +12054,7 @@ export class BattleScene extends Phaser.Scene { this.resultFormationReviewFeedback = ''; this.resultPresetOverwriteConfirmId = undefined; this.resultFormationReviewButton = undefined; + this.resultActionButtons = []; this.resultFormationReviewView = undefined; this.resultSortieOrderButton = undefined; this.resultObjects.forEach((object) => object.destroy()); @@ -14078,26 +14086,90 @@ export class BattleScene extends Phaser.Scene { }); } - private addResultButton(label: string, x: number, y: number, width: number, action: () => void, depth: number) { - const bg = this.trackResultObject(this.add.rectangle(x, y, width, 34, 0x1a2630, 0.96)); + private addResultButton( + label: string, + x: number, + y: number, + width: number, + action: () => void, + depth: number, + tone: ResultButtonTone = 'utility', + kind: ResultButtonKind = 'detail' + ) { + const styles: Record = { + primary: { + fill: 0x5b4221, + hoverFill: 0x7a5a28, + stroke: palette.gold, + strokeAlpha: 0.98, + lineWidth: 2, + text: '#fff2b8', + hoverText: '#fff8d8' + }, + secondary: { + fill: 0x162632, + hoverFill: 0x24445a, + stroke: palette.blue, + strokeAlpha: 0.82, + lineWidth: 1, + text: '#dbe8f2', + hoverText: '#f1f8ff' + }, + utility: { + fill: 0x1a2630, + hoverFill: 0x283947, + stroke: 0x647684, + strokeAlpha: 0.76, + lineWidth: 1, + text: '#f2e3bf', + hoverText: '#fff2b8' + }, + ghost: { + fill: 0x0d141b, + hoverFill: 0x202b34, + stroke: 0x53606c, + strokeAlpha: 0.58, + lineWidth: 1, + text: '#aeb9c4', + hoverText: '#d4dce6' + } + }; + const style = styles[tone]; + const bg = this.trackResultObject(this.add.rectangle(x, y, width, 34, style.fill, 0.96)); bg.setDepth(depth); - bg.setStrokeStyle(1, label === '다시 하기' ? palette.blue : palette.gold, 0.78); + bg.setStrokeStyle(style.lineWidth, style.stroke, style.strokeAlpha); 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.trackResultObject(this.add.text(x, y, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', - color: '#f2e3bf', + color: style.text, fontStyle: '700' })); text.setOrigin(0.5); text.setDepth(depth + 1); - text.setInteractive({ useHandCursor: true }); - text.on('pointerdown', action); - return { background: bg, label: text }; + const view: ResultButtonView = { background: bg, label: text, tone, kind, hovered: false }; + bg.on('pointerover', () => { + view.hovered = true; + bg.setFillStyle(style.hoverFill, 0.99); + text.setColor(style.hoverText); + }); + bg.on('pointerout', () => { + view.hovered = false; + bg.setFillStyle(style.fill, 0.96); + text.setColor(style.text); + }); + bg.on('pointerdown', action); + this.resultActionButtons.push(view); + return view; } private trackResultObject(object: T) { @@ -24984,6 +25056,23 @@ export class BattleScene extends Phaser.Scene { saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0, saveSlotPanelMode: this.saveSlotPanelMode ?? null, resultVisible: this.resultObjects.length > 0, + resultActions: this.resultActionButtons + .filter((button) => button.background.active && button.label.active) + .map((button) => { + const bounds = button.background.getBounds(); + return { + kind: button.kind, + label: button.label.text, + tone: button.tone, + primary: button.tone === 'primary', + hovered: button.hovered, + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + interactive: Boolean(button.background.input?.enabled), + fillColor: button.background.fillColor, + strokeColor: button.background.strokeColor, + lineWidth: button.background.lineWidth + }; + }), sortieEvaluation: resultFormationReview ? { available: true, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 9578e52..3b843e0 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -240,6 +240,15 @@ type CampTabButtonView = { tab: CampTab; bg: Phaser.GameObjects.Rectangle; text: Phaser.GameObjects.Text; + indicator: Phaser.GameObjects.Rectangle; + hovered: boolean; +}; + +type CampActionButtonView = { + background: Phaser.GameObjects.Rectangle; + label: Phaser.GameObjects.Text; + primary: boolean; + hovered: boolean; }; type CampRosterBounds = { @@ -11247,6 +11256,7 @@ export class CampScene extends Phaser.Scene { private reportFormationHistoryView?: ReportFormationHistoryView; private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState; private tabButtons: CampTabButtonView[] = []; + private sortiePrimaryActionButton?: CampActionButtonView; private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; private sortieFormationAssignments: SortieFormationAssignments = {}; @@ -11355,6 +11365,7 @@ export class CampScene extends Phaser.Scene { this.reportFormationHistoryView = undefined; this.reportFormationHistoryUndoState = undefined; this.tabButtons = []; + this.sortiePrimaryActionButton = undefined; this.visitedTabs = new Set(['status']); this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); @@ -12594,7 +12605,7 @@ export class CampScene extends Phaser.Scene { return; } this.showSortiePrep(); - }); + }, true); } private showCampSaveSlotPanel() { @@ -12938,11 +12949,15 @@ export class CampScene extends Phaser.Scene { const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, 76, 34, 0x182431, 0.94)); bg.setStrokeStyle(1, palette.blue, 0.62); bg.setInteractive({ useHandCursor: true }); - bg.on('pointerdown', () => { + + const indicator = this.scaleLegacyCampUi(this.add.rectangle(x, y + 14, 52, 3, palette.gold, 1)); + indicator.setVisible(false); + + const selectTab = () => { soundDirector.playSelect(); this.activeTab = tab; this.render(); - }); + }; const text = this.scaleLegacyCampUi(this.add.text(x, y, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', @@ -12952,42 +12967,58 @@ export class CampScene extends Phaser.Scene { })); text.setOrigin(0.5); text.setInteractive({ useHandCursor: true }); - text.on('pointerdown', () => { - soundDirector.playSelect(); - this.activeTab = tab; - this.render(); + const view: CampTabButtonView = { tab, bg, text, indicator, hovered: false }; + const setHovered = (hovered: boolean) => { + view.hovered = hovered; + this.updateTabButtons(); + }; + [bg, text].forEach((target) => { + target.on('pointerover', () => setHovered(true)); + target.on('pointerout', () => setHovered(false)); + target.on('pointerdown', selectTab); }); - this.tabButtons.push({ tab, bg, text }); + this.tabButtons.push(view); this.updateTabButtons(); } private updateTabButtons() { const accentColor = this.campSkinSelection?.skin.accentColor ?? palette.gold; - this.tabButtons.forEach(({ tab, bg, text }) => { + this.tabButtons.forEach(({ tab, bg, text, indicator, hovered }) => { const active = this.activeTab === tab; - bg.setFillStyle(active ? 0x25384a : 0x182431, active ? 0.98 : 0.94); - bg.setStrokeStyle(1, active ? accentColor : palette.blue, active ? 0.9 : 0.62); - text.setColor(active ? '#fff2b8' : '#f2e3bf'); + bg.setFillStyle(active ? 0x2b4052 : hovered ? 0x243747 : 0x182431, active || hovered ? 0.98 : 0.94); + bg.setStrokeStyle(active || hovered ? 2 : 1, active || hovered ? accentColor : palette.blue, active ? 0.96 : hovered ? 0.82 : 0.62); + text.setColor(active || hovered ? '#fff2b8' : '#f2e3bf'); + indicator.setFillStyle(accentColor, 1); + indicator.setVisible(active || hovered); + indicator.setAlpha(active ? 1 : 0.66); }); } - private addCommandButton(label: string, x: number, y: number, width: number, action: () => void) { - const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96)); - bg.setStrokeStyle(1, this.campSkinSelection?.skin.accentColor ?? palette.gold, 0.84); + private addCommandButton(label: string, x: number, y: number, width: number, action: () => void, primary = false) { + const restingFill = primary ? 0x4a371d : 0x1a2630; + const hoverFill = primary ? 0x654c25 : 0x283947; + const accentColor = this.campSkinSelection?.skin.accentColor ?? palette.gold; + const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, width, 36, restingFill, 0.96)); + bg.setStrokeStyle(primary ? 2 : 1, accentColor, primary ? 0.96 : 0.84); 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.scaleLegacyCampUi(this.add.text(x, y, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '16px', - color: '#f2e3bf', + color: primary ? '#fff2b8' : '#f2e3bf', fontStyle: '700' })); text.setOrigin(0.5); text.setInteractive({ useHandCursor: true }); - text.on('pointerdown', action); + const setHovered = (hovered: boolean) => { + bg.setFillStyle(hovered ? hoverFill : restingFill, hovered ? 0.99 : 0.96); + text.setColor(hovered || primary ? '#fff2b8' : '#f2e3bf'); + }; + [bg, text].forEach((target) => { + target.on('pointerover', () => setHovered(true)); + target.on('pointerout', () => setHovered(false)); + target.on('pointerdown', action); + }); } private render() { @@ -15077,10 +15108,12 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, active ? palette.green : enabled ? palette.gold : 0x53606c, active ? 0.78 : enabled ? 0.56 : 0.3); + const hoverFill = active ? 0x314738 : 0x283947; + const setHovered = (hovered: boolean) => bg.setFillStyle(hovered ? hoverFill : fill, hovered ? 0.98 : 0.96); if (enabled) { bg.setInteractive({ useHandCursor: true }); - bg.on('pointerover', () => bg.setFillStyle(active ? 0x314738 : 0x283947, 0.98)); - bg.on('pointerout', () => bg.setFillStyle(fill, 0.96)); + bg.on('pointerover', () => setHovered(true)); + bg.on('pointerout', () => setHovered(false)); bg.on('pointerdown', action); } const text = this.trackSortie(this.add.text(x + width / 2, y + height / 2, label, this.textStyle(12, enabled || active ? '#f2e3bf' : '#77818c', true))); @@ -15088,6 +15121,8 @@ export class CampScene extends Phaser.Scene { text.setDepth(depth + 1); if (enabled) { text.setInteractive({ useHandCursor: true }); + text.on('pointerover', () => setHovered(true)); + text.on('pointerout', () => setHovered(false)); text.on('pointerdown', action); } } @@ -18946,19 +18981,32 @@ export class CampScene extends Phaser.Scene { } private addSortieButton(label: string, x: number, y: number, width: number, action: () => void, depth: number, primary = false) { - const bg = this.trackSortie(this.add.rectangle(x, y, width, 36, 0x1a2630, 0.96)); + const restingFill = primary ? 0x4a371d : 0x1a2630; + const hoverFill = primary ? 0x654c25 : 0x283947; + const bg = this.trackSortie(this.add.rectangle(x, y, width, 36, restingFill, 0.96)); bg.setDepth(depth); - bg.setStrokeStyle(1, primary ? palette.gold : palette.blue, 0.78); + bg.setStrokeStyle(primary ? 2 : 1, primary ? palette.gold : palette.blue, primary ? 0.96 : 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))); + const text = this.trackSortie(this.add.text(x, y, label, this.textStyle(15, primary ? '#fff2b8' : '#f2e3bf', true))); text.setOrigin(0.5); text.setDepth(depth + 1); text.setInteractive({ useHandCursor: true }); - text.on('pointerdown', action); + const view: CampActionButtonView = { background: bg, label: text, primary, hovered: false }; + const setHovered = (hovered: boolean) => { + view.hovered = hovered; + bg.setFillStyle(hovered ? hoverFill : restingFill, hovered ? 0.99 : 0.96); + text.setColor(hovered || primary ? '#fff2b8' : '#f2e3bf'); + }; + [bg, text].forEach((target) => { + target.on('pointerover', () => setHovered(true)); + target.on('pointerout', () => setHovered(false)); + target.on('pointerdown', action); + }); + if (primary) { + this.sortiePrimaryActionButton = view; + } + return view; } private startVictoryStory() { @@ -19072,6 +19120,7 @@ export class CampScene extends Phaser.Scene { private hideSortiePrep(clearPinnedSwap = true) { this.sortieObjects.forEach((object) => object.destroy()); this.sortieObjects = []; + this.sortiePrimaryActionButton = undefined; this.sortiePortraitRosterLayout = undefined; this.sortieHoveredUnitId = undefined; if (clearPinnedSwap) { @@ -19139,11 +19188,24 @@ export class CampScene extends Phaser.Scene { const y = listY + index * rowGap; const active = this.selectedUnitId === unit.id; const rosterStatus = this.unitRosterStatus(unit); + const disabled = rosterStatus.tone === 'disabled'; const training = this.latestReserveTraining(unit.id); - const bg = this.track(this.add.rectangle(x, y, width, cardHeight, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86)); + const restingFill = active ? 0x25384a : disabled ? 0x0d131a : 0x111922; + const restingAlpha = active ? 0.96 : disabled ? 0.72 : 0.86; + const restingStroke = active ? palette.gold : disabled ? 0x53606c : palette.blue; + const restingStrokeAlpha = active ? 0.9 : disabled ? 0.42 : 0.46; + const bg = this.track(this.add.rectangle(x, y, width, cardHeight, restingFill, restingAlpha)); bg.setOrigin(0); - bg.setStrokeStyle(2, active ? palette.gold : palette.blue, active ? 0.9 : 0.46); + bg.setStrokeStyle(2, restingStroke, restingStrokeAlpha); bg.setInteractive({ useHandCursor: true }); + bg.on('pointerover', () => { + bg.setFillStyle(active ? 0x31495d : disabled ? 0x182029 : 0x1c2a36, active || !disabled ? 0.98 : 0.82); + bg.setStrokeStyle(2, active || !disabled ? palette.gold : 0x71808e, active ? 0.98 : disabled ? 0.62 : 0.88); + }); + bg.on('pointerout', () => { + bg.setFillStyle(restingFill, restingAlpha); + bg.setStrokeStyle(2, restingStroke, restingStrokeAlpha); + }); bg.on('pointerdown', () => { soundDirector.playSelect(); this.selectedUnitId = unit.id; @@ -22669,6 +22731,18 @@ export class CampScene extends Phaser.Scene { return { scene: this.scene.key, activeTab: this.activeTab, + campTabs: this.tabButtons.map((button) => ({ + id: button.tab, + active: this.activeTab === button.tab, + hovered: button.hovered, + visualState: this.activeTab === button.tab ? 'active' : button.hovered ? 'hover' : 'idle', + bounds: this.sortieObjectBoundsDebug(button.bg), + interactive: Boolean(button.bg.input?.enabled && button.text.input?.enabled), + indicatorVisible: button.indicator.visible, + fillColor: button.bg.fillColor, + strokeColor: button.bg.strokeColor, + lineWidth: button.bg.lineWidth + })), campRoster: this.campRosterLayout ? { ...this.campRosterLayout, @@ -23032,11 +23106,21 @@ export class CampScene extends Phaser.Scene { active: step.id === this.sortiePrepStep, complete: this.firstSortieStepComplete(step.id, sortieChecklist) })), - sortiePrimaryAction: stagedSortiePrep + sortiePrimaryAction: this.sortiePrimaryActionButton?.background.active ? { kind: nextSortiePrepStep ? 'next' : 'launch', nextStep: nextSortiePrepStep, - label: nextSortiePrepStep === 'formation' ? '편성하기' : nextSortiePrepStep === 'loadout' ? '장비·보급' : '출진' + label: this.sortiePrimaryActionButton.label.text, + primary: this.sortiePrimaryActionButton.primary, + hovered: this.sortiePrimaryActionButton.hovered, + visualState: this.sortiePrimaryActionButton.hovered ? 'hover' : 'primary', + bounds: this.sortieObjectBoundsDebug(this.sortiePrimaryActionButton.background), + interactive: Boolean( + this.sortiePrimaryActionButton.background.input?.enabled && this.sortiePrimaryActionButton.label.input?.enabled + ), + fillColor: this.sortiePrimaryActionButton.background.fillColor, + strokeColor: this.sortiePrimaryActionButton.background.strokeColor, + lineWidth: this.sortiePrimaryActionButton.background.lineWidth } : null, sortiePortraitRosterView: { diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 9c2f4ab..a416835 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -80,6 +80,7 @@ export class StoryScene extends Phaser.Scene { private portraitDivider?: Phaser.GameObjects.Rectangle; private nameText?: Phaser.GameObjects.Text; private bodyText?: Phaser.GameObjects.Text; + private progressBackground?: Phaser.GameObjects.Rectangle; private progressText?: Phaser.GameObjects.Text; private loadingText?: Phaser.GameObjects.Text; private cutsceneLayer?: Phaser.GameObjects.Container; @@ -98,6 +99,9 @@ export class StoryScene extends Phaser.Scene { getDebugState() { const page = this.pages[this.pageIndex]; + const progressBounds = this.progressText?.active ? this.progressText.getBounds() : undefined; + const progressPanelBounds = this.progressBackground?.active ? this.progressBackground.getBounds() : undefined; + const isLastPage = this.pageIndex >= this.pages.length - 1; return { scene: this.scene.key, @@ -111,6 +115,15 @@ export class StoryScene extends Phaser.Scene { activeTexture: this.background?.texture.key ?? null, portraitKey: page ? this.pagePortraitKey(page) ?? null : null, portraitTextureKey: this.portrait?.visible ? this.portrait.texture.key : null, + progressLabel: this.progressText?.text ?? '', + progressBounds: progressBounds + ? { x: progressBounds.x, y: progressBounds.y, width: progressBounds.width, height: progressBounds.height } + : null, + progressPanelBounds: progressPanelBounds + ? { x: progressPanelBounds.x, y: progressPanelBounds.y, width: progressPanelBounds.width, height: progressPanelBounds.height } + : null, + isLastPage, + advanceHint: isLastPage ? 'continue' : 'next', cutsceneKind: page?.cutscene?.kind ?? null, cutsceneActors: page?.cutscene?.actors?.map((actor) => actor.unitId) ?? [] }; @@ -299,11 +312,21 @@ export class StoryScene extends Phaser.Scene { }); this.bodyText.setDepth(dialogDepth + 2); - this.progressText = this.add.text(width - ui(220), panelY + panelH + ui(20), '', { - fontFamily: '"Segoe UI", sans-serif', - fontSize: uiPx(15), - color: '#9aa3ad' + const progressX = width - ui(174); + const progressY = panelY + panelH + ui(20); + this.progressBackground = this.add.rectangle(progressX, progressY, ui(300), ui(34), cutsceneUiColors.ink, 0.82); + this.progressBackground.setStrokeStyle(ui(1), palette.gold, 0.42); + this.progressBackground.setDepth(dialogDepth + 1); + + this.progressText = this.add.text(progressX, progressY, '', { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: uiPx(14), + color: '#d8b15f', + fontStyle: '700', + align: 'center', + fixedWidth: ui(276) }); + this.progressText.setOrigin(0.5); this.progressText.setDepth(dialogDepth + 2); } @@ -379,7 +402,10 @@ export class StoryScene extends Phaser.Scene { this.nameText?.setText(page.speaker ?? '나레이션'); this.bodyText?.setText(page.text); - this.progressText?.setText(`${this.pageIndex + 1} / ${this.pages.length} SPACE / ENTER`); + const isLastPage = this.pageIndex >= this.pages.length - 1; + this.progressText?.setText( + `${this.pageIndex + 1} / ${this.pages.length} · 클릭 / 스페이스·엔터 · ${isLastPage ? '계속' : '다음'}` + ); } private pagePortraitKey(page: StoryPage) { @@ -1065,6 +1091,7 @@ export class StoryScene extends Phaser.Scene { this.portraitDivider, this.nameText, this.bodyText, + this.progressBackground, this.progressText, this.cutsceneLayer ];