132 lines
6.0 KiB
Python
132 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Deterministically package imagegen artwork and synthesize mission PCM cues.
|
|
|
|
Source artwork is generated by the built-in image tool. This packaging step
|
|
only resizes/quantizes it and slices a shared terrain canvas; it paints no art.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import random
|
|
import struct
|
|
import wave
|
|
from pathlib import Path
|
|
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 768-byte original VGA palette")
|
|
colors = [(min(v, 63) << 2) | (min(v, 63) >> 4) for v in raw]
|
|
palette = Image.new("P", (1, 1))
|
|
palette.putpalette(colors)
|
|
return palette, colors
|
|
|
|
|
|
def sd(source, size, palette):
|
|
resized = source.convert("RGB").resize(size, Image.Resampling.LANCZOS)
|
|
return resized.quantize(palette=palette, dither=Image.Dither.NONE).convert("RGBA")
|
|
|
|
|
|
def synth(name, duration, notes, alarm=False):
|
|
rate = 22050
|
|
rng = random.Random(70404 + sum(map(ord, name)))
|
|
samples = bytearray()
|
|
for i in range(int(rate * duration)):
|
|
t = i / rate
|
|
segment = min(len(notes) - 1, int(t / duration * len(notes)))
|
|
local = t / duration * len(notes) - segment
|
|
freq = notes[segment]
|
|
envelope = min(1.0, local * 30) * (1 - local) ** 1.3
|
|
tone = math.sin(2 * math.pi * freq * t)
|
|
tone += 0.23 * math.sin(2 * math.pi * freq * 2 * t)
|
|
if alarm:
|
|
tone += 0.14 * math.sin(2 * math.pi * freq * 0.5 * t)
|
|
tone += 0.07 * rng.uniform(-1, 1) * math.exp(-local * 8)
|
|
value = int(tone * envelope * 7800)
|
|
samples.extend(struct.pack("<h", max(-32767, min(32767, value))))
|
|
path = ROOT / "audio" / f"{name}.wav"
|
|
with wave.open(str(path), "wb") as output:
|
|
output.setnchannels(1)
|
|
output.setsampwidth(2)
|
|
output.setframerate(rate)
|
|
output.writeframes(samples)
|
|
return {"file": str(path.relative_to(ROOT)), "seconds": duration,
|
|
"sample_rate": rate, "samples": len(samples) // 2,
|
|
"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)
|
|
source = Image.open(art / "terrain-source-hd.png")
|
|
hd = source.convert("RGBA").resize((1152, 1536), Image.Resampling.LANCZOS)
|
|
standard = sd(source, (288, 384), palette)
|
|
# One full-map palette conversion precedes slicing. Forward/reflected rows
|
|
# form a continuous 24-row period without independently filtered tile edges.
|
|
tile = 0
|
|
for reflected in [False, True]:
|
|
h = ImageOps.flip(hd) if reflected else hd
|
|
s = ImageOps.flip(standard) if reflected else standard
|
|
for row in range(12):
|
|
for column in range(9):
|
|
h.crop((column*128, row*128, (column+1)*128, (row+1)*128)).save(
|
|
art / f"tile_{tile:03}_hd.png")
|
|
s.crop((column*32, row*32, (column+1)*32, (row+1)*32)).save(
|
|
art / f"tile_{tile:03}_sd.png")
|
|
tile += 1
|
|
for image in [hd, standard]:
|
|
width, height = image.size
|
|
reflected = ImageOps.flip(image)
|
|
assert image.crop((0,height-1,width,height)).tobytes() == reflected.crop((0,0,width,1)).tobytes()
|
|
assert reflected.crop((0,height-1,width,height)).tobytes() == image.crop((0,0,width,1)).tobytes()
|
|
for scale, master, suffix in [(128,hd,"hd"),(32,standard,"sd")]:
|
|
joined = Image.new("RGBA",(9*scale,24*scale))
|
|
for number in range(216):
|
|
joined.paste(Image.open(art/f"tile_{number:03}_{suffix}.png"),
|
|
((number%9)*scale,(number//9)*scale))
|
|
assert joined.crop((0,0,9*scale,12*scale)).tobytes() == master.tobytes()
|
|
assert joined.crop((0,12*scale,9*scale,24*scale)).tobytes() == ImageOps.flip(master).tobytes()
|
|
hd.save(art / "terrain-preview-hd.png")
|
|
standard.save(art / "terrain-preview-sd.png")
|
|
briefing = Image.open(art / "briefing-source-hd.png")
|
|
briefing.convert("RGBA").resize((1280,800), Image.Resampling.LANCZOS).save(art / "briefing_hd.png")
|
|
briefing.convert("RGBA").resize((1280,800), Image.Resampling.LANCZOS).convert("RGB").resize((320,200), Image.Resampling.LANCZOS).save(art / "briefing_sd.png")
|
|
palette_colors = {tuple(colors[i:i+3]) for i in range(0,768,3)}
|
|
rgb_pixels = standard.convert("RGB").tobytes()
|
|
sd_colors = {tuple(rgb_pixels[i:i+3]) for i in range(0,len(rgb_pixels),3)}
|
|
assert sd_colors <= palette_colors
|
|
audio = {
|
|
"approach": synth("approach", 0.9, [130.81, 196.0, 146.83]),
|
|
"battery_alarm": synth("battery_alarm", 0.8, [369.99, 246.94, 369.99, 246.94], True),
|
|
"cruiser_alarm": synth("cruiser_alarm", 1.1, [164.81, 130.81, 98.0], True),
|
|
"victory": synth("victory", 1.15, [293.66, 369.99, 440.0, 587.33]),
|
|
}
|
|
report = {
|
|
"tile_count": tile, "logical_tile_size": [32,32], "hd_tile_size": [128,128],
|
|
"terrain_period_rows": 24, "mission_rows": 96,
|
|
"vertical_repeat": "12 forward rows followed by 12 reflected rows",
|
|
"seam_checks_passed": True, "joined_tile_canvas_checks_passed": True,
|
|
"sd_pixels_match_original_palette": True,
|
|
"actual_sd_unique_colors": len(sd_colors),
|
|
"palette_sha256": hashlib.sha256(args.palette.read_bytes()).hexdigest(),
|
|
"sources": {p.name: {"size": list(Image.open(p).size),
|
|
"sha256": hashlib.sha256(p.read_bytes()).hexdigest()}
|
|
for p in art.glob("*-source-hd.png")}, "audio": audio,
|
|
}
|
|
(ROOT / "asset-report.json").write_text(json.dumps(report,indent=2)+"\n")
|
|
print(f"Packaged {tile} SD/HD terrain pairs, briefing pair and {len(audio)} synthesized WAVs")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|