import Phaser from 'phaser'; import { Difficulty } from '../game/types'; import { GameSceneData, loadAudioPreferences } from '../game/preferences'; import { checkForUpdate, isDismissed, dismissUpdate, CURRENT_BUILD, UpdateInfo } from '../game/update-check'; type MenuButtonPalette = { base: number; hover: number; }; type DifficultyOption = { label: string; subtitle: string; value: Difficulty; palette: MenuButtonPalette; }; type RuleSection = { heading: string; 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', color: '#ffd700', stroke: '#000000', strokeThickness: 4, resolution: 2, }; const PANEL_TITLE_STYLE: Phaser.Types.GameObjects.Text.TextStyle = { fontFamily: 'Georgia, serif', fontSize: '24px', color: '#ffd700', resolution: 2, }; const BODY_STYLE: Phaser.Types.GameObjects.Text.TextStyle = { fontFamily: 'Georgia, serif', fontSize: '18px', color: '#f8f5e6', resolution: 2, }; export class MenuScene extends Phaser.Scene { constructor() { super({ key: 'MenuScene' }); } create(): void { const width = this.scale.width; const height = this.scale.height; const audioPreferences = loadAudioPreferences(); const layout = this.createLayout(width, height); this.cameras.main.setZoom(layout.cameraZoom); this.cameras.main.centerOn(width / 2, height / 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); 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); // Fire-and-forget update check — shows a dismissible banner if a newer // CI build is available on Gitea. checkForUpdate(CURRENT_BUILD).then(info => { if (info && !isDismissed(info.buildNumber) && this.scene.isActive()) { this.showUpdateBanner(layout, info); } }).catch(() => { /* network unavailable — silent */ }); } 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( 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( layout.rulesPanel.centerX, layout.rulesPanel.centerY, layout.rulesPanel.width, layout.rulesPanel.height, 0x0d2215, 0.84, ) .setStrokeStyle(2, 0xc8a445, 0.4); 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(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(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, 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; }); } sections.forEach((section) => { const sectionTitle = this.add.text(panelX, currentY, section.heading, { fontFamily: 'Georgia, serif', fontSize: `${layout.rulesHeadingFontSize}px`, color: '#ffffff', resolution: 2, }).setOrigin(0, 0); currentY += sectionTitle.height + (layout.isCompactViewport ? 6 : 10); section.lines.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; }); currentY += layout.rulesSectionGap; }); 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: `${layout.footerFontSize}px`, color: '#cfe5cd', wordWrap: { width: layout.rulesWrapWidth }, lineSpacing: layout.rulesLineSpacing, }).setOrigin(0, 1); } private createControlPanel(layout: MenuLayout, audioPreferences: ReturnType): void { const panelCenterX = layout.controlPanel.centerX; const difficultyOptions: DifficultyOption[] = [ { label: 'Principiante', subtitle: 'AI prudente e leggibile', value: 'beginner', palette: { base: 0x2e7d32, hover: 0x43a047 }, }, { label: 'Avanzato', subtitle: 'Pressione costante sul tavolo', value: 'advanced', palette: { base: 0xd97706, hover: 0xf59e0b }, }, { label: 'Maestro', subtitle: 'Massima lettura e priorità alle prese forti', value: 'master', palette: { base: 0xb91c1c, hover: 0xdc2626 }, }, ]; 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 = 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); }); }); const musicLabel = audioPreferences.musicEnabled ? 'attiva' : 'disattivata'; const effectsLabel = audioPreferences.effectsEnabled ? 'attivi' : 'disattivati'; this.add.text(panelCenterX, audioStatusY, `Musica ${musicLabel} · Effetti ${effectsLabel}`, { ...BODY_STYLE, fontSize: `${layout.footerFontSize}px`, color: '#cfe5cd', align: 'center', wordWrap: { width: layout.controlIntroWrapWidth }, }).setOrigin(0.5); this.createButton( panelCenterX, settingsButtonCenterY, layout.buttonWidth, layout.buttonHeight, 'Impostazioni audio', layout.showButtonSubtitle ? 'Modifica musica ed effetti in modo indipendente' : null, { base: 0x1f6f78, hover: 0x2f8f99 }, layout, () => { this.openSettings(); }, ); } private createButton( x: number, y: number, width: number, height: number, label: 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 + layout.buttonLabelOffset, label, { fontFamily: 'Georgia, serif', fontSize: `${layout.buttonLabelFontSize}px`, color: '#ffffff', stroke: '#000000', strokeThickness: 2, resolution: 2, }).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)); background.on('pointerdown', onClick); } private startGame(difficulty: Difficulty): void { const gameData: GameSceneData = { difficulty, audioPreferences: loadAudioPreferences(), }; this.cameras.main.fadeOut(300, 0, 30, 0); this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('GameScene', gameData); }); } private openSettings(): void { this.cameras.main.fadeOut(250, 0, 30, 0); this.cameras.main.once('camerafadeoutcomplete', () => { this.scene.start('SettingsScene', { returnSceneKey: 'MenuScene' }); }); } /** * Renders a slim dismissible notification bar at the bottom of the visible * area when a newer CI build is available on Gitea. * * Layout: [ "Aggiornamento disponibile — build N" | Scarica → | ✕ ] */ private showUpdateBanner(layout: MenuLayout, info: UpdateInfo): void { const bannerH = layout.isCompactViewport ? 40 : 48; const bannerW = layout.visibleBounds.width - layout.frameInset * 2; const cx = layout.visibleBounds.centerX; // Float the banner along the very bottom edge of the visible area. const cy = layout.visibleBounds.bottom - bannerH / 2 - 4; const fs = layout.isCompactViewport ? 13 : 15; const depth = 200; // Collect all objects so the dismiss handler can destroy them together. const objs: Phaser.GameObjects.GameObject[] = []; const track = (o: T): T => { objs.push(o); return o; }; track( this.add.rectangle(cx, cy, bannerW, bannerH, 0x0a1e3a, 0.96) .setStrokeStyle(1, 0x4a90d9, 0.85) .setDepth(depth), ); track( this.add.text(cx - bannerW / 2 + 12, cy, `Aggiornamento disponibile — build ${info.buildNumber}`, { fontFamily: 'Georgia, serif', fontSize: `${fs}px`, color: '#b8d8f8', resolution: 2 }, ).setOrigin(0, 0.5).setDepth(depth + 1), ); // Dismiss (✕) — always present, rightmost. const dismissBtn = track( this.add.text(cx + bannerW / 2 - 12, cy, '✕', { fontFamily: 'Georgia, serif', fontSize: `${fs + 4}px`, color: '#7a8a9a', resolution: 2, }).setOrigin(1, 0.5).setDepth(depth + 1).setInteractive({ useHandCursor: true }), ) as Phaser.GameObjects.Text; dismissBtn.on('pointerover', () => dismissBtn.setColor('#c0d0e0')); dismissBtn.on('pointerout', () => dismissBtn.setColor('#7a8a9a')); dismissBtn.on('pointerdown', () => { dismissUpdate(info.buildNumber); objs.forEach(o => o.destroy()); }); // "Scarica →" link — present when a direct APK URL is available. if (info.apkUrl) { const dlBtn = track( this.add.text(cx + bannerW / 2 - 38, cy, 'Scarica →', { fontFamily: 'Georgia, serif', fontSize: `${fs}px`, color: '#5ba3e8', resolution: 2, }).setOrigin(1, 0.5).setDepth(depth + 1).setInteractive({ useHandCursor: true }), ) as Phaser.GameObjects.Text; dlBtn.on('pointerover', () => dlBtn.setColor('#90c8ff')); dlBtn.on('pointerout', () => dlBtn.setColor('#5ba3e8')); dlBtn.on('pointerdown', () => window.open(info.apkUrl!, '_blank')); } } }