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 = 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 = 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 = { bastoni: 'Bastoni', coppe: 'Coppe', denara: 'Denari', spade: 'Spade' }; const v: Record = { 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'; }