feat(city): animation primitives and effects
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
104
crates/city/src/anim/effects.rs
Normal file
104
crates/city/src/anim/effects.rs
Normal 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
143
crates/city/src/anim/mod.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user