feat(SCOPONE-0010): improve capture pacing and settings
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
94
src/game/preferences.ts
Normal file
94
src/game/preferences.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<GameSceneData>): 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';
|
||||
}
|
||||
|
||||
@@ -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<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 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);
|
||||
}
|
||||
}
|
||||
|
||||
193
src/scenes/SettingsScene.ts
Normal file
193
src/scenes/SettingsScene.ts
Normal 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ì, 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user