/** * Orbital-Befehle: Eingabe (Halte-Taste + Richtungsfolge), Abklingzeiten, * Bodenmarkierung und verzögerter Einschlag. * * Die Eingabelogik steckt in `logic/comboInput.js`, die Abklingzeiten in * `logic/cooldowns.js`; hier wird beides mit Phaser-Tastatur und Grafik verbunden. * @module systems/OrbitalSystem */ import { COLORS, GAME, ORBITAL } from '../config.js'; import { codeToDirection, ComboInput } from '../logic/comboInput.js'; import { CooldownTracker } from '../logic/cooldowns.js'; import { clamp, distance } from '../logic/math.js'; import { GroundMarker } from './GroundMarker.js'; import { oncePerEvent } from './keyboard.js'; /** Farbe der Markierung je Befehl. */ const MARKER_COLORS = Object.freeze({ strike: COLORS.strike, supply: COLORS.supply, turret: COLORS.turret, }); /** * @typedef {typeof ORBITAL.commands[number]} OrbitalCommand */ /** * @typedef {Object} OrbitalCallbacks * @property {() => {x: number, y: number}} getPlayer * @property {() => {x: number, y: number}} getAimPoint Mausposition in Weltkoordinaten. * @property {(command: OrbitalCommand, x: number, y: number) => void} onImpact * @property {(kind: 'step'|'fail'|'deploy'|'notReady', command?: OrbitalCommand, remainingMs?: number) => void} onFeedback */ export class OrbitalSystem { /** * @param {Phaser.Scene} scene * @param {OrbitalCallbacks} callbacks */ constructor(scene, callbacks) { this.scene = scene; this.callbacks = callbacks; this.commands = ORBITAL.commands; this.combo = new ComboInput(this.commands); this.cooldowns = new CooldownTracker(); /** @type {{command: OrbitalCommand, marker: GroundMarker}[]} */ this.pending = []; this.now = 0; this.holdKey = scene.input.keyboard.addKey(ORBITAL.holdKey); // Pfeiltasten abfangen, sonst scrollt der Browser die Seite. scene.input.keyboard.addCapture('UP,DOWN,LEFT,RIGHT'); this.onKeyDown = oncePerEvent((event) => this.handleKeyDown(event)); scene.input.keyboard.on('keydown', this.onKeyDown); } /** @returns {boolean} true, solange ein Befehl eingegeben wird (Bewegung pausiert). */ get isInputActive() { return this.combo.active; } /** * @param {number} now Spielzeit in ms. */ update(now) { this.now = now; if (this.holdKey.isDown && !this.combo.active) this.combo.begin(); else if (!this.holdKey.isDown && this.combo.active) this.combo.end(); for (let i = this.pending.length - 1; i >= 0; i -= 1) { const entry = this.pending[i]; if (entry.marker.isDue(now)) { entry.marker.destroy(); this.pending.splice(i, 1); this.callbacks.onImpact(entry.command, entry.marker.x, entry.marker.y); } else { entry.marker.update(now); } } } /** * @param {KeyboardEvent} event * @private */ handleKeyDown(event) { // Gedrückt gehaltene Tasten erzeugen Wiederholungen – die zählen nicht als Eingabe. if (event.repeat) return; // Q und erste Richtung können im selben Frame ankommen, bevor update() die // Eingabe gestartet hat – dann hier starten, sonst ginge die erste Richtung verloren. if (!this.combo.active && this.holdKey.isDown) this.combo.begin(); if (!this.combo.active) return; const direction = codeToDirection(event.code); if (!direction) return; const result = this.combo.push(direction); if (result.status === 'progress') this.callbacks.onFeedback('step'); else if (result.status === 'fail') this.callbacks.onFeedback('fail'); else if (result.status === 'match') this.request(/** @type {string} */ (result.commandId)); } /** * Fordert einen Befehl an (sofern bereit) und setzt die Markierung ans Ziel. * @param {string} commandId * @returns {boolean} */ request(commandId) { const command = this.commands.find((c) => c.id === commandId); if (!command) return false; const now = this.now; if (!this.cooldowns.isReady(command.id, now)) { this.callbacks.onFeedback('notReady', command, this.cooldowns.remaining(command.id, now)); return false; } const target = this.clampTarget(this.callbacks.getAimPoint()); this.cooldowns.trigger(command.id, command.cooldownMs, now); const marker = new GroundMarker( this.scene, target.x, target.y, command.radius, MARKER_COLORS[command.id] ?? COLORS.gold, now, now + command.delayMs, { label: command.shortName, beam: true }, ); marker.update(now); this.pending.push({ command, marker }); this.callbacks.onFeedback('deploy', command); return true; } /** * Begrenzt das Ziel auf die maximale Reichweite und die Arena. * @param {{x: number, y: number}} aim * @returns {{x: number, y: number}} */ clampTarget(aim) { const player = this.callbacks.getPlayer(); const d = distance(player, aim); let { x, y } = aim; if (d > ORBITAL.maxTargetRange) { const f = ORBITAL.maxTargetRange / d; x = player.x + (aim.x - player.x) * f; y = player.y + (aim.y - player.y) * f; } const margin = GAME.spawnMargin; return { x: clamp(x, margin, GAME.world.width - margin), y: clamp(y, margin, GAME.world.height - margin), }; } /** * Daten für die HUD-Anzeige. * @param {number} now */ hudState(now) { return { active: this.combo.active, commands: this.commands.map((c) => ({ id: c.id, name: c.shortName, sequence: c.sequence, progress: this.combo.progressFor(c.id), ready: this.cooldowns.isReady(c.id, now), remainingMs: this.cooldowns.remaining(c.id, now), cooldownProgress: this.cooldowns.progress(c.id, now), })), }; } /** Aufräumen beim Beenden der Szene. */ destroy() { this.scene.input.keyboard?.off('keydown', this.onKeyDown); for (const entry of this.pending) entry.marker.destroy(); this.pending = []; } }