feat(SCOPONE-0010): improve capture pacing and settings
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 all’ultimo 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
193
src/scenes/SettingsScene.ts
Normal 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ì, l’audio 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user