/** * Game Over: Statistik, Namenseingabe für die Ehrentafel und Top 10. * Formular als HTML-Overlay (Texteingabe im Canvas wäre unzugänglich). * @module scenes/GameOverScene */ import { HIGHSCORE } from '../config.js'; import { GAME_OVER, HIGHSCORES } from '../content/texts.js'; import { formatDuration, formatScore, validateName } from '../logic/format.js'; import { el, Overlay } from '../systems/Overlay.js'; /** * @typedef {Object} RunStats * @property {number} score * @property {number} wave * @property {number} kills * @property {number} bestStreak * @property {number} durationMs * @property {string|null} runToken */ export class GameOverScene extends Phaser.Scene { constructor() { super('GameOver'); } /** @param {RunStats} stats */ create(stats) { this.stats = stats; this.services = this.registry.get('services'); const { width, height } = this.scale; this.input.setDefaultCursor('default'); // Sonst würde Phaser Tasten wie Leertaste oder WASD im Namensfeld abfangen. this.input.keyboard.clearCaptures(); this.input.keyboard.disableGlobalCapture(); this.events.once('shutdown', () => { this.overlay?.close(); this.input.keyboard?.enableGlobalCapture(); }); this.services.audio.stopMusic(); this.services.audio.play('game-over'); const bg = this.add.graphics(); bg.fillGradientStyle(0x2a0a10, 0x2a0a10, 0x0d1321, 0x0d1321, 1); bg.fillRect(0, 0, width, height); this.showForm(); } /** @returns {HTMLElement[]} Untertitel und Statistik. */ summary() { const s = this.stats; const subtitle = GAME_OVER.subtitles[Math.floor(Math.random() * GAME_OVER.subtitles.length)]; const list = el('dl', 'stats'); const rows = [ [GAME_OVER.stats.score, formatScore(s.score)], [GAME_OVER.stats.wave, String(s.wave)], [GAME_OVER.stats.kills, String(s.kills)], [GAME_OVER.stats.bestStreak, String(s.bestStreak)], [GAME_OVER.stats.duration, formatDuration(s.durationMs)], ]; for (const [label, value] of rows) { const row = el('div', 'stats__row'); row.append(el('dt', '', label), el('dd', '', value)); list.append(row); } this.subtitle = this.subtitle ?? subtitle; return [el('p', 'disclaimer__lead', this.subtitle), list]; } endButtons() { return [ { label: GAME_OVER.retry, onClick: () => this.scene.start('Game'), testId: 'gameover-retry' }, { label: GAME_OVER.menu, onClick: () => this.scene.start('Menu'), secondary: true, testId: 'gameover-menu' }, ]; } showForm() { const content = this.summary(); if (!this.stats.runToken || this.stats.score <= 0) { if (!this.stats.runToken) content.push(el('p', 'field__error', GAME_OVER.noToken)); this.openOverlay(content, this.endButtons()); return; } const form = /** @type {HTMLFormElement} */ (el('form', 'highscore-form')); const field = el('label', 'field'); const input = /** @type {HTMLInputElement} */ (el('input', 'field__input')); input.type = 'text'; input.name = 'name'; input.maxLength = HIGHSCORE.nameMaxLength; input.autocomplete = 'nickname'; input.spellcheck = false; input.required = true; input.value = this.services.settings.getLastName(); input.dataset.testid = 'highscore-name'; const error = el('p', 'field__error'); error.setAttribute('role', 'alert'); field.append(el('span', 'field__label', GAME_OVER.nameLabel), input, el('span', 'field__hint', GAME_OVER.nameHint)); form.append(field, error); content.push(form); const submit = async () => { const check = validateName(input.value); if (!check.ok) { error.textContent = check.error; input.focus(); return; } submitButton.disabled = true; error.textContent = GAME_OVER.submitting; const result = await this.services.api.submitHighscore({ name: check.name, score: this.stats.score, wave: this.stats.wave, durationS: Math.floor(this.stats.durationMs / 1000), runToken: /** @type {string} */ (this.stats.runToken), }); if (!this.sys.isActive()) return; if (result.ok) { this.services.settings.setLastName(check.name); this.showResults(GAME_OVER.saved(result.rank), result.highscores ?? [], result.rank); return; } submitButton.disabled = false; if (result.error === 'network') error.textContent = GAME_OVER.offline; else error.textContent = result.fields?.name ?? result.message ?? GAME_OVER.offline; }; form.addEventListener('submit', (event) => { event.preventDefault(); submit(); }); this.openOverlay(content, [ { label: GAME_OVER.submit, onClick: () => submit(), testId: 'highscore-submit' }, { label: GAME_OVER.skip, onClick: () => this.showResults('', null, null), secondary: true, testId: 'highscore-skip' }, ]); const submitButton = /** @type {HTMLButtonElement} */ (this.overlay.actions.firstElementChild); input.select(); } /** * @param {string} message * @param {import('../systems/ApiClient.js').HighscoreEntry[]|null} highscores null = neu laden * @param {number|null} ownRank */ async showResults(message, highscores, ownRank) { const content = this.summary(); if (message) content.push(el('p', 'disclaimer__lead', message)); const table = el('ol', 'highscore-list'); content.push(table); this.openOverlay(content, this.endButtons()); let entries = highscores; if (!entries) { table.append(el('li', '', HIGHSCORES.loading)); const result = await this.services.api.fetchHighscores(); if (!this.sys.isActive()) return; entries = result.ok ? result.highscores : []; table.textContent = ''; if (!result.ok) table.append(el('li', '', HIGHSCORES.error)); } if (entries.length === 0 && table.childElementCount === 0) table.append(el('li', '', HIGHSCORES.empty)); for (const entry of entries) { const item = el('li', entry.rank === ownRank ? 'highscore-list__own' : ''); item.append( el('span', 'highscore-list__rank', `${entry.rank}.`), el('span', 'highscore-list__name', entry.name), el('span', 'highscore-list__score', formatScore(entry.score)), el('span', 'highscore-list__wave', `W${entry.wave}`), ); table.append(item); } } /** * @param {HTMLElement[]} content * @param {import('../systems/Overlay.js').OverlayButton[]} buttons */ openOverlay(content, buttons) { this.overlay?.close(); this.overlay = new Overlay({ id: 'gameover-overlay', title: GAME_OVER.title, content, buttons }); } }