Files
scopone/src/scenes/MenuScene.ts
Giancarmine Salucci b2a84eb167
Some checks failed
Android Build & Publish / android (push) Failing after 2m0s
feat: CI creates Gitea releases with changelog, app polls for updates on startup
- android-build.yml: fetch full history+tags, embed VITE_APP_BUILD, add step
  to create a tagged Gitea release (build-N) with markdown changelog and APK
  release assets after every push; bump permissions to contents:write
- src/game/update-check.ts: polls Gitea releases/latest, compares build-N tag
  against CURRENT_BUILD (0 in dev), returns UpdateInfo or null; dismissal
  persisted to localStorage
- src/vite-env.d.ts: TypeScript env declarations for VITE_APP_BUILD
- src/scenes/MenuScene.ts: fire-and-forget update check on menu load; renders
  dismissible bottom-bar banner with optional APK download link
- src/game/ai.ts: early-game empty-table dump heuristic (safest card first)
2026-05-25 09:39:08 +02:00

649 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Phaser from 'phaser';
import { Difficulty } from '../game/types';
import { GameSceneData, loadAudioPreferences } from '../game/preferences';
import { checkForUpdate, isDismissed, dismissUpdate, CURRENT_BUILD, UpdateInfo } from '../game/update-check';
type MenuButtonPalette = {
base: number;
hover: number;
};
type DifficultyOption = {
label: string;
subtitle: string;
value: Difficulty;
palette: MenuButtonPalette;
};
type RuleSection = {
heading: 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 = {
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() {
super({ key: 'MenuScene' });
}
create(): void {
const width = this.scale.width;
const height = this.scale.height;
const audioPreferences = loadAudioPreferences();
const layout = this.createLayout(width, height);
this.cameras.main.setZoom(layout.cameraZoom);
this.cameras.main.centerOn(width / 2, height / 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);
if (!layout.isCompactViewport) {
this.add.text(layout.titleX, layout.subtitleY, 'Due squadre da due, una mano intera e lettura del tavolo fino allultimo 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);
// Fire-and-forget update check — shows a dismissible banner if a newer
// CI build is available on Gitea.
checkForUpdate(CURRENT_BUILD).then(info => {
if (info && !isDismissed(info.buildNumber) && this.scene.isActive()) {
this.showUpdateBanner(layout, info);
}
}).catch(() => { /* network unavailable — silent */ });
}
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(
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(
layout.rulesPanel.centerX,
layout.rulesPanel.centerY,
layout.rulesPanel.width,
layout.rulesPanel.height,
0x0d2215,
0.84,
)
.setStrokeStyle(2, 0xc8a445, 0.4);
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(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(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, 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;
});
}
sections.forEach((section) => {
const sectionTitle = this.add.text(panelX, currentY, section.heading, {
fontFamily: 'Georgia, serif',
fontSize: `${layout.rulesHeadingFontSize}px`,
color: '#ffffff',
resolution: 2,
}).setOrigin(0, 0);
currentY += sectionTitle.height + (layout.isCompactViewport ? 6 : 10);
section.lines.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;
});
currentY += layout.rulesSectionGap;
});
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: `${layout.footerFontSize}px`,
color: '#cfe5cd',
wordWrap: { width: layout.rulesWrapWidth },
lineSpacing: layout.rulesLineSpacing,
}).setOrigin(0, 1);
}
private createControlPanel(layout: MenuLayout, audioPreferences: ReturnType<typeof loadAudioPreferences>): void {
const panelCenterX = layout.controlPanel.centerX;
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 },
},
];
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 = 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);
});
});
const musicLabel = audioPreferences.musicEnabled ? 'attiva' : 'disattivata';
const effectsLabel = audioPreferences.effectsEnabled ? 'attivi' : 'disattivati';
this.add.text(panelCenterX, audioStatusY, `Musica ${musicLabel} · Effetti ${effectsLabel}`, {
...BODY_STYLE,
fontSize: `${layout.footerFontSize}px`,
color: '#cfe5cd',
align: 'center',
wordWrap: { width: layout.controlIntroWrapWidth },
}).setOrigin(0.5);
this.createButton(
panelCenterX,
settingsButtonCenterY,
layout.buttonWidth,
layout.buttonHeight,
'Impostazioni audio',
layout.showButtonSubtitle ? 'Modifica musica ed effetti in modo indipendente' : null,
{ base: 0x1f6f78, hover: 0x2f8f99 },
layout,
() => {
this.openSettings();
},
);
}
private createButton(
x: number,
y: number,
width: number,
height: number,
label: 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 + layout.buttonLabelOffset, label, {
fontFamily: 'Georgia, serif',
fontSize: `${layout.buttonLabelFontSize}px`,
color: '#ffffff',
stroke: '#000000',
strokeThickness: 2,
resolution: 2,
}).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));
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.cameras.main.fadeOut(250, 0, 30, 0);
this.cameras.main.once('camerafadeoutcomplete', () => {
this.scene.start('SettingsScene', { returnSceneKey: 'MenuScene' });
});
}
/**
* Renders a slim dismissible notification bar at the bottom of the visible
* area when a newer CI build is available on Gitea.
*
* Layout: [ "Aggiornamento disponibile — build N" | Scarica → | ✕ ]
*/
private showUpdateBanner(layout: MenuLayout, info: UpdateInfo): void {
const bannerH = layout.isCompactViewport ? 40 : 48;
const bannerW = layout.visibleBounds.width - layout.frameInset * 2;
const cx = layout.visibleBounds.centerX;
// Float the banner along the very bottom edge of the visible area.
const cy = layout.visibleBounds.bottom - bannerH / 2 - 4;
const fs = layout.isCompactViewport ? 13 : 15;
const depth = 200;
// Collect all objects so the dismiss handler can destroy them together.
const objs: Phaser.GameObjects.GameObject[] = [];
const track = <T extends Phaser.GameObjects.GameObject>(o: T): T => {
objs.push(o); return o;
};
track(
this.add.rectangle(cx, cy, bannerW, bannerH, 0x0a1e3a, 0.96)
.setStrokeStyle(1, 0x4a90d9, 0.85)
.setDepth(depth),
);
track(
this.add.text(cx - bannerW / 2 + 12, cy,
`Aggiornamento disponibile — build ${info.buildNumber}`,
{ fontFamily: 'Georgia, serif', fontSize: `${fs}px`, color: '#b8d8f8', resolution: 2 },
).setOrigin(0, 0.5).setDepth(depth + 1),
);
// Dismiss (✕) — always present, rightmost.
const dismissBtn = track(
this.add.text(cx + bannerW / 2 - 12, cy, '✕', {
fontFamily: 'Georgia, serif', fontSize: `${fs + 4}px`, color: '#7a8a9a', resolution: 2,
}).setOrigin(1, 0.5).setDepth(depth + 1).setInteractive({ useHandCursor: true }),
) as Phaser.GameObjects.Text;
dismissBtn.on('pointerover', () => dismissBtn.setColor('#c0d0e0'));
dismissBtn.on('pointerout', () => dismissBtn.setColor('#7a8a9a'));
dismissBtn.on('pointerdown', () => {
dismissUpdate(info.buildNumber);
objs.forEach(o => o.destroy());
});
// "Scarica →" link — present when a direct APK URL is available.
if (info.apkUrl) {
const dlBtn = track(
this.add.text(cx + bannerW / 2 - 38, cy, 'Scarica →', {
fontFamily: 'Georgia, serif', fontSize: `${fs}px`, color: '#5ba3e8', resolution: 2,
}).setOrigin(1, 0.5).setDepth(depth + 1).setInteractive({ useHandCursor: true }),
) as Phaser.GameObjects.Text;
dlBtn.on('pointerover', () => dlBtn.setColor('#90c8ff'));
dlBtn.on('pointerout', () => dlBtn.setColor('#5ba3e8'));
dlBtn.on('pointerdown', () => window.open(info.apkUrl!, '_blank'));
}
}
}