diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bdf2a3a..8100cca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -> Last Updated: 2026-04-08T19:48:08.000Z +> Last Updated: 2026-04-09T20:59:51.000Z ## Overview @@ -8,14 +8,15 @@ |-----------|-------| | Primary language | TypeScript | | 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 | | 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` | | Build command | `npm run build` | | 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 @@ -27,13 +28,17 @@ scopone-phaser/ | | |- types.ts | | |- engine.ts | | |- card-tracker.ts +| | |- preferences.ts | | |- ai.ts | | |- ai-worker-protocol.ts | | |- ai-worker-client.ts -| | `- ai.worker.ts +| | |- ai.worker.ts +| | |- ai-benchmark.ts +| | `- ai-benchmark-fixtures.ts | `- scenes/ | |- BootScene.ts | |- MenuScene.ts +| |- SettingsScene.ts | `- GameScene.ts |- public/ |- android/ @@ -51,9 +56,9 @@ scopone-phaser/ | Directory | Purpose | |-----------|---------| -| `src/game/` | Rules engine, score calculation, imperfect-information tracking, AI heuristics, and master search | -| `src/scenes/` | Phaser scene lifecycle, menus, board rendering, interaction, HUD, audio, and FX | -| `public/` | Atlas metadata and other static assets loaded by Phaser | +| `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, settings UI, board rendering, interaction, HUD, audio, and FX | +| `public/` | Atlas metadata and static assets loaded by Phaser | | `android/` | Capacitor Android project, Gradle configuration, generated wrapper assets, and the native activity | | `docs/` | Architecture, code style, findings, and cache metadata | | `prompts/` | JIRA workflow artifacts and iteration state | @@ -66,46 +71,53 @@ Observed architectural patterns: | 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 | | 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 | -| Typed message protocol | `ai-worker-protocol.ts` defines worker request, progress, result, and error shapes | -| Imperfect-information search | `CardTracker` plus determinization sampling support the `master` AI tier | +| Typed message protocol | `ai-worker-protocol.ts` defines worker request, progress, result, and serialized error shapes | +| 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 ### `src/main.ts` - Creates the `Phaser.Game` instance. -- Registers `BootScene`, `MenuScene`, and `GameScene`. - 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` - 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. ### `src/game/engine.ts` - Builds and shuffles the 40-card deck. -- Creates a round state for four players with dealer-relative opening order. -- Implements 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. +- Creates a round state for four players with dealer-relative opening order and stable player labels. +- Implements Scopone capture rules where direct value matches take priority over subset-sum captures. +- 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` - Tracks cards visible through play and capture events without exposing hidden hands. - 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` - Exposes `chooseMove()` as the async AI entry point. - 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. -- Configures the current master profile with a 4600 ms budget, 10 samples, depth 6, and batch size 2. +- Uses role-aware heuristics, tracker-based inference, tactical priority scoring, determinization sampling, and alpha-beta search. +- 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` @@ -117,7 +129,7 @@ Observed architectural patterns: - 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' })`. -- 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` @@ -125,6 +137,12 @@ Observed architectural patterns: - Delegates move selection to `chooseMove()`. - 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` - Loads the card atlas and card back. @@ -133,13 +151,22 @@ Observed architectural patterns: ### `src/scenes/MenuScene.ts` -- Renders the title, rules summary, and difficulty selection. -- Starts `GameScene` with the chosen difficulty. +- Renders the title, compact rules summary, difficulty selection, and audio-settings entry point. +- 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` - 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. +- 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. ### `android/app/src/main/java/com/phaser/scopa/MainActivity.java` @@ -163,6 +190,7 @@ Observed architectural patterns: | 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 | | `vite` | `^5.0.0` | Dev server and production bundler | @@ -191,14 +219,21 @@ Observed architectural patterns: main.ts -> BootScene -> MenuScene - -> GameScene - -> engine.ts - -> types.ts - -> card-tracker.ts - -> ai-worker-client.ts - -> ai-worker-protocol.ts - -> ai.worker.ts - -> ai.ts + -> SettingsScene (opened on demand after dynamic registration) + -> GameScene + -> engine.ts + -> types.ts + -> preferences.ts + -> card-tracker.ts + -> ai-worker-client.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: @@ -209,16 +244,18 @@ Application-level dependency direction is one-way: ## Data Flow -1. `main.ts` creates the Phaser app and registers all scenes. -2. `BootScene` loads textures and starts `MenuScene`. -3. `MenuScene` passes the chosen difficulty to `GameScene`. -4. `GameScene.create()` creates a fresh `CardTracker`, constructs a new `GameState`, and starts the opening deal. -5. Human turns use pointer-driven card selection and `findCaptures()` output to choose legal captures. -6. AI turns call `AIWorkerClient.chooseMove(state, playerIdx, difficulty, tracker, onProgress)`. -7. `AIWorkerClient` posts a typed request to `ai.worker.ts`; if workers are unavailable, it reruns the same request in-thread. -8. `chooseMove()` returns a heuristic move for lower tiers or performs batched master search while emitting `AIDecisionProgress`. -9. `GameScene` updates the think bar from progress callbacks, executes the returned move, records tracker state, and advances turn order. -10. When every hand is empty, `engine.ts` finalizes scoring and `GameScene` presents the round or match outcome. +1. `main.ts` creates the Phaser app, installs fullscreen-on-first-input, and registers the scene list. +2. `BootScene` loads atlas assets and starts `MenuScene`. +3. `MenuScene` reads persisted audio preferences, lets the player choose difficulty, and can open `SettingsScene` for audio toggles. +4. `SettingsScene` writes audio preferences immediately and returns to `MenuScene`. +5. `GameScene.create()` normalizes incoming scene data, creates a fresh `CardTracker`, constructs a new `GameState`, and starts the opening deal. +6. Human turns use pointer-driven card selection and `findCaptures()` output to choose legal captures. +7. AI turns call `AIWorkerClient.chooseMove(state, playerIdx, difficulty, tracker, onProgress)`. +8. `AIWorkerClient` posts a typed request to `ai.worker.ts`; if workers are unavailable, it reruns the same request in-thread. +9. `chooseMove()` returns a heuristic move for lower tiers or performs batched master search while emitting `AIDecisionProgress`. +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 @@ -227,4 +264,5 @@ Application-level dependency direction is one-way: | `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 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 | diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index 2fedcf2..bcde09f 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -1,34 +1,40 @@ # Findings -> Last Updated: 2026-04-09T00:00:00.000Z +> Last Updated: 2026-04-09T20:59:51.000Z ## 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 -- 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/`. - `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`. - `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` 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. +- 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. -- 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. - The only repository-wide verification command supplied is `npx tsc --noEmit`. ## Potential Improvement Areas -- `GameScene.ts` still centralizes layout, turn flow, HUD updates, effects, and audio in one scene class, which raises maintenance cost. -- `ai.ts` still combines heuristic tiers, inference helpers, determinization, and alpha-beta evaluation in one module. -- Worker transport is isolated cleanly, but progress rendering remains coupled to scene-level UI concerns. -- A 4600 ms master search budget may still be noticeable on slower mobile devices even with batch yielding. -- There is no dedicated automated rules or AI test suite beyond type-checking. +- `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, move ordering, and alpha-beta evaluation in one module. +- 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. +- Worker transport is isolated cleanly, but progress rendering and fallback behavior remain coupled to scene-level UI concerns. +- 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. ## 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. - 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. -- 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 - `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. -- `master` orders legal moves with a quick evaluator, samples hidden hands, and scores them with alpha-beta search under the active deadline. +- `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 a dynamic deadline. - 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 @@ -59,11 +66,19 @@ Initializer refresh for SCOPONE-0009. The cached findings were stale relative to ### Scene / UI implementation snapshot - `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. -- 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. +### 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 ### 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. - 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. -- 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. -- 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`. -- `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. - -### SCOPONE-0009: Iteration 3 continuation notes (2026-04-09) - -- 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. +- `src/game/preferences.ts` is now the authoritative audio preference seam. It normalizes stored values and shields scenes from malformed storage state. +- `src/scenes/MenuScene.ts` now reads persisted audio preferences and exposes a dedicated settings entry point instead of keeping audio options implicit. +- `src/scenes/SettingsScene.ts` exists as a real scene and persists music and effects toggles independently through `saveAudioPreferences()`. +- `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. +- `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. diff --git a/src/game/preferences.ts b/src/game/preferences.ts new file mode 100644 index 0000000..5745e6f --- /dev/null +++ b/src/game/preferences.ts @@ -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; + + 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; +}; \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 86530bd..b41bd50 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,12 @@ import { BootScene } from './scenes/BootScene'; import { MenuScene } from './scenes/MenuScene'; import { GameScene } from './scenes/GameScene'; +class SettingsScene extends Phaser.Scene { + constructor() { + super({ key: 'SettingsScene' }); + } +} + const installFullscreenRequest = (host: HTMLElement): void => { const canRequestFullscreen = typeof document.fullscreenEnabled === 'boolean' @@ -48,7 +54,7 @@ const config: Phaser.Types.Core.GameConfig = { height: 720, backgroundColor: '#1a5c2a', parent: 'game', - scene: [BootScene, MenuScene, GameScene], + scene: [BootScene, MenuScene, GameScene, SettingsScene], scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index bae1aed..d3d2b64 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -7,6 +7,12 @@ import { import { AIMove, AIDecisionProgress } from '../game/ai'; import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client'; import { CardTracker } from '../game/card-tracker'; +import { + DEFAULT_AUDIO_PREFERENCES, + GameSceneData, + loadAudioPreferences, + normalizeAudioPreferences, +} from '../game/preferences'; // --------------------------------------------------------------------------- // 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 AI_MIN_THINK_MS = 1000; 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: // 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 musicGain: GainNode | null = null; private musicStarted = false; + private audioPreferences = DEFAULT_AUDIO_PREFERENCES; constructor() { super({ key: 'GameScene' }); @@ -110,13 +121,16 @@ export class GameScene extends Phaser.Scene { // Create // --------------------------------------------------------------------------- - create(data?: { difficulty?: Difficulty }): void { + create(data?: Partial): void { const W = this.scale.width; const H = this.scale.height; this.tableCenter = { x: W / 2, y: (H + SCOREBAR_H) / 2 + 10 }; // Read difficulty from scene data (MenuScene passes it) this.difficulty = data?.difficulty ?? 'advanced'; + this.audioPreferences = data?.audioPreferences + ? normalizeAudioPreferences(data.audioPreferences) + : loadAudioPreferences(); this.tracker = new CardTracker(); this.aiClient?.dispose(); this.aiClient = new AIWorkerClient(); @@ -467,12 +481,47 @@ export class GameScene extends Phaser.Scene { } 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) { - 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; } const cur = this.state.currentPlayer; const player = this.state.players[cur]; - this.setStatus(`Turno di ${player.name}`, { persist: true }); + this.setStatus(this.getTurnStatus(cur), { persist: true }); this.pulseLabel(cur); if (player.isHuman) { @@ -725,7 +774,7 @@ export class GameScene extends Phaser.Scene { } catch (error) { console.error('AI move failed', error); if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) { - this.setStatus('Errore durante la mossa AI'); + this.setStatus(this.getAiMoveErrorStatus(playerIdx)); } } finally { 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) { - 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); } 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.highlightCapture(captures[0]); } else { - this.setStatus('Scegli le carte da catturare'); + this.setStatus('Scegli quale presa vuoi fare.'); this.pendingCaptures = captures; this.highlightMultipleCaptures(captures); } @@ -878,7 +927,7 @@ export class GameScene extends Phaser.Scene { bg.lineStyle(2, color.stroke, 0.8); 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 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, }).setOrigin(0.5).setDepth(21); btn.on('pointerdown', () => this.confirmMove(this.selectedCard!, cap)); @@ -966,7 +1015,7 @@ export class GameScene extends Phaser.Scene { this.tweens.add({ targets: cardImg, x: this.tableCenter.x, y: this.tableCenter.y, - duration: 200, ease: 'Power2', + duration: PLAYED_CARD_TRAVEL_MS, ease: 'Power2', onComplete: () => { this.spawnCaptureEffect(this.tableCenter.x, this.tableCenter.y, isSettebello); @@ -991,7 +1040,7 @@ export class GameScene extends Phaser.Scene { this.tweens.add({ targets: img, x: pilePos.x, y: pilePos.y, alpha: 0, - duration: 240, delay: 30, + duration: CAPTURE_COLLAPSE_MS, delay: CAPTURE_COLLAPSE_DELAY_MS, onComplete: () => { img.setVisible(false); done++; @@ -1022,7 +1071,7 @@ export class GameScene extends Phaser.Scene { this.tweens.add({ targets: cardImg, 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), }); } @@ -1300,6 +1349,7 @@ export class GameScene extends Phaser.Scene { // --------------------------------------------------------------------------- private startMusic(): void { + if (!this.audioPreferences.musicEnabled) return; if (this.musicStarted) return; this.musicStarted = true; try { @@ -1374,6 +1424,7 @@ export class GameScene extends Phaser.Scene { } private playSfx(type: 'card_play' | 'capture' | 'scopa' | 'settebello'): void { + if (!this.audioPreferences.effectsEnabled) return; if (!this.audioCtx) return; const ctx = this.audioCtx; const now = ctx.currentTime; @@ -1406,6 +1457,7 @@ export class GameScene extends Phaser.Scene { } private stopMusic(): void { + if (!this.audioPreferences.musicEnabled) return; if (this.musicGain && this.audioCtx) { 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); 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'], - [`Team B +${t1.roundPoints} pt → ${t1.totalPoints} totali`, '#ffaaaa'], + [`Squadra tua +${t0.roundPoints} pt → ${t0.totalPoints} totali`, '#aaffaa'], + [`Avversari +${t1.roundPoints} pt → ${t1.totalPoints} totali`, '#ffaaaa'], ['', ''], [`Carte A=${t0.cards} B=${t1.cards} ${pointStr(bd.cartePoint)}`, '#ffffff'], [`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'], [`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.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', stroke: '#000', strokeThickness: 6, }).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', color: win ? '#aaffaa' : '#ffaaaa', }).setOrigin(0.5).setDepth(42); @@ -1584,5 +1636,5 @@ function cardName(card: Card): 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'; } diff --git a/src/scenes/MenuScene.ts b/src/scenes/MenuScene.ts index 76c17ad..c58c576 100644 --- a/src/scenes/MenuScene.ts +++ b/src/scenes/MenuScene.ts @@ -1,5 +1,47 @@ import Phaser from 'phaser'; 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 { constructor() { @@ -7,97 +49,244 @@ export class MenuScene extends Phaser.Scene { } create(): void { - const W = this.scale.width; - const H = this.scale.height; + const width = this.scale.width; + const height = this.scale.height; + const audioPreferences = loadAudioPreferences(); - // Background felt - this.add.rectangle(0, 0, W, H, 0x1a5c2a).setOrigin(0); + this.drawBackground(width, height); + this.drawDecorativeCards(width, height); + this.ensureSettingsSceneAvailable(); - // Title - this.add.text(W / 2, H * 0.18, 'Scopone Scientifico', { + this.add.text(width / 2, 92, 'Scopone Scientifico', TITLE_STYLE).setOrigin(0.5); + this.add.text(width / 2, 142, 'Due squadre da due, una mano intera e lettura del tavolo fino all’ultimo punto.', { fontFamily: 'Georgia, serif', - fontSize: '52px', - color: '#ffd700', - stroke: '#000000', - strokeThickness: 4, + fontSize: '21px', + color: '#d9f2d2', resolution: 2, }).setOrigin(0.5); - this.add.text(W / 2, H * 0.30, '2 vs 2 · Tu + Compagno vs 2 AI', { - fontFamily: 'serif', - fontSize: '22px', - color: '#ccffcc', - resolution: 2, - }).setOrigin(0.5); + this.createRulesPanel(width, height); + this.createControlPanel(width, height, audioPreferences); + } - // Rules summary - const rules = [ - '40 carte Napoletane · 10 a testa', - 'Cattura per valore o somma', - 'Punteggio: Carte · Denari · Settebello · Primiera · Scope', - 'Prima squadra a 11 punti vince', + private drawBackground(width: number, height: number): void { + this.add.rectangle(0, 0, width, height, 0x123d22).setOrigin(0); + this.add.rectangle(width / 2, height / 2, width - 60, height - 60, 0x0b2916, 0.28) + .setStrokeStyle(2, 0xe8c25d, 0.35); + this.add.rectangle(width * 0.34, height * 0.58, width * 0.46, height * 0.50, 0x0d2215, 0.82) + .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, { - fontFamily: 'serif', - fontSize: '17px', - color: '#ffffff', - resolution: 2, - }).setOrigin(0.5); + + positions.forEach(([x, y]) => { + this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15)).setAlpha(0.9); }); + } - // Difficulty selection label - this.add.text(W / 2, H * 0.60, 'Scegli la difficoltà:', { - fontFamily: 'Georgia, serif', - fontSize: '20px', - color: '#ffd700', - resolution: 2, - }).setOrigin(0.5); - - // Difficulty buttons - const difficulties: Array<{ label: string; value: Difficulty; color: number; hoverColor: number }> = [ - { label: 'Principiante', value: 'beginner', color: 0x4caf50, hoverColor: 0x66bb6a }, - { label: 'Avanzato', value: 'advanced', color: 0xff9800, hoverColor: 0xffb74d }, - { label: 'Maestro', value: 'master', color: 0xf44336, hoverColor: 0xef5350 }, + private createRulesPanel(width: number, height: number): void { + const panelX = width * 0.12; + const panelY = 206; + const sections: RuleSection[] = [ + { + heading: 'Tavolo e squadre', + lines: [ + 'Si gioca in coppia: Sud e Nord contro Ovest ed Est.', + 'Nel vero scopone si distribuiscono tutte le 40 carte, 10 a giocatore.', + ], + }, + { + 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; - const btnHeight = 50; - const totalWidth = difficulties.length * btnWidth + (difficulties.length - 1) * 20; - const startX = (W - totalWidth) / 2 + btnWidth / 2; + this.add.text(panelX, panelY, 'Regolamento essenziale', PANEL_TITLE_STYLE).setOrigin(0, 0.5); - difficulties.forEach((d, i) => { - const x = startX + i * (btnWidth + 20); - const y = H * 0.70; - - const btn = this.add.rectangle(x, y, btnWidth, btnHeight, d.color, 1) - .setInteractive({ useHandCursor: true }); - - this.add.text(x, y, d.label, { + let currentY = panelY + 44; + sections.forEach((section) => { + this.add.text(panelX, currentY, section.heading, { fontFamily: 'Georgia, serif', fontSize: '20px', color: '#ffffff', - stroke: '#000000', - strokeThickness: 2, resolution: 2, - }).setOrigin(0.5); + }).setOrigin(0, 0.5); + currentY += 30; - btn.on('pointerover', () => btn.setFillStyle(d.hoverColor)); - btn.on('pointerout', () => btn.setFillStyle(d.color)); - btn.on('pointerdown', () => { - this.cameras.main.fadeOut(300, 0, 30, 0); - this.cameras.main.once('camerafadeoutcomplete', () => { - this.scene.start('GameScene', { difficulty: d.value }); - }); + section.lines.forEach((line) => { + this.add.text(panelX, currentY, `• ${line}`, { + ...BODY_STYLE, + wordWrap: { width: width * 0.40 }, + lineSpacing: 5, + }).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): 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 positions = [ - [W * 0.08, H * 0.85], [W * 0.14, H * 0.87], [W * 0.92, H * 0.85], [W * 0.86, H * 0.87], - ]; - for (const [x, y] of positions) { - this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15)); + const musicLabel = audioPreferences.musicEnabled ? 'attiva' : 'disattivata'; + const effectsLabel = audioPreferences.effectsEnabled ? 'attivi' : 'disattivati'; + + this.add.text(panelCenterX, height - 154, `Musica ${musicLabel} · Effetti ${effectsLabel}`, { + ...BODY_STYLE, + 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); } } diff --git a/src/scenes/SettingsScene.ts b/src/scenes/SettingsScene.ts new file mode 100644 index 0000000..f51f273 --- /dev/null +++ b/src/scenes/SettingsScene.ts @@ -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(); + + 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ì, l’audio 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); + }); + } +} \ No newline at end of file