"""Offline alpha/gutter normalization for the MASM hall animation packs.""" import json from pathlib import Path from PIL import Image, ImageDraw, ImageFont from pack_arrival_assets import solid_alpha, write_pack import pack_arrival_assets as packs ROOT = Path(__file__).resolve().parents[2] SOURCE = ROOT / "assets/sprites/hall" OUTPUT = ROOT / "assets/animations/hall" def extract(name, columns, rows): source = solid_alpha(Image.open(SOURCE / name).convert("RGBA")) row_spans, start = [], None alpha = source.getchannel("A") for y in range(source.height + 1): occupied = y < source.height and alpha.crop((0, y, source.width, y + 1)).getbbox() if occupied and start is None: start = y if not occupied and start is not None: if y - start > 30: row_spans.append((start, y)) start = None if len(row_spans) != rows: row_spans = [(row * source.height // rows, (row + 1) * source.height // rows) for row in range(rows)] sprites = [] for row, (top, bottom) in enumerate(row_spans): strip = source.crop((0, top, source.width, bottom)) 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 > 24: spans.append((start, x)) start = None assert len(spans) == columns, (name, row, spans) for left, right in spans: sprite = strip.crop((left, 0, right, strip.height)) sprites.append(sprite.crop(sprite.getchannel("A").getbbox())) return sprites def normalize(sprites, size, common=True): scale = min((size[0] - 8) / max(s.width for s in sprites), (size[1] - 8) / max(s.height for s in sprites)) frames = [] for sprite in sprites: factor = scale if common else min((size[0] - 8) / sprite.width, (size[1] - 8) / sprite.height) sprite = sprite.resize((round(sprite.width * factor), round(sprite.height * factor)), Image.Resampling.NEAREST) frame = Image.new("RGBA", size) frame.alpha_composite(sprite, ((size[0] - sprite.width) // 2, size[1] - 4 - sprite.height)) frames.append(frame) return frames def main(): OUTPUT.mkdir(parents=True, exist_ok=True) packs.OUTPUT = OUTPUT props = extract("props-v1.png", 3, 3) # A detached cue below the table is a separate sheet item, not its floor anchor. alpha = props[4].getchannel("A") spans, start = [], None for y in range(alpha.height + 1): occupied = y < alpha.height and alpha.crop((0, y, alpha.width, y + 1)).getbbox() if occupied and start is None: start = y if not occupied and start is not None: spans.append((start, y)) start = None top, bottom = max(spans, key=lambda span: span[1] - span[0]) props[4] = props[4].crop((0, top, props[4].width, bottom)) frames = { "bartender.bin": normalize(extract("bartender-v1.png", 4, 4), (256, 384)), "props.bin": normalize(props, (256, 256), False), "drink.bin": normalize(extract("drink-hand-v1.png", 4, 2), (256, 256)), } patrons = extract("patrons-v2.png", 4, 4) frames["patrons.bin"] = (normalize(patrons[:4], (192, 320)) + normalize(patrons[4:8], (192, 320)) + normalize(patrons[8:], (192, 320), False)) materials = Image.open(SOURCE / "room-materials-v2.png").convert("RGBA") surfaces = [] for row in range(3): for column in range(4): tile = materials.crop((column * materials.width // 4, row * materials.height // 3, (column + 1) * materials.width // 4, (row + 1) * materials.height // 3)) tile = tile.crop((3, 3, tile.width - 3, tile.height - 3)) surfaces.append(tile.resize((128, 128), Image.Resampling.LANCZOS)) frames["room-materials.bin"] = surfaces frames["room-materials-hd.bin"] = [] architecture = Image.open(SOURCE / "trader-architecture-v3.png").convert("RGBA") frames["architecture.bin"], frames["architecture-hd.bin"] = [], [] for source, low, high in ((materials, None, "room-materials-hd.bin"), (architecture, "architecture.bin", "architecture-hd.bin")): for row in range(3): for column in range(4): tile = source.crop((column * source.width // 4, row * source.height // 3, (column + 1) * source.width // 4, (row + 1) * source.height // 3)) tile = tile.crop((3, 3, tile.width - 3, tile.height - 3)) if low: frames[low].append(tile.resize((128, 128), Image.Resampling.LANCZOS)) frames[high].append(tile.resize((256, 256), Image.Resampling.LANCZOS)) # Individual bounds keep the bay-window finial and desk lamp, which cross nominal rows. prop_source = solid_alpha(Image.open(SOURCE / "architecture-props-v3.png").convert("RGBA")) bounds = [(0, 0, 560, 525), (560, 0, 1030, 525), (1030, 0, 1536, 590), (0, 580, 515, 1024), (515, 555, 1040, 1024), (1040, 590, 1536, 1024)] decor = [] for index, bound in enumerate(bounds): item = prop_source.crop(bound) item = item.crop(item.getchannel("A").getbbox()) if index == 4: item = item.crop((0, 0, item.width, round(item.height * .78))) decor.append(item) frames["architecture-props.bin"] = normalize(decor, (256, 256), False) for name, items in frames.items(): write_pack(name, items) preview = Image.new("RGBA", (items[0].width * 4, items[0].height * ((len(items) + 3) // 4)), (24, 24, 26, 255)) for index, frame in enumerate(items): preview.alpha_composite(frame, ((index % 4) * frame.width, (index // 4) * frame.height)) preview.save(ROOT / "build/hall" / (name + ".png")) sign = Image.new("RGBA", (384, 128), (54, 17, 23, 255)) draw = ImageDraw.Draw(sign) draw.rectangle((2, 2, 381, 125), outline=(182, 146, 72), width=5) font = ImageFont.truetype("C:/Windows/Fonts/arialbd.ttf", 77) box = draw.textbbox((0, 0), "CASINO", font=font) draw.text(((384 - (box[2] - box[0])) // 2, (128 - (box[3] - box[1])) // 2 - box[1]), "CASINO", font=font, fill=(252, 227, 153), stroke_width=1) write_pack("sign.bin", [sign.crop((i * 128, 0, (i + 1) * 128, 128)) for i in range(3)]) sign.save(ROOT / "build/hall/sign.png") tiles = Image.open(ROOT / "assets/sprites/arrival/city-tiles-pixel-v4.png").convert("RGBA") detailed = [] for row in range(4): for column in range(4): tile = tiles.crop((column * tiles.width // 4, row * tiles.height // 4, (column + 1) * tiles.width // 4, (row + 1) * tiles.height // 4)) inset = max(2, min(tile.size) // 80) detailed.append(tile.crop((inset, inset, tile.width - inset, tile.height - inset)) .resize((256, 256), Image.Resampling.NEAREST)) write_pack("city-detail.bin", detailed) (OUTPUT / "manifest.json").write_text(json.dumps({name: {"frames": len(items), "size": items[0].size} for name, items in frames.items()}, indent=2), encoding="utf-8") print("Packed bartender (16), hall props (9), drinking hand (8)") if __name__ == "__main__": main()