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

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';
}