diff --git a/src/game/ai.ts b/src/game/ai.ts index 163f4ba..1df8b3b 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -1,37 +1,61 @@ -import { Card, GameState, PlayerIndex } from './types'; -import { findCaptures, canCapture, calcPrimiera, teamOf } from './engine'; +import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES } from './types'; +import { findCaptures, canCapture, calcPrimiera, teamOf, applyMove, buildDeck } from './engine'; +import { CardTracker } from './card-tracker'; export interface AIMove { card: Card; capture: Card[]; } -/** - * Heuristic AI for Scopone Scientifico. - * Evaluates moves by scoring the value of captured cards. - */ -export function chooseMove(state: GameState, playerIdx: PlayerIndex): AIMove { +// --------------------------------------------------------------------------- +// Main entry point — dispatches by difficulty +// --------------------------------------------------------------------------- + +export function chooseMove( + state: GameState, + playerIdx: PlayerIndex, + difficulty: Difficulty = 'advanced', + tracker?: CardTracker, +): AIMove { + switch (difficulty) { + case 'beginner': return beginnerMove(state, playerIdx); + case 'advanced': return advancedMove(state, playerIdx, tracker); + case 'master': return masterMove(state, playerIdx, tracker); + } +} + +// --------------------------------------------------------------------------- +// BEGINNER — weakened heuristic with random noise +// --------------------------------------------------------------------------- + +function beginnerMove(state: GameState, playerIdx: PlayerIndex): AIMove { const player = state.players[playerIdx]; - const hand = player.hand; const table = state.table; const myTeam = teamOf(playerIdx); + // 20% chance to pick a completely random legal move + if (Math.random() < 0.2) { + return randomMove(state, playerIdx); + } + let bestMove: AIMove | null = null; let bestScore = -Infinity; - for (const card of hand) { + for (const card of player.hand) { const captures = findCaptures(card, table); if (captures.length > 0) { for (const captureSet of captures) { - const score = scoreCapture(card, captureSet, table, state, myTeam); + // Weakened heuristic: half weights + random noise + const base = scoreCaptureBasic(card, captureSet, table); + const score = base * 0.5 + (Math.random() - 0.5) * base * 0.6; if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } } } else { - // No capture — score the "dump" move - const score = scoreDump(card, table, state, myTeam); + const base = scoreDumpBasic(card); + const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.6; if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; @@ -42,122 +66,396 @@ export function chooseMove(state: GameState, playerIdx: PlayerIndex): AIMove { return bestMove!; } -function scoreCapture( - played: Card, - captured: Card[], - table: Card[], - state: GameState, - myTeam: 0 | 1 -): number { - let score = 100; // base for capturing anything +function randomMove(state: GameState, playerIdx: PlayerIndex): AIMove { + const hand = state.players[playerIdx].hand; + const card = hand[Math.floor(Math.random() * hand.length)]; + const captures = findCaptures(card, state.table); + if (captures.length > 0) { + return { card, capture: captures[Math.floor(Math.random() * captures.length)] }; + } + return { card, capture: [] }; +} +// Basic scoring (no cheating, no card counting) +function scoreCaptureBasic(played: Card, captured: Card[], table: Card[]): number { + let score = 100; const allCaptured = [played, ...captured]; const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); - const isScopa = afterTable.length === 0; - // Scopa is very valuable - if (isScopa) score += 500; - - // Settebello + if (afterTable.length === 0) score += 500; if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 300; - - // Table has settebello and this capture takes it - if (table.some(c => c.suit === 'denara' && c.value === 7) && - captured.some(c => c.suit === 'denara' && c.value === 7)) score += 200; - - // Denari cards score += allCaptured.filter(c => c.suit === 'denara').length * 50; - - // More cards = better score += captured.length * 20; - - // High-value primiera cards - score += allCaptured.reduce((s, c) => s + primieraScore(c), 0); - - // Avoid leaving settebello on table for opponent - const settebelloOnTable = afterTable.some(c => c.suit === 'denara' && c.value === 7); - if (settebelloOnTable) score -= 150; - - // Opponent could easily capture remaining table - score -= opponentThreatScore(afterTable, state, myTeam) * 30; + score += allCaptured.reduce((s, c) => s + primieraValue(c), 0); return score; } -function scoreDump( - card: Card, - table: Card[], - state: GameState, - myTeam: 0 | 1 -): number { +function scoreDumpBasic(card: Card): number { let score = 0; - - // Don't dump cards that complete opponent captures - const afterTable = [...table, card]; - - // Check if opponent can make a scopa after this dump - const opponentTeam = myTeam === 0 ? 1 : 0; - const opponentPlayers = state.players.filter(p => teamOf(p.index) === opponentTeam); - for (const opp of opponentPlayers) { - for (const oppCard of opp.hand) { - const caps = findCaptures(oppCard, afterTable); - for (const cap of caps) { - const leftAfter = afterTable.filter(c => !cap.some(cc => cc.id === c.id)); - if (leftAfter.length === 0) score -= 400; // would give opponent scopa - } - } - } - - // Prefer to dump low-value non-denari cards if (card.suit !== 'denara') score += 30; if (card.suit === 'denara') score -= 40; - if (card.suit === 'denara' && card.value === 7) score -= 300; // never dump settebello - - // Prefer dumping face cards (10, 9, 8) that are less useful + if (card.suit === 'denara' && card.value === 7) score -= 300; if (card.value >= 8) score += 10; - - // Don't dump 7s (primiera value) if (card.value === 7) score -= 50; - - // Don't dump 1s (primiera value) if (card.value === 1) score -= 30; - - // Penalty for making easy captures for opponents - const capturable = state.players - .filter(p => teamOf(p.index) !== myTeam) - .flatMap(p => p.hand) - .some(oppCard => canCapture(oppCard, afterTable)); - if (capturable) score -= 20; - return score; } -function primieraScore(card: Card): number { - // Reward for capturing high-primiera cards +function primieraValue(card: Card): number { const vals: Record = { 7: 8, 6: 6, 1: 5, 5: 4, 4: 3, 3: 2, 2: 1 }; return vals[card.value] ?? 0; } -function opponentThreatScore( - table: Card[], - state: GameState, - myTeam: 0 | 1 +// --------------------------------------------------------------------------- +// ADVANCED — improved heuristic with card counting +// --------------------------------------------------------------------------- + +function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { + const player = state.players[playerIdx]; + const table = state.table; + const myTeam = teamOf(playerIdx); + const allyIdx = ((playerIdx + 2) % 4) as PlayerIndex; + const allyIsNext = state.currentPlayer === playerIdx && + ((playerIdx + 1) % 4) === allyIdx; + + let bestMove: AIMove | null = null; + let bestScore = -Infinity; + + for (const card of player.hand) { + const captures = findCaptures(card, table); + if (captures.length > 0) { + for (const captureSet of captures) { + const score = scoreCaptureAdvanced(card, captureSet, table, state, myTeam, tracker, player.hand, allyIsNext); + if (score > bestScore) { + bestScore = score; + bestMove = { card, capture: captureSet }; + } + } + } else { + const score = scoreDumpAdvanced(card, table, state, myTeam, tracker, player.hand, allyIsNext); + if (score > bestScore) { + bestScore = score; + bestMove = { card, capture: [] }; + } + } + } + + return bestMove!; +} + +function scoreCaptureAdvanced( + played: Card, captured: Card[], table: Card[], state: GameState, + myTeam: 0 | 1, tracker: CardTracker | undefined, myHand: Card[], + allyIsNext: boolean, ): number { - const opponentTeam = myTeam === 0 ? 1 : 0; - let threat = 0; - for (const player of state.players) { - if (teamOf(player.index) !== opponentTeam) continue; - for (const card of player.hand) { - const caps = findCaptures(card, table); - if (caps.length > 0) { - threat += caps[0].length; - // Extra threat if settebello is capturable - if (table.some(c => c.suit === 'denara' && c.value === 7) && - caps.some(cap => cap.some(c => c.suit === 'denara' && c.value === 7))) { - threat += 5; + let score = 100; + const allCaptured = [played, ...captured]; + const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); + const isScopa = afterTable.length === 0; + + // Scopa bonus + if (isScopa) score += 600; + + // Settebello — highest priority + if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 400; + if (table.some(c => c.suit === 'denara' && c.value === 7) && + captured.some(c => c.suit === 'denara' && c.value === 7)) score += 250; + + // Denari cards + score += allCaptured.filter(c => c.suit === 'denara').length * 60; + + // Card count (carte majority) + score += captured.length * 25; + + // Primiera — prefer capturing 7s, then 6s, then aces + for (const c of allCaptured) { + score += PRIMIERA_VALUES[c.value] * 2; + } + + // Card counting: avoid leaving settebello on table + if (afterTable.some(c => c.suit === 'denara' && c.value === 7)) score -= 200; + + // Anti-scopa: prefer leaving table total ≥ 11 + if (!isScopa) { + const tableSum = afterTable.reduce((s, c) => s + c.value, 0); + if (tableSum >= 11) score += 40; + if (tableSum < 5) score -= 30; + } + + // Card tracking threat assessment (no cheating) + if (tracker) { + const unseen = tracker.getUnseenCards(myHand, afterTable); + // Check if any unseen card could scopa the remaining table + for (const uc of unseen) { + const caps = findCaptures(uc, afterTable); + for (const cap of caps) { + if (afterTable.filter(c => !cap.some(cc => cc.id === c.id)).length === 0) { + score -= 200; // potential opponent scopa + break; } } } } - return threat; + + // Cooperative: if ally is next, allow leaving cards ally can capture + if (allyIsNext && !isScopa) { + score += 15; // slight preference if we leave cards and ally plays next + } + + return score; +} + +function scoreDumpAdvanced( + card: Card, table: Card[], state: GameState, + myTeam: 0 | 1, tracker: CardTracker | undefined, myHand: Card[], + allyIsNext: boolean, +): number { + let score = 0; + const afterTable = [...table, card]; + + // Never dump settebello + if (card.suit === 'denara' && card.value === 7) score -= 500; + + // Avoid dumping denari + if (card.suit === 'denara') score -= 50; + if (card.suit !== 'denara') score += 30; + + // Avoid dumping 7s (primiera) + if (card.value === 7) score -= 60; + if (card.value === 1) score -= 35; + + // Prefer dumping face cards + if (card.value >= 8) score += 15; + + // Anti-scopa: prefer leaving table total ≥ 11 + const tableSum = afterTable.reduce((s, c) => s + c.value, 0); + if (tableSum >= 11) score += 50; + if (tableSum < 5) score -= 40; + + // Card tracking: check if unseen cards could scopa after this dump + if (tracker) { + const unseen = tracker.getUnseenCards(myHand, afterTable); + let scopaThreat = 0; + for (const uc of unseen) { + const caps = findCaptures(uc, afterTable); + for (const cap of caps) { + if (afterTable.filter(c => !cap.some(cc => cc.id === c.id)).length === 0) { + scopaThreat++; + break; + } + } + } + score -= scopaThreat * 80; + } + + // Cooperative: if ally is next, leaving captures for ally is OK + if (allyIsNext) { + score += 10; + } + + return score; +} + +// --------------------------------------------------------------------------- +// MASTER — minimax with alpha-beta pruning and determinization +// --------------------------------------------------------------------------- + +function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { + const player = state.players[playerIdx]; + const table = state.table; + const myTeam = teamOf(playerIdx); + const NUM_SAMPLES = 12; + const MAX_DEPTH = 4; // 4 plies = one full rotation + + // Generate all legal moves for this player + const legalMoves = getLegalMoves(state, playerIdx); + if (legalMoves.length === 1) return legalMoves[0]; + + // Score accumulator for each move + const moveScores = new Map(); + for (const m of legalMoves) { + moveScores.set(moveKey(m), 0); + } + + // Determinization: sample possible opponent hand assignments + const samples = generateSamples(state, playerIdx, tracker, NUM_SAMPLES); + + for (const sample of samples) { + for (const move of legalMoves) { + const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined); + const score = alphaBeta(result.nextState, MAX_DEPTH - 1, -Infinity, Infinity, false, myTeam, playerIdx); + moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score); + } + } + + // Pick move with highest total score across samples + let bestMove = legalMoves[0]; + let bestScore = -Infinity; + for (const move of legalMoves) { + const totalScore = moveScores.get(moveKey(move)) ?? 0; + if (totalScore > bestScore) { + bestScore = totalScore; + bestMove = move; + } + } + + return bestMove; +} + +function moveKey(move: AIMove): string { + const capIds = move.capture.map(c => c.id).sort().join(','); + return `${move.card.id}|${capIds}`; +} + +function getLegalMoves(state: GameState, playerIdx: PlayerIndex): AIMove[] { + const moves: AIMove[] = []; + const player = state.players[playerIdx]; + const table = state.table; + + for (const card of player.hand) { + const captures = findCaptures(card, table); + if (captures.length > 0) { + for (const captureSet of captures) { + moves.push({ card, capture: captureSet }); + } + } else { + moves.push({ card, capture: [] }); + } + } + return moves; +} + +function generateSamples( + state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, count: number, +): GameState[] { + const myHand = state.players[playerIdx].hand; + const samples: GameState[] = []; + + // Cards we know about: our hand + table + tracked + const unseen = tracker + ? tracker.getUnseenCards(myHand, state.table) + : getUnseenWithoutTracker(state, playerIdx); + + for (let s = 0; s < count; s++) { + const sample = JSON.parse(JSON.stringify(state)) as GameState; + const shuffled = shuffleArray([...unseen]); + + // Distribute unseen cards among other players proportionally to their hand sizes + let idx = 0; + for (let p = 0; p < 4; p++) { + if (p === playerIdx) continue; + const need = sample.players[p].hand.length; + sample.players[p].hand = shuffled.slice(idx, idx + need); + idx += need; + } + + samples.push(sample); + } + + return samples; +} + +function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] { + // Without tracker, we only know our own hand and the table + const known = new Set(); + for (const c of state.players[playerIdx].hand) known.add(c.id); + for (const c of state.table) known.add(c.id); + // Also know cards in all players' piles (they've been captured visibly) + for (const p of state.players) { + for (const c of p.pile) known.add(c.id); + } + + const unseen: Card[] = []; + const deck = buildDeck(); + for (const c of deck) { + if (!known.has(c.id)) unseen.push(c); + } + return unseen; +} + +function shuffleArray(arr: T[]): T[] { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +function alphaBeta( + state: GameState, depth: number, alpha: number, beta: number, + maximizing: boolean, myTeam: 0 | 1, rootPlayer: PlayerIndex, +): number { + if (depth === 0 || state.roundOver) { + return evaluate(state, myTeam); + } + + const cur = state.currentPlayer; + const isMyTeam = teamOf(cur) === myTeam; + const moves = getLegalMoves(state, cur); + + if (moves.length === 0) return evaluate(state, myTeam); + + if (isMyTeam) { + let value = -Infinity; + for (const move of moves) { + const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); + const child = alphaBeta(result.nextState, depth - 1, alpha, beta, !isMyTeam, myTeam, rootPlayer); + value = Math.max(value, child); + alpha = Math.max(alpha, value); + if (beta <= alpha) break; + } + return value; + } else { + let value = Infinity; + for (const move of moves) { + const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); + const child = alphaBeta(result.nextState, depth - 1, alpha, beta, !isMyTeam, myTeam, rootPlayer); + value = Math.min(value, child); + beta = Math.min(beta, value); + if (beta <= alpha) break; + } + return value; + } +} + +function evaluate(state: GameState, myTeam: 0 | 1): number { + const oppTeam = myTeam === 0 ? 1 : 0; + const myPlayers = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; + const oppPlayers = oppTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; + + const myPile = myPlayers.flatMap(p => p.pile); + const oppPile = oppPlayers.flatMap(p => p.pile); + + let score = 0; + + // Cards (20 = majority) + score += (myPile.length - oppPile.length) * 10; + + // Denari + const myDenari = myPile.filter(c => c.suit === 'denara').length; + const oppDenari = oppPile.filter(c => c.suit === 'denara').length; + score += (myDenari - oppDenari) * 30; + + // Settebello + if (myPile.some(c => c.suit === 'denara' && c.value === 7)) score += 200; + if (oppPile.some(c => c.suit === 'denara' && c.value === 7)) score -= 200; + + // Primiera + const myPrim = calcPrimiera(myPile); + const oppPrim = calcPrimiera(oppPile); + if (myPrim > 0 && oppPrim > 0) { + score += (myPrim - oppPrim) * 2; + } else if (myPrim > 0) { + score += 100; + } else if (oppPrim > 0) { + score -= 100; + } + + // Scope + const myScope = myPlayers.reduce((s, p) => s + p.scope, 0); + const oppScope = oppPlayers.reduce((s, p) => s + p.scope, 0); + score += (myScope - oppScope) * 150; + + return score; } diff --git a/src/game/card-tracker.ts b/src/game/card-tracker.ts new file mode 100644 index 0000000..33432ef --- /dev/null +++ b/src/game/card-tracker.ts @@ -0,0 +1,68 @@ +import { Card, Suit, SUITS } from './types'; + +/** + * Tracks which cards have been played/captured during a round. + * Used by AI to infer opponent hands WITHOUT cheating. + */ +export class CardTracker { + private played: Set = new Set(); // card IDs that have been seen + + /** Record a card being played to the table */ + trackPlay(card: Card): void { + this.played.add(card.id); + } + + /** Record cards captured from the table */ + trackCapture(cards: Card[]): void { + for (const c of cards) { + this.played.add(c.id); + } + } + + /** Reset for a new round */ + reset(): void { + this.played.clear(); + } + + /** Has a specific card been seen played? */ + hasBeenPlayed(cardId: string): boolean { + return this.played.has(cardId); + } + + /** Is the settebello (7 of denara) still unseen (not played/captured)? */ + isSettebelloUnseen(): boolean { + return !this.played.has('denara_7'); + } + + /** + * Get cards that could be in opponent hands. + * = full 40-card deck minus: already played, my hand, currently on table + */ + getUnseenCards(myHand: Card[], table: Card[]): Card[] { + const known = new Set(); + for (const id of this.played) known.add(id); + for (const c of myHand) known.add(c.id); + for (const c of table) known.add(c.id); + + const unseen: Card[] = []; + for (const suit of SUITS) { + for (let v = 1; v <= 10; v++) { + const id = `${suit}_${v}`; + if (!known.has(id)) { + unseen.push({ suit, value: v, id }); + } + } + } + return unseen; + } + + /** Count how many cards of a suit are still unseen */ + countRemainingSuit(suit: Suit, myHand: Card[], table: Card[]): number { + return this.getUnseenCards(myHand, table).filter(c => c.suit === suit).length; + } + + /** Get count of all played/seen cards */ + get playedCount(): number { + return this.played.size; + } +} diff --git a/src/game/types.ts b/src/game/types.ts index 324d136..aef0f40 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -14,6 +14,8 @@ export interface Capture { export type PlayerIndex = 0 | 1 | 2 | 3; +export type Difficulty = 'beginner' | 'advanced' | 'master'; + export interface Player { index: PlayerIndex; hand: Card[]; diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 86f7870..88e4fcc 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -1,9 +1,10 @@ import Phaser from 'phaser'; -import { Card, PlayerIndex, GameState } from '../game/types'; +import { Card, PlayerIndex, GameState, Difficulty } from '../game/types'; import { createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera } from '../game/engine'; import { chooseMove } from '../game/ai'; +import { CardTracker } from '../game/card-tracker'; // --------------------------------------------------------------------------- // Layout constants @@ -46,13 +47,20 @@ export class GameScene extends Phaser.Scene { private state!: GameState; private cardImages: Map = new Map(); + // Difficulty & card tracker + private difficulty: Difficulty = 'advanced'; + private tracker: CardTracker = new CardTracker(); + + // Active player highlight + private activeHighlightRect: Phaser.GameObjects.Graphics | null = null; + // Live score bar texts private hudA!: { scope: Phaser.GameObjects.Text; cards: Phaser.GameObjects.Text; - denari: Phaser.GameObjects.Text; prim: Phaser.GameObjects.Text; - total: 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; prim: Phaser.GameObjects.Text; - total: 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 @@ -90,11 +98,15 @@ export class GameScene extends Phaser.Scene { // Create // --------------------------------------------------------------------------- - create(): void { + create(data?: { difficulty?: Difficulty }): void { const W = this.scale.width; const H = this.scale.height; this.tableCenter = { x: W / 2, y: (H + SCOREBAR_H) / 2 + 10 }; + // Read difficulty from scene data (MenuScene passes it) + this.difficulty = data?.difficulty ?? 'advanced'; + this.tracker = new CardTracker(); + this.generateParticleTextures(); this.drawBackground(W, H); this.buildScoreBar(W); @@ -245,12 +257,12 @@ export class GameScene extends Phaser.Scene { 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]; + 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', 'Pr', 'Pt'][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); @@ -264,15 +276,15 @@ export class GameScene extends Phaser.Scene { 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', { + 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), prim: mkB(3), - total: this.add.text(xB[4], SCOREBAR_H * 0.72, '0', { + 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), @@ -294,6 +306,8 @@ export class GameScene extends Phaser.Scene { 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); @@ -305,12 +319,14 @@ export class GameScene extends Phaser.Scene { 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); @@ -408,12 +424,37 @@ export class GameScene extends Phaser.Scene { } private pulseLabel(playerIdx: PlayerIndex): void { - // Reset all + // Reset all labels for (const [idx, lbl] of this.playerLabels) { - lbl.setAlpha(idx === playerIdx ? 1 : 0.5); + lbl.setAlpha(idx === playerIdx ? 1 : 0.4); } - // Pulse active + + // Remove old highlight + if (this.activeHighlightRect) { + this.activeHighlightRect.destroy(); + this.activeHighlightRect = null; + } + + // Draw glow rectangle behind active player label const lbl = this.playerLabels.get(playerIdx)!; + const bounds = lbl.getBounds(); + const pad = 6; + const color = teamOf(playerIdx) === 0 ? 0x00ff44 : 0xff4444; + const gfx = this.add.graphics().setDepth(1); + gfx.fillStyle(color, 0.25); + gfx.fillRoundedRect(bounds.x - pad, bounds.y - pad, bounds.width + pad * 2, bounds.height + pad * 2, 6); + gfx.lineStyle(2, color, 0.8); + gfx.strokeRoundedRect(bounds.x - pad, bounds.y - pad, bounds.width + pad * 2, bounds.height + pad * 2, 6); + 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 this.tweens.add({ targets: lbl, scaleX: 1.2, scaleY: 1.2, @@ -548,7 +589,7 @@ export class GameScene extends Phaser.Scene { } private doAIMove(playerIdx: PlayerIndex): void { - const move = chooseMove(this.state, playerIdx); + const move = chooseMove(this.state, playerIdx, this.difficulty, this.tracker); this.aiThinking = false; this.executeMove(playerIdx, move.card, move.capture); } @@ -750,6 +791,12 @@ export class GameScene extends Phaser.Scene { const oldState = this.state; this.state = nextState; + // Update card tracker + this.tracker.trackPlay(card); + if (captureResult) { + this.tracker.trackCapture(captureResult.captured); + } + const cardImg = this.cardImages.get(card.id)!; cardImg.setDepth(15); @@ -1318,6 +1365,7 @@ export class GameScene extends Phaser.Scene { const startingPlayer = ((nextRound - 1) % 4) as PlayerIndex; for (const img of this.cardImages.values()) img.destroy(); this.cardImages.clear(); + this.tracker.reset(); this.state = createInitialState(startingPlayer); this.state.teamScores[0].totalPoints = totals[0]; this.state.teamScores[1].totalPoints = totals[1]; diff --git a/src/scenes/MenuScene.ts b/src/scenes/MenuScene.ts index daea9ae..76c17ad 100644 --- a/src/scenes/MenuScene.ts +++ b/src/scenes/MenuScene.ts @@ -1,4 +1,5 @@ import Phaser from 'phaser'; +import { Difficulty } from '../game/types'; export class MenuScene extends Phaser.Scene { constructor() { @@ -13,7 +14,7 @@ export class MenuScene extends Phaser.Scene { this.add.rectangle(0, 0, W, H, 0x1a5c2a).setOrigin(0); // Title - this.add.text(W / 2, H * 0.2, 'Scopone Scientifico', { + this.add.text(W / 2, H * 0.18, 'Scopone Scientifico', { fontFamily: 'Georgia, serif', fontSize: '52px', color: '#ffd700', @@ -22,7 +23,7 @@ export class MenuScene extends Phaser.Scene { resolution: 2, }).setOrigin(0.5); - this.add.text(W / 2, H * 0.32, '2 vs 2 · Tu + Compagno vs 2 AI', { + this.add.text(W / 2, H * 0.30, '2 vs 2 · Tu + Compagno vs 2 AI', { fontFamily: 'serif', fontSize: '22px', color: '#ccffcc', @@ -37,36 +38,63 @@ export class MenuScene extends Phaser.Scene { 'Prima squadra a 11 punti vince', ]; rules.forEach((line, i) => { - this.add.text(W / 2, H * 0.44 + i * 28, line, { + this.add.text(W / 2, H * 0.40 + i * 26, line, { fontFamily: 'serif', - fontSize: '18px', + fontSize: '17px', color: '#ffffff', resolution: 2, }).setOrigin(0.5); }); - // Start button - const btn = this.add.rectangle(W / 2, H * 0.72, 220, 60, 0xffd700, 1) - .setInteractive({ useHandCursor: true }); - const btnText = this.add.text(W / 2, H * 0.72, 'INIZIA PARTITA', { + // Difficulty selection label + this.add.text(W / 2, H * 0.60, 'Scegli la difficoltà:', { fontFamily: 'Georgia, serif', - fontSize: '22px', - color: '#1a5c2a', + fontSize: '20px', + color: '#ffd700', resolution: 2, }).setOrigin(0.5); - btn.on('pointerover', () => btn.setFillStyle(0xffec6e)); - btn.on('pointerout', () => btn.setFillStyle(0xffd700)); - btn.on('pointerdown', () => { - this.cameras.main.fadeOut(300, 0, 30, 0); - this.cameras.main.once('camerafadeoutcomplete', () => { - this.scene.start('GameScene'); + // Difficulty buttons + const difficulties: Array<{ label: string; value: Difficulty; color: number; hoverColor: number }> = [ + { label: 'Principiante', value: 'beginner', color: 0x4caf50, hoverColor: 0x66bb6a }, + { label: 'Avanzato', value: 'advanced', color: 0xff9800, hoverColor: 0xffb74d }, + { label: 'Maestro', value: 'master', color: 0xf44336, hoverColor: 0xef5350 }, + ]; + + const btnWidth = 200; + const btnHeight = 50; + const totalWidth = difficulties.length * btnWidth + (difficulties.length - 1) * 20; + const startX = (W - totalWidth) / 2 + btnWidth / 2; + + difficulties.forEach((d, i) => { + const x = startX + i * (btnWidth + 20); + const y = H * 0.70; + + const btn = this.add.rectangle(x, y, btnWidth, btnHeight, d.color, 1) + .setInteractive({ useHandCursor: true }); + + this.add.text(x, y, d.label, { + fontFamily: 'Georgia, serif', + fontSize: '20px', + color: '#ffffff', + stroke: '#000000', + strokeThickness: 2, + resolution: 2, + }).setOrigin(0.5); + + btn.on('pointerover', () => btn.setFillStyle(d.hoverColor)); + btn.on('pointerout', () => btn.setFillStyle(d.color)); + btn.on('pointerdown', () => { + this.cameras.main.fadeOut(300, 0, 30, 0); + this.cameras.main.once('camerafadeoutcomplete', () => { + this.scene.start('GameScene', { difficulty: d.value }); + }); }); }); // Show some face-down cards decoratively const positions = [ - [W * 0.1, H * 0.5], [W * 0.15, H * 0.52], [W * 0.9, H * 0.5], [W * 0.85, H * 0.52], + [W * 0.08, H * 0.85], [W * 0.14, H * 0.87], [W * 0.92, H * 0.85], [W * 0.86, H * 0.87], ]; for (const [x, y] of positions) { this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15));