Files
slither-2.3/client/src/ui.ts
T
2026-07-22 00:34:09 -05:00

163 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------------------------------------------------------------------------
// DOM UI: main menu, settings, HUD (leaderboard + score), death overlay,
// fullscreen support, localStorage persistence.
// ---------------------------------------------------------------------------
import type { LeaderboardRow, YouState } from '../../shared/protocol.js';
export interface Settings {
volume: number; // 0..1
quality: 'high' | 'low';
minimap: boolean;
}
const SETTINGS_KEY = 'slither.settings';
const NAME_KEY = 'slither.name';
const BEST_KEY = 'slither.bestEver';
function el<T extends HTMLElement>(id: string): T {
const e = document.getElementById(id);
if (!e) throw new Error(`missing element #${id}`);
return e as T;
}
export class UI {
settings: Settings = { volume: 0.6, quality: 'high', minimap: true };
bestEver = 0;
onPlay: (name: string) => void = () => {};
onRespawn: () => void = () => {};
onSettingsChanged: () => void = () => {};
private menu = el<HTMLDivElement>('menu');
private settingsEl = el<HTMLDivElement>('settings');
private hud = el<HTMLDivElement>('hud');
private death = el<HTMLDivElement>('death');
private nick = el<HTMLInputElement>('nick');
private lbRows = el<HTMLDivElement>('lb-rows');
private scoreEl = el<HTMLSpanElement>('score');
private scoreSub = el<HTMLSpanElement>('score-sub');
private deathVisible = false;
constructor() {
// persisted settings / nickname / best score
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (raw) Object.assign(this.settings, JSON.parse(raw));
} catch { /* ignore */ }
this.nick.value = localStorage.getItem(NAME_KEY) || '';
this.bestEver = Number(localStorage.getItem(BEST_KEY) || 0) || 0;
el<HTMLButtonElement>('play').addEventListener('click', () => {
const name = this.nick.value.trim() || 'Player';
localStorage.setItem(NAME_KEY, name);
this.onPlay(name);
});
this.nick.addEventListener('keydown', (e) => {
if (e.key === 'Enter') el<HTMLButtonElement>('play').click();
e.stopPropagation();
});
// settings panel
const vol = el<HTMLInputElement>('opt-volume');
const qual = el<HTMLSelectElement>('opt-quality');
const mm = el<HTMLInputElement>('opt-minimap');
const fs = el<HTMLInputElement>('opt-fullscreen');
vol.value = String(Math.round(this.settings.volume * 100));
qual.value = this.settings.quality;
mm.checked = this.settings.minimap;
fs.checked = !!document.fullscreenElement;
el<HTMLButtonElement>('settings-btn').addEventListener('click', () => {
this.menu.classList.add('hidden');
this.settingsEl.classList.remove('hidden');
this.onSettingsChanged();
});
el<HTMLButtonElement>('settings-back').addEventListener('click', () => {
this.settingsEl.classList.add('hidden');
this.menu.classList.remove('hidden');
this.onSettingsChanged();
});
vol.addEventListener('input', () => { this.settings.volume = Number(vol.value) / 100; this.save(); });
qual.addEventListener('change', () => { this.settings.quality = qual.value === 'low' ? 'low' : 'high'; this.save(); });
mm.addEventListener('change', () => { this.settings.minimap = mm.checked; this.save(); });
fs.addEventListener('change', () => { UI.toggleFullscreen(); });
el<HTMLButtonElement>('fullscreen-btn').addEventListener('click', () => UI.toggleFullscreen());
document.addEventListener('fullscreenchange', () => { fs.checked = !!document.fullscreenElement; });
el<HTMLButtonElement>('respawn').addEventListener('click', () => this.onRespawn());
}
private save(): void {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(this.settings));
this.onSettingsChanged();
}
static toggleFullscreen(): void {
if (document.fullscreenElement) {
void document.exitFullscreen();
} else {
void document.documentElement.requestFullscreen().catch(() => {});
}
}
showError(msg: string): void {
const e = el<HTMLDivElement>('menu-error');
e.textContent = msg;
e.classList.remove('hidden');
// if we were in game, fall back to the menu
if (!this.hud.classList.contains('hidden')) this.showMenu();
}
showMenu(): void {
this.menu.classList.remove('hidden');
this.hud.classList.add('hidden');
this.death.classList.add('hidden');
this.deathVisible = false;
}
showGame(): void {
this.menu.classList.add('hidden');
this.settingsEl.classList.add('hidden');
this.hud.classList.remove('hidden');
this.death.classList.add('hidden');
this.deathVisible = false;
}
showDeath(you: YouState): void {
if (this.deathVisible) return;
this.deathVisible = true;
if (you.best > this.bestEver) {
this.bestEver = you.best;
localStorage.setItem(BEST_KEY, String(you.best));
}
el<HTMLSpanElement>('d-score').textContent = String(you.score);
el<HTMLSpanElement>('d-best').textContent = String(Math.max(you.best, this.bestEver));
el<HTMLSpanElement>('d-kills').textContent = String(you.kills);
this.death.classList.remove('hidden');
el<HTMLButtonElement>('respawn').focus();
}
hideDeath(): void {
this.death.classList.add('hidden');
this.deathVisible = false;
}
updateHud(lb: LeaderboardRow[], myId: number, you: YouState, total: number): void {
this.scoreEl.textContent = String(you.score);
this.scoreSub.textContent =
`rank #${you.alive ? you.rank : '–'} of ${total} · best ${Math.max(you.best, this.bestEver)} · kills ${you.kills}`;
let html = '';
for (let i = 0; i < lb.length; i++) {
const row = lb[i];
const me = row.id === myId ? ' me' : '';
const name = row.n.replace(/&/g, '&amp;').replace(/</g, '&lt;');
html += `<div class="lb-row${me}"><span class="lb-rank">${i + 1}</span>` +
`<span class="lb-name">${name}</span><span class="lb-score">${row.s}</span></div>`;
}
this.lbRows.innerHTML = html;
}
}