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 across the canvas to reveal uncolored text, reverse sweep to color the text | synthgrid

Create a grid which fills with characters dissolving into the final text |
| thunderstorm

Create a thunderstorm in the terminal | unstable

Spawn characters jumbled, explode them to the edge of the canvas, then reassemble them in the correct layout |
| vhstape

Lines of characters glitch left and right and lose detail like an old VHS tape | waves

Waves travel across the terminal leaving behind the characters |
-| wipe

Wipes the text across the terminal to reveal characters | |
+| wipe

Wipes the text across the terminal to reveal characters | airstrike

ASCII planes dive into the text, scattering it through fire and debris before it reassembles |
+| automata

An elementary cellular automaton grows a fractal lattice whose cells stream into the text | malfunction

A printer types nonsense, recovers happily, and prints readable text |
+| reverselife

Conway's Game of Life runs backward from chaos and resolves into readable text | roses

Curling vines grow pink roses, scatter petals, and flower into the text |
+| sunshower

Rain falls as the sun rises and paints a rainbow across the text | voronoi

Living crystal grows from the text, breathes as a faceted Voronoi mosaic, then returns as readable lettering |
+| 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
|