163 lines
5.9 KiB
TypeScript
163 lines
5.9 KiB
TypeScript
// ---------------------------------------------------------------------------
|
||
// 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, '&').replace(/</g, '<');
|
||
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;
|
||
}
|
||
}
|