feat: add player list component with slot-based display
Made-with: Cursor
This commit is contained in:
305
js/player-list.js
Normal file
305
js/player-list.js
Normal file
@@ -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<typeof setTimeout>[]} */
|
||||
this._animationTimers = [];
|
||||
/** @type {ReturnType<typeof setTimeout> | 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 = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user