Imp digging animation and gold sack in high-res; sack also on the small imp instead of the coin sign

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-07 21:08:07 +01:00
1 parent 76f3b3c7b2
commit 2d1ed177a7
14 files changed
+370 -96

No files matched your search

+221 -57
View File
@@ -1374,10 +1374,43 @@ 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 in the
// high-res graphics style when close 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"))];
// high-res graphics style when close enough (render3d::billboards). Transparent background, the
// feet on the bottom row. `idle`: the five views side by side (VIEWS); `hack`: the digging
// animation, HACK_FRAMES frames side by side, one row per view. No gear yet.
struct HdSprite {
kind: EK,
idle: &'static [u8],
hack: &'static [u8],
}
const HD_SPRITES: [HdSprite; 1] = [HdSprite {
kind: EK::Worker,
idle: include_bytes!("../assets/sprites/imp-32.png"),
hack: include_bytes!("../assets/sprites/imp-hack-32.png"),
}];
const HD: u32 = 32;
pub const HACK_FRAMES: u32 = 4;
// atlas columns of a detailed sprite: idle and the hack frames, each without and with the sack
const HD_COLS: u32 = (1 + HACK_FRAMES) * 2;
// 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
const SACK_KINDS: [EK; 1] = [EK::Worker];
// the sack (k outline, B burlap, b its shade and the rope, y/Y gold coins at the opening)
const SACK_SMALL: [&str; 5] = [".kk.", "kyYk", "kBbk", "kBBk", ".kk."];
const SACK_HD: [&str; 12] = [
"....kkkk....",
"...kyYYyk...",
"..kyYyyYyk..",
"..kkbbbbkk..",
"...kBBBBk...",
"..kBBBBBBk..",
".kBBBbBBBBk.",
".kBBBBBBbBk.",
".kBbBBBBBBk.",
".kBBBBBBBBk.",
"..kBBBBBBk..",
"...kkkkkk...",
];
fn sprite_rows(k: EK) -> [&'static str; 10] {
match k {
@@ -1835,7 +1868,7 @@ pub const SPRITE_KINDS: [EK; 31] = [
EK::Inquisitor,
];
// width of the sprite texture in pixels
pub const SPRITE_TEX_W: f32 = ((SPRITE_KINDS.len() + HD_SPRITES.len()) as u32 * ACELL) as f32;
pub const SPRITE_TEX_W: f32 = ((SPRITE_KINDS.len() + SACK_KINDS.len()) as u32 * ACELL + HD_SPRITES.len() as u32 * HD_COLS * ACELL) as f32;
pub const SPRITE_TEX_H: f32 = (2 * VIEWS * GEAR_TIERS * ACELL) as f32;
pub fn sprite_index(k: EK) -> u32 {
@@ -2031,46 +2064,124 @@ fn sprite_with_gear(k: EK, tier: u32, view: u32) -> [[Option<Color>; 12]; 12] {
g
}
// row (view * GEAR_TIERS + tier) * 2: sprites with the gear of the tier, the row after it:
// 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(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);
type Cell = Vec<Vec<Option<Color>>>;
// a sack on the back of a figure in a cell: from the front it shows beside the body (drawn
// behind the figure), from behind on the back (in front of it); `view` as in VIEWS; `front_dx`:
// where it shows beside the body in the front and front three-quarter view (in body widths,
// the side away from the tool)
fn add_sack(g: &mut Cell, view: u32, pat: &[&str], front_dx: (f32, f32)) {
let n = g.len();
let rows: Vec<usize> = (0..n).filter(|&y| g[y].iter().any(|p| p.is_some())).collect();
let (Some(&top), Some(&bot)) = (rows.first(), rows.last()) else { return };
let h = (bot - top + 1) as f32;
// the body's middle: the opaque columns of the torso band (tools and tails stick out)
let mut xs: Vec<usize> = Vec::new();
for y in top + (h * 0.4) as usize..=top + (h * 0.6) as usize {
xs.extend((0..n).filter(|&x| g[y][x].is_some()));
}
if xs.is_empty() {
return;
}
xs.sort();
let cx = xs[xs.len() / 2] as f32;
let span = (xs[xs.len() * 9 / 10] - xs[xs.len() / 10]) as f32;
let pw = pat[0].len() as f32;
// (offset of the sack's centre from the body's middle in body widths, its top in heights, in front)
let (dx, dy, front) = match view {
0 => (front_dx.0, 0.34, false),
1 => (front_dx.1, 0.32, false),
2 => (-0.55, 0.3, false),
3 => (-0.15, 0.36, true),
_ => (0.0, 0.36, true),
};
for (i, &k) in SPRITE_KINDS.iter().enumerate() {
for view in 0..VIEWS {
for tier in 0..GEAR_TIERS {
let g = sprite_with_gear(k, tier, view);
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 * 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);
}
}
}
let x0 = (cx + dx * span - pw * 0.5).round() as i32;
let y0 = (top as f32 + dy * h).round() as i32;
for (py, row) in pat.iter().enumerate() {
for (px, ch) in row.chars().enumerate() {
let col = match ch {
'k' => c(22, 16, 22),
'B' => c(150, 104, 58),
'b' => c(108, 70, 40),
'y' => c(232, 176, 48),
'Y' => c(255, 232, 120),
_ => continue,
};
let (x, y) = (x0 + px as i32, y0 + py as i32);
if x < 0 || y < 0 || x as usize >= n || y as usize >= n {
continue;
}
let p = &mut g[y as usize][x as usize];
if front || p.is_none() {
*p = Some(col);
}
}
}
}
// writes a cell into the atlas: column, cell row (the white silhouette goes into the row below),
// every cell pixel as k x k texels
fn put_cell(img: &mut Image, col: u32, row: u32, g: &Cell, k: u32) {
for (y, line) in g.iter().enumerate() {
for (x, p) in line.iter().enumerate() {
let Some(c) = p else { continue };
for d in 0..k * k {
let (px, py) = (col * ACELL + x as u32 * k + d % k, row * ACELL + y as u32 * k + d / k);
img.set_pixel(px, py, *c);
img.set_pixel(px, py + ACELL, WHITE);
}
}
}
}
// a 32x32 picture from a sheet into a 36x36 cell (the feet on the 10x10 sprite's bottom row)
fn hd_cell(src: &Image, sx: u32, sy: u32) -> Cell {
let mut g = vec![vec![None; ACELL as usize]; ACELL as usize];
for y in 0..HD {
for x in 0..HD {
if sx + x < src.width as u32 && sy + y < src.height as u32 {
let p = src.get_pixel(sx + x, sy + y);
if p.a > 0.5 {
g[1 + y as usize][2 + x as usize] = Some(Color::new(p.r, p.g, p.b, 1.0));
}
}
}
}
for (j, (_, png)) in HD_SPRITES.iter().enumerate() {
let src = Image::from_file_with_format(png, Some(ImageFormat::Png)).expect("hd sprite");
g
}
// The sprite atlas. Columns: SPRITE_KINDS, SACK_KINDS (with the sack), then HD_COLS per
// detailed sprite. 10x10 sprites: row (view * GEAR_TIERS + tier) * 2 with the gear of the tier,
// the row after it the white silhouette (hit flash). Detailed sprites: row view * 2 (+1 white).
fn gen_sprites() -> Image {
let n = SPRITE_KINDS.len() as u32;
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 small = |k: EK, tier: u32, view: u32| -> Cell { sprite_with_gear(k, tier, view).iter().map(|r| r.to_vec()).collect() };
for view in 0..VIEWS {
for tier in 0..GEAR_TIERS {
let row = (view * GEAR_TIERS + tier) * 2;
for (i, &k) in SPRITE_KINDS.iter().enumerate() {
put_cell(&mut img, i as u32, row, &small(k, tier, view), SPR_K);
}
for (i, &k) in SACK_KINDS.iter().enumerate() {
let mut g = small(k, tier, view);
add_sack(&mut g, view, &SACK_SMALL, (-0.5, -0.5));
put_cell(&mut img, n + i as u32, row, &g, SPR_K);
}
}
}
let base = n + SACK_KINDS.len() as u32;
for (j, hd) in HD_SPRITES.iter().enumerate() {
let idle = Image::from_file_with_format(hd.idle, Some(ImageFormat::Png)).expect("hd sprite");
let hack = Image::from_file_with_format(hd.hack, 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));
}
}
}
for f in 0..1 + HACK_FRAMES {
let g = if f == 0 { hd_cell(&idle, view * HD, 0) } else { hd_cell(&hack, (f - 1) * HD, view * HD) };
let col = base + j as u32 * HD_COLS + f * 2;
put_cell(&mut img, col, view * 2, &g, 1);
let mut s = g;
add_sack(&mut s, view, &SACK_HD, (0.7, -0.65));
put_cell(&mut img, col + 1, view * 2, &s, 1);
}
}
}
@@ -2224,10 +2335,26 @@ impl Gfx {
}
// 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))
// the cell of a detailed sprite, if the kind has one: a view, standing (`hack` None) or a
// frame of the digging animation, with or without the sack
pub fn hd_cell_view(k: EK, white: bool, view: u32, hack: Option<u32>, sack: bool) -> Option<Rect> {
let j = HD_SPRITES.iter().position(|h| h.kind == k)? as u32;
let f = hack.map_or(0, |f| 1 + f % HACK_FRAMES);
let col = (SPRITE_KINDS.len() + SACK_KINDS.len()) as u32 + j * HD_COLS + f * 2 + sack as u32;
let row = view.min(VIEWS - 1) * 2 + white as u32;
Some(Rect::new((col * ACELL) as f32, (row * ACELL) as f32, ACELL as f32, ACELL as f32))
}
// whether a kind carries its treasure in a sack on the back
pub fn has_sack(k: EK) -> bool {
SACK_KINDS.contains(&k)
}
// the cell of a 10x10 sprite with the sack on the back (only SACK_KINDS)
pub fn sprite_cell_sack(k: EK, white: bool, level: u32, view: u32) -> Option<Rect> {
let i = SACK_KINDS.iter().position(|&s| s == k)? as u32;
let r = Gfx::sprite_cell_view(k, white, level, view);
Some(Rect::new(((SPRITE_KINDS.len() as u32 + i) * ACELL) as f32, r.y, r.w, r.h))
}
pub fn tile_tex(kind: Kind, owner: Owner, var: u8, reinforced: bool) -> u32 {
@@ -2454,19 +2581,37 @@ impl Gfx {
mod sprite_tests {
use super::*;
fn opaque(img: &Image, r: Rect) -> usize {
(0..ACELL * ACELL).filter(|i| img.get_pixel(r.x as u32 + i % ACELL, r.y as u32 + i / ACELL).a > 0.5).count()
}
// texels that differ between two cells
fn differ(img: &Image, a: Rect, b: Rect) -> usize {
(0..ACELL * ACELL)
.filter(|i| {
let p = img.get_pixel(a.x as u32 + i % ACELL, a.y as u32 + i / ACELL);
let q = img.get_pixel(b.x as u32 + i % ACELL, b.y as u32 + i / ACELL);
(p.r, p.g, p.b, p.a) != (q.r, q.g, q.b, q.a)
})
.count()
}
#[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");
for hack in [None, Some(0), Some(1), Some(2), Some(3)] {
let r = Gfx::hd_cell_view(EK::Worker, false, view, hack, false).expect("the imp has a detailed sprite");
let w = Gfx::hd_cell_view(EK::Worker, true, view, hack, false).unwrap();
let s = Gfx::hd_cell_view(EK::Worker, false, view, hack, true).unwrap();
assert!(opaque(&img, r) > 150, "view {view} {hack:?} drawn");
assert_eq!(opaque(&img, r), opaque(&img, w), "white silhouette matches");
assert!(differ(&img, s, r) > 15, "view {view} {hack:?}: the sack shows");
}
}
assert!(Gfx::hd_cell_view(EK::Knight, false, 1, 0).is_none());
// the 10x10 sprites: every pixel a 3x3 block
assert!(Gfx::hd_cell_view(EK::Knight, false, 0, None, false).is_none());
// the 10x10 sprites: every pixel a 3x3 block; the imp's sack variant
let r = Gfx::sprite_cell_view(EK::Knight, false, 1, 0);
for y in 0..SPR_CELL {
for x in 0..SPR_CELL {
@@ -2475,21 +2620,40 @@ mod sprite_tests {
assert_eq!((p.r, p.g, p.b, p.a), (q.r, q.g, q.b, q.a));
}
}
assert!(Gfx::sprite_cell_sack(EK::Knight, false, 1, 0).is_none());
for view in 0..VIEWS {
let plain = Gfx::sprite_cell_view(EK::Worker, false, 1, view);
let sack = Gfx::sprite_cell_sack(EK::Worker, false, 1, view).unwrap();
assert!(differ(&img, sack, plain) > 30, "view {view}: the small sack shows");
}
}
// `cargo test --bins dump_imp_cells -- --ignored` writes the imp's cells to /tmp for a look
// `cargo test --bins dump_imp_cells -- --ignored` writes the imp's cells to /tmp for a look:
// rows: 10x10, 10x10 with sack, then idle and the hack frames without and with sack
#[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);
let mut cells: Vec<Vec<Rect>> = Vec::new();
for sack in [false, true] {
let r: Vec<Rect> = (0..VIEWS).map(|v| if sack { Gfx::sprite_cell_sack(EK::Worker, false, 1, v).unwrap() } else { Gfx::sprite_cell_view(EK::Worker, false, 1, v) }).collect();
cells.push(r);
}
for sack in [false, true] {
for hack in [None, Some(0), Some(1), Some(2), Some(3)] {
cells.push((0..VIEWS).map(|v| Gfx::hd_cell_view(EK::Worker, false, v, hack, sack).unwrap()).collect());
}
}
let z = 3;
let (w, h) = (ACELL * z * VIEWS, ACELL * z * cells.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 cells.iter().enumerate() {
for (view, r) in list.iter().enumerate() {
for y in 0..ACELL * z {
for x in 0..ACELL * z {
let p = img.get_pixel(r.x as u32 + x / z, r.y as u32 + y / z);
if p.a > 0.5 {
out.set_pixel(view * ACELL * 4 + x, (ACELL * 8 - 1) - (row as u32 * ACELL * 4 + y), p);
out.set_pixel(view as u32 * ACELL * z + x, h - 1 - (row as u32 * ACELL * z + y), p);
}
}
}
+14 -2
View File
@@ -1762,7 +1762,18 @@ impl R3d {
let white = e.flash > 0.07;
let v = if e.captive != 0 { 0 } else { view };
let detailed = hd && 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));
// 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);
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) } 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 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 (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 {
@@ -1872,7 +1883,8 @@ impl R3d {
let rise = (k * 14.0 * F() as f32).floor();
gfx.glyph('z', head.x + cw * 2.0, head.y - GH * S() - rise, S(), Color::new(0.8, 0.85, 1.0, 1.0 - k));
}
if e.carry > 0 {
// treasure without a sack sprite: a coin over the unit (imps carry a sack instead)
if e.carry > 0 && !Gfx::has_sack(e.kind) {
let p = proj(vec3(e.disp_x, 0.35, e.disp_y));
gfx.glyph('☼', p.x + cw * 2.0, p.y - GH * small, small, Color::from_rgba(250, 210, 60, 255));
}