feat: add collapsible Room Stats panel with puzzle timer and player list

- Introduce state machine (expanded → folded → collapsed) for animated toggle
- Freeze timer on completion and display total time in completion UI
- Refactor `_updateTimer()` to use `_formatTime()` helper and skip updates when completed
- Enhance DOM UI with styled, animated stats panel below menu button
This commit is contained in:
Brian Fertig 2026-04-05 13:10:44 -06:00
parent 35f050dfeb
commit cba680c44d
1 changed files with 197 additions and 20 deletions

View File

@ -46,6 +46,25 @@ class PuzzleScene extends Phaser.Scene {
this._playerNames = new Map(); // playerId -> name this._playerNames = new Map(); // playerId -> name
this._remoteLabels = new Map(); // playerId -> { bg: Graphics, text: Text } this._remoteLabels = new Map(); // playerId -> { bg: Graphics, text: Text }
// Reset state from any previous puzzle
this._completed = false;
this._completionTime = null;
this._ready = false;
this._heldPiece = null;
this._isPanning = false;
this._isBoxSelecting = false;
this._boxStartWorld = null;
this._boxSelectedIds = null;
this._startTime = null;
this._currentMusic = null;
this._musicStarted = false;
this._timerEl = null;
this._playersListEl = null;
this._connStatusEl = null;
this._trackInfoEl = null;
this._muteBtn = null;
this._minZoom = null;
// Network join from MainMenuScene // Network join from MainMenuScene
if (this.cfg._networkJoin) { if (this.cfg._networkJoin) {
this._isNetworked = true; this._isNetworked = true;
@ -1089,6 +1108,8 @@ class PuzzleScene extends Phaser.Scene {
const gm = this._groupManager; const gm = this._groupManager;
if (gm.groupCount === 1 && gm.pieceCount === this._pieces.length) { if (gm.groupCount === 1 && gm.pieceCount === this._pieces.length) {
this._completed = true; this._completed = true;
// Freeze the timer
this._completionTime = Date.now() - this._startTime;
StorageManager.clearCurrent(); StorageManager.clearCurrent();
this._showCompletion(true); this._showCompletion(true);
} }
@ -1151,13 +1172,16 @@ class PuzzleScene extends Phaser.Scene {
// ─── Room Stats helpers ────────────────────────────────────────────── // ─── Room Stats helpers ──────────────────────────────────────────────
_updateTimer() { _updateTimer() {
if (!this._timerEl || !this._startTime) return; if (!this._timerEl || !this._startTime || this._completed) return;
const elapsed = Math.floor((Date.now() - this._startTime) / 1000); const elapsed = Math.floor((Date.now() - this._startTime) / 1000);
const h = Math.floor(elapsed / 3600); this._timerEl.textContent = this._formatTime(elapsed);
const m = Math.floor((elapsed % 3600) / 60); }
const s = elapsed % 60;
this._timerEl.textContent = _formatTime(totalSeconds) {
String(h).padStart(2, '0') + ':' + const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return String(h).padStart(2, '0') + ':' +
String(m).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' +
String(s).padStart(2, '0'); String(s).padStart(2, '0');
} }
@ -1435,6 +1459,7 @@ class PuzzleScene extends Phaser.Scene {
this._uiLayer.appendChild(backBtn); this._uiLayer.appendChild(backBtn);
// Room Stats panel — below menu button // Room Stats panel — below menu button
// States: 'expanded' → 'folded' (content hidden, title visible) → 'collapsed' (icon only)
const statsPanel = document.createElement('div'); const statsPanel = document.createElement('div');
Object.assign(statsPanel.style, { Object.assign(statsPanel.style, {
position: 'absolute', position: 'absolute',
@ -1447,25 +1472,64 @@ class PuzzleScene extends Phaser.Scene {
color: '#ddeeff', color: '#ddeeff',
fontSize: '14px', fontSize: '14px',
lineHeight: '1.6', lineHeight: '1.6',
minWidth: '160px', overflow: 'hidden',
transition: 'width 0.3s ease, padding 0.3s ease',
width: 'auto',
}); });
const statsTitle = document.createElement('div'); // Title row with collapse toggle
const statsHeader = document.createElement('div');
Object.assign(statsHeader.style, {
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
pointerEvents: 'auto',
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
const toggleIcon = document.createElement('span');
Object.assign(toggleIcon.style, {
fontSize: '14px',
color: '#8899bb',
display: 'inline-block',
flexShrink: '0',
});
toggleIcon.textContent = '\u25B2'; // ▲
const statsTitle = document.createElement('span');
Object.assign(statsTitle.style, { Object.assign(statsTitle.style, {
fontWeight: 'bold', fontWeight: 'bold',
fontSize: '15px', fontSize: '15px',
marginBottom: '6px',
color: '#ddeeff', color: '#ddeeff',
letterSpacing: '0.05em', letterSpacing: '0.05em',
marginLeft: '8px',
overflow: 'hidden',
transition: 'max-width 0.3s ease, opacity 0.25s ease, margin 0.3s ease',
display: 'inline-block',
maxWidth: '200px',
opacity: '1',
}); });
statsTitle.textContent = 'Room Stats'; statsTitle.textContent = 'Room Stats';
statsPanel.appendChild(statsTitle);
statsHeader.appendChild(toggleIcon);
statsHeader.appendChild(statsTitle);
statsPanel.appendChild(statsHeader);
// Collapsible content wrapper
const statsContent = document.createElement('div');
Object.assign(statsContent.style, {
overflow: 'hidden',
transition: 'max-height 0.3s ease, opacity 0.25s ease',
opacity: '1',
});
// Puzzle Time // Puzzle Time
const timeLabel = document.createElement('div'); const timeLabel = document.createElement('div');
Object.assign(timeLabel.style, { color: '#8899bb', fontSize: '12px', marginTop: '4px' }); Object.assign(timeLabel.style, { color: '#8899bb', fontSize: '12px', marginTop: '6px' });
timeLabel.textContent = 'Puzzle Time'; timeLabel.textContent = 'Puzzle Time';
statsPanel.appendChild(timeLabel); statsContent.appendChild(timeLabel);
this._timerEl = document.createElement('div'); this._timerEl = document.createElement('div');
Object.assign(this._timerEl.style, { Object.assign(this._timerEl.style, {
@ -1475,20 +1539,131 @@ class PuzzleScene extends Phaser.Scene {
fontWeight: 'bold', fontWeight: 'bold',
}); });
this._timerEl.textContent = '00:00:00'; this._timerEl.textContent = '00:00:00';
statsPanel.appendChild(this._timerEl); statsContent.appendChild(this._timerEl);
// Players // Players
const playersLabel = document.createElement('div'); const playersLabel = document.createElement('div');
Object.assign(playersLabel.style, { color: '#8899bb', fontSize: '12px', marginTop: '8px' }); Object.assign(playersLabel.style, { color: '#8899bb', fontSize: '12px', marginTop: '8px' });
playersLabel.textContent = 'Players'; playersLabel.textContent = 'Players';
statsPanel.appendChild(playersLabel); statsContent.appendChild(playersLabel);
this._playersListEl = document.createElement('div'); this._playersListEl = document.createElement('div');
Object.assign(this._playersListEl.style, { Object.assign(this._playersListEl.style, {
color: '#ddeeff', color: '#ddeeff',
fontSize: '13px', fontSize: '13px',
}); });
statsPanel.appendChild(this._playersListEl); statsContent.appendChild(this._playersListEl);
statsPanel.appendChild(statsContent);
// Measure natural height after first render
requestAnimationFrame(() => {
statsContent.style.maxHeight = statsContent.scrollHeight + 'px';
});
// Collapse / expand state machine
let statsState = 'expanded'; // 'expanded' | 'folded' | 'collapsed'
let animating = false;
const collapseContent = () => {
// Phase 1: fold content up
animating = true;
statsContent.style.maxHeight = statsContent.scrollHeight + 'px';
statsContent.offsetHeight; // eslint-disable-line no-unused-expressions
statsContent.style.maxHeight = '0px';
statsContent.style.opacity = '0';
toggleIcon.textContent = '\u25BC'; // ▼
statsContent.addEventListener('transitionend', function onFolded(ev) {
if (ev.propertyName !== 'max-height') return;
statsContent.removeEventListener('transitionend', onFolded);
statsState = 'folded';
// Phase 2: collapse title horizontally
collapseTitle();
});
};
const collapseTitle = () => {
// Snapshot current width so we can animate from it
const currentW = statsPanel.offsetWidth;
statsPanel.style.width = currentW + 'px';
statsPanel.offsetHeight; // eslint-disable-line no-unused-expressions
statsTitle.style.maxWidth = '0px';
statsTitle.style.opacity = '0';
statsTitle.style.marginLeft = '0px';
// Icon is ~14px + 8px padding each side = 30px
statsPanel.style.width = '30px';
statsPanel.style.padding = '8px';
statsTitle.addEventListener('transitionend', function onTitleDone(ev) {
if (ev.propertyName !== 'max-width') return;
statsTitle.removeEventListener('transitionend', onTitleDone);
toggleIcon.textContent = '\u25B6'; // ▶
statsState = 'collapsed';
animating = false;
});
};
const expandTitle = () => {
// Phase 1: expand title horizontally
animating = true;
toggleIcon.textContent = '\u25BC'; // ▼
statsPanel.style.padding = '10px 16px';
statsPanel.style.width = 'auto';
// Measure the full expanded width, then animate to it
const fullW = statsPanel.offsetWidth;
statsPanel.style.width = '30px';
statsPanel.offsetHeight; // eslint-disable-line no-unused-expressions
statsPanel.style.width = fullW + 'px';
statsTitle.style.maxWidth = '200px';
statsTitle.style.opacity = '1';
statsTitle.style.marginLeft = '8px';
statsTitle.addEventListener('transitionend', function onTitleBack(ev) {
if (ev.propertyName !== 'max-width') return;
statsTitle.removeEventListener('transitionend', onTitleBack);
statsPanel.style.width = 'auto';
statsState = 'folded';
// Phase 2: expand content
expandContent();
});
};
const expandContent = () => {
// Measure natural height
statsContent.style.transition = 'none';
statsContent.style.maxHeight = 'none';
const fullHeight = statsContent.scrollHeight;
statsContent.style.maxHeight = '0px';
statsContent.offsetHeight; // eslint-disable-line no-unused-expressions
statsContent.style.transition = 'max-height 0.3s ease, opacity 0.25s ease';
statsContent.style.maxHeight = fullHeight + 'px';
statsContent.style.opacity = '1';
toggleIcon.textContent = '\u25B2'; // ▲
statsContent.addEventListener('transitionend', function onExpanded(ev) {
if (ev.propertyName !== 'max-height') return;
statsContent.removeEventListener('transitionend', onExpanded);
statsContent.style.maxHeight = 'none';
statsState = 'expanded';
animating = false;
});
};
statsHeader.addEventListener('click', (e) => {
e.stopPropagation();
if (animating) return;
if (statsState === 'expanded') {
collapseContent();
} else if (statsState === 'collapsed') {
expandTitle();
}
});
// Hover feedback on toggle icon
statsHeader.addEventListener('mouseenter', () => { toggleIcon.style.color = '#ddeeff'; });
statsHeader.addEventListener('mouseleave', () => { toggleIcon.style.color = '#8899bb'; });
this._uiLayer.appendChild(statsPanel); this._uiLayer.appendChild(statsPanel);
this._updatePlayersList(); this._updatePlayersList();
@ -1589,12 +1764,14 @@ class PuzzleScene extends Phaser.Scene {
}); });
title.textContent = 'Puzzle Complete!'; title.textContent = 'Puzzle Complete!';
const roomLabel = document.createElement('div'); const timeLabel = document.createElement('div');
Object.assign(roomLabel.style, { Object.assign(timeLabel.style, {
color: '#aaccff', color: '#aaccff',
fontSize: '2.2vmin', fontSize: '2.5vmin',
fontFamily: 'Arial, sans-serif',
}); });
roomLabel.textContent = `Room: ${this.cfg.roomCode}`; const totalSec = Math.floor((this._completionTime || 0) / 1000);
timeLabel.textContent = `Total Puzzle Time: ${this._formatTime(totalSec)}`;
const btnRow = document.createElement('div'); const btnRow = document.createElement('div');
Object.assign(btnRow.style, { Object.assign(btnRow.style, {
@ -1612,7 +1789,7 @@ class PuzzleScene extends Phaser.Scene {
btnRow.appendChild(menuBtn); btnRow.appendChild(menuBtn);
overlay.appendChild(title); overlay.appendChild(title);
overlay.appendChild(roomLabel); overlay.appendChild(timeLabel);
overlay.appendChild(btnRow); overlay.appendChild(btnRow);
this._uiLayer.appendChild(overlay); this._uiLayer.appendChild(overlay);
} }