modifiers/path/SineWave.js

import { Particle } from '../../core/Particle.js';
import { ModifierCategory } from '../../shared/Constants.js';

import { Modifier } from '../Modifier.js';

/**
 * @import { ModifierConfig } from './Modifier.js';
 */

/**
 * SineWave configuration options.
 * Includes all properties from {@link ModifierConfig}.
 * @typedef {object} SineWaveConfig
 * @property {number|number[]} [amplitude=10] Wave amplitude (in pixels), or a [start, end] range array interpolated over lifespan.
 * @property {number|number[]} [frequency=2] Cycles per second (Hz), or a [start, end] range array interpolated over lifespan.
 */

/**
 * Path modifier that applies a perpendicular sine-wave displacement relative to the particle's current movement direction.
 * @class
 * @extends Modifier
 */
export class SineWave extends Modifier {
    // Skipped @override because it fails on static members in TypeScript
    /**
     * Gets the architectural category of the modifier.
     * Used by emitters to sort modifiers into specialized update loops (e.g., visual vs. path).
     * @ignore
     * @type {string}
     * @returns {string}
     */
    static get category() {
        return ModifierCategory.PATH;
    }

    /**
     * Magnitude of displacement (in pixels) at particle emission.
     * @type {number}
     */
    startAmplitude;

    /**
     * Magnitude of displacement (in pixels) at particle death.
     * @type {number}
     */
    endAmplitude;

    /**
     * Cycles per second (Hz) at particle emission.
     * @type {number}
     */
    startFrequency;

    /**
     * Cycles per second (Hz) at particle death.
     * @type {number}
     */
    endFrequency;

    /**
     * Initializes a sine wave path modifier.
     * @constructor
     * @param {SineWaveConfig} [config={}] SineWave configuration options.
     */
    constructor(config = {}) {
        super(config);

        const amplitude = config.amplitude ?? 10;

        if (Array.isArray(amplitude)) {
            this.startAmplitude = amplitude[0];
            this.endAmplitude = amplitude[1];
        } else {
            this.startAmplitude = amplitude;
            this.endAmplitude = amplitude;
        }

        const frequency = config.frequency ?? 2;

        if (Array.isArray(frequency)) {
            this.startFrequency = frequency[0];
            this.endFrequency = frequency[1];
        } else {
            this.startFrequency = frequency;
            this.endFrequency = frequency;
        }
    }

    /**
     * Offsets the particle's position along a wave axis perpendicular to the particle's current movement direction.
     * @override
     * @param {Particle} particle Particle instance to affect.
     * @param {number} normalizedAge Normalized age of the particle (0.0 = emitted, 1.0 = dead).
     * @param {number} dt Frame time step in seconds.
     * @returns {void}
     */
    update(particle, normalizedAge, dt) {
        if (particle.vx === 0 && particle.vy === 0) {
            return;
        }

        const amplitude = this.startAmplitude + (this.endAmplitude - this.startAmplitude) * normalizedAge;
        const frequency = this.startFrequency + (this.endFrequency - this.startFrequency) * normalizedAge;

        if (amplitude === 0 || frequency === 0) {
            return;
        }

        const movementAngle = Math.atan2(particle.vy, particle.vx);
        const perpendicularAngle = movementAngle + (Math.PI / 2);
        const currentPhase = particle.age * frequency * Math.PI * 2;
        const waveValue = Math.cos(currentPhase) * (amplitude * frequency * Math.PI * 2);

        particle.x += Math.cos(perpendicularAngle) * waveValue * dt;
        particle.y += Math.sin(perpendicularAngle) * waveValue * dt;
    }
}