feat(SCOPONE-0010): improve capture pacing and settings

This commit is contained in:
Giancarmine Salucci
2026-04-09 23:00:59 +02:00
parent 77ab1f43a6
commit c107489b0a
7 changed files with 740 additions and 176 deletions

94
src/game/preferences.ts Normal file
View File

@@ -0,0 +1,94 @@
import { Difficulty } from './types';
export interface AudioPreferences {
musicEnabled: boolean;
effectsEnabled: boolean;
}
export interface GameSceneData {
difficulty: Difficulty;
audioPreferences: AudioPreferences;
}
export interface SettingsSceneData {
returnSceneKey?: string;
}
export const AUDIO_PREFERENCES_STORAGE_KEY = 'scopone.audio-preferences';
export const DEFAULT_AUDIO_PREFERENCES: AudioPreferences = {
musicEnabled: true,
effectsEnabled: true,
};
const cloneDefaultAudioPreferences = (): AudioPreferences => ({
...DEFAULT_AUDIO_PREFERENCES,
});
const getBrowserStorage = (): Storage | null => {
if (typeof window === 'undefined') {
return null;
}
try {
return window.localStorage;
} catch {
return null;
}
};
export const normalizeAudioPreferences = (value: unknown): AudioPreferences => {
if (!value || typeof value !== 'object') {
return cloneDefaultAudioPreferences();
}
const candidate = value as Partial<AudioPreferences>;
return {
musicEnabled:
typeof candidate.musicEnabled === 'boolean'
? candidate.musicEnabled
: DEFAULT_AUDIO_PREFERENCES.musicEnabled,
effectsEnabled:
typeof candidate.effectsEnabled === 'boolean'
? candidate.effectsEnabled
: DEFAULT_AUDIO_PREFERENCES.effectsEnabled,
};
};
export const loadAudioPreferences = (storage: Storage | null = getBrowserStorage()): AudioPreferences => {
if (!storage) {
return cloneDefaultAudioPreferences();
}
try {
const rawPreferences = storage.getItem(AUDIO_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return cloneDefaultAudioPreferences();
}
return normalizeAudioPreferences(JSON.parse(rawPreferences));
} catch {
return cloneDefaultAudioPreferences();
}
};
export const saveAudioPreferences = (
preferences: AudioPreferences,
storage: Storage | null = getBrowserStorage(),
): AudioPreferences => {
const normalizedPreferences = normalizeAudioPreferences(preferences);
if (!storage) {
return normalizedPreferences;
}
try {
storage.setItem(AUDIO_PREFERENCES_STORAGE_KEY, JSON.stringify(normalizedPreferences));
} catch {
return normalizedPreferences;
}
return normalizedPreferences;
};

View File

@@ -3,6 +3,12 @@ import { BootScene } from './scenes/BootScene';
import { MenuScene } from './scenes/MenuScene';
import { GameScene } from './scenes/GameScene';
class SettingsScene extends Phaser.Scene {
constructor() {
super({ key: 'SettingsScene' });
}
}
const installFullscreenRequest = (host: HTMLElement): void => {
const canRequestFullscreen =
typeof document.fullscreenEnabled === 'boolean'
@@ -48,7 +54,7 @@ const config: Phaser.Types.Core.GameConfig = {
height: 720,
backgroundColor: '#1a5c2a',
parent: 'game',
scene: [BootScene, MenuScene, GameScene],
scene: [BootScene, MenuScene, GameScene, SettingsScene],
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,

View File

@@ -7,6 +7,12 @@ import {
import { AIMove, AIDecisionProgress } from '../game/ai';
import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client';
import { CardTracker } from '../game/card-tracker';
import {
DEFAULT_AUDIO_PREFERENCES,
GameSceneData,
loadAudioPreferences,
normalizeAudioPreferences,
} from '../game/preferences';
// ---------------------------------------------------------------------------
// Suit ordering for hand grouping
@@ -31,6 +37,10 @@ const CH_A = 645 * CARD_SCALE_AI; // card height for AI ≈ 81
const SCOREBAR_H = 54;
const AI_MIN_THINK_MS = 1000;
const MOVE_OUTCOME_STATUS_MS = 2000;
const PLAYED_CARD_TRAVEL_MS = 400;
const CAPTURE_COLLAPSE_MS = 480;
const CAPTURE_COLLAPSE_DELAY_MS = 60;
const NON_CAPTURE_TABLE_TWEEN_MS = 560;
// Player positions:
// 0 = South (human, bottom), 1 = West (AI, left, rotated -90°)
@@ -101,6 +111,7 @@ export class GameScene extends Phaser.Scene {
private audioCtx: AudioContext | null = null;
private musicGain: GainNode | null = null;
private musicStarted = false;
private audioPreferences = DEFAULT_AUDIO_PREFERENCES;
constructor() {
super({ key: 'GameScene' });
@@ -110,13 +121,16 @@ export class GameScene extends Phaser.Scene {
// Create
// ---------------------------------------------------------------------------
create(data?: { difficulty?: Difficulty }): void {
create(data?: Partial<GameSceneData>): void {
const W = this.scale.width;
const H = this.scale.height;
this.tableCenter = { x: W / 2, y: (H + SCOREBAR_H) / 2 + 10 };
// Read difficulty from scene data (MenuScene passes it)
this.difficulty = data?.difficulty ?? 'advanced';
this.audioPreferences = data?.audioPreferences
? normalizeAudioPreferences(data.audioPreferences)
: loadAudioPreferences();
this.tracker = new CardTracker();
this.aiClient?.dispose();
this.aiClient = new AIWorkerClient();
@@ -467,12 +481,47 @@ export class GameScene extends Phaser.Scene {
}
private buildMoveOutcomeStatus(playerIdx: PlayerIndex, card: Card, capture: Card[] | null): string {
const player = this.state.players[playerIdx];
const actor = this.getMoveActorPrefix(playerIdx);
if (!capture || capture.length === 0) {
return `${player.name} gioca ${cardName(card)}`;
return `${actor} giocato ${cardName(card)}.`;
}
return `${player.name} cattura ${capture.map(cardName).join(', ')} con ${cardName(card)}`;
return `${actor} preso ${capture.map(cardName).join(', ')} con ${cardName(card)}.`;
}
private getTurnStatus(playerIdx: PlayerIndex): string {
switch (playerIdx) {
case 0:
return 'Tocca a te.';
case 2:
return 'Sta giocando il tuo compagno.';
default:
return `Sta giocando ${this.state.players[playerIdx].name}.`;
}
}
private getMoveActorPrefix(playerIdx: PlayerIndex): string {
switch (playerIdx) {
case 0:
return 'Hai';
case 2:
return 'Il tuo compagno ha';
default:
return `${this.state.players[playerIdx].name} ha`;
}
}
private getSingleCapturePrompt(capture: Card[]): string {
return `Puoi prendere ${capture.map(cardName).join(', ')}. Clicca di nuovo per confermare.`;
}
private getAiMoveErrorStatus(playerIdx: PlayerIndex): string {
if (playerIdx === 2) {
return 'Problema durante la mossa del tuo compagno.';
}
return `Problema durante la mossa di ${this.state.players[playerIdx].name}.`;
}
// ---------------------------------------------------------------------------
@@ -662,7 +711,7 @@ export class GameScene extends Phaser.Scene {
if (this.state.roundOver) { this.showRoundEnd(); return; }
const cur = this.state.currentPlayer;
const player = this.state.players[cur];
this.setStatus(`Turno di ${player.name}`, { persist: true });
this.setStatus(this.getTurnStatus(cur), { persist: true });
this.pulseLabel(cur);
if (player.isHuman) {
@@ -725,7 +774,7 @@ export class GameScene extends Phaser.Scene {
} catch (error) {
console.error('AI move failed', error);
if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) {
this.setStatus('Errore durante la mossa AI');
this.setStatus(this.getAiMoveErrorStatus(playerIdx));
}
} finally {
if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) {
@@ -795,14 +844,14 @@ export class GameScene extends Phaser.Scene {
}
if (captures.length === 0) {
this.setStatus('Nessuna cattura — clicca di nuovo per giocare sul tavolo');
this.setStatus('Non puoi prendere nulla: clicca di nuovo per lasciare la carta sul tavolo.');
this.highlightTableForDump(card);
} else if (captures.length === 1) {
this.setStatus(`Cattura: ${captures[0].map(cardName).join(', ')} — clicca di nuovo per confermare`);
this.setStatus(this.getSingleCapturePrompt(captures[0]));
this.pendingCaptures = captures;
this.highlightCapture(captures[0]);
} else {
this.setStatus('Scegli le carte da catturare');
this.setStatus('Scegli quale presa vuoi fare.');
this.pendingCaptures = captures;
this.highlightMultipleCaptures(captures);
}
@@ -878,7 +927,7 @@ export class GameScene extends Phaser.Scene {
bg.lineStyle(2, color.stroke, 0.8);
bg.strokeRoundedRect(W / 2 - 180, y - 14, 360, 28, 7);
const btn = this.add.zone(W / 2, y, 360, 28).setInteractive({ useHandCursor: true }).setDepth(21);
const txt = this.add.text(W / 2, y, `Cattura: ${label}`, {
const txt = this.add.text(W / 2, y, `Prendi: ${label}`, {
fontFamily: 'serif', fontSize: '14px', color: color.text,
}).setOrigin(0.5).setDepth(21);
btn.on('pointerdown', () => this.confirmMove(this.selectedCard!, cap));
@@ -966,7 +1015,7 @@ export class GameScene extends Phaser.Scene {
this.tweens.add({
targets: cardImg,
x: this.tableCenter.x, y: this.tableCenter.y,
duration: 200, ease: 'Power2',
duration: PLAYED_CARD_TRAVEL_MS, ease: 'Power2',
onComplete: () => {
this.spawnCaptureEffect(this.tableCenter.x, this.tableCenter.y, isSettebello);
@@ -991,7 +1040,7 @@ export class GameScene extends Phaser.Scene {
this.tweens.add({
targets: img,
x: pilePos.x, y: pilePos.y, alpha: 0,
duration: 240, delay: 30,
duration: CAPTURE_COLLAPSE_MS, delay: CAPTURE_COLLAPSE_DELAY_MS,
onComplete: () => {
img.setVisible(false);
done++;
@@ -1022,7 +1071,7 @@ export class GameScene extends Phaser.Scene {
this.tweens.add({
targets: cardImg,
x: tablePos.x, y: tablePos.y, angle: randomAngle,
duration: 280, ease: 'Back.Out',
duration: NON_CAPTURE_TABLE_TWEEN_MS, ease: 'Back.Out',
onComplete: () => this.afterMove(playerIdx, card, null, nextState, oldState),
});
}
@@ -1300,6 +1349,7 @@ export class GameScene extends Phaser.Scene {
// ---------------------------------------------------------------------------
private startMusic(): void {
if (!this.audioPreferences.musicEnabled) return;
if (this.musicStarted) return;
this.musicStarted = true;
try {
@@ -1374,6 +1424,7 @@ export class GameScene extends Phaser.Scene {
}
private playSfx(type: 'card_play' | 'capture' | 'scopa' | 'settebello'): void {
if (!this.audioPreferences.effectsEnabled) return;
if (!this.audioCtx) return;
const ctx = this.audioCtx;
const now = ctx.currentTime;
@@ -1406,6 +1457,7 @@ export class GameScene extends Phaser.Scene {
}
private stopMusic(): void {
if (!this.audioPreferences.musicEnabled) return;
if (this.musicGain && this.audioCtx) {
this.musicGain.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + 1.5);
}
@@ -1432,14 +1484,14 @@ export class GameScene extends Phaser.Scene {
panel.strokeRoundedRect(W / 2 - 280, H / 2 - 210, 560, 420, 16);
const lines: Array<[string, string]> = [
[`Fine Mano ${this.state.roundNumber ?? 1}`, '#ffd700'],
[`Fine della mano ${this.state.roundNumber ?? 1}`, '#ffd700'],
['', ''],
[`Team A +${t0.roundPoints} pt → ${t0.totalPoints} totali`, '#aaffaa'],
[`Team B +${t1.roundPoints} pt → ${t1.totalPoints} totali`, '#ffaaaa'],
[`Squadra tua +${t0.roundPoints} pt → ${t0.totalPoints} totali`, '#aaffaa'],
[`Avversari +${t1.roundPoints} pt → ${t1.totalPoints} totali`, '#ffaaaa'],
['', ''],
[`Carte A=${t0.cards} B=${t1.cards} ${pointStr(bd.cartePoint)}`, '#ffffff'],
[`Denari A=${t0.denari} B=${t1.denari} ${pointStr(bd.denariPoint)}`, '#ffdd88'],
[`Settebello → ${bd.settebelloPoint === 0 ? 'Team A' : 'Team B'}`, '#ffd700'],
[`Settebello → ${bd.settebelloPoint === 0 ? 'squadra tua' : 'avversari'}`, '#ffd700'],
[`Primiera A=${t0.primiera} B=${t1.primiera} ${pointStr(bd.primieraPoint)}`, '#aaddff'],
[`Scope A=${bd.scopeTeam0} B=${bd.scopeTeam1}`, '#ccffcc'],
];
@@ -1501,11 +1553,11 @@ export class GameScene extends Phaser.Scene {
pg.lineStyle(3, 0xffd700, 0.8);
pg.strokeRoundedRect(W / 2 - 220, H / 2 - 150, 440, 310, 20);
this.add.text(W / 2, H / 2 - 110, 'PARTITA FINITA', {
this.add.text(W / 2, H / 2 - 110, 'PARTITA CONCLUSA', {
fontFamily: 'Georgia, serif', fontSize: '44px', color: '#ffd700',
stroke: '#000', strokeThickness: 6,
}).setOrigin(0.5).setDepth(42);
this.add.text(W / 2, H / 2 - 30, win ? 'Team A (Tu + Compagno)' : 'Team B (AI)', {
this.add.text(W / 2, H / 2 - 30, win ? 'Vince la tua squadra' : 'Vincono gli avversari', {
fontFamily: 'serif', fontSize: '26px',
color: win ? '#aaffaa' : '#ffaaaa',
}).setOrigin(0.5).setDepth(42);
@@ -1584,5 +1636,5 @@ function cardName(card: Card): string {
}
function pointStr(p: 0 | 1 | null): string {
return p === null ? '(pari)' : p === 0 ? '→ A' : '→ B';
return p === null ? '(pari)' : p === 0 ? '→ squadra tua' : '→ avversari';
}

View File

@@ -1,5 +1,47 @@
import Phaser from 'phaser';
import { Difficulty } from '../game/types';
import { GameSceneData, loadAudioPreferences } from '../game/preferences';
import { SettingsScene } from './SettingsScene';
type MenuButtonPalette = {
base: number;
hover: number;
};
type DifficultyOption = {
label: string;
subtitle: string;
value: Difficulty;
palette: MenuButtonPalette;
};
type RuleSection = {
heading: string;
lines: string[];
};
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() {
@@ -7,97 +49,244 @@ export class MenuScene extends Phaser.Scene {
}
create(): void {
const W = this.scale.width;
const H = this.scale.height;
const width = this.scale.width;
const height = this.scale.height;
const audioPreferences = loadAudioPreferences();
// Background felt
this.add.rectangle(0, 0, W, H, 0x1a5c2a).setOrigin(0);
this.drawBackground(width, height);
this.drawDecorativeCards(width, height);
this.ensureSettingsSceneAvailable();
// Title
this.add.text(W / 2, H * 0.18, 'Scopone Scientifico', {
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: '52px',
color: '#ffd700',
stroke: '#000000',
strokeThickness: 4,
fontSize: '21px',
color: '#d9f2d2',
resolution: 2,
}).setOrigin(0.5);
this.add.text(W / 2, H * 0.30, '2 vs 2 · Tu + Compagno vs 2 AI', {
fontFamily: 'serif',
fontSize: '22px',
color: '#ccffcc',
resolution: 2,
}).setOrigin(0.5);
this.createRulesPanel(width, height);
this.createControlPanel(width, height, audioPreferences);
}
// Rules summary
const rules = [
'40 carte Napoletane · 10 a testa',
'Cattura per valore o somma',
'Punteggio: Carte · Denari · Settebello · Primiera · Scope',
'Prima squadra a 11 punti vince',
private drawBackground(width: number, height: number): 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)
.setStrokeStyle(2, 0xe8c25d, 0.35);
this.add.rectangle(width * 0.34, height * 0.58, width * 0.46, height * 0.50, 0x0d2215, 0.82)
.setStrokeStyle(2, 0xc8a445, 0.4);
this.add.rectangle(width * 0.77, height * 0.58, width * 0.24, height * 0.50, 0x10261b, 0.86)
.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],
];
rules.forEach((line, i) => {
this.add.text(W / 2, H * 0.40 + i * 26, line, {
fontFamily: 'serif',
fontSize: '17px',
color: '#ffffff',
resolution: 2,
}).setOrigin(0.5);
positions.forEach(([x, y]) => {
this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15)).setAlpha(0.9);
});
}
// Difficulty selection label
this.add.text(W / 2, H * 0.60, 'Scegli la difficoltà:', {
fontFamily: 'Georgia, serif',
fontSize: '20px',
color: '#ffd700',
resolution: 2,
}).setOrigin(0.5);
// Difficulty buttons
const difficulties: Array<{ label: string; value: Difficulty; color: number; hoverColor: number }> = [
{ label: 'Principiante', value: 'beginner', color: 0x4caf50, hoverColor: 0x66bb6a },
{ label: 'Avanzato', value: 'advanced', color: 0xff9800, hoverColor: 0xffb74d },
{ label: 'Maestro', value: 'master', color: 0xf44336, hoverColor: 0xef5350 },
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.',
],
},
];
const btnWidth = 200;
const btnHeight = 50;
const totalWidth = difficulties.length * btnWidth + (difficulties.length - 1) * 20;
const startX = (W - totalWidth) / 2 + btnWidth / 2;
this.add.text(panelX, panelY, 'Regolamento essenziale', PANEL_TITLE_STYLE).setOrigin(0, 0.5);
difficulties.forEach((d, i) => {
const x = startX + i * (btnWidth + 20);
const y = H * 0.70;
const btn = this.add.rectangle(x, y, btnWidth, btnHeight, d.color, 1)
.setInteractive({ useHandCursor: true });
this.add.text(x, y, d.label, {
let currentY = panelY + 44;
sections.forEach((section) => {
this.add.text(panelX, currentY, section.heading, {
fontFamily: 'Georgia, serif',
fontSize: '20px',
color: '#ffffff',
stroke: '#000000',
strokeThickness: 2,
resolution: 2,
}).setOrigin(0.5);
}).setOrigin(0, 0.5);
currentY += 30;
btn.on('pointerover', () => btn.setFillStyle(d.hoverColor));
btn.on('pointerout', () => btn.setFillStyle(d.color));
btn.on('pointerdown', () => {
this.cameras.main.fadeOut(300, 0, 30, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('GameScene', { difficulty: d.value });
});
section.lines.forEach((line) => {
this.add.text(panelX, currentY, `${line}`, {
...BODY_STYLE,
wordWrap: { width: width * 0.40 },
lineSpacing: 5,
}).setOrigin(0, 0);
currentY += 54;
});
currentY += 6;
});
this.add.text(panelX, height - 92, 'Scegli la difficoltà quando vuoi iniziare: le preferenze audio vengono lette al momento della partita.', {
...BODY_STYLE,
fontSize: '16px',
color: '#cfe5cd',
wordWrap: { width: width * 0.41 },
}).setOrigin(0, 0.5);
}
private createControlPanel(width: number, height: number, audioPreferences: ReturnType<typeof loadAudioPreferences>): void {
const panelCenterX = width * 0.77;
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 },
},
];
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 },
}).setOrigin(0.5, 0);
difficultyOptions.forEach((option, index) => {
const y = 340 + index * 96;
this.createButton(panelCenterX, y, 260, 64, option.label, option.subtitle, option.palette, () => {
this.startGame(option.value);
});
});
// Show some face-down cards decoratively
const positions = [
[W * 0.08, H * 0.85], [W * 0.14, H * 0.87], [W * 0.92, H * 0.85], [W * 0.86, H * 0.87],
];
for (const [x, y] of positions) {
this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15));
const musicLabel = audioPreferences.musicEnabled ? 'attiva' : 'disattivata';
const effectsLabel = audioPreferences.effectsEnabled ? 'attivi' : 'disattivati';
this.add.text(panelCenterX, height - 154, `Musica ${musicLabel} · Effetti ${effectsLabel}`, {
...BODY_STYLE,
fontSize: '16px',
color: '#cfe5cd',
align: 'center',
}).setOrigin(0.5);
this.createButton(
panelCenterX,
height - 100,
260,
58,
'Impostazioni audio',
'Modifica musica ed effetti in modo indipendente',
{ base: 0x1f6f78, hover: 0x2f8f99 },
() => {
this.openSettings();
},
);
}
private createButton(
x: number,
y: number,
width: number,
height: number,
label: string,
subtitle: string,
palette: MenuButtonPalette,
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, {
fontFamily: 'Georgia, serif',
fontSize: '21px',
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);
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.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);
}
}

193
src/scenes/SettingsScene.ts Normal file
View File

@@ -0,0 +1,193 @@
import Phaser from 'phaser';
import {
AudioPreferences,
SettingsSceneData,
loadAudioPreferences,
saveAudioPreferences,
} from '../game/preferences';
type ToggleDefinition = {
key: keyof AudioPreferences;
label: string;
description: string;
};
type ToggleVisuals = {
background: Phaser.GameObjects.Rectangle;
statusText: Phaser.GameObjects.Text;
};
const TOGGLE_DEFINITIONS: ToggleDefinition[] = [
{
key: 'musicEnabled',
label: 'Musica del tavolo',
description: 'Controlla il tappeto sonoro di sottofondo durante la partita.',
},
{
key: 'effectsEnabled',
label: 'Effetti di gioco',
description: 'Attiva prese, scope e segnali sonori senza toccare la musica.',
},
];
export class SettingsScene extends Phaser.Scene {
private preferences: AudioPreferences = loadAudioPreferences();
private returnSceneKey = 'MenuScene';
private toggleVisuals = new Map<keyof AudioPreferences, ToggleVisuals>();
constructor() {
super({ key: 'SettingsScene' });
}
create(data?: SettingsSceneData): void {
const width = this.scale.width;
const height = this.scale.height;
this.preferences = loadAudioPreferences();
this.returnSceneKey = data?.returnSceneKey ?? 'MenuScene';
this.add.rectangle(0, 0, width, height, 0x123d22).setOrigin(0);
this.add.rectangle(width / 2, height / 2, width - 120, height - 120, 0x0d2216, 0.88)
.setStrokeStyle(2, 0xd9b75f, 0.45);
this.add.text(width / 2, 120, 'Impostazioni audio', {
fontFamily: 'Georgia, serif',
fontSize: '46px',
color: '#ffd700',
stroke: '#000000',
strokeThickness: 4,
resolution: 2,
}).setOrigin(0.5);
this.add.text(width / 2, 176, 'Musica ed effetti sono separati: ogni scelta viene salvata subito e sarà usata nelle prossime partite.', {
fontFamily: 'Georgia, serif',
fontSize: '19px',
color: '#d7ead1',
resolution: 2,
align: 'center',
wordWrap: { width: 760 },
}).setOrigin(0.5);
TOGGLE_DEFINITIONS.forEach((definition, index) => {
this.createToggleRow(definition, width / 2, 286 + index * 132, 760, 96);
});
this.add.text(width / 2, 556, 'Puoi tornare al menu in qualsiasi momento: la difficoltà si sceglie lì, laudio resta salvato qui.', {
fontFamily: 'Georgia, serif',
fontSize: '16px',
color: '#cfe5cd',
resolution: 2,
align: 'center',
wordWrap: { width: 720 },
}).setOrigin(0.5);
this.createButton(width / 2, height - 112, 280, 60, 'Torna al menu', 'Rientra alla schermata iniziale', () => {
this.returnToMenu();
});
}
private createToggleRow(definition: ToggleDefinition, x: number, y: number, width: number, height: number): void {
const row = this.add.rectangle(x, y, width, height, 0x173323, 0.96)
.setStrokeStyle(2, 0xcaa74a, 0.35)
.setInteractive({ useHandCursor: true });
this.add.text(x - width / 2 + 28, y - 16, definition.label, {
fontFamily: 'Georgia, serif',
fontSize: '24px',
color: '#ffffff',
resolution: 2,
}).setOrigin(0, 0.5);
this.add.text(x - width / 2 + 28, y + 14, definition.description, {
fontFamily: 'Georgia, serif',
fontSize: '16px',
color: '#d8ead2',
resolution: 2,
wordWrap: { width: 470 },
}).setOrigin(0, 0.5);
const toggleBackground = this.add.rectangle(x + width / 2 - 106, y, 148, 46, 0x356b39, 1)
.setStrokeStyle(2, 0xf5e1a4, 0.45);
const statusText = this.add.text(x + width / 2 - 106, y, '', {
fontFamily: 'Georgia, serif',
fontSize: '20px',
color: '#ffffff',
resolution: 2,
}).setOrigin(0.5);
this.toggleVisuals.set(definition.key, {
background: toggleBackground,
statusText,
});
row.on('pointerdown', () => {
this.togglePreference(definition.key);
});
row.on('pointerover', () => row.setFillStyle(0x1d402c, 1));
row.on('pointerout', () => row.setFillStyle(0x173323, 0.96));
this.refreshToggle(definition.key);
}
private createButton(
x: number,
y: number,
width: number,
height: number,
label: string,
subtitle: string,
onClick: () => void,
): void {
const button = this.add.rectangle(x, y, width, height, 0x1f6f78, 1)
.setStrokeStyle(2, 0xf5e1a4, 0.4)
.setInteractive({ useHandCursor: true });
this.add.text(x, y - 10, label, {
fontFamily: 'Georgia, serif',
fontSize: '21px',
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);
button.on('pointerover', () => button.setFillStyle(0x2f8f99));
button.on('pointerout', () => button.setFillStyle(0x1f6f78));
button.on('pointerdown', onClick);
}
private togglePreference(key: keyof AudioPreferences): void {
this.preferences = saveAudioPreferences({
...this.preferences,
[key]: !this.preferences[key],
});
this.refreshToggle(key);
}
private refreshToggle(key: keyof AudioPreferences): void {
const visuals = this.toggleVisuals.get(key);
if (!visuals) {
return;
}
const enabled = this.preferences[key];
visuals.background.setFillStyle(enabled ? 0x356b39 : 0x7a2f2f);
visuals.statusText.setText(enabled ? 'Attivo' : 'Disattivato');
}
private returnToMenu(): void {
this.cameras.main.fadeOut(250, 0, 30, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start(this.returnSceneKey);
});
}
}