Skip to main content

dragon/
dragon.rs

1//! The dragon demo.
2//!
3//!   cargo run --example dragon                       # live reveal in your terminal
4//!   cargo run --example dragon -- --snapshots        # staged text frames (no TTY needed)
5//!   cargo run --example dragon -- --art path/to.txt  # bring your own ASCII art
6//!
7//! By default it reveals a procedurally generated serpent, a single, perfectly
8//! 8-connected stroke, so the geodesic spine-trace paints it tip-to-tip. Point
9//! `--art` at any file to watch arbitrary art reveal (imperfect art leans on the
10//! island fallback and still looks intentional).
11
12use std::io::IsTerminal;
13
14use inkling::{
15    art::Art,
16    frame,
17    ordering::{Geodesic, GeodesicReport, Ordering, StartHint},
18};
19
20fn main() {
21    let args: Vec<String> = std::env::args().skip(1).collect();
22    let snapshots = args.iter().any(|a| a == "--snapshots");
23
24    let art = match arg_value(&args, "--art") {
25        Some(path) => match std::fs::read_to_string(&path) {
26            Ok(text) => Art::parse(&text),
27            Err(e) => {
28                eprintln!("inkling: could not read {path}: {e}");
29                std::process::exit(1);
30            }
31        },
32        None => Art::parse(&serpent(64, 13)),
33    };
34
35    let ordering = Geodesic {
36        start: StartHint::TopLeft,
37    };
38    let GeodesicReport {
39        ink_cells,
40        connected_cells,
41        spine_length,
42    } = ordering.diagnose(&art);
43    let ranks = ordering.rank(&art);
44
45    eprintln!(
46        "inkling · {ink_cells} ink cells · {connected_cells} on the spine \
47         ({:.0}% connected) · spine length {spine_length}",
48        100.0 * connected_cells as f32 / ink_cells.max(1) as f32,
49    );
50
51    // Headless / piped / explicit: print staged text frames and exit.
52    if snapshots || !std::io::stdout().is_terminal() {
53        for p in [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] {
54            println!("\n── progress {:>3.0}% {}", p * 100.0, "─".repeat(28));
55            print!("{}", frame::to_string(&art, &ranks, p));
56        }
57        return;
58    }
59
60    #[cfg(feature = "terminal")]
61    {
62        use inkling::{
63            easing::Easing,
64            render::{animate, Style},
65        };
66        use std::time::Duration;
67        if let Err(e) = animate(
68            &art,
69            &ranks,
70            Style::default(),
71            Duration::from_millis(3500),
72            Easing::EaseInOutCubic,
73        ) {
74            eprintln!("inkling: render error: {e}");
75        }
76    }
77}
78
79/// Read the value following `key` in `args` (supports `--key value` and `--key=value`).
80fn arg_value(args: &[String], key: &str) -> Option<String> {
81    let mut it = args.iter();
82    while let Some(a) = it.next() {
83        if a == key {
84            return it.next().cloned();
85        }
86        if let Some(v) = a.strip_prefix(&format!("{key}=")) {
87            return Some(v.to_string());
88        }
89    }
90    None
91}
92
93/// Generate an Eastern-style serpent as a single 8-connected stroke: a sine-wave
94/// body with crest spikes and a small head. Connectivity is guaranteed by
95/// filling any vertical gap between adjacent columns, so the spine-trace reveals
96/// it flawlessly from tail to head.
97// Columns are scanned by index because each row `y` is *computed* from `x`
98// (y = sine(x)); writing `grid[y][x]` is the clearest expression of that.
99#[allow(clippy::needless_range_loop)]
100fn serpent(width: usize, height: usize) -> String {
101    use std::f32::consts::TAU;
102
103    let amp = ((height as f32) - 3.0).max(1.0) / 2.0;
104    let mid = (height as f32 - 1.0) / 2.0;
105    let period = (width as f32) / 2.0; // two coils across the width
106
107    let y_at = |x: usize| -> usize {
108        let yf = mid - amp * ((x as f32) / period * TAU).sin();
109        yf.round().clamp(0.0, height as f32 - 1.0) as usize
110    };
111
112    let mut grid = vec![vec![' '; width]; height];
113    let mut prev_y = y_at(0);
114    for x in 0..width {
115        let y = y_at(x);
116        // Bridge any vertical gap so the body is one continuous stroke.
117        if x > 0 && y.abs_diff(prev_y) > 1 {
118            for row in grid[y.min(prev_y)..=y.max(prev_y)].iter_mut() {
119                if row[x] == ' ' {
120                    row[x] = '|';
121                }
122            }
123        }
124        grid[y][x] = match prev_y {
125            _ if x == 0 => '~',
126            py if y < py => '/',
127            py if y > py => '\\',
128            _ => '~',
129        };
130        prev_y = y;
131    }
132
133    // Spikes on the crests (local highs), one row above the body.
134    for x in 1..width.saturating_sub(1) {
135        let y = y_at(x);
136        if y > 0 && y < y_at(x - 1) && y <= y_at(x + 1) {
137            grid[y - 1][x] = '^';
138        }
139    }
140
141    // A small head at the leading (right) tip.
142    let (hx, hy) = (width - 1, y_at(width - 1));
143    grid[hy][hx] = '>';
144    if hy > 0 {
145        grid[hy - 1][hx.saturating_sub(1)] = 'o'; // eye
146    }
147
148    grid.into_iter()
149        .map(|row| row.into_iter().collect::<String>())
150        .collect::<Vec<_>>()
151        .join("\n")
152}