#!/usr/bin/env python3 """Extrait les graphismes d'un jeu Bitsy : 2 spritesheets PNG + GIF animés (stdlib uniquement). 20261026 / réalisé par un LLM arguments : -o / --output (par défaut : spritesheet.png) : nom de base : produit _1.png, _2.png, .gif, .json -c / --columns (par défaut : 16) : colonnes -s / --scale (par défaut : 1) : facteur d'agrandissement -p / --palette (par défaut : 1) : id de palette -d / --delay (par défaut : 400) : durée d'une frame en ms --opaque : fond de palette aussi pour sprites et objets (sinon transparent) --no-individual : ne pas générer un GIF par graphique exemples : python3 bitsy_extract_sprites_anim.py mon_jeu.html -o sheet.png -s 8 -c 8 # -> sheet_1.png, sheet_2.png, sheet.gif, sheet.json, sheet_gifs/*.gif python3 bitsy_extract_sprites_anim.py mon_jeu.html -d 250 --no-individual """ import argparse import json import re import struct import sys import zlib from pathlib import Path PIXEL_ROW = re.compile(r"[01]+") RGB = re.compile(r"\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*$") HEADER = re.compile(r"(PAL|TIL|SPR|ITM)\s+(\S+)\s*$") # Index de couleur par défaut dans une palette Bitsy : 0 = fond, 1 = tuiles, 2 = sprites/objets DEFAULT_COL = {"TIL": 1, "SPR": 2, "ITM": 2} KIND_LABEL = {"TIL": "tile", "SPR": "sprite", "ITM": "item"} KIND_ORDER = {"TIL": 0, "SPR": 1, "ITM": 2} BITSY_FRAME_MS = 400 # --------------------------------------------------------------------------- # # Lecture des données Bitsy # --------------------------------------------------------------------------- # def extract_game_data(text: str) -> str: """Retourne les données Bitsy, qu'il s'agisse d'un export HTML ou d'un fichier brut.""" m = re.search( r']*id=["\']exportedGameData["\'][^>]*>(.*?)', text, re.S ) return m.group(1) if m else text def parse_bitsy(data: str): """Retourne (palettes, graphiques).""" palettes = {} graphics = [] for block in re.split(r"\n\s*\n", data.replace("\r\n", "\n").replace("\r", "\n")): lines = [l.strip() for l in block.strip().split("\n")] if not lines: continue m = HEADER.match(lines[0]) if not m: continue kind, gid = m.groups() if kind == "PAL": palettes[gid] = [ tuple(int(v) for v in c.groups()) for c in (RGB.match(l) for l in lines[1:]) if c ] continue frames = [[]] name = None col = DEFAULT_COL[kind] for l in lines[1:]: if PIXEL_ROW.fullmatch(l): frames[-1].append(l) elif l == ">": frames.append([]) elif l.startswith("NAME "): name = l[5:].strip() elif l.startswith("COL "): try: col = int(l[4:].strip()) except ValueError: pass frames = [f for f in frames if f] if frames: graphics.append( {"kind": kind, "id": gid, "name": name, "col": col, "frames": frames} ) return palettes, graphics # --------------------------------------------------------------------------- # # Rendu en pixels indexés # --------------------------------------------------------------------------- # def make_color_table(palette): """Table de couleurs : couleurs de la palette + une entrée transparente. Retourne (table_complète_pour_GIF, bits, index_transparent, couleurs).""" colors = [ palette[i] if i < len(palette) else (255, 255, 255) for i in range(max(3, len(palette))) ] transparent = len(colors) bits = max(2, transparent.bit_length()) # transparent = dernier index utilisé table = colors + [(0, 0, 0)] * ((1 << bits) - len(colors)) return table, bits, transparent, colors def render_sheet(graphics, frame_idx, columns, rows, cell_w, cell_h, scale, opaque, transparent, ncolors): """Dessine une feuille en indices de palette (1 octet par pixel). frame_idx vaut 0 ou 1 ; un graphique à une seule frame est répété.""" W, H = columns * cell_w * scale, rows * cell_h * scale buf = bytearray([transparent]) * (W * H) for i, g in enumerate(graphics): frame = g["frames"][frame_idx] if frame_idx < len(g["frames"]) else g["frames"][0] cx, cy = (i % columns) * cell_w, (i // columns) * cell_h fg = g["col"] if g["col"] < ncolors else 1 bg = 0 if (g["kind"] == "TIL" or opaque) else transparent # tuiles toujours opaques for y, row in enumerate(frame): line = b"".join(bytes([fg if px == "1" else bg]) * scale for px in row) for dy in range(scale): off = ((cy + y) * scale + dy) * W + cx * scale buf[off:off + len(line)] = line return W, H, buf def crop(buf, W, x, y, w, h): return b"".join(buf[(y + r) * W + x:(y + r) * W + x + w] for r in range(h)) # --------------------------------------------------------------------------- # # Encodeur PNG # --------------------------------------------------------------------------- # def write_png(path, width, height, indices, colors, transparent): # Conversion indices -> RGBA par translate() (rapide) def lut(channel): t = bytearray(256) for i, c in enumerate(colors): t[i] = c[channel] return bytes(t) alpha = bytearray(b"\xff" * 256) alpha[transparent] = 0 raw_rgba = bytearray(width * height * 4) raw_rgba[0::4] = bytes(indices).translate(lut(0)) raw_rgba[1::4] = bytes(indices).translate(lut(1)) raw_rgba[2::4] = bytes(indices).translate(lut(2)) raw_rgba[3::4] = bytes(indices).translate(bytes(alpha)) def chunk(tag, payload): body = tag + payload return struct.pack(">I", len(payload)) + body + struct.pack(">I", zlib.crc32(body)) stride = width * 4 raw = b"".join(b"\x00" + bytes(raw_rgba[y * stride:(y + 1) * stride]) for y in range(height)) png = ( b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"") ) Path(path).write_bytes(png) # --------------------------------------------------------------------------- # # Encodeur GIF (LZW) # --------------------------------------------------------------------------- # def lzw_encode(data, min_size): """Compression LZW au format GIF (codes de largeur variable, bits LSB d'abord).""" clear = 1 << min_size eoi = clear + 1 out = bytearray() bitbuf = 0 bitcnt = 0 size = min_size + 1 def emit(code): nonlocal bitbuf, bitcnt bitbuf |= code << bitcnt bitcnt += size while bitcnt >= 8: out.append(bitbuf & 0xFF) bitbuf >>= 8 bitcnt -= 8 table = {} next_code = eoi + 1 emit(clear) prefix = data[0] for px in data[1:]: key = (prefix, px) code = table.get(key) if code is not None: prefix = code continue emit(prefix) if next_code < 4096: if next_code >= (1 << size) and size < 12: size += 1 table[key] = next_code next_code += 1 else: # table pleine : on la réinitialise emit(clear) table.clear() next_code = eoi + 1 size = min_size + 1 prefix = px emit(prefix) if next_code >= (1 << size) and size < 12: size += 1 emit(eoi) if bitcnt: out.append(bitbuf & 0xFF) return bytes(out) def sub_blocks(data): out = bytearray() for i in range(0, len(data), 255): chunk = data[i:i + 255] out.append(len(chunk)) out += chunk out.append(0) return bytes(out) def write_gif(path, w, h, frames, table, bits, transparent, delay_ms): """Écrit un GIF (animé en boucle si plusieurs frames) à partir de frames indexées.""" delay_cs = max(1, round(delay_ms / 10)) out = bytearray(b"GIF89a") out += struct.pack(" 1: out += b"\x21\xff\x0bNETSCAPE2.0\x03\x01\x00\x00\x00" # boucle infinie for frame in frames: # disposal 2 = on efface avant la frame suivante (indispensable avec la transparence) out += b"\x21\xf9\x04" + bytes([(2 << 2) | 1]) + struct.pack(" 1 entry = { "type": KIND_LABEL[g["kind"]], "id": g["id"], "name": g["name"], "animated": animated, "x": x, "y": y, "w": w, "h": h, } if not args.no_individual: frames = [crop(sheets[0], W, x, y, w, h)] if animated: frames.append(crop(sheets[1], W, x, y, w, h)) label = f"{KIND_LABEL[g['kind']]}_{safe(g['id'])}" if g["name"]: label += f"_{safe(g['name'])}" gif_path = gif_dir / f"{label}.gif" write_gif(gif_path, w, h, frames, table, bits, transparent, args.delay) entry["gif"] = gif_path.name index.append(entry) index_path = out.with_suffix(".json") index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8") print(f"{len(graphics)} graphiques ({W}x{H}) -> {png_paths[0]}, {png_paths[1]}, " f"{sheet_gif}" + (f", {gif_dir}/" if not args.no_individual else "") + f" + {index_path}") if __name__ == "__main__": main()