diff --git a/assets/scene-trophies.gif b/assets/scene-trophies.gif new file mode 100644 index 0000000..2f74c41 Binary files /dev/null and b/assets/scene-trophies.gif differ diff --git a/crates/awan-core/src/lib.rs b/crates/awan-core/src/lib.rs index 98e1fb0..6d196d5 100644 --- a/crates/awan-core/src/lib.rs +++ b/crates/awan-core/src/lib.rs @@ -53,6 +53,12 @@ pub mod stats { pub use crate::scene::stats::{PANEL, SLOTS, chars_at, panel_at, typing}; } +/// Where the trophy shelf hangs and how much of it he's filled. The labels +/// under the cups are the reader's numbers — the engine never sees them. +pub mod trophies { + pub use crate::scene::trophies::{PITCH, SHELF, SLOTS, landed, stand_pct}; +} + /// Where the contribution wall hangs, how far up it is and how lit its month /// is. The /// squares are the renderer's job — the engine never sees anyone's numbers. diff --git a/crates/awan-core/src/reel.rs b/crates/awan-core/src/reel.rs index cb69deb..14fe30e 100644 --- a/crates/awan-core/src/reel.rs +++ b/crates/awan-core/src/reel.rs @@ -28,6 +28,7 @@ pub enum Act { Campfire, Stats, Contributions, + Trophies, Sleep, Dance, Soccer, diff --git a/crates/awan-core/src/scene/mod.rs b/crates/awan-core/src/scene/mod.rs index b15810a..774b04a 100644 --- a/crates/awan-core/src/scene/mod.rs +++ b/crates/awan-core/src/scene/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod soccer; pub(crate) mod song; pub(crate) mod stats; pub(crate) mod street; +pub(crate) mod trophies; pub(crate) mod wander; use crate::grid::{Grid, blit}; @@ -90,6 +91,7 @@ pub(crate) fn scene_for(act: crate::reel::Act) -> Scene { Sing => scene(song::DUR, false, song::sing, ""), Campfire => scene(campfire::DUR, false, campfire::campfire, ""), Stats => scene(stats::DUR, false, stats::stats, ""), + Trophies => scene(trophies::DUR, false, trophies::trophies, ""), Contributions => scene(contributions::DUR, false, contributions::contributions, ""), Sleep => scene(sleep::DUR, false, sleep::sleep, ""), Dance => scene(dance::DANCE_TICKS, false, dance::dance, ""), diff --git a/crates/awan-core/src/scene/trophies.rs b/crates/awan-core/src/scene/trophies.rs new file mode 100644 index 0000000..8cf18de --- /dev/null +++ b/crates/awan-core/src/scene/trophies.rs @@ -0,0 +1,177 @@ +//! The trophy shelf: he steps aside, a plank drops in beside him, and cups +//! land on it one at a time while he watches. Then it folds away and he strolls +//! back — the same shape as the stats readout, because it's the same problem: +//! something to show him, with nowhere to put it if he's standing in front. +//! +//! Trophies are *objects*, not cards. That's the whole reason this scene exists +//! in his grammar rather than beside it: the engine draws crates, ovens, cakes +//! and rockets, and a grid of boxes with letter-ranks in them is a language +//! from somebody else's app. He can't pick up a card. +//! +//! The engine builds the shelf and says which cups have landed ([`SHELF`], +//! [`landed`], [`stand_pct`]); the labels under them are the reader's numbers, +//! so the renderer owns those — the same split the readout and the wall use. + +use crate::grid::{Grid, blit}; +use crate::palette::Role; +use crate::pose::{EyeMode, LegsMode, Pose}; + +pub(super) const DUR: i32 = 128; +const DROP: i32 = 8; // the plank arrives +const SET: i32 = 16; // he sets off — from home, because every scene starts there +const STEP: i32 = 2; // ticks per cell — never skip, never stutter +const SHIFT: i32 = 10; // how far LEFT he steps, to leave the shelf room +const WALK_END: i32 = SET + SHIFT * STEP; +/// Ticks between one cup landing and the next. +const LAND: i32 = 12; +/// He's seen enough. The shelf folds *here* — before he walks back, because +/// home is underneath the labels and he'd walk straight through them. +const LOOK_END: i32 = SET + LAND * SLOTS as i32 + 12; +const FOLD: i32 = 6; +const HOME: i32 = LOOK_END + FOLD; +const HOME_END: i32 = HOME + SHIFT * STEP; + +/// How many cups the shelf holds. +/// +/// Three, and the count comes *last*. A cup needs a bowl, handles, a stem and a +/// base or it isn't a cup — that's five cells, and no fewer: the first draft +/// picked four slots, shrank the art to three cells to make them fit, and +/// rendered four gold letter-Ts. He takes eleven of the stage's thirty-two +/// cells; nineteen are left; three five-cell cups with air between them is +/// nineteen exactly. Fit the count to the art, never the art to the count. +pub const SLOTS: usize = 3; +/// The shelf, in cells: `(x, y, w)` — beside him, not above. Labels need the +/// rows underneath, and his head was already using them. +pub const SHELF: (i32, i32, i32) = (13, 5, 19); +/// Cells between one cup and the next: five for the cup, one for air. +pub const PITCH: i32 = 6; + +/// A cup: handles, bowl, taper, stem, base. Five by five. +/// +/// Every number here was arrived at by drawing it. Five wide is the floor — +/// four reads as a mug, three as a letter T. Five *tall* is also the floor: the +/// taper is what turns two bars and a stick into a bowl, and dropping it to +/// save a row rendered three gold dumbbells. +/// +/// Which leaves no room: he owns rows 6-11, so the shelf has six rows, and a +/// five-row cup on a one-row plank is seven. So the cup's base *is* the plank — +/// it lands on the shelf's own row, gold on brown, and the sky keeps row 0. +const CUP: &[&str] = &["#---#", "#####", " ### ", " # ", "#####"]; + +/// How far right of home he is at tick `k`. He starts *at* home — a scene that +/// opens with him already somewhere else has teleported him, and the reel's +/// seam is the thing that notices. +fn shift_at(k: i32) -> i32 { + match k { + k if k < SET => 0, + k if k < WALK_END => -((k - SET) / STEP), + k if k < HOME => -SHIFT, + k if k < HOME_END => -SHIFT + (k - HOME) / STEP, + _ => 0, + } +} + +/// How many cups are on the shelf at tick `k` — they land one at a time while +/// he watches, rather than all at once, so there's something to watch. +pub fn landed(k: i32) -> usize { + if k < SET { + return 0; + } + let since = k - SET; + ((since / LAND).clamp(0, SLOTS as i32)) as usize +} + +/// How far the shelf itself has settled, 0–100 — the renderer fades the labels +/// with it so nothing is legible before there's anything to read. +pub fn stand_pct(k: i32) -> u32 { + match k { + k if k < DROP => 0, + k if k < DROP + FOLD => (100 * (k - DROP) / FOLD) as u32, + k if k < LOOK_END => 100, + k if k < HOME => (100 * (HOME - k) / FOLD) as u32, + _ => 0, + } +} + +pub(super) fn trophies(k: i32, _t: i32, grid: &mut Grid) -> Pose { + let (sx, sy, sw) = SHELF; + if stand_pct(k) > 0 { + // the plank, then whatever he's got up so far + for x in sx..sx + sw { + grid.set(x, sy, "▓▓", Role::Crate); + } + for i in 0..landed(k) { + blit(grid, CUP, sx + 1 + i as i32 * PITCH, sy - 4, Role::Spark); + } + } + + let walking = (SET..WALK_END).contains(&k) || (HOME..HOME_END).contains(&k); + Pose { + dx: shift_at(k), + // he watches the cup he's carrying land, then looks back down the shelf + eyes: match k { + k if k >= HOME_END => EyeMode::Auto, + k if k >= HOME => EyeMode::Left, // heading home + _ => EyeMode::Right, // the shelf is on his right + }, + legs: if walking { + LegsMode::Walk + } else { + LegsMode::Still + }, + ..Pose::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Open and close on an empty stage, and put him back where he started, or + /// the reel's seam breaks and the act can't be reordered. + #[test] + fn it_opens_and_closes_empty() { + for k in [0, DUR] { + assert_eq!(stand_pct(k), 0, "shelf still up at k={k}"); + assert_eq!(shift_at(k), 0, "he's not home at k={k}"); + } + } + + /// He walks; he never teleports. Exactly the bug the stats act shipped once. + #[test] + fn he_never_skips_a_cell() { + for k in 0..DUR { + assert!( + (shift_at(k + 1) - shift_at(k)).abs() <= 1, + "jumped at k={k}" + ); + } + } + + /// Every cup must be up before the shelf starts folding away, or the last + /// number is one nobody ever reads. + #[test] + fn every_cup_gets_its_moment() { + assert_eq!( + landed(LOOK_END), + SLOTS, + "still landing when the shelf folds" + ); + } + + /// The shelf must be gone before he walks home — home is under the labels. + #[test] + fn the_shelf_clears_before_he_walks_back() { + assert_eq!(stand_pct(HOME), 0, "shelf still up as he sets off home"); + for k in HOME..=DUR { + assert_eq!(stand_pct(k), 0, "shelf came back at k={k}"); + } + } + + /// Four cups at four cells apart must fit the shelf they sit on. + #[test] + fn the_cups_fit_the_shelf() { + let (_, _, w) = SHELF; + assert!(PITCH * (SLOTS as i32) < w, "cups overrun the plank"); + } +} diff --git a/profile/src/gif.rs b/profile/src/gif.rs index 6deea07..1c17318 100644 --- a/profile/src/gif.rs +++ b/profile/src/gif.rs @@ -11,6 +11,7 @@ use image::{Delay, Frame, Rgba, RgbaImage}; use crate::draw::{draw_bits, draw_text, fill}; use crate::script::{Line, Profile}; +use crate::shelf::shelf; use crate::wall::wall; use awan_core::icons; @@ -74,6 +75,9 @@ fn rasterize(reel: &Reel, profile: &Profile, t: i32) -> RgbaImage { if let Some(k) = profile.stats_at(reel, t) { stat_labels(&mut img, profile, k); } + if let Some(k) = profile.trophies_at(reel, t) { + shelf(&mut img, profile, k); + } match profile.sing_at(reel, t) { Some(k) => karaoke(&mut img, profile, k, ground), diff --git a/profile/src/main.rs b/profile/src/main.rs index 89a1204..a0e63a1 100644 --- a/profile/src/main.rs +++ b/profile/src/main.rs @@ -15,6 +15,7 @@ use awan_core::{Character, Reel, Size}; mod draw; mod gif; mod script; +mod shelf; mod story; mod wall; diff --git a/profile/src/script.rs b/profile/src/script.rs index b82e9b3..fb73a5e 100644 --- a/profile/src/script.rs +++ b/profile/src/script.rs @@ -52,6 +52,10 @@ pub struct Profile { pub contributions: String, pub contrib_year: u32, pub contrib_recent: u32, + /// Labels for the `trophies` act, as `"label:value"` — CI fills these. + /// Five at most: that's what fits on the shelf, and as many numbers as + /// anyone actually reads. + pub trophies: Vec, pub lyrics: Vec, pub output: String, pub scenes: Vec, @@ -88,6 +92,11 @@ impl Profile { self.beat_at(reel, t, "stats") } + /// If the beat at tick `t` is the trophy shelf, its tick-within-scene. + pub fn trophies_at(&self, reel: &Reel, t: i32) -> Option { + self.beat_at(reel, t, "trophies") + } + /// If the beat at tick `t` is the year wall, its tick-within-scene. pub fn contributions_at(&self, reel: &Reel, t: i32) -> Option { self.beat_at(reel, t, "contributions") diff --git a/profile/src/shelf.rs b/profile/src/shelf.rs new file mode 100644 index 0000000..81f22ed --- /dev/null +++ b/profile/src/shelf.rs @@ -0,0 +1,49 @@ +//! Labels under the trophies — the reader's numbers, in the engine's own font. +//! +//! The engine puts the cups up and says which have landed; what each one is +//! *for* is data, so it's ours to draw. Same split as the readout and the wall. + +use image::RgbaImage; + +use crate::draw::{draw_text, mix}; +use crate::gif::{BG, CELL_H, CELL_W}; +use crate::script::Profile; + +/// Small type: a cup is six cells wide and the label sits under it. +const SCALE: u32 = 2; +const GLYPH: u32 = 8 * SCALE; +const INK: [u8; 3] = [150, 150, 160]; +const ACCENT: [u8; 3] = [230, 180, 100]; + +/// Paint the labels at tick `k` of a trophies beat. +pub fn shelf(img: &mut RgbaImage, profile: &Profile, k: i32) { + let up = awan_core::trophies::stand_pct(k); + let landed = awan_core::trophies::landed(k); + if up == 0 || landed == 0 { + return; + } + let (sx, sy, _) = awan_core::trophies::SHELF; + let pitch = awan_core::trophies::PITCH as u32 * CELL_W; + let y = (sy as u32 + 1) * CELL_H + 6; + + for (i, entry) in profile + .trophies + .iter() + .take(landed.min(awan_core::trophies::SLOTS)) + .enumerate() + { + let (label, value) = entry.split_once(':').unwrap_or((entry.as_str(), "")); + let x0 = (sx as u32 + 1) * CELL_W + i as u32 * pitch; + // the number leads: it's what the cup is for + centred(img, value, x0, y, ACCENT, up); + centred(img, label, x0, y + GLYPH + 4, INK, up); + } +} + +/// Centre a line under a six-cell cup, fading in with the shelf. +fn centred(img: &mut RgbaImage, text: &str, x0: u32, y: u32, colour: [u8; 3], up: u32) { + let w = 6 * CELL_W; + let text_w = text.chars().count() as u32 * GLYPH; + let x = x0 + w.saturating_sub(text_w) / 2; + draw_text(img, text, x, y, SCALE, mix(BG, colour, up)); +} diff --git a/profile/src/story.rs b/profile/src/story.rs index 5a8e942..d5fdcca 100644 --- a/profile/src/story.rs +++ b/profile/src/story.rs @@ -18,6 +18,7 @@ pub fn act_of(name: &str) -> Act { "sing" => Act::Sing, "campfire" => Act::Campfire, "stats" => Act::Stats, + "trophies" => Act::Trophies, "contributions" => Act::Contributions, "sleep" => Act::Sleep, "dance" => Act::Dance, @@ -35,6 +36,7 @@ pub fn icon_of(act: &str) -> &'static Icon { "bake" | "sleep" => &icons::HEART, "campfire" => &icons::FIRE, "stats" => &icons::DIAMOND, + "trophies" => &icons::STAR, "contributions" => &icons::CODE, "present" => &icons::BRIEFCASE, _ => &icons::DIAMOND,