McRogueFace/shade_sprite/formats.py

261 lines
8.2 KiB
Python

"""Sheet layout definitions for Merchant Shade sprite packs.
Data-driven animation format descriptions. Each SheetFormat defines the
tile dimensions, direction layout, and animation frame sequences for a
specific sprite sheet layout.
"""
from dataclasses import dataclass, field
from enum import IntEnum
class Direction(IntEnum):
"""8-directional facing. Row index matches Puny Character sheet layout."""
S = 0
SW = 1
W = 2
NW = 3
N = 4
NE = 5
E = 6
SE = 7
@dataclass
class AnimFrame:
"""A single frame in an animation sequence."""
col: int # Column index in the sheet
duration: int # Duration in milliseconds
@dataclass
class AnimDef:
"""Definition of a single animation type."""
name: str
frames: list # list of AnimFrame
loop: bool = True
chain_to: str = None # animation to play after one-shot finishes
@dataclass
class SheetFormat:
"""Complete definition of a sprite sheet layout."""
name: str
tile_w: int # Pixel width of each frame
tile_h: int # Pixel height of each frame
columns: int # Total columns in the sheet
rows: int # Total rows (directions)
directions: int # Number of directional rows (4 or 8)
animations: dict = field(default_factory=dict) # name -> AnimDef
grid_cell: tuple = (16, 16) # Target grid cell size
render_offset: tuple = (0, 0) # Pixel offset for rendering on grid
def direction_row(self, d):
"""Get row index for a direction, wrapping for 4-dir and 1-dir sheets."""
if self.directions == 1:
return 0
if self.directions == 8:
return int(d)
# 4-dir: S=0, W=1, E=2, N=3
mapping = {
Direction.S: 0, Direction.SW: 0,
Direction.W: 1, Direction.NW: 1,
Direction.N: 3, Direction.NE: 3,
Direction.E: 2, Direction.SE: 2,
}
return mapping.get(d, 0)
def sprite_index(self, col, direction):
"""Get the flat sprite index for a column and direction."""
row = self.direction_row(direction)
return row * self.columns + col
def _make_anim(name, start_col, count, ms_per_frame, loop=True, chain_to="idle"):
"""Helper to create a simple sequential animation."""
frames = [AnimFrame(col=start_col + i, duration=ms_per_frame)
for i in range(count)]
if loop:
return AnimDef(name=name, frames=frames, loop=True)
return AnimDef(name=name, frames=frames, loop=False, chain_to=chain_to)
# =============================================================================
# 29-column paid Puny Character format (928x256 @ 32x32)
# =============================================================================
_puny29_anims = {}
# idle: cols 0-1, 300ms each, loop
_puny29_anims["idle"] = AnimDef("idle", [
AnimFrame(0, 300), AnimFrame(1, 300),
], loop=True)
# walk: cols 1-4, 200ms each, loop
_puny29_anims["walk"] = AnimDef("walk", [
AnimFrame(1, 200), AnimFrame(2, 200), AnimFrame(3, 200), AnimFrame(4, 200),
], loop=True)
# slash: cols 5-8, 100ms each, one-shot
_puny29_anims["slash"] = _make_anim("slash", 5, 4, 100, loop=False)
# bow: cols 9-12, 100ms each, one-shot
_puny29_anims["bow"] = _make_anim("bow", 9, 4, 100, loop=False)
# thrust: cols 13-15 + repeat last, 100ms each, one-shot
_puny29_anims["thrust"] = AnimDef("thrust", [
AnimFrame(13, 100), AnimFrame(14, 100), AnimFrame(15, 100), AnimFrame(15, 100),
], loop=False, chain_to="idle")
# spellcast: cols 16-18 + repeat last, 100ms each, one-shot
_puny29_anims["spellcast"] = AnimDef("spellcast", [
AnimFrame(16, 100), AnimFrame(17, 100), AnimFrame(18, 100), AnimFrame(18, 100),
], loop=False, chain_to="idle")
# hurt: cols 19-21, 100ms each, one-shot
_puny29_anims["hurt"] = _make_anim("hurt", 19, 3, 100, loop=False)
# death: cols 22-24, 100ms + 800ms hold, one-shot (no chain)
_puny29_anims["death"] = AnimDef("death", [
AnimFrame(22, 100), AnimFrame(23, 100), AnimFrame(24, 800),
], loop=False, chain_to=None)
# dodge: bounce pattern 25,26,25,27, 200ms each, one-shot
_puny29_anims["dodge"] = AnimDef("dodge", [
AnimFrame(25, 200), AnimFrame(26, 200), AnimFrame(25, 200), AnimFrame(27, 200),
], loop=False, chain_to="idle")
# item_use: col 28, mixed timing, one-shot
_puny29_anims["item_use"] = AnimDef("item_use", [
AnimFrame(28, 300),
], loop=False, chain_to="idle")
PUNY_29 = SheetFormat(
name="puny_29",
tile_w=32, tile_h=32,
columns=29, rows=8,
directions=8,
animations=_puny29_anims,
grid_cell=(16, 16),
render_offset=(-8, -16),
)
# =============================================================================
# 24-column free Puny Character format (768x256 @ 32x32)
# =============================================================================
_puny24_anims = {}
# Same layout but without dodge, item_use, and death has fewer frames
_puny24_anims["idle"] = AnimDef("idle", [
AnimFrame(0, 300), AnimFrame(1, 300),
], loop=True)
_puny24_anims["walk"] = AnimDef("walk", [
AnimFrame(1, 200), AnimFrame(2, 200), AnimFrame(3, 200), AnimFrame(4, 200),
], loop=True)
_puny24_anims["slash"] = _make_anim("slash", 5, 4, 100, loop=False)
_puny24_anims["bow"] = _make_anim("bow", 9, 4, 100, loop=False)
_puny24_anims["thrust"] = AnimDef("thrust", [
AnimFrame(13, 100), AnimFrame(14, 100), AnimFrame(15, 100), AnimFrame(15, 100),
], loop=False, chain_to="idle")
_puny24_anims["spellcast"] = AnimDef("spellcast", [
AnimFrame(16, 100), AnimFrame(17, 100), AnimFrame(18, 100), AnimFrame(18, 100),
], loop=False, chain_to="idle")
_puny24_anims["hurt"] = _make_anim("hurt", 19, 3, 100, loop=False)
_puny24_anims["death"] = AnimDef("death", [
AnimFrame(22, 100), AnimFrame(23, 100),
], loop=False, chain_to=None)
PUNY_24 = SheetFormat(
name="puny_24",
tile_w=32, tile_h=32,
columns=24, rows=8,
directions=8,
animations=_puny24_anims,
grid_cell=(16, 16),
render_offset=(-8, -16),
)
# =============================================================================
# RPG Maker creature format (288x192, 3x4 per character, 4 chars per sheet)
# =============================================================================
_creature_rpg_anims = {}
# Walk: 3 columns (left-step, stand, right-step), 200ms each, loop
_creature_rpg_anims["idle"] = AnimDef("idle", [
AnimFrame(1, 400),
], loop=True)
_creature_rpg_anims["walk"] = AnimDef("walk", [
AnimFrame(0, 200), AnimFrame(1, 200), AnimFrame(2, 200), AnimFrame(1, 200),
], loop=True)
CREATURE_RPGMAKER = SheetFormat(
name="creature_rpgmaker",
tile_w=24, tile_h=24,
columns=3, rows=4,
directions=4,
animations=_creature_rpg_anims,
grid_cell=(16, 16),
render_offset=(-4, -8),
)
# =============================================================================
# Slime format (480x32, 15x1, non-directional)
# =============================================================================
_slime_anims = {}
_slime_anims["idle"] = AnimDef("idle", [
AnimFrame(0, 300), AnimFrame(1, 300),
], loop=True)
_slime_anims["walk"] = AnimDef("walk", [
AnimFrame(0, 200), AnimFrame(1, 200), AnimFrame(2, 200), AnimFrame(3, 200),
], loop=True)
SLIME = SheetFormat(
name="slime",
tile_w=32, tile_h=32,
columns=15, rows=1,
directions=1,
animations=_slime_anims,
grid_cell=(16, 16),
render_offset=(-8, -16),
)
# =============================================================================
# Format auto-detection
# =============================================================================
_FORMAT_TABLE = {
(928, 256): PUNY_29,
(768, 256): PUNY_24,
(480, 32): SLIME,
# RPG Maker sheets: 288x192 is 4 characters, each 72x192 / 3x4
# Individual characters extracted: 72x96 (3 cols x 4 rows of 24x24)
(72, 96): CREATURE_RPGMAKER,
(288, 192): CREATURE_RPGMAKER, # Full sheet (need sub-region extraction)
}
def detect_format(width, height):
"""Auto-detect sheet format from pixel dimensions.
Args:
width: Image width in pixels
height: Image height in pixels
Returns:
SheetFormat or None if no match found
"""
return _FORMAT_TABLE.get((width, height))
# All predefined formats for iteration
ALL_FORMATS = [PUNY_29, PUNY_24, CREATURE_RPGMAKER, SLIME]