Skip to main content

gasm_host/
splash.rs

1//! The gasm splash screen: a few frames of Pong in the style of gasm's icon (the
2//! paddles, the dashed net and the square ball on the dark tile), then the court
3//! shrinks into the icon, the paddles and the ball move into their places in it,
4//! and "gasm" appears under it.
5//!
6//! Frames are 2D (RGBA8, [`W`]×[`H`]) and shown through the runner's usual 2D
7//! presentation (letterbox, filter). Everything is integer arithmetic, so
8//! runners/web/lib/splash.js draws the same pixels (`splash_hash` in the tests
9//! and scripts/splash-test.mjs compare them).
10
11pub const W: usize = 320;
12pub const H: usize = 180;
13/// Frames at 60 Hz: the rally, the move into the logo, the name, a pause, the fade.
14pub const FRAMES: u32 = 96;
15const RALLY_END: u32 = 36;
16const MORPH_END: u32 = 60;
17const NAME_END: u32 = 72;
18const FADE_START: u32 = 84;
19/// The logo with the name: runners stay on it while the game is still loading.
20pub const HOLD: u32 = FADE_START - 1;
21
22const BG: [u8; 3] = [0x07, 0x09, 0x0c];
23const TILE: [u8; 3] = [0x0e, 0x11, 0x16];
24const NET: [u8; 3] = [0x3a, 0x42, 0x50];
25const RED: [u8; 3] = [0xe8, 0x55, 0x4e];
26const BLUE: [u8; 3] = [0x3b, 0x7f, 0xf5];
27const WHITE: [u8; 3] = [0xf2, 0xf4, 0xf8];
28
29/// A rectangle: x, y, w, h.
30type Rect = [i32; 4];
31
32// The logo: the icon (a 64-unit viewBox) at 2x, its tile at (LOGO_X, LOGO_Y).
33const LOGO_X: i32 = (W as i32 - 128) / 2;
34const LOGO_Y: i32 = 10;
35const fn logo(x: i32, y: i32, w: i32, h: i32) -> Rect {
36    [LOGO_X + x * 2, LOGO_Y + y * 2, w * 2, h * 2]
37}
38const LOGO_TILE: Rect = logo(2, 2, 60, 60);
39const LOGO_RADIUS: i32 = 26;
40const LOGO_LEFT: Rect = logo(9, 15, 5, 18);
41const LOGO_RIGHT: Rect = logo(50, 29, 5, 18);
42const LOGO_BALL: Rect = logo(38, 22, 6, 6);
43/// the net's dashes in the icon: x 30.5, width 3, every 9 units from 9
44const fn logo_dash(i: i32) -> Rect {
45    [LOGO_X + 61, LOGO_Y + (9 + 9 * i) * 2, 6, 10]
46}
47
48// The court: the whole frame.
49const COURT: Rect = [0, 0, W as i32, H as i32];
50const PADDLE_W: i32 = 10;
51const PADDLE_H: i32 = 36;
52const LEFT_X: i32 = 18;
53const RIGHT_X: i32 = W as i32 - 18 - PADDLE_W;
54const BALL: i32 = 12;
55const fn court_dash(i: i32) -> Rect {
56    [W as i32 / 2 - 3, 8 + 36 * i, 6, 20]
57}
58
59// The ball's left edge travels between the paddles' faces, LEFT_FACE..RIGHT_FACE, at
60// VX px per frame: one crossing every CROSS frames, so the paddles meet it exactly.
61const LEFT_FACE: i32 = LEFT_X + PADDLE_W;
62const RIGHT_FACE: i32 = RIGHT_X - BALL;
63const VX: i32 = 9;
64const CROSS: i32 = (RIGHT_FACE - LEFT_FACE) / VX; // 28
65/// The right paddle hits at frame RIGHT_HIT, the left one CROSS frames later (the
66/// rally's last frame), and so on every 2 * CROSS.
67const RIGHT_HIT: i32 = RALLY_END as i32 - CROSS;
68const VY: i32 = 5;
69
70fn bounce(p: i32, lo: i32, hi: i32) -> i32 {
71    let span = hi - lo;
72    let m = (p - lo).rem_euclid(2 * span);
73    lo + if m <= span { m } else { 2 * span - m }
74}
75
76/// The ball during the rally (frame t, any integer).
77fn ball_at(t: i32) -> (i32, i32) {
78    (bounce(RIGHT_FACE - (RIGHT_HIT - t) * VX, LEFT_FACE, RIGHT_FACE), bounce(40 + t * VY, 0, H as i32 - BALL))
79}
80
81/// A paddle's top at frame t: it moves evenly from where it met the ball last to
82/// where it will meet it next (hits every 2 * CROSS frames from `first`), meeting
83/// it a little off center.
84fn paddle_y(t: i32, first: i32, offset: i32) -> i32 {
85    let period = 2 * CROSS;
86    let prev = first + (t - first).div_euclid(period) * period;
87    let next = prev + period;
88    let at = |h: i32| ball_at(h).1 + BALL / 2 - PADDLE_H / 2 + offset;
89    let y = at(prev) + (at(next) - at(prev)) * (t - prev) / period;
90    y.clamp(0, H as i32 - PADDLE_H)
91}
92
93/// Smoothstep over 0..=n in 1/4096ths: 3t² - 2t³.
94fn ease(t: u32, n: u32) -> i64 {
95    let t = t.min(n) as i64;
96    let n = n as i64;
97    (4096 * (3 * t * t * n - 2 * t * t * t)) / (n * n * n)
98}
99
100fn lerp(a: i32, b: i32, e: i64) -> i32 {
101    a + ((b - a) as i64 * e / 4096) as i32
102}
103
104fn lerp_rect(a: Rect, b: Rect, e: i64) -> Rect {
105    [lerp(a[0], b[0], e), lerp(a[1], b[1], e), lerp(a[2], b[2], e), lerp(a[3], b[3], e)]
106}
107
108fn put(px: &mut [u8], x: i32, y: i32, c: [u8; 3]) {
109    if x >= 0 && y >= 0 && (x as usize) < W && (y as usize) < H {
110        let i = (y as usize * W + x as usize) * 4;
111        px[i..i + 4].copy_from_slice(&[c[0], c[1], c[2], 255]);
112    }
113}
114
115fn fill(px: &mut [u8], r: Rect, c: [u8; 3]) {
116    for y in r[1]..r[1] + r[3] {
117        for x in r[0]..r[0] + r[2] {
118            put(px, x, y, c);
119        }
120    }
121}
122
123/// A rectangle with rounded corners: a pixel is in if its center is (in half pixels).
124fn fill_round(px: &mut [u8], r: Rect, radius: i32, c: [u8; 3]) {
125    let rad = radius.min(r[2] / 2).min(r[3] / 2);
126    for y in r[1]..r[1] + r[3] {
127        for x in r[0]..r[0] + r[2] {
128            // distance from the nearest corner circle's center, doubled
129            let cx = (2 * x + 1).clamp(2 * (r[0] + rad), 2 * (r[0] + r[2] - rad));
130            let cy = (2 * y + 1).clamp(2 * (r[1] + rad), 2 * (r[1] + r[3] - rad));
131            let (dx, dy) = (2 * x + 1 - cx, 2 * y + 1 - cy);
132            if dx * dx + dy * dy <= 4 * rad * rad {
133                put(px, x, y, c);
134            }
135        }
136    }
137}
138
139// "gasm" in a 5-wide pixel font, x-height rows 0..5, g's descender to row 7.
140const GLYPHS: [[&str; 7]; 4] = [
141    [".####", "#...#", "#...#", ".####", "....#", "....#", ".###."],
142    [".###.", "....#", ".####", "#...#", ".####", ".....", "....."],
143    [".####", "#....", ".###.", "....#", "####.", ".....", "....."],
144    ["##.#.", "#.#.#", "#.#.#", "#.#.#", "#.#.#", ".....", "....."],
145];
146const SCALE: i32 = 3;
147const NAME_Y: i32 = LOGO_Y + 128 + 12;
148const NAME_X: i32 = (W as i32 - (4 * 5 + 3 * 1) * SCALE) / 2;
149
150fn name(px: &mut [u8], letters: usize) {
151    for (g, glyph) in GLYPHS.iter().enumerate().take(letters) {
152        for (row, bits) in glyph.iter().enumerate() {
153            for (col, b) in bits.bytes().enumerate() {
154                if b == b'#' {
155                    let x = NAME_X + (g as i32 * 6 + col as i32) * SCALE;
156                    fill(px, [x, NAME_Y + row as i32 * SCALE, SCALE, SCALE], WHITE);
157                }
158            }
159        }
160    }
161}
162
163/// Frame `f` (0..[`FRAMES`]; later frames are the last one) as RGBA8, `W`×`H`.
164pub fn frame(f: u32) -> Vec<u8> {
165    let f = f.min(FRAMES - 1);
166    let mut px = vec![0u8; W * H * 4];
167    fill(&mut px, COURT, BG);
168    // where everything is when the rally ends, and how far it has moved since
169    let r = f.min(RALLY_END);
170    let e = if f <= RALLY_END { 0 } else { ease(f - RALLY_END, MORPH_END - RALLY_END) };
171    let (bx, by) = ball_at(r as i32);
172    let tile = lerp_rect(COURT, LOGO_TILE, e);
173    fill_round(&mut px, tile, lerp(0, LOGO_RADIUS, e), TILE);
174    for i in 0..5 {
175        fill(&mut px, lerp_rect(court_dash(i), logo_dash(i), e), NET);
176    }
177    fill(&mut px, lerp_rect([LEFT_X, paddle_y(r as i32, RIGHT_HIT + CROSS, -7), PADDLE_W, PADDLE_H], LOGO_LEFT, e), RED);
178    fill(&mut px, lerp_rect([RIGHT_X, paddle_y(r as i32, RIGHT_HIT, 8), PADDLE_W, PADDLE_H], LOGO_RIGHT, e), BLUE);
179    fill(&mut px, lerp_rect([bx, by, BALL, BALL], LOGO_BALL, e), WHITE);
180    if f > MORPH_END {
181        let letters = 1 + (f - MORPH_END) as usize * 4 / (NAME_END - MORPH_END) as usize;
182        name(&mut px, letters.min(4));
183    }
184    if f >= FADE_START {
185        // to black, in whole steps of 1/16
186        let k = 16 - (16 * (f - FADE_START + 1) / (FRAMES - FADE_START)).min(16);
187        for p in px.chunks_exact_mut(4) {
188            for c in &mut p[..3] {
189                *c = (*c as u32 * k / 16) as u8;
190            }
191        }
192    }
193    px
194}
195
196/// [`splash_hash`] of these frames: change it with the animation (the unit test says
197/// the new value); `gen-abi.mjs --check` compares runners/web/lib/splash.js with it.
198pub const SPLASH_HASH: u32 = 0xab6bc49a;
199
200/// FNV-1a 32 over every frame: the same as runners/web/lib/splash.js.
201pub fn splash_hash() -> u32 {
202    (0..FRAMES).flat_map(frame).fold(0x811c_9dc5u32, |h, b| (h ^ b as u32).wrapping_mul(0x0100_0193))
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn frames() {
211        // SPLASH_DIR=dir cargo test --lib splash: write every frame as a PNG (to look at)
212        if let Ok(dir) = std::env::var("SPLASH_DIR") {
213            std::fs::create_dir_all(&dir).unwrap();
214            for f in 0..FRAMES {
215                let file = std::fs::File::create(format!("{dir}/{f:03}.png")).unwrap();
216                let mut enc = png::Encoder::new(std::io::BufWriter::new(file), W as u32, H as u32);
217                enc.set_color(png::ColorType::Rgba);
218                enc.write_header().unwrap().write_image_data(&frame(f)).unwrap();
219            }
220        }
221        // the logo is the icon: its ball sits where the icon's does
222        let last = frame(MORPH_END);
223        let i = ((LOGO_BALL[1] + 1) as usize * W + (LOGO_BALL[0] + 1) as usize) * 4;
224        assert_eq!(&last[i..i + 3], &WHITE);
225        assert_eq!(splash_hash(), SPLASH_HASH, "the frames changed: SPLASH_HASH = {:#010x}", splash_hash());
226    }
227}