feat(SCOPONE-0010): redesign main menu layout
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# Findings
|
# Findings
|
||||||
|
|
||||||
> Last Updated: 2026-04-09T20:59:51.000Z
|
> Last Updated: 2026-04-10T09:39:37.000Z
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
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.
|
Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDINGS.md` no longer matched its recorded hash and still contained an outdated note about a `SettingsScene` placeholder in `main.ts`. The observations below reflect the current repository state; `docs/ARCHITECTURE.md` and `docs/CODE_STYLE.md` were revalidated unchanged against the live source.
|
||||||
|
|
||||||
## Codebase Observations
|
## Codebase Observations
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN
|
|||||||
- The project is structurally split between framework-free gameplay modules in `src/game/` and Phaser scene code in `src/scenes/`.
|
- The project is structurally split between framework-free gameplay modules in `src/game/` and Phaser scene code in `src/scenes/`.
|
||||||
- `src/scenes/GameScene.ts` and `src/game/ai.ts` remain the two largest concentrations of application logic.
|
- `src/scenes/GameScene.ts` and `src/game/ai.ts` remain the two largest concentrations of application logic.
|
||||||
- A dedicated audio preference path now exists: `src/game/preferences.ts`, `src/scenes/MenuScene.ts`, and `src/scenes/SettingsScene.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.
|
- `main.ts` now imports and registers `SettingsScene` directly in the Phaser scene list; the earlier placeholder-scene workaround is no longer present.
|
||||||
- The AI transport layer is a stable three-file path: `ai-worker-protocol.ts`, `ai-worker-client.ts`, and `ai.worker.ts`.
|
- The AI transport layer is a stable three-file path: `ai-worker-protocol.ts`, `ai-worker-client.ts`, and `ai.worker.ts`.
|
||||||
- The AI exposes three difficulty levels: `beginner`, `advanced`, and `master`.
|
- The AI exposes three difficulty levels: `beginner`, `advanced`, and `master`.
|
||||||
- `advanced` and `master` both use `CardTracker` to reason about unseen cards without directly reading hidden hands.
|
- `advanced` and `master` both use `CardTracker` to reason about unseen cards without directly reading hidden hands.
|
||||||
@@ -20,6 +20,7 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN
|
|||||||
- `GameScene` consumes AI progress callbacks to update an on-screen think bar while a worker request is running.
|
- `GameScene` consumes AI progress callbacks to update an on-screen think bar while a worker request is running.
|
||||||
- `GameScene` now enforces `AI_MIN_THINK_MS = 1000` and `MOVE_OUTCOME_STATUS_MS = 2000` through timer-backed scene logic.
|
- `GameScene` now enforces `AI_MIN_THINK_MS = 1000` and `MOVE_OUTCOME_STATUS_MS = 2000` through timer-backed scene logic.
|
||||||
- `AIWorkerClient` fails over pending work to in-thread `chooseMove()` if worker creation, posting, or deserialization fails.
|
- `AIWorkerClient` fails over pending work to in-thread `chooseMove()` if worker creation, posting, or deserialization fails.
|
||||||
|
- `MenuScene` now includes a responsive layout path for compact viewports, driven by calculated panel bounds and camera zoom instead of a fixed desktop-only composition.
|
||||||
- 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 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 current benchmark contract is iteration 5: 13 fixed fixtures, 6 critical concepts, and 48 self-play matches.
|
||||||
- The Android wrapper targets SDK 36 with `minSdkVersion` 24 and applies immersive mode from the native activity.
|
- The Android wrapper targets SDK 36 with `minSdkVersion` 24 and applies immersive mode from the native activity.
|
||||||
@@ -31,7 +32,7 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN
|
|||||||
|
|
||||||
- `GameScene.ts` still centralizes layout, turn flow, HUD updates, effects, audio, status messaging, and AI orchestration in one scene class.
|
- `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.
|
- `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.
|
- `MenuScene.ts` now carries substantial responsive layout and decorative rendering logic in the same scene that handles navigation and difficulty selection.
|
||||||
- Worker transport is isolated cleanly, but progress rendering and fallback behavior remain coupled to scene-level UI concerns.
|
- 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.
|
- 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.
|
- There is no dedicated automated rules test suite beyond type-checking and the AI benchmark harness.
|
||||||
@@ -66,7 +67,8 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN
|
|||||||
### Scene / UI implementation snapshot
|
### Scene / UI implementation snapshot
|
||||||
|
|
||||||
- `BootScene` loads atlas assets and presents a simple loading bar.
|
- `BootScene` loads atlas assets and presents a simple loading bar.
|
||||||
- `MenuScene` now exposes both difficulty selection and a dedicated entry point into `SettingsScene`.
|
- `main.ts` registers `BootScene`, `MenuScene`, `GameScene`, and `SettingsScene` directly in the Phaser game config.
|
||||||
|
- `MenuScene` now exposes both difficulty selection and a dedicated entry point into `SettingsScene`, with a separate compact-layout branch for smaller viewports.
|
||||||
- `SettingsScene` persists music and effects toggles immediately through `saveAudioPreferences()`.
|
- `SettingsScene` persists music and effects toggles immediately through `saveAudioPreferences()`.
|
||||||
- `GameScene` reads normalized audio preferences from scene data or persisted storage before match start.
|
- `GameScene` reads normalized audio preferences from scene data or persisted storage before match start.
|
||||||
- `GameScene` tracks played and captured cards in `CardTracker` as the round evolves.
|
- `GameScene` tracks played and captured cards in `CardTracker` as the round evolves.
|
||||||
@@ -140,4 +142,11 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN
|
|||||||
- `src/scenes/SettingsScene.ts` exists as a real scene and persists music and effects toggles independently through `saveAudioPreferences()`.
|
- `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/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.
|
- `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.
|
- `src/main.ts` now imports and registers `SettingsScene` directly; the earlier placeholder-scene note is no longer accurate.
|
||||||
|
|
||||||
|
### SCOPONE-0010: Phaser scene-manager and resize notes (2026-04-10)
|
||||||
|
|
||||||
|
- Source: Context7 `/websites/phaser_io_api-documentation`, queries `Phaser 3.87 ScenePlugin add remove get duplicate key behavior and Scale Manager resize event for responsive UI layout in scenes` and `Phaser 3.87 SceneManager add scene duplicate key error getScene get key existing scene unique key documentation`.
|
||||||
|
- `SceneManager.add(key, ...)` requires a unique scene key; replacing a scene under the same key should remove the existing scene first rather than attempting a duplicate add.
|
||||||
|
- `SceneManager.remove(key)` clears the scene key from the cache and destroys that scene's systems, so the current `MenuScene.ensureSettingsSceneAvailable()` pattern is intentionally destructive when it replaces the placeholder scene.
|
||||||
|
- Phaser's resize path dispatches resize events from the Scale Manager / renderer when the display changes size, which is the framework-supported hook for responsive scene relayout if this iteration introduces viewport-aware menu composition.
|
||||||
|
|||||||
@@ -2,12 +2,7 @@ import Phaser from 'phaser';
|
|||||||
import { BootScene } from './scenes/BootScene';
|
import { BootScene } from './scenes/BootScene';
|
||||||
import { MenuScene } from './scenes/MenuScene';
|
import { MenuScene } from './scenes/MenuScene';
|
||||||
import { GameScene } from './scenes/GameScene';
|
import { GameScene } from './scenes/GameScene';
|
||||||
|
import { SettingsScene } from './scenes/SettingsScene';
|
||||||
class SettingsScene extends Phaser.Scene {
|
|
||||||
constructor() {
|
|
||||||
super({ key: 'SettingsScene' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const installFullscreenRequest = (host: HTMLElement): void => {
|
const installFullscreenRequest = (host: HTMLElement): void => {
|
||||||
const canRequestFullscreen =
|
const canRequestFullscreen =
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { Difficulty } from '../game/types';
|
import { Difficulty } from '../game/types';
|
||||||
import { GameSceneData, loadAudioPreferences } from '../game/preferences';
|
import { GameSceneData, loadAudioPreferences } from '../game/preferences';
|
||||||
import { SettingsScene } from './SettingsScene';
|
|
||||||
|
|
||||||
type MenuButtonPalette = {
|
type MenuButtonPalette = {
|
||||||
base: number;
|
base: number;
|
||||||
@@ -20,6 +19,62 @@ type RuleSection = {
|
|||||||
lines: string[];
|
lines: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PanelBounds = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DecorativeCardLayout = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
angle: number;
|
||||||
|
scale: number;
|
||||||
|
alpha: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MenuLayout = {
|
||||||
|
frameInset: number;
|
||||||
|
isCompactViewport: boolean;
|
||||||
|
cameraZoom: number;
|
||||||
|
visibleBounds: PanelBounds;
|
||||||
|
titleX: number;
|
||||||
|
titleY: number;
|
||||||
|
titleFontSize: number;
|
||||||
|
subtitleY: number;
|
||||||
|
subtitleFontSize: number;
|
||||||
|
subtitleWrapWidth: number;
|
||||||
|
panelPaddingX: number;
|
||||||
|
panelPaddingY: number;
|
||||||
|
panelTitleFontSize: number;
|
||||||
|
rulesHeadingFontSize: number;
|
||||||
|
rulesBodyFontSize: number;
|
||||||
|
rulesLineSpacing: number;
|
||||||
|
rulesLineGap: number;
|
||||||
|
rulesSectionGap: number;
|
||||||
|
rulesWrapWidth: number;
|
||||||
|
footerFontSize: number;
|
||||||
|
controlIntroFontSize: number;
|
||||||
|
controlIntroWrapWidth: number;
|
||||||
|
buttonWidth: number;
|
||||||
|
buttonHeight: number;
|
||||||
|
buttonGap: number;
|
||||||
|
buttonTextWrapWidth: number;
|
||||||
|
buttonLabelFontSize: number;
|
||||||
|
buttonSubtitleFontSize: number;
|
||||||
|
buttonLabelOffset: number;
|
||||||
|
buttonSubtitleOffset: number;
|
||||||
|
showButtonSubtitle: boolean;
|
||||||
|
rulesPanel: PanelBounds;
|
||||||
|
controlPanel: PanelBounds;
|
||||||
|
decorativeCards: DecorativeCardLayout[];
|
||||||
|
};
|
||||||
|
|
||||||
const TITLE_STYLE: Phaser.Types.GameObjects.Text.TextStyle = {
|
const TITLE_STYLE: Phaser.Types.GameObjects.Text.TextStyle = {
|
||||||
fontFamily: 'Georgia, serif',
|
fontFamily: 'Georgia, serif',
|
||||||
fontSize: '52px',
|
fontSize: '52px',
|
||||||
@@ -52,107 +107,335 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
const width = this.scale.width;
|
const width = this.scale.width;
|
||||||
const height = this.scale.height;
|
const height = this.scale.height;
|
||||||
const audioPreferences = loadAudioPreferences();
|
const audioPreferences = loadAudioPreferences();
|
||||||
|
const layout = this.createLayout(width, height);
|
||||||
|
|
||||||
this.drawBackground(width, height);
|
this.cameras.main.setZoom(layout.cameraZoom);
|
||||||
this.drawDecorativeCards(width, height);
|
this.cameras.main.centerOn(width / 2, height / 2);
|
||||||
this.ensureSettingsSceneAvailable();
|
|
||||||
|
|
||||||
this.add.text(width / 2, 92, 'Scopone Scientifico', TITLE_STYLE).setOrigin(0.5);
|
this.drawBackground(width, height, layout);
|
||||||
this.add.text(width / 2, 142, 'Due squadre da due, una mano intera e lettura del tavolo fino all’ultimo punto.', {
|
this.drawDecorativeCards(layout);
|
||||||
fontFamily: 'Georgia, serif',
|
|
||||||
fontSize: '21px',
|
this.add.text(layout.titleX, layout.titleY, 'Scopone Scientifico', {
|
||||||
color: '#d9f2d2',
|
...TITLE_STYLE,
|
||||||
resolution: 2,
|
fontSize: `${layout.titleFontSize}px`,
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
this.createRulesPanel(width, height);
|
if (!layout.isCompactViewport) {
|
||||||
this.createControlPanel(width, height, audioPreferences);
|
this.add.text(layout.titleX, layout.subtitleY, 'Due squadre da due, una mano intera e lettura del tavolo fino all’ultimo punto.', {
|
||||||
|
fontFamily: 'Georgia, serif',
|
||||||
|
fontSize: `${layout.subtitleFontSize}px`,
|
||||||
|
color: '#d9f2d2',
|
||||||
|
resolution: 2,
|
||||||
|
align: 'center',
|
||||||
|
wordWrap: { width: layout.subtitleWrapWidth },
|
||||||
|
}).setOrigin(0.5, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.createRulesPanel(layout);
|
||||||
|
this.createControlPanel(layout, audioPreferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawBackground(width: number, height: number): void {
|
private createLayout(width: number, height: number): MenuLayout {
|
||||||
|
const viewportWidth = this.scale.parentSize.width || width;
|
||||||
|
const viewportHeight = this.scale.parentSize.height || height;
|
||||||
|
const displayWidth = this.scale.displaySize.width || width;
|
||||||
|
const displayHeight = this.scale.displaySize.height || height;
|
||||||
|
const viewportAspect = viewportWidth / Math.max(viewportHeight, 1);
|
||||||
|
const displayScale = Math.min(displayWidth / width, displayHeight / height);
|
||||||
|
const isCompactViewport = viewportWidth <= 720 || viewportAspect < 1;
|
||||||
|
const cameraZoom = isCompactViewport
|
||||||
|
? Phaser.Math.Clamp(0.43 / Math.max(displayScale, 0.001), 1.38, 1.46)
|
||||||
|
: 1;
|
||||||
|
const visibleBounds = this.createPanelBounds(
|
||||||
|
(width - width / cameraZoom) / 2,
|
||||||
|
(height - height / cameraZoom) / 2,
|
||||||
|
width / cameraZoom,
|
||||||
|
height / cameraZoom,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isCompactViewport) {
|
||||||
|
const frameInset = 22;
|
||||||
|
const panelGap = 12;
|
||||||
|
const panelPaddingX = 18;
|
||||||
|
const panelPaddingY = 14;
|
||||||
|
const contentWidth = visibleBounds.width - frameInset * 2;
|
||||||
|
const contentTop = visibleBounds.y + 74;
|
||||||
|
const contentBottom = visibleBounds.bottom - 14;
|
||||||
|
const contentHeight = contentBottom - contentTop;
|
||||||
|
const rulesHeight = Math.round(contentHeight * 0.25);
|
||||||
|
const controlHeight = contentHeight - rulesHeight - panelGap;
|
||||||
|
const rulesPanel = this.createPanelBounds(
|
||||||
|
visibleBounds.x + frameInset,
|
||||||
|
contentTop,
|
||||||
|
contentWidth,
|
||||||
|
rulesHeight,
|
||||||
|
);
|
||||||
|
const controlPanel = this.createPanelBounds(
|
||||||
|
visibleBounds.x + frameInset,
|
||||||
|
rulesPanel.bottom + panelGap,
|
||||||
|
contentWidth,
|
||||||
|
controlHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
frameInset,
|
||||||
|
isCompactViewport,
|
||||||
|
cameraZoom,
|
||||||
|
visibleBounds,
|
||||||
|
titleX: width / 2,
|
||||||
|
titleY: visibleBounds.y + 28,
|
||||||
|
titleFontSize: 34,
|
||||||
|
subtitleY: visibleBounds.y + 56,
|
||||||
|
subtitleFontSize: 18,
|
||||||
|
subtitleWrapWidth: contentWidth - 32,
|
||||||
|
panelPaddingX,
|
||||||
|
panelPaddingY,
|
||||||
|
panelTitleFontSize: 19,
|
||||||
|
rulesHeadingFontSize: 14,
|
||||||
|
rulesBodyFontSize: 15,
|
||||||
|
rulesLineSpacing: 2,
|
||||||
|
rulesLineGap: 6,
|
||||||
|
rulesSectionGap: 8,
|
||||||
|
rulesWrapWidth: rulesPanel.width - panelPaddingX * 2,
|
||||||
|
footerFontSize: 12,
|
||||||
|
controlIntroFontSize: 13,
|
||||||
|
controlIntroWrapWidth: controlPanel.width - panelPaddingX * 2,
|
||||||
|
buttonWidth: controlPanel.width - panelPaddingX * 2,
|
||||||
|
buttonHeight: 38,
|
||||||
|
buttonGap: 6,
|
||||||
|
buttonTextWrapWidth: controlPanel.width - panelPaddingX * 2 - 24,
|
||||||
|
buttonLabelFontSize: 20,
|
||||||
|
buttonSubtitleFontSize: 12,
|
||||||
|
buttonLabelOffset: 0,
|
||||||
|
buttonSubtitleOffset: 0,
|
||||||
|
showButtonSubtitle: false,
|
||||||
|
rulesPanel,
|
||||||
|
controlPanel,
|
||||||
|
decorativeCards: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameInset = 34;
|
||||||
|
const panelGap = 34;
|
||||||
|
const contentTop = 186;
|
||||||
|
const contentBottom = height - 48;
|
||||||
|
const contentHeight = contentBottom - contentTop;
|
||||||
|
const contentWidth = width - frameInset * 2;
|
||||||
|
const rulesWidth = Math.round(contentWidth * 0.57);
|
||||||
|
const controlWidth = contentWidth - rulesWidth - panelGap;
|
||||||
|
const panelPaddingX = 34;
|
||||||
|
const panelPaddingY = 28;
|
||||||
|
const rulesPanel = this.createPanelBounds(frameInset, contentTop, rulesWidth, contentHeight);
|
||||||
|
const controlPanel = this.createPanelBounds(rulesPanel.right + panelGap, contentTop, controlWidth, contentHeight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
frameInset,
|
||||||
|
isCompactViewport,
|
||||||
|
cameraZoom,
|
||||||
|
visibleBounds,
|
||||||
|
titleX: width / 2,
|
||||||
|
titleY: 82,
|
||||||
|
titleFontSize: 52,
|
||||||
|
subtitleY: 124,
|
||||||
|
subtitleFontSize: 21,
|
||||||
|
subtitleWrapWidth: width * 0.46,
|
||||||
|
panelPaddingX,
|
||||||
|
panelPaddingY,
|
||||||
|
panelTitleFontSize: 24,
|
||||||
|
rulesHeadingFontSize: 20,
|
||||||
|
rulesBodyFontSize: 17,
|
||||||
|
rulesLineSpacing: 5,
|
||||||
|
rulesLineGap: 12,
|
||||||
|
rulesSectionGap: 18,
|
||||||
|
rulesWrapWidth: rulesPanel.width - panelPaddingX * 2,
|
||||||
|
footerFontSize: 16,
|
||||||
|
controlIntroFontSize: 16,
|
||||||
|
controlIntroWrapWidth: controlPanel.width - panelPaddingX * 2,
|
||||||
|
buttonWidth: controlPanel.width - panelPaddingX * 2,
|
||||||
|
buttonHeight: 66,
|
||||||
|
buttonGap: 18,
|
||||||
|
buttonTextWrapWidth: controlPanel.width - panelPaddingX * 2 - 28,
|
||||||
|
buttonLabelFontSize: 22,
|
||||||
|
buttonSubtitleFontSize: 13,
|
||||||
|
buttonLabelOffset: -10,
|
||||||
|
buttonSubtitleOffset: 15,
|
||||||
|
showButtonSubtitle: true,
|
||||||
|
rulesPanel,
|
||||||
|
controlPanel,
|
||||||
|
decorativeCards: [
|
||||||
|
{
|
||||||
|
x: frameInset + 70,
|
||||||
|
y: 104,
|
||||||
|
angle: -18,
|
||||||
|
scale: 0.072,
|
||||||
|
alpha: 0.72,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: frameInset + 132,
|
||||||
|
y: 136,
|
||||||
|
angle: -6,
|
||||||
|
scale: 0.069,
|
||||||
|
alpha: 0.64,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: width - frameInset - 70,
|
||||||
|
y: 104,
|
||||||
|
angle: 18,
|
||||||
|
scale: 0.072,
|
||||||
|
alpha: 0.72,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: width - frameInset - 132,
|
||||||
|
y: 136,
|
||||||
|
angle: 6,
|
||||||
|
scale: 0.069,
|
||||||
|
alpha: 0.64,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPanelBounds(x: number, y: number, width: number, height: number): PanelBounds {
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
centerX: x + width / 2,
|
||||||
|
centerY: y + height / 2,
|
||||||
|
right: x + width,
|
||||||
|
bottom: y + height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawBackground(width: number, height: number, layout: MenuLayout): void {
|
||||||
this.add.rectangle(0, 0, width, height, 0x123d22).setOrigin(0);
|
this.add.rectangle(0, 0, width, height, 0x123d22).setOrigin(0);
|
||||||
this.add.rectangle(width / 2, height / 2, width - 60, height - 60, 0x0b2916, 0.28)
|
this.add.rectangle(
|
||||||
|
layout.visibleBounds.centerX,
|
||||||
|
layout.visibleBounds.centerY,
|
||||||
|
layout.visibleBounds.width - layout.frameInset * 2,
|
||||||
|
layout.visibleBounds.height - layout.frameInset * 2,
|
||||||
|
0x0b2916,
|
||||||
|
0.28,
|
||||||
|
)
|
||||||
.setStrokeStyle(2, 0xe8c25d, 0.35);
|
.setStrokeStyle(2, 0xe8c25d, 0.35);
|
||||||
this.add.rectangle(width * 0.34, height * 0.58, width * 0.46, height * 0.50, 0x0d2215, 0.82)
|
|
||||||
|
this.add.rectangle(
|
||||||
|
layout.rulesPanel.centerX,
|
||||||
|
layout.rulesPanel.centerY,
|
||||||
|
layout.rulesPanel.width,
|
||||||
|
layout.rulesPanel.height,
|
||||||
|
0x0d2215,
|
||||||
|
0.84,
|
||||||
|
)
|
||||||
.setStrokeStyle(2, 0xc8a445, 0.4);
|
.setStrokeStyle(2, 0xc8a445, 0.4);
|
||||||
this.add.rectangle(width * 0.77, height * 0.58, width * 0.24, height * 0.50, 0x10261b, 0.86)
|
|
||||||
|
this.add.rectangle(
|
||||||
|
layout.controlPanel.centerX,
|
||||||
|
layout.controlPanel.centerY,
|
||||||
|
layout.controlPanel.width,
|
||||||
|
layout.controlPanel.height,
|
||||||
|
0x10261b,
|
||||||
|
0.88,
|
||||||
|
)
|
||||||
.setStrokeStyle(2, 0xc8a445, 0.4);
|
.setStrokeStyle(2, 0xc8a445, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawDecorativeCards(width: number, height: number): void {
|
private drawDecorativeCards(layout: MenuLayout): void {
|
||||||
const positions = [
|
layout.decorativeCards.forEach((card) => {
|
||||||
[width * 0.08, height * 0.85],
|
this.add.image(card.x, card.y, 'retro').setScale(card.scale).setAngle(card.angle).setAlpha(card.alpha);
|
||||||
[width * 0.14, height * 0.87],
|
|
||||||
[width * 0.92, height * 0.85],
|
|
||||||
[width * 0.86, height * 0.87],
|
|
||||||
];
|
|
||||||
|
|
||||||
positions.forEach(([x, y]) => {
|
|
||||||
this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15)).setAlpha(0.9);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createRulesPanel(width: number, height: number): void {
|
private createRulesPanel(layout: MenuLayout): void {
|
||||||
const panelX = width * 0.12;
|
const panelX = layout.rulesPanel.x + layout.panelPaddingX;
|
||||||
const panelY = 206;
|
const panelY = layout.rulesPanel.y + layout.panelPaddingY;
|
||||||
const sections: RuleSection[] = [
|
const sections: RuleSection[] = layout.isCompactViewport
|
||||||
{
|
? []
|
||||||
heading: 'Tavolo e squadre',
|
: [
|
||||||
lines: [
|
{
|
||||||
'Si gioca in coppia: Sud e Nord contro Ovest ed Est.',
|
heading: 'Tavolo e squadre',
|
||||||
'Nel vero scopone si distribuiscono tutte le 40 carte, 10 a giocatore.',
|
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.',
|
heading: 'Come si prende',
|
||||||
'Se sul tavolo c’è una presa diretta dello stesso valore, quella ha sempre la precedenza.',
|
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.',
|
heading: 'Punteggio partita',
|
||||||
'La sfida prosegue mano dopo mano finché una squadra arriva ad almeno 11 punti.',
|
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.',
|
||||||
];
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
this.add.text(panelX, panelY, 'Regolamento essenziale', PANEL_TITLE_STYLE).setOrigin(0, 0.5);
|
this.add.text(panelX, panelY, layout.isCompactViewport ? 'Regole rapide' : 'Regolamento essenziale', {
|
||||||
|
...PANEL_TITLE_STYLE,
|
||||||
|
fontSize: `${layout.panelTitleFontSize}px`,
|
||||||
|
}).setOrigin(0, 0);
|
||||||
|
|
||||||
|
let currentY = panelY + layout.panelTitleFontSize + (layout.isCompactViewport ? 10 : 18);
|
||||||
|
|
||||||
|
if (layout.isCompactViewport) {
|
||||||
|
const summaryLines = [
|
||||||
|
'• Sud e Nord contro Ovest ed Est, con 10 carte per giocatore.',
|
||||||
|
'• Prendi lo stesso valore o una somma: la presa diretta viene prima.',
|
||||||
|
'• Carte, denari, settebello, primiera e scope: vince chi tocca 11 punti.',
|
||||||
|
];
|
||||||
|
|
||||||
|
summaryLines.forEach((line) => {
|
||||||
|
const ruleText = this.add.text(panelX, currentY, line, {
|
||||||
|
...BODY_STYLE,
|
||||||
|
fontSize: `${layout.rulesBodyFontSize}px`,
|
||||||
|
wordWrap: { width: layout.rulesWrapWidth },
|
||||||
|
lineSpacing: layout.rulesLineSpacing,
|
||||||
|
}).setOrigin(0, 0);
|
||||||
|
currentY += ruleText.height + layout.rulesLineGap;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let currentY = panelY + 44;
|
|
||||||
sections.forEach((section) => {
|
sections.forEach((section) => {
|
||||||
this.add.text(panelX, currentY, section.heading, {
|
const sectionTitle = this.add.text(panelX, currentY, section.heading, {
|
||||||
fontFamily: 'Georgia, serif',
|
fontFamily: 'Georgia, serif',
|
||||||
fontSize: '20px',
|
fontSize: `${layout.rulesHeadingFontSize}px`,
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
resolution: 2,
|
resolution: 2,
|
||||||
}).setOrigin(0, 0.5);
|
}).setOrigin(0, 0);
|
||||||
currentY += 30;
|
currentY += sectionTitle.height + (layout.isCompactViewport ? 6 : 10);
|
||||||
|
|
||||||
section.lines.forEach((line) => {
|
section.lines.forEach((line) => {
|
||||||
this.add.text(panelX, currentY, `• ${line}`, {
|
const ruleText = this.add.text(panelX, currentY, `• ${line}`, {
|
||||||
...BODY_STYLE,
|
...BODY_STYLE,
|
||||||
wordWrap: { width: width * 0.40 },
|
fontSize: `${layout.rulesBodyFontSize}px`,
|
||||||
lineSpacing: 5,
|
wordWrap: { width: layout.rulesWrapWidth },
|
||||||
|
lineSpacing: layout.rulesLineSpacing,
|
||||||
}).setOrigin(0, 0);
|
}).setOrigin(0, 0);
|
||||||
currentY += 54;
|
currentY += ruleText.height + layout.rulesLineGap;
|
||||||
});
|
});
|
||||||
|
|
||||||
currentY += 6;
|
currentY += layout.rulesSectionGap;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.add.text(panelX, height - 92, 'Scegli la difficoltà quando vuoi iniziare: le preferenze audio vengono lette al momento della partita.', {
|
this.add.text(panelX, layout.rulesPanel.bottom - layout.panelPaddingY - 4, layout.isCompactViewport
|
||||||
|
? 'Le preferenze audio salvate si applicano appena inizi.'
|
||||||
|
: 'Scegli la difficoltà quando vuoi iniziare: le preferenze audio vengono lette al momento della partita.', {
|
||||||
...BODY_STYLE,
|
...BODY_STYLE,
|
||||||
fontSize: '16px',
|
fontSize: `${layout.footerFontSize}px`,
|
||||||
color: '#cfe5cd',
|
color: '#cfe5cd',
|
||||||
wordWrap: { width: width * 0.41 },
|
wordWrap: { width: layout.rulesWrapWidth },
|
||||||
}).setOrigin(0, 0.5);
|
lineSpacing: layout.rulesLineSpacing,
|
||||||
|
}).setOrigin(0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createControlPanel(width: number, height: number, audioPreferences: ReturnType<typeof loadAudioPreferences>): void {
|
private createControlPanel(layout: MenuLayout, audioPreferences: ReturnType<typeof loadAudioPreferences>): void {
|
||||||
const panelCenterX = width * 0.77;
|
const panelCenterX = layout.controlPanel.centerX;
|
||||||
const difficultyOptions: DifficultyOption[] = [
|
const difficultyOptions: DifficultyOption[] = [
|
||||||
{
|
{
|
||||||
label: 'Principiante',
|
label: 'Principiante',
|
||||||
@@ -174,18 +457,37 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
this.add.text(panelCenterX, 214, 'Inizia una partita', PANEL_TITLE_STYLE).setOrigin(0.5);
|
const panelTop = layout.controlPanel.y + layout.panelPaddingY;
|
||||||
this.add.text(panelCenterX, 250, 'Ogni partita usa la difficoltà scelta qui sotto e le preferenze audio salvate.', {
|
this.add.text(panelCenterX, panelTop, layout.isCompactViewport ? 'Gioca subito' : 'Inizia una partita', {
|
||||||
...BODY_STYLE,
|
...PANEL_TITLE_STYLE,
|
||||||
fontSize: '16px',
|
fontSize: `${layout.panelTitleFontSize}px`,
|
||||||
color: '#d7ead1',
|
|
||||||
align: 'center',
|
|
||||||
wordWrap: { width: 250 },
|
|
||||||
}).setOrigin(0.5, 0);
|
}).setOrigin(0.5, 0);
|
||||||
|
|
||||||
|
const introText = layout.isCompactViewport
|
||||||
|
? null
|
||||||
|
: this.add.text(panelCenterX, panelTop + layout.panelTitleFontSize + 14, 'Ogni partita usa la difficoltà scelta qui sotto e le preferenze audio salvate.', {
|
||||||
|
...BODY_STYLE,
|
||||||
|
fontSize: `${layout.controlIntroFontSize}px`,
|
||||||
|
color: '#d7ead1',
|
||||||
|
align: 'center',
|
||||||
|
wordWrap: { width: layout.controlIntroWrapWidth },
|
||||||
|
}).setOrigin(0.5, 0);
|
||||||
|
|
||||||
|
const settingsButtonCenterY = layout.controlPanel.bottom - layout.panelPaddingY - layout.buttonHeight / 2;
|
||||||
|
const audioStatusY = settingsButtonCenterY - layout.buttonHeight / 2 - (layout.isCompactViewport ? 12 : 24);
|
||||||
|
const difficultyAreaTop = layout.isCompactViewport
|
||||||
|
? panelTop + layout.panelTitleFontSize + 10
|
||||||
|
: introText!.y + introText!.height + 28;
|
||||||
|
const difficultyAreaBottom = audioStatusY - (layout.isCompactViewport ? 18 : 34);
|
||||||
|
const maxButtonHeight = (difficultyAreaBottom - difficultyAreaTop - layout.buttonGap * (difficultyOptions.length - 1)) / difficultyOptions.length;
|
||||||
|
const buttonHeight = Math.min(layout.buttonHeight, maxButtonHeight);
|
||||||
|
const buttonGap = difficultyOptions.length > 1
|
||||||
|
? (difficultyAreaBottom - difficultyAreaTop - buttonHeight * difficultyOptions.length) / (difficultyOptions.length - 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
difficultyOptions.forEach((option, index) => {
|
difficultyOptions.forEach((option, index) => {
|
||||||
const y = 340 + index * 96;
|
const y = difficultyAreaTop + buttonHeight / 2 + index * (buttonHeight + buttonGap);
|
||||||
this.createButton(panelCenterX, y, 260, 64, option.label, option.subtitle, option.palette, () => {
|
this.createButton(panelCenterX, y, layout.buttonWidth, buttonHeight, option.label, layout.showButtonSubtitle ? option.subtitle : null, option.palette, layout, () => {
|
||||||
this.startGame(option.value);
|
this.startGame(option.value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -193,21 +495,23 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
const musicLabel = audioPreferences.musicEnabled ? 'attiva' : 'disattivata';
|
const musicLabel = audioPreferences.musicEnabled ? 'attiva' : 'disattivata';
|
||||||
const effectsLabel = audioPreferences.effectsEnabled ? 'attivi' : 'disattivati';
|
const effectsLabel = audioPreferences.effectsEnabled ? 'attivi' : 'disattivati';
|
||||||
|
|
||||||
this.add.text(panelCenterX, height - 154, `Musica ${musicLabel} · Effetti ${effectsLabel}`, {
|
this.add.text(panelCenterX, audioStatusY, `Musica ${musicLabel} · Effetti ${effectsLabel}`, {
|
||||||
...BODY_STYLE,
|
...BODY_STYLE,
|
||||||
fontSize: '16px',
|
fontSize: `${layout.footerFontSize}px`,
|
||||||
color: '#cfe5cd',
|
color: '#cfe5cd',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
wordWrap: { width: layout.controlIntroWrapWidth },
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
this.createButton(
|
this.createButton(
|
||||||
panelCenterX,
|
panelCenterX,
|
||||||
height - 100,
|
settingsButtonCenterY,
|
||||||
260,
|
layout.buttonWidth,
|
||||||
58,
|
layout.buttonHeight,
|
||||||
'Impostazioni audio',
|
'Impostazioni audio',
|
||||||
'Modifica musica ed effetti in modo indipendente',
|
layout.showButtonSubtitle ? 'Modifica musica ed effetti in modo indipendente' : null,
|
||||||
{ base: 0x1f6f78, hover: 0x2f8f99 },
|
{ base: 0x1f6f78, hover: 0x2f8f99 },
|
||||||
|
layout,
|
||||||
() => {
|
() => {
|
||||||
this.openSettings();
|
this.openSettings();
|
||||||
},
|
},
|
||||||
@@ -220,30 +524,34 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
label: string,
|
label: string,
|
||||||
subtitle: string,
|
subtitle: string | null,
|
||||||
palette: MenuButtonPalette,
|
palette: MenuButtonPalette,
|
||||||
|
layout: MenuLayout,
|
||||||
onClick: () => void,
|
onClick: () => void,
|
||||||
): void {
|
): void {
|
||||||
const background = this.add.rectangle(x, y, width, height, palette.base, 1)
|
const background = this.add.rectangle(x, y, width, height, palette.base, 1)
|
||||||
.setStrokeStyle(2, 0xf5e1a4, 0.4)
|
.setStrokeStyle(2, 0xf5e1a4, 0.4)
|
||||||
.setInteractive({ useHandCursor: true });
|
.setInteractive({ useHandCursor: true });
|
||||||
|
|
||||||
this.add.text(x, y - 10, label, {
|
this.add.text(x, y + layout.buttonLabelOffset, label, {
|
||||||
fontFamily: 'Georgia, serif',
|
fontFamily: 'Georgia, serif',
|
||||||
fontSize: '21px',
|
fontSize: `${layout.buttonLabelFontSize}px`,
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
stroke: '#000000',
|
stroke: '#000000',
|
||||||
strokeThickness: 2,
|
strokeThickness: 2,
|
||||||
resolution: 2,
|
resolution: 2,
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
this.add.text(x, y + 14, subtitle, {
|
if (subtitle) {
|
||||||
fontFamily: 'Georgia, serif',
|
this.add.text(x, y + layout.buttonSubtitleOffset, subtitle, {
|
||||||
fontSize: '13px',
|
fontFamily: 'Georgia, serif',
|
||||||
color: '#f7f1d5',
|
fontSize: `${layout.buttonSubtitleFontSize}px`,
|
||||||
resolution: 2,
|
color: '#f7f1d5',
|
||||||
align: 'center',
|
resolution: 2,
|
||||||
}).setOrigin(0.5);
|
align: 'center',
|
||||||
|
wordWrap: { width: layout.buttonTextWrapWidth },
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
background.on('pointerover', () => background.setFillStyle(palette.hover));
|
background.on('pointerover', () => background.setFillStyle(palette.hover));
|
||||||
background.on('pointerout', () => background.setFillStyle(palette.base));
|
background.on('pointerout', () => background.setFillStyle(palette.base));
|
||||||
@@ -263,30 +571,9 @@ export class MenuScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private openSettings(): void {
|
private openSettings(): void {
|
||||||
this.ensureSettingsSceneAvailable();
|
|
||||||
this.cameras.main.fadeOut(250, 0, 30, 0);
|
this.cameras.main.fadeOut(250, 0, 30, 0);
|
||||||
this.cameras.main.once('camerafadeoutcomplete', () => {
|
this.cameras.main.once('camerafadeoutcomplete', () => {
|
||||||
this.scene.start('SettingsScene', { returnSceneKey: 'MenuScene' });
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user