Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/scene-trophies.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions crates/awan-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/awan-core/src/reel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub enum Act {
Campfire,
Stats,
Contributions,
Trophies,
Sleep,
Dance,
Soccer,
Expand Down
2 changes: 2 additions & 0 deletions crates/awan-core/src/scene/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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, ""),
Expand Down
177 changes: 177 additions & 0 deletions crates/awan-core/src/scene/trophies.rs
Original file line number Diff line number Diff line change
@@ -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");
}
}
4 changes: 4 additions & 0 deletions profile/src/gif.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions profile/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use awan_core::{Character, Reel, Size};
mod draw;
mod gif;
mod script;
mod shelf;
mod story;
mod wall;

Expand Down
9 changes: 9 additions & 0 deletions profile/src/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub lyrics: Vec<String>,
pub output: String,
pub scenes: Vec<SceneSpec>,
Expand Down Expand Up @@ -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<i32> {
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<i32> {
self.beat_at(reel, t, "contributions")
Expand Down
49 changes: 49 additions & 0 deletions profile/src/shelf.rs
Original file line number Diff line number Diff line change
@@ -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));
}
2 changes: 2 additions & 0 deletions profile/src/story.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading