From 928c34e78b077e3f9b2a3a8d93b803ae02b16fd8 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sat, 7 Nov 2026 20:41:29 +0100 Subject: [PATCH] Detailed 32 px imp sprite and finer world pixel rows (540, 1080) Co-Authored-By: Claude Opus 5.5 --- assets/sprites/imp-32.png | Bin 0 -> 2680 bytes docs/ROADMAP.md | 2 +- src/gfx.rs | 109 ++++++++++++++++++++++++++++++++++---- src/main.rs | 4 +- src/menu.rs | 2 +- src/render3d.rs | 13 +++-- src/settings.rs | 9 +++- 7 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 assets/sprites/imp-32.png diff --git a/assets/sprites/imp-32.png b/assets/sprites/imp-32.png new file mode 100644 index 0000000000000000000000000000000000000000..bb40b569b8225d55aa52ce641854ff8e2b9c48cf GIT binary patch literal 2680 zcmV-;3WxQHP)001Be1^@s6m49>f000Uu>5h)c^qiuSG#W$Au4GivmjKTbe$CB`&{abgqn9 zH)?%aEiOi@8aGa2*WyNoZ&kJ_XD5wiEzjK$VjYAbY=Ba>OMtk*qc-37~*U^*(1 zj~ZRD47Qi!Q?)@N*?Vj1FvIZLk;pO23L1jL#jRruKuLvGw^7BAQm9^}Z%Y^@rbzDS z2D*Ag_{(jQ&AB)ZnJh0>ur~O*oyv+PZ1}UzLp(;O4(DsimgEx$0Ne$KbnsmN@ZyB63rCpe-dtHdxm}x?x6`Y=BKMMX~Q; zz~^0?He(LPq1!BKfD1g5^&R8ka&dILIQG^Q{2>)`0ldD=!roi!W_Pe%1-Nx)BBl+R zK7MX-PbGXQ#d(-^c>6A&7$*Q2dV#JJ06Yg-gsyU2ZyBoUF|Dhs_GE{2Lryz$-!$#N z3V~0jy)7VJF9ZgIkdf2ar;K?7Bj*w0VueXv<1l?wMx%k{7)1oDFi=0$sGF#>H*Dcf)0AvtiKwS%zmjUPmu5+G)`(zB6 z%YkZc#Du5xr*U>JnqQeNy%U2mC@`#DFB z*Ykky`($+gniijh!uw+pvSli?q5~!RckT07;mym;9IY0{>9!zrPi)p(Td5fIAIb`;y3I^Z z#Ph^}(3s36czi;G7|Jn*UO;4nm#b}=_Ln%tj46~Gx)Edt2QMmdotEX~W!Gzs`$ZaA zj@~y%e2%72n;kD$FOBQPhtOYvNLOhd7vpkc+ac%s35$FyEyD07Y{5=-%2HIp3z;1Jd~t0)}cWaLTem zW!u(c3SKZi0R9GWl!RYrs}e>c>+rF-xKl%n)BPfGzUjSOqWriKJ~8!Jlu3w;m9>5I z1eu;(jxY7-f5g$f^v>aJwqZOW6eg?dk5wk?4ppwumXpWPepmOyS4x9B0Lh!MbOgtV z{yR%3nR#4_rUe?dLY==U2Lz_+fEnM@P~{5O_7UA0OoKs&7}$*83)AN+S5`D7<74`9 zr@p5dA2+rT-16BV&Hew*5}&kl^iR_-H%e}Z414=K0*{liJs_R9 z#^**sV}Lv?5()R_Dt`=qEVo$*&B+EBn|8S&0+WLD5a$x$d)NSJ&|R;;_m&(NyV4NO z-)%co#%8Xk$OqP=Ukrh1*-H1w-j<#RJken|mFFZ_{?ut zvG01~^QBaf&pGo9;%PhnZ*6Nn5uDg(D^9>{R4a=Z%P6lumX4V2clUdxj~?#*bkF@F z`DIH7e0KZeUSN1UrlWex6V4gTzys4aUE^@_vm=8)-Y?R0N>@2_m7^`CQBH^tq3+t~ z%5^WALV-AiI2^>{YTHHKwMHjyPVBasDqB~%YedOhnnEGV5W7Em65@F(Ex$QjZ*ZQ& zwCZ$vX95x8(h9?g;64%s0Di7J*A=Gjp(t2)lXp{l=bQGX!`TQA$q5Y}$>H)`*(*rm zWS)-67@vOqKB^PQ$D?xpU}2gNVt^0>IxXHJ3+J0QM$V_=n8AhiL-&FL$MpCb3T7lY{Jp3cc)bOMY=y@KNIz+KmH|@Vmdi)8Y8rz2PPed2rCfXebHtP* zTEF?& z6R4ck*++l|`+kvQQ!FdKDb!M(U5dD0r1@|;t`Wv~@0A;AD_9mN--GCYIs@r_KBU^) zUynoRvo!9+Ex|!NgkDVdo?@V>YZwHC7-CPDjDd~QBS&AR-Gr&rs3@> zT403w3p^G#EN41&+!tER5RazG0QTN4J-jhJ54daFD}>-{8USK1b(zP-_(nL8w&+~? z&j?-;X`c3Y?DJR+TkkM5=DwrnuO5#{Mjxwd6ed-FgvUwN9rs%Aer|khyW~ipgHNc_ zK;AC`VCfcr(c8({o}qV(hno|+Xo_iJ7QiBpl>ZIG27N-x-qbVBA&-DR8g<|(aPd5s zB92tO>-|Ujd~!a<*yr!yl*W%MpTNP~pR#*EGvoatHywNKdOss_e`>uWh{^cBd92C# mKV+r6PWiNlU>Djw#Nj_*5f1ax`dlCY0000 Image { const SPR: usize = 10; const SPR_CELL: u32 = 12; +// the atlas holds every sprite pixel as SPR_K x SPR_K texels, so detailed sprites (HD_SPRITES) +// fit into the same cells: a cell is SPR_CELL * SPR_K = 36 texels +const SPR_K: u32 = 3; +pub const ACELL: u32 = SPR_CELL * SPR_K; + +// Detailed 32x32 sprites (generated, see art/generated/), used instead of the 10x10 ones when +// the world is rendered fine enough (render3d::billboards). Five views side by side like VIEWS, +// transparent background, the feet on the bottom row. No gear yet: every tier looks the same. +const HD_SPRITES: [(EK, &[u8]); 1] = [(EK::Worker, include_bytes!("../assets/sprites/imp-32.png"))]; +const HD: u32 = 32; fn sprite_rows(k: EK) -> [&'static str; 10] { match k { @@ -1825,8 +1835,8 @@ pub const SPRITE_KINDS: [EK; 31] = [ EK::Inquisitor, ]; // width of the sprite texture in pixels -pub const SPRITE_TEX_W: f32 = (SPRITE_KINDS.len() * 12) as f32; -pub const SPRITE_TEX_H: f32 = (2 * VIEWS * GEAR_TIERS * SPR_CELL) as f32; +pub const SPRITE_TEX_W: f32 = ((SPRITE_KINDS.len() + HD_SPRITES.len()) as u32 * ACELL) as f32; +pub const SPRITE_TEX_H: f32 = (2 * VIEWS * GEAR_TIERS * ACELL) as f32; pub fn sprite_index(k: EK) -> u32 { SPRITE_KINDS.iter().position(|&s| s == k).unwrap_or(0) as u32 @@ -2022,10 +2032,14 @@ fn sprite_with_gear(k: EK, tier: u32, view: u32) -> [[Option; 12]; 12] { } // row (view * GEAR_TIERS + tier) * 2: sprites with the gear of the tier, the row after it: -// white silhouettes (hit flash) +// white silhouettes (hit flash). Columns: SPRITE_KINDS, then HD_SPRITES. fn gen_sprites() -> Image { let n = SPRITE_KINDS.len() as u32; - let mut img = Image::gen_image_color((n * SPR_CELL) as u16, (2 * VIEWS * GEAR_TIERS * SPR_CELL) as u16, Color::new(0.0, 0.0, 0.0, 0.0)); + let mut img = Image::gen_image_color(SPRITE_TEX_W as u16, SPRITE_TEX_H as u16, Color::new(0.0, 0.0, 0.0, 0.0)); + let put = |img: &mut Image, px: u32, py: u32, col: Color| { + img.set_pixel(px, py, col); + img.set_pixel(px, py + ACELL, WHITE); + }; for (i, &k) in SPRITE_KINDS.iter().enumerate() { for view in 0..VIEWS { for tier in 0..GEAR_TIERS { @@ -2033,9 +2047,27 @@ fn gen_sprites() -> Image { for (y, row) in g.iter().enumerate() { for (x, px) in row.iter().enumerate() { if let Some(col) = px { - let (px, py) = (i as u32 * SPR_CELL + x as u32, (view * GEAR_TIERS + tier) * 2 * SPR_CELL + y as u32); - img.set_pixel(px, py, *col); - img.set_pixel(px, py + SPR_CELL, WHITE); + let (px, py) = (i as u32 * ACELL + x as u32 * SPR_K, (view * GEAR_TIERS + tier) * 2 * ACELL + y as u32 * SPR_K); + for d in 0..SPR_K * SPR_K { + put(&mut img, px + d % SPR_K, py + d / SPR_K, *col); + } + } + } + } + } + } + } + for (j, (_, png)) in HD_SPRITES.iter().enumerate() { + let src = Image::from_file_with_format(png, Some(ImageFormat::Png)).expect("hd sprite"); + for view in 0..VIEWS { + for tier in 0..GEAR_TIERS { + for y in 0..HD.min(src.height as u32) { + for x in 0..HD { + let col = src.get_pixel(view * HD + x, y); + if col.a > 0.5 { + // the feet on the 10x10 sprite's bottom row (cell texel 33) + let (px, py) = ((n + j as u32) * ACELL + 2 + x, (view * GEAR_TIERS + tier) * 2 * ACELL + 1 + y); + put(&mut img, px, py, Color::new(col.r, col.g, col.b, 1.0)); } } } @@ -2175,7 +2207,8 @@ impl Gfx { // the plain 10x10 sprite (interface icons) pub fn sprite_rect(k: EK, white: bool) -> Rect { let i = sprite_index(k); - Rect::new((i * SPR_CELL + 1) as f32, (1 + if white { SPR_CELL } else { 0 }) as f32, SPR as f32, SPR as f32) + let k = SPR_K as f32; + Rect::new((i * ACELL) as f32 + k, (if white { ACELL } else { 0 }) as f32 + k, SPR as f32 * k, SPR as f32 * k) } // the whole 12x12 cell with the gear of a level (billboards) @@ -2187,7 +2220,14 @@ impl Gfx { pub fn sprite_cell_view(k: EK, white: bool, level: u32, view: u32) -> Rect { let i = sprite_index(k); let row = (view.min(VIEWS - 1) * GEAR_TIERS + gear_tier(level)) * 2 + white as u32; - Rect::new((i * SPR_CELL) as f32, (row * SPR_CELL) as f32, SPR_CELL as f32, SPR_CELL as f32) + Rect::new((i * ACELL) as f32, (row * ACELL) as f32, ACELL as f32, ACELL as f32) + } + + // the cell of a detailed sprite's view, if the kind has one (same layout as sprite_cell_view) + pub fn hd_cell_view(k: EK, white: bool, level: u32, view: u32) -> Option { + let j = HD_SPRITES.iter().position(|(h, _)| *h == k)? as u32; + let row = (view.min(VIEWS - 1) * GEAR_TIERS + gear_tier(level)) * 2 + white as u32; + Some(Rect::new(((SPRITE_KINDS.len() as u32 + j) * ACELL) as f32, (row * ACELL) as f32, ACELL as f32, ACELL as f32)) } pub fn tile_tex(kind: Kind, owner: Owner, var: u8, reinforced: bool) -> u32 { @@ -2410,6 +2450,55 @@ impl Gfx { } } +#[cfg(test)] +mod sprite_tests { + use super::*; + + #[test] + fn detailed_sprites_in_the_atlas() { + let img = gen_sprites(); + assert_eq!((img.width as f32, img.height as f32), (SPRITE_TEX_W, SPRITE_TEX_H)); + for view in 0..VIEWS { + let r = Gfx::hd_cell_view(EK::Worker, false, 1, view).expect("the imp has a detailed sprite"); + let w = Gfx::hd_cell_view(EK::Worker, true, 1, view).unwrap(); + let opaque = |r: Rect| (0..ACELL * ACELL).filter(|i| img.get_pixel(r.x as u32 + i % ACELL, r.y as u32 + i / ACELL).a > 0.5).count(); + assert!(opaque(r) > 150, "view {view} drawn"); + assert_eq!(opaque(r), opaque(w), "white silhouette matches"); + } + assert!(Gfx::hd_cell_view(EK::Knight, false, 1, 0).is_none()); + // the 10x10 sprites: every pixel a 3x3 block + let r = Gfx::sprite_cell_view(EK::Knight, false, 1, 0); + for y in 0..SPR_CELL { + for x in 0..SPR_CELL { + let p = img.get_pixel(r.x as u32 + x * SPR_K, r.y as u32 + y * SPR_K); + let q = img.get_pixel(r.x as u32 + x * SPR_K + 2, r.y as u32 + y * SPR_K + 2); + assert_eq!((p.r, p.g, p.b, p.a), (q.r, q.g, q.b, q.a)); + } + } + } + + // `cargo test --bins dump_imp_cells -- --ignored` writes the imp's cells to /tmp for a look + #[test] + #[ignore] + fn dump_imp_cells() { + let img = gen_sprites(); + let mut out = Image::gen_image_color((ACELL * VIEWS * 4) as u16, (ACELL * 8) as u16, Color::new(0.35, 0.33, 0.38, 1.0)); + for view in 0..VIEWS { + for (row, r) in [Gfx::sprite_cell_view(EK::Worker, false, 1, view), Gfx::hd_cell_view(EK::Worker, false, 1, view).unwrap()].iter().enumerate() { + for y in 0..ACELL * 4 { + for x in 0..ACELL * 4 { + let p = img.get_pixel(r.x as u32 + x / 4, r.y as u32 + y / 4); + if p.a > 0.5 { + out.set_pixel(view * ACELL * 4 + x, (ACELL * 8 - 1) - (row as u32 * ACELL * 4 + y), p); + } + } + } + } + } + out.export_png("/tmp/imp-cells.png"); + } +} + #[cfg(test)] mod doc_images { use super::*; @@ -2438,7 +2527,7 @@ mod doc_images { for &k in SPRITE_KINDS.iter() { let id = crate::game::st(k).name.trim_start_matches("unit."); for (lv, tag) in [(1u32, "1"), (5, "5"), (10, "10")] { - save(&sprites, Gfx::sprite_cell(k, false, lv), 8, &format!("docs/img/units/{}_{}.png", id, tag)); + save(&sprites, Gfx::sprite_cell(k, false, lv), 3, &format!("docs/img/units/{}_{}.png", id, tag)); } } let atlas = gen_atlas(); diff --git a/src/main.rs b/src/main.rs index 67bb25c..9b75874 100644 --- a/src/main.rs +++ b/src/main.rs @@ -478,7 +478,7 @@ fn apply_settings(s: &Settings, r3d: &mut R3d) { r3d.outlines = s.outlines; r3d.set_wall_detail(s.wall_subdivisions()); r3d.max_lights = s.max_lights as usize; - r3d.pixel_rows = if [270, 360, 480].contains(&s.pixel_rows) { s.pixel_rows as u32 } else { 360 }; + r3d.pixel_rows = settings::pixel_rows(s.pixel_rows); audio::set_volumes(s.music_volume, s.volume, s.muted); } @@ -985,7 +985,7 @@ async fn main() { }; ui::set_touch(touch_mode); // pixel look: the interface on a virtual screen whose pixels are whole parts of the world's - let rows = if [270, 360, 480].contains(&app.settings.pixel_rows) { app.settings.pixel_rows as u32 } else { 360 }; + let rows = settings::pixel_rows(app.settings.pixel_rows); // the interface needs room: the smallest whole multiple of the world's rows with at least // 900 rows (360 -> 1080), on a desktop and on a touch screen alike (a phone has about // 1080 device rows in landscape; a smaller screen draws in device pixels) diff --git a/src/menu.rs b/src/menu.rs index 560141d..cbfa75e 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -200,7 +200,7 @@ fn item_change(it: Item, s: &mut Settings, d: i32) { Item::WallDetail => s.wall_detail = cycle(&[1u8, 2, 3], s.wall_detail, d), Item::Lights => s.max_lights = cycle(&[8u8, 16, 24], s.max_lights, d), Item::UiScale => s.ui_size = cycle(&crate::settings::UI_SIZES, s.ui_size, d), - Item::RenderScale => s.pixel_rows = cycle(&[270u16, 360, 480], if [270, 360, 480].contains(&s.pixel_rows) { s.pixel_rows } else { 360 }, d), + Item::RenderScale => s.pixel_rows = cycle(&crate::settings::PIXEL_ROWS, crate::settings::pixel_rows(s.pixel_rows) as u16, d), Item::DebugFps => s.debug_fps = !s.debug_fps, Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time, Item::DebugPing => s.debug_ping = !s.debug_ping, diff --git a/src/render3d.rs b/src/render3d.rs index a123173..2f316dd 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1561,7 +1561,9 @@ impl R3d { } } // sprites as billboards - if let Some(mesh) = self.billboards(g, gfx, cam, t) { + // 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); + if let Some(mesh) = self.billboards(g, gfx, cam, t, world_rows) { draw_mesh(&mesh); } // solid unlit objects (default material, depth tested) @@ -1636,7 +1638,7 @@ impl R3d { self.draw_2d(g, gfx, cam, &m, t); } - fn billboards(&self, g: &Game, gfx: &Gfx, cam: &Cam, t: f32) -> Option { + fn billboards(&self, g: &Game, gfx: &Gfx, cam: &Cam, t: f32, world_rows: f32) -> Option { let (_, r, _) = cam.basis(); let toward = cam.eye_dir(); let amb = |x: f32, z: f32| { @@ -1656,6 +1658,9 @@ impl R3d { let mut mb = MB::new(); let sw = SPRITE_TEX_W; let sh = SPRITE_TEX_H; + // screen pixels per tile: a sprite's cell (1.2 sizes high) shows a detailed sprite when + // every texel of it gets nearly a pixel; fewer pixels would only turn it into noise + let px_per_tile = world_rows / cam.view_h().max(0.1); for e in &g.ents { if !e.alive || e.held || unit_hidden(g, e) { continue; @@ -1754,7 +1759,9 @@ impl R3d { lunge = 0.0; } let white = e.flash > 0.07; - let src = Gfx::sprite_cell_view(e.kind, white, e.level, if e.captive != 0 { 0 } else { view }); + let v = if e.captive != 0 { 0 } else { view }; + let detailed = w * 1.2 * px_per_tile >= crate::gfx::ACELL as f32 * 0.85; + let src = detailed.then(|| Gfx::hd_cell_view(e.kind, white, e.level, v)).flatten().unwrap_or_else(|| Gfx::sprite_cell_view(e.kind, white, e.level, v)); let (mut u0, v0) = (src.x / sw, src.y / sh); let (mut u1, v1) = ((src.x + src.w) / sw, (src.y + src.h) / sh); if mirror { diff --git a/src/settings.rs b/src/settings.rs index b3341ec..3ee1308 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -151,7 +151,7 @@ pub struct Settings { pub ui_factor: u8, // old interface size (2 large, 3 medium, 4 small; only read to carry old settings over) pub ui_size: u16, // interface size in percent of the standard (100 = 1080 interface rows), 0 = automatic pub render_scale: u8, // old pixel-look divisor (no longer used, kept for old settings files) - pub pixel_rows: u16, // pixel look: rows of the world on every screen (270, 360, 480) + pub pixel_rows: u16, // pixel look: rows of the world on every screen (one of PIXEL_ROWS) pub touch_ui: u8, // touch interface: 0 = automatic (after the first touch), 1 = on, 2 = off pub debug_fps: bool, // show FPS and rendering backend pub debug_frame_time: bool, // show the frame-time line and graph @@ -264,6 +264,13 @@ impl Settings { } } +// the world's pixel rows of the settings menu (more rows: finer world, detailed sprites fit) +pub const PIXEL_ROWS: [u16; 5] = [270, 360, 480, 540, 1080]; + +pub fn pixel_rows(set: u16) -> u32 { + if PIXEL_ROWS.contains(&set) { set as u32 } else { 360 } +} + // the interface sizes of the settings menu in percent (0 = automatic), from small to large pub const UI_SIZES: [u16; 9] = [0, 75, 85, 100, 115, 130, 150, 175, 200];