/** * Eingabe der Orbital-Befehle als Richtungsfolge (ohne Phaser). * * Solange die Halte-Taste gedrückt ist, sammelt `ComboInput` Richtungen. Nach jeder * Eingabe wird geprüft, ob der Puffer noch Anfang mindestens eines Befehls ist: * - passt nichts mehr → „fail“, Puffer wird geleert (man kann direkt neu ansetzen) * - Folge vollständig → „match“ mit Befehls-ID * - sonst → „progress“ * Ob ein Befehl gerade abklingt, entscheidet der Aufrufer – hier geht es nur um Folgen. * @module logic/comboInput */ /** @typedef {'up'|'down'|'left'|'right'} Direction */ /** @typedef {{id: string, sequence: readonly Direction[]}} ComboCommand */ /** @type {readonly Direction[]} */ export const DIRECTIONS = Object.freeze(['up', 'down', 'left', 'right']); /** * Physische Tastencodes (KeyboardEvent.code) → Richtung. `code` statt `key`, damit * WASD auch auf AZERTY- oder QWERTZ-Tastaturen an derselben Stelle liegt. */ const CODE_TO_DIRECTION = Object.freeze({ ArrowUp: 'up', KeyW: 'up', ArrowDown: 'down', KeyS: 'down', ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right', }); /** * @param {string} code KeyboardEvent.code * @returns {Direction|null} */ export function codeToDirection(code) { return CODE_TO_DIRECTION[code] ?? null; } /** * @param {readonly string[]} prefix * @param {readonly string[]} sequence * @returns {boolean} */ export function isPrefix(prefix, sequence) { if (prefix.length > sequence.length) return false; return prefix.every((dir, i) => sequence[i] === dir); } /** * Prüft die Befehlsliste: gültige Richtungen, eindeutige IDs und keine Folge, die * Anfang einer anderen ist (sonst wäre die längere Folge nie erreichbar). * @param {readonly ComboCommand[]} commands * @throws {Error} bei ungültiger Konfiguration. */ export function validateCommands(commands) { const ids = new Set(); for (const command of commands) { if (ids.has(command.id)) throw new Error(`Doppelte Befehls-ID: ${command.id}`); ids.add(command.id); if (command.sequence.length === 0) throw new Error(`Leere Folge bei ${command.id}`); for (const dir of command.sequence) { if (!DIRECTIONS.includes(dir)) throw new Error(`Ungültige Richtung „${dir}“ bei ${command.id}`); } } for (const a of commands) { for (const b of commands) { if (a !== b && isPrefix(a.sequence, b.sequence)) { throw new Error(`Folge von ${a.id} ist Anfang der Folge von ${b.id}`); } } } } export class ComboInput { /** @param {readonly ComboCommand[]} commands */ constructor(commands) { validateCommands(commands); this.commands = commands; /** @type {Direction[]} */ this.buffer = []; this.active = false; } /** Halte-Taste gedrückt: Eingabe beginnt mit leerem Puffer. */ begin() { this.active = true; this.buffer = []; } /** Halte-Taste losgelassen: halbe Eingaben verfallen. */ end() { this.active = false; this.buffer = []; } /** * Verarbeitet eine Richtung. * @param {string} direction * @returns {{status: 'inactive'|'ignored'|'fail'|'progress'|'match', commandId?: string, candidates?: string[]}} */ push(direction) { if (!this.active) return { status: 'inactive' }; if (!DIRECTIONS.includes(/** @type {Direction} */ (direction))) return { status: 'ignored' }; this.buffer.push(/** @type {Direction} */ (direction)); const candidates = this.commands.filter((c) => isPrefix(this.buffer, c.sequence)); if (candidates.length === 0) { this.buffer = []; return { status: 'fail' }; } const exact = candidates.find((c) => c.sequence.length === this.buffer.length); if (exact) { this.buffer = []; return { status: 'match', commandId: exact.id }; } return { status: 'progress', candidates: candidates.map((c) => c.id) }; } /** * Wie viele Schritte eines Befehls bereits korrekt eingegeben sind (für die HUD-Anzeige). * @param {string} commandId * @returns {number} */ progressFor(commandId) { const command = this.commands.find((c) => c.id === commandId); if (!command || !isPrefix(this.buffer, command.sequence)) return 0; return this.buffer.length; } }