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.
180 lines
8.6 KiB
Python
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()
|