"""Offline atlas packing; all in-game animation and compositing run in MASM.""" import json import struct import wave from pathlib import Path import math import random from PIL import Image, ImageOps ROOT = Path(__file__).resolve().parents[2] SOURCE = ROOT / 'assets/sprites/buckshot' OUTPUT = ROOT / 'assets/animations/buckshot' PREVIEW = ROOT / 'build/buckshot' def sheet(name, columns, rows, size, props=False): source = Image.open(SOURCE / name).convert('RGBA') if source.getchannel('A').getextrema()[0] != 0: raise ValueError(f'{name}: transparent source required') cells = [] for row in range(rows): for col in range(columns): cell = source.crop((col * source.width // columns, row * source.height // rows, (col + 1) * source.width // columns, (row + 1) * source.height // rows)) # The generated matte is transparent but can contain low-alpha RGB noise. cell.putalpha(cell.getchannel('A').point(lambda a: 255 if a >= 128 else 0)) bounds = cell.getchannel('A').getbbox() if bounds is None: raise ValueError(f'{name}: empty cell {row * columns + col}') cells.append(cell.crop(bounds)) factor = min((size[0] - 12) / max(c.width for c in cells), (size[1] - 12) / max(c.height for c in cells)) frames = [] for cell in cells: scale = min((size[0] - 12) / cell.width, (size[1] - 12) / cell.height) if props else factor cell = cell.resize((round(cell.width * scale), round(cell.height * scale)), Image.Resampling.NEAREST) frame = Image.new('RGBA', size) y = (size[1] - cell.height) // 2 if props else size[1] - 6 - cell.height frame.alpha_composite(cell, ((size[0] - cell.width) // 2, y)) frames.append(frame) return frames def write_pack(name, frames): width, height = frames[0].size if not 0 < len(frames) <= 64 or not all(f.size == (width, height) for f in frames): raise ValueError(name) data = b''.join(f.tobytes('raw', 'BGRA') for f in frames) (OUTPUT / name).write_bytes(struct.pack('<4sIIII', b'ANM1', width, height, len(frames), len(data)) + data) contact = Image.new('RGBA', (width * 4, height * ((len(frames) + 3) // 4)), (24, 29, 29, 255)) for i, frame in enumerate(frames): contact.alpha_composite(frame, ((i % 4) * width, (i // 4) * height)) contact.save(PREVIEW / (name + '.png')) def screen_effects(name, columns, rows): source = Image.open(SOURCE / name).convert('RGBA') frames = [] for row in range(rows): for col in range(columns): cell = source.crop((col * source.width // columns, row * source.height // rows, (col + 1) * source.width // columns, (row + 1) * source.height // rows)) frames.append(cell.resize((320, 240), Image.Resampling.NEAREST)) return frames def anchored_cells(directory, name, columns=4, rows=4, size=(384, 256)): source = Image.open(directory / name).convert('RGBA') if source.getchannel('A').getextrema()[0] != 0: raise ValueError(f'{name}: actual alpha required') frames = [] for row in range(rows): for col in range(columns): cell = source.crop((col * source.width // columns, row * source.height // rows, (col + 1) * source.width // columns, (row + 1) * source.height // rows)) cell.putalpha(cell.getchannel('A').point(lambda a: 255 if a >= 128 else 0)) # Fixed cell-space origin: no per-pose recentering or scale changes. cell = cell.resize((size[0] - 12, size[1] - 12), Image.Resampling.NEAREST) frame = Image.new('RGBA', size) frame.alpha_composite(cell, (6, 6)) frames.append(frame) return frames def muzzle_effects(name): source = Image.open(SOURCE / name).convert('RGBA') cells = [source.crop((col * source.width // 4, row * source.height // 2, (col + 1) * source.width // 4, (row + 1) * source.height // 2)) for row in range(2) for col in range(4)] anchors = [] for cell in cells[:4]: samples = [(x, y, a) for y in range(cell.height) for x in range(cell.width) for r, g, b, a in [cell.getpixel((x, y))] if r > 240 and g > 220 and b > 150 and a > 64] total = sum(a for x, y, a in samples) if not total: raise ValueError('Frontal flash has no white-hot origin') anchors.append((sum(x * a for x, y, a in samples) / total, sum(y * a for x, y, a in samples) / total)) scale = min(120 / cells[0].width, 120 / cells[0].height) frames = [] for i, cell in enumerate(cells): anchor = anchors[i % 4] scaled = cell.resize((round(cell.width * scale), round(cell.height * scale)), Image.Resampling.NEAREST) frame = Image.new('RGBA', (128, 128)) # A fixed ignition anchor preserves expansion; do not recenter a smoke bounding box. frame.alpha_composite(scaled, (round(64 - anchor[0] * scale), round(64 - anchor[1] * scale))) frames.append(frame) return frames def mechanical_sound(): rate, duration = 22050, .75 rng = random.Random(17) samples = [] for i in range(round(rate * duration)): t, value = i / rate, 0.0 for start, length, gain, freq in ((.02, .13, .24, 1200), (.32, .16, .27, 1800), (.50, .20, .17, 530)): local = t - start if 0 <= local < length: value += gain * math.exp(-local * 28) * (.8 * rng.uniform(-1, 1) + .2 * math.sin(local * freq * math.tau)) samples.append(round(max(-.95, min(.95, value)) * 32767)) audio = ROOT / 'assets/audio/generated/buckshot-pump.wav' with wave.open(str(audio), 'wb') as target: target.setparams((1, 2, rate, 0, 'NONE', 'not compressed')) target.writeframes(struct.pack('<' + 'h' * len(samples), *samples)) def main(): OUTPUT.mkdir(parents=True, exist_ok=True) PREVIEW.mkdir(parents=True, exist_ok=True) room = ImageOps.fit(Image.open(SOURCE / 'room-v2.png').convert('RGBA'), (640, 480), method=Image.Resampling.NEAREST) medical_dealer = anchored_cells(SOURCE, 'medical-dealer-v5.png') medical_original = anchored_cells(SOURCE, 'medical-v3.png') # Keep the dealer's camera fixed: the generated third row changes to an upward view. # The existing frontal shock/release poses fill that phase without a camera jump. medical_dealer = (medical_dealer[:6] + [medical_original[4]] + medical_dealer[6:8] + medical_original[5:7] + medical_dealer[12:16] + [medical_original[7]]) packs = { 'room-v2.bin': [room], 'loading-v2.bin': sheet('loading-v2.png', 4, 3, (320, 280)), 'shots-v2.bin': sheet('shots-v2.png', 4, 4, (320, 280)), 'dealer-v2.bin': sheet('dealer-source-v2.png', 4, 4, (256, 224)), 'props-v2.bin': sheet('props-v2.png', 4, 3, (128, 128), props=True), 'items-v2.bin': sheet('items-v2.png', 4, 2, (256, 256)), 'self-v3.bin': sheet('self-v3.png', 4, 2, (320, 280)), 'death-v3.bin': sheet('death-v3.png', 4, 3, (256, 224)), 'medical-v5.bin': (medical_dealer + anchored_cells(SOURCE, 'medical-player-v5.png')), 'blood-v3.bin': screen_effects('blood-v3.png', 4, 2), 'dealer-actions-v3.bin': sheet('dealer-actions-v3.png', 4, 4, (256, 224)), 'self-flash-v4.bin': muzzle_effects('self-flash-v4.png'), } for name, frames in packs.items(): write_pack(name, frames) rr_hands = anchored_cells(ROOT / 'assets/sprites/roulette', 'hands-v5.png', size=(384, 256)) width, height = rr_hands[0].size payload = b''.join(f.tobytes('raw', 'BGRA') for f in rr_hands) (ROOT / 'assets/animations/roulette/hands-v5.bin').write_bytes( struct.pack('<4s4I', b'ANM1', width, height, len(rr_hands), len(payload)) + payload) mechanical_sound() manifest = {name: {'frames': len(frames), 'size': frames[0].size, 'format': 'ANM1 BGRA'} for name, frames in packs.items()} (OUTPUT / 'manifest-v2.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8') print('Packed background and 148 Buckshot poses; 16 first-person revolver hand poses') if __name__ == '__main__': main()