KAZIK/tools/generators/pack_buckshot_assets.py
Artvasil09 3ad5bf4c9d Develop assembly-only casino scenes and native games
Add native Durak, poker, blackjack, roulette, Buckshot and billiards scenes with animation assets and regression tests. Improve arrival, city, hall, profiles, shops, audio and exit handling; remove the portable C game core and refresh shipping executables.
2026-10-08 22:39:19 +03:00

180 lines
8.6 KiB
Python

"""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()