"""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]