feat: add real-time puzzle timer and player list UI

- Persist puzzle start time in state for accurate session tracking
- Display elapsed time (HH:MM:SS) and connected players in a new "Room Stats" panel
- Update players list on join/left events and network sync
- Refine UI button styling for consistency with main theme
This commit is contained in:
Brian Fertig 2026-04-05 12:42:21 -06:00
parent e71dab9c45
commit 35f050dfeb
2 changed files with 121 additions and 12 deletions

View File

@ -178,6 +178,9 @@ class PuzzleScene extends Phaser.Scene {
update() { update() {
if (!this._ready) return; if (!this._ready) return;
// Update puzzle timer once per second
this._updateTimer();
// Fallback: detect when music track has finished (Phaser 3.9 'complete' event can be unreliable) // Fallback: detect when music track has finished (Phaser 3.9 'complete' event can be unreliable)
if (this._currentMusic && this._musicStarted && !this._currentMusic.isPlaying && !this._currentMusic.isPaused) { if (this._currentMusic && this._musicStarted && !this._currentMusic.isPlaying && !this._currentMusic.isPaused) {
this._playNextTrack(); this._playNextTrack();
@ -310,6 +313,9 @@ class PuzzleScene extends Phaser.Scene {
this._panLastY = 0; this._panLastY = 0;
this._ready = true; this._ready = true;
// Puzzle timer — restore from saved state or start fresh
this._startTime = (saved && saved.startTime) ? saved.startTime : Date.now();
// Box selection state (CTRL+drag) // Box selection state (CTRL+drag)
this._isBoxSelecting = false; this._isBoxSelecting = false;
this._boxStartWorld = null; // { x, y } in world coords this._boxStartWorld = null; // { x, y } in world coords
@ -396,6 +402,7 @@ class PuzzleScene extends Phaser.Scene {
} }
this._setupNetworkListeners(); this._setupNetworkListeners();
this._updatePlayersList();
} else if (NetworkManager.connected) { } else if (NetworkManager.connected) {
// Host — create the room on the server // Host — create the room on the server
this._isNetworked = true; this._isNetworked = true;
@ -412,6 +419,7 @@ class PuzzleScene extends Phaser.Scene {
completed: false, completed: false,
bgKey: this._bgKey, bgKey: this._bgKey,
bgPath: this._bgPath, bgPath: this._bgPath,
startTime: this._startTime,
}); });
NetworkManager.createRoom(this.cfg.roomCode, state.serialize(), this.cfg.playerName); NetworkManager.createRoom(this.cfg.roomCode, state.serialize(), this.cfg.playerName);
this._setupNetworkListeners(); this._setupNetworkListeners();
@ -548,6 +556,7 @@ class PuzzleScene extends Phaser.Scene {
this._playerNames.set(msg.playerId, msg.playerName); this._playerNames.set(msg.playerId, msg.playerName);
} }
console.log(`${msg.playerName || 'Player ' + msg.playerId} joined the room`); console.log(`${msg.playerName || 'Player ' + msg.playerId} joined the room`);
this._updatePlayersList();
} }
_onNetworkPlayerLeft(msg) { _onNetworkPlayerLeft(msg) {
@ -564,6 +573,7 @@ class PuzzleScene extends Phaser.Scene {
} }
this._playerNames.delete(msg.playerId); this._playerNames.delete(msg.playerId);
console.log(`Player ${msg.playerId} left the room`); console.log(`Player ${msg.playerId} left the room`);
this._updatePlayersList();
} }
_onNetworkCompleted() { _onNetworkCompleted() {
@ -1138,6 +1148,43 @@ class PuzzleScene extends Phaser.Scene {
this.time.delayedCall(animate ? 1300 : 0, () => this._showDomCompletion()); this.time.delayedCall(animate ? 1300 : 0, () => this._showDomCompletion());
} }
// ─── Room Stats helpers ──────────────────────────────────────────────
_updateTimer() {
if (!this._timerEl || !this._startTime) return;
const elapsed = Math.floor((Date.now() - this._startTime) / 1000);
const h = Math.floor(elapsed / 3600);
const m = Math.floor((elapsed % 3600) / 60);
const s = elapsed % 60;
this._timerEl.textContent =
String(h).padStart(2, '0') + ':' +
String(m).padStart(2, '0') + ':' +
String(s).padStart(2, '0');
}
_updatePlayersList() {
if (!this._playersListEl) return;
const names = [];
// Add local player first
if (this.cfg.playerName) {
names.push(this.cfg.playerName + ' (you)');
}
// Add remote players
this._playerNames.forEach((name) => {
names.push(name);
});
if (names.length === 0) {
this._playersListEl.textContent = '\u2014';
} else {
this._playersListEl.innerHTML = '';
names.forEach(n => {
const row = document.createElement('div');
row.textContent = n;
this._playersListEl.appendChild(row);
});
}
}
// ─── DOM UI ────────────────────────────────────────────────────────── // ─── DOM UI ──────────────────────────────────────────────────────────
// ─── Music ────────────────────────────────────────────────────────── // ─── Music ──────────────────────────────────────────────────────────
@ -1384,9 +1431,68 @@ class PuzzleScene extends Phaser.Scene {
this._saveState(); this._saveState();
this.scene.start('MainMenuScene'); this.scene.start('MainMenuScene');
}); });
Object.assign(backBtn.style, { top: '1%', left: '0.5%' }); Object.assign(backBtn.style, { top: '10px', left: '10px' });
this._uiLayer.appendChild(backBtn); this._uiLayer.appendChild(backBtn);
// Room Stats panel — below menu button
const statsPanel = document.createElement('div');
Object.assign(statsPanel.style, {
position: 'absolute',
top: '50px',
left: '10px',
background: 'rgba(0, 0, 0, 0.55)',
padding: '10px 16px',
borderRadius: '4px',
fontFamily: 'Arial, sans-serif',
color: '#ddeeff',
fontSize: '14px',
lineHeight: '1.6',
minWidth: '160px',
});
const statsTitle = document.createElement('div');
Object.assign(statsTitle.style, {
fontWeight: 'bold',
fontSize: '15px',
marginBottom: '6px',
color: '#ddeeff',
letterSpacing: '0.05em',
});
statsTitle.textContent = 'Room Stats';
statsPanel.appendChild(statsTitle);
// Puzzle Time
const timeLabel = document.createElement('div');
Object.assign(timeLabel.style, { color: '#8899bb', fontSize: '12px', marginTop: '4px' });
timeLabel.textContent = 'Puzzle Time';
statsPanel.appendChild(timeLabel);
this._timerEl = document.createElement('div');
Object.assign(this._timerEl.style, {
color: '#ddeeff',
fontSize: '16px',
fontFamily: 'monospace',
fontWeight: 'bold',
});
this._timerEl.textContent = '00:00:00';
statsPanel.appendChild(this._timerEl);
// Players
const playersLabel = document.createElement('div');
Object.assign(playersLabel.style, { color: '#8899bb', fontSize: '12px', marginTop: '8px' });
playersLabel.textContent = 'Players';
statsPanel.appendChild(playersLabel);
this._playersListEl = document.createElement('div');
Object.assign(this._playersListEl.style, {
color: '#ddeeff',
fontSize: '13px',
});
statsPanel.appendChild(this._playersListEl);
this._uiLayer.appendChild(statsPanel);
this._updatePlayersList();
// Music controls — top-right // Music controls — top-right
const musicRow = document.createElement('div'); const musicRow = document.createElement('div');
Object.assign(musicRow.style, { Object.assign(musicRow.style, {
@ -1399,9 +1505,9 @@ class PuzzleScene extends Phaser.Scene {
}); });
const btnStyle = { const btnStyle = {
background: 'rgba(0, 0, 0, 0.55)', background: '#1565c0',
color: '#ddeeff', color: '#ddeeff',
border: '1px solid #4477bb', border: '1px solid #64b5f6',
borderRadius: '4px', borderRadius: '4px',
fontSize: '1.8vmin', fontSize: '1.8vmin',
fontFamily: 'Arial, sans-serif', fontFamily: 'Arial, sans-serif',
@ -1415,16 +1521,16 @@ class PuzzleScene extends Phaser.Scene {
skipBtn.textContent = '\u23ED'; // next track symbol skipBtn.textContent = '\u23ED'; // next track symbol
skipBtn.title = 'Next Track'; skipBtn.title = 'Next Track';
Object.assign(skipBtn.style, btnStyle); Object.assign(skipBtn.style, btnStyle);
skipBtn.addEventListener('mouseenter', () => { skipBtn.style.background = '#223355'; }); skipBtn.addEventListener('mouseenter', () => { skipBtn.style.background = '#1e88e5'; });
skipBtn.addEventListener('mouseleave', () => { skipBtn.style.background = 'rgba(0, 0, 0, 0.55)'; }); skipBtn.addEventListener('mouseleave', () => { skipBtn.style.background = '#1565c0'; });
skipBtn.addEventListener('click', () => this._skipTrack()); skipBtn.addEventListener('click', () => this._skipTrack());
// Mute/unmute button // Mute/unmute button
this._muteBtn = document.createElement('button'); this._muteBtn = document.createElement('button');
this._muteBtn.title = 'Mute / Unmute'; this._muteBtn.title = 'Mute / Unmute';
Object.assign(this._muteBtn.style, btnStyle); Object.assign(this._muteBtn.style, btnStyle);
this._muteBtn.addEventListener('mouseenter', () => { this._muteBtn.style.background = '#223355'; }); this._muteBtn.addEventListener('mouseenter', () => { this._muteBtn.style.background = '#1e88e5'; });
this._muteBtn.addEventListener('mouseleave', () => { this._muteBtn.style.background = 'rgba(0, 0, 0, 0.55)'; }); this._muteBtn.addEventListener('mouseleave', () => { this._muteBtn.style.background = '#1565c0'; });
this._muteBtn.addEventListener('click', () => { this._muteBtn.addEventListener('click', () => {
this._toggleMute(); this._toggleMute();
this._muteBtn.textContent = this._musicMuted ? '\uD83D\uDD07' : '\uD83D\uDD0A'; this._muteBtn.textContent = this._musicMuted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
@ -1517,9 +1623,9 @@ class PuzzleScene extends Phaser.Scene {
Object.assign(btn.style, { Object.assign(btn.style, {
position: 'absolute', position: 'absolute',
padding: '0.8vmin 2vmin', padding: '0.8vmin 2vmin',
background: '#1a2a4a', background: '#1565c0',
color: '#ddeeff', color: '#ddeeff',
border: '1px solid #4477bb', border: '1px solid #64b5f6',
borderRadius: '4px', borderRadius: '4px',
fontSize: '1.6vmin', fontSize: '1.6vmin',
fontFamily: 'Arial, sans-serif', fontFamily: 'Arial, sans-serif',
@ -1528,8 +1634,8 @@ class PuzzleScene extends Phaser.Scene {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}); });
// Hover effect // Hover effect
btn.addEventListener('mouseenter', () => { btn.style.background = '#223355'; btn.style.borderColor = '#66aaff'; }); btn.addEventListener('mouseenter', () => { btn.style.background = '#1e88e5'; btn.style.borderColor = '#64b5f6'; });
btn.addEventListener('mouseleave', () => { btn.style.background = '#1a2a4a'; btn.style.borderColor = '#4477bb'; }); btn.addEventListener('mouseleave', () => { btn.style.background = '#1565c0'; btn.style.borderColor = '#64b5f6'; });
btn.addEventListener('click', onClick); btn.addEventListener('click', onClick);
return btn; return btn;
} }
@ -1595,6 +1701,7 @@ class PuzzleScene extends Phaser.Scene {
completed: this._completed || false, completed: this._completed || false,
bgKey: this._bgKey, bgKey: this._bgKey,
bgPath: this._bgPath, bgPath: this._bgPath,
startTime: this._startTime,
}); });
StorageManager.save(state); StorageManager.save(state);
StorageManager.saveCurrent(this.cfg.roomCode); StorageManager.saveCurrent(this.cfg.roomCode);

View File

@ -1,5 +1,5 @@
class PuzzleState { class PuzzleState {
constructor({ imageKey, imagePath, pieceCount, roomCode, cols, rows, pieces, groups, completed, bgKey, bgPath }) { constructor({ imageKey, imagePath, pieceCount, roomCode, cols, rows, pieces, groups, completed, bgKey, bgPath, startTime }) {
this.imageKey = imageKey; this.imageKey = imageKey;
this.imagePath = imagePath; this.imagePath = imagePath;
this.pieceCount = pieceCount; this.pieceCount = pieceCount;
@ -11,6 +11,7 @@ class PuzzleState {
this.completed = completed || false; this.completed = completed || false;
this.bgKey = bgKey || 'bg_dark_wood'; this.bgKey = bgKey || 'bg_dark_wood';
this.bgPath = bgPath || 'assets/images/ui/dark_wood.jpg'; this.bgPath = bgPath || 'assets/images/ui/dark_wood.jpg';
this.startTime = startTime || Date.now();
} }
serialize() { serialize() {
@ -33,6 +34,7 @@ class PuzzleState {
completed: this.completed, completed: this.completed,
bgKey: this.bgKey, bgKey: this.bgKey,
bgPath: this.bgPath, bgPath: this.bgPath,
startTime: this.startTime,
}; };
} }