forces/Vortex.js

import { Particle } from '../core/Particle.js';

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

/**
 * @import { ForceConfig } from './Force.js'
 */

/**
 * Vortex force configuration options.
 * Includes all properties from {@link ForceConfig}.
 * @typedef {object} VortexConfig
 * @property {number} [x=0] Horizontal coordinate of the vortex center.
 * @property {number} [y=0] Vertical coordinate of the vortex center.
 * @property {number} [rotationSpeed=100] Rotation speed. Positive values for clockwise, negative values for counter-clockwise rotation.
 * @property {number} [suctionSpeed=50] Inward suction speed. Positive values pull inward, negative values push outward.
 * @property {number} [radius=Infinity] Maximum radius of influence. Particles outside this distance are unaffected.
 * @property {number} [cullingRadius=0] Distance threshold from the center below which particles are marked dead, surrounded
 * by a frame-rate independent soft-aging buffer zone to prevent visual popping. Note that high velocities or long lifespans
 * may cause particles to mathematically bypass the center and slingshot outward. In such cases, increase this radius to
 * intercept particles before they reach their escape brink. Omit or set to 0 to disable. Stored internally as
 * {@link Vortex#cullingRadius} and as a squared value in {@link Vortex#cullingRadiusSquared}.
 */

/**
 * Environmental force that applies a swirling combination of radial attraction/repulsion and tangential (orbital) forces to particles.
 * @class
 * @extends Force
 */
export class Vortex extends Force {
    /**
     * Horizontal coordinate of the vortex center.
     * @type {number}
     */
    x;

    /**
     * Vertical coordinate of the vortex center.
     * @type {number}
     */
    y;

    /**
     * Strength of the rotational force. Positive values rotate clockwise, negative values rotate counter-clockwise.
     * @type {number}
     */
    rotationSpeed;

    /**
     * Strength of the radial attraction/repulsion. Positive values pull inward, negative values push outward.
     * @type {number}
     */
    suctionSpeed;

    /**
     * Radius of influence. Particles outside this distance are unaffected.
     * @type {number}
     */
    radius;

    /**
     * Distance threshold from the center below which particles are marked dead, surrounded by a frame-rate independent
     * soft-aging buffer zone to prevent visual popping. Note that high velocities or long lifespans may cause particles
     * to mathematically bypass the center and slingshot outward. In such cases, increase this radius to intercept
     * particles before they reach their escape brink. Omit or set to 0 to disable.
     * @type {number}
     */
    cullingRadius;

    /**
     * Culling radius stored internally as a squared value for high-performance comparisons.
     * @type {number}
     */
    cullingRadiusSquared;

    /**
     * Initializes a vortex force with a center point, rotation speed, and suction speed.
     * @constructor
     * @param {VortexConfig} [config={}] Vortex configuration options.
     */
    constructor(config = {}) {
        super(config);

        this.x = config.x ?? 0;
        this.y = config.y ?? 0;
        this.rotationSpeed = config.rotationSpeed ?? 100;
        this.suctionSpeed = config.suctionSpeed ?? 50;
        this.radius = config.radius ?? Infinity;

        this.cullingRadius = config.cullingRadius ?? 0;
        this.cullingRadiusSquared = this.cullingRadius * this.cullingRadius;
    }

    /**
     * Applies a swirling combination of centripetal (suction) and tangential (orbital) forces to a particle.
     * @override
     * @param {Particle} particle Particle instance to affect.
     * @param {number} dt Time elapsed since the last frame (in seconds).
     * @returns {void}
     */
    apply(particle, dt) {
        const dx = this.x - particle.x;
        const dy = this.y - particle.y;
        const distanceSq = dx * dx + dy * dy;

        // Influence radius exit check
        if (this.radius !== Infinity && distanceSq > this.radius * this.radius) {
            return;
        }

        // Inside dead zone check
        if (this.cullingRadiusSquared > 0 && distanceSq <= this.cullingRadiusSquared) {
            particle.age = particle.lifespan;
            particle.alive = false;
            return;
        }

        // Singularity gate check
        if (distanceSq < 0.01) {
            return;
        }

        const distance = Math.sqrt(distanceSq);

        // Soft-aging buffer zone
        if (this.cullingRadiusSquared > 0) {
            const agingZoneThickness = this.cullingRadius * 0.5;
            const agingThreshold = this.cullingRadius + agingZoneThickness;

            if (distance < agingThreshold) {
                const proximityFactor = 1.0 - ((distance - this.cullingRadius) / agingZoneThickness);
                particle.age += dt * proximityFactor * 5.0;
            }
        }

        // Calculate directional and tangential vectors
        const nx = dx / distance;
        const ny = dy / distance;
        const tx = -ny;
        const ty = nx;

        // Apply velocity update
        particle.vx += (nx * this.suctionSpeed + tx * this.rotationSpeed) * dt;
        particle.vy += (ny * this.suctionSpeed + ty * this.rotationSpeed) * dt;
    }
}