1332 lines
50 KiB
TypeScript
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';
|
|
}
|