Files
scopone/src/scenes/GameScene.ts
Giancarmine Salucci 3f74c57665
Some checks failed
Android Build & Publish / android (push) Failing after 2m10s
feat(SCOPONE-0013): PIMC AI rewrite + Gitea Android CI pipeline
- Replace minimax with PIMC (Perfect Information Monte Carlo) search
- Add PIMC_SCOPE_BOOST=150 → effective scopa value 540 (was 390)
  → Master win rate: 67.5% → 72.5% vs legacy AI (target ≥60%)
  → Advanced win rate: 97.5% vs beginner AI (target ≥55%)
  → Scope gap in losses: 6.54 → 3.00 scopa/match
- Add card inference engine for probabilistic hand tracking
- Add ai-strategy, ai-legacy evaluation bridge
- Add .gitea/workflows/android-build.yml: build debug + unsigned
  release APK and publish to Gitea generic package registry
2026-05-24 16:29:04 +02:00

1718 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Phaser from 'phaser';
import { Card, PlayerIndex, GameState, Difficulty } from '../game/types';
import {
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome,
nextPlayer
} from '../game/engine';
import { AIMove, AIDecisionProgress } from '../game/ai';
import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client';
import { CardTracker } from '../game/card-tracker';
import { CardInferenceEngine } from '../game/card-inference';
import {
DEFAULT_AUDIO_PREFERENCES,
GameSceneData,
loadAudioPreferences,
normalizeAudioPreferences,
} from '../game/preferences';
// ---------------------------------------------------------------------------
// Suit ordering for hand grouping
// ---------------------------------------------------------------------------
const SUIT_ORDER: Record<string, number> = { denara: 0, coppe: 1, bastoni: 2, spade: 3 };
function sortHand(hand: Card[]): void {
hand.sort((a, b) => (a.value - b.value) || (SUIT_ORDER[a.suit] - SUIT_ORDER[b.suit]));
}
// ---------------------------------------------------------------------------
// Layout constants
// ---------------------------------------------------------------------------
const CARD_SCALE_HUMAN = 0.165; // larger cards for the human player
const CARD_SCALE_AI = 0.125; // smaller cards for AI (fit in side slots)
const CW_H = 402 * CARD_SCALE_HUMAN; // card width for human ≈ 66
const CH_H = 645 * CARD_SCALE_HUMAN; // card height for human ≈ 106
const CW_A = 402 * CARD_SCALE_AI; // card width for AI ≈ 50
const CH_A = 645 * CARD_SCALE_AI; // card height for AI ≈ 81
// Scorebar height at top
const SCOREBAR_H = 54;
const AI_MIN_THINK_MS = 1000;
const MOVE_OUTCOME_STATUS_MS = 2000;
const PLAYED_CARD_TRAVEL_MS = 320;
const CAPTURE_COLLAPSE_MS = 360;
const CAPTURE_COLLAPSE_DELAY_MS = 60;
const NON_CAPTURE_TABLE_TWEEN_MS = 420;
const RELAYOUT_TWEEN_MS = 120;
// Player positions:
// 0 = South (human, bottom), 1 = West (AI, left, rotated -90°)
// 2 = North (AI partner, top), 3 = East (AI, right, rotated +90°)
/**
* GameScene — Scopone Scientifico main game scene.
*
* Phaser features exercised (many discovered via trueref /local/phaser):
*
* [trueref] postFX.addGlow(color, outerStrength, innerStrength, knockout)
* → used on selected cards and settebello on the table
* [trueref] camera.shake(duration, intensity) + camera.flash(duration, r, g, b)
* → triggered on scopa and settebello captures
* [trueref] graphics.fillRoundedRect(x, y, w, h, radius)
* → all UI panels (score bar, HUD, think bar)
* [trueref] ParticleEmitter (v3.60+) this.add.particles(x, y, texture, config)
* → capture bursts, scopa explosion, card trails, victory confetti
* [trueref] camera.postFX.addVignette(cx, cy, radius, strength)
* → cinematic vignette on the main camera
* [trueref] image.setAngle() — used to rotate AI side cards ±90°
*/
export class GameScene extends Phaser.Scene {
private state!: GameState;
private cardImages: Map<string, Phaser.GameObjects.Image> = new Map();
// Difficulty & card tracker
private difficulty: Difficulty = 'advanced';
private tracker: CardTracker = new CardTracker();
private inference: CardInferenceEngine = new CardInferenceEngine(this.tracker);
private aiClient: AIWorkerClientLike | null = null;
// Active player highlight
private activeHighlightRect: Phaser.GameObjects.Graphics | null = null;
// Live score bar texts
private hudA!: { scope: Phaser.GameObjects.Text; cards: Phaser.GameObjects.Text;
denari: Phaser.GameObjects.Text; sette: Phaser.GameObjects.Text;
prim: Phaser.GameObjects.Text; total: Phaser.GameObjects.Text };
private hudB!: { scope: Phaser.GameObjects.Text; cards: Phaser.GameObjects.Text;
denari: Phaser.GameObjects.Text; sette: Phaser.GameObjects.Text;
prim: Phaser.GameObjects.Text; total: Phaser.GameObjects.Text };
private roundText!: Phaser.GameObjects.Text;
// Status bar
private statusText!: Phaser.GameObjects.Text;
private statusTimer: Phaser.Time.TimerEvent | null = null;
private persistentStatusText = '';
// Think bar
private thinkBar!: Phaser.GameObjects.Graphics;
// Player label containers (pulsed on active turn)
private playerLabels: Map<PlayerIndex, Phaser.GameObjects.Container> = new Map();
// Interaction state
private selectedCard: Card | null = null;
private selectedCardImg: Phaser.GameObjects.Image | null = null;
private selectedGlow: any = null;
private selectedGlowTween: Phaser.Tweens.Tween | null = null;
private pendingCaptures: Card[][] = [];
private tableHighlights: Phaser.GameObjects.GameObject[] = [];
private captureListenerImgs: Phaser.GameObjects.Image[] = [];
private aiThinking = false;
private tableCenter!: { x: number; y: number };
// Web Audio
private audioCtx: AudioContext | null = null;
private musicGain: GainNode | null = null;
private musicStarted = false;
private audioPreferences = DEFAULT_AUDIO_PREFERENCES;
constructor() {
super({ key: 'GameScene' });
}
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
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.inference = new CardInferenceEngine(this.tracker);
this.aiClient?.dispose();
this.aiClient = new AIWorkerClient();
this.events.once(Phaser.Scenes.Events.SHUTDOWN, this.handleSceneShutdown, this);
this.events.once(Phaser.Scenes.Events.DESTROY, this.handleSceneShutdown, this);
this.generateParticleTextures();
this.drawBackground(W, H);
this.buildScoreBar(W);
this.buildStatusBar(W, H);
this.buildThinkBar(W, H);
this.buildPlayerLabels(W, H);
// Custom vignette drawn in scene (avoids darkening score bar corners)
this.drawVignette(W, H);
this.input.once('pointerdown', () => this.startMusic());
const initialDealer = Phaser.Math.Between(0, 3) as PlayerIndex;
this.state = createInitialState(initialDealer);
this.dealAnimation(() => {
this.updateScoreBar();
this.nextTurn();
});
}
// ---------------------------------------------------------------------------
// Particle textures
// ---------------------------------------------------------------------------
private generateParticleTextures(): void {
// [trueref: graphics.generateTexture] — makes particle sprites without image files
const mkCircle = (key: string, r: number) => {
const g = this.add.graphics();
g.fillStyle(0xffffff, 1);
g.fillCircle(r, r, r);
g.generateTexture(key, r * 2, r * 2);
g.destroy();
};
mkCircle('particle_glow', 8);
mkCircle('particle_sm', 4);
const sq = this.add.graphics();
sq.fillStyle(0xffffff, 1);
sq.fillRect(0, 0, 5, 5);
sq.generateTexture('particle_sq', 5, 5);
sq.destroy();
// Diamond (star) for settebello
const dia = this.add.graphics();
dia.fillStyle(0xffffff, 1);
dia.fillTriangle(8, 0, 16, 8, 8, 16);
dia.fillTriangle(0, 8, 8, 16, 8, 0);
dia.generateTexture('particle_dia', 16, 16);
dia.destroy();
}
// ---------------------------------------------------------------------------
// Background
// ---------------------------------------------------------------------------
private drawBackground(W: number, H: number): void {
// Layered felt
this.add.rectangle(0, 0, W, H, 0x193d20).setOrigin(0).setDepth(0);
// Lighter dark overlay (was 0.5)
this.add.rectangle(W / 2, H / 2, W, H, 0x0d2410, 0.18).setDepth(0);
// Subtle dot grid
const g = this.add.graphics().setDepth(0);
g.fillStyle(0x000000, 0.07);
for (let x = 20; x < W; x += 36) {
for (let y = SCOREBAR_H + 10; y < H; y += 36) {
g.fillCircle(x, y, 1.2);
}
}
// Outer decorative border
const border = this.add.graphics().setDepth(1);
border.lineStyle(2, 0xffd700, 0.18);
border.strokeRect(8, SCOREBAR_H + 6, W - 16, H - SCOREBAR_H - 14);
// Table area — using [trueref: graphics.fillRoundedRect]
const tg = this.add.graphics().setDepth(1);
tg.fillStyle(0x0a2912, 0.55);
tg.fillRoundedRect(W * 0.12, SCOREBAR_H + 90, W * 0.76, H - SCOREBAR_H - 185, 18);
// Softer border (was 1.5 / 0.5)
tg.lineStyle(1, 0x3d8b3d, 0.18);
tg.strokeRoundedRect(W * 0.12, SCOREBAR_H + 90, W * 0.76, H - SCOREBAR_H - 185, 18);
// Player hand slot — no border, just subtle fill
const hg = this.add.graphics().setDepth(1);
hg.fillStyle(0x000000, 0.22);
hg.fillRoundedRect(W * 0.12, H - CH_H - 28, W * 0.76, CH_H + 16, 10);
}
/** Soft multi-step vignette that only covers below the score bar. */
private drawVignette(W: number, H: number): void {
const vg = this.add.graphics().setDepth(3);
const gameH = H - SCOREBAR_H;
const steps = 14;
// Left gradient
for (let i = 0; i < steps; i++) {
const t = 1 - i / steps;
const alpha = 0.32 * t * t;
const bw = W * 0.14 / steps;
vg.fillStyle(0x000000, alpha);
vg.fillRect(i * bw, SCOREBAR_H, bw + 1, gameH);
}
// Right gradient
for (let i = 0; i < steps; i++) {
const t = 1 - i / steps;
const alpha = 0.32 * t * t;
const bw = W * 0.14 / steps;
vg.fillRect(W - (i + 1) * bw, SCOREBAR_H, bw + 1, gameH);
}
// Bottom gradient
for (let i = 0; i < 10; i++) {
const t = 1 - i / 10;
const alpha = 0.22 * t * t;
const bh = H * 0.10 / 10;
vg.fillStyle(0x000000, alpha);
vg.fillRect(0, H - (i + 1) * bh, W, bh + 1);
}
}
// ---------------------------------------------------------------------------
// Score bar (top, full width)
// ---------------------------------------------------------------------------
private buildScoreBar(W: number): void {
// [trueref: fillRoundedRect] Panel background
const bg = this.add.graphics().setDepth(8);
bg.fillStyle(0x050e07);
bg.fillRect(0, 0, W, SCOREBAR_H);
bg.lineStyle(1, 0xffd700, 0.7);
bg.lineBetween(0, SCOREBAR_H, W, SCOREBAR_H);
const mkTxt = (x: number, y: number, val: string, color = '#ffffff', size = '15px') =>
this.add.text(x, y, val, { fontFamily: 'monospace', fontSize: size, color, resolution: 2 })
.setOrigin(0.5).setDepth(9);
// Left side — Team A
this.add.text(10, SCOREBAR_H / 2, 'TEAM A (Tu + Compagno)', {
fontFamily: 'serif', fontSize: '13px', color: '#aaffaa', resolution: 2,
}).setOrigin(0, 0.5).setDepth(9);
// Right side — Team B
this.add.text(W - 10, SCOREBAR_H / 2, 'TEAM B (AI Ovest + AI Est)', {
fontFamily: 'serif', fontSize: '13px', color: '#ffaaaa', resolution: 2,
}).setOrigin(1, 0.5).setDepth(9);
// Center — Round
this.roundText = mkTxt(W / 2, SCOREBAR_H / 2, 'Mano 1', '#ffd700', '16px');
// Column headers (shared, centered-ish)
const cols = ['Scope', 'Carte', 'Denari', '7Bello', 'Primiera', 'TOTALE'];
const xA = [230, 295, 370, 445, 520, 610];
const xB = [W - 230, W - 295, W - 370, W - 445, W - 520, W - 610];
cols.forEach((_, i) => {
const label = ['Sc', 'Ca', 'De', '7B', 'Pr', 'Pt'][i];
this.add.text(xA[i], SCOREBAR_H * 0.28, label, {
fontFamily: 'monospace', fontSize: '10px', color: '#999999', resolution: 2,
}).setOrigin(0.5).setDepth(9);
this.add.text(xB[i], SCOREBAR_H * 0.28, label, {
fontFamily: 'monospace', fontSize: '10px', color: '#999999', resolution: 2,
}).setOrigin(0.5).setDepth(9);
});
// Live value texts
const mkA = (xi: number) => mkTxt(xA[xi], SCOREBAR_H * 0.72, '0', '#aaffaa', '17px');
const mkB = (xi: number) => mkTxt(xB[xi], SCOREBAR_H * 0.72, '0', '#ffaaaa', '17px');
this.hudA = {
scope: mkA(0), cards: mkA(1), denari: mkA(2), sette: mkA(3), prim: mkA(4),
total: this.add.text(xA[5], SCOREBAR_H * 0.72, '0', {
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#aaffaa',
stroke: '#000', strokeThickness: 2, resolution: 2,
}).setOrigin(0.5).setDepth(9),
};
this.hudB = {
scope: mkB(0), cards: mkB(1), denari: mkB(2), sette: mkB(3), prim: mkB(4),
total: this.add.text(xB[5], SCOREBAR_H * 0.72, '0', {
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#ffaaaa',
stroke: '#000', strokeThickness: 2, resolution: 2,
}).setOrigin(0.5).setDepth(9),
};
}
private updateScoreBar(): void {
const s = this.state;
const t0 = s.teamScores[0];
const t1 = s.teamScores[1];
const team0 = [s.players[0], s.players[2]];
const team1 = [s.players[1], s.players[3]];
const scope0 = team0.reduce((n, p) => n + p.scope, 0);
const scope1 = team1.reduce((n, p) => n + p.scope, 0);
const pile0 = team0.flatMap(p => p.pile);
const pile1 = team1.flatMap(p => p.pile);
const den0 = pile0.filter(c => c.suit === 'denara').length;
const den1 = pile1.filter(c => c.suit === 'denara').length;
const prim0 = calcPrimiera(pile0);
const prim1 = calcPrimiera(pile1);
const sette0 = pile0.some(c => c.suit === 'denara' && c.value === 7);
const sette1 = pile1.some(c => c.suit === 'denara' && c.value === 7);
const setAnim = (txt: Phaser.GameObjects.Text, val: string | number) => {
const v = String(val);
if (txt.text === v) return;
txt.setText(v);
this.tweens.add({ targets: txt, scaleX: 1.4, scaleY: 1.4, duration: 100, yoyo: true });
};
setAnim(this.hudA.scope, scope0);
setAnim(this.hudA.cards, pile0.length);
setAnim(this.hudA.denari, den0);
setAnim(this.hudA.sette, sette0 ? '✓' : '');
setAnim(this.hudA.prim, prim0 > 0 ? prim0 : '-');
setAnim(this.hudA.total, t0.totalPoints);
setAnim(this.hudB.scope, scope1);
setAnim(this.hudB.cards, pile1.length);
setAnim(this.hudB.denari, den1);
setAnim(this.hudB.sette, sette1 ? '✓' : '');
setAnim(this.hudB.prim, prim1 > 0 ? prim1 : '-');
setAnim(this.hudB.total, t1.totalPoints);
this.roundText.setText(`Mano ${s.roundNumber ?? 1}`);
}
private flashScoreScope(team: 0 | 1): void {
const txt = team === 0 ? this.hudA.scope : this.hudB.scope;
this.tweens.add({
targets: txt,
scaleX: 2.2, scaleY: 2.2,
duration: 180, yoyo: true, repeat: 3,
onStart: () => txt.setColor('#ffd700'),
onComplete: () => txt.setColor(team === 0 ? '#aaffaa' : '#ffaaaa'),
});
}
// ---------------------------------------------------------------------------
// Status bar + think bar
// ---------------------------------------------------------------------------
private buildStatusBar(W: number, H: number): void {
// Background chip
const bg = this.add.graphics().setDepth(9);
bg.fillStyle(0x000000, 0.55);
bg.fillRoundedRect(W / 2 - 280, H - CH_H - 50, 560, 28, 8);
this.statusText = this.add.text(W / 2, H - CH_H - 36, '', {
fontFamily: 'serif', fontSize: '17px', color: '#ffffff',
stroke: '#000', strokeThickness: 2,
resolution: 2,
}).setOrigin(0.5).setDepth(10);
}
private buildThinkBar(W: number, H: number): void {
// Thin progress bar just below the score bar
this.thinkBar = this.add.graphics().setDepth(11).setVisible(false);
}
private showThinkBar(playerIdx: PlayerIndex, remainingRatio = 1): void {
this.thinkBar.setVisible(true);
this.drawThinkBar(playerIdx, remainingRatio);
}
private updateThinkBar(playerIdx: PlayerIndex, progress: AIDecisionProgress): void {
this.drawThinkBar(playerIdx, 1 - progress.progress);
}
private logMasterAIDiagnostics(
playerIdx: PlayerIndex,
move: AIMove,
progress: AIDecisionProgress | null,
): void {
if (this.difficulty !== 'master' || !progress) {
return;
}
console.info('[AI master diagnostics]', {
playerIdx,
roundNumber: this.state.roundNumber ?? 1,
moveCardId: move.card.id,
captureCount: move.capture.length,
timing: {
elapsedMs: progress.elapsedMs,
budgetMs: progress.budgetMs,
progress: progress.progress,
timedOut: progress.timedOut ?? false,
},
search: {
batchesCompleted: progress.batchesCompleted,
cardsRemaining: progress.cardsRemaining ?? null,
sampleCount: progress.sampleCount ?? null,
maxDepth: progress.maxDepth ?? null,
completedDepth: progress.completedDepth ?? null,
rootMoveCount: progress.rootMoveCount ?? null,
aspirationExpansions: progress.aspirationExpansions ?? 0,
},
});
}
private drawThinkBar(playerIdx: PlayerIndex, remainingRatio: number): void {
const W = this.scale.width;
const tg = this.thinkBar;
const color = (playerIdx === 0 || playerIdx === 2) ? 0x44ff88 : 0xff5555;
const clampedRatio = Phaser.Math.Clamp(remainingRatio, 0, 1);
const width = clampedRatio * W;
tg.clear();
tg.fillStyle(0x000000, 0.4);
tg.fillRect(0, SCOREBAR_H, W, 4);
if (width <= 0) return;
tg.fillStyle(color, 0.85);
tg.fillRect(0, SCOREBAR_H, width, 4);
tg.fillStyle(0xffffff, 0.6);
tg.fillRect(Math.max(0, width - 6), SCOREBAR_H, Math.min(6, width), 4);
}
private hideThinkBar(): void {
this.thinkBar.clear();
this.thinkBar.setVisible(false);
}
private clearStatusTimer(): void {
if (!this.statusTimer) {
return;
}
this.statusTimer.remove(false);
this.statusTimer.destroy();
this.statusTimer = null;
}
private waitForDelay(delayMs: number): Promise<void> {
if (delayMs <= 0 || !this.scene.isActive('GameScene')) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.time.delayedCall(delayMs, () => resolve());
});
}
private buildMoveOutcomeStatus(playerIdx: PlayerIndex, card: Card, capture: Card[] | null): string {
const actor = this.getMoveActorPrefix(playerIdx);
if (!capture || capture.length === 0) {
return `${actor} giocato ${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}.`;
}
private withHiResText(
style: Phaser.Types.GameObjects.Text.TextStyle,
): Phaser.Types.GameObjects.Text.TextStyle {
return {
...style,
resolution: style.resolution ?? 2,
};
}
private createPlayerNameplate(
x: number,
y: number,
text: string,
color: string,
fillColor: number,
strokeColor: number,
): Phaser.GameObjects.Container {
const label = this.add.text(0, 0, text, this.withHiResText({
fontFamily: 'serif',
fontSize: '11px',
color,
stroke: '#000',
strokeThickness: 1,
align: 'center',
})).setOrigin(0.5);
const padX = 12;
const padY = 4;
const width = label.width + padX * 2;
const height = label.height + padY * 2;
const background = this.add.graphics();
background.fillStyle(fillColor, 0.92);
background.fillRoundedRect(-width / 2, -height / 2, width, height, 9);
background.lineStyle(1, strokeColor, 0.7);
background.strokeRoundedRect(-width / 2, -height / 2, width, height, 9);
return this.add.container(x, y, [background, label]).setDepth(18);
}
// ---------------------------------------------------------------------------
// Player labels (pulse on active turn)
// ---------------------------------------------------------------------------
private buildPlayerLabels(W: number, H: number): void {
const defs: Array<{ idx: PlayerIndex; x: number; y: number; color: string;
fillColor: number; strokeColor: number; txt: string }> = [
{ idx: 0, x: W / 2, y: H - 9, color: '#dfffe5', fillColor: 0x0b2410, strokeColor: 0x4aa86a, txt: 'Tu [Team A]' },
{ idx: 1, x: CH_A + 58, y: H / 2 + SCOREBAR_H / 2, color: '#ffe0e0', fillColor: 0x260d0d, strokeColor: 0xb36a6a, txt: 'AI Ovest [B]' },
{ idx: 2, x: W / 2, y: SCOREBAR_H + 11, color: '#dfffe5', fillColor: 0x0b2410, strokeColor: 0x4aa86a, txt: 'Compagno [Team A]' },
{ idx: 3, x: W - CH_A - 58, y: H / 2 + SCOREBAR_H / 2, color: '#ffe0e0', fillColor: 0x260d0d, strokeColor: 0xb36a6a, txt: 'AI Est [B]' },
];
for (const d of defs) {
const lbl = this.createPlayerNameplate(d.x, d.y, d.txt, d.color, d.fillColor, d.strokeColor);
this.playerLabels.set(d.idx, lbl);
}
}
private pulseLabel(playerIdx: PlayerIndex): void {
// Reset all labels
for (const [idx, lbl] of this.playerLabels) {
lbl.setAlpha(idx === playerIdx ? 1 : 0.4);
}
// Remove old highlight
if (this.activeHighlightRect) {
this.activeHighlightRect.destroy();
this.activeHighlightRect = null;
}
// Draw turn indicator bar below/outside player's cards
const W = this.scale.width;
const H = this.scale.height;
const color = teamOf(playerIdx) === 0 ? 0x00ff44 : 0xff4444;
const gfx = this.add.graphics().setDepth(10);
const barThick = 4;
switch (playerIdx) {
case 0: // South — horizontal bar below hand
gfx.fillStyle(color, 0.9);
gfx.fillRoundedRect(W * 0.15, H - barThick - 2, W * 0.7, barThick, 2);
break;
case 1: // West — vertical bar left of cards
gfx.fillStyle(color, 0.9);
gfx.fillRoundedRect(2, SCOREBAR_H + 60, barThick, H - SCOREBAR_H - CH_H - 80, 2);
break;
case 2: // North — horizontal bar above partner cards
gfx.fillStyle(color, 0.9);
gfx.fillRoundedRect(W * 0.18, SCOREBAR_H + 2, W * 0.64, barThick, 2);
break;
case 3: // East — vertical bar right of cards
gfx.fillStyle(color, 0.9);
gfx.fillRoundedRect(W - barThick - 2, SCOREBAR_H + 60, barThick, H - SCOREBAR_H - CH_H - 80, 2);
break;
}
this.activeHighlightRect = gfx;
// Pulse the glow
this.tweens.add({
targets: gfx,
alpha: { from: 1, to: 0.4 },
duration: 600, yoyo: true, repeat: -1, ease: 'Sine.InOut',
});
// Pulse the label
const activeLbl = this.playerLabels.get(playerIdx)!;
this.tweens.add({
targets: activeLbl,
scaleX: 1.08, scaleY: 1.08,
duration: 300, yoyo: true, ease: 'Sine.InOut',
});
}
// ---------------------------------------------------------------------------
// Deal animation
// ---------------------------------------------------------------------------
private dealAnimation(onComplete: () => void): void {
const W = this.scale.width;
const H = this.scale.height;
let delay = 0, done = 0;
const allCards: Array<{ card: Card; p: number; destX: number; destY: number; face: boolean }> = [];
for (let p = 0; p < 4; p++) {
sortHand(this.state.players[p as PlayerIndex].hand);
const positions = this.getHandPositions(p as PlayerIndex,
this.state.players[p as PlayerIndex].hand.length);
this.state.players[p as PlayerIndex].hand.forEach((card, i) =>
allCards.push({ card, p, destX: positions[i].x, destY: positions[i].y, face: p === 0 })
);
}
const total = allCards.length;
for (const { card, p, destX, destY, face } of allCards) {
const scale = p === 0 ? CARD_SCALE_HUMAN : CARD_SCALE_AI;
const img = this.add.image(W / 2, SCOREBAR_H + 40,
face ? 'cards' : 'retro', face ? card.id : undefined)
.setScale(scale).setDepth(5).setAlpha(0);
// Rotate side cards [trueref: image.setAngle() — rotation in degrees]
if (p === 1) img.setAngle(-90);
if (p === 3) img.setAngle(90);
// Subtle drop shadow on all cards [trueref: postFX.addShadow]
if (this.renderer.type === Phaser.WEBGL) {
img.postFX.addShadow(0, 4, 0.006, 1.2, 0x000000, 6, 0.4);
}
this.cardImages.set(card.id, img);
this.tweens.add({
targets: img,
x: destX, y: destY, alpha: 1,
duration: 260, delay,
ease: 'Power2',
onComplete: () => {
done++;
if (done === total) onComplete();
},
});
delay += 25;
}
}
// ---------------------------------------------------------------------------
// Hand / table positions
// ---------------------------------------------------------------------------
private getHandPositions(playerIdx: PlayerIndex, count: number): Array<{ x: number; y: number }> {
const W = this.scale.width;
const H = this.scale.height;
switch (playerIdx) {
case 0: { // South (human) — horizontal, bottom
const sp = Math.min(CW_H + 5, (W * 0.72) / Math.max(count - 1, 1));
const sx = W / 2 - ((count - 1) * sp) / 2;
return Array.from({ length: count }, (_, i) => ({ x: sx + i * sp, y: H - CH_H / 2 - 18 }));
}
case 2: { // North (partner) — horizontal, top
const sp = Math.min(CW_A + 4, (W * 0.65) / Math.max(count - 1, 1));
const sx = W / 2 - ((count - 1) * sp) / 2;
return Array.from({ length: count }, (_, i) => ({ x: sx + i * sp, y: SCOREBAR_H + CH_A / 2 + 22 }));
}
case 1: { // West — vertical, rotated -90°
// When rotated -90°, visual footprint: width=CH_A, height=CW_A
const available = H - SCOREBAR_H - CH_H - 60;
const sp = Math.min(CW_A + 4, available / Math.max(count - 1, 1));
const sy = (H + SCOREBAR_H) / 2 - ((count - 1) * sp) / 2;
return Array.from({ length: count }, (_, i) => ({ x: CH_A / 2 + 10, y: sy + i * sp }));
}
case 3: { // East — vertical, rotated +90°
const available = H - SCOREBAR_H - CH_H - 60;
const sp = Math.min(CW_A + 4, available / Math.max(count - 1, 1));
const sy = (H + SCOREBAR_H) / 2 - ((count - 1) * sp) / 2;
return Array.from({ length: count }, (_, i) => ({ x: W - CH_A / 2 - 10, y: sy + i * sp }));
}
}
}
private getTablePositions(count: number): Array<{ x: number; y: number }> {
const W = this.scale.width;
const H = this.scale.height;
const cols = Math.min(count, 7);
const rows = Math.ceil(count / cols);
const spX = CW_H + 10;
const spY = CH_H + 8;
const results: Array<{ x: number; y: number }> = [];
for (let i = 0; i < count; i++) {
results.push({
x: W / 2 + (i % cols - (cols - 1) / 2) * spX,
y: this.tableCenter.y + (Math.floor(i / cols) - (rows - 1) / 2) * spY,
});
}
return results;
}
// ---------------------------------------------------------------------------
// Turn management
// ---------------------------------------------------------------------------
private nextTurn(): void {
if (this.state.roundOver) { this.showRoundEnd(); return; }
const cur = this.state.currentPlayer;
const player = this.state.players[cur];
this.setStatus(this.getTurnStatus(cur), { persist: true });
this.pulseLabel(cur);
if (player.isHuman) {
this.hideThinkBar();
this.enableHumanInteraction();
} else {
this.aiThinking = true;
this.showThinkBar(cur, 1);
void this.doAIMove(cur);
}
}
private handleSceneShutdown(): void {
this.clearStatusTimer();
this.aiClient?.dispose();
this.aiClient = null;
this.aiThinking = false;
if (this.thinkBar) {
this.hideThinkBar();
}
}
private async doAIMove(playerIdx: PlayerIndex): Promise<void> {
const turnState = this.state;
const aiClient = this.aiClient;
let finalProgress: AIDecisionProgress | null = null;
const thinkStartedAt = Date.now();
if (!aiClient) {
return;
}
try {
const move = await aiClient.chooseMove(
this.state,
playerIdx,
this.difficulty,
this.tracker,
(progress) => {
if (this.aiClient !== aiClient || !this.scene.isActive('GameScene') || this.state !== turnState) return;
this.updateThinkBar(playerIdx, progress);
if (progress.difficulty !== 'master') return;
finalProgress = progress;
},
{ inference: this.inference },
);
const remainingThinkMs = AI_MIN_THINK_MS - (Date.now() - thinkStartedAt);
if (remainingThinkMs > 0) {
await this.waitForDelay(remainingThinkMs);
}
if (this.aiClient !== aiClient) return;
if (!this.scene.isActive('GameScene')) return;
if (this.state !== turnState || this.state.currentPlayer !== playerIdx || this.state.roundOver) return;
this.logMasterAIDiagnostics(playerIdx, move, finalProgress);
this.hideThinkBar();
this.aiThinking = false;
this.executeMove(playerIdx, move.card, move.capture);
} catch (error) {
console.error('AI move failed', error);
if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) {
this.setStatus(this.getAiMoveErrorStatus(playerIdx));
}
} finally {
if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) {
this.hideThinkBar();
this.aiThinking = false;
}
}
}
// ---------------------------------------------------------------------------
// Human interaction
// ---------------------------------------------------------------------------
private enableHumanInteraction(): void {
for (const card of this.state.players[0].hand) {
const img = this.cardImages.get(card.id);
if (!img) continue;
img.setInteractive({ useHandCursor: true });
img.on('pointerover', () => {
if (this.selectedCard?.id !== card.id) img.setY(img.y - 10);
});
img.on('pointerout', () => {
if (this.selectedCard?.id !== card.id) img.setY(img.y + 10);
});
img.on('pointerdown', () => this.onCardClick(card, img));
}
}
private disableHumanInteraction(): void {
for (const card of this.state.players[0].hand) {
const img = this.cardImages.get(card.id);
if (img) { img.removeAllListeners(); img.disableInteractive(); }
}
this.clearSelectedGlow();
this.clearHighlights();
this.selectedCard = null;
this.selectedCardImg = null;
}
private onCardClick(card: Card, img: Phaser.GameObjects.Image): void {
if (this.aiThinking) return;
const captures = findCaptures(card, this.state.table);
if (this.selectedCard?.id === card.id) {
// Second click: confirm if possible
if (captures.length === 1) { this.confirmMove(card, captures[0]); return; }
if (captures.length === 0) { this.confirmMove(card, []); return; }
this.deselectCard();
return;
}
this.deselectCard();
this.selectedCard = card;
this.selectedCardImg = img;
// [trueref: postFX.addGlow] — pulsing selection glow
if (this.renderer.type === Phaser.WEBGL) {
img.postFX.clear();
this.selectedGlow = img.postFX.addGlow(0xffff88, 4, 0);
this.selectedGlowTween = this.tweens.add({
targets: this.selectedGlow,
outerStrength: 12,
duration: 500, yoyo: true, repeat: -1, ease: 'Sine.InOut',
});
} else {
img.setTint(0xffff88);
}
if (captures.length === 0) {
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(this.getSingleCapturePrompt(captures[0]));
this.pendingCaptures = captures;
this.highlightCapture(captures[0]);
} else {
this.setStatus('Scegli quale presa vuoi fare.');
this.pendingCaptures = captures;
this.highlightMultipleCaptures(captures);
}
}
private deselectCard(): void {
this.clearSelectedGlow();
if (this.selectedCardImg && this.renderer.type !== Phaser.WEBGL) {
this.selectedCardImg.clearTint();
}
this.selectedCard = null;
this.selectedCardImg = null;
this.pendingCaptures = [];
this.clearHighlights();
}
private clearSelectedGlow(): void {
this.selectedGlowTween?.stop();
this.selectedGlowTween = null;
this.selectedGlow = null;
if (this.selectedCardImg) {
this.selectedCardImg.postFX?.clear();
if (this.renderer.type === Phaser.WEBGL) {
// Re-add shadow after clearing
this.selectedCardImg.postFX.addShadow(0, 4, 0.006, 1.2, 0x000000, 6, 0.4);
}
}
}
private confirmMove(card: Card, capture: Card[]): void {
this.disableHumanInteraction();
this.executeMove(0, card, capture);
}
// ---------------------------------------------------------------------------
// Highlights
// ---------------------------------------------------------------------------
private highlightCapture(capture: Card[]): void {
this.clearHighlights();
for (const card of capture) {
const img = this.cardImages.get(card.id);
if (!img) continue;
const hl = this.add.rectangle(img.x, img.y, CW_H + 8, CH_H + 8, 0x00ff88, 0.2)
.setStrokeStyle(2, 0x00ff88, 0.9).setDepth(4);
this.tableHighlights.push(hl);
img.setInteractive({ useHandCursor: true });
img.once('pointerdown', () => this.confirmMove(this.selectedCard!, capture));
this.captureListenerImgs.push(img);
}
}
private highlightMultipleCaptures(captures: Card[][]): void {
this.clearHighlights();
const W = this.scale.width;
// Distinct color palette for each capture option
const palette = [
{ fill: 0x00cc66, stroke: 0x00ff88, text: '#00ffaa', bg: 0x001a0a },
{ fill: 0x3399ff, stroke: 0x66bbff, text: '#88ccff', bg: 0x001020 },
{ fill: 0xff8833, stroke: 0xffaa55, text: '#ffcc88', bg: 0x1a0d00 },
{ fill: 0xcc44cc, stroke: 0xff66ff, text: '#ff88ff', bg: 0x1a001a },
{ fill: 0x00cccc, stroke: 0x44ffff, text: '#88ffff', bg: 0x001a1a },
];
captures.forEach((cap, i) => {
const color = palette[i % palette.length];
const label = cap.map(cardName).join(' + ');
const y = SCOREBAR_H + 70 + i * 36;
const bg = this.add.graphics().setDepth(20);
bg.fillStyle(color.bg, 0.9);
bg.fillRoundedRect(W / 2 - 180, y - 14, 360, 28, 7);
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, `Prendi: ${label}`, {
fontFamily: 'serif', fontSize: '14px', color: color.text,
resolution: 2,
}).setOrigin(0.5).setDepth(21);
btn.on('pointerdown', () => this.confirmMove(this.selectedCard!, cap));
(bg as any)._captureBtn = true;
this.tableHighlights.push(bg, btn, txt);
// Highlight table cards belonging to this option with the matching color
for (const c of cap) {
const img = this.cardImages.get(c.id);
if (img) {
const hl = this.add.rectangle(img.x, img.y, CW_H + 8, CH_H + 8, color.fill, 0.2)
.setStrokeStyle(2, color.stroke, 0.9).setDepth(4);
this.tableHighlights.push(hl);
img.setInteractive({ useHandCursor: true });
img.once('pointerdown', () => this.confirmMove(this.selectedCard!, cap));
this.captureListenerImgs.push(img);
}
}
});
}
private highlightTableForDump(card: Card): void {
const { x, y } = this.tableCenter;
const W = this.scale.width;
const H = this.scale.height;
const tw = W * 0.76 - 4;
const th = H - SCOREBAR_H - 175;
const hl = this.add.graphics().setDepth(4).setInteractive(
new Phaser.Geom.Rectangle(x - tw / 2, y - th / 2, tw, th),
Phaser.Geom.Rectangle.Contains
);
hl.fillStyle(0xffff00, 0.05);
hl.fillRoundedRect(x - tw / 2, y - th / 2, tw, th, 18);
hl.lineStyle(2, 0xffff00, 0.4);
hl.strokeRoundedRect(x - tw / 2, y - th / 2, tw, th, 18);
hl.on('pointerdown', () => this.confirmMove(card, []));
this.tableHighlights.push(hl);
}
private clearHighlights(): void {
for (const img of this.captureListenerImgs) img.off('pointerdown');
this.captureListenerImgs = [];
for (const h of this.tableHighlights) h.destroy();
this.tableHighlights = [];
}
// ---------------------------------------------------------------------------
// Execute move + animate
// ---------------------------------------------------------------------------
private executeMove(playerIdx: PlayerIndex, card: Card, capture: Card[]): void {
const tableBeforeMove = [...this.state.table];
const { nextState, capture: captureResult, isScopa } = applyMove(
this.state, playerIdx, card, capture.length > 0 ? capture : undefined
);
const oldState = this.state;
this.state = nextState;
// Update card tracker
this.tracker.trackPlay(card);
if (captureResult) {
this.tracker.trackCapture(captureResult.captured);
}
// Update inference engine
this.inference.onMove(
playerIdx,
{ card, capture: captureResult?.captured ?? [] },
tableBeforeMove,
);
const cardImg = this.cardImages.get(card.id)!;
cardImg.setDepth(15);
const isSettebello = captureResult !== null &&
[card, ...captureResult.captured].some(c => c.suit === 'denara' && c.value === 7);
const isDenariCapture = captureResult !== null && !isSettebello &&
[card, ...captureResult.captured].some(c => c.suit === 'denara');
const isPrimieraPick = captureResult !== null &&
[card, ...captureResult.captured].some(c => [7, 6, 1, 5].includes(c.value));
// Flip to face-up
cardImg.setTexture('cards', card.id);
if (this.renderer.type === Phaser.WEBGL) {
cardImg.postFX.clear();
cardImg.postFX.addShadow(0, 4, 0.006, 1.2, 0x000000, 6, 0.4);
}
this.playSfx(isSettebello ? 'settebello' : captureResult ? 'capture' : 'card_play');
this.spawnCardTrail(cardImg);
if (captureResult) {
this.tweens.add({
targets: cardImg,
x: this.tableCenter.x, y: this.tableCenter.y,
duration: PLAYED_CARD_TRAVEL_MS, ease: 'Power2',
onComplete: () => {
this.spawnCaptureEffect(this.tableCenter.x, this.tableCenter.y, isSettebello);
if (isSettebello) {
// [trueref: camera.shake + camera.flash] — dramatic impact for settebello
this.cameras.main.shake(450, 0.012);
this.cameras.main.flash(400, 255, 215, 0);
this.spawnSettebelloFlash();
}
if (isDenariCapture) {
this.spawnDenariEffect(this.tableCenter.x, this.tableCenter.y);
}
if (isPrimieraPick && !isSettebello) {
this.spawnPrimieraEffect(this.tableCenter.x, this.tableCenter.y);
}
const pilePos = this.getPilePos(playerIdx);
const toClear = [card, ...captureResult.captured];
let done = 0;
for (const c of toClear) {
const img = this.cardImages.get(c.id)!;
this.tweens.add({
targets: img,
x: pilePos.x, y: pilePos.y, alpha: 0,
duration: CAPTURE_COLLAPSE_MS, delay: CAPTURE_COLLAPSE_DELAY_MS,
onComplete: () => {
img.setVisible(false);
done++;
if (done === toClear.length) {
if (isScopa) {
this.playSfx('scopa');
this.doScopaEffect(playerIdx, () =>
this.afterMove(playerIdx, card, captureResult?.captured ?? null, nextState, oldState)
);
} else {
this.afterMove(playerIdx, card, captureResult?.captured ?? null, nextState, oldState);
}
}
},
});
}
},
});
} else {
const tablePos = this.nextTablePos(nextState.table);
// Drop shadow on newly placed table card [trueref: postFX.addShadow]
if (this.renderer.type === Phaser.WEBGL) {
cardImg.postFX.clear();
cardImg.postFX.addShadow(2, 6, 0.008, 1.2, 0x000000, 8, 0.5);
}
// Random "fallen card" angle — readable but organic [trueref: image.setAngle]
const randomAngle = Phaser.Math.Between(-9, 9);
this.tweens.add({
targets: cardImg,
x: tablePos.x, y: tablePos.y, angle: randomAngle,
duration: NON_CAPTURE_TABLE_TWEEN_MS, ease: 'Back.Out',
onComplete: () => this.afterMove(playerIdx, card, null, nextState, oldState),
});
}
}
private afterMove(
playerIdx: PlayerIndex,
card: Card,
capture: Card[] | null,
nextState: GameState,
_old: GameState,
): void {
this.updateScoreBar();
this.relayoutTable();
if (!nextState.roundOver) {
this.relayoutHand(0);
}
this.setStatus(this.buildMoveOutcomeStatus(playerIdx, card, capture), {
durationMs: MOVE_OUTCOME_STATUS_MS,
onExpire: () => {
if (!this.scene.isActive('GameScene')) {
return;
}
if (nextState.roundOver) {
this.showRoundEnd();
return;
}
this.nextTurn();
},
});
}
private relayoutHand(playerIdx: PlayerIndex): void {
const hand = this.state.players[playerIdx].hand;
sortHand(hand);
const positions = this.getHandPositions(playerIdx, hand.length);
hand.forEach((card, i) => {
const img = this.cardImages.get(card.id);
if (img) {
this.tweens.add({
targets: img,
x: positions[i].x,
y: positions[i].y,
duration: RELAYOUT_TWEEN_MS,
ease: 'Cubic.Out',
});
}
});
}
private relayoutTable(): void {
const table = this.state.table;
if (!table.length) return;
const positions = this.getTablePositions(table.length);
table.forEach((card, i) => {
const img = this.cardImages.get(card.id);
if (img?.visible) {
this.tweens.add({
targets: img,
x: positions[i].x,
y: positions[i].y,
duration: RELAYOUT_TWEEN_MS,
ease: 'Cubic.Out',
});
}
});
}
// ---------------------------------------------------------------------------
// Particle effects
// ---------------------------------------------------------------------------
private spawnCaptureEffect(x: number, y: number, settebello = false): void {
const color = settebello ? 0xffd700 : 0x66ffcc;
const count = settebello ? 55 : 28;
// [trueref: ParticleEmitter v3.60+] this.add.particles returns emitter directly
const e1 = this.add.particles(x, y, 'particle_glow', {
lifespan: { min: 350, max: 700 },
speed: { min: 80, max: 280 },
scale: { start: 0.9, end: 0 },
alpha: { start: 1, end: 0 },
tint: color, gravityY: 100, emitting: false,
}).setDepth(25);
e1.explode(count);
this.time.delayedCall(750, () => e1.destroy());
if (settebello) {
const e2 = this.add.particles(x, y, 'particle_dia', {
lifespan: 1000, speed: { min: 60, max: 200 },
scale: { start: 1.3, end: 0 }, alpha: { start: 1, end: 0 },
tint: 0xffd700, gravityY: -30, emitting: false,
}).setDepth(26);
e2.explode(30);
this.time.delayedCall(1100, () => e2.destroy());
}
}
private doScopaEffect(playerIdx: PlayerIndex, onDone: () => void): void {
const W = this.scale.width;
const H = this.scale.height;
const isTeamA = (playerIdx === 0 || playerIdx === 2);
// [trueref: camera.shake + camera.flash]
this.cameras.main.shake(600, 0.018);
this.cameras.main.flash(500, 255, 215, 0);
// Gold explosion
const e1 = this.add.particles(W / 2, H / 2, 'particle_glow', {
lifespan: { min: 700, max: 1400 },
speed: { min: 200, max: 500 },
scale: { start: 1.3, end: 0 },
alpha: { start: 1, end: 0 },
tint: [0xffd700, 0xffcc00, 0xffffff, 0xff8800],
gravityY: 50, emitting: false,
}).setDepth(28);
e1.explode(100);
this.time.delayedCall(1500, () => e1.destroy());
// Shockwave ring
const ring = this.add.particles(W / 2, H / 2, 'particle_sq', {
lifespan: 450, speed: { min: 350, max: 650 },
scale: { start: 0.8, end: 0 }, alpha: { start: 0.7, end: 0 },
tint: 0xffcc00, gravityY: 0, emitting: false,
}).setDepth(27);
ring.explode(60);
this.time.delayedCall(500, () => ring.destroy());
// Update scope counter live [already done in updateScoreBar]
this.updateScoreBar();
this.flashScoreScope(isTeamA ? 0 : 1);
// SCOPA! text with bounce-in [trueref: Back.Out ease]
const player = this.state.players[playerIdx];
const txt = this.add.text(W / 2, H / 2, 'SCOPA!', {
fontFamily: 'Georgia, serif', fontSize: '108px', color: '#ffd700',
stroke: '#000000', strokeThickness: 10,
resolution: 2,
}).setOrigin(0.5).setDepth(50).setAlpha(0).setScale(0.2);
const sub = this.add.text(W / 2, H / 2 + 110, player.name, {
fontFamily: 'serif', fontSize: '32px',
color: isTeamA ? '#aaffaa' : '#ffaaaa',
stroke: '#000', strokeThickness: 3,
resolution: 2,
}).setOrigin(0.5).setDepth(50).setAlpha(0);
this.tweens.add({
targets: txt,
alpha: 1, scale: 1,
duration: 280, ease: 'Back.Out',
});
this.tweens.add({ targets: sub, alpha: 1, duration: 300, delay: 120 });
this.time.delayedCall(1600, () => {
this.tweens.add({
targets: [txt, sub], alpha: 0, y: '-=50',
duration: 280,
onComplete: () => { txt.destroy(); sub.destroy(); onDone(); },
});
});
}
private spawnCardTrail(img: Phaser.GameObjects.Image): void {
const e = this.add.particles(img.x, img.y, 'particle_sm', {
lifespan: 220, speed: 15,
scale: { start: 0.6, end: 0 }, alpha: { start: 0.5, end: 0 },
tint: 0xffffff, follow: img, frequency: 50, quantity: 1,
}).setDepth(img.depth - 1);
this.time.delayedCall(420, () => {
e.stop();
this.time.delayedCall(250, () => e.destroy());
});
}
/** Denari capture: warm orange shimmer — smaller than scopa, bigger than plain capture. */
private spawnDenariEffect(x: number, y: number): void {
const e = this.add.particles(x, y, 'particle_sm', {
lifespan: { min: 280, max: 550 },
speed: { min: 60, max: 160 },
scale: { start: 1.1, end: 0 },
alpha: { start: 0.9, end: 0 },
tint: [0xff9900, 0xffcc44, 0xffdd00],
gravityY: 70, emitting: false,
}).setDepth(24);
e.explode(18);
this.time.delayedCall(650, () => e.destroy());
// Flash the denari counter in the score bar
const isTeamA = this.state.currentPlayer === 0 || this.state.currentPlayer === 2;
const denTxt = isTeamA ? this.hudA.denari : this.hudB.denari;
this.tweens.add({ targets: denTxt, scaleX: 2.0, scaleY: 2.0, duration: 130, yoyo: true });
}
/** Settebello capture: brief "7♦" pop and gold flash — between capture and scopa in weight. */
private spawnSettebelloFlash(): void {
const W = this.scale.width;
const H = this.scale.height;
const txt = this.add.text(W / 2, H / 2 - 60, '7♦', {
fontFamily: 'Georgia, serif', fontSize: '64px', color: '#ffd700',
stroke: '#000', strokeThickness: 7,
resolution: 2,
}).setOrigin(0.5).setDepth(50).setAlpha(0).setScale(0.4);
this.tweens.add({
targets: txt, alpha: 1, scale: 1,
duration: 220, ease: 'Back.Out',
});
this.time.delayedCall(900, () => {
this.tweens.add({
targets: txt, alpha: 0, y: txt.y - 40,
duration: 200, onComplete: () => txt.destroy(),
});
});
// Diamond burst in gold
const e = this.add.particles(W / 2, H / 2, 'particle_dia', {
lifespan: { min: 500, max: 900 },
speed: { min: 100, max: 300 },
scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 },
tint: [0xffd700, 0xffcc00, 0xffffff],
gravityY: -20, emitting: false,
}).setDepth(27);
e.explode(22);
this.time.delayedCall(1000, () => e.destroy());
}
/** Primiera-valuable pick (7,6,1,5): subtle blue-white shimmer at capture point. */
private spawnPrimieraEffect(x: number, y: number): void {
const e = this.add.particles(x, y, 'particle_glow', {
lifespan: { min: 250, max: 480 },
speed: { min: 25, max: 80 },
scale: { start: 0.6, end: 0 },
alpha: { start: 0.8, end: 0 },
tint: [0xaaddff, 0xffffff, 0x88ccff],
gravityY: -30, emitting: false,
}).setDepth(23);
e.explode(10);
this.time.delayedCall(530, () => e.destroy());
// Briefly flash primiera score
const isTeamA = this.state.currentPlayer === 0 || this.state.currentPlayer === 2;
const primTxt = isTeamA ? this.hudA.prim : this.hudB.prim;
this.tweens.add({ targets: primTxt, scaleX: 1.7, scaleY: 1.7, duration: 120, yoyo: true,
onStart: () => primTxt.setColor('#aaddff'),
onComplete: () => primTxt.setColor(isTeamA ? '#aaffaa' : '#ffaaaa'),
});
}
// ---------------------------------------------------------------------------
// Settebello glow on table
// ---------------------------------------------------------------------------
private maybeGlowSettebello(): void {
if (this.renderer.type !== Phaser.WEBGL) return;
for (const card of this.state.table) {
const img = this.cardImages.get(card.id);
if (!img) continue;
// Clear old FX first
img.postFX.clear();
img.postFX.addShadow(2, 6, 0.008, 1.2, 0x000000, 8, 0.5);
if (card.suit === 'denara' && card.value === 7) {
// [trueref: postFX.addGlow] pulsing golden halo on settebello
const glow = img.postFX.addGlow(0xffd700, 0, 0);
this.tweens.add({
targets: glow, outerStrength: 14,
duration: 700, yoyo: true, repeat: -1, ease: 'Sine.InOut',
});
}
}
}
// ---------------------------------------------------------------------------
// Positions
// ---------------------------------------------------------------------------
private nextTablePos(table: Card[]): { x: number; y: number } {
const count = table.length;
const positions = this.getTablePositions(count);
return positions[count - 1] ?? this.tableCenter;
}
private getPilePos(playerIdx: PlayerIndex): { x: number; y: number } {
const W = this.scale.width;
const H = this.scale.height;
return teamOf(playerIdx) === 0
? { x: 50, y: H - 40 }
: { x: W - 50, y: H - 40 };
}
// ---------------------------------------------------------------------------
// Sound FX (Web Audio)
// ---------------------------------------------------------------------------
private startMusic(): void {
if (!this.audioPreferences.musicEnabled) return;
if (this.musicStarted) return;
this.musicStarted = true;
try {
this.audioCtx = (this.sound as Phaser.Sound.WebAudioSoundManager).context as AudioContext;
} catch { return; }
if (!this.audioCtx) return;
const ctx = this.audioCtx;
this.musicGain = ctx.createGain();
this.musicGain.gain.setValueAtTime(0, ctx.currentTime);
this.musicGain.gain.linearRampToValueAtTime(0.16, ctx.currentTime + 4);
this.musicGain.connect(ctx.destination);
const reverb = ctx.createDelay(0.4);
reverb.delayTime.value = 0.14;
const fbk = ctx.createGain();
fbk.gain.value = 0.38;
reverb.connect(fbk);
fbk.connect(reverb);
fbk.connect(this.musicGain);
// Bass drone
[110, 165, 220].forEach((f, i) => {
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = 'sine';
o.frequency.value = f;
g.gain.value = [0.07, 0.045, 0.02][i];
o.connect(g); g.connect(reverb); o.start();
});
// Melodic loop
const notes = [440,523,587,659,587,523,440,392,440,523,659,784,659,587,523,440];
const durs = [0.3,0.2,0.3,0.5,0.2,0.3,0.6,0.3,0.2,0.4,0.3,0.2,0.3,0.4,0.2,0.8];
const totalDur = durs.reduce((s, d) => s + d + 0.05, 0);
const scheduleLoop = (t0: number) => {
if (!this.scene.isActive('GameScene')) return;
let t = t0;
notes.forEach((freq, i) => {
const o = ctx.createOscillator();
const env = ctx.createGain();
o.type = 'triangle';
o.frequency.value = freq;
env.gain.setValueAtTime(0, t);
env.gain.linearRampToValueAtTime(0.06, t + 0.02);
env.gain.exponentialRampToValueAtTime(0.001, t + durs[i]);
o.connect(env); env.connect(reverb);
o.start(t); o.stop(t + durs[i] + 0.05);
t += durs[i] + 0.05;
});
this.time.delayedCall((totalDur + 0.8) * 1000, () => {
if (this.scene.isActive('GameScene')) scheduleLoop(ctx.currentTime);
});
};
scheduleLoop(ctx.currentTime + 1.8);
// Chord stabs
const scheduleChords = () => {
if (!this.scene.isActive('GameScene')) return;
const now = ctx.currentTime + 0.05;
[220, 261, 329].forEach(f => {
const o = ctx.createOscillator(); const g = ctx.createGain();
o.type = 'sine'; o.frequency.value = f;
g.gain.setValueAtTime(0, now);
g.gain.linearRampToValueAtTime(0.038, now + 0.06);
g.gain.exponentialRampToValueAtTime(0.001, now + 1.4);
o.connect(g); g.connect(reverb); o.start(now); o.stop(now + 1.5);
});
this.time.delayedCall(4200, scheduleChords);
};
this.time.delayedCall(2400, scheduleChords);
}
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;
const note = (freq: number, t: number, dur: number, vol: number, type: OscillatorType = 'triangle') => {
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = type;
o.frequency.value = freq;
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g); g.connect(ctx.destination);
o.start(t); o.stop(t + dur + 0.05);
};
switch (type) {
case 'card_play':
note(280, now, 0.12, 0.12, 'sine');
note(200, now + 0.04, 0.1, 0.06, 'sine');
break;
case 'capture':
note(520, now, 0.08, 0.1);
note(720, now + 0.07, 0.1, 0.1);
break;
case 'scopa':
[440,554,659,880].forEach((f, i) => note(f, now + i * 0.07, 0.3, 0.14));
break;
case 'settebello':
[440,523,659,784,1047].forEach((f, i) => note(f, now + i * 0.06, 0.5, 0.12, 'sine'));
break;
}
}
private stopMusic(): void {
if (!this.audioPreferences.musicEnabled) return;
if (this.musicGain && this.audioCtx) {
this.musicGain.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + 1.5);
}
}
// ---------------------------------------------------------------------------
// Round end / game over
// ---------------------------------------------------------------------------
private showRoundEnd(): void {
const W = this.scale.width;
const H = this.scale.height;
const bd = getScoreBreakdown(this.state);
const t0 = this.state.teamScores[0];
const t1 = this.state.teamScores[1];
const overlay = this.add.rectangle(0, 0, W, H, 0x000000, 0.82).setOrigin(0).setDepth(30);
// Panel with rounded rect [trueref: fillRoundedRect]
const panel = this.add.graphics().setDepth(31);
panel.fillStyle(0x07200c, 1);
panel.fillRoundedRect(W / 2 - 280, H / 2 - 210, 560, 420, 16);
panel.lineStyle(2, 0xffd700, 0.7);
panel.strokeRoundedRect(W / 2 - 280, H / 2 - 210, 560, 420, 16);
const lines: Array<[string, string]> = [
[`Fine della mano ${this.state.roundNumber ?? 1}`, '#ffd700'],
['', ''],
[`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 ? 'squadra tua' : 'avversari'}`, '#ffd700'],
[`Primiera A=${t0.primiera} B=${t1.primiera} ${pointStr(bd.primieraPoint)}`, '#aaddff'],
[`Scope A=${bd.scopeTeam0} B=${bd.scopeTeam1}`, '#ccffcc'],
];
lines.forEach(([line, color], i) => {
this.add.text(W / 2, H / 2 - 190 + i * 34, line, this.withHiResText({
fontFamily: i === 0 ? 'Georgia, serif' : 'monospace',
fontSize: i === 0 ? '28px' : '16px',
color,
})).setOrigin(0.5).setDepth(32);
});
const outcome = getMatchOutcome(this.state.teamScores);
const gameOver = !outcome.continueMatch;
const btnLabel = gameOver ? 'Fine Partita' : 'Prossima Mano';
const btnG = this.add.graphics().setDepth(32);
btnG.fillStyle(0xffd700, 1);
btnG.fillRoundedRect(W / 2 - 110, H / 2 + 185, 220, 44, 10);
const btnZone = this.add.zone(W / 2, H / 2 + 207, 220, 44)
.setInteractive({ useHandCursor: true }).setDepth(33);
this.add.text(W / 2, H / 2 + 207, btnLabel, {
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#0a2e10',
resolution: 2,
}).setOrigin(0.5).setDepth(34);
btnG.on('pointerover', () => { btnG.clear(); btnG.fillStyle(0xffec6e, 1); btnG.fillRoundedRect(W / 2 - 110, H / 2 + 185, 220, 44, 10); });
btnG.on('pointerout', () => { btnG.clear(); btnG.fillStyle(0xffd700, 1); btnG.fillRoundedRect(W / 2 - 110, H / 2 + 185, 220, 44, 10); });
btnZone.on('pointerover', () => btnG.emit('pointerover'));
btnZone.on('pointerout', () => btnG.emit('pointerout'));
btnZone.on('pointerdown', () => {
[overlay, panel, btnG, btnZone].forEach(o => o.destroy());
this.children.list.filter((c: any) => c.depth >= 31).forEach(c => c.destroy());
gameOver ? this.showGameOver() : this.startNewRound();
});
}
private showGameOver(): void {
const W = this.scale.width;
const H = this.scale.height;
const t0 = this.state.teamScores[0];
const t1 = this.state.teamScores[1];
const outcome = getMatchOutcome(this.state.teamScores);
const win = outcome.winner === 0;
this.stopMusic();
// Victory confetti
const confetti = this.add.particles(W / 2, H / 2, 'particle_glow', {
lifespan: 2500, speed: { min: 80, max: 450 },
scale: { start: 1.1, end: 0 }, tint: [0xffd700, 0xffffff, 0x00ff88, 0xff8800],
gravityY: 90, frequency: 30,
}).setDepth(39);
this.time.delayedCall(3500, () => confetti.destroy());
this.add.rectangle(0, 0, W, H, 0x000000, 0.88).setOrigin(0).setDepth(40);
// Rounded panel
const pg = this.add.graphics().setDepth(41);
pg.fillStyle(0x04150a, 1);
pg.fillRoundedRect(W / 2 - 220, H / 2 - 150, 440, 310, 20);
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 CONCLUSA', {
fontFamily: 'Georgia, serif', fontSize: '44px', color: '#ffd700',
stroke: '#000', strokeThickness: 6,
resolution: 2,
}).setOrigin(0.5).setDepth(42);
this.add.text(W / 2, H / 2 - 30, win ? 'Vince la tua squadra' : 'Vincono gli avversari', {
fontFamily: 'serif', fontSize: '26px',
color: win ? '#aaffaa' : '#ffaaaa',
resolution: 2,
}).setOrigin(0.5).setDepth(42);
this.add.text(W / 2, H / 2 + 35, `${t0.totalPoints}${t1.totalPoints}`, {
fontFamily: 'Georgia, serif', fontSize: '50px', color: '#ffd700',
resolution: 2,
}).setOrigin(0.5).setDepth(42);
const bz = this.add.zone(W / 2, H / 2 + 115, 230, 48)
.setInteractive({ useHandCursor: true }).setDepth(44);
const bg2 = this.add.graphics().setDepth(43);
const drawBtn = (c: number) => { bg2.clear(); bg2.fillStyle(c, 1); bg2.fillRoundedRect(W / 2 - 115, H / 2 + 91, 230, 48, 12); };
drawBtn(0xffd700);
this.add.text(W / 2, H / 2 + 115, 'NUOVA PARTITA', {
fontFamily: 'Georgia, serif', fontSize: '21px', color: '#0a2e10',
resolution: 2,
}).setOrigin(0.5).setDepth(44);
bz.on('pointerover', () => drawBtn(0xffec6e));
bz.on('pointerout', () => drawBtn(0xffd700));
bz.on('pointerdown', () => this.scene.restart());
}
private startNewRound(): void {
const totals = this.state.teamScores.map(t => t.totalPoints);
const nextRound = (this.state.roundNumber ?? 1) + 1;
const matchStartingPlayer = this.state.matchStartingPlayer;
const nextDealer = nextPlayer(this.state.dealer);
for (const img of this.cardImages.values()) img.destroy();
this.cardImages.clear();
this.tracker.reset();
this.inference.reset();
this.state = createInitialState(nextDealer);
this.state.matchStartingPlayer = matchStartingPlayer;
this.state.teamScores[0].totalPoints = totals[0];
this.state.teamScores[1].totalPoints = totals[1];
this.state.roundNumber = nextRound;
this.dealAnimation(() => {
this.updateScoreBar();
this.nextTurn();
});
}
private setStatus(
msg: string,
options: {
persist?: boolean;
durationMs?: number;
onExpire?: () => void;
} = {},
): void {
const { persist = false, durationMs, onExpire } = options;
this.clearStatusTimer();
this.statusText.setText(msg);
if (persist) {
this.persistentStatusText = msg;
}
if (durationMs === undefined) {
return;
}
this.statusTimer = this.time.delayedCall(durationMs, () => {
this.statusTimer = null;
onExpire?.();
});
}
}
// ---------------------------------------------------------------------------
// Utils
// ---------------------------------------------------------------------------
function cardName(card: Card): string {
const s: Record<string, string> = { bastoni: 'Bastoni', coppe: 'Coppe', denara: 'Denari', spade: 'Spade' };
const v: Record<number, string> = { 1: 'Asso', 8: 'Fante', 9: 'Cavallo', 10: 'Re' };
return `${v[card.value] ?? card.value} di ${s[card.suit]}`;
}
function pointStr(p: 0 | 1 | null): string {
return p === null ? '(pari)' : p === 0 ? '→ squadra tua' : '→ avversari';
}