Detailed 32 px imp sprite and finer world pixel rows (540, 1080)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-07 20:41:29 +01:00
1 parent 10af85fd95
commit 928c34e78b
7 files changed
+121 -18

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+1 -1
View File
@@ -118,7 +118,7 @@ painted mission whose triggers switch each other on. **Block 4 is done.**
## 5. Next (agreed 2026-11-06) ## 5. Next (agreed 2026-11-06)
Done: the pixel look (the world 360 rows on every device, 270/480 selectable; the interface on the same Done: the pixel look (the world 360 rows on every device, 270/480/540/1080 selectable, 32x32 sprites from 1080 rows or close zoom (first: the imp); the interface on the same
grid), guard and attack markers (shields and swords), savegames with the game state only. grid), guard and attack markers (shields and swords), savegames with the game state only.
1. **Real AI masters** (done, v202611-00052) - rivals, AI seats and the co-op partner play by the player's rules: imps dig, 1. **Real AI masters** (done, v202611-00052) - rivals, AI seats and the co-op partner play by the player's rules: imps dig,
+99 -10
View File
@@ -1368,6 +1368,16 @@ fn gen_atlas() -> Image {
const SPR: usize = 10; const SPR: usize = 10;
const SPR_CELL: u32 = 12; 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] { fn sprite_rows(k: EK) -> [&'static str; 10] {
match k { match k {
@@ -1825,8 +1835,8 @@ pub const SPRITE_KINDS: [EK; 31] = [
EK::Inquisitor, EK::Inquisitor,
]; ];
// width of the sprite texture in pixels // width of the sprite texture in pixels
pub const SPRITE_TEX_W: f32 = (SPRITE_KINDS.len() * 12) 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 * SPR_CELL) as f32; pub const SPRITE_TEX_H: f32 = (2 * VIEWS * GEAR_TIERS * ACELL) as f32;
pub fn sprite_index(k: EK) -> u32 { pub fn sprite_index(k: EK) -> u32 {
SPRITE_KINDS.iter().position(|&s| s == k).unwrap_or(0) as 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<Color>; 12]; 12] {
} }
// row (view * GEAR_TIERS + tier) * 2: sprites with the gear of the tier, the row after it: // 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 { fn gen_sprites() -> Image {
let n = SPRITE_KINDS.len() as u32; 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 (i, &k) in SPRITE_KINDS.iter().enumerate() {
for view in 0..VIEWS { for view in 0..VIEWS {
for tier in 0..GEAR_TIERS { for tier in 0..GEAR_TIERS {
@@ -2033,9 +2047,27 @@ fn gen_sprites() -> Image {
for (y, row) in g.iter().enumerate() { for (y, row) in g.iter().enumerate() {
for (x, px) in row.iter().enumerate() { for (x, px) in row.iter().enumerate() {
if let Some(col) = px { 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); let (px, py) = (i as u32 * ACELL + x as u32 * SPR_K, (view * GEAR_TIERS + tier) * 2 * ACELL + y as u32 * SPR_K);
img.set_pixel(px, py, *col); for d in 0..SPR_K * SPR_K {
img.set_pixel(px, py + SPR_CELL, WHITE); 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) // the plain 10x10 sprite (interface icons)
pub fn sprite_rect(k: EK, white: bool) -> Rect { pub fn sprite_rect(k: EK, white: bool) -> Rect {
let i = sprite_index(k); 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) // 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 { pub fn sprite_cell_view(k: EK, white: bool, level: u32, view: u32) -> Rect {
let i = sprite_index(k); let i = sprite_index(k);
let row = (view.min(VIEWS - 1) * GEAR_TIERS + gear_tier(level)) * 2 + white as u32; 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<Rect> {
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 { 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)] #[cfg(test)]
mod doc_images { mod doc_images {
use super::*; use super::*;
@@ -2438,7 +2527,7 @@ mod doc_images {
for &k in SPRITE_KINDS.iter() { for &k in SPRITE_KINDS.iter() {
let id = crate::game::st(k).name.trim_start_matches("unit."); let id = crate::game::st(k).name.trim_start_matches("unit.");
for (lv, tag) in [(1u32, "1"), (5, "5"), (10, "10")] { 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(); let atlas = gen_atlas();
+2 -2
View File
@@ -478,7 +478,7 @@ fn apply_settings(s: &Settings, r3d: &mut R3d) {
r3d.outlines = s.outlines; r3d.outlines = s.outlines;
r3d.set_wall_detail(s.wall_subdivisions()); r3d.set_wall_detail(s.wall_subdivisions());
r3d.max_lights = s.max_lights as usize; 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); audio::set_volumes(s.music_volume, s.volume, s.muted);
} }
@@ -985,7 +985,7 @@ async fn main() {
}; };
ui::set_touch(touch_mode); ui::set_touch(touch_mode);
// pixel look: the interface on a virtual screen whose pixels are whole parts of the world's // 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 // 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 // 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) // 1080 device rows in landscape; a smaller screen draws in device pixels)
+1 -1
View File
@@ -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::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::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::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::DebugFps => s.debug_fps = !s.debug_fps,
Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time, Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time,
Item::DebugPing => s.debug_ping = !s.debug_ping, Item::DebugPing => s.debug_ping = !s.debug_ping,
+10 -3
View File
@@ -1561,7 +1561,9 @@ impl R3d {
} }
} }
// sprites as billboards // 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); draw_mesh(&mesh);
} }
// solid unlit objects (default material, depth tested) // solid unlit objects (default material, depth tested)
@@ -1636,7 +1638,7 @@ impl R3d {
self.draw_2d(g, gfx, cam, &m, t); self.draw_2d(g, gfx, cam, &m, t);
} }
fn billboards(&self, g: &Game, gfx: &Gfx, cam: &Cam, t: f32) -> Option<Mesh> { fn billboards(&self, g: &Game, gfx: &Gfx, cam: &Cam, t: f32, world_rows: f32) -> Option<Mesh> {
let (_, r, _) = cam.basis(); let (_, r, _) = cam.basis();
let toward = cam.eye_dir(); let toward = cam.eye_dir();
let amb = |x: f32, z: f32| { let amb = |x: f32, z: f32| {
@@ -1656,6 +1658,9 @@ impl R3d {
let mut mb = MB::new(); let mut mb = MB::new();
let sw = SPRITE_TEX_W; let sw = SPRITE_TEX_W;
let sh = SPRITE_TEX_H; 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 { for e in &g.ents {
if !e.alive || e.held || unit_hidden(g, e) { if !e.alive || e.held || unit_hidden(g, e) {
continue; continue;
@@ -1754,7 +1759,9 @@ impl R3d {
lunge = 0.0; lunge = 0.0;
} }
let white = e.flash > 0.07; 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 u0, v0) = (src.x / sw, src.y / sh);
let (mut u1, v1) = ((src.x + src.w) / sw, (src.y + src.h) / sh); let (mut u1, v1) = ((src.x + src.w) / sw, (src.y + src.h) / sh);
if mirror { if mirror {
+8 -1
View File
@@ -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_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 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 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 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_fps: bool, // show FPS and rendering backend
pub debug_frame_time: bool, // show the frame-time line and graph 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 // 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]; pub const UI_SIZES: [u16; 9] = [0, 75, 85, 100, 115, 130, 150, 175, 200];