initial commit!
This commit is contained in:
702
graphics/prepare_doom_faces.py
Normal file
702
graphics/prepare_doom_faces.py
Normal file
@@ -0,0 +1,702 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user