Files
RustRaptor/mods/iron_coast_boss/build_assets.py

137 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Convert saved imagegen artwork and synthesize reproducible package-local WAVs.
This script resizes/atlases supplied art; it never redraws source or generated art.
Generated masters, SD/HD artwork and WAVs are tracked in Git. See prompts/ and provenance.json.
Use --audio-only to regenerate sound without Pillow or processing artwork.
"""
import argparse
import hashlib
import json
import math
from pathlib import Path
import random
import struct
import wave
def sounds(root, kind):
rate = 22050
values = {
"scout": (0.11, 1400, 380, 0.34, 230, 19),
"turret": (0.18, 900, 160, 0.52, 150, 37),
"boss": (0.24, 500, 80, 0.90, 75, 83),
}
shot_length, start, end, death_length, bass, seed = values[kind]
(root / "audio").mkdir(exist_ok=True)
stats = {}
for name, duration in [("shot", shot_length), ("death", death_length)]:
rng = random.Random(seed + (100 if name == "death" else 0))
count = int(duration * rate)
phase = low_noise = 0.0
samples = []
for index in range(count):
t = index / rate
position = index / max(1, count - 1)
noise = rng.uniform(-1, 1)
low_noise = low_noise * 0.86 + noise * 0.14
if name == "shot":
frequency = end + (start - end) * (1 - position) ** 2
phase += 2 * math.pi * frequency / rate
value = (0.60 * math.sin(phase) + 0.15 * math.sin(2.31 * phase)
+ 0.16 * noise + 0.16 * math.sin(2 * math.pi * bass * t))
envelope = min(1, t / 0.002) * math.exp(-position * 6.0)
else:
frequency = bass * (1.0 - position * 0.65)
phase += 2 * math.pi * frequency / rate
value = 0.62 * low_noise + 0.19 * noise + 0.28 * math.sin(phase)
envelope = min(1, t / 0.003) * math.exp(-position * 4.7)
# Five milliseconds at the tail avoid discontinuities at mixer retirement.
envelope *= min(1, (count - 1 - index) / (rate * 0.005))
samples.append(value * max(0, envelope))
peak = max(abs(value) for value in samples)
pcm = [round(value / peak * 0.78 * 32767) for value in samples]
path = root / "audio" / (name + ".wav")
with wave.open(str(path), "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(rate)
output.writeframes(struct.pack("<" + "h" * len(pcm), *pcm))
stats[str(path.relative_to(root))] = {
"sample_rate": rate, "channels": 1, "bits": 16,
"samples": count, "peak": max(abs(value) for value in pcm),
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
(root / "audio-recipe.json").write_text(json.dumps({
"generator": "build_assets.py deterministic chirp/noise synthesis; no recordings",
"kind": kind, "seed": seed, "files": stats,
}, indent=2) + "\n")
def artwork(root, kind):
from PIL import Image
dimensions = {"scout": (24, 24), "turret": (32, 32), "boss": (96, 64)}
width, height = dimensions[kind]
paths = [root / "art/unit-generated-v1.png"]
if kind == "boss":
paths.append(root / "art/unit-charge-generated-v1.png")
hd_frames, sd_frames = [], []
stats = []
for path in paths:
source = Image.open(path).convert("RGBA")
if source.width * height != source.height * width:
raise ValueError(f"{path}: source canvas aspect differs from logical canvas")
# Identical canvas resize and padding for all frames retain their registration.
hd = Image.new("RGBA", (width * 8, height * 8), (0, 0, 0, 0))
pad_x, pad_y = width // 3, height // 3
resized = source.resize((hd.width - 2 * pad_x, hd.height - 2 * pad_y), Image.Resampling.LANCZOS)
hd.alpha_composite(resized, (pad_x, pad_y))
sd = hd.resize((width, height), Image.Resampling.BOX)
for image in [hd, sd]:
bbox = image.getchannel("A").point(lambda value: 255 if value >= 128 else 0).getbbox()
if bbox is None or bbox[0] == 0 or bbox[1] == 0 or bbox[2] == image.width or bbox[3] == image.height:
raise ValueError(f"{path}: opaque silhouette touches logical canvas edge")
hd_frames.append(hd)
sd_frames.append(sd)
stats.append({"source": str(path.relative_to(root)),
"source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"sd_opaque_bounds": list(sd.getchannel("A").point(lambda value: 255 if value >= 128 else 0).getbbox())})
for name, frames in [("unit-hd.png", hd_frames), ("unit-sd.png", sd_frames)]:
atlas = Image.new("RGBA", (frames[0].width * len(frames), frames[0].height), (0, 0, 0, 0))
for index, frame in enumerate(frames):
atlas.alpha_composite(frame, (index * frame.width, 0))
atlas.save(root / "art" / name)
# Shared visual ammunition language; each package includes its own local copy.
source = Image.open(root / "art/projectile-generated-v1.png").convert("RGBA")
hd = source.resize((64, 64), Image.Resampling.LANCZOS)
hd.save(root / "art/projectile-hd.png")
hd.resize((8, 8), Image.Resampling.BOX).save(root / "art/projectile-sd.png")
metadata = {"logical_size": [width, height], "hd_scale": 8, "origin": [0, 0],
"frames": len(paths), "frame_order": ["idle", "charged"][:len(paths)],
"alpha": "Preserved RGBA; runtime uses alpha>=128 indexed transparency",
"registration": "Common full-canvas resize and equal padding for every frame",
"sources": stats}
if len(hd_frames) == 2:
masks = [f.getchannel("A").point(lambda value: 255 if value >= 128 else 0) for f in hd_frames]
pairs = list(zip(masks[0].get_flattened_data(), masks[1].get_flattened_data()))
metadata["animation_silhouette_overlap"] = sum(bool(a and b) for a, b in pairs) / sum(bool(a or b) for a, b in pairs)
if metadata["animation_silhouette_overlap"] < 0.99:
raise ValueError("generated animation changes the silhouette beyond accepted registration tolerance")
(root / "art-qa.json").write_text(json.dumps(metadata, indent=2) + "\n")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--audio-only", action="store_true")
args = parser.parse_args()
root = Path(__file__).resolve().parent
kind = root.name.removeprefix("iron_coast_")
sounds(root, kind)
if not args.audio_only:
artwork(root, kind)
print(f"Generated {root.name} package assets")
if __name__ == "__main__":
main()