KAZIK/tools/generators/pack_arrival_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

238 lines
12 KiB
Python

"""Offline sprite normalization; game decoding/compositing is entirely MASM."""
import json
import struct
from pathlib import Path
from PIL import Image, ImageOps
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "assets/sprites/arrival"
OUTPUT = ROOT / "assets/animations/arrival"
def write_pack(name, frames):
width, height = frames[0].size
assert all(frame.size == (width, height) for frame in frames)
payload = b"".join(frame.tobytes("raw", "BGRA") for frame in frames)
(OUTPUT / name).write_bytes(struct.pack("<4sIIII", b"ANM1", width, height,
len(frames), len(payload)) + payload)
def sheet(name, columns, rows, size, baseline=False, pixel=False):
source = Image.open(SOURCE / name).convert("RGBA")
assert source.getchannel("A").getextrema()[0] == 0, "Real alpha required"
frames = []
# Keep the sheet's common anchor for people; props have different aspect ratios.
for row in range(rows):
for column in range(columns):
frame = source.crop((column * source.width // columns,
row * source.height // rows,
(column + 1) * source.width // columns,
(row + 1) * source.height // rows))
if baseline:
frame = frame.crop(frame.getchannel("A").getbbox())
frame.thumbnail((size[0] - 8, size[1] - 12),
Image.Resampling.NEAREST if pixel else Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", size)
canvas.alpha_composite(frame, ((size[0] - frame.width) // 2,
size[1] - 6 - frame.height))
frame = canvas
else:
frame = frame.resize(size, Image.Resampling.NEAREST if pixel else Image.Resampling.LANCZOS)
frames.append(frame)
return frames
def solid_alpha(frame):
# Solid geometry must never carry the generator's translucent matte into the game.
frame.putalpha(frame.getchannel("A").point(lambda value: 255 if value >= 128 else 0))
return frame
def car_sheet():
source = solid_alpha(Image.open(SOURCE / "city-cars-pixel-v4.png").convert("RGBA"))
sprites = []
# Generated side views exceed nominal cells. Split at the real transparent gutters.
for row in range(4):
strip = source.crop((0, row * source.height // 4, source.width,
(row + 1) * source.height // 4))
mask = strip.getchannel("A")
spans = []
start = None
for x in range(strip.width + 1):
occupied = x < strip.width and mask.crop((x, 0, x + 1, strip.height)).getbbox()
if occupied and start is None:
start = x
if not occupied and start is not None:
if x - start > 80:
spans.append((start, x))
start = None
assert len(spans) == 4, f"Car row {row} does not have four separated silhouettes: {spans}"
for left, right in spans:
sprite = strip.crop((left, 0, right, strip.height))
sprites.append(sprite.crop(sprite.getchannel("A").getbbox()))
scale = min(248 / max(sprite.width for sprite in sprites),
244 / max(sprite.height for sprite in sprites))
frames = []
for sprite in sprites:
sprite = sprite.resize((round(sprite.width * scale), round(sprite.height * scale)),
Image.Resampling.NEAREST)
canvas = Image.new("RGBA", (256, 256))
canvas.alpha_composite(sprite, ((256 - sprite.width) // 2, 250 - sprite.height))
frames.append(canvas)
return frames
def main():
OUTPUT.mkdir(parents=True, exist_ok=True)
background = Image.open(SOURCE / "casino-street-v1.png").convert("RGBA")
background = ImageOps.fit(background, (640, 480), method=Image.Resampling.LANCZOS)
write_pack("street.bin", [background])
active_table = Image.open(ROOT / "assets/sprites/tables/card-table-pixel-v3.png").convert("RGBA")
active_table = ImageOps.fit(active_table, (320, 240), method=Image.Resampling.NEAREST)
active_table = active_table.resize((640, 480), Image.Resampling.NEAREST)
write_pack("table-active.bin", [active_table])
guards = sheet("guard-poses-pixel-v3.png", 8, 4, (192, 256), pixel=True)
merchant = sheet("sidorovich-poses-pixel-v3.png", 8, 3, (128, 128), pixel=True)
props = sheet("hall-props-pixel-v2.png", 3, 3, (128, 128), baseline=True, pixel=True)
write_pack("guards.bin", guards)
write_pack("merchant.bin", merchant)
write_pack("props.bin", props)
backs = sheet("card-backs-v1.png", 4, 4, (68, 96))[:13]
write_pack("card-backs.bin", backs)
hands = sheet("table-hands-pixel-v3.png", 4, 2, (144, 192), pixel=True)
write_pack("table-hands.bin", hands)
material_source = Image.open(SOURCE / "hall-materials-v1.png").convert("RGBA")
# This generated sheet's horizontal divider is at 45%, not its geometric center.
split = round(material_source.height * 0.45)
material_frames = []
for top, bottom in ((0, split), (split, material_source.height)):
for left, right in ((0, material_source.width // 2),
(material_source.width // 2, material_source.width)):
tile = material_source.crop((left, top, right, bottom))
material_frames.append(tile.resize((128, 128), Image.Resampling.LANCZOS))
write_pack("materials.bin", material_frames)
city_tiles = Image.open(SOURCE / "city-tiles-pixel-v4.png").convert("RGBA")
tiles = []
for row in range(4):
for column in range(4):
tile = city_tiles.crop((column * city_tiles.width // 4, row * city_tiles.height // 4,
(column + 1) * city_tiles.width // 4,
(row + 1) * city_tiles.height // 4))
inset = max(2, min(tile.size) // 80)
tile = tile.crop((inset, inset, tile.width - inset, tile.height - inset))
tiles.append(tile.resize((128, 128), Image.Resampling.NEAREST))
write_pack("city-tiles-v4.bin", tiles)
legacy = Image.open(SOURCE / "city-tiles-pixel-v1.png").convert("RGBA")
legacy_tiles = []
for row in range(2):
for column in range(4):
tile = legacy.crop((column * legacy.width // 4, row * legacy.height // 2,
(column + 1) * legacy.width // 4,
(row + 1) * legacy.height // 2))
inset = max(2, min(tile.size) // 80)
tile = tile.crop((inset, inset, tile.width - inset, tile.height - inset))
legacy_tiles.append(tile.resize((128, 128), Image.Resampling.NEAREST))
write_pack("city-tiles.bin", legacy_tiles)
hands = [solid_alpha(frame) for frame in sheet("alley-hands-pixel-v4.png", 4, 4,
(256, 192), pixel=True)]
props = [solid_alpha(frame) for frame in sheet("city-props-pixel-v4.png", 3, 2,
(256, 256), baseline=True, pixel=True)]
smoke = sheet("cigarette-smoke-pixel-v4.png", 4, 1, (96, 128), pixel=True)
write_pack("city-hands.bin", hands)
write_pack("city-props.bin", props)
write_pack("city-smoke.bin", smoke)
make_smoke_anchors()
write_pack("city-clouds.bin", sheet("smoke-clouds-pixel-v4.png", 2, 2, (192, 192), pixel=True))
write_pack("city-cars.bin", car_sheet())
bin_source = Image.open(SOURCE / "city-props-pixel-v4.png").convert("RGBA")
cell = bin_source.crop((0, 0, bin_source.width // 3, bin_source.height // 2))
panels = []
# Orthographic surface patches, not the bin's original perspective silhouette.
for region in ((0.10, 0.48, 0.94, 0.84), (0.17, 0.04, 0.89, 0.25),
(0.19, 0.30, 0.82, 0.39)):
box = tuple(round(value * (cell.width if index % 2 == 0 else cell.height))
for index, value in enumerate(region))
panel = cell.crop(box).resize((128, 128), Image.Resampling.NEAREST)
panel.putalpha(255)
panels.append(panel)
panels.append(Image.new("RGBA", (128, 128), (15, 17, 18, 255)))
write_pack("city-bin-materials.bin", panels)
make_city_map()
preview = Image.new("RGB", (640, 480), (35, 43, 48))
preview.paste(background, (0, 0))
preview.paste(guards[15], (80, 190), guards[15])
preview.paste(guards[0], (422, 190), guards[0])
preview.save(ROOT / "build/arrival/asset-preview.png")
contact = Image.new("RGB", (1536, 1024), (38, 45, 49))
for index, frame in enumerate(guards):
contact.paste(frame, ((index % 8) * 192, (index // 8) * 256), frame)
contact.save(ROOT / "build/arrival/guard-contact.png")
print(json.dumps({"guard_poses": len(guards), "merchant_poses": len(merchant),
"city_props": len(props), "output": str(OUTPUT)}))
def smoke_anchors():
data = (OUTPUT / "city-smoke.bin").read_bytes()
magic, width, height, count, size = struct.unpack_from("<4s4I", data)
assert (magic, width, height, count, size) == (b"ANM1", 96, 128, 4, 96 * 128 * 4 * 4)
roots = []
stride = width * height * 4
for index in range(count):
alpha = Image.frombytes("RGBA", (width, height),
data[20 + index * stride:20 + (index + 1) * stride],
"raw", "BGRA").getchannel("A")
box = alpha.point(lambda value: 255 if value >= 32 else 0).getbbox()
assert box, index
y = box[3] - 1
xs = [x for x in range(width) if alpha.getpixel((x, y)) >= 32]
roots.append((round(sum(xs) / len(xs)), y))
return roots
def make_smoke_anchors():
roots = smoke_anchors()
lines = ["; Generated emitter anchors; transparent sprite gutters are not part of the plume.",
"citySmokeRootX3D dd " + ",".join(str(x) for x, _ in roots),
"citySmokeRootY3D dd " + ",".join(str(y) for _, y in roots)]
(ROOT / "src_graphics/city_smoke_anchors.inc").write_text("\n".join(lines) + "\n", encoding="ascii")
print("Smoke emitter anchors:", roots)
def make_city_map():
grid = [[57] * 32 for _ in range(32)]
for left, right, top, bottom in ((8, 9, 14, 27), (0, 31, 12, 16), (20, 24, 8, 12)):
for y in range(top, bottom + 1):
for x in range(left, right + 1):
grid[y][x] = 0
for y in range(17, 28):
grid[y][7] = 68 if y % 3 else 59
grid[y][10] = 59 if y % 4 else 66
grid[28][8] = 69
grid[28][9] = 71
for x in range(3, 29):
grid[11][x] = (57, 65, 61, 57, 66, 62)[x % 6]
grid[17][x] = (67, 57, 70, 65, 57, 66)[x % 6]
for y in range(17, 28):
for x in range(8, 10):
grid[y][x] = 0
# Restore the open casino approach where it crosses the upper storefront row.
for y in range(8, 13):
for x in range(20, 25):
grid[y][x] = 0
grid[7][22] = 58
# Keep the avenue open through the map edge while extending both block faces.
for y in range(32):
for x in range(28, 32):
grid[y][x] = 0 if 11 <= y <= 17 or x >= 29 else (57, 65, 61, 57, 66, 62)[(x + y) % 6]
for x, y in [(8, y) for y in range(14, 26)] + [(x, 14) for x in range(8, 23)] + \
[(22, y) for y in range(9, 15)]:
assert grid[y][x] == 0, (x, y)
lines = ["; Generated by pack_arrival_assets.py: separate exterior map.", ".data"]
for index, row in enumerate(grid):
lines.append(("cityMap3D db " if index == 0 else " db ") + ",".join(map(str, row)))
(ROOT / "src_graphics/city_map.inc").write_text("\n".join(lines) + "\n", encoding="ascii")
if __name__ == "__main__":
main()