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:
parent
35f050dfeb
commit
cba680c44d
|
|
@ -46,6 +46,25 @@ class PuzzleScene extends Phaser.Scene {
|
|||
this._playerNames = new Map(); // playerId -> name
|
||||
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
|
||||
if (this.cfg._networkJoin) {
|
||||
this._isNetworked = true;
|
||||
|
|
@ -1089,6 +1108,8 @@ class PuzzleScene extends Phaser.Scene {
|
|||
const gm = this._groupManager;
|
||||
if (gm.groupCount === 1 && gm.pieceCount === this._pieces.length) {
|
||||
this._completed = true;
|
||||
// Freeze the timer
|
||||
this._completionTime = Date.now() - this._startTime;
|
||||
StorageManager.clearCurrent();
|
||||
this._showCompletion(true);
|
||||
}
|
||||
|
|
@ -1151,13 +1172,16 @@ class PuzzleScene extends Phaser.Scene {
|
|||
// ─── Room Stats helpers ──────────────────────────────────────────────
|
||||
|
||||
_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 h = Math.floor(elapsed / 3600);
|
||||
const m = Math.floor((elapsed % 3600) / 60);
|
||||
const s = elapsed % 60;
|
||||
this._timerEl.textContent =
|
||||
String(h).padStart(2, '0') + ':' +
|
||||
this._timerEl.textContent = this._formatTime(elapsed);
|
||||
}
|
||||
|
||||
_formatTime(totalSeconds) {
|
||||
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(s).padStart(2, '0');
|
||||
}
|
||||
|
|
@ -1435,6 +1459,7 @@ class PuzzleScene extends Phaser.Scene {
|
|||
this._uiLayer.appendChild(backBtn);
|
||||
|
||||
// Room Stats panel — below menu button
|
||||
// States: 'expanded' → 'folded' (content hidden, title visible) → 'collapsed' (icon only)
|
||||
const statsPanel = document.createElement('div');
|
||||
Object.assign(statsPanel.style, {
|
||||
position: 'absolute',
|
||||
|
|
@ -1447,25 +1472,64 @@ class PuzzleScene extends Phaser.Scene {
|
|||
color: '#ddeeff',
|
||||
fontSize: '14px',
|
||||
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, {
|
||||
fontWeight: 'bold',
|
||||
fontSize: '15px',
|
||||
marginBottom: '6px',
|
||||
color: '#ddeeff',
|
||||
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';
|
||||
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
|
||||
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';
|
||||
statsPanel.appendChild(timeLabel);
|
||||
statsContent.appendChild(timeLabel);
|
||||
|
||||
this._timerEl = document.createElement('div');
|
||||
Object.assign(this._timerEl.style, {
|
||||
|
|
@ -1475,20 +1539,131 @@ class PuzzleScene extends Phaser.Scene {
|
|||
fontWeight: 'bold',
|
||||
});
|
||||
this._timerEl.textContent = '00:00:00';
|
||||
statsPanel.appendChild(this._timerEl);
|
||||
statsContent.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);
|
||||
statsContent.appendChild(playersLabel);
|
||||
|
||||
this._playersListEl = document.createElement('div');
|
||||
Object.assign(this._playersListEl.style, {
|
||||
color: '#ddeeff',
|
||||
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._updatePlayersList();
|
||||
|
|
@ -1589,12 +1764,14 @@ class PuzzleScene extends Phaser.Scene {
|
|||
});
|
||||
title.textContent = 'Puzzle Complete!';
|
||||
|
||||
const roomLabel = document.createElement('div');
|
||||
Object.assign(roomLabel.style, {
|
||||
const timeLabel = document.createElement('div');
|
||||
Object.assign(timeLabel.style, {
|
||||
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');
|
||||
Object.assign(btnRow.style, {
|
||||
|
|
@ -1612,7 +1789,7 @@ class PuzzleScene extends Phaser.Scene {
|
|||
btnRow.appendChild(menuBtn);
|
||||
|
||||
overlay.appendChild(title);
|
||||
overlay.appendChild(roomLabel);
|
||||
overlay.appendChild(timeLabel);
|
||||
overlay.appendChild(btnRow);
|
||||
this._uiLayer.appendChild(overlay);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue