mod choreo;
mod draw;
mod geom;
mod physics;
use std::f64::consts::{FRAC_PI_2, TAU};
use self::choreo::{BEATS_PER_SECOND, Choreo, HAT_TOSS, LOOP_BEATS, Skeleton};
use self::draw::Brush;
use self::geom::{V2, lerp, v2};
use self::physics::{Flight, SCARF_POINTS};
use super::canvas::{LumaCanvas, Rect};
use crate::FrameRate;
const STAGE_TALL: f64 = 1.45;
const STAGE_WIDE: f64 = 1.7;
const FLOOR: f64 = 0.07;
const MIN_UNIT_PX: f64 = 14.0;
const BOOMBOX_ROOM: f64 = 0.65;
const SPOT_MAX: f64 = 2.8;
#[derive(Clone, Copy, Debug)]
struct View {
unit: f64,
x0: f64,
floor: f64,
}
pub(crate) struct Dancer {
clip: Rect,
view: View,
size: V2,
choreo: Choreo,
boombox: Option<f64>,
toss: Toss,
nominal: u32,
loop_frames: u64,
scarf: Vec<[V2; SCARF_POINTS]>,
}
impl Dancer {
pub fn new(stage: Rect, fps: FrameRate) -> Option<Dancer> {
let (w, h) = (f64::from(stage.width()), f64::from(stage.height()));
let unit = (h / STAGE_TALL).min(w / STAGE_WIDE);
if unit < MIN_UNIT_PX {
return None;
}
let width = w / unit;
let has_boombox = width >= STAGE_WIDE + BOOMBOX_ROOM + 0.5;
let spot = (width - if has_boombox { BOOMBOX_ROOM } else { 0.0 }).min(SPOT_MAX);
let left = (width - spot - if has_boombox { BOOMBOX_ROOM } else { 0.0 }) / 2.0;
let choreo = Choreo {
home: left + spot / 2.0,
travel: ((spot - STAGE_WIDE) / 0.8).clamp(0.0, 1.0),
};
let nominal = fps.nominal();
let loop_frames = u64::from(nominal) * (LOOP_BEATS / BEATS_PER_SECOND) as u64;
let knot_at =
|seconds: f64| scarf_knot(&Skeleton::new(&choreo.pose(seconds * BEATS_PER_SECOND)));
Some(Dancer {
clip: stage,
view: View {
unit,
x0: f64::from(stage.x0),
floor: f64::from(stage.y1) - FLOOR * unit,
},
size: v2(width, h / unit - FLOOR),
choreo,
boombox: has_boombox.then_some(left + spot + BOOMBOX_ROOM / 2.0),
toss: Toss::new(&choreo),
nominal,
loop_frames,
scarf: physics::scarf(knot_at, loop_frames as usize, f64::from(nominal)),
})
}
pub fn draw(&self, frame_id: u32, canvas: &mut LumaCanvas<'_>) {
let frame = u64::from(frame_id) % self.loop_frames;
let beat = frame as f64 * BEATS_PER_SECOND / f64::from(self.nominal);
let skeleton = Skeleton::new(&self.choreo.pose(beat));
let (hat_at, hat_angle) = self.toss.hat(beat, &skeleton);
let mut brush = Brush::new(canvas, self.clip, self.view);
draw::floor(&mut brush, self.size.x, beat, &skeleton);
draw::shadow(&mut brush, &skeleton);
if let Some(x) = self.boombox {
draw::boombox(&mut brush, x, beat);
draw::notes(&mut brush, x, beat, self.size.y);
}
draw::scarf_tail(&mut brush, &self.scarf[frame as usize]);
draw::body(&mut brush, &skeleton);
draw::scarf_wrap(&mut brush, &skeleton);
draw::cane(&mut brush, &skeleton);
draw::hat(&mut brush, hat_at, hat_angle);
draw::dust(&mut brush, self.choreo.home, beat);
}
}
fn scarf_knot(s: &Skeleton) -> V2 {
s.neck + s.up.rot(-FRAC_PI_2) * 0.02 - s.up * 0.012
}
struct Toss {
flight: Flight,
angles: (f64, f64),
}
impl Toss {
fn new(choreo: &Choreo) -> Self {
let (b0, b1) = HAT_TOSS;
let (from, a0) = Skeleton::new(&choreo.pose(b0)).hat();
let (to, a1) = Skeleton::new(&choreo.pose(b1)).hat();
let turn = if to.x <= from.x { TAU } else { -TAU };
Toss {
flight: Flight::new(from, to, (b1 - b0) / BEATS_PER_SECOND),
angles: (a0, a1 + turn),
}
}
fn hat(&self, beat: f64, worn_by: &Skeleton) -> (V2, f64) {
let (b0, b1) = HAT_TOSS;
if (b0..b1).contains(&beat) {
let s = (beat - b0) / (b1 - b0);
let at = self.flight.at(s * self.flight.time);
(at, lerp(self.angles.0, self.angles.1, s))
} else {
worn_by.hat()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pattern::canvas::{BLACK, LumaImage};
fn dancer(w: i32, h: i32, fps: &str) -> (Dancer, Rect) {
let stage = Rect::new(20, 30, w, h);
(Dancer::new(stage, fps.parse().unwrap()).unwrap(), stage)
}
#[test]
fn needs_room() {
let fps = "30".parse().unwrap();
assert!(Dancer::new(Rect::new(0, 0, 40, 20), fps).is_none());
assert!(Dancer::new(Rect::new(0, 0, 60, 40), fps).is_some());
}
#[test]
fn the_boombox_comes_when_there_is_room() {
let (wide, _) = dancer(700, 160, "30");
let (narrow, _) = dancer(170, 160, "30");
let box_x = wide.boombox.unwrap();
assert!(box_x > wide.choreo.home + 1.0 && box_x < wide.size.x);
assert!(wide.choreo.travel > 0.99);
assert!(narrow.boombox.is_none());
assert!(narrow.choreo.travel < 0.1);
}
#[test]
fn the_hat_lands_on_the_head() {
let (d, _) = dancer(700, 160, "30");
let (b0, b1) = HAT_TOSS;
for (b, eps) in [(b0, 0.0), (b0 - 1e-6, 1e-6), (b1 - 1e-6, 1e-6), (b1, 0.0)] {
let worn = Skeleton::new(&d.choreo.pose(b)).hat();
let (at, angle) = d.toss.hat(b, &Skeleton::new(&d.choreo.pose(b)));
assert!(
(at - worn.0).len() < 1e-4 + eps * 10.0,
"beat {b}: {at:?} vs {:?}",
worn.0
);
let turned = (angle - worn.1).rem_euclid(TAU);
assert!(
!(1e-3..=TAU - 1e-3).contains(&turned),
"beat {b}: angle {angle} vs {}",
worn.1
);
}
let mid = d
.toss
.hat((b0 + b1) / 2.0, &Skeleton::new(&d.choreo.pose(b0)))
.0;
assert!(mid.y > 1.05 && mid.y < d.size.y - 0.12, "apex {mid:?}");
}
#[test]
fn draws_only_on_the_stage_and_moves() {
for (w, h, fps) in [
(700, 160, "30"),
(200, 300, "29.97"),
(300, 300, "120"),
(90, 50, "15"),
] {
let (d, stage) = dancer(w, h, fps);
let size = (w as usize + 60, h as usize + 80);
let mut seen = Vec::new();
for frame in [0, 7, 100, 239, 240, 1000] {
let mut img = LumaImage::new(size.0, size.1, BLACK);
d.draw(frame, &mut img.canvas());
let view = img.view();
let mut lit = 0;
for y in 0..size.1 {
for x in 0..size.0 {
let inside = (stage.x0..stage.x1).contains(&(x as i32))
&& (stage.y0..stage.y1).contains(&(y as i32));
let v = view.get(x, y);
assert!(
inside || v == BLACK,
"{w}x{h}: frame {frame} drew at ({x}, {y})"
);
lit += usize::from(v > 128);
}
}
assert!(lit > 50, "{w}x{h}: frame {frame} is nearly empty");
seen.push(img.data);
}
assert_ne!(seen[0], seen[1], "{w}x{h}: frames 0 and 7 look the same");
assert_ne!(seen[1], seen[2]);
}
}
#[test]
fn loops_every_eight_seconds() {
for fps in ["30", "29.97", "25", "60"] {
let (d, _) = dancer(500, 160, fps);
let n = d.loop_frames as u32;
let render = |f| {
let mut img = LumaImage::new(560, 240, BLACK);
d.draw(f, &mut img.canvas());
img.data
};
assert_eq!(render(3), render(n + 3), "{fps}");
assert_eq!(
n,
"8".parse::<f64>().unwrap() as u32 * fps.parse::<FrameRate>().unwrap().nominal()
);
}
}
}