Files
scopone/src/scenes/GameScene.ts
Giancarmine Salucci 113bb12723 fix(SCOPONE-0006): remove stale capture handlers when switching card selection
- Track table card images with capture listeners in captureListenerImgs[]
- Clear pointerdown handlers in clearHighlights() before destroying overlays
- Prevents wrong capture executing when selecting a different hand card
2026-04-01 10:54:45 +02:00

1425 lines
54 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
} from '../game/engine';
import { chooseMove } from '../game/ai';
import { CardTracker } from '../game/card-tracker';
// ---------------------------------------------------------------------------
// 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
const AI_DELAY = 1100; // ms — think bar fills over this time
// Scorebar height at top
const SCOREBAR_H = 54;
// 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();
// 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;
// Think bar
private thinkBar!: Phaser.GameObjects.Graphics;
private thinkTween: Phaser.Tweens.Tween | null = null;
private thinkProgress = 0;
// Player label containers (pulsed on active turn)
private playerLabels: Map<PlayerIndex, Phaser.GameObjects.Text> = 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;
constructor() {
super({ key: 'GameScene' });
}
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
create(data?: { difficulty?: Difficulty }): 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.tracker = new CardTracker();
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());
this.state = createInitialState();
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,
}).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): void {
this.thinkProgress = 0;
this.thinkBar.setVisible(true);
this.thinkTween?.stop();
const W = this.scale.width;
const tg = this.thinkBar;
const color = (playerIdx === 0 || playerIdx === 2) ? 0x44ff88 : 0xff5555;
const tweenTarget = { v: 0 };
this.thinkTween = this.tweens.add({
targets: tweenTarget,
v: 1,
duration: AI_DELAY - 80,
ease: 'Linear',
onUpdate: () => {
tg.clear();
const w = tweenTarget.v * W;
tg.fillStyle(0x000000, 0.4);
tg.fillRect(0, SCOREBAR_H, W, 4);
tg.fillStyle(color, 0.85);
tg.fillRect(0, SCOREBAR_H, w, 4);
// Glow tip
tg.fillStyle(0xffffff, 0.6);
tg.fillRect(w - 6, SCOREBAR_H, 6, 4);
},
onComplete: () => { tg.clear(); tg.setVisible(false); },
});
}
private hideThinkBar(): void {
this.thinkTween?.stop();
this.thinkTween = null;
this.thinkBar.clear();
this.thinkBar.setVisible(false);
}
// ---------------------------------------------------------------------------
// Player labels (pulse on active turn)
// ---------------------------------------------------------------------------
private buildPlayerLabels(W: number, H: number): void {
const defs: Array<{ idx: PlayerIndex; x: number; y: number; color: string;
txt: string; originX: number; originY: number }> = [
{ idx: 0, x: W / 2, y: H - CH_H - 28, color: '#aaffaa', txt: 'Tu [Team A]', originX: 0.5, originY: 1 },
{ idx: 1, x: CH_A + 20, y: H / 2 + SCOREBAR_H / 2, color: '#ffaaaa', txt: 'AI\nOvest\n[B]', originX: 0, originY: 0.5 },
{ idx: 2, x: W / 2, y: SCOREBAR_H + CH_A + 44, color: '#aaffaa', txt: 'Compagno [Team A]', originX: 0.5, originY: 0 },
{ idx: 3, x: W - CH_A - 20, y: H / 2 + SCOREBAR_H / 2, color: '#ffaaaa', txt: 'AI\nEst\n[B]', originX: 1, originY: 0.5 },
];
for (const d of defs) {
const lbl = this.add.text(d.x, d.y, d.txt, {
fontFamily: 'serif', fontSize: '12px', color: d.color,
stroke: '#000', strokeThickness: 1, align: 'center', resolution: 2,
}).setOrigin(d.originX, d.originY).setDepth(2);
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.2, scaleY: 1.2,
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(`Turno di ${player.name}`);
this.pulseLabel(cur);
if (player.isHuman) {
this.enableHumanInteraction();
} else {
this.aiThinking = true;
this.showThinkBar(cur);
this.time.delayedCall(AI_DELAY, () => {
this.hideThinkBar();
this.doAIMove(cur);
});
}
}
private doAIMove(playerIdx: PlayerIndex): void {
const move = chooseMove(this.state, playerIdx, this.difficulty, this.tracker);
this.aiThinking = false;
this.executeMove(playerIdx, move.card, move.capture);
}
// ---------------------------------------------------------------------------
// 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('Nessuna cattura — clicca di nuovo per giocare sul tavolo');
this.highlightTableForDump(card);
} else if (captures.length === 1) {
this.setStatus(`Cattura: ${captures[0].map(cardName).join(', ')} — clicca di nuovo per confermare`);
this.pendingCaptures = captures;
this.highlightCapture(captures[0]);
} else {
this.setStatus('Scegli le carte da catturare');
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, `Cattura: ${label}`, {
fontFamily: 'serif', fontSize: '14px', color: color.text,
}).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 { 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);
}
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: 200, 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: 240, delay: 30,
onComplete: () => {
img.setVisible(false);
done++;
if (done === toClear.length) {
if (isScopa) {
this.playSfx('scopa');
this.doScopaEffect(playerIdx, () =>
this.afterMove(nextState, oldState)
);
} else {
this.afterMove(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: 280, ease: 'Back.Out',
onComplete: () => this.afterMove(nextState, oldState),
});
}
}
private afterMove(nextState: GameState, _old: GameState): void {
this.updateScoreBar();
this.relayoutTable();
if (nextState.roundOver) {
this.time.delayedCall(500, () => this.showRoundEnd());
} else {
this.relayoutHand(0);
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: 160 });
});
}
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: 160 });
});
}
// ---------------------------------------------------------------------------
// 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,
}).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,
}).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,
}).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.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.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.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 Mano ${this.state.roundNumber ?? 1}`, '#ffd700'],
['', ''],
[`Team A +${t0.roundPoints} pt → ${t0.totalPoints} totali`, '#aaffaa'],
[`Team B +${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'],
[`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, {
fontFamily: i === 0 ? 'Georgia, serif' : 'monospace',
fontSize: i === 0 ? '28px' : '16px', color,
}).setOrigin(0.5).setDepth(32);
});
const gameOver = t0.totalPoints >= 11 || t1.totalPoints >= 11;
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',
}).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 win = t0.totalPoints >= t1.totalPoints;
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 FINITA', {
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)', {
fontFamily: 'serif', fontSize: '26px',
color: win ? '#aaffaa' : '#ffaaaa',
}).setOrigin(0.5).setDepth(42);
this.add.text(W / 2, H / 2 + 35, `${t0.totalPoints}${t1.totalPoints}`, {
fontFamily: 'Georgia, serif', fontSize: '50px', color: '#ffd700',
}).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',
}).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 startingPlayer = ((nextRound - 1) % 4) as PlayerIndex;
for (const img of this.cardImages.values()) img.destroy();
this.cardImages.clear();
this.tracker.reset();
this.state = createInitialState(startingPlayer);
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): void { this.statusText.setText(msg); }
}
// ---------------------------------------------------------------------------
// 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 ? '→ A' : '→ B';
}