142 lines
6.8 KiB
Python
142 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Package imagegen sources into palette SD/HD game footprints and synthesize WAVs.
|
|
|
|
This performs deterministic cropping/resampling/palette conversion, not image
|
|
generation. The three imagegen originals remain beside the packaged local files.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import random
|
|
import struct
|
|
import wave
|
|
from pathlib import Path
|
|
import runpy
|
|
from PIL import Image, ImageOps
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
|
|
|
|
def palette_image(path):
|
|
raw = path.read_bytes()
|
|
if len(raw) != 768:
|
|
raise ValueError("expected the original 768-byte VGA palette")
|
|
colors = [(min(value, 63) << 2) | (min(value, 63) >> 4) for value in raw]
|
|
palette = Image.new("P", (1, 1))
|
|
palette.putpalette(colors)
|
|
return palette, colors
|
|
|
|
|
|
def sd(image, size, palette):
|
|
image = image.convert("RGBA").resize(size, Image.Resampling.LANCZOS)
|
|
alpha = image.getchannel("A").point(lambda a: 255 if a >= 128 else 0)
|
|
# Use one full-canvas palette pass before splitting terrain, so tile borders
|
|
# receive identical context and no independent quantizer creates seams.
|
|
rgb = image.convert("RGB").quantize(palette=palette, dither=Image.Dither.NONE).convert("RGBA")
|
|
rgb.putalpha(alpha)
|
|
return rgb
|
|
|
|
|
|
def wav(name, duration, notes, mode="tone"):
|
|
sample_rate = 22050
|
|
rng = random.Random(7053 + sum(map(ord, name)))
|
|
values = bytearray()
|
|
count = int(duration * sample_rate)
|
|
for frame in range(count):
|
|
t = frame / sample_rate
|
|
section = min(len(notes) - 1, int(t / duration * len(notes)))
|
|
frequency = notes[section]
|
|
local = (t / duration * len(notes)) % 1
|
|
envelope = min(1, local * 18) * (1 - local) ** 0.8
|
|
signal = math.sin(2 * math.pi * frequency * t)
|
|
signal += 0.24 * math.sin(2 * math.pi * frequency * 2 * t)
|
|
if mode == "metal":
|
|
signal += 0.45 * rng.uniform(-1, 1) * math.exp(-t * 15)
|
|
signal += 0.3 * math.sin(2 * math.pi * 817 * t) * math.exp(-t * 10)
|
|
if mode == "alarm":
|
|
signal += 0.25 * math.sin(2 * math.pi * frequency * 0.5 * t)
|
|
value = max(-32767, min(32767, int(signal * envelope * 7200)))
|
|
values += struct.pack("<h", value)
|
|
path = ROOT / "audio" / (name + ".wav")
|
|
with wave.open(str(path), "wb") as output:
|
|
output.setnchannels(1)
|
|
output.setsampwidth(2)
|
|
output.setframerate(sample_rate)
|
|
output.writeframes(values)
|
|
return {"file": str(path.relative_to(ROOT)), "samples": count, "seconds": duration,
|
|
"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--palette", type=Path, default=ROOT.parents[2] / "data/misc/PALETTE_DAT")
|
|
args = parser.parse_args()
|
|
art = ROOT / "art"
|
|
(ROOT / "audio").mkdir(exist_ok=True)
|
|
palette, colors = palette_image(args.palette)
|
|
terrain = Image.open(art / "terrain-source-hd.png").convert("RGBA")
|
|
terrain_hd = terrain.resize((1152, 1536), Image.Resampling.LANCZOS)
|
|
terrain_sd = sd(terrain, (288, 384), palette)
|
|
tile_count = 0
|
|
for reverse in (False, True):
|
|
hd_map = ImageOps.flip(terrain_hd) if reverse else terrain_hd
|
|
sd_map = ImageOps.flip(terrain_sd) if reverse else terrain_sd
|
|
for row in range(12):
|
|
for col in range(9):
|
|
number = tile_count
|
|
hd_tile = hd_map.crop((col * 128, row * 128, (col + 1) * 128, (row + 1) * 128))
|
|
sd_tile = sd_map.crop((col * 32, row * 32, (col + 1) * 32, (row + 1) * 32))
|
|
hd_tile.save(art / f"tile_{number:03}_hd.png")
|
|
sd_tile.save(art / f"tile_{number:03}_sd.png")
|
|
tile_count += 1
|
|
# Ping-pong ordering makes both repetition boundaries identical without
|
|
# painting over generated artwork. Validate every seam across the period.
|
|
assert terrain_sd.crop((0, 383, 288, 384)).tobytes() == ImageOps.flip(terrain_sd).crop((0, 0, 288, 1)).tobytes()
|
|
assert ImageOps.flip(terrain_sd).crop((0, 383, 288, 384)).tobytes() == terrain_sd.crop((0, 0, 288, 1)).tobytes()
|
|
assert terrain_hd.crop((0, 1535, 1152, 1536)).tobytes() == ImageOps.flip(terrain_hd).crop((0, 0, 1152, 1)).tobytes()
|
|
terrain_sd.save(art / "terrain-preview-sd.png")
|
|
terrain_hd.save(art / "terrain-preview-hd.png")
|
|
# The cinematic briefing has its own unchanged generated master.
|
|
runpy.run_path(str(art / "build_cinematic_briefing.py"), run_name="__main__")
|
|
convoy = Image.open(art / "convoy-source-hd.png").convert("RGBA")
|
|
# Use the generated alpha bounds, then preserve its complete silhouette in
|
|
# the logical canvas with a small transparent border. This is packaging.
|
|
bounds = convoy.getchannel("A").getbbox()
|
|
if bounds is None:
|
|
raise ValueError("generated convoy has no opaque pixels")
|
|
convoy = convoy.crop(bounds)
|
|
hd_canvas = Image.new("RGBA", (96, 160))
|
|
fitted = ImageOps.contain(convoy, (88, 152), Image.Resampling.LANCZOS)
|
|
hd_canvas.alpha_composite(fitted, ((96-fitted.width)//2, (160-fitted.height)//2))
|
|
hd_canvas.save(art / "convoy_hd.png")
|
|
sd(hd_canvas, (24, 40), palette).save(art / "convoy_sd.png")
|
|
audio = {
|
|
"approach": wav("approach", 0.85, [146, 220, 293]),
|
|
"convoy_alarm": wav("convoy_alarm", 0.75, [392, 294, 392], "alarm"),
|
|
"boss_alarm": wav("boss_alarm", 1.1, [220, 165, 110], "alarm"),
|
|
"cargo_salvage": wav("cargo_salvage", 0.5, [440, 587, 740], "metal"),
|
|
"victory": wav("victory", 1.2, [294, 370, 440, 588]),
|
|
}
|
|
report = {"tile_count": tile_count, "tile_logical_size": [32, 32],
|
|
"hd_tile_size": [128, 128], "sd_palette_colors": 256,
|
|
"palette_sha256": hashlib.sha256(args.palette.read_bytes()).hexdigest(),
|
|
"vertical_repeat": "12 rows forward + 12 rows reflected; matching boundary pixels",
|
|
"seam_checks_passed": True,
|
|
"convoy_transparent_pixels": sum(1 for a in Image.open(art / "convoy_sd.png").getchannel("A").tobytes() if a == 0),
|
|
"audio": audio}
|
|
palette_colors = set(tuple(colors[i:i+3]) for i in range(0, 768, 3))
|
|
actual_colors = set(tuple(terrain_sd.convert("RGB").tobytes()[i:i+3]) for i in range(0, 288*384*3, 3))
|
|
assert actual_colors <= palette_colors
|
|
report["actual_sd_unique_colors"] = len(actual_colors)
|
|
report["sd_pixels_match_original_palette"] = True
|
|
report["sources"] = {path.name: {"size": Image.open(path).size,
|
|
"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
for path in art.glob("*-source-hd.png")}
|
|
(ROOT / "asset-report.json").write_text(json.dumps(report, indent=2) + "\n")
|
|
print(f"Packaged {tile_count} SD/HD tiles, briefing/convoy art and {len(audio)} WAVs")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|