defmodule Raxol.Demo.Particles do @moduledoc """ Particle system for animated demo effects. Supports sparkles, explosions, trails, and floating elements. """ @sparkle_chars ~w(* . + ' `) @star_chars ["*", ".", "+", "o"] @trail_chars ["█", "▓", "▒", "░"] # Size-based character sets @small_chars [".", "'", "`", ","] @medium_chars ["*", "+", "o", "x"] @large_chars ["@", "#", "O", "*"] # Afterimage trail characters (bright to dim) @afterimage_chars [".", "'", "`"] @cyan_palette [51, 50, 49, 44, 45] @magenta_palette [201, 200, 199, 164, 165] @gold_palette [220, 221, 222, 228, 229] @white_palette [255, 254, 253, 252, 251] @red_palette [196, 197, 198, 199, 200] @type particle :: %{ :x => float(), :y => float(), :vx => float(), :vy => float(), :char => String.t(), :color => non_neg_integer(), :life => integer(), :history => list({float(), float()}), :generation => non_neg_integer(), :spawn_at_life => non_neg_integer(), optional(:phase) => float() } @doc """ Creates a new particle at the given position. """ @spec create(float(), float(), keyword()) :: particle() def create(x, y, opts \\ []) do %{ x: x, y: y, vx: Keyword.get(opts, :vx, (:rand.uniform() - 0.5) * 2), vy: Keyword.get(opts, :vy, -:rand.uniform() * 2), char: Keyword.get(opts, :char, Enum.random(@sparkle_chars)), color: Keyword.get(opts, :color, random_color()), life: Keyword.get(opts, :life, 20 + :rand.uniform(30)), history: Keyword.get(opts, :history, []), generation: Keyword.get(opts, :generation, 0), spawn_at_life: Keyword.get(opts, :spawn_at_life, 0) } end @doc """ Creates a particle with size-based character selection. Size can be :small, :medium, or :large. """ @spec create_sized(float(), float(), atom(), keyword()) :: particle() def create_sized(x, y, size, opts \\ []) do chars = case size do :small -> @small_chars :medium -> @medium_chars :large -> @large_chars _ -> @medium_chars end create(x, y, Keyword.put(opts, :char, Enum.random(chars))) end @doc """ Creates a particle with trail tracking enabled. """ @spec create_with_trail(float(), float(), keyword()) :: particle() def create_with_trail(x, y, opts \\ []) do create(x, y, Keyword.put(opts, :history, [{x, y}])) end @doc """ Creates a sparkle particle (rising, fading). """ @spec create_sparkle(float(), float()) :: particle() def create_sparkle(x, y) do create(x, y, vx: (:rand.uniform() - 0.5) * 0.5, vy: -0.3 - :rand.uniform() * 0.5, char: Enum.random(@sparkle_chars), color: Enum.random(@white_palette ++ @cyan_palette), life: 30 + :rand.uniform(40) ) end @doc """ Creates an explosion particle (radiates outward). """ @spec create_explosion(float(), float()) :: particle() def create_explosion(x, y) do angle = :rand.uniform() * 2 * :math.pi() speed = 0.5 + :rand.uniform() * 1.5 create(x, y, vx: :math.cos(angle) * speed, vy: :math.sin(angle) * speed * 0.5, char: Enum.random(@star_chars), color: Enum.random(@gold_palette ++ @magenta_palette), life: 15 + :rand.uniform(25) ) end @doc """ Creates an explosion particle with size distribution. 70% small, 25% medium, 5% large. """ @spec create_sized_explosion(float(), float()) :: particle() def create_sized_explosion(x, y) do size = case :rand.uniform(100) do n when n <= 70 -> :small n when n <= 95 -> :medium _ -> :large end angle = :rand.uniform() * 2 * :math.pi() speed = 0.5 + :rand.uniform() * 1.5 create_sized(x, y, size, vx: :math.cos(angle) * speed, vy: :math.sin(angle) * speed * 0.5, color: Enum.random(@gold_palette ++ @magenta_palette), life: 15 + :rand.uniform(25) ) end @doc """ Creates a cascading explosion particle that spawns children. """ @spec create_cascade_explosion(float(), float(), integer()) :: particle() def create_cascade_explosion(x, y, generation) do angle = :rand.uniform() * 2 * :math.pi() speed = 0.8 + :rand.uniform() * 1.2 life = 20 + :rand.uniform(15) spawn_at = div(life, 2) create(x, y, vx: :math.cos(angle) * speed, vy: :math.sin(angle) * speed * 0.5, char: Enum.random(@star_chars), color: Enum.random(@gold_palette ++ @magenta_palette ++ @red_palette), life: life, generation: generation, spawn_at_life: spawn_at, history: [{x, y}] ) end @doc """ Creates a trail particle (slow fade, minimal movement). """ @spec create_trail(float(), float(), non_neg_integer()) :: particle() def create_trail(x, y, color) do create(x, y, vx: 0, vy: 0, char: Enum.at(@trail_chars, :rand.uniform(4) - 1), color: color, life: 5 + :rand.uniform(10) ) end @doc """ Creates a rain particle (falls down). """ @spec create_rain(float(), float(), non_neg_integer()) :: particle() def create_rain(x, y, color) do create(x, y, vx: (:rand.uniform() - 0.5) * 0.2, vy: 0.3 + :rand.uniform() * 0.3, char: Enum.random([".", "|", ":", "'"]), color: color, life: 40 + :rand.uniform(20) ) end @doc """ Creates a rain particle with phase for sine wave motion. """ @spec create_rain_with_phase(float(), float(), non_neg_integer(), float()) :: particle() def create_rain_with_phase(x, y, color, phase) do p = create_rain(x, y, color) Map.put(p, :phase, phase) end @doc """ Creates a ring of particles expanding outward. Y is compressed for terminal aspect ratio. """ @spec create_ring_burst(float(), float(), integer(), keyword()) :: list(particle()) def create_ring_burst(center_x, center_y, count \\ 24, opts \\ []) do speed = Keyword.get(opts, :speed, 1.5) color = Keyword.get(opts, :color, random_color()) life = Keyword.get(opts, :life, 25) char = Keyword.get(opts, :char, "*") for i <- 0..(count - 1) do angle = i / count * 2 * :math.pi() create(center_x, center_y, vx: :math.cos(angle) * speed, vy: :math.sin(angle) * speed * 0.5, char: char, color: color, life: life + :rand.uniform(10), history: [{center_x, center_y}] ) end end @doc """ Creates a rising rocket particle with trail. """ @spec create_rocket(float(), float(), keyword()) :: particle() def create_rocket(x, y, opts \\ []) do target_y = Keyword.get(opts, :target_y, 5) color = Keyword.get(opts, :color, random_color()) create(x, y, vx: (:rand.uniform() - 0.5) * 0.3, vy: -1.2 - :rand.uniform() * 0.5, char: "|", color: color, life: round((y - target_y) * 1.2), history: [{x, y}] ) end @doc """ Updates a particle's position and life. """ @spec update(particle(), keyword()) :: particle() def update(particle, opts \\ []) do gravity = Keyword.get(opts, :gravity, 0.05) friction = Keyword.get(opts, :friction, 0.98) %{ particle | x: particle.x + particle.vx, y: particle.y + particle.vy, vx: particle.vx * friction, vy: particle.vy + gravity, life: particle.life - 1 } end @doc """ Updates particle without gravity (for floating effects). """ @spec update_float(particle()) :: particle() def update_float(particle) do update(particle, gravity: 0, friction: 1.0) end @doc """ Updates particle and maintains position history for trail effect. Keeps last 4 positions. """ @spec update_with_trail(particle(), keyword()) :: particle() def update_with_trail(particle, opts \\ []) do history = Map.get(particle, :history, []) new_history = [{particle.x, particle.y} | Enum.take(history, 3)] particle |> update(opts) |> Map.put(:history, new_history) end @doc """ Updates cascading particles and returns {updated_particle, spawned_children}. Call this when you need cascade behavior. """ @spec update_cascade(particle(), keyword()) :: {particle(), list(particle())} def update_cascade(particle, opts \\ []) do updated = update_with_trail(particle, opts) generation = Map.get(particle, :generation, 0) spawn_at = Map.get(particle, :spawn_at_life, 0) children = if generation > 0 and updated.life == spawn_at do for _ <- 1..(3 + :rand.uniform(2)) do create_cascade_explosion(updated.x, updated.y, generation - 1) end else [] end {updated, children} end @doc """ Renders particles to a frame buffer, returns ANSI escape sequences. """ @spec render(list(particle()), non_neg_integer(), non_neg_integer()) :: String.t() def render(particles, width, height) do particles |> Enum.filter(fn p -> p.life > 0 and p.x >= 0 and p.x < width and p.y >= 0 and p.y < height end) |> Enum.map_join(fn p -> x = trunc(p.x) + 1 y = trunc(p.y) + 1 alpha = min(1.0, p.life / 20) color_code = if alpha > 0.5, do: p.color, else: dim_color(p.color) "\e[#{y};#{x}H\e[38;5;#{color_code}m#{p.char}\e[0m" end) end @doc """ Renders particles with their trail history. Trail chars fade from bright to dim. """ @spec render_with_trails( list(particle()), non_neg_integer(), non_neg_integer() ) :: String.t() def render_with_trails(particles, width, height) do trail_output = particles |> Enum.flat_map(&render_particle_trail(&1, width, height)) |> Enum.join("") trail_output <> render(particles, width, height) end defp render_particle_trail(particle, width, height) do color = dim_color(dim_color(particle.color)) particle |> Map.get(:history, []) |> Enum.with_index() |> Enum.map(fn {{hx, hy}, idx} -> render_trail_point(hx, hy, idx, color, width, height) end) end defp render_trail_point(hx, hy, idx, color, width, height) when hx >= 0 and hx < width and hy >= 0 and hy < height do x = trunc(hx) + 1 y = trunc(hy) + 1 char = Enum.at(@afterimage_chars, min(idx, length(@afterimage_chars) - 1)) "\e[#{y};#{x}H\e[38;5;#{color}m#{char}\e[0m" end defp render_trail_point(_hx, _hy, _idx, _color, _width, _height), do: "" @doc """ Filters out dead particles. """ @spec prune(list(particle())) :: list(particle()) def prune(particles) do Enum.filter(particles, fn p -> p.life > 0 end) end @doc """ Returns a random color from the available palettes. """ @spec random_color() :: non_neg_integer() def random_color do Enum.random( @cyan_palette ++ @magenta_palette ++ @gold_palette ++ @white_palette ) end @doc """ Returns a random color from a specific palette. """ @spec palette_color(atom()) :: non_neg_integer() def palette_color(:cyan), do: Enum.random(@cyan_palette) def palette_color(:magenta), do: Enum.random(@magenta_palette) def palette_color(:gold), do: Enum.random(@gold_palette) def palette_color(:white), do: Enum.random(@white_palette) def palette_color(:red), do: Enum.random(@red_palette) defp dim_color(color) when color > 240, do: color - 3 defp dim_color(color), do: max(232, color - 2) end