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.
238 lines
12 KiB
Python
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()
|