From ffad92b641396c595fd94576008fff5b60352b41 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sat, 7 Nov 2026 23:32:29 +0100 Subject: [PATCH] Detailed sprites: frames for walk and attack animations, gear laid over them, hit flash in the shader, 32x32 level in its own texture Co-Authored-By: Claude Opus 5.5 --- src/gfx.rs | 370 +++++++++++++++++++++++++++++++++--------------- src/render3d.rs | 126 ++++++++++++++--- 2 files changed, 366 insertions(+), 130 deletions(-) diff --git a/src/gfx.rs b/src/gfx.rs index e6162ee..d95e015 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -101,7 +101,9 @@ pub const T_EGG: u32 = 222; // 2 variants: a painted egg (Easter) pub struct Gfx { pub atlas: Texture2D, pub sprites: Texture2D, - pub hd_sprites: Texture2D, // detailed sprites (see HD_SPRITES, hd_cell_view) + pub hd_sprites: Texture2D, // detailed sprites, 64x64 level (see HD_SPRITES, hd_picture) + pub hd_sprites32: Texture2D, // the same, 32x32 level + pub gear: Texture2D, // their gear parts (see GEAR_HELMET etc.) pub atlas_hd: Texture2D, // the tile atlas of the high-res style, 64x64 tiles (see HD_TILES) pub atlas_hd2: Texture2D, // the same with 128x128 tiles (close up) pub materials: [Texture2D; 8], // furniture materials, high-res (see crate::props::Mat) @@ -1735,17 +1737,26 @@ const SPR_CELL: u32 = 12; const SPR_K: u32 = 3; pub const ACELL: u32 = SPR_CELL * SPR_K; -// Detailed sprites (generated, see art/generated/imp-sprite and art/generated/sprites) in their -// own texture, used instead of the 10x10 ones in the high-res graphics style -// (render3d::billboards). Two levels of detail from the same pictures: 64x64 when the sprite gets -// enough screen pixels (close), 32x32 otherwise, so neither loses pixels nor flickers. Sheets: -// transparent, the feet on the bottom row; idle: the five views side by side (VIEWS); hack (the -// imp): the digging animation, HACK_FRAMES frames side by side, one row per view. No gear yet. +// Detailed sprites (generated, see art/generated/imp-sprite and art/generated/sprites), used +// instead of the 10x10 ones in the high-res graphics style (render3d::billboards). Two levels of +// detail from the same pictures, in two textures: 64x64 when the sprite gets enough screen +// pixels (close), 32x32 otherwise, so neither loses pixels nor flickers. Sheets: transparent, +// the feet on the bottom row; idle: the five views side by side (VIEWS); an animation: +// ANIM_FRAMES frames side by side, one row per view. The gear (shoulder pieces, helmet, cape, +// crown) is laid over them at draw time (see hd_anchor, GEAR_PARTS); the hit flash is drawn +// white by the shader. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum HdAnim { + Hack, + Walk, + Attack, +} +pub const ANIM_FRAMES: u32 = 4; struct HdSprite { kind: EK, idle: &'static [u8], idle_32: &'static [u8], - hack: Option<(&'static [u8], &'static [u8])>, // 64x64 and 32x32 + anims: &'static [(HdAnim, &'static [u8], &'static [u8])], // 64x64 and 32x32 sheets } macro_rules! hd_sprite { ($kind:expr, $name:literal) => { @@ -1753,7 +1764,7 @@ macro_rules! hd_sprite { kind: $kind, idle: include_bytes!(concat!("../assets/sprites/", $name, "-64.png")), idle_32: include_bytes!(concat!("../assets/sprites/", $name, "-32.png")), - hack: None, + anims: &[], } }; } @@ -1762,7 +1773,7 @@ const HD_SPRITES: [HdSprite; 31] = [ kind: EK::Worker, idle: include_bytes!("../assets/sprites/imp-64.png"), idle_32: include_bytes!("../assets/sprites/imp-32.png"), - hack: Some((include_bytes!("../assets/sprites/imp-hack-64.png"), include_bytes!("../assets/sprites/imp-hack-32.png"))), + anims: &[(HdAnim::Hack, include_bytes!("../assets/sprites/imp-hack-64.png"), include_bytes!("../assets/sprites/imp-hack-32.png"))], }, hd_sprite!(EK::Kobold, "kobold"), hd_sprite!(EK::Spider, "spider"), @@ -1795,38 +1806,73 @@ const HD_SPRITES: [HdSprite; 31] = [ hd_sprite!(EK::Ranger, "ranger"), hd_sprite!(EK::Inquisitor, "inquisitor"), ]; -pub const HACK_FRAMES: u32 = 4; // a detailed sprite's cell: the same shape as a 10x10 sprite's 12x12 cell (6 texels per pixel), -// so the billboard is the same; the 64x64 picture sits where the 10x10 sprite would +// so the billboard is the same; the 64x64 picture sits where the 10x10 sprite would; the 32x32 +// level has cells of half the size pub const HD_CELL: u32 = SPR_CELL * 6; -// columns of a sprite with the digging animation, per detail level: idle and the hack frames, -// each without and with the sack; a sprite without it has one column per level -const HD_COLS: u32 = (1 + HACK_FRAMES) * 2; -// the texture: the sprites' columns one after another (a sprite's own columns: the 64x64 level, -// then the 32x32 one), in bands of HD_BAND columns (a sprite never straddles two bands), every -// band VIEWS * 2 rows (a view, its white silhouette) -const HD_BAND: u32 = 40; -const fn hd_width(j: usize) -> u32 { - if HD_SPRITES[j].hack.is_some() { HD_COLS * 2 } else { 2 } -} -const fn hd_start(j: usize) -> u32 { - let mut p = 0; - let mut i = 0; - while i < j { - p += hd_width(i); - i += 1; - } - p -} -const HD_BANDS: u32 = (hd_start(HD_SPRITES.len()) + HD_BAND - 1) / HD_BAND; -pub const HD_TEX_W: f32 = (HD_BAND * HD_CELL) as f32; -pub const HD_TEX_H: f32 = (HD_BANDS * VIEWS * 2 * HD_CELL) as f32; +const HD_COLS64: u32 = 56; +const HD_COLS32: u32 = 112; -// where column `c` of sprite j lies: (texture column, first row of its band) -fn hd_place(j: usize, c: u32) -> (u32, u32) { - let p = hd_start(j) + c; - (p % HD_BAND, p / HD_BAND * VIEWS * 2) +// the pictures one after another: per sprite its frames (idle, then every animation's frames), +// per frame without and (kinds with a sack) with the sack, per those the five views +fn hd_frames(j: usize) -> u32 { + 1 + HD_SPRITES[j].anims.len() as u32 * ANIM_FRAMES } +fn hd_sacks(j: usize) -> u32 { + if SACK_KINDS.contains(&HD_SPRITES[j].kind) { 2 } else { 1 } +} +fn hd_base(j: usize) -> u32 { + (0..j).map(|i| hd_frames(i) * hd_sacks(i) * VIEWS).sum() +} +fn hd_pictures() -> u32 { + hd_base(HD_SPRITES.len()) +} +// the size of a detailed sprite texture (fine: the 64x64 one) +pub fn hd_tex_size(fine: bool) -> (u32, u32) { + let (cols, cell) = if fine { (HD_COLS64, HD_CELL) } else { (HD_COLS32, HD_CELL / 2) }; + (cols * cell, hd_pictures().div_ceil(cols) * cell) +} + +// where a picture's gear goes: rows and columns of a 72x72 cell as shares of it (0..1) +#[derive(Clone, Copy, Debug, Default)] +pub struct GearAnchor { + pub top: f32, // top of the head + pub bottom: f32, // the feet + pub head: (f32, f32), // the head's left and right edge (a little below its top) + pub shoulder: f32, // the shoulders' row + pub sh: (f32, f32), // the shoulders' left and right edge +} +static HD_ANCHORS: std::sync::OnceLock> = std::sync::OnceLock::new(); + +fn gear_anchor(g: &Cell) -> GearAnchor { + let n = g.len(); + let f = |v: usize| v as f32 / n as f32; + let span = |y: usize| -> Option<(usize, usize)> { + let xs: Vec = (0..n).filter(|&x| g[y][x].is_some()).collect(); + Some((*xs.first()?, *xs.last()?)) + }; + let rows: Vec = (0..n).filter(|&y| span(y).is_some()).collect(); + let (Some(&top), Some(&bot)) = (rows.first(), rows.last()) else { return GearAnchor::default() }; + let h = bot - top + 1; + let head_y = (top + h / 10).min(bot); + let head = span(head_y).unwrap_or((n / 3, 2 * n / 3)); + // the shoulders: the widest row of the upper body (tools and wings stick out: the middle + // row of the widest few counts) + let (a, b) = (top + h * 3 / 10, (top + h / 2).min(bot)); + let shoulder = (a..=b).max_by_key(|&y| span(y).map_or(0, |(l, r)| r - l)).unwrap_or(a); + let sh = span(shoulder).unwrap_or(head); + GearAnchor { top: f(top), bottom: f(bot + 1), head: (f(head.0), f(head.1 + 1)), shoulder: f(shoulder), sh: (f(sh.0), f(sh.1 + 1)) } +} + +// the gear's parts in their texture (grey, tinted by the metal or cloth colour at draw time): +// (x, y, w, h) in texels of GEAR_TEX +pub const GEAR_TEX: u32 = 64; +pub const GEAR_HELMET: (u32, u32, u32, u32) = (0, 0, 24, 12); +pub const GEAR_PAULDRON: (u32, u32, u32, u32) = (24, 0, 12, 9); +pub const GEAR_CROWN: (u32, u32, u32, u32) = (36, 0, 20, 10); +pub const GEAR_CAPE: (u32, u32, u32, u32) = (0, 14, 28, 40); +pub const GEAR_HORN: (u32, u32, u32, u32) = (28, 14, 7, 10); +pub const GEAR_PLUME: (u32, u32, u32, u32) = (36, 14, 7, 12); // kinds that carry treasure in a sack on the back (instead of the coin sign over the head); // their 10x10 sprite with the sack gets an own atlas column after SPRITE_KINDS @@ -2637,36 +2683,96 @@ fn gen_sprites() -> Image { img } -// The texture of the detailed sprites (HD_SPRITES): per sprite HD_COLS columns of 64x64 -// pictures, then HD_COLS of 32x32 ones (two texels per pixel); row view * 2, the row after it -// the white silhouette. -fn gen_hd_sprites() -> Image { - let mut img = Image::gen_image_color(HD_TEX_W as u16, HD_TEX_H as u16, Color::new(0.0, 0.0, 0.0, 0.0)); +// a picture into a texture of `cell` x `cell` cells, `cols` per row (no white silhouette: +// the shader flashes the detailed sprites) +fn put_picture(img: &mut Image, pic: u32, cols: u32, cell: u32, g: &Cell) { + let (ox, oy) = ((pic % cols) * cell, (pic / cols) * cell); + for (y, line) in g.iter().enumerate() { + for (x, p) in line.iter().enumerate() { + if let Some(c) = p { + img.set_pixel(ox + x as u32, oy + y as u32, *c); + } + } + } +} + +// the two textures of the detailed sprites (64x64 level, 32x32 level); the gear anchors of every +// picture are measured on the way +fn gen_hd_sprites() -> (Image, Image) { + let (w64, h64) = hd_tex_size(true); + let (w32, h32) = hd_tex_size(false); + let clear = Color::new(0.0, 0.0, 0.0, 0.0); + let mut fine = Image::gen_image_color(w64 as u16, h64 as u16, clear); + let mut coarse = Image::gen_image_color(w32 as u16, h32 as u16, clear); + let mut anchors = vec![GearAnchor::default(); hd_pictures() as usize]; let png = |b: &[u8]| Image::from_file_with_format(b, Some(ImageFormat::Png)).expect("hd sprite"); for (j, hd) in HD_SPRITES.iter().enumerate() { - // (idle, hack, picture size, cell grid, texels per pixel, sack, where the sack shows from the front) - let levels = [ - (png(hd.idle), hd.hack.map(|h| png(h.0)), 64, HD_CELL, 1, &SACK_64[..], (0.7, -0.65)), - (png(hd.idle_32), hd.hack.map(|h| png(h.1)), 32, HD_CELL / 2, 2, &SACK_32[..], (0.7, -0.65)), - ]; - for (l, (idle, hack, n, size, k, sack, dx)) in levels.iter().enumerate() { + let sheets64: Vec = std::iter::once(png(hd.idle)).chain(hd.anims.iter().map(|a| png(a.1))).collect(); + let sheets32: Vec = std::iter::once(png(hd.idle_32)).chain(hd.anims.iter().map(|a| png(a.2))).collect(); + let sacks = hd_sacks(j); + for frame in 0..hd_frames(j) { + // the frame's sheet and its place there: idle (frame 0) or frame k of an animation + let (sheet, fx) = if frame == 0 { (0, None) } else { (1 + ((frame - 1) / ANIM_FRAMES) as usize, Some((frame - 1) % ANIM_FRAMES)) }; for view in 0..VIEWS { - let Some(hack) = hack else { - let (col, row) = hd_place(j, l as u32); - put_cell(&mut img, col, row + view * 2, &hd_cell(idle, view * n, 0, *n, *size), *k, HD_CELL); - continue; + let at = |n: u32| match fx { + None => (view * n, 0), + Some(f) => (f * n, view * n), }; - for f in 0..1 + HACK_FRAMES { - let g = if f == 0 { hd_cell(idle, view * n, 0, *n, *size) } else { hd_cell(hack, (f - 1) * n, view * n, *n, *size) }; - let (col, row) = hd_place(j, l as u32 * HD_COLS + f * 2); - put_cell(&mut img, col, row + view * 2, &g, *k, HD_CELL); - let mut s = g; - add_sack(&mut s, view, sack, *dx); - put_cell(&mut img, col + 1, row + view * 2, &s, *k, HD_CELL); + let (x64, y64) = at(64); + let (x32, y32) = at(32); + let g64 = hd_cell(&sheets64[sheet], x64, y64, 64, HD_CELL); + let g32 = hd_cell(&sheets32[sheet], x32, y32, 32, HD_CELL / 2); + for sack in 0..sacks { + let pic = hd_base(j) + (frame * sacks + sack) * VIEWS + view; + let (mut a, mut b) = (g64.clone(), g32.clone()); + if sack == 1 { + add_sack(&mut a, view, &SACK_64, (0.7, -0.65)); + add_sack(&mut b, view, &SACK_32, (0.7, -0.65)); + } + anchors[pic as usize] = gear_anchor(&g64); + put_picture(&mut fine, pic, HD_COLS64, HD_CELL, &a); + put_picture(&mut coarse, pic, HD_COLS32, HD_CELL / 2, &b); } } } } + let _ = HD_ANCHORS.set(anchors); + (fine, coarse) +} + +// the gear parts (see GEAR_HELMET etc.): grey with dark outlines, tinted at draw time +fn gen_gear() -> Image { + let mut img = Image::gen_image_color(GEAR_TEX as u16, GEAR_TEX as u16, Color::new(0.0, 0.0, 0.0, 0.0)); + let grey = |v: f32| Color::new(v, v, v, 1.0); + let outline = Color::new(0.08, 0.07, 0.09, 1.0); + // a shape given by an inside test, outlined, lit from the upper left + let mut shape = |(ox, oy, w, h): (u32, u32, u32, u32), inside: &dyn Fn(f32, f32) -> bool, shade: &dyn Fn(f32, f32) -> f32| { + for y in 0..h { + for x in 0..w { + let (fx, fy) = ((x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32); + if !inside(fx, fy) { + continue; + } + let edge = [(-1i32, 0i32), (1, 0), (0, -1), (0, 1)].iter().any(|&(dx, dy)| { + let (nx, ny) = (x as i32 + dx, y as i32 + dy); + nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 || !inside((nx as f32 + 0.5) / w as f32, (ny as f32 + 0.5) / h as f32) + }); + img.set_pixel(ox + x, oy + y, if edge { outline } else { grey(shade(fx, fy)) }); + } + } + }; + // helmet: a dome with a rim + shape(GEAR_HELMET, &|x, y| (x - 0.5).powi(2) * 4.0 + (1.0 - y).powi(2) <= 1.0, &|x, y| if y > 0.75 { 0.55 } else { 0.95 - (x * 0.35 + y * 0.25) }); + // shoulder piece: a rounded plate with a rivet + shape(GEAR_PAULDRON, &|x, y| (x - 0.5).powi(2) * 4.0 + (y - 0.6).powi(2) * 2.6 <= 1.0, &|x, y| if (x - 0.5).abs() < 0.1 && (y - 0.5).abs() < 0.12 { 1.0 } else { 0.9 - y * 0.4 }); + // crown: a band with three spikes + shape(GEAR_CROWN, &|x, y| y > 0.55 || ((x * 3.0).fract() - 0.5).abs() < y * 0.55, &|x, y| if y > 0.6 && (x * 5.0).fract() < 0.25 { 0.6 } else { 1.0 - x * 0.2 }); + // cape: wider at the hem, with folds + shape(GEAR_CAPE, &|x, y| (x - 0.5).abs() < 0.32 + 0.18 * y, &|x, y| 0.8 - (((x - 0.5) * 9.0 + y).sin().abs()) * 0.25 - y * 0.15); + // horn: a curved spike (bone) + shape(GEAR_HORN, &|x, y| (x - (1.0 - y) * 0.4 - 0.3).abs() < 0.16 + 0.3 * y, &|x, y| 0.95 - y * 0.25 - x * 0.1); + // plume: a feather bush + shape(GEAR_PLUME, &|x, y| (x - 0.5).powi(2) * 3.2 + (y - 0.5).powi(2) * 1.1 <= 0.26 + 0.04 * (y * 14.0).sin(), &|_x, y| 0.95 - y * 0.3); img } @@ -2794,8 +2900,13 @@ impl Gfx { atlas_hd.set_filter(FilterMode::Nearest); let atlas_hd2 = Texture2D::from_image(&gen_hd_atlas(HD_TILE2)); atlas_hd2.set_filter(FilterMode::Nearest); - let hd_sprites = Texture2D::from_image(&gen_hd_sprites()); + let (hd64, hd32) = gen_hd_sprites(); + let hd_sprites = Texture2D::from_image(&hd64); hd_sprites.set_filter(FilterMode::Nearest); + let hd_sprites32 = Texture2D::from_image(&hd32); + hd_sprites32.set_filter(FilterMode::Nearest); + let gear = Texture2D::from_image(&gen_gear()); + gear.set_filter(FilterMode::Nearest); let mat = |b: &[u8]| { let t = Texture2D::from_file_with_format(b, Some(ImageFormat::Png)); t.set_filter(FilterMode::Nearest); @@ -2803,7 +2914,7 @@ impl Gfx { }; let materials = MATERIALS.map(|(hd, _)| mat(hd)); let materials_px = MATERIALS.map(|(_, px)| mat(px)); - Gfx { atlas, sprites, hd_sprites, atlas_hd, atlas_hd2, materials, materials_px, cursors, badges, font, font_map } + Gfx { atlas, sprites, hd_sprites, hd_sprites32, gear, atlas_hd, atlas_hd2, materials, materials_px, cursors, badges, font, font_map } } pub fn cell(id: u32) -> Rect { @@ -2830,21 +2941,34 @@ impl Gfx { } // the cell of a detailed sprite's view, if the kind has one (same layout as sprite_cell_view) - // the cell of a detailed sprite in `hd_sprites`, if the kind has one: a view, standing - // (`hack` None) or a frame of the digging animation, with or without the sack; `fine`: the - // 64x64 level, else the 32x32 one - pub fn hd_cell_view(k: EK, white: bool, view: u32, hack: Option, sack: bool, fine: bool) -> Option { + // the picture of a detailed sprite, if the kind has one: a view, standing or a frame of an + // animation (an animation the kind does not have: standing), with or without the sack + pub fn hd_picture(k: EK, view: u32, anim: Option<(HdAnim, u32)>, sack: bool) -> Option { let j = HD_SPRITES.iter().position(|h| h.kind == k)?; - // sprites without the digging animation (and without a sack) show their idle picture - let c = if HD_SPRITES[j].hack.is_some() { - let f = hack.map_or(0, |f| 1 + f % HACK_FRAMES); - (!fine as u32) * HD_COLS + f * 2 + sack as u32 - } else { - !fine as u32 - }; - let (col, band) = hd_place(j, c); - let row = band + view.min(VIEWS - 1) * 2 + white as u32; - Some(Rect::new((col * HD_CELL) as f32, (row * HD_CELL) as f32, HD_CELL as f32, HD_CELL as f32)) + let frame = anim + .and_then(|(a, f)| HD_SPRITES[j].anims.iter().position(|x| x.0 == a).map(|i| 1 + i as u32 * ANIM_FRAMES + f % ANIM_FRAMES)) + .unwrap_or(0); + let sacks = hd_sacks(j); + let sack = if sacks > 1 { sack as u32 } else { 0 }; + Some(hd_base(j) + (frame * sacks + sack) * VIEWS + view.min(VIEWS - 1)) + } + + // whether a kind has a drawn animation + pub fn hd_has_anim(k: EK, anim: HdAnim) -> bool { + HD_SPRITES.iter().any(|h| h.kind == k && h.anims.iter().any(|a| a.0 == anim)) + } + + // a picture's place in its texture as texture coordinates (fine: hd_sprites, else hd_sprites32) + pub fn hd_uv(pic: u32, fine: bool) -> Rect { + let (cols, cell) = if fine { (HD_COLS64, HD_CELL) } else { (HD_COLS32, HD_CELL / 2) }; + let (w, h) = hd_tex_size(fine); + let (x, y) = ((pic % cols) * cell, (pic / cols) * cell); + Rect::new(x as f32 / w as f32, y as f32 / h as f32, cell as f32 / w as f32, cell as f32 / h as f32) + } + + // where the gear of a picture goes (measured when the textures were made) + pub fn hd_anchor(pic: u32) -> GearAnchor { + HD_ANCHORS.get().and_then(|a| a.get(pic as usize).copied()).unwrap_or_default() } // whether a kind carries its treasure in a sack on the back @@ -3231,11 +3355,6 @@ mod atlas_dump { mod sprite_tests { use super::*; - fn opaque(img: &Image, r: Rect) -> usize { - let n = r.w as u32; - (0..n * n).filter(|i| img.get_pixel(r.x as u32 + i % n, r.y as u32 + i / n).a > 0.5).count() - } - // texels that differ between two cells fn differ(img: &Image, a: Rect, b: Rect) -> usize { let n = a.w as u32; @@ -3248,35 +3367,50 @@ mod sprite_tests { .count() } + // texels of a picture that differ with and without something (pictures of the 64x64 level) + fn pic_texels(img: &Image, pic: u32) -> Vec<(u8, u8, u8, u8)> { + let (ox, oy) = ((pic % HD_COLS64) * HD_CELL, (pic / HD_COLS64) * HD_CELL); + (0..HD_CELL * HD_CELL) + .map(|i| { + let p = img.get_pixel(ox + i % HD_CELL, oy + i / HD_CELL); + ((p.r * 255.0) as u8, (p.g * 255.0) as u8, (p.b * 255.0) as u8, (p.a * 255.0) as u8) + }) + .collect() + } + #[test] fn detailed_sprites() { - let img = gen_hd_sprites(); - assert_eq!((img.width as f32, img.height as f32), (HD_TEX_W, HD_TEX_H)); - for fine in [true, false] { - for view in 0..VIEWS { - for hack in [None, Some(0), Some(1), Some(2), Some(3)] { - let r = Gfx::hd_cell_view(EK::Worker, false, view, hack, false, fine).expect("the imp has a detailed sprite"); - let w = Gfx::hd_cell_view(EK::Worker, true, view, hack, false, fine).unwrap(); - let s = Gfx::hd_cell_view(EK::Worker, false, view, hack, true, fine).unwrap(); - assert!(opaque(&img, r) > 600, "view {view} {hack:?} {fine} drawn"); - assert_eq!(opaque(&img, r), opaque(&img, w), "white silhouette matches"); - assert!(differ(&img, s, r) > 60, "view {view} {hack:?} {fine}: the sack shows"); - } + let (fine, coarse) = gen_hd_sprites(); + assert_eq!((fine.width as u32, fine.height as u32), hd_tex_size(true)); + assert_eq!((coarse.width as u32, coarse.height as u32), hd_tex_size(false)); + assert!(fine.width <= 4096 && fine.height <= 4096, "the texture fits every GPU"); + // the imp: idle and the digging frames, with and without the sack + for view in 0..VIEWS { + for anim in [None, Some((HdAnim::Hack, 0)), Some((HdAnim::Hack, 3))] { + let p = Gfx::hd_picture(EK::Worker, view, anim, false).unwrap(); + let s = Gfx::hd_picture(EK::Worker, view, anim, true).unwrap(); + let (a, b) = (pic_texels(&fine, p), pic_texels(&fine, s)); + assert!(a.iter().filter(|t| t.3 > 128).count() > 600, "view {view} {anim:?} drawn"); + assert!(a.iter().zip(b.iter()).filter(|(x, y)| x != y).count() > 60, "view {view} {anim:?}: the sack shows"); } } - // every kind has a detailed sprite, none straddles two bands of the texture + assert!(Gfx::hd_has_anim(EK::Worker, HdAnim::Hack)); + // every kind has its pictures; an animation it lacks shows it standing + let mut seen = std::collections::HashSet::new(); for &k in SPRITE_KINDS.iter() { - for fine in [true, false] { - for view in 0..VIEWS { - let r = Gfx::hd_cell_view(k, false, view, None, false, fine).expect("a detailed sprite for every kind"); - assert!(opaque(&img, r) > 100, "{k:?} view {view} {fine}"); - assert!(r.x + r.w <= HD_TEX_W && r.y + r.h <= HD_TEX_H); + for view in 0..VIEWS { + let p = Gfx::hd_picture(k, view, None, false).expect("a detailed sprite for every kind"); + assert!(seen.insert(p), "{k:?}: pictures do not overlap"); + assert!(pic_texels(&fine, p).iter().filter(|t| t.3 > 128).count() > 100, "{k:?} view {view}"); + let a = Gfx::hd_anchor(p); + assert!(a.top < a.shoulder && a.shoulder < a.bottom && a.sh.0 < a.sh.1, "{k:?} view {view}: {a:?}"); + if !Gfx::hd_has_anim(k, HdAnim::Walk) { + assert_eq!(Gfx::hd_picture(k, view, Some((HdAnim::Walk, 2)), false), Some(p)); } + let uv = Gfx::hd_uv(p, false); + assert!(uv.x + uv.w <= 1.0 + 1e-6 && uv.y + uv.h <= 1.0 + 1e-6); } } - for j in 0..HD_SPRITES.len() { - assert_eq!(hd_start(j) / HD_BAND, (hd_start(j) + hd_width(j) - 1) / HD_BAND, "sprite {j} straddles two bands"); - } } #[test] @@ -3300,28 +3434,27 @@ mod sprite_tests { } } - // `cargo test --bins dump_imp_cells -- --ignored` writes the imp's detailed cells to /tmp for - // a look: rows idle and the hack frames, without and with sack; 64x64 left, 32x32 right + // `cargo test --bins dump_imp_cells -- --ignored` writes the imp's detailed pictures (idle + // and the digging frames, without and with sack; the 64x64 level) and the gear parts to /tmp #[test] #[ignore] fn dump_imp_cells() { - let img = gen_hd_sprites(); - let mut rows: Vec> = Vec::new(); + let (fine, _) = gen_hd_sprites(); + let mut rows: Vec> = Vec::new(); for sack in [false, true] { - for hack in [None, Some(0), Some(1), Some(2), Some(3)] { - let mut r: Vec = (0..VIEWS).map(|v| Gfx::hd_cell_view(EK::Worker, false, v, hack, sack, true).unwrap()).collect(); - r.extend((0..VIEWS).map(|v| Gfx::hd_cell_view(EK::Worker, false, v, hack, sack, false).unwrap())); - rows.push(r); + for anim in [None, Some((HdAnim::Hack, 0)), Some((HdAnim::Hack, 1)), Some((HdAnim::Hack, 2)), Some((HdAnim::Hack, 3))] { + rows.push((0..VIEWS).map(|v| Gfx::hd_picture(EK::Worker, v, anim, sack).unwrap()).collect()); } } let c = HD_CELL; - let (w, h) = (c * VIEWS * 2, c * rows.len() as u32); + let (w, h) = (c * VIEWS, c * rows.len() as u32); let mut out = Image::gen_image_color(w as u16, h as u16, Color::new(0.35, 0.33, 0.38, 1.0)); for (row, list) in rows.iter().enumerate() { - for (i, r) in list.iter().enumerate() { + for (i, &pic) in list.iter().enumerate() { + let (ox, oy) = ((pic % HD_COLS64) * c, (pic / HD_COLS64) * c); for y in 0..c { for x in 0..c { - let p = img.get_pixel(r.x as u32 + x, r.y as u32 + y); + let p = fine.get_pixel(ox + x, oy + y); if p.a > 0.5 { out.set_pixel(i as u32 * c + x, h - 1 - (row as u32 * c + y), p); } @@ -3330,6 +3463,17 @@ mod sprite_tests { } } out.export_png("/tmp/imp-cells.png"); + let gear = gen_gear(); + let mut g = Image::gen_image_color((GEAR_TEX * 4) as u16, (GEAR_TEX * 4) as u16, Color::new(0.35, 0.33, 0.38, 1.0)); + for y in 0..GEAR_TEX * 4 { + for x in 0..GEAR_TEX * 4 { + let p = gear.get_pixel(x / 4, y / 4); + if p.a > 0.5 { + g.set_pixel(x, GEAR_TEX * 4 - 1 - y, p); + } + } + } + g.export_png("/tmp/gear.png"); } } diff --git a/src/render3d.rs b/src/render3d.rs index 9e6bbd3..0f74caa 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -16,6 +16,8 @@ pub const MAX_LIGHTS: usize = 24; const ATLAS_W: f32 = 256.0; const ATLAS_H: f32 = (crate::gfx::ATLAS_ROWS * 16) as f32; const DISP_AMP: f32 = 0.11; // wall surface irregularity +// vertex alpha of a detailed sprite flashing white after a hit (see the lit shader) +const FLASH_A: u8 = 204; // ------------------------------------------------------------------ camera @@ -294,6 +296,12 @@ void main() { wave = dot(slope, vec2(0.45, 0.35)) * 0.6 + ripple * 0.4; } vec4 tex = texture2D(Texture, tuv); + // the hit flash of the detailed sprites (vertex alpha FLASH_A): the picture in white + if (color.a > 0.77 && color.a < 0.83) { + if (tex.a < 0.5) discard; + gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); + return; + } if (liquid && HdLiquid > 0.5) { // a second, larger layer drifting slower and sideways: the surface churns instead of // only sliding along @@ -1654,8 +1662,7 @@ impl R3d { // sprites as billboards // the world's pixel rows (detailed sprites only where they get enough pixels) let world_rows = pix.map_or(screen_height() * screen_dpi_scale(), |(_, th, _)| th as f32); - let (small, detailed) = self.billboards(g, gfx, cam, t, world_rows); - for mesh in [small, detailed].iter().flatten() { + for mesh in self.billboards(g, gfx, cam, t, world_rows).iter() { draw_mesh(mesh); } // solid unlit objects (default material, depth tested) @@ -1732,8 +1739,8 @@ impl R3d { self.draw_2d(g, gfx, cam, &m, t); } - // the units as billboards: the 10x10 sprites and the detailed ones (two textures) - fn billboards(&self, g: &Game, gfx: &Gfx, cam: &Cam, t: f32, world_rows: f32) -> (Option, Option) { + // the units as billboards: the 10x10 sprites, the detailed ones (two levels) and their gear + fn billboards(&self, g: &Game, gfx: &Gfx, cam: &Cam, t: f32, world_rows: f32) -> Vec { let (_, r, _) = cam.basis(); let toward = cam.eye_dir(); let amb = |x: f32, z: f32| { @@ -1752,6 +1759,8 @@ impl R3d { let tg = vec2(toward.x, toward.z).normalize_or_zero(); let mut mb = MB::new(); let mut mb_hd = MB::new(); + let mut mb_hd32 = MB::new(); + let mut mb_gear = MB::new(); let sw = SPRITE_TEX_W; let sh = SPRITE_TEX_H; // detailed sprites only in the high-res style, and only where a sprite's cell (1.2 sizes @@ -1865,18 +1874,40 @@ impl R3d { // treasure in a sack on the back; digging as a drawn animation (detailed sprites), // frame by frame with the same rhythm as the swing below let sack = e.carry > 0 && Gfx::has_sack(e.kind); + // drawn animations (detailed sprites): digging, walking, attacking, frame by frame in + // the rhythm of the swings below (two steps per walk cycle, as the bobbing) + let cycle = |p: f32| ((p / std::f32::consts::TAU).rem_euclid(1.0) * crate::gfx::ANIM_FRAMES as f32) as u32; let hacking = e.working && !e.moving && !sleeping && matches!(e.job, Job::Dig(_) | Job::TearBridge(_)); - let frame = ((ph * 1.3 / std::f32::consts::TAU).rem_euclid(1.0) * crate::gfx::HACK_FRAMES as f32) as u32; - let hd = if detailed { Gfx::hd_cell_view(e.kind, white, v, hacking.then_some(frame), sack, fine) } else { None }; - if hd.is_some() && hacking { - // the frames swing the pickaxe: no tilting of the whole figure - rot = 0.0; - lunge = 0.0; + let attacking = e.working && !e.moving && !sleeping && matches!(e.job, Job::Attack(_) | Job::AttackTower(_) | Job::AttackCore | Job::Torment(_)); + let anim = if e.captive != 0 || sleeping { + None + } else if hacking { + Some((crate::gfx::HdAnim::Hack, cycle(ph * 1.3))) + } else if e.moving { + Some((crate::gfx::HdAnim::Walk, cycle(e.anim * 12.0))) + } else if attacking { + Some((crate::gfx::HdAnim::Attack, cycle(ph * 0.9))) + } else { + None + }; + let pic = if detailed { Gfx::hd_picture(e.kind, v, anim, sack) } else { None }; + if let (Some(_), Some((a, _))) = (pic, anim) { + if Gfx::hd_has_anim(e.kind, a) { + // the frames move the figure: no tilting, lunging or bobbing of the whole + rot = 0.0; + lunge = 0.0; + bob = bob.min(0.02); + } } - let src = hd.or_else(|| sack.then(|| Gfx::sprite_cell_sack(e.kind, white, e.level, v)).flatten()).unwrap_or_else(|| Gfx::sprite_cell_view(e.kind, white, e.level, v)); - let (sw, sh) = if hd.is_some() { (crate::gfx::HD_TEX_W, crate::gfx::HD_TEX_H) } else { (sw, sh) }; - let (mut u0, v0) = (src.x / sw, src.y / sh); - let (mut u1, v1) = ((src.x + src.w) / sw, (src.y + src.h) / sh); + let src = match pic { + Some(p) => Gfx::hd_uv(p, fine), + None => { + let r = sack.then(|| Gfx::sprite_cell_sack(e.kind, white, e.level, v)).flatten().unwrap_or_else(|| Gfx::sprite_cell_view(e.kind, white, e.level, v)); + Rect::new(r.x / sw, r.y / sh, r.w / sw, r.h / sh) + } + }; + let (mut u0, v0) = (src.x, src.y); + let (mut u1, v1) = (src.x + src.w, src.y + src.h); if mirror { std::mem::swap(&mut u0, &mut u1); } @@ -1884,7 +1915,9 @@ impl R3d { // spell looks: stone grey, plague green, see-through when invisible let alpha = if e.invis > 0.0 { 0.35 } else { 1.0 }; let c = if white { - [255, 255, 255, 255] + // the detailed sprites flash white in the shader (FLASH_A), the 10x10 ones have + // white silhouettes + [255, 255, 255, if pic.is_some() { FLASH_A } else { 255 }] } else if e.captive == 3 { col(vec3(a * 1.2, a * 0.55, a * 0.55), 1.0) } else if e.shield > 0.0 { @@ -1914,7 +1947,66 @@ impl R3d { }; // the 12x12 cell (sprite with a one pixel border for the gear): the sprite's feet // (bottom of its 11th pixel row) stay on the ground - let mb = if hd.is_some() { &mut mb_hd } else { &mut mb }; + // the gear of the detailed sprites: parts laid over the picture where its anchors say + // (shoulder pieces from level 3, a helmet from 5, a cape from 7, gold and a crown + // from 9, like the 10x10 sprites' gear) + if let Some(p) = pic { + let tier = crate::gfx::gear_tier(e.level); + if tier > 0 && !white && e.captive == 0 && !sleeping { + let an = Gfx::hd_anchor(p); + let shade = |rgb: (f32, f32, f32)| { + let k = a * 1.25; + [(rgb.0 * k).min(255.0) as u8, (rgb.1 * k).min(255.0) as u8, (rgb.2 * k).min(255.0) as u8, 255u8] + }; + let metal = shade(match tier { + 1 => (196.0, 126.0, 60.0), + 4 => (250.0, 206.0, 72.0), + _ => (208.0, 214.0, 228.0), + }); + // a point of the picture's cell (shares of it) in the world + let at = |fx: f32, fy: f32, depth: f32| { + let fx = if mirror { 1.0 - fx } else { fx }; + corner((fx - 0.5) * 1.2 * w, (1.0 - fy) * 1.2 * h - 0.1 * h) + toward * depth + }; + let gt = crate::gfx::GEAR_TEX as f32; + let mut part = |x0: f32, y0: f32, x1: f32, y1: f32, uv: (u32, u32, u32, u32), c: [u8; 4], depth: f32, flip: bool| { + let (mut a0, b0) = (uv.0 as f32 / gt, uv.1 as f32 / gt); + let (mut a1, b1) = ((uv.0 + uv.2) as f32 / gt, (uv.1 + uv.3) as f32 / gt); + if mirror != flip { + std::mem::swap(&mut a0, &mut a1); + } + mb_gear.quad([at(x0, y0, depth), at(x1, y0, depth), at(x1, y1, depth), at(x0, y1, depth)], [vec2(a0, b0), vec2(a1, b0), vec2(a1, b1), vec2(a0, b1)], [c; 4], [toward; 4]); + }; + let mid = (an.head.0 + an.head.1) * 0.5; + let neck = an.top + (an.shoulder - an.top) * 0.45; + if tier >= 3 { + // the cape hangs behind the figure (seen from behind: over its back) + let cape = shade(if e.hero { (44.0, 74.0, 156.0) } else { (128.0, 22.0, 34.0) }); + let depth = if v >= 3 { 0.004 } else { -0.004 }; + part(an.sh.0 - 0.03, an.shoulder - 0.02, an.sh.1 + 0.03, an.bottom - 0.1, crate::gfx::GEAR_CAPE, cape, depth, false); + } + if tier >= 2 { + part(an.head.0 - 0.02, an.top - 0.03, an.head.1 + 0.02, neck, crate::gfx::GEAR_HELMET, metal, 0.006, false); + if tier >= 4 { + part(mid - 0.1, an.top - 0.11, mid + 0.1, an.top + 0.01, crate::gfx::GEAR_CROWN, shade((255.0, 220.0, 90.0)), 0.008, false); + } else if e.hero { + part(mid - 0.035, an.top - 0.13, mid + 0.035, an.top + 0.01, crate::gfx::GEAR_PLUME, shade((210.0, 40.0, 40.0)), 0.008, false); + } else { + let bone = shade((232.0, 222.0, 200.0)); + part(an.head.0 - 0.06, an.top - 0.11, an.head.0 + 0.02, an.top + 0.02, crate::gfx::GEAR_HORN, bone, 0.008, false); + part(an.head.1 - 0.02, an.top - 0.11, an.head.1 + 0.06, an.top + 0.02, crate::gfx::GEAR_HORN, bone, 0.008, true); + } + } + for (x, flip) in [(an.sh.0 + 0.03, false), (an.sh.1 - 0.03, true)] { + part(x - 0.075, an.shoulder - 0.05, x + 0.075, an.shoulder + 0.06, crate::gfx::GEAR_PAULDRON, metal, 0.007, flip); + } + } + } + let mb = match pic { + Some(_) if fine => &mut mb_hd, + Some(_) => &mut mb_hd32, + None => &mut mb, + }; mb.quad( [corner(-w * 0.6, h * 1.1), corner(w * 0.6, h * 1.1), corner(w * 0.6, -h * 0.1), corner(-w * 0.6, -h * 0.1)], [vec2(u0, v0), vec2(u1, v0), vec2(u1, v1), vec2(u0, v1)], @@ -1923,7 +2015,7 @@ impl R3d { ); } let mesh = |mb: MB, tex: &Texture2D| if mb.v.is_empty() { None } else { Some(Mesh { vertices: mb.v, indices: mb.i, texture: Some(tex.clone()) }) }; - (mesh(mb, &gfx.sprites), mesh(mb_hd, &gfx.hd_sprites)) + [mesh(mb, &gfx.sprites), mesh(mb_hd, &gfx.hd_sprites), mesh(mb_hd32, &gfx.hd_sprites32), mesh(mb_gear, &gfx.gear)].into_iter().flatten().collect() } fn draw_2d(&self, g: &Game, gfx: &Gfx, cam: &Cam, m: &Mat4, t: f32) {