146 lines
5.0 KiB
JavaScript
146 lines
5.0 KiB
JavaScript
/**
|
|
* Steuerungsverhalten („Steering Behaviors“) für die Gegner-KI, ohne Phaser.
|
|
*
|
|
* Jede Funktion liefert eine Wunschgeschwindigkeit. Die Entities mischen mehrere
|
|
* davon (z. B. Verfolgen + Abstand zu Nachbarn) und begrenzen das Ergebnis auf ihr
|
|
* Tempo. Die drei Grundmuster aus dem Design:
|
|
* - Verfolgen → seek
|
|
* - Abstand → keepDistance (mit seitlichem Ausweichen im Wohlfühlbereich)
|
|
* - Flankieren → flankPoint / encirclePoint als Ziel für seek
|
|
* @module logic/steering
|
|
*/
|
|
|
|
import { add, angleTo, clampLength, distance, fromAngle, length, normalize, scale, sub } from './math.js';
|
|
|
|
/** @typedef {import('./math.js').Vec2} Vec2 */
|
|
|
|
/** Anteil der Grundgeschwindigkeit beim seitlichen Ausweichen. */
|
|
export const DEFAULT_STRAFE_FACTOR = 0.6;
|
|
|
|
/**
|
|
* Direkt auf ein Ziel zu.
|
|
* @param {Vec2} position
|
|
* @param {Vec2} target
|
|
* @param {number} speed
|
|
* @returns {Vec2}
|
|
*/
|
|
export function seek(position, target, speed) {
|
|
return scale(normalize(sub(target, position)), speed);
|
|
}
|
|
|
|
/**
|
|
* Direkt von einer Bedrohung weg.
|
|
* @param {Vec2} position
|
|
* @param {Vec2} threat
|
|
* @param {number} speed
|
|
* @returns {Vec2}
|
|
*/
|
|
export function flee(position, threat, speed) {
|
|
return scale(seek(position, threat, speed), -1);
|
|
}
|
|
|
|
/**
|
|
* Abstoßung von Nachbarn, damit Gruppen nicht zu einem Klumpen verschmelzen.
|
|
* Nähere Nachbarn stoßen stärker ab (linear bis 0 am Rand des Radius).
|
|
* @param {Vec2} position
|
|
* @param {Iterable<Vec2>} neighbors Andere Gegner (der eigene Eintrag darf enthalten sein).
|
|
* @param {number} radius
|
|
* @returns {Vec2} Unskalierter Richtungsvektor (Länge ≈ Anzahl/Stärke der Überlappungen).
|
|
*/
|
|
export function separation(position, neighbors, radius) {
|
|
let push = { x: 0, y: 0 };
|
|
for (const other of neighbors) {
|
|
if (other === position) continue;
|
|
const d = distance(position, other);
|
|
if (d >= radius) continue;
|
|
// Exakt übereinander: feste Richtung statt NaN, damit sich die beiden trennen.
|
|
const away = d > 0 ? normalize(sub(position, other)) : { x: 1, y: 0 };
|
|
push = add(push, scale(away, 1 - d / radius));
|
|
}
|
|
return push;
|
|
}
|
|
|
|
/**
|
|
* Hält einen Abstand zwischen `band.min` und `band.max` zum Ziel ein.
|
|
* Zu weit weg → annähern, zu nah → zurückweichen, dazwischen → seitlich ausweichen,
|
|
* damit Fernkämpfer kein stehendes Ziel sind.
|
|
* @param {Vec2} position
|
|
* @param {Vec2} target
|
|
* @param {{min: number, max: number}} band
|
|
* @param {number} speed
|
|
* @param {1|-1} strafeDirection 1 = im Uhrzeigersinn, -1 = dagegen.
|
|
* @param {number} [strafeFactor]
|
|
* @returns {Vec2}
|
|
*/
|
|
export function keepDistance(position, target, band, speed, strafeDirection, strafeFactor = DEFAULT_STRAFE_FACTOR) {
|
|
const d = distance(position, target);
|
|
const toTarget = normalize(sub(target, position));
|
|
if (d > band.max) return scale(toTarget, speed);
|
|
if (d < band.min) return scale(toTarget, -speed);
|
|
const tangent = { x: -toTarget.y * strafeDirection, y: toTarget.x * strafeDirection };
|
|
return scale(tangent, speed * strafeFactor);
|
|
}
|
|
|
|
/**
|
|
* Flankenposition: Punkt auf einem Kreis um das Ziel, gegenüber der aktuellen
|
|
* Blickachse Ziel→Gegner um `angleOffset` gedreht. Der Gegner läuft also nicht
|
|
* frontal an, sondern schwenkt seitlich ein.
|
|
* @param {Vec2} target
|
|
* @param {Vec2} position
|
|
* @param {number} angleOffset Bogenmaß; Vorzeichen bestimmt die Seite.
|
|
* @param {number} radius
|
|
* @returns {Vec2}
|
|
*/
|
|
export function flankPoint(target, position, angleOffset, radius) {
|
|
const baseAngle = angleTo(target, position);
|
|
return add(target, scale(fromAngle(baseAngle + angleOffset), radius));
|
|
}
|
|
|
|
/**
|
|
* Umzingelungspunkt: fester Winkel („Platz im Kreis“) um das Ziel. Verteilt man
|
|
* die Winkel auf einen Schwarm, greift er von allen Seiten an.
|
|
* @param {Vec2} target
|
|
* @param {number} slotAngle Bogenmaß.
|
|
* @param {number} radius
|
|
* @returns {Vec2}
|
|
*/
|
|
export function encirclePoint(target, slotAngle, radius) {
|
|
return add(target, scale(fromAngle(slotAngle), radius));
|
|
}
|
|
|
|
/**
|
|
* Gewichtete Summe mehrerer Steuerungsvektoren, begrenzt auf `maxSpeed`.
|
|
* @param {{vector: Vec2, weight: number}[]} parts
|
|
* @param {number} maxSpeed
|
|
* @returns {Vec2}
|
|
*/
|
|
export function combine(parts, maxSpeed) {
|
|
const sum = parts.reduce((acc, p) => add(acc, scale(p.vector, p.weight)), { x: 0, y: 0 });
|
|
return clampLength(sum, maxSpeed);
|
|
}
|
|
|
|
/**
|
|
* Nähert die aktuelle Geschwindigkeit der Wunschgeschwindigkeit an. Ohne diese
|
|
* Glättung würden Gegner bei jedem Richtungswechsel ruckartig umspringen.
|
|
* @param {Vec2} current
|
|
* @param {Vec2} desired
|
|
* @param {number} responsiveness 0..1 (1 = sofort).
|
|
* @returns {Vec2}
|
|
*/
|
|
export function steerTowards(current, desired, responsiveness) {
|
|
return {
|
|
x: current.x + (desired.x - current.x) * responsiveness,
|
|
y: current.y + (desired.y - current.y) * responsiveness,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ist ein Vektor (fast) null? Nützlich für Animationen („steht still“).
|
|
* @param {Vec2} v
|
|
* @param {number} [epsilon]
|
|
* @returns {boolean}
|
|
*/
|
|
export function isNearlyZero(v, epsilon = 1e-3) {
|
|
return length(v) < epsilon;
|
|
}
|