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