| """FrameStack -> sprite sheet PNG. |
| |
| A sprite sheet is a single PNG containing N animation frames in a grid. This is |
| the idiomatic asset format for 2D game engines (Babylon.js `SpriteManager`, |
| Phaser, PixiJS) — no codec dependency, instant load, deterministic playback. |
| |
| Convention: frames packed left-to-right, top-to-bottom. Default 4 columns gives |
| square-ish output for 16-frame stacks (4x4 = 16 cells). |
| """ |
| from __future__ import annotations |
| from pathlib import Path |
|
|
| import numpy as np |
| from PIL import Image as PILImage |
|
|
| from pixel_cursor import FrameStack |
|
|
|
|
| def frame_stack_to_sprite_sheet( |
| stack: FrameStack, |
| cols: int = 4, |
| bg_rgba: tuple[int, int, int, int] = (0, 0, 0, 0), |
| ) -> PILImage.Image: |
| """Pack T frames into a (rows*cols)-cell grid PNG. RGBA output.""" |
| t, h, w, c = stack.frames.shape |
| if c not in (3, 4): |
| raise ValueError(f"expected 3 or 4 channels; got shape {stack.frames.shape}") |
| rows = (t + cols - 1) // cols |
| sheet_w = w * cols |
| sheet_h = h * rows |
|
|
| sheet = np.zeros((sheet_h, sheet_w, 4), dtype=np.uint8) |
| sheet[:, :] = bg_rgba |
|
|
| for idx in range(t): |
| r, k = divmod(idx, cols) |
| y0, x0 = r * h, k * w |
| frame = stack.frames[idx] |
| if c == 3: |
| sheet[y0 : y0 + h, x0 : x0 + w, :3] = frame |
| sheet[y0 : y0 + h, x0 : x0 + w, 3] = 255 |
| else: |
| sheet[y0 : y0 + h, x0 : x0 + w, :] = frame |
|
|
| return PILImage.fromarray(sheet, mode="RGBA") |
|
|
|
|
| def write_sprite_sheet( |
| stack: FrameStack, |
| out_path: Path | str, |
| cols: int = 4, |
| bg_rgba: tuple[int, int, int, int] = (0, 0, 0, 0), |
| ) -> Path: |
| out = Path(out_path) |
| img = frame_stack_to_sprite_sheet(stack, cols=cols, bg_rgba=bg_rgba) |
| img.save(out, optimize=True) |
| return out |
|
|
|
|
| def sheet_metadata(stack: FrameStack, cols: int = 4) -> dict: |
| """Return a manifest describing the sheet (cell size, count, fps). |
| |
| Game engines need this to play back. Babylon SpriteManager expects |
| `cellWidth, cellHeight, capacity (total cells)`. |
| """ |
| t, h, w, _ = stack.frames.shape |
| rows = (t + cols - 1) // cols |
| return { |
| "frames": t, |
| "cols": cols, |
| "rows": rows, |
| "cell_width": w, |
| "cell_height": h, |
| "sheet_width": w * cols, |
| "sheet_height": h * rows, |
| "fps": stack.fps, |
| "loop_duration_ms": int(round(1000 * t / max(1, stack.fps))), |
| } |
|
|