Files
2026-09-24 17:57:54 +02:00

186 lines
6.6 KiB
JavaScript

/**
* 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 });
}
}