""" prepare_particles.py Convert PUFF A-D particle sprites to VB format and create shotgun group variants. Single puffs: variable size per frame: PuffA0: 8x8 (1x1 tile) PuffB0: 8x8 (1x1 tile) PuffC0: 16x16 (2x2 tiles) PuffD0: 16x16 (2x2 tiles) Shotgun groups: 32x32 pixels (4x4 tiles), 4 variants x 4 frames All tile data is deduplicated. Output: particle_sprites.c and particle_sprites.h """ import os import random from PIL import Image SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) PARTICLE_DIR = os.path.join(SCRIPT_DIR, "particles") OUTPUT_DIR = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "images") PREVIEW_DIR = os.path.join(SCRIPT_DIR, "particle_previews") PARTICLE_CHAR_START = 764 TILE_SIZE = 8 # Per-frame puff sizes PUFF_FRAMES = ["PUFFA0.png", "PUFFB0.png", "PUFFC0.png", "PUFFD0.png"] PUFF_SIZES = [8, 8, 16, 16] # target pixel size per frame GROUP_SIZE = 32 GROUP_TILES_W = 4 GROUP_TILES_H = 4 GROUP_TILE_COUNT = GROUP_TILES_W * GROUP_TILES_H # 16 NUM_GROUP_VARIANTS = 4 MAGENTA_THRESHOLD = 30 VB_PAL = {0: (0, 0, 0, 0), 1: (80, 0, 0, 255), 2: (170, 0, 0, 255), 3: (255, 0, 0, 255)} def luminance(r, g, b): return int(0.299 * r + 0.587 * g + 0.114 * b) def is_bg(r, g, b, a): if a < 128: return True if g > 200 and b > 200 and r < 40: return True if r > 220 and g < MAGENTA_THRESHOLD and b > 220: return True return False def load_and_convert_puff(filepath, target_size): """Load a puff PNG, detect bbox, resize to target_size, convert to VB indices.""" img = Image.open(filepath).convert('RGBA') w, h = img.size pix = img.load() min_x, min_y, max_x, max_y = w, h, 0, 0 for y in range(h): for x in range(w): r, g, b, a = pix[x, y] if not is_bg(r, g, b, a): min_x = min(min_x, x) min_y = min(min_y, y) max_x = max(max_x, x) max_y = max(max_y, y) if max_x < min_x: return [[0] * target_size for _ in range(target_size)] crop = img.crop((min_x, min_y, max_x + 1, max_y + 1)) cw, ch = crop.size scale = min(target_size / cw, target_size / ch) new_w = max(1, int(cw * scale)) new_h = max(1, int(ch * scale)) resized = crop.resize((new_w, new_h), Image.LANCZOS) canvas = [[0] * target_size for _ in range(target_size)] off_x = (target_size - new_w) // 2 off_y = (target_size - new_h) // 2 rpix = resized.load() for y in range(new_h): for x in range(new_w): r, g, b, a = rpix[x, y] dx = off_x + x dy = off_y + y if dx < target_size and dy < target_size: if is_bg(r, g, b, a): canvas[dy][dx] = 0 else: gray = luminance(r, g, b) if gray < 85: canvas[dy][dx] = 1 elif gray < 170: canvas[dy][dx] = 2 else: canvas[dy][dx] = 3 return canvas def composite_group(puff_canvases, variant_idx, frame_idx): """Create a 32x32 shotgun group by compositing multiple puff copies. Uses the appropriately-sized puff for this frame.""" random.seed(42 + variant_idx * 17 + frame_idx) group = [[0] * GROUP_SIZE for _ in range(GROUP_SIZE)] puff = puff_canvases[frame_idx] puff_h = len(puff) puff_w = len(puff[0]) if puff_h > 0 else 0 num_puffs = 4 + (variant_idx % 2) for _ in range(num_puffs): ox = random.randint(-2, GROUP_SIZE - puff_w + 2) oy = random.randint(-2, GROUP_SIZE - puff_h + 2) for y in range(puff_h): for x in range(puff_w): dx = ox + x dy = oy + y if 0 <= dx < GROUP_SIZE and 0 <= dy < GROUP_SIZE: val = puff[y][x] if val > 0: group[dy][dx] = max(group[dy][dx], val) return group def slice_tiles(canvas, tw, th): tiles = [] for tr in range(th): for tc in range(tw): tile = [] for y in range(TILE_SIZE): row = [] for x in range(TILE_SIZE): py = tr * TILE_SIZE + y px = tc * TILE_SIZE + x if py < len(canvas) and px < len(canvas[0]): row.append(canvas[py][px]) else: row.append(0) tile.append(row) tiles.append(tile) return tiles def tile_to_tuple(tile): return tuple(tuple(row) for row in tile) def hflip_tile(tile): return [row[::-1] for row in tile] def tile_to_vb_2bpp(tile): """Convert 8x8 tile to VB 2bpp sequential format (16 bytes). VB stores each row as a u16 (LE) with pixel 0 at bits[1:0] (LSB). """ data = [] for y in range(TILE_SIZE): u16_val = 0 for x in range(TILE_SIZE): u16_val |= (tile[y][x] << (x * 2)) data.append(u16_val & 0xFF) data.append((u16_val >> 8) & 0xFF) return data def bytes_to_u32(data): words = [] for i in range(0, len(data), 4): w = data[i] | (data[i+1] << 8) | (data[i+2] << 16) | (data[i+3] << 24) words.append(w) return words def save_preview(canvas, path, scale=4): os.makedirs(os.path.dirname(path), exist_ok=True) h = len(canvas) w = len(canvas[0]) img = Image.new('RGBA', (w * scale, h * scale), (0, 0, 0, 255)) pix = img.load() for y in range(h): for x in range(w): color = VB_PAL[canvas[y][x]] for sy in range(scale): for sx in range(scale): pix[x * scale + sx, y * scale + sy] = color img.save(path) def main(): os.makedirs(OUTPUT_DIR, exist_ok=True) os.makedirs(PREVIEW_DIR, exist_ok=True) # --- Load single puffs with per-frame sizes --- puff_canvases = [] for fi, fname in enumerate(PUFF_FRAMES): path = os.path.join(PARTICLE_DIR, fname) target = PUFF_SIZES[fi] canvas = load_and_convert_puff(path, target) puff_canvases.append(canvas) save_preview(canvas, os.path.join(PREVIEW_DIR, f"puff_{fname[:-4]}.png")) print(f"Loaded {fname}: {target}x{target}") # --- Create shotgun group variants --- group_canvases = [] for vi in range(NUM_GROUP_VARIANTS): frames = [] for fi in range(4): group = composite_group(puff_canvases, vi, fi) frames.append(group) save_preview(group, os.path.join(PREVIEW_DIR, f"group_{vi}_frame{fi}.png")) group_canvases.append(frames) print(f"Generated shotgun group variant {vi}: 4 frames") # --- Deduplicate all tiles --- unique_tiles = {} unique_tile_data = [] def add_tile(tile): key = tile_to_tuple(tile) flags = 0 if key in unique_tiles: return unique_tiles[key], flags hkey = tile_to_tuple(hflip_tile(tile)) if hkey in unique_tiles: return unique_tiles[hkey], 0x2000 idx = len(unique_tile_data) unique_tiles[key] = idx unique_tile_data.append(bytes_to_u32(tile_to_vb_2bpp(tile))) return idx, flags # Single puff maps: variable tiles per frame # Frame 0,1: 1x1 = 1 tile; Frame 2,3: 2x2 = 4 tiles puff_maps = [] puff_tile_counts = [] for fi, canvas in enumerate(puff_canvases): sz = PUFF_SIZES[fi] tw = sz // TILE_SIZE th = sz // TILE_SIZE tile_count = tw * th puff_tile_counts.append(tile_count) tiles = slice_tiles(canvas, tw, th) frame_map = [] for t in tiles: idx, flags = add_tile(t) char_idx = PARTICLE_CHAR_START + idx frame_map.append(char_idx | flags) puff_maps.append(frame_map) # Group maps: 4 variants x 4 frames x 16 tiles group_maps = [] for vi in range(NUM_GROUP_VARIANTS): variant_maps = [] for fi in range(4): canvas = group_canvases[vi][fi] tiles = slice_tiles(canvas, GROUP_TILES_W, GROUP_TILES_H) frame_map = [] for t in tiles: idx, flags = add_tile(t) char_idx = PARTICLE_CHAR_START + idx frame_map.append(char_idx | flags) variant_maps.append(frame_map) group_maps.append(variant_maps) num_tiles = len(unique_tile_data) print(f"\nTotal unique particle tiles: {num_tiles}") print(f"Tile data: {num_tiles * 16} bytes") # Compute puff map offsets for variable-size frames # puffMap layout: [frame0 tiles...][frame1 tiles...][frame2 tiles...][frame3 tiles...] puff_offsets = [] off = 0 for tc in puff_tile_counts: puff_offsets.append(off) off += tc total_puff_entries = off # --- Write C file --- c_path = os.path.join(OUTPUT_DIR, "particle_sprites.c") with open(c_path, 'w') as f: f.write("/* Particle sprite tiles -- auto-generated by prepare_particles.py */\n\n") # Tile data all_u32 = [] for td in unique_tile_data: all_u32.extend(td) f.write(f"/* {num_tiles} unique particle tiles */\n") f.write(f"const unsigned int particleTiles[{len(all_u32)}]" f" __attribute__((aligned(4))) =\n{{\n") for i in range(0, len(all_u32), 8): chunk = all_u32[i:i+8] s = ",".join(f"0x{w:08X}" for w in chunk) comma = "," if i + 8 < len(all_u32) else "" f.write(f"\t{s}{comma}\n") f.write("};\n\n") # Single puff maps (variable-size frames concatenated) all_puff = [] for fm in puff_maps: all_puff.extend(fm) f.write(f"/* Single puff maps: {total_puff_entries} entries " f"(frames: {','.join(str(tc) for tc in puff_tile_counts)} tiles) */\n") f.write(f"const unsigned short puffMap[{len(all_puff)}]" f" __attribute__((aligned(4))) =\n{{\n") # Write frame by frame with comments for fi in range(4): start = puff_offsets[fi] count = puff_tile_counts[fi] chunk = all_puff[start:start + count] s = ",".join(f"0x{v:04X}" for v in chunk) comma = "," if fi < 3 else "" f.write(f"\t{s}{comma} /* frame {fi}: {PUFF_SIZES[fi]}x{PUFF_SIZES[fi]} */\n") f.write("};\n\n") # Shotgun group maps (unchanged structure) all_group = [] for vm in group_maps: for fm in vm: all_group.extend(fm) f.write(f"/* Shotgun group maps: {NUM_GROUP_VARIANTS} variants x 4 frames x {GROUP_TILE_COUNT} tiles */\n") f.write(f"const unsigned short shotgunGroupMap[{len(all_group)}]" f" __attribute__((aligned(4))) =\n{{\n") for i in range(0, len(all_group), GROUP_TILE_COUNT): chunk = all_group[i:i+GROUP_TILE_COUNT] s = ",".join(f"0x{v:04X}" for v in chunk) comma = "," if i + GROUP_TILE_COUNT < len(all_group) else "" f.write(f"\t{s}{comma}\n") f.write("};\n") print(f"\nWrote {c_path}") # --- Write H file --- h_path = os.path.join(OUTPUT_DIR, "particle_sprites.h") with open(h_path, 'w') as f: f.write("#ifndef __PARTICLE_SPRITES_H__\n#define __PARTICLE_SPRITES_H__\n\n") f.write(f"#define PARTICLE_CHAR_START {PARTICLE_CHAR_START}\n") f.write(f"#define PARTICLE_TILE_COUNT {num_tiles}\n") f.write(f"#define PARTICLE_TILE_BYTES {num_tiles * 16}\n\n") f.write("/* Per-frame puff sizes (pixels) and tile counts */\n") for fi in range(4): sz = PUFF_SIZES[fi] tw = sz // TILE_SIZE f.write(f"#define PUFF_FRAME{fi}_SIZE {sz}\n") f.write(f"#define PUFF_FRAME{fi}_TILES_W {tw}\n") f.write(f"#define PUFF_FRAME{fi}_TILES {tw * tw}\n") f.write(f"\n/* Puff map offsets (index into puffMap[]) */\n") for fi in range(4): f.write(f"#define PUFF_OFFSET{fi} {puff_offsets[fi]}\n") f.write(f"#define PUFF_FRAME_COUNT 4\n\n") f.write(f"/* Shotgun group: {GROUP_SIZE}x{GROUP_SIZE} px = {GROUP_TILES_W}x{GROUP_TILES_H} tiles */\n") f.write(f"#define GROUP_TILES_W {GROUP_TILES_W}\n") f.write(f"#define GROUP_TILES_H {GROUP_TILES_H}\n") f.write(f"#define GROUP_FRAME_TILES {GROUP_TILE_COUNT}\n") f.write(f"#define GROUP_VARIANT_COUNT {NUM_GROUP_VARIANTS}\n") f.write(f"#define GROUP_FRAME_COUNT 4\n\n") f.write(f"extern const unsigned int particleTiles[{len(all_u32)}];\n") f.write(f"extern const unsigned short puffMap[{total_puff_entries}];\n") f.write(f"extern const unsigned short shotgunGroupMap[{len(all_group)}];\n\n") f.write("#endif\n") print(f"Wrote {h_path}") print(f"\nVRAM: PARTICLE_CHAR_START={PARTICLE_CHAR_START}, tiles={num_tiles}, " f"last char={PARTICLE_CHAR_START + num_tiles - 1}") if __name__ == "__main__": main()