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