feat(SCOPONE-0005): complete iteration 0 — AI mastery levels, score bar fix, difficulty selection
This commit is contained in:
498
src/game/ai.ts
498
src/game/ai.ts
@@ -1,37 +1,61 @@
|
|||||||
import { Card, GameState, PlayerIndex } from './types';
|
import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES } from './types';
|
||||||
import { findCaptures, canCapture, calcPrimiera, teamOf } from './engine';
|
import { findCaptures, canCapture, calcPrimiera, teamOf, applyMove, buildDeck } from './engine';
|
||||||
|
import { CardTracker } from './card-tracker';
|
||||||
|
|
||||||
export interface AIMove {
|
export interface AIMove {
|
||||||
card: Card;
|
card: Card;
|
||||||
capture: Card[];
|
capture: Card[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* Heuristic AI for Scopone Scientifico.
|
// Main entry point — dispatches by difficulty
|
||||||
* Evaluates moves by scoring the value of captured cards.
|
// ---------------------------------------------------------------------------
|
||||||
*/
|
|
||||||
export function chooseMove(state: GameState, playerIdx: PlayerIndex): AIMove {
|
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 player = state.players[playerIdx];
|
||||||
const hand = player.hand;
|
|
||||||
const table = state.table;
|
const table = state.table;
|
||||||
const myTeam = teamOf(playerIdx);
|
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 bestMove: AIMove | null = null;
|
||||||
let bestScore = -Infinity;
|
let bestScore = -Infinity;
|
||||||
|
|
||||||
for (const card of hand) {
|
for (const card of player.hand) {
|
||||||
const captures = findCaptures(card, table);
|
const captures = findCaptures(card, table);
|
||||||
if (captures.length > 0) {
|
if (captures.length > 0) {
|
||||||
for (const captureSet of captures) {
|
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) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
bestMove = { card, capture: captureSet };
|
bestMove = { card, capture: captureSet };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No capture — score the "dump" move
|
const base = scoreDumpBasic(card);
|
||||||
const score = scoreDump(card, table, state, myTeam);
|
const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.6;
|
||||||
if (score > bestScore) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
bestMove = { card, capture: [] };
|
bestMove = { card, capture: [] };
|
||||||
@@ -42,122 +66,396 @@ export function chooseMove(state: GameState, playerIdx: PlayerIndex): AIMove {
|
|||||||
return bestMove!;
|
return bestMove!;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreCapture(
|
function randomMove(state: GameState, playerIdx: PlayerIndex): AIMove {
|
||||||
played: Card,
|
const hand = state.players[playerIdx].hand;
|
||||||
captured: Card[],
|
const card = hand[Math.floor(Math.random() * hand.length)];
|
||||||
table: Card[],
|
const captures = findCaptures(card, state.table);
|
||||||
state: GameState,
|
if (captures.length > 0) {
|
||||||
myTeam: 0 | 1
|
return { card, capture: captures[Math.floor(Math.random() * captures.length)] };
|
||||||
): number {
|
}
|
||||||
let score = 100; // base for capturing anything
|
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 allCaptured = [played, ...captured];
|
||||||
const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
|
const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
|
||||||
const isScopa = afterTable.length === 0;
|
|
||||||
|
|
||||||
// Scopa is very valuable
|
if (afterTable.length === 0) score += 500;
|
||||||
if (isScopa) score += 500;
|
|
||||||
|
|
||||||
// Settebello
|
|
||||||
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 300;
|
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;
|
score += allCaptured.filter(c => c.suit === 'denara').length * 50;
|
||||||
|
|
||||||
// More cards = better
|
|
||||||
score += captured.length * 20;
|
score += captured.length * 20;
|
||||||
|
score += allCaptured.reduce((s, c) => s + primieraValue(c), 0);
|
||||||
// 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;
|
|
||||||
|
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreDump(
|
function scoreDumpBasic(card: Card): number {
|
||||||
card: Card,
|
|
||||||
table: Card[],
|
|
||||||
state: GameState,
|
|
||||||
myTeam: 0 | 1
|
|
||||||
): number {
|
|
||||||
let score = 0;
|
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 += 30;
|
||||||
if (card.suit === 'denara') score -= 40;
|
if (card.suit === 'denara') score -= 40;
|
||||||
if (card.suit === 'denara' && card.value === 7) score -= 300; // never dump settebello
|
if (card.suit === 'denara' && card.value === 7) score -= 300;
|
||||||
|
|
||||||
// Prefer dumping face cards (10, 9, 8) that are less useful
|
|
||||||
if (card.value >= 8) score += 10;
|
if (card.value >= 8) score += 10;
|
||||||
|
|
||||||
// Don't dump 7s (primiera value)
|
|
||||||
if (card.value === 7) score -= 50;
|
if (card.value === 7) score -= 50;
|
||||||
|
|
||||||
// Don't dump 1s (primiera value)
|
|
||||||
if (card.value === 1) score -= 30;
|
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;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
function primieraScore(card: Card): number {
|
function primieraValue(card: Card): number {
|
||||||
// Reward for capturing high-primiera cards
|
|
||||||
const vals: Record<number, number> = { 7: 8, 6: 6, 1: 5, 5: 4, 4: 3, 3: 2, 2: 1 };
|
const vals: Record<number, number> = { 7: 8, 6: 6, 1: 5, 5: 4, 4: 3, 3: 2, 2: 1 };
|
||||||
return vals[card.value] ?? 0;
|
return vals[card.value] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function opponentThreatScore(
|
// ---------------------------------------------------------------------------
|
||||||
table: Card[],
|
// ADVANCED — improved heuristic with card counting
|
||||||
state: GameState,
|
// ---------------------------------------------------------------------------
|
||||||
myTeam: 0 | 1
|
|
||||||
): number {
|
function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
|
||||||
const opponentTeam = myTeam === 0 ? 1 : 0;
|
const player = state.players[playerIdx];
|
||||||
let threat = 0;
|
const table = state.table;
|
||||||
for (const player of state.players) {
|
const myTeam = teamOf(playerIdx);
|
||||||
if (teamOf(player.index) !== opponentTeam) continue;
|
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) {
|
for (const card of player.hand) {
|
||||||
const caps = findCaptures(card, table);
|
const captures = findCaptures(card, table);
|
||||||
if (caps.length > 0) {
|
if (captures.length > 0) {
|
||||||
threat += caps[0].length;
|
for (const captureSet of captures) {
|
||||||
// Extra threat if settebello is capturable
|
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 {
|
||||||
|
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) &&
|
if (table.some(c => c.suit === 'denara' && c.value === 7) &&
|
||||||
caps.some(cap => cap.some(c => c.suit === 'denara' && c.value === 7))) {
|
captured.some(c => c.suit === 'denara' && c.value === 7)) score += 250;
|
||||||
threat += 5;
|
|
||||||
|
// 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<string, number>();
|
||||||
|
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<string>();
|
||||||
|
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<T>(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;
|
||||||
}
|
}
|
||||||
|
|||||||
68
src/game/card-tracker.ts
Normal file
68
src/game/card-tracker.ts
Normal file
@@ -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<string> = 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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export interface Capture {
|
|||||||
|
|
||||||
export type PlayerIndex = 0 | 1 | 2 | 3;
|
export type PlayerIndex = 0 | 1 | 2 | 3;
|
||||||
|
|
||||||
|
export type Difficulty = 'beginner' | 'advanced' | 'master';
|
||||||
|
|
||||||
export interface Player {
|
export interface Player {
|
||||||
index: PlayerIndex;
|
index: PlayerIndex;
|
||||||
hand: Card[];
|
hand: Card[];
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { Card, PlayerIndex, GameState } from '../game/types';
|
import { Card, PlayerIndex, GameState, Difficulty } from '../game/types';
|
||||||
import {
|
import {
|
||||||
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera
|
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera
|
||||||
} from '../game/engine';
|
} from '../game/engine';
|
||||||
import { chooseMove } from '../game/ai';
|
import { chooseMove } from '../game/ai';
|
||||||
|
import { CardTracker } from '../game/card-tracker';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Layout constants
|
// Layout constants
|
||||||
@@ -46,13 +47,20 @@ export class GameScene extends Phaser.Scene {
|
|||||||
private state!: GameState;
|
private state!: GameState;
|
||||||
private cardImages: Map<string, Phaser.GameObjects.Image> = new Map();
|
private cardImages: Map<string, Phaser.GameObjects.Image> = 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
|
// Live score bar texts
|
||||||
private hudA!: { scope: Phaser.GameObjects.Text; cards: Phaser.GameObjects.Text;
|
private hudA!: { scope: Phaser.GameObjects.Text; cards: Phaser.GameObjects.Text;
|
||||||
denari: Phaser.GameObjects.Text; prim: Phaser.GameObjects.Text;
|
denari: Phaser.GameObjects.Text; sette: Phaser.GameObjects.Text;
|
||||||
total: Phaser.GameObjects.Text };
|
prim: Phaser.GameObjects.Text; total: Phaser.GameObjects.Text };
|
||||||
private hudB!: { scope: Phaser.GameObjects.Text; cards: Phaser.GameObjects.Text;
|
private hudB!: { scope: Phaser.GameObjects.Text; cards: Phaser.GameObjects.Text;
|
||||||
denari: Phaser.GameObjects.Text; prim: Phaser.GameObjects.Text;
|
denari: Phaser.GameObjects.Text; sette: Phaser.GameObjects.Text;
|
||||||
total: Phaser.GameObjects.Text };
|
prim: Phaser.GameObjects.Text; total: Phaser.GameObjects.Text };
|
||||||
private roundText!: Phaser.GameObjects.Text;
|
private roundText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
// Status bar
|
// Status bar
|
||||||
@@ -90,11 +98,15 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Create
|
// Create
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
create(): void {
|
create(data?: { difficulty?: Difficulty }): void {
|
||||||
const W = this.scale.width;
|
const W = this.scale.width;
|
||||||
const H = this.scale.height;
|
const H = this.scale.height;
|
||||||
this.tableCenter = { x: W / 2, y: (H + SCOREBAR_H) / 2 + 10 };
|
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.generateParticleTextures();
|
||||||
this.drawBackground(W, H);
|
this.drawBackground(W, H);
|
||||||
this.buildScoreBar(W);
|
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');
|
this.roundText = mkTxt(W / 2, SCOREBAR_H / 2, 'Mano 1', '#ffd700', '16px');
|
||||||
|
|
||||||
// Column headers (shared, centered-ish)
|
// Column headers (shared, centered-ish)
|
||||||
const cols = ['Scope', 'Carte', 'Denari', 'Primiera', 'TOTALE'];
|
const cols = ['Scope', 'Carte', 'Denari', '7Bello', 'Primiera', 'TOTALE'];
|
||||||
const xA = [240, 320, 410, 510, 620];
|
const xA = [230, 295, 370, 445, 520, 610];
|
||||||
const xB = [W - 240, W - 320, W - 410, W - 510, W - 620];
|
const xB = [W - 230, W - 295, W - 370, W - 445, W - 520, W - 610];
|
||||||
|
|
||||||
cols.forEach((_, i) => {
|
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, {
|
this.add.text(xA[i], SCOREBAR_H * 0.28, label, {
|
||||||
fontFamily: 'monospace', fontSize: '10px', color: '#999999', resolution: 2,
|
fontFamily: 'monospace', fontSize: '10px', color: '#999999', resolution: 2,
|
||||||
}).setOrigin(0.5).setDepth(9);
|
}).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');
|
const mkB = (xi: number) => mkTxt(xB[xi], SCOREBAR_H * 0.72, '0', '#ffaaaa', '17px');
|
||||||
|
|
||||||
this.hudA = {
|
this.hudA = {
|
||||||
scope: mkA(0), cards: mkA(1), denari: mkA(2), prim: mkA(3),
|
scope: mkA(0), cards: mkA(1), denari: mkA(2), sette: mkA(3), prim: mkA(4),
|
||||||
total: this.add.text(xA[4], SCOREBAR_H * 0.72, '0', {
|
total: this.add.text(xA[5], SCOREBAR_H * 0.72, '0', {
|
||||||
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#aaffaa',
|
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#aaffaa',
|
||||||
stroke: '#000', strokeThickness: 2, resolution: 2,
|
stroke: '#000', strokeThickness: 2, resolution: 2,
|
||||||
}).setOrigin(0.5).setDepth(9),
|
}).setOrigin(0.5).setDepth(9),
|
||||||
};
|
};
|
||||||
this.hudB = {
|
this.hudB = {
|
||||||
scope: mkB(0), cards: mkB(1), denari: mkB(2), prim: mkB(3),
|
scope: mkB(0), cards: mkB(1), denari: mkB(2), sette: mkB(3), prim: mkB(4),
|
||||||
total: this.add.text(xB[4], SCOREBAR_H * 0.72, '0', {
|
total: this.add.text(xB[5], SCOREBAR_H * 0.72, '0', {
|
||||||
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#ffaaaa',
|
fontFamily: 'Georgia, serif', fontSize: '20px', color: '#ffaaaa',
|
||||||
stroke: '#000', strokeThickness: 2, resolution: 2,
|
stroke: '#000', strokeThickness: 2, resolution: 2,
|
||||||
}).setOrigin(0.5).setDepth(9),
|
}).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 den1 = pile1.filter(c => c.suit === 'denara').length;
|
||||||
const prim0 = calcPrimiera(pile0);
|
const prim0 = calcPrimiera(pile0);
|
||||||
const prim1 = calcPrimiera(pile1);
|
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 setAnim = (txt: Phaser.GameObjects.Text, val: string | number) => {
|
||||||
const v = String(val);
|
const v = String(val);
|
||||||
@@ -305,12 +319,14 @@ export class GameScene extends Phaser.Scene {
|
|||||||
setAnim(this.hudA.scope, scope0);
|
setAnim(this.hudA.scope, scope0);
|
||||||
setAnim(this.hudA.cards, pile0.length);
|
setAnim(this.hudA.cards, pile0.length);
|
||||||
setAnim(this.hudA.denari, den0);
|
setAnim(this.hudA.denari, den0);
|
||||||
|
setAnim(this.hudA.sette, sette0 ? '✓' : '–');
|
||||||
setAnim(this.hudA.prim, prim0 > 0 ? prim0 : '-');
|
setAnim(this.hudA.prim, prim0 > 0 ? prim0 : '-');
|
||||||
setAnim(this.hudA.total, t0.totalPoints);
|
setAnim(this.hudA.total, t0.totalPoints);
|
||||||
|
|
||||||
setAnim(this.hudB.scope, scope1);
|
setAnim(this.hudB.scope, scope1);
|
||||||
setAnim(this.hudB.cards, pile1.length);
|
setAnim(this.hudB.cards, pile1.length);
|
||||||
setAnim(this.hudB.denari, den1);
|
setAnim(this.hudB.denari, den1);
|
||||||
|
setAnim(this.hudB.sette, sette1 ? '✓' : '–');
|
||||||
setAnim(this.hudB.prim, prim1 > 0 ? prim1 : '-');
|
setAnim(this.hudB.prim, prim1 > 0 ? prim1 : '-');
|
||||||
setAnim(this.hudB.total, t1.totalPoints);
|
setAnim(this.hudB.total, t1.totalPoints);
|
||||||
|
|
||||||
@@ -408,12 +424,37 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private pulseLabel(playerIdx: PlayerIndex): void {
|
private pulseLabel(playerIdx: PlayerIndex): void {
|
||||||
// Reset all
|
// Reset all labels
|
||||||
for (const [idx, lbl] of this.playerLabels) {
|
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 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({
|
this.tweens.add({
|
||||||
targets: lbl,
|
targets: lbl,
|
||||||
scaleX: 1.2, scaleY: 1.2,
|
scaleX: 1.2, scaleY: 1.2,
|
||||||
@@ -548,7 +589,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private doAIMove(playerIdx: PlayerIndex): void {
|
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.aiThinking = false;
|
||||||
this.executeMove(playerIdx, move.card, move.capture);
|
this.executeMove(playerIdx, move.card, move.capture);
|
||||||
}
|
}
|
||||||
@@ -750,6 +791,12 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const oldState = this.state;
|
const oldState = this.state;
|
||||||
this.state = nextState;
|
this.state = nextState;
|
||||||
|
|
||||||
|
// Update card tracker
|
||||||
|
this.tracker.trackPlay(card);
|
||||||
|
if (captureResult) {
|
||||||
|
this.tracker.trackCapture(captureResult.captured);
|
||||||
|
}
|
||||||
|
|
||||||
const cardImg = this.cardImages.get(card.id)!;
|
const cardImg = this.cardImages.get(card.id)!;
|
||||||
cardImg.setDepth(15);
|
cardImg.setDepth(15);
|
||||||
|
|
||||||
@@ -1318,6 +1365,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const startingPlayer = ((nextRound - 1) % 4) as PlayerIndex;
|
const startingPlayer = ((nextRound - 1) % 4) as PlayerIndex;
|
||||||
for (const img of this.cardImages.values()) img.destroy();
|
for (const img of this.cardImages.values()) img.destroy();
|
||||||
this.cardImages.clear();
|
this.cardImages.clear();
|
||||||
|
this.tracker.reset();
|
||||||
this.state = createInitialState(startingPlayer);
|
this.state = createInitialState(startingPlayer);
|
||||||
this.state.teamScores[0].totalPoints = totals[0];
|
this.state.teamScores[0].totalPoints = totals[0];
|
||||||
this.state.teamScores[1].totalPoints = totals[1];
|
this.state.teamScores[1].totalPoints = totals[1];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
|
import { Difficulty } from '../game/types';
|
||||||
|
|
||||||
export class MenuScene extends Phaser.Scene {
|
export class MenuScene extends Phaser.Scene {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -13,7 +14,7 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
this.add.rectangle(0, 0, W, H, 0x1a5c2a).setOrigin(0);
|
this.add.rectangle(0, 0, W, H, 0x1a5c2a).setOrigin(0);
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
this.add.text(W / 2, H * 0.2, 'Scopone Scientifico', {
|
this.add.text(W / 2, H * 0.18, 'Scopone Scientifico', {
|
||||||
fontFamily: 'Georgia, serif',
|
fontFamily: 'Georgia, serif',
|
||||||
fontSize: '52px',
|
fontSize: '52px',
|
||||||
color: '#ffd700',
|
color: '#ffd700',
|
||||||
@@ -22,7 +23,7 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
resolution: 2,
|
resolution: 2,
|
||||||
}).setOrigin(0.5);
|
}).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',
|
fontFamily: 'serif',
|
||||||
fontSize: '22px',
|
fontSize: '22px',
|
||||||
color: '#ccffcc',
|
color: '#ccffcc',
|
||||||
@@ -37,36 +38,63 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
'Prima squadra a 11 punti vince',
|
'Prima squadra a 11 punti vince',
|
||||||
];
|
];
|
||||||
rules.forEach((line, i) => {
|
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',
|
fontFamily: 'serif',
|
||||||
fontSize: '18px',
|
fontSize: '17px',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
resolution: 2,
|
resolution: 2,
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start button
|
// Difficulty selection label
|
||||||
const btn = this.add.rectangle(W / 2, H * 0.72, 220, 60, 0xffd700, 1)
|
this.add.text(W / 2, H * 0.60, 'Scegli la difficoltà:', {
|
||||||
.setInteractive({ useHandCursor: true });
|
|
||||||
const btnText = this.add.text(W / 2, H * 0.72, 'INIZIA PARTITA', {
|
|
||||||
fontFamily: 'Georgia, serif',
|
fontFamily: 'Georgia, serif',
|
||||||
fontSize: '22px',
|
fontSize: '20px',
|
||||||
color: '#1a5c2a',
|
color: '#ffd700',
|
||||||
resolution: 2,
|
resolution: 2,
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
btn.on('pointerover', () => btn.setFillStyle(0xffec6e));
|
// Difficulty buttons
|
||||||
btn.on('pointerout', () => btn.setFillStyle(0xffd700));
|
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', () => {
|
btn.on('pointerdown', () => {
|
||||||
this.cameras.main.fadeOut(300, 0, 30, 0);
|
this.cameras.main.fadeOut(300, 0, 30, 0);
|
||||||
this.cameras.main.once('camerafadeoutcomplete', () => {
|
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||||
this.scene.start('GameScene');
|
this.scene.start('GameScene', { difficulty: d.value });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show some face-down cards decoratively
|
// Show some face-down cards decoratively
|
||||||
const positions = [
|
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) {
|
for (const [x, y] of positions) {
|
||||||
this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15));
|
this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15));
|
||||||
|
|||||||
Reference in New Issue
Block a user