diff --git a/README.md b/README.md index 1472a73..95f6de1 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ git log --oneline -10 | ttfx matrix ## Credit where it's due **This is a port of [TerminalTextEffects](https://github.com/ChrisBuilds/terminaltexteffects) -(TTE) by [ChrisBuilds](https://github.com/ChrisBuilds).** Every effect, the animation engine, -and the command-line interface are their design — this project translates that work to Rust -and adds nothing of its own to the art. If you like what you see here, star the original. +(TTE) by [ChrisBuilds](https://github.com/ChrisBuilds).** The animation engine, command-line +interface, and 37 original effects are their design — this project translates that work to Rust. +The additional `airstrike`, `automata`, `bubblepop`, `malfunction`, `reverselife`, `roses`, +`sunshower`, and `voronoi` effects are native to ttfx. If you like what you see here, star the +original. TTE is MIT licensed and so is this port; the original copyright is preserved in [LICENSE](LICENSE) and [NOTICE](NOTICE). Please file *effect* ideas upstream, where they belong. @@ -51,8 +53,10 @@ command, best of five. ## The effects -All 37, each animating the Omarchy logo. Every frame below came out of the Rust binary — and is -byte-identical to what the Python original produces from the same input and seed. +All 45, each animating the Omarchy logo. Every frame below came out of the Rust binary. The 37 +ported effects are byte-identical to what the Python original produces from the same input and seed; +`airstrike`, `automata`, `bubblepop`, `malfunction`, `reverselife`, `roses`, `sunshower`, and +`voronoi` are ttfx originals. | | | |:---:|:---:| @@ -74,7 +78,11 @@ byte-identical to what the Python original produces from the same input and seed | sweep
sweep
Sweep across the canvas to reveal uncolored text, reverse sweep to color the text | synthgrid
synthgrid
Create a grid which fills with characters dissolving into the final text | | thunderstorm
thunderstorm
Create a thunderstorm in the terminal | unstable
unstable
Spawn characters jumbled, explode them to the edge of the canvas, then reassemble them in the correct layout | | vhstape
vhstape
Lines of characters glitch left and right and lose detail like an old VHS tape | waves
waves
Waves travel across the terminal leaving behind the characters | -| wipe
wipe
Wipes the text across the terminal to reveal characters | | +| wipe
wipe
Wipes the text across the terminal to reveal characters | airstrike
airstrike
ASCII planes dive into the text, scattering it through fire and debris before it reassembles | +| automata
automata
An elementary cellular automaton grows a fractal lattice whose cells stream into the text | malfunction
malfunction
A printer types nonsense, recovers happily, and prints readable text | +| reverselife
reverselife
Conway's Game of Life runs backward from chaos and resolves into readable text | roses
roses
Curling vines grow pink roses, scatter petals, and flower into the text | +| sunshower
sunshower
Rain falls as the sun rises and paints a rainbow across the text | voronoi
voronoi
Living crystal grows from the text, breathes as a faceted Voronoi mosaic, then returns as readable lettering | +| bubblepop
bubblepop
Rainbow-shimmering bubbles float upward, pop, and reveal the text | | Every effect takes its own options — `ttfx --help`. A few of the GIFs above shorten a timed phase so the loop stays watchable (`matrix --rain-time 3`, `thunderstorm --storm-time 3`, @@ -115,7 +123,7 @@ no interpreter to load them. ``` | ttfx [terminal options] [effect options] -ttfx --help # all 37 effects and the terminal options +ttfx --help # all 44 effects and the terminal options ttfx --help # options for one effect ttfx --random-effect # surprise me (--include-effects / --exclude-effects to filter) ttfx --print-completion bash|zsh diff --git a/docs/effects/airstrike.gif b/docs/effects/airstrike.gif new file mode 100644 index 0000000..d06437e Binary files /dev/null and b/docs/effects/airstrike.gif differ diff --git a/docs/effects/automata.gif b/docs/effects/automata.gif new file mode 100644 index 0000000..4628127 Binary files /dev/null and b/docs/effects/automata.gif differ diff --git a/docs/effects/bubblepop.gif b/docs/effects/bubblepop.gif new file mode 100644 index 0000000..1e7193b Binary files /dev/null and b/docs/effects/bubblepop.gif differ diff --git a/docs/effects/malfunction.gif b/docs/effects/malfunction.gif new file mode 100644 index 0000000..c1d0f91 Binary files /dev/null and b/docs/effects/malfunction.gif differ diff --git a/docs/effects/reverselife.gif b/docs/effects/reverselife.gif new file mode 100644 index 0000000..4a2b0dc Binary files /dev/null and b/docs/effects/reverselife.gif differ diff --git a/docs/effects/roses.gif b/docs/effects/roses.gif new file mode 100644 index 0000000..8f6d5d8 Binary files /dev/null and b/docs/effects/roses.gif differ diff --git a/docs/effects/sunshower.gif b/docs/effects/sunshower.gif new file mode 100644 index 0000000..81d15e0 Binary files /dev/null and b/docs/effects/sunshower.gif differ diff --git a/docs/effects/voronoi.gif b/docs/effects/voronoi.gif new file mode 100644 index 0000000..ee70a29 Binary files /dev/null and b/docs/effects/voronoi.gif differ diff --git a/src/effects/airstrike.rs b/src/effects/airstrike.rs new file mode 100644 index 0000000..f7fdefb --- /dev/null +++ b/src/effects/airstrike.rs @@ -0,0 +1,449 @@ +//! Airstrike: ASCII planes dive into the input, scattering its glyphs through +//! fire and debris before the text pulls itself back together. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_positive_float, parse_positive_int, +}; +use crate::engine::animation::ExistingColorHandling; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct AirstrikeConfig { + /// Number of planes which strike the text. + #[arg(long = "plane-count", default_value_t = 6, value_parser = parse_positive_int)] + pub plane_count: i64, + + /// Plane travel speed in terminal cells per frame. + #[arg(long = "flight-speed", default_value_t = 0.55, value_parser = parse_positive_float)] + pub flight_speed: f64, + + /// Frames between successive plane launches. + #[arg(long = "launch-delay", default_value_t = 50, value_parser = parse_non_negative_int)] + pub launch_delay: i64, + + /// Radius, in terminal cells, affected by each impact. + #[arg(long = "blast-radius", default_value_t = 20, value_parser = parse_positive_int)] + pub blast_radius: i64, + + /// Frames the text glyphs spend flying outward as debris. + #[arg(long = "debris-duration", default_value_t = 70, value_parser = parse_positive_int)] + pub debris_duration: i64, + + /// Frames the scattered text takes to reassemble. + #[arg(long = "reassembly-duration", default_value_t = 100, value_parser = parse_positive_int)] + pub reassembly_duration: i64, + + /// Frames to hold the restored text after the smoke clears. + #[arg(long = "final-hold-time", default_value_t = 70, value_parser = parse_non_negative_int)] + pub final_hold_time: i64, + + /// Number of fire and shrapnel particles emitted by each impact. + #[arg(long = "fire-particles", default_value_t = 52, value_parser = parse_positive_int)] + pub fire_particles: i64, + + /// Color of the incoming aircraft. + #[arg(long = "plane-color", default_value = "b8c4d6", value_parser = parse_color)] + pub plane_color: Color, + + /// Space separated list of colors used by the fireball. + #[arg(long = "fire-colors", num_args = 1.., value_parser = parse_color, + default_values = ["fff3a3", "ffb000", "ff4d00", "7a1f00"])] + pub fire_colors: Vec, + + /// Space separated list of colors for the restored text gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ff5f00", "ffd166", "fff3b0"])] + pub final_gradient_stops: Vec, + + /// Number of gradient steps to use. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["12"])] + pub final_gradient_steps: Vec, + + /// Direction of the restored text gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +struct Plane { + parts: Vec<(CharId, Coord)>, + start: Coord, + target: Coord, + launch_frame: i64, + impact_frame: i64, + impacted: bool, +} + +struct Debris { + id: CharId, + home: Coord, + impact: usize, + velocity_x: f64, + velocity_y: f64, +} + +struct FireParticle { + id: CharId, + impact: usize, + velocity_x: f64, + velocity_y: f64, + lifetime: i64, +} + +struct TrailParticle { + id: CharId, + born: i64, + origin_x: f64, + origin_y: f64, + velocity_x: f64, + velocity_y: f64, + lifetime: i64, +} + +pub struct Airstrike { + config: AirstrikeConfig, + planes: Vec, + debris: Vec, + fire: Vec, + trails: Vec, + final_colors: HashMap, + frame: i64, + end_frame: i64, +} + +impl Airstrike { + pub fn new(config: AirstrikeConfig) -> Self { + Self { + config, + planes: Vec::new(), + debris: Vec::new(), + fire: Vec::new(), + trails: Vec::new(), + final_colors: HashMap::new(), + frame: 0, + end_frame: 0, + } + } + + fn set_visual(ctx: &mut EngineCtx, id: CharId, symbol: &str, color: Color) { + let uses_pre = ctx.terminal.arena[id.0 as usize].uses_input_preexisting_colors; + ctx.terminal.arena[id.0 as usize].animation.set_appearance( + symbol, + uses_pre, + Some(symbol), + Some(ColorPair::new(Some(color), None)), + ); + } + + fn impact(&mut self, ctx: &mut EngineCtx, plane_index: usize) { + let target = self.planes[plane_index].target; + for &(id, _) in &self.planes[plane_index].parts { + ctx.terminal.set_character_visibility(id, false); + } + self.planes[plane_index].impacted = true; + + for piece in self.debris.iter_mut().filter(|piece| piece.impact == plane_index) { + let dx = piece.home.column - target.column; + let dy = piece.home.row - target.row; + let distance = ((dx * dx + dy * dy) as f64).sqrt().max(1.0); + let force = (1.0 - distance / (self.config.blast_radius as f64 * 1.35)).clamp(0.15, 1.0); + piece.velocity_x = dx as f64 / distance * (0.18 + force * 0.45) + ctx.rng.uniform(-0.18, 0.18); + piece.velocity_y = dy as f64 / distance * (0.12 + force * 0.30) + ctx.rng.uniform(0.18, 0.55); + } + } + + fn update_planes(&mut self, ctx: &mut EngineCtx) { + for index in 0..self.planes.len() { + if self.frame < self.planes[index].launch_frame || self.planes[index].impacted { + continue; + } + if self.frame >= self.planes[index].impact_frame { + self.impact(ctx, index); + continue; + } + let plane = &self.planes[index]; + let duration = (plane.impact_frame - plane.launch_frame).max(1); + let progress = (self.frame - plane.launch_frame) as f64 / duration as f64; + let column = plane.start.column as f64 + (plane.target.column - plane.start.column) as f64 * progress; + let row = plane.start.row as f64 + (plane.target.row - plane.start.row) as f64 * progress; + for &(id, offset) in &plane.parts { + ctx.terminal.arena[id.0 as usize] + .motion + .set_coordinate(Coord::new(column.round() as i64 + offset.column, row.round() as i64 + offset.row)); + ctx.terminal.set_character_visibility(id, true); + } + + if (self.frame - plane.launch_frame) % 3 == 0 { + let direction = if plane.start.column < plane.target.column { -1.0 } else { 1.0 }; + let origin_x = column + direction * 3.6; + let origin_y = row + ctx.rng.uniform(-0.25, 0.25); + let id = ctx.terminal.add_character("*", Coord::new(origin_x.round() as i64, origin_y.round() as i64)); + ctx.terminal.arena[id.0 as usize].layer = 2; + self.trails.push(TrailParticle { + id, + born: self.frame, + origin_x, + origin_y, + velocity_x: direction * ctx.rng.uniform(0.03, 0.11), + velocity_y: ctx.rng.uniform(0.01, 0.08), + lifetime: ctx.rng.randint(24, 43), + }); + } + } + } + + fn update_trails(&mut self, ctx: &mut EngineCtx) { + for particle in &self.trails { + let age = self.frame - particle.born; + if age < 0 || age >= particle.lifetime { + ctx.terminal.set_character_visibility(particle.id, false); + continue; + } + let t = age as f64; + ctx.terminal.arena[particle.id.0 as usize].motion.set_coordinate(Coord::new( + (particle.origin_x + particle.velocity_x * t).round() as i64, + (particle.origin_y + particle.velocity_y * t + 0.003 * t * t).round() as i64, + )); + let ratio = age as f64 / particle.lifetime as f64; + let symbol = if ratio < 0.25 { + "*" + } else if ratio < 0.65 { + "+" + } else { + "." + }; + let color_index = + ((ratio * self.config.fire_colors.len() as f64) as usize).min(self.config.fire_colors.len() - 1); + Self::set_visual(ctx, particle.id, symbol, self.config.fire_colors[color_index]); + ctx.terminal.set_character_visibility(particle.id, true); + } + } + + fn update_debris(&mut self, ctx: &mut EngineCtx) { + let hot = self.config.fire_colors[1.min(self.config.fire_colors.len() - 1)]; + let soot = Color::from_hex("6b625f").unwrap(); + for piece in &self.debris { + let impact_frame = self.planes[piece.impact].impact_frame; + let age = self.frame - impact_frame; + if age < 0 { + continue; + } + let input_symbol = ctx.terminal.arena[piece.id.0 as usize].input_symbol.clone(); + if age < self.config.debris_duration { + let t = age as f64; + let coord = Coord::new( + (piece.home.column as f64 + piece.velocity_x * t).round() as i64, + (piece.home.row as f64 + piece.velocity_y * t - 0.010 * t * t).round() as i64, + ); + ctx.terminal.arena[piece.id.0 as usize].motion.set_coordinate(coord); + let color = if age < self.config.debris_duration / 3 { hot } else { soot }; + Self::set_visual(ctx, piece.id, &input_symbol, color); + } else if age < self.config.debris_duration + self.config.reassembly_duration { + let elapsed = age - self.config.debris_duration; + let t = elapsed as f64 / self.config.reassembly_duration as f64; + let eased = 1.0 - (1.0 - t).powi(3); + let blast_t = self.config.debris_duration as f64; + let from_col = piece.home.column as f64 + piece.velocity_x * blast_t; + let from_row = piece.home.row as f64 + piece.velocity_y * blast_t - 0.010 * blast_t * blast_t; + let coord = Coord::new( + (from_col + (piece.home.column as f64 - from_col) * eased).round() as i64, + (from_row + (piece.home.row as f64 - from_row) * eased).round() as i64, + ); + ctx.terminal.arena[piece.id.0 as usize].motion.set_coordinate(coord); + let colors = self.final_colors.get(&piece.id).cloned().unwrap_or_default(); + let uses_pre = ctx.terminal.arena[piece.id.0 as usize].uses_input_preexisting_colors; + ctx.terminal.arena[piece.id.0 as usize].animation.set_appearance( + &input_symbol, + uses_pre, + Some(&input_symbol), + Some(colors), + ); + } else { + ctx.terminal.arena[piece.id.0 as usize].motion.set_coordinate(piece.home); + } + } + } + + fn update_fire(&mut self, ctx: &mut EngineCtx) { + for particle in &self.fire { + let impact_frame = self.planes[particle.impact].impact_frame; + let age = self.frame - impact_frame; + if age < 0 || age >= particle.lifetime { + ctx.terminal.set_character_visibility(particle.id, false); + continue; + } + let target = self.planes[particle.impact].target; + let t = age as f64; + let coord = Coord::new( + (target.column as f64 + particle.velocity_x * t).round() as i64, + (target.row as f64 + particle.velocity_y * t - 0.025 * t * t).round() as i64, + ); + ctx.terminal.arena[particle.id.0 as usize].motion.set_coordinate(coord); + let ratio = age as f64 / particle.lifetime as f64; + let color_index = + ((ratio * self.config.fire_colors.len() as f64) as usize).min(self.config.fire_colors.len() - 1); + let symbol = if ratio < 0.2 { + "*" + } else if ratio < 0.65 { + "+" + } else { + "." + }; + Self::set_visual(ctx, particle.id, symbol, self.config.fire_colors[color_index]); + ctx.terminal.set_character_visibility(particle.id, true); + } + } +} + +impl EffectHooks for Airstrike { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Airstrike { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let mapping = gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let dynamic = ctx.terminal.config.existing_color_handling == ExistingColorHandling::Dynamic; + let characters = ctx.terminal.get_characters( + &mut ctx.rng, + CharacterFilter::default(), + CharacterSort::TopToBottomLeftToRight, + ); + + let left = ctx.terminal.canvas.text_left; + let right = ctx.terminal.canvas.text_right; + let bottom = ctx.terminal.canvas.text_bottom; + let top = ctx.terminal.canvas.text_top; + let width = (right - left).max(1); + let height = (top - bottom).max(1); + + for index in 0..self.config.plane_count { + let fraction = (index + 1) as f64 / (self.config.plane_count + 1) as f64; + let rough_target = Coord::new( + left + (width as f64 * fraction).round() as i64, + top - (height as f64 * fraction).round() as i64, + ); + let target = characters + .iter() + .filter(|&&id| ctx.terminal.arena[id.0 as usize].input_symbol != " ") + .min_by_key(|&&id| { + let c = ctx.terminal.arena[id.0 as usize].input_coord; + (c.column - rough_target.column).pow(2) + 3 * (c.row - rough_target.row).pow(2) + }) + .map(|&id| ctx.terminal.arena[id.0 as usize].input_coord) + .unwrap_or(rough_target); + let from_left = index % 2 == 0; + let start = Coord::new( + if from_left { ctx.terminal.canvas.left - 5 } else { ctx.terminal.canvas.right + 5 }, + (top + 4 - index * 2).min(ctx.terminal.canvas.top + 3), + ); + let distance = (((target.column - start.column).pow(2) + (target.row - start.row).pow(2)) as f64).sqrt(); + let launch_frame = index * self.config.launch_delay; + let impact_frame = launch_frame + (distance / self.config.flight_speed).ceil() as i64; + let sprite: [(&str, i64, i64); 7] = if from_left { + [("=", -3, 0), ("=", -2, 0), ("=", -1, 0), (">", 0, 0), ("/", -2, 1), ("\\", -2, -1), ("o", -3, 1)] + } else { + [("<", 0, 0), ("=", 1, 0), ("=", 2, 0), ("=", 3, 0), ("\\", 2, 1), ("/", 2, -1), ("o", 3, 1)] + }; + let mut parts = Vec::new(); + for (symbol, dx, dy) in sprite { + let id = ctx.terminal.add_character(symbol, start); + ctx.terminal.arena[id.0 as usize].layer = 3; + Self::set_visual(ctx, id, symbol, self.config.plane_color); + parts.push((id, Coord::new(dx, dy))); + } + self.planes.push(Plane { parts, start, target, launch_frame, impact_frame, impacted: false }); + } + + for id in characters { + let home = ctx.terminal.arena[id.0 as usize].input_coord; + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + let colors = if dynamic { + let animation = &ctx.terminal.arena[id.0 as usize].animation; + ColorPair::new(animation.input_fg_color, animation.input_bg_color) + } else { + ColorPair::new(Some(*mapping.get(&home).unwrap()), None) + }; + self.final_colors.insert(id, colors); + let uses_pre = ctx.terminal.arena[id.0 as usize].uses_input_preexisting_colors; + ctx.terminal.arena[id.0 as usize].animation.set_appearance(&symbol, uses_pre, Some(&symbol), Some(colors)); + ctx.terminal.set_character_visibility(id, true); + if symbol != " " { + let impact = self + .planes + .iter() + .enumerate() + .min_by_key(|(_, plane)| { + let dx = home.column - plane.target.column; + let dy = home.row - plane.target.row; + dx * dx + dy * dy + }) + .map(|(index, _)| index) + .unwrap_or(0); + let target = self.planes[impact].target; + let dx = home.column - target.column; + let dy = home.row - target.row; + if dx * dx + dy * dy <= self.config.blast_radius * self.config.blast_radius { + self.debris.push(Debris { id, home, impact, velocity_x: 0.0, velocity_y: 0.0 }); + } + } + } + + for impact in 0..self.planes.len() { + for _ in 0..self.config.fire_particles { + let symbol = "*"; + let id = ctx.terminal.add_character(symbol, self.planes[impact].target); + ctx.terminal.arena[id.0 as usize].layer = 4; + let angle = ctx.rng.uniform(0.0, std::f64::consts::TAU); + let speed = ctx.rng.uniform(0.18, 1.10); + self.fire.push(FireParticle { + id, + impact, + velocity_x: angle.cos() * speed, + velocity_y: angle.sin() * speed + 0.35, + lifetime: ctx.rng.randint(28, 66), + }); + } + } + + let last_impact = self.planes.iter().map(|plane| plane.impact_frame).max().unwrap_or(0); + self.end_frame = + last_impact + self.config.debris_duration + self.config.reassembly_duration + self.config.final_hold_time; + self.frame = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.frame > self.end_frame { + return None; + } + self.update_planes(ctx); + self.update_trails(ctx); + self.update_debris(ctx); + self.update_fire(ctx); + self.frame += 1; + Some(ctx.frame()) + } +} diff --git a/src/effects/automata.rs b/src/effects/automata.rs new file mode 100644 index 0000000..0a39090 --- /dev/null +++ b/src/effects/automata.rs @@ -0,0 +1,338 @@ +//! Automata: an elementary cellular automaton grows down the canvas, forming +//! a fractal lattice whose living cells stream into the input text. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_positive_int, parse_symbol, +}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct AutomataConfig { + /// Elementary cellular automaton rule, from 0 to 255. Rule 90 produces a Sierpinski triangle. + #[arg(long = "rule", default_value_t = 90)] + pub rule: u8, + + /// Number of evenly spaced living cells in the initial generation. + #[arg(long = "seed-cells", default_value_t = 1, value_parser = parse_positive_int)] + pub seed_cells: i64, + + /// Frames to display before adding each new generation. + #[arg(long = "generation-time", default_value_t = 5, value_parser = parse_positive_int)] + pub generation_time: i64, + + /// Frames to hold the completed cellular pattern. + #[arg(long = "hold-time", default_value_t = 75, value_parser = parse_non_negative_int)] + pub hold_time: i64, + + /// Frames over which the cellular pattern dissolves into the text. + #[arg(long = "dissolve-duration", default_value_t = 180, value_parser = parse_positive_int)] + pub dissolve_duration: i64, + + /// Frames to hold the readable text after the fractal has flowed into it. + #[arg(long = "final-hold-time", default_value_t = 85, value_parser = parse_non_negative_int)] + pub final_hold_time: i64, + + /// Space separated symbols used for living cells. + #[arg(long = "cell-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["█", "▓", "▒", "◆"])] + pub cell_symbols: Vec, + + /// Space separated colors used by the fractal. + #[arg(long = "cell-colors", num_args = 1.., value_parser = parse_color, + default_values = ["00f5d4", "00bbf9", "4361ee", "7209b7", "f72585"])] + pub cell_colors: Vec, + + /// Space separated colors for the final readable text gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["00f5d4", "4361ee", "a78bfa", "f8fafc"])] + pub final_gradient_stops: Vec, + + /// Number of steps in the final text gradient. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["20"])] + pub final_gradient_steps: Vec, + + /// Direction of the final text gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, + + /// Color of the text before the automaton resolves into it. + #[arg(long = "shadow-color", default_value = "283747", value_parser = parse_color)] + pub shadow_color: Color, +} + +struct Cell { + id: CharId, + coord: Coord, + generation: i64, + dissolve_order: f64, + destination: Coord, +} + +pub struct Automata { + config: AutomataConfig, + cells: Vec, + text: Vec, + text_colors: HashMap, + text_origins: HashMap, + text_reveal: HashMap, + frame: i64, + growth_end: i64, + dissolve_start: i64, + end_frame: i64, +} + +impl Automata { + pub fn new(config: AutomataConfig) -> Self { + Self { + config, + cells: Vec::new(), + text: Vec::new(), + text_colors: HashMap::new(), + text_origins: HashMap::new(), + text_reveal: HashMap::new(), + frame: 0, + growth_end: 0, + dissolve_start: 0, + end_frame: 0, + } + } + + fn set_visual(ctx: &mut EngineCtx, id: CharId, symbol: &str, color: Color) { + let uses_pre = ctx.terminal.arena[id.0 as usize].uses_input_preexisting_colors; + ctx.terminal.arena[id.0 as usize].animation.set_appearance( + symbol, + uses_pre, + Some(symbol), + Some(ColorPair::new(Some(color), None)), + ); + } + + fn next_generation(rule: u8, current: &[bool]) -> Vec { + let mut next = vec![false; current.len()]; + for index in 0..current.len() { + let left = index.checked_sub(1).is_some_and(|i| current[i]) as u8; + let center = current[index] as u8; + let right = (index + 1 < current.len() && current[index + 1]) as u8; + let neighborhood = (left << 2) | (center << 1) | right; + next[index] = (rule >> neighborhood) & 1 == 1; + } + next + } +} + +impl EffectHooks for Automata { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Automata { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let canvas_left = ctx.terminal.canvas.left; + let canvas_right = ctx.terminal.canvas.right; + let canvas_top = ctx.terminal.canvas.top; + let canvas_bottom = ctx.terminal.canvas.bottom; + let width = (canvas_right - canvas_left + 1).max(1) as usize; + let height = (canvas_top - canvas_bottom + 1).max(1); + + self.text = ctx.terminal.get_characters( + &mut ctx.rng, + CharacterFilter::default(), + CharacterSort::TopToBottomLeftToRight, + ); + let final_gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let final_mapping = final_gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + for &id in &self.text { + let coord = ctx.terminal.arena[id.0 as usize].input_coord; + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + self.text_colors.insert(id, *final_mapping.get(&coord).expect("final gradient coordinate")); + Self::set_visual(ctx, id, &symbol, self.config.shadow_color); + ctx.terminal.set_character_visibility(id, true); + } + + let mut generation = vec![false; width]; + for seed in 0..self.config.seed_cells { + let position = (((seed + 1) as f64 / (self.config.seed_cells + 1) as f64) + * (width.saturating_sub(1)) as f64) + .round() as usize; + generation[position.min(width - 1)] = true; + } + + for row_index in 0..height { + for (column_index, &alive) in generation.iter().enumerate() { + if !alive { + continue; + } + let coord = Coord::new(canvas_left + column_index as i64, canvas_top - row_index); + let symbol = ctx.rng.choice(&self.config.cell_symbols).clone(); + let color_index = ((row_index as usize / 3) + column_index / 7) % self.config.cell_colors.len(); + let id = ctx.terminal.add_character(&symbol, coord); + ctx.terminal.arena[id.0 as usize].layer = 3; + Self::set_visual(ctx, id, &symbol, self.config.cell_colors[color_index]); + let hash = (coord.column as u64) + .wrapping_mul(0x9e37_79b9) + .wrapping_add((coord.row as u64).wrapping_mul(0x85eb_ca6b)); + self.cells.push(Cell { + id, + coord, + generation: row_index, + dissolve_order: (hash % 10_000) as f64 / 10_000.0, + destination: coord, + }); + } + generation = Self::next_generation(self.config.rule, &generation); + } + + // Couple the resolution to the fractal itself. Each input glyph is + // sourced from its nearest automaton cell; the cell's deterministic + // dissolve order becomes the glyph's departure time. + for &id in &self.text { + let home = ctx.terminal.arena[id.0 as usize].input_coord; + let source = self + .cells + .iter() + .min_by_key(|cell| { + let dx = home.column - cell.coord.column; + let dy = home.row - cell.coord.row; + dx * dx + 4 * dy * dy + }) + .expect("automaton has at least one living cell"); + self.text_origins.insert(id, source.coord); + self.text_reveal.insert(id, source.dissolve_order * 0.55); + } + + // Every living cell gets a destination inside the lettering. During + // the finale the complete fractal therefore streams into the text, + // rather than merely disappearing behind it. + for cell in &mut self.cells { + cell.destination = self + .text + .iter() + .filter(|&&id| ctx.terminal.arena[id.0 as usize].input_symbol != " ") + .min_by_key(|&&id| { + let home = ctx.terminal.arena[id.0 as usize].input_coord; + let dx = home.column - cell.coord.column; + let dy = home.row - cell.coord.row; + dx * dx + 3 * dy * dy + }) + .map(|&id| ctx.terminal.arena[id.0 as usize].input_coord) + .unwrap_or(cell.coord); + } + + self.growth_end = height * self.config.generation_time; + self.dissolve_start = self.growth_end + self.config.hold_time; + self.end_frame = self.dissolve_start + self.config.dissolve_duration + self.config.final_hold_time; + self.frame = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.frame > self.end_frame { + return None; + } + + if self.frame < self.dissolve_start { + for cell in &self.cells { + let born = cell.generation * self.config.generation_time; + let visible = self.frame >= born; + if visible { + let pulse = ((self.frame - born) / 12).max(0) as usize; + let color_index = (cell.generation as usize / 3 + pulse) % self.config.cell_colors.len(); + let symbol_index = (cell.generation as usize + pulse / 2) % self.config.cell_symbols.len(); + Self::set_visual( + ctx, + cell.id, + &self.config.cell_symbols[symbol_index], + self.config.cell_colors[color_index], + ); + } + ctx.terminal.set_character_visibility(cell.id, visible); + } + } else { + let progress = + ((self.frame - self.dissolve_start) as f64 / self.config.dissolve_duration as f64).clamp(0.0, 1.0); + for cell in &self.cells { + let departure = cell.dissolve_order * 0.65; + if progress < departure { + ctx.terminal.arena[cell.id.0 as usize].motion.set_coordinate(cell.coord); + ctx.terminal.set_character_visibility(cell.id, true); + continue; + } + let local = ((progress - departure) / 0.28).clamp(0.0, 1.0); + if local >= 1.0 { + ctx.terminal.set_character_visibility(cell.id, false); + continue; + } + let eased = local * local * (3.0 - 2.0 * local); + let coord = Coord::new( + (cell.coord.column as f64 + (cell.destination.column - cell.coord.column) as f64 * eased).round() + as i64, + (cell.coord.row as f64 + (cell.destination.row - cell.coord.row) as f64 * eased).round() as i64, + ); + ctx.terminal.arena[cell.id.0 as usize].motion.set_coordinate(coord); + let symbol = if local < 0.40 { + "◆" + } else if local < 0.75 { + "•" + } else { + "·" + }; + let color_index = ((1.0 - local) * (self.config.cell_colors.len() - 1) as f64).round() as usize; + Self::set_visual(ctx, cell.id, symbol, self.config.cell_colors[color_index]); + ctx.terminal.set_character_visibility(cell.id, true); + } + for &id in &self.text { + let start = self.text_reveal[&id]; + if progress < start { + ctx.terminal.set_character_visibility(id, false); + continue; + } + let local = ((progress - start) / 0.40).clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - local).powi(3); + let origin = self.text_origins[&id]; + let home = ctx.terminal.arena[id.0 as usize].input_coord; + let coord = Coord::new( + (origin.column as f64 + (home.column - origin.column) as f64 * eased).round() as i64, + (origin.row as f64 + (home.row - origin.row) as f64 * eased).round() as i64, + ); + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + ctx.terminal.arena[id.0 as usize].motion.set_coordinate(coord); + Self::set_visual(ctx, id, &symbol, self.text_colors[&id]); + ctx.terminal.set_character_visibility(id, true); + } + + if progress >= 1.0 { + let white = Color::from_hex("ffffff").unwrap(); + for &id in &self.text { + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + let twinkle = (self.frame + id.0 as i64 * 7) % 37 < 2; + Self::set_visual(ctx, id, &symbol, if twinkle { white } else { self.text_colors[&id] }); + } + } + } + + self.frame += 1; + Some(ctx.frame()) + } +} diff --git a/src/effects/bubble_pop.rs b/src/effects/bubble_pop.rs new file mode 100644 index 0000000..568104d --- /dev/null +++ b/src/effects/bubble_pop.rs @@ -0,0 +1,400 @@ +//! Bubble pop: iridescent bubbles float up through the canvas, burst over the +//! input, and reveal crisp rainbow lettering one sparkling patch at a time. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_gradient_direction, parse_gradient_steps, parse_positive_int}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{shift_color_towards, Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct BubblePopConfig { + /// Number of bubbles that rise and pop across the lettering. + #[arg(long = "bubble-count", default_value_t = 28, value_parser = parse_positive_int)] + pub bubble_count: i64, + + /// Frames each bubble spends floating up toward the text. + #[arg(long = "rise-duration", default_value_t = 175, value_parser = parse_positive_int)] + pub rise_duration: i64, + + /// Frames between successive bubble launches. + #[arg(long = "launch-delay", default_value_t = 8, value_parser = parse_positive_int)] + pub launch_delay: i64, + + /// Frames occupied by each sparkling pop. + #[arg(long = "pop-duration", default_value_t = 24, value_parser = parse_positive_int)] + pub pop_duration: i64, + + /// Frames to admire the completed text after the last bubbles clear. + #[arg(long = "final-hold-time", default_value_t = 105, value_parser = parse_positive_int)] + pub final_hold_time: i64, + + /// Rainbow colors that travel around the bubble rims. + #[arg(long = "bubble-colors", num_args = 1.., value_parser = parse_color, + default_values = ["ff3b8d", "ff7a18", "ffe14a", "50e991", "36d9ff", "6384ff", "b45cff"])] + pub bubble_colors: Vec, + + /// Bright reflection color on the bubbles and their pops. + #[arg(long = "highlight-color", default_value = "ffffff", value_parser = parse_color)] + pub highlight_color: Color, + + /// Space separated colors for the final readable text. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["ff3b8d", "ff9f1c", "ffe66d", "4de8a5", "35d8ff", "7386ff", "c65cff"])] + pub final_gradient_stops: Vec, + + /// Number of steps in the final text gradient. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["30"])] + pub final_gradient_steps: Vec, + + /// Direction of the final text gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +struct BubblePart { + id: CharId, + offset: Coord, + symbol: &'static str, + highlight: bool, +} + +struct Bubble { + parts: Vec, + target: Coord, + start_x: f64, + start_y: f64, + launch: i64, + pop: i64, + sway: f64, + phase: f64, + color_offset: usize, +} + +struct PopSpark { + id: CharId, + bubble: usize, + dx: f64, + dy: f64, + color_offset: usize, +} + +pub struct BubblePop { + config: BubblePopConfig, + bubbles: Vec, + sparks: Vec, + text: Vec, + text_colors: HashMap, + text_reveal: HashMap, + frame: i64, + final_start: i64, + end_frame: i64, +} + +impl BubblePop { + pub fn new(config: BubblePopConfig) -> Self { + Self { + config, + bubbles: Vec::new(), + sparks: Vec::new(), + text: Vec::new(), + text_colors: HashMap::new(), + text_reveal: HashMap::new(), + frame: 0, + final_start: 0, + end_frame: 0, + } + } + + fn set_visual(ctx: &mut EngineCtx, id: CharId, symbol: &str, color: Color, bold: bool) { + let uses_pre = ctx.terminal.arena[id.0 as usize].uses_input_preexisting_colors; + ctx.terminal.arena[id.0 as usize].animation.set_appearance( + symbol, + uses_pre, + Some(symbol), + Some(ColorPair::new(Some(color), None)), + ); + if bold { + let visual = ctx.terminal.arena[id.0 as usize].animation.current_character_visual.clone(); + let params = crate::engine::animation::VisualParams { + bold: true, + colors: visual.colors, + fg_color_code: visual.fg_color_code.clone(), + bg_color_code: visual.bg_color_code.clone(), + ..Default::default() + }; + ctx.terminal.arena[id.0 as usize].animation.current_character_visual = + crate::engine::animation::CharacterVisual::new(symbol, params).into(); + } + } + + fn sprite(size: usize) -> Vec<(Coord, &'static str, bool)> { + match size { + 0 => vec![(Coord::new(0, 0), "◯", true)], + 1 => vec![ + (Coord::new(-1, 1), "╭", false), + (Coord::new(0, 1), "─", true), + (Coord::new(1, 1), "╮", false), + (Coord::new(-2, 0), "(", false), + (Coord::new(0, 0), "°", true), + (Coord::new(2, 0), ")", false), + (Coord::new(-1, -1), "╰", false), + (Coord::new(0, -1), "─", false), + (Coord::new(1, -1), "╯", false), + ], + _ => vec![ + (Coord::new(-2, 2), "╭", false), + (Coord::new(-1, 2), "─", true), + (Coord::new(0, 2), "─", true), + (Coord::new(1, 2), "─", false), + (Coord::new(2, 2), "╮", false), + (Coord::new(-3, 1), "╱", false), + (Coord::new(3, 1), "╲", false), + (Coord::new(-4, 0), "(", false), + (Coord::new(-1, 0), "°", true), + (Coord::new(4, 0), ")", false), + (Coord::new(-3, -1), "╲", false), + (Coord::new(3, -1), "╱", false), + (Coord::new(-2, -2), "╰", false), + (Coord::new(-1, -2), "─", false), + (Coord::new(0, -2), "─", false), + (Coord::new(1, -2), "─", false), + (Coord::new(2, -2), "╯", false), + ], + } + } + + fn update_bubbles(&self, ctx: &mut EngineCtx) { + let black = Color::from_hex("070b18").unwrap(); + for (index, bubble) in self.bubbles.iter().enumerate() { + let age = self.frame - bubble.launch; + let visible = age >= 0 && self.frame < bubble.pop; + if !visible { + for part in &bubble.parts { + ctx.terminal.set_character_visibility(part.id, false); + } + continue; + } + + let progress = (age as f64 / self.config.rise_duration as f64).clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - progress).powi(3); + let sway = (age as f64 * 0.055 + bubble.phase).sin() * bubble.sway * (0.72 + progress * 0.28); + let center_x = bubble.start_x + (bubble.target.column as f64 - bubble.start_x) * eased + sway; + let center_y = bubble.start_y + (bubble.target.row as f64 - bubble.start_y) * eased; + let excitement = self.frame >= bubble.pop - 18; + + for (part_index, part) in bubble.parts.iter().enumerate() { + let coord = + Coord::new(center_x.round() as i64 + part.offset.column, center_y.round() as i64 + part.offset.row); + ctx.terminal.arena[part.id.0 as usize].motion.set_coordinate(coord); + let traveling = ((self.frame / 4) as usize + bubble.color_offset + part_index * 2) + % self.config.bubble_colors.len(); + let base = self.config.bubble_colors[traveling]; + let gleam = part.highlight || (self.frame as usize + part_index * 7 + index * 3) % 29 < 2; + let color = if gleam { + shift_color_towards(&base, &self.config.highlight_color, if excitement { 0.92 } else { 0.70 }) + .unwrap() + } else { + shift_color_towards(&base, &black, 0.12).unwrap() + }; + Self::set_visual(ctx, part.id, part.symbol, color, gleam || excitement); + ctx.terminal.set_character_visibility(part.id, true); + } + } + } + + fn update_sparks(&self, ctx: &mut EngineCtx) { + for spark in &self.sparks { + let bubble = &self.bubbles[spark.bubble]; + let age = self.frame - bubble.pop; + if age < 0 || age > self.config.pop_duration { + ctx.terminal.set_character_visibility(spark.id, false); + continue; + } + let progress = age as f64 / self.config.pop_duration as f64; + let distance = (1.0 - (1.0 - progress).powi(2)) * 1.16; + let coord = Coord::new( + (bubble.target.column as f64 + spark.dx * distance).round() as i64, + (bubble.target.row as f64 + spark.dy * distance).round() as i64, + ); + ctx.terminal.arena[spark.id.0 as usize].motion.set_coordinate(coord); + let color_index = (spark.color_offset + (self.frame / 3) as usize) % self.config.bubble_colors.len(); + let base = self.config.bubble_colors[color_index]; + let color = shift_color_towards(&base, &self.config.highlight_color, (1.0 - progress) * 0.62).unwrap(); + let symbol = if progress < 0.22 { + "✦" + } else if progress < 0.58 { + "•" + } else { + "·" + }; + Self::set_visual(ctx, spark.id, symbol, color, progress < 0.45); + ctx.terminal.set_character_visibility(spark.id, true); + } + } + + fn update_text(&self, ctx: &mut EngineCtx) { + for &id in &self.text { + let reveal = self.text_reveal[&id]; + if self.frame < reveal { + ctx.terminal.set_character_visibility(id, false); + continue; + } + let age = self.frame - reveal; + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + let twinkle = age < 5 || (self.frame >= self.final_start && (self.frame + id.0 as i64 * 11) % 67 < 2); + if age < 5 { + Self::set_visual(ctx, id, "✦", self.config.highlight_color, true); + } else { + let color = if twinkle { self.config.highlight_color } else { self.text_colors[&id] }; + Self::set_visual(ctx, id, &symbol, color, true); + } + ctx.terminal.set_character_visibility(id, true); + } + } +} + +impl EffectHooks for BubblePop { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for BubblePop { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + self.text = ctx.terminal.get_characters( + &mut ctx.rng, + CharacterFilter::default(), + CharacterSort::TopToBottomLeftToRight, + ); + let gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let mapping = gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + + let non_space: Vec = self + .text + .iter() + .filter(|&&id| ctx.terminal.arena[id.0 as usize].input_symbol != " ") + .map(|&id| ctx.terminal.arena[id.0 as usize].input_coord) + .collect(); + for &id in &self.text { + let coord = ctx.terminal.arena[id.0 as usize].input_coord; + self.text_colors.insert(id, *mapping.get(&coord).expect("bubble-pop gradient coordinate")); + ctx.terminal.arena[id.0 as usize].layer = 6; + ctx.terminal.set_character_visibility(id, false); + } + + let count = (self.config.bubble_count as usize).min(non_space.len().max(1)); + let fallback = Coord::new( + (ctx.terminal.canvas.text_left + ctx.terminal.canvas.text_right) / 2, + (ctx.terminal.canvas.text_bottom + ctx.terminal.canvas.text_top) / 2, + ); + for index in 0..count { + let sample = if non_space.is_empty() { + fallback + } else { + non_space[((index * non_space.len()) / count + index * 17) % non_space.len()] + }; + let target = Coord::new( + (sample.column + ctx.rng.randint(-3, 3)).clamp(ctx.terminal.canvas.left, ctx.terminal.canvas.right), + (sample.row + ctx.rng.randint(-1, 1)).clamp(ctx.terminal.canvas.bottom, ctx.terminal.canvas.top), + ); + let launch = index as i64 * self.config.launch_delay; + let pop = launch + self.config.rise_duration + ctx.rng.randint(-12, 12); + let size = if index % 7 == 0 { + 2 + } else if index % 3 == 0 { + 1 + } else { + 0 + }; + let mut parts = Vec::new(); + for (offset, symbol, highlight) in Self::sprite(size) { + let id = ctx.terminal.add_character(symbol, Coord::new(target.column, ctx.terminal.canvas.bottom - 4)); + ctx.terminal.arena[id.0 as usize].layer = 4; + ctx.terminal.set_character_visibility(id, false); + parts.push(BubblePart { id, offset, symbol, highlight }); + } + self.bubbles.push(Bubble { + parts, + target, + start_x: (target.column + ctx.rng.randint(-14, 14)) as f64, + start_y: (ctx.terminal.canvas.bottom - 4 - size as i64) as f64, + launch, + pop, + sway: ctx.rng.uniform(1.4, 4.2), + phase: ctx.rng.uniform(0.0, std::f64::consts::TAU), + color_offset: index * 3 % self.config.bubble_colors.len(), + }); + } + + for bubble_index in 0..self.bubbles.len() { + for spark_index in 0..14 { + let angle = spark_index as f64 / 14.0 * std::f64::consts::TAU + ctx.rng.uniform(-0.12, 0.12); + let reach = if spark_index % 3 == 0 { ctx.rng.uniform(6.0, 9.0) } else { ctx.rng.uniform(3.0, 6.5) }; + let id = ctx.terminal.add_character("✦", self.bubbles[bubble_index].target); + ctx.terminal.arena[id.0 as usize].layer = 7; + ctx.terminal.set_character_visibility(id, false); + self.sparks.push(PopSpark { + id, + bubble: bubble_index, + dx: angle.cos() * reach, + dy: angle.sin() * reach * 0.52, + color_offset: bubble_index * 3 + spark_index, + }); + } + } + + let last_pop = self.bubbles.iter().map(|bubble| bubble.pop).max().unwrap_or(self.config.rise_duration); + for &id in &self.text { + let coord = ctx.terminal.arena[id.0 as usize].input_coord; + let reveal = self + .bubbles + .iter() + .min_by_key(|bubble| { + let dx = (coord.column - bubble.target.column).abs(); + let dy = (coord.row - bubble.target.row).abs(); + dx * dx + dy * dy * 3 + }) + .map(|bubble| { + let dx = (coord.column - bubble.target.column).abs(); + let dy = (coord.row - bubble.target.row).abs(); + bubble.pop + (dx + dy * 2).min(10) * 2 + }) + .unwrap_or(last_pop); + self.text_reveal.insert(id, reveal); + } + self.final_start = last_pop + self.config.pop_duration + 24; + self.end_frame = self.final_start + self.config.final_hold_time; + self.frame = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.frame > self.end_frame { + return None; + } + self.update_bubbles(ctx); + self.update_sparks(ctx); + self.update_text(ctx); + self.frame += 1; + Some(ctx.frame()) + } +} diff --git a/src/effects/malfunction.rs b/src/effects/malfunction.rs new file mode 100644 index 0000000..2c6847b --- /dev/null +++ b/src/effects/malfunction.rs @@ -0,0 +1,552 @@ +//! Malfunction: a cheerful little printer types nonsense, develops a harmless +//! paper jam, recovers, and proudly prints the input in a shower of loose type. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_positive_int, +}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{shift_color_towards, Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct MalfunctionConfig { + /// Frames spent waking the printer and feeding in its first sheet. + #[arg(long = "warmup-duration", default_value_t = 65, value_parser = parse_positive_int)] + pub warmup_duration: i64, + + /// Frames of increasingly dubious printing before the paper jam. + #[arg(long = "printing-duration", default_value_t = 215, value_parser = parse_positive_int)] + pub printing_duration: i64, + + /// Frames the printer rattles, glitches, and spits loose type. + #[arg(long = "malfunction-duration", default_value_t = 125, value_parser = parse_positive_int)] + pub malfunction_duration: i64, + + /// Frames the celebratory stream of loose type remains airborne. + #[arg(long = "letter-flight-duration", default_value_t = 145, value_parser = parse_positive_int)] + pub letter_flight_duration: i64, + + /// Frames in which the scattered type composes itself into the input. + #[arg(long = "reassembly-duration", default_value_t = 165, value_parser = parse_positive_int)] + pub reassembly_duration: i64, + + /// Frames to admire the correctly printed result. + #[arg(long = "final-hold-time", default_value_t = 100, value_parser = parse_non_negative_int)] + pub final_hold_time: i64, + + /// Number of colorful letters and paper flecks in the recovery celebration. + #[arg(long = "celebration-particles", default_value_t = 105, value_parser = parse_positive_int)] + pub celebration_particles: i64, + + /// Printer casing color. + #[arg(long = "printer-color", default_value = "b8c4ce", value_parser = parse_color)] + pub printer_color: Color, + + /// Color of the paper and fresh type. + #[arg(long = "paper-color", default_value = "fff7e6", value_parser = parse_color)] + pub paper_color: Color, + + /// Warning lamp and error-message color. + #[arg(long = "error-color", default_value = "ff334f", value_parser = parse_color)] + pub error_color: Color, + + /// Space separated colors for loose type, paper flecks, and happy confetti. + #[arg(long = "letter-colors", num_args = 1.., value_parser = parse_color, + default_values = ["ffffff", "ffe066", "ff9f1c", "ff5fa2", "7cdaff", "7ee787"])] + pub letter_colors: Vec, + + /// Space separated colors for the final readable text. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["00d9ff", "7c3aed", "ff2d95", "ff9f1c", "fff7e6"])] + pub final_gradient_stops: Vec, + + /// Number of steps in the final text gradient. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["28"])] + pub final_gradient_steps: Vec, + + /// Direction of the final text gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +struct PrinterPart { + id: CharId, + offset: Coord, + symbol: String, +} + +struct PaperCell { + id: CharId, + offset: Coord, + border: bool, + order: f64, +} + +struct LooseType { + id: CharId, + born: i64, + start_x: f64, + start_y: f64, + velocity_x: f64, + velocity_y: f64, +} + +struct ConfettiParticle { + id: CharId, + delay: i64, + velocity_x: f64, + velocity_y: f64, + lifetime: i64, + color_index: usize, + kind: usize, +} + +struct TextPiece { + id: CharId, + home: Coord, + scatter: Coord, + delay: f64, + random_symbol: String, +} + +pub struct Malfunction { + config: MalfunctionConfig, + printer: Vec, + paper: Vec, + loose_type: Vec, + confetti: Vec, + text: Vec, + text_colors: HashMap, + anchor: Coord, + frame: i64, + print_start: i64, + malfunction_start: i64, + recovery_frame: i64, + settle_start: i64, + settle_end: i64, + end_frame: i64, +} + +impl Malfunction { + pub fn new(config: MalfunctionConfig) -> Self { + Self { + config, + printer: Vec::new(), + paper: Vec::new(), + loose_type: Vec::new(), + confetti: Vec::new(), + text: Vec::new(), + text_colors: HashMap::new(), + anchor: Coord::new(0, 0), + frame: 0, + print_start: 0, + malfunction_start: 0, + recovery_frame: 0, + settle_start: 0, + settle_end: 0, + end_frame: 0, + } + } + + fn set_visual(ctx: &mut EngineCtx, id: CharId, symbol: &str, color: Color, bold: bool) { + let uses_pre = ctx.terminal.arena[id.0 as usize].uses_input_preexisting_colors; + ctx.terminal.arena[id.0 as usize].animation.set_appearance( + symbol, + uses_pre, + Some(symbol), + Some(ColorPair::new(Some(color), None)), + ); + if bold { + let visual = ctx.terminal.arena[id.0 as usize].animation.current_character_visual.clone(); + let params = crate::engine::animation::VisualParams { + bold: true, + colors: visual.colors, + fg_color_code: visual.fg_color_code.clone(), + bg_color_code: visual.bg_color_code.clone(), + ..Default::default() + }; + ctx.terminal.arena[id.0 as usize].animation.current_character_visual = + crate::engine::animation::CharacterVisual::new(symbol, params).into(); + } + } + + fn random_type(seed: u64) -> &'static str { + const TYPE: [&str; 42] = [ + "A", "B", "C", "D", "E", "F", "G", "H", "J", "K", "M", "N", "P", "Q", "R", "S", "T", "U", "V", "W", "X", + "Y", "Z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "#", "%", "&", "?", "!", "@", "*", "/", "=", + ]; + TYPE[seed as usize % TYPE.len()] + } + + fn machine_offset(&self) -> Coord { + if self.frame < self.malfunction_start || self.frame >= self.recovery_frame { + return Coord::new(0, 0); + } + let age = self.frame - self.malfunction_start; + let intensity = (age as f64 / self.config.malfunction_duration as f64).clamp(0.0, 1.0); + let dx = if age % 7 < 2 { + 1 + } else if age % 7 > 4 { + -1 + } else { + 0 + }; + let dy = if intensity > 0.55 && age % 11 == 0 { 1 } else { 0 }; + Coord::new(dx, dy) + } + + fn update_printer(&self, ctx: &mut EngineCtx) { + let shake = self.machine_offset(); + let dim = Color::from_hex("59636e").unwrap(); + let settle = ((self.frame - self.settle_start) as f64 / 90.0).clamp(0.0, 1.0); + let resting_drop = (settle * settle * (3.0 - 2.0 * settle) * 5.0).round() as i64; + let happy_age = self.frame - self.recovery_frame; + let happy_bounce = if (0..72).contains(&happy_age) && happy_age % 18 < 5 { 1 } else { 0 }; + let happy_green = Color::from_hex("7ee787").unwrap(); + for part in &self.printer { + let coord = Coord::new( + self.anchor.column + part.offset.column + shake.column, + self.anchor.row + part.offset.row + shake.row - resting_drop + happy_bounce, + ); + ctx.terminal.arena[part.id.0 as usize].motion.set_coordinate(coord); + let error_lamp = part.symbol == "●"; + let recovered = self.frame >= self.recovery_frame; + let color = if error_lamp && recovered { + happy_green + } else if error_lamp && self.frame >= self.malfunction_start { + if (self.frame / 5) % 2 == 0 { + self.config.error_color + } else { + dim + } + } else if self.frame < self.config.warmup_duration / 2 { + dim + } else { + self.config.printer_color + }; + let symbol = if error_lamp && recovered { "♥" } else { &part.symbol }; + Self::set_visual(ctx, part.id, symbol, color, error_lamp); + ctx.terminal.set_character_visibility(part.id, true); + } + } + + fn update_paper(&self, ctx: &mut EngineCtx) { + let shake = self.machine_offset(); + let settle = ((self.frame - self.settle_start) as f64 / 90.0).clamp(0.0, 1.0); + let resting_drop = (settle * settle * (3.0 - 2.0 * settle) * 5.0).round() as i64; + let mut feed = if self.frame < self.print_start { + ((self.frame as f64 / self.print_start.max(1) as f64) * 7.0).floor() as i64 + } else { + 7 + }; + if self.frame >= self.settle_start { + feed = (feed - (self.frame - self.settle_start) / 7).max(0); + } + let print_progress = + ((self.frame - self.print_start) as f64 / self.config.printing_duration as f64).clamp(0.0, 1.0); + for cell in &self.paper { + if cell.offset.row - 3 >= feed { + ctx.terminal.set_character_visibility(cell.id, false); + continue; + } + let jam = self.frame >= self.malfunction_start; + let jam_age = (self.frame - self.malfunction_start).max(0); + let skew = if jam { (cell.offset.row - 3) * (jam_age / 25).min(2) / 3 } else { 0 }; + let coord = Coord::new( + self.anchor.column + cell.offset.column + shake.column + skew, + self.anchor.row + cell.offset.row + shake.row - resting_drop, + ); + ctx.terminal.arena[cell.id.0 as usize].motion.set_coordinate(coord); + let mut symbol = if cell.border { + ctx.terminal.arena[cell.id.0 as usize].input_symbol.clone() + } else if cell.order <= print_progress { + Self::random_type(cell.id.0 as u64 * 97 + (self.frame.max(0) as u64 / if jam { 3 } else { 17 })) + .to_string() + } else { + " ".to_string() + }; + let mut color = self.config.paper_color; + if jam && !cell.border { + let error_band = ((cell.offset.row + 2) * 5 + cell.offset.column).rem_euclid(17) < 4; + if error_band { + symbol = + ["E", "R", "R", "!", "#"][(cell.id.0 as usize + (self.frame / 3) as usize) % 5].to_string(); + color = self.config.error_color; + } + } + Self::set_visual(ctx, cell.id, &symbol, color, jam && !cell.border); + ctx.terminal.set_character_visibility(cell.id, symbol != " "); + } + } + + fn update_loose_type(&self, ctx: &mut EngineCtx) { + for (index, piece) in self.loose_type.iter().enumerate() { + let age = self.frame - piece.born; + if age < 0 || age > 80 { + ctx.terminal.set_character_visibility(piece.id, false); + continue; + } + let t = age as f64; + let coord = Coord::new( + (piece.start_x + piece.velocity_x * t).round() as i64, + (piece.start_y + piece.velocity_y * t - 0.007 * t * t).round() as i64, + ); + ctx.terminal.arena[piece.id.0 as usize].motion.set_coordinate(coord); + let symbol = Self::random_type(index as u64 * 71 + (self.frame / 4) as u64); + let color = if index % 6 == 0 { self.config.error_color } else { self.config.paper_color }; + Self::set_visual(ctx, piece.id, symbol, color, index % 6 == 0); + ctx.terminal.set_character_visibility(piece.id, true); + } + } + + fn update_confetti(&self, ctx: &mut EngineCtx) { + let age = self.frame - self.recovery_frame; + for particle in &self.confetti { + let local = age - particle.delay; + if local < 0 || local >= particle.lifetime { + ctx.terminal.set_character_visibility(particle.id, false); + continue; + } + let t = local as f64; + let coord = Coord::new( + (self.anchor.column as f64 + particle.velocity_x * t).round() as i64, + (self.anchor.row as f64 + 3.0 + particle.velocity_y * t - 0.0045 * t * t).round() as i64, + ); + ctx.terminal.arena[particle.id.0 as usize].motion.set_coordinate(coord); + let symbols = match particle.kind { + 0 => ["A", "?", "+", "·"], + 1 => ["▱", "▫", "~", "·"], + _ => ["#", "%", "♥", "."], + }; + let stage = (local * 4 / particle.lifetime.max(1)).clamp(0, 3) as usize; + let color = self.config.letter_colors[particle.color_index]; + Self::set_visual(ctx, particle.id, symbols[stage], color, stage < 2); + ctx.terminal.set_character_visibility(particle.id, true); + } + } + + fn update_text(&self, ctx: &mut EngineCtx) { + if self.frame < self.recovery_frame { + for piece in &self.text { + ctx.terminal.set_character_visibility(piece.id, false); + } + return; + } + let burst_progress = + ((self.frame - self.recovery_frame) as f64 / self.config.letter_flight_duration as f64).clamp(0.0, 1.0); + let settle_progress = + ((self.frame - self.settle_start) as f64 / self.config.reassembly_duration as f64).clamp(0.0, 1.0); + let white = Color::from_hex("ffffff").unwrap(); + for (index, piece) in self.text.iter().enumerate() { + if self.frame < self.settle_start { + let local = ((burst_progress - piece.delay) / (1.0 - piece.delay).max(0.05)).clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - local).powi(3); + let arc = (std::f64::consts::PI * local).sin() * (4.0 + index as f64 % 5.0); + let coord = Coord::new( + (self.anchor.column as f64 + (piece.scatter.column - self.anchor.column) as f64 * eased).round() + as i64, + (self.anchor.row as f64 + (piece.scatter.row - self.anchor.row) as f64 * eased + arc).round() + as i64, + ); + ctx.terminal.arena[piece.id.0 as usize].motion.set_coordinate(coord); + let color = self.config.letter_colors[index % self.config.letter_colors.len()]; + Self::set_visual(ctx, piece.id, &piece.random_symbol, color, local < 0.45); + ctx.terminal.set_character_visibility(piece.id, local > 0.0); + continue; + } + + let stagger = (piece.home.column - ctx.terminal.canvas.text_left) as f64 + / (ctx.terminal.canvas.text_right - ctx.terminal.canvas.text_left).max(1) as f64 + * 0.20; + let local = ((settle_progress - stagger) / (1.0 - stagger).max(0.01)).clamp(0.0, 1.0); + let eased = local * local * (3.0 - 2.0 * local); + let wobble = (local * std::f64::consts::TAU + index as f64).sin() * (1.0 - local) * 0.8; + let coord = Coord::new( + (piece.scatter.column as f64 + (piece.home.column - piece.scatter.column) as f64 * eased + wobble) + .round() as i64, + (piece.scatter.row as f64 + (piece.home.row - piece.scatter.row) as f64 * eased).round() as i64, + ); + ctx.terminal.arena[piece.id.0 as usize].motion.set_coordinate(coord); + let input = ctx.terminal.arena[piece.id.0 as usize].input_symbol.clone(); + let symbol = if local < 0.58 { &piece.random_symbol } else { &input }; + let base = self.text_colors[&piece.id]; + let debris_color = self.config.letter_colors[index % self.config.letter_colors.len()]; + let color_progress = local * local * (3.0 - 2.0 * local); + let color = if local > 0.92 && (self.frame + piece.id.0 as i64 * 11) % 61 < 2 { + white + } else { + shift_color_towards(&debris_color, &base, color_progress).unwrap() + }; + Self::set_visual(ctx, piece.id, symbol, color, local > 0.68); + ctx.terminal.set_character_visibility(piece.id, true); + } + } +} + +impl EffectHooks for Malfunction { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Malfunction { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let left = ctx.terminal.canvas.left; + let right = ctx.terminal.canvas.right; + let bottom = ctx.terminal.canvas.bottom; + let top = ctx.terminal.canvas.top; + self.anchor = Coord::new((left + right) / 2, bottom + ((top - bottom) as f64 * 0.34) as i64); + + self.print_start = self.config.warmup_duration; + self.malfunction_start = self.print_start + self.config.printing_duration; + self.recovery_frame = self.malfunction_start + self.config.malfunction_duration; + self.settle_start = self.recovery_frame + self.config.letter_flight_duration; + self.settle_end = self.settle_start + self.config.reassembly_duration; + self.end_frame = self.settle_end + self.config.final_hold_time; + + let machine = [ + " ╭─────────────────────────╮ ", + "╭────┤ ▒▒ PRINT-O-MATIC ▒▒ ├────╮", + "│ ╰─────────────────────────╯ │", + "├───────────────────────────────────┤", + "│ [■] ═══════════════════ ◉ ● │", + "╰───────────────────────────────────╯", + ]; + for (line_index, line) in machine.iter().enumerate() { + let row = 2 - line_index as i64; + let width = line.chars().count() as i64; + for (column, symbol) in line.chars().enumerate() { + if symbol == ' ' { + continue; + } + let offset = Coord::new(column as i64 - width / 2, row); + let id = ctx.terminal.add_character( + &symbol.to_string(), + Coord::new(self.anchor.column + offset.column, self.anchor.row + offset.row), + ); + ctx.terminal.arena[id.0 as usize].layer = 5; + ctx.terminal.set_character_visibility(id, false); + self.printer.push(PrinterPart { id, offset, symbol: symbol.to_string() }); + } + } + + // The sheet sits one cell inside the raised paper guide in the shared + // 37-cell machine artwork above. + let paper_width = 25_i64; + let paper_height = 7_i64; + for row in 0..paper_height { + for column in 0..paper_width { + let border = row == paper_height - 1 || column == 0 || column == paper_width - 1; + let symbol = if row == paper_height - 1 { + if column == 0 { + "╭" + } else if column == paper_width - 1 { + "╮" + } else { + "─" + } + } else if column == 0 || column == paper_width - 1 { + "│" + } else { + " " + }; + let offset = Coord::new(column - paper_width / 2, 3 + row); + let id = ctx.terminal.add_character( + symbol, + Coord::new(self.anchor.column + offset.column, self.anchor.row + offset.row), + ); + ctx.terminal.arena[id.0 as usize].layer = 3; + ctx.terminal.set_character_visibility(id, false); + let line_order = (paper_height - 1 - row) as f64 / paper_height as f64; + let across = column as f64 / paper_width as f64 * 0.12; + self.paper.push(PaperCell { id, offset, border, order: (line_order * 0.88 + across).min(1.0) }); + } + } + + for index in 0..58 { + let born = self.malfunction_start + index as i64 * 2 + ctx.rng.randint(0, 14); + let id = ctx.terminal.add_character("?", self.anchor); + ctx.terminal.arena[id.0 as usize].layer = 6; + ctx.terminal.set_character_visibility(id, false); + self.loose_type.push(LooseType { + id, + born, + start_x: self.anchor.column as f64 + ctx.rng.uniform(-9.0, 9.0), + start_y: self.anchor.row as f64 - 1.0, + velocity_x: ctx.rng.uniform(-0.18, 0.18), + velocity_y: ctx.rng.uniform(-0.05, 0.16), + }); + } + + for index in 0..self.config.celebration_particles as usize { + let id = ctx.terminal.add_character("*", Coord::new(self.anchor.column, self.anchor.row + 3)); + ctx.terminal.arena[id.0 as usize].layer = 7; + ctx.terminal.set_character_visibility(id, false); + self.confetti.push(ConfettiParticle { + id, + delay: ctx.rng.randint(0, 88), + velocity_x: ctx.rng.uniform(-0.24, 0.24), + velocity_y: ctx.rng.uniform(0.20, 0.46), + lifetime: ctx.rng.randint(58, 125), + color_index: index % self.config.letter_colors.len(), + kind: index % 3, + }); + } + + let ids = ctx.terminal.get_characters( + &mut ctx.rng, + CharacterFilter::default(), + CharacterSort::TopToBottomLeftToRight, + ); + let gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let mapping = gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + for (index, id) in ids.into_iter().enumerate() { + let home = ctx.terminal.arena[id.0 as usize].input_coord; + let scatter = Coord::new(ctx.rng.randint(left, right + 1), ctx.rng.randint(bottom, top + 1)); + ctx.terminal.arena[id.0 as usize].layer = 8; + ctx.terminal.set_character_visibility(id, false); + self.text_colors.insert(id, *mapping.get(&home).expect("malfunction gradient coordinate")); + self.text.push(TextPiece { + id, + home, + scatter, + delay: (index % 31) as f64 / 310.0, + random_symbol: Self::random_type(id.0 as u64 * 113 + index as u64).to_string(), + }); + } + self.frame = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.frame > self.end_frame { + return None; + } + self.update_printer(ctx); + self.update_paper(ctx); + self.update_loose_type(ctx); + self.update_confetti(ctx); + self.update_text(ctx); + self.frame += 1; + Some(ctx.frame()) + } +} diff --git a/src/effects/mod.rs b/src/effects/mod.rs index 1b71bbc..637170d 100644 --- a/src/effects/mod.rs +++ b/src/effects/mod.rs @@ -1,14 +1,17 @@ //! Static effect registry (replaces upstream pkgutil discovery). //! GENERATED structure — keep alphabetical by variant when adding effects. -pub mod common; +pub mod airstrike; +pub mod automata; pub mod beams; pub mod binarypath; pub mod blackhole; pub mod bouncyballs; +pub mod bubble_pop; pub mod bubbles; pub mod burn; pub mod colorshift; +pub mod common; pub mod crumble; pub mod decrypt; pub mod errorcorrect; @@ -16,6 +19,7 @@ pub mod expand; pub mod fireworks; pub mod highlight; pub mod laseretch; +pub mod malfunction; pub mod matrix; pub mod middleout; pub mod orbittingvolley; @@ -24,19 +28,23 @@ pub mod pour; pub mod print_effect; pub mod rain; pub mod random_sequence; +pub mod reverse_life; pub mod rings; +pub mod roses; pub mod scattered; pub mod slice; pub mod slide; pub mod smoke; pub mod spotlights; pub mod spray; +pub mod sunshower; pub mod swarm; pub mod sweep; pub mod synthgrid; pub mod thunderstorm; pub mod unstable; pub mod vhstape; +pub mod voronoi; pub mod waves; pub mod wipe; @@ -46,6 +54,10 @@ use crate::engine::effect::Effect; #[derive(Subcommand, Debug, Clone)] pub enum EffectCommand { + /// ASCII planes dive into the text, scattering it through fire and debris before it reassembles. + Airstrike(airstrike::AirstrikeConfig), + /// An elementary cellular automaton grows a fractal lattice whose cells stream into the text. + Automata(automata::AutomataConfig), /// Create beams which travel over the canvas illuminating the characters behind them. Beams(beams::BeamsConfig), /// Binary representations of each character move towards the home coordinate of the character. @@ -54,6 +66,8 @@ pub enum EffectCommand { Blackhole(blackhole::BlackholeConfig), /// Characters are bouncy balls falling from the top of the canvas. Bouncyballs(bouncyballs::BouncyBallsConfig), + /// Rainbow-shimmering bubbles float upward, pop, and reveal the text. + Bubblepop(bubble_pop::BubblePopConfig), /// Characters are formed into bubbles that float down and pop. Bubbles(bubbles::BubblesConfig), /// Burns vertically in the canvas. @@ -74,6 +88,8 @@ pub enum EffectCommand { Highlight(highlight::HighlightConfig), /// A laser etches characters onto the terminal. Laseretch(laseretch::LaserEtchConfig), + /// A printer types nonsense, recovers happily, and prints readable text. + Malfunction(malfunction::MalfunctionConfig), /// Matrix digital rain effect. Matrix(matrix::MatrixConfig), /// Text expands in a single row or column in the middle of the canvas then out. @@ -90,8 +106,12 @@ pub enum EffectCommand { Rain(rain::RainConfig), /// Prints the input data in a random sequence. Randomsequence(random_sequence::RandomSequenceConfig), + /// Conway's Game of Life runs backward from chaos and resolves into readable text. + Reverselife(reverse_life::ReverseLifeConfig), /// Characters are dispersed and form into spinning rings. Rings(rings::RingsConfig), + /// Curling vines grow pink roses, scatter petals, and flower into the text. + Roses(roses::RosesConfig), /// Text is scattered across the canvas and moves into position. Scattered(scattered::ScatteredConfig), /// Slices the input in half and slides it into place from opposite directions. @@ -110,12 +130,16 @@ pub enum EffectCommand { Sweep(sweep::SweepConfig), /// Create a grid which fills with characters dissolving into the final text. Synthgrid(synthgrid::SynthGridConfig), + /// Rain falls as the sun rises and paints a rainbow across the text. + Sunshower(sunshower::SunshowerConfig), /// Create a thunderstorm in the terminal. Thunderstorm(thunderstorm::ThunderstormConfig), /// Spawn characters jumbled, explode them to the edge of the canvas, then reassemble them in the correct layout. Unstable(unstable::UnstableConfig), /// Lines of characters glitch left and right and lose detail like an old VHS tape. Vhstape(vhstape::VhsTapeConfig), + /// Living stained glass grows, breathes, and collapses into readable text. + Voronoi(voronoi::VoronoiConfig), /// Waves travel across the terminal leaving behind the characters. Waves(waves::WavesConfig), /// Wipes the text across the terminal to reveal characters. @@ -125,10 +149,13 @@ pub enum EffectCommand { impl EffectCommand { pub fn build_effect(&self) -> Box { match self { + EffectCommand::Airstrike(config) => Box::new(airstrike::Airstrike::new(config.clone())), + EffectCommand::Automata(config) => Box::new(automata::Automata::new(config.clone())), EffectCommand::Beams(config) => Box::new(beams::Beams::new(config.clone())), EffectCommand::Binarypath(config) => Box::new(binarypath::BinaryPath::new(config.clone())), EffectCommand::Blackhole(config) => Box::new(blackhole::Blackhole::new(config.clone())), EffectCommand::Bouncyballs(config) => Box::new(bouncyballs::BouncyBalls::new(config.clone())), + EffectCommand::Bubblepop(config) => Box::new(bubble_pop::BubblePop::new(config.clone())), EffectCommand::Bubbles(config) => Box::new(bubbles::Bubbles::new(config.clone())), EffectCommand::Burn(config) => Box::new(burn::Burn::new(config.clone())), EffectCommand::Colorshift(config) => Box::new(colorshift::ColorShift::new(config.clone())), @@ -139,6 +166,7 @@ impl EffectCommand { EffectCommand::Fireworks(config) => Box::new(fireworks::Fireworks::new(config.clone())), EffectCommand::Highlight(config) => Box::new(highlight::Highlight::new(config.clone())), EffectCommand::Laseretch(config) => Box::new(laseretch::LaserEtch::new(config.clone())), + EffectCommand::Malfunction(config) => Box::new(malfunction::Malfunction::new(config.clone())), EffectCommand::Matrix(config) => Box::new(matrix::Matrix::new(config.clone())), EffectCommand::Middleout(config) => Box::new(middleout::Middleout::new(config.clone())), EffectCommand::Orbittingvolley(config) => Box::new(orbittingvolley::OrbittingVolley::new(config.clone())), @@ -147,7 +175,9 @@ impl EffectCommand { EffectCommand::Print(config) => Box::new(print_effect::Print::new(config.clone())), EffectCommand::Rain(config) => Box::new(rain::Rain::new(config.clone())), EffectCommand::Randomsequence(config) => Box::new(random_sequence::RandomSequence::new(config.clone())), + EffectCommand::Reverselife(config) => Box::new(reverse_life::ReverseLife::new(config.clone())), EffectCommand::Rings(config) => Box::new(rings::Rings::new(config.clone())), + EffectCommand::Roses(config) => Box::new(roses::Roses::new(config.clone())), EffectCommand::Scattered(config) => Box::new(scattered::Scattered::new(config.clone())), EffectCommand::Slice(config) => Box::new(slice::Slice::new(config.clone())), EffectCommand::Slide(config) => Box::new(slide::Slide::new(config.clone())), @@ -157,9 +187,11 @@ impl EffectCommand { EffectCommand::Swarm(config) => Box::new(swarm::Swarm::new(config.clone())), EffectCommand::Sweep(config) => Box::new(sweep::Sweep::new(config.clone())), EffectCommand::Synthgrid(config) => Box::new(synthgrid::SynthGrid::new(config.clone())), + EffectCommand::Sunshower(config) => Box::new(sunshower::Sunshower::new(config.clone())), EffectCommand::Thunderstorm(config) => Box::new(thunderstorm::Thunderstorm::new(config.clone())), EffectCommand::Unstable(config) => Box::new(unstable::Unstable::new(config.clone())), EffectCommand::Vhstape(config) => Box::new(vhstape::VhsTape::new(config.clone())), + EffectCommand::Voronoi(config) => Box::new(voronoi::Voronoi::new(config.clone())), EffectCommand::Waves(config) => Box::new(waves::Waves::new(config.clone())), EffectCommand::Wipe(config) => Box::new(wipe::Wipe::new(config.clone())), } @@ -167,10 +199,13 @@ impl EffectCommand { pub fn name(&self) -> &'static str { match self { + EffectCommand::Airstrike(_) => "airstrike", + EffectCommand::Automata(_) => "automata", EffectCommand::Beams(_) => "beams", EffectCommand::Binarypath(_) => "binarypath", EffectCommand::Blackhole(_) => "blackhole", EffectCommand::Bouncyballs(_) => "bouncyballs", + EffectCommand::Bubblepop(_) => "bubblepop", EffectCommand::Bubbles(_) => "bubbles", EffectCommand::Burn(_) => "burn", EffectCommand::Colorshift(_) => "colorshift", @@ -181,6 +216,7 @@ impl EffectCommand { EffectCommand::Fireworks(_) => "fireworks", EffectCommand::Highlight(_) => "highlight", EffectCommand::Laseretch(_) => "laseretch", + EffectCommand::Malfunction(_) => "malfunction", EffectCommand::Matrix(_) => "matrix", EffectCommand::Middleout(_) => "middleout", EffectCommand::Orbittingvolley(_) => "orbittingvolley", @@ -189,7 +225,9 @@ impl EffectCommand { EffectCommand::Print(_) => "print", EffectCommand::Rain(_) => "rain", EffectCommand::Randomsequence(_) => "randomsequence", + EffectCommand::Reverselife(_) => "reverselife", EffectCommand::Rings(_) => "rings", + EffectCommand::Roses(_) => "roses", EffectCommand::Scattered(_) => "scattered", EffectCommand::Slice(_) => "slice", EffectCommand::Slide(_) => "slide", @@ -199,9 +237,11 @@ impl EffectCommand { EffectCommand::Swarm(_) => "swarm", EffectCommand::Sweep(_) => "sweep", EffectCommand::Synthgrid(_) => "synthgrid", + EffectCommand::Sunshower(_) => "sunshower", EffectCommand::Thunderstorm(_) => "thunderstorm", EffectCommand::Unstable(_) => "unstable", EffectCommand::Vhstape(_) => "vhstape", + EffectCommand::Voronoi(_) => "voronoi", EffectCommand::Waves(_) => "waves", EffectCommand::Wipe(_) => "wipe", } diff --git a/src/effects/reverse_life.rs b/src/effects/reverse_life.rs new file mode 100644 index 0000000..7d13601 --- /dev/null +++ b/src/effects/reverse_life.rs @@ -0,0 +1,306 @@ +//! Reverse Life: use the input glyphs as a Conway's Game of Life seed, +//! simulate forward, then play the generations backward into readable text. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_positive_int, parse_symbol, +}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{shift_color_towards, Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct ReverseLifeConfig { + /// Number of Conway generations to simulate before playing them backward. + #[arg(long = "simulation-steps", default_value_t = 52, value_parser = parse_positive_int)] + pub simulation_steps: i64, + + /// Frames to display each reversed generation. + #[arg(long = "generation-time", default_value_t = 4, value_parser = parse_positive_int)] + pub generation_time: i64, + + /// Frames to hold the most evolved, chaotic generation. + #[arg(long = "chaos-hold-time", default_value_t = 65, value_parser = parse_non_negative_int)] + pub chaos_hold_time: i64, + + /// Frames used to exchange the final cell silhouette for the original glyphs. + #[arg(long = "resolve-duration", default_value_t = 150, value_parser = parse_positive_int)] + pub resolve_duration: i64, + + /// Frames to hold the readable final text. + #[arg(long = "final-hold-time", default_value_t = 85, value_parser = parse_non_negative_int)] + pub final_hold_time: i64, + + /// Space separated symbols used for living cells. + #[arg(long = "cell-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["◆", "✦", "•"])] + pub cell_symbols: Vec, + + /// Space separated colors applied smoothly across the living cell field. + #[arg(long = "cell-colors", num_args = 1.., value_parser = parse_color, + default_values = ["075985", "0891b2", "22d3ee", "a78bfa"])] + pub cell_colors: Vec, + + /// Space separated colors for the final readable text gradient. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["22d3ee", "a78bfa", "f8fafc"])] + pub final_gradient_stops: Vec, + + /// Number of steps in the final text gradient. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["18"])] + pub final_gradient_steps: Vec, + + /// Direction of the final text gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +pub struct ReverseLife { + config: ReverseLifeConfig, + snapshots: Vec>, + grid: Vec, + text: Vec, + text_colors: HashMap, + width: usize, + frame: i64, + reverse_end: i64, + resolve_end: i64, + end_frame: i64, +} + +impl ReverseLife { + pub fn new(config: ReverseLifeConfig) -> Self { + Self { + config, + snapshots: Vec::new(), + grid: Vec::new(), + text: Vec::new(), + text_colors: HashMap::new(), + width: 0, + frame: 0, + reverse_end: 0, + resolve_end: 0, + end_frame: 0, + } + } + + fn set_visual(ctx: &mut EngineCtx, id: CharId, symbol: &str, color: Color) { + let uses_pre = ctx.terminal.arena[id.0 as usize].uses_input_preexisting_colors; + ctx.terminal.arena[id.0 as usize].animation.set_appearance( + symbol, + uses_pre, + Some(symbol), + Some(ColorPair::new(Some(color), None)), + ); + } + + fn life_step(current: &[bool], width: usize, height: usize) -> Vec { + let mut next = vec![false; current.len()]; + for row in 0..height { + for column in 0..width { + let mut neighbors = 0; + for row_offset in -1_i64..=1 { + for column_offset in -1_i64..=1 { + if row_offset == 0 && column_offset == 0 { + continue; + } + let other_row = row as i64 + row_offset; + let other_column = column as i64 + column_offset; + if other_row >= 0 + && other_row < height as i64 + && other_column >= 0 + && other_column < width as i64 + && current[other_row as usize * width + other_column as usize] + { + neighbors += 1; + } + } + } + let index = row * width + column; + next[index] = neighbors == 3 || (current[index] && neighbors == 2); + } + } + next + } + + fn display_snapshot(&self, ctx: &mut EngineCtx, snapshot_index: usize) { + let snapshot = &self.snapshots[snapshot_index]; + for (index, &id) in self.grid.iter().enumerate() { + let alive = snapshot[index]; + let trailing = if snapshot_index + 1 < self.snapshots.len() { + let end = (snapshot_index + 5).min(self.snapshots.len()); + self.snapshots[snapshot_index + 1..end].iter().any(|ghost| ghost[index]) + } else { + let start = snapshot_index.saturating_sub(4); + self.snapshots[start..snapshot_index].iter().any(|ghost| ghost[index]) + }; + if alive { + let symbol_index = (index + snapshot_index / 3) % self.config.cell_symbols.len(); + let column = index % self.width; + let color_index = (column * self.config.cell_colors.len() / self.width.max(1) + snapshot_index / 4) + % self.config.cell_colors.len(); + Self::set_visual( + ctx, + id, + &self.config.cell_symbols[symbol_index], + self.config.cell_colors[color_index], + ); + } else if trailing { + Self::set_visual(ctx, id, "·", self.config.cell_colors[0]); + } + ctx.terminal.set_character_visibility(id, alive || trailing); + } + } +} + +impl EffectHooks for ReverseLife { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for ReverseLife { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let left = ctx.terminal.canvas.left; + let right = ctx.terminal.canvas.right; + let bottom = ctx.terminal.canvas.bottom; + let top = ctx.terminal.canvas.top; + self.width = (right - left + 1).max(1) as usize; + let height = (top - bottom + 1).max(1) as usize; + + self.text = ctx.terminal.get_characters( + &mut ctx.rng, + CharacterFilter::default(), + CharacterSort::TopToBottomLeftToRight, + ); + let gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let mapping = gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let mut seed = vec![false; self.width * height]; + for &id in &self.text { + let coord = ctx.terminal.arena[id.0 as usize].input_coord; + let column = (coord.column - left) as usize; + let row = (coord.row - bottom) as usize; + if ctx.terminal.arena[id.0 as usize].input_symbol != " " { + seed[row * self.width + column] = true; + } + self.text_colors.insert(id, *mapping.get(&coord).expect("final gradient coordinate")); + ctx.terminal.set_character_visibility(id, false); + } + + self.snapshots.push(seed); + for _ in 0..self.config.simulation_steps { + let next = Self::life_step(self.snapshots.last().unwrap(), self.width, height); + self.snapshots.push(next); + } + + for row in 0..height { + for column in 0..self.width { + let coord = Coord::new(left + column as i64, bottom + row as i64); + let symbol = ctx.rng.choice(&self.config.cell_symbols).clone(); + let fraction = column as f64 / self.width.max(1) as f64; + let color_index = (fraction * self.config.cell_colors.len() as f64) as usize; + let color = self.config.cell_colors[color_index.min(self.config.cell_colors.len() - 1)]; + let id = ctx.terminal.add_character(&symbol, coord); + ctx.terminal.arena[id.0 as usize].layer = 3; + Self::set_visual(ctx, id, &symbol, color); + self.grid.push(id); + } + } + + self.reverse_end = + self.config.chaos_hold_time + (self.config.simulation_steps + 1) * self.config.generation_time; + self.resolve_end = self.reverse_end + self.config.resolve_duration; + self.end_frame = self.resolve_end + self.config.final_hold_time; + self.frame = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.frame > self.end_frame { + return None; + } + + if self.frame < self.config.chaos_hold_time { + self.display_snapshot(ctx, self.config.simulation_steps as usize); + } else if self.frame < self.reverse_end { + let elapsed = self.frame - self.config.chaos_hold_time; + let generations_back = elapsed / self.config.generation_time; + let snapshot = (self.config.simulation_steps - generations_back).max(0) as usize; + self.display_snapshot(ctx, snapshot); + } else if self.frame < self.resolve_end { + let elapsed = self.frame - self.reverse_end; + let pulse_duration = (self.config.resolve_duration / 5).max(1); + let seed = &self.snapshots[0]; + if elapsed < pulse_duration { + let pulse = elapsed as f64 / pulse_duration as f64; + let white = Color::from_hex("ffffff").unwrap(); + for (index, &id) in self.grid.iter().enumerate() { + if seed[index] { + let symbol = if pulse < 0.45 { "◆" } else { "✦" }; + let column = index % self.width; + let base_index = column * self.config.cell_colors.len() / self.width.max(1); + let base = self.config.cell_colors[base_index.min(self.config.cell_colors.len() - 1)]; + let brightness = (pulse * 1.35).min(1.0); + let color = shift_color_towards(&base, &white, brightness).unwrap(); + Self::set_visual(ctx, id, symbol, color); + } + ctx.terminal.set_character_visibility(id, seed[index]); + } + for &id in &self.text { + ctx.terminal.set_character_visibility(id, false); + } + } else { + for &id in &self.grid { + ctx.terminal.set_character_visibility(id, false); + } + let color_duration = (self.config.resolve_duration - pulse_duration).max(1); + let progress = ((elapsed - pulse_duration) as f64 / color_duration as f64).clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - progress).powi(3); + let white = Color::from_hex("ffffff").unwrap(); + for &id in &self.text { + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + let mut color = + shift_color_towards(&self.config.cell_colors[0], &self.text_colors[&id], eased).unwrap(); + let gleam = ((id.0.wrapping_mul(47) % 100) as f64) / 100.0; + if (gleam - progress).abs() < 0.018 { + color = white; + } + Self::set_visual(ctx, id, &symbol, color); + ctx.terminal.set_character_visibility(id, true); + } + } + } else { + for &id in &self.grid { + ctx.terminal.set_character_visibility(id, false); + } + for &id in &self.text { + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + let white = Color::from_hex("ffffff").unwrap(); + let twinkle = (self.frame + id.0 as i64 * 11) % 43 < 2; + Self::set_visual(ctx, id, &symbol, if twinkle { white } else { self.text_colors[&id] }); + ctx.terminal.set_character_visibility(id, true); + } + } + + self.frame += 1; + Some(ctx.frame()) + } +} diff --git a/src/effects/roses.rs b/src/effects/roses.rs new file mode 100644 index 0000000..5d9951f --- /dev/null +++ b/src/effects/roses.rs @@ -0,0 +1,467 @@ +//! Roses: curling vines climb through the canvas, layered pink roses bloom, +//! petals drift, and the input flowers into a rose-gold gradient. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{ + parse_gradient_direction, parse_gradient_steps, parse_non_negative_int, parse_positive_int, +}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{shift_color_towards, Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct RosesConfig { + /// Frames the two flowering vines take to climb the canvas. + #[arg(long = "vine-duration", default_value_t = 180, value_parser = parse_positive_int)] + pub vine_duration: i64, + + /// Frame on which the first rosebud begins to open. + #[arg(long = "bloom-start", default_value_t = 95, value_parser = parse_non_negative_int)] + pub bloom_start: i64, + + /// Frames each rose takes to unfurl all of its petals. + #[arg(long = "bloom-duration", default_value_t = 85, value_parser = parse_positive_int)] + pub bloom_duration: i64, + + /// Frames between successive rose blooms. + #[arg(long = "bloom-delay", default_value_t = 20, value_parser = parse_non_negative_int)] + pub bloom_delay: i64, + + /// Number of petals that drift from the completed garden. + #[arg(long = "falling-petals", default_value_t = 72, value_parser = parse_positive_int)] + pub falling_petals: i64, + + /// Frame on which the lettering begins to flower into color. + #[arg(long = "text-bloom-start", default_value_t = 285, value_parser = parse_non_negative_int)] + pub text_bloom_start: i64, + + /// Frames used for the sparkling text reveal. + #[arg(long = "text-bloom-duration", default_value_t = 155, value_parser = parse_positive_int)] + pub text_bloom_duration: i64, + + /// Frames to hold the completed rose garden. + #[arg(long = "final-hold-time", default_value_t = 105, value_parser = parse_non_negative_int)] + pub final_hold_time: i64, + + /// Space separated greens used by stems and leaves. + #[arg(long = "vine-colors", num_args = 1.., value_parser = parse_color, + default_values = ["14532d", "15803d", "22c55e", "86efac"])] + pub vine_colors: Vec, + + /// Space separated pinks from the outer petals to the glowing center. + #[arg(long = "rose-colors", num_args = 1.., value_parser = parse_color, + default_values = ["831843", "be185d", "ec4899", "fb7185", "fbcfe8"])] + pub rose_colors: Vec, + + /// Space separated colors for the final readable text. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["f43f5e", "ec4899", "f9a8d4", "fde68a", "fff1f2"])] + pub final_gradient_stops: Vec, + + /// Number of steps in the final text gradient. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["24"])] + pub final_gradient_steps: Vec, + + /// Direction of the final text gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, + + /// Dim color of the lettering before it blooms. + #[arg(long = "shadow-color", default_value = "263b32", value_parser = parse_color)] + pub shadow_color: Color, +} + +struct VineCell { + id: CharId, + reveal: f64, + leaf: bool, + side: usize, +} + +struct RosePetal { + id: CharId, + offset: Coord, + stage: f64, + color_index: usize, + center: bool, +} + +struct Rose { + center: Coord, + petals: Vec, + start: i64, +} + +struct FallingPetal { + id: CharId, + origin: Coord, + born: i64, + lifetime: i64, + drift: f64, + fall_speed: f64, + phase: f64, + color_index: usize, +} + +pub struct Roses { + config: RosesConfig, + vines: Vec, + roses: Vec, + falling_petals: Vec, + text: Vec, + text_colors: HashMap, + text_reveal: HashMap, + frame: i64, + end_frame: i64, +} + +impl Roses { + pub fn new(config: RosesConfig) -> Self { + Self { + config, + vines: Vec::new(), + roses: Vec::new(), + falling_petals: Vec::new(), + text: Vec::new(), + text_colors: HashMap::new(), + text_reveal: HashMap::new(), + frame: 0, + end_frame: 0, + } + } + + fn set_visual(ctx: &mut EngineCtx, id: CharId, symbol: &str, color: Color, bold: bool) { + let uses_pre = ctx.terminal.arena[id.0 as usize].uses_input_preexisting_colors; + ctx.terminal.arena[id.0 as usize].animation.set_appearance( + symbol, + uses_pre, + Some(symbol), + Some(ColorPair::new(Some(color), None)), + ); + if bold { + let visual = ctx.terminal.arena[id.0 as usize].animation.current_character_visual.clone(); + let params = crate::engine::animation::VisualParams { + bold: true, + colors: visual.colors, + fg_color_code: visual.fg_color_code.clone(), + bg_color_code: visual.bg_color_code.clone(), + ..Default::default() + }; + ctx.terminal.arena[id.0 as usize].animation.current_character_visual = + crate::engine::animation::CharacterVisual::new(symbol, params).into(); + } + } + + fn vine_coord(ctx: &EngineCtx, side: usize, t: f64) -> Coord { + let left = ctx.terminal.canvas.left; + let right = ctx.terminal.canvas.right; + let bottom = ctx.terminal.canvas.bottom; + let top = ctx.terminal.canvas.top; + let width = (right - left).max(1) as f64; + let height = (top - bottom).max(1) as f64; + let amplitude = (width / 12.0).clamp(3.0, 7.0); + let wave = (t * std::f64::consts::TAU * 1.75 + side as f64 * 1.4).sin() * amplitude; + let column = if side == 0 { + left as f64 + 4.0 + t * width * 0.28 + wave + } else { + right as f64 - 4.0 - t * width * 0.28 - wave + }; + Coord::new(column.round() as i64, (bottom as f64 + t * height).round() as i64) + } + + fn update_vines(&self, ctx: &mut EngineCtx) { + let progress = (self.frame as f64 / self.config.vine_duration as f64).clamp(0.0, 1.0); + for vine in &self.vines { + if vine.reveal > progress { + ctx.terminal.set_character_visibility(vine.id, false); + continue; + } + let pulse = ((self.frame / 18) as usize + vine.side) % self.config.vine_colors.len(); + let color = self.config.vine_colors[pulse]; + let input_symbol = ctx.terminal.arena[vine.id.0 as usize].input_symbol.clone(); + let symbol = if vine.leaf && (self.frame + vine.id.0 as i64) % 28 < 14 { + if vine.side == 0 { + ")" + } else { + "(" + } + } else { + &input_symbol + }; + Self::set_visual(ctx, vine.id, symbol, color, vine.leaf); + ctx.terminal.set_character_visibility(vine.id, true); + } + } + + fn update_roses(&self, ctx: &mut EngineCtx) { + for rose in &self.roses { + let progress = ((self.frame - rose.start) as f64 / self.config.bloom_duration as f64).clamp(0.0, 1.0); + for petal in &rose.petals { + if self.frame < rose.start || progress < petal.stage { + ctx.terminal.set_character_visibility(petal.id, false); + continue; + } + let local = ((progress - petal.stage) / (1.0 - petal.stage).max(0.01)).clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - local).powi(3); + let coord = Coord::new( + rose.center.column + (petal.offset.column as f64 * eased).round() as i64, + rose.center.row + (petal.offset.row as f64 * eased).round() as i64, + ); + ctx.terminal.arena[petal.id.0 as usize].motion.set_coordinate(coord); + let shimmer = if progress >= 1.0 && (self.frame + petal.id.0 as i64) % 41 < 3 { 1 } else { 0 }; + let color_index = (petal.color_index + shimmer).min(self.config.rose_colors.len() - 1); + let symbol = if petal.center { + "✦" + } else if local < 0.45 { + "•" + } else { + "●" + }; + Self::set_visual(ctx, petal.id, symbol, self.config.rose_colors[color_index], true); + ctx.terminal.set_character_visibility(petal.id, true); + } + } + } + + fn update_falling_petals(&self, ctx: &mut EngineCtx) { + for petal in &self.falling_petals { + let age = self.frame - petal.born; + if age < 0 || age >= petal.lifetime { + ctx.terminal.set_character_visibility(petal.id, false); + continue; + } + let t = age as f64; + let sway = (t * 0.13 + petal.phase).sin() * 2.2; + let coord = Coord::new( + (petal.origin.column as f64 + petal.drift * t + sway).round() as i64, + (petal.origin.row as f64 - petal.fall_speed * t).round() as i64, + ); + ctx.terminal.arena[petal.id.0 as usize].motion.set_coordinate(coord); + let ratio = age as f64 / petal.lifetime as f64; + let symbol = if ratio < 0.70 { "♥" } else { "·" }; + let color_index = + (petal.color_index + (ratio * 2.0).floor() as usize).min(self.config.rose_colors.len() - 1); + Self::set_visual(ctx, petal.id, symbol, self.config.rose_colors[color_index], true); + ctx.terminal.set_character_visibility(petal.id, true); + } + } + + fn update_text(&self, ctx: &mut EngineCtx) { + let progress = ((self.frame - self.config.text_bloom_start) as f64 / self.config.text_bloom_duration as f64) + .clamp(0.0, 1.0); + let white = Color::from_hex("ffffff").unwrap(); + for &id in &self.text { + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + if self.frame < self.config.text_bloom_start { + Self::set_visual(ctx, id, &symbol, self.config.shadow_color, false); + ctx.terminal.set_character_visibility(id, true); + continue; + } + let start = self.text_reveal[&id] * 0.62; + let local = ((progress - start) / 0.38).clamp(0.0, 1.0); + if local <= 0.0 { + continue; + } + if local < 0.16 { + Self::set_visual(ctx, id, "✦", white, true); + } else { + let color_progress = ((local - 0.16) / 0.84).clamp(0.0, 1.0); + let color = shift_color_towards( + &self.config.rose_colors[1.min(self.config.rose_colors.len() - 1)], + &self.text_colors[&id], + 1.0 - (1.0 - color_progress).powi(3), + ) + .unwrap(); + Self::set_visual(ctx, id, &symbol, color, true); + } + ctx.terminal.set_character_visibility(id, true); + } + } +} + +impl EffectHooks for Roses { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Roses { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + self.text = ctx.terminal.get_characters( + &mut ctx.rng, + CharacterFilter::default(), + CharacterSort::TopToBottomLeftToRight, + ); + let gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let mapping = gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let text_width = (ctx.terminal.canvas.text_right - ctx.terminal.canvas.text_left).max(1) as f64; + let text_height = (ctx.terminal.canvas.text_top - ctx.terminal.canvas.text_bottom).max(1) as f64; + for &id in &self.text { + let coord = ctx.terminal.arena[id.0 as usize].input_coord; + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + ctx.terminal.arena[id.0 as usize].layer = 6; + self.text_colors.insert(id, *mapping.get(&coord).expect("rose gradient coordinate")); + let diagonal = ((coord.column - ctx.terminal.canvas.text_left) as f64 / text_width + + (ctx.terminal.canvas.text_top - coord.row) as f64 / text_height) + / 2.0; + let jitter = (id.0.wrapping_mul(89) % 100) as f64 / 1000.0; + self.text_reveal.insert(id, (diagonal + jitter).min(1.0)); + Self::set_visual(ctx, id, &symbol, self.config.shadow_color, false); + ctx.terminal.set_character_visibility(id, true); + } + + let steps = ((ctx.terminal.canvas.top - ctx.terminal.canvas.bottom).max(1) * 4) as usize; + let mut rose_centers = Vec::new(); + for side in 0..2 { + let mut previous = Self::vine_coord(ctx, side, 0.0); + for step in 0..=steps { + let t = step as f64 / steps.max(1) as f64; + let coord = Self::vine_coord(ctx, side, t); + let dx = coord.column - previous.column; + let symbol = if dx > 0 { + "/" + } else if dx < 0 { + "\\" + } else { + "│" + }; + let id = ctx.terminal.add_character(symbol, coord); + ctx.terminal.arena[id.0 as usize].layer = 2; + Self::set_visual(ctx, id, symbol, self.config.vine_colors[0], false); + ctx.terminal.set_character_visibility(id, false); + self.vines.push(VineCell { id, reveal: t * 0.92, leaf: false, side }); + if step % 13 == 7 { + let leaf_coord = Coord::new(coord.column + if side == 0 { 1 } else { -1 }, coord.row); + let leaf_symbol = if side == 0 { ")" } else { "(" }; + let leaf_id = ctx.terminal.add_character(leaf_symbol, leaf_coord); + ctx.terminal.arena[leaf_id.0 as usize].layer = 2; + Self::set_visual( + ctx, + leaf_id, + leaf_symbol, + self.config.vine_colors[2.min(self.config.vine_colors.len() - 1)], + true, + ); + ctx.terminal.set_character_visibility(leaf_id, false); + self.vines.push(VineCell { id: leaf_id, reveal: t * 0.92 + 0.025, leaf: true, side }); + } + previous = coord; + } + for &t in &[0.22, 0.43, 0.65, 0.86] { + rose_centers.push(Self::vine_coord(ctx, side, t)); + } + } + + let petal_layout: [(i64, i64, f64, usize, bool); 23] = [ + (0, 0, 0.00, 4, true), + (-1, 0, 0.16, 3, false), + (1, 0, 0.16, 3, false), + (0, 1, 0.16, 3, false), + (0, -1, 0.16, 3, false), + (-1, 1, 0.31, 2, false), + (1, 1, 0.31, 2, false), + (-1, -1, 0.31, 2, false), + (1, -1, 0.31, 2, false), + (-2, 0, 0.48, 1, false), + (2, 0, 0.48, 1, false), + (0, 2, 0.48, 1, false), + (0, -2, 0.48, 1, false), + (-2, 1, 0.58, 0, false), + (-2, -1, 0.58, 0, false), + (2, 1, 0.58, 0, false), + (2, -1, 0.58, 0, false), + (-1, 2, 0.64, 0, false), + (1, 2, 0.64, 0, false), + (-1, -2, 0.64, 0, false), + (1, -2, 0.64, 0, false), + (-3, 0, 0.72, 0, false), + (3, 0, 0.72, 0, false), + ]; + for (index, ¢er) in rose_centers.iter().enumerate() { + let mut petals = Vec::new(); + for &(dx, dy, stage, color_index, is_center) in &petal_layout { + let id = ctx.terminal.add_character("•", center); + ctx.terminal.arena[id.0 as usize].layer = 4; + Self::set_visual( + ctx, + id, + "•", + self.config.rose_colors[color_index.min(self.config.rose_colors.len() - 1)], + true, + ); + ctx.terminal.set_character_visibility(id, false); + petals.push(RosePetal { + id, + offset: Coord::new(dx, dy), + stage, + color_index: color_index.min(self.config.rose_colors.len() - 1), + center: is_center, + }); + } + self.roses.push(Rose { + center, + petals, + start: self.config.bloom_start + index as i64 * self.config.bloom_delay, + }); + } + + let mut last_petal_death = 0; + for index in 0..self.config.falling_petals { + let rose_index = index as usize % self.roses.len(); + let wave = index as usize / self.roses.len(); + let rose = &self.roses[rose_index]; + let born = rose.start + self.config.bloom_duration + wave as i64 * 13; + let lifetime = ctx.rng.randint(120, 181); + let id = ctx.terminal.add_character("♥", rose.center); + ctx.terminal.arena[id.0 as usize].layer = 5; + ctx.terminal.set_character_visibility(id, false); + self.falling_petals.push(FallingPetal { + id, + origin: rose.center, + born, + lifetime, + drift: ctx.rng.uniform(-0.045, 0.045), + fall_speed: ctx.rng.uniform(0.055, 0.12), + phase: ctx.rng.uniform(0.0, std::f64::consts::TAU), + color_index: ctx.rng.randint(1, self.config.rose_colors.len() as i64) as usize, + }); + last_petal_death = last_petal_death.max(born + lifetime); + } + + let text_end = self.config.text_bloom_start + self.config.text_bloom_duration; + let last_bloom = self.roses.last().map(|rose| rose.start + self.config.bloom_duration).unwrap_or(0); + self.end_frame = text_end.max(last_bloom).max(last_petal_death) + self.config.final_hold_time; + self.frame = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.frame > self.end_frame { + return None; + } + self.update_vines(ctx); + self.update_roses(ctx); + self.update_falling_petals(ctx); + self.update_text(ctx); + self.frame += 1; + Some(ctx.frame()) + } +} diff --git a/src/effects/sunshower.rs b/src/effects/sunshower.rs new file mode 100644 index 0000000..b43de35 --- /dev/null +++ b/src/effects/sunshower.rs @@ -0,0 +1,519 @@ +//! Sunshower: rain falls over the input while a sun rises, paints a rainbow +//! across the canvas, and leaves the text glowing in the same spectrum. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_non_negative_int, parse_positive_float_range, parse_positive_int, parse_symbol}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{shift_color_towards, Color, ColorPair}; + +#[derive(Args, Debug, Clone)] +pub struct SunshowerConfig { + /// Number of independent raindrops in the shower. + #[arg(long = "rain-density", default_value_t = 170, value_parser = parse_positive_int)] + pub rain_density: i64, + + /// Falling speed range of the raindrops in cells per frame. + #[arg(long = "rain-speed", default_value = "0.22-0.58", value_parser = parse_positive_float_range)] + pub rain_speed: (f64, f64), + + /// Space separated list of symbols used for raindrops. + #[arg(long = "rain-symbols", num_args = 1.., value_parser = parse_symbol, + default_values = ["|", "/", ".", ","])] + pub rain_symbols: Vec, + + /// Space separated list of colors used for raindrops. + #[arg(long = "rain-colors", num_args = 1.., value_parser = parse_color, + default_values = ["3977a8", "55a6d9", "8bd3f7", "d4f1ff"])] + pub rain_colors: Vec, + + /// Frame on which the sun begins to rise. + #[arg(long = "sun-start", default_value_t = 75, value_parser = parse_non_negative_int)] + pub sun_start: i64, + + /// Number of frames the sunrise takes. + #[arg(long = "sunrise-duration", default_value_t = 95, value_parser = parse_positive_int)] + pub sunrise_duration: i64, + + /// Color of the sun and its rays. + #[arg(long = "sun-color", default_value = "ffd447", value_parser = parse_color)] + pub sun_color: Color, + + /// Frame on which the rainbow begins to grow. + #[arg(long = "rainbow-start", default_value_t = 150, value_parser = parse_non_negative_int)] + pub rainbow_start: i64, + + /// Number of frames the rainbow takes to cross the canvas. + #[arg(long = "rainbow-duration", default_value_t = 180, value_parser = parse_positive_int)] + pub rainbow_duration: i64, + + /// Space separated colors for the rainbow, ordered outermost to innermost. + #[arg(long = "rainbow-colors", num_args = 1.., value_parser = parse_color, + default_values = ["ff3b30", "ff9500", "ffd60a", "34c759", "00b7ff", "5856d6", "af52de"])] + pub rainbow_colors: Vec, + + /// Frames to hold the completed sunshower before the weather clears. + #[arg(long = "hold-time", default_value_t = 90, value_parser = parse_non_negative_int)] + pub hold_time: i64, + + /// Frames used to clear the rain, sun, and rainbow from the canvas. + #[arg(long = "clear-duration", default_value_t = 75, value_parser = parse_positive_int)] + pub clear_duration: i64, + + /// Frames to admire the rainbow-colored text after the sky clears. + #[arg(long = "final-hold-time", default_value_t = 75, value_parser = parse_non_negative_int)] + pub final_hold_time: i64, +} + +struct Raindrop { + id: CharId, + column: i64, + row: f64, + speed: f64, +} + +struct RainbowCell { + id: CharId, + reveal: f64, +} + +struct Cloud { + parts: Vec<(CharId, Coord)>, + column: f64, + row: i64, + speed: f64, +} + +struct Splash { + id: CharId, + column: i64, + phase: i64, + period: i64, +} + +struct Sparkle { + id: CharId, + coord: Coord, + phase: i64, +} + +pub struct Sunshower { + config: SunshowerConfig, + rain: Vec, + sun: Vec<(CharId, Coord)>, + clouds: Vec, + splashes: Vec, + sparkles: Vec, + rainbow: Vec, + text_colors: HashMap, + text: Vec, + frame: i64, + clear_start: i64, + end_frame: i64, +} + +impl Sunshower { + pub fn new(config: SunshowerConfig) -> Self { + Self { + config, + rain: Vec::new(), + sun: Vec::new(), + clouds: Vec::new(), + splashes: Vec::new(), + sparkles: Vec::new(), + rainbow: Vec::new(), + text_colors: HashMap::new(), + text: Vec::new(), + frame: 0, + clear_start: 0, + end_frame: 0, + } + } + + fn set_visual(ctx: &mut EngineCtx, id: CharId, symbol: &str, color: Color, bold: bool) { + let uses_pre = ctx.terminal.arena[id.0 as usize].uses_input_preexisting_colors; + ctx.terminal.arena[id.0 as usize].animation.set_appearance( + symbol, + uses_pre, + Some(symbol), + Some(ColorPair::new(Some(color), None)), + ); + if bold { + let visual = ctx.terminal.arena[id.0 as usize].animation.current_character_visual.clone(); + let params = crate::engine::animation::VisualParams { + bold: true, + colors: visual.colors, + fg_color_code: visual.fg_color_code.clone(), + bg_color_code: visual.bg_color_code.clone(), + ..Default::default() + }; + ctx.terminal.arena[id.0 as usize].animation.current_character_visual = + crate::engine::animation::CharacterVisual::new(symbol, params).into(); + } + } + + fn rainbow_color(&self, fraction: f64) -> Color { + let index = (fraction.clamp(0.0, 0.999_999) * self.config.rainbow_colors.len() as f64) as usize; + self.config.rainbow_colors[index.min(self.config.rainbow_colors.len() - 1)] + } + + fn update_rain(&mut self, ctx: &mut EngineCtx) { + let bottom = ctx.terminal.canvas.bottom; + let top = ctx.terminal.canvas.top; + let tapering = self.frame >= self.clear_start; + let clear_progress = if tapering { + ((self.frame - self.clear_start) as f64 / self.config.clear_duration as f64).clamp(0.0, 1.0) + } else { + 0.0 + }; + for drop in &mut self.rain { + let fallaway_order = ((drop.id.0.wrapping_mul(2654435761) % 1000) as f64) / 1000.0; + if tapering && fallaway_order < clear_progress { + ctx.terminal.set_character_visibility(drop.id, false); + continue; + } + drop.row -= drop.speed; + if drop.row < bottom as f64 { + if tapering { + ctx.terminal.set_character_visibility(drop.id, false); + continue; + } + drop.column = ctx.terminal.canvas.random_column(&mut ctx.rng, false); + drop.row = top as f64 + ctx.rng.uniform(0.0, 8.0); + drop.speed = ctx.rng.uniform(self.config.rain_speed.0, self.config.rain_speed.1); + } + let coord = Coord::new(drop.column, drop.row.round() as i64); + ctx.terminal.arena[drop.id.0 as usize].motion.set_coordinate(coord); + ctx.terminal.set_character_visibility(drop.id, true); + } + } + + fn update_sun(&mut self, ctx: &mut EngineCtx) { + if self.frame < self.config.sun_start { + return; + } + let target = Coord::new( + (ctx.terminal.canvas.right - 8).max(ctx.terminal.canvas.left + 4), + (ctx.terminal.canvas.top - 5).max(ctx.terminal.canvas.bottom + 5), + ); + // Begin with even the highest ray below the canvas so the complete + // sun visibly rises through the bottom edge rather than popping in. + let start_row = ctx.terminal.canvas.bottom - 7; + let progress = + ((self.frame - self.config.sun_start) as f64 / self.config.sunrise_duration as f64).clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - progress).powi(3); + let mut center_row = start_row as f64 + (target.row - start_row) as f64 * eased; + if self.frame >= self.clear_start { + let clear = ((self.frame - self.clear_start) as f64 / self.config.clear_duration as f64).clamp(0.0, 1.0); + center_row += clear * 7.0; + } + for &(id, offset) in &self.sun { + let coord = Coord::new(target.column + offset.column, center_row.round() as i64 + offset.row); + ctx.terminal.arena[id.0 as usize].motion.set_coordinate(coord); + let is_ray = matches!(ctx.terminal.arena[id.0 as usize].input_symbol.as_str(), "|" | "/" | "\\" | "-"); + let twinkle = !is_ray || (self.frame + id.0 as i64) % 8 < 5; + let clear_end = self.clear_start + self.config.clear_duration; + ctx.terminal.set_character_visibility( + id, + self.frame < clear_end && clear_end - self.frame > offset.column.abs() && twinkle, + ); + } + } + + fn update_clouds(&mut self, ctx: &mut EngineCtx) { + let clearing = + ((self.frame - self.clear_start).max(0) as f64 / self.config.clear_duration as f64).clamp(0.0, 1.0); + for cloud in &mut self.clouds { + cloud.column += cloud.speed; + if cloud.column > ctx.terminal.canvas.right as f64 + 12.0 { + cloud.column = ctx.terminal.canvas.left as f64 - 12.0; + } + for &(id, offset) in &cloud.parts { + let coord = Coord::new(cloud.column.round() as i64 + offset.column, cloud.row + offset.row); + ctx.terminal.arena[id.0 as usize].motion.set_coordinate(coord); + let order = ((id.0.wrapping_mul(97) % 100) as f64) / 100.0; + ctx.terminal.set_character_visibility(id, order > clearing); + } + } + } + + fn update_splashes(&mut self, ctx: &mut EngineCtx) { + for splash in &self.splashes { + let age = (self.frame + splash.phase) % splash.period; + let active = self.frame < self.clear_start && age < 5; + let (symbol, row) = match age { + 0 => ("·", ctx.terminal.canvas.bottom), + 1 | 2 => ("v", ctx.terminal.canvas.bottom + 1), + _ => ("_", ctx.terminal.canvas.bottom), + }; + ctx.terminal.arena[splash.id.0 as usize].motion.set_coordinate(Coord::new(splash.column, row)); + Self::set_visual( + ctx, + splash.id, + symbol, + self.config.rain_colors[2.min(self.config.rain_colors.len() - 1)], + false, + ); + ctx.terminal.set_character_visibility(splash.id, active); + } + } + + fn update_sparkles(&mut self, ctx: &mut EngineCtx) { + let start = self.config.rainbow_start + self.config.rainbow_duration * 3 / 4; + for sparkle in &self.sparkles { + let age = self.frame - start + sparkle.phase; + let pulse = age.rem_euclid(24); + let active = self.frame >= start && self.frame <= self.end_frame && pulse < 9; + let symbol = match pulse { + 0..=2 => ".", + 3..=5 => "+", + _ => "*", + }; + let fraction = (sparkle.coord.column - ctx.terminal.canvas.left) as f64 + / (ctx.terminal.canvas.right - ctx.terminal.canvas.left).max(1) as f64; + Self::set_visual(ctx, sparkle.id, symbol, self.rainbow_color(fraction), true); + ctx.terminal.set_character_visibility(sparkle.id, active); + } + } + + fn update_rainbow(&mut self, ctx: &mut EngineCtx) { + if self.frame < self.config.rainbow_start { + for cell in &self.rainbow { + ctx.terminal.set_character_visibility(cell.id, false); + } + return; + } + let progress = + ((self.frame - self.config.rainbow_start) as f64 / self.config.rainbow_duration as f64).clamp(0.0, 1.0); + let clearing = if self.frame >= self.clear_start { + ((self.frame - self.clear_start) as f64 / self.config.clear_duration as f64).clamp(0.0, 1.0) + } else { + 0.0 + }; + for cell in &self.rainbow { + let visible = cell.reveal <= progress && cell.reveal > clearing; + ctx.terminal.set_character_visibility(cell.id, visible); + } + + let left = ctx.terminal.canvas.text_left; + let width = (ctx.terminal.canvas.text_right - left).max(1); + for &id in &self.text { + let coord = ctx.terminal.arena[id.0 as usize].input_coord; + let reveal = (coord.column - left) as f64 / width as f64; + if reveal <= progress { + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + Self::set_visual(ctx, id, &symbol, self.text_colors[&id], true); + } + } + } +} + +impl EffectHooks for Sunshower { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Sunshower { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let dim = Color::from_hex("526675").unwrap(); + self.text = ctx.terminal.get_characters( + &mut ctx.rng, + CharacterFilter::default(), + CharacterSort::TopToBottomLeftToRight, + ); + let left = ctx.terminal.canvas.text_left; + let right = ctx.terminal.canvas.text_right; + let bottom = ctx.terminal.canvas.text_bottom; + let top = ctx.terminal.canvas.text_top; + let width = (right - left).max(1); + let height = (top - bottom).max(1); + for &id in &self.text { + let coord = ctx.terminal.arena[id.0 as usize].input_coord; + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + let diagonal = + (((coord.column - left) as f64 / width as f64) + ((top - coord.row) as f64 / height as f64)) / 2.0; + self.text_colors.insert(id, self.rainbow_color(diagonal)); + ctx.terminal.arena[id.0 as usize].layer = 5; + Self::set_visual(ctx, id, &symbol, dim, false); + ctx.terminal.set_character_visibility(id, true); + } + + for _ in 0..self.config.rain_density { + let column = ctx.terminal.canvas.random_column(&mut ctx.rng, false); + let row = ctx.rng.uniform(bottom as f64, top as f64 + 8.0); + let speed = ctx.rng.uniform(self.config.rain_speed.0, self.config.rain_speed.1); + let symbol = ctx.rng.choice(&self.config.rain_symbols).clone(); + let color = *ctx.rng.choice(&self.config.rain_colors); + let id = ctx.terminal.add_character(&symbol, Coord::new(column, row.round() as i64)); + ctx.terminal.arena[id.0 as usize].layer = 3; + Self::set_visual(ctx, id, &symbol, color, false); + self.rain.push(Raindrop { id, column, row, speed }); + } + + let sun_sprite = [ + ("|", 0, 5), + ("/", -5, 4), + ("\\", 5, 4), + ("—", -7, 0), + ("—", 7, 0), + ("\\", -5, -4), + ("/", 5, -4), + ("|", 0, -5), + ("▄", -2, 2), + ("▄", -1, 2), + ("▄", 0, 2), + ("▄", 1, 2), + ("▄", 2, 2), + ("█", -3, 1), + ("█", -2, 1), + ("█", -1, 1), + ("█", 0, 1), + ("█", 1, 1), + ("█", 2, 1), + ("█", 3, 1), + ("█", -3, 0), + ("█", -2, 0), + ("█", -1, 0), + ("█", 0, 0), + ("█", 1, 0), + ("█", 2, 0), + ("█", 3, 0), + ("█", -3, -1), + ("█", -2, -1), + ("█", -1, -1), + ("█", 0, -1), + ("█", 1, -1), + ("█", 2, -1), + ("█", 3, -1), + ("▀", -2, -2), + ("▀", -1, -2), + ("▀", 0, -2), + ("▀", 1, -2), + ("▀", 2, -2), + ]; + for (symbol, dx, dy) in sun_sprite { + let id = ctx.terminal.add_character(symbol, Coord::new(0, 0)); + ctx.terminal.arena[id.0 as usize].layer = 6; + Self::set_visual(ctx, id, symbol, self.config.sun_color, true); + self.sun.push((id, Coord::new(dx, dy))); + } + + let cloud_sprite = [ + ("▄", -2, 2), + ("▄", -1, 2), + ("▄", 0, 2), + ("▄", 1, 2), + ("▒", -4, 1), + ("▓", -3, 1), + ("▓", -2, 1), + ("▓", -1, 1), + ("▓", 0, 1), + ("▓", 1, 1), + ("▓", 2, 1), + ("▓", 3, 1), + ("▒", 4, 1), + ("▀", -6, 0), + ("▀", -5, 0), + ("▀", -4, 0), + ("▀", -3, 0), + ("▀", -2, 0), + ("▀", -1, 0), + ("▀", 0, 0), + ("▀", 1, 0), + ("▀", 2, 0), + ("▀", 3, 0), + ("▀", 4, 0), + ("▀", 5, 0), + ("▀", 6, 0), + ]; + let cloud_color = Color::from_hex("a9bdca").unwrap(); + for index in 0..3 { + let mut parts = Vec::new(); + for (symbol, dx, dy) in cloud_sprite { + let id = ctx.terminal.add_character(symbol, Coord::new(0, 0)); + ctx.terminal.arena[id.0 as usize].layer = 4; + Self::set_visual(ctx, id, symbol, cloud_color, symbol == "▓"); + parts.push((id, Coord::new(dx, dy))); + } + self.clouds.push(Cloud { + parts, + column: ctx.terminal.canvas.left as f64 - 8.0 + index as f64 * 31.0, + row: ctx.terminal.canvas.top - 3 - (index % 2) as i64 * 4, + speed: 0.035 + index as f64 * 0.014, + }); + } + + for index in 0..48 { + let column = ctx.terminal.canvas.random_column(&mut ctx.rng, false); + let id = ctx.terminal.add_character("·", Coord::new(column, ctx.terminal.canvas.bottom)); + ctx.terminal.arena[id.0 as usize].layer = 4; + self.splashes.push(Splash { id, column, phase: index * 7, period: 19 + index % 23 }); + } + + let center_col = (left + right) / 2; + let base_row = bottom + 1; + let outer_x = (width / 2).max(4); + let outer_y = (outer_x / 2).min((height - 2).max(3)); + let colors = self.config.rainbow_colors.clone(); + let sky = Color::from_hex("12121a").unwrap(); + for (band, color) in colors.into_iter().enumerate() { + let radius_x = (outer_x - band as i64 * 2).max(2); + let radius_y = (outer_y - band as i64).max(1); + for dx in -radius_x..=radius_x { + if (dx + band as i64 * 2).rem_euclid(4) == 0 { + continue; + } + let normalized = dx as f64 / radius_x as f64; + let dy = ((1.0 - normalized * normalized).max(0.0).sqrt() * radius_y as f64).round() as i64; + let coord = Coord::new(center_col + dx, base_row + dy); + if coord.column < ctx.terminal.canvas.left || coord.column > ctx.terminal.canvas.right { + continue; + } + let symbol = if (dx + band as i64).rem_euclid(2) == 0 { "░" } else { "·" }; + let transparent = shift_color_towards(&color, &sky, 0.14).unwrap(); + let id = ctx.terminal.add_character(symbol, coord); + ctx.terminal.arena[id.0 as usize].layer = -1; + Self::set_visual(ctx, id, symbol, transparent, false); + self.rainbow.push(RainbowCell { id, reveal: (dx + radius_x) as f64 / (radius_x * 2).max(1) as f64 }); + } + } + + for index in 0..42 { + let coord = Coord::new( + ctx.terminal.canvas.random_column(&mut ctx.rng, false), + ctx.terminal.canvas.random_row(&mut ctx.rng, false), + ); + let id = ctx.terminal.add_character(".", coord); + ctx.terminal.arena[id.0 as usize].layer = 7; + ctx.terminal.set_character_visibility(id, false); + self.sparkles.push(Sparkle { id, coord, phase: index * 11 }); + } + + self.clear_start = self.config.rainbow_start + self.config.rainbow_duration + self.config.hold_time; + self.end_frame = self.clear_start + self.config.clear_duration + self.config.final_hold_time; + self.frame = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.frame > self.end_frame { + return None; + } + self.update_rain(ctx); + self.update_clouds(ctx); + self.update_splashes(ctx); + self.update_sun(ctx); + self.update_rainbow(ctx); + self.update_sparkles(ctx); + self.frame += 1; + Some(ctx.frame()) + } +} diff --git a/src/effects/voronoi.rs b/src/effects/voronoi.rs new file mode 100644 index 0000000..39a6df9 --- /dev/null +++ b/src/effects/voronoi.rs @@ -0,0 +1,500 @@ +//! Voronoi: living stained glass grows from drifting sites, breathes as a +//! jewel-colored mosaic, then collapses into crisp readable text. + +use std::collections::HashMap; + +use clap::Args; + +use crate::cli::parse_color; +use crate::effects::common::{parse_gradient_direction, parse_gradient_steps, parse_positive_int}; +use crate::engine::character::CharId; +use crate::engine::ctx::{EffectHooks, EngineCtx}; +use crate::engine::effect::Effect; +use crate::engine::error::EngineError; +use crate::engine::events::EffectCallback; +use crate::engine::terminal::{CharacterFilter, CharacterSort}; +use crate::utils::geometry::Coord; +use crate::utils::graphics::{shift_color_towards, Color, ColorPair, Gradient, GradientDirection}; + +#[derive(Args, Debug, Clone)] +pub struct VoronoiConfig { + /// Number of moving sites which shape the stained-glass territories. + #[arg(long = "site-count", default_value_t = 18, value_parser = parse_positive_int)] + pub site_count: i64, + + /// Maximum horizontal and vertical drift of each site, in cells. + #[arg(long = "site-drift", default_value_t = 4, value_parser = parse_positive_int)] + pub site_drift: i64, + + /// Frames over which the glass grows outward from the sites. + #[arg(long = "growth-duration", default_value_t = 135, value_parser = parse_positive_int)] + pub growth_duration: i64, + + /// Frames the completed living mosaic spends breathing and reshaping. + #[arg(long = "living-duration", default_value_t = 175, value_parser = parse_positive_int)] + pub living_duration: i64, + + /// Frames the crystalline shards take to stream into the text. + #[arg(long = "shatter-duration", default_value_t = 165, value_parser = parse_positive_int)] + pub shatter_duration: i64, + + /// Frames to admire the final lettering after the glass clears. + #[arg(long = "final-hold-time", default_value_t = 95, value_parser = parse_positive_int)] + pub final_hold_time: i64, + + /// Jewel colors assigned to the moving Voronoi territories. + #[arg(long = "glass-colors", num_args = 1.., value_parser = parse_color, + default_values = ["ff2d95", "ff6b35", "ffd60a", "34c759", "00d9ff", "3478f6", "7c3aed", "c026d3"])] + pub glass_colors: Vec, + + /// Color used to illuminate the borders between territories. + #[arg(long = "border-color", default_value = "fff7ed", value_parser = parse_color)] + pub border_color: Color, + + /// Space separated colors for the final readable text. + #[arg(long = "final-gradient-stops", num_args = 1.., value_parser = parse_color, + default_values = ["00d9ff", "7c3aed", "ff2d95", "ffd60a", "fff7ed"])] + pub final_gradient_stops: Vec, + + /// Number of steps in the final text gradient. + #[arg(long = "final-gradient-steps", num_args = 1.., value_parser = parse_gradient_steps, + default_values = ["28"])] + pub final_gradient_steps: Vec, + + /// Direction of the final text gradient. + #[arg(long = "final-gradient-direction", default_value = "diagonal", value_parser = parse_gradient_direction)] + pub final_gradient_direction: GradientDirection, +} + +struct Site { + id: CharId, + base_x: f64, + base_y: f64, + amplitude_x: f64, + amplitude_y: f64, + phase_x: f64, + phase_y: f64, + color_index: usize, +} + +struct GlassCell { + id: CharId, + coord: Coord, + target: Coord, + growth_order: f64, + shatter_order: f64, +} + +pub struct Voronoi { + config: VoronoiConfig, + sites: Vec, + cells: Vec, + text: Vec, + text_colors: HashMap, + text_reveal: HashMap, + width: usize, + height: usize, + frame: i64, + growth_end: i64, + shatter_start: i64, + shatter_end: i64, + end_frame: i64, +} + +impl Voronoi { + pub fn new(config: VoronoiConfig) -> Self { + Self { + config, + sites: Vec::new(), + cells: Vec::new(), + text: Vec::new(), + text_colors: HashMap::new(), + text_reveal: HashMap::new(), + width: 0, + height: 0, + frame: 0, + growth_end: 0, + shatter_start: 0, + shatter_end: 0, + end_frame: 0, + } + } + + fn set_visual(ctx: &mut EngineCtx, id: CharId, symbol: &str, color: Color, bold: bool) { + let uses_pre = ctx.terminal.arena[id.0 as usize].uses_input_preexisting_colors; + ctx.terminal.arena[id.0 as usize].animation.set_appearance( + symbol, + uses_pre, + Some(symbol), + Some(ColorPair::new(Some(color), None)), + ); + if bold { + let visual = ctx.terminal.arena[id.0 as usize].animation.current_character_visual.clone(); + let params = crate::engine::animation::VisualParams { + bold: true, + colors: visual.colors, + fg_color_code: visual.fg_color_code.clone(), + bg_color_code: visual.bg_color_code.clone(), + ..Default::default() + }; + ctx.terminal.arena[id.0 as usize].animation.current_character_visual = + crate::engine::animation::CharacterVisual::new(symbol, params).into(); + } + } + + fn site_positions(&self, frame: i64) -> Vec<(f64, f64)> { + let motion_frame = frame.min(self.shatter_start) as f64; + self.sites + .iter() + .map(|site| { + ( + site.base_x + (motion_frame * 0.020 + site.phase_x).sin() * site.amplitude_x, + site.base_y + (motion_frame * 0.017 + site.phase_y).cos() * site.amplitude_y, + ) + }) + .collect() + } + + fn nearest_site(&self, coord: Coord, positions: &[(f64, f64)]) -> (usize, f64) { + positions + .iter() + .enumerate() + .map(|(index, &(x, y))| { + let dx = coord.column as f64 - x; + let dy = (coord.row as f64 - y) * 1.75; + (index, dx * dx + dy * dy) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .unwrap_or((0, 0.0)) + } + + fn owner_map(&self, positions: &[(f64, f64)]) -> Vec { + self.cells.iter().map(|cell| self.nearest_site(cell.coord, positions).0).collect() + } + + fn is_border(&self, index: usize, owners: &[usize]) -> bool { + let owner = owners[index]; + let column = index % self.width; + let row = index / self.width; + (column > 0 && owners[index - 1] != owner) + || (column + 1 < self.width && owners[index + 1] != owner) + || (row > 0 && owners[index - self.width] != owner) + || (row + 1 < self.height && owners[index + self.width] != owner) + } + + fn border_symbol(&self, index: usize, owners: &[usize]) -> &'static str { + let owner = owners[index]; + let column = index % self.width; + let row = index / self.width; + let left = column > 0 && owners[index - 1] != owner; + let right = column + 1 < self.width && owners[index + 1] != owner; + let down = row > 0 && owners[index - self.width] != owner; + let up = row + 1 < self.height && owners[index + self.width] != owner; + match (left || right, down || up) { + (true, true) if (left && up) || (right && down) => "╲", + (true, true) => "╱", + (true, false) => "│", + (false, true) => "─", + (false, false) => "·", + } + } + + fn update_sites(&self, ctx: &mut EngineCtx, positions: &[(f64, f64)]) { + let visible = self.frame < self.shatter_start; + for (index, site) in self.sites.iter().enumerate() { + let coord = Coord::new(positions[index].0.round() as i64, positions[index].1.round() as i64); + ctx.terminal.arena[site.id.0 as usize].motion.set_coordinate(coord); + let symbol = if (self.frame + index as i64 * 5) % 24 < 8 { "✦" } else { "◆" }; + Self::set_visual(ctx, site.id, symbol, self.config.border_color, true); + ctx.terminal.set_character_visibility(site.id, visible); + } + } + + fn update_intro_text(&self, ctx: &mut EngineCtx) { + let hold = 18; + let intro_end = self.config.growth_duration * 2 / 3; + let erosion = ((self.frame - hold).max(0) as f64 / (intro_end - hold).max(1) as f64).clamp(0.0, 1.0); + let black = Color::from_hex("080812").unwrap(); + let white = Color::from_hex("ffffff").unwrap(); + for &id in &self.text { + let order = (id.0.wrapping_mul(83) % 1000) as f64 / 1000.0; + if self.frame >= intro_end || order < erosion { + ctx.terminal.set_character_visibility(id, false); + continue; + } + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + let glint = (self.frame + id.0 as i64 * 7) % 47 < 2; + let base = self.text_colors[&id]; + let color = if glint { white } else { shift_color_towards(&base, &black, 0.62).unwrap() }; + Self::set_visual(ctx, id, &symbol, color, glint); + ctx.terminal.set_character_visibility(id, true); + } + } + + fn update_living_glass(&self, ctx: &mut EngineCtx, positions: &[(f64, f64)], owners: &[usize]) { + let progress = (self.frame as f64 / self.config.growth_duration as f64).clamp(0.0, 1.0); + let black = Color::from_hex("080812").unwrap(); + for (index, cell) in self.cells.iter().enumerate() { + if cell.growth_order > progress { + ctx.terminal.set_character_visibility(cell.id, false); + continue; + } + ctx.terminal.arena[cell.id.0 as usize].motion.set_coordinate(cell.coord); + let owner = owners[index]; + let site = &self.sites[owner]; + let base = self.config.glass_colors[site.color_index]; + let border = self.is_border(index, owners); + let distance = self.nearest_site(cell.coord, positions).1.sqrt(); + let at_site = distance < 1.4; + let dx = cell.coord.column as f64 - positions[owner].0; + let dy = (cell.coord.row as f64 - positions[owner].1) * 1.75; + let facet_down = (dx - dy).abs() < 0.72 && distance > 2.0; + let facet_up = (dx + dy).abs() < 0.72 && distance > 2.0; + let faceted = facet_down || facet_up; + let breath = ((self.frame as f64 * 0.045 + owner as f64 * 1.7).sin() + 1.0) * 0.5; + let color = if border { + shift_color_towards(&base, &self.config.border_color, 0.48 + breath * 0.30).unwrap() + } else if faceted { + shift_color_towards(&base, &self.config.border_color, 0.22 + breath * 0.18).unwrap() + } else { + shift_color_towards(&base, &black, 0.40 - breath * 0.10).unwrap() + }; + let symbol = if at_site { + "✦" + } else if border { + self.border_symbol(index, owners) + } else if facet_down { + "╲" + } else if facet_up { + "╱" + } else if (index + (self.frame / 16) as usize) % 11 == 0 { + "◇" + } else { + "·" + }; + Self::set_visual(ctx, cell.id, symbol, color, border || faceted || at_site); + ctx.terminal.set_character_visibility(cell.id, true); + } + } + + fn update_shatter(&self, ctx: &mut EngineCtx, owners: &[usize]) { + let progress = ((self.frame - self.shatter_start) as f64 / self.config.shatter_duration as f64).clamp(0.0, 1.0); + for (index, cell) in self.cells.iter().enumerate() { + let local = ((progress - cell.shatter_order) / (1.0 - cell.shatter_order)).clamp(0.0, 1.0); + if local >= 1.0 { + ctx.terminal.set_character_visibility(cell.id, false); + continue; + } + let eased = local * local * (3.0 - 2.0 * local); + let coord = Coord::new( + (cell.coord.column as f64 + (cell.target.column - cell.coord.column) as f64 * eased).round() as i64, + (cell.coord.row as f64 + (cell.target.row - cell.coord.row) as f64 * eased).round() as i64, + ); + ctx.terminal.arena[cell.id.0 as usize].motion.set_coordinate(coord); + let base = self.config.glass_colors[self.sites[owners[index]].color_index]; + let color = shift_color_towards(&base, &self.config.border_color, local * 0.82).unwrap(); + let symbol = if local < 0.24 && self.is_border(index, owners) { + self.border_symbol(index, owners) + } else if local < 0.55 { + if (index + owners[index]) % 2 == 0 { + "◇" + } else { + "◆" + } + } else if local < 0.84 { + "✦" + } else { + "·" + }; + Self::set_visual(ctx, cell.id, symbol, color, local > 0.30); + ctx.terminal.set_character_visibility(cell.id, true); + } + + let white = Color::from_hex("ffffff").unwrap(); + for &id in &self.text { + let start = self.text_reveal[&id]; + if progress < start { + ctx.terminal.set_character_visibility(id, false); + continue; + } + let local = ((progress - start) / (1.0 - start).max(0.01)).clamp(0.0, 1.0); + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + if local < 0.18 { + Self::set_visual(ctx, id, "✦", white, true); + } else { + let color = shift_color_towards( + &self.config.glass_colors[id.0 as usize % self.config.glass_colors.len()], + &self.text_colors[&id], + ((local - 0.18) / 0.82).clamp(0.0, 1.0), + ) + .unwrap(); + Self::set_visual(ctx, id, &symbol, color, true); + } + ctx.terminal.set_character_visibility(id, true); + } + } + + fn update_final(&self, ctx: &mut EngineCtx) { + for cell in &self.cells { + ctx.terminal.set_character_visibility(cell.id, false); + } + let white = Color::from_hex("ffffff").unwrap(); + for &id in &self.text { + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + let twinkle = (self.frame + id.0 as i64 * 13) % 53 < 2; + Self::set_visual(ctx, id, &symbol, if twinkle { white } else { self.text_colors[&id] }, true); + ctx.terminal.set_character_visibility(id, true); + } + } +} + +impl EffectHooks for Voronoi { + fn dispatch_callback(&mut self, _ctx: &mut EngineCtx, _character: CharId, _callback: &EffectCallback) {} +} + +impl Effect for Voronoi { + fn build(&mut self, ctx: &mut EngineCtx) -> Result<(), EngineError> { + let left = ctx.terminal.canvas.left; + let right = ctx.terminal.canvas.right; + let bottom = ctx.terminal.canvas.bottom; + let top = ctx.terminal.canvas.top; + self.width = (right - left + 1).max(1) as usize; + self.height = (top - bottom + 1).max(1) as usize; + + self.text = ctx.terminal.get_characters( + &mut ctx.rng, + CharacterFilter::default(), + CharacterSort::TopToBottomLeftToRight, + ); + let gradient = + Gradient::new(&self.config.final_gradient_stops, &self.config.final_gradient_steps, false, false) + .map_err(EngineError::Other)?; + let mapping = gradient + .build_coordinate_color_mapping( + ctx.terminal.canvas.text_bottom, + ctx.terminal.canvas.text_top, + ctx.terminal.canvas.text_left, + ctx.terminal.canvas.text_right, + self.config.final_gradient_direction, + ) + .map_err(EngineError::Other)?; + let text_width = (ctx.terminal.canvas.text_right - ctx.terminal.canvas.text_left).max(1) as f64; + for &id in &self.text { + let coord = ctx.terminal.arena[id.0 as usize].input_coord; + ctx.terminal.arena[id.0 as usize].layer = 6; + self.text_colors.insert(id, *mapping.get(&coord).expect("Voronoi gradient coordinate")); + let across = (coord.column - ctx.terminal.canvas.text_left) as f64 / text_width; + let jitter = (id.0.wrapping_mul(97) % 100) as f64 / 1000.0; + self.text_reveal.insert(id, 0.43 + across * 0.23 + jitter); + let black = Color::from_hex("080812").unwrap(); + let initial = shift_color_towards(&self.text_colors[&id], &black, 0.62).unwrap(); + let symbol = ctx.terminal.arena[id.0 as usize].input_symbol.clone(); + Self::set_visual(ctx, id, &symbol, initial, false); + ctx.terminal.set_character_visibility(id, true); + } + + let non_space_text: Vec = self + .text + .iter() + .filter(|&&id| ctx.terminal.arena[id.0 as usize].input_symbol != " ") + .map(|&id| ctx.terminal.arena[id.0 as usize].input_coord) + .collect(); + let columns = ((self.config.site_count as f64 * 1.8).sqrt().ceil() as usize).max(1); + let rows = (self.config.site_count as usize).div_ceil(columns).max(1); + for index in 0..self.config.site_count as usize { + let column = index % columns; + let row = index / columns; + let (base_x, base_y) = if non_space_text.is_empty() { + ( + left as f64 + (column + 1) as f64 / (columns + 1) as f64 * (right - left) as f64, + bottom as f64 + (row + 1) as f64 / (rows + 1) as f64 * (top - bottom) as f64, + ) + } else { + let sample = + ((index * non_space_text.len()) / self.config.site_count as usize).min(non_space_text.len() - 1); + let coord = non_space_text[sample]; + (coord.column as f64 + ctx.rng.uniform(-0.65, 0.65), coord.row as f64 + ctx.rng.uniform(-0.35, 0.35)) + }; + let id = ctx.terminal.add_character("✦", Coord::new(base_x.round() as i64, base_y.round() as i64)); + ctx.terminal.arena[id.0 as usize].layer = 7; + ctx.terminal.set_character_visibility(id, false); + self.sites.push(Site { + id, + base_x, + base_y, + amplitude_x: ctx.rng.uniform(0.6, (self.config.site_drift as f64).max(0.6)), + amplitude_y: ctx.rng.uniform(0.4, (self.config.site_drift as f64 * 0.65).max(0.4)), + phase_x: ctx.rng.uniform(0.0, std::f64::consts::TAU), + phase_y: ctx.rng.uniform(0.0, std::f64::consts::TAU), + color_index: index % self.config.glass_colors.len(), + }); + } + + let base_positions: Vec<(f64, f64)> = self.sites.iter().map(|site| (site.base_x, site.base_y)).collect(); + let fallback = Coord::new((left + right) / 2, (bottom + top) / 2); + let mut distances = Vec::with_capacity(self.width * self.height); + for row in 0..self.height { + for column in 0..self.width { + let coord = Coord::new(left + column as i64, bottom + row as i64); + let (_, distance) = self.nearest_site(coord, &base_positions); + distances.push(distance.sqrt()); + let target = non_space_text + .iter() + .min_by_key(|target| { + let dx = target.column - coord.column; + let dy = target.row - coord.row; + dx * dx + 3 * dy * dy + }) + .copied() + .unwrap_or(fallback); + let id = ctx.terminal.add_character("░", coord); + ctx.terminal.arena[id.0 as usize].layer = 2; + ctx.terminal.set_character_visibility(id, false); + let hash = (coord.column as u64) + .wrapping_mul(0x9e37_79b9) + .wrapping_add((coord.row as u64).wrapping_mul(0x85eb_ca6b)); + self.cells.push(GlassCell { + id, + coord, + target, + growth_order: 0.0, + shatter_order: (hash % 10_000) as f64 / 10_000.0 * 0.24, + }); + } + } + let max_distance = distances.iter().copied().fold(1.0_f64, f64::max); + for (cell, distance) in self.cells.iter_mut().zip(distances) { + let radial = distance / max_distance; + let jitter = (cell.id.0.wrapping_mul(61) % 100) as f64 / 1000.0; + cell.growth_order = (radial * 0.86 + jitter).min(1.0); + } + + self.growth_end = self.config.growth_duration; + self.shatter_start = self.growth_end + self.config.living_duration; + self.shatter_end = self.shatter_start + self.config.shatter_duration; + self.end_frame = self.shatter_end + self.config.final_hold_time; + self.frame = 0; + Ok(()) + } + + fn next_frame(&mut self, ctx: &mut EngineCtx) -> Option { + if self.frame > self.end_frame { + return None; + } + let positions = self.site_positions(self.frame); + let owners = self.owner_map(&positions); + if self.frame < self.shatter_start { + self.update_living_glass(ctx, &positions, &owners); + self.update_sites(ctx, &positions); + self.update_intro_text(ctx); + } else if self.frame < self.shatter_end { + self.update_sites(ctx, &positions); + self.update_shatter(ctx, &owners); + } else { + self.update_sites(ctx, &positions); + self.update_final(ctx); + } + self.frame += 1; + Some(ctx.frame()) + } +} diff --git a/tools/demo/build_gallery.py b/tools/demo/build_gallery.py index 367b7b4..55a03d2 100644 --- a/tools/demo/build_gallery.py +++ b/tools/demo/build_gallery.py @@ -37,6 +37,38 @@ extra_args[k] = v.strip() effects = sorted(p.stem for p in gif_dir.glob("*.gif")) +helps.setdefault( + "airstrike", + "ASCII planes dive into the text, scattering it through fire and debris before it reassembles", +) +helps.setdefault( + "automata", + "An elementary cellular automaton grows a fractal lattice whose cells stream into the text", +) +helps.setdefault( + "bubblepop", + "Rainbow-shimmering bubbles float upward, pop, and reveal the text", +) +helps.setdefault( + "malfunction", + "A printer types nonsense, recovers happily, and prints readable text", +) +helps.setdefault( + "reverselife", + "Conway's Game of Life runs backward from chaos and resolves into readable text", +) +helps.setdefault( + "roses", + "Curling vines grow pink roses, scatter petals, and flower into the text", +) +helps.setdefault( + "sunshower", + "Rain falls as the sun rises and paints a rainbow across the text", +) +helps.setdefault( + "voronoi", + "Living crystal grows from the text, breathes as a faceted Voronoi mosaic, then returns as readable lettering", +) cards = [] for name in effects: @@ -54,7 +86,7 @@ total_kb = sum((gif_dir / f"{n}.gif").stat().st_size for n in effects) // 1024 -html = f"""ttfx — all 37 effects +html = f"""ttfx — all {len(effects)} effects