Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
80 kB · 2330 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331//! 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<std::borrow::Cow<'a, [u8]>>, /// 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<Vec<u8>>, /// 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<String>, pub machines: Vec<Machine<'a>>,}
/// 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<Force<'a>>, /// 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<Vec<u8>>,}
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<Layout> { 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<u8>,}
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<Vec<u8>> { 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<u8>,}
/// 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<Picture> { 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<Picture> { 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<Picture> { 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<u8> { 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<ab_glyph::GlyphId> = 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<ab_glyph::GlyphId> = 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<String> { let mut lines: Vec<String> = 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<Vec<u8>>, /// 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<String>, /// 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<Vec<u8>> { 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<Vec<u8>> { 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<i64> = 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>,) -> 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<i32> { 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::<i64>(), side.iter().map(|f| f.units_start).sum::<i64>(), ); 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<String>) { 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), }}