feat(city): animation primitives and effects

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 17:34:04 +02:00
parent 68be96a8b2
commit 0e14cd14fc
3 changed files with 248 additions and 0 deletions

View File

@@ -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<Item = (u16, u16, char)> {
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);
}
}

143
crates/city/src/anim/mod.rs Normal file
View File

@@ -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));
}
}

View File

@@ -3,6 +3,7 @@
// Screens, widgets and overlays land task by task; lifted once the app is wired up. // Screens, widgets and overlays land task by task; lifted once the app is wired up.
#![allow(dead_code)] #![allow(dead_code)]
mod anim;
mod app; mod app;
mod cli; mod cli;
mod save; mod save;