- Billiards: add native Russian Pyramid & American Eight-Ball pool with physics prediction, controls, and regression tests - Scratch Tickets: add dedicated counter, mat presentation, elliptical coin scraping, 4 ticket types, and payout effects - First-Person Dice: add native dice scene, hand anchor frames, throwing animation, and table perspective - City Wake & Weather: add arrival cutscene, alley geometry, weather/rain effects, and lighter/rocker animation - World Dressing: add hall paintings, furniture packs, hall lighting, and scene regression tests - Build & Tooling: update build.bat for modern MSVC toolchain, update launcher hash, and refresh shipping binaries
69 lines
3.2 KiB
Python
69 lines
3.2 KiB
Python
"""Offline art normalization to the exact assembly table and ball anchors."""
|
|
from pathlib import Path
|
|
import struct
|
|
from PIL import Image
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SOURCE = ROOT / "assets/sprites/pyramid"
|
|
OUT = ROOT / "assets/animations/pyramid"
|
|
|
|
|
|
def pack(name, frames):
|
|
w, h = frames[0].size
|
|
payload = b"".join(frame.tobytes("raw", "BGRA") for frame in frames)
|
|
(OUT / name).write_bytes(struct.pack("<4s4I", b"ANM1", w, h, len(frames), len(payload)) + payload)
|
|
|
|
|
|
def main():
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
source = Image.open(SOURCE / "table-v2.png").convert("RGBA")
|
|
# Nine-slice-style normalization keeps the mouths and cushion noses aligned
|
|
# with the shared logical cloth rectangle, rather than scaling the whole art.
|
|
source_x = [0, 128, 160, 723, 1290, 1320, 1448]
|
|
source_y = [0, 197, 220, 733, 747, 1086]
|
|
target_x = [0, 60, 61, 320, 579, 580, 640]
|
|
target_y = [0, 100, 101, 359, 360, 480]
|
|
frame = Image.new("RGBA", (640, 480))
|
|
for row in range(len(target_y) - 1):
|
|
for col in range(len(target_x) - 1):
|
|
cell = source.crop((round(source_x[col] * source.width / 1448),
|
|
round(source_y[row] * source.height / 1086),
|
|
round(source_x[col + 1] * source.width / 1448),
|
|
round(source_y[row + 1] * source.height / 1086)))
|
|
cell = cell.resize((target_x[col + 1] - target_x[col],
|
|
target_y[row + 1] - target_y[row]), Image.Resampling.NEAREST)
|
|
frame.paste(cell, (target_x[col], target_y[row]))
|
|
frame.putalpha(255)
|
|
pack("table-v2.bin", [frame])
|
|
sheet = Image.open(SOURCE / "balls-v1.png").convert("RGBA")
|
|
frames = []
|
|
for i in range(8):
|
|
col, row = i % 4, i // 4
|
|
cell = sheet.crop((round(col * sheet.width / 4), round(row * sheet.height / 2),
|
|
round((col + 1) * sheet.width / 4), round((row + 1) * sheet.height / 2)))
|
|
# Ignore faint generator fringe outside the actual circular ball.
|
|
cell.putalpha(cell.getchannel("A").point(lambda a: 255 if a >= 192 else 0))
|
|
cell = cell.crop(cell.getchannel("A").getbbox())
|
|
cell.thumbnail((10, 10), Image.Resampling.LANCZOS)
|
|
frame = Image.new("RGBA", (12, 12))
|
|
frame.paste(cell, ((12 - cell.width) // 2, (12 - cell.height) // 2))
|
|
frames.append(frame)
|
|
pack("balls-v1.bin", frames)
|
|
sheet = Image.open(SOURCE / "pool-balls-v1.png").convert("RGBA")
|
|
frames = []
|
|
for i in range(16):
|
|
col, row = i % 4, i // 4
|
|
cell = sheet.crop((round(col * sheet.width / 4), round(row * sheet.height / 4),
|
|
round((col + 1) * sheet.width / 4), round((row + 1) * sheet.height / 4)))
|
|
cell.putalpha(cell.getchannel("A").point(lambda a: 255 if a >= 192 else 0))
|
|
cell = cell.crop(cell.getchannel("A").getbbox())
|
|
cell.thumbnail((10, 10), Image.Resampling.LANCZOS)
|
|
frame = Image.new("RGBA", (12, 12))
|
|
frame.paste(cell, ((12 - cell.width) // 2, (12 - cell.height) // 2))
|
|
frames.append(frame)
|
|
pack("pool-balls-v1.bin", frames)
|
|
print("Packed anchored 640x480 table, eight pyramid poses and sixteen pool balls")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|