File size: 2,413 Bytes
7faaef2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
"""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))),
    }