diff --git a/crates/city/src/anim/effects.rs b/crates/city/src/anim/effects.rs new file mode 100644 index 0000000..92f8a86 --- /dev/null +++ b/crates/city/src/anim/effects.rs @@ -0,0 +1,104 @@ +//! Effects as pure functions of progress and tick: typewriter, slide, flicker, pulse, glitch. + +use citygen::rng::hash64; +use ratatui::layout::Rect; + +/// Characters a glitch effect scatters over the frame. +pub const GLITCH_CHARS: &[char] = &[ + '▓', '▒', '░', '█', '▀', '▄', '▌', '▐', '╳', '╬', '≋', '≈', 'ヲ', 'ア', 'ツ', 'ネ', '0', '1', '/', + '\\', +]; + +/// The first `p` share of `text`, by characters, never splitting one. +pub fn typewriter(text: &str, p: f32) -> &str { + let n = text.chars().count(); + let shown = ((n as f32) * p.clamp(0.0, 1.0)).floor() as usize; + let end = text + .char_indices() + .nth(shown) + .map(|(i, _)| i) + .unwrap_or(text.len()); + &text[..end] +} + +/// Horizontal offset for something sliding in from the left: -width at 0, 0 at 1. +pub fn slide_x(width: u16, p: f32) -> i32 { + let p = p.clamp(0.0, 1.0); + (-(width as f32) * (1.0 - p)).round() as i32 +} + +/// Brightness of a neon tube in 0..=1. Broken tubes drop out on a per-variant schedule. +pub fn flicker(tick: u64, variant: u8, broken: bool) -> f32 { + if broken { + let phase = (tick / 3 + variant as u64 * 7) % 64; + if matches!(phase, 10..=13 | 20 | 21 | 40) { + return 0.05; + } + } + 0.8 + 0.2 * ((tick as f32) / 9.0 + variant as f32).sin() +} + +/// Smooth 0..=1 oscillation with the given period in ticks. +pub fn pulse(tick: u64, period: u64) -> f32 { + let period = period.max(1) as f32; + 0.5 + 0.5 * (2.0 * std::f32::consts::PI * (tick as f32) / period).sin() +} + +/// Cells to corrupt at progress `p` of a glitch wipe: dense at the start, gone at the end. +pub fn glitch_cells(area: Rect, tick: u64, p: f32) -> impl Iterator { + let total = area.width as usize * area.height as usize; + let n = ((total as f32) * 0.25 * (1.0 - p.clamp(0.0, 1.0))) as usize; + (0..n).filter_map(move |i| { + if area.width == 0 || area.height == 0 { + return None; + } + let h = hash64(tick, i as u64); + let x = area.x + (h % area.width as u64) as u16; + let y = area.y + ((h >> 16) % area.height as u64) as u16; + let ch = GLITCH_CHARS[((h >> 32) % GLITCH_CHARS.len() as u64) as usize]; + Some((x, y, ch)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::layout::Rect; + + #[test] + fn typewriter_is_char_safe() { + assert_eq!(typewriter("ヲァィ", 0.34), "ヲ"); + assert_eq!(typewriter("abc", 1.0), "abc"); + assert_eq!(typewriter("abc", 0.0), ""); + } + + #[test] + fn slide_offset_runs_from_minus_width_to_zero() { + assert_eq!(slide_x(20, 0.0), -20); + assert_eq!(slide_x(20, 1.0), 0); + assert!(slide_x(20, 0.5) < 0 && slide_x(20, 0.5) > -20); + } + + #[test] + fn flicker_is_deterministic_and_bounded() { + for tick in 0..200 { + let v = flicker(tick, 7, true); + assert!((0.0..=1.0).contains(&v)); + assert_eq!(v, flicker(tick, 7, true)); + } + assert!(flicker(3, 0, false) > 0.6); + assert!((0..200).any(|t| flicker(t, 7, true) < 0.1)); + assert!((0.0..=1.0).contains(&pulse(17, 30))); + } + + #[test] + fn glitch_density_shrinks_with_progress() { + let a = glitch_cells(Rect::new(0, 0, 50, 10), 1, 0.1).count(); + let b = glitch_cells(Rect::new(0, 0, 50, 10), 1, 0.9).count(); + assert!(a > b); + for (x, y, _) in glitch_cells(Rect::new(3, 4, 50, 10), 2, 0.2) { + assert!((3..53).contains(&x) && (4..14).contains(&y)); + } + assert_eq!(glitch_cells(Rect::new(0, 0, 0, 0), 1, 0.1).count(), 0); + } +} diff --git a/crates/city/src/anim/mod.rs b/crates/city/src/anim/mod.rs new file mode 100644 index 0000000..5d82b53 --- /dev/null +++ b/crates/city/src/anim/mod.rs @@ -0,0 +1,143 @@ +//! Tick-driven animation primitives: easing, tweens, timelines. + +pub mod effects; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Easing { + Linear, + OutCubic, + InOutCubic, + OutBack, +} + +impl Easing { + /// Maps linear progress in 0..=1 to eased progress. + pub fn apply(self, t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + match self { + Easing::Linear => t, + Easing::OutCubic => 1.0 - (1.0 - t).powi(3), + Easing::InOutCubic => { + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } + } + Easing::OutBack => { + let c1 = 1.70158; + let c3 = c1 + 1.0; + 1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2) + } + } + } +} + +/// One animated value running from `start` for `dur` ticks. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tween { + pub start: u64, + pub dur: u64, + pub easing: Easing, +} + +impl Tween { + pub fn new(start: u64, dur: u64, easing: Easing) -> Self { + Tween { start, dur, easing } + } + + /// Eased progress in 0..=1 at `tick`. + pub fn progress(&self, tick: u64) -> f32 { + if tick <= self.start { + return 0.0; + } + if self.dur == 0 || tick >= self.start + self.dur { + return 1.0; + } + self.easing + .apply((tick - self.start) as f32 / self.dur as f32) + } + + pub fn done(&self, tick: u64) -> bool { + tick >= self.start + self.dur + } + + pub fn active(&self, tick: u64) -> bool { + tick >= self.start && !self.done(tick) + } +} + +/// Named tweens, looked up by label. +#[derive(Clone, Debug, Default)] +pub struct Timeline { + tweens: Vec<(&'static str, Tween)>, +} + +impl Timeline { + pub fn new(tweens: Vec<(&'static str, Tween)>) -> Self { + Timeline { tweens } + } + + /// Progress of `label`; unknown labels count as finished. + pub fn progress(&self, label: &str, tick: u64) -> f32 { + self.tweens + .iter() + .find(|(l, _)| *l == label) + .map(|(_, t)| t.progress(tick)) + .unwrap_or(1.0) + } + + pub fn all_done(&self, tick: u64) -> bool { + self.tweens.iter().all(|(_, t)| t.done(tick)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tween_clamps_and_eases() { + let t = Tween { + start: 10, + dur: 10, + easing: Easing::Linear, + }; + assert_eq!(t.progress(5), 0.0); + assert_eq!(t.progress(15), 0.5); + assert_eq!(t.progress(30), 1.0); + assert!(t.done(20)); + assert!(t.active(12)); + assert!(!t.active(5)); + assert!(Easing::OutCubic.apply(0.5) > 0.5); + assert_eq!(Easing::OutBack.apply(1.0), 1.0); + assert_eq!(Easing::InOutCubic.apply(0.0), 0.0); + } + + #[test] + fn timeline_lookup() { + let tl = Timeline::new(vec![ + ( + "a", + Tween { + start: 0, + dur: 10, + easing: Easing::Linear, + }, + ), + ( + "b", + Tween { + start: 10, + dur: 10, + easing: Easing::Linear, + }, + ), + ]); + assert_eq!(tl.progress("a", 5), 0.5); + assert_eq!(tl.progress("b", 5), 0.0); + assert_eq!(tl.progress("zzz", 5), 1.0); + assert!(!tl.all_done(15)); + assert!(tl.all_done(20)); + } +} diff --git a/crates/city/src/main.rs b/crates/city/src/main.rs index 64f9e34..f9bed1f 100644 --- a/crates/city/src/main.rs +++ b/crates/city/src/main.rs @@ -3,6 +3,7 @@ // Screens, widgets and overlays land task by task; lifted once the app is wired up. #![allow(dead_code)] +mod anim; mod app; mod cli; mod save;