//! The picture a shared match gets in a feed. //! //! Not the page in miniature, and not the board on its own. A link card is //! read at a glance, at a few hundred pixels wide, by somebody who was not //! there and may never have heard of any of this - so it says the one thing //! that happened (who beat whom), what it was (the challenge or the scenario, //! and the day), and how it went (what each force had left, and what it was //! made of), over the battlefield it was fought on. //! //! 1200x630, which is what every unfurler crops to. Drawn rather than //! screenshotted: the API has the result, the board, the silhouettes and two //! fonts, and a headless browser in the request path to take a picture of a //! page is a browser to keep alive and secure. //! //! Everything here is `png` and `ab_glyph` - decode, scale, blend, rasterise //! glyphs. No canvas, no SVG, no system fonts. use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; use crate::png_util; /// The card's size, and every unfurler's. Bluesky, Discord, Mastodon and /// Slack all render a large card at about 1.91:1 and crop anything else. pub const WIDTH: u32 = 1200; pub const HEIGHT: u32 = 630; const MARGIN: i32 = 56; // The site's palette, from web/src/styles.css. const INK: [u8; 3] = [0xe6, 0xed, 0xf5]; const MUTED: [u8; 3] = [0x8e, 0xa0, 0xb5]; const BRAND: [u8; 3] = [0x4f, 0xc1, 0xd6]; const ACCENT: [u8; 3] = [0x4c, 0x8d, 0xff]; const DANGER: [u8; 3] = [0xff, 0x8a, 0x7a]; const BACKDROP: [u8; 3] = [0x0b, 0x10, 0x17]; const LINE: [u8; 3] = [0x22, 0x30, 0x3f]; const PANEL: [u8; 3] = [0x13, 0x1b, 0x26]; /// Quieter than muted, for the line at the foot that nobody has to read. const FAINT: [u8; 3] = [0x4a, 0x5a, 0x6d]; /// The site's green, for a machine that came through untouched, and its /// amber, for one that is hurt but standing. const WHOLE: [u8; 3] = [0x35, 0xd0, 0x8a]; const WARN: [u8; 3] = [0xff, 0xb0, 0x66]; const HOT: [u8; 3] = [0xd8, 0xa1, 0x3a]; /// What colour a machine's condition is written in. /// /// One scale, read the way a damage track is: green while it is whole, amber /// as it is worn down, red once it can no longer fight or is gone. The words /// come from MegaMek (`getDamageLevel`) and its removal conditions; anything /// unrecognised is muted rather than guessed at. fn condition_ink(condition: &str) -> [u8; 3] { match condition { "unhurt" => WHOLE, "light damage" => WARN, "damaged" => WARN, "heavy damage" => HOT, "crippled" => DANGER, "destroyed" | "devastated" | "salvage" | "captured" => DANGER, // Left the field in one piece: not a loss to mourn, not a machine // standing on it either. "withdrew" | "ejected" | "pushed off" => MUTED, _ => MUTED, } } /// The faces, the same two the site sets itself in. See the README beside /// them for where they came from and what may be done with them. /// The ground a lance.blue match is fought on: arena's own board tile, a /// thousand pixels square, with its hexes and its markings. Laid under /// everything at a whisper, because a card about a match should read as a /// tactical display rather than as a slide. /// /// The 84x72 tile the camo editor previews on was the obvious thing to reach /// for and the wrong one: repeated fourteen times across a card it reads as /// corduroy rather than as terrain. const HEX: &[u8] = include_bytes!("../assets/art/battlespace-hex.png"); const DISPLAY: &[u8] = include_bytes!("../assets/fonts/ChakraPetch-Bold.ttf"); const TEXT: &[u8] = include_bytes!("../assets/fonts/BarlowSemiCondensed-Medium.ttf"); /// One machine, as small as a card can say it. pub struct Machine<'a> { pub name: &'a str, /// The picture's bytes, all 84x72 and all MegaMek's: the design's own art /// where the unit index resolved it, and MegaMek's default silhouette for /// its class where it did not. `None` when neither came down, and the /// machine is drawn as its name and its condition alone. pub sprite: Option>, /// What became of it, in a word: "unhurt", "heavy damage", "destroyed". pub condition: &'a str, /// Whether it came home. Drawn struck through when it did not. pub alive: bool, } /// One force, as the card needs it: who, what is left, and what it was made /// of. The page's own model carries more than a card can show. pub struct Force<'a> { pub name: &'a str, /// Princess, not a person. A bot seat fights under the site's own handle, /// so without saying so the card reads as though @lance.blue the account /// took the field. pub bot: bool, /// The side it fought on. Two forces sharing one are allies, which is the /// whole of what a 2v2 needs said. pub team: i64, pub victor: bool, /// Whose side the reader is on. Exactly one force carries this when the /// card was asked for from somebody's point of view, and none does /// otherwise. pub mine: bool, pub units_remaining: i64, pub units_start: i64, pub bv_share: i64, /// The battle value behind the share, so a side made of several forces /// can be totalled rather than averaged. pub bv_remaining: i64, pub bv_start: i64, /// The force's own camo, as the match staged it. Every machine on the /// card is painted in it, the way MegaMek painted them on the board. pub camo: Option>, /// Where it started, from the scenario: "N", "SW", "CTR". The front page /// stands each force in its zone, and the daily card does the same. pub zone: Option, pub machines: Vec>, } /// Which card this is. /// /// A finished match is not one picture. The player who wants to post it is /// posting *their* fight, and what is worth showing differs: a duel is two /// columns of machines, a lance is one force in detail, a daily challenge is /// a challenge with a date on it, and a match with four seats in it is a /// dossier. The share menu offers these; the URL names one. #[derive(Clone, Copy, PartialEq)] pub enum Layout { /// Everyone who fought, with bars and silhouettes. Scales to any number /// of seats and is what an unnamed match gets. Dossier, /// One force in detail: its machines named, with what became of each. /// Wants a perspective, and is the "my lance" card. Lance, /// Two forces facing each other, machine by machine. Only for a match /// with exactly two. Duel, /// The day's challenge, stamped with its date. Daily, /// The board as it ended, as the whole picture. Only for a match that /// uploaded one. Battlefield, } /// Everything the card says. pub struct Card<'a> { pub layout: Layout, /// How the match was reached: "Daily challenge", "Duel", "Scenario". pub eyebrow: &'a str, /// The one word, when the card was asked for from somebody's side: /// VICTORY, DEFEAT or DRAW. Empty otherwise - a match seen from nowhere /// has no verdict to shout. pub verdict: &'a str, /// The line under it: the handles, or the challenge's name. pub title: &'a str, /// The scenario's own description, which is what the front page prints /// under a challenge's name. Empty for anything that has none. pub blurb: &'a str, pub sub: &'a str, pub forces: Vec>, /// The final board, as arena uploaded it. Absent for a match that sent /// none, and the card is then drawn on the site's own dark ground. pub board: Option>, } impl Layout { /// The name in a URL, and back. pub fn slug(self) -> &'static str { match self { Layout::Dossier => "dossier", Layout::Lance => "lance", Layout::Duel => "duel", Layout::Daily => "daily", Layout::Battlefield => "battlefield", } } pub fn from_slug(slug: &str) -> Option { Some(match slug { "dossier" => Layout::Dossier, "lance" => Layout::Lance, "duel" => Layout::Duel, "daily" => Layout::Daily, "battlefield" => Layout::Battlefield, _ => return None, }) } /// What the share menu calls it. pub fn label(self) -> &'static str { match self { Layout::Dossier => "The match", Layout::Lance => "My lance", Layout::Duel => "Head to head", Layout::Daily => "The challenge", Layout::Battlefield => "The battlefield", } } } /// A canvas of straight RGB. No alpha: the card is opaque by the time /// anything looks at it, and blending against a known ground is simpler than /// carrying a channel nothing reads. struct Canvas { width: u32, height: u32, pixels: Vec, } impl Canvas { fn new(width: u32, height: u32, fill: [u8; 3]) -> Canvas { let mut pixels = Vec::with_capacity((width * height * 3) as usize); for _ in 0..(width * height) { pixels.extend_from_slice(&fill); } Canvas { width, height, pixels, } } /// One pixel, blended by coverage. `alpha` is 0..=1. fn blend(&mut self, x: i32, y: i32, colour: [u8; 3], alpha: f32) { if alpha <= 0.0 || x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 { return; } let alpha = alpha.min(1.0); let index = ((y as u32 * self.width + x as u32) * 3) as usize; for (pixel, over) in self.pixels[index..index + 3].iter_mut().zip(colour) { let under = *pixel as f32; *pixel = (under + (over as f32 - under) * alpha).round() as u8; } } fn rect(&mut self, x: i32, y: i32, width: i32, height: i32, colour: [u8; 3], alpha: f32) { for row in y..y + height { for column in x..x + width { self.blend(column, row, colour, alpha); } } } fn into_png(self) -> Option> { let mut out = Vec::new(); { let mut encoder = png::Encoder::new(&mut out, self.width, self.height); encoder.set_color(png::ColorType::Rgb); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder.write_header().ok()?; writer.write_image_data(&self.pixels).ok()?; } Some(out) } } /// One decoded image: straight RGBA, as `png_util` hands it over. struct Picture { width: u32, height: u32, rgba: Vec, } /// A picture, from whichever of the two formats it is in. /// /// PNG is what everything this module draws with is: MegaMek's art, the hex /// tile, a camo, a board render. An avatar is the exception - it is whatever /// the account uploaded, and what a Bluesky client uploads is JPEG - so the /// two are tried in turn rather than one being assumed. fn decode(bytes: &[u8]) -> Option { decode_png(bytes).or_else(|| decode_jpeg(bytes)) } /// A JPEG, expanded to the same straight RGBA everything here draws from. /// /// Greyscale and RGB are the two shapes a photograph arrives in. CMYK is /// refused rather than converted: it needs an ICC profile to mean anything, /// and an avatar in it is rare enough that a card without a picture is the /// better answer than one with the colours inverted. fn decode_jpeg(bytes: &[u8]) -> Option { let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(bytes)); let pixels = decoder.decode().ok()?; let info = decoder.info()?; let (width, height) = (info.width as u32, info.height as u32); let count = (width as usize).checked_mul(height as usize)?; let mut rgba = Vec::with_capacity(count.checked_mul(4)?); match info.pixel_format { jpeg_decoder::PixelFormat::L8 => { if pixels.len() < count { return None; } for &grey in &pixels[..count] { rgba.extend_from_slice(&[grey, grey, grey, 255]); } } jpeg_decoder::PixelFormat::RGB24 => { if pixels.len() < count * 3 { return None; } for pixel in pixels[..count * 3].chunks_exact(3) { rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]); } } _ => return None, } Some(Picture { width, height, rgba, }) } fn decode_png(bytes: &[u8]) -> Option { let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); decoder.set_limits(png::Limits { // A board render is the largest thing here and is a few megapixels; // this is the same ceiling the flare path uses. bytes: 64 * 1024 * 1024, }); decoder.set_transformations( png::Transformations::normalize_to_color8() | png::Transformations::ALPHA, ); let mut reader = decoder.read_info().ok()?; let mut buffer = vec![0; reader.output_buffer_size()?]; let frame = reader.next_frame(&mut buffer).ok()?; // The colour type of what came *out*, not what the file declared. The // transformations above expand a palette to eight-bit colour with an // alpha channel, and the file still says "indexed": read that and every // paletted sprite is quietly dropped from the card. Two of the nine are // paletted, which is how this got as far as a rendered picture with two // machines missing from a lance of four. let (colour, _) = reader.output_color_type(); let rgba = png_util::to_rgba( &buffer[..frame.buffer_size()], colour, frame.width, frame.height, )?; Some(Picture { width: frame.width, height: frame.height, rgba, }) } /// A photograph, drawn square and cropped to fill. /// /// Averaged rather than sampled, unlike `draw_sprite`: a sprite is pixel art /// and smoothing it turns MegaMek's hexes into mush, and an avatar is a /// photograph coming down from 1000-odd pixels to two hundred, where taking /// one pixel in five is what makes a face look like a mosaic. /// /// `radius` rounds the corners. The plate the rest of the card is drawn on is /// notched rather than rounded, so this is a small radius rather than a /// circle: a circular portrait beside notched panels reads as two designs. fn draw_portrait(canvas: &mut Canvas, picture: &Picture, x: i32, y: i32, size: i32, radius: f32) { if picture.width == 0 || picture.height == 0 || size <= 0 { return; } // Cover: the short side decides the scale, and the long one is cropped // evenly. A face is usually in the middle of the frame. let side = picture.width.min(picture.height) as f32; let left = (picture.width as f32 - side) / 2.0; let top = (picture.height as f32 - side) / 2.0; let step = side / size as f32; for row in 0..size { for column in 0..size { // The box this destination pixel covers in the source. let sx0 = left + column as f32 * step; let sy0 = top + row as f32 * step; let (mut r, mut g, mut b, mut a, mut n) = (0.0, 0.0, 0.0, 0.0, 0.0); let mut sy = sy0; while sy < sy0 + step { let mut sx = sx0; while sx < sx0 + step { let px = (sx as u32).min(picture.width - 1); let py = (sy as u32).min(picture.height - 1); let index = ((py * picture.width + px) * 4) as usize; r += picture.rgba[index] as f32; g += picture.rgba[index + 1] as f32; b += picture.rgba[index + 2] as f32; a += picture.rgba[index + 3] as f32; n += 1.0; sx += 1.0; } sy += 1.0; } if n == 0.0 { continue; } let coverage = (a / n / 255.0) * corner_coverage(column, row, size, radius); canvas.blend( x + column, y + row, [(r / n) as u8, (g / n) as u8, (b / n) as u8], coverage, ); } } } /// How much of a pixel is inside a rounded square. 1 away from the corners, /// and a soft edge across each corner's arc so the rounding is not a staircase. fn corner_coverage(column: i32, row: i32, size: i32, radius: f32) -> f32 { if radius <= 0.0 { return 1.0; } let (x, y) = (column as f32 + 0.5, row as f32 + 0.5); let far = size as f32 - radius; let cx = if x < radius { radius } else if x > far { far } else { return 1.0; }; let cy = if y < radius { radius } else if y > far { far } else { return 1.0; }; let distance = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt(); (radius + 0.5 - distance).clamp(0.0, 1.0) } /// The letter an account stands behind when it has no picture, or none this /// module could decode. /// /// The same monogram the site draws in the same place, for the same reason: /// an empty square reads as a picture that failed to load, and a letter reads /// as an account. fn draw_monogram(canvas: &mut Canvas, kit: &Kit, name: &str, x: i32, y: i32, size: i32) { canvas.rect(x, y, size, size, PANEL, 0.9); let letter = name .chars() .find(|c| c.is_ascii_alphanumeric()) .unwrap_or('?') .to_ascii_uppercase() .to_string(); let scale = size as f32 * 0.52; let width = measure(&kit.display, &letter, scale, 0.0); draw_text( canvas, &kit.display, &letter, x + (size - width as i32) / 2, y + size - (size as f32 * 0.28) as i32, ink(scale, MUTED, 0.0), ); } /// Draw a picture over the whole canvas, cropped to fill it, dimmed. /// /// Cover rather than fit: a letterboxed board leaves bars the card cannot use /// and the middle of a battlefield is the interesting part of it. Nearest /// neighbour, because the board is pixel art and smoothing it turns MegaMek's /// hexes into mush. fn draw_backdrop(canvas: &mut Canvas, picture: &Picture, dim: f32) { if picture.width == 0 || picture.height == 0 { return; } let scale = (canvas.width as f32 / picture.width as f32) .max(canvas.height as f32 / picture.height as f32); let offset_x = (canvas.width as f32 - picture.width as f32 * scale) / 2.0; let offset_y = (canvas.height as f32 - picture.height as f32 * scale) / 2.0; for y in 0..canvas.height as i32 { for x in 0..canvas.width as i32 { let source_x = ((x as f32 - offset_x) / scale) as i32; let source_y = ((y as f32 - offset_y) / scale) as i32; if source_x < 0 || source_y < 0 || source_x >= picture.width as i32 || source_y >= picture.height as i32 { continue; } let index = ((source_y as u32 * picture.width + source_x as u32) * 4) as usize; let alpha = picture.rgba[index + 3] as f32 / 255.0; let colour = [ picture.rgba[index], picture.rgba[index + 1], picture.rgba[index + 2], ]; canvas.blend(x, y, colour, alpha * (1.0 - dim)); } } } /// One flat colour as a camo, for the test that painting reaches a machine. #[cfg(test)] pub fn flat_camo(colour: [u8; 3]) -> Vec { let mut pixels = Vec::with_capacity(84 * 72 * 4); for _ in 0..(84 * 72) { pixels.extend_from_slice(&[colour[0], colour[1], colour[2], 255]); } let mut out = Vec::new(); { let mut encoder = png::Encoder::new(&mut out, 84, 72); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder.write_header().expect("header"); writer.write_image_data(&pixels).expect("pixels"); } out } /// A player's colours on a machine, the way MegaMek paints them. /// /// The same rule as `web/src/camo/mech.ts` and MegaMek's own /// `EntityImage.applyColor`: only grey pixels take the camo, and the sprite's /// grey level scales the colour, so the shading stays and the hue comes from /// the player. Both images are authored at 84x72, so the sample is the same /// pixel and there is nothing to interpolate. /// /// Third copy of this rule, and the last one worth having: the editor paints /// it in the browser, the match paints it in MegaMek, and a card that painted /// it differently would show colours the player never chose. fn paint(sprite: &Picture, camo: Option<&Picture>) -> Picture { let Some(camo) = camo else { return Picture { width: sprite.width, height: sprite.height, rgba: sprite.rgba.clone(), }; }; let mut rgba = sprite.rgba.clone(); for y in 0..sprite.height { for x in 0..sprite.width { let index = ((y * sprite.width + x) * 4) as usize; let (r, g, b, a) = ( rgba[index], rgba[index + 1], rgba[index + 2], rgba[index + 3], ); if a == 0 || r != g || g != b { continue; } let cx = (x % camo.width.max(1)).min(camo.width.saturating_sub(1)); let cy = (y % camo.height.max(1)).min(camo.height.saturating_sub(1)); let source = ((cy * camo.width + cx) * 4) as usize; let grey = b as u32; for channel in 0..3 { rgba[index + channel] = ((camo.rgba[source + channel] as u32 * grey) / 255) as u8; } } } Picture { width: sprite.width, height: sprite.height, rgba, } } /// One machine's picture, at whatever size the layout wants it. /// /// A machine that did not come home is drawn dimmed and struck through: it /// was in the fight, and a card that leaves it out says a lance of four was a /// lance of three. fn draw_sprite(canvas: &mut Canvas, picture: &Picture, x: i32, y: i32, size: i32, alive: bool) { if picture.width == 0 || picture.height == 0 { return; } let scale = size as f32 / picture.width as f32; let height = (picture.height as f32 * scale) as i32; // A wreck is still on the card - it was in the fight - but it reads as // one: dimmed towards the ground and struck through. Not dimmed further // than this, because at 0.34 on this background it disappeared entirely // and a lance of four looked like a lance of two. let fade = if alive { 1.0 } else { 0.62 }; for row in 0..height { for column in 0..size { let source_x = (column as f32 / scale) as u32; let source_y = (row as f32 / scale) as u32; if source_x >= picture.width || source_y >= picture.height { continue; } let index = ((source_y * picture.width + source_x) * 4) as usize; let alpha = picture.rgba[index + 3] as f32 / 255.0; let colour = [ picture.rgba[index], picture.rgba[index + 1], picture.rgba[index + 2], ]; canvas.blend(x + column, y + row, colour, alpha * fade); } } if !alive { canvas.rect(x, y + height / 2 - 1, size, 3, DANGER, 0.95); } } /// One line of text, drawn at a baseline. Returns where it ended. /// How a line is set: its size, its colour and the air between its letters. #[derive(Clone, Copy)] struct Ink { size: f32, colour: [u8; 3], tracking: f32, } fn ink(size: f32, colour: [u8; 3], tracking: f32) -> Ink { Ink { size, colour, tracking, } } fn draw_text( canvas: &mut Canvas, font: &FontRef, text: &str, x: i32, baseline: i32, ink: Ink, ) -> i32 { let Ink { size, colour, tracking, } = ink; let scaled = font.as_scaled(PxScale::from(size)); let mut pen = x as f32; let mut previous: Option = None; for character in text.chars() { let id = font.glyph_id(character); if let Some(last) = previous { pen += scaled.kern(last, id); } let glyph = id.with_scale_and_position(PxScale::from(size), ab_glyph::point(pen, baseline as f32)); if let Some(outline) = font.outline_glyph(glyph) { let bounds = outline.px_bounds(); outline.draw(|gx, gy, coverage| { canvas.blend( bounds.min.x as i32 + gx as i32, bounds.min.y as i32 + gy as i32, colour, coverage, ); }); } pen += scaled.h_advance(id) + tracking; previous = Some(id); } pen as i32 } /// How wide a line would be, for centring, truncating and right-aligning. fn measure(font: &FontRef, text: &str, size: f32, tracking: f32) -> f32 { let scaled = font.as_scaled(PxScale::from(size)); let mut width = 0.0; let mut previous: Option = None; for character in text.chars() { let id = font.glyph_id(character); if let Some(last) = previous { width += scaled.kern(last, id); } width += scaled.h_advance(id) + tracking; previous = Some(id); } width } /// A line cut to fit: shrunk as far as it may go, then trimmed with an /// ellipsis. /// /// An ATProto handle is a domain name and can be as long as one. Shrinking /// alone turns a card's own name into six-point type; this stops shrinking at /// something readable and takes the tail off instead. fn clipped( font: &FontRef, text: &str, size: f32, floor: f32, tracking: f32, room: f32, ) -> (String, f32) { let size = { let mut size = size; while size > floor && measure(font, text, size, tracking) > room { size -= 1.0; } size }; if measure(font, text, size, tracking) <= room { return (text.to_owned(), size); } let mut cut = text.to_owned(); while !cut.is_empty() { cut.pop(); let candidate = format!("{cut}…"); if measure(font, &candidate, size, tracking) <= room { return (candidate, size); } } (String::from("…"), size) } /// A paragraph broken into lines that fit. fn wrapped(font: &FontRef, text: &str, size: f32, room: f32) -> Vec { let mut lines: Vec = Vec::new(); let mut line = String::new(); for word in text.split_whitespace() { let candidate = if line.is_empty() { word.to_owned() } else { format!("{line} {word}") }; if measure(font, &candidate, size, 0.0) <= room || line.is_empty() { line = candidate; } else { lines.push(std::mem::take(&mut line)); line = word.to_owned(); } } if !line.is_empty() { lines.push(line); } lines } /// The largest size at or under `size` that fits `room`, so a long pair of /// handles shrinks rather than running off the card. fn fitted(font: &FontRef, text: &str, size: f32, tracking: f32, room: f32) -> f32 { let mut size = size; while size > 18.0 && measure(font, text, size, tracking) > room { size -= 2.0; } size } /// Everything a layout needs to draw with, resolved once. struct Kit<'a> { display: FontRef<'a>, text: FontRef<'a>, } /// The ground every card stands on: the hex tile, under a wash that keeps the /// type legible. /// /// The board render used to be laid over this, and the two together read as /// noise - a drawn map showing through a photographed one. The board is a /// card of its own now (`Layout::Battlefield`), where it is the picture /// rather than a texture behind one. fn draw_ground(canvas: &mut Canvas, wash_from: f32) { if let Some(hex) = decode(HEX) { tile(canvas, &hex, 0.55); } for y in 0..HEIGHT as i32 { let depth = (y as f32 / HEIGHT as f32 - wash_from).max(0.0) / (1.0 - wash_from); canvas.rect(0, y, WIDTH as i32, 1, BACKDROP, depth * 0.80); } canvas.rect(0, 0, WIDTH as i32, 4, BRAND, 1.0); // Corner ticks, the way a sight is marked. Cheap, and they turn a // rectangle of information into an instrument. let tick = 26; for (x, y, dx, dy) in [ (MARGIN - 18, MARGIN - 18, 1, 1), (WIDTH as i32 - MARGIN + 18, MARGIN - 18, -1, 1), (MARGIN - 18, HEIGHT as i32 - MARGIN + 18, 1, -1), ( WIDTH as i32 - MARGIN + 18, HEIGHT as i32 - MARGIN + 18, -1, -1, ), ] { let (ax, ay) = (x.min(x + dx * tick), y.min(y + dy * tick)); canvas.rect(ax, y, tick, 2, LINE, 0.85); canvas.rect(x.min(x + dx * 2), ay, 2, tick, LINE, 0.85); } } /// One picture repeated across the whole canvas. fn tile(canvas: &mut Canvas, picture: &Picture, alpha: f32) { if picture.width == 0 || picture.height == 0 { return; } for y in 0..canvas.height as i32 { for x in 0..canvas.width as i32 { let sx = (x as u32) % picture.width; let sy = (y as u32) % picture.height; let index = ((sy * picture.width + sx) * 4) as usize; let colour = [ picture.rgba[index], picture.rgba[index + 1], picture.rgba[index + 2], ]; let coverage = picture.rgba[index + 3] as f32 / 255.0; canvas.blend(x, y, colour, alpha * coverage); } } } /// The faint line along the foot: when the match was fought and how long it /// ran. Centred, small, and the last thing anybody reads - the designation /// across the top already says what was played, so this does not repeat it. fn draw_footer(canvas: &mut Canvas, kit: &Kit, card: &Card) { if card.sub.is_empty() { return; } let width = measure(&kit.text, card.sub, 20.0, 2.0) as i32; draw_text( canvas, &kit.text, card.sub, (WIDTH as i32 - width) / 2, HEIGHT as i32 - 26, ink(20.0, FAINT, 2.0), ); } /// The site's name, quietly, where every card carries it. fn draw_mark(canvas: &mut Canvas, kit: &Kit, x: i32, baseline: i32) { let mark = "lance.blue"; let width = measure(&kit.display, mark, 26.0, 2.0); draw_text( canvas, &kit.display, mark, x - width as i32, baseline, ink(26.0, MUTED, 2.0), ); } /// The line at the top of every card: how the match was reached, and what it /// was. Returns the baseline it ended on. fn draw_heading(canvas: &mut Canvas, kit: &Kit, card: &Card) -> i32 { let y = MARGIN + 30; let eyebrow = card.eyebrow.to_uppercase(); let end = draw_text( canvas, &kit.display, &eyebrow, MARGIN, y, ink(24.0, BRAND, 4.0), ); // A rule from the end of the label to the mark, which is what makes the // line read as a header rather than as a caption. let mark = WIDTH as i32 - MARGIN - 140; if mark > end + 18 { canvas.rect(end + 18, y - 8, mark - end - 18, 1, LINE, 0.9); } draw_mark(canvas, kit, WIDTH as i32 - MARGIN, y); y } /// The one word, when there is one: VICTORY, DEFEAT, DRAW. /// /// Set in the display face at the size the site sets its own verdict, in the /// colour the outcome deserves. "@a won" is a caption; this is what the /// player is posting. fn draw_verdict(canvas: &mut Canvas, kit: &Kit, card: &Card, y: i32) -> i32 { if card.verdict.is_empty() { return y; } let colour = match card.verdict { "DEFEAT" => DANGER, "DRAW" => MUTED, _ => BRAND, }; draw_text( canvas, &kit.display, card.verdict, MARGIN, y + 74, ink(66.0, colour, 14.0), ); y + 74 } /// Draw the card. /// /// `None` only if the fonts do not parse or the PNG will not encode, neither /// of which depends on the match: the caller falls back to the board itself. /// What a player's card says. pub struct Player<'a> { /// "@handle", or the DID where no handle resolves back to it. /// /// The DID used to be printed under this and is not any more: it is how a /// bug report names an account, not how a person does, and the line it /// took is worth more to the bio. pub name: &'a str, /// The account's own picture as it uploaded it, in whichever format that /// is. None where it has none, or where the repository would not answer: /// the card draws a monogram in its place rather than a hole. pub avatar: Option>, /// The figures that are a standing, in the order they are read. /// /// Separate from `activity` because they are a different kind of claim: /// a rating and a kill ratio say how somebody plays and want to be read /// against each other, which is what an instrument column is for. A /// figure nothing records yet is an em dash here exactly as it is on the /// page. pub scores: Vec<(String, String)>, /// The figures that are a count of what somebody has done: matches /// played, forces built, camo painted. Blocks rather than instruments - /// they are read one at a time and none of them means anything against /// the others. pub activity: Vec<(String, String)>, /// A line about the player, when there is somewhere for one to come from. pub bio: Option, /// How many medals to draw. Nothing awards one yet. pub medals: usize, /// Whether the faction mark is drawn behind the card. pub faction: bool, } /// A player's card, for the link somebody posts about them. pub fn draw_player(player: &Player) -> Option> { let kit = Kit { display: FontRef::try_from_slice(DISPLAY).ok()?, text: FontRef::try_from_slice(TEXT).ok()?, }; let mut canvas = Canvas::new(WIDTH, HEIGHT, BACKDROP); let portrait = player.avatar.as_deref().and_then(decode); draw_hud_player(&mut canvas, &kit, player, portrait.as_ref()); canvas.into_png() } /// The header every one of them wears: the label, a rule, and the mark. fn draw_player_header(canvas: &mut Canvas, kit: &Kit) -> i32 { let y = MARGIN + 30; let end = draw_text( canvas, kit_display(kit), "PLAYER", MARGIN, y, ink(24.0, BRAND, 4.0), ); let mark = WIDTH as i32 - MARGIN - 140; if mark > end + 18 { canvas.rect(end + 18, y - 8, mark - end - 18, 1, LINE, 0.9); } draw_mark(canvas, kit, WIDTH as i32 - MARGIN, y); y } fn kit_display<'a>(kit: &'a Kit) -> &'a FontRef<'a> { &kit.display } /// The picture, or the letter it stands in for. fn draw_face( canvas: &mut Canvas, kit: &Kit, player: &Player, portrait: Option<&Picture>, x: i32, y: i32, size: i32, ) { match portrait { Some(picture) => draw_portrait(canvas, picture, x, y, size, 10.0), None => draw_monogram(canvas, kit, player.name, x, y, size), } // A hairline around it, so a dark photograph does not dissolve into the // ground it is standing on. canvas.rect(x, y, size, 1, LINE, 0.9); canvas.rect(x, y + size - 1, size, 1, LINE, 0.9); canvas.rect(x, y, 1, size, LINE, 0.9); canvas.rect(x + size - 1, y, 1, size, LINE, 0.9); } /// The name, as large as the column allows. /// /// Just the name: the DID used to be set under it, and it is debugging /// information rather than something anybody reads. What it was taking is /// where the bio goes. fn draw_who( kit: &Kit, canvas: &mut Canvas, player: &Player, x: i32, baseline: i32, room: f32, big: f32, ) { let (name, size) = clipped(&kit.display, player.name, big, 34.0, 0.0, room); draw_text( canvas, &kit.display, &name, x, baseline, ink(size, INK, 0.0), ); } /// One figure with no panel under it: for the shapes that rule instead. fn stat_bare( canvas: &mut Canvas, kit: &Kit, x: i32, baseline: i32, stat: &(String, String), figure_size: f32, ) { draw_text( canvas, &kit.display, &stat.1, x, baseline, ink(figure_size, INK, 0.0), ); let upper = stat.0.to_uppercase(); draw_text( canvas, &kit.display, &upper, x, baseline + 26, ink(16.0, MUTED, 3.0), ); } /// Where everything on a player's card sits. /// /// Two columns, because the card is two things: on the right the account - /// its picture, its medals, the mark it flies under - and on the left what it /// has done. Fixed rather than flowed, so that a shape which leaves a region /// empty leaves it empty in the same place as the shape that fills it, and /// the five can be compared without measuring. mod player_grid { use super::{HEIGHT, MARGIN, WIDTH}; /// The account's column, on the right. pub const FACE: i32 = 280; pub const FACE_X: i32 = WIDTH as i32 - MARGIN - FACE; pub const FACE_Y: i32 = 140; /// Under the picture: the medals, and then the mark. pub const MEDALS_Y: i32 = FACE_Y + FACE + 26; pub const MEDAL: i32 = 44; /// What it has done, on the left. pub const LEFT: i32 = MARGIN; pub const RIGHT: i32 = FACE_X - 50; pub const ROOM: i32 = RIGHT - LEFT; pub const NAME_BASE: i32 = 196; /// The line or three about the player, in the room the DID used to take. pub const BIO_Y: i32 = 262; /// The standing, as instruments. pub const SCORES_Y: i32 = 384; /// The counts, ruled along the foot. pub const COUNTS_Y: i32 = HEIGHT as i32 - MARGIN - 96; } /// Corner ticks around the portrait, the way the ground marks its own. fn draw_ticks(canvas: &mut Canvas, x: i32, y: i32, size: i32) { let tick = 30; for (cx, cy, dx, dy) in [ (x - 14, y - 14, 1, 1), (x + size + 14, y - 14, -1, 1), (x - 14, y + size + 14, 1, -1), (x + size + 14, y + size + 14, -1, -1), ] { let (ax, ay) = (cx.min(cx + dx * tick), cy.min(cy + dy * tick)); canvas.rect(ax, cy, tick, 2, BRAND, 0.75); canvas.rect(cx.min(cx + dx * 2), ay, 2, tick, BRAND, 0.75); } } /// A standing, read as an instrument: the word, a leader, the figure. /// /// The leader is what makes an em dash read as a gauge at rest rather than as /// a number somebody forgot to fill in, which matters while neither of these /// has a source yet. fn draw_scores(canvas: &mut Canvas, kit: &Kit, player: &Player, top: i32) { use player_grid::{LEFT, RIGHT}; let mut y = top; for stat in &player.scores { let upper = stat.0.to_uppercase(); let label_end = draw_text(canvas, &kit.display, &upper, LEFT, y, ink(20.0, MUTED, 3.0)); let figure_width = measure(&kit.display, &stat.1, 38.0, 0.0) as i32; let leader = RIGHT - figure_width - label_end - 28; if leader > 0 { canvas.rect(label_end + 14, y - 8, leader, 1, LINE, 0.6); } draw_text( canvas, &kit.display, &stat.1, RIGHT - figure_width, y, ink(38.0, INK, 0.0), ); y += 60; } } /// The counts, ruled rather than panelled. fn draw_activity_strip(canvas: &mut Canvas, kit: &Kit, player: &Player) { use player_grid::{COUNTS_Y, LEFT, ROOM}; let top = COUNTS_Y + 10; canvas.rect(LEFT, top, ROOM, 1, LINE, 0.9); let count = player.activity.len().max(1) as i32; let step = ROOM / count; for (index, stat) in player.activity.iter().enumerate() { let x = LEFT + index as i32 * step; if index > 0 { canvas.rect(x - 22, top + 8, 1, 72, LINE, 0.7); } stat_bare(canvas, kit, x, top + 56, stat, 44.0); } } /// A line about the player, wrapped to three. /// /// Three because that is what the DID's line and the space under it add up /// to, and because the fourth is where somebody starts writing an essay - and /// this is a card in a timeline. Nothing writes one yet. fn draw_bio(canvas: &mut Canvas, kit: &Kit, text: &str, top: i32) { use player_grid::{LEFT, ROOM}; for (index, line) in wrapped(&kit.text, text, 24.0, ROOM as f32) .into_iter() .take(3) .enumerate() { draw_text( canvas, &kit.text, &line, LEFT, top + index as i32 * 32, ink(24.0, MUTED, 0.0), ); } } /// Medals, drawn as the notched plates every other control on the site is cut /// from rather than as circles: a round badge would be the only round thing /// on the card. /// /// Nothing awards one yet. These are drawn to see whether the row belongs on /// the card at all, and what it costs the shapes around it. fn draw_medals(canvas: &mut Canvas, x: i32, y: i32, count: usize, size: i32) { let gap = 12; for index in 0..count { let left = x + index as i32 * (size + gap); canvas.rect(left, y, size, size, PANEL, 0.85); // The plate's notch, top left and bottom right, cut by hand. let notch = size / 5; for step in 0..notch { canvas.rect(left, y + step, notch - step, 1, BACKDROP, 0.9); canvas.rect( left + size - (notch - step), y + size - 1 - step, notch - step, 1, BACKDROP, 0.9, ); } // A rank pip, so the row does not read as a row of empty boxes. let pip = size / 3; canvas.rect( left + (size - pip) / 2, y + (size - pip) / 2, pip, pip, if index == 0 { BRAND } else { FAINT }, 0.9, ); canvas.rect(left, y + size - 2, size, 2, LINE, 0.9); } } /// A faction's mark, ghosted behind the card. /// /// A drawn stand-in rather than anybody's real insignia: which factions a /// player can fly under, and where those marks come from, is a decision /// nobody has made. Concentric hexes and a chevron, at the weight a watermark /// has to sit at to stay behind the words. fn draw_faction_mark(canvas: &mut Canvas, cx: i32, cy: i32, size: i32) { let radius = size as f32 / 2.0; for (scale, alpha) in [(1.0, 0.20), (0.74, 0.15), (0.48, 0.11)] { let r = radius * scale; for step in 0..6 { let a0 = std::f32::consts::PI / 3.0 * step as f32; let a1 = std::f32::consts::PI / 3.0 * (step + 1) as f32; line( canvas, cx as f32 + r * a0.cos(), cy as f32 + r * a0.sin(), cx as f32 + r * a1.cos(), cy as f32 + r * a1.sin(), BRAND, alpha, ); } } // The chevrons through the middle, which is what stops it reading as a // target rather than as a mark. let arm = radius * 0.5; for offset in [-arm * 0.45, arm * 0.15] { line( canvas, cx as f32 - arm, cy as f32 + offset + arm * 0.5, cx as f32, cy as f32 + offset - arm * 0.4, BRAND, 0.17, ); line( canvas, cx as f32, cy as f32 + offset - arm * 0.4, cx as f32 + arm, cy as f32 + offset + arm * 0.5, BRAND, 0.17, ); } } /// A straight line, stepped along its longer axis. fn line(canvas: &mut Canvas, x0: f32, y0: f32, x1: f32, y1: f32, colour: [u8; 3], alpha: f32) { let steps = ((x1 - x0).abs().max((y1 - y0).abs()) * 1.5).ceil() as i32; for step in 0..=steps.max(1) { let t = step as f32 / steps.max(1) as f32; let x = x0 + (x1 - x0) * t; let y = y0 + (y1 - y0) * t; canvas.blend(x as i32, y as i32, colour, alpha); canvas.blend(x as i32 + 1, y as i32, colour, alpha * 0.6); } } /// A player's card: the account on the right, what it has done on the left. /// /// The split the five shapes share is the thing being decided. A rating and a /// kill ratio are a standing - they are read against each other and against /// other players - so they are instruments with leaders. Matches, forces and /// camo are counts of what somebody has done, read one at a time, so they are /// blocks. Putting all five in one row made the two kinds look like one kind. fn draw_hud_player(canvas: &mut Canvas, kit: &Kit, player: &Player, portrait: Option<&Picture>) { use player_grid::*; draw_ground(canvas, 0.16); if player.faction { // Behind everything, and centred on the card rather than on either // column: a watermark that sits inside one of them is an illustration. draw_faction_mark(canvas, WIDTH as i32 / 2 + 60, HEIGHT as i32 / 2 + 20, 440); } draw_player_header(canvas, kit); draw_face(canvas, kit, player, portrait, FACE_X, FACE_Y, FACE); draw_ticks(canvas, FACE_X, FACE_Y, FACE); draw_who(kit, canvas, player, LEFT, NAME_BASE, ROOM as f32, 64.0); // A line or three about the player, where the DID used to be. Nothing // writes one yet, and the room is held rather than closed up: what fills // it is a decision about profiles, not about this card. if let Some(bio) = &player.bio { draw_bio(canvas, kit, bio, BIO_Y); } // Where the instruments start. The room above them is the bio's, and // nothing writes a bio yet: held open on a card that has none reads as a // hole rather than as a reservation, so they come up to meet the name and // drop back down the day there is a line to sit above them. let scores_y = if player.bio.is_some() { SCORES_Y } else { SCORES_Y - 56 }; draw_scores(canvas, kit, player, scores_y); // Medals under the picture, where they belong to the account rather than // to the figures. Nothing awards one yet either. if player.medals > 0 { draw_medals(canvas, FACE_X, MEDALS_Y, player.medals, MEDAL); } draw_activity_strip(canvas, kit, player); } pub fn draw(card: &Card) -> Option> { let kit = Kit { display: FontRef::try_from_slice(DISPLAY).ok()?, text: FontRef::try_from_slice(TEXT).ok()?, }; let mut canvas = Canvas::new(WIDTH, HEIGHT, BACKDROP); match card.layout { Layout::Dossier => draw_dossier(&mut canvas, &kit, card), Layout::Lance => draw_lance(&mut canvas, &kit, card), Layout::Duel => draw_duel(&mut canvas, &kit, card), Layout::Daily => draw_daily(&mut canvas, &kit, card), Layout::Battlefield => draw_battlefield(&mut canvas, &kit, card), } canvas.into_png() } /// The board as the picture, rather than as a texture behind one. /// /// Full bleed and barely dimmed, with the type sitting on scrims rather than /// on a wash over the whole card: the point of this one is the terrain the /// match was actually fought over, and every pixel spent making it quieter is /// spent against it. Falls back to the ordinary ground if the board will not /// decode, so this never renders as an empty card. fn draw_battlefield(canvas: &mut Canvas, kit: &Kit, card: &Card) { match card.board.as_ref().and_then(|bytes| decode(bytes)) { Some(board) => { draw_backdrop(canvas, &board, 0.12); // Scrims top and bottom, so the type has ground without the whole // picture being dimmed for it. for y in 0..HEIGHT as i32 { let from_top = 1.0 - (y as f32 / 260.0).min(1.0); let from_bottom = 1.0 - ((HEIGHT as i32 - y) as f32 / 200.0).min(1.0); let shade = from_top.max(from_bottom); if shade > 0.0 { canvas.rect(0, y, WIDTH as i32, 1, BACKDROP, shade * 0.86); } } canvas.rect(0, 0, WIDTH as i32, 4, BRAND, 1.0); } None => draw_ground(canvas, 0.26), } let y = draw_heading(canvas, kit, card); let y = draw_verdict(canvas, kit, card, y); if !card.title.is_empty() { let room = (WIDTH as i32 - MARGIN * 2) as f32; let (title, size) = clipped(&kit.display, card.title, 32.0, 20.0, 0.0, room); draw_text( canvas, &kit.display, &title, MARGIN, y + 40, ink(size, INK, 0.0), ); } draw_footer(canvas, kit, card); } /// Everyone who fought, allies together. /// /// Two shapes, because a 1v1 and a four-cornered fight are not the same /// picture: up to two teams stack as full-width rows, and three or four sit /// in a two-by-two of panels. Eight seats across four teams is the most this /// can hold and still name anybody. fn draw_dossier(canvas: &mut Canvas, kit: &Kit, card: &Card) { draw_ground(canvas, 0.26); let y = draw_heading(canvas, kit, card); let y = draw_verdict(canvas, kit, card, y); let room = (WIDTH as i32 - MARGIN * 2) as f32; let y = if card.title.is_empty() { y } else { let (title, size) = clipped(&kit.display, card.title, 32.0, 20.0, 0.0, room); draw_text( canvas, &kit.display, &title, MARGIN, y + 40, ink(size, INK, 0.0), ); y + 40 }; draw_footer(canvas, kit, card); let mut teams: Vec = card.forces.iter().map(|f| f.team).collect(); teams.sort_unstable(); teams.dedup(); let head = y + 40; // Clear of the footer line every card now carries. let space = HEIGHT as i32 - MARGIN - 18 - head; if teams.len() > 2 { // Four corners. Each panel is one team, its forces inside it. let column = (WIDTH as i32 - MARGIN * 2 - 24) / 2; let row = (space - 20) / 2; for (index, team) in teams.iter().take(4).enumerate() { let x = MARGIN + (index as i32 % 2) * (column + 24); let y = head + (index as i32 / 2) * (row + 20); let members: Vec<&Force> = card.forces.iter().filter(|f| f.team == *team).collect(); draw_team_panel( canvas, kit, *team, &members, Box { x, y, width: column, height: row, }, ); } return; } let rows = card.forces.len() as i32; let block = ((space - teams.len() as i32 * 30) / rows.max(1)).clamp(64, 104); let mut row_y = head; for team in teams { let members: Vec<&Force> = card.forces.iter().filter(|f| f.team == team).collect(); if members.is_empty() { continue; } let tint = side_tint(&members); draw_text( canvas, &kit.display, &format!("TEAM {team}"), MARGIN, row_y + 12, ink(17.0, tint, 3.0), ); // The bracket down the side of the group, which is what says these // two are one side. let group = members.len() as i32 * block; canvas.rect(MARGIN, row_y + 20, 3, group - 12, tint, 0.8); for force in members { draw_force_row(canvas, kit, force, MARGIN + 14, row_y + 20, block); row_y += block; } row_y += 30; } } /// A side's colour: the victors' accent, everyone else muted. fn side_tint(members: &[&Force]) -> [u8; 3] { if members.iter().any(|force| force.victor) { ACCENT } else { MUTED } } /// One team in a corner of a four-cornered fight: who was on it, what each of /// them has left, and their machines. /// A rectangle on the card, for the layouts that place panels rather than /// rows. #[derive(Clone, Copy)] struct Box { x: i32, y: i32, width: i32, height: i32, } fn draw_team_panel(canvas: &mut Canvas, kit: &Kit, team: i64, members: &[&Force], area: Box) { let Box { x, y, width, height, } = area; if members.is_empty() { return; } let tint = side_tint(members); canvas.rect(x, y, width, height, PANEL, 0.72); canvas.rect(x, y, width, 2, tint, if tint == ACCENT { 1.0 } else { 0.5 }); canvas.rect( x, y, 2, height, tint, if tint == ACCENT { 1.0 } else { 0.5 }, ); let mut end = draw_text( canvas, &kit.display, &format!("TEAM {team}"), x + 16, y + 26, ink(17.0, tint, 3.0), ); if tint == ACCENT { draw_text( canvas, &kit.display, "VICTOR", end + 14, y + 26, ink(17.0, ACCENT, 3.0), ); end += 0; } let _ = end; let block = (height - 36) / members.len().max(1) as i32; for (index, force) in members.iter().enumerate() { let row_y = y + 34 + index as i32 * block; // The machines sit beside the numbers rather than under them: a panel // is a quarter of a card, and a row of silhouettes below two lines of // text lands on the next player's name. let text_room = (width * 3 / 5 - 32) as f32; let (name, size) = clipped(&kit.display, force.name, 26.0, 15.0, 0.0, text_room); let name_end = draw_text( canvas, &kit.display, &name, x + 16, row_y + 22, ink(size, INK, 0.0), ); if force.bot { draw_text( canvas, &kit.display, "AI", name_end + 10, row_y + 22, ink(15.0, MUTED, 3.0), ); } let line = format!( "{}/{} standing · {}%", force.units_remaining, force.units_start, force.bv_share, ); draw_text( canvas, &kit.text, &line, x + 16, row_y + 44, ink(20.0, MUTED, 0.0), ); let icons_x = x + width * 3 / 5; draw_machines( canvas, force, icons_x, row_y - 6, width - (icons_x - x) - 16, (block - 16).clamp(24, 40), None, ); } } /// One force in a dossier row: who, what is left, and its machines. /// /// No rail of its own: the team bracket beside the group is what marks the /// side, and a second vertical line per row read as four sides in a 2v2. fn draw_force_row(canvas: &mut Canvas, kit: &Kit, force: &Force, x: i32, y: i32, block: i32) { let tint = if force.victor { ACCENT } else { MUTED }; let name_x = x + 12; // A handle is a domain name; the tags that follow it need room whatever // it is called. let (name, size) = clipped(&kit.display, force.name, 28.0, 17.0, 0.0, 300.0); let end = draw_text( canvas, &kit.display, &name, name_x, y + 24, ink(size, INK, 0.0), ); // Princess fights under the site's own handle. Without this the card // reads as though the @lance.blue account took the field. let end = if force.bot { draw_text( canvas, &kit.display, "AI · PRINCESS", end + 12, y + 24, ink(16.0, MUTED, 3.0), ) } else { end }; if force.victor { draw_text( canvas, &kit.display, "VICTOR", end + 12, y + 24, ink(17.0, ACCENT, 3.0), ); } let line = format!( "{} of {} standing · {}% battle value", force.units_remaining, force.units_start, force.bv_share, ); draw_text( canvas, &kit.text, &line, name_x, y + 48, ink(23.0, MUTED, 0.0), ); let bar_width = 360; canvas.rect(name_x, y + 58, bar_width, 4, LINE, 1.0); let filled = (bar_width as i64 * force.bv_share.clamp(0, 100) / 100) as i32; canvas.rect(name_x, y + 58, filled, 4, tint, 1.0); let icons_x = name_x + bar_width + 32; draw_machines( canvas, force, icons_x, y, WIDTH as i32 - MARGIN - icons_x, (block - 20).min(56), None, ); } /// A force's machines, painted in its camo, inside the room they are given. /// /// Bug Company fields twelve machines against one lance, so a row of four at /// a fixed size is not a layout - it is a card that quietly stops drawing. /// These shrink to fit the width and wrap onto as many rows as they need, /// down to a floor where a silhouette still reads as one. Returns the bottom /// of what it drew, so a caller can put something under it. /// /// `pitch` is for a caller that writes something under each machine. Without /// one they are packed edge to edge, which is what makes a force read as a /// formation; with one, each machine takes a column of that width, so the /// caller can put a name at the same `x` and have the two line up. Passing a /// pitch is the only way to know where a machine landed - the packing is /// derived from the room and the count, and a caller that recomputed it /// would be writing the layout twice. fn draw_machines( canvas: &mut Canvas, force: &Force, x: i32, y: i32, room: i32, want: i32, pitch: Option, ) -> i32 { let count = force.machines.len() as i32; if count == 0 || room <= 0 { return y; } let rows = rows_for(count); let widest = rows.iter().copied().max().unwrap_or(1); let gap = (want / 6).clamp(4, 18); let fits = (room - gap * (widest - 1)) / widest; // The floor gives way to the ceiling when a caller has less room than the // floor: clamp(26, 20) is a panic, and a row asked for inside a 20px band // should be 20px rather than bring the card down. let size = fits.clamp(26.min(want), want.max(1)); let step = size * 72 / 84 + gap; // The column a machine occupies across, which is its own width unless a // caller has asked for a wider one to write under. let across = pitch.unwrap_or(size + gap); let camo = force.camo.as_ref().and_then(|bytes| decode(bytes)); let mut machines = force.machines.iter(); let mut row_y = y; for (index, row) in rows.iter().enumerate() { // Each row centred under the widest, which is what makes a lance of // seven read as a formation rather than as a ragged list. let row_width = (row - 1).max(0) * across + size; let full = (widest - 1).max(0) * across + size; let row_x = x + (full - row_width) / 2; for column in 0..*row { let Some(machine) = machines.next() else { break; }; if let Some(sprite) = machine.sprite.as_ref().and_then(|bytes| decode(bytes)) { let painted = paint(&sprite, camo.as_ref()); draw_sprite( canvas, &painted, row_x + column * across, row_y, size, machine.alive, ); } } if index + 1 < rows.len() { row_y += step; } } row_y + size * 72 / 84 } /// How to break `count` machines into rows. /// /// A force is not a list, it is a formation: twelve machines in one line is a /// stripe across the card, and 6+6 or 5+4+3 is a shape. Six is the widest row /// a card can carry and still leave a silhouette worth looking at, so that is /// the ceiling; past it the rows are balanced to within one of each other, /// widest first, which is how a real formation is drawn up. /// /// A .mms carries no force structure - MegaMek has lances and stars, the /// scenario file does not say so - so this is a shape rather than an /// organisation. When the result learns to carry one, this is where it goes. fn rows_for(count: i32) -> Vec { if count <= 6 { return vec![count.max(1)]; } let rows = (count + 5) / 6; let base = count / rows; let over = count % rows; // The wider rows first: a formation stands with its line at the front. (0..rows) .map(|index| base + i32::from(index < over)) .collect() } /// The column one machine gets on the lance card, which is the drawing width /// split evenly between them. /// /// The picture and the name are two different draws and were two different /// grids, so they walked apart. This is the one both ask, and the one the /// test asks as well - a copy of the rule in the test would pass while the /// card was wrong, which is exactly what happened before. pub(crate) fn lance_column(machines: usize) -> i32 { (WIDTH as i32 - MARGIN * 2) / machines.max(1) as i32 } /// Where machine `index`'s column starts. pub(crate) fn lance_column_x(index: usize, machines: usize) -> i32 { MARGIN + index as i32 * lance_column(machines) } /// One force in detail: the card a player posts about their own lance. fn draw_lance(canvas: &mut Canvas, kit: &Kit, card: &Card) { draw_ground(canvas, 0.16); let y = draw_heading(canvas, kit, card); let Some(force) = card.forces.iter().find(|f| f.mine).or(card.forces.first()) else { return; }; let y = draw_verdict(canvas, kit, card, y); let room = (WIDTH as i32 - MARGIN * 2) as f32; let size = fitted(&kit.display, card.title, 40.0, 0.0, room); draw_text( canvas, &kit.display, card.title, MARGIN, y + 42, ink(size, INK, 0.0), ); draw_footer(canvas, kit, card); // Named underneath, while there is room for a name per machine. Past // four they are a force rather than a list, and the names would be two // characters wide each. // // A name needs far more width than a silhouette - "Vedette Medium Tank" // against 132 pixels of tank - so when there are names the machines are // laid out on the names' grid rather than packed. One column width for // both, or the pictures walk out from under the words: they used to be // packed at 150 and named at 272, which put the fourth name 366 pixels // from its machine. let named = force.machines.len() <= 4; let column = lance_column(force.machines.len()); // As big as they fit: a lance is four machines and Bug Company is twelve, // and both have to land on the same card. let bottom = draw_machines( canvas, force, MARGIN, y + 104, WIDTH as i32 - MARGIN * 2, 132, named.then_some(column), ); if named { for (index, machine) in force.machines.iter().enumerate() { let x = lance_column_x(index, force.machines.len()); let name_size = fitted(&kit.display, machine.name, 26.0, 0.0, (column - 16) as f32); draw_text( canvas, &kit.display, machine.name, x, bottom + 34, ink(name_size, if machine.alive { INK } else { MUTED }, 0.0), ); draw_text( canvas, &kit.text, machine.condition, x, bottom + 60, ink(22.0, condition_ink(machine.condition), 0.0), ); } } let line = format!( "{} of {} standing · {}% battle value", force.units_remaining, force.units_start, force.bv_share, ); draw_text( canvas, &kit.display, &line, MARGIN, HEIGHT as i32 - MARGIN, ink(30.0, INK, 0.0), ); } /// Two forces facing each other, machine by machine. fn draw_duel(canvas: &mut Canvas, kit: &Kit, card: &Card) { draw_ground(canvas, 0.10); let y = draw_heading(canvas, kit, card); draw_footer(canvas, kit, card); let top = y + 40; let height = HEIGHT as i32 - top - MARGIN - 16; let column = (WIDTH as i32 - MARGIN * 2 - 24) / 2; for (index, force) in card.forces.iter().take(2).enumerate() { let x = MARGIN + index as i32 * (column + 24); draw_duel_plate(canvas, kit, force, x, top, column, height); } } /// One plate in a duel: the notched card the site cuts its own plates as. fn draw_duel_plate( canvas: &mut Canvas, kit: &Kit, force: &Force, x: i32, y: i32, width: i32, height: i32, ) { let tint = if force.victor { ACCENT } else { MUTED }; canvas.rect(x, y, width, height, PANEL, 0.92); // The edge, and the corner the site notches out of its plates. canvas.rect(x, y, width, 2, tint, if force.victor { 1.0 } else { 0.5 }); canvas.rect(x, y, 2, height, tint, if force.victor { 1.0 } else { 0.5 }); let (name, size) = clipped( &kit.display, force.name, 30.0, 17.0, 0.0, (width - 130) as f32, ); let end = draw_text( canvas, &kit.display, &name, x + 20, y + 40, ink(size, INK, 0.0), ); let end = if force.victor { draw_text( canvas, &kit.display, "VICTOR", end + 14, y + 40, ink(18.0, ACCENT, 3.0), ) } else { end }; // Princess fights under the site's own handle, and this plate is the one // layout that never said so - a duel card read as though the @lance.blue // account had taken the field. if force.bot { draw_text( canvas, &kit.display, "AI \u{b7} PRINCESS", end + 14, y + 40, ink(15.0, MUTED, 3.0), ); } // Named one by one while a plate can hold them all; past that they are a // formation, drawn as one. let camo = force.camo.as_ref().and_then(|bytes| decode(bytes)); if force.machines.len() <= 5 { let mut row_y = y + 62; for machine in &force.machines { if let Some(sprite) = machine.sprite.as_ref().and_then(|bytes| decode(bytes)) { let painted = paint(&sprite, camo.as_ref()); draw_sprite(canvas, &painted, x + 18, row_y, 52, machine.alive); } let (name, size) = clipped( &kit.display, machine.name, 24.0, 15.0, 0.0, (width - 110) as f32, ); draw_text( canvas, &kit.display, &name, x + 82, row_y + 24, ink(size, if machine.alive { INK } else { MUTED }, 0.0), ); draw_text( canvas, &kit.text, machine.condition, x + 82, row_y + 46, ink(20.0, condition_ink(machine.condition), 0.0), ); row_y += 62; } } else { draw_machines(canvas, force, x + 18, y + 66, width - 36, 60, None); } let line = format!( "{} of {} standing · {}%", force.units_remaining, force.units_start, force.bv_share, ); draw_text( canvas, &kit.text, &line, x + 20, y + height - 22, ink(23.0, MUTED, 0.0), ); let bar_width = width - 40; canvas.rect(x + 20, y + height - 14, bar_width, 4, LINE, 1.0); let filled = (bar_width as i64 * force.bv_share.clamp(0, 100) / 100) as i32; canvas.rect(x + 20, y + height - 14, filled, 4, tint, 1.0); } /// The day's challenge, drawn as the front page draws it. /// /// Deliberately the same card, not a card in the same colours: the board /// figure with each force standing in the zone it deployed to, the label, /// the challenge's name, its own description, and the head-to-head with each /// side's figure lit in its colour. What the front page ends with is the way /// in; a finished one ends with the result. fn draw_daily(canvas: &mut Canvas, kit: &Kit, card: &Card) { draw_ground(canvas, 0.10); let Some(mine) = card.forces.iter().find(|f| f.mine).or(card.forces.first()) else { return; }; // The figure, in the left half, inside its own frame. let figure = Box { x: MARGIN - 16, y: MARGIN - 8, width: 520, height: HEIGHT as i32 - MARGIN * 2 + 20, }; draw_board_figure(canvas, kit, card, figure); // The column, in the front page's own order. let x = figure.x + figure.width + 34; let room = (WIDTH as i32 - MARGIN - x) as f32; let mut y = MARGIN + 22; draw_text( canvas, &kit.display, "DAILY CHALLENGE", x, y, ink(19.0, BRAND, 4.0), ); y += 46; let (title, size) = clipped(&kit.display, card.title, 46.0, 26.0, 0.0, room); draw_text(canvas, &kit.display, &title, x, y, ink(size, INK, 0.0)); if !card.blurb.is_empty() { y += 20; for line in wrapped(&kit.text, card.blurb, 22.0, room) .into_iter() .take(4) { y += 28; draw_text(canvas, &kit.text, &line, x, y, ink(22.0, MUTED, 0.0)); } } // The head-to-head. Left is whoever asked, as the front page's left is // always the player's own side. y += 66; // Everyone else, added up. A daily fields three pirate lances against one // assault; naming the first of them and printing its battle value alone // is a lie about what was fought. let opposition: Vec<&Force> = card .forces .iter() .filter(|f| !std::ptr::eq(*f, mine)) .collect(); draw_bv_side( canvas, kit, &[mine], Figure { x, baseline: y, colour: ACCENT, label: "CHALLENGER", right: false, }, ); if !opposition.is_empty() { let right = WIDTH as i32 - MARGIN; let label = if opposition.len() > 1 { format!("{} LANCES", opposition.len()) } else { String::from("OPFOR") }; draw_bv_side( canvas, kit, &opposition, Figure { x: right, baseline: y, colour: DANGER, label: &label, right: true, }, ); let middle = (x + right) / 2; let system = "BV 2.0"; let width = measure(&kit.display, system, 13.0, 4.0) as i32; draw_text( canvas, &kit.display, system, middle - width / 2, y - 12, ink(13.0, BRAND, 4.0), ); } // Where the front page puts its way in, this puts what happened. y += 74; draw_result_plate(canvas, kit, mine, x, y); draw_footer(canvas, kit, card); } /// Where one side's figure goes, and how it is set. struct Figure<'a> { x: i32, baseline: i32, colour: [u8; 3], label: &'a str, /// Right-aligned, for the far side of the head-to-head. right: bool, } /// One side of the head-to-head: what it has left, of what it brought. fn draw_bv_side(canvas: &mut Canvas, kit: &Kit, side: &[&Force], at: Figure) { let Figure { x, baseline, colour, label, right, } = at; let left_bv: i64 = side.iter().map(|f| f.bv_remaining).sum(); let start_bv: i64 = side.iter().map(|f| f.bv_start).sum(); let share = if start_bv > 0 { (left_bv * 100 / start_bv).clamp(0, 100) } else { 0 }; let figure = format!("{share}%"); let width = measure(&kit.display, &figure, 40.0, 0.0) as i32; let start = if right { x - width } else { x }; draw_text( canvas, &kit.display, &figure, start, baseline, ink(40.0, colour, 0.0), ); let width = measure(&kit.display, label, 13.0, 4.0) as i32; let start = if right { x - width } else { x }; draw_text( canvas, &kit.display, label, start, baseline + 20, ink(13.0, MUTED, 4.0), ); let standing = format!( "{} of {} standing", side.iter().map(|f| f.units_remaining).sum::(), side.iter().map(|f| f.units_start).sum::(), ); let width = measure(&kit.text, &standing, 18.0, 0.0) as i32; let start = if right { x - width } else { x }; draw_text( canvas, &kit.text, &standing, start, baseline + 42, ink(18.0, FAINT, 0.0), ); } /// The plate the front page's Play control stands in, saying what happened /// instead of offering a fight: the outcome, and whose it was. fn draw_result_plate(canvas: &mut Canvas, kit: &Kit, force: &Force, x: i32, y: i32) { let (word, tint) = if force.victor { ("CLEARED", BRAND) } else { ("FAILED", DANGER) }; let notch = 12; let lit_width = measure(&kit.display, word, 24.0, 5.0) as i32 + 56; let height = 50; // The lit half, notched at its top left the way the site cuts its plate. for row in 0..height { let inset = (notch - row).max(0); canvas.rect(x + inset, y + row, lit_width - inset, 1, tint, 1.0); } draw_text( canvas, &kit.display, word, x + 30, y + 34, ink(24.0, BACKDROP, 5.0), ); // The grey half beside it, carrying whose match this was. let (name, size) = clipped(&kit.display, force.name, 17.0, 12.0, 3.0, 320.0); let grey_x = x + lit_width + 2; let grey_width = measure(&kit.display, &name, size, 3.0) as i32 + 40; for row in 0..height { let cut = (row - (height - notch)).max(0); canvas.rect(grey_x, y + row, grey_width - cut, 1, LINE, 0.92); } draw_text( canvas, &kit.display, &name, grey_x + 20, y + 33, ink(size, INK, 3.0), ); } /// The board figure: every force standing where it deployed, named the way /// the front page names them. fn draw_board_figure(canvas: &mut Canvas, kit: &Kit, card: &Card, area: Box) { let Box { x, y, width, height, } = area; canvas.rect(x, y, width, height, [0x09, 0x0d, 0x13], 0.72); // The frame, and the hairline inside it the front page draws. for (bx, by, bw, bh) in [ (x, y, width, 1), (x, y + height - 1, width, 1), (x, y, 1, height), (x + width - 1, y, 1, height), ] { canvas.rect(bx, by, bw, bh, LINE, 1.0); } for (bx, by, bw, bh) in [ (x + 10, y + 10, width - 20, 1), (x + 10, y + height - 11, width - 20, 1), (x + 10, y + 10, 1, height - 20), (x + width - 11, y + 10, 1, height - 20), ] { canvas.rect(bx, by, bw, bh, BRAND, 0.28); } // Where each force stands. The scenario's own zones, read through the // mms crate - the same field the front page places them by. let rows = height - 40; for force in &card.forces { let (place_y, place_x) = zone_place(force.zone.as_deref(), force.mine); let cell_y = y + 24 + (rows as f32 * place_y) as i32; let cell_x = x + 20 + ((width - 40) as f32 * place_x) as i32; // A force in a corner shares the width with the force in the other // corner, so it stands two abreast; one holding an edge or the middle // has the frame to itself and stands in line. let abreast = if corner(force.zone.as_deref()) { 2 } else { 4 }; draw_figure_force(canvas, kit, force, cell_x, cell_y, width - 40, abreast); } } /// Whether a zone puts a force in a corner, where it shares the width. fn corner(zone: Option<&str>) -> bool { matches!( zone.map(str::to_uppercase).as_deref(), Some("NE" | "SE" | "SW" | "NW") ) } /// A deploy zone as a place in the frame: across, then down. A force with no /// zone falls to its own half - the reader's at the bottom, the opposition at /// the top, which is how the front page arranges a fight it cannot place. fn zone_place(zone: Option<&str>, mine: bool) -> (f32, f32) { let default = if mine { (0.68, 0.5) } else { (0.06, 0.5) }; let Some(zone) = zone else { return default; }; match zone.to_uppercase().as_str() { "N" => (0.06, 0.5), "NE" => (0.06, 0.78), "E" => (0.38, 0.82), "SE" => (0.68, 0.78), "S" => (0.68, 0.5), "SW" => (0.68, 0.22), "W" => (0.38, 0.18), "NW" => (0.06, 0.22), "CTR" | "C" => (0.38, 0.5), _ => default, } } /// One force in the figure: its machines in a row, with the first one named /// under it the way the front page names each machine. fn draw_figure_force( canvas: &mut Canvas, kit: &Kit, force: &Force, cx: i32, y: i32, room: i32, abreast: i32, ) { let count = force.machines.len().min(4) as i32; if count == 0 { return; } let per_row = abreast.min(count).max(1); let size = if per_row > 2 { 54 } else { 60 }; let gap = 14; let width = per_row * size + (per_row - 1) * gap; let x = (cx - width / 2).max(cx - room / 2); let camo = force.camo.as_ref().and_then(|bytes| decode(bytes)); let tint = if force.victor { ACCENT } else { DANGER }; for (index, machine) in force.machines.iter().take(4).enumerate() { let index = index as i32; let mx = x + (index % per_row) * (size + gap); let y = y + (index / per_row) * (size * 72 / 84 + 40); if let Some(sprite) = machine.sprite.as_ref().and_then(|bytes| decode(bytes)) { let painted = paint(&sprite, camo.as_ref()); draw_sprite(canvas, &painted, mx, y, size, machine.alive); } // Chassis over model, in the side's colour, as on the front page. let (chassis, model) = split_designation(machine.name); let (chassis, size_c) = clipped(&kit.display, &chassis, 13.0, 9.0, 1.0, (size + gap) as f32); let width = measure(&kit.display, &chassis, size_c, 1.0) as i32; draw_text( canvas, &kit.display, &chassis, mx + (size - width) / 2, y + size * 72 / 84 + 16, ink(size_c, if machine.alive { tint } else { MUTED }, 1.0), ); if let Some(model) = model { let (model, size_m) = clipped(&kit.text, &model, 12.0, 9.0, 1.0, (size + gap) as f32); let width = measure(&kit.text, &model, size_m, 1.0) as i32; draw_text( canvas, &kit.text, &model, mx + (size - width) / 2, y + size * 72 / 84 + 30, ink(size_m, FAINT, 1.0), ); } } } /// "Atlas AS7-D" into its chassis and its model, the way a record sheet sets /// them: the name a person says, and the variant under it. fn split_designation(name: &str) -> (String, Option) { match name.rsplit_once(' ') { // A model is the last word when it looks like one: capitals, digits // and dashes. "Timber Wolf" is not a model; "AS7-D" is. Some((head, tail)) if !head.is_empty() && tail.chars().any(|c| c.is_ascii_digit()) && tail .chars() .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-') => { (head.to_uppercase(), Some(tail.to_owned())) } _ => (name.to_uppercase(), None), } }