feat: parameterize sheet renderers for scale, add HD tier generator
draw_sprites.py renderers take scale/smooth/src_dir/out_dir keywords with SD-identical defaults (verified pixel-for-pixel against the committed sheets); smooth swaps NEAREST for LANCZOS/BICUBIC. tools/hd_assets.py derives the 4x WebP tier from assets/raw sources: flood-key, crop, margin/fit/center without palette quantization, then renders 4x sheets through a temp PNG bridge.
This commit is contained in:
1 parent
c09a66ae9e
commit
efa4a7fb0a
2 files changed
+410
-63
No files matched your search
+83
-63
@@ -223,7 +223,7 @@ def a_player():
|
|||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
def _player_legs_frame(index=0, frame_size=96):
|
def _player_legs_frame(index=0, frame_size=96, k=1, smooth=False, src_dir=OUT):
|
||||||
"""Eight-frame, distance-driven butcher stride authored facing right.
|
"""Eight-frame, distance-driven butcher stride authored facing right.
|
||||||
|
|
||||||
Uses the generated single-leg production sprite when present, so the walk
|
Uses the generated single-leg production sprite when present, so the walk
|
||||||
@@ -231,7 +231,8 @@ def _player_legs_frame(index=0, frame_size=96):
|
|||||||
procedural blocky fallback below only exists for source-only rebuilds.
|
procedural blocky fallback below only exists for source-only rebuilds.
|
||||||
Runtime rendering rotates this forward-facing frame smoothly.
|
Runtime rendering rotates this forward-facing frame smoothly.
|
||||||
"""
|
"""
|
||||||
leg_path = OUT / "player_leg.png"
|
leg_path = src_dir / "player_leg.png"
|
||||||
|
resample = Image.Resampling.LANCZOS if smooth else Image.Resampling.NEAREST
|
||||||
if leg_path.exists():
|
if leg_path.exists():
|
||||||
source = Image.open(leg_path).convert("RGBA")
|
source = Image.open(leg_path).convert("RGBA")
|
||||||
box = source.getbbox()
|
box = source.getbbox()
|
||||||
@@ -239,11 +240,11 @@ def _player_legs_frame(index=0, frame_size=96):
|
|||||||
source = source.crop(box)
|
source = source.crop(box)
|
||||||
# Legs are deliberately tucked under the waist: only boots/lower shins
|
# Legs are deliberately tucked under the waist: only boots/lower shins
|
||||||
# should peek out from beneath the torso during the stride.
|
# should peek out from beneath the torso during the stride.
|
||||||
scale = min(44 / source.width, 25 / source.height)
|
scale = min(44 * k / source.width, 25 * k / source.height)
|
||||||
source = source.resize((max(1, round(source.width * scale)), max(1, round(source.height * scale))), Image.Resampling.NEAREST)
|
source = source.resize((max(1, round(source.width * scale)), max(1, round(source.height * scale))), resample)
|
||||||
|
|
||||||
waist = None
|
waist = None
|
||||||
waist_path = OUT / "player_waist.png"
|
waist_path = src_dir / "player_waist.png"
|
||||||
if waist_path.exists():
|
if waist_path.exists():
|
||||||
waist = Image.open(waist_path).convert("RGBA")
|
waist = Image.open(waist_path).convert("RGBA")
|
||||||
waist_box = waist.getbbox()
|
waist_box = waist.getbbox()
|
||||||
@@ -251,14 +252,14 @@ def _player_legs_frame(index=0, frame_size=96):
|
|||||||
waist = waist.crop(waist_box)
|
waist = waist.crop(waist_box)
|
||||||
# Keep the attachment socket hidden under the freely aimed torso;
|
# Keep the attachment socket hidden under the freely aimed torso;
|
||||||
# a larger waist reads as a second body when aim and travel diverge.
|
# a larger waist reads as a second body when aim and travel diverge.
|
||||||
waist_scale = min(38 / waist.width, 38 / waist.height)
|
waist_scale = min(38 * k / waist.width, 38 * k / waist.height)
|
||||||
waist = waist.resize((max(1, round(waist.width * waist_scale)), max(1, round(waist.height * waist_scale))), Image.Resampling.NEAREST)
|
waist = waist.resize((max(1, round(waist.width * waist_scale)), max(1, round(waist.height * waist_scale))), resample)
|
||||||
|
|
||||||
phase = index / 8 * math.tau
|
phase = index / 8 * math.tau
|
||||||
contact = math.cos(phase) # frames 0/4 are full-contact extremes
|
contact = math.cos(phase) # frames 0/4 are full-contact extremes
|
||||||
pass_k = math.sin(phase)
|
pass_k_val = math.sin(phase)
|
||||||
frame = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
|
frame = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
|
||||||
cx, cy = frame_size // 2, frame_size // 2 + 3
|
cx, cy = frame_size // 2, frame_size // 2 + round(3 * k)
|
||||||
|
|
||||||
def paste_leg(extension, side, lift, mirrored=False):
|
def paste_leg(extension, side, lift, mirrored=False):
|
||||||
# Anchor both thighs beneath the waist and vary only their extension.
|
# Anchor both thighs beneath the waist and vary only their extension.
|
||||||
@@ -266,25 +267,27 @@ def _player_legs_frame(index=0, frame_size=96):
|
|||||||
# the hips; scaling keeps both boots unmistakably pointed forward.
|
# the hips; scaling keeps both boots unmistakably pointed forward.
|
||||||
img = source.transpose(Image.Transpose.FLIP_TOP_BOTTOM) if mirrored else source
|
img = source.transpose(Image.Transpose.FLIP_TOP_BOTTOM) if mirrored else source
|
||||||
lift_scale = 1 + lift * 0.08
|
lift_scale = 1 + lift * 0.08
|
||||||
img = img.resize((round(extension * lift_scale), round(img.height * lift_scale)), Image.Resampling.NEAREST)
|
img = img.resize((round(extension * lift_scale), round(img.height * lift_scale)), resample)
|
||||||
x = round(cx - 8)
|
x = round(cx - round(8 * k))
|
||||||
y = round(cy + side * 11 - img.height / 2)
|
y = round(cy + side * round(11 * k) - img.height / 2)
|
||||||
frame.alpha_composite(img, (x, y))
|
frame.alpha_composite(img, (x, y))
|
||||||
|
|
||||||
# Feet trade long/short extensions through contact and pass poses.
|
# Feet trade long/short extensions through contact and pass poses.
|
||||||
# Mirroring one copy makes the generated art read as a left/right pair.
|
# Mirroring one copy makes the generated art read as a left/right pair.
|
||||||
left_extension = 34 + contact * 9
|
left_extension = k * (34 + contact * 9)
|
||||||
right_extension = 34 - contact * 9
|
right_extension = k * (34 - contact * 9)
|
||||||
paste_leg(right_extension, 1, max(0, -pass_k), True)
|
paste_leg(right_extension, 1, max(0, -pass_k_val), True)
|
||||||
paste_leg(left_extension, -1, max(0, pass_k))
|
paste_leg(left_extension, -1, max(0, pass_k_val))
|
||||||
if waist is not None:
|
if waist is not None:
|
||||||
frame.alpha_composite(waist, (round(cx - waist.width / 2), round(cy - waist.height / 2)))
|
frame.alpha_composite(waist, (round(cx - waist.width / 2), round(cy - waist.height / 2)))
|
||||||
else:
|
else:
|
||||||
# Source-only fallback: cover the thigh joints with a compact hip block.
|
# Source-only fallback: cover the thigh joints with a compact hip block.
|
||||||
d = ImageDraw.Draw(frame)
|
d = ImageDraw.Draw(frame)
|
||||||
d.polygon([(cx - 15, cy - 13), (cx + 12, cy - 12), (cx + 16, cy + 11), (cx - 17, cy + 12)], fill=OUTL)
|
d.polygon([(cx - round(15 * k), cy - round(13 * k)), (cx + round(12 * k), cy - round(12 * k)),
|
||||||
d.polygon([(cx - 13, cy - 11), (cx + 10, cy - 10), (cx + 13, cy + 9), (cx - 14, cy + 10)], fill=(116, 76, 45, 255))
|
(cx + round(16 * k), cy + round(11 * k)), (cx - round(17 * k), cy + round(12 * k))], fill=OUTL)
|
||||||
d.line([(cx - 11, cy - 9), (cx + 8, cy - 9)], fill=(201, 151, 88, 255), width=2)
|
d.polygon([(cx - round(13 * k), cy - round(11 * k)), (cx + round(10 * k), cy - round(10 * k)),
|
||||||
|
(cx + round(13 * k), cy + round(9 * k)), (cx - round(14 * k), cy + round(10 * k))], fill=(116, 76, 45, 255))
|
||||||
|
d.line([(cx - round(11 * k), cy - round(9 * k)), (cx + round(8 * k), cy - round(9 * k))], fill=(201, 151, 88, 255), width=2)
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
logical = 48
|
logical = 48
|
||||||
@@ -328,7 +331,7 @@ def _player_legs_frame(index=0, frame_size=96):
|
|||||||
|
|
||||||
frame = finish_sprite(img, "player_legs")
|
frame = finish_sprite(img, "player_legs")
|
||||||
if frame.size != (frame_size, frame_size):
|
if frame.size != (frame_size, frame_size):
|
||||||
frame = frame.resize((frame_size, frame_size), Image.Resampling.NEAREST)
|
frame = frame.resize((frame_size, frame_size), resample)
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
|
|
||||||
@@ -1334,18 +1337,30 @@ ACTORS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _fit_actor(img, name, frame_size):
|
def sd_frame_size(name):
|
||||||
|
"""Return the SD frame size for a given actor name."""
|
||||||
|
if name.startswith("boss_"):
|
||||||
|
return 128
|
||||||
|
if name == "player":
|
||||||
|
return 96
|
||||||
|
return 64
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_actor(img, name, frame_size, smooth=False, k=None):
|
||||||
box = img.getbbox()
|
box = img.getbbox()
|
||||||
img = img.crop(box) if box else img
|
img = img.crop(box) if box else img
|
||||||
|
if k is None:
|
||||||
|
k = frame_size / sd_frame_size(name)
|
||||||
if name.startswith("boss_"):
|
if name.startswith("boss_"):
|
||||||
target = 120
|
target = round(120 * k)
|
||||||
elif name == "player":
|
elif name == "player":
|
||||||
target = 88
|
target = round(88 * k)
|
||||||
else:
|
else:
|
||||||
target = 58
|
target = round(58 * k)
|
||||||
target = min(target, frame_size - 8)
|
target = min(target, frame_size - round(8 * k))
|
||||||
scale = min(target / img.width, target / img.height)
|
scale = min(target / img.width, target / img.height)
|
||||||
return img.resize((max(1, round(img.width * scale)), max(1, round(img.height * scale))), Image.Resampling.NEAREST)
|
resample = Image.Resampling.LANCZOS if smooth else Image.Resampling.NEAREST
|
||||||
|
return img.resize((max(1, round(img.width * scale)), max(1, round(img.height * scale))), resample)
|
||||||
|
|
||||||
|
|
||||||
def _tint(img, color, amount):
|
def _tint(img, color, amount):
|
||||||
@@ -1354,7 +1369,7 @@ def _tint(img, color, amount):
|
|||||||
return Image.blend(img, tint, amount)
|
return Image.blend(img, tint, amount)
|
||||||
|
|
||||||
|
|
||||||
def _actor_frame(base, name, action, index, count, direction, frame_size):
|
def _actor_frame(base, name, action, index, count, direction, frame_size, k=1, smooth=False):
|
||||||
phase = index / count * math.tau
|
phase = index / count * math.tau
|
||||||
work = base.copy()
|
work = base.copy()
|
||||||
xscale = yscale = 1.0
|
xscale = yscale = 1.0
|
||||||
@@ -1363,37 +1378,39 @@ def _actor_frame(base, name, action, index, count, direction, frame_size):
|
|||||||
if action == "idle":
|
if action == "idle":
|
||||||
yscale = 1 + math.sin(phase) * 0.035
|
yscale = 1 + math.sin(phase) * 0.035
|
||||||
xscale = 1 - math.sin(phase) * 0.018
|
xscale = 1 - math.sin(phase) * 0.018
|
||||||
yoff = round(-abs(math.sin(phase)) * 1.5)
|
yoff = round(-abs(math.sin(phase)) * 1.5 * k)
|
||||||
elif action == "move":
|
elif action == "move":
|
||||||
stride = math.sin(phase)
|
stride = math.sin(phase)
|
||||||
xscale = 1 + abs(stride) * 0.06
|
xscale = 1 + abs(stride) * 0.06
|
||||||
yscale = 1 - abs(stride) * 0.045
|
yscale = 1 - abs(stride) * 0.045
|
||||||
yoff = round(-abs(stride) * 3)
|
yoff = round(-abs(stride) * 3 * k)
|
||||||
angle = stride * 4.5
|
angle = stride * 4.5
|
||||||
xoff = round(stride * 1.5)
|
xoff = round(stride * 1.5 * k)
|
||||||
elif action == "attack":
|
elif action == "attack":
|
||||||
thrust = math.sin(min(index / max(count - 1, 1), 1) * math.pi)
|
thrust = math.sin(min(index / max(count - 1, 1), 1) * math.pi)
|
||||||
xscale = 1 + thrust * 0.10
|
xscale = 1 + thrust * 0.10
|
||||||
yscale = 1 - thrust * 0.04
|
yscale = 1 - thrust * 0.04
|
||||||
xoff = round(thrust * 5)
|
xoff = round(thrust * 5 * k)
|
||||||
angle = -thrust * 5
|
angle = -thrust * 5
|
||||||
elif action == "hit":
|
elif action == "hit":
|
||||||
xoff = (-2, 3, -1)[index % 3]
|
xoff = round((-2, 3, -1)[index % 3] * k)
|
||||||
angle = (-7, 5, -2)[index % 3]
|
angle = (-7, 5, -2)[index % 3]
|
||||||
work = _tint(work, (255, 236, 218, 255), 0.58 - index * 0.16)
|
work = _tint(work, (255, 236, 218, 255), 0.58 - index * 0.16)
|
||||||
elif action == "death":
|
elif action == "death":
|
||||||
fall = index / max(count - 1, 1)
|
fall = index / max(count - 1, 1)
|
||||||
angle = fall * 82
|
angle = fall * 82
|
||||||
xoff = round(fall * 8)
|
xoff = round(fall * 8 * k)
|
||||||
yoff = round(fall * 13)
|
yoff = round(fall * 13 * k)
|
||||||
yscale = max(0.28, 1 - fall * 0.68)
|
yscale = max(0.28, 1 - fall * 0.68)
|
||||||
work.putalpha(work.getchannel("A").point(lambda a: round(a * (1 - fall * 0.62))))
|
work.putalpha(work.getchannel("A").point(lambda a: round(a * (1 - fall * 0.62))))
|
||||||
|
|
||||||
|
resample_resize = Image.Resampling.LANCZOS if smooth else Image.Resampling.NEAREST
|
||||||
|
resample_rotate = Image.Resampling.BICUBIC if smooth else Image.Resampling.NEAREST
|
||||||
w = max(1, round(work.width * xscale))
|
w = max(1, round(work.width * xscale))
|
||||||
h = max(1, round(work.height * yscale))
|
h = max(1, round(work.height * yscale))
|
||||||
work = work.resize((w, h), Image.Resampling.NEAREST)
|
work = work.resize((w, h), resample_resize)
|
||||||
if angle:
|
if angle:
|
||||||
work = work.rotate(angle, Image.Resampling.NEAREST, expand=True)
|
work = work.rotate(angle, resample_rotate, expand=True)
|
||||||
|
|
||||||
frame = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
|
frame = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
|
||||||
frame.alpha_composite(work, ((frame_size - work.width) // 2 + xoff, (frame_size - work.height) // 2 + yoff))
|
frame.alpha_composite(work, ((frame_size - work.width) // 2 + xoff, (frame_size - work.height) // 2 + yoff))
|
||||||
@@ -1404,28 +1421,28 @@ def _actor_frame(base, name, action, index, count, direction, frame_size):
|
|||||||
cy = frame_size // 2
|
cy = frame_size // 2
|
||||||
if action == "attack" and index in (2, 3):
|
if action == "attack" and index in (2, 3):
|
||||||
accent = GORE if "spitter" not in name else GREEN
|
accent = GORE if "spitter" not in name else GREEN
|
||||||
d.line([(cx + frame_size // 5, cy - 3), (cx + frame_size // 2 - 3, cy)], fill=accent, width=max(1, frame_size // 32))
|
d.line([(cx + frame_size // 5, cy - round(3 * k)), (cx + frame_size // 2 - 3, cy)], fill=accent, width=max(1, frame_size // 32))
|
||||||
if name == "enemy_exploder" and action != "death" and index % 2:
|
if name == "enemy_exploder" and action != "death" and index % 2:
|
||||||
d.ellipse([cx - 3, cy - 3, cx + 3, cy + 3], fill=YELLOW)
|
d.ellipse([cx - round(3 * k), cy - round(3 * k), cx + round(3 * k), cy + round(3 * k)], fill=YELLOW)
|
||||||
if name == "boss_gorecrown" and index % 2:
|
if name == "boss_gorecrown" and index % 2:
|
||||||
d.point([(cx - 12, cy - 10), (cx + 12, cy - 10)], fill=WHITE)
|
d.point([(cx - round(12 * k), cy - round(10 * k)), (cx + round(12 * k), cy - round(10 * k))], fill=WHITE)
|
||||||
|
|
||||||
# The canonical actor faces right. Bake all eight compass directions into
|
# The canonical actor faces right. Bake all eight compass directions into
|
||||||
# the atlas so runtime rendering never rotates/softens character pixels.
|
# the atlas so runtime rendering never rotates/softens character pixels.
|
||||||
degrees = direction * 45
|
degrees = direction * 45
|
||||||
if degrees:
|
if degrees:
|
||||||
frame = frame.rotate(-degrees, Image.Resampling.NEAREST, expand=False)
|
frame = frame.rotate(-degrees, resample_rotate, expand=False)
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
|
|
||||||
def render_actor_sheet(name, use_existing=False):
|
def render_actor_sheet(name, use_existing=False, scale=1, smooth=False, src_dir=OUT, out_dir=OUT):
|
||||||
frame_size = 128 if name.startswith("boss_") else (96 if name == "player" else 64)
|
frame_size = sd_frame_size(name) * scale
|
||||||
source_path = OUT / f"{name}.png"
|
source_path = src_dir / f"{name}.png"
|
||||||
if use_existing and source_path.exists():
|
if use_existing and source_path.exists():
|
||||||
source = Image.open(source_path).convert("RGBA")
|
source = Image.open(source_path).convert("RGBA")
|
||||||
else:
|
else:
|
||||||
source = finish_sprite(ASSETS[name](), name)
|
source = finish_sprite(ASSETS[name](), name)
|
||||||
source = _fit_actor(source, name, frame_size)
|
source = _fit_actor(source, name, frame_size, smooth=smooth)
|
||||||
columns = 8
|
columns = 8
|
||||||
cells = sum(count * 8 for count, _fps in ANIM_ACTIONS.values())
|
cells = sum(count * 8 for count, _fps in ANIM_ACTIONS.values())
|
||||||
rows = math.ceil(cells / columns)
|
rows = math.ceil(cells / columns)
|
||||||
@@ -1434,36 +1451,37 @@ def render_actor_sheet(name, use_existing=False):
|
|||||||
for action, (count, _fps) in ANIM_ACTIONS.items():
|
for action, (count, _fps) in ANIM_ACTIONS.items():
|
||||||
for direction in range(8):
|
for direction in range(8):
|
||||||
for index in range(count):
|
for index in range(count):
|
||||||
frame = _actor_frame(source, name, action, index, count, direction, frame_size)
|
frame = _actor_frame(source, name, action, index, count, direction, frame_size, k=scale, smooth=smooth)
|
||||||
sheet.alpha_composite(frame, ((cell % columns) * frame_size, (cell // columns) * frame_size))
|
sheet.alpha_composite(frame, ((cell % columns) * frame_size, (cell // columns) * frame_size))
|
||||||
cell += 1
|
cell += 1
|
||||||
sheet.save(OUT / f"{name}_sheet.png", optimize=True)
|
sheet.save(out_dir / f"{name}_sheet.png", optimize=True)
|
||||||
return sheet
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
def render_legs_sheet():
|
def render_legs_sheet(scale=1, smooth=False, src_dir=OUT, out_dir=OUT):
|
||||||
"""Render one forward-facing stride row; runtime rotates it smoothly."""
|
"""Render one forward-facing stride row; runtime rotates it smoothly."""
|
||||||
frame_size = 96
|
frame_size = 96 * scale
|
||||||
sheet = Image.new("RGBA", (frame_size * 8, frame_size), (0, 0, 0, 0))
|
sheet = Image.new("RGBA", (frame_size * 8, frame_size), (0, 0, 0, 0))
|
||||||
planted = None
|
planted = None
|
||||||
for index in range(8):
|
for index in range(8):
|
||||||
frame = _player_legs_frame(index, frame_size)
|
frame = _player_legs_frame(index, frame_size, k=scale, smooth=smooth, src_dir=src_dir)
|
||||||
if index == 0:
|
if index == 0:
|
||||||
planted = frame.copy()
|
planted = frame.copy()
|
||||||
sheet.alpha_composite(frame, (index * frame_size, 0))
|
sheet.alpha_composite(frame, (index * frame_size, 0))
|
||||||
sheet.save(OUT / "player_legs_sheet.png", optimize=True)
|
sheet.save(out_dir / "player_legs_sheet.png", optimize=True)
|
||||||
if planted is not None:
|
if planted is not None:
|
||||||
planted.save(OUT / "player_legs.png", optimize=True)
|
planted.save(out_dir / "player_legs.png", optimize=True)
|
||||||
return sheet
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
def render_player_death_sheet():
|
def render_player_death_sheet(scale=1, smooth=False, src_dir=OUT, out_dir=OUT):
|
||||||
"""Derive a gore burst from the shipped AI player still without redrawing it."""
|
"""Derive a gore burst from the shipped AI player still without redrawing it."""
|
||||||
source_path = OUT / "player.png"
|
frame_size = 128 * scale
|
||||||
|
source_path = src_dir / "player.png"
|
||||||
if not source_path.exists():
|
if not source_path.exists():
|
||||||
raise FileNotFoundError("assets/player.png is required for the derived death sheet")
|
raise FileNotFoundError("assets/player.png is required for the derived death sheet")
|
||||||
source = _fit_actor(Image.open(source_path).convert("RGBA"), "player", 128)
|
source = _fit_actor(Image.open(source_path).convert("RGBA"), "player", frame_size, smooth=smooth, k=scale)
|
||||||
frame_size, frame_count = 128, 12
|
frame_count = 12
|
||||||
sheet = Image.new("RGBA", (frame_size * frame_count, frame_size), (0, 0, 0, 0))
|
sheet = Image.new("RGBA", (frame_size * frame_count, frame_size), (0, 0, 0, 0))
|
||||||
rng = random.Random(0x5A17CE)
|
rng = random.Random(0x5A17CE)
|
||||||
fragments = []
|
fragments = []
|
||||||
@@ -1482,21 +1500,23 @@ def render_player_death_sheet():
|
|||||||
a = math.atan2(rely, relx) + rng.uniform(-0.7, 0.7)
|
a = math.atan2(rely, relx) + rng.uniform(-0.7, 0.7)
|
||||||
fragments.append((piece, relx, rely, a, rng.uniform(30, 68), rng.uniform(-105, 105)))
|
fragments.append((piece, relx, rely, a, rng.uniform(30, 68), rng.uniform(-105, 105)))
|
||||||
|
|
||||||
|
resample_rotate = Image.Resampling.BICUBIC if smooth else Image.Resampling.NEAREST
|
||||||
for index in range(frame_count):
|
for index in range(frame_count):
|
||||||
p = index / max(1, frame_count - 1)
|
p = index / max(1, frame_count - 1)
|
||||||
burst = p * p * (3 - 2 * p)
|
burst = p * p * (3 - 2 * p)
|
||||||
frame = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
|
frame = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
|
||||||
d = ImageDraw.Draw(frame)
|
d = ImageDraw.Draw(frame)
|
||||||
|
s = frame_size // 2
|
||||||
if index in (1, 2, 3):
|
if index in (1, 2, 3):
|
||||||
flash = (1 - abs(index - 2) / 2) * 0.9
|
flash = (1 - abs(index - 2) / 2) * 0.9
|
||||||
rr = 8 + index * 9
|
rr = round((8 + index * 9) * scale)
|
||||||
d.ellipse([64 - rr, 64 - rr, 64 + rr, 64 + rr], fill=(255, 223, 164, round(255 * flash)))
|
d.ellipse([s - rr, s - rr, s + rr, s + rr], fill=(255, 223, 164, round(255 * flash)))
|
||||||
for dot in range(20):
|
for dot in range(20):
|
||||||
da = (dot * 2.399963 + 0.37) % math.tau
|
da = (dot * 2.399963 + 0.37) % math.tau
|
||||||
dr = burst * (18 + (dot * 17) % 47)
|
dr = burst * round((18 + (dot * 17) % 47) * scale)
|
||||||
rr = max(1, round((3 + dot % 4) * (1 - p * 0.55)))
|
rr = max(1, round((3 + dot % 4) * (1 - p * 0.55) * scale))
|
||||||
dx = round(64 + math.cos(da) * dr)
|
dx = round(s + math.cos(da) * dr)
|
||||||
dy = round(64 + math.sin(da) * dr)
|
dy = round(s + math.sin(da) * dr)
|
||||||
alpha = round(230 * max(0, 1 - p * 0.75))
|
alpha = round(230 * max(0, 1 - p * 0.75))
|
||||||
color = (174, 15 + (dot % 3) * 8, 38, alpha)
|
color = (174, 15 + (dot % 3) * 8, 38, alpha)
|
||||||
d.ellipse([dx - rr, dy - rr, dx + rr, dy + rr], fill=color)
|
d.ellipse([dx - rr, dy - rr, dx + rr, dy + rr], fill=color)
|
||||||
@@ -1510,7 +1530,7 @@ def render_player_death_sheet():
|
|||||||
work.putalpha(work.getchannel("A").point(lambda alpha: round(alpha * fade)))
|
work.putalpha(work.getchannel("A").point(lambda alpha: round(alpha * fade)))
|
||||||
rotation = spin * burst
|
rotation = spin * burst
|
||||||
if rotation:
|
if rotation:
|
||||||
work = work.rotate(rotation, Image.Resampling.NEAREST, expand=True)
|
work = work.rotate(rotation, resample_rotate, expand=True)
|
||||||
cx = origin_x + source.width / 2 + relx
|
cx = origin_x + source.width / 2 + relx
|
||||||
cy = origin_y + source.height / 2 + rely
|
cy = origin_y + source.height / 2 + rely
|
||||||
travel = speed * burst
|
travel = speed * burst
|
||||||
@@ -1518,7 +1538,7 @@ def render_player_death_sheet():
|
|||||||
cy += math.sin(ang) * travel + rely * burst * 0.45
|
cy += math.sin(ang) * travel + rely * burst * 0.45
|
||||||
frame.alpha_composite(work, (round(cx - work.width / 2), round(cy - work.height / 2)))
|
frame.alpha_composite(work, (round(cx - work.width / 2), round(cy - work.height / 2)))
|
||||||
sheet.alpha_composite(frame, (index * frame_size, 0))
|
sheet.alpha_composite(frame, (index * frame_size, 0))
|
||||||
sheet.save(OUT / "player_death_sheet.png", optimize=True)
|
sheet.save(out_dir / "player_death_sheet.png", optimize=True)
|
||||||
return sheet
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+327
@@ -0,0 +1,327 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate 4x HD WebP asset tier from raw AI source art.
|
||||||
|
|
||||||
|
Reads ``assets/raw/<name>.png`` (1024x1024 magenta-keyed originals), applies
|
||||||
|
the same crop/composition as the SD pipeline but at 4x the shipped size,
|
||||||
|
without palette quantization, and saves as WebP (q90, method=4).
|
||||||
|
|
||||||
|
Sheets are rendered at 4x via draw_sprites renderers, bridged through a
|
||||||
|
temp dir of PNG stills, then converted to WebP.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
python tools/hd_assets.py # full generation pass
|
||||||
|
python tools/hd_assets.py boss player # substring filter
|
||||||
|
python tools/hd_assets.py --force # overwrite existing
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from shutil import rmtree
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from gen_assets import SPECS, flood_key_magenta
|
||||||
|
from draw_sprites import (
|
||||||
|
ACTORS,
|
||||||
|
render_actor_sheet,
|
||||||
|
render_legs_sheet,
|
||||||
|
render_player_death_sheet,
|
||||||
|
)
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
RAW = ROOT / "assets" / "raw"
|
||||||
|
SHIPPED = ROOT / "assets"
|
||||||
|
HD = ROOT / "assets" / "hd"
|
||||||
|
|
||||||
|
# Composition-source stills that exist in assets/raw/ but are not in SPECS.
|
||||||
|
EXTRA_STILLS = {"player_leg", "player_waist"}
|
||||||
|
|
||||||
|
STILL_NAMES: set[str] = set(SPECS) | EXTRA_STILLS
|
||||||
|
|
||||||
|
WEBP_KWARGS = {"quality": 90, "method": 4}
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _shipped_size(name: str) -> int:
|
||||||
|
"""Return the HD target side length = 4 x shipped image side."""
|
||||||
|
with Image.open(SHIPPED / f"{name}.png") as im:
|
||||||
|
w, h = im.size
|
||||||
|
return max(w, h) * 4
|
||||||
|
|
||||||
|
|
||||||
|
def _process_sprite(raw_path: Path, target_size: int) -> Image.Image | None:
|
||||||
|
"""Replicate ``gen_assets.process_sprite`` minus palette quantization."""
|
||||||
|
image = flood_key_magenta(Image.open(raw_path))
|
||||||
|
box = image.getbbox()
|
||||||
|
if not box:
|
||||||
|
return None
|
||||||
|
image = image.crop(box)
|
||||||
|
margin = max(1, target_size // 16)
|
||||||
|
inner = target_size - margin * 2
|
||||||
|
scale = min(inner / image.width, inner / image.height)
|
||||||
|
dims = (
|
||||||
|
max(1, round(image.width * scale)),
|
||||||
|
max(1, round(image.height * scale)),
|
||||||
|
)
|
||||||
|
image = image.resize(dims, Image.Resampling.LANCZOS)
|
||||||
|
canvas = Image.new("RGBA", (target_size, target_size), (0, 0, 0, 0))
|
||||||
|
canvas.alpha_composite(
|
||||||
|
image,
|
||||||
|
((target_size - dims[0]) // 2, (target_size - dims[1]) // 2),
|
||||||
|
)
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def _process_tile(raw_path: Path, target_size: int) -> Image.Image:
|
||||||
|
"""Replicate ``gen_assets.process_tile`` minus palette quantization."""
|
||||||
|
image = Image.open(raw_path).convert("RGB")
|
||||||
|
side = min(image.size)
|
||||||
|
left = (image.width - side) // 2
|
||||||
|
top = (image.height - side) // 2
|
||||||
|
image = image.crop((left, top, left + side, top + side))
|
||||||
|
return image.resize((target_size, target_size), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_webp(image: Image.Image, path: Path) -> None:
|
||||||
|
"""Save image as WebP preserving alpha transparently."""
|
||||||
|
image.save(str(path), **WEBP_KWARGS)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_png_to_webp(src: Path, dst: Path) -> None:
|
||||||
|
"""Open a PNG and re-save as WebP."""
|
||||||
|
_save_webp(Image.open(src), dst)
|
||||||
|
|
||||||
|
|
||||||
|
def _hd_total_size() -> int:
|
||||||
|
"""Total bytes of all files under ``assets/hd/``."""
|
||||||
|
if not HD.is_dir():
|
||||||
|
return 0
|
||||||
|
return sum(f.stat().st_size for f in HD.rglob("*") if f.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"filters",
|
||||||
|
nargs="*",
|
||||||
|
help="asset-name substrings (match any → process; no filters = all)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="overwrite existing WebP outputs",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
# ── main ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
filters = args.filters
|
||||||
|
force = args.force
|
||||||
|
|
||||||
|
HD.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="hd_assets_"))
|
||||||
|
print(f"Using temp dir: {tmp}\n")
|
||||||
|
|
||||||
|
# Filter stills.
|
||||||
|
stills_to_process = [
|
||||||
|
n for n in STILL_NAMES
|
||||||
|
if not filters or any(f in n for f in filters)
|
||||||
|
]
|
||||||
|
|
||||||
|
missing_raw: list[str] = []
|
||||||
|
processed_stills = 0
|
||||||
|
processed_sheets = 0
|
||||||
|
skip_stills = 0
|
||||||
|
|
||||||
|
# ── Pass 1 – stills ──────────────────────────────────────────
|
||||||
|
|
||||||
|
for name in stills_to_process:
|
||||||
|
raw_path = RAW / f"{name}.png"
|
||||||
|
if not raw_path.is_file():
|
||||||
|
missing_raw.append(name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_size = _shipped_size(name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Shipped asset missing.
|
||||||
|
continue
|
||||||
|
|
||||||
|
spec = SPECS.get(name)
|
||||||
|
is_tile = spec is not None and spec.kind == "tile"
|
||||||
|
|
||||||
|
if is_tile:
|
||||||
|
result = _process_tile(raw_path, target_size)
|
||||||
|
else:
|
||||||
|
result = _process_sprite(raw_path, target_size)
|
||||||
|
if result is None:
|
||||||
|
print(f" ERROR {name}: magenta-key emptied image, skipping")
|
||||||
|
missing_raw.append(name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Always save PNG to temp dir for sheet compositing.
|
||||||
|
result.save(str(tmp / f"{name}.png"))
|
||||||
|
|
||||||
|
webp_path = HD / f"{name}.webp"
|
||||||
|
if not force and webp_path.is_file():
|
||||||
|
print(f" SKIP {name}.webp (exists)")
|
||||||
|
skip_stills += 1
|
||||||
|
else:
|
||||||
|
_save_webp(result, webp_path)
|
||||||
|
print(f" STILL {name}.webp ({target_size}x{target_size})")
|
||||||
|
processed_stills += 1
|
||||||
|
|
||||||
|
# Ensure every still that sheet compositing needs is present as
|
||||||
|
# HD PNG in the temp dir. For items excluded by filters, or items
|
||||||
|
# whose raw was missing (no bbox etc.), read back the already-made
|
||||||
|
# WebP and convert to PNG, or fall back to re-processing from raw.
|
||||||
|
for name in STILL_NAMES:
|
||||||
|
png_in_tmp = tmp / f"{name}.png"
|
||||||
|
if png_in_tmp.is_file():
|
||||||
|
continue
|
||||||
|
# Try reading back from already-existing WebP.
|
||||||
|
webp_path = HD / f"{name}.webp"
|
||||||
|
if webp_path.is_file():
|
||||||
|
Image.open(webp_path).save(str(png_in_tmp))
|
||||||
|
continue
|
||||||
|
# Last chance: process from raw.
|
||||||
|
raw_path = RAW / f"{name}.png"
|
||||||
|
if not raw_path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
target_size = _shipped_size(name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
spec = SPECS.get(name)
|
||||||
|
is_tile = spec is not None and spec.kind == "tile"
|
||||||
|
if is_tile:
|
||||||
|
result = _process_tile(raw_path, target_size)
|
||||||
|
else:
|
||||||
|
result = _process_sprite(raw_path, target_size)
|
||||||
|
if result is not None:
|
||||||
|
result.save(str(png_in_tmp))
|
||||||
|
webp_path = HD / f"{name}.webp"
|
||||||
|
if force or not webp_path.is_file():
|
||||||
|
_save_webp(result, webp_path)
|
||||||
|
processed_stills += 1
|
||||||
|
|
||||||
|
# ── Pass 2 – sheets ──────────────────────────────────────────
|
||||||
|
|
||||||
|
# Determine which actor sheets to render.
|
||||||
|
sheet_actors = [
|
||||||
|
a for a in ACTORS
|
||||||
|
if not filters or any(f in a for f in filters)
|
||||||
|
or any(f in f"{a}_sheet" for f in filters)
|
||||||
|
]
|
||||||
|
|
||||||
|
do_legs = (
|
||||||
|
not filters
|
||||||
|
or any(f in "player_legs" for f in filters)
|
||||||
|
or any(f in "player" for f in filters)
|
||||||
|
)
|
||||||
|
do_death = (
|
||||||
|
not filters
|
||||||
|
or any(f in "player_death" for f in filters)
|
||||||
|
or any(f in "player" for f in filters)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _maybe_sheet(
|
||||||
|
png_name: str,
|
||||||
|
render_fn,
|
||||||
|
*render_args,
|
||||||
|
**render_kwargs,
|
||||||
|
) -> None:
|
||||||
|
"""Render a single sheet if its WebP does not yet exist."""
|
||||||
|
nonlocal processed_sheets
|
||||||
|
webp_name = png_name.rsplit(".", 1)[0] + ".webp"
|
||||||
|
webp_path = HD / webp_name
|
||||||
|
if not force and webp_path.is_file():
|
||||||
|
print(f" SKIP {webp_name} (exists)")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
render_fn(*render_args, **render_kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" ERROR {webp_name}: {exc}")
|
||||||
|
return
|
||||||
|
png_path = tmp / png_name
|
||||||
|
if png_path.is_file():
|
||||||
|
_convert_png_to_webp(png_path, webp_path)
|
||||||
|
print(f" SHEET {webp_name} ({png_path.stat().st_size} bytes)")
|
||||||
|
processed_sheets += 1
|
||||||
|
png_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
for actor in sheet_actors:
|
||||||
|
_maybe_sheet(
|
||||||
|
f"{actor}_sheet.png",
|
||||||
|
render_actor_sheet,
|
||||||
|
actor,
|
||||||
|
use_existing=True,
|
||||||
|
scale=4,
|
||||||
|
smooth=True,
|
||||||
|
src_dir=tmp,
|
||||||
|
out_dir=tmp,
|
||||||
|
)
|
||||||
|
|
||||||
|
if do_legs:
|
||||||
|
_maybe_sheet(
|
||||||
|
"player_legs_sheet.png",
|
||||||
|
render_legs_sheet,
|
||||||
|
scale=4,
|
||||||
|
smooth=True,
|
||||||
|
src_dir=tmp,
|
||||||
|
out_dir=tmp,
|
||||||
|
)
|
||||||
|
_maybe_sheet(
|
||||||
|
"player_legs.png",
|
||||||
|
render_legs_sheet,
|
||||||
|
scale=4,
|
||||||
|
smooth=True,
|
||||||
|
src_dir=tmp,
|
||||||
|
out_dir=tmp,
|
||||||
|
)
|
||||||
|
|
||||||
|
if do_death:
|
||||||
|
_maybe_sheet(
|
||||||
|
"player_death_sheet.png",
|
||||||
|
render_player_death_sheet,
|
||||||
|
scale=4,
|
||||||
|
smooth=True,
|
||||||
|
src_dir=tmp,
|
||||||
|
out_dir=tmp,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up temp dir.
|
||||||
|
rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
# ── Report ──────────────────────────────────────────────────
|
||||||
|
total_size = _hd_total_size()
|
||||||
|
print()
|
||||||
|
print(f" Still images processed: {processed_stills}")
|
||||||
|
print(f" Sheets processed: {processed_sheets}")
|
||||||
|
print(f" Skipped (already exist): {skip_stills}")
|
||||||
|
if missing_raw:
|
||||||
|
print(f" Missing raw sources: {len(missing_raw)}")
|
||||||
|
for n in missing_raw:
|
||||||
|
print(f" - {n}")
|
||||||
|
print(" (runtime per-sprite SD fallback when raw is absent)")
|
||||||
|
print(f" assets/hd/ total size: {total_size} bytes")
|
||||||
|
if missing_raw:
|
||||||
|
print(" Note: missing-raw assets fall back to SD at runtime")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user