feat(SCOPONE-0008): complete iteration 0 improve ai rules

This commit is contained in:
Giancarmine Salucci
2026-04-02 20:10:55 +02:00
parent e4edc4d660
commit 747da35190
6 changed files with 397 additions and 122 deletions

View File

@@ -118,6 +118,18 @@ Phaser `Text` objects render to an internal Canvas. When the game uses `Scale.FI
- `scoreDump()` (ai.ts:103): Uses `caps` for threat checking — unaffected.
- `GameScene.onCardClick()`: Already routes `captures.length > 1` to `highlightMultipleCaptures()` — works correctly with new behavior.
### SCOPONE-0008: Phaser AI Turn Progress Timing (2026-04-02)
**Source**: Phaser 3 API docs (Context7 — `/websites/phaser_io_api-documentation`)
- `Phaser.Time.TimerEvent#getProgress()` returns the current iteration progress as a normalized value between `0` and `1`.
- `Phaser.Time.TimerEvent#getOverallProgress()` returns normalized overall progress when repeats are involved.
- `TimerEvent` instances are managed by the scene clock, so they are suitable for rendering an AI decision countdown bar that reflects elapsed scene time.
**Planning impact**:
- A visible AI countdown can be driven from elapsed timer progress instead of a blind tween.
- If the AI search budget increases beyond the current short synchronous window, the search loop must yield back to the browser between batches or Phaser will not repaint the progress bar while the AI is thinking.
### Minimax Feasibility Analysis
- 10 cards per player × 4 players = 40 total moves per round
- Full game tree: ~10^12 nodes — infeasible for exhaustive search

View File

@@ -7,6 +7,27 @@ export interface AIMove {
capture: Card[];
}
export interface AIDecisionProgress {
difficulty: Difficulty;
progress: number;
elapsedMs: number;
budgetMs: number;
batchesCompleted: number;
}
interface SearchProfile {
timeBudgetMs: number;
sampleCount: number;
maxDepth: number;
batchSize: number;
}
const SEARCH_PROFILES: Record<Difficulty, SearchProfile> = {
beginner: { timeBudgetMs: 120, sampleCount: 0, maxDepth: 0, batchSize: 0 },
advanced: { timeBudgetMs: 650, sampleCount: 0, maxDepth: 0, batchSize: 0 },
master: { timeBudgetMs: 9800, sampleCount: 12, maxDepth: 6, batchSize: 2 },
};
// ---------------------------------------------------------------------------
// Helpers shared across all difficulty levels
// ---------------------------------------------------------------------------
@@ -51,28 +72,78 @@ function countValueInHand(hand: Card[], value: number): number {
return n;
}
function getSearchProfile(state: GameState, difficulty: Difficulty): SearchProfile {
if (difficulty !== 'master') return SEARCH_PROFILES[difficulty];
const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0);
if (cardsRemaining <= 6) {
return { timeBudgetMs: 9800, sampleCount: 18, maxDepth: Math.min(cardsRemaining, 8), batchSize: 1 };
}
if (cardsRemaining <= 12) {
return { timeBudgetMs: 9000, sampleCount: 16, maxDepth: 8, batchSize: 2 };
}
if (cardsRemaining <= 20) {
return { timeBudgetMs: 8200, sampleCount: 14, maxDepth: 7, batchSize: 2 };
}
return SEARCH_PROFILES.master;
}
function reportDecisionProgress(
onProgress: ((progress: AIDecisionProgress) => void) | undefined,
difficulty: Difficulty,
startedAt: number,
budgetMs: number,
progress: number,
batchesCompleted: number,
): void {
if (!onProgress) return;
onProgress({
difficulty,
progress: Math.max(0, Math.min(1, progress)),
elapsedMs: Date.now() - startedAt,
budgetMs,
batchesCompleted,
});
}
function yieldToBrowser(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0));
}
function handLikelyHasValue(
value: number,
handSize: number,
state: GameState,
playerIdx: PlayerIndex,
tracker: CardTracker | undefined,
myHand: Card[],
table: Card[],
): number {
if (handSize <= 0) return 0;
if (tracker) {
return tracker.probabilityHandHasValue(value, handSize, myHand, table);
}
const unseen = getUnseenCardsForEstimate(state, playerIdx, myHand, table, tracker);
let unseenWithValue = 0;
for (const card of unseen) {
if (card.value === value) unseenWithValue++;
}
if (unseenWithValue === 0 || unseen.length === 0) return 0;
const probNone = hypergeometricNone(unseen.length, unseenWithValue, handSize);
return 1 - probNone;
}
/** Check if partner likely holds a card of given value (via tracker inference) */
function partnerLikelyHolds(
value: number, playerIdx: PlayerIndex, state: GameState,
tracker: CardTracker | undefined, myHand: Card[], table: Card[],
): number {
const partner = partnerOf(playerIdx);
const partnerHandSize = state.players[partner].hand.length;
if (partnerHandSize === 0) return 0;
const unseen = tracker
? tracker.getUnseenCards(myHand, table)
: getUnseenWithoutTracker(state, playerIdx);
let unseenWithValue = 0;
for (const c of unseen) if (c.value === value) unseenWithValue++;
if (unseenWithValue === 0) return 0;
// P(partner has ≥1 card of this value) ≈ 1 - hypergeometric(0 drawn)
const totalUnseen = unseen.length;
if (totalUnseen === 0) return 0;
const probNone = hypergeometricNone(totalUnseen, unseenWithValue, partnerHandSize);
return 1 - probNone;
return handLikelyHasValue(value, state.players[partner].hand.length, state, playerIdx, tracker, myHand, table);
}
/** Race state: who's winning each scoring category */
@@ -196,16 +267,30 @@ function hypergeometricNone(total: number, threats: number, drawn: number): numb
// Main entry point
// ---------------------------------------------------------------------------
export function chooseMove(
export async function chooseMove(
state: GameState,
playerIdx: PlayerIndex,
difficulty: Difficulty = 'advanced',
tracker?: CardTracker,
): AIMove {
onProgress?: (progress: AIDecisionProgress) => void,
): Promise<AIMove> {
const startedAt = Date.now();
const profile = getSearchProfile(state, difficulty);
reportDecisionProgress(onProgress, difficulty, startedAt, profile.timeBudgetMs, 0, 0);
switch (difficulty) {
case 'beginner': return beginnerMove(state, playerIdx, tracker);
case 'advanced': return advancedMove(state, playerIdx, tracker);
case 'master': return masterMove(state, playerIdx, tracker);
case 'beginner': {
const move = beginnerMove(state, playerIdx, tracker);
reportDecisionProgress(onProgress, difficulty, startedAt, profile.timeBudgetMs, 1, 1);
return move;
}
case 'advanced': {
const move = advancedMove(state, playerIdx, tracker);
reportDecisionProgress(onProgress, difficulty, startedAt, profile.timeBudgetMs, 1, 1);
return move;
}
case 'master':
return masterMove(state, playerIdx, tracker, onProgress, profile, startedAt);
}
}
@@ -652,27 +737,86 @@ function scoreDumpAdv(
// improved evaluation, team-aware search, last-play awareness
// ===========================================================================
function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
function tableControlPressure(
afterTable: Card[],
state: GameState,
playerIdx: PlayerIndex,
tracker: CardTracker | undefined,
myHand: Card[],
race: RaceState,
): number {
if (afterTable.length === 0) return 0;
let score = 0;
const next = nextPlayer(playerIdx);
const partner = partnerOf(playerIdx);
const nextHandSize = state.players[next].hand.length;
const partnerHandSize = state.players[partner].hand.length;
const nextIsOpp = isOpponent(playerIdx, next);
const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0);
if (tableSum >= 11) score += 70;
if (tableSum <= 10 && nextIsOpp) score -= 110;
if (race.behindInDenari && afterTable.some(card => card.suit === 'denara')) score += 35;
if (race.need7s && afterTable.some(card => card.value === 7)) score += 45;
for (const tableCard of afterTable) {
const myAnchors = countValueInHand(myHand, tableCard.value);
if (myAnchors > 0) score += myAnchors * 18;
const partnerProb = handLikelyHasValue(
tableCard.value,
partnerHandSize,
state,
playerIdx,
tracker,
myHand,
afterTable,
);
score += partnerProb * (nextIsOpp ? 20 : 55);
if (nextHandSize > 0 && nextIsOpp) {
const nextProb = handLikelyHasValue(
tableCard.value,
nextHandSize,
state,
playerIdx,
tracker,
myHand,
afterTable,
);
score -= nextProb * 80;
}
}
if (race.aheadOverall && nextIsOpp && tableSum <= 10) score -= 60;
return score;
}
async function masterMove(
state: GameState,
playerIdx: PlayerIndex,
tracker: CardTracker | undefined,
onProgress: ((progress: AIDecisionProgress) => void) | undefined,
profile: SearchProfile,
startedAt: number,
): Promise<AIMove> {
const myTeam = teamOf(playerIdx);
const phase = gamePhase(state);
const cardsRemaining = state.players.reduce((s, p) => s + p.hand.length, 0);
const isDeepEndgame = cardsRemaining <= 6;
const isEndgame = cardsRemaining <= 12;
const NUM_SAMPLES = isDeepEndgame ? 1 : isEndgame ? 14 : 10;
const MAX_DEPTH = isDeepEndgame ? cardsRemaining : isEndgame ? 8 : 6;
const legalMoves = getLegalMoves(state, playerIdx);
if (legalMoves.length === 1) return legalMoves[0];
if (legalMoves.length === 1) {
reportDecisionProgress(onProgress, 'master', startedAt, profile.timeBudgetMs, 1, 1);
return legalMoves[0];
}
// Time budget: 1.5 seconds max
const deadline = Date.now() + 1500;
const deadline = startedAt + profile.timeBudgetMs;
// Quick-eval move ordering for better pruning
const lastPlay = isLastPlay(state, playerIdx);
const race = getRaceState(state, playerIdx);
const quickScored = legalMoves.map(m => ({
move: m,
quick: quickEval(m, state, playerIdx, tracker, lastPlay),
quick: quickEval(m, state, playerIdx, tracker, lastPlay, race),
}));
quickScored.sort((a, b) => b.quick - a.quick);
const sortedMoves = quickScored.map(qs => qs.move);
@@ -680,26 +824,56 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
const moveScores = new Map<string, number>();
for (const m of sortedMoves) moveScores.set(moveKey(m), 0);
// Deep endgame: use actual state (perfect info), otherwise sample
const samples = isDeepEndgame
? [state]
: generateSamples(state, playerIdx, tracker, NUM_SAMPLES);
const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount);
let timedOut = false;
let samplesCompleted = 0;
let batchesCompleted = 0;
let evaluationsCompleted = 0;
const totalEvaluations = Math.max(1, samples.length * sortedMoves.length);
for (const sample of samples) {
if (timedOut) break;
for (const move of sortedMoves) {
if (Date.now() > deadline) { timedOut = true; break; }
const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined);
const score = alphaBeta(
result.nextState, MAX_DEPTH - 1, -Infinity, Infinity,
myTeam, playerIdx, phase, deadline,
);
moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score);
for (let start = 0; start < samples.length; start += profile.batchSize) {
if (timedOut || Date.now() > deadline) break;
const batch = samples.slice(start, start + profile.batchSize);
for (const sample of batch) {
for (const move of sortedMoves) {
if (Date.now() > deadline) {
timedOut = true;
break;
}
const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined);
const score = alphaBeta(
result.nextState,
profile.maxDepth - 1,
-Infinity,
Infinity,
myTeam,
playerIdx,
phase,
deadline,
tracker,
);
moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score);
evaluationsCompleted++;
}
if (timedOut) break;
}
batchesCompleted++;
reportDecisionProgress(
onProgress,
'master',
startedAt,
profile.timeBudgetMs,
Math.max(evaluationsCompleted / totalEvaluations, Math.min(1, (Date.now() - startedAt) / profile.timeBudgetMs)),
batchesCompleted,
);
if (!timedOut && start + profile.batchSize < samples.length && Date.now() < deadline) {
await yieldToBrowser();
}
samplesCompleted++;
}
let bestMove = sortedMoves[0];
@@ -709,12 +883,14 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
if (totalScore > bestScore) { bestScore = totalScore; bestMove = move; }
}
reportDecisionProgress(onProgress, 'master', startedAt, profile.timeBudgetMs, 1, batchesCompleted);
return bestMove;
}
function quickEval(
move: AIMove, state: GameState, playerIdx: PlayerIndex,
tracker: CardTracker | undefined, lastPlay: boolean,
race: RaceState,
): number {
let score = 0;
const table = state.table;
@@ -731,9 +907,9 @@ function quickEval(
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 900;
if (move.capture.length === 0 && move.card.suit === 'denara' && move.card.value === 7) score -= 5000;
score += move.capture.length * 65;
score += allCaptured.filter(c => c.suit === 'denara').length * 100;
score += allCaptured.filter(c => c.value === 7).length * 80;
score += move.capture.length * (race.behindInCards ? 75 : 55);
score += allCaptured.filter(c => c.suit === 'denara').length * (race.behindInDenari ? 135 : 95);
score += allCaptured.filter(c => c.value === 7).length * (race.need7s ? 110 : 75);
for (const c of allCaptured) score += primieraVal(c) * 2.5;
if (move.capture.length === 0) {
@@ -762,6 +938,8 @@ function quickEval(
if (sum >= 1 && sum <= 10) score += 40; // partner might scopa
}
score += tableControlPressure(afterTable, state, playerIdx, tracker, state.players[playerIdx].hand, race);
return score;
}
@@ -810,12 +988,37 @@ function generateSamples(
}
function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] {
return getUnseenCardsForEstimate(
state,
playerIdx,
state.players[playerIdx].hand,
state.table,
undefined,
);
}
function getUnseenCardsForEstimate(
state: GameState,
playerIdx: PlayerIndex,
myHand: Card[],
table: Card[],
tracker: CardTracker | undefined,
): Card[] {
if (tracker) {
return tracker.getUnseenCards(myHand, table);
}
const known = new Set<string>();
for (const c of state.players[playerIdx].hand) known.add(c.id);
for (const c of state.table) known.add(c.id);
for (const p of state.players) { for (const c of p.pile) known.add(c.id); }
for (const card of myHand) known.add(card.id);
for (const card of table) known.add(card.id);
for (const player of state.players) {
for (const card of player.pile) {
known.add(card.id);
}
}
const deck = buildDeck();
return deck.filter(c => !known.has(c.id));
return deck.filter(card => !known.has(card.id));
}
function shuffleArray<T>(arr: T[]): T[] {
@@ -830,6 +1033,7 @@ function alphaBeta(
state: GameState, depth: number, alpha: number, beta: number,
myTeam: 0 | 1, rootPlayer: PlayerIndex,
phase: number, deadline: number,
tracker: CardTracker | undefined,
): number {
if (depth === 0 || state.roundOver || Date.now() > deadline) {
return evaluateFast(state, myTeam, phase);
@@ -843,29 +1047,16 @@ function alphaBeta(
// Move ordering: settebello captures first, then scopa, then captures by size, then dumps
if (moves.length > 2) {
moves.sort((a, b) => {
const aSettebello = a.capture.some(c => c.suit === 'denara' && c.value === 7) ? 1 : 0;
const bSettebello = b.capture.some(c => c.suit === 'denara' && c.value === 7) ? 1 : 0;
if (aSettebello !== bSettebello) return bSettebello - aSettebello;
// Scopa moves first
const aScopa = a.capture.length > 0 && state.table.filter(c => !a.capture.some(cc => cc.id === c.id)).length === 0 ? 1 : 0;
const bScopa = b.capture.length > 0 && state.table.filter(c => !b.capture.some(cc => cc.id === c.id)).length === 0 ? 1 : 0;
if (aScopa !== bScopa) return bScopa - aScopa;
// Captures before dumps
if (a.capture.length > 0 && b.capture.length === 0) return -1;
if (a.capture.length === 0 && b.capture.length > 0) return 1;
// Larger captures first
return b.capture.length - a.capture.length;
});
const race = getRaceState(state, cur);
const lastPlay = isLastPlay(state, cur);
moves.sort((a, b) => quickEval(b, state, cur, tracker, lastPlay, race) - quickEval(a, state, cur, tracker, lastPlay, race));
}
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, myTeam, rootPlayer, phase, deadline);
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, deadline, tracker);
value = Math.max(value, child);
alpha = Math.max(alpha, value);
if (beta <= alpha) break;
@@ -875,7 +1066,7 @@ function alphaBeta(
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, myTeam, rootPlayer, phase, deadline);
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, deadline, tracker);
value = Math.min(value, child);
beta = Math.min(beta, value);
if (beta <= alpha) break;

View File

@@ -61,6 +61,27 @@ export class CardTracker {
return this.getUnseenCards(myHand, table).filter(c => c.suit === suit).length;
}
/** Count how many unseen cards share a value */
countRemainingValue(value: number, myHand: Card[], table: Card[]): number {
return this.getUnseenCards(myHand, table).filter(c => c.value === value).length;
}
/** Probability that a hidden hand contains at least one card with the requested value */
probabilityHandHasValue(value: number, handSize: number, myHand: Card[], table: Card[]): number {
if (handSize <= 0) return 0;
const unseen = this.getUnseenCards(myHand, table);
const matching = unseen.filter(c => c.value === value).length;
if (matching === 0) return 0;
if (handSize >= unseen.length) return 1;
let probNone = 1;
for (let i = 0; i < handSize; i++) {
probNone *= Math.max(0, unseen.length - matching - i) / (unseen.length - i);
}
return 1 - probNone;
}
/** Get count of all played/seen cards */
get playedCount(): number {
return this.played.size;

View File

@@ -39,15 +39,14 @@ export function shuffle<T>(arr: T[]): T[] {
* Returns array of capture sets (each is a list of cards taken from table).
*/
export function findCaptures(played: Card, table: Card[]): Card[][] {
const results: Card[][] = [];
// Each direct-match card is a separate single-card capture option
const directMatches = table.filter(c => c.value === played.value);
for (const dm of directMatches) {
results.push([dm]);
if (directMatches.length > 0) {
return directMatches.map((directMatch): Card[] => [directMatch]);
}
// Also find multi-card subsets that sum to played.value
const results: Card[][] = [];
// Only sum captures are legal when no direct match is available.
const subsets = getSubsets(table);
for (const subset of subsets) {
if (subset.length >= 2) {
@@ -102,6 +101,7 @@ export function createInitialState(startingPlayer: PlayerIndex = 0): GameState {
return {
players,
table,
matchStartingPlayer: startingPlayer,
currentPlayer: startingPlayer,
roundOver: false,
gameOver: false,
@@ -293,6 +293,33 @@ export function getScoreBreakdown(state: GameState): ScoreBreakdown {
return scoreRound(team0, team1);
}
export function getMatchOutcome(teamScores: [TeamScore, TeamScore]): {
winner: 0 | 1 | null;
continueMatch: boolean;
} {
const [team0, team1] = teamScores;
const thresholdReached = team0.totalPoints >= 11 || team1.totalPoints >= 11;
if (!thresholdReached) {
return {
winner: null,
continueMatch: true,
};
}
if (team0.totalPoints === team1.totalPoints) {
return {
winner: null,
continueMatch: true,
};
}
return {
winner: team0.totalPoints > team1.totalPoints ? 0 : 1,
continueMatch: false,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -325,6 +352,7 @@ export function cloneState(state: GameState): GameState {
clonePlayer(state.players[3]),
],
table: state.table.map(cloneCard),
matchStartingPlayer: state.matchStartingPlayer,
currentPlayer: state.currentPlayer,
roundOver: state.roundOver,
gameOver: state.gameOver,

View File

@@ -28,6 +28,7 @@ export interface Player {
export interface GameState {
players: [Player, Player, Player, Player];
table: Card[];
matchStartingPlayer: PlayerIndex;
currentPlayer: PlayerIndex;
roundOver: boolean;
gameOver: boolean;

View File

@@ -1,9 +1,9 @@
import Phaser from 'phaser';
import { Card, PlayerIndex, GameState, Difficulty } from '../game/types';
import {
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome
} from '../game/engine';
import { chooseMove } from '../game/ai';
import { chooseMove, AIDecisionProgress } from '../game/ai';
import { CardTracker } from '../game/card-tracker';
// ---------------------------------------------------------------------------
@@ -25,8 +25,6 @@ const CH_H = 645 * CARD_SCALE_HUMAN; // card height for human ≈ 106
const CW_A = 402 * CARD_SCALE_AI; // card width for AI ≈ 50
const CH_A = 645 * CARD_SCALE_AI; // card height for AI ≈ 81
const AI_DELAY = 1100; // ms — think bar fills over this time
// Scorebar height at top
const SCOREBAR_H = 54;
@@ -76,8 +74,6 @@ export class GameScene extends Phaser.Scene {
// Think bar
private thinkBar!: Phaser.GameObjects.Graphics;
private thinkTween: Phaser.Tweens.Tween | null = null;
private thinkProgress = 0;
// Player label containers (pulsed on active turn)
private playerLabels: Map<PlayerIndex, Phaser.GameObjects.Text> = new Map();
@@ -128,7 +124,8 @@ export class GameScene extends Phaser.Scene {
this.input.once('pointerdown', () => this.startMusic());
this.state = createInitialState();
const startingPlayer = Phaser.Math.Between(0, 3) as PlayerIndex;
this.state = createInitialState(startingPlayer);
this.dealAnimation(() => {
this.updateScoreBar();
this.nextTurn();
@@ -374,39 +371,34 @@ export class GameScene extends Phaser.Scene {
this.thinkBar = this.add.graphics().setDepth(11).setVisible(false);
}
private showThinkBar(playerIdx: PlayerIndex): void {
this.thinkProgress = 0;
private showThinkBar(playerIdx: PlayerIndex, remainingRatio = 1): void {
this.thinkBar.setVisible(true);
this.thinkTween?.stop();
this.drawThinkBar(playerIdx, remainingRatio);
}
private updateThinkBar(playerIdx: PlayerIndex, progress: AIDecisionProgress): void {
this.drawThinkBar(playerIdx, 1 - progress.progress);
}
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;
const tweenTarget = { v: 0 };
this.thinkTween = this.tweens.add({
targets: tweenTarget,
v: 1,
duration: AI_DELAY - 80,
ease: 'Linear',
onUpdate: () => {
tg.clear();
const w = tweenTarget.v * W;
tg.fillStyle(0x000000, 0.4);
tg.fillRect(0, SCOREBAR_H, W, 4);
tg.fillStyle(color, 0.85);
tg.fillRect(0, SCOREBAR_H, w, 4);
// Glow tip
tg.fillStyle(0xffffff, 0.6);
tg.fillRect(w - 6, SCOREBAR_H, 6, 4);
},
onComplete: () => { tg.clear(); tg.setVisible(false); },
});
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.thinkTween?.stop();
this.thinkTween = null;
this.thinkBar.clear();
this.thinkBar.setVisible(false);
}
@@ -602,21 +594,47 @@ export class GameScene extends Phaser.Scene {
this.pulseLabel(cur);
if (player.isHuman) {
this.hideThinkBar();
this.enableHumanInteraction();
} else {
this.aiThinking = true;
this.showThinkBar(cur);
this.time.delayedCall(AI_DELAY, () => {
this.hideThinkBar();
this.doAIMove(cur);
});
this.showThinkBar(cur, 1);
void this.doAIMove(cur);
}
}
private doAIMove(playerIdx: PlayerIndex): void {
const move = chooseMove(this.state, playerIdx, this.difficulty, this.tracker);
this.aiThinking = false;
this.executeMove(playerIdx, move.card, move.capture);
private async doAIMove(playerIdx: PlayerIndex): Promise<void> {
const turnState = this.state;
try {
const move = await chooseMove(
this.state,
playerIdx,
this.difficulty,
this.tracker,
(progress) => {
if (!this.scene.isActive('GameScene') || this.state !== turnState) return;
this.updateThinkBar(playerIdx, progress);
}
);
if (!this.scene.isActive('GameScene')) return;
if (this.state !== turnState || this.state.currentPlayer !== playerIdx || this.state.roundOver) return;
this.hideThinkBar();
this.aiThinking = false;
this.executeMove(playerIdx, move.card, move.capture);
} catch (error) {
console.error('AI move failed', error);
if (this.scene.isActive('GameScene') && this.state === turnState) {
this.setStatus('Errore durante la mossa AI');
}
} finally {
if (this.scene.isActive('GameScene') && this.state === turnState) {
this.hideThinkBar();
this.aiThinking = false;
}
}
}
// ---------------------------------------------------------------------------
@@ -1316,7 +1334,8 @@ export class GameScene extends Phaser.Scene {
}).setOrigin(0.5).setDepth(32);
});
const gameOver = t0.totalPoints >= 11 || t1.totalPoints >= 11;
const outcome = getMatchOutcome(this.state.teamScores);
const gameOver = !outcome.continueMatch;
const btnLabel = gameOver ? 'Fine Partita' : 'Prossima Mano';
const btnG = this.add.graphics().setDepth(32);
@@ -1344,7 +1363,8 @@ export class GameScene extends Phaser.Scene {
const H = this.scale.height;
const t0 = this.state.teamScores[0];
const t1 = this.state.teamScores[1];
const win = t0.totalPoints >= t1.totalPoints;
const outcome = getMatchOutcome(this.state.teamScores);
const win = outcome.winner === 0;
this.stopMusic();
// Victory confetti
@@ -1392,11 +1412,13 @@ export class GameScene extends Phaser.Scene {
private startNewRound(): void {
const totals = this.state.teamScores.map(t => t.totalPoints);
const nextRound = (this.state.roundNumber ?? 1) + 1;
const startingPlayer = ((nextRound - 1) % 4) as PlayerIndex;
const matchStartingPlayer = this.state.matchStartingPlayer;
const startingPlayer = ((matchStartingPlayer + 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.matchStartingPlayer = matchStartingPlayer;
this.state.teamScores[0].totalPoints = totals[0];
this.state.teamScores[1].totalPoints = totals[1];
this.state.roundNumber = nextRound;