133 lines
4.2 KiB
JavaScript
133 lines
4.2 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|