feat(SCOPONE-0010): redesign main menu layout
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# Findings
|
||||
|
||||
> Last Updated: 2026-04-09T20:59:51.000Z
|
||||
> Last Updated: 2026-04-10T09:39:37.000Z
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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/`.
|
||||
- `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`.
|
||||
- `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 exposes three difficulty levels: `beginner`, `advanced`, and `master`.
|
||||
- `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` 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.
|
||||
- `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 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.
|
||||
@@ -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.
|
||||
- `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.
|
||||
- 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.
|
||||
@@ -66,7 +67,8 @@ Initializer refresh for SCOPONE-0010. The cache was invalid because `docs/FINDIN
|
||||
### Scene / UI implementation snapshot
|
||||
|
||||
- `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()`.
|
||||
- `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.
|
||||
@@ -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/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.
|
||||
- `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 { MenuScene } from './scenes/MenuScene';
|
||||
import { GameScene } from './scenes/GameScene';
|
||||
|
||||
class SettingsScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'SettingsScene' });
|
||||
}
|
||||
}
|
||||
import { SettingsScene } from './scenes/SettingsScene';
|
||||
|
||||
const installFullscreenRequest = (host: HTMLElement): void => {
|
||||
const canRequestFullscreen =
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Phaser from 'phaser';
|
||||
import { Difficulty } from '../game/types';
|
||||
import { GameSceneData, loadAudioPreferences } from '../game/preferences';
|
||||
import { SettingsScene } from './SettingsScene';
|
||||
|
||||
type MenuButtonPalette = {
|
||||
base: number;
|
||||
@@ -20,6 +19,62 @@ type RuleSection = {
|
||||
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 = {
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '52px',
|
||||
@@ -52,107 +107,335 @@ export class MenuScene extends Phaser.Scene {
|
||||
const width = this.scale.width;
|
||||
const height = this.scale.height;
|
||||
const audioPreferences = loadAudioPreferences();
|
||||
const layout = this.createLayout(width, height);
|
||||
|
||||
this.drawBackground(width, height);
|
||||
this.drawDecorativeCards(width, height);
|
||||
this.ensureSettingsSceneAvailable();
|
||||
this.cameras.main.setZoom(layout.cameraZoom);
|
||||
this.cameras.main.centerOn(width / 2, height / 2);
|
||||
|
||||
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: '21px',
|
||||
color: '#d9f2d2',
|
||||
resolution: 2,
|
||||
this.drawBackground(width, height, layout);
|
||||
this.drawDecorativeCards(layout);
|
||||
|
||||
this.add.text(layout.titleX, layout.titleY, 'Scopone Scientifico', {
|
||||
...TITLE_STYLE,
|
||||
fontSize: `${layout.titleFontSize}px`,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.createRulesPanel(width, height);
|
||||
this.createControlPanel(width, height, audioPreferences);
|
||||
if (!layout.isCompactViewport) {
|
||||
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(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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
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],
|
||||
];
|
||||
|
||||
positions.forEach(([x, y]) => {
|
||||
this.add.image(x, y, 'retro').setScale(0.08).setAngle(Phaser.Math.Between(-15, 15)).setAlpha(0.9);
|
||||
private drawDecorativeCards(layout: MenuLayout): void {
|
||||
layout.decorativeCards.forEach((card) => {
|
||||
this.add.image(card.x, card.y, 'retro').setScale(card.scale).setAngle(card.angle).setAlpha(card.alpha);
|
||||
});
|
||||
}
|
||||
|
||||
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.',
|
||||
],
|
||||
},
|
||||
];
|
||||
private createRulesPanel(layout: MenuLayout): void {
|
||||
const panelX = layout.rulesPanel.x + layout.panelPaddingX;
|
||||
const panelY = layout.rulesPanel.y + layout.panelPaddingY;
|
||||
const sections: RuleSection[] = layout.isCompactViewport
|
||||
? []
|
||||
: [
|
||||
{
|
||||
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.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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) => {
|
||||
this.add.text(panelX, currentY, section.heading, {
|
||||
const sectionTitle = this.add.text(panelX, currentY, section.heading, {
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '20px',
|
||||
fontSize: `${layout.rulesHeadingFontSize}px`,
|
||||
color: '#ffffff',
|
||||
resolution: 2,
|
||||
}).setOrigin(0, 0.5);
|
||||
currentY += 30;
|
||||
}).setOrigin(0, 0);
|
||||
currentY += sectionTitle.height + (layout.isCompactViewport ? 6 : 10);
|
||||
|
||||
section.lines.forEach((line) => {
|
||||
this.add.text(panelX, currentY, `• ${line}`, {
|
||||
const ruleText = this.add.text(panelX, currentY, `• ${line}`, {
|
||||
...BODY_STYLE,
|
||||
wordWrap: { width: width * 0.40 },
|
||||
lineSpacing: 5,
|
||||
fontSize: `${layout.rulesBodyFontSize}px`,
|
||||
wordWrap: { width: layout.rulesWrapWidth },
|
||||
lineSpacing: layout.rulesLineSpacing,
|
||||
}).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,
|
||||
fontSize: '16px',
|
||||
fontSize: `${layout.footerFontSize}px`,
|
||||
color: '#cfe5cd',
|
||||
wordWrap: { width: width * 0.41 },
|
||||
}).setOrigin(0, 0.5);
|
||||
wordWrap: { width: layout.rulesWrapWidth },
|
||||
lineSpacing: layout.rulesLineSpacing,
|
||||
}).setOrigin(0, 1);
|
||||
}
|
||||
|
||||
private createControlPanel(width: number, height: number, audioPreferences: ReturnType<typeof loadAudioPreferences>): void {
|
||||
const panelCenterX = width * 0.77;
|
||||
private createControlPanel(layout: MenuLayout, audioPreferences: ReturnType<typeof loadAudioPreferences>): void {
|
||||
const panelCenterX = layout.controlPanel.centerX;
|
||||
const difficultyOptions: DifficultyOption[] = [
|
||||
{
|
||||
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);
|
||||
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 },
|
||||
const panelTop = layout.controlPanel.y + layout.panelPaddingY;
|
||||
this.add.text(panelCenterX, panelTop, layout.isCompactViewport ? 'Gioca subito' : 'Inizia una partita', {
|
||||
...PANEL_TITLE_STYLE,
|
||||
fontSize: `${layout.panelTitleFontSize}px`,
|
||||
}).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) => {
|
||||
const y = 340 + index * 96;
|
||||
this.createButton(panelCenterX, y, 260, 64, option.label, option.subtitle, option.palette, () => {
|
||||
const y = difficultyAreaTop + buttonHeight / 2 + index * (buttonHeight + buttonGap);
|
||||
this.createButton(panelCenterX, y, layout.buttonWidth, buttonHeight, option.label, layout.showButtonSubtitle ? option.subtitle : null, option.palette, layout, () => {
|
||||
this.startGame(option.value);
|
||||
});
|
||||
});
|
||||
@@ -193,21 +495,23 @@ export class MenuScene extends Phaser.Scene {
|
||||
const musicLabel = audioPreferences.musicEnabled ? 'attiva' : 'disattivata';
|
||||
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,
|
||||
fontSize: '16px',
|
||||
fontSize: `${layout.footerFontSize}px`,
|
||||
color: '#cfe5cd',
|
||||
align: 'center',
|
||||
wordWrap: { width: layout.controlIntroWrapWidth },
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.createButton(
|
||||
panelCenterX,
|
||||
height - 100,
|
||||
260,
|
||||
58,
|
||||
settingsButtonCenterY,
|
||||
layout.buttonWidth,
|
||||
layout.buttonHeight,
|
||||
'Impostazioni audio',
|
||||
'Modifica musica ed effetti in modo indipendente',
|
||||
layout.showButtonSubtitle ? 'Modifica musica ed effetti in modo indipendente' : null,
|
||||
{ base: 0x1f6f78, hover: 0x2f8f99 },
|
||||
layout,
|
||||
() => {
|
||||
this.openSettings();
|
||||
},
|
||||
@@ -220,30 +524,34 @@ export class MenuScene extends Phaser.Scene {
|
||||
width: number,
|
||||
height: number,
|
||||
label: string,
|
||||
subtitle: string,
|
||||
subtitle: string | null,
|
||||
palette: MenuButtonPalette,
|
||||
layout: MenuLayout,
|
||||
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, {
|
||||
this.add.text(x, y + layout.buttonLabelOffset, label, {
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '21px',
|
||||
fontSize: `${layout.buttonLabelFontSize}px`,
|
||||
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);
|
||||
if (subtitle) {
|
||||
this.add.text(x, y + layout.buttonSubtitleOffset, subtitle, {
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: `${layout.buttonSubtitleFontSize}px`,
|
||||
color: '#f7f1d5',
|
||||
resolution: 2,
|
||||
align: 'center',
|
||||
wordWrap: { width: layout.buttonTextWrapWidth },
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
|
||||
background.on('pointerover', () => background.setFillStyle(palette.hover));
|
||||
background.on('pointerout', () => background.setFillStyle(palette.base));
|
||||
@@ -263,30 +571,9 @@ export class MenuScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user