initial commit!
This commit is contained in:
337
graphics/prepare_commando_sprites.py
Normal file
337
graphics/prepare_commando_sprites.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
prepare_commando_sprites.py
|
||||
|
||||
Convert ZombieCommando (Doom chaingunner CPOS) frames to VB tile format.
|
||||
Source: make_into_4_colors_3/ZombieCommando/ (69 PNGs, Doom CPOS naming)
|
||||
|
||||
Frame layout (standard Doom CPOS):
|
||||
A-D (1-8): 4 walk frames x 8 directions = 32 frames
|
||||
E-F (1-8): 2 attack frames x 8 directions = 16 frames
|
||||
G (1-8): 1 pain frame x 8 directions = 8 frames
|
||||
H-T (0): 13 death frames (direction-independent)
|
||||
|
||||
Pipeline:
|
||||
1. Convert each full-color PNG to VB 4-color palette (luminance-based)
|
||||
2. Pad to 8x8 tile-aligned 64x64 (8x8 tiles = 64 chars per frame)
|
||||
3. Run grit.exe on each frame
|
||||
4. Generate commando_sprites.h with frame pointer table
|
||||
|
||||
Output: src/vbdoom/assets/images/sprites/commando/
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import re
|
||||
from PIL import Image
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SOURCE_DIR = os.path.join(SCRIPT_DIR, "make_into_4_colors_3", "ZombieCommando")
|
||||
PROJECT_ROOT = SCRIPT_DIR
|
||||
GRIT_EXE = os.path.join(PROJECT_ROOT, 'grit_conversions', 'grit.exe')
|
||||
GRIT_DIR = os.path.join(PROJECT_ROOT, 'grit_conversions')
|
||||
SPRITES_BASE = os.path.join(PROJECT_ROOT, 'src', 'vbdoom', 'assets', 'images', 'sprites')
|
||||
OUTPUT_DIR = os.path.join(SPRITES_BASE, 'commando')
|
||||
|
||||
GRIT_FLAGS = ['-fh!', '-ftc', '-gB2', '-p!', '-m!']
|
||||
|
||||
# VB Palette (indices 1-3 for sprite pixels, 0 for transparent)
|
||||
VB_PALETTE = [
|
||||
(0, 0, 0), # 0: Black (transparent)
|
||||
(85, 0, 0), # 1: Dark red
|
||||
(164, 0, 0), # 2: Medium red
|
||||
(239, 0, 0), # 3: Bright red
|
||||
]
|
||||
|
||||
TILE_SIZE = 8
|
||||
TARGET_SIZE = 64 # 8x8 tiles
|
||||
|
||||
# Doom CPOS frame order: A-G directional, H-T rotation-independent
|
||||
FRAME_ORDER = []
|
||||
# Walk: A-D, directions 1-8
|
||||
for letter in ['A', 'B', 'C', 'D']:
|
||||
for d in range(1, 9):
|
||||
FRAME_ORDER.append(f"CPOS{letter}{d}.png")
|
||||
# Attack: E-F, directions 1-8
|
||||
for letter in ['E', 'F']:
|
||||
for d in range(1, 9):
|
||||
FRAME_ORDER.append(f"CPOS{letter}{d}.png")
|
||||
# Pain: G, directions 1-8
|
||||
for d in range(1, 9):
|
||||
FRAME_ORDER.append(f"CPOSG{d}.png")
|
||||
# Death: H-T, rotation 0
|
||||
for letter in "HIJKLMNOPQRST":
|
||||
FRAME_ORDER.append(f"CPOS{letter}0.png")
|
||||
|
||||
|
||||
def luminance(r, g, b):
|
||||
return int(0.299 * r + 0.587 * g + 0.114 * b)
|
||||
|
||||
|
||||
def is_magenta(r, g, b):
|
||||
return (r > 220 and g < 30 and b > 220)
|
||||
|
||||
|
||||
def map_to_vb_color(gray):
|
||||
if gray < 85:
|
||||
return 1
|
||||
elif gray < 170:
|
||||
return 2
|
||||
else:
|
||||
return 3
|
||||
|
||||
|
||||
def enhance_contrast(img):
|
||||
"""Adaptive contrast enhancement for sprite pixels."""
|
||||
pixels = []
|
||||
for y in range(img.height):
|
||||
for x in range(img.width):
|
||||
r, g, b = img.getpixel((x, y))[:3]
|
||||
if not is_magenta(r, g, b):
|
||||
gray = luminance(r, g, b)
|
||||
if gray > 5:
|
||||
pixels.append(gray)
|
||||
if not pixels:
|
||||
return img
|
||||
pixels.sort()
|
||||
lo = pixels[int(len(pixels) * 0.02)]
|
||||
hi = pixels[int(len(pixels) * 0.98)]
|
||||
if hi <= lo:
|
||||
return img
|
||||
out = img.copy()
|
||||
for y in range(img.height):
|
||||
for x in range(img.width):
|
||||
r, g, b = img.getpixel((x, y))[:3]
|
||||
if not is_magenta(r, g, b):
|
||||
gray = luminance(r, g, b)
|
||||
if gray <= 5:
|
||||
stretched = gray
|
||||
else:
|
||||
stretched = int(255.0 * (gray - lo) / (hi - lo))
|
||||
stretched = max(0, min(255, stretched))
|
||||
out.putpixel((x, y), (stretched, stretched, stretched))
|
||||
return out
|
||||
|
||||
|
||||
def convert_frame_to_vb(input_path, output_path):
|
||||
"""Convert a single frame PNG to VB 4-color red palette, padded to 64x64."""
|
||||
raw = Image.open(input_path)
|
||||
# Convert to RGBA first to capture palette transparency / alpha
|
||||
img_rgba = raw.convert('RGBA')
|
||||
img = img_rgba.convert('RGB')
|
||||
|
||||
# Build alpha mask from RGBA (alpha == 0 means transparent)
|
||||
alpha_data = img_rgba.split()[3] # A channel
|
||||
|
||||
# Enhance contrast (works on RGB)
|
||||
enhanced = enhance_contrast(img)
|
||||
|
||||
w, h = img.size
|
||||
|
||||
# Find bounding box of non-background content
|
||||
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):
|
||||
a = alpha_data.getpixel((x, y))
|
||||
if a == 0:
|
||||
continue # transparent pixel
|
||||
r, g, b = img.getpixel((x, y))
|
||||
if is_magenta(r, g, b) or (r < 5 and g < 5 and b < 5):
|
||||
continue # background 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:
|
||||
# Empty frame - just make a transparent 64x64
|
||||
out = Image.new('RGB', (TARGET_SIZE, TARGET_SIZE), VB_PALETTE[0])
|
||||
out.save(output_path)
|
||||
return
|
||||
|
||||
# Crop to content
|
||||
cw = max_x - min_x + 1
|
||||
ch = max_y - min_y + 1
|
||||
|
||||
# Scale to fit TARGET_SIZE while preserving aspect ratio
|
||||
scale = min(TARGET_SIZE / cw, TARGET_SIZE / ch)
|
||||
if scale > 1.0:
|
||||
scale = 1.0 # Don't upscale
|
||||
nw = max(1, int(cw * scale))
|
||||
nh = max(1, int(ch * scale))
|
||||
|
||||
# Crop and resize (both RGB and alpha)
|
||||
cropped = img.crop((min_x, min_y, max_x + 1, max_y + 1))
|
||||
enhanced_crop = enhanced.crop((min_x, min_y, max_x + 1, max_y + 1))
|
||||
alpha_crop = alpha_data.crop((min_x, min_y, max_x + 1, max_y + 1))
|
||||
if scale < 1.0:
|
||||
cropped = cropped.resize((nw, nh), Image.LANCZOS)
|
||||
enhanced_crop = enhanced_crop.resize((nw, nh), Image.LANCZOS)
|
||||
alpha_crop = alpha_crop.resize((nw, nh), Image.LANCZOS)
|
||||
|
||||
# Create target canvas, bottom-center aligned (feet on ground)
|
||||
out = Image.new('RGB', (TARGET_SIZE, TARGET_SIZE), VB_PALETTE[0])
|
||||
off_x = (TARGET_SIZE - nw) // 2
|
||||
off_y = TARGET_SIZE - nh # bottom-aligned
|
||||
|
||||
for y in range(nh):
|
||||
for x in range(nw):
|
||||
a = alpha_crop.getpixel((x, y))
|
||||
if a < 128:
|
||||
continue # transparent (threshold for resized alpha)
|
||||
r, g, b = cropped.getpixel((x, y))
|
||||
if is_magenta(r, g, b):
|
||||
continue # magenta background
|
||||
er = enhanced_crop.getpixel((x, y))[0]
|
||||
idx = map_to_vb_color(er)
|
||||
out.putpixel((off_x + x, off_y + y), VB_PALETTE[idx])
|
||||
|
||||
out.save(output_path)
|
||||
|
||||
|
||||
def run_grit(png_path, output_name):
|
||||
"""Run grit on a single PNG file."""
|
||||
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 running grit: {result.stderr}")
|
||||
return None
|
||||
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):
|
||||
print(f" ERROR: Expected output not found: {c_file}")
|
||||
return None
|
||||
return c_file
|
||||
|
||||
|
||||
def get_tiles_array_name(c_file_path):
|
||||
"""Extract the Tiles array name and size from a grit .c file."""
|
||||
with open(c_file_path, 'r') as f:
|
||||
content = f.read()
|
||||
match = re.search(r'const unsigned int (\w+Tiles)\[(\d+)\]', content)
|
||||
if match:
|
||||
return match.group(1), int(match.group(2))
|
||||
return None, None
|
||||
|
||||
|
||||
def main():
|
||||
print("=== ZombieCommando Sprite Converter ===\n")
|
||||
|
||||
if not os.path.exists(SOURCE_DIR):
|
||||
print(f"ERROR: Source dir not found: {SOURCE_DIR}")
|
||||
return
|
||||
if not os.path.exists(GRIT_EXE):
|
||||
print(f"ERROR: grit.exe not found: {GRIT_EXE}")
|
||||
return
|
||||
|
||||
# Verify all expected frames exist
|
||||
missing = [f for f in FRAME_ORDER if not os.path.exists(os.path.join(SOURCE_DIR, f))]
|
||||
if missing:
|
||||
print(f"WARNING: Missing {len(missing)} frames: {missing[:5]}...")
|
||||
|
||||
# Filter to only existing frames
|
||||
frame_files = [f for f in FRAME_ORDER if os.path.exists(os.path.join(SOURCE_DIR, f))]
|
||||
print(f"Found {len(frame_files)} frames to process")
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
temp_dir = os.path.join(GRIT_DIR, "_commando_temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
tile_arrays = []
|
||||
tiles_per_frame = None
|
||||
|
||||
for i, frame_file in enumerate(frame_files):
|
||||
src_path = os.path.join(SOURCE_DIR, frame_file)
|
||||
|
||||
# Convert to VB palette and pad to 64x64
|
||||
vb_path = os.path.join(temp_dir, f"commando_{i:03d}.png")
|
||||
convert_frame_to_vb(src_path, vb_path)
|
||||
|
||||
# Run grit
|
||||
c_file = run_grit(vb_path, f"commando_{i:03d}")
|
||||
if c_file is None:
|
||||
continue
|
||||
|
||||
array_name, array_size = get_tiles_array_name(c_file)
|
||||
if array_name is None:
|
||||
print(f" WARNING: Could not parse array from {c_file}")
|
||||
continue
|
||||
|
||||
if tiles_per_frame is None:
|
||||
tiles_per_frame = array_size
|
||||
|
||||
tile_arrays.append((array_name, array_size, i))
|
||||
|
||||
# Move .c file to output directory
|
||||
dest_c = os.path.join(OUTPUT_DIR, f"Commando_{i:03d}.c")
|
||||
shutil.move(c_file, dest_c)
|
||||
|
||||
if (i + 1) % 10 == 0 or i == len(frame_files) - 1:
|
||||
print(f" Converted {i + 1}/{len(frame_files)} frames...")
|
||||
|
||||
# Copy source PNGs for reference
|
||||
png_dir = os.path.join(OUTPUT_DIR, 'png')
|
||||
os.makedirs(png_dir, exist_ok=True)
|
||||
for f in frame_files:
|
||||
shutil.copy2(os.path.join(SOURCE_DIR, f), os.path.join(png_dir, f))
|
||||
|
||||
# Clean up temp
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
# Generate header
|
||||
generate_header(tile_arrays, tiles_per_frame)
|
||||
print(f"\nDone! {len(tile_arrays)} frames converted.")
|
||||
|
||||
|
||||
def generate_header(tile_arrays, tiles_per_frame):
|
||||
"""Generate commando_sprites.h with frame pointer table."""
|
||||
header_path = os.path.join(OUTPUT_DIR, "commando_sprites.h")
|
||||
guard = "__COMMANDO_SPRITES_H__"
|
||||
bytes_per_frame = tiles_per_frame * 4 if tiles_per_frame else 0
|
||||
|
||||
lines = []
|
||||
lines.append(f"#ifndef {guard}")
|
||||
lines.append(f"#define {guard}")
|
||||
lines.append(f"")
|
||||
lines.append(f"/*")
|
||||
lines.append(f" * ZombieCommando (Chaingunner) sprite frames")
|
||||
lines.append(f" * Auto-generated by prepare_commando_sprites.py")
|
||||
lines.append(f" *")
|
||||
lines.append(f" * Total frames: {len(tile_arrays)}")
|
||||
lines.append(f" * Frame size: 64x64 px (8x8 tiles)")
|
||||
lines.append(f" * Tiles per frame: {tiles_per_frame} u32 words = {bytes_per_frame} bytes")
|
||||
lines.append(f" *")
|
||||
lines.append(f" * Frame layout:")
|
||||
lines.append(f" * 0-31: Walk (A-D, 8 dirs each)")
|
||||
lines.append(f" * 32-47: Attack (E-F, 8 dirs each)")
|
||||
lines.append(f" * 48-55: Pain (G, 8 dirs)")
|
||||
lines.append(f" * 56-68: Death (H-T, rotation-independent)")
|
||||
lines.append(f" */")
|
||||
lines.append(f"")
|
||||
|
||||
for array_name, array_size, idx in tile_arrays:
|
||||
lines.append(f"extern const unsigned int {array_name}[{array_size}];")
|
||||
|
||||
lines.append(f"")
|
||||
lines.append(f"#define COMMANDO_FRAME_COUNT {len(tile_arrays)}")
|
||||
lines.append(f"#define COMMANDO_FRAME_BYTES {bytes_per_frame}")
|
||||
lines.append(f"")
|
||||
|
||||
lines.append(f"static const unsigned int* const COMMANDO_FRAMES[{len(tile_arrays)}] = {{")
|
||||
for i, (array_name, array_size, idx) in enumerate(tile_arrays):
|
||||
comma = "," if i < len(tile_arrays) - 1 else ""
|
||||
lines.append(f" {array_name}{comma}")
|
||||
lines.append(f"}};")
|
||||
lines.append(f"")
|
||||
lines.append(f"#endif /* {guard} */")
|
||||
lines.append(f"")
|
||||
|
||||
with open(header_path, 'w') as f:
|
||||
f.write('\n'.join(lines))
|
||||
print(f" Header generated: commando_sprites.h")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user