From 38f675eda5ee0541b944d114b6e1e48580764232 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Fri, 10 Apr 2026 23:29:51 +0200 Subject: [PATCH] fix(SCOPONE-0011): complete iteration 0 - tune ai and ui --- docs/FINDINGS.md | 12 +++ src/game/ai-benchmark-fixtures.ts | 54 ++++++----- src/game/ai.ts | 154 ++++++++++++++++++++++++++++-- src/main.ts | 6 ++ src/scenes/GameScene.ts | 102 ++++++++++++++++---- 5 files changed, 275 insertions(+), 53 deletions(-) diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index ed5309c..3f48686 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -143,3 +143,15 @@ Documentation refresh for the current repository state. `docs/ARCHITECTURE.md` a - `src/scenes/GameScene.ts` contains the current pacing and status behavior: `AI_MIN_THINK_MS = 1000`, `MOVE_OUTCOME_STATUS_MS = 2000`, timer-backed status updates, and shutdown cleanup. - `src/game/ai-benchmark.ts` enforces an iteration 5 contract with simulated timing, cross-seed aggregation, dual-loss reporting, and a regression watchlist intersection. - `src/main.ts` imports and registers `SettingsScene` directly. + +### SCOPONE-0011: AI opening, render config, and HUD layout notes (2026-04-10) + +- Source: Context7 `/websites/phaser_io_api-documentation`, query `Phaser 3.87 game config antialias antialiasGL roundPixels pixelArt text resolution sharp text image smoothing camera round pixels texture filtering`. +- Phaser 3.87 render config keeps `antialias` and `antialiasGL` enabled by default, while `pixelArt: true` would forcibly disable antialiasing and enable `roundPixels`; `roundPixels` also snaps texture-based objects to integer positions. +- `src/main.ts` currently relies on Phaser defaults and does not declare an explicit `render` block, so card filtering behavior is not pinned in project code. +- `src/scenes/MenuScene.ts` and `src/scenes/SettingsScene.ts` already use `resolution: 2` text styles broadly. `src/scenes/GameScene.ts` uses `resolution: 2` in score-bar and some label text, but `buildStatusBar()` and several large overlay texts (`SCOPA!`, `7♦`, match-end copy) omit it. +- `src/scenes/GameScene.ts` currently drives card pacing with `PLAYED_CARD_TRAVEL_MS = 400`, `CAPTURE_COLLAPSE_MS = 480`, `NON_CAPTURE_TABLE_TWEEN_MS = 560`, and separate hand/table relayout tweens at `160 ms`. +- `src/scenes/GameScene.ts` places player names with `buildPlayerLabels()` at depth `2`; deal/gameplay cards render at depths `5-15`, and status/dialog overlays render at depths `9-50`, so names can be obscured by both cards and bottom-center status UI. +- `src/game/ai.ts` already concentrates opening-role and duplicate-rank logic in `scoreDumpAdv()`, `scoreCaptureAdv()`, `summarizeMoveTactics()`, `scoreControlOverrideCandidate()`, and `findStrategicControlOverride()`. +- `src/game/ai.ts` calls `findStrategicControlOverride()` in the master path before determinized search, so first-player opening and safe-scopa heuristics must be aligned across both advanced scoring and master root selection. +- `src/game/ai-benchmark-fixtures.ts` currently includes 13 fixtures and 6 critical concepts; it has coverage for `full-table-scopa`, `anti-scopa-safe-dump`, and `only-safe-release`, but no fixture isolates the new "safe scopa when there is no repercussion" rule or the new first-hand duplicate-rank opening preference on an empty/unknown table. diff --git a/src/game/ai-benchmark-fixtures.ts b/src/game/ai-benchmark-fixtures.ts index 318d388..4f80209 100644 --- a/src/game/ai-benchmark-fixtures.ts +++ b/src/game/ai-benchmark-fixtures.ts @@ -245,24 +245,26 @@ const RAW_FIXTURES: RawFixture[] = [ }, }, { - id: 'safe-low-dump', - name: 'Safe Low Dump', - description: 'The search should prefer the lone safe release over cards that either capture or create leverage for the next player.', - tags: ['table-control'], - dealer: 2, + id: 'safe-scopa-conversion', + name: 'Safe Scopa Conversion', + description: 'When a clean sweep is available, the search should take the safe scopa instead of settling for a smaller direct capture.', + tags: ['safe-scopa', 'scopa-window'], + dealer: 3, currentPlayer: 0, handSizes: [5, 5, 5, 5], hands: [[ - 'coppe_10', - 'spade_8', - 'bastoni_6', - 'denara_4', - 'coppe_3', + 'bastoni_10', + 'denara_6', + 'coppe_9', + 'bastoni_3', + 'spade_2', ], undefined, undefined, undefined], - table: ['denara_2', 'spade_9', 'bastoni_4', 'coppe_5'], - piles: PILES_TEMPLATE_A, + table: ['coppe_4', 'spade_6'], + pileCardCounts: [5, 4, 5, 4], + totalPoints: [8, 8], expectedMove: { - cardId: 'coppe_3', + cardId: 'bastoni_10', + captureIds: ['coppe_4', 'spade_6'], }, }, { @@ -337,25 +339,25 @@ const RAW_FIXTURES: RawFixture[] = [ }, }, { - id: 'direct-eight-conversion', - name: 'Direct Eight Conversion', - description: 'The direct eight capture should be preferred when it removes the strongest immediate counter-card from the table.', - tags: ['material-swing'], - dealer: 2, + id: 'duplicate-rank-opening-release', + name: 'Duplicate Rank Opening Release', + description: 'On an empty opening table, the search should release the non-denari duplicate high rank instead of opening with weaker denari or singleton alternatives.', + tags: ['opening-release', 'duplicate-rank'], + dealer: 3, currentPlayer: 0, handSizes: [5, 5, 5, 5], hands: [[ - 'coppe_10', - 'spade_5', + 'coppe_8', 'denara_8', - 'bastoni_9', - 'coppe_3', + 'denara_7', + 'denara_6', + 'bastoni_2', ], undefined, undefined, undefined], - table: ['denara_2', 'coppe_8', 'bastoni_4', 'spade_9'], - piles: PILES_TEMPLATE_A, + table: [], + pileCardCounts: [5, 5, 5, 5], + totalPoints: [7, 7], expectedMove: { - cardId: 'denara_8', - captureIds: ['coppe_8'], + cardId: 'coppe_8', }, }, ]; diff --git a/src/game/ai.ts b/src/game/ai.ts index 5514101..5d10527 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -721,6 +721,72 @@ function getPriorityThreatSummary( return countScopaThreats(afterTable, myHand, tracker, state, playerIdx); } +function isImmediateTacticalConcession( + afterTable: Card[], + nextIsOpp: boolean, + threats: ScopaThreatSummary | null, +): boolean { + if (!nextIsOpp || afterTable.length === 0) return false; + + const tableSum = sumCardValues(afterTable); + if (afterTable.length === 1 && tableSum <= 10) return true; + if (afterTable.length === 2 && tableSum <= 10) return true; + + return Boolean(threats?.nextOppCanScopa); +} + +function evaluateSafeScopaPriority( + clearsTable: boolean, + afterTable: Card[], + lastPlay: boolean, + nextIsOpp: boolean, + threats: ScopaThreatSummary | null, +): number { + if (!clearsTable || lastPlay) return 0; + return isImmediateTacticalConcession(afterTable, nextIsOpp, threats) ? 1 : 2; +} + +function evaluateFirstHandOpeningReleasePriority( + card: Card, + myHand: Card[], + projectedHand: Card[], + afterTable: Card[], + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + nextIsOpp: boolean, + roleContext: DealerRoleContext, +): number { + if (!nextIsOpp || roleContext.role !== 'first-hand' || afterTable.length !== 1) { + return 0; + } + + const nextHandSize = state.players[nextPlayer(playerIdx)].hand.length; + if (nextHandSize <= 0) return 0; + + const sameValueCount = countValueInHand(myHand, card.value); + const immediateScopaRisk = handLikelyHasValue( + card.value, + nextHandSize, + state, + playerIdx, + tracker, + projectedHand, + afterTable, + ); + + let score = 0; + score += Math.max(0, sameValueCount - 1) * 2; + if (sameValueCount >= 3) score += 2; + score += Math.round((0.32 - immediateScopaRisk) * 12); + + if (sameValueCount >= 2 && card.value >= 8 && card.suit !== 'denara') score += 1; + if (card.suit === 'denara') score -= 1; + if (card.value === 7) score -= 1; + + return clampPriorityBand(score, -8, 8); +} + function evaluateAntiScopaPriority( afterTable: Card[], nextIsOpp: boolean, @@ -1062,10 +1128,12 @@ function scoreCaptureAdv( ): number { const allCaptured = [played, ...captured]; const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); + const projectedHand = myHand.filter(card => card.id !== played.id); const isScopa = afterTable.length === 0; const tableHasSettebello = table.some(c => c.suit === 'denara' && c.value === 7); const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); - const threats = getPriorityThreatSummary(afterTable, myHand, tracker, state, playerIdx); + const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx); + const scopaPriority = evaluateSafeScopaPriority(isScopa, afterTable, lastPlay, nextIsOpp, threats); let material = 30 + captured.length * (race.behindInCards ? 16 : 10) + phase * captured.length * 6; material += allCaptured.filter(c => c.suit === 'denara').length * (race.behindInDenari ? 14 : 8); @@ -1109,7 +1177,7 @@ function scoreCaptureAdv( if (roleContext.role === 'dealer' && !isScopa && sumCardValues(afterTable) >= 11) material += 10; return scoreTacticalPriorityLadder({ - scopa: isScopa && !lastPlay ? 2 : isScopa ? 0 : 0, + scopa: scopaPriority, settebello: capturesSettebello ? 4 : tableHasSettebello && nextIsOpp ? -4 : tableHasSettebello ? -2 : 0, antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), partnerSetup: isScopa ? 0 : evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), @@ -1126,10 +1194,22 @@ function scoreDumpAdv( lastPlay: boolean, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null, ): number { const afterTable = [...table, card]; + const projectedHand = myHand.filter(held => held.id !== card.id); // --- HARD RULES --- if (card.suit === 'denara' && card.value === 7) return -10000; - const threats = getPriorityThreatSummary(afterTable, myHand, tracker, state, playerIdx); + const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx); + const openingReleasePriority = evaluateFirstHandOpeningReleasePriority( + card, + myHand, + projectedHand, + afterTable, + state, + playerIdx, + tracker, + nextIsOpp, + roleContext, + ); let material = -20 + phase * 6; if (card.suit === 'denara') material -= race.behindInDenari ? 28 : 16; @@ -1186,7 +1266,7 @@ function scoreDumpAdv( return scoreTacticalPriorityLadder({ scopa: 0, settebello: 0, - antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), + antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats) + openingReleasePriority, partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, race.need7s), denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, race.behindInDenari), @@ -1947,18 +2027,37 @@ function scoreControlOverrideCandidate( playerIdx: PlayerIndex, race: RaceState, roleContext: DealerRoleContext, + tracker: CardTracker | undefined, ): number { const hand = state.players[playerIdx].hand; const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + const lastPlay = isLastPlay(state, playerIdx); const summary = summarizeMoveTactics(move, hand, state.table); const projectedHand = hand.filter(card => card.id !== move.card.id); + const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); + const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats); + const openingReleasePriority = move.capture.length === 0 + ? evaluateFirstHandOpeningReleasePriority( + move.card, + hand, + projectedHand, + summary.projectedTable, + state, + playerIdx, + tracker, + nextIsOpp, + roleContext, + ) + : 0; let score = Math.round(scoreHandStructure(projectedHand, summary.projectedTable, roleContext) * 0.55); score += summary.projectedTable.length * 48; score += summary.tableSum >= 11 ? 90 + summary.tableSum * 8 : -260; + score += scopaPriority * 600; if (move.capture.length === 0) { if (summary.highQuietRelease) score += 220; + score += openingReleasePriority * 180; if (move.card.suit !== 'denara' && move.card.value <= 3) score += roleContext.defendingDealerAdvantage ? 260 : 70; if (nextIsOpp && summary.projectedTable.length >= 5) score += 110; if ( @@ -1972,6 +2071,9 @@ function scoreControlOverrideCandidate( } } else { if (!isForcingSearchMove(summary, race)) score -= 200; + if (!summary.clearsTable && isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats)) { + score -= 180; + } if (nextIsOpp && summary.projectedTable.length <= 3) score -= 150; if (nextIsOpp) score -= summary.exposedDenariCount * 90; if (nextIsOpp) score -= summary.exposedSevenCount * 70; @@ -2004,9 +2106,11 @@ function findStrategicControlOverride( playerIdx: PlayerIndex, race: RaceState, roleContext: DealerRoleContext, + tracker: CardTracker | undefined, ): AIMove | undefined { if (legalMoves.length <= 1) return undefined; - if (isLastPlay(state, playerIdx)) return undefined; + const lastPlay = isLastPlay(state, playerIdx); + if (lastPlay) return undefined; if (!isOpponent(playerIdx, nextPlayer(playerIdx))) return undefined; let bestQuiet: @@ -2015,9 +2119,21 @@ function findStrategicControlOverride( let bestCapture: | { move: AIMove; score: number } | undefined; + let bestSafeScopa: + | { move: AIMove; score: number } + | undefined; for (const move of legalMoves) { - const score = scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext); + const score = scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker); + const summary = summarizeMoveTactics(move, state.players[playerIdx].hand, state.table); + const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== move.card.id); + const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); + const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, true, threats); + + if (scopaPriority > 0) { + if (!bestSafeScopa || score > bestSafeScopa.score) bestSafeScopa = { move, score }; + } + if (move.capture.length === 0) { if (!bestQuiet || score > bestQuiet.score) bestQuiet = { move, score }; continue; @@ -2026,6 +2142,8 @@ function findStrategicControlOverride( if (!bestCapture || score > bestCapture.score) bestCapture = { move, score }; } + if (bestSafeScopa) return bestSafeScopa.move; + if (!bestQuiet) return undefined; const quietSummary = summarizeMoveTactics(bestQuiet.move, state.players[playerIdx].hand, state.table); @@ -2216,7 +2334,7 @@ async function masterMove( roleContext, rankResidue, ); - const controlOverride = findStrategicControlOverride(legalMoves, state, playerIdx, race, roleContext); + const controlOverride = findStrategicControlOverride(legalMoves, state, playerIdx, race, roleContext, tracker); if (controlOverride) { reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { cardsRemaining, @@ -2395,13 +2513,28 @@ function quickEval( const projectedTableHasDenari = projectedTable.some(card => card.suit === 'denara'); const projectedTableHasSeven = projectedTable.some(card => card.value === 7); const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + const threats = getPriorityThreatSummary(projectedTable, projectedHand, tracker, state, playerIdx); + const scopaPriority = evaluateSafeScopaPriority(moveSummary.clearsTable, projectedTable, lastPlay, nextIsOpp, threats); + const openingReleasePriority = move.capture.length === 0 + ? evaluateFirstHandOpeningReleasePriority( + move.card, + hand, + projectedHand, + projectedTable, + state, + playerIdx, + tracker, + nextIsOpp, + roleContext, + ) + : 0; const capturesSettebello = move.capture.some(card => card.suit === 'denara' && card.value === 7); const tableHasSettebello = table.some(card => card.suit === 'denara' && card.value === 7); const tableHasDenari = table.some(card => card.suit === 'denara'); // Scopa (not on last play!) if (move.capture.length > 0 && projectedTable.length === 0) { - score += lastPlay ? 50 : 1200; + score += lastPlay ? 50 : scopaPriority * 780; } // Settebello @@ -2430,6 +2563,7 @@ function quickEval( if (move.capture.length === 0) { score -= 200; + score += openingReleasePriority * 220; if (move.card.value >= 8) score += 40; if (move.card.suit === 'denara') score -= 130; if (move.card.value === 7) score -= 100; @@ -2480,6 +2614,10 @@ function quickEval( } } + if (move.capture.length > 0 && !moveSummary.clearsTable && isImmediateTacticalConcession(projectedTable, nextIsOpp, threats)) { + score -= 180; + } + // Partner awareness const next = nextPlayer(playerIdx); if (!isOpponent(playerIdx, next) && projectedTable.length > 0) { diff --git a/src/main.ts b/src/main.ts index 72b7e99..a988c7a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -50,6 +50,12 @@ const config: Phaser.Types.Core.GameConfig = { backgroundColor: '#1a5c2a', parent: 'game', scene: [BootScene, MenuScene, GameScene, SettingsScene], + render: { + antialias: true, + antialiasGL: true, + pixelArt: false, + roundPixels: false, + }, scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index d3d2b64..9a326cd 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -37,10 +37,11 @@ const CH_A = 645 * CARD_SCALE_AI; // card height for AI ≈ 81 const SCOREBAR_H = 54; const AI_MIN_THINK_MS = 1000; const MOVE_OUTCOME_STATUS_MS = 2000; -const PLAYED_CARD_TRAVEL_MS = 400; -const CAPTURE_COLLAPSE_MS = 480; +const PLAYED_CARD_TRAVEL_MS = 320; +const CAPTURE_COLLAPSE_MS = 360; const CAPTURE_COLLAPSE_DELAY_MS = 60; -const NON_CAPTURE_TABLE_TWEEN_MS = 560; +const NON_CAPTURE_TABLE_TWEEN_MS = 420; +const RELAYOUT_TWEEN_MS = 120; // Player positions: // 0 = South (human, bottom), 1 = West (AI, left, rotated -90°) @@ -93,7 +94,7 @@ export class GameScene extends Phaser.Scene { private thinkBar!: Phaser.GameObjects.Graphics; // Player label containers (pulsed on active turn) - private playerLabels: Map = new Map(); + private playerLabels: Map = new Map(); // Interaction state private selectedCard: Card | null = null; @@ -388,6 +389,7 @@ export class GameScene extends Phaser.Scene { this.statusText = this.add.text(W / 2, H - CH_H - 36, '', { fontFamily: 'serif', fontSize: '17px', color: '#ffffff', stroke: '#000', strokeThickness: 2, + resolution: 2, }).setOrigin(0.5).setDepth(10); } @@ -524,23 +526,59 @@ export class GameScene extends Phaser.Scene { return `Problema durante la mossa di ${this.state.players[playerIdx].name}.`; } + private withHiResText( + style: Phaser.Types.GameObjects.Text.TextStyle, + ): Phaser.Types.GameObjects.Text.TextStyle { + return { + ...style, + resolution: style.resolution ?? 2, + }; + } + + private createPlayerNameplate( + x: number, + y: number, + text: string, + color: string, + fillColor: number, + strokeColor: number, + ): Phaser.GameObjects.Container { + const label = this.add.text(0, 0, text, this.withHiResText({ + fontFamily: 'serif', + fontSize: '11px', + color, + stroke: '#000', + strokeThickness: 1, + align: 'center', + })).setOrigin(0.5); + + const padX = 12; + const padY = 4; + const width = label.width + padX * 2; + const height = label.height + padY * 2; + const background = this.add.graphics(); + background.fillStyle(fillColor, 0.92); + background.fillRoundedRect(-width / 2, -height / 2, width, height, 9); + background.lineStyle(1, strokeColor, 0.7); + background.strokeRoundedRect(-width / 2, -height / 2, width, height, 9); + + return this.add.container(x, y, [background, label]).setDepth(18); + } + // --------------------------------------------------------------------------- // Player labels (pulse on active turn) // --------------------------------------------------------------------------- private buildPlayerLabels(W: number, H: number): void { const defs: Array<{ idx: PlayerIndex; x: number; y: number; color: string; - txt: string; originX: number; originY: number }> = [ - { idx: 0, x: W / 2, y: H - CH_H - 28, color: '#aaffaa', txt: 'Tu [Team A]', originX: 0.5, originY: 1 }, - { idx: 1, x: CH_A + 20, y: H / 2 + SCOREBAR_H / 2, color: '#ffaaaa', txt: 'AI\nOvest\n[B]', originX: 0, originY: 0.5 }, - { idx: 2, x: W / 2, y: SCOREBAR_H + CH_A + 44, color: '#aaffaa', txt: 'Compagno [Team A]', originX: 0.5, originY: 0 }, - { idx: 3, x: W - CH_A - 20, y: H / 2 + SCOREBAR_H / 2, color: '#ffaaaa', txt: 'AI\nEst\n[B]', originX: 1, originY: 0.5 }, + fillColor: number; strokeColor: number; txt: string }> = [ + { idx: 0, x: W / 2, y: H - 9, color: '#dfffe5', fillColor: 0x0b2410, strokeColor: 0x4aa86a, txt: 'Tu [Team A]' }, + { idx: 1, x: CH_A + 58, y: H / 2 + SCOREBAR_H / 2, color: '#ffe0e0', fillColor: 0x260d0d, strokeColor: 0xb36a6a, txt: 'AI Ovest [B]' }, + { idx: 2, x: W / 2, y: SCOREBAR_H + 11, color: '#dfffe5', fillColor: 0x0b2410, strokeColor: 0x4aa86a, txt: 'Compagno [Team A]' }, + { idx: 3, x: W - CH_A - 58, y: H / 2 + SCOREBAR_H / 2, color: '#ffe0e0', fillColor: 0x260d0d, strokeColor: 0xb36a6a, txt: 'AI Est [B]' }, ]; for (const d of defs) { - const lbl = this.add.text(d.x, d.y, d.txt, { - fontFamily: 'serif', fontSize: '12px', color: d.color, - stroke: '#000', strokeThickness: 1, align: 'center', resolution: 2, - }).setOrigin(d.originX, d.originY).setDepth(2); + const lbl = this.createPlayerNameplate(d.x, d.y, d.txt, d.color, d.fillColor, d.strokeColor); this.playerLabels.set(d.idx, lbl); } } @@ -594,7 +632,7 @@ export class GameScene extends Phaser.Scene { const activeLbl = this.playerLabels.get(playerIdx)!; this.tweens.add({ targets: activeLbl, - scaleX: 1.2, scaleY: 1.2, + scaleX: 1.08, scaleY: 1.08, duration: 300, yoyo: true, ease: 'Sine.InOut', }); } @@ -929,6 +967,7 @@ export class GameScene extends Phaser.Scene { const btn = this.add.zone(W / 2, y, 360, 28).setInteractive({ useHandCursor: true }).setDepth(21); const txt = this.add.text(W / 2, y, `Prendi: ${label}`, { fontFamily: 'serif', fontSize: '14px', color: color.text, + resolution: 2, }).setOrigin(0.5).setDepth(21); btn.on('pointerdown', () => this.confirmMove(this.selectedCard!, cap)); (bg as any)._captureBtn = true; @@ -1113,7 +1152,15 @@ export class GameScene extends Phaser.Scene { const positions = this.getHandPositions(playerIdx, hand.length); hand.forEach((card, i) => { const img = this.cardImages.get(card.id); - if (img) this.tweens.add({ targets: img, x: positions[i].x, y: positions[i].y, duration: 160 }); + if (img) { + this.tweens.add({ + targets: img, + x: positions[i].x, + y: positions[i].y, + duration: RELAYOUT_TWEEN_MS, + ease: 'Cubic.Out', + }); + } }); } @@ -1123,7 +1170,15 @@ export class GameScene extends Phaser.Scene { const positions = this.getTablePositions(table.length); table.forEach((card, i) => { const img = this.cardImages.get(card.id); - if (img?.visible) this.tweens.add({ targets: img, x: positions[i].x, y: positions[i].y, duration: 160 }); + if (img?.visible) { + this.tweens.add({ + targets: img, + x: positions[i].x, + y: positions[i].y, + duration: RELAYOUT_TWEEN_MS, + ease: 'Cubic.Out', + }); + } }); } @@ -1196,12 +1251,14 @@ export class GameScene extends Phaser.Scene { const txt = this.add.text(W / 2, H / 2, 'SCOPA!', { fontFamily: 'Georgia, serif', fontSize: '108px', color: '#ffd700', stroke: '#000000', strokeThickness: 10, + resolution: 2, }).setOrigin(0.5).setDepth(50).setAlpha(0).setScale(0.2); const sub = this.add.text(W / 2, H / 2 + 110, player.name, { fontFamily: 'serif', fontSize: '32px', color: isTeamA ? '#aaffaa' : '#ffaaaa', stroke: '#000', strokeThickness: 3, + resolution: 2, }).setOrigin(0.5).setDepth(50).setAlpha(0); this.tweens.add({ @@ -1257,6 +1314,7 @@ export class GameScene extends Phaser.Scene { const txt = this.add.text(W / 2, H / 2 - 60, '7♦', { fontFamily: 'Georgia, serif', fontSize: '64px', color: '#ffd700', stroke: '#000', strokeThickness: 7, + resolution: 2, }).setOrigin(0.5).setDepth(50).setAlpha(0).setScale(0.4); this.tweens.add({ @@ -1497,10 +1555,11 @@ export class GameScene extends Phaser.Scene { ]; lines.forEach(([line, color], i) => { - this.add.text(W / 2, H / 2 - 190 + i * 34, line, { + this.add.text(W / 2, H / 2 - 190 + i * 34, line, this.withHiResText({ fontFamily: i === 0 ? 'Georgia, serif' : 'monospace', - fontSize: i === 0 ? '28px' : '16px', color, - }).setOrigin(0.5).setDepth(32); + fontSize: i === 0 ? '28px' : '16px', + color, + })).setOrigin(0.5).setDepth(32); }); const outcome = getMatchOutcome(this.state.teamScores); @@ -1514,6 +1573,7 @@ export class GameScene extends Phaser.Scene { .setInteractive({ useHandCursor: true }).setDepth(33); this.add.text(W / 2, H / 2 + 207, btnLabel, { fontFamily: 'Georgia, serif', fontSize: '20px', color: '#0a2e10', + resolution: 2, }).setOrigin(0.5).setDepth(34); btnG.on('pointerover', () => { btnG.clear(); btnG.fillStyle(0xffec6e, 1); btnG.fillRoundedRect(W / 2 - 110, H / 2 + 185, 220, 44, 10); }); @@ -1556,13 +1616,16 @@ export class GameScene extends Phaser.Scene { this.add.text(W / 2, H / 2 - 110, 'PARTITA CONCLUSA', { fontFamily: 'Georgia, serif', fontSize: '44px', color: '#ffd700', stroke: '#000', strokeThickness: 6, + resolution: 2, }).setOrigin(0.5).setDepth(42); this.add.text(W / 2, H / 2 - 30, win ? 'Vince la tua squadra' : 'Vincono gli avversari', { fontFamily: 'serif', fontSize: '26px', color: win ? '#aaffaa' : '#ffaaaa', + resolution: 2, }).setOrigin(0.5).setDepth(42); this.add.text(W / 2, H / 2 + 35, `${t0.totalPoints} — ${t1.totalPoints}`, { fontFamily: 'Georgia, serif', fontSize: '50px', color: '#ffd700', + resolution: 2, }).setOrigin(0.5).setDepth(42); const bz = this.add.zone(W / 2, H / 2 + 115, 230, 48) @@ -1572,6 +1635,7 @@ export class GameScene extends Phaser.Scene { drawBtn(0xffd700); this.add.text(W / 2, H / 2 + 115, 'NUOVA PARTITA', { fontFamily: 'Georgia, serif', fontSize: '21px', color: '#0a2e10', + resolution: 2, }).setOrigin(0.5).setDepth(44); bz.on('pointerover', () => drawBtn(0xffec6e)); bz.on('pointerout', () => drawBtn(0xffd700));