feat: add saved puzzle persistence and fullscreen support

- Introduce StorageManager API for tracking current room (`saveCurrent`, `loadCurrent`, `clearCurrent`)
- Persist active puzzle state when joining/creating; restore on main menu load if incomplete
- Add Continue Puzzle button that only activates when a valid saved puzzle exists
- Implement Enter Fullscreen button with dynamic state handling and event listeners
- Adjust logo/sub-logo animation positions for cleaner visual flow
- Clear saved puzzle data on new puzzles or completion to avoid stale state
This commit is contained in:
Brian Fertig 2026-04-05 10:42:53 -06:00
parent 9a353dbacf
commit 8b30269b56
3 changed files with 106 additions and 15 deletions

View File

@ -1,4 +1,4 @@
/* global Phaser, NetworkManager */
/* global Phaser, NetworkManager, StorageManager */
class MainMenuScene extends Phaser.Scene {
constructor() {
@ -50,16 +50,15 @@ class MainMenuScene extends Phaser.Scene {
// Logo drops in at 300ms
this.tweens.add({
targets: logo,
y: 270,
y: 240,
alpha: 1,
duration: 1200,
delay: 300,
ease: 'Cubic.easeOut',
onComplete: () => {
// Subtle bounce settle
this.tweens.add({
targets: logo,
y: 280,
y: 250,
duration: 300,
ease: 'Sine.easeInOut',
});
@ -69,16 +68,15 @@ class MainMenuScene extends Phaser.Scene {
// Sub-logo rises in at 900ms (halfway through logo anim)
this.tweens.add({
targets: sublogo,
y: 500,
y: 460,
alpha: 1,
duration: 1000,
delay: 900,
ease: 'Cubic.easeOut',
onComplete: () => {
// Subtle bounce settle
this.tweens.add({
targets: sublogo,
y: 490,
y: 450,
duration: 250,
ease: 'Sine.easeInOut',
});
@ -87,8 +85,10 @@ class MainMenuScene extends Phaser.Scene {
// Buttons fade in at 1400ms (halfway through sub-logo anim)
this.time.delayedCall(1400, () => {
if (this._continueBtn) this._continueBtn.style.opacity = this._hasSavedPuzzle ? '1' : '0.4';
if (this._newBtn) this._newBtn.style.opacity = '1';
if (this._joinBtn) this._joinBtn.style.opacity = '1';
if (this._fullscreenBtn) this._fullscreenBtn.style.opacity = '1';
});
// Idle breathing pulse — starts after intro finishes, repeats every 10s
@ -98,7 +98,6 @@ class MainMenuScene extends Phaser.Scene {
}
_breathePulse(logo, logoBase, sublogo, sublogoBase) {
// Logo scales up then back down
this.tweens.add({
targets: logo,
scaleX: logoBase * 1.06,
@ -107,7 +106,6 @@ class MainMenuScene extends Phaser.Scene {
ease: 'Sine.easeInOut',
yoyo: true,
onComplete: () => {
// Sub-logo follows after logo settles
this.tweens.add({
targets: sublogo,
scaleX: sublogoBase * 1.06,
@ -116,7 +114,6 @@ class MainMenuScene extends Phaser.Scene {
ease: 'Sine.easeInOut',
yoyo: true,
onComplete: () => {
// Schedule next pulse ~10s from now
this.time.delayedCall(10000, () => {
this._breathePulse(logo, logoBase, sublogo, sublogoBase);
});
@ -144,11 +141,41 @@ class MainMenuScene extends Phaser.Scene {
});
document.body.appendChild(this._uiLayer);
// Check for saved puzzle
const saved = StorageManager.loadCurrent();
this._hasSavedPuzzle = saved && !saved.completed;
// Continue Puzzle button
this._continueBtn = this._makeDomBtn('Continue Puzzle', '#2e7d32', '#43a047', '#66bb6a', () => {
const current = StorageManager.loadCurrent();
if (current && !current.completed) {
this.scene.start('PuzzleScene', {
roomCode: current.roomCode,
_restore: true,
});
}
});
Object.assign(this._continueBtn.style, {
top: '630px',
left: '50%',
transform: 'translateX(-50%)',
width: '340px',
opacity: '0',
transition: 'opacity 0.6s ease',
});
if (!this._hasSavedPuzzle) {
this._continueBtn.disabled = true;
this._continueBtn.style.cursor = 'not-allowed';
this._continueBtn.style.pointerEvents = 'auto';
}
this._uiLayer.appendChild(this._continueBtn);
// New Puzzle button
this._newBtn = this._makeDomBtn('New Puzzle', '#1565c0', '#1e88e5', '#64b5f6', () => {
this.scene.start('NewPuzzleScene');
});
Object.assign(this._newBtn.style, {
top: '630px',
top: '550px',
left: '50%',
transform: 'translateX(-50%)',
width: '340px',
@ -157,6 +184,7 @@ class MainMenuScene extends Phaser.Scene {
});
this._uiLayer.appendChild(this._newBtn);
// Join Puzzle button
this._joinBtn = this._makeDomBtn('Join Puzzle', '#e65100', '#f57c00', '#ffb74d', () => {
this._showJoinDialog();
});
@ -170,7 +198,46 @@ class MainMenuScene extends Phaser.Scene {
});
this._uiLayer.appendChild(this._joinBtn);
this.events.once('shutdown', () => this._destroyDomUI());
// Enter Fullscreen button
const isFullscreen = !!document.fullscreenElement;
this._fullscreenBtn = this._makeDomBtn(
isFullscreen ? 'Fullscreen Active' : 'Enter Fullscreen',
'rgba(60,60,60,0.8)', 'rgba(80,80,80,0.9)', '#999999',
() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
}
}
);
Object.assign(this._fullscreenBtn.style, {
top: '790px',
left: '50%',
transform: 'translateX(-50%)',
width: '340px',
opacity: '0',
transition: 'opacity 0.6s ease',
});
if (isFullscreen) {
this._fullscreenBtn.disabled = true;
this._fullscreenBtn.style.cursor = 'default';
this._fullscreenBtn.style.opacity = '0.4';
}
this._uiLayer.appendChild(this._fullscreenBtn);
// Listen for fullscreen changes
this._onFullscreenChange = () => {
const fs = !!document.fullscreenElement;
this._fullscreenBtn.textContent = fs ? 'Fullscreen Active' : 'Enter Fullscreen';
this._fullscreenBtn.disabled = fs;
this._fullscreenBtn.style.cursor = fs ? 'default' : 'pointer';
this._fullscreenBtn.style.opacity = fs ? '0.4' : '1';
};
document.addEventListener('fullscreenchange', this._onFullscreenChange);
this.events.once('shutdown', () => {
document.removeEventListener('fullscreenchange', this._onFullscreenChange);
this._destroyDomUI();
});
}
_showJoinDialog() {
@ -400,9 +467,9 @@ class MainMenuScene extends Phaser.Scene {
whiteSpace: 'nowrap',
textAlign: 'center',
});
btn.addEventListener('mouseenter', () => { btn.style.background = bgHover; });
btn.addEventListener('mouseleave', () => { btn.style.background = bgNormal; });
btn.addEventListener('mousedown', () => { btn.style.transform = (btn.style.transform || '') + ' scale(0.97)'; });
btn.addEventListener('mouseenter', () => { if (!btn.disabled) btn.style.background = bgHover; });
btn.addEventListener('mouseleave', () => { if (!btn.disabled) btn.style.background = bgNormal; });
btn.addEventListener('mousedown', () => { if (!btn.disabled) btn.style.transform = (btn.style.transform || '') + ' scale(0.97)'; });
btn.addEventListener('mouseup', () => { btn.style.transform = btn.style.transform.replace(' scale(0.97)', ''); });
btn.addEventListener('click', onClick);
return btn;

View File

@ -202,6 +202,11 @@ class PuzzleScene extends Phaser.Scene {
// ─── Scene construction ──────────────────────────────────────────────
_buildScene(saved) {
// Clear old saved puzzle when starting fresh (not restoring or joining)
if (!saved) {
StorageManager.clearCurrent();
}
const { width, height } = this.sys.game.config;
// Tile the background texture across the playfield, centered on the world
@ -518,6 +523,7 @@ class PuzzleScene extends Phaser.Scene {
_onNetworkCompleted() {
if (!this._completed) {
this._completed = true;
StorageManager.clearCurrent();
this._showCompletion(true);
}
}
@ -871,6 +877,7 @@ class PuzzleScene extends Phaser.Scene {
const gm = this._groupManager;
if (gm.groupCount === 1 && gm.pieceCount === this._pieces.length) {
this._completed = true;
StorageManager.clearCurrent();
this._showCompletion(true);
}
}
@ -1301,6 +1308,7 @@ class PuzzleScene extends Phaser.Scene {
bgPath: this._bgPath,
});
StorageManager.save(state);
StorageManager.saveCurrent(this.cfg.roomCode);
}

View File

@ -27,4 +27,20 @@ class StorageManager {
static remove(roomCode) {
localStorage.removeItem(StorageManager._key(roomCode));
}
static saveCurrent(roomCode) {
localStorage.setItem('ipuzzle_currentRoom', roomCode);
}
static loadCurrent() {
const roomCode = localStorage.getItem('ipuzzle_currentRoom');
if (!roomCode) return null;
return StorageManager.load(roomCode);
}
static clearCurrent() {
const roomCode = localStorage.getItem('ipuzzle_currentRoom');
if (roomCode) StorageManager.remove(roomCode);
localStorage.removeItem('ipuzzle_currentRoom');
}
}