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,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()