use crate::gif::Gif;
use crate::sim::{
Board, CrabKind, Direction, GullState, MAX_PLAYERS, PlayerId, Replay, TICKS_PER_SECOND,
TileKind,
};
pub const REEL_SECONDS: u32 = 15;
const FPS: u32 = 10;
const TICKS_PER_FRAME: u32 = TICKS_PER_SECOND / FPS;
const TILE: u32 = 16;
const WINDOW_W: u8 = 9;
const WINDOW_H: u8 = 7;
mod ink {
pub const SAND: u8 = 0;
pub const SAND_DARK: u8 = 1;
pub const ROCK: u8 = 2;
pub const WALL: u8 = 3;
pub const KELP: u8 = 4;
pub const POOL: u8 = 5;
pub const LOG: u8 = 6;
pub const HOLE: u8 = 7;
pub const GULL: u8 = 8;
pub const CRAB: u8 = 9;
pub const GOLDEN: u8 = 10;
pub const GIANT: u8 = 11;
pub const SEAT: u8 = 12;
}
const PALETTE: [[u8; 3]; 18] = [
[222, 196, 148], [206, 180, 132], [96, 96, 104], [82, 62, 44], [46, 132, 72], [96, 168, 208], [122, 88, 54], [56, 40, 30], [244, 244, 250], [214, 92, 60], [240, 206, 74], [150, 52, 44], [214, 68, 68], [64, 120, 202], [76, 176, 79], [227, 186, 58], [178, 102, 209], [242, 140, 51], ];
const MAX_SEAT: u8 = MAX_PLAYERS as u8 - 1;
struct Frame {
pixels: Vec<u8>,
width: u32,
height: u32,
origin: (u8, u8),
}
impl Frame {
fn new(origin: (u8, u8)) -> Frame {
let (width, height) = (u32::from(WINDOW_W) * TILE, u32::from(WINDOW_H) * TILE);
Frame {
pixels: vec![ink::SAND; (width * height) as usize],
width,
height,
origin,
}
}
fn set(&mut self, x: u32, y: u32, color: u8) {
if x < self.width && y < self.height {
self.pixels[(y * self.width + x) as usize] = color;
}
}
fn rect(&mut self, x: i64, y: i64, w: u32, h: u32, color: u8) {
for dy in 0..h {
for dx in 0..w {
let (px, py) = (x + i64::from(dx), y + i64::from(dy));
if px >= 0 && py >= 0 {
self.set(px as u32, py as u32, color);
}
}
}
}
fn at(&self, tile_x: f32, tile_y: f32) -> (i64, i64) {
let x = (tile_x - f32::from(self.origin.0)) * TILE as f32;
let y = (tile_y - f32::from(self.origin.1)) * TILE as f32;
(x as i64, y as i64)
}
}
fn deciding_seat(
final_scores: &[u32; MAX_PLAYERS],
gains: &[u32; MAX_PLAYERS],
in_play: impl Fn(usize) -> bool,
) -> Option<u8> {
let seats = || (0..MAX_PLAYERS).filter(|&s| in_play(s));
let best = seats().map(|s| final_scores[s]).max()?;
if best == 0 {
return None;
}
seats()
.filter(|&s| final_scores[s] == best)
.rev()
.max_by_key(|&s| gains[s])
.map(|s| s as u8)
}
fn reel_focus(
board: &Board,
final_scores: &[u32; MAX_PLAYERS],
gains: &[u32; MAX_PLAYERS],
) -> (u8, u8) {
let has_keep = |s: usize| board.castle_of(s as PlayerId).is_some();
deciding_seat(final_scores, gains, has_keep)
.and_then(|seat| board.castle_of(seat))
.unwrap_or((board.width() / 2, board.height() / 2))
}
fn window_origin(board: &Board, focus: (u8, u8)) -> (u8, u8) {
let clamp = |value: u8, window: u8, size: u8| {
let half = window / 2;
if size <= window {
0
} else {
value.saturating_sub(half).min(size - window)
}
};
(
clamp(focus.0, WINDOW_W, board.width()),
clamp(focus.1, WINDOW_H, board.height()),
)
}
fn draw(board: &Board, origin: (u8, u8)) -> Frame {
let mut frame = Frame::new(origin);
for row in 0..WINDOW_H {
for col in 0..WINDOW_W {
let (x, y) = (origin.0 + col, origin.1 + row);
if x >= board.width() || y >= board.height() {
continue;
}
let (px, py) = (u32::from(col) * TILE, u32::from(row) * TILE);
let checker = if (u16::from(x) + u16::from(y)).is_multiple_of(2) {
ink::SAND
} else {
ink::SAND_DARK
};
let (fill, inset) = match board.tile_at(x, y) {
TileKind::Empty => (checker, 0),
TileKind::Rock => (ink::ROCK, 2),
TileKind::Kelp => (ink::KELP, 2),
TileKind::Pool => (ink::POOL, 0),
TileKind::Turnstile { .. } => (ink::LOG, 5),
TileKind::Spawner(_) => (ink::HOLE, 3),
TileKind::Castle(owner) => (ink::SEAT + owner.min(MAX_SEAT), 1),
};
if inset > 0 {
frame.rect(i64::from(px), i64::from(py), TILE, TILE, checker);
}
frame.rect(
i64::from(px + inset),
i64::from(py + inset),
TILE - 2 * inset,
TILE - 2 * inset,
fill,
);
for (dir, wx, wy, w, h) in [
(Direction::Up, px, py, TILE, 2),
(Direction::Down, px, py + TILE - 2, TILE, 2),
(Direction::Left, px, py, 2, TILE),
(Direction::Right, px + TILE - 2, py, 2, TILE),
] {
if board.wall_at(x, y, dir) {
frame.rect(i64::from(wx), i64::from(wy), w, h, ink::WALL);
}
}
if let Some(post) = board.signpost_at(x, y) {
frame.rect(
i64::from(px + 5),
i64::from(py + 5),
6,
6,
ink::SEAT + post.owner.min(MAX_SEAT),
);
}
}
}
for crab in board.crabs() {
let (x, y) = creature_tile_pos(board, crab.tile, crab.dir, crab.progress);
let (px, py) = frame.at(x, y);
frame.rect(px + 4, py + 4, 8, 8, crab_ink(crab.kind));
}
for flight in board.sweeping() {
let crab = &flight.crab;
let from = creature_tile_pos(board, crab.tile, crab.dir, crab.progress);
let keep = board
.castle_of(flight.owner)
.map_or(from, |(x, y)| (f32::from(x), f32::from(y)));
let span = flight.lands.saturating_sub(flight.launch).max(1) as f32;
let t = (board.ticks().saturating_sub(flight.launch) as f32 / span).min(1.0);
let (x, y) = (
from.0 + (keep.0 - from.0) * t,
from.1 + (keep.1 - from.1) * t,
);
let (px, py) = frame.at(x, y);
frame.rect(px + 4, py + 4, 8, 8, crab_ink(crab.kind));
}
for gull in board.gulls() {
let (x, y) = creature_tile_pos(board, gull.tile, gull.dir, gull.progress);
let (px, py) = frame.at(x, y);
let size = if matches!(gull.state, GullState::Flying { .. }) {
12
} else {
10
};
frame.rect(px + 3, py + 3, size, size, ink::GULL);
}
frame
}
fn crab_ink(kind: CrabKind) -> u8 {
match kind {
CrabKind::Golden => ink::GOLDEN,
CrabKind::Giant => ink::GIANT,
CrabKind::Common | CrabKind::Juvenile | CrabKind::Molting | CrabKind::Sparkling => {
ink::CRAB
}
}
}
fn creature_tile_pos(board: &Board, tile: u16, dir: Direction, progress: u16) -> (f32, f32) {
let (x, y) = board.coords_u8(tile);
let (dx, dy) = dir.offset();
let t = f32::from(progress) / f32::from(crate::sim::SUBUNITS_PER_TILE);
(f32::from(x) + dx as f32 * t, f32::from(y) + dy as f32 * t)
}
pub fn reel(replay: &Replay) -> Option<Vec<u8>> {
if replay.inputs.is_empty() {
return None;
}
let total = replay.inputs.len();
let window = (REEL_SECONDS * TICKS_PER_SECOND) as usize;
let start = total.saturating_sub(window);
let mut board = replay.level.board();
let mut opening = None;
for (tick, actions) in replay.inputs.iter().enumerate() {
if tick == start {
opening = Some(board.clone());
}
board.tick(actions);
}
let finals = *board.scores();
let mut board = opening?;
let at_start = *board.scores();
let gains = std::array::from_fn(|s| finals[s].saturating_sub(at_start[s]));
let focus = reel_focus(&board, &finals, &gains);
let origin = window_origin(&board, focus);
let (width, height) = (u32::from(WINDOW_W) * TILE, u32::from(WINDOW_H) * TILE);
let mut gif = Gif::new(width as u16, height as u16, &PALETTE);
let delay = (100 / FPS) as u16;
let mut frames = 0;
for (tick, actions) in replay.inputs.iter().enumerate().skip(start) {
if (tick - start).is_multiple_of(TICKS_PER_FRAME as usize) {
gif.add_frame(&draw(&board, origin).pixels, delay);
frames += 1;
}
board.tick(actions);
}
gif.add_frame(&draw(&board, origin).pixels, delay * 15);
frames += 1;
(frames > 1).then(|| gif.finish())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sim::{Level, PlayerAction, classic_arena_seeded};
#[test]
fn a_swept_crab_stays_on_the_reel_until_it_lands() {
let mut board = Board::new(9, 7, 5);
board.rules_mut().sweep_flight = true;
board.set_tile(7, 5, TileKind::Castle(1));
board.spawn_crab(
1,
1,
Direction::Down,
crate::sim::Handedness::Left,
CrabKind::Golden,
);
board.spawn_crab(
2,
1,
Direction::Down,
crate::sim::Handedness::Left,
CrabKind::Common,
);
board.force_tide_event(crate::sim::TideEvent::Monopoly, 1);
assert_eq!(board.sweeping().len(), 1, "the golden crab is in the air");
let pixel = |frame: &Frame, x: u8, y: u8| {
let (px, py) = (
u32::from(x) * TILE + TILE / 2,
u32::from(y) * TILE + TILE / 2,
);
frame.pixels[(py * frame.width + px) as usize]
};
let waiting = draw(&board, (0, 0));
assert_eq!(pixel(&waiting, 1, 1), ink::GOLDEN, "where it was picked up");
let lands = board.sweeping()[0].lands;
while board.ticks() < lands {
board.tick_idle();
}
assert_eq!(board.sweeping().len(), 1, "on its last tick in the air");
let landing = draw(&board, (0, 0));
assert_eq!(pixel(&landing, 7, 5), ink::GOLDEN, "at the keep");
}
#[test]
fn the_reel_flies_the_games_own_flags() {
for seat in 0..MAX_PLAYERS as u8 {
let want = crate::app::palette::classic_color(seat).to_srgba();
let want = [want.red, want.green, want.blue].map(|c| (c * 255.0).round() as i32);
let got = PALETTE[(ink::SEAT + seat) as usize].map(i32::from);
for channel in 0..3 {
assert!(
(want[channel] - got[channel]).abs() <= 1,
"seat {seat} channel {channel}: reel {got:?} against game {want:?}"
);
}
}
}
fn recorded_round(ticks: usize) -> Replay {
let board = classic_arena_seeded(0x21EE1, false, 4);
let mut replay = Replay::new(Level::from_board("Reel", 3, board.clone()));
let mut board = board;
for tick in 0..ticks {
let mut actions = [PlayerAction::None; MAX_PLAYERS];
if tick == 40 {
actions[0] = PlayerAction::Place {
x: 4,
y: 4,
dir: Direction::Up,
};
}
board.tick(&actions);
replay.record(actions);
}
replay
}
#[test]
fn a_reel_is_a_playable_gif() {
let replay = recorded_round(900);
let bytes = reel(&replay).expect("a round of that length has a reel");
assert_eq!(&bytes[..6], b"GIF89a");
assert_eq!(*bytes.last().expect("trailer"), 0x3B);
let frames = bytes.iter().filter(|&&b| b == 0x2C).count();
assert!(
frames >= (REEL_SECONDS * FPS) as usize,
"expected a full window of frames, got {frames}"
);
}
#[test]
fn short_rounds_still_make_a_reel() {
assert!(reel(&recorded_round(60)).is_some());
assert!(reel(&recorded_round(0)).is_none(), "nothing to show");
}
#[test]
fn the_window_follows_the_deciding_castle() {
let board = classic_arena_seeded(1, false, 4);
let corner = board.castle_of(1).expect("seat 2 has a castle");
let origin = window_origin(&board, corner);
assert!(
origin.0 + WINDOW_W <= board.width() && origin.1 + WINDOW_H <= board.height(),
"the window stayed on the board: {origin:?}"
);
let mut wide = Board::new(20, 13, 1);
wide.set_tile(10, 6, TileKind::Castle(0));
assert_eq!(window_origin(&wide, (10, 6)), (6, 3));
}
#[test]
fn a_window_far_out_on_a_huge_board_draws() {
let mut huge = Board::new(255, 255, 1);
huge.set_tile(250, 250, TileKind::Castle(0));
let origin = window_origin(&huge, (250, 250));
assert!(origin.0 > 128 && origin.1 > 128, "{origin:?}");
let frame = draw(&huge, origin);
assert!(!frame.pixels.is_empty());
}
#[test]
fn the_deciding_seat_is_the_winner_then_the_late_surge() {
let all = |_: usize| true;
let decide = |finals, gains| deciding_seat(finals, gains, all);
assert_eq!(decide(&[10, 40, 5, 0, 0, 0], &[0; MAX_PLAYERS]), Some(1));
assert_eq!(decide(&[40, 40, 0, 0, 0, 0], &[3, 9, 0, 0, 0, 0]), Some(1));
assert_eq!(decide(&[40, 40, 0, 0, 0, 0], &[9, 3, 0, 0, 0, 0]), Some(0));
assert_eq!(decide(&[40, 40, 0, 0, 0, 0], &[5, 5, 0, 0, 0, 0]), Some(0));
}
#[test]
fn a_scoreless_round_or_an_empty_chair_decides_nothing() {
let two = |s: usize| s < 2;
assert_eq!(
deciding_seat(&[0; MAX_PLAYERS], &[0; MAX_PLAYERS], two),
None
);
assert_eq!(
deciding_seat(&[3, 0, 0, 0, 0, 9], &[0; MAX_PLAYERS], two),
Some(0),
"seat 6 is not at the table"
);
let board = classic_arena_seeded(1, false, 2);
let middle = (board.width() / 2, board.height() / 2);
assert_eq!(
reel_focus(&board, &[0; MAX_PLAYERS], &[0; MAX_PLAYERS]),
middle
);
}
#[test]
fn the_reel_frames_the_keep_the_winner_holds_now() {
let mut board = Board::new(20, 13, 1);
board.set_tile(1, 1, TileKind::Castle(1));
board.set_tile(18, 11, TileKind::Castle(0));
let finals = [0, 40, 0, 0, 0, 0];
assert_eq!(reel_focus(&board, &finals, &[0; MAX_PLAYERS]), (1, 1));
}
}