High-res effects: explosions, smoke, bursts, impact stars, healing and glowing projectiles as flipbooks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 01:00:20 +01:00
1 parent 7972e8ad1b
commit 8c5afe872f
10 files changed
+218 -51

No files matched your search

+1
View File
@@ -11,3 +11,4 @@ art/generated/sprites/anim/*-raw*.png
art/generated/sprites/scripts/__pycache__/
art/generated/portraits/*-raw*.png
art/generated/portraits/scripts/__pycache__/
art/generated/fx/*-raw.png
+32
View File
@@ -0,0 +1,32 @@
# Effect flipbooks (high-res)
Generated on 2026-11-08 with `fal-ai/nano-banana-pro` (`aspect_ratio: 16:9`, `resolution: 2K`,
one image per effect; the first try with one row of 8 frames at `8:1` came back empty), the
prompt in `batch.py`: the shared text followed by the effect.
```text
An animation sprite sheet of exactly 8 frames in a grid of 4 columns and 2 rows (reading order), each frame in its own equal square cell, the effect centred in every cell and filling most of it, the frames showing the animation from start to end. Hand-painted pixel art for a dark-fantasy dungeon strategy game, crisp pixels, glowing. The background is completely flat pure black (#000000) everywhere - no frames, no grid lines, no text, no numbers. The effect:
```
- **explosion**: a fiery explosion: a small bright flash, growing into a ball of orange and yellow fire with dark red edges, breaking up into flames and embers, fading into thin dark smoke.
- **smoke**: a puff of grey smoke: a small dense cloud growing larger and softer, drifting apart and fading out.
- **burst**: a magic burst seen from the front: a bright white spark growing into a ring of white light rays and sparkles, then scattering into small fading sparkles (pure white and light grey, so it can be tinted).
- **impact**: a hit impact star: a sharp white flash with jagged rays, then smaller sparks flying outwards and fading (pure white and light grey, so it can be tinted).
- **heal**: a healing effect: soft green glowing crosses and sparkles rising upwards and fading, around a gentle green glow.
- **orbs**: eight different glowing magic projectiles, one per cell (not an animation): 1 a fireball with a flaming tail, 2 the same fireball flickering, 3 a white glowing orb with a soft halo (to be tinted), 4 the same white orb pulsing larger, 5 a holy golden light orb, 6 a dark violet shadow orb with wisps, 7 a glowing red rune orb, 8 a blue arcane orb with small sparkles.
## Processing
The explosion sheet had a grey border around its black panel; it was cut to the panel first
(`explosion-trim.png`). Then
```bash
python3 tools/make_fx.py fx.png explosion-trim.png smoke-raw.png burst-raw.png impact-raw.png heal-raw.png orbs-raw.png --margin 0.12
```
cuts the 2x4 frames, turns the dark background into transparency (alpha from the brightness, so
glows and smoke keep soft edges) and writes one row of eight 64x64 frames per effect;
`fx.png` is copied to `assets/textures/fx/fx.png`. In the game (`fx_sprite` in
`src/render3d.rs`, high-res only): fireballs and cannon blasts explode, other flashes burst in
their colour, smoke puffs, hits flash with an impact star, healing rises in green, and the
magic projectiles are glowing orbs (the fireball's tail points back along its way).
+13
View File
@@ -0,0 +1,13 @@
# Effect flipbooks of the high-res style (2026-11-08): one sheet per effect, 8 frames in a row.
STYLE = ("An animation sprite sheet of exactly 8 frames in a grid of 4 columns and 2 rows (reading order), each frame in its own equal square cell, the effect centred in every "
"cell and filling most of it, the frames showing the animation from start to end. Hand-painted pixel art for a dark-fantasy dungeon "
"strategy game, crisp pixels, glowing. The background is completely flat pure black (#000000) everywhere - no frames, no grid lines, "
"no text, no numbers. The effect: ")
FX = {
"explosion": "a fiery explosion: a small bright flash, growing into a ball of orange and yellow fire with dark red edges, breaking up into flames and embers, fading into thin dark smoke.",
"smoke": "a puff of grey smoke: a small dense cloud growing larger and softer, drifting apart and fading out.",
"burst": "a magic burst seen from the front: a bright white spark growing into a ring of white light rays and sparkles, then scattering into small fading sparkles (pure white and light grey, so it can be tinted).",
"impact": "a hit impact star: a sharp white flash with jagged rays, then smaller sparks flying outwards and fading (pure white and light grey, so it can be tinted).",
"heal": "a healing effect: soft green glowing crosses and sparkles rising upwards and fading, around a gentle green glow.",
"orbs": "eight different glowing magic projectiles, one per cell (not an animation): 1 a fireball with a flaming tail, 2 the same fireball flickering, 3 a white glowing orb with a soft halo (to be tinted), 4 the same white orb pulsing larger, 5 a holy golden light orb, 6 a dark violet shadow orb with wisps, 7 a glowing red rune orb, 8 a blue arcane orb with small sparkles.",
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+47 -51
View File
@@ -104,6 +104,7 @@ pub struct Gfx {
pub hd_sprites: LazyTex, // detailed sprites, 64x64 level (see HD_SPRITES, hd_picture)
pub hd_sprites32: LazyTex, // the same, 32x32 level
pub gear: LazyTex, // their gear parts (see GEAR_HELMET etc.)
pub fx: LazyTex, // effect flipbooks of the high-res style (see render3d FX_*)
pub atlas_hd: LazyTex, // the tile atlas of the high-res style, 64x64 tiles (see HD_TILES)
pub atlas_hd2: LazyTex, // the same with 128x128 tiles (close up)
pub materials: [Texture2D; 9], // furniture materials, high-res (see crate::props::Mat)
@@ -1815,65 +1816,55 @@ struct HdSprite {
idle_32: &'static [u8],
anims: &'static [(HdAnim, &'static [u8], &'static [u8])], // 64x64 and 32x32 sheets
}
macro_rules! hd_sprite {
($kind:expr, $name:literal) => {
HdSprite {
kind: $kind,
idle: include_bytes!(concat!("../assets/sprites/", $name, "-64.png")),
idle_32: include_bytes!(concat!("../assets/sprites/", $name, "-32.png")),
anims: &[],
}
// a detailed sprite: the idle sheets assets/sprites/<name>-64.png / -32.png, and for every
// animation named (walk, attack, die, hack) the sheets <name>-<animation>-64.png / -32.png
macro_rules! hd_anim {
($name:literal, $anim:ident, $file:literal) => {
(HdAnim::$anim, include_bytes!(concat!("../assets/sprites/", $name, "-", $file, "-64.png")) as &[u8], include_bytes!(concat!("../assets/sprites/", $name, "-", $file, "-32.png")) as &[u8])
};
// with walking and attacking (assets/sprites/<name>-walk-64.png etc.)
($kind:expr, $name:literal, animated) => {
}
macro_rules! hd_sprite {
($kind:expr, $name:literal $(, $anim:ident $file:literal)*) => {
HdSprite {
kind: $kind,
idle: include_bytes!(concat!("../assets/sprites/", $name, "-64.png")),
idle_32: include_bytes!(concat!("../assets/sprites/", $name, "-32.png")),
anims: &[
(HdAnim::Walk, include_bytes!(concat!("../assets/sprites/", $name, "-walk-64.png")), include_bytes!(concat!("../assets/sprites/", $name, "-walk-32.png"))),
(HdAnim::Attack, include_bytes!(concat!("../assets/sprites/", $name, "-attack-64.png")), include_bytes!(concat!("../assets/sprites/", $name, "-attack-32.png"))),
],
anims: &[$(hd_anim!($name, $anim, $file)),*],
}
};
}
const HD_SPRITES: [HdSprite; 31] = [
HdSprite {
kind: EK::Worker,
idle: include_bytes!("../assets/sprites/imp-64.png"),
idle_32: include_bytes!("../assets/sprites/imp-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", animated),
hd_sprite!(EK::Spider, "spider"),
hd_sprite!(EK::Mage, "mage"),
hd_sprite!(EK::Ogre, "ogre"),
hd_sprite!(EK::Knight, "knight", animated),
hd_sprite!(EK::Archer, "archer"),
hd_sprite!(EK::Priest, "priest"),
hd_sprite!(EK::Dwarf, "dwarf"),
hd_sprite!(EK::Paladin, "paladin"),
hd_sprite!(EK::Jailer, "jailer"),
hd_sprite!(EK::Priestess, "priestess"),
hd_sprite!(EK::Chainbreaker, "chainbreaker"),
hd_sprite!(EK::Bat, "bat"),
hd_sprite!(EK::Gargoyle, "gargoyle"),
hd_sprite!(EK::Owl, "owl"),
hd_sprite!(EK::Wyrm, "wyrm"),
hd_sprite!(EK::Cogling, "cogling"),
hd_sprite!(EK::Revenant, "revenant"),
hd_sprite!(EK::Giant, "giant"),
hd_sprite!(EK::Fairy, "fairy"),
hd_sprite!(EK::BattleMage, "battlemage"),
hd_sprite!(EK::Ram, "ram"),
hd_sprite!(EK::Catapult, "catapult"),
hd_sprite!(EK::CaveBear, "cavebear"),
hd_sprite!(EK::Beetle, "beetle"),
hd_sprite!(EK::Trader, "trader"),
hd_sprite!(EK::Thief, "thief"),
hd_sprite!(EK::Monk, "monk"),
hd_sprite!(EK::Ranger, "ranger"),
hd_sprite!(EK::Inquisitor, "inquisitor"),
hd_sprite!(EK::Worker, "imp", Hack "hack", Walk "walk", Die "die"),
hd_sprite!(EK::Kobold, "kobold", Walk "walk", Die "die", Attack "attack"),
hd_sprite!(EK::Spider, "spider", Walk "walk", Die "die", Attack "attack"),
hd_sprite!(EK::Mage, "mage", Walk "walk", Die "die", Attack "attack"),
hd_sprite!(EK::Ogre, "ogre", Walk "walk", Die "die", Attack "attack"),
hd_sprite!(EK::Knight, "knight", Walk "walk", Die "die", Attack "attack"),
hd_sprite!(EK::Archer, "archer", Walk "walk", Die "die", Attack "attack"),
hd_sprite!(EK::Priest, "priest", Walk "walk", Die "die"),
hd_sprite!(EK::Dwarf, "dwarf", Walk "walk", Die "die", Attack "attack"),
hd_sprite!(EK::Paladin, "paladin", Walk "walk", Die "die", Attack "attack"),
hd_sprite!(EK::Jailer, "jailer", Walk "walk", Die "die"),
hd_sprite!(EK::Priestess, "priestess", Walk "walk", Die "die"),
hd_sprite!(EK::Chainbreaker, "chainbreaker", Walk "walk", Die "die"),
hd_sprite!(EK::Bat, "bat", Walk "walk", Die "die"),
hd_sprite!(EK::Gargoyle, "gargoyle", Walk "walk", Die "die"),
hd_sprite!(EK::Owl, "owl", Walk "walk", Die "die"),
hd_sprite!(EK::Wyrm, "wyrm", Walk "walk", Die "die"),
hd_sprite!(EK::Cogling, "cogling", Walk "walk", Die "die"),
hd_sprite!(EK::Revenant, "revenant", Walk "walk", Die "die"),
hd_sprite!(EK::Giant, "giant", Walk "walk", Die "die"),
hd_sprite!(EK::Fairy, "fairy", Walk "walk", Die "die"),
hd_sprite!(EK::BattleMage, "battlemage", Walk "walk", Die "die"),
hd_sprite!(EK::Ram, "ram", Walk "walk", Die "die"),
hd_sprite!(EK::Catapult, "catapult", Walk "walk", Die "die"),
hd_sprite!(EK::CaveBear, "cavebear", Walk "walk", Die "die"),
hd_sprite!(EK::Beetle, "beetle", Walk "walk", Die "die"),
hd_sprite!(EK::Trader, "trader", Walk "walk", Die "die"),
hd_sprite!(EK::Thief, "thief", Walk "walk", Die "die"),
hd_sprite!(EK::Monk, "monk", Walk "walk", Die "die"),
hd_sprite!(EK::Ranger, "ranger", Walk "walk", Die "die"),
hd_sprite!(EK::Inquisitor, "inquisitor", Walk "walk", Die "die"),
];
// 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; the 32x32
@@ -3087,6 +3078,11 @@ impl Gfx {
.clone()
});
let gear = LazyTex::new(|| tex(&gen_gear()));
let fx = LazyTex::new(|| {
let t = Texture2D::from_file_with_format(include_bytes!("../assets/textures/fx/fx.png"), Some(ImageFormat::Png));
t.set_filter(FilterMode::Nearest);
t
});
let mat = |b: &[u8]| {
let t = Texture2D::from_file_with_format(b, Some(ImageFormat::Png));
t.set_filter(FilterMode::Nearest);
@@ -3094,7 +3090,7 @@ impl Gfx {
};
let materials = MATERIALS.map(|(hd, _)| mat(hd));
let materials_px = MATERIALS.map(|(_, px)| mat(px));
Gfx { atlas, sprites, hd_sprites, hd_sprites32, gear, atlas_hd, atlas_hd2, materials, materials_px, cursors, badges, font, font_map }
Gfx { atlas, sprites, hd_sprites, hd_sprites32, gear, fx, atlas_hd, atlas_hd2, materials, materials_px, cursors, badges, font, font_map }
}
pub fn cell(id: u32) -> Rect {
+68
View File
@@ -1602,6 +1602,9 @@ impl R3d {
let m = cam.matrix();
self.update_chunks(g, gfx, &m);
EYE.with(|e| e.set(cam.eye_dir()));
let (_, cr, cu) = cam.basis();
let hd_fx = self.pixel_rows >= crate::settings::HIGH_RES_ROWS as u32;
FX.with(|f| *f.borrow_mut() = hd_fx.then(|| (gfx.fx.clone(), cr, cu)));
// pixel look: the world is rendered at a fixed number of rows and scaled up to the screen
// on the virtual screen (ui.rs) the world takes exactly k x k interface pixels per pixel
let vrows = crate::ui::virtual_rows();
@@ -2712,6 +2715,44 @@ fn draw_towers_glow(g: &Game, t: f32) {
// Camera direction for camera-facing line quads (set once per frame)
thread_local! {
static EYE: std::cell::Cell<Vec3> = std::cell::Cell::new(Vec3::Y);
// the high-res style's effect flipbooks and the camera's right and up (set once per frame;
// None in the pixel-art style, whose effects stay simple shapes)
static FX: std::cell::RefCell<Option<(Texture2D, Vec3, Vec3)>> = const { std::cell::RefCell::new(None) };
}
// the rows of the effect atlas (8 frames of 64x64 each)
const FX_EXPLOSION: u32 = 0;
const FX_SMOKE: u32 = 1;
const FX_BURST: u32 = 2; // white, tinted
const FX_IMPACT: u32 = 3; // white, tinted
const FX_HEAL: u32 = 4;
const FX_ORBS: u32 = 5; // projectiles: 0-1 fireball, 2-3 white orb (tinted), 4 holy, 5 shadow, 6 rune, 7 arcane
// draws frame `frame` of an effect row as a camera-facing picture (`turn`: radians in the
// screen plane); false in the pixel-art style (the caller draws its simple shapes)
fn fx_sprite(row: u32, frame: u32, at: Vec3, size: f32, turn: f32, c: Color) -> bool {
FX.with(|f| {
let f = f.borrow();
let Some((tex, r, u)) = f.as_ref() else { return false };
let (s, co) = turn.sin_cos();
let (rr, uu) = ((*r * co + *u * s) * size * 0.5, (*u * co - *r * s) * size * 0.5);
let (u0, v0) = ((frame.min(7) as f32) / 8.0, row as f32 / 6.0);
let (u1, v1) = (u0 + 1.0 / 8.0, v0 + 1.0 / 6.0);
let col = [(c.r * 255.0) as u8, (c.g * 255.0) as u8, (c.b * 255.0) as u8, (c.a.clamp(0.0, 1.0) * 255.0) as u8];
let v = |p: Vec3, uv: Vec2| Vertex { position: p, uv, color: col, normal: Vec4::ZERO };
let mesh = Mesh {
vertices: vec![v(at - rr + uu, vec2(u0, v0)), v(at + rr + uu, vec2(u1, v0)), v(at + rr - uu, vec2(u1, v1)), v(at - rr - uu, vec2(u0, v1))],
indices: vec![0, 1, 2, 0, 2, 3],
texture: Some(tex.clone()),
};
draw_mesh(&mesh);
true
})
}
// the frame of an 8-frame effect at life p (0..1)
fn fx_frame(p: f32) -> u32 {
((p * 8.0) as u32).min(7)
}
// Thick 3D line as a camera-facing quad (GL lines don't work with custom materials)
@@ -3509,6 +3550,23 @@ fn draw_projectile(f: &Fx, p: f32, t: f32) {
draw_sphere(head, 0.1 * sz, None, Color::from_rgba(24, 24, 28, 255));
draw_sphere(head, 0.14 * sz, None, Color::new(1.0, 0.6, 0.2, 0.15));
}
ST_FIRE | ST_HOLY | ST_SHADOW | ST_RUNE | ST_ARCANE | ST_MAGIC if {
// the high-res style: a picture, a fireball's tail pointing back along its way
let back = at((p - 0.05).max(0.0));
let (_, r, u) = FX.with(|x| x.borrow().as_ref().map(|x| (0, x.1, x.2))).unwrap_or((0, Vec3::X, Vec3::Y));
let d = head - back;
let turn = d.dot(u).atan2(d.dot(r));
let flick = ((t * 12.0 + f.x) as u32) % 2;
let (frame, tint) = match f.style {
ST_FIRE => (flick, WHITE),
ST_HOLY => (4, WHITE),
ST_SHADOW => (5, WHITE),
ST_RUNE => (6, WHITE),
ST_ARCANE => (7, WHITE),
_ => (2 + flick, Color::new(c.r, c.g, c.b, 1.0)),
};
fx_sprite(FX_ORBS, frame, head, 0.42 * sz, if f.style == ST_FIRE { turn } else { 0.0 }, tint)
} => {}
ST_FIRE => {
let flick = 1.0 + (t * 30.0 + f.x).sin() * 0.12;
draw_sphere(head, 0.08 * sz * flick, None, Color::new(1.0, 0.95, 0.6, 1.0));
@@ -3801,7 +3859,14 @@ fn draw_parts_glow(g: &Game) {
let s = p.size * (1.0 - life * 0.6);
draw_cube(at, Vec3::splat(s), None, Color::new(c.r, c.g, c.b, 1.0 - life * life));
}
SMOKE if fx_sprite(FX_SMOKE, fx_frame(life), at, p.size * 2.6, p.size * 3.0, Color::new(c.r.max(0.5), c.g.max(0.5), c.b.max(0.5), 0.55 * (1.0 - life))) => {}
SMOKE => draw_sphere(at, p.size, None, Color::new(c.r, c.g, c.b, 0.14 * (1.0 - life))),
FLASH if {
// fire explodes, anything else bursts in its colour
let fiery = c.r > 0.8 && c.g < 0.8 && c.b < 0.45;
let (row, tint) = if fiery { (FX_EXPLOSION, WHITE) } else { (FX_BURST, Color::new(c.r, c.g, c.b, 1.0)) };
fx_sprite(row, fx_frame(life), at, p.size * 2.6, 0.0, tint)
} => {}
FLASH => {
let r = p.size * (0.3 + life * 0.9);
draw_sphere(at, r * 0.6, None, Color::new(1.0, 0.95, 0.8, 0.95 * (1.0 - life)));
@@ -3886,6 +3951,7 @@ fn draw_fx(f: &Fx, t: f32) {
}
disc(vec3(f.x, 0.05, f.y), 1.6 * (0.5 + p), Color::new(1.0, 1.0, 0.7, 0.35 * (1.0 - p)), 24);
}
2 if fx_sprite(FX_IMPACT, fx_frame(p), at, 0.7 * f.size, 0.0, Color::new(c.r.max(0.4), c.g.max(0.4), c.b.max(0.4), 1.0)) => {}
2 => {
let r = (0.1 + 0.3 * p) * f.size;
let n = if f.size > 1.35 { 10 } else { 6 };
@@ -3897,7 +3963,9 @@ fn draw_fx(f: &Fx, t: f32) {
disc(vec3(f.x, 0.03, f.y), r * 1.3, a(0.3 * (1.0 - p)), 12);
}
}
3 if fx_sprite(FX_BURST, fx_frame(p), vec3(f.x, 0.4, f.y), 1.4 * f.size, 0.0, Color::new(c.r, c.g, c.b, 1.0)) => {}
3 => draw_sphere(vec3(f.x, 0.3, f.y), (0.2 + 0.5 * p) * f.size, None, a(0.4 * (1.0 - p))),
4 if fx_sprite(FX_HEAL, fx_frame(p), at + vec3(0.0, 0.4, 0.0), 1.0, 0.0, WHITE) => {}
4 => {
for k in 0..3 {
let o = vec3((k as f32 - 1.0) * 0.2, 0.3 + p * 0.9 + k as f32 * 0.1, 0.0);
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Turn generated effect sheets (a 2x4 grid of frames on black) into the effect atlas of the
high-res style: every frame cut out (a margin off, the cell frames the model drew), the dark
background turned into transparency (alpha from the brightness, the colour divided by it, so
glows and smoke keep their soft edges), scaled to 64x64; one row of 8 frames per sheet.
Usage:
make_fx.py out.png sheet1-raw.png sheet2-raw.png ... [--margin 0.08] [--floor 0.1]
--floor brightness counted as background (the model's not quite black)
Writes out.png (8 x 64 wide, 64 per sheet high) and out-preview.png (on grey, 2x). Needs Pillow.
"""
import argparse
from PIL import Image
def main():
ap = argparse.ArgumentParser()
ap.add_argument("out")
ap.add_argument("sheets", nargs="+")
ap.add_argument("--margin", type=float, default=0.08)
ap.add_argument("--floor", type=float, default=0.1)
a = ap.parse_args()
size = 64
atlas = Image.new("RGBA", (8 * size, len(a.sheets) * size), (0, 0, 0, 0))
for row, path in enumerate(a.sheets):
img = Image.open(path).convert("RGB")
w, h = img.size
for k in range(8):
r, c = divmod(k, 4)
x0, x1 = c * w / 4, (c + 1) * w / 4
y0, y1 = r * h / 2, (r + 1) * h / 2
m = a.margin * (x1 - x0)
cell = img.crop((int(x0 + m), int(y0 + m), int(x1 - m), int(y1 - m))).resize((size, size), Image.BOX)
px = cell.load()
out = Image.new("RGBA", (size, size))
op = out.load()
for y in range(size):
for x in range(size):
rr, gg, bb = (v / 255 for v in px[x, y])
al = max(0.0, (max(rr, gg, bb) - a.floor) / (1 - a.floor))
if al < 0.03:
op[x, y] = (0, 0, 0, 0)
continue
k2 = max(rr, gg, bb)
op[x, y] = (int(255 * rr / k2), int(255 * gg / k2), int(255 * bb / k2), int(255 * min(1.0, al * 1.3)))
atlas.paste(out, (k * size, row * size))
atlas.save(a.out)
prev = Image.new("RGBA", atlas.size, (70, 66, 74, 255))
prev.alpha_composite(atlas)
prev.resize((prev.width * 2, prev.height * 2), Image.NEAREST).save(a.out.replace(".png", "-preview.png"))
print(a.out)
if __name__ == "__main__":
main()