slop game oder so
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Abklingzeiten für Fähigkeiten (Ausweichrolle, Orbital-Befehle) ohne Phaser.
|
||||
* Arbeitet mit der Spielzeit des GameScene, die während der Pause stillsteht –
|
||||
* Abklingzeiten laufen in der Pause also nicht heimlich weiter.
|
||||
* @module logic/cooldowns
|
||||
*/
|
||||
|
||||
import { clamp } from './math.js';
|
||||
|
||||
export class CooldownTracker {
|
||||
constructor() {
|
||||
/** @type {Map<string, number>} */
|
||||
this.readyAt = new Map();
|
||||
/** @type {Map<string, number>} */
|
||||
this.durations = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet die Abklingzeit von `id`.
|
||||
* @param {string} id
|
||||
* @param {number} durationMs
|
||||
* @param {number} now Spielzeit in ms.
|
||||
*/
|
||||
trigger(id, durationMs, now) {
|
||||
this.readyAt.set(id, now + durationMs);
|
||||
this.durations.set(id, durationMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {number} now
|
||||
* @returns {boolean} true, wenn die Fähigkeit benutzt werden kann (auch wenn nie ausgelöst).
|
||||
*/
|
||||
isReady(id, now) {
|
||||
return (this.readyAt.get(id) ?? -Infinity) <= now;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {number} now
|
||||
* @returns {number} Restzeit in ms (0 wenn bereit).
|
||||
*/
|
||||
remaining(id, now) {
|
||||
return Math.max(0, (this.readyAt.get(id) ?? now) - now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fortschritt für Ladebalken im HUD.
|
||||
* @param {string} id
|
||||
* @param {number} now
|
||||
* @returns {number} 0 = gerade ausgelöst, 1 = bereit.
|
||||
*/
|
||||
progress(id, now) {
|
||||
const duration = this.durations.get(id);
|
||||
if (!duration) return 1;
|
||||
return clamp(1 - this.remaining(id, now) / duration, 0, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt eine oder alle Abklingzeiten zurück.
|
||||
* @param {string} [id]
|
||||
*/
|
||||
reset(id) {
|
||||
if (id === undefined) {
|
||||
this.readyAt.clear();
|
||||
this.durations.clear();
|
||||
} else {
|
||||
this.readyAt.delete(id);
|
||||
this.durations.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Entscheidungslogik der Gegner (ohne Phaser): Bewegung pro Verhalten,
|
||||
* Sprungangriff, Schuss-Salven, Brut. Die Entities rufen diese Funktionen jeden
|
||||
* Frame auf und setzen das Ergebnis in Physik und Grafik um.
|
||||
*
|
||||
* Zustände liegen in kleinen, veränderlichen „Brain“-Objekten pro Gegner. Das ist
|
||||
* bewusst kein unveränderlicher Stil: Bei 60 Gegnern und 60 FPS sollen pro Frame
|
||||
* keine Objektberge für den Garbage Collector entstehen.
|
||||
* @module logic/enemyAi
|
||||
*/
|
||||
|
||||
import { degToRad, distance, normalize, scale, sub } from './math.js';
|
||||
import { encirclePoint, flankPoint, keepDistance, seek } from './steering.js';
|
||||
|
||||
/** @typedef {import('./math.js').Vec2} Vec2 */
|
||||
|
||||
/**
|
||||
* @typedef {Object} Brain
|
||||
* @property {number} slotAngle Anflugwinkel im Schwarm (Bogenmaß).
|
||||
* @property {1|-1} strafeDirection Richtung beim seitlichen Ausweichen.
|
||||
* @property {number} nextStrafeSwitchAt
|
||||
* @property {1|-1} flankSide Seite, von der flankiert wird.
|
||||
* @property {'chase'|'windup'|'leap'} leapState
|
||||
* @property {number} leapStateUntil
|
||||
* @property {number} nextLeapAt
|
||||
* @property {Vec2} leapDirection
|
||||
* @property {AttackState} attack
|
||||
* @property {AttackState} mortar
|
||||
* @property {number} nextBroodAt
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AttackState
|
||||
* @property {number} nextAttackAt
|
||||
* @property {number} burstRemaining
|
||||
* @property {number} nextShotAt
|
||||
*/
|
||||
|
||||
/**
|
||||
* Neuer Angriffszustand. `firstAttackAt` verzögert den ersten Schuss, damit frisch
|
||||
* gespawnte Gegner nicht sofort und alle gleichzeitig feuern.
|
||||
* @param {number} firstAttackAt
|
||||
* @returns {AttackState}
|
||||
*/
|
||||
export function createAttackState(firstAttackAt = 0) {
|
||||
return { nextAttackAt: firstAttackAt, burstRemaining: 0, nextShotAt: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Neues Brain mit zufälligen, aber reproduzierbaren Eigenheiten.
|
||||
* @param {import('./rng.js').Rng} rng
|
||||
* @param {number} now
|
||||
* @param {{firstAttackDelayMs?: number}} [options]
|
||||
* @returns {Brain}
|
||||
*/
|
||||
export function createBrain(rng, now, options = {}) {
|
||||
const firstAttackAt = now + (options.firstAttackDelayMs ?? 0) * (0.5 + rng());
|
||||
return {
|
||||
slotAngle: rng() * Math.PI * 2,
|
||||
strafeDirection: rng() < 0.5 ? 1 : -1,
|
||||
nextStrafeSwitchAt: now,
|
||||
flankSide: rng() < 0.5 ? 1 : -1,
|
||||
leapState: 'chase',
|
||||
leapStateUntil: 0,
|
||||
nextLeapAt: now,
|
||||
leapDirection: { x: 0, y: 0 },
|
||||
attack: createAttackState(firstAttackAt),
|
||||
mortar: createAttackState(firstAttackAt + (options.firstAttackDelayMs ?? 0)),
|
||||
nextBroodAt: now + (options.firstAttackDelayMs ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wechselt gelegentlich die Ausweichrichtung, damit Fernkämpfer nicht vorhersehbar kreisen.
|
||||
* @param {Brain} brain
|
||||
* @param {number} now
|
||||
* @param {{min: number, max: number}} switchMs
|
||||
* @param {import('./rng.js').Rng} rng
|
||||
*/
|
||||
export function updateStrafe(brain, now, switchMs, rng) {
|
||||
if (now >= brain.nextStrafeSwitchAt) {
|
||||
brain.strafeDirection = brain.strafeDirection === 1 ? -1 : 1;
|
||||
brain.nextStrafeSwitchAt = now + switchMs.min + rng() * (switchMs.max - switchMs.min);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schwarm: aus der Distanz den eigenen Platz im Umzingelungskreis ansteuern,
|
||||
* in Reichweite direkt angreifen. So kommt ein Rudel von mehreren Seiten.
|
||||
* @param {Brain} brain
|
||||
* @param {Vec2} self
|
||||
* @param {Vec2} player
|
||||
* @param {number} speed
|
||||
* @param {{engageRange: number, encircleRadius: number}} type
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function swarmVelocity(brain, self, player, speed, type) {
|
||||
if (distance(self, player) <= type.engageRange) {
|
||||
return seek(self, player, speed);
|
||||
}
|
||||
return seek(self, encirclePoint(player, brain.slotAngle, type.encircleRadius), speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprungkralle: verfolgen → ansetzen (stillstehen, Vorwarnung) → springen → verfolgen.
|
||||
* Die Sprungrichtung wird beim Ansetzen festgelegt: Wer die Vorwarnung sieht, kann
|
||||
* ausweichen. Das ist die „Fairness“ des Angriffs.
|
||||
* @param {Brain} brain
|
||||
* @param {Vec2} self
|
||||
* @param {Vec2} player
|
||||
* @param {number} now
|
||||
* @param {number} speed
|
||||
* @param {{triggerRange: number, windupMs: number, speed: number, durationMs: number, cooldownMs: number}} leap
|
||||
* @returns {{velocity: Vec2, telegraph: boolean, leaping: boolean}}
|
||||
*/
|
||||
export function leaperStep(brain, self, player, now, speed, leap) {
|
||||
if (brain.leapState === 'windup') {
|
||||
if (now < brain.leapStateUntil) {
|
||||
return { velocity: { x: 0, y: 0 }, telegraph: true, leaping: false };
|
||||
}
|
||||
brain.leapState = 'leap';
|
||||
brain.leapStateUntil = now + leap.durationMs;
|
||||
}
|
||||
if (brain.leapState === 'leap') {
|
||||
if (now < brain.leapStateUntil) {
|
||||
return { velocity: scale(brain.leapDirection, leap.speed), telegraph: false, leaping: true };
|
||||
}
|
||||
brain.leapState = 'chase';
|
||||
brain.nextLeapAt = now + leap.cooldownMs;
|
||||
}
|
||||
// chase
|
||||
if (distance(self, player) <= leap.triggerRange && now >= brain.nextLeapAt) {
|
||||
brain.leapState = 'windup';
|
||||
brain.leapStateUntil = now + leap.windupMs;
|
||||
brain.leapDirection = normalize(sub(player, self));
|
||||
return { velocity: { x: 0, y: 0 }, telegraph: true, leaping: false };
|
||||
}
|
||||
return { velocity: seek(self, player, speed), telegraph: false, leaping: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fernkämpfer auf Abstand (Nieter-Drohne, Walzmeister).
|
||||
* @param {Brain} brain
|
||||
* @param {Vec2} self
|
||||
* @param {Vec2} player
|
||||
* @param {number} speed
|
||||
* @param {{min: number, max: number}} range
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function kiteVelocity(brain, self, player, speed, range) {
|
||||
return keepDistance(self, player, range, speed, brain.strafeDirection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flankierer (Blechsoldat): steuert eine Position seitlich des Spielers an und
|
||||
* hält dort Abstand. Nah an der Flankenposition geht er ins Abstandhalten über.
|
||||
* @param {Brain} brain
|
||||
* @param {Vec2} self
|
||||
* @param {Vec2} player
|
||||
* @param {number} speed
|
||||
* @param {{flankAngleDeg: number, flankDistance: number, range: {min: number, max: number}}} type
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function flankVelocity(brain, self, player, speed, type) {
|
||||
const target = flankPoint(player, self, degToRad(type.flankAngleDeg) * brain.flankSide, type.flankDistance);
|
||||
// Ist die Flanke (fast) erreicht, wie ein Fernkämpfer weiterkämpfen.
|
||||
if (distance(self, target) < type.range.min * 0.5) {
|
||||
return keepDistance(self, player, type.range, speed, brain.flankSide);
|
||||
}
|
||||
return seek(self, target, speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Taktgeber für Schüsse und Salven.
|
||||
* @param {AttackState} state
|
||||
* @param {number} now
|
||||
* @param {number} distanceToPlayer
|
||||
* @param {{cooldownMs: number, burst?: number, burstIntervalMs?: number, maxDistance: number}} attack
|
||||
* @returns {boolean} true, wenn in diesem Frame geschossen werden soll.
|
||||
*/
|
||||
export function attackStep(state, now, distanceToPlayer, attack) {
|
||||
if (state.burstRemaining > 0) {
|
||||
if (now >= state.nextShotAt) {
|
||||
state.burstRemaining -= 1;
|
||||
state.nextShotAt = now + (attack.burstIntervalMs ?? 0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (now >= state.nextAttackAt && distanceToPlayer <= attack.maxDistance) {
|
||||
state.burstRemaining = (attack.burst ?? 1) - 1;
|
||||
state.nextShotAt = now + (attack.burstIntervalMs ?? 0);
|
||||
state.nextAttackAt = now + attack.cooldownMs;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Winkel aller Projektile einer Salve, gleichmäßig über `spreadDeg` verteilt.
|
||||
* @param {number} baseAngle Bogenmaß (Richtung zum Ziel).
|
||||
* @param {number} count
|
||||
* @param {number} spreadDeg Gesamtbreite des Fächers in Grad.
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function volleyAngles(baseAngle, count, spreadDeg) {
|
||||
if (count <= 1) return [baseAngle];
|
||||
const spread = degToRad(spreadDeg);
|
||||
const step = spread / (count - 1);
|
||||
return Array.from({ length: count }, (_, i) => baseAngle - spread / 2 + i * step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Brutmutter: Wie viele Krabbler jetzt schlüpfen sollen.
|
||||
* @param {Brain} brain
|
||||
* @param {number} now
|
||||
* @param {number} aliveChildren Aktuell lebende Nachkommen dieser Brutmutter.
|
||||
* @param {{intervalMs: number, spawnCount: number, maxChildren: number}} brood
|
||||
* @returns {number}
|
||||
*/
|
||||
export function broodStep(brain, now, aliveChildren, brood) {
|
||||
if (now < brain.nextBroodAt) return 0;
|
||||
brain.nextBroodAt = now + brood.intervalMs;
|
||||
return Math.max(0, Math.min(brood.spawnCount, brood.maxChildren - aliveChildren));
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Formatierung und Eingabeprüfung für Anzeige und Highscore-Formular (ohne Phaser).
|
||||
* @module logic/format
|
||||
*/
|
||||
|
||||
import { DIRECTION_SYMBOLS, HIGHSCORE } from '../config.js';
|
||||
|
||||
const scoreFormatter = new Intl.NumberFormat('de-DE');
|
||||
|
||||
/**
|
||||
* Punktestand mit Tausendertrennzeichen („12.345“).
|
||||
* @param {number} score
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatScore(score) {
|
||||
return scoreFormatter.format(Math.max(0, Math.floor(score)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dauer als „m:ss“.
|
||||
* @param {number} ms
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDuration(ms) {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = String(totalSeconds % 60).padStart(2, '0');
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplikator mit deutschem Dezimalkomma („×1,5“).
|
||||
* @param {number} multiplier
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatMultiplier(multiplier) {
|
||||
return `×${String(Math.round(multiplier * 10) / 10).replace('.', ',')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abklingzeit in Sekunden für das HUD („12“, „0,8“).
|
||||
* @param {number} ms
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCooldown(ms) {
|
||||
if (ms <= 0) return '';
|
||||
const seconds = ms / 1000;
|
||||
return seconds >= 1 ? String(Math.ceil(seconds)) : seconds.toFixed(1).replace('.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Richtungsfolge als Pfeile („↑ → ↓ ↓“).
|
||||
* @param {readonly string[]} sequence
|
||||
* @param {string} [separator]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSequence(sequence, separator = ' ') {
|
||||
return sequence.map((dir) => DIRECTION_SYMBOLS[dir] ?? '?').join(separator);
|
||||
}
|
||||
|
||||
/** Wie auf dem Server (app/validation.py): alles außer Buchstaben, Ziffern und wenigen Zeichen. */
|
||||
const DISALLOWED_NAME_CHARS = /[^\p{L}\p{N}_ .\-'!?]/gu;
|
||||
/** Steuer-, Format-, Surrogat- und private Zeichen (Unicode-Kategorie C). */
|
||||
const CONTROL_CHARS = /\p{C}/gu;
|
||||
|
||||
/**
|
||||
* Bereinigt einen Namen genauso wie das Backend, damit der Spieler vor dem Absenden
|
||||
* sieht, was gespeichert wird. Der Server prüft trotzdem selbst.
|
||||
* @param {string} raw
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sanitizeName(raw) {
|
||||
return String(raw)
|
||||
.normalize('NFKC')
|
||||
.replace(CONTROL_CHARS, '')
|
||||
.replace(DISALLOWED_NAME_CHARS, '')
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft einen Namen für das Highscore-Formular.
|
||||
* @param {string} raw
|
||||
* @returns {{ok: true, name: string} | {ok: false, error: string}}
|
||||
*/
|
||||
export function validateName(raw) {
|
||||
const name = sanitizeName(raw);
|
||||
if (!name) return { ok: false, error: 'Bitte einen Namen mit Buchstaben oder Ziffern eingeben.' };
|
||||
if ([...name].length > HIGHSCORE.nameMaxLength) {
|
||||
return { ok: false, error: `Höchstens ${HIGHSCORE.nameMaxLength} Zeichen.` };
|
||||
}
|
||||
return { ok: true, name };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Notenmuster der Hintergrundmusik: ein pompöser Marsch (eigene Komposition, CC0),
|
||||
* der die Propaganda-Stimmung überzeichnet. Ohne Web-Audio-Abhängigkeit, damit sich
|
||||
* Rhythmus und Harmonie testen lassen; gespielt wird er von `systems/Music.js`.
|
||||
*
|
||||
* Raster: 16 Schritte (Sechzehntel) pro Takt, 8 Takte, Viervierteltakt.
|
||||
* @module logic/marchPattern
|
||||
*/
|
||||
|
||||
export const TEMPO_BPM = 112;
|
||||
export const STEPS_PER_BAR = 16;
|
||||
export const BARS = 8;
|
||||
export const TOTAL_STEPS = STEPS_PER_BAR * BARS;
|
||||
|
||||
/** Dauer eines Sechzehntels in Sekunden. */
|
||||
export const STEP_SECONDS = 60 / TEMPO_BPM / 4;
|
||||
|
||||
/** Akkordgrundtöne (MIDI) je Takt: I I IV V I IV V I in C-Dur. */
|
||||
export const CHORD_ROOTS = Object.freeze([48, 48, 53, 55, 48, 53, 55, 48]);
|
||||
|
||||
/** Dur-Dreiklang über dem Grundton. */
|
||||
const MAJOR_TRIAD = Object.freeze([0, 4, 7]);
|
||||
|
||||
/**
|
||||
* Fanfarenmelodie: [Takt, Schritt, MIDI-Ton, Länge in Schritten].
|
||||
* @type {readonly (readonly [number, number, number, number])[]}
|
||||
*/
|
||||
const MELODY = Object.freeze([
|
||||
[0, 0, 67, 3], [0, 3, 67, 1], [0, 4, 72, 4], [0, 8, 76, 6], [0, 14, 72, 2],
|
||||
[1, 0, 74, 4], [1, 4, 72, 4], [1, 8, 71, 4], [1, 12, 67, 4],
|
||||
[2, 0, 69, 3], [2, 3, 69, 1], [2, 4, 72, 4], [2, 8, 77, 6], [2, 14, 76, 2],
|
||||
[3, 0, 74, 4], [3, 4, 71, 4], [3, 8, 67, 8],
|
||||
[4, 0, 67, 3], [4, 3, 67, 1], [4, 4, 72, 4], [4, 8, 76, 4], [4, 12, 79, 4],
|
||||
[5, 0, 81, 6], [5, 6, 77, 2], [5, 8, 76, 4], [5, 12, 72, 4],
|
||||
[6, 0, 74, 3], [6, 3, 76, 1], [6, 4, 74, 4], [6, 8, 71, 4], [6, 12, 67, 4],
|
||||
[7, 0, 72, 8],
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {Object} MusicEvent
|
||||
* @property {'kick'|'snare'|'bass'|'stab'|'lead'} instrument
|
||||
* @property {number[]} notes MIDI-Töne (leer bei Schlagzeug).
|
||||
* @property {number} lengthSteps
|
||||
* @property {number} velocity 0..1
|
||||
*/
|
||||
|
||||
/**
|
||||
* Alle Ereignisse eines Schritts.
|
||||
* @param {number} step 0..TOTAL_STEPS-1 (größere Werte werden umgebrochen).
|
||||
* @returns {MusicEvent[]}
|
||||
*/
|
||||
export function eventsAtStep(step) {
|
||||
const s = ((step % TOTAL_STEPS) + TOTAL_STEPS) % TOTAL_STEPS;
|
||||
const bar = Math.floor(s / STEPS_PER_BAR);
|
||||
const pos = s % STEPS_PER_BAR;
|
||||
const root = CHORD_ROOTS[bar];
|
||||
const events = [];
|
||||
|
||||
// „Umm“ auf 1 und 3 (Bass + große Trommel), „ta“ auf 2 und 4 (Akkord + kleine Trommel).
|
||||
if (pos === 0 || pos === 8) {
|
||||
events.push({ instrument: 'kick', notes: [], lengthSteps: 2, velocity: 1 });
|
||||
events.push({ instrument: 'bass', notes: [pos === 0 ? root - 12 : root - 5], lengthSteps: 4, velocity: 0.9 });
|
||||
}
|
||||
if (pos === 4 || pos === 12) {
|
||||
events.push({ instrument: 'snare', notes: [], lengthSteps: 1, velocity: 0.8 });
|
||||
events.push({ instrument: 'stab', notes: MAJOR_TRIAD.map((i) => root + 12 + i), lengthSteps: 2, velocity: 0.8 });
|
||||
}
|
||||
// Trommelwirbel in der zweiten Hälfte des letzten Takts leitet die Wiederholung ein.
|
||||
if (bar === BARS - 1 && pos >= 8 && pos !== 12) {
|
||||
events.push({ instrument: 'snare', notes: [], lengthSteps: 1, velocity: 0.35 + (pos - 8) * 0.07 });
|
||||
}
|
||||
for (const [mBar, mStep, note, length] of MELODY) {
|
||||
if (mBar === bar && mStep === pos) {
|
||||
events.push({ instrument: 'lead', notes: [note], lengthSteps: length, velocity: 0.8 });
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequenz eines MIDI-Tons (A4 = 69 = 440 Hz).
|
||||
* @param {number} midi
|
||||
* @returns {number}
|
||||
*/
|
||||
export function midiToFrequency(midi) {
|
||||
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Kleine Vektor- und Zahlenhelfer ohne Phaser-Abhängigkeit.
|
||||
* Vektoren sind einfache Objekte `{x, y}`; die Funktionen verändern ihre Eingaben nie.
|
||||
* @module logic/math
|
||||
*/
|
||||
|
||||
/** @typedef {{x: number, y: number}} Vec2 */
|
||||
|
||||
/**
|
||||
* Begrenzt `value` auf das Intervall [min, max].
|
||||
* @param {number} value
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lineare Interpolation zwischen `a` und `b`.
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @param {number} t 0..1
|
||||
* @returns {number}
|
||||
*/
|
||||
export function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
/** @param {number} deg @returns {number} */
|
||||
export function degToRad(deg) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** @param {number} x @param {number} y @returns {Vec2} */
|
||||
export function vec(x = 0, y = 0) {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** @param {Vec2} a @param {Vec2} b @returns {Vec2} */
|
||||
export function add(a, b) {
|
||||
return { x: a.x + b.x, y: a.y + b.y };
|
||||
}
|
||||
|
||||
/** @param {Vec2} a @param {Vec2} b @returns {Vec2} */
|
||||
export function sub(a, b) {
|
||||
return { x: a.x - b.x, y: a.y - b.y };
|
||||
}
|
||||
|
||||
/** @param {Vec2} v @param {number} s @returns {Vec2} */
|
||||
export function scale(v, s) {
|
||||
return { x: v.x * s, y: v.y * s };
|
||||
}
|
||||
|
||||
/** @param {Vec2} v @returns {number} */
|
||||
export function length(v) {
|
||||
return Math.hypot(v.x, v.y);
|
||||
}
|
||||
|
||||
/** @param {Vec2} a @param {Vec2} b @returns {number} */
|
||||
export function distance(a, b) {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Einheitsvektor in Richtung `v`. Der Nullvektor bleibt der Nullvektor –
|
||||
* so muss kein Aufrufer Division durch null abfangen.
|
||||
* @param {Vec2} v
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function normalize(v) {
|
||||
const len = length(v);
|
||||
return len > 0 ? { x: v.x / len, y: v.y / len } : { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kürzt `v` auf höchstens `max` Länge, die Richtung bleibt erhalten.
|
||||
* @param {Vec2} v
|
||||
* @param {number} max
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function clampLength(v, max) {
|
||||
const len = length(v);
|
||||
return len > max && len > 0 ? scale(v, max / len) : { x: v.x, y: v.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dreht `v` um `angle` (Bogenmaß, im Uhrzeigersinn bei y nach unten wie im Canvas).
|
||||
* @param {Vec2} v
|
||||
* @param {number} angle
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function rotate(v, angle) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
return { x: v.x * cos - v.y * sin, y: v.x * sin + v.y * cos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Winkel des Vektors von `from` nach `to` (Bogenmaß, 0 = nach rechts).
|
||||
* @param {Vec2} from
|
||||
* @param {Vec2} to
|
||||
* @returns {number}
|
||||
*/
|
||||
export function angleTo(from, to) {
|
||||
return Math.atan2(to.y - from.y, to.x - from.x);
|
||||
}
|
||||
|
||||
/** @param {number} angle @returns {Vec2} Einheitsvektor für den Winkel. */
|
||||
export function fromAngle(angle) {
|
||||
return { x: Math.cos(angle), y: Math.sin(angle) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Punkt in einem Kreis liegt (inkl. Radius eines Objekts).
|
||||
* @param {Vec2} point
|
||||
* @param {Vec2} center
|
||||
* @param {number} radius
|
||||
* @param {number} [objectRadius=0] Ausdehnung des Objekts, damit große Gegner am Rand zählen.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function inCircle(point, center, radius, objectRadius = 0) {
|
||||
return distance(point, center) <= radius + objectRadius;
|
||||
}
|
||||
|
||||
/**
|
||||
* Richtungsvektor aus gedrückten Tasten; diagonal wird normalisiert, damit man
|
||||
* schräg nicht schneller läuft (klassischer 8-Wege-Fehler).
|
||||
* @param {{up: boolean, down: boolean, left: boolean, right: boolean}} keys
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function directionFromKeys(keys) {
|
||||
const x = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
|
||||
const y = (keys.down ? 1 : 0) - (keys.up ? 1 : 0);
|
||||
return normalize({ x, y });
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Deterministischer Zufallszahlengenerator.
|
||||
*
|
||||
* Warum nicht Math.random: Mit einem Seed lässt sich eine Welle reproduzieren –
|
||||
* das macht die Wellengenerierung testbar und Balancing-Fehler nachstellbar.
|
||||
* @module logic/rng
|
||||
*/
|
||||
|
||||
/** @typedef {() => number} Rng Liefert Zahlen im Intervall [0, 1). */
|
||||
|
||||
/**
|
||||
* Mulberry32: klein, schnell und für Spielzwecke gut genug verteilt.
|
||||
* @param {number} seed Beliebige Ganzzahl.
|
||||
* @returns {Rng}
|
||||
*/
|
||||
export function createRng(seed) {
|
||||
let state = seed >>> 0;
|
||||
return function next() {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ganzzahl im geschlossenen Intervall [min, max].
|
||||
* @param {Rng} rng
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
*/
|
||||
export function randInt(rng, min, max) {
|
||||
return min + Math.floor(rng() * (max - min + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gleitkommazahl im Intervall [min, max).
|
||||
* @param {Rng} rng
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
*/
|
||||
export function randFloat(rng, min, max) {
|
||||
return min + rng() * (max - min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zufälliges Element einer nicht-leeren Liste.
|
||||
* @template T
|
||||
* @param {Rng} rng
|
||||
* @param {readonly T[]} items
|
||||
* @returns {T}
|
||||
*/
|
||||
export function pick(rng, items) {
|
||||
if (items.length === 0) {
|
||||
throw new Error('pick() braucht mindestens ein Element');
|
||||
}
|
||||
return items[Math.floor(rng() * items.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed aus der aktuellen Zeit, für echte Spielrunden.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function timeSeed() {
|
||||
return (Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Punkte, Serien und Multiplikator (ohne Phaser).
|
||||
*
|
||||
* Regeln: Jeder Abschuss innerhalb von `streakWindowMs` nach dem letzten setzt die
|
||||
* Serie fort. Alle `stepKills` Abschüsse steigt der Multiplikator um `stepValue`
|
||||
* bis `maxMultiplier`. Ein Treffer am Präsidenten beendet die Serie – das belohnt
|
||||
* sauberes Spielen statt blindem Draufhalten.
|
||||
* @module logic/scoring
|
||||
*/
|
||||
|
||||
import { SCORING } from '../config.js';
|
||||
|
||||
/**
|
||||
* Multiplikator für eine Serienlänge.
|
||||
* @param {number} streak Anzahl Abschüsse in Folge (inkl. des aktuellen).
|
||||
* @param {typeof SCORING} [cfg]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function multiplierForStreak(streak, cfg = SCORING) {
|
||||
if (streak <= 0) return 1;
|
||||
return Math.min(cfg.maxMultiplier, 1 + Math.floor(streak / cfg.stepKills) * cfg.stepValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bonus für eine überstandene Welle (ohne Multiplikator).
|
||||
* @param {number} waveNumber
|
||||
* @param {typeof SCORING} [cfg]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function waveBonus(waveNumber, cfg = SCORING) {
|
||||
return waveNumber * cfg.waveBonusPerWave;
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt Punktestand, Serie und Statistik einer Spielrunde.
|
||||
*/
|
||||
export class ScoreKeeper {
|
||||
/** @param {typeof SCORING} [cfg] */
|
||||
constructor(cfg = SCORING) {
|
||||
this.cfg = cfg;
|
||||
this.score = 0;
|
||||
this.kills = 0;
|
||||
this.streak = 0;
|
||||
this.bestStreak = 0;
|
||||
this.lastKillAt = -Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft die Serie zum Zeitpunkt `now` noch?
|
||||
* @param {number} now Spielzeit in ms.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isStreakActive(now) {
|
||||
return this.streak > 0 && now - this.lastKillAt <= this.cfg.streakWindowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Multiplikator (1, wenn die Serie abgelaufen ist).
|
||||
* @param {number} now
|
||||
* @returns {number}
|
||||
*/
|
||||
multiplierAt(now) {
|
||||
return this.isStreakActive(now) ? multiplierForStreak(this.streak, this.cfg) : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbleibender Anteil des Serienfensters (1 = gerade getroffen, 0 = abgelaufen).
|
||||
* @param {number} now
|
||||
* @returns {number}
|
||||
*/
|
||||
streakTimeLeft(now) {
|
||||
if (!this.isStreakActive(now)) return 0;
|
||||
return 1 - (now - this.lastKillAt) / this.cfg.streakWindowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbucht einen Abschuss.
|
||||
* @param {number} basePoints Punkte des Gegnertyps.
|
||||
* @param {number} now Spielzeit in ms.
|
||||
* @returns {{awarded: number, multiplier: number, streak: number}}
|
||||
*/
|
||||
registerKill(basePoints, now) {
|
||||
if (!this.isStreakActive(now)) {
|
||||
this.streak = 0;
|
||||
}
|
||||
this.streak += 1;
|
||||
this.kills += 1;
|
||||
this.bestStreak = Math.max(this.bestStreak, this.streak);
|
||||
this.lastKillAt = now;
|
||||
const multiplier = multiplierForStreak(this.streak, this.cfg);
|
||||
const awarded = Math.round(basePoints * multiplier);
|
||||
this.score += awarded;
|
||||
return { awarded, multiplier, streak: this.streak };
|
||||
}
|
||||
|
||||
/** Beendet die laufende Serie (z. B. wenn der Präsident getroffen wird). */
|
||||
breakStreak() {
|
||||
this.streak = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt Bonuspunkte ohne Multiplikator hinzu.
|
||||
* @param {number} points
|
||||
* @returns {number} Tatsächlich gutgeschriebene Punkte.
|
||||
*/
|
||||
addBonus(points) {
|
||||
const awarded = Math.max(0, Math.round(points));
|
||||
this.score += awarded;
|
||||
return awarded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Momentaufnahme für HUD und Game-Over-Bildschirm.
|
||||
* @param {number} now
|
||||
*/
|
||||
snapshot(now) {
|
||||
const active = this.isStreakActive(now);
|
||||
return {
|
||||
score: this.score,
|
||||
kills: this.kills,
|
||||
streak: active ? this.streak : 0,
|
||||
multiplier: this.multiplierAt(now),
|
||||
streakTimeLeft: this.streakTimeLeft(now),
|
||||
bestStreak: this.bestStreak,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Ablaufsteuerung der Wellen (ohne Phaser): Pause → Welle → Spawns → Welle geschafft.
|
||||
*
|
||||
* Der Director kennt keine Sprites. Er ruft `spawnGroup` auf, wenn eine Gruppe
|
||||
* erscheinen soll, und fragt `aliveCount` ab, um zu erkennen, wann eine Welle
|
||||
* geschafft ist. So lässt sich der gesamte Ablauf ohne Browser testen.
|
||||
* @module logic/waveDirector
|
||||
*/
|
||||
|
||||
import { WAVES } from '../config.js';
|
||||
import { generateWave } from './waves.js';
|
||||
|
||||
/** @typedef {import('./waves.js').WavePlan} WavePlan */
|
||||
/** @typedef {import('./waves.js').SpawnGroup} SpawnGroup */
|
||||
|
||||
/**
|
||||
* @typedef {Object} WaveDirectorOptions
|
||||
* @property {import('./rng.js').Rng} rng
|
||||
* @property {(group: SpawnGroup, plan: WavePlan) => void} spawnGroup
|
||||
* @property {() => number} aliveCount
|
||||
* @property {(plan: WavePlan) => void} [onWaveStart]
|
||||
* @property {(waveNumber: number) => void} [onWaveCleared]
|
||||
* @property {typeof WAVES} [cfg]
|
||||
*/
|
||||
|
||||
export class WaveDirector {
|
||||
/** @param {WaveDirectorOptions} options */
|
||||
constructor(options) {
|
||||
this.rng = options.rng;
|
||||
this.spawnGroup = options.spawnGroup;
|
||||
this.aliveCount = options.aliveCount;
|
||||
this.onWaveStart = options.onWaveStart ?? (() => {});
|
||||
this.onWaveCleared = options.onWaveCleared ?? (() => {});
|
||||
this.cfg = options.cfg ?? WAVES;
|
||||
|
||||
/** @type {'idle'|'intermission'|'active'} */
|
||||
this.state = 'idle';
|
||||
this.waveNumber = 0;
|
||||
/** @type {WavePlan|null} */
|
||||
this.plan = null;
|
||||
this.nextWaveAt = 0;
|
||||
this.nextGroupIndex = 0;
|
||||
/**
|
||||
* Uhr der laufenden Welle. Sie steht still, solange zu viele Gegner leben –
|
||||
* so stauen sich Spawns nicht auf, wenn der Spieler in Bedrängnis ist.
|
||||
*/
|
||||
this.waveClock = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Ablauf; die erste Welle folgt nach `firstWaveDelayMs`.
|
||||
* @param {number} now Spielzeit in ms.
|
||||
*/
|
||||
start(now) {
|
||||
this.state = 'intermission';
|
||||
this.waveNumber = 0;
|
||||
this.nextWaveAt = now + this.cfg.firstWaveDelayMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Muss jeden Frame aufgerufen werden.
|
||||
* @param {number} now Spielzeit in ms.
|
||||
* @param {number} deltaMs Seit dem letzten Aufruf vergangene Spielzeit.
|
||||
*/
|
||||
update(now, deltaMs) {
|
||||
if (this.state === 'intermission' && now >= this.nextWaveAt) {
|
||||
this.beginWave();
|
||||
return;
|
||||
}
|
||||
if (this.state !== 'active' || !this.plan) return;
|
||||
|
||||
if (this.aliveCount() < this.cfg.maxAliveEnemies) {
|
||||
this.waveClock += deltaMs;
|
||||
}
|
||||
const { groups } = this.plan;
|
||||
while (this.nextGroupIndex < groups.length && groups[this.nextGroupIndex].atMs <= this.waveClock) {
|
||||
this.spawnGroup(groups[this.nextGroupIndex], this.plan);
|
||||
this.nextGroupIndex += 1;
|
||||
}
|
||||
if (this.nextGroupIndex >= groups.length && this.aliveCount() === 0) {
|
||||
const cleared = this.waveNumber;
|
||||
this.state = 'intermission';
|
||||
this.nextWaveAt = now + this.cfg.intermissionMs;
|
||||
this.onWaveCleared(cleared);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
beginWave() {
|
||||
this.waveNumber += 1;
|
||||
this.plan = generateWave(this.waveNumber, this.rng, this.cfg);
|
||||
this.nextGroupIndex = 0;
|
||||
this.waveClock = 0;
|
||||
this.state = 'active';
|
||||
this.onWaveStart(this.plan);
|
||||
}
|
||||
|
||||
/** @returns {number} Gegner, die in dieser Welle noch erscheinen werden. */
|
||||
get pendingEnemies() {
|
||||
if (this.state !== 'active' || !this.plan) return 0;
|
||||
return this.plan.groups.slice(this.nextGroupIndex).reduce((sum, g) => sum + g.count, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @returns {number} Restzeit der Pause in ms (0 während einer Welle).
|
||||
*/
|
||||
intermissionRemaining(now) {
|
||||
return this.state === 'intermission' ? Math.max(0, this.nextWaveAt - now) : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Wellengenerierung (ohne Phaser).
|
||||
*
|
||||
* Idee: Jede Welle hat ein Punktebudget, das mit der Wellennummer wächst. Gegner
|
||||
* „kosten“ Budget (config ENEMY_TYPES.cost). Der Generator kauft zuerst Eliten
|
||||
* (in jeder n-ten Welle), dann mittlere Gegner (Anteil steigt mit der Welle) und
|
||||
* füllt den Rest mit kleinen Gegnern auf. So wird es gleichmäßig schwerer, ohne
|
||||
* dass man jede Welle von Hand bauen muss.
|
||||
* @module logic/waves
|
||||
*/
|
||||
|
||||
import { ENEMY_TYPES, WAVES } from '../config.js';
|
||||
import { pick, randFloat, randInt } from './rng.js';
|
||||
|
||||
/** @typedef {import('./rng.js').Rng} Rng */
|
||||
/** @typedef {'north'|'south'|'east'|'west'} Edge */
|
||||
|
||||
/**
|
||||
* @typedef {Object} SpawnGroup
|
||||
* @property {string} type Gegnertyp-ID aus ENEMY_TYPES.
|
||||
* @property {number} count Anzahl Gegner in dieser Gruppe.
|
||||
* @property {number} atMs Spawn-Zeitpunkt relativ zum Wellenstart.
|
||||
* @property {Edge} edge Arenarand, an dem die Gruppe erscheint.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} WavePlan
|
||||
* @property {number} number
|
||||
* @property {number} budget
|
||||
* @property {string[]} factions
|
||||
* @property {SpawnGroup[]} groups Nach atMs aufsteigend sortiert.
|
||||
* @property {number} totalEnemies Summe aller Gruppen (ohne ausgebrütete Krabbler).
|
||||
* @property {number} hpMultiplier
|
||||
* @property {number} speedMultiplier
|
||||
*/
|
||||
|
||||
/** @type {readonly Edge[]} */
|
||||
export const EDGES = Object.freeze(['north', 'south', 'east', 'west']);
|
||||
|
||||
/** Anteil des Budgets, der auch in Elite-Wellen für Fußvolk reserviert bleibt. */
|
||||
const MIN_FODDER_SHARE = 0.3;
|
||||
|
||||
/** Position der Eliten im Spawn-Ablauf (0 = zuerst, 1 = zuletzt): nicht sofort, nicht am Ende. */
|
||||
const ELITE_TIMELINE_POSITION = 0.4;
|
||||
|
||||
/**
|
||||
* Budget der Welle `n`.
|
||||
* @param {number} n Wellennummer ab 1.
|
||||
* @param {typeof WAVES} [cfg]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function waveBudget(n, cfg = WAVES) {
|
||||
return Math.round(cfg.firstBudget + cfg.budgetGrowth * Math.pow(n - 1, cfg.budgetExponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Fraktionen in Welle `n` angreifen.
|
||||
* @param {number} n
|
||||
* @param {typeof WAVES} [cfg]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function waveFactions(n, cfg = WAVES) {
|
||||
if (n < cfg.legionFromWave) return ['swarm'];
|
||||
if (n < cfg.mixedFromWave) return ['legion'];
|
||||
return ['swarm', 'legion'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzahl der Elite-Gegner in Welle `n`.
|
||||
* @param {number} n
|
||||
* @param {typeof WAVES} [cfg]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function eliteCount(n, cfg = WAVES) {
|
||||
if (n % cfg.eliteEvery !== 0) return 0;
|
||||
return Math.min(cfg.maxElitesPerWave, n / cfg.eliteEvery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lebenspunkte- und Tempo-Multiplikatoren für späte Wellen.
|
||||
* @param {number} n
|
||||
* @param {typeof WAVES} [cfg]
|
||||
* @returns {{hp: number, speed: number}}
|
||||
*/
|
||||
export function waveMultipliers(n, cfg = WAVES) {
|
||||
const steps = Math.max(0, n - cfg.scalingFromWave + 1);
|
||||
return {
|
||||
hp: 1 + steps * cfg.hpScalePerWave,
|
||||
speed: Math.min(cfg.speedScaleMax, 1 + steps * cfg.speedScalePerWave),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Typ-IDs einer Fraktion und Stufe.
|
||||
* @param {string} faction
|
||||
* @param {'small'|'medium'|'elite'} tier
|
||||
* @param {typeof ENEMY_TYPES} [types]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function typesOf(faction, tier, types = ENEMY_TYPES) {
|
||||
return Object.values(types)
|
||||
.filter((t) => t.faction === faction && t.tier === tier)
|
||||
.map((t) => t.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kauft Gruppen einer Stufe, bis das Budget nicht mehr reicht.
|
||||
* @returns {{groups: {type: string, count: number}[], leftover: number}}
|
||||
*/
|
||||
function buyGroups(rng, factions, tier, budget, types) {
|
||||
const groups = [];
|
||||
let remaining = budget;
|
||||
for (;;) {
|
||||
const affordable = factions
|
||||
.flatMap((f) => typesOf(f, tier, types))
|
||||
.filter((id) => types[id].cost <= remaining);
|
||||
if (affordable.length === 0) break;
|
||||
const type = types[pick(rng, affordable)];
|
||||
const maxAffordable = Math.floor(remaining / type.cost);
|
||||
const count = Math.min(randInt(rng, type.groupSize.min, type.groupSize.max), maxAffordable);
|
||||
groups.push({ type: type.id, count });
|
||||
remaining -= count * type.cost;
|
||||
}
|
||||
return { groups, leftover: remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mischt eine Liste (Fisher-Yates) mit dem übergebenen Zufallsgenerator.
|
||||
* @template T
|
||||
* @param {Rng} rng
|
||||
* @param {T[]} items
|
||||
* @returns {T[]}
|
||||
*/
|
||||
function shuffled(rng, items) {
|
||||
const copy = items.slice();
|
||||
for (let i = copy.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt den Plan für Welle `n`.
|
||||
* @param {number} n Wellennummer ab 1.
|
||||
* @param {Rng} rng Zufallsgenerator (für Tests mit festem Seed).
|
||||
* @param {typeof WAVES} [cfg]
|
||||
* @param {typeof ENEMY_TYPES} [types]
|
||||
* @returns {WavePlan}
|
||||
*/
|
||||
export function generateWave(n, rng, cfg = WAVES, types = ENEMY_TYPES) {
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new RangeError(`Ungültige Wellennummer: ${n}`);
|
||||
}
|
||||
const budget = waveBudget(n, cfg);
|
||||
const factions = waveFactions(n, cfg);
|
||||
|
||||
// Eliten zuerst – sie sind der Höhepunkt der Welle. In gemischten Wellen wechselt
|
||||
// die Fraktion der Eliten von Elite-Welle zu Elite-Welle (erste Elite-Welle:
|
||||
// Schwärmer, weil die Wellen davor der Blechlegion gehörten).
|
||||
const elites = [];
|
||||
let remaining = budget;
|
||||
const eliteRound = n / cfg.eliteEvery - 1;
|
||||
for (let i = 0; i < eliteCount(n, cfg); i += 1) {
|
||||
const faction = factions[(eliteRound + i) % factions.length];
|
||||
const eliteType = typesOf(faction, 'elite', types)[0];
|
||||
elites.push({ type: eliteType, count: 1 });
|
||||
remaining -= types[eliteType].cost;
|
||||
}
|
||||
// Auch neben Eliten braucht es Fußvolk, sonst wirkt die Welle leer.
|
||||
remaining = Math.max(remaining, Math.ceil(budget * MIN_FODDER_SHARE));
|
||||
|
||||
const mediumShare = Math.min(
|
||||
cfg.mediumShareMax,
|
||||
cfg.mediumShareBase + cfg.mediumSharePerWave * (n - 1),
|
||||
);
|
||||
const medium = buyGroups(rng, factions, 'medium', Math.floor(remaining * mediumShare), types);
|
||||
const smallBudget = remaining - Math.floor(remaining * mediumShare) + medium.leftover;
|
||||
const small = buyGroups(rng, factions, 'small', smallBudget, types);
|
||||
|
||||
// Reihenfolge: Fußvolk gemischt, Eliten etwa bei 40 % des Ablaufs.
|
||||
const ordered = shuffled(rng, [...small.groups, ...medium.groups]);
|
||||
const eliteIndex = Math.floor(ordered.length * ELITE_TIMELINE_POSITION);
|
||||
ordered.splice(eliteIndex, 0, ...elites);
|
||||
|
||||
// Spätere Wellen kommen dichter hintereinander.
|
||||
const delayFactor = Math.max(cfg.groupDelayMinFactor, 1 - cfg.groupDelayFactorPerWave * (n - 1));
|
||||
let atMs = 0;
|
||||
const groups = ordered.map((group, index) => {
|
||||
if (index > 0) {
|
||||
atMs += Math.round(randFloat(rng, cfg.groupDelayMs.min, cfg.groupDelayMs.max) * delayFactor);
|
||||
}
|
||||
return { ...group, atMs, edge: pick(rng, EDGES) };
|
||||
});
|
||||
|
||||
const multipliers = waveMultipliers(n, cfg);
|
||||
return {
|
||||
number: n,
|
||||
budget,
|
||||
factions,
|
||||
groups,
|
||||
totalEnemies: groups.reduce((sum, g) => sum + g.count, 0),
|
||||
hpMultiplier: multipliers.hp,
|
||||
speedMultiplier: multipliers.speed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Zufälliger Spawnpunkt am gegebenen Arenarand, knapp innerhalb der Welt.
|
||||
* @param {Edge} edge
|
||||
* @param {{width: number, height: number}} world
|
||||
* @param {number} margin Abstand zum Rand.
|
||||
* @param {Rng} rng
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
export function spawnPointOnEdge(edge, world, margin, rng) {
|
||||
const along = (size) => randFloat(rng, margin, size - margin);
|
||||
switch (edge) {
|
||||
case 'north':
|
||||
return { x: along(world.width), y: margin };
|
||||
case 'south':
|
||||
return { x: along(world.width), y: world.height - margin };
|
||||
case 'west':
|
||||
return { x: margin, y: along(world.height) };
|
||||
case 'east':
|
||||
return { x: world.width - margin, y: along(world.height) };
|
||||
default:
|
||||
throw new RangeError(`Unbekannter Rand: ${edge}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Waffenzustand: Magazin, Reserve, Feuerrate, Nachladen (ohne Phaser).
|
||||
* @module logic/weapon
|
||||
*/
|
||||
|
||||
import { degToRad } from './math.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} WeaponDef
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {number} magazineSize
|
||||
* @property {number|null} reserveStart null = unbegrenzt
|
||||
* @property {number|null} reserveMax null = unbegrenzt
|
||||
* @property {number} fireIntervalMs
|
||||
* @property {number} reloadMs
|
||||
* @property {number} damage
|
||||
* @property {number} pellets
|
||||
* @property {number} spreadDeg
|
||||
* @property {number} bulletSpeed
|
||||
* @property {number} rangePx
|
||||
* @property {boolean} automatic
|
||||
*/
|
||||
|
||||
export class WeaponState {
|
||||
/** @param {WeaponDef} def */
|
||||
constructor(def) {
|
||||
this.def = def;
|
||||
this.magazine = def.magazineSize;
|
||||
/** @type {number} Bei unbegrenzter Reserve Infinity. */
|
||||
this.reserve = def.reserveStart === null ? Infinity : def.reserveStart;
|
||||
/** Spielzeit, zu der das Nachladen fertig ist; 0 = lädt nicht nach. */
|
||||
this.reloadingUntil = 0;
|
||||
this.reloadStartedAt = 0;
|
||||
this.lastShotAt = -Infinity;
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
get hasInfiniteReserve() {
|
||||
return this.def.reserveMax === null;
|
||||
}
|
||||
|
||||
/** @returns {boolean} Weder Magazin noch Reserve – Waffe ist leer. */
|
||||
get isEmpty() {
|
||||
return this.magazine === 0 && this.reserve === 0;
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
get isReloading() {
|
||||
return this.reloadingUntil > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schließt ein fälliges Nachladen ab. Muss jeden Frame aufgerufen werden.
|
||||
* @param {number} now
|
||||
* @returns {boolean} true, wenn in diesem Aufruf fertig nachgeladen wurde.
|
||||
*/
|
||||
update(now) {
|
||||
if (this.isReloading && now >= this.reloadingUntil) {
|
||||
const needed = this.def.magazineSize - this.magazine;
|
||||
const taken = Math.min(needed, this.reserve);
|
||||
this.magazine += taken;
|
||||
this.reserve -= taken;
|
||||
this.reloadingUntil = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canFire(now) {
|
||||
return !this.isReloading && this.magazine > 0 && now - this.lastShotAt >= this.def.fireIntervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feuert einen Schuss, falls möglich.
|
||||
* @param {number} now
|
||||
* @param {() => number} rng Zufall für die Streuung.
|
||||
* @returns {number[]|null} Winkelabweichungen der Projektile (Bogenmaß) oder null.
|
||||
*/
|
||||
fire(now, rng) {
|
||||
if (!this.canFire(now)) return null;
|
||||
this.magazine -= 1;
|
||||
this.lastShotAt = now;
|
||||
const offsets = pelletOffsets(this.def.pellets, this.def.spreadDeg, rng);
|
||||
// Leeres Magazin lädt automatisch nach – niemand will im Gefecht R suchen.
|
||||
if (this.magazine === 0) this.startReload(now);
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beginnt das Nachladen.
|
||||
* @param {number} now
|
||||
* @returns {boolean} false, wenn es nichts nachzuladen gibt.
|
||||
*/
|
||||
startReload(now) {
|
||||
if (this.isReloading || this.magazine >= this.def.magazineSize || this.reserve <= 0) {
|
||||
return false;
|
||||
}
|
||||
this.reloadStartedAt = now;
|
||||
this.reloadingUntil = now + this.def.reloadMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Bricht das Nachladen ab (z. B. beim Waffenwechsel); nichts wird verbraucht. */
|
||||
cancelReload() {
|
||||
this.reloadingUntil = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @returns {number} 0..1, 1 wenn nicht nachgeladen wird.
|
||||
*/
|
||||
reloadProgress(now) {
|
||||
if (!this.isReloading) return 1;
|
||||
return Math.min(1, (now - this.reloadStartedAt) / this.def.reloadMs);
|
||||
}
|
||||
|
||||
/** Füllt die Reserve bis zum Maximum auf (Nachschubkapsel). */
|
||||
refill() {
|
||||
if (!this.hasInfiniteReserve) {
|
||||
this.reserve = /** @type {number} */ (this.def.reserveMax);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streuung der Projektile eines Schusses. Mehrere Projektile (Schrot) werden
|
||||
* gleichmäßig über den Fächer verteilt und leicht verwackelt, damit sich das Muster
|
||||
* nicht wiederholt; ein Einzelprojektil streut zufällig innerhalb des Fächers.
|
||||
* @param {number} pellets
|
||||
* @param {number} spreadDeg
|
||||
* @param {() => number} rng
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function pelletOffsets(pellets, spreadDeg, rng) {
|
||||
const spread = degToRad(spreadDeg);
|
||||
if (pellets <= 1) {
|
||||
return [(rng() - 0.5) * spread];
|
||||
}
|
||||
const step = spread / (pellets - 1);
|
||||
return Array.from({ length: pellets }, (_, i) => -spread / 2 + i * step + (rng() - 0.5) * step * 0.5);
|
||||
}
|
||||
Reference in New Issue
Block a user