feat(SCOPONE-0010): improve capture pacing and settings

This commit is contained in:
Giancarmine Salucci
2026-04-09 23:00:59 +02:00
parent 77ab1f43a6
commit c107489b0a
7 changed files with 740 additions and 176 deletions

View File

@@ -1,6 +1,6 @@
# Architecture # Architecture
> Last Updated: 2026-04-08T19:48:08.000Z > Last Updated: 2026-04-09T20:59:51.000Z
## Overview ## Overview
@@ -8,14 +8,15 @@
|-----------|-------| |-----------|-------|
| Primary language | TypeScript | | Primary language | TypeScript |
| Secondary language | Java | | Secondary language | Java |
| Source counts | 15 TypeScript files under `src/`, 3 Java files under `android/` |
| Project type | Phaser browser game packaged for Android with Capacitor | | Project type | Phaser browser game packaged for Android with Capacitor |
| Framework | Phaser 3.87.0 | | Framework | Phaser 3.87.0 |
| Tooling | Vite 5, TypeScript 5.x, Capacitor 8.3 | | Tooling | Vite 5, TypeScript 5.x, Capacitor 8.3, tsx 4.19 |
| Runtime layout | 1280 x 720, `Phaser.Scale.FIT`, centered in `#game` | | Runtime layout | 1280 x 720, `Phaser.Scale.FIT`, centered in `#game` |
| Build command | `npm run build` | | Build command | `npm run build` |
| Test command | `npx tsc --noEmit` | | Test command | `npx tsc --noEmit` |
The repository is a TypeScript-first implementation of Scopone Scientifico. Gameplay rules, scoring, inference, and AI live in framework-independent modules under `src/game/`. Phaser scenes under `src/scenes/` own rendering, input, UI, animation, and scene transitions. The `android/` tree is a Capacitor wrapper with a small custom `MainActivity` for immersive full-screen behavior. The repository is a TypeScript-first implementation of Scopone Scientifico. Pure game rules, scoring, imperfect-information tracking, AI heuristics, worker transport, and benchmark code live under `src/game/`. Phaser scenes under `src/scenes/` own rendering, input, animation, menu flow, status messaging, and procedural audio. The `android/` tree is a Capacitor wrapper with a small custom `MainActivity` that forces immersive full-screen behavior.
## Project Structure ## Project Structure
@@ -27,13 +28,17 @@ scopone-phaser/
| | |- types.ts | | |- types.ts
| | |- engine.ts | | |- engine.ts
| | |- card-tracker.ts | | |- card-tracker.ts
| | |- preferences.ts
| | |- ai.ts | | |- ai.ts
| | |- ai-worker-protocol.ts | | |- ai-worker-protocol.ts
| | |- ai-worker-client.ts | | |- ai-worker-client.ts
| | `- ai.worker.ts | | |- ai.worker.ts
| | |- ai-benchmark.ts
| | `- ai-benchmark-fixtures.ts
| `- scenes/ | `- scenes/
| |- BootScene.ts | |- BootScene.ts
| |- MenuScene.ts | |- MenuScene.ts
| |- SettingsScene.ts
| `- GameScene.ts | `- GameScene.ts
|- public/ |- public/
|- android/ |- android/
@@ -51,9 +56,9 @@ scopone-phaser/
| Directory | Purpose | | Directory | Purpose |
|-----------|---------| |-----------|---------|
| `src/game/` | Rules engine, score calculation, imperfect-information tracking, AI heuristics, and master search | | `src/game/` | Rules engine, score calculation, imperfect-information tracking, audio preference persistence, AI heuristics, worker transport, and benchmark harnesses |
| `src/scenes/` | Phaser scene lifecycle, menus, board rendering, interaction, HUD, audio, and FX | | `src/scenes/` | Phaser scene lifecycle, menus, settings UI, board rendering, interaction, HUD, audio, and FX |
| `public/` | Atlas metadata and other static assets loaded by Phaser | | `public/` | Atlas metadata and static assets loaded by Phaser |
| `android/` | Capacitor Android project, Gradle configuration, generated wrapper assets, and the native activity | | `android/` | Capacitor Android project, Gradle configuration, generated wrapper assets, and the native activity |
| `docs/` | Architecture, code style, findings, and cache metadata | | `docs/` | Architecture, code style, findings, and cache metadata |
| `prompts/` | JIRA workflow artifacts and iteration state | | `prompts/` | JIRA workflow artifacts and iteration state |
@@ -66,46 +71,53 @@ Observed architectural patterns:
| Pattern | Where it appears | | Pattern | Where it appears |
|---------|------------------| |---------|------------------|
| Scene-based flow | `BootScene -> MenuScene -> GameScene` via Phaser scene registration | | Scene-based flow | `BootScene -> MenuScene -> GameScene`, with `SettingsScene` opened from the menu and returning to it |
| Functional core / imperative shell | `src/game/` avoids Phaser imports while `src/scenes/` owns runtime side effects | | Functional core / imperative shell | `src/game/` avoids Phaser imports while `src/scenes/` owns runtime side effects |
| Immutable state transitions | `applyMove()` clones `GameState` before mutating round state | | Immutable state transitions | `applyMove()` clones `GameState` before mutating round state |
| Worker offload with fallback | `AIWorkerClient` uses `ai.worker.ts` when available and falls back to direct `chooseMove()` otherwise | | Worker offload with fallback | `AIWorkerClient` uses `ai.worker.ts` when available and falls back to direct `chooseMove()` otherwise |
| Typed message protocol | `ai-worker-protocol.ts` defines worker request, progress, result, and error shapes | | Typed message protocol | `ai-worker-protocol.ts` defines worker request, progress, result, and serialized error shapes |
| Imperfect-information search | `CardTracker` plus determinization sampling support the `master` AI tier | | Persistence adapter | `preferences.ts` normalizes and stores audio settings through a storage boundary instead of scene-local flags |
| Deterministic benchmark harness | `ai-benchmark.ts` uses fixtures, seeded self-play, and simulated timing sources to evaluate AI quality |
## Key Components ## Key Components
### `src/main.ts` ### `src/main.ts`
- Creates the `Phaser.Game` instance. - Creates the `Phaser.Game` instance.
- Registers `BootScene`, `MenuScene`, and `GameScene`.
- Installs a one-shot fullscreen request on first user input when supported. - Installs a one-shot fullscreen request on first user input when supported.
- Registers `BootScene`, `MenuScene`, `GameScene`, and a local `SettingsScene` placeholder; `MenuScene` replaces that placeholder with the concrete `src/scenes/SettingsScene.ts` class before navigation.
### `src/game/types.ts` ### `src/game/types.ts`
- Defines the core game model: `Card`, `Capture`, `Player`, `GameState`, `TeamScore`, and `ScoreBreakdown`. - Defines the core game model: `Card`, `Capture`, `Player`, `GameState`, `TeamScore`, and `ScoreBreakdown`.
- Models constrained domains with unions such as `PlayerIndex` and `Difficulty`. - Models constrained domains with unions such as `PlayerIndex`, `Difficulty`, and `DealerRelativeRole`.
- Stores `PRIMIERA_VALUES` for end-of-round scoring. - Stores `PRIMIERA_VALUES` for end-of-round scoring.
### `src/game/engine.ts` ### `src/game/engine.ts`
- Builds and shuffles the 40-card deck. - Builds and shuffles the 40-card deck.
- Creates a round state for four players with dealer-relative opening order. - Creates a round state for four players with dealer-relative opening order and stable player labels.
- Implements capture rules where direct value matches take priority over subset-sum captures. - Implements Scopone capture rules where direct value matches take priority over subset-sum captures.
- Applies moves immutably, awards scope, assigns leftover table cards, and computes round and match scoring. - Applies moves immutably, awards scopa only before the final play, assigns leftover table cards to the last capturing team, and computes round and match scoring.
### `src/game/card-tracker.ts` ### `src/game/card-tracker.ts`
- Tracks cards visible through play and capture events without exposing hidden hands. - Tracks cards visible through play and capture events without exposing hidden hands.
- Reconstructs unseen cards from `played + myHand + table`. - Reconstructs unseen cards from `played + myHand + table`.
- Supplies value and suit residue helpers used by AI inference and probability estimates. - Supplies suit counts, same-rank residue summaries, and hand-value probabilities used by the AI tiers.
### `src/game/preferences.ts`
- Defines the persisted audio preference model.
- Normalizes possibly invalid storage payloads back to safe defaults.
- Loads and saves preferences through `localStorage` when available, with browser-safe fallbacks.
### `src/game/ai.ts` ### `src/game/ai.ts`
- Exposes `chooseMove()` as the async AI entry point. - Exposes `chooseMove()` as the async AI entry point.
- Implements three difficulty tiers: `beginner`, `advanced`, and `master`. - Implements three difficulty tiers: `beginner`, `advanced`, and `master`.
- Uses table-driven search profiles, role-aware heuristics, tracker-based inference, and determinization plus alpha-beta search. - Uses role-aware heuristics, tracker-based inference, tactical priority scoring, determinization sampling, and alpha-beta search.
- Configures the current master profile with a 4600 ms budget, 10 samples, depth 6, and batch size 2. - Applies dynamic master search profiles based on total cards remaining, ranging from the base profile `4300 ms / 8 samples / depth 5 / batch 2` down to `3200 ms / 4 samples / exact endgame depth / batch 1` in the last four cards.
### `src/game/ai-worker-protocol.ts` ### `src/game/ai-worker-protocol.ts`
@@ -117,7 +129,7 @@ Observed architectural patterns:
- Wraps worker lifecycle and pending-request tracking behind the same `chooseMove()` API that scenes consume. - Wraps worker lifecycle and pending-request tracking behind the same `chooseMove()` API that scenes consume.
- Creates the worker as an ES module with `new Worker(new URL('./ai.worker.ts', import.meta.url), { type: 'module' })`. - Creates the worker as an ES module with `new Worker(new URL('./ai.worker.ts', import.meta.url), { type: 'module' })`.
- Fails over pending requests to in-thread AI execution if worker creation, messaging, or deserialization fails. - Fails over pending requests to in-thread AI execution if worker creation, posting, or deserialization fails.
### `src/game/ai.worker.ts` ### `src/game/ai.worker.ts`
@@ -125,6 +137,12 @@ Observed architectural patterns:
- Delegates move selection to `chooseMove()`. - Delegates move selection to `chooseMove()`.
- Posts progress, result, or serialized error messages back to the main thread. - Posts progress, result, or serialized error messages back to the main thread.
### `src/game/ai-benchmark.ts` and `src/game/ai-benchmark-fixtures.ts`
- Define the AI quality harness invoked by `npm run benchmark:ai-quality`.
- Combine fixed fixtures, critical-concept checks, seeded self-play, regression watchlists, and simulated timing sources.
- Encode the current iteration 5 benchmark contract: 13 fixed fixtures, 6 critical concepts, and 48 self-play matches.
### `src/scenes/BootScene.ts` ### `src/scenes/BootScene.ts`
- Loads the card atlas and card back. - Loads the card atlas and card back.
@@ -133,13 +151,22 @@ Observed architectural patterns:
### `src/scenes/MenuScene.ts` ### `src/scenes/MenuScene.ts`
- Renders the title, rules summary, and difficulty selection. - Renders the title, compact rules summary, difficulty selection, and audio-settings entry point.
- Starts `GameScene` with the chosen difficulty. - Reads persisted audio preferences to describe current state before match start.
- Ensures the concrete `SettingsScene` class is registered before opening it.
### `src/scenes/SettingsScene.ts`
- Provides a dedicated audio settings surface.
- Toggles music and effects independently.
- Saves each change immediately and returns control to the menu scene.
### `src/scenes/GameScene.ts` ### `src/scenes/GameScene.ts`
- Owns match flow, dealing, selection, capture resolution, AI turn orchestration, score HUD, status UI, think bar, particles, and procedural audio. - Owns match flow, dealing, selection, capture resolution, AI turn orchestration, score HUD, status UI, think bar, particles, and procedural audio.
- Instantiates and disposes `AIWorkerClient` on scene lifecycle events. - Instantiates and disposes `AIWorkerClient` on scene lifecycle events.
- Enforces a minimum AI think display time and timer-based move outcome status messages.
- Reads normalized audio preferences from scene data or persisted storage.
- Updates `CardTracker` after play and capture events so AI inference remains derived from visible information. - Updates `CardTracker` after play and capture events so AI inference remains derived from visible information.
### `android/app/src/main/java/com/phaser/scopa/MainActivity.java` ### `android/app/src/main/java/com/phaser/scopa/MainActivity.java`
@@ -163,6 +190,7 @@ Observed architectural patterns:
| Package | Version | Purpose | | Package | Version | Purpose |
|---------|---------|---------| |---------|---------|---------|
| `tsx` | `^4.19.2` | TypeScript execution for benchmark and prompt-local tooling |
| `typescript` | `^5.0.0` | Static type checking and TS compilation step | | `typescript` | `^5.0.0` | Static type checking and TS compilation step |
| `vite` | `^5.0.0` | Dev server and production bundler | | `vite` | `^5.0.0` | Dev server and production bundler |
@@ -191,14 +219,21 @@ Observed architectural patterns:
main.ts main.ts
-> BootScene -> BootScene
-> MenuScene -> MenuScene
-> GameScene -> SettingsScene (opened on demand after dynamic registration)
-> engine.ts -> GameScene
-> types.ts -> engine.ts
-> card-tracker.ts -> types.ts
-> ai-worker-client.ts -> preferences.ts
-> ai-worker-protocol.ts -> card-tracker.ts
-> ai.worker.ts -> ai-worker-client.ts
-> ai.ts -> ai-worker-protocol.ts
-> ai.worker.ts
-> ai.ts
ai-benchmark.ts
-> ai.ts
-> ai-benchmark-fixtures.ts
-> engine.ts
-> card-tracker.ts
``` ```
Application-level dependency direction is one-way: Application-level dependency direction is one-way:
@@ -209,16 +244,18 @@ Application-level dependency direction is one-way:
## Data Flow ## Data Flow
1. `main.ts` creates the Phaser app and registers all scenes. 1. `main.ts` creates the Phaser app, installs fullscreen-on-first-input, and registers the scene list.
2. `BootScene` loads textures and starts `MenuScene`. 2. `BootScene` loads atlas assets and starts `MenuScene`.
3. `MenuScene` passes the chosen difficulty to `GameScene`. 3. `MenuScene` reads persisted audio preferences, lets the player choose difficulty, and can open `SettingsScene` for audio toggles.
4. `GameScene.create()` creates a fresh `CardTracker`, constructs a new `GameState`, and starts the opening deal. 4. `SettingsScene` writes audio preferences immediately and returns to `MenuScene`.
5. Human turns use pointer-driven card selection and `findCaptures()` output to choose legal captures. 5. `GameScene.create()` normalizes incoming scene data, creates a fresh `CardTracker`, constructs a new `GameState`, and starts the opening deal.
6. AI turns call `AIWorkerClient.chooseMove(state, playerIdx, difficulty, tracker, onProgress)`. 6. Human turns use pointer-driven card selection and `findCaptures()` output to choose legal captures.
7. `AIWorkerClient` posts a typed request to `ai.worker.ts`; if workers are unavailable, it reruns the same request in-thread. 7. AI turns call `AIWorkerClient.chooseMove(state, playerIdx, difficulty, tracker, onProgress)`.
8. `chooseMove()` returns a heuristic move for lower tiers or performs batched master search while emitting `AIDecisionProgress`. 8. `AIWorkerClient` posts a typed request to `ai.worker.ts`; if workers are unavailable, it reruns the same request in-thread.
9. `GameScene` updates the think bar from progress callbacks, executes the returned move, records tracker state, and advances turn order. 9. `chooseMove()` returns a heuristic move for lower tiers or performs batched master search while emitting `AIDecisionProgress`.
10. When every hand is empty, `engine.ts` finalizes scoring and `GameScene` presents the round or match outcome. 10. `GameScene` updates the think bar from progress callbacks, enforces a minimum visible think time, executes the returned move, records tracker state, and advances turn order.
11. When every hand is empty, `engine.ts` finalizes scoring and `GameScene` presents the round or match outcome.
12. Separately, `ai-benchmark.ts` exercises the same game and AI modules through fixed fixtures, seeded self-play, and simulated timing to produce quality summaries.
## Build System ## Build System
@@ -227,4 +264,5 @@ Application-level dependency direction is one-way:
| `npm run dev` | `package.json` | Starts the Vite dev server | | `npm run dev` | `package.json` | Starts the Vite dev server |
| `npm run build` | user-provided build command | Runs `tsc && vite build` and writes web output to `dist/` | | `npm run build` | user-provided build command | Runs `tsc && vite build` and writes web output to `dist/` |
| `npm run preview` | `package.json` | Serves the built app with Vite preview | | `npm run preview` | `package.json` | Serves the built app with Vite preview |
| `npm run benchmark:ai-quality` | `package.json` | Runs the AI benchmark harness through `tsx` |
| `npx tsc --noEmit` | user-provided test command | Type-checks the TypeScript codebase without emitting files | | `npx tsc --noEmit` | user-provided test command | Type-checks the TypeScript codebase without emitting files |

View File

@@ -1,34 +1,40 @@
# Findings # Findings
> Last Updated: 2026-04-09T00:00:00.000Z > Last Updated: 2026-04-09T20:59:51.000Z
## Summary ## Summary
Initializer refresh for SCOPONE-0009. The cached findings were stale relative to the live source tree, so the observations below reflect the current Phaser, worker, and AI implementation. Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDINGS.md` no longer matched its recorded hash, and the architecture document no longer matched the live source layout after settings, preferences, and benchmark changes. The observations below reflect the current repository state.
## Codebase Observations ## Codebase Observations
- Primary gameplay code currently lives in 10 TypeScript source files under `src/`; the Android wrapper adds 3 Java files. - Primary gameplay code currently lives in 15 TypeScript source files under `src/`; the Android wrapper adds 3 Java files.
- The project is structurally split between framework-free gameplay modules in `src/game/` and Phaser scene code in `src/scenes/`. - The project is structurally split between framework-free gameplay modules in `src/game/` and Phaser scene code in `src/scenes/`.
- `src/scenes/GameScene.ts` and `src/game/ai.ts` remain the two largest concentrations of application logic. - `src/scenes/GameScene.ts` and `src/game/ai.ts` remain the two largest concentrations of application logic.
- The AI transport layer is now a stable three-file path: `ai-worker-protocol.ts`, `ai-worker-client.ts`, and `ai.worker.ts`. - A dedicated audio preference path now exists: `src/game/preferences.ts`, `src/scenes/MenuScene.ts`, and `src/scenes/SettingsScene.ts`.
- `main.ts` still contains a local `SettingsScene` placeholder class, while `MenuScene.ensureSettingsSceneAvailable()` swaps in the concrete imported scene before navigation.
- The AI transport layer is a stable three-file path: `ai-worker-protocol.ts`, `ai-worker-client.ts`, and `ai.worker.ts`.
- The AI exposes three difficulty levels: `beginner`, `advanced`, and `master`. - The AI exposes three difficulty levels: `beginner`, `advanced`, and `master`.
- `advanced` and `master` both use `CardTracker` to reason about unseen cards without directly reading hidden hands. - `advanced` and `master` both use `CardTracker` to reason about unseen cards without directly reading hidden hands.
- The current `master` search profile is `timeBudgetMs: 4600`, `sampleCount: 10`, `maxDepth: 6`, `batchSize: 2`. - The base `master` search profile is `4300 ms / 8 samples / depth 5 / batch 2`, with tighter endgame branches down to `3200 ms / 4 samples / exact remaining depth / batch 1` when 4 cards remain.
- `GameScene` consumes AI progress callbacks to update an on-screen think bar while a worker request is running. - `GameScene` consumes AI progress callbacks to update an on-screen think bar while a worker request is running.
- `GameScene` now enforces `AI_MIN_THINK_MS = 1000` and `MOVE_OUTCOME_STATUS_MS = 2000` through timer-backed scene logic.
- `AIWorkerClient` fails over pending work to in-thread `chooseMove()` if worker creation, posting, or deserialization fails. - `AIWorkerClient` fails over pending work to in-thread `chooseMove()` if worker creation, posting, or deserialization fails.
- The AI benchmark harness is now in source under `src/game/ai-benchmark.ts` and `src/game/ai-benchmark-fixtures.ts`, and `package.json` exposes it as `npm run benchmark:ai-quality`.
- The current benchmark contract is iteration 5: 13 fixed fixtures, 6 critical concepts, and 48 self-play matches.
- The Android wrapper targets SDK 36 with `minSdkVersion` 24 and applies immersive mode from the native activity. - The Android wrapper targets SDK 36 with `minSdkVersion` 24 and applies immersive mode from the native activity.
- Audio remains procedural via Web Audio; no dedicated audio asset pipeline is present in the source tree. - Audio remains procedural via Web Audio; there is still no dedicated audio asset pipeline in the source tree.
- No ESLint or Prettier configuration is present. - No ESLint or Prettier configuration is present.
- The only repository-wide verification command supplied is `npx tsc --noEmit`. - The only repository-wide verification command supplied is `npx tsc --noEmit`.
## Potential Improvement Areas ## Potential Improvement Areas
- `GameScene.ts` still centralizes layout, turn flow, HUD updates, effects, and audio in one scene class, which raises maintenance cost. - `GameScene.ts` still centralizes layout, turn flow, HUD updates, effects, audio, status messaging, and AI orchestration in one scene class.
- `ai.ts` still combines heuristic tiers, inference helpers, determinization, and alpha-beta evaluation in one module. - `ai.ts` still combines heuristic tiers, inference helpers, determinization, move ordering, and alpha-beta evaluation in one module.
- Worker transport is isolated cleanly, but progress rendering remains coupled to scene-level UI concerns. - The current settings flow works, but the dual registration pattern for `SettingsScene` in `main.ts` plus dynamic replacement in `MenuScene` is fragile and worth simplifying later.
- A 4600 ms master search budget may still be noticeable on slower mobile devices even with batch yielding. - Worker transport is isolated cleanly, but progress rendering and fallback behavior remain coupled to scene-level UI concerns.
- There is no dedicated automated rules or AI test suite beyond type-checking. - A 3.2 to 4.35 second master search window may still be noticeable on slower mobile devices even with yielding and the minimum-think pacing already in place.
- There is no dedicated automated rules test suite beyond type-checking and the AI benchmark harness.
- Formatting and style are enforced socially rather than by automated linting or formatting tools. - Formatting and style are enforced socially rather than by automated linting or formatting tools.
## Current Rule / Implementation Notes ## Current Rule / Implementation Notes
@@ -39,14 +45,15 @@ Initializer refresh for SCOPONE-0009. The cached findings were stale relative to
- When multiple direct matches exist, `findCaptures()` returns one single-card option per matching card. - When multiple direct matches exist, `findCaptures()` returns one single-card option per matching card.
- Subset-sum captures are considered only when no direct match exists. - Subset-sum captures are considered only when no direct match exists.
- `applyMove()` defaults to the first legal capture if no explicit capture choice is supplied. - `applyMove()` defaults to the first legal capture if no explicit capture choice is supplied.
- Scope is awarded only when a capture clears the table before the final play of the round. - Scopa is awarded only when a capture clears the table before the final play of the round.
### AI implementation snapshot ### AI implementation snapshot
- `beginner` uses a simpler heuristic with noise to remain beatable. - `beginner` uses a simpler heuristic with noise to remain beatable.
- `advanced` adds race awareness, anti-scopa logic, partner setup, anchor play, and tracker-based probability estimates. - `advanced` adds race awareness, anti-scopa logic, partner setup, denari pressure, and tracker-based probability estimates.
- `master` orders legal moves with a quick evaluator, samples hidden hands, and scores them with alpha-beta search under the active deadline. - `master` orders legal moves with a quick evaluator, samples hidden hands, and scores them with alpha-beta search under a dynamic deadline.
- Progress is reported through `AIDecisionProgress` so the scene can keep the think bar responsive. - Progress is reported through `AIDecisionProgress` so the scene can keep the think bar responsive.
- `CardTracker` now exposes same-rank residue summaries through `getValueRankResidue()` and `getValueRankResidueSummary()`, and those semantics are the live inference surface for unseen-value reasoning.
### Worker execution snapshot ### Worker execution snapshot
@@ -59,11 +66,19 @@ Initializer refresh for SCOPONE-0009. The cached findings were stale relative to
### Scene / UI implementation snapshot ### Scene / UI implementation snapshot
- `BootScene` loads atlas assets and presents a simple loading bar. - `BootScene` loads atlas assets and presents a simple loading bar.
- `MenuScene` exposes difficulty selection before match start. - `MenuScene` now exposes both difficulty selection and a dedicated entry point into `SettingsScene`.
- `SettingsScene` persists music and effects toggles immediately through `saveAudioPreferences()`.
- `GameScene` reads normalized audio preferences from scene data or persisted storage before match start.
- `GameScene` tracks played and captured cards in `CardTracker` as the round evolves. - `GameScene` tracks played and captured cards in `CardTracker` as the round evolves.
- The scene owns score HUD rendering, player labels, status text, think-bar rendering, and procedural particle effects. - The scene owns score HUD rendering, player labels, status text, think-bar rendering, procedural audio, and particle effects.
- Round-end and match-end flows remain managed inside the scene instead of separate overlay components. - Round-end and match-end flows remain managed inside the scene instead of separate overlay components.
### Benchmark snapshot
- `ai-benchmark.ts` now uses a simulated timing source for fixture and self-play evaluation instead of depending only on wall-clock timing.
- The benchmark summary records per-seed aggregates, dual-loss seeds, and a regression watchlist intersection.
- The harness remains source-local under `src/`, so it is covered by the default `npx tsc --noEmit` include set.
## Research Performed ## Research Performed
### Web Research: Scopone Scientifico Rules (2026-03-31) ### Web Research: Scopone Scientifico Rules (2026-03-31)
@@ -118,34 +133,11 @@ Initializer refresh for SCOPONE-0009. The cached findings were stale relative to
- The current `GameScene` pattern of registering one-shot shutdown and destroy handlers is aligned with Phaser guidance for worker disposal and UI cleanup. - The current `GameScene` pattern of registering one-shot shutdown and destroy handlers is aligned with Phaser guidance for worker disposal and UI cleanup.
- Dealer rotation and next-round state changes can stay inside the existing in-scene orchestration without requiring a different Phaser lifecycle primitive. - Dealer rotation and next-round state changes can stay inside the existing in-scene orchestration without requiring a different Phaser lifecycle primitive.
### SCOPONE-0009: Iteration 3 strength-planning notes (2026-04-08) ### SCOPONE-0010: UI, settings, and benchmark refresh notes (2026-04-09)
- `src/game/ai.ts` currently generates master determinization samples by uniformly shuffling all unseen cards and slicing them into opponents' hidden hands; it does not yet bias assignments by dealer role, parity residue, or observed capture semantics. - `src/game/preferences.ts` is now the authoritative audio preference seam. It normalizes stored values and shields scenes from malformed storage state.
- The transposition-table key in `src/game/ai.ts` includes the exact sampled hidden hands, so reuse is effective within a determinized sample but does not merge equivalent uncertainty classes across different sample assignments. - `src/scenes/MenuScene.ts` now reads persisted audio preferences and exposes a dedicated settings entry point instead of keeping audio options implicit.
- No executable benchmark harness or AI quality test module exists under `src/`; the current timing evidence lives only in prompt artifacts such as `prompts/SCOPONE-0009/iteration_2/benchmark_summary.md`. - `src/scenes/SettingsScene.ts` exists as a real scene and persists music and effects toggles independently through `saveAudioPreferences()`.
- `tsconfig.json` includes only `src`, so any automated quality or self-play harness that should be typechecked by the default `npx tsc --noEmit` command needs to live under `src/` unless the project configuration changes. - `src/scenes/GameScene.ts` already contains the previously planned pacing and status work: `AI_MIN_THINK_MS = 1000`, `MOVE_OUTCOME_STATUS_MS = 2000`, timer-backed `setStatus(...)`, and `handleSceneShutdown()` timer cleanup are all present in source and should be treated as current behavior, not future work.
- `src/game/ai-benchmark.ts` now enforces an iteration 5 contract with simulated timing, cross-seed aggregation, dual-loss reporting, and a regression watchlist intersection. Older findings that described iteration 4 targets or wall-clock-only timing are stale.
### SCOPONE-0009: Iteration 3 continuation notes (2026-04-09) - `main.ts` still registers a local `SettingsScene` stub while `MenuScene` dynamically installs the concrete scene implementation before use. This works today but is an architectural wrinkle worth remembering in later planning.
- The accepted iteration 3 benchmark work is now present in source: `src/game/ai-benchmark.ts` and `src/game/ai-benchmark-fixtures.ts` exist under `src/`, `package.json` exposes `benchmark:ai-quality`, and the harness already measures fixed fixtures, self-play, and production-master timing.
- The live production master budgets in `src/game/ai.ts` are already below the requested five-second ceiling in every shipped branch: base `4300`, `<= 20 cards` `4350`, `<= 12 cards` `4200`, `<= 8 cards` `3900`, `<= 6 cards` `3600`, and `<= 4 cards` `3200` milliseconds.
- `src/scenes/GameScene.ts` still executes AI turns immediately after `await aiClient.chooseMove(...)` resolves in `doAIMove()`; there is currently no scene-level minimum think-time floor.
- `src/scenes/GameScene.ts` still uses a bare `setStatus(msg)` helper that only calls `this.statusText.setText(msg)`; there is no timed persistence policy, no cancellation of prior status timers, and no dedicated post-move outcome message path.
- Phaser 3.87 scene timers can be cancelled with `TimerEvent.remove()` and their references cleaned with `TimerEvent.destroy()`; the current scene already listens to `shutdown` and `destroy`, so timed status cleanup belongs in the existing `handleSceneShutdown()` path.
### SCOPONE-0009: Iteration 3 refresh notes (2026-04-09)
- The current `src/game/ai.ts` heuristic does not reason about numeric even/odd card values; it already computes the unseen copy count for each rank and stores whether the remaining copies for that rank are in a singleton residue or a paired residue, but the internal names still use `oddResidue`, `evenResidue`, and `scoreParityTableState`, which can mislead future work.
- The live tactical seam that needs refresh is therefore naming and policy framing, not a wholesale replacement of the underlying signal: the AI should explicitly treat `apparigliare` / `sparigliare` as preserving or breaking same-rank copy residues and connect that to table control, scopa prevention, and forced replies.
- The accepted benchmark harness in `src/game/ai-benchmark.ts` still measures runtime with `performance.now()` and therefore depends on wall-clock search time. It does not yet use an injected or simulated search clock for fast validation runs.
- `src/scenes/GameScene.ts` already contains the previously planned pacing and status work: `AI_MIN_THINK_MS = 1000`, `MOVE_OUTCOME_STATUS_MS = 2000`, a timer-backed `setStatus(...)`, and `handleSceneShutdown()` timer cleanup are all present in source and should be preserved rather than re-planned.
- `src/game/ai-benchmark-fixtures.ts` still contains one fixture and tag using the stale label `dealer-parity-preserve-pair` / `critical-dealer-parity`; if benchmark files are reopened for simulated timing, that terminology should be refreshed to rank-residue wording at the same time.
### SCOPONE-0009: Iteration 5 planning notes (2026-04-09)
- The live AI quality harness in `src/game/ai-benchmark.ts` still hard-codes an `iteration: 4` quality gate with targets of `12` fixed fixtures, `4` critical concepts, and `48` self-play matches requiring `>= 30` wins and `<= 12` losses; the readable summary does not yet surface cross-seed aggregation such as the recurring dual-loss seeds from the latest rejected run.
- `src/game/ai-benchmark-fixtures.ts` currently covers `settebello-capture`, `anti-scopa-defense`, `dealer-rank-residue-preservation`, and `exact-endgame-resolution` as critical concepts, but it does not yet encode an explicit critical fixture for partner invitation / partner scopa setup and does not yet make `fare scopa` itself a critical concept despite the user's new ordering.
- Non-critical fixtures already exist for denari pressure, late denari shielding, and seven pressure, so the benchmark seam for iteration 5 is to rebalance critical-vs-fixed coverage and ordering expectations rather than to introduce a second harness.
- Cross-tier heuristic priorities are concentrated in `src/game/ai.ts`: beginner logic in `scoreCaptureBeginner()` / `scoreDumpBeginner()`, advanced logic in `scoreCaptureAdv()` / `scoreDumpAdv()`, and master root/search logic in `quickEval()`, `orderSearchMoves()`, `generateSamples()`, and `evaluateFast()`.
- Partner-aware logic already exists in all three tiers, but it is currently additive and distributed across multiple heuristics; there is no single explicit priority ladder that guarantees `partner setup` outranks seven denial, denari denial, and generic material capture across the whole file.
- Anti-scopa prevention is already strong enough to pass the fixed tactical fixtures, but the rejected iteration 4 result (`18` wins, `30` losses over `48` seeded self-play matches) indicates that full-game strength is still limited by strategic continuity across seed-intrinsic lines rather than by isolated tactical blindness.

94
src/game/preferences.ts Normal file
View File

@@ -0,0 +1,94 @@
import { Difficulty } from './types';
export interface AudioPreferences {
musicEnabled: boolean;
effectsEnabled: boolean;
}
export interface GameSceneData {
difficulty: Difficulty;
audioPreferences: AudioPreferences;
}
export interface SettingsSceneData {
returnSceneKey?: string;
}
export const AUDIO_PREFERENCES_STORAGE_KEY = 'scopone.audio-preferences';
export const DEFAULT_AUDIO_PREFERENCES: AudioPreferences = {
musicEnabled: true,
effectsEnabled: true,
};
const cloneDefaultAudioPreferences = (): AudioPreferences => ({
...DEFAULT_AUDIO_PREFERENCES,
});
const getBrowserStorage = (): Storage | null => {
if (typeof window === 'undefined') {
return null;
}
try {
return window.localStorage;
} catch {
return null;
}
};
export const normalizeAudioPreferences = (value: unknown): AudioPreferences => {
if (!value || typeof value !== 'object') {
return cloneDefaultAudioPreferences();
}
const candidate = value as Partial<AudioPreferences>;
return {
musicEnabled:
typeof candidate.musicEnabled === 'boolean'
? candidate.musicEnabled
: DEFAULT_AUDIO_PREFERENCES.musicEnabled,
effectsEnabled:
typeof candidate.effectsEnabled === 'boolean'
? candidate.effectsEnabled
: DEFAULT_AUDIO_PREFERENCES.effectsEnabled,
};
};
export const loadAudioPreferences = (storage: Storage | null = getBrowserStorage()): AudioPreferences => {
if (!storage) {
return cloneDefaultAudioPreferences();
}
try {
const rawPreferences = storage.getItem(AUDIO_PREFERENCES_STORAGE_KEY);
if (!rawPreferences) {
return cloneDefaultAudioPreferences();
}
return normalizeAudioPreferences(JSON.parse(rawPreferences));
} catch {
return cloneDefaultAudioPreferences();
}
};
export const saveAudioPreferences = (
preferences: AudioPreferences,
storage: Storage | null = getBrowserStorage(),
): AudioPreferences => {
const normalizedPreferences = normalizeAudioPreferences(preferences);
if (!storage) {
return normalizedPreferences;
}
try {
storage.setItem(AUDIO_PREFERENCES_STORAGE_KEY, JSON.stringify(normalizedPreferences));
} catch {
return normalizedPreferences;
}
return normalizedPreferences;
};

View File

@@ -3,6 +3,12 @@ import { BootScene } from './scenes/BootScene';
import { MenuScene } from './scenes/MenuScene'; import { MenuScene } from './scenes/MenuScene';
import { GameScene } from './scenes/GameScene'; import { GameScene } from './scenes/GameScene';
class SettingsScene extends Phaser.Scene {
constructor() {
super({ key: 'SettingsScene' });
}
}
const installFullscreenRequest = (host: HTMLElement): void => { const installFullscreenRequest = (host: HTMLElement): void => {
const canRequestFullscreen = const canRequestFullscreen =
typeof document.fullscreenEnabled === 'boolean' typeof document.fullscreenEnabled === 'boolean'
@@ -48,7 +54,7 @@ const config: Phaser.Types.Core.GameConfig = {
height: 720, height: 720,
backgroundColor: '#1a5c2a', backgroundColor: '#1a5c2a',
parent: 'game', parent: 'game',
scene: [BootScene, MenuScene, GameScene], scene: [BootScene, MenuScene, GameScene, SettingsScene],
scale: { scale: {
mode: Phaser.Scale.FIT, mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH, autoCenter: Phaser.Scale.CENTER_BOTH,

View File

@@ -7,6 +7,12 @@ import {
import { AIMove, AIDecisionProgress } from '../game/ai'; import { AIMove, AIDecisionProgress } from '../game/ai';
import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client'; import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client';
import { CardTracker } from '../game/card-tracker'; import { CardTracker } from '../game/card-tracker';
import {
DEFAULT_AUDIO_PREFERENCES,
GameSceneData,
loadAudioPreferences,
normalizeAudioPreferences,
} from '../game/preferences';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Suit ordering for hand grouping // Suit ordering for hand grouping
@@ -31,6 +37,10 @@ const CH_A = 645 * CARD_SCALE_AI; // card height for AI ≈ 81
const SCOREBAR_H = 54; const SCOREBAR_H = 54;
const AI_MIN_THINK_MS = 1000; const AI_MIN_THINK_MS = 1000;
const MOVE_OUTCOME_STATUS_MS = 2000; const MOVE_OUTCOME_STATUS_MS = 2000;
const PLAYED_CARD_TRAVEL_MS = 400;
const CAPTURE_COLLAPSE_MS = 480;
const CAPTURE_COLLAPSE_DELAY_MS = 60;
const NON_CAPTURE_TABLE_TWEEN_MS = 560;
// Player positions: // Player positions:
// 0 = South (human, bottom), 1 = West (AI, left, rotated -90°) // 0 = South (human, bottom), 1 = West (AI, left, rotated -90°)
@@ -101,6 +111,7 @@ export class GameScene extends Phaser.Scene {
private audioCtx: AudioContext | null = null; private audioCtx: AudioContext | null = null;
private musicGain: GainNode | null = null; private musicGain: GainNode | null = null;
private musicStarted = false; private musicStarted = false;
private audioPreferences = DEFAULT_AUDIO_PREFERENCES;
constructor() { constructor() {
super({ key: 'GameScene' }); super({ key: 'GameScene' });
@@ -110,13 +121,16 @@ export class GameScene extends Phaser.Scene {
// Create // Create
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
create(data?: { difficulty?: Difficulty }): void { create(data?: Partial<GameSceneData>): 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) // Read difficulty from scene data (MenuScene passes it)
this.difficulty = data?.difficulty ?? 'advanced'; this.difficulty = data?.difficulty ?? 'advanced';
this.audioPreferences = data?.audioPreferences
? normalizeAudioPreferences(data.audioPreferences)
: loadAudioPreferences();
this.tracker = new CardTracker(); this.tracker = new CardTracker();
this.aiClient?.dispose(); this.aiClient?.dispose();
this.aiClient = new AIWorkerClient(); this.aiClient = new AIWorkerClient();
@@ -467,12 +481,47 @@ export class GameScene extends Phaser.Scene {
} }
private buildMoveOutcomeStatus(playerIdx: PlayerIndex, card: Card, capture: Card[] | null): string { private buildMoveOutcomeStatus(playerIdx: PlayerIndex, card: Card, capture: Card[] | null): string {
const player = this.state.players[playerIdx]; const actor = this.getMoveActorPrefix(playerIdx);
if (!capture || capture.length === 0) { if (!capture || capture.length === 0) {
return `${player.name} gioca ${cardName(card)}`; return `${actor} giocato ${cardName(card)}.`;
} }
return `${player.name} cattura ${capture.map(cardName).join(', ')} con ${cardName(card)}`; return `${actor} preso ${capture.map(cardName).join(', ')} con ${cardName(card)}.`;
}
private getTurnStatus(playerIdx: PlayerIndex): string {
switch (playerIdx) {
case 0:
return 'Tocca a te.';
case 2:
return 'Sta giocando il tuo compagno.';
default:
return `Sta giocando ${this.state.players[playerIdx].name}.`;
}
}
private getMoveActorPrefix(playerIdx: PlayerIndex): string {
switch (playerIdx) {
case 0:
return 'Hai';
case 2:
return 'Il tuo compagno ha';
default:
return `${this.state.players[playerIdx].name} ha`;
}
}
private getSingleCapturePrompt(capture: Card[]): string {
return `Puoi prendere ${capture.map(cardName).join(', ')}. Clicca di nuovo per confermare.`;
}
private getAiMoveErrorStatus(playerIdx: PlayerIndex): string {
if (playerIdx === 2) {
return 'Problema durante la mossa del tuo compagno.';
}
return `Problema durante la mossa di ${this.state.players[playerIdx].name}.`;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -662,7 +711,7 @@ export class GameScene extends Phaser.Scene {
if (this.state.roundOver) { this.showRoundEnd(); return; } if (this.state.roundOver) { this.showRoundEnd(); return; }
const cur = this.state.currentPlayer; const cur = this.state.currentPlayer;
const player = this.state.players[cur]; const player = this.state.players[cur];
this.setStatus(`Turno di ${player.name}`, { persist: true }); this.setStatus(this.getTurnStatus(cur), { persist: true });
this.pulseLabel(cur); this.pulseLabel(cur);
if (player.isHuman) { if (player.isHuman) {
@@ -725,7 +774,7 @@ export class GameScene extends Phaser.Scene {
} catch (error) { } catch (error) {
console.error('AI move failed', error); console.error('AI move failed', error);
if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) { if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) {
this.setStatus('Errore durante la mossa AI'); this.setStatus(this.getAiMoveErrorStatus(playerIdx));
} }
} finally { } finally {
if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) { if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) {
@@ -795,14 +844,14 @@ export class GameScene extends Phaser.Scene {
} }
if (captures.length === 0) { if (captures.length === 0) {
this.setStatus('Nessuna cattura — clicca di nuovo per giocare sul tavolo'); this.setStatus('Non puoi prendere nulla: clicca di nuovo per lasciare la carta sul tavolo.');
this.highlightTableForDump(card); this.highlightTableForDump(card);
} else if (captures.length === 1) { } else if (captures.length === 1) {
this.setStatus(`Cattura: ${captures[0].map(cardName).join(', ')} — clicca di nuovo per confermare`); this.setStatus(this.getSingleCapturePrompt(captures[0]));
this.pendingCaptures = captures; this.pendingCaptures = captures;
this.highlightCapture(captures[0]); this.highlightCapture(captures[0]);
} else { } else {
this.setStatus('Scegli le carte da catturare'); this.setStatus('Scegli quale presa vuoi fare.');
this.pendingCaptures = captures; this.pendingCaptures = captures;
this.highlightMultipleCaptures(captures); this.highlightMultipleCaptures(captures);
} }
@@ -878,7 +927,7 @@ export class GameScene extends Phaser.Scene {
bg.lineStyle(2, color.stroke, 0.8); bg.lineStyle(2, color.stroke, 0.8);
bg.strokeRoundedRect(W / 2 - 180, y - 14, 360, 28, 7); bg.strokeRoundedRect(W / 2 - 180, y - 14, 360, 28, 7);
const btn = this.add.zone(W / 2, y, 360, 28).setInteractive({ useHandCursor: true }).setDepth(21); const btn = this.add.zone(W / 2, y, 360, 28).setInteractive({ useHandCursor: true }).setDepth(21);
const txt = this.add.text(W / 2, y, `Cattura: ${label}`, { const txt = this.add.text(W / 2, y, `Prendi: ${label}`, {
fontFamily: 'serif', fontSize: '14px', color: color.text, fontFamily: 'serif', fontSize: '14px', color: color.text,
}).setOrigin(0.5).setDepth(21); }).setOrigin(0.5).setDepth(21);
btn.on('pointerdown', () => this.confirmMove(this.selectedCard!, cap)); btn.on('pointerdown', () => this.confirmMove(this.selectedCard!, cap));
@@ -966,7 +1015,7 @@ export class GameScene extends Phaser.Scene {
this.tweens.add({ this.tweens.add({
targets: cardImg, targets: cardImg,
x: this.tableCenter.x, y: this.tableCenter.y, x: this.tableCenter.x, y: this.tableCenter.y,
duration: 200, ease: 'Power2', duration: PLAYED_CARD_TRAVEL_MS, ease: 'Power2',
onComplete: () => { onComplete: () => {
this.spawnCaptureEffect(this.tableCenter.x, this.tableCenter.y, isSettebello); this.spawnCaptureEffect(this.tableCenter.x, this.tableCenter.y, isSettebello);
@@ -991,7 +1040,7 @@ export class GameScene extends Phaser.Scene {
this.tweens.add({ this.tweens.add({
targets: img, targets: img,
x: pilePos.x, y: pilePos.y, alpha: 0, x: pilePos.x, y: pilePos.y, alpha: 0,
duration: 240, delay: 30, duration: CAPTURE_COLLAPSE_MS, delay: CAPTURE_COLLAPSE_DELAY_MS,
onComplete: () => { onComplete: () => {
img.setVisible(false); img.setVisible(false);
done++; done++;
@@ -1022,7 +1071,7 @@ export class GameScene extends Phaser.Scene {
this.tweens.add({ this.tweens.add({
targets: cardImg, targets: cardImg,
x: tablePos.x, y: tablePos.y, angle: randomAngle, x: tablePos.x, y: tablePos.y, angle: randomAngle,
duration: 280, ease: 'Back.Out', duration: NON_CAPTURE_TABLE_TWEEN_MS, ease: 'Back.Out',
onComplete: () => this.afterMove(playerIdx, card, null, nextState, oldState), onComplete: () => this.afterMove(playerIdx, card, null, nextState, oldState),
}); });
} }
@@ -1300,6 +1349,7 @@ export class GameScene extends Phaser.Scene {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private startMusic(): void { private startMusic(): void {
if (!this.audioPreferences.musicEnabled) return;
if (this.musicStarted) return; if (this.musicStarted) return;
this.musicStarted = true; this.musicStarted = true;
try { try {
@@ -1374,6 +1424,7 @@ export class GameScene extends Phaser.Scene {
} }
private playSfx(type: 'card_play' | 'capture' | 'scopa' | 'settebello'): void { private playSfx(type: 'card_play' | 'capture' | 'scopa' | 'settebello'): void {
if (!this.audioPreferences.effectsEnabled) return;
if (!this.audioCtx) return; if (!this.audioCtx) return;
const ctx = this.audioCtx; const ctx = this.audioCtx;
const now = ctx.currentTime; const now = ctx.currentTime;
@@ -1406,6 +1457,7 @@ export class GameScene extends Phaser.Scene {
} }
private stopMusic(): void { private stopMusic(): void {
if (!this.audioPreferences.musicEnabled) return;
if (this.musicGain && this.audioCtx) { if (this.musicGain && this.audioCtx) {
this.musicGain.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + 1.5); this.musicGain.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + 1.5);
} }
@@ -1432,14 +1484,14 @@ export class GameScene extends Phaser.Scene {
panel.strokeRoundedRect(W / 2 - 280, H / 2 - 210, 560, 420, 16); panel.strokeRoundedRect(W / 2 - 280, H / 2 - 210, 560, 420, 16);
const lines: Array<[string, string]> = [ const lines: Array<[string, string]> = [
[`Fine Mano ${this.state.roundNumber ?? 1}`, '#ffd700'], [`Fine della mano ${this.state.roundNumber ?? 1}`, '#ffd700'],
['', ''], ['', ''],
[`Team A +${t0.roundPoints} pt → ${t0.totalPoints} totali`, '#aaffaa'], [`Squadra tua +${t0.roundPoints} pt → ${t0.totalPoints} totali`, '#aaffaa'],
[`Team B +${t1.roundPoints} pt → ${t1.totalPoints} totali`, '#ffaaaa'], [`Avversari +${t1.roundPoints} pt → ${t1.totalPoints} totali`, '#ffaaaa'],
['', ''], ['', ''],
[`Carte A=${t0.cards} B=${t1.cards} ${pointStr(bd.cartePoint)}`, '#ffffff'], [`Carte A=${t0.cards} B=${t1.cards} ${pointStr(bd.cartePoint)}`, '#ffffff'],
[`Denari A=${t0.denari} B=${t1.denari} ${pointStr(bd.denariPoint)}`, '#ffdd88'], [`Denari A=${t0.denari} B=${t1.denari} ${pointStr(bd.denariPoint)}`, '#ffdd88'],
[`Settebello → ${bd.settebelloPoint === 0 ? 'Team A' : 'Team B'}`, '#ffd700'], [`Settebello → ${bd.settebelloPoint === 0 ? 'squadra tua' : 'avversari'}`, '#ffd700'],
[`Primiera A=${t0.primiera} B=${t1.primiera} ${pointStr(bd.primieraPoint)}`, '#aaddff'], [`Primiera A=${t0.primiera} B=${t1.primiera} ${pointStr(bd.primieraPoint)}`, '#aaddff'],
[`Scope A=${bd.scopeTeam0} B=${bd.scopeTeam1}`, '#ccffcc'], [`Scope A=${bd.scopeTeam0} B=${bd.scopeTeam1}`, '#ccffcc'],
]; ];
@@ -1501,11 +1553,11 @@ export class GameScene extends Phaser.Scene {
pg.lineStyle(3, 0xffd700, 0.8); pg.lineStyle(3, 0xffd700, 0.8);
pg.strokeRoundedRect(W / 2 - 220, H / 2 - 150, 440, 310, 20); pg.strokeRoundedRect(W / 2 - 220, H / 2 - 150, 440, 310, 20);
this.add.text(W / 2, H / 2 - 110, 'PARTITA FINITA', { this.add.text(W / 2, H / 2 - 110, 'PARTITA CONCLUSA', {
fontFamily: 'Georgia, serif', fontSize: '44px', color: '#ffd700', fontFamily: 'Georgia, serif', fontSize: '44px', color: '#ffd700',
stroke: '#000', strokeThickness: 6, stroke: '#000', strokeThickness: 6,
}).setOrigin(0.5).setDepth(42); }).setOrigin(0.5).setDepth(42);
this.add.text(W / 2, H / 2 - 30, win ? 'Team A (Tu + Compagno)' : 'Team B (AI)', { this.add.text(W / 2, H / 2 - 30, win ? 'Vince la tua squadra' : 'Vincono gli avversari', {
fontFamily: 'serif', fontSize: '26px', fontFamily: 'serif', fontSize: '26px',
color: win ? '#aaffaa' : '#ffaaaa', color: win ? '#aaffaa' : '#ffaaaa',
}).setOrigin(0.5).setDepth(42); }).setOrigin(0.5).setDepth(42);
@@ -1584,5 +1636,5 @@ function cardName(card: Card): string {
} }
function pointStr(p: 0 | 1 | null): string { function pointStr(p: 0 | 1 | null): string {
return p === null ? '(pari)' : p === 0 ? '→ A' : '→ B'; return p === null ? '(pari)' : p === 0 ? '→ squadra tua' : '→ avversari';
} }

View File

@@ -1,5 +1,47 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { Difficulty } from '../game/types'; import { Difficulty } from '../game/types';
import { GameSceneData, loadAudioPreferences } from '../game/preferences';
import { SettingsScene } from './SettingsScene';
type MenuButtonPalette = {
base: number;
hover: number;
};
type DifficultyOption = {
label: string;
subtitle: string;
value: Difficulty;
palette: MenuButtonPalette;
};
type RuleSection = {
heading: string;
lines: string[];
};
const TITLE_STYLE: Phaser.Types.GameObjects.Text.TextStyle = {
fontFamily: 'Georgia, serif',
fontSize: '52px',
color: '#ffd700',
stroke: '#000000',
strokeThickness: 4,
resolution: 2,
};
const PANEL_TITLE_STYLE: Phaser.Types.GameObjects.Text.TextStyle = {
fontFamily: 'Georgia, serif',
fontSize: '24px',
color: '#ffd700',
resolution: 2,
};
const BODY_STYLE: Phaser.Types.GameObjects.Text.TextStyle = {
fontFamily: 'Georgia, serif',
fontSize: '18px',
color: '#f8f5e6',
resolution: 2,
};
export class MenuScene extends Phaser.Scene { export class MenuScene extends Phaser.Scene {
constructor() { constructor() {
@@ -7,97 +49,244 @@ export class MenuScene extends Phaser.Scene {
} }
create(): void { create(): void {
const W = this.scale.width; const width = this.scale.width;
const H = this.scale.height; const height = this.scale.height;
const audioPreferences = loadAudioPreferences();
// Background felt this.drawBackground(width, height);
this.add.rectangle(0, 0, W, H, 0x1a5c2a).setOrigin(0); this.drawDecorativeCards(width, height);
this.ensureSettingsSceneAvailable();
// Title this.add.text(width / 2, 92, 'Scopone Scientifico', TITLE_STYLE).setOrigin(0.5);
this.add.text(W / 2, H * 0.18, 'Scopone Scientifico', { this.add.text(width / 2, 142, 'Due squadre da due, una mano intera e lettura del tavolo fino allultimo punto.', {
fontFamily: 'Georgia, serif', fontFamily: 'Georgia, serif',
fontSize: '52px', fontSize: '21px',
color: '#ffd700', color: '#d9f2d2',
stroke: '#000000',
strokeThickness: 4,
resolution: 2, resolution: 2,
}).setOrigin(0.5); }).setOrigin(0.5);
this.add.text(W / 2, H * 0.30, '2 vs 2 · Tu + Compagno vs 2 AI', { this.createRulesPanel(width, height);
fontFamily: 'serif', this.createControlPanel(width, height, audioPreferences);
fontSize: '22px', }
color: '#ccffcc',
resolution: 2,
}).setOrigin(0.5);
// Rules summary private drawBackground(width: number, height: number): void {
const rules = [ this.add.rectangle(0, 0, width, height, 0x123d22).setOrigin(0);
'40 carte Napoletane · 10 a testa', this.add.rectangle(width / 2, height / 2, width - 60, height - 60, 0x0b2916, 0.28)
'Cattura per valore o somma', .setStrokeStyle(2, 0xe8c25d, 0.35);
'Punteggio: Carte · Denari · Settebello · Primiera · Scope', this.add.rectangle(width * 0.34, height * 0.58, width * 0.46, height * 0.50, 0x0d2215, 0.82)
'Prima squadra a 11 punti vince', .setStrokeStyle(2, 0xc8a445, 0.4);
this.add.rectangle(width * 0.77, height * 0.58, width * 0.24, height * 0.50, 0x10261b, 0.86)
.setStrokeStyle(2, 0xc8a445, 0.4);
}
private drawDecorativeCards(width: number, height: number): void {
const positions = [
[width * 0.08, height * 0.85],
[width * 0.14, height * 0.87],
[width * 0.92, height * 0.85],
[width * 0.86, height * 0.87],
]; ];
rules.forEach((line, i) => {
this.add.text(W / 2, H * 0.40 + i * 26, line, { positions.forEach(([x, y]) => {
fontFamily: 'serif', this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15)).setAlpha(0.9);
fontSize: '17px',
color: '#ffffff',
resolution: 2,
}).setOrigin(0.5);
}); });
}
// Difficulty selection label private createRulesPanel(width: number, height: number): void {
this.add.text(W / 2, H * 0.60, 'Scegli la difficoltà:', { const panelX = width * 0.12;
fontFamily: 'Georgia, serif', const panelY = 206;
fontSize: '20px', const sections: RuleSection[] = [
color: '#ffd700', {
resolution: 2, heading: 'Tavolo e squadre',
}).setOrigin(0.5); lines: [
'Si gioca in coppia: Sud e Nord contro Ovest ed Est.',
// Difficulty buttons 'Nel vero scopone si distribuiscono tutte le 40 carte, 10 a giocatore.',
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 }, heading: 'Come si prende',
lines: [
'Ogni carta cattura una carta dello stesso valore oppure una combinazione equivalente.',
'Se sul tavolo cè una presa diretta dello stesso valore, quella ha sempre la precedenza.',
],
},
{
heading: 'Punteggio partita',
lines: [
'A fine mano contano carte, denari, settebello, primiera e scope.',
'La sfida prosegue mano dopo mano finché una squadra arriva ad almeno 11 punti.',
],
},
]; ];
const btnWidth = 200; this.add.text(panelX, panelY, 'Regolamento essenziale', PANEL_TITLE_STYLE).setOrigin(0, 0.5);
const btnHeight = 50;
const totalWidth = difficulties.length * btnWidth + (difficulties.length - 1) * 20;
const startX = (W - totalWidth) / 2 + btnWidth / 2;
difficulties.forEach((d, i) => { let currentY = panelY + 44;
const x = startX + i * (btnWidth + 20); sections.forEach((section) => {
const y = H * 0.70; this.add.text(panelX, currentY, section.heading, {
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', fontFamily: 'Georgia, serif',
fontSize: '20px', fontSize: '20px',
color: '#ffffff', color: '#ffffff',
stroke: '#000000',
strokeThickness: 2,
resolution: 2, resolution: 2,
}).setOrigin(0.5); }).setOrigin(0, 0.5);
currentY += 30;
btn.on('pointerover', () => btn.setFillStyle(d.hoverColor)); section.lines.forEach((line) => {
btn.on('pointerout', () => btn.setFillStyle(d.color)); this.add.text(panelX, currentY, `${line}`, {
btn.on('pointerdown', () => { ...BODY_STYLE,
this.cameras.main.fadeOut(300, 0, 30, 0); wordWrap: { width: width * 0.40 },
this.cameras.main.once('camerafadeoutcomplete', () => { lineSpacing: 5,
this.scene.start('GameScene', { difficulty: d.value }); }).setOrigin(0, 0);
}); currentY += 54;
});
currentY += 6;
});
this.add.text(panelX, height - 92, 'Scegli la difficoltà quando vuoi iniziare: le preferenze audio vengono lette al momento della partita.', {
...BODY_STYLE,
fontSize: '16px',
color: '#cfe5cd',
wordWrap: { width: width * 0.41 },
}).setOrigin(0, 0.5);
}
private createControlPanel(width: number, height: number, audioPreferences: ReturnType<typeof loadAudioPreferences>): void {
const panelCenterX = width * 0.77;
const difficultyOptions: DifficultyOption[] = [
{
label: 'Principiante',
subtitle: 'AI prudente e leggibile',
value: 'beginner',
palette: { base: 0x2e7d32, hover: 0x43a047 },
},
{
label: 'Avanzato',
subtitle: 'Pressione costante sul tavolo',
value: 'advanced',
palette: { base: 0xd97706, hover: 0xf59e0b },
},
{
label: 'Maestro',
subtitle: 'Massima lettura e priorità alle prese forti',
value: 'master',
palette: { base: 0xb91c1c, hover: 0xdc2626 },
},
];
this.add.text(panelCenterX, 214, 'Inizia una partita', PANEL_TITLE_STYLE).setOrigin(0.5);
this.add.text(panelCenterX, 250, 'Ogni partita usa la difficoltà scelta qui sotto e le preferenze audio salvate.', {
...BODY_STYLE,
fontSize: '16px',
color: '#d7ead1',
align: 'center',
wordWrap: { width: 250 },
}).setOrigin(0.5, 0);
difficultyOptions.forEach((option, index) => {
const y = 340 + index * 96;
this.createButton(panelCenterX, y, 260, 64, option.label, option.subtitle, option.palette, () => {
this.startGame(option.value);
}); });
}); });
// Show some face-down cards decoratively const musicLabel = audioPreferences.musicEnabled ? 'attiva' : 'disattivata';
const positions = [ const effectsLabel = audioPreferences.effectsEnabled ? 'attivi' : 'disattivati';
[W * 0.08, H * 0.85], [W * 0.14, H * 0.87], [W * 0.92, H * 0.85], [W * 0.86, H * 0.87],
]; this.add.text(panelCenterX, height - 154, `Musica ${musicLabel} · Effetti ${effectsLabel}`, {
for (const [x, y] of positions) { ...BODY_STYLE,
this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15)); fontSize: '16px',
color: '#cfe5cd',
align: 'center',
}).setOrigin(0.5);
this.createButton(
panelCenterX,
height - 100,
260,
58,
'Impostazioni audio',
'Modifica musica ed effetti in modo indipendente',
{ base: 0x1f6f78, hover: 0x2f8f99 },
() => {
this.openSettings();
},
);
}
private createButton(
x: number,
y: number,
width: number,
height: number,
label: string,
subtitle: string,
palette: MenuButtonPalette,
onClick: () => void,
): void {
const background = this.add.rectangle(x, y, width, height, palette.base, 1)
.setStrokeStyle(2, 0xf5e1a4, 0.4)
.setInteractive({ useHandCursor: true });
this.add.text(x, y - 10, label, {
fontFamily: 'Georgia, serif',
fontSize: '21px',
color: '#ffffff',
stroke: '#000000',
strokeThickness: 2,
resolution: 2,
}).setOrigin(0.5);
this.add.text(x, y + 14, subtitle, {
fontFamily: 'Georgia, serif',
fontSize: '13px',
color: '#f7f1d5',
resolution: 2,
align: 'center',
}).setOrigin(0.5);
background.on('pointerover', () => background.setFillStyle(palette.hover));
background.on('pointerout', () => background.setFillStyle(palette.base));
background.on('pointerdown', onClick);
}
private startGame(difficulty: Difficulty): void {
const gameData: GameSceneData = {
difficulty,
audioPreferences: loadAudioPreferences(),
};
this.cameras.main.fadeOut(300, 0, 30, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('GameScene', gameData);
});
}
private openSettings(): void {
this.ensureSettingsSceneAvailable();
this.cameras.main.fadeOut(250, 0, 30, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('SettingsScene', { returnSceneKey: 'MenuScene' });
});
}
private ensureSettingsSceneAvailable(): void {
let existingScene: Phaser.Scene | null = null;
try {
existingScene = this.scene.get('SettingsScene');
} catch {
existingScene = null;
} }
if (existingScene instanceof SettingsScene) {
return;
}
if (existingScene) {
this.scene.remove('SettingsScene');
}
this.scene.add('SettingsScene', SettingsScene, false);
} }
} }

193
src/scenes/SettingsScene.ts Normal file
View File

@@ -0,0 +1,193 @@
import Phaser from 'phaser';
import {
AudioPreferences,
SettingsSceneData,
loadAudioPreferences,
saveAudioPreferences,
} from '../game/preferences';
type ToggleDefinition = {
key: keyof AudioPreferences;
label: string;
description: string;
};
type ToggleVisuals = {
background: Phaser.GameObjects.Rectangle;
statusText: Phaser.GameObjects.Text;
};
const TOGGLE_DEFINITIONS: ToggleDefinition[] = [
{
key: 'musicEnabled',
label: 'Musica del tavolo',
description: 'Controlla il tappeto sonoro di sottofondo durante la partita.',
},
{
key: 'effectsEnabled',
label: 'Effetti di gioco',
description: 'Attiva prese, scope e segnali sonori senza toccare la musica.',
},
];
export class SettingsScene extends Phaser.Scene {
private preferences: AudioPreferences = loadAudioPreferences();
private returnSceneKey = 'MenuScene';
private toggleVisuals = new Map<keyof AudioPreferences, ToggleVisuals>();
constructor() {
super({ key: 'SettingsScene' });
}
create(data?: SettingsSceneData): void {
const width = this.scale.width;
const height = this.scale.height;
this.preferences = loadAudioPreferences();
this.returnSceneKey = data?.returnSceneKey ?? 'MenuScene';
this.add.rectangle(0, 0, width, height, 0x123d22).setOrigin(0);
this.add.rectangle(width / 2, height / 2, width - 120, height - 120, 0x0d2216, 0.88)
.setStrokeStyle(2, 0xd9b75f, 0.45);
this.add.text(width / 2, 120, 'Impostazioni audio', {
fontFamily: 'Georgia, serif',
fontSize: '46px',
color: '#ffd700',
stroke: '#000000',
strokeThickness: 4,
resolution: 2,
}).setOrigin(0.5);
this.add.text(width / 2, 176, 'Musica ed effetti sono separati: ogni scelta viene salvata subito e sarà usata nelle prossime partite.', {
fontFamily: 'Georgia, serif',
fontSize: '19px',
color: '#d7ead1',
resolution: 2,
align: 'center',
wordWrap: { width: 760 },
}).setOrigin(0.5);
TOGGLE_DEFINITIONS.forEach((definition, index) => {
this.createToggleRow(definition, width / 2, 286 + index * 132, 760, 96);
});
this.add.text(width / 2, 556, 'Puoi tornare al menu in qualsiasi momento: la difficoltà si sceglie lì, laudio resta salvato qui.', {
fontFamily: 'Georgia, serif',
fontSize: '16px',
color: '#cfe5cd',
resolution: 2,
align: 'center',
wordWrap: { width: 720 },
}).setOrigin(0.5);
this.createButton(width / 2, height - 112, 280, 60, 'Torna al menu', 'Rientra alla schermata iniziale', () => {
this.returnToMenu();
});
}
private createToggleRow(definition: ToggleDefinition, x: number, y: number, width: number, height: number): void {
const row = this.add.rectangle(x, y, width, height, 0x173323, 0.96)
.setStrokeStyle(2, 0xcaa74a, 0.35)
.setInteractive({ useHandCursor: true });
this.add.text(x - width / 2 + 28, y - 16, definition.label, {
fontFamily: 'Georgia, serif',
fontSize: '24px',
color: '#ffffff',
resolution: 2,
}).setOrigin(0, 0.5);
this.add.text(x - width / 2 + 28, y + 14, definition.description, {
fontFamily: 'Georgia, serif',
fontSize: '16px',
color: '#d8ead2',
resolution: 2,
wordWrap: { width: 470 },
}).setOrigin(0, 0.5);
const toggleBackground = this.add.rectangle(x + width / 2 - 106, y, 148, 46, 0x356b39, 1)
.setStrokeStyle(2, 0xf5e1a4, 0.45);
const statusText = this.add.text(x + width / 2 - 106, y, '', {
fontFamily: 'Georgia, serif',
fontSize: '20px',
color: '#ffffff',
resolution: 2,
}).setOrigin(0.5);
this.toggleVisuals.set(definition.key, {
background: toggleBackground,
statusText,
});
row.on('pointerdown', () => {
this.togglePreference(definition.key);
});
row.on('pointerover', () => row.setFillStyle(0x1d402c, 1));
row.on('pointerout', () => row.setFillStyle(0x173323, 0.96));
this.refreshToggle(definition.key);
}
private createButton(
x: number,
y: number,
width: number,
height: number,
label: string,
subtitle: string,
onClick: () => void,
): void {
const button = this.add.rectangle(x, y, width, height, 0x1f6f78, 1)
.setStrokeStyle(2, 0xf5e1a4, 0.4)
.setInteractive({ useHandCursor: true });
this.add.text(x, y - 10, label, {
fontFamily: 'Georgia, serif',
fontSize: '21px',
color: '#ffffff',
stroke: '#000000',
strokeThickness: 2,
resolution: 2,
}).setOrigin(0.5);
this.add.text(x, y + 14, subtitle, {
fontFamily: 'Georgia, serif',
fontSize: '13px',
color: '#f7f1d5',
resolution: 2,
align: 'center',
}).setOrigin(0.5);
button.on('pointerover', () => button.setFillStyle(0x2f8f99));
button.on('pointerout', () => button.setFillStyle(0x1f6f78));
button.on('pointerdown', onClick);
}
private togglePreference(key: keyof AudioPreferences): void {
this.preferences = saveAudioPreferences({
...this.preferences,
[key]: !this.preferences[key],
});
this.refreshToggle(key);
}
private refreshToggle(key: keyof AudioPreferences): void {
const visuals = this.toggleVisuals.get(key);
if (!visuals) {
return;
}
const enabled = this.preferences[key];
visuals.background.setFillStyle(enabled ? 0x356b39 : 0x7a2f2f);
visuals.statusText.setText(enabled ? 'Attivo' : 'Disattivato');
}
private returnToMenu(): void {
this.cameras.main.fadeOut(250, 0, 30, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start(this.returnSceneKey);
});
}
}