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() {
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)
if (this._currentMusic && this._musicStarted && !this._currentMusic.isPlaying && !this._currentMusic.isPaused) {
this._playNextTrack();
@ -310,6 +313,9 @@ class PuzzleScene extends Phaser.Scene {
this._panLastY = 0;
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)
this._isBoxSelecting = false;
this._boxStartWorld = null; // { x, y } in world coords
@ -396,6 +402,7 @@ class PuzzleScene extends Phaser.Scene {
}
this._setupNetworkListeners();
this._updatePlayersList();
} else if (NetworkManager.connected) {
// Host — create the room on the server
this._isNetworked = true;
@ -412,6 +419,7 @@ class PuzzleScene extends Phaser.Scene {
completed: false,
bgKey: this._bgKey,
bgPath: this._bgPath,
startTime: this._startTime,
});
NetworkManager.createRoom(this.cfg.roomCode, state.serialize(), this.cfg.playerName);
this._setupNetworkListeners();
@ -548,6 +556,7 @@ class PuzzleScene extends Phaser.Scene {
this._playerNames.set(msg.playerId, msg.playerName);
}
console.log(`${msg.playerName || 'Player ' + msg.playerId} joined the room`);
this._updatePlayersList();
}
_onNetworkPlayerLeft(msg) {
@ -564,6 +573,7 @@ class PuzzleScene extends Phaser.Scene {
}
this._playerNames.delete(msg.playerId);
console.log(`Player ${msg.playerId} left the room`);
this._updatePlayersList();
}
_onNetworkCompleted() {
@ -1138,6 +1148,43 @@ class PuzzleScene extends Phaser.Scene {
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 ──────────────────────────────────────────────────────────
// ─── Music ──────────────────────────────────────────────────────────
@ -1384,9 +1431,68 @@ class PuzzleScene extends Phaser.Scene {
this._saveState();
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);
// 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
const musicRow = document.createElement('div');
Object.assign(musicRow.style, {
@ -1399,9 +1505,9 @@ class PuzzleScene extends Phaser.Scene {
});
const btnStyle = {
background: 'rgba(0, 0, 0, 0.55)',
background: '#1565c0',
color: '#ddeeff',
border: '1px solid #4477bb',
border: '1px solid #64b5f6',
borderRadius: '4px',
fontSize: '1.8vmin',
fontFamily: 'Arial, sans-serif',
@ -1415,16 +1521,16 @@ class PuzzleScene extends Phaser.Scene {
skipBtn.textContent = '\u23ED'; // next track symbol
skipBtn.title = 'Next Track';
Object.assign(skipBtn.style, btnStyle);
skipBtn.addEventListener('mouseenter', () => { skipBtn.style.background = '#223355'; });
skipBtn.addEventListener('mouseleave', () => { skipBtn.style.background = 'rgba(0, 0, 0, 0.55)'; });
skipBtn.addEventListener('mouseenter', () => { skipBtn.style.background = '#1e88e5'; });
skipBtn.addEventListener('mouseleave', () => { skipBtn.style.background = '#1565c0'; });
skipBtn.addEventListener('click', () => this._skipTrack());
// Mute/unmute button
this._muteBtn = document.createElement('button');
this._muteBtn.title = 'Mute / Unmute';
Object.assign(this._muteBtn.style, btnStyle);
this._muteBtn.addEventListener('mouseenter', () => { this._muteBtn.style.background = '#223355'; });
this._muteBtn.addEventListener('mouseleave', () => { this._muteBtn.style.background = 'rgba(0, 0, 0, 0.55)'; });
this._muteBtn.addEventListener('mouseenter', () => { this._muteBtn.style.background = '#1e88e5'; });
this._muteBtn.addEventListener('mouseleave', () => { this._muteBtn.style.background = '#1565c0'; });
this._muteBtn.addEventListener('click', () => {
this._toggleMute();
this._muteBtn.textContent = this._musicMuted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
@ -1517,9 +1623,9 @@ class PuzzleScene extends Phaser.Scene {
Object.assign(btn.style, {
position: 'absolute',
padding: '0.8vmin 2vmin',
background: '#1a2a4a',
background: '#1565c0',
color: '#ddeeff',
border: '1px solid #4477bb',
border: '1px solid #64b5f6',
borderRadius: '4px',
fontSize: '1.6vmin',
fontFamily: 'Arial, sans-serif',
@ -1528,8 +1634,8 @@ class PuzzleScene extends Phaser.Scene {
whiteSpace: 'nowrap',
});
// Hover effect
btn.addEventListener('mouseenter', () => { btn.style.background = '#223355'; btn.style.borderColor = '#66aaff'; });
btn.addEventListener('mouseleave', () => { btn.style.background = '#1a2a4a'; btn.style.borderColor = '#4477bb'; });
btn.addEventListener('mouseenter', () => { btn.style.background = '#1e88e5'; btn.style.borderColor = '#64b5f6'; });
btn.addEventListener('mouseleave', () => { btn.style.background = '#1565c0'; btn.style.borderColor = '#64b5f6'; });
btn.addEventListener('click', onClick);
return btn;
}
@ -1595,6 +1701,7 @@ class PuzzleScene extends Phaser.Scene {
completed: this._completed || false,
bgKey: this._bgKey,
bgPath: this._bgPath,
startTime: this._startTime,
});
StorageManager.save(state);
StorageManager.saveCurrent(this.cfg.roomCode);

View File

@ -1,5 +1,5 @@
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.imagePath = imagePath;
this.pieceCount = pieceCount;
@ -11,6 +11,7 @@ class PuzzleState {
this.completed = completed || false;
this.bgKey = bgKey || 'bg_dark_wood';
this.bgPath = bgPath || 'assets/images/ui/dark_wood.jpg';
this.startTime = startTime || Date.now();
}
serialize() {
@ -33,6 +34,7 @@ class PuzzleState {
completed: this.completed,
bgKey: this.bgKey,
bgPath: this.bgPath,
startTime: this.startTime,
};
}