From cddfe9125d7f21af5d34442e0651b3bda092c94f Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 20 Mar 2026 12:58:29 -0400 Subject: [PATCH] feat: add player list component with slot-based display Made-with: Cursor --- js/player-list.js | 305 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 js/player-list.js diff --git a/js/player-list.js b/js/player-list.js new file mode 100644 index 0000000..0a5df99 --- /dev/null +++ b/js/player-list.js @@ -0,0 +1,305 @@ +/** + * Numbered player slot roster beside the room code. Implements the overlay component contract: + * init, activate, deactivate, update, getStatus. + */ + +const DEFAULT_MAX_PLAYERS = 8; + +/** + * @typedef {object} PlayerListInputs + * @property {HTMLInputElement} enabled + * @property {HTMLSelectElement | HTMLInputElement} position + * @property {HTMLInputElement} fontSize + * @property {HTMLInputElement} textColor + * @property {HTMLInputElement} emptyColor + * @property {HTMLInputElement} offset + */ + +/** + * @typedef {object} PlayerListTimingInputs + * @property {HTMLInputElement} headerAppearDelay + * @property {HTMLInputElement} headerAppearDuration + */ + +/** + * @param {unknown} p + * @returns {string} + */ +function displayName(p) { + if (p == null) { + return ''; + } + if (typeof p === 'string') { + return p.trim(); + } + if (typeof p === 'object' && p !== null && 'name' in p) { + return String(/** @type {{ name?: unknown }} */ (p).name ?? '').trim(); + } + return String(p).trim(); +} + +export class PlayerList { + constructor() { + this._active = false; + /** @type {HTMLElement | null} */ + this._container = null; + /** @type {PlayerListInputs | null} */ + this._inputs = null; + /** @type {PlayerListTimingInputs | null} */ + this._timingInputs = null; + /** @type {{ element: HTMLDivElement, nameEl: HTMLSpanElement, filled: boolean }[]} */ + this._slots = []; + /** @type {unknown[]} */ + this._players = []; + this._maxPlayers = DEFAULT_MAX_PLAYERS; + /** @type {ReturnType[]} */ + this._animationTimers = []; + /** @type {ReturnType | null} */ + this._deactivateFadeTimer = null; + } + + /** + * @param {HTMLElement} container + * @param {PlayerListInputs} inputs + * @param {PlayerListTimingInputs} animationTimingInputs + */ + init(container, inputs, animationTimingInputs) { + this._container = container; + this._inputs = inputs; + this._timingInputs = animationTimingInputs; + } + + /** + * @param {{ maxPlayers?: number, players?: unknown[], [key: string]: unknown }} ctx + */ + activate(ctx) { + this.deactivate(); + + this._active = true; + + const inputs = this._inputs; + if (!inputs || !inputs.enabled.checked) { + return; + } + + const rawMax = ctx.maxPlayers != null ? Number(ctx.maxPlayers) : DEFAULT_MAX_PLAYERS; + this._maxPlayers = + Number.isFinite(rawMax) && rawMax > 0 ? Math.floor(rawMax) : DEFAULT_MAX_PLAYERS; + + this._players = Array.isArray(ctx.players) ? [...ctx.players] : []; + + this._buildSlots(); + this._applySettings(); + this._fillSlots(this._players.map(displayName)); + this._animateIn(); + } + + deactivate() { + this._active = false; + this._clearTimers(); + + if (this._deactivateFadeTimer != null) { + clearTimeout(this._deactivateFadeTimer); + this._deactivateFadeTimer = null; + } + + const container = this._container; + if (!container) { + return; + } + + container.style.transition = 'opacity 0.3s ease-out'; + container.style.opacity = '0'; + + this._deactivateFadeTimer = setTimeout(() => { + this._deactivateFadeTimer = null; + if (!this._active && this._container) { + this._container.innerHTML = ''; + this._slots = []; + this._players = []; + } + }, 300); + } + + /** + * @param {{ maxPlayers?: number, players?: unknown[], [key: string]: unknown }} ctx + */ + update(ctx) { + const inputs = this._inputs; + if (!this._active || !inputs?.enabled.checked) { + return; + } + + if (ctx.maxPlayers != null) { + const rawMax = Number(ctx.maxPlayers); + const nextMax = + Number.isFinite(rawMax) && rawMax > 0 ? Math.floor(rawMax) : DEFAULT_MAX_PLAYERS; + if (nextMax !== this._maxPlayers) { + this._maxPlayers = nextMax; + this._buildSlots(); + this._applySettings(); + const names = (ctx.players != null ? ctx.players : this._players).map(displayName); + this._players = ctx.players != null ? [...ctx.players] : [...this._players]; + this._fillSlots(names); + return; + } + } + + if (ctx.players != null && Array.isArray(ctx.players)) { + const prevNames = this._players.map(displayName); + const nextNames = ctx.players.map(displayName); + const prevSet = new Set(prevNames.filter(Boolean)); + const newlyJoined = nextNames.filter((n) => n && !prevSet.has(n)); + + this._players = [...ctx.players]; + this._fillSlots(nextNames, newlyJoined); + } + } + + getStatus() { + return { + active: this._active, + enabled: this._inputs?.enabled.checked ?? false, + playerCount: this._players.length, + maxPlayers: this._maxPlayers, + players: this._players.map((p) => displayName(p)), + }; + } + + _buildSlots() { + const container = this._container; + if (!container) { + return; + } + + container.innerHTML = ''; + this._slots = []; + + for (let i = 0; i < this._maxPlayers; i++) { + const slot = document.createElement('div'); + slot.className = 'player-slot'; + slot.dataset.index = String(i); + + const number = document.createElement('span'); + number.className = 'player-slot-number'; + number.textContent = `${i + 1}.`; + + const name = document.createElement('span'); + name.className = 'player-slot-name empty'; + name.textContent = '────────'; + + slot.appendChild(number); + slot.appendChild(name); + container.appendChild(slot); + this._slots.push({ element: slot, nameEl: name, filled: false }); + } + } + + _applySettings() { + const container = this._container; + const inputs = this._inputs; + if (!container || !inputs) { + return; + } + + container.classList.remove('player-list-position-left', 'player-list-position-right'); + const pos = String(inputs.position.value || 'left').toLowerCase(); + if (pos === 'right') { + container.classList.add('player-list-position-right'); + } else { + container.classList.add('player-list-position-left'); + } + + const fontPx = `${parseInt(inputs.fontSize.value, 10) || 14}px`; + const textColor = inputs.textColor.value; + const emptyColor = inputs.emptyColor.value; + const offsetY = `${parseInt(inputs.offset.value, 10) || 0}px`; + + container.style.transform = `translateY(${offsetY})`; + + for (const slot of this._slots) { + slot.nameEl.style.fontSize = fontPx; + if (slot.nameEl.classList.contains('filled')) { + slot.nameEl.style.color = textColor; + } else { + slot.nameEl.style.color = emptyColor; + } + } + } + + /** + * @param {string[]} players + * @param {string[]} [newlyJoined] + */ + _fillSlots(players, newlyJoined = []) { + const newSet = new Set(newlyJoined.filter(Boolean)); + const inputs = this._inputs; + + for (let i = 0; i < this._slots.length; i++) { + const slot = this._slots[i]; + const name = players[i]; + const hasPlayer = name != null && String(name).length > 0; + + if (hasPlayer) { + const label = String(name); + slot.nameEl.textContent = label; + slot.nameEl.classList.remove('empty'); + slot.nameEl.classList.add('filled'); + slot.filled = true; + if (inputs) { + slot.nameEl.style.color = inputs.textColor.value; + } + + if (newSet.has(label)) { + slot.nameEl.style.opacity = '0'; + requestAnimationFrame(() => { + slot.nameEl.style.transition = 'opacity 0.4s ease-out'; + slot.nameEl.style.opacity = '1'; + }); + } else { + slot.nameEl.style.transition = ''; + slot.nameEl.style.opacity = '1'; + } + } else if (slot.filled) { + slot.nameEl.textContent = '────────'; + slot.nameEl.classList.remove('filled'); + slot.nameEl.classList.add('empty'); + slot.filled = false; + slot.nameEl.style.transition = ''; + slot.nameEl.style.opacity = '1'; + if (inputs) { + slot.nameEl.style.color = inputs.emptyColor.value; + } + } + } + } + + _animateIn() { + const container = this._container; + const timing = this._timingInputs; + if (!container || !timing) { + return; + } + + this._clearTimers(); + + container.style.opacity = '0'; + container.style.display = 'flex'; + + const sec = (input) => parseInt(input.value, 10) * 1000; + const headerAppearDelayMs = sec(timing.headerAppearDelay); + const headerAppearDurationMs = sec(timing.headerAppearDuration); + + this._animationTimers.push( + setTimeout(() => { + container.style.transition = `opacity ${headerAppearDurationMs / 1000}s ease-out`; + container.style.opacity = '1'; + }, headerAppearDelayMs), + ); + } + + _clearTimers() { + this._animationTimers.forEach((id) => clearTimeout(id)); + this._animationTimers = []; + } +}