diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index bcde09f..739a5d7 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -1,10 +1,10 @@ # Findings -> Last Updated: 2026-04-09T20:59:51.000Z +> Last Updated: 2026-04-10T09:39:37.000Z ## Summary -Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDINGS.md` no longer matched its recorded hash, and the architecture document no longer matched the live source layout after settings, preferences, and benchmark changes. The observations below reflect the current repository state. +Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDINGS.md` no longer matched its recorded hash and still contained an outdated note about a `SettingsScene` placeholder in `main.ts`. The observations below reflect the current repository state; `docs/ARCHITECTURE.md` and `docs/CODE_STYLE.md` were revalidated unchanged against the live source. ## Codebase Observations @@ -12,7 +12,7 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN - The project is structurally split between framework-free gameplay modules in `src/game/` and Phaser scene code in `src/scenes/`. - `src/scenes/GameScene.ts` and `src/game/ai.ts` remain the two largest concentrations of application logic. - A dedicated audio preference path now exists: `src/game/preferences.ts`, `src/scenes/MenuScene.ts`, and `src/scenes/SettingsScene.ts`. -- `main.ts` still contains a local `SettingsScene` placeholder class, while `MenuScene.ensureSettingsSceneAvailable()` swaps in the concrete imported scene before navigation. +- `main.ts` now imports and registers `SettingsScene` directly in the Phaser scene list; the earlier placeholder-scene workaround is no longer present. - The AI transport layer is a stable three-file path: `ai-worker-protocol.ts`, `ai-worker-client.ts`, and `ai.worker.ts`. - The AI exposes three difficulty levels: `beginner`, `advanced`, and `master`. - `advanced` and `master` both use `CardTracker` to reason about unseen cards without directly reading hidden hands. @@ -20,6 +20,7 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN - `GameScene` consumes AI progress callbacks to update an on-screen think bar while a worker request is running. - `GameScene` now enforces `AI_MIN_THINK_MS = 1000` and `MOVE_OUTCOME_STATUS_MS = 2000` through timer-backed scene logic. - `AIWorkerClient` fails over pending work to in-thread `chooseMove()` if worker creation, posting, or deserialization fails. +- `MenuScene` now includes a responsive layout path for compact viewports, driven by calculated panel bounds and camera zoom instead of a fixed desktop-only composition. - The AI benchmark harness is now in source under `src/game/ai-benchmark.ts` and `src/game/ai-benchmark-fixtures.ts`, and `package.json` exposes it as `npm run benchmark:ai-quality`. - The current benchmark contract is iteration 5: 13 fixed fixtures, 6 critical concepts, and 48 self-play matches. - The Android wrapper targets SDK 36 with `minSdkVersion` 24 and applies immersive mode from the native activity. @@ -31,7 +32,7 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN - `GameScene.ts` still centralizes layout, turn flow, HUD updates, effects, audio, status messaging, and AI orchestration in one scene class. - `ai.ts` still combines heuristic tiers, inference helpers, determinization, move ordering, and alpha-beta evaluation in one module. -- The current settings flow works, but the dual registration pattern for `SettingsScene` in `main.ts` plus dynamic replacement in `MenuScene` is fragile and worth simplifying later. +- `MenuScene.ts` now carries substantial responsive layout and decorative rendering logic in the same scene that handles navigation and difficulty selection. - Worker transport is isolated cleanly, but progress rendering and fallback behavior remain coupled to scene-level UI concerns. - A 3.2 to 4.35 second master search window may still be noticeable on slower mobile devices even with yielding and the minimum-think pacing already in place. - There is no dedicated automated rules test suite beyond type-checking and the AI benchmark harness. @@ -66,7 +67,8 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN ### Scene / UI implementation snapshot - `BootScene` loads atlas assets and presents a simple loading bar. -- `MenuScene` now exposes both difficulty selection and a dedicated entry point into `SettingsScene`. +- `main.ts` registers `BootScene`, `MenuScene`, `GameScene`, and `SettingsScene` directly in the Phaser game config. +- `MenuScene` now exposes both difficulty selection and a dedicated entry point into `SettingsScene`, with a separate compact-layout branch for smaller viewports. - `SettingsScene` persists music and effects toggles immediately through `saveAudioPreferences()`. - `GameScene` reads normalized audio preferences from scene data or persisted storage before match start. - `GameScene` tracks played and captured cards in `CardTracker` as the round evolves. @@ -140,4 +142,11 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN - `src/scenes/SettingsScene.ts` exists as a real scene and persists music and effects toggles independently through `saveAudioPreferences()`. - `src/scenes/GameScene.ts` already contains the previously planned pacing and status work: `AI_MIN_THINK_MS = 1000`, `MOVE_OUTCOME_STATUS_MS = 2000`, timer-backed `setStatus(...)`, and `handleSceneShutdown()` timer cleanup are all present in source and should be treated as current behavior, not future work. - `src/game/ai-benchmark.ts` now enforces an iteration 5 contract with simulated timing, cross-seed aggregation, dual-loss reporting, and a regression watchlist intersection. Older findings that described iteration 4 targets or wall-clock-only timing are stale. -- `main.ts` still registers a local `SettingsScene` stub while `MenuScene` dynamically installs the concrete scene implementation before use. This works today but is an architectural wrinkle worth remembering in later planning. +- `src/main.ts` now imports and registers `SettingsScene` directly; the earlier placeholder-scene note is no longer accurate. + +### SCOPONE-0010: Phaser scene-manager and resize notes (2026-04-10) + +- Source: Context7 `/websites/phaser_io_api-documentation`, queries `Phaser 3.87 ScenePlugin add remove get duplicate key behavior and Scale Manager resize event for responsive UI layout in scenes` and `Phaser 3.87 SceneManager add scene duplicate key error getScene get key existing scene unique key documentation`. +- `SceneManager.add(key, ...)` requires a unique scene key; replacing a scene under the same key should remove the existing scene first rather than attempting a duplicate add. +- `SceneManager.remove(key)` clears the scene key from the cache and destroys that scene's systems, so the current `MenuScene.ensureSettingsSceneAvailable()` pattern is intentionally destructive when it replaces the placeholder scene. +- Phaser's resize path dispatches resize events from the Scale Manager / renderer when the display changes size, which is the framework-supported hook for responsive scene relayout if this iteration introduces viewport-aware menu composition. diff --git a/src/main.ts b/src/main.ts index b41bd50..72b7e99 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,12 +2,7 @@ import Phaser from 'phaser'; import { BootScene } from './scenes/BootScene'; import { MenuScene } from './scenes/MenuScene'; import { GameScene } from './scenes/GameScene'; - -class SettingsScene extends Phaser.Scene { - constructor() { - super({ key: 'SettingsScene' }); - } -} +import { SettingsScene } from './scenes/SettingsScene'; const installFullscreenRequest = (host: HTMLElement): void => { const canRequestFullscreen = diff --git a/src/scenes/MenuScene.ts b/src/scenes/MenuScene.ts index c58c576..a010753 100644 --- a/src/scenes/MenuScene.ts +++ b/src/scenes/MenuScene.ts @@ -1,7 +1,6 @@ import Phaser from 'phaser'; import { Difficulty } from '../game/types'; import { GameSceneData, loadAudioPreferences } from '../game/preferences'; -import { SettingsScene } from './SettingsScene'; type MenuButtonPalette = { base: number; @@ -20,6 +19,62 @@ type RuleSection = { lines: string[]; }; +type PanelBounds = { + x: number; + y: number; + width: number; + height: number; + centerX: number; + centerY: number; + right: number; + bottom: number; +}; + +type DecorativeCardLayout = { + x: number; + y: number; + angle: number; + scale: number; + alpha: number; +}; + +type MenuLayout = { + frameInset: number; + isCompactViewport: boolean; + cameraZoom: number; + visibleBounds: PanelBounds; + titleX: number; + titleY: number; + titleFontSize: number; + subtitleY: number; + subtitleFontSize: number; + subtitleWrapWidth: number; + panelPaddingX: number; + panelPaddingY: number; + panelTitleFontSize: number; + rulesHeadingFontSize: number; + rulesBodyFontSize: number; + rulesLineSpacing: number; + rulesLineGap: number; + rulesSectionGap: number; + rulesWrapWidth: number; + footerFontSize: number; + controlIntroFontSize: number; + controlIntroWrapWidth: number; + buttonWidth: number; + buttonHeight: number; + buttonGap: number; + buttonTextWrapWidth: number; + buttonLabelFontSize: number; + buttonSubtitleFontSize: number; + buttonLabelOffset: number; + buttonSubtitleOffset: number; + showButtonSubtitle: boolean; + rulesPanel: PanelBounds; + controlPanel: PanelBounds; + decorativeCards: DecorativeCardLayout[]; +}; + const TITLE_STYLE: Phaser.Types.GameObjects.Text.TextStyle = { fontFamily: 'Georgia, serif', fontSize: '52px', @@ -52,107 +107,335 @@ export class MenuScene extends Phaser.Scene { const width = this.scale.width; const height = this.scale.height; const audioPreferences = loadAudioPreferences(); + const layout = this.createLayout(width, height); - this.drawBackground(width, height); - this.drawDecorativeCards(width, height); - this.ensureSettingsSceneAvailable(); + this.cameras.main.setZoom(layout.cameraZoom); + this.cameras.main.centerOn(width / 2, height / 2); - this.add.text(width / 2, 92, 'Scopone Scientifico', TITLE_STYLE).setOrigin(0.5); - this.add.text(width / 2, 142, 'Due squadre da due, una mano intera e lettura del tavolo fino all’ultimo punto.', { - fontFamily: 'Georgia, serif', - fontSize: '21px', - color: '#d9f2d2', - resolution: 2, + this.drawBackground(width, height, layout); + this.drawDecorativeCards(layout); + + this.add.text(layout.titleX, layout.titleY, 'Scopone Scientifico', { + ...TITLE_STYLE, + fontSize: `${layout.titleFontSize}px`, }).setOrigin(0.5); - this.createRulesPanel(width, height); - this.createControlPanel(width, height, audioPreferences); + if (!layout.isCompactViewport) { + this.add.text(layout.titleX, layout.subtitleY, 'Due squadre da due, una mano intera e lettura del tavolo fino all’ultimo punto.', { + fontFamily: 'Georgia, serif', + fontSize: `${layout.subtitleFontSize}px`, + color: '#d9f2d2', + resolution: 2, + align: 'center', + wordWrap: { width: layout.subtitleWrapWidth }, + }).setOrigin(0.5, 0); + } + + this.createRulesPanel(layout); + this.createControlPanel(layout, audioPreferences); } - private drawBackground(width: number, height: number): void { + private createLayout(width: number, height: number): MenuLayout { + const viewportWidth = this.scale.parentSize.width || width; + const viewportHeight = this.scale.parentSize.height || height; + const displayWidth = this.scale.displaySize.width || width; + const displayHeight = this.scale.displaySize.height || height; + const viewportAspect = viewportWidth / Math.max(viewportHeight, 1); + const displayScale = Math.min(displayWidth / width, displayHeight / height); + const isCompactViewport = viewportWidth <= 720 || viewportAspect < 1; + const cameraZoom = isCompactViewport + ? Phaser.Math.Clamp(0.43 / Math.max(displayScale, 0.001), 1.38, 1.46) + : 1; + const visibleBounds = this.createPanelBounds( + (width - width / cameraZoom) / 2, + (height - height / cameraZoom) / 2, + width / cameraZoom, + height / cameraZoom, + ); + + if (isCompactViewport) { + const frameInset = 22; + const panelGap = 12; + const panelPaddingX = 18; + const panelPaddingY = 14; + const contentWidth = visibleBounds.width - frameInset * 2; + const contentTop = visibleBounds.y + 74; + const contentBottom = visibleBounds.bottom - 14; + const contentHeight = contentBottom - contentTop; + const rulesHeight = Math.round(contentHeight * 0.25); + const controlHeight = contentHeight - rulesHeight - panelGap; + const rulesPanel = this.createPanelBounds( + visibleBounds.x + frameInset, + contentTop, + contentWidth, + rulesHeight, + ); + const controlPanel = this.createPanelBounds( + visibleBounds.x + frameInset, + rulesPanel.bottom + panelGap, + contentWidth, + controlHeight, + ); + + return { + frameInset, + isCompactViewport, + cameraZoom, + visibleBounds, + titleX: width / 2, + titleY: visibleBounds.y + 28, + titleFontSize: 34, + subtitleY: visibleBounds.y + 56, + subtitleFontSize: 18, + subtitleWrapWidth: contentWidth - 32, + panelPaddingX, + panelPaddingY, + panelTitleFontSize: 19, + rulesHeadingFontSize: 14, + rulesBodyFontSize: 15, + rulesLineSpacing: 2, + rulesLineGap: 6, + rulesSectionGap: 8, + rulesWrapWidth: rulesPanel.width - panelPaddingX * 2, + footerFontSize: 12, + controlIntroFontSize: 13, + controlIntroWrapWidth: controlPanel.width - panelPaddingX * 2, + buttonWidth: controlPanel.width - panelPaddingX * 2, + buttonHeight: 38, + buttonGap: 6, + buttonTextWrapWidth: controlPanel.width - panelPaddingX * 2 - 24, + buttonLabelFontSize: 20, + buttonSubtitleFontSize: 12, + buttonLabelOffset: 0, + buttonSubtitleOffset: 0, + showButtonSubtitle: false, + rulesPanel, + controlPanel, + decorativeCards: [], + }; + } + + const frameInset = 34; + const panelGap = 34; + const contentTop = 186; + const contentBottom = height - 48; + const contentHeight = contentBottom - contentTop; + const contentWidth = width - frameInset * 2; + const rulesWidth = Math.round(contentWidth * 0.57); + const controlWidth = contentWidth - rulesWidth - panelGap; + const panelPaddingX = 34; + const panelPaddingY = 28; + const rulesPanel = this.createPanelBounds(frameInset, contentTop, rulesWidth, contentHeight); + const controlPanel = this.createPanelBounds(rulesPanel.right + panelGap, contentTop, controlWidth, contentHeight); + + return { + frameInset, + isCompactViewport, + cameraZoom, + visibleBounds, + titleX: width / 2, + titleY: 82, + titleFontSize: 52, + subtitleY: 124, + subtitleFontSize: 21, + subtitleWrapWidth: width * 0.46, + panelPaddingX, + panelPaddingY, + panelTitleFontSize: 24, + rulesHeadingFontSize: 20, + rulesBodyFontSize: 17, + rulesLineSpacing: 5, + rulesLineGap: 12, + rulesSectionGap: 18, + rulesWrapWidth: rulesPanel.width - panelPaddingX * 2, + footerFontSize: 16, + controlIntroFontSize: 16, + controlIntroWrapWidth: controlPanel.width - panelPaddingX * 2, + buttonWidth: controlPanel.width - panelPaddingX * 2, + buttonHeight: 66, + buttonGap: 18, + buttonTextWrapWidth: controlPanel.width - panelPaddingX * 2 - 28, + buttonLabelFontSize: 22, + buttonSubtitleFontSize: 13, + buttonLabelOffset: -10, + buttonSubtitleOffset: 15, + showButtonSubtitle: true, + rulesPanel, + controlPanel, + decorativeCards: [ + { + x: frameInset + 70, + y: 104, + angle: -18, + scale: 0.072, + alpha: 0.72, + }, + { + x: frameInset + 132, + y: 136, + angle: -6, + scale: 0.069, + alpha: 0.64, + }, + { + x: width - frameInset - 70, + y: 104, + angle: 18, + scale: 0.072, + alpha: 0.72, + }, + { + x: width - frameInset - 132, + y: 136, + angle: 6, + scale: 0.069, + alpha: 0.64, + }, + ], + }; + } + + private createPanelBounds(x: number, y: number, width: number, height: number): PanelBounds { + return { + x, + y, + width, + height, + centerX: x + width / 2, + centerY: y + height / 2, + right: x + width, + bottom: y + height, + }; + } + + private drawBackground(width: number, height: number, layout: MenuLayout): void { this.add.rectangle(0, 0, width, height, 0x123d22).setOrigin(0); - this.add.rectangle(width / 2, height / 2, width - 60, height - 60, 0x0b2916, 0.28) + this.add.rectangle( + layout.visibleBounds.centerX, + layout.visibleBounds.centerY, + layout.visibleBounds.width - layout.frameInset * 2, + layout.visibleBounds.height - layout.frameInset * 2, + 0x0b2916, + 0.28, + ) .setStrokeStyle(2, 0xe8c25d, 0.35); - this.add.rectangle(width * 0.34, height * 0.58, width * 0.46, height * 0.50, 0x0d2215, 0.82) + + this.add.rectangle( + layout.rulesPanel.centerX, + layout.rulesPanel.centerY, + layout.rulesPanel.width, + layout.rulesPanel.height, + 0x0d2215, + 0.84, + ) .setStrokeStyle(2, 0xc8a445, 0.4); - this.add.rectangle(width * 0.77, height * 0.58, width * 0.24, height * 0.50, 0x10261b, 0.86) + + this.add.rectangle( + layout.controlPanel.centerX, + layout.controlPanel.centerY, + layout.controlPanel.width, + layout.controlPanel.height, + 0x10261b, + 0.88, + ) .setStrokeStyle(2, 0xc8a445, 0.4); } - private drawDecorativeCards(width: number, height: number): void { - const positions = [ - [width * 0.08, height * 0.85], - [width * 0.14, height * 0.87], - [width * 0.92, height * 0.85], - [width * 0.86, height * 0.87], - ]; - - positions.forEach(([x, y]) => { - this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15)).setAlpha(0.9); + private drawDecorativeCards(layout: MenuLayout): void { + layout.decorativeCards.forEach((card) => { + this.add.image(card.x, card.y, 'retro').setScale(card.scale).setAngle(card.angle).setAlpha(card.alpha); }); } - private createRulesPanel(width: number, height: number): void { - const panelX = width * 0.12; - const panelY = 206; - const sections: RuleSection[] = [ - { - heading: 'Tavolo e squadre', - lines: [ - 'Si gioca in coppia: Sud e Nord contro Ovest ed Est.', - 'Nel vero scopone si distribuiscono tutte le 40 carte, 10 a giocatore.', - ], - }, - { - heading: 'Come si prende', - lines: [ - 'Ogni carta cattura una carta dello stesso valore oppure una combinazione equivalente.', - 'Se sul tavolo c’è una presa diretta dello stesso valore, quella ha sempre la precedenza.', - ], - }, - { - heading: 'Punteggio partita', - lines: [ - 'A fine mano contano carte, denari, settebello, primiera e scope.', - 'La sfida prosegue mano dopo mano finché una squadra arriva ad almeno 11 punti.', - ], - }, - ]; + private createRulesPanel(layout: MenuLayout): void { + const panelX = layout.rulesPanel.x + layout.panelPaddingX; + const panelY = layout.rulesPanel.y + layout.panelPaddingY; + const sections: RuleSection[] = layout.isCompactViewport + ? [] + : [ + { + heading: 'Tavolo e squadre', + lines: [ + 'Si gioca in coppia: Sud e Nord contro Ovest ed Est.', + 'Nel vero scopone si distribuiscono tutte le 40 carte, 10 a giocatore.', + ], + }, + { + heading: 'Come si prende', + lines: [ + 'Ogni carta cattura una carta dello stesso valore oppure una combinazione equivalente.', + 'Se sul tavolo c’è una presa diretta dello stesso valore, quella ha sempre la precedenza.', + ], + }, + { + heading: 'Punteggio partita', + lines: [ + 'A fine mano contano carte, denari, settebello, primiera e scope.', + 'La sfida prosegue mano dopo mano finché una squadra arriva ad almeno 11 punti.', + ], + }, + ]; - this.add.text(panelX, panelY, 'Regolamento essenziale', PANEL_TITLE_STYLE).setOrigin(0, 0.5); + this.add.text(panelX, panelY, layout.isCompactViewport ? 'Regole rapide' : 'Regolamento essenziale', { + ...PANEL_TITLE_STYLE, + fontSize: `${layout.panelTitleFontSize}px`, + }).setOrigin(0, 0); + + let currentY = panelY + layout.panelTitleFontSize + (layout.isCompactViewport ? 10 : 18); + + if (layout.isCompactViewport) { + const summaryLines = [ + '• Sud e Nord contro Ovest ed Est, con 10 carte per giocatore.', + '• Prendi lo stesso valore o una somma: la presa diretta viene prima.', + '• Carte, denari, settebello, primiera e scope: vince chi tocca 11 punti.', + ]; + + summaryLines.forEach((line) => { + const ruleText = this.add.text(panelX, currentY, line, { + ...BODY_STYLE, + fontSize: `${layout.rulesBodyFontSize}px`, + wordWrap: { width: layout.rulesWrapWidth }, + lineSpacing: layout.rulesLineSpacing, + }).setOrigin(0, 0); + currentY += ruleText.height + layout.rulesLineGap; + }); + } - let currentY = panelY + 44; sections.forEach((section) => { - this.add.text(panelX, currentY, section.heading, { + const sectionTitle = this.add.text(panelX, currentY, section.heading, { fontFamily: 'Georgia, serif', - fontSize: '20px', + fontSize: `${layout.rulesHeadingFontSize}px`, color: '#ffffff', resolution: 2, - }).setOrigin(0, 0.5); - currentY += 30; + }).setOrigin(0, 0); + currentY += sectionTitle.height + (layout.isCompactViewport ? 6 : 10); section.lines.forEach((line) => { - this.add.text(panelX, currentY, `• ${line}`, { + const ruleText = this.add.text(panelX, currentY, `• ${line}`, { ...BODY_STYLE, - wordWrap: { width: width * 0.40 }, - lineSpacing: 5, + fontSize: `${layout.rulesBodyFontSize}px`, + wordWrap: { width: layout.rulesWrapWidth }, + lineSpacing: layout.rulesLineSpacing, }).setOrigin(0, 0); - currentY += 54; + currentY += ruleText.height + layout.rulesLineGap; }); - currentY += 6; + currentY += layout.rulesSectionGap; }); - this.add.text(panelX, height - 92, 'Scegli la difficoltà quando vuoi iniziare: le preferenze audio vengono lette al momento della partita.', { + this.add.text(panelX, layout.rulesPanel.bottom - layout.panelPaddingY - 4, layout.isCompactViewport + ? 'Le preferenze audio salvate si applicano appena inizi.' + : 'Scegli la difficoltà quando vuoi iniziare: le preferenze audio vengono lette al momento della partita.', { ...BODY_STYLE, - fontSize: '16px', + fontSize: `${layout.footerFontSize}px`, color: '#cfe5cd', - wordWrap: { width: width * 0.41 }, - }).setOrigin(0, 0.5); + wordWrap: { width: layout.rulesWrapWidth }, + lineSpacing: layout.rulesLineSpacing, + }).setOrigin(0, 1); } - private createControlPanel(width: number, height: number, audioPreferences: ReturnType): void { - const panelCenterX = width * 0.77; + private createControlPanel(layout: MenuLayout, audioPreferences: ReturnType): void { + const panelCenterX = layout.controlPanel.centerX; const difficultyOptions: DifficultyOption[] = [ { label: 'Principiante', @@ -174,18 +457,37 @@ export class MenuScene extends Phaser.Scene { }, ]; - this.add.text(panelCenterX, 214, 'Inizia una partita', PANEL_TITLE_STYLE).setOrigin(0.5); - this.add.text(panelCenterX, 250, 'Ogni partita usa la difficoltà scelta qui sotto e le preferenze audio salvate.', { - ...BODY_STYLE, - fontSize: '16px', - color: '#d7ead1', - align: 'center', - wordWrap: { width: 250 }, + const panelTop = layout.controlPanel.y + layout.panelPaddingY; + this.add.text(panelCenterX, panelTop, layout.isCompactViewport ? 'Gioca subito' : 'Inizia una partita', { + ...PANEL_TITLE_STYLE, + fontSize: `${layout.panelTitleFontSize}px`, }).setOrigin(0.5, 0); + const introText = layout.isCompactViewport + ? null + : this.add.text(panelCenterX, panelTop + layout.panelTitleFontSize + 14, 'Ogni partita usa la difficoltà scelta qui sotto e le preferenze audio salvate.', { + ...BODY_STYLE, + fontSize: `${layout.controlIntroFontSize}px`, + color: '#d7ead1', + align: 'center', + wordWrap: { width: layout.controlIntroWrapWidth }, + }).setOrigin(0.5, 0); + + const settingsButtonCenterY = layout.controlPanel.bottom - layout.panelPaddingY - layout.buttonHeight / 2; + const audioStatusY = settingsButtonCenterY - layout.buttonHeight / 2 - (layout.isCompactViewport ? 12 : 24); + const difficultyAreaTop = layout.isCompactViewport + ? panelTop + layout.panelTitleFontSize + 10 + : introText!.y + introText!.height + 28; + const difficultyAreaBottom = audioStatusY - (layout.isCompactViewport ? 18 : 34); + const maxButtonHeight = (difficultyAreaBottom - difficultyAreaTop - layout.buttonGap * (difficultyOptions.length - 1)) / difficultyOptions.length; + const buttonHeight = Math.min(layout.buttonHeight, maxButtonHeight); + const buttonGap = difficultyOptions.length > 1 + ? (difficultyAreaBottom - difficultyAreaTop - buttonHeight * difficultyOptions.length) / (difficultyOptions.length - 1) + : 0; + difficultyOptions.forEach((option, index) => { - const y = 340 + index * 96; - this.createButton(panelCenterX, y, 260, 64, option.label, option.subtitle, option.palette, () => { + const y = difficultyAreaTop + buttonHeight / 2 + index * (buttonHeight + buttonGap); + this.createButton(panelCenterX, y, layout.buttonWidth, buttonHeight, option.label, layout.showButtonSubtitle ? option.subtitle : null, option.palette, layout, () => { this.startGame(option.value); }); }); @@ -193,21 +495,23 @@ export class MenuScene extends Phaser.Scene { const musicLabel = audioPreferences.musicEnabled ? 'attiva' : 'disattivata'; const effectsLabel = audioPreferences.effectsEnabled ? 'attivi' : 'disattivati'; - this.add.text(panelCenterX, height - 154, `Musica ${musicLabel} · Effetti ${effectsLabel}`, { + this.add.text(panelCenterX, audioStatusY, `Musica ${musicLabel} · Effetti ${effectsLabel}`, { ...BODY_STYLE, - fontSize: '16px', + fontSize: `${layout.footerFontSize}px`, color: '#cfe5cd', align: 'center', + wordWrap: { width: layout.controlIntroWrapWidth }, }).setOrigin(0.5); this.createButton( panelCenterX, - height - 100, - 260, - 58, + settingsButtonCenterY, + layout.buttonWidth, + layout.buttonHeight, 'Impostazioni audio', - 'Modifica musica ed effetti in modo indipendente', + layout.showButtonSubtitle ? 'Modifica musica ed effetti in modo indipendente' : null, { base: 0x1f6f78, hover: 0x2f8f99 }, + layout, () => { this.openSettings(); }, @@ -220,30 +524,34 @@ export class MenuScene extends Phaser.Scene { width: number, height: number, label: string, - subtitle: string, + subtitle: string | null, palette: MenuButtonPalette, + layout: MenuLayout, onClick: () => void, ): void { const background = this.add.rectangle(x, y, width, height, palette.base, 1) .setStrokeStyle(2, 0xf5e1a4, 0.4) .setInteractive({ useHandCursor: true }); - this.add.text(x, y - 10, label, { + this.add.text(x, y + layout.buttonLabelOffset, label, { fontFamily: 'Georgia, serif', - fontSize: '21px', + fontSize: `${layout.buttonLabelFontSize}px`, color: '#ffffff', stroke: '#000000', strokeThickness: 2, resolution: 2, }).setOrigin(0.5); - this.add.text(x, y + 14, subtitle, { - fontFamily: 'Georgia, serif', - fontSize: '13px', - color: '#f7f1d5', - resolution: 2, - align: 'center', - }).setOrigin(0.5); + if (subtitle) { + this.add.text(x, y + layout.buttonSubtitleOffset, subtitle, { + fontFamily: 'Georgia, serif', + fontSize: `${layout.buttonSubtitleFontSize}px`, + color: '#f7f1d5', + resolution: 2, + align: 'center', + wordWrap: { width: layout.buttonTextWrapWidth }, + }).setOrigin(0.5); + } background.on('pointerover', () => background.setFillStyle(palette.hover)); background.on('pointerout', () => background.setFillStyle(palette.base)); @@ -263,30 +571,9 @@ export class MenuScene extends Phaser.Scene { } private openSettings(): void { - this.ensureSettingsSceneAvailable(); this.cameras.main.fadeOut(250, 0, 30, 0); this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('SettingsScene', { returnSceneKey: 'MenuScene' }); }); } - - private ensureSettingsSceneAvailable(): void { - let existingScene: Phaser.Scene | null = null; - - try { - existingScene = this.scene.get('SettingsScene'); - } catch { - existingScene = null; - } - - if (existingScene instanceof SettingsScene) { - return; - } - - if (existingScene) { - this.scene.remove('SettingsScene'); - } - - this.scene.add('SettingsScene', SettingsScene, false); - } }