slop game oder so
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Startszene: lädt Grafiken mit Fortschrittsbalken und erzeugt für alles, was nicht
|
||||
* geladen werden konnte, Platzhalter. Danach geht es ins Hauptmenü.
|
||||
* @module scenes/BootScene
|
||||
*/
|
||||
|
||||
import { COLORS } from '../config.js';
|
||||
import { queueAssets } from '../systems/Assets.js';
|
||||
import { createPlaceholderTextures } from '../systems/Placeholders.js';
|
||||
|
||||
const BAR = Object.freeze({ width: 420, height: 14 });
|
||||
|
||||
export class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Boot');
|
||||
}
|
||||
|
||||
preload() {
|
||||
const { width, height } = this.scale;
|
||||
const context = this.registry.get('context');
|
||||
/** @type {string[]} */
|
||||
this.failedAssets = [];
|
||||
|
||||
const frame = this.add.graphics();
|
||||
frame.lineStyle(2, COLORS.gold, 1);
|
||||
frame.strokeRect(width / 2 - BAR.width / 2 - 4, height / 2 - BAR.height / 2 - 4, BAR.width + 8, BAR.height + 8);
|
||||
const bar = this.add.graphics();
|
||||
this.load.on('progress', (value) => {
|
||||
bar.clear();
|
||||
bar.fillStyle(COLORS.crimson, 1);
|
||||
bar.fillRect(width / 2 - BAR.width / 2, height / 2 - BAR.height / 2, BAR.width * value, BAR.height);
|
||||
});
|
||||
// Fehlende Dateien sind kein Abbruchgrund – dafür gibt es Platzhalter.
|
||||
this.load.on('loaderror', (file) => this.failedAssets.push(file.key));
|
||||
|
||||
queueAssets(this, context.staticBase);
|
||||
}
|
||||
|
||||
create() {
|
||||
if (this.failedAssets.length > 0) {
|
||||
console.warn(`Glory Defense: ${this.failedAssets.length} Grafik(en) nicht geladen, verwende Platzhalter:`, this.failedAssets);
|
||||
}
|
||||
createPlaceholderTextures(this);
|
||||
document.getElementById('boot-message')?.remove();
|
||||
this.scene.start('Menu');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Missionsbriefing im Schreibmaschinenstil. Leertaste/Klick zeigt zuerst den ganzen
|
||||
* Text, ein zweiter Druck startet den Einsatz.
|
||||
* @module scenes/BriefingScene
|
||||
*/
|
||||
|
||||
import { BRIEFING_CONTINUE, BRIEFINGS, PRESIDENT } from '../content/texts.js';
|
||||
import { oncePerEvent } from '../systems/keyboard.js';
|
||||
import { textStyle, UI } from '../ui/theme.js';
|
||||
|
||||
/** Tippgeschwindigkeit der Schreibmaschine in Zeichen pro Sekunde. */
|
||||
const CHARS_PER_SECOND = 70;
|
||||
|
||||
export class BriefingScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Briefing');
|
||||
}
|
||||
|
||||
create() {
|
||||
const { width, height } = this.scale;
|
||||
this.input.setDefaultCursor('default');
|
||||
const briefing = BRIEFINGS[Math.floor(Math.random() * BRIEFINGS.length)];
|
||||
|
||||
const bg = this.add.graphics();
|
||||
bg.fillStyle(0x0d1321, 1);
|
||||
bg.fillRect(0, 0, width, height);
|
||||
bg.lineStyle(3, 0xf2c14e, 1);
|
||||
bg.strokeRect(60, 50, width - 120, height - 100);
|
||||
bg.fillStyle(0xc1121f, 1);
|
||||
bg.fillRect(60, 50, width - 120, 64);
|
||||
|
||||
this.add.text(width / 2, 82, briefing.title, textStyle({ fontSize: '26px', strokeThickness: 0 })).setOrigin(0.5);
|
||||
this.add.text(96, 140, `AN: ${PRESIDENT.toUpperCase()}`, textStyle({ fontSize: '18px', color: UI.colors.gold }));
|
||||
|
||||
this.fullText = briefing.lines.join('\n\n');
|
||||
this.bodyText = this.add.text(96, 185, '', {
|
||||
...textStyle({ fontSize: '21px', strokeThickness: 0 }),
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
wordWrap: { width: width - 192 },
|
||||
lineSpacing: 6,
|
||||
});
|
||||
this.typed = 0;
|
||||
|
||||
this.add.text(width / 2, height - 80, BRIEFING_CONTINUE, textStyle({ fontSize: '17px', color: UI.colors.muted })).setOrigin(0.5);
|
||||
|
||||
const advance = oncePerEvent((event) => {
|
||||
// Gedrückt gehaltene Tasten sollen das Briefing nicht versehentlich überspringen.
|
||||
if (event?.repeat) return;
|
||||
if (this.typed < this.fullText.length) {
|
||||
this.typed = this.fullText.length;
|
||||
this.bodyText.setText(this.fullText);
|
||||
} else {
|
||||
this.scene.start('Game');
|
||||
}
|
||||
});
|
||||
this.input.on('pointerup', advance);
|
||||
this.input.keyboard.on('keydown-SPACE', advance);
|
||||
this.input.keyboard.on('keydown-ENTER', advance);
|
||||
this.input.keyboard.on('keydown-ESC', oncePerEvent(() => this.scene.start('Menu')));
|
||||
this.input.keyboard.addCapture('SPACE');
|
||||
}
|
||||
|
||||
update(_time, delta) {
|
||||
if (this.typed >= this.fullText.length) return;
|
||||
this.typed = Math.min(this.fullText.length, this.typed + (CHARS_PER_SECOND * delta) / 1000);
|
||||
this.bodyText.setText(this.fullText.slice(0, Math.floor(this.typed)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Disclaimer als HTML-Overlay. Der Text kommt aus dem Template
|
||||
* `_disclaimer_content.html` (einzige Quelle, auch für die Seite /disclaimer).
|
||||
* Die Bestätigung wird in localStorage gespeichert.
|
||||
* @module scenes/DisclaimerScene
|
||||
*/
|
||||
|
||||
import { DISCLAIMER } from '../content/texts.js';
|
||||
import { el, Overlay } from '../systems/Overlay.js';
|
||||
|
||||
export class DisclaimerScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Disclaimer');
|
||||
}
|
||||
|
||||
/** @param {{mandatory?: boolean}} data */
|
||||
create(data) {
|
||||
const services = this.registry.get('services');
|
||||
const context = this.registry.get('context');
|
||||
const mandatory = Boolean(data?.mandatory);
|
||||
|
||||
const content = [];
|
||||
const template = document.getElementById('disclaimer-content');
|
||||
if (template instanceof HTMLTemplateElement) {
|
||||
content.push(template.content.cloneNode(true));
|
||||
} else {
|
||||
content.push(el('p', '', DISCLAIMER.missingTemplate));
|
||||
}
|
||||
const link = /** @type {HTMLAnchorElement} */ (el('a', '', DISCLAIMER.openPage));
|
||||
link.href = context.disclaimerUrl;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
const linkLine = el('p');
|
||||
linkLine.append(link);
|
||||
content.push(linkLine);
|
||||
|
||||
const close = () => {
|
||||
// Der Klick ist eine Nutzeraktion – guter Moment, um den Ton freizuschalten.
|
||||
services.audio.unlock();
|
||||
services.audio.play('ui-confirm');
|
||||
services.settings.acceptDisclaimer();
|
||||
this.overlay.close();
|
||||
this.events.emit('disclaimer-closed');
|
||||
this.scene.stop();
|
||||
};
|
||||
|
||||
this.overlay = new Overlay({
|
||||
id: 'disclaimer-overlay',
|
||||
content,
|
||||
buttons: [{ label: mandatory ? DISCLAIMER.accept : DISCLAIMER.close, onClick: close, testId: 'disclaimer-accept' }],
|
||||
// Beim ersten Start muss aktiv bestätigt werden; Esc schließt nur den erneuten Aufruf.
|
||||
onEscape: mandatory ? undefined : close,
|
||||
});
|
||||
this.events.once('shutdown', () => this.overlay?.element.isConnected && this.overlay.close());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* Die eigentliche Spielszene: Arena, Präsident, Gegner, Projektile, Wellen und
|
||||
* Orbital-Befehle. Das HUD läuft als eigene Szene darüber (HudScene) und liest
|
||||
* seinen Zustand über `hudState()`.
|
||||
*
|
||||
* Zeitmessung: Alle Spielmechaniken verwenden `this.gameTime` (eigene Uhr, die nur
|
||||
* läuft, während die Szene aktiv ist). So laufen Abklingzeiten in der Pause nicht
|
||||
* weiter, und die gemeldete Spieldauer enthält keine Pausen.
|
||||
* @module scenes/GameScene
|
||||
*/
|
||||
|
||||
import { COLORS, ENEMY_TYPES, FX, GAME, PLAYER, SCORING } from '../config.js';
|
||||
import { ORBITAL_TEXT, PLAYER_TEXT, PROPAGANDA, WAVE } from '../content/texts.js';
|
||||
import { SupplyPod, Turret } from '../entities/Deployables.js';
|
||||
import { Enemy } from '../entities/Enemy.js';
|
||||
import { Player } from '../entities/Player.js';
|
||||
import { ProjectilePool } from '../entities/ProjectilePool.js';
|
||||
import { clamp, directionFromKeys, distance, inCircle } from '../logic/math.js';
|
||||
import { createRng, randFloat, timeSeed } from '../logic/rng.js';
|
||||
import { ScoreKeeper, waveBonus } from '../logic/scoring.js';
|
||||
import { WaveDirector } from '../logic/waveDirector.js';
|
||||
import { spawnPointOnEdge } from '../logic/waves.js';
|
||||
import { formatMultiplier, formatSequence } from '../logic/format.js';
|
||||
import { buildArena } from '../systems/Arena.js';
|
||||
import { Effects } from '../systems/Effects.js';
|
||||
import { GroundMarker } from '../systems/GroundMarker.js';
|
||||
import { OrbitalSystem } from '../systems/OrbitalSystem.js';
|
||||
import { oncePerEvent } from '../systems/keyboard.js';
|
||||
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
|
||||
|
||||
/** Längere Frames (z. B. nach Tab-Wechsel) zählen nicht voll als Spielzeit. */
|
||||
const MAX_FRAME_MS = 100;
|
||||
/** Streuung der Mitglieder einer Spawn-Gruppe um ihren Spawnpunkt. */
|
||||
const SPAWN_SPREAD = 70;
|
||||
/** Kein Spawn näher als das am Präsidenten – sonst gäbe es unfaire Treffer aus dem Nichts. */
|
||||
const MIN_SPAWN_DISTANCE = 420;
|
||||
/** Ein Klick kurz vor Ende der Feuerpause wird nachgeholt (fühlt sich reaktionsschneller an). */
|
||||
const CLICK_BUFFER_MS = 180;
|
||||
const PLAYER_BULLET_POOL = 240;
|
||||
const ENEMY_BULLET_POOL = 280;
|
||||
const ENEMY_BULLET_RANGE = 950;
|
||||
/** Reichweitenzuschlag für Geschützprojektile über den Zielradius hinaus. */
|
||||
const TURRET_BULLET_EXTRA_RANGE = 80;
|
||||
const OPPOSITE_EDGE = Object.freeze({ north: 'south', south: 'north', east: 'west', west: 'east' });
|
||||
const FACTION_COLORS = Object.freeze({ swarm: COLORS.swarm, legion: COLORS.legion });
|
||||
const ORBITAL_COLORS = Object.freeze({ strike: COLORS.strike, supply: COLORS.supply, turret: COLORS.turret });
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Game');
|
||||
}
|
||||
|
||||
create() {
|
||||
this.services = this.registry.get('services');
|
||||
this.rng = createRng(timeSeed());
|
||||
this.gameTime = 0;
|
||||
this.isOver = false;
|
||||
this.runToken = null;
|
||||
this.lowAmmoHinted = false;
|
||||
this.propagandaDeck = [];
|
||||
this.firePressedAt = -Infinity;
|
||||
this.services.api.startRun().then((token) => {
|
||||
this.runToken = token;
|
||||
});
|
||||
|
||||
this.createArena();
|
||||
this.player = new Player(this, GAME.world.width / 2, GAME.world.height / 2);
|
||||
this.cameras.main.setBounds(0, 0, GAME.world.width, GAME.world.height);
|
||||
this.cameras.main.startFollow(this.player, true, GAME.cameraLerp, GAME.cameraLerp);
|
||||
|
||||
this.enemies = this.physics.add.group();
|
||||
this.pods = this.physics.add.group();
|
||||
/** @type {Turret[]} */
|
||||
this.turrets = [];
|
||||
/** @type {{marker: GroundMarker, cfg: object}[]} */
|
||||
this.mortars = [];
|
||||
this.playerBullets = new ProjectilePool(this, TEXTURE_KEYS.bulletPlayer, PLAYER_BULLET_POOL, GAME.depth.projectiles);
|
||||
this.enemyBullets = new ProjectilePool(this, TEXTURE_KEYS.bulletEnemy, ENEMY_BULLET_POOL, GAME.depth.projectiles);
|
||||
this.effects = new Effects(this);
|
||||
this.score = new ScoreKeeper();
|
||||
this.healthBars = this.add.graphics().setDepth(GAME.depth.effects);
|
||||
|
||||
this.enemyContext = {
|
||||
player: this.player,
|
||||
neighbors: [],
|
||||
rng: this.rng,
|
||||
fireBullet: (x, y, angle, speed, damage) => {
|
||||
this.enemyBullets.fire(x, y, angle, speed, damage, ENEMY_BULLET_RANGE);
|
||||
this.services.audio.play('enemy-shot');
|
||||
},
|
||||
spawnChildren: (mother, count) => this.spawnBrood(mother, count),
|
||||
scheduleMortar: (x, y, cfg) => this.scheduleMortar(x, y, cfg),
|
||||
};
|
||||
|
||||
this.setupInput();
|
||||
this.orbital = new OrbitalSystem(this, {
|
||||
getPlayer: () => this.player,
|
||||
getAimPoint: () => this.aimPoint(),
|
||||
onImpact: (command, x, y) => this.onOrbitalImpact(command, x, y),
|
||||
onFeedback: (kind, command, remainingMs) => this.onOrbitalFeedback(kind, command, remainingMs),
|
||||
});
|
||||
this.waves = new WaveDirector({
|
||||
rng: this.rng,
|
||||
spawnGroup: (group, plan) => this.spawnGroup(group, plan),
|
||||
aliveCount: () => this.enemies.countActive(true),
|
||||
onWaveStart: (plan) => this.onWaveStart(plan),
|
||||
onWaveCleared: (n) => this.onWaveCleared(n),
|
||||
});
|
||||
this.setupCollisions();
|
||||
this.waves.start(this.gameTime);
|
||||
|
||||
this.scene.launch('Hud');
|
||||
this.input.setDefaultCursor('none');
|
||||
this.game.events.on('blur', this.autoPause, this);
|
||||
this.events.once('shutdown', this.cleanup, this);
|
||||
this.services.audio.startMusic();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Aufbau
|
||||
|
||||
createArena() {
|
||||
const { width, height } = GAME.world;
|
||||
this.physics.world.setBounds(0, 0, width, height);
|
||||
this.worldRect = new Phaser.Geom.Rectangle(0, 0, width, height);
|
||||
buildArena(this, this.rng);
|
||||
}
|
||||
|
||||
setupInput() {
|
||||
// Bewegung wird pro Frame abgefragt (gehaltene Tasten). Einmalige Aktionen laufen
|
||||
// dagegen über keydown-Ereignisse: Phasers JustDown verliert Tastendrücke, die
|
||||
// innerhalb eines Frames gedrückt UND losgelassen werden (schnelles Antippen).
|
||||
this.keys = this.input.keyboard.addKeys('W,A,S,D,R,SPACE,ONE,TWO,THREE,ESC');
|
||||
/** @type {Set<string>} Seit dem letzten Frame angeforderte Aktionen. */
|
||||
this.pendingActions = new Set();
|
||||
const bindings = { ESC: 'pause', SPACE: 'dodge', R: 'reload', ONE: 'slot-0', TWO: 'slot-1', THREE: 'slot-2' };
|
||||
for (const [key, action] of Object.entries(bindings)) {
|
||||
this.input.keyboard.on(
|
||||
`keydown-${key}`,
|
||||
oncePerEvent((event) => {
|
||||
if (!event.repeat) this.pendingActions.add(action);
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.input.on('pointerdown', (pointer) => {
|
||||
this.services.audio.unlock();
|
||||
if (pointer.leftButtonDown()) this.firePressedAt = this.gameTime;
|
||||
});
|
||||
this.input.on('wheel', (_pointer, _over, _dx, dy) => {
|
||||
if (dy !== 0 && this.player.cycleWeapon(dy > 0 ? 1 : -1)) this.services.audio.play('switch');
|
||||
});
|
||||
}
|
||||
|
||||
setupCollisions() {
|
||||
this.physics.add.overlap(this.playerBullets.group, this.enemies, (a, b) => {
|
||||
const [bullet, enemy] = a instanceof Enemy ? [b, a] : [a, b];
|
||||
this.onBulletHitEnemy(bullet, enemy);
|
||||
});
|
||||
this.physics.add.overlap(
|
||||
this.player,
|
||||
this.enemyBullets.group,
|
||||
(a, b) => this.onEnemyBulletHitPlayer(a === this.player ? b : a),
|
||||
// Während der Rolle fliegen Projektile hindurch, statt verschluckt zu werden.
|
||||
() => !this.player.isDodging(this.gameTime),
|
||||
);
|
||||
this.physics.add.overlap(this.player, this.enemies, (a, b) => this.onEnemyContact(a === this.player ? b : a));
|
||||
this.physics.add.overlap(this.player, this.pods, (a, b) => this.onPickup(a === this.player ? b : a));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Hauptschleife
|
||||
|
||||
update(_time, delta) {
|
||||
const dt = Math.min(delta, MAX_FRAME_MS);
|
||||
this.gameTime += dt;
|
||||
const now = this.gameTime;
|
||||
|
||||
if (this.isOver) {
|
||||
this.player.move(now, { x: 0, y: 0 }, true);
|
||||
this.updateEnemies(now);
|
||||
this.updateProjectiles();
|
||||
return;
|
||||
}
|
||||
if (this.pendingActions.has('pause')) {
|
||||
this.pendingActions.clear();
|
||||
this.openPause();
|
||||
return;
|
||||
}
|
||||
|
||||
this.orbital.update(now);
|
||||
this.updatePlayer(now);
|
||||
this.updateEnemies(now);
|
||||
this.updateDeployables(now);
|
||||
this.updateMortars(now);
|
||||
this.updateProjectiles();
|
||||
this.waves.update(now, dt);
|
||||
this.drawHealthBars();
|
||||
}
|
||||
|
||||
updatePlayer(now) {
|
||||
const { keys, player } = this;
|
||||
const comboActive = this.orbital.isInputActive;
|
||||
// Während der Befehlseingabe dienen WASD als Richtungstasten, nicht zum Laufen.
|
||||
const move = comboActive
|
||||
? { x: 0, y: 0 }
|
||||
: directionFromKeys({ up: keys.W.isDown, down: keys.S.isDown, left: keys.A.isDown, right: keys.D.isDown });
|
||||
|
||||
const actions = this.pendingActions;
|
||||
if (!comboActive && actions.has('dodge') && player.tryDodge(now, move)) {
|
||||
this.services.audio.play('dodge');
|
||||
this.effects.afterimage(player);
|
||||
this.effects.burst(player.x, player.y, COLORS.muted, 8);
|
||||
}
|
||||
player.move(now, move, comboActive);
|
||||
const aim = this.aimPoint();
|
||||
player.aimAt(aim.x, aim.y);
|
||||
|
||||
player.weapons.forEach((_weapon, index) => {
|
||||
if (actions.has(`slot-${index}`) && player.switchWeapon(index)) this.services.audio.play('switch');
|
||||
});
|
||||
if (actions.has('reload') && player.weapon.startReload(now)) {
|
||||
this.services.audio.play('reload');
|
||||
}
|
||||
actions.clear();
|
||||
if (player.updateWeapons(now)) this.services.audio.play('reload-done');
|
||||
this.handleFiring(now);
|
||||
}
|
||||
|
||||
handleFiring(now) {
|
||||
const { player } = this;
|
||||
const weapon = player.weapon;
|
||||
const def = weapon.def;
|
||||
const held = this.input.activePointer.leftButtonDown();
|
||||
const clicked = now - this.firePressedAt <= CLICK_BUFFER_MS;
|
||||
if (!(def.automatic ? held || clicked : clicked)) return;
|
||||
|
||||
if (weapon.isEmpty) {
|
||||
// Waffe leer: automatisch zur Dienstpistole mit unbegrenzter Munition wechseln.
|
||||
const fallback = player.weapons.findIndex((w) => w.hasInfiniteReserve);
|
||||
if (fallback >= 0 && player.switchWeapon(fallback)) this.feedback(PLAYER_TEXT.outOfAmmo, 'warn');
|
||||
this.firePressedAt = -Infinity;
|
||||
return;
|
||||
}
|
||||
if (weapon.magazine === 0 && weapon.startReload(now)) this.services.audio.play('reload');
|
||||
|
||||
const offsets = weapon.fire(now, this.rng);
|
||||
if (!offsets) return;
|
||||
this.firePressedAt = -Infinity;
|
||||
const muzzle = player.muzzlePosition();
|
||||
for (const offset of offsets) {
|
||||
this.playerBullets.fire(muzzle.x, muzzle.y, player.aimAngle + offset, def.bulletSpeed, def.damage, def.rangePx);
|
||||
}
|
||||
this.effects.muzzle(muzzle.x, muzzle.y, player.aimAngle);
|
||||
this.services.audio.play(`shot-${def.id}`);
|
||||
if (weapon.isReloading && weapon.reserve > 0) this.services.audio.play('reload');
|
||||
|
||||
if (!weapon.hasInfiniteReserve && weapon.reserve === 0 && !this.lowAmmoHinted) {
|
||||
this.lowAmmoHinted = true;
|
||||
const supply = this.orbital.commands.find((c) => c.id === 'supply');
|
||||
this.feedback(PLAYER_TEXT.lowAmmo(supply ? formatSequence(supply.sequence) : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
updateEnemies(now) {
|
||||
const list = this.enemies.getChildren().slice();
|
||||
const ctx = this.enemyContext;
|
||||
ctx.neighbors = list;
|
||||
for (const enemy of list) {
|
||||
if (enemy.alive) enemy.step(now, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
updateDeployables(now) {
|
||||
const alive = this.enemies.getChildren().filter((e) => e.alive);
|
||||
for (let i = this.turrets.length - 1; i >= 0; i -= 1) {
|
||||
const turret = this.turrets[i];
|
||||
const running = turret.step(now, alive, (x, y, angle) => {
|
||||
this.playerBullets.fire(
|
||||
x,
|
||||
y,
|
||||
angle,
|
||||
turret.cfg.bulletSpeed,
|
||||
turret.cfg.bulletDamage,
|
||||
turret.cfg.range + TURRET_BULLET_EXTRA_RANGE,
|
||||
TEXTURE_KEYS.bulletTurret,
|
||||
);
|
||||
this.effects.muzzle(x, y, angle, COLORS.turretBullet);
|
||||
this.services.audio.play('turret-shot');
|
||||
});
|
||||
if (!running) {
|
||||
this.effects.burst(turret.x, turret.y, COLORS.turret, 14);
|
||||
turret.destroy();
|
||||
this.turrets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
for (const pod of this.pods.getChildren().slice()) {
|
||||
if (!pod.tick(now)) pod.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
updateMortars(now) {
|
||||
for (let i = this.mortars.length - 1; i >= 0; i -= 1) {
|
||||
const { marker, cfg } = this.mortars[i];
|
||||
if (!marker.isDue(now)) {
|
||||
marker.update(now);
|
||||
continue;
|
||||
}
|
||||
marker.destroy();
|
||||
this.mortars.splice(i, 1);
|
||||
this.effects.explosion(marker.x, marker.y, cfg.radius, COLORS.mortar);
|
||||
this.services.audio.play('explosion-small');
|
||||
if (inCircle(this.player, marker, cfg.radius, PLAYER.radius)) this.damagePlayer(cfg.damage);
|
||||
}
|
||||
}
|
||||
|
||||
updateProjectiles() {
|
||||
this.playerBullets.update(this.worldRect);
|
||||
this.enemyBullets.update(this.worldRect);
|
||||
}
|
||||
|
||||
drawHealthBars() {
|
||||
const g = this.healthBars;
|
||||
g.clear();
|
||||
for (const enemy of this.enemies.getChildren()) {
|
||||
if (!enemy.alive || (enemy.hp >= enemy.maxHp && !enemy.isElite)) continue;
|
||||
const width = enemy.isElite ? 72 : 30;
|
||||
const height = enemy.isElite ? 7 : 4;
|
||||
const x = enemy.x - width / 2;
|
||||
const y = enemy.y - enemy.type.radius - 12;
|
||||
g.fillStyle(0x000000, 0.6);
|
||||
g.fillRect(x - 1, y - 1, width + 2, height + 2);
|
||||
g.fillStyle(enemy.isElite ? COLORS.gold : COLORS.hpBarLow, 1);
|
||||
g.fillRect(x, y, (width * enemy.hp) / enemy.maxHp, height);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {{x: number, y: number}} Mausposition in Weltkoordinaten. */
|
||||
aimPoint() {
|
||||
const point = this.input.activePointer.positionToCamera(this.cameras.main);
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Gegner
|
||||
|
||||
spawnGroup(group, plan) {
|
||||
let point = spawnPointOnEdge(group.edge, GAME.world, GAME.spawnMargin, this.rng);
|
||||
if (distance(point, this.player) < MIN_SPAWN_DISTANCE) {
|
||||
point = spawnPointOnEdge(OPPOSITE_EDGE[group.edge], GAME.world, GAME.spawnMargin, this.rng);
|
||||
}
|
||||
const margin = GAME.spawnMargin;
|
||||
for (let i = 0; i < group.count; i += 1) {
|
||||
const x = clamp(point.x + randFloat(this.rng, -SPAWN_SPREAD, SPAWN_SPREAD), margin, GAME.world.width - margin);
|
||||
const y = clamp(point.y + randFloat(this.rng, -SPAWN_SPREAD, SPAWN_SPREAD), margin, GAME.world.height - margin);
|
||||
this.spawnEnemy(group.type, x, y, { hpMultiplier: plan.hpMultiplier, speedMultiplier: plan.speedMultiplier });
|
||||
}
|
||||
const type = ENEMY_TYPES[group.type];
|
||||
if (type.tier === 'elite') {
|
||||
this.announce(WAVE.elite[type.id] ?? type.name, '', 'elite');
|
||||
this.services.audio.play('alarm');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} typeId
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {{hpMultiplier?: number, speedMultiplier?: number, parent?: Enemy}} [options]
|
||||
* @returns {Enemy}
|
||||
*/
|
||||
spawnEnemy(typeId, x, y, options = {}) {
|
||||
const enemy = new Enemy(this, x, y, typeId, { now: this.gameTime, rng: this.rng, ...options });
|
||||
this.enemies.add(enemy);
|
||||
enemy.initBody();
|
||||
enemy.setAlpha(0);
|
||||
this.tweens.add({ targets: enemy, alpha: 1, duration: 250 });
|
||||
return enemy;
|
||||
}
|
||||
|
||||
spawnBrood(mother, count) {
|
||||
const childType = mother.type.brood.childType;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const angle = this.rng() * Math.PI * 2;
|
||||
const reach = mother.type.radius + ENEMY_TYPES[childType].radius;
|
||||
const child = this.spawnEnemy(childType, mother.x + Math.cos(angle) * reach, mother.y + Math.sin(angle) * reach, {
|
||||
hpMultiplier: mother.multipliers.hp,
|
||||
speedMultiplier: mother.multipliers.speed,
|
||||
parent: mother,
|
||||
});
|
||||
child.setAlpha(1);
|
||||
mother.childCount += 1;
|
||||
}
|
||||
this.effects.burst(mother.x, mother.y, COLORS.swarm, 10);
|
||||
this.services.audio.play('brood');
|
||||
}
|
||||
|
||||
scheduleMortar(x, y, cfg) {
|
||||
const now = this.gameTime;
|
||||
this.mortars.push({ marker: new GroundMarker(this, x, y, cfg.radius, COLORS.mortar, now, now + cfg.delayMs), cfg });
|
||||
this.services.audio.play('mortar');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Enemy} enemy
|
||||
* @param {number} amount
|
||||
* @param {number} hitX
|
||||
* @param {number} hitY
|
||||
*/
|
||||
damageEnemy(enemy, amount, hitX, hitY) {
|
||||
if (!enemy.alive) return;
|
||||
const killed = enemy.takeDamage(amount);
|
||||
this.effects.flash(enemy);
|
||||
this.effects.burst(hitX, hitY, FACTION_COLORS[enemy.type.faction], 4);
|
||||
this.services.audio.play(enemy.type.faction === 'legion' ? 'hit-metal' : 'hit-flesh');
|
||||
if (killed) this.killEnemy(enemy);
|
||||
}
|
||||
|
||||
/** @param {Enemy} enemy */
|
||||
killEnemy(enemy) {
|
||||
const now = this.gameTime;
|
||||
const previousMultiplier = this.score.multiplierAt(now);
|
||||
const result = this.score.registerKill(enemy.type.points, now);
|
||||
const label = result.multiplier > 1 ? `+${result.awarded} ${formatMultiplier(result.multiplier)}` : `+${result.awarded}`;
|
||||
this.effects.floatingText(enemy.x, enemy.y - enemy.type.radius, label);
|
||||
if (result.multiplier > previousMultiplier) {
|
||||
// Neue Multiplikatorstufe deutlich am Präsidenten melden – Belohnung soll man sehen.
|
||||
this.effects.floatingText(this.player.x, this.player.y - 48, `SERIE ${formatMultiplier(result.multiplier)}`, '#ffe28a', 26);
|
||||
}
|
||||
this.effects.burst(enemy.x, enemy.y, FACTION_COLORS[enemy.type.faction], enemy.isElite ? 40 : 12);
|
||||
if (enemy.isElite) this.effects.explosion(enemy.x, enemy.y, enemy.type.radius * 3, COLORS.gold);
|
||||
if (enemy.parentEnemy) enemy.parentEnemy.childCount = Math.max(0, enemy.parentEnemy.childCount - 1);
|
||||
this.services.audio.play(enemy.type.faction === 'legion' ? 'death-legion' : 'death-swarm');
|
||||
|
||||
// Körper sofort deaktivieren (zählt nicht mehr als lebend, keine Kollisionen),
|
||||
// die Grafik aber kurz ausblenden statt sie hart verschwinden zu lassen.
|
||||
enemy.disableBody(true, false);
|
||||
this.tweens.add({
|
||||
targets: enemy,
|
||||
alpha: 0,
|
||||
scale: 0.4,
|
||||
duration: 220,
|
||||
onComplete: () => enemy.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Kollisionen
|
||||
|
||||
onBulletHitEnemy(bullet, enemy) {
|
||||
if (!bullet.active || !enemy.alive) return;
|
||||
this.playerBullets.kill(bullet);
|
||||
this.damageEnemy(enemy, bullet.damage, bullet.x, bullet.y);
|
||||
}
|
||||
|
||||
onEnemyBulletHitPlayer(bullet) {
|
||||
if (!bullet.active || this.isOver) return;
|
||||
this.enemyBullets.kill(bullet);
|
||||
this.damagePlayer(bullet.damage);
|
||||
}
|
||||
|
||||
/** @param {Enemy} enemy */
|
||||
onEnemyContact(enemy) {
|
||||
const now = this.gameTime;
|
||||
if (!enemy.alive || this.isOver || this.player.isDodging(now)) return;
|
||||
if (now - enemy.lastContactAt < enemy.type.contactCooldownMs) return;
|
||||
enemy.lastContactAt = now;
|
||||
this.damagePlayer(enemy.type.contactDamage);
|
||||
}
|
||||
|
||||
/** @param {SupplyPod} pod */
|
||||
onPickup(pod) {
|
||||
if (pod.collected || this.isOver) return;
|
||||
pod.collected = true;
|
||||
this.player.heal(pod.cfg.heal);
|
||||
this.player.refillAmmo();
|
||||
this.lowAmmoHinted = false;
|
||||
this.feedback(ORBITAL_TEXT.supplyPickup(pod.cfg.heal), 'ok');
|
||||
this.effects.burst(pod.x, pod.y, COLORS.supply, 20);
|
||||
this.services.audio.play('pickup');
|
||||
pod.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} amount
|
||||
* @returns {number} Tatsächlich erlittener Schaden.
|
||||
*/
|
||||
damagePlayer(amount) {
|
||||
const dealt = this.player.takeDamage(amount, this.gameTime);
|
||||
if (dealt <= 0) return 0;
|
||||
this.effects.flash(this.player);
|
||||
this.effects.shake('hit');
|
||||
this.services.audio.play('player-hit');
|
||||
if (SCORING.damageBreaksStreak) this.score.breakStreak();
|
||||
this.events.emit('player-damaged', dealt);
|
||||
if (!this.player.alive) this.onPlayerDeath();
|
||||
return dealt;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Orbital-Befehle
|
||||
|
||||
onOrbitalFeedback(kind, command, remainingMs) {
|
||||
const audio = this.services.audio;
|
||||
if (kind === 'step') audio.play('combo-step');
|
||||
else if (kind === 'fail') {
|
||||
audio.play('combo-fail');
|
||||
this.feedback(ORBITAL_TEXT.invalid, 'warn');
|
||||
} else if (kind === 'notReady') {
|
||||
audio.play('combo-fail');
|
||||
this.feedback(ORBITAL_TEXT.notReady(command.shortName, Math.ceil(remainingMs / 1000)), 'warn');
|
||||
} else if (kind === 'deploy') {
|
||||
audio.play('orbital-call');
|
||||
this.feedback(ORBITAL_TEXT.requested(command.shortName), 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
onOrbitalImpact(command, x, y) {
|
||||
const center = { x, y };
|
||||
this.effects.explosion(x, y, command.radius, ORBITAL_COLORS[command.id] ?? COLORS.gold);
|
||||
this.services.audio.play(command.id === 'strike' ? 'explosion-big' : 'explosion-small');
|
||||
|
||||
for (const enemy of this.enemies.getChildren().slice()) {
|
||||
if (enemy.alive && inCircle(enemy, center, command.radius, enemy.type.radius)) {
|
||||
this.damageEnemy(enemy, command.damage, enemy.x, enemy.y);
|
||||
}
|
||||
}
|
||||
if (command.playerDamage > 0 && inCircle(this.player, center, command.radius, PLAYER.radius)) {
|
||||
if (this.damagePlayer(command.playerDamage) > 0 && command.id === 'strike') {
|
||||
this.feedback(ORBITAL_TEXT.selfHit, 'warn');
|
||||
}
|
||||
}
|
||||
if (command.id === 'supply' && !this.isOver) {
|
||||
const pod = new SupplyPod(this, x, y, command, this.gameTime);
|
||||
this.pods.add(pod);
|
||||
pod.initBody();
|
||||
}
|
||||
if (command.id === 'turret' && !this.isOver) {
|
||||
this.turrets.push(new Turret(this, x, y, command, this.gameTime));
|
||||
this.feedback(ORBITAL_TEXT.turretOnline, 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Wellen
|
||||
|
||||
onWaveStart(plan) {
|
||||
const factionKey = plan.factions.length > 1 ? 'mixed' : plan.factions[0];
|
||||
this.announce(WAVE.incoming(plan.number), WAVE.factionLine[factionKey], 'wave');
|
||||
this.services.audio.play('wave-start');
|
||||
}
|
||||
|
||||
onWaveCleared(waveNumber) {
|
||||
const bonus = this.score.addBonus(waveBonus(waveNumber));
|
||||
const healed = this.player.heal(PLAYER.waveClearHeal);
|
||||
const lines = [WAVE.bonus(bonus)];
|
||||
if (healed > 0) lines.push(WAVE.heal(healed));
|
||||
lines.push(`„${this.nextPropaganda()}“`);
|
||||
this.announce(WAVE.cleared(waveNumber), lines.join('\n'), 'cleared');
|
||||
this.services.audio.play('wave-cleared');
|
||||
}
|
||||
|
||||
/** Propaganda ohne direkte Wiederholung: Stapel mischen und nacheinander ziehen. */
|
||||
nextPropaganda() {
|
||||
if (this.propagandaDeck.length === 0) {
|
||||
this.propagandaDeck = PROPAGANDA.slice();
|
||||
for (let i = this.propagandaDeck.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(this.rng() * (i + 1));
|
||||
[this.propagandaDeck[i], this.propagandaDeck[j]] = [this.propagandaDeck[j], this.propagandaDeck[i]];
|
||||
}
|
||||
}
|
||||
return this.propagandaDeck.pop();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Meldungen
|
||||
|
||||
/**
|
||||
* Große Einblendung im HUD.
|
||||
* @param {string} title
|
||||
* @param {string} subtitle
|
||||
* @param {'wave'|'cleared'|'elite'} kind
|
||||
*/
|
||||
announce(title, subtitle, kind) {
|
||||
this.events.emit('announce', { title, subtitle, kind });
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurze Statuszeile im HUD.
|
||||
* @param {string} text
|
||||
* @param {'ok'|'warn'} tone
|
||||
*/
|
||||
feedback(text, tone) {
|
||||
this.events.emit('feedback', { text, tone });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Pause und Ende
|
||||
|
||||
openPause() {
|
||||
if (this.isOver || !this.scene.isActive()) return;
|
||||
this.scene.pause();
|
||||
this.scene.launch('Pause');
|
||||
}
|
||||
|
||||
autoPause() {
|
||||
// Fenster verliert den Fokus: lieber pausieren, als den Präsidenten allein zu lassen.
|
||||
if (this.scene.isActive() && !this.isOver) this.openPause();
|
||||
}
|
||||
|
||||
onPlayerDeath() {
|
||||
if (this.isOver) return;
|
||||
this.isOver = true;
|
||||
this.services.audio.play('player-death');
|
||||
this.services.audio.stopMusic();
|
||||
this.effects.explosion(this.player.x, this.player.y, 120, COLORS.crimson);
|
||||
this.player.setTint(0x555555);
|
||||
this.tweens.add({ targets: this.player, alpha: 0.3, duration: FX.deathDelayMs });
|
||||
this.time.delayedCall(FX.deathDelayMs, () => this.finish());
|
||||
}
|
||||
|
||||
finish() {
|
||||
const snapshot = this.score.snapshot(this.gameTime);
|
||||
const stats = {
|
||||
score: snapshot.score,
|
||||
wave: Math.max(1, this.waves.waveNumber),
|
||||
kills: snapshot.kills,
|
||||
bestStreak: snapshot.bestStreak,
|
||||
durationMs: this.gameTime,
|
||||
runToken: this.runToken,
|
||||
};
|
||||
this.scene.stop('Hud');
|
||||
this.scene.start('GameOver', stats);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.orbital?.destroy();
|
||||
this.game.events.off('blur', this.autoPause, this);
|
||||
this.input.setDefaultCursor('default');
|
||||
this.services.audio.stopMusic();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ HUD-Daten
|
||||
|
||||
/** Zustand für das HUD (wird jeden Frame von HudScene gelesen). */
|
||||
hudState() {
|
||||
const now = this.gameTime;
|
||||
const { player } = this;
|
||||
const weapon = player.weapon;
|
||||
return {
|
||||
hp: player.hp,
|
||||
maxHp: player.maxHp,
|
||||
dodge: player.dodgeReadiness(now),
|
||||
weaponIndex: player.weaponIndex,
|
||||
weapons: player.weapons.map((w) => ({ name: w.def.shortName, empty: w.isEmpty })),
|
||||
weaponName: weapon.def.name,
|
||||
magazine: weapon.magazine,
|
||||
magazineSize: weapon.def.magazineSize,
|
||||
reserve: weapon.reserve,
|
||||
reloading: weapon.isReloading,
|
||||
reloadProgress: weapon.reloadProgress(now),
|
||||
score: this.score.snapshot(now),
|
||||
wave: {
|
||||
number: this.waves.waveNumber,
|
||||
state: this.waves.state,
|
||||
enemies: this.waves.pendingEnemies + this.enemies.countActive(true),
|
||||
nextInMs: this.waves.intermissionRemaining(now),
|
||||
},
|
||||
orbital: this.orbital.hudState(now),
|
||||
over: this.isOver,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* HUD über der Spielszene: Leben, Munition, Welle, Punkte, Serie, Rolle,
|
||||
* Orbital-Befehle mit Abklingzeiten, Einblendungen und Fadenkreuz.
|
||||
*
|
||||
* Läuft als eigene Szene, damit das HUD nicht mit der Kamera mitscrollt und nicht
|
||||
* vom Bildschirmwackeln betroffen ist. Texte werden nur bei Änderungen neu gesetzt
|
||||
* (setText rendert intern eine Textur neu und ist vergleichsweise teuer).
|
||||
* @module scenes/HudScene
|
||||
*/
|
||||
|
||||
import { COLORS, DIRECTION_SYMBOLS, FX, ORBITAL, PLAYER, WEAPONS } from '../config.js';
|
||||
import { ORBITAL_TEXT, PRESIDENT, WAVE } from '../content/texts.js';
|
||||
import { formatCooldown, formatMultiplier, formatScore } from '../logic/format.js';
|
||||
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
|
||||
import { textStyle, UI, uiStyle } from '../ui/theme.js';
|
||||
|
||||
const MARGIN = 24;
|
||||
const HP_BAR = Object.freeze({ width: 280, height: 18 });
|
||||
const DODGE_BAR = Object.freeze({ width: 120, height: 6 });
|
||||
const RELOAD_BAR = Object.freeze({ width: 220, height: 6 });
|
||||
const STREAK_BAR = Object.freeze({ width: 140, height: 5 });
|
||||
const ORBITAL_PANEL = Object.freeze({ width: 350, rowHeight: 34, arrowsX: 150 });
|
||||
const SLOT_GAP = 22;
|
||||
/** Ab diesem Anteil Leben wird der Balken rot und der Bildschirmrand pulsiert. */
|
||||
const LOW_HP_RATIO = 0.3;
|
||||
const LOW_HP_BORDER = 16;
|
||||
const LOW_HP_PULSE_MS = 140;
|
||||
/** Randpfeile für Gegner außerhalb des Bildes. */
|
||||
const INDICATOR = Object.freeze({ edge: 22, size: 9, eliteSize: 14, max: 30 });
|
||||
const FEEDBACK_MS = 2200;
|
||||
|
||||
export class HudScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Hud');
|
||||
}
|
||||
|
||||
create() {
|
||||
this.gameScene = this.scene.get('Game');
|
||||
const { width, height } = this.scale;
|
||||
/** Letzter gesetzter Text je Textobjekt (nicht `this.cache` – das ist Phasers Cache). */
|
||||
this.textCache = new Map();
|
||||
this.colorCache = new Map();
|
||||
this.g = this.add.graphics();
|
||||
|
||||
// Oben links: Leben und Rolle
|
||||
this.add.text(MARGIN, 14, PRESIDENT.toUpperCase(), uiStyle({ fontSize: '15px', color: UI.colors.gold }));
|
||||
this.hpText = this.add.text(MARGIN + HP_BAR.width + 12, 34, '', uiStyle({ fontSize: '17px' }));
|
||||
this.add.text(MARGIN, 64, 'ROLLE', uiStyle({ fontSize: '13px', color: UI.colors.muted }));
|
||||
|
||||
// Oben Mitte: Welle
|
||||
this.waveTitle = this.add.text(width / 2, 12, '', textStyle({ fontSize: '28px', color: UI.colors.gold })).setOrigin(0.5, 0);
|
||||
this.waveInfo = this.add.text(width / 2, 48, '', uiStyle({ fontSize: '18px' })).setOrigin(0.5, 0);
|
||||
|
||||
// Oben rechts: Punkte und Multiplikator
|
||||
this.scoreText = this.add.text(width - MARGIN, 10, '', textStyle({ fontSize: '32px' })).setOrigin(1, 0);
|
||||
this.multText = this.add.text(width - MARGIN, 50, '', uiStyle({ fontSize: '20px', color: UI.colors.gold })).setOrigin(1, 0);
|
||||
|
||||
// Unten links: Waffe
|
||||
// Waffennamen stehen fest – einmal setzen und nach tatsächlicher Breite anordnen.
|
||||
let slotX = MARGIN;
|
||||
this.slotTexts = PLAYER.weaponSlots.map((id, i) => {
|
||||
const slot = this.add.text(slotX, height - 132, `${i + 1} ${WEAPONS[id].shortName}`, uiStyle({ fontSize: '15px' }));
|
||||
slotX += slot.width + SLOT_GAP;
|
||||
return slot;
|
||||
});
|
||||
this.weaponName = this.add.text(MARGIN, height - 106, '', uiStyle({ fontSize: '18px', color: UI.colors.gold }));
|
||||
this.ammoText = this.add.text(MARGIN, height - 80, '', uiStyle({ fontSize: '34px' }));
|
||||
|
||||
// Unten rechts: Orbital-Befehle
|
||||
const panelX = width - MARGIN - ORBITAL_PANEL.width;
|
||||
const panelTop = height - MARGIN - ORBITAL.commands.length * ORBITAL_PANEL.rowHeight - 34;
|
||||
this.orbitalPanel = { x: panelX, y: panelTop };
|
||||
this.orbitalTitle = this.add.text(panelX + 10, panelTop + 7, ORBITAL_TEXT.panelTitle, uiStyle({ fontSize: '15px', color: UI.colors.gold }));
|
||||
this.orbitalRows = ORBITAL.commands.map((command, i) => {
|
||||
const y = panelTop + 34 + i * ORBITAL_PANEL.rowHeight;
|
||||
return {
|
||||
name: this.add.text(panelX + 10, y + 1, command.shortName, uiStyle({ fontSize: '15px' })),
|
||||
arrows: command.sequence.map((dir, j) =>
|
||||
// Pfeile aus einer Symbolschrift, damit sie überall gleich breit und gut erkennbar sind.
|
||||
this.add.text(panelX + ORBITAL_PANEL.arrowsX + j * 24, y - 3, DIRECTION_SYMBOLS[dir], textStyle({ fontSize: '19px', fontFamily: UI.symbolFont })),
|
||||
),
|
||||
cooldown: this.add.text(panelX + ORBITAL_PANEL.width - 10, y, '', uiStyle({ fontSize: '16px', color: UI.colors.muted })).setOrigin(1, 0),
|
||||
y,
|
||||
};
|
||||
});
|
||||
|
||||
// Einblendungen
|
||||
this.bannerTitle = this.add.text(width / 2, 150, '', textStyle({ fontSize: '46px', color: UI.colors.gold, strokeThickness: 8 })).setOrigin(0.5).setAlpha(0);
|
||||
this.bannerSubtitle = this.add
|
||||
.text(width / 2, 186, '', { ...uiStyle({ fontSize: '22px', align: 'center' }), wordWrap: { width: 900 } })
|
||||
.setOrigin(0.5, 0)
|
||||
.setAlpha(0);
|
||||
this.feedbackText = this.add.text(width / 2, height - 170, '', uiStyle({ fontSize: '20px' })).setOrigin(0.5).setAlpha(0);
|
||||
|
||||
this.vignette = this.add.rectangle(0, 0, width, height, COLORS.crimson, 0).setOrigin(0);
|
||||
this.crosshair = this.add.image(0, 0, TEXTURE_KEYS.crosshair);
|
||||
|
||||
const events = this.gameScene.events;
|
||||
events.on('announce', this.showBanner, this);
|
||||
events.on('feedback', this.showFeedback, this);
|
||||
events.on('player-damaged', this.flashDamage, this);
|
||||
this.events.once('shutdown', () => {
|
||||
events.off('announce', this.showBanner, this);
|
||||
events.off('feedback', this.showFeedback, this);
|
||||
events.off('player-damaged', this.flashDamage, this);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Text nur, wenn er sich geändert hat.
|
||||
* @param {Phaser.GameObjects.Text} target
|
||||
* @param {string} value
|
||||
*/
|
||||
setText(target, value) {
|
||||
if (this.textCache.get(target) !== value) {
|
||||
this.textCache.set(target, value);
|
||||
target.setText(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Textfarbe nur bei Änderung (setColor rendert den Text sonst neu).
|
||||
* @param {Phaser.GameObjects.Text} target
|
||||
* @param {string} color
|
||||
*/
|
||||
setColor(target, color) {
|
||||
if (this.colorCache.get(target) !== color) {
|
||||
this.colorCache.set(target, color);
|
||||
target.setColor(color);
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
const gameScene = this.gameScene;
|
||||
if (!gameScene?.player || !(gameScene.sys.isActive() || gameScene.sys.isPaused())) return;
|
||||
const s = gameScene.hudState();
|
||||
const paused = gameScene.sys.isPaused();
|
||||
const g = this.g;
|
||||
g.clear();
|
||||
|
||||
// Leben
|
||||
const hpRatio = s.hp / s.maxHp;
|
||||
this.bar(g, MARGIN, 36, HP_BAR.width, HP_BAR.height, hpRatio, hpRatio <= LOW_HP_RATIO ? COLORS.hpBarLow : COLORS.hpBar);
|
||||
this.setText(this.hpText, `${Math.ceil(s.hp)} / ${s.maxHp}`);
|
||||
this.bar(g, MARGIN + 56, 70, DODGE_BAR.width, DODGE_BAR.height, s.dodge, s.dodge >= 1 ? COLORS.gold : COLORS.muted);
|
||||
|
||||
// Welle
|
||||
this.setText(this.waveTitle, s.wave.number > 0 ? WAVE.incoming(s.wave.number) : WAVE.preparing);
|
||||
const info = s.wave.state === 'intermission' ? WAVE.nextIn(Math.ceil(s.wave.nextInMs / 1000)) : WAVE.remaining(s.wave.enemies);
|
||||
this.setText(this.waveInfo, info);
|
||||
|
||||
// Punkte
|
||||
this.setText(this.scoreText, formatScore(s.score.score));
|
||||
let streakLabel = '';
|
||||
if (s.score.multiplier > 1) streakLabel = `${formatMultiplier(s.score.multiplier)} · Serie ${s.score.streak}`;
|
||||
else if (s.score.streak >= 2) streakLabel = `Serie ${s.score.streak}`;
|
||||
this.setText(this.multText, streakLabel);
|
||||
if (s.score.streakTimeLeft > 0) {
|
||||
this.bar(g, this.scale.width - MARGIN - STREAK_BAR.width, 80, STREAK_BAR.width, STREAK_BAR.height, s.score.streakTimeLeft, COLORS.gold);
|
||||
}
|
||||
|
||||
// Waffe
|
||||
s.weapons.forEach((weapon, i) => {
|
||||
const active = i === s.weaponIndex;
|
||||
this.setColor(this.slotTexts[i], active ? UI.colors.gold : weapon.empty ? UI.colors.danger : UI.colors.muted);
|
||||
});
|
||||
this.setText(this.weaponName, s.weaponName);
|
||||
const reserve = Number.isFinite(s.reserve) ? String(s.reserve) : '∞';
|
||||
this.setText(this.ammoText, s.reloading ? `NACHLADEN · ${reserve}` : `${s.magazine} / ${s.magazineSize} · ${reserve}`);
|
||||
this.setColor(this.ammoText, s.magazine === 0 && !s.reloading ? UI.colors.danger : UI.colors.cream);
|
||||
if (s.reloading) this.bar(g, MARGIN, this.scale.height - 36, RELOAD_BAR.width, RELOAD_BAR.height, s.reloadProgress, COLORS.gold);
|
||||
|
||||
this.drawOrbitalPanel(g, s.orbital);
|
||||
this.drawOffscreenIndicators(g, gameScene);
|
||||
if (hpRatio <= LOW_HP_RATIO && !s.over) {
|
||||
// Pulsierender Rand statt Dauerrot: auffällig, verdeckt aber nichts.
|
||||
const pulse = 0.35 + 0.35 * Math.sin(this.time.now / LOW_HP_PULSE_MS);
|
||||
g.lineStyle(LOW_HP_BORDER, COLORS.crimson, pulse);
|
||||
g.strokeRect(LOW_HP_BORDER / 2, LOW_HP_BORDER / 2, this.scale.width - LOW_HP_BORDER, this.scale.height - LOW_HP_BORDER);
|
||||
}
|
||||
|
||||
// Fadenkreuz folgt der Maus; in der Pause und nach dem Tod ausgeblendet.
|
||||
const pointer = this.input.activePointer;
|
||||
this.crosshair.setPosition(pointer.x, pointer.y);
|
||||
this.crosshair.setVisible(!paused && !s.over);
|
||||
}
|
||||
|
||||
drawOrbitalPanel(g, orbital) {
|
||||
const { x, y } = this.orbitalPanel;
|
||||
const height = 34 + orbital.commands.length * ORBITAL_PANEL.rowHeight;
|
||||
g.fillStyle(COLORS.navy, 0.72);
|
||||
g.fillRect(x, y, ORBITAL_PANEL.width, height);
|
||||
g.lineStyle(2, orbital.active ? COLORS.gold : COLORS.muted, orbital.active ? 1 : 0.5);
|
||||
g.strokeRect(x, y, ORBITAL_PANEL.width, height);
|
||||
|
||||
orbital.commands.forEach((command, i) => {
|
||||
const row = this.orbitalRows[i];
|
||||
// Dünner Ladebalken unter der Zeile
|
||||
if (!command.ready) {
|
||||
g.fillStyle(COLORS.muted, 0.35);
|
||||
g.fillRect(x + 10, row.y + 22, ORBITAL_PANEL.width - 20, 3);
|
||||
g.fillStyle(COLORS.gold, 0.9);
|
||||
g.fillRect(x + 10, row.y + 22, (ORBITAL_PANEL.width - 20) * command.cooldownProgress, 3);
|
||||
}
|
||||
this.setColor(row.name, command.ready ? UI.colors.cream : UI.colors.muted);
|
||||
row.arrows.forEach((arrow, j) => {
|
||||
const done = orbital.active && j < command.progress;
|
||||
this.setColor(arrow, done ? UI.colors.gold : command.ready ? UI.colors.cream : UI.colors.muted);
|
||||
arrow.setScale(done ? 1.2 : 1);
|
||||
});
|
||||
this.setText(row.cooldown, command.ready ? 'bereit' : `${formatCooldown(command.remainingMs)} s`);
|
||||
this.setColor(row.cooldown, command.ready ? UI.colors.ok : UI.colors.muted);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pfeile am Bildschirmrand für Gegner außerhalb des sichtbaren Bereichs. Die Arena ist
|
||||
* größer als der Bildschirm – ohne Hinweis kämen Angriffe scheinbar aus dem Nichts.
|
||||
* @param {Phaser.GameObjects.Graphics} g
|
||||
* @param {Phaser.Scene & {enemies: Phaser.Physics.Arcade.Group}} gameScene
|
||||
*/
|
||||
drawOffscreenIndicators(g, gameScene) {
|
||||
const view = gameScene.cameras.main.worldView;
|
||||
const cx = this.scale.width / 2;
|
||||
const cy = this.scale.height / 2;
|
||||
let shown = 0;
|
||||
for (const enemy of gameScene.enemies.getChildren()) {
|
||||
if (!enemy.alive || view.contains(enemy.x, enemy.y)) continue;
|
||||
const dx = enemy.x - view.x - cx;
|
||||
const dy = enemy.y - view.y - cy;
|
||||
// Schnittpunkt der Linie Bildmitte→Gegner mit dem (eingerückten) Bildschirmrand.
|
||||
const reach = Math.min((cx - INDICATOR.edge) / Math.abs(dx || 1e-6), (cy - INDICATOR.edge) / Math.abs(dy || 1e-6));
|
||||
const x = cx + dx * reach;
|
||||
const y = cy + dy * reach;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const size = enemy.isElite ? INDICATOR.eliteSize : INDICATOR.size;
|
||||
const color = enemy.isElite ? COLORS.gold : enemy.type.faction === 'swarm' ? COLORS.swarm : COLORS.legion;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
g.fillStyle(color, enemy.isElite ? 1 : 0.8);
|
||||
g.fillTriangle(
|
||||
x + cos * size,
|
||||
y + sin * size,
|
||||
x - cos * size * 0.6 - sin * size * 0.7,
|
||||
y - sin * size * 0.6 + cos * size * 0.7,
|
||||
x - cos * size * 0.6 + sin * size * 0.7,
|
||||
y - sin * size * 0.6 - cos * size * 0.7,
|
||||
);
|
||||
shown += 1;
|
||||
if (shown >= INDICATOR.max) break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Einfacher Balken mit dunklem Hintergrund. */
|
||||
bar(g, x, y, width, height, ratio, color) {
|
||||
g.fillStyle(0x000000, 0.55);
|
||||
g.fillRect(x - 2, y - 2, width + 4, height + 4);
|
||||
g.fillStyle(color, 1);
|
||||
g.fillRect(x, y, width * Math.max(0, Math.min(1, ratio)), height);
|
||||
}
|
||||
|
||||
/** @param {{title: string, subtitle: string, kind: string}} message */
|
||||
showBanner(message) {
|
||||
this.tweens.killTweensOf([this.bannerTitle, this.bannerSubtitle]);
|
||||
this.bannerTitle.setText(message.title).setColor(message.kind === 'elite' ? UI.colors.danger : UI.colors.gold);
|
||||
this.bannerSubtitle.setText(message.subtitle);
|
||||
this.bannerTitle.setAlpha(0).setScale(1.3);
|
||||
this.bannerSubtitle.setAlpha(0);
|
||||
this.tweens.add({ targets: this.bannerTitle, alpha: 1, scale: 1, duration: 220, ease: 'Back.easeOut' });
|
||||
this.tweens.add({ targets: this.bannerSubtitle, alpha: 1, duration: 300 });
|
||||
this.tweens.add({
|
||||
targets: [this.bannerTitle, this.bannerSubtitle],
|
||||
alpha: 0,
|
||||
delay: FX.announcementMs,
|
||||
duration: 500,
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {{text: string, tone: 'ok'|'warn'}} message */
|
||||
showFeedback(message) {
|
||||
this.tweens.killTweensOf(this.feedbackText);
|
||||
this.feedbackText.setText(message.text).setColor(message.tone === 'warn' ? UI.colors.danger : UI.colors.cyan);
|
||||
this.feedbackText.setAlpha(1);
|
||||
this.tweens.add({ targets: this.feedbackText, alpha: 0, delay: FEEDBACK_MS, duration: 400 });
|
||||
}
|
||||
|
||||
flashDamage() {
|
||||
this.tweens.killTweensOf(this.vignette);
|
||||
this.vignette.setFillStyle(COLORS.crimson, 0.28);
|
||||
this.tweens.add({ targets: this.vignette, fillAlpha: 0, duration: 320 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Einfache Informationsseiten im Canvas: Ehrentafel, Steuerung, Credits.
|
||||
* Alle kehren mit Esc, Enter oder Klick auf „Zurück“ ins Hauptmenü zurück.
|
||||
* @module scenes/InfoScenes
|
||||
*/
|
||||
|
||||
import { ORBITAL } from '../config.js';
|
||||
import { CONTROLS, CREDITS, HIGHSCORES } from '../content/texts.js';
|
||||
import { formatScore, formatSequence } from '../logic/format.js';
|
||||
import { oncePerEvent } from '../systems/keyboard.js';
|
||||
import { textStyle, UI, uiStyle } from '../ui/theme.js';
|
||||
|
||||
/**
|
||||
* Gemeinsamer Rahmen: Hintergrund, Titel und Zurück-Schaltfläche.
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {string} title
|
||||
* @param {string} backLabel
|
||||
*/
|
||||
function frame(scene, title, backLabel) {
|
||||
const { width, height } = scene.scale;
|
||||
scene.input.setDefaultCursor('default');
|
||||
const bg = scene.add.graphics();
|
||||
bg.fillGradientStyle(0x0d1321, 0x0d1321, 0x1b2438, 0x1b2438, 1);
|
||||
bg.fillRect(0, 0, width, height);
|
||||
bg.lineStyle(3, 0xf2c14e, 1);
|
||||
bg.strokeRect(40, 30, width - 80, height - 60);
|
||||
|
||||
scene.add.text(width / 2, 70, title, textStyle({ fontSize: '38px', color: UI.colors.gold })).setOrigin(0.5);
|
||||
const back = scene.add
|
||||
.text(width / 2, height - 70, backLabel, textStyle({ fontSize: '22px' }))
|
||||
.setOrigin(0.5)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
back.on('pointerover', () => back.setColor(UI.colors.gold));
|
||||
back.on('pointerout', () => back.setColor(UI.colors.cream));
|
||||
const leave = () => scene.scene.start('Menu');
|
||||
back.on('pointerup', leave);
|
||||
const leaveByKey = oncePerEvent((event) => {
|
||||
if (!event.repeat) leave();
|
||||
});
|
||||
scene.input.keyboard.on('keydown-ESC', leaveByKey);
|
||||
scene.input.keyboard.on('keydown-ENTER', leaveByKey);
|
||||
}
|
||||
|
||||
export class HighscoresScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Highscores');
|
||||
}
|
||||
|
||||
create() {
|
||||
frame(this, HIGHSCORES.title, HIGHSCORES.back);
|
||||
const { width } = this.scale;
|
||||
this.statusText = this.add.text(width / 2, 300, HIGHSCORES.loading, uiStyle({ fontSize: '22px', color: UI.colors.muted })).setOrigin(0.5);
|
||||
this.registry
|
||||
.get('services')
|
||||
.api.fetchHighscores()
|
||||
.then((result) => {
|
||||
if (!this.sys.isActive()) return;
|
||||
if (!result.ok) this.statusText.setText(HIGHSCORES.error);
|
||||
else if (result.highscores.length === 0) this.statusText.setText(HIGHSCORES.empty);
|
||||
else {
|
||||
this.statusText.destroy();
|
||||
this.renderTable(result.highscores);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {import('../systems/ApiClient.js').HighscoreEntry[]} entries */
|
||||
renderTable(entries) {
|
||||
const columns = { rank: 280, name: 360, score: 900, wave: 1000 };
|
||||
const top = 130;
|
||||
const rowHeight = 40;
|
||||
const header = uiStyle({ fontSize: '17px', color: UI.colors.muted });
|
||||
this.add.text(columns.rank, top, HIGHSCORES.columns.rank, header);
|
||||
this.add.text(columns.name, top, HIGHSCORES.columns.name, header);
|
||||
this.add.text(columns.score, top, HIGHSCORES.columns.score, header).setOrigin(1, 0);
|
||||
this.add.text(columns.wave, top, HIGHSCORES.columns.wave, header).setOrigin(1, 0);
|
||||
entries.forEach((entry, i) => {
|
||||
const y = top + 40 + i * rowHeight;
|
||||
const color = i === 0 ? UI.colors.gold : UI.colors.cream;
|
||||
const style = uiStyle({ fontSize: '24px', color });
|
||||
this.add.text(columns.rank, y, `${entry.rank}.`, style);
|
||||
// Canvas-Text interpretiert kein HTML – Namen werden hier sicher als Text gezeichnet.
|
||||
this.add.text(columns.name, y, entry.name, style);
|
||||
this.add.text(columns.score, y, formatScore(entry.score), style).setOrigin(1, 0);
|
||||
this.add.text(columns.wave, y, String(entry.wave), style).setOrigin(1, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ControlsScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Controls');
|
||||
}
|
||||
|
||||
create() {
|
||||
frame(this, CONTROLS.title, CONTROLS.back);
|
||||
const keyStyle = uiStyle({ fontSize: '20px', color: UI.colors.gold });
|
||||
const valueStyle = uiStyle({ fontSize: '20px' });
|
||||
CONTROLS.rows.forEach(([key, action], i) => {
|
||||
const y = 130 + i * 38;
|
||||
this.add.text(560, y, key, keyStyle).setOrigin(1, 0);
|
||||
this.add.text(590, y, action, valueStyle);
|
||||
});
|
||||
const top = 130 + CONTROLS.rows.length * 38 + 30;
|
||||
this.add.text(this.scale.width / 2, top, CONTROLS.orbitalTitle, textStyle({ fontSize: '22px', color: UI.colors.gold })).setOrigin(0.5);
|
||||
ORBITAL.commands.forEach((command, i) => {
|
||||
const y = top + 40 + i * 36;
|
||||
this.add.text(560, y, command.name, valueStyle).setOrigin(1, 0);
|
||||
this.add.text(590, y, formatSequence(command.sequence, ' '), { ...keyStyle, fontFamily: UI.symbolFont });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CreditsScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Credits');
|
||||
}
|
||||
|
||||
create() {
|
||||
frame(this, CREDITS.title, CREDITS.back);
|
||||
const { width } = this.scale;
|
||||
let y = 118;
|
||||
for (const section of CREDITS.sections) {
|
||||
const heading = this.add.text(width / 2, y, section.heading, textStyle({ fontSize: '21px', color: UI.colors.gold })).setOrigin(0.5, 0);
|
||||
y += heading.height + 4;
|
||||
for (const line of section.lines) {
|
||||
const text = this.add
|
||||
.text(width / 2, y, line, { ...uiStyle({ fontSize: '17px', align: 'center' }), wordWrap: { width: width - 200 } })
|
||||
.setOrigin(0.5, 0);
|
||||
// Tatsächliche Höhe verwenden: umgebrochene Zeilen sind höher als eine Zeile.
|
||||
y += text.height + 2;
|
||||
}
|
||||
y += 12;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Hauptmenü mit Propaganda-Laufband. Beim allerersten Start wird zuerst der
|
||||
* Disclaimer angezeigt; das Menü ist erst danach bedienbar.
|
||||
* @module scenes/MenuScene
|
||||
*/
|
||||
|
||||
import { GAME } from '../config.js';
|
||||
import { MENU, PROPAGANDA, TAGLINE, TITLE } from '../content/texts.js';
|
||||
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
|
||||
import { MenuList } from '../ui/MenuList.js';
|
||||
import { textStyle, UI, uiStyle } from '../ui/theme.js';
|
||||
|
||||
/** Geschwindigkeit des Laufbands in px/s. */
|
||||
const TICKER_SPEED = 90;
|
||||
/** Anzahl Scheinwerferkegel im Hintergrund. */
|
||||
const SEARCHLIGHTS = 3;
|
||||
|
||||
export class MenuScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Menu');
|
||||
}
|
||||
|
||||
create() {
|
||||
const { width, height } = this.scale;
|
||||
const services = this.registry.get('services');
|
||||
this.input.setDefaultCursor('default');
|
||||
|
||||
this.drawBackground(width, height);
|
||||
|
||||
this.add.image(width / 2, 150, TEXTURE_KEYS.emblem).setDisplaySize(150, 150);
|
||||
this.add
|
||||
.text(width / 2, 262, TITLE, textStyle({ fontSize: '68px', color: UI.colors.gold, strokeThickness: 10, stroke: '#7a0a12' }))
|
||||
.setOrigin(0.5);
|
||||
this.add.text(width / 2, 318, TAGLINE, textStyle({ fontSize: '24px' })).setOrigin(0.5);
|
||||
|
||||
this.menu = new MenuList(this, width / 2, 372, [
|
||||
{ label: MENU.start, onSelect: () => this.startMission() },
|
||||
{ label: MENU.highscores, onSelect: () => this.scene.start('Highscores') },
|
||||
{ label: MENU.controls, onSelect: () => this.scene.start('Controls') },
|
||||
{ label: MENU.disclaimer, onSelect: () => this.openDisclaimer(false) },
|
||||
{ label: MENU.credits, onSelect: () => this.scene.start('Credits') },
|
||||
], { spacing: 54 });
|
||||
|
||||
this.add.text(width / 2, height - 62, MENU.hint, uiStyle({ fontSize: '16px', color: UI.colors.muted })).setOrigin(0.5);
|
||||
this.createTicker(width, height);
|
||||
|
||||
// Reine Touch-Geräte (Handy, Tablet) haben weder WASD noch Maus – lieber gleich sagen.
|
||||
if (globalThis.matchMedia?.('(pointer: coarse)').matches && !globalThis.matchMedia?.('(any-pointer: fine)').matches) {
|
||||
this.add.text(width / 2, height - 92, MENU.touchNotice, uiStyle({ fontSize: '18px', color: UI.colors.danger })).setOrigin(0.5);
|
||||
}
|
||||
|
||||
// Ton darf erst nach einer Nutzeraktion starten (Browser-Richtlinie); die Musik
|
||||
// wird vorgemerkt und beginnt mit dem ersten Klick oder Tastendruck.
|
||||
services.audio.startMusic();
|
||||
this.input.once('pointerdown', () => services.audio.unlock());
|
||||
this.input.keyboard.once('keydown', () => services.audio.unlock());
|
||||
|
||||
if (!services.settings.isDisclaimerAccepted()) {
|
||||
this.openDisclaimer(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} mandatory Beim ersten Start muss bestätigt werden.
|
||||
*/
|
||||
openDisclaimer(mandatory) {
|
||||
this.menu.setEnabled(false);
|
||||
this.scene.launch('Disclaimer', { mandatory });
|
||||
this.scene.get('Disclaimer').events.once('disclaimer-closed', () => {
|
||||
this.menu.setEnabled(true);
|
||||
});
|
||||
}
|
||||
|
||||
startMission() {
|
||||
this.registry.get('services').audio.unlock();
|
||||
this.scene.start('Briefing');
|
||||
}
|
||||
|
||||
drawBackground(width, height) {
|
||||
const bg = this.add.graphics();
|
||||
bg.fillGradientStyle(0x0d1321, 0x0d1321, 0x2a0a10, 0x2a0a10, 1);
|
||||
bg.fillRect(0, 0, width, height);
|
||||
// Diagonale Streifen wie auf einem Propagandaplakat.
|
||||
bg.fillStyle(0xc1121f, 0.08);
|
||||
for (let x = -height; x < width; x += 90) {
|
||||
bg.fillTriangle(x, height, x + 45, height, x + height + 45, 0);
|
||||
}
|
||||
|
||||
for (let i = 0; i < SEARCHLIGHTS; i += 1) {
|
||||
const beam = this.add.triangle(width * (0.2 + 0.3 * i), height + 20, 0, 0, -120, -height * 1.1, 120, -height * 1.1, 0xf2c14e, 0.06);
|
||||
beam.setOrigin(0, 0);
|
||||
this.tweens.add({
|
||||
targets: beam,
|
||||
angle: { from: -25, to: 25 },
|
||||
duration: 4200 + i * 900,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.easeInOut',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createTicker(width, height) {
|
||||
const bar = this.add.rectangle(0, height - 36, width, 36, 0xc1121f, 0.9).setOrigin(0, 0);
|
||||
bar.setDepth(GAME.depth.hud);
|
||||
const line = PROPAGANDA.join(' ★ ');
|
||||
this.ticker = this.add
|
||||
.text(width, height - 18, line, uiStyle({ fontSize: '19px', strokeThickness: 0 }))
|
||||
.setOrigin(0, 0.5)
|
||||
.setDepth(GAME.depth.hud + 1);
|
||||
}
|
||||
|
||||
update(_time, delta) {
|
||||
if (!this.ticker) return;
|
||||
this.ticker.x -= (TICKER_SPEED * delta) / 1000;
|
||||
if (this.ticker.x < -this.ticker.width) this.ticker.x = this.scale.width;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Pausenmenü (Esc) mit Lautstärkereglern. Als HTML-Overlay umgesetzt, damit die
|
||||
* Schieberegler per Maus, Tastatur und Screenreader bedienbar sind.
|
||||
* @module scenes/PauseScene
|
||||
*/
|
||||
|
||||
import { ORBITAL } from '../config.js';
|
||||
import { formatSequence } from '../logic/format.js';
|
||||
import { PAUSE } from '../content/texts.js';
|
||||
import { oncePerEvent } from '../systems/keyboard.js';
|
||||
import { el, Overlay } from '../systems/Overlay.js';
|
||||
import { VOLUME_CHANNELS } from '../systems/Settings.js';
|
||||
|
||||
export class PauseScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Pause');
|
||||
}
|
||||
|
||||
create() {
|
||||
const { width, height } = this.scale;
|
||||
const services = this.registry.get('services');
|
||||
this.input.setDefaultCursor('default');
|
||||
// Solange die Pause offen ist, soll der Browser Tasten normal verarbeiten
|
||||
// (Pfeiltasten für Schieberegler, Tab für den Fokus).
|
||||
this.input.keyboard.disableGlobalCapture();
|
||||
services.audio.duck(true);
|
||||
this.add.rectangle(0, 0, width, height, 0x000000, 0.45).setOrigin(0);
|
||||
|
||||
const content = [el('p', 'disclaimer__lead', PAUSE.subtitle)];
|
||||
for (const channel of VOLUME_CHANNELS) {
|
||||
content.push(this.volumeField(channel, services.settings));
|
||||
}
|
||||
const codes = el('p', 'field__hint');
|
||||
codes.textContent = ORBITAL.commands
|
||||
.map((c) => `${c.shortName}: ${formatSequence(c.sequence)}`)
|
||||
.join(' · ');
|
||||
content.push(codes);
|
||||
|
||||
let quitArmed = false;
|
||||
this.overlay = new Overlay({
|
||||
id: 'pause-overlay',
|
||||
title: PAUSE.title,
|
||||
content,
|
||||
buttons: [
|
||||
{ label: PAUSE.resume, onClick: () => this.resume(), testId: 'pause-resume' },
|
||||
{
|
||||
label: PAUSE.quit,
|
||||
secondary: true,
|
||||
testId: 'pause-quit',
|
||||
onClick: () => {
|
||||
// Zweistufig statt Browser-Dialog: erster Klick fragt nach, zweiter bricht ab.
|
||||
if (!quitArmed) {
|
||||
quitArmed = true;
|
||||
quitButton.textContent = PAUSE.confirmQuit;
|
||||
return;
|
||||
}
|
||||
this.quit();
|
||||
},
|
||||
},
|
||||
],
|
||||
onEscape: () => this.resume(),
|
||||
});
|
||||
const quitButton = this.overlay.actions.lastElementChild;
|
||||
|
||||
this.input.keyboard.on(
|
||||
'keydown-ESC',
|
||||
oncePerEvent((event) => {
|
||||
if (!event.repeat) this.resume();
|
||||
}),
|
||||
);
|
||||
this.events.once('shutdown', () => {
|
||||
services.audio.duck(false);
|
||||
if (this.overlay.element.isConnected) this.overlay.close();
|
||||
this.input.keyboard?.enableGlobalCapture();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schieberegler für einen Lautstärkekanal.
|
||||
* @param {'master'|'music'|'sfx'} channel
|
||||
* @param {import('../systems/Settings.js').Settings} settings
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
volumeField(channel, settings) {
|
||||
const field = el('label', 'field');
|
||||
const label = el('span', 'field__label', PAUSE.volumes[channel]);
|
||||
const input = /** @type {HTMLInputElement} */ (el('input', 'field__input'));
|
||||
input.type = 'range';
|
||||
input.min = '0';
|
||||
input.max = '100';
|
||||
input.step = '5';
|
||||
input.value = String(Math.round(settings.getVolume(channel) * 100));
|
||||
input.dataset.testid = `volume-${channel}`;
|
||||
input.addEventListener('input', () => settings.setVolume(channel, Number(input.value) / 100));
|
||||
field.append(label, input);
|
||||
return field;
|
||||
}
|
||||
|
||||
resume() {
|
||||
this.scene.resume('Game');
|
||||
this.scene.get('Game').input.setDefaultCursor('none');
|
||||
this.scene.stop();
|
||||
}
|
||||
|
||||
quit() {
|
||||
this.scene.stop('Hud');
|
||||
this.scene.stop('Game');
|
||||
this.scene.start('Menu');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user