initial commit!

This commit is contained in:
2026-02-19 23:28:57 +01:00
parent b0d594a9c0
commit 2a36117c25
1558 changed files with 74163 additions and 0 deletions

View File

@@ -0,0 +1,568 @@
"""
prepare_shotgun_sprites.py
Convert shotgun first-person sprite sheet to VB tile format using grit.exe.
Source: shotgun_6_sprites/shottygun01_2.png (3x2 grid)
Generates DUAL-LAYER sprites (red + black), matching the pistol's approach:
- Red layer: the weapon body (normal palette GPLT0)
- Black layer: 1-pixel outline around the weapon (GPLT2 palette for dark outlines)
Tiles are deduplicated across all 12 images (6 red + 6 black, including H-flip).
Uses grit.exe for tile encoding to match the proven zombie sprite pipeline.
Frame layout:
0: idle (top-left) -- holding shotgun
1: fire1 (top-middle) -- small muzzle flash
2: fire2 (top-right) -- large muzzle flash
3: pump1 (bottom-left) -- pump start
4: pump2 (bottom-middle)-- pump mid
5: pump3 (bottom-right) -- pump end
Output: shotgun_sprites.c, shotgun_sprites.h, shotgun_previews/
"""
import os
import re
import math
import shutil
import subprocess
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SHEET_PATH = os.path.join(SCRIPT_DIR, "shotgun_6_sprites", "shottygun01_2.png")
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "images")
PREVIEW_DIR = os.path.join(SCRIPT_DIR, "shotgun_previews")
GRIT_EXE = os.path.join(SCRIPT_DIR, "grit_conversions", "grit.exe")
GRIT_DIR = os.path.join(SCRIPT_DIR, "grit_conversions")
TILE_SIZE = 8
MAX_WIDTH_TILES = 8 # max 64px wide
MAX_HEIGHT_TILES = 8 # max 64px tall
SHOTGUN_CHAR_START = 120 # Uses the free space after faces (120-543)
MAGENTA_THRESHOLD = 30
BAYER_2x2 = [[0.0, 0.5], [0.75, 0.25]]
DITHER_STRENGTH = 22
VB_PAL = {0: (0, 0, 0, 0), 1: (80, 0, 0, 255), 2: (170, 0, 0, 255),
3: (255, 0, 0, 255)}
FRAME_NAMES = ["idle", "fire1", "fire2", "pump1", "pump2", "pump3"]
GRIT_FLAGS = ['-fh!', '-ftc', '-gB2', '-p!', '-m!']
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 r > 220 and g < MAGENTA_THRESHOLD and b > 220:
return True
return False
def find_sprite_bboxes(img):
"""Find 6 sub-sprite bounding boxes in a 3x2 grid layout."""
w, h = img.size
pix = img.load()
mask = [[False] * w for _ in range(h)]
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):
mask[y][x] = True
row_has_content = [any(mask[y]) for y in range(h)]
bands = []
in_band = False
band_start = 0
for y in range(h):
if row_has_content[y] and not in_band:
band_start = y
in_band = True
elif not row_has_content[y] and in_band:
bands.append((band_start, y - 1))
in_band = False
if in_band:
bands.append((band_start, h - 1))
if len(bands) < 2:
raise ValueError(f"Expected 2 rows of sprites, found {len(bands)} bands")
bboxes = []
for band_top, band_bot in bands[:2]:
col_has_content = [False] * w
for x in range(w):
for y in range(band_top, band_bot + 1):
if mask[y][x]:
col_has_content[x] = True
break
cols = []
in_col = False
col_start = 0
for x in range(w):
if col_has_content[x] and not in_col:
col_start = x
in_col = True
elif not col_has_content[x] and in_col:
cols.append((col_start, x - 1))
in_col = False
if in_col:
cols.append((col_start, w - 1))
if len(cols) < 3:
raise ValueError(f"Expected 3 cols in row, found {len(cols)}")
for col_left, col_right in cols[:3]:
min_y = band_bot + 1
max_y = band_top - 1
for y in range(band_top, band_bot + 1):
for x in range(col_left, col_right + 1):
if mask[y][x]:
min_y = min(min_y, y)
max_y = max(max_y, y)
break
bboxes.append((col_left, min_y, col_right, max_y))
return bboxes
def convert_frame(img, bbox, max_w, max_h):
"""Crop, scale, dither, quantize a single frame. Returns (canvas, cols, rows)."""
left, top, right, bottom = bbox
crop = img.crop((left, top, right + 1, bottom + 1))
cw, ch = crop.size
scale = min(max_w / cw, max_h / ch)
nw = max(1, int(cw * scale))
nh = max(1, int(ch * scale))
resized = crop.resize((nw, nh), Image.LANCZOS)
cols = (nw + TILE_SIZE - 1) // TILE_SIZE
rows = (nh + TILE_SIZE - 1) // TILE_SIZE
canvas_w = cols * TILE_SIZE
canvas_h = rows * TILE_SIZE
gray = [[0.0] * canvas_w for _ in range(canvas_h)]
alpha = [[0] * canvas_w for _ in range(canvas_h)]
off_x = (canvas_w - nw) // 2
off_y = canvas_h - nh # bottom-align
rpix = resized.load()
for y in range(nh):
for x in range(nw):
r, g, b, a = rpix[x, y]
dx, dy = off_x + x, off_y + y
if dx < canvas_w and dy < canvas_h:
if not is_bg(r, g, b, a):
gray[dy][dx] = float(luminance(r, g, b))
alpha[dy][dx] = 1
# Contrast stretch
values = [gray[y][x] for y in range(canvas_h)
for x in range(canvas_w) if alpha[y][x]]
if len(values) > 10:
values.sort()
lo = values[len(values) * 3 // 100]
hi = values[len(values) * 97 // 100]
if hi - lo < 20:
hi = lo + 20
for y in range(canvas_h):
for x in range(canvas_w):
if alpha[y][x]:
v = (gray[y][x] - lo) / (hi - lo) * 255.0
gray[y][x] = max(0.0, min(255.0, v))
# Quantize with Bayer dithering
canvas = [[0] * canvas_w for _ in range(canvas_h)]
for y in range(canvas_h):
for x in range(canvas_w):
if not alpha[y][x]:
continue
g = gray[y][x]
g += BAYER_2x2[y % 2][x % 2] * DITHER_STRENGTH - DITHER_STRENGTH * 0.375
if g < 72:
canvas[y][x] = 1
elif g < 158:
canvas[y][x] = 2
else:
canvas[y][x] = 3
return canvas, cols, rows
def generate_black_canvas(canvas):
"""Generate a black-layer canvas from the red canvas.
The black canvas contains the FULL weapon body (all pixel indices
preserved) PLUS a 2-pixel dilated dark border around the weapon
silhouette. This matches the pistol's approach:
When rendered with GPLT2 (0x84):
idx 0 -> transparent
idx 1 -> dark (visible as black)
idx 2 -> transparent (medium pixels disappear!)
idx 3 -> medium (bright pixels become dimmer)
Interior tiles will be identical to red tiles and deduplicate to
zero additional cost. Only edge tiles with the dilated border add
to the tile count.
"""
h = len(canvas)
w = len(canvas[0]) if h > 0 else 0
black = [[0] * w for _ in range(h)]
# Copy full weapon body
for y in range(h):
for x in range(w):
black[y][x] = canvas[y][x]
# Add 1-pixel dilated dark border (8-directional)
for y in range(h):
for x in range(w):
if canvas[y][x] != 0:
continue # only fill transparent pixels
# Check 8 neighbors for any non-transparent pixel
has_neighbor = False
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dy == 0 and dx == 0:
continue
ny, nx = y + dy, x + dx
if 0 <= ny < h and 0 <= nx < w and canvas[ny][nx] != 0:
has_neighbor = True
break
if has_neighbor:
break
if has_neighbor:
black[y][x] = 1 # dark outline pixel
return black
def save_indexed_png(canvas, path):
"""Save a canvas as indexed 4-color PNG for grit."""
h = len(canvas)
w = len(canvas[0]) if h > 0 else 0
img = Image.new('P', (w, h))
palette = [0, 0, 0, 85, 85, 85, 170, 170, 170, 255, 255, 255]
palette += [0] * (768 - len(palette))
img.putpalette(palette)
pixels = img.load()
for y in range(h):
for x in range(w):
pixels[x, y] = canvas[y][x]
img.save(path)
def run_grit(png_path):
"""Run grit on a PNG, return .c filepath (in GRIT_DIR)."""
basename = os.path.splitext(os.path.basename(png_path))[0]
cmd = [GRIT_EXE, png_path] + GRIT_FLAGS
result = subprocess.run(cmd, cwd=GRIT_DIR, capture_output=True, text=True)
if result.returncode != 0:
print(f" ERROR: grit failed for {basename}: {result.stderr}")
return None
c_file = os.path.join(GRIT_DIR, f"{basename}.c")
if not os.path.exists(c_file):
print(f" ERROR: grit output not found: {c_file}")
return None
# Clean up any .h
h_file = os.path.join(GRIT_DIR, f"{basename}.h")
if os.path.exists(h_file):
os.remove(h_file)
return c_file
def parse_grit_tiles(c_path):
"""Parse grit .c output, return list of tiles (each tile = 4 u32 words)."""
with open(c_path, 'r') as f:
content = f.read()
m = re.search(r'const unsigned int \w+Tiles\[\d+\].*?=\s*\{(.*?)\};',
content, re.DOTALL)
if not m:
return []
words = [int(x, 16) for x in re.findall(r'0x[0-9A-Fa-f]+', m.group(1))]
tiles = []
for i in range(0, len(words), 4):
tiles.append(tuple(words[i:i+4]))
return tiles
def hflip_tile_words(words):
"""H-flip a VB 2bpp tile (sequential format) given as 4 u32 words.
VB 2bpp tiles use sequential packing: each row is a u16 (LE) where
bits[15:14]=px0, bits[13:12]=px1, ..., bits[1:0]=px7.
H-flip reverses pixel order within each row.
Returns tuple of 4 words.
"""
data = []
for w in words:
data.append(w & 0xFF)
data.append((w >> 8) & 0xFF)
data.append((w >> 16) & 0xFF)
data.append((w >> 24) & 0xFF)
flipped = []
for row in range(8):
byte0 = data[row * 2] # low byte of u16 (pixels 4-7)
byte1 = data[row * 2 + 1] # high byte of u16 (pixels 0-3)
u16_val = (byte1 << 8) | byte0
# Extract 8 pixels (px0 at LSB), reverse them, repack
pixels = [(u16_val >> (x * 2)) & 3 for x in range(8)]
pixels.reverse()
new_u16 = 0
for x in range(8):
new_u16 |= (pixels[x] << (x * 2))
flipped.append(new_u16 & 0xFF)
flipped.append((new_u16 >> 8) & 0xFF)
result = []
for i in range(0, 16, 4):
w = (flipped[i] | (flipped[i+1] << 8) |
(flipped[i+2] << 16) | (flipped[i+3] << 24))
result.append(w)
return tuple(result)
def save_preview(canvas, path, scale=3):
os.makedirs(os.path.dirname(path), exist_ok=True)
h = len(canvas)
w = len(canvas[0]) if h > 0 else 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):
c = VB_PAL[canvas[y][x]]
for sy in range(scale):
for sx in range(scale):
pix[x * scale + sx, y * scale + sy] = c
img.save(path)
def deduplicate_tiles(tile_list, unique_tiles, unique_tile_data):
"""Deduplicate a list of tile word tuples against the shared pool.
Returns a list of (char_idx | flags) map entries.
Modifies unique_tiles and unique_tile_data in-place.
"""
frame_map = []
for tile_words in tile_list:
flags = 0
if tile_words in unique_tiles:
idx = unique_tiles[tile_words]
else:
hflipped = hflip_tile_words(tile_words)
if hflipped in unique_tiles:
idx = unique_tiles[hflipped]
flags = 0x2000 # H-flip
else:
idx = len(unique_tile_data)
unique_tiles[tile_words] = idx
unique_tile_data.append(tile_words)
char_idx = SHOTGUN_CHAR_START + idx
frame_map.append(char_idx | flags)
return frame_map
def main():
os.makedirs(OUTPUT_DIR, exist_ok=True)
os.makedirs(PREVIEW_DIR, exist_ok=True)
if not os.path.exists(GRIT_EXE):
print(f"ERROR: grit.exe not found at {GRIT_EXE}")
return
img = Image.open(SHEET_PATH).convert('RGBA')
w, h = img.size
print(f"Loaded {SHEET_PATH}: {w}x{h}")
bboxes = find_sprite_bboxes(img)
print(f"Found {len(bboxes)} sub-sprites")
max_w = MAX_WIDTH_TILES * TILE_SIZE
max_h = MAX_HEIGHT_TILES * TILE_SIZE
# Temp dir for grit input PNGs
grit_input_dir = os.path.join(PREVIEW_DIR, "grit_input")
os.makedirs(grit_input_dir, exist_ok=True)
# Phase 1: process frames, generate red + black canvases, run grit
red_grit_tiles = [] # list of lists of tile tuples (red layer)
blk_grit_tiles = [] # list of lists of tile tuples (black layer)
frame_dims = [] # list of (cols, rows) -- same for red and black
for fi in range(6):
bbox = bboxes[fi]
bw = bbox[2] - bbox[0] + 1
bh = bbox[3] - bbox[1] + 1
canvas, cols, rows = convert_frame(img, bbox, max_w, max_h)
# Generate black layer: full weapon body + 2px dilated border
black = generate_black_canvas(canvas)
# Save previews
save_preview(canvas, os.path.join(PREVIEW_DIR,
f"shotgun_{FRAME_NAMES[fi]}_red.png"))
save_preview(black, os.path.join(PREVIEW_DIR,
f"shotgun_{FRAME_NAMES[fi]}_blk.png"))
print(f" Frame {fi} ({FRAME_NAMES[fi]}): {bw}x{bh} -> "
f"{cols}x{rows} tiles ({cols*8}x{rows*8} px)")
# Save indexed PNGs for grit (red + black)
red_png = os.path.join(grit_input_dir, f"shotgun_f{fi}_red.png")
blk_png = os.path.join(grit_input_dir, f"shotgun_f{fi}_blk.png")
save_indexed_png(canvas, red_png)
save_indexed_png(black, blk_png)
# Run grit on red layer
red_c = run_grit(red_png)
if red_c is None:
print(f" FATAL: grit failed for red frame {fi}")
return
red_tiles = parse_grit_tiles(red_c)
os.remove(red_c)
# Run grit on black layer
blk_c = run_grit(blk_png)
if blk_c is None:
print(f" FATAL: grit failed for black frame {fi}")
return
blk_tiles = parse_grit_tiles(blk_c)
os.remove(blk_c)
expected = cols * rows
if len(red_tiles) != expected:
print(f" WARNING: expected {expected} red tiles, got {len(red_tiles)}")
if len(blk_tiles) != expected:
print(f" WARNING: expected {expected} blk tiles, got {len(blk_tiles)}")
red_grit_tiles.append(red_tiles)
blk_grit_tiles.append(blk_tiles)
frame_dims.append((cols, rows))
# Phase 2: deduplicate ALL tiles (red + black) into one shared pool
unique_tiles = {} # tile_words_tuple -> index
unique_tile_data = [] # list of tile word tuples
red_frame_maps = [] # list of (cols, rows, map_entries)
blk_frame_maps = [] # list of (cols, rows, map_entries)
for fi in range(6):
cols, rows = frame_dims[fi]
# Red layer
red_map = deduplicate_tiles(red_grit_tiles[fi],
unique_tiles, unique_tile_data)
red_frame_maps.append((cols, rows, red_map))
# Black layer (same tile grid dimensions)
blk_map = deduplicate_tiles(blk_grit_tiles[fi],
unique_tiles, unique_tile_data)
blk_frame_maps.append((cols, rows, blk_map))
num_tiles = len(unique_tile_data)
print(f"\nTotal unique shotgun tiles (red+black): {num_tiles}")
print(f"Tile data: {num_tiles * 16} bytes ({num_tiles * 4} u32 words)")
if num_tiles > 424:
print(f"WARNING: {num_tiles} tiles exceeds shotgun slot limit of 424!")
# --- Write C file ---
c_path = os.path.join(OUTPUT_DIR, "shotgun_sprites.c")
with open(c_path, 'w') as f:
f.write("/* Shotgun weapon sprite tiles (red+black layers) -- "
"auto-generated by prepare_shotgun_sprites.py + grit */\n\n")
all_u32 = []
for td in unique_tile_data:
all_u32.extend(td)
f.write(f"/* {num_tiles} unique shotgun tiles (red+black shared) */\n")
f.write(f"const unsigned int shotgunTiles[{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")
# Red frame maps
for fi in range(6):
cols, rows, fmap = red_frame_maps[fi]
entries = cols * rows
f.write(f"/* Red frame {fi} ({FRAME_NAMES[fi]}): "
f"{cols} cols x {rows} rows */\n")
f.write(f"const unsigned short shotgunFrame{fi}Map[{entries}]"
f" __attribute__((aligned(4))) =\n{{\n")
for r in range(rows):
chunk = fmap[r * cols:(r + 1) * cols]
s = ",".join(f"0x{v:04X}" for v in chunk)
comma = "," if r < rows - 1 else ""
f.write(f"\t{s}{comma}\n")
f.write("};\n\n")
# Black frame maps
for fi in range(6):
cols, rows, fmap = blk_frame_maps[fi]
entries = cols * rows
f.write(f"/* Black frame {fi} ({FRAME_NAMES[fi]}): "
f"{cols} cols x {rows} rows */\n")
f.write(f"const unsigned short shotgunBlkFrame{fi}Map[{entries}]"
f" __attribute__((aligned(4))) =\n{{\n")
for r in range(rows):
chunk = fmap[r * cols:(r + 1) * cols]
s = ",".join(f"0x{v:04X}" for v in chunk)
comma = "," if r < rows - 1 else ""
f.write(f"\t{s}{comma}\n")
f.write("};\n\n")
print(f"\nWrote {c_path}")
# --- Write H file ---
h_path = os.path.join(OUTPUT_DIR, "shotgun_sprites.h")
with open(h_path, 'w') as f:
f.write("#ifndef __SHOTGUN_SPRITES_H__\n"
"#define __SHOTGUN_SPRITES_H__\n\n")
f.write(f"#define SHOTGUN_CHAR_START {SHOTGUN_CHAR_START}\n")
f.write(f"#define SHOTGUN_TILE_COUNT {num_tiles}\n")
f.write(f"#define SHOTGUN_TILE_BYTES {num_tiles * 16}\n\n")
f.write(f"extern const unsigned int shotgunTiles[{len(all_u32)}];\n\n")
f.write("/* Red layer frames */\n")
for fi in range(6):
cols, rows, fmap = red_frame_maps[fi]
entries = cols * rows
f.write(f"#define SHOTGUN_F{fi}_XTILES {cols * 2} "
f"/* {cols} cols x 2 bytes */\n")
f.write(f"#define SHOTGUN_F{fi}_ROWS {rows}\n")
f.write(f"extern const unsigned short "
f"shotgunFrame{fi}Map[{entries}];\n\n")
f.write("/* Black layer frames */\n")
for fi in range(6):
cols, rows, fmap = blk_frame_maps[fi]
entries = cols * rows
f.write(f"#define SHOTGUN_BLK_F{fi}_XTILES {cols * 2} "
f"/* {cols} cols x 2 bytes */\n")
f.write(f"#define SHOTGUN_BLK_F{fi}_ROWS {rows}\n")
f.write(f"extern const unsigned short "
f"shotgunBlkFrame{fi}Map[{entries}];\n\n")
f.write("#endif\n")
print(f"Wrote {h_path}")
if __name__ == "__main__":
main()