feat(SCOPONE-0010): redesign main menu layout

This commit is contained in:
Giancarmine Salucci
2026-04-10 11:41:11 +02:00
parent c107489b0a
commit a4e2891c87
3 changed files with 418 additions and 127 deletions

View File

@@ -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 allultimo 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 allultimo 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<typeof loadAudioPreferences>): void {
const panelCenterX = width * 0.77;
private createControlPanel(layout: MenuLayout, audioPreferences: ReturnType<typeof loadAudioPreferences>): 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);
}
}