Add rooftop elevator, lounge, volumetric city, river and animated traffic. Prune invisible mesh faces, clip CPU surfaces, streamline compositing and add automatic/F8 roof quality profiles with renderer diagnostics. Include dartboard/reticle improvements, tracked fortune orb numerals and fate-session visuals, native regression tests, compatible jukebox IDs and updated shipping executables.
102 lines
4.5 KiB
Python
102 lines
4.5 KiB
Python
"""Offline orb tracking metadata and antialiased numeral sprites; no runtime Python."""
|
|
from pathlib import Path
|
|
import json
|
|
import struct
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
OUT = ROOT / "assets/animations/fortune"
|
|
ART = ROOT / "assets/sprites/fortune"
|
|
PREVIEW = ROOT / "build/hall-expansion"
|
|
|
|
|
|
def load_orbs():
|
|
raw = (OUT / "orb-mist-v1.bin").read_bytes()
|
|
magic, width, height, count, length = struct.unpack_from("<4s4I", raw)
|
|
assert magic == b"ANM1" and count == 16
|
|
assert length == width * height * count * 4 == len(raw) - 20
|
|
stride = width * height * 4
|
|
return [Image.frombytes("RGBA", (width, height), raw[20+i*stride:20+(i+1)*stride],
|
|
"raw", "BGRA") for i in range(count)]
|
|
|
|
|
|
def sphere_geometry(frame):
|
|
# Fit the upper glass silhouette, explicitly excluding the metal pedestal.
|
|
alpha = np.asarray(frame.getchannel("A"))
|
|
rows, midpoints, half_widths = [], [], []
|
|
for y in range(12, round(frame.height * .70)):
|
|
xs = np.flatnonzero(alpha[y] >= 170)
|
|
if len(xs) < frame.width * .35:
|
|
continue
|
|
left, right = int(xs[0]), int(xs[-1])
|
|
rows.append(y)
|
|
midpoints.append((left + right) / 2)
|
|
half_widths.append((right - left) / 2)
|
|
a, b, c = np.polyfit(rows, np.square(half_widths), 2)
|
|
assert a < 0
|
|
cy = -b / (2*a)
|
|
cx = float(np.median(midpoints))
|
|
rx = float(np.sqrt(c - b*b/(4*a)))
|
|
ry = float(rx / np.sqrt(-a))
|
|
assert 70 < cx < 125 and 60 < cy < 115
|
|
assert 55 < rx < 95 and 55 < ry < 95
|
|
return {"cx": cx, "cy": cy, "rx": rx, "ry": ry}
|
|
|
|
|
|
def numeral(digit, font):
|
|
factor = 4
|
|
mask = Image.new("L", (32*factor, 48*factor))
|
|
draw = ImageDraw.Draw(mask)
|
|
box = draw.textbbox((0, 0), str(digit), font=font)
|
|
x = (mask.width-(box[2]-box[0]))/2-box[0]
|
|
y = (mask.height-(box[3]-box[1]))/2-box[1]
|
|
draw.text((round(x), round(y)), str(digit), font=font, fill=255)
|
|
mask = mask.resize((32, 48), Image.Resampling.LANCZOS)
|
|
shadow = Image.new("RGBA", mask.size, (8, 30, 66, 0))
|
|
shadow.putalpha(mask.filter(ImageFilter.MaxFilter(3)).point(lambda a: round(a*.65)))
|
|
halo = Image.new("RGBA", mask.size, (65, 168, 243, 0))
|
|
halo.putalpha(mask.filter(ImageFilter.GaussianBlur(2.0)).point(lambda a: round(a*.65)))
|
|
ink = Image.new("RGBA", mask.size, (200, 235, 255, 0))
|
|
ink.putalpha(mask.point(lambda a: round(a*.90)))
|
|
return Image.alpha_composite(Image.alpha_composite(halo, shadow), ink)
|
|
|
|
|
|
def main():
|
|
PREVIEW.mkdir(parents=True, exist_ok=True)
|
|
orbs = load_orbs()
|
|
geometry = [sphere_geometry(frame) for frame in orbs]
|
|
# Q8 offsets in the exact 128px runtime drawing rectangle, not its centre.
|
|
lines = ["; Generated by tools/generators/pack_fortune_orb.py; glass only, pedestal excluded."]
|
|
for name, key in (("fortuneOrbAnchorX3D", "cx"), ("fortuneOrbAnchorY3D", "cy")):
|
|
values = [round(item[key] * 128 / 192 * 256) for item in geometry]
|
|
lines.append(name + " dd " + ",".join(map(str, values)))
|
|
(ROOT / "src_graphics/fortune_orb_art.inc").write_text("\n".join(lines)+"\n", encoding="ascii")
|
|
font = ImageFont.truetype("C:/Windows/Fonts/cambria.ttc", 34*4)
|
|
digits = [numeral(i, font) for i in range(10)]
|
|
payload = b"".join(frame.tobytes("raw", "BGRA") for frame in digits)
|
|
(OUT / "orb-numerals-v1.bin").write_bytes(struct.pack("<4s4I", b"ANM1", 32, 48, 10,
|
|
len(payload)) + payload)
|
|
sheet = Image.new("RGBA", (320, 48))
|
|
for i, digit in enumerate(digits):
|
|
sheet.alpha_composite(digit, (32*i, 0))
|
|
sheet.save(ART / "orb-numerals-v1.png")
|
|
(ART / "orb-tracking-v1.json").write_text(json.dumps({"canvas": [192, 192],
|
|
"font": "Cambria Regular, rasterized at 4x; no font needed at runtime",
|
|
"frames": geometry}, indent=2)+"\n", encoding="ascii")
|
|
sheet = Image.new("RGB", (192*4, 192*4), (5, 15, 28))
|
|
for i, (frame, item) in enumerate(zip(orbs, geometry)):
|
|
debug = frame.copy()
|
|
draw = ImageDraw.Draw(debug)
|
|
x, y = item["cx"], item["cy"]
|
|
draw.ellipse((x-item["rx"], y-item["ry"], x+item["rx"], y+item["ry"]), outline="yellow")
|
|
draw.line((x-5, y, x+5, y), fill="yellow")
|
|
draw.line((x, y-5, x, y+5), fill="yellow")
|
|
sheet.paste(debug, ((i%4)*192, (i//4)*192), debug)
|
|
sheet.save(PREVIEW / "fortune-orb-tracking.png")
|
|
print("Packed 10 antialiased Cambria numerals and 16 measured glass-centre anchors.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|