Files
scopone/src/scenes/GameScene.ts
Giancarmine Salucci 3d1f3e5eb4 chore: initial commit
2026-03-31 18:38:34 +02:00

1332 lines
50 KiB
TypeScript

import Phaser from 'phaser';
import { Card, PlayerIndex, GameState } from '../game/types';
import {
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera
} from '../game/engine';
import { chooseMove } from '../game/ai';
// ---------------------------------------------------------------------------
// 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();
// Live score bar texts
private hudA!: { scope: Phaser.GameObjects.Text; cards: Phaser.GameObjects.Text;
denari: 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; 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 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(): void {
const W = this.scale.width;
const H = this.scale.height;
this.tableCenter = { x: W / 2, y: (H + SCOREBAR_H) / 2 + 10 };
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, 0.92);
bg.fillRect(0, 0, W, SCOREBAR_H);
bg.lineStyle(1, 0xffd700, 0.35);
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 })
.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',
}).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',
}).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', 'Primiera', 'TOTALE'];
const xA = [240, 320, 410, 510, 620];
const xB = [W - 240, W - 320, W - 410, W - 510, W - 620];
cols.forEach((_, i) => {
const label = ['Sc', 'Ca', 'De', 'Pr', 'Pt'][i];
this.add.text(xA[i], SCOREBAR_H * 0.28, label, {
fontFamily: 'monospace', fontSize: '10px', color: '#666666',
}).setOrigin(0.5).setDepth(9);
this.add.text(xB[i], SCOREBAR_H * 0.28, label, {
fontFamily: 'monospace', fontSize: '10px', color: '#666666',
}).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), prim: mkA(3),
total: this.add.text(xA[4], SCOREBAR_H * 0.72, '0', {
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#aaffaa',
stroke: '#000', strokeThickness: 2,
}).setOrigin(0.5).setDepth(9),
};
this.hudB = {
scope: mkB(0), cards: mkB(1), denari: mkB(2), prim: mkB(3),
total: this.add.text(xB[4], SCOREBAR_H * 0.72, '0', {
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#ffaaaa',
stroke: '#000', strokeThickness: 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 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.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.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 }> = [
{ idx: 0, x: W / 2, y: H - CH_H - 56, color: '#aaffaa', txt: 'Tu [Team A]' },
{ idx: 1, x: CH_A + 14, y: H / 2 + SCOREBAR_H / 2 - 60, color: '#ffaaaa', txt: 'AI\nOvest\n[B]' },
{ idx: 2, x: W / 2, y: SCOREBAR_H + 18, color: '#aaffaa', txt: 'Compagno [Team A]' },
{ idx: 3, x: W - CH_A - 14, y: H / 2 + SCOREBAR_H / 2 - 60, color: '#ffaaaa', txt: 'AI\nEst\n[B]' },
];
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',
}).setOrigin(0.5).setDepth(2);
this.playerLabels.set(d.idx, lbl);
}
}
private pulseLabel(playerIdx: PlayerIndex): void {
// Reset all
for (const [idx, lbl] of this.playerLabels) {
lbl.setAlpha(idx === playerIdx ? 1 : 0.5);
}
// Pulse active
const lbl = this.playerLabels.get(playerIdx)!;
this.tweens.add({
targets: lbl,
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++) {
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.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));
}
}
private highlightMultipleCaptures(captures: Card[][]): void {
this.clearHighlights();
const W = this.scale.width;
captures.forEach((cap, i) => {
const label = cap.map(cardName).join(' + ');
const y = SCOREBAR_H + 70 + i * 36;
const bg = this.add.graphics().setDepth(20);
bg.fillStyle(0x001a0a, 0.9);
bg.fillRoundedRect(W / 2 - 180, y - 14, 360, 28, 7);
bg.lineStyle(1, 0x00ff88, 0.7);
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: '#00ffaa',
}).setOrigin(0.5).setDepth(21);
btn.on('pointerdown', () => this.confirmMove(this.selectedCard!, cap));
(bg as any)._captureBtn = true;
this.tableHighlights.push(bg, btn, txt);
});
for (const cap of captures) {
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, 0xffff00, 0.15)
.setStrokeStyle(2, 0xffff00, 0.8).setDepth(4);
this.tableHighlights.push(hl);
}
}
}
}
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 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;
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;
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;
for (const img of this.cardImages.values()) img.destroy();
this.cardImages.clear();
this.state = createInitialState();
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';
}