/**
 * squishyProceduralAnimator.ts
 * ------------------------------------------------------------------------
 * AppalachAI Studios -- Asset Forge :: Squishy Procedural Animator (Three.js)
 * ------------------------------------------------------------------------
 *
 * A little hand-built character squish-rig for single-mesh assets. No
 * skeleton, no bones, no external tween/animation libraries -- just honest
 * math on an Object3D's scale, position, and rotation. Drop it next to any
 * Three.js model you forged and it'll keep the little fella feeling alive,
 * springy, and full of personality.
 *
 * Made in the hills, animated by hand. We aim for charm and good feel rather
 * than physical accuracy -- tune the numbers until it makes you smile.
 *
 * Animation model (single_mesh_procedural):
 *   - scale     -> squash & stretch (non-uniform; volume-ish preserved)
 *   - position.y-> bob / bounce
 *   - rotation.z-> jelly wobble
 * The base transform is captured on construction and every motion is layered
 * relative to it, so calling stop() always restores exactly what you started
 * with.
 *
 * Engine-parity note: this mirrors the shared AppalachAI procedural animator
 * surface (playIdle / playSquish / playStretch / playWobble / playCelebrate /
 * stop) so the same direction reads the same way across every engine export.
 *
 * ------------------------------------------------------------------------
 * USAGE
 * ------------------------------------------------------------------------
 *   import * as THREE from 'three';
 *   import { SquishyProceduralAnimator } from './squishyProceduralAnimator';
 *
 *   const mesh = new THREE.Mesh(geometry, material);
 *   scene.add(mesh);
 *
 *   // One animator per character; it captures the base transform right here.
 *   const squishy = new SquishyProceduralAnimator(mesh);
 *   squishy.playIdle();
 *
 *   // On a tap / hit:
 *   squishy.playSquish(0.8);
 *
 *   // On a jump / launch (elongate upward, then settle):
 *   squishy.playStretch(new THREE.Vector3(0, 1, 0));
 *
 *   // After a big landing, let it jiggle:
 *   squishy.playWobble(0.6);
 *
 *   // On a reward / pickup, tinted by rarity:
 *   squishy.playCelebrate('legendary');
 *
 *   // Render loop:
 *   const clock = new THREE.Clock();
 *   function animate() {
 *     requestAnimationFrame(animate);
 *     squishy.update(clock.getDelta());
 *     renderer.render(scene, camera);
 *   }
 *   animate();
 *
 *   // When you're done:
 *   squishy.stop();      // restores the captured base transform
 *   squishy.dispose();   // drops any spawned celebrate sparkles
 *
 * ------------------------------------------------------------------------
 * All sample IP (c) AppalachAI Studios. Family-friendly. Tinker freely.
 * ------------------------------------------------------------------------
 */

import * as THREE from 'three';

/* ========================================================================
 * Public types
 * ===================================================================== */

/** Rarity tiers used to flavor the celebrate burst (color + energy). */
export type Rarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';

/** The kind of motion currently driving a target. */
export type SquishyMotion =
  | 'none'
  | 'idle'
  | 'squish'
  | 'stretch'
  | 'wobble'
  | 'celebrate';

/**
 * Tuning for the idle "alive" loop. Every field is optional; sensible,
 * gentle defaults fill in the gaps. Amplitudes are fractions of the base
 * size (e.g. 0.04 == a 4% squash).
 */
export interface IdleProfile {
  /** Vertical bob height as a fraction of the base height. Default 0.05. */
  bobAmplitude?: number;
  /** Squash/stretch depth as a fraction of base scale. Default 0.04. */
  squashAmplitude?: number;
  /** Full bob cycles per second. Default 0.6 (slow, breathing). */
  bobFrequency?: number;
  /** Squash cycles per second. Default 1.2 (twice the bob, feels lively). */
  squashFrequency?: number;
  /** Tiny side-to-side sway in radians on rotation.z. Default 0.015. */
  swayAmplitude?: number;
}

/**
 * Optional hooks so the host package can react to AppalachAI motions without
 * us reaching into anyone's particle system. All optional.
 */
export interface SquishyCallbacks {
  /**
   * Fired once per celebrate when sparkles should appear. If you don't supply
   * this, the animator spawns its own lightweight point-sprite burst.
   * @param origin World-ish local origin (the target's base position).
   * @param color  Rarity-tinted color for the burst.
   * @param count  Suggested particle count for the rarity.
   */
  onSparkle?: (origin: THREE.Vector3, color: THREE.Color, count: number) => void;
  /** Fired when any motion finishes naturally (not on stop()). */
  onMotionComplete?: (motion: SquishyMotion) => void;
}

/* ========================================================================
 * Internal constants
 * ===================================================================== */

const TWO_PI = Math.PI * 2;

/** Rarity -> celebrate flavor (warm Appalachian sunset-to-aurora ramp). */
const RARITY_TABLE: Record<
  Rarity,
  { color: number; bounces: number; sparkles: number; energy: number }
> = {
  common: { color: 0x9fb4a7, bounces: 2, sparkles: 8, energy: 0.5 },
  uncommon: { color: 0x6fcf97, bounces: 3, sparkles: 14, energy: 0.65 },
  rare: { color: 0x56a0ff, bounces: 3, sparkles: 22, energy: 0.8 },
  epic: { color: 0xb06fff, bounces: 4, sparkles: 32, energy: 0.9 },
  legendary: { color: 0xffb74d, bounces: 5, sparkles: 48, energy: 1.0 },
};

/* ========================================================================
 * Easing helpers (hand-rolled -- no tween library)
 * ===================================================================== */

/**
 * Smooth, symmetric ease in and out. Great for breathing / looping motion.
 * @param t Normalized time, 0..1.
 * @returns Eased value, 0..1.
 */
export function easeInOutSine(t: number): number {
  return -(Math.cos(Math.PI * t) - 1) / 2;
}

/**
 * Springy overshoot that settles -- the classic "boing". Perfect for the
 * rebound after a squash.
 * @param t Normalized time, 0..1.
 * @returns Eased value, typically 0..1 with overshoot near the end.
 */
export function easeOutElastic(t: number): number {
  if (t <= 0) return 0;
  if (t >= 1) return 1;
  const period = 0.3;
  const amplitude = 1;
  const s = (period / TWO_PI) * Math.asin(1 / amplitude);
  return (
    amplitude *
      Math.pow(2, -10 * t) *
      Math.sin(((t - s) * TWO_PI) / period) +
    1
  );
}

/**
 * Decelerating ease -- quick out of the gate, gentle landing. Used for the
 * stretch elongation and as a general settle curve.
 * @param t Normalized time, 0..1.
 * @returns Eased value, 0..1.
 */
export function easeOutCubic(t: number): number {
  const inv = 1 - t;
  return 1 - inv * inv * inv;
}

/** Clamp a number to a range. */
function clamp(value: number, min: number, max: number): number {
  return value < min ? min : value > max ? max : value;
}

/** Clamp to 0..1 -- intensities and rarities live here a lot. */
function clamp01(value: number): number {
  return clamp(value, 0, 1);
}

/* ========================================================================
 * SquishyProceduralAnimator
 * ===================================================================== */

/**
 * Procedural squash-and-stretch animator for a single skeleton-free
 * Object3D. Construct one per character, call a play* method to kick off a
 * motion, and pump update(deltaSeconds) every frame.
 *
 * The animator owns no schedule of its own beyond the active motion: idle
 * loops forever until you start something else or call stop(); one-shot
 * motions (squish/stretch/wobble/celebrate) run their course and then either
 * return to idle (if idle was active before) or settle to the base pose.
 */
export class SquishyProceduralAnimator {
  /** The character this animator drives. */
  public readonly target: THREE.Object3D;

  /** Captured base transform -- everything animates relative to these. */
  private readonly baseScale: THREE.Vector3;
  private readonly basePosition: THREE.Vector3;
  private readonly baseRotationZ: number;

  /** Approximate base height in local units, for bob scaling. */
  private readonly baseHeight: number;

  /** Optional host hooks. */
  private readonly callbacks: SquishyCallbacks;

  /** What's currently playing. */
  private motion: SquishyMotion = 'none';

  /** Whether idle should resume after a one-shot motion finishes. */
  private idleQueued = false;

  /** Resolved idle settings (filled with defaults on playIdle). */
  private idle: Required<IdleProfile> = {
    bobAmplitude: 0.05,
    squashAmplitude: 0.04,
    bobFrequency: 0.6,
    squashFrequency: 1.2,
    swayAmplitude: 0.015,
  };

  /** Free-running clock for the looping idle motion (seconds). */
  private idleClock = 0;

  /** One-shot motion bookkeeping. */
  private motionTime = 0;
  private motionDuration = 0;
  private intensity = 0;

  /** Stretch direction, normalized into local space (defaults to up). */
  private readonly stretchDir = new THREE.Vector3(0, 1, 0);

  /** Celebrate bookkeeping. */
  private celebrateBounces = 1;
  private rarityEnergy = 0.5;

  /** Self-managed sparkle system (only created if we need it). */
  private sparkles: THREE.Points | null = null;
  private sparkleVel: Float32Array | null = null;
  private sparkleLife = 0;
  private sparkleMaxLife = 0;

  /** Scratch vectors to avoid per-frame allocation. */
  private readonly tmpScale = new THREE.Vector3();
  private readonly tmpVec = new THREE.Vector3();

  /**
   * @param target    A single, skeleton-free Object3D (Mesh or Group root).
   * @param callbacks Optional sparkle / completion hooks.
   */
  constructor(target: THREE.Object3D, callbacks: SquishyCallbacks = {}) {
    this.target = target;
    this.callbacks = callbacks;

    // Capture the base transform on first use -- this is our home pose.
    this.baseScale = target.scale.clone();
    this.basePosition = target.position.clone();
    this.baseRotationZ = target.rotation.z;

    // Estimate a base height so bob amplitude reads the same on any model.
    this.baseHeight = this.measureHeight(target);
  }

  /* ------------------------------------------------------------------ *
   * Public API -- the shared AppalachAI procedural surface
   * ------------------------------------------------------------------ */

  /**
   * Gentle looping bounce with a subtle squash -- the "this character is
   * alive and breathing" baseline. Loops until another motion replaces it.
   * @param profile Optional tuning; omitted fields use warm defaults.
   */
  public playIdle(profile?: IdleProfile): void {
    this.idle = {
      bobAmplitude: profile?.bobAmplitude ?? 0.05,
      squashAmplitude: profile?.squashAmplitude ?? 0.04,
      bobFrequency: profile?.bobFrequency ?? 0.6,
      squashFrequency: profile?.squashFrequency ?? 1.2,
      swayAmplitude: profile?.swayAmplitude ?? 0.015,
    };
    this.motion = 'idle';
    this.idleQueued = true;
    // Keep idleClock running so we don't snap -- idle is continuous.
  }

  /**
   * Fast squash followed by a springy elastic rebound -- the tap / hit /
   * stomp reaction. Squashes flat-ish on the way in, boings back on the way
   * out.
   * @param intensity 0..1; how hard the squash hits (depth + speed).
   */
  public playSquish(intensity = 0.7): void {
    this.intensity = clamp01(intensity);
    this.motion = 'squish';
    this.motionTime = 0;
    // Harder hits resolve a touch faster; all land near a satisfying ~0.5s.
    this.motionDuration = 0.55 - 0.1 * this.intensity;
  }

  /**
   * Elongate along a direction (jump / launch) then ease back home. The
   * dominant axis of the direction decides whether we stretch tall, wide, or
   * deep; volume is roughly conserved by pinching the other axes.
   * @param direction Local-space direction to stretch along. Defaults to up.
   * @param intensity 0..1; how far it elongates. Default 0.7.
   */
  public playStretch(
    direction?: THREE.Vector3,
    intensity = 0.7,
  ): void {
    if (direction && direction.lengthSq() > 1e-8) {
      this.stretchDir.copy(direction).normalize();
    } else {
      this.stretchDir.set(0, 1, 0);
    }
    this.intensity = clamp01(intensity);
    this.motion = 'stretch';
    this.motionTime = 0;
    this.motionDuration = 0.6;
  }

  /**
   * Damped jelly oscillation side to side -- the jiggle that sells a big
   * landing or a heavy squish settling down.
   * @param intensity 0..1; starting amplitude of the wobble.
   */
  public playWobble(intensity = 0.6): void {
    this.intensity = clamp01(intensity);
    this.motion = 'wobble';
    this.motionTime = 0;
    this.motionDuration = 1.1 + 0.6 * this.intensity;
  }

  /**
   * Excited multi-bounce celebration with a rarity-tinted sparkle burst --
   * the reward / level-up / pickup moment. Bigger rarities bounce more and
   * sparkle brighter.
   * @param rarity Tier flavoring color, bounce count, and sparkle energy.
   */
  public playCelebrate(rarity: Rarity = 'common'): void {
    const flavor = RARITY_TABLE[rarity] ?? RARITY_TABLE.common;
    this.celebrateBounces = flavor.bounces;
    this.rarityEnergy = flavor.energy;
    this.intensity = flavor.energy;
    this.motion = 'celebrate';
    this.motionTime = 0;
    this.motionDuration = 0.42 * flavor.bounces;

    // Tinted sparkle burst -- host hook first, else our own little system.
    const color = new THREE.Color(flavor.color);
    const origin = this.basePosition;
    if (this.callbacks.onSparkle) {
      this.callbacks.onSparkle(origin.clone(), color, flavor.sparkles);
    } else {
      this.spawnSparkles(color, flavor.sparkles);
    }
  }

  /**
   * Cancel any active motion and restore the captured base transform exactly.
   * Idle is not resumed -- the character returns home and waits.
   */
  public stop(): void {
    this.motion = 'none';
    this.idleQueued = false;
    this.motionTime = 0;
    this.killSparkles();
    this.restoreBase();
  }

  /**
   * Advance all active motion. Call once per frame from your render loop.
   * @param deltaSeconds Seconds elapsed since the previous update.
   */
  public update(deltaSeconds: number): void {
    // Guard against pauses / tab-switch spikes so motion stays well-behaved.
    const dt = clamp(deltaSeconds, 0, 0.1);

    this.idleClock += dt;
    if (this.sparkles) this.updateSparkles(dt);

    switch (this.motion) {
      case 'idle':
        this.applyIdle();
        break;
      case 'squish':
        this.updateOneShot(dt, () => this.applySquish());
        break;
      case 'stretch':
        this.updateOneShot(dt, () => this.applyStretch());
        break;
      case 'wobble':
        this.updateOneShot(dt, () => this.applyWobble());
        break;
      case 'celebrate':
        this.updateOneShot(dt, () => this.applyCelebrate());
        break;
      case 'none':
      default:
        break;
    }
  }

  /**
   * Current motion label -- handy for host UI / debugging.
   */
  public getMotion(): SquishyMotion {
    return this.motion;
  }

  /**
   * Release any animator-owned resources (the self-spawned sparkle system).
   * Does not touch the target mesh's own geometry/material.
   */
  public dispose(): void {
    this.killSparkles();
  }

  /* ------------------------------------------------------------------ *
   * One-shot driver
   * ------------------------------------------------------------------ */

  /**
   * Tick a one-shot motion, apply its current pose, and resolve completion.
   * @param dt    Clamped delta seconds.
   * @param apply Pose function for this motion at the current motionTime.
   */
  private updateOneShot(dt: number, apply: () => void): void {
    this.motionTime += dt;
    apply();

    if (this.motionTime >= this.motionDuration) {
      const finished = this.motion;
      // Return to idle if it was the resting state, else go home.
      if (this.idleQueued) {
        this.motion = 'idle';
      } else {
        this.motion = 'none';
        this.restoreBase();
      }
      this.callbacks.onMotionComplete?.(finished);
    }
  }

  /* ------------------------------------------------------------------ *
   * Pose functions -- each writes scale / position.y / rotation.z
   * ------------------------------------------------------------------ */

  /** Continuous breathing bob + subtle squash + tiny sway. */
  private applyIdle(): void {
    const t = this.idleClock;
    const bob = Math.sin(t * TWO_PI * this.idle.bobFrequency);
    const squash = Math.sin(t * TWO_PI * this.idle.squashFrequency);
    const sway = Math.sin(t * TWO_PI * this.idle.bobFrequency * 0.5);

    // Squash: shorter -> wider, taller -> thinner. Volume-ish preserved.
    const sQuash = squash * this.idle.squashAmplitude;
    const scaleY = 1 - sQuash;
    const scaleXZ = 1 + sQuash * 0.5;

    this.tmpScale.set(scaleXZ, scaleY, scaleXZ);
    this.writeScale(this.tmpScale);

    // Bob rides up on the upstroke of the squash for a lively pop.
    const bobOffset = Math.abs(bob) * this.idle.bobAmplitude * this.baseHeight;
    this.target.position.y = this.basePosition.y + bobOffset;

    this.target.rotation.z = this.baseRotationZ + sway * this.idle.swayAmplitude;
  }

  /** Fast squash in, elastic boing out. */
  private applySquish(): void {
    const p = clamp01(this.motionTime / this.motionDuration);
    const depth = 0.45 * this.intensity; // up to a 45% flatten

    // First ~30%: drive down quickly. Remainder: elastic rebound to base.
    let squash: number;
    if (p < 0.3) {
      squash = easeOutCubic(p / 0.3) * depth;
    } else {
      const r = (p - 0.3) / 0.7;
      squash = depth * (1 - easeOutElastic(r));
    }

    const scaleY = 1 - squash;
    const scaleXZ = 1 + squash * 0.6;
    this.tmpScale.set(scaleXZ, scaleY, scaleXZ);
    this.writeScale(this.tmpScale);

    // Sink a touch into the squash, then it rides back up with the rebound.
    const sink = squash * 0.5 * this.baseHeight;
    this.target.position.y = this.basePosition.y - sink;
    this.target.rotation.z = this.baseRotationZ;
  }

  /** Elongate along the dominant axis of stretchDir, then ease home. */
  private applyStretch(): void {
    const p = clamp01(this.motionTime / this.motionDuration);
    const amount = 0.5 * this.intensity; // up to a 50% elongation

    // Out fast (first 35%), settle back with an elastic kiss.
    let stretch: number;
    if (p < 0.35) {
      stretch = easeOutCubic(p / 0.35) * amount;
    } else {
      const r = (p - 0.35) / 0.65;
      stretch = amount * (1 - easeOutElastic(r));
    }

    // Pick the dominant local axis from the requested direction.
    const ax = Math.abs(this.stretchDir.x);
    const ay = Math.abs(this.stretchDir.y);
    const az = Math.abs(this.stretchDir.z);
    const pinch = stretch * 0.5; // squeeze the off-axes to conserve volume

    let sx = 1 - pinch;
    let sy = 1 - pinch;
    let sz = 1 - pinch;
    if (ay >= ax && ay >= az) {
      sy = 1 + stretch;
    } else if (ax >= ay && ax >= az) {
      sx = 1 + stretch;
    } else {
      sz = 1 + stretch;
    }

    this.tmpScale.set(sx, sy, sz);
    this.writeScale(this.tmpScale);

    // A vertical stretch lifts the body a little as it reaches.
    const lift = this.stretchDir.y * stretch * 0.5 * this.baseHeight;
    this.target.position.y = this.basePosition.y + lift;
    this.target.rotation.z = this.baseRotationZ;
  }

  /** Damped sinusoidal jelly wobble on rotation.z with a hint of squash. */
  private applyWobble(): void {
    const t = this.motionTime;
    const p = clamp01(t / this.motionDuration);

    const amp = 0.35 * this.intensity; // radians at the start
    const frequency = 6; // wobbles per second-ish
    const damping = Math.exp(-3.2 * p); // fade the jiggle out
    const wave = Math.sin(t * TWO_PI * frequency);

    this.target.rotation.z = this.baseRotationZ + wave * amp * damping;

    // A faint squash that breathes with the wobble keeps it gelatinous.
    const squash = wave * 0.06 * this.intensity * damping;
    this.tmpScale.set(1 + squash * 0.5, 1 - squash, 1 + squash * 0.5);
    this.writeScale(this.tmpScale);

    this.target.position.y = this.basePosition.y;
  }

  /** Excited series of decaying bounces with a little squash on each landing. */
  private applyCelebrate(): void {
    const p = clamp01(this.motionTime / this.motionDuration);

    // A train of bounces whose height tapers over the celebration.
    const phase = p * this.celebrateBounces;
    const local = phase - Math.floor(phase); // 0..1 within current bounce
    const decay = 1 - p * 0.45; // later bounces are smaller
    const hop = Math.sin(local * Math.PI); // up-and-down arc

    const height = hop * 0.4 * this.rarityEnergy * decay * this.baseHeight;
    this.target.position.y = this.basePosition.y + height;

    // Squash at the bottom of each hop (when hop is near 0), stretch at apex.
    const squash = (0.5 - hop * 0.5) * 0.18 * this.rarityEnergy;
    const stretchUp = hop * 0.12 * this.rarityEnergy;
    const scaleY = 1 - squash + stretchUp;
    const scaleXZ = 1 + squash * 0.7 - stretchUp * 0.5;
    this.tmpScale.set(scaleXZ, scaleY, scaleXZ);
    this.writeScale(this.tmpScale);

    // A happy little waggle.
    const waggle = Math.sin(phase * TWO_PI) * 0.05 * this.rarityEnergy * decay;
    this.target.rotation.z = this.baseRotationZ + waggle;
  }

  /* ------------------------------------------------------------------ *
   * Transform helpers
   * ------------------------------------------------------------------ */

  /** Multiply a relative (1-centered) scale factor onto the base scale. */
  private writeScale(relative: THREE.Vector3): void {
    this.target.scale.set(
      this.baseScale.x * relative.x,
      this.baseScale.y * relative.y,
      this.baseScale.z * relative.z,
    );
  }

  /** Snap scale / position / rotation.z back to the captured base pose. */
  private restoreBase(): void {
    this.target.scale.copy(this.baseScale);
    this.target.position.copy(this.basePosition);
    this.target.rotation.z = this.baseRotationZ;
  }

  /**
   * Estimate the target's base height in local units (pre-scale) so bob
   * amplitudes feel consistent across differently-sized models. Falls back to
   * 1 for empty / unmeasurable objects.
   */
  private measureHeight(obj: THREE.Object3D): number {
    const box = new THREE.Box3();
    try {
      box.setFromObject(obj);
      if (box.isEmpty()) return 1;
      const size = box.getSize(this.tmpVec);
      // Divide out current Y scale so we store height in unscaled local units.
      const sy = Math.abs(obj.scale.y) > 1e-6 ? Math.abs(obj.scale.y) : 1;
      const h = size.y / sy;
      return h > 1e-4 ? h : 1;
    } catch {
      return 1;
    }
  }

  /* ------------------------------------------------------------------ *
   * Self-managed sparkle burst (used only when no onSparkle hook is given)
   * ------------------------------------------------------------------ */

  /** Build (or reuse) a tiny point-sprite burst tinted by rarity. */
  private spawnSparkles(color: THREE.Color, count: number): void {
    this.killSparkles();

    const positions = new Float32Array(count * 3);
    const velocities = new Float32Array(count * 3);
    const origin = this.basePosition;
    const spread = 0.5 + this.rarityEnergy * 0.8;

    for (let i = 0; i < count; i++) {
      const i3 = i * 3;
      positions[i3] = origin.x;
      positions[i3 + 1] = origin.y + this.baseHeight * 0.5;
      positions[i3 + 2] = origin.z;

      // Random upward-biased cone -- a cheerful little fountain.
      const theta = Math.random() * TWO_PI;
      const speed = (0.6 + Math.random() * 0.9) * spread;
      velocities[i3] = Math.cos(theta) * speed * 0.6;
      velocities[i3 + 1] = (0.8 + Math.random() * 0.8) * speed;
      velocities[i3 + 2] = Math.sin(theta) * speed * 0.6;
    }

    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

    const material = new THREE.PointsMaterial({
      color,
      size: 0.12 + this.rarityEnergy * 0.1,
      transparent: true,
      opacity: 1,
      depthWrite: false,
      blending: THREE.AdditiveBlending,
    });

    const points = new THREE.Points(geometry, material);
    points.name = 'AppalachAI_CelebrateSparkles';

    // Park the burst on the target's parent so it sits in the same space.
    const host = this.target.parent ?? this.target;
    host.add(points);

    this.sparkles = points;
    this.sparkleVel = velocities;
    this.sparkleMaxLife = 0.9 + this.rarityEnergy * 0.5;
    this.sparkleLife = 0;
  }

  /** Advance the self-managed sparkles: gravity, drift, fade, retire. */
  private updateSparkles(dt: number): void {
    if (!this.sparkles || !this.sparkleVel) return;

    this.sparkleLife += dt;
    const geom = this.sparkles.geometry as THREE.BufferGeometry;
    const attr = geom.getAttribute('position') as THREE.BufferAttribute;
    const pos = attr.array as Float32Array;
    const vel = this.sparkleVel;
    const gravity = 2.2;

    for (let i = 0; i < pos.length; i += 3) {
      vel[i + 1] -= gravity * dt;
      pos[i] += vel[i] * dt;
      pos[i + 1] += vel[i + 1] * dt;
      pos[i + 2] += vel[i + 2] * dt;
    }
    attr.needsUpdate = true;

    const lifeRatio = clamp01(this.sparkleLife / this.sparkleMaxLife);
    const mat = this.sparkles.material as THREE.PointsMaterial;
    mat.opacity = 1 - lifeRatio;

    if (this.sparkleLife >= this.sparkleMaxLife) {
      this.killSparkles();
    }
  }

  /** Remove + free the self-managed sparkle system, if any. */
  private killSparkles(): void {
    if (!this.sparkles) return;
    this.sparkles.parent?.remove(this.sparkles);
    this.sparkles.geometry.dispose();
    (this.sparkles.material as THREE.Material).dispose();
    this.sparkles = null;
    this.sparkleVel = null;
    this.sparkleLife = 0;
    this.sparkleMaxLife = 0;
  }
}

export default SquishyProceduralAnimator;
