703 lines
24 KiB
Python
703 lines
24 KiB
Python
"""
|
|
prepare_doom_faces.py
|
|
|
|
Convert Doom face sprites to VB 2bpp tile format using grit.exe,
|
|
matching the proven zombie sprite pipeline exactly.
|
|
|
|
Face layout:
|
|
- Faces 0-2: hand-crafted originals from vb_doom.c (idle full-health left/center/right)
|
|
- Faces 3-14: DOOM faces from doom_faces.png rows 1-4 (idle, health brackets 1-4)
|
|
- Faces 15-19: DOOM ouch front (one per damage level)
|
|
- Faces 20-24: DOOM severe ouch
|
|
- Faces 25-29: DOOM evil grin
|
|
- Face 30: dead
|
|
- Face 31: god mode
|
|
|
|
Ouch-left and ouch-right are removed (too large, won't downscale well).
|
|
|
|
Output (individual .c files + header, matching zombie_sprites.h pattern):
|
|
- src/vbdoom/assets/images/sprites/faces/face_XX.c (grit-generated tile data)
|
|
- src/vbdoom/assets/images/sprites/faces/face_sprites.h (extern decls + static ptr table)
|
|
- face_previews/ (VB-palette preview PNGs)
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import math
|
|
import shutil
|
|
import subprocess
|
|
from PIL import Image
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
INPUT_IMG = os.path.join(SCRIPT_DIR, "more_doom_gfx", "doom_faces.png")
|
|
VB_DOOM_C = os.path.join(SCRIPT_DIR, "grit_conversions", "vb_doom.c")
|
|
GRIT_EXE = os.path.join(SCRIPT_DIR, "grit_conversions", "grit.exe")
|
|
GRIT_DIR = os.path.join(SCRIPT_DIR, "grit_conversions")
|
|
FACES_DIR = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "images",
|
|
"sprites", "faces")
|
|
PREVIEW_DIR = os.path.join(SCRIPT_DIR, "face_previews")
|
|
|
|
FACE_CHAR_START = 108
|
|
TILE_SIZE = 8
|
|
FACE_TILE_W = 3 # 3 tiles wide = 24px
|
|
FACE_TILE_H = 4 # 4 tiles tall = 32px
|
|
FACE_PX_W = FACE_TILE_W * TILE_SIZE # 24
|
|
FACE_PX_H = FACE_TILE_H * TILE_SIZE # 32
|
|
TILES_PER_FACE = FACE_TILE_W * FACE_TILE_H # 12
|
|
WORDS_PER_FACE = TILES_PER_FACE * 4 # 48
|
|
|
|
MAGENTA_THRESHOLD = 30
|
|
DITHER_STRENGTH = 28
|
|
EDGE_BOOST = 18
|
|
THRESH_DARK = 72
|
|
THRESH_MED = 158
|
|
|
|
BAYER_2x2 = [
|
|
[0.00, 0.50],
|
|
[0.75, 0.25],
|
|
]
|
|
|
|
VB_PALETTE = {0: (0, 0, 0, 0), 1: (80, 0, 0, 255), 2: (170, 0, 0, 255),
|
|
3: (255, 0, 0, 255)}
|
|
|
|
FACE_COUNT = 32
|
|
|
|
GRIT_FLAGS = ['-fh!', '-ftc', '-gB2', '-p!', '-m!']
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parse vb_doom.c for hand-crafted face tiles
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def parse_hex_array(filepath, array_name):
|
|
"""Parse a C hex array, return list of ints."""
|
|
with open(filepath, 'r') as f:
|
|
text = f.read()
|
|
pattern = rf'{re.escape(array_name)}\[\d+\].*?=\s*\{{(.*?)\}};'
|
|
m = re.search(pattern, text, re.DOTALL)
|
|
if not m:
|
|
raise ValueError(f"Array '{array_name}' not found in {filepath}")
|
|
return [int(x, 16) for x in re.findall(r'0x[0-9A-Fa-f]+', m.group(1))]
|
|
|
|
|
|
def extract_handcrafted_faces():
|
|
"""Extract the 3 hand-crafted faces from vb_doom.c tile data.
|
|
|
|
The faces live in the BGMap at (col 8, rows 4-15), 3 faces stacked vertically.
|
|
Each face is 3 tiles wide x 4 tiles tall = 12 tiles.
|
|
Returns list of 3 canvases (each 24x32, values 0-3).
|
|
"""
|
|
tiles_u32 = parse_hex_array(VB_DOOM_C, "vb_doomTiles")
|
|
map_u16 = parse_hex_array(VB_DOOM_C, "vb_doomMap")
|
|
|
|
MAP_COLS = 48
|
|
canvases = []
|
|
|
|
for face_id in range(3):
|
|
face_tile_data = []
|
|
for row_off in range(4):
|
|
map_row = 4 + face_id * 4 + row_off
|
|
for col_off in range(3):
|
|
map_col = 8 + col_off
|
|
map_idx = map_row * MAP_COLS + map_col
|
|
entry = map_u16[map_idx]
|
|
char_idx = entry & 0x07FF
|
|
hflip = (entry >> 13) & 1
|
|
|
|
t_off = char_idx * 4
|
|
words = list(tiles_u32[t_off:t_off + 4])
|
|
|
|
if hflip:
|
|
words = hflip_tile_words(words)
|
|
|
|
face_tile_data.extend(words)
|
|
|
|
# Decode tile data to canvas
|
|
canvas = tile_u32_to_canvas(face_tile_data)
|
|
canvases.append(canvas)
|
|
|
|
return canvases
|
|
|
|
|
|
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.
|
|
"""
|
|
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 result
|
|
|
|
|
|
def tile_u32_to_canvas(tile_words):
|
|
"""Convert 48 u32 words back to a 24x32 VB-index canvas.
|
|
|
|
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.
|
|
"""
|
|
canvas = [[0] * FACE_PX_W for _ in range(FACE_PX_H)]
|
|
|
|
for ti in range(TILES_PER_FACE):
|
|
ty = ti // FACE_TILE_W
|
|
tx = ti % FACE_TILE_W
|
|
base = ti * 4
|
|
|
|
data = []
|
|
for wi in range(4):
|
|
w = tile_words[base + wi]
|
|
data.append(w & 0xFF)
|
|
data.append((w >> 8) & 0xFF)
|
|
data.append((w >> 16) & 0xFF)
|
|
data.append((w >> 24) & 0xFF)
|
|
|
|
for y in range(TILE_SIZE):
|
|
byte0 = data[y * 2] # low byte of u16
|
|
byte1 = data[y * 2 + 1] # high byte of u16
|
|
u16_val = (byte1 << 8) | byte0
|
|
|
|
for x in range(TILE_SIZE):
|
|
shift = x * 2 # px0 (leftmost) at bits 1:0 (LSB)
|
|
px_val = (u16_val >> shift) & 3
|
|
py = ty * TILE_SIZE + y
|
|
px = tx * TILE_SIZE + x
|
|
canvas[py][px] = px_val
|
|
|
|
return canvas
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DOOM face detection and extraction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def is_magenta(r, g, b):
|
|
return r > 220 and g < MAGENTA_THRESHOLD and b > 220
|
|
|
|
|
|
def luminance(r, g, b):
|
|
return int(0.299 * r + 0.587 * g + 0.114 * b)
|
|
|
|
|
|
def find_faces(img):
|
|
"""Find individual face bounding boxes by scanning for magenta separators."""
|
|
w, h = img.size
|
|
pixels = img.load()
|
|
|
|
mask = [[False] * w for _ in range(h)]
|
|
for y in range(h):
|
|
for x in range(w):
|
|
pix = pixels[x, y]
|
|
r, g, b = pix[0], pix[1], pix[2]
|
|
a = pix[3] if len(pix) > 3 else 255
|
|
if not is_magenta(r, g, b) and a > 128:
|
|
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))
|
|
|
|
faces = []
|
|
for band_top, band_bottom in bands:
|
|
col_has_content = [False] * w
|
|
for x in range(w):
|
|
for y in range(band_top, band_bottom + 1):
|
|
if mask[y][x]:
|
|
col_has_content[x] = True
|
|
break
|
|
|
|
in_face = False
|
|
face_left = 0
|
|
for x in range(w):
|
|
if col_has_content[x] and not in_face:
|
|
face_left = x
|
|
in_face = True
|
|
elif not col_has_content[x] and in_face:
|
|
faces.append((face_left, band_top, x - 1, band_bottom))
|
|
in_face = False
|
|
if in_face:
|
|
faces.append((face_left, band_top, w - 1, band_bottom))
|
|
|
|
return faces
|
|
|
|
|
|
def extract_face_grayscale(img, bbox):
|
|
"""Extract face, DOWNSCALE to 24x32, convert to grayscale+alpha."""
|
|
left, top, right, bottom = bbox
|
|
face_img = img.crop((left, top, right + 1, bottom + 1))
|
|
face_img = face_img.resize((FACE_PX_W, FACE_PX_H), Image.LANCZOS)
|
|
|
|
pixels = face_img.load()
|
|
gray_canvas = [[0.0] * FACE_PX_W for _ in range(FACE_PX_H)]
|
|
alpha_canvas = [[0] * FACE_PX_W for _ in range(FACE_PX_H)]
|
|
|
|
for y in range(FACE_PX_H):
|
|
for x in range(FACE_PX_W):
|
|
pix = pixels[x, y]
|
|
r, g, b = pix[0], pix[1], pix[2]
|
|
a = pix[3] if len(pix) > 3 else 255
|
|
|
|
if is_magenta(r, g, b) or a < 128:
|
|
gray_canvas[y][x] = 0.0
|
|
alpha_canvas[y][x] = 0
|
|
else:
|
|
gray_canvas[y][x] = float(luminance(r, g, b))
|
|
alpha_canvas[y][x] = 1
|
|
|
|
return gray_canvas, alpha_canvas
|
|
|
|
|
|
def contrast_stretch(gray, alpha, low_pct=2, high_pct=98):
|
|
"""Apply per-face contrast stretching (percentile-based)."""
|
|
values = []
|
|
for y in range(FACE_PX_H):
|
|
for x in range(FACE_PX_W):
|
|
if alpha[y][x]:
|
|
values.append(gray[y][x])
|
|
|
|
if len(values) < 10:
|
|
return gray
|
|
|
|
values.sort()
|
|
lo = values[max(0, len(values) * low_pct // 100)]
|
|
hi = values[min(len(values) - 1, len(values) * high_pct // 100)]
|
|
if hi - lo < 20:
|
|
hi = lo + 20
|
|
|
|
out = [[0.0] * FACE_PX_W for _ in range(FACE_PX_H)]
|
|
for y in range(FACE_PX_H):
|
|
for x in range(FACE_PX_W):
|
|
if alpha[y][x]:
|
|
v = (gray[y][x] - lo) / (hi - lo) * 255.0
|
|
out[y][x] = max(0.0, min(255.0, v))
|
|
return out
|
|
|
|
|
|
def sobel_edge_magnitude(gray, alpha):
|
|
"""Compute edge magnitude via Sobel filter."""
|
|
edge = [[0.0] * FACE_PX_W for _ in range(FACE_PX_H)]
|
|
for y in range(1, FACE_PX_H - 1):
|
|
for x in range(1, FACE_PX_W - 1):
|
|
if not alpha[y][x]:
|
|
continue
|
|
gx = (-gray[y-1][x-1] + gray[y-1][x+1]
|
|
- 2*gray[y][x-1] + 2*gray[y][x+1]
|
|
- gray[y+1][x-1] + gray[y+1][x+1])
|
|
gy = (-gray[y-1][x-1] - 2*gray[y-1][x] - gray[y-1][x+1]
|
|
+ gray[y+1][x-1] + 2*gray[y+1][x] + gray[y+1][x+1])
|
|
edge[y][x] = math.sqrt(gx*gx + gy*gy) / 4.0
|
|
return edge
|
|
|
|
|
|
def quantize_face(gray, alpha, edges):
|
|
"""Quantize to VB palette with ordered dithering and edge boost."""
|
|
canvas = [[0] * FACE_PX_W for _ in range(FACE_PX_H)]
|
|
for y in range(FACE_PX_H):
|
|
for x in range(FACE_PX_W):
|
|
if not alpha[y][x]:
|
|
continue
|
|
|
|
g = gray[y][x]
|
|
edge_mag = min(edges[y][x], 80.0)
|
|
if edge_mag > 20:
|
|
boost = EDGE_BOOST * (edge_mag - 20) / 60.0
|
|
if g < 128:
|
|
g = max(0.0, g - boost)
|
|
else:
|
|
g = min(255.0, g + boost)
|
|
|
|
dval = BAYER_2x2[y % 2][x % 2] * DITHER_STRENGTH
|
|
g = g + dval - DITHER_STRENGTH * 0.375
|
|
|
|
if g < THRESH_DARK:
|
|
canvas[y][x] = 1
|
|
elif g < THRESH_MED:
|
|
canvas[y][x] = 2
|
|
else:
|
|
canvas[y][x] = 3
|
|
|
|
return canvas
|
|
|
|
|
|
def extract_doom_face(img, bbox):
|
|
"""Full pipeline: extract, downscale, contrast stretch, edge detect, dither, quantize."""
|
|
gray, alpha = extract_face_grayscale(img, bbox)
|
|
gray = contrast_stretch(gray, alpha)
|
|
edges = sobel_edge_magnitude(gray, alpha)
|
|
return quantize_face(gray, alpha, edges)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Grit integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def save_indexed_png(canvas, path):
|
|
"""Save a 24x32 indexed PNG with 4-color grayscale palette for grit."""
|
|
img = Image.new('P', (FACE_PX_W, FACE_PX_H))
|
|
# 4-color palette: index 0=black, 1=dark, 2=medium, 3=white
|
|
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(FACE_PX_H):
|
|
for x in range(FACE_PX_W):
|
|
pixels[x, y] = canvas[y][x]
|
|
img.save(path)
|
|
|
|
|
|
def run_grit(png_path):
|
|
"""Run grit on a face PNG, return the generated .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 grit might create
|
|
h_file = os.path.join(GRIT_DIR, f"{basename}.h")
|
|
if os.path.exists(h_file):
|
|
os.remove(h_file)
|
|
|
|
return c_file
|
|
|
|
|
|
def get_grit_array_info(c_path):
|
|
"""Extract tile array name and size from a grit .c file."""
|
|
with open(c_path, 'r') as f:
|
|
content = f.read()
|
|
m = re.search(r'const unsigned int (\w+Tiles)\[(\d+)\]', content)
|
|
if m:
|
|
return m.group(1), int(m.group(2))
|
|
return None, None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preview generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def save_face_preview(canvas, path):
|
|
"""Save a 24x32 PNG at true pixel size with VB red palette."""
|
|
img = Image.new('RGBA', (FACE_PX_W, FACE_PX_H), (0, 0, 0, 255))
|
|
pix = img.load()
|
|
for y in range(FACE_PX_H):
|
|
for x in range(FACE_PX_W):
|
|
pix[x, y] = VB_PALETTE[canvas[y][x]]
|
|
img.save(path)
|
|
|
|
|
|
def save_all_faces_sheet(all_canvases, path):
|
|
"""Save sheet of all faces at 1x (true pixel size)."""
|
|
cols = 8
|
|
rows = (len(all_canvases) + cols - 1) // cols
|
|
gap = 1
|
|
sw = (FACE_PX_W + gap) * cols
|
|
sh = (FACE_PX_H + gap) * rows
|
|
sheet = Image.new('RGBA', (sw, sh), (40, 0, 0, 255))
|
|
spix = sheet.load()
|
|
for fi, canvas in enumerate(all_canvases):
|
|
col = fi % cols
|
|
row = fi // cols
|
|
ox = col * (FACE_PX_W + gap)
|
|
oy = row * (FACE_PX_H + gap)
|
|
for y in range(FACE_PX_H):
|
|
for x in range(FACE_PX_W):
|
|
color = VB_PALETTE[canvas[y][x]]
|
|
px = ox + x
|
|
py = oy + y
|
|
if 0 <= px < sw and 0 <= py < sh:
|
|
spix[px, py] = color
|
|
sheet.save(path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Header generation (matches zombie_sprites.h pattern)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def generate_header(array_names, array_sizes):
|
|
"""Generate face_sprites.h matching zombie_sprites.h pattern."""
|
|
h_path = os.path.join(FACES_DIR, "face_sprites.h")
|
|
|
|
with open(h_path, 'w') as f:
|
|
f.write("#ifndef __FACE_SPRITES_H__\n")
|
|
f.write("#define __FACE_SPRITES_H__\n\n")
|
|
f.write("/*\n")
|
|
f.write(" * Face sprite frames\n")
|
|
f.write(" * Auto-generated by prepare_doom_faces.py + grit\n")
|
|
f.write(" *\n")
|
|
f.write(f" * Total faces: {FACE_COUNT}\n")
|
|
f.write(f" * Face size: {FACE_PX_W}x{FACE_PX_H} px "
|
|
f"({FACE_TILE_W}x{FACE_TILE_H} tiles)\n")
|
|
f.write(f" * Tiles per face: {WORDS_PER_FACE} u32 words "
|
|
f"= {TILES_PER_FACE * 16} bytes\n")
|
|
f.write(" *\n")
|
|
f.write(" * Usage:\n")
|
|
f.write(" * copymem((void*)addr, "
|
|
f"(void*)FACE_TILE_DATA[faceIndex], {TILES_PER_FACE * 16});\n")
|
|
f.write(" */\n\n")
|
|
|
|
# Defines
|
|
f.write(f"#define FACE_CHAR_START {FACE_CHAR_START}\n")
|
|
f.write(f"#define FACE_TILE_COUNT {TILES_PER_FACE}\n")
|
|
f.write(f"#define FACE_TILE_BYTES {TILES_PER_FACE * 16}\n")
|
|
f.write(f"#define FACE_COUNT {FACE_COUNT}\n\n")
|
|
|
|
f.write("/* Face index defines */\n")
|
|
f.write("#define FACE_IDLE_BASE 0 "
|
|
"/* 15 faces: 5 damage x 3 dir */\n")
|
|
f.write("#define FACE_OUCH_FRONT 15 "
|
|
"/* 5 faces: one per damage lvl */\n")
|
|
f.write("#define FACE_OUCH_SEVERE 20\n")
|
|
f.write("#define FACE_EVIL_GRIN 25\n")
|
|
f.write("#define FACE_DEAD 30\n")
|
|
f.write("#define FACE_GOD 31\n\n")
|
|
|
|
# Extern declarations (like zombie_sprites.h)
|
|
f.write(f"/* Individual face tile data "
|
|
f"(each is {WORDS_PER_FACE} u32 words "
|
|
f"= {TILES_PER_FACE * 16} bytes) */\n")
|
|
for i in range(FACE_COUNT):
|
|
f.write(f"extern const unsigned int "
|
|
f"{array_names[i]}[{array_sizes[i]}];\n")
|
|
|
|
f.write("\n")
|
|
|
|
# Static pointer table (like ZOMBIE_FRAMES)
|
|
f.write("/* Frame pointer table for dynamic face loading */\n")
|
|
f.write(f"static const unsigned int* const "
|
|
f"FACE_TILE_DATA[{FACE_COUNT}] = {{\n")
|
|
for i in range(FACE_COUNT):
|
|
comma = "," if i < FACE_COUNT - 1 else ""
|
|
f.write(f" {array_names[i]}{comma}\n")
|
|
f.write("};\n\n")
|
|
|
|
# Static faceMap
|
|
f.write("/* Fixed face BGMap: always references chars 108-119 */\n")
|
|
f.write(f"static const unsigned short faceMap[{TILES_PER_FACE}]"
|
|
f" __attribute__((aligned(4))) = {{\n\t")
|
|
entries = [FACE_CHAR_START + i for i in range(TILES_PER_FACE)]
|
|
f.write(",".join(f"0x{e:04X}" for e in entries))
|
|
f.write("\n};\n\n")
|
|
|
|
f.write("#endif /* __FACE_SPRITES_H__ */\n")
|
|
|
|
return h_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
os.makedirs(PREVIEW_DIR, exist_ok=True)
|
|
os.makedirs(FACES_DIR, exist_ok=True)
|
|
|
|
if not os.path.exists(GRIT_EXE):
|
|
print(f"ERROR: grit.exe not found at {GRIT_EXE}")
|
|
sys.exit(1)
|
|
|
|
# --- Step 1: Extract 3 hand-crafted faces from vb_doom.c ---
|
|
print("Extracting 3 hand-crafted faces from vb_doom.c...")
|
|
handcrafted_canvases = extract_handcrafted_faces()
|
|
print(f" Got {len(handcrafted_canvases)} hand-crafted faces")
|
|
|
|
# --- Step 2: Load and detect DOOM faces ---
|
|
img = Image.open(INPUT_IMG).convert('RGBA')
|
|
w, h = img.size
|
|
print(f"\nLoaded {INPUT_IMG}: {w}x{h}")
|
|
|
|
bboxes = find_faces(img)
|
|
print(f"Found {len(bboxes)} faces in doom_faces.png")
|
|
|
|
if len(bboxes) < 42:
|
|
print(f"WARNING: Expected at least 42 faces, found {len(bboxes)}")
|
|
|
|
# Group bboxes by row
|
|
doom_bboxes = bboxes[:42] if len(bboxes) >= 42 else bboxes
|
|
row_counts = [3, 3, 3, 3, 3, 5, 5, 5, 5, 5, 2]
|
|
rows = []
|
|
idx = 0
|
|
for cnt in row_counts:
|
|
rows.append(doom_bboxes[idx:idx + cnt])
|
|
idx += cnt
|
|
|
|
# --- Step 3: Build all 32 face canvases ---
|
|
all_canvases = []
|
|
|
|
# Faces 0-2: hand-crafted
|
|
for fi in range(3):
|
|
all_canvases.append(handcrafted_canvases[fi])
|
|
print(f" Face {fi:2d}: hand-crafted (from vb_doom.c)")
|
|
|
|
# Faces 3-14: idle damage 1-4 (rows 1-4, 3 faces each)
|
|
for row_idx in range(1, 5):
|
|
for col_idx in range(3):
|
|
fi = len(all_canvases)
|
|
bbox = rows[row_idx][col_idx]
|
|
canvas = extract_doom_face(img, bbox)
|
|
all_canvases.append(canvas)
|
|
fw = bbox[2] - bbox[0] + 1
|
|
fh = bbox[3] - bbox[1] + 1
|
|
print(f" Face {fi:2d}: doom row {row_idx} col {col_idx} "
|
|
f"({fw}x{fh} -> 24x32)")
|
|
|
|
# Faces 15-19: ouch front (row 5, 5 faces)
|
|
for col_idx in range(5):
|
|
fi = len(all_canvases)
|
|
bbox = rows[5][col_idx]
|
|
canvas = extract_doom_face(img, bbox)
|
|
all_canvases.append(canvas)
|
|
fw = bbox[2] - bbox[0] + 1
|
|
fh = bbox[3] - bbox[1] + 1
|
|
print(f" Face {fi:2d}: ouch front ({fw}x{fh} -> 24x32)")
|
|
|
|
# SKIP rows 6 and 7 (ouch left, ouch right)
|
|
|
|
# Faces 20-24: severe ouch (row 8, 5 faces)
|
|
for col_idx in range(5):
|
|
fi = len(all_canvases)
|
|
bbox = rows[8][col_idx]
|
|
canvas = extract_doom_face(img, bbox)
|
|
all_canvases.append(canvas)
|
|
fw = bbox[2] - bbox[0] + 1
|
|
fh = bbox[3] - bbox[1] + 1
|
|
print(f" Face {fi:2d}: severe ouch ({fw}x{fh} -> 24x32)")
|
|
|
|
# Faces 25-29: evil grin (row 9, 5 faces)
|
|
for col_idx in range(5):
|
|
fi = len(all_canvases)
|
|
bbox = rows[9][col_idx]
|
|
canvas = extract_doom_face(img, bbox)
|
|
all_canvases.append(canvas)
|
|
fw = bbox[2] - bbox[0] + 1
|
|
fh = bbox[3] - bbox[1] + 1
|
|
print(f" Face {fi:2d}: evil grin ({fw}x{fh} -> 24x32)")
|
|
|
|
# Face 30: dead (row 10 col 0)
|
|
fi = len(all_canvases)
|
|
bbox = rows[10][0]
|
|
canvas = extract_doom_face(img, bbox)
|
|
all_canvases.append(canvas)
|
|
print(f" Face {fi:2d}: dead")
|
|
|
|
# Face 31: god mode (row 10 col 1)
|
|
fi = len(all_canvases)
|
|
bbox = rows[10][1]
|
|
canvas = extract_doom_face(img, bbox)
|
|
all_canvases.append(canvas)
|
|
print(f" Face {fi:2d}: god mode")
|
|
|
|
assert len(all_canvases) == FACE_COUNT, \
|
|
f"Expected {FACE_COUNT} faces, got {len(all_canvases)}"
|
|
|
|
# --- Step 4: Save previews (true 24x32 pixel size, VB palette) ---
|
|
for fi, canvas in enumerate(all_canvases):
|
|
save_face_preview(canvas, os.path.join(PREVIEW_DIR,
|
|
f"face_{fi:02d}.png"))
|
|
save_all_faces_sheet(all_canvases, os.path.join(PREVIEW_DIR,
|
|
"all_faces.png"))
|
|
print(f"\nSaved {FACE_COUNT} previews to {PREVIEW_DIR}/")
|
|
|
|
# --- Step 5: Save indexed PNGs and run grit on each ---
|
|
print(f"\nRunning grit on {FACE_COUNT} faces...")
|
|
grit_input_dir = os.path.join(PREVIEW_DIR, "grit_input")
|
|
os.makedirs(grit_input_dir, exist_ok=True)
|
|
|
|
array_names = []
|
|
array_sizes = []
|
|
|
|
for fi, canvas in enumerate(all_canvases):
|
|
name = f"face_{fi:02d}"
|
|
png_path = os.path.join(grit_input_dir, f"{name}.png")
|
|
|
|
# Save indexed 4-color PNG for grit
|
|
save_indexed_png(canvas, png_path)
|
|
|
|
# Run grit
|
|
c_file = run_grit(png_path)
|
|
if c_file is None:
|
|
print(f" FATAL: grit failed for face {fi}")
|
|
sys.exit(1)
|
|
|
|
# Move .c file to faces directory
|
|
dest = os.path.join(FACES_DIR, f"{name}.c")
|
|
shutil.move(c_file, dest)
|
|
|
|
# Parse array name/size from grit output
|
|
arr_name, arr_size = get_grit_array_info(dest)
|
|
if arr_name is None:
|
|
print(f" FATAL: could not parse grit output for face {fi}")
|
|
sys.exit(1)
|
|
|
|
array_names.append(arr_name)
|
|
array_sizes.append(arr_size)
|
|
print(f" Face {fi:2d}: {arr_name}[{arr_size}] -> {name}.c")
|
|
|
|
# --- Step 6: Generate face_sprites.h (zombie-style) ---
|
|
h_path = generate_header(array_names, array_sizes)
|
|
print(f"\nWrote {h_path}")
|
|
|
|
# --- Step 7: Clean up old files ---
|
|
old_c = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "images",
|
|
"face_sprites.c")
|
|
old_h = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "images",
|
|
"face_sprites.h")
|
|
for old_file in [old_c, old_h]:
|
|
if os.path.exists(old_file):
|
|
os.remove(old_file)
|
|
print(f"Removed old {old_file}")
|
|
|
|
print(f"\nDone! {FACE_COUNT} face .c files + header in {FACES_DIR}/")
|
|
print(f"VRAM: FACE_CHAR_START={FACE_CHAR_START}, "
|
|
f"tiles per face={TILES_PER_FACE}")
|
|
print(f"Total ROM: {FACE_COUNT} faces x {TILES_PER_FACE * 16} bytes = "
|
|
f"{FACE_COUNT * TILES_PER_FACE * 16} bytes")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|