initial commit!
This commit is contained in:
363
graphics/prepare_chaingun_sprites.py
Normal file
363
graphics/prepare_chaingun_sprites.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
prepare_chaingun_sprites.py
|
||||
|
||||
Convert chaingun first-person sprite strip to VB tile format using grit.exe.
|
||||
Source: new_weapons/chaingun[idle,shootframe1,shootframe2].png
|
||||
|
||||
Generates DUAL-LAYER sprites (red + black), matching the shotgun/rocket approach:
|
||||
- Red layer: the weapon body (normal palette GPLT0)
|
||||
- Black layer: 1-pixel outline around the weapon (GPLT2 palette)
|
||||
|
||||
Output: src/vbdoom/assets/images/sprites/chaingun/chaingun_sprites.c/.h
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
from PIL import Image
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STRIP_PATH = os.path.join(SCRIPT_DIR, "new_weapons",
|
||||
"chaingun[idle,shootframe1,shootframe2].png")
|
||||
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "images",
|
||||
"sprites", "chaingun")
|
||||
GRIT_EXE = os.path.join(SCRIPT_DIR, "grit_conversions", "grit.exe")
|
||||
GRIT_DIR = os.path.join(SCRIPT_DIR, "grit_conversions")
|
||||
|
||||
TILE_SIZE = 8
|
||||
CHAINGUN_CHAR_START = 120 # Same shared region as shotgun/rocket (only one loaded at a time)
|
||||
|
||||
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", "shootframe1", "shootframe2"]
|
||||
GRIT_FLAGS = ['-fh!', '-ftc', '-gB2', '-p!', '-m!']
|
||||
|
||||
NUM_FRAMES = 3
|
||||
|
||||
|
||||
def luminance(r, g, b):
|
||||
return int(0.299 * r + 0.587 * g + 0.114 * b)
|
||||
|
||||
|
||||
def is_bg(r, g, b, a, bg_color=(128, 0, 128, 255)):
|
||||
if a < 128:
|
||||
return True
|
||||
br, bg_g, bb = bg_color[0], bg_color[1], bg_color[2]
|
||||
if abs(r - br) < 15 and abs(g - bg_g) < 15 and abs(b - bb) < 15:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_strip_bboxes(img, num_frames, bg_color):
|
||||
"""Find num_frames sub-sprite bounding boxes in a horizontal strip."""
|
||||
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, bg_color):
|
||||
mask[y][x] = True
|
||||
|
||||
col_has_content = [any(mask[y][x] for y in range(h)) for x in range(w)]
|
||||
|
||||
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) == num_frames:
|
||||
bboxes = []
|
||||
for col_left, col_right in cols[:num_frames]:
|
||||
min_y = h
|
||||
max_y = 0
|
||||
for y in range(h):
|
||||
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
|
||||
|
||||
# Fall back to tile-aligned split
|
||||
fw = (w // num_frames // TILE_SIZE) * TILE_SIZE
|
||||
if fw < TILE_SIZE:
|
||||
fw = TILE_SIZE
|
||||
return [(i * fw, 0, (i + 1) * fw - 1, h - 1) for i in range(num_frames)]
|
||||
|
||||
|
||||
def split_strip(img, num_frames, bg_color=None):
|
||||
w, h = img.size
|
||||
if bg_color is None:
|
||||
bg_color = img.getpixel((0, 0))
|
||||
bboxes = find_strip_bboxes(img, num_frames, bg_color)
|
||||
frames = []
|
||||
for left, top, right, bottom in bboxes:
|
||||
frame = img.crop((left, top, right + 1, bottom + 1))
|
||||
frames.append(frame)
|
||||
return frames
|
||||
|
||||
|
||||
def crop_to_content(img, max_w_tiles=8, max_h_tiles=8, bg_color=(128, 0, 128, 255)):
|
||||
pix = img.load()
|
||||
w, h = img.size
|
||||
min_x, min_y, max_x, max_y = w, h, 0, 0
|
||||
has_content = False
|
||||
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, bg_color):
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
has_content = True
|
||||
if not has_content:
|
||||
return Image.new('RGBA', (TILE_SIZE, TILE_SIZE), (0, 0, 0, 0))
|
||||
cw = max_x - min_x + 1
|
||||
ch = max_y - min_y + 1
|
||||
tw = ((cw + TILE_SIZE - 1) // TILE_SIZE) * TILE_SIZE
|
||||
th = ((ch + TILE_SIZE - 1) // TILE_SIZE) * TILE_SIZE
|
||||
tw = min(tw, max_w_tiles * TILE_SIZE)
|
||||
th = min(th, max_h_tiles * TILE_SIZE)
|
||||
ox = (tw - cw) // 2
|
||||
oy = (th - ch) // 2
|
||||
result = Image.new('RGBA', (tw, th), (0, 0, 0, 0))
|
||||
cropped = img.crop((min_x, min_y, max_x + 1, max_y + 1))
|
||||
result.paste(cropped, (ox, oy))
|
||||
return result
|
||||
|
||||
|
||||
def quantize_vb(img, bg_color=(128, 0, 128, 255)):
|
||||
pix = img.load()
|
||||
w, h = img.size
|
||||
out = Image.new('RGBA', (w, h), (0, 0, 0, 0))
|
||||
opix = out.load()
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = pix[x, y]
|
||||
if is_bg(r, g, b, a, bg_color):
|
||||
opix[x, y] = (0, 0, 0, 0)
|
||||
continue
|
||||
lum = luminance(r, g, b)
|
||||
bayer = BAYER_2x2[y % 2][x % 2]
|
||||
dither = (bayer - 0.5) * DITHER_STRENGTH
|
||||
lum_d = max(0, min(255, lum + dither))
|
||||
if lum_d < 42:
|
||||
vb = 1
|
||||
elif lum_d < 128:
|
||||
vb = 2
|
||||
else:
|
||||
vb = 3
|
||||
opix[x, y] = VB_PAL[vb]
|
||||
return out
|
||||
|
||||
|
||||
def make_black_layer(img):
|
||||
pix = img.load()
|
||||
w, h = img.size
|
||||
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 a > 0 and not (r == 0 and g == 0 and b == 0 and a == 0):
|
||||
mask[y][x] = True
|
||||
out = Image.new('RGBA', (w, h), (0, 0, 0, 0))
|
||||
opix = out.load()
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if mask[y][x]:
|
||||
continue
|
||||
for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
|
||||
ny, nx = y + dy, x + dx
|
||||
if 0 <= ny < h and 0 <= nx < w and mask[ny][nx]:
|
||||
opix[x, y] = VB_PAL[1]
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def run_grit(png_path):
|
||||
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" GRIT ERROR: {result.stderr}")
|
||||
return []
|
||||
basename = os.path.splitext(os.path.basename(png_path))[0]
|
||||
c_file = os.path.join(GRIT_DIR, f'{basename}.c')
|
||||
if not os.path.exists(c_file):
|
||||
return []
|
||||
with open(c_file) as f:
|
||||
text = f.read()
|
||||
os.remove(c_file)
|
||||
m = re.search(r'const unsigned int \w+\[\d+\][^=]*=\s*\{([^}]+)\}', text, re.DOTALL)
|
||||
if not m:
|
||||
return []
|
||||
raw = m.group(1).replace('\n', ' ').replace('\t', ' ')
|
||||
vals = [int(x.strip(), 0) for x in raw.split(',') if x.strip()]
|
||||
return vals
|
||||
|
||||
|
||||
def tiles_to_tuples(data, tile_count):
|
||||
tiles = []
|
||||
for i in range(tile_count):
|
||||
t = tuple(data[i*4:(i+1)*4])
|
||||
tiles.append(t)
|
||||
return tiles
|
||||
|
||||
|
||||
def deduplicate_tiles(all_frame_tiles):
|
||||
EMPTY = (0, 0, 0, 0)
|
||||
unique = [EMPTY]
|
||||
tile_map = {EMPTY: 0}
|
||||
frame_maps = []
|
||||
for frame_tiles, tw, th in all_frame_tiles:
|
||||
fmap = []
|
||||
for tile in frame_tiles:
|
||||
if tile not in tile_map:
|
||||
tile_map[tile] = len(unique)
|
||||
unique.append(tile)
|
||||
fmap.append(tile_map[tile])
|
||||
frame_maps.append((fmap, tw, th))
|
||||
return unique, frame_maps
|
||||
|
||||
|
||||
def write_output(unique_tiles, red_maps, blk_maps, frame_sizes):
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
c_path = os.path.join(OUTPUT_DIR, "chaingun_sprites.c")
|
||||
h_path = os.path.join(OUTPUT_DIR, "chaingun_sprites.h")
|
||||
|
||||
tile_count = len(unique_tiles)
|
||||
|
||||
with open(c_path, 'w') as f:
|
||||
f.write("/* Chaingun weapon sprites -- auto-generated */\n")
|
||||
f.write(f"const unsigned int chaingunTiles[{tile_count * 4}] = {{\n")
|
||||
for i, tile in enumerate(unique_tiles):
|
||||
vals = ', '.join(f'0x{v:08X}' for v in tile)
|
||||
comma = ',' if i < tile_count - 1 else ''
|
||||
f.write(f' {vals}{comma}\n')
|
||||
f.write("};\n\n")
|
||||
|
||||
for layer, maps, prefix in [("Red", red_maps, "chaingun"),
|
||||
("Black", blk_maps, "chaingunBlk")]:
|
||||
for fi, (fmap, tw, th) in enumerate(maps):
|
||||
arr_name = f"{prefix}Frame{fi}Map"
|
||||
f.write(f"const unsigned short {arr_name}[{len(fmap)}] = {{\n")
|
||||
for row in range(th):
|
||||
entries = []
|
||||
for col in range(tw):
|
||||
idx = fmap[row * tw + col]
|
||||
char_idx = CHAINGUN_CHAR_START + idx
|
||||
entries.append(f'0x{char_idx:04X}')
|
||||
comma = ',' if row < th - 1 else ''
|
||||
f.write(f' {", ".join(entries)}{comma}\n')
|
||||
f.write("};\n\n")
|
||||
|
||||
with open(h_path, 'w') as f:
|
||||
f.write("#ifndef __CHAINGUN_SPRITES_H__\n")
|
||||
f.write("#define __CHAINGUN_SPRITES_H__\n\n")
|
||||
f.write(f"#define CHAINGUN_CHAR_START {CHAINGUN_CHAR_START}\n")
|
||||
f.write(f"#define CHAINGUN_TILE_COUNT {tile_count}\n")
|
||||
f.write(f"#define CHAINGUN_TILE_BYTES {tile_count * 16}\n\n")
|
||||
f.write(f"extern const unsigned int chaingunTiles[{tile_count * 4}];\n\n")
|
||||
|
||||
f.write("/* Red layer frames */\n")
|
||||
for fi, (fmap, tw, th) in enumerate(red_maps):
|
||||
f.write(f"#define CHAINGUN_F{fi}_XTILES {tw * 2}\n")
|
||||
f.write(f"#define CHAINGUN_F{fi}_ROWS {th}\n")
|
||||
f.write(f"extern const unsigned short chaingunFrame{fi}Map[{len(fmap)}];\n\n")
|
||||
|
||||
f.write("/* Black layer frames */\n")
|
||||
for fi, (fmap, tw, th) in enumerate(blk_maps):
|
||||
f.write(f"#define CHAINGUN_BLK_F{fi}_XTILES {tw * 2}\n")
|
||||
f.write(f"#define CHAINGUN_BLK_F{fi}_ROWS {th}\n")
|
||||
f.write(f"extern const unsigned short chaingunBlkFrame{fi}Map[{len(fmap)}];\n\n")
|
||||
|
||||
f.write("#endif\n")
|
||||
|
||||
print(f"Written: {c_path}")
|
||||
print(f"Written: {h_path}")
|
||||
print(f"Total unique tiles: {tile_count}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=== Chaingun Sprite Converter ===\n")
|
||||
|
||||
if not os.path.exists(STRIP_PATH):
|
||||
print(f"ERROR: Strip not found: {STRIP_PATH}")
|
||||
return
|
||||
if not os.path.exists(GRIT_EXE):
|
||||
print(f"ERROR: grit.exe not found: {GRIT_EXE}")
|
||||
return
|
||||
|
||||
img = Image.open(STRIP_PATH).convert('RGBA')
|
||||
print(f"Strip: {img.size[0]}x{img.size[1]}")
|
||||
|
||||
bg_color = img.getpixel((0, 0))
|
||||
print(f"Background color (top-left pixel): RGBA{bg_color}")
|
||||
|
||||
frames = split_strip(img, NUM_FRAMES, bg_color)
|
||||
print(f"Split into {len(frames)} frames")
|
||||
|
||||
all_red_tiles = []
|
||||
all_blk_tiles = []
|
||||
frame_sizes = []
|
||||
temp_dir = os.path.join(GRIT_DIR, "_chaingun_temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
for fi, frame in enumerate(frames):
|
||||
print(f"\nFrame {fi} ({FRAME_NAMES[fi]}):")
|
||||
cropped = crop_to_content(frame, bg_color=bg_color)
|
||||
tw = cropped.size[0] // TILE_SIZE
|
||||
th = cropped.size[1] // TILE_SIZE
|
||||
print(f" Content: {cropped.size[0]}x{cropped.size[1]} ({tw}x{th} tiles)")
|
||||
frame_sizes.append((tw, th))
|
||||
|
||||
quantized = quantize_vb(cropped, bg_color=bg_color)
|
||||
black = make_black_layer(quantized)
|
||||
|
||||
red_path = os.path.join(temp_dir, f"chaingun_red_{fi}.png")
|
||||
blk_path = os.path.join(temp_dir, f"chaingun_blk_{fi}.png")
|
||||
quantized.save(red_path)
|
||||
black.save(blk_path)
|
||||
|
||||
red_data = run_grit(red_path)
|
||||
blk_data = run_grit(blk_path)
|
||||
|
||||
red_tiles = tiles_to_tuples(red_data, tw * th)
|
||||
blk_tiles = tiles_to_tuples(blk_data, tw * th)
|
||||
|
||||
all_red_tiles.append((red_tiles, tw, th))
|
||||
all_blk_tiles.append((blk_tiles, tw, th))
|
||||
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
combined = all_red_tiles + all_blk_tiles
|
||||
unique_tiles, all_maps = deduplicate_tiles(combined)
|
||||
|
||||
red_maps = all_maps[:NUM_FRAMES]
|
||||
blk_maps = all_maps[NUM_FRAMES:]
|
||||
|
||||
print(f"\nDeduplication: {sum(tw*th for _, tw, th in combined)} total -> {len(unique_tiles)} unique tiles")
|
||||
|
||||
write_output(unique_tiles, red_maps, blk_maps, frame_sizes)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user