diff --git a/shade_sprite/__init__.py b/shade_sprite/__init__.py new file mode 100644 index 0000000..b3da59d --- /dev/null +++ b/shade_sprite/__init__.py @@ -0,0 +1,66 @@ +"""shade_sprite - Sprite animation and compositing for Merchant Shade sprite packs. + +A standalone module for McRogueFace that loads, composites, and animates +layered pixel art character sheets. Supports multiple sprite sheet formats +including the Puny Characters pack (paid & free), RPG Maker creatures, +and slime sheets. + +Quick start: + from shade_sprite import AnimatedSprite, Direction, PUNY_29 + import mcrfpy + + tex = mcrfpy.Texture("Warrior-Red.png", 32, 32) + sprite = mcrfpy.Sprite(texture=tex, pos=(100, 100), scale=2.0) + scene.children.append(sprite) + + anim = AnimatedSprite(sprite, PUNY_29) + anim.play("walk") + anim.direction = Direction.E + + def tick_anims(timer, runtime): + anim.tick(timer.interval) + mcrfpy.Timer("anim", tick_anims, 50) + +For layered characters: + from shade_sprite import CharacterAssembler, PUNY_29 + + assembler = CharacterAssembler(PUNY_29) + assembler.add_layer("skins/Human1.png") + assembler.add_layer("clothes/BasicBlue-Body.png", hue_shift=120.0) + assembler.add_layer("hair/M-Hairstyle1-Black.png") + texture = assembler.build("my_character") +""" + +from .formats import ( + Direction, + AnimFrame, + AnimDef, + SheetFormat, + PUNY_29, + PUNY_24, + CREATURE_RPGMAKER, + SLIME, + ALL_FORMATS, + detect_format, +) +from .animation import AnimatedSprite +from .assembler import CharacterAssembler + +__all__ = [ + # Core classes + "AnimatedSprite", + "CharacterAssembler", + # Format definitions + "Direction", + "AnimFrame", + "AnimDef", + "SheetFormat", + # Predefined formats + "PUNY_29", + "PUNY_24", + "CREATURE_RPGMAKER", + "SLIME", + "ALL_FORMATS", + # Utilities + "detect_format", +] diff --git a/shade_sprite/animation.py b/shade_sprite/animation.py new file mode 100644 index 0000000..3132c50 --- /dev/null +++ b/shade_sprite/animation.py @@ -0,0 +1,127 @@ +"""AnimatedSprite - animation state machine for sprite sheet playback. + +Wraps an mcrfpy.Sprite with frame timing and directional animation. +Call tick() each frame (or from a timer) to advance the animation. +""" +from .formats import Direction, SheetFormat, AnimDef + + +class AnimatedSprite: + """Animates an mcrfpy.Sprite using a SheetFormat definition. + + The sprite's sprite_index is updated automatically based on the + current animation, direction, and elapsed time. + + Args: + sprite: An mcrfpy.Sprite object to animate + fmt: SheetFormat describing the sheet layout + direction: Initial facing direction (default: Direction.S) + """ + + def __init__(self, sprite, fmt, direction=Direction.S): + self.sprite = sprite + self.fmt = fmt + self._direction = direction + self._anim_name = None + self._anim = None + self._frame_idx = 0 + self._elapsed = 0.0 + self._finished = False + + # Start with idle if available + if "idle" in fmt.animations: + self.play("idle") + + @property + def direction(self): + return self._direction + + @direction.setter + def direction(self, d): + if not isinstance(d, Direction): + d = Direction(d) + if d != self._direction: + self._direction = d + self._update_tile() + + @property + def animation_name(self): + return self._anim_name + + @property + def frame_index(self): + return self._frame_idx + + @property + def finished(self): + return self._finished + + def set_direction(self, d): + """Set facing direction. Updates tile immediately.""" + self.direction = d + + def play(self, anim_name): + """Start playing a named animation. + + Args: + anim_name: Animation name (must exist in the format's animations dict) + + Raises: + KeyError: If animation name not found in format + """ + if anim_name not in self.fmt.animations: + raise KeyError( + f"Animation '{anim_name}' not found in format '{self.fmt.name}'. " + f"Available: {list(self.fmt.animations.keys())}" + ) + self._anim_name = anim_name + self._anim = self.fmt.animations[anim_name] + self._frame_idx = 0 + self._elapsed = 0.0 + self._finished = False + self._update_tile() + + def tick(self, dt_ms): + """Advance animation clock by dt_ms milliseconds. + + Call this from a timer callback or game loop. Updates the + sprite's sprite_index when frames change. + + Args: + dt_ms: Time elapsed in milliseconds since last tick + """ + if self._anim is None or self._finished: + return + + self._elapsed += dt_ms + frames = self._anim.frames + + # Advance frames while we have accumulated enough time + while self._elapsed >= frames[self._frame_idx].duration: + self._elapsed -= frames[self._frame_idx].duration + self._frame_idx += 1 + + if self._frame_idx >= len(frames): + if self._anim.loop: + self._frame_idx = 0 + else: + # One-shot finished + if self._anim.chain_to and self._anim.chain_to in self.fmt.animations: + self.play(self._anim.chain_to) + return + else: + # Stay on last frame + self._frame_idx = len(frames) - 1 + self._finished = True + self._elapsed = 0.0 + break + + self._update_tile() + + def _update_tile(self): + """Set sprite.sprite_index based on current animation frame and direction.""" + if self._anim is None: + return + frame = self._anim.frames[self._frame_idx] + idx = self.fmt.sprite_index(frame.col, self._direction) + self.sprite.sprite_index = idx diff --git a/shade_sprite/assembler.py b/shade_sprite/assembler.py new file mode 100644 index 0000000..d70e4f8 --- /dev/null +++ b/shade_sprite/assembler.py @@ -0,0 +1,75 @@ +"""CharacterAssembler - composite layered sprite sheets with HSL recoloring. + +Uses the engine's Texture.composite() and texture.hsl_shift() methods to +build composite character textures from multiple layer PNG files, without +requiring PIL or any external Python packages. +""" +import mcrfpy +from .formats import PUNY_29, SheetFormat + + +class CharacterAssembler: + """Build composite character sheets from layer files. + + Layers are added bottom-to-top (skin first, then clothes, hair, etc). + Each layer can be HSL-shifted for recoloring before compositing. + + Args: + fmt: SheetFormat describing the sprite dimensions (default: PUNY_29) + """ + + def __init__(self, fmt=None): + if fmt is None: + fmt = PUNY_29 + self.fmt = fmt + self.layers = [] + + def add_layer(self, path, hue_shift=0.0, sat_shift=0.0, lit_shift=0.0): + """Queue a layer PNG with optional HSL recoloring. + + Args: + path: File path to the layer PNG + hue_shift: Hue rotation in degrees [0, 360) + sat_shift: Saturation adjustment [-1.0, 1.0] + lit_shift: Lightness adjustment [-1.0, 1.0] + """ + self.layers.append((path, hue_shift, sat_shift, lit_shift)) + return self # allow chaining + + def clear(self): + """Remove all queued layers.""" + self.layers.clear() + return self + + def build(self, name=""): + """Composite all queued layers into a single Texture. + + Loads each layer file, applies HSL shifts if any, then composites + all layers bottom-to-top using alpha blending. + + Args: + name: Optional name for the resulting texture + + Returns: + mcrfpy.Texture: The composited texture + + Raises: + ValueError: If no layers have been added + IOError: If a layer file cannot be loaded + """ + if not self.layers: + raise ValueError("No layers added. Call add_layer() first.") + + textures = [] + for path, h, s, l in self.layers: + tex = mcrfpy.Texture(path, self.fmt.tile_w, self.fmt.tile_h) + if h != 0.0 or s != 0.0 or l != 0.0: + tex = tex.hsl_shift(h, s, l) + textures.append(tex) + + if len(textures) == 1: + return textures[0] + + return mcrfpy.Texture.composite( + textures, self.fmt.tile_w, self.fmt.tile_h, name + ) diff --git a/shade_sprite/demo.py b/shade_sprite/demo.py new file mode 100644 index 0000000..43d3ee1 --- /dev/null +++ b/shade_sprite/demo.py @@ -0,0 +1,661 @@ +"""shade_sprite interactive demo. + +Run from the build directory: + ./mcrogueface --exec ../shade_sprite/demo.py + +Or copy the shade_sprite directory into build/scripts/ and run: + ./mcrogueface --exec scripts/shade_sprite/demo.py + +Scenes: + 1 - Animation Viewer: cycle animations and directions + 2 - HSL Recolor: live hue/saturation/lightness shifting + 3 - Creature Gallery: grid of animated characters + 4 - Faction Generator: random faction color schemes + +Controls shown on-screen per scene. +""" +import mcrfpy +import sys +import os +import random + +# --------------------------------------------------------------------------- +# Asset discovery +# --------------------------------------------------------------------------- + +# Search paths for Puny Character sprites +_SEARCH_PATHS = [ + "assets/Puny-Characters", + "../assets/Puny-Characters", + # 7DRL dev location + os.path.expanduser( + "~/Development/7DRL2026_Liber_Noster_jmccardle/" + "assets_sources/Puny-Characters" + ), +] + +def _find_asset_dir(): + for p in _SEARCH_PATHS: + if os.path.isdir(p): + return p + return None + +ASSET_DIR = _find_asset_dir() + +# Character sheets available in the free CC0 pack +_CHARACTER_FILES = [ + "Warrior-Red.png", "Warrior-Blue.png", + "Soldier-Red.png", "Soldier-Blue.png", "Soldier-Yellow.png", + "Archer-Green.png", "Archer-Purple.png", + "Mage-Red.png", "Mage-Cyan.png", + "Human-Soldier-Red.png", "Human-Soldier-Cyan.png", + "Human-Worker-Red.png", "Human-Worker-Cyan.png", + "Orc-Grunt.png", "Orc-Peon-Red.png", "Orc-Peon-Cyan.png", + "Orc-Soldier-Red.png", "Orc-Soldier-Cyan.png", + "Character-Base.png", +] + +def _available_sheets(): + """Return list of full paths to available character sheets.""" + if not ASSET_DIR: + return [] + sheets = [] + for f in _CHARACTER_FILES: + p = os.path.join(ASSET_DIR, f) + if os.path.isfile(p): + sheets.append(p) + return sheets + +# Import shade_sprite (handle being run from different locations) +if __name__ == "__main__": + # Add parent dir to path so shade_sprite can be imported + script_dir = os.path.dirname(os.path.abspath(__file__)) + parent_dir = os.path.dirname(script_dir) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + +from shade_sprite import ( + AnimatedSprite, Direction, PUNY_24, PUNY_29, SLIME, + detect_format, CharacterAssembler, +) + +# --------------------------------------------------------------------------- +# Globals +# --------------------------------------------------------------------------- +_animated_sprites = [] # all AnimatedSprite instances to tick +_active_scene = None + + +def _tick_all(timer, runtime): + """Global animation tick callback.""" + for a in _animated_sprites: + a.tick(timer.interval) + + +# --------------------------------------------------------------------------- +# Scene 1: Animation Viewer +# --------------------------------------------------------------------------- +def _build_scene_viewer(): + scene = mcrfpy.Scene("viewer") + ui = scene.children + + # Background + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), + fill_color=mcrfpy.Color(30, 30, 40)) + ui.append(bg) + + # Title + title = mcrfpy.Caption(text="shade_sprite - Animation Viewer", + pos=(20, 10), + fill_color=mcrfpy.Color(220, 220, 255)) + ui.append(title) + + sheets = _available_sheets() + if not sheets: + msg = mcrfpy.Caption( + text="No sprite assets found. Place Puny-Characters PNGs in assets/Puny-Characters/", + pos=(20, 60), + fill_color=mcrfpy.Color(255, 100, 100)) + ui.append(msg) + return scene + + # State + state = { + "sheet_idx": 0, + "anim_idx": 0, + "dir_idx": 0, + } + + # Determine format + fmt = PUNY_24 # Free pack is 768x256 + + anim_names = list(fmt.animations.keys()) + dir_names = [d.name for d in Direction] + + # Load first sheet + tex = mcrfpy.Texture(sheets[0], fmt.tile_w, fmt.tile_h) + + # Main sprite display (scaled up 4x) + sprite = mcrfpy.Sprite(texture=tex, pos=(200, 200), scale=6.0) + ui.append(sprite) + + anim = AnimatedSprite(sprite, fmt, Direction.S) + anim.play("idle") + _animated_sprites.append(anim) + + # Info labels + sheet_label = mcrfpy.Caption( + text=f"Sheet: {os.path.basename(sheets[0])}", + pos=(20, 50), + fill_color=mcrfpy.Color(180, 180, 200)) + ui.append(sheet_label) + + anim_label = mcrfpy.Caption( + text=f"Animation: idle", + pos=(20, 80), + fill_color=mcrfpy.Color(180, 180, 200)) + ui.append(anim_label) + + dir_label = mcrfpy.Caption( + text=f"Direction: S", + pos=(20, 110), + fill_color=mcrfpy.Color(180, 180, 200)) + ui.append(dir_label) + + controls = mcrfpy.Caption( + text="[Q/E] Sheet [A/D] Animation [W/S] Direction [2] HSL [3] Gallery [4] Factions", + pos=(20, 740), + fill_color=mcrfpy.Color(120, 120, 140)) + ui.append(controls) + + # Also show all 8 directions as small sprites + dir_sprites = [] + dir_anims = [] + for i, d in enumerate(Direction): + dx = 500 + (i % 4) * 80 + dy = 200 + (i // 4) * 100 + s = mcrfpy.Sprite(texture=tex, pos=(dx, dy), scale=3.0) + ui.append(s) + a = AnimatedSprite(s, fmt, d) + a.play("idle") + _animated_sprites.append(a) + dir_sprites.append(s) + dir_anims.append(a) + + # Direction label + lbl = mcrfpy.Caption(text=d.name, pos=(dx + 10, dy - 18), + fill_color=mcrfpy.Color(150, 150, 170)) + ui.append(lbl) + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + + if key == mcrfpy.Key.Q: + # Previous sheet + state["sheet_idx"] = (state["sheet_idx"] - 1) % len(sheets) + _reload_sheet() + elif key == mcrfpy.Key.E: + # Next sheet + state["sheet_idx"] = (state["sheet_idx"] + 1) % len(sheets) + _reload_sheet() + elif key == mcrfpy.Key.A: + # Previous animation + state["anim_idx"] = (state["anim_idx"] - 1) % len(anim_names) + _update_anim() + elif key == mcrfpy.Key.D: + # Next animation + state["anim_idx"] = (state["anim_idx"] + 1) % len(anim_names) + _update_anim() + elif key == mcrfpy.Key.W: + # Previous direction + state["dir_idx"] = (state["dir_idx"] - 1) % 8 + _update_dir() + elif key == mcrfpy.Key.S: + # Next direction + state["dir_idx"] = (state["dir_idx"] + 1) % 8 + _update_dir() + elif key == mcrfpy.Key.Num2: + mcrfpy.Scene("hsl").activate() + elif key == mcrfpy.Key.Num3: + mcrfpy.Scene("gallery").activate() + elif key == mcrfpy.Key.Num4: + mcrfpy.Scene("factions").activate() + + def _reload_sheet(): + path = sheets[state["sheet_idx"]] + new_tex = mcrfpy.Texture(path, fmt.tile_w, fmt.tile_h) + sprite.texture = new_tex + for s in dir_sprites: + s.texture = new_tex + sheet_label.text = f"Sheet: {os.path.basename(path)}" + _update_anim() + + def _update_anim(): + name = anim_names[state["anim_idx"]] + anim.play(name) + for a in dir_anims: + a.play(name) + anim_label.text = f"Animation: {name}" + + def _update_dir(): + d = Direction(state["dir_idx"]) + anim.direction = d + dir_label.text = f"Direction: {d.name}" + + scene.on_key = on_key + return scene + + +# --------------------------------------------------------------------------- +# Scene 2: HSL Recolor Demo +# --------------------------------------------------------------------------- +def _build_scene_hsl(): + scene = mcrfpy.Scene("hsl") + ui = scene.children + + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), + fill_color=mcrfpy.Color(30, 30, 40)) + ui.append(bg) + + title = mcrfpy.Caption(text="shade_sprite - HSL Recoloring", + pos=(20, 10), + fill_color=mcrfpy.Color(220, 220, 255)) + ui.append(title) + + sheets = _available_sheets() + if not sheets: + msg = mcrfpy.Caption( + text="No sprite assets found.", + pos=(20, 60), + fill_color=mcrfpy.Color(255, 100, 100)) + ui.append(msg) + + def on_key(key, action): + if action == mcrfpy.InputState.PRESSED and key == mcrfpy.Key.Num1: + mcrfpy.Scene("viewer").activate() + scene.on_key = on_key + return scene + + fmt = PUNY_24 + + state = { + "hue": 0.0, + "sat": 0.0, + "lit": 0.0, + "sheet_idx": 0, + } + + # Original sprite (left) + orig_tex = mcrfpy.Texture(sheets[0], fmt.tile_w, fmt.tile_h) + orig_sprite = mcrfpy.Sprite(texture=orig_tex, pos=(150, 250), scale=6.0) + ui.append(orig_sprite) + orig_anim = AnimatedSprite(orig_sprite, fmt, Direction.S) + orig_anim.play("walk") + _animated_sprites.append(orig_anim) + + orig_label = mcrfpy.Caption(text="Original", pos=(170, 220), + fill_color=mcrfpy.Color(180, 180, 200)) + ui.append(orig_label) + + # Shifted sprite (right) + shifted_sprite = mcrfpy.Sprite(texture=orig_tex, pos=(550, 250), scale=6.0) + ui.append(shifted_sprite) + shifted_anim = AnimatedSprite(shifted_sprite, fmt, Direction.S) + shifted_anim.play("walk") + _animated_sprites.append(shifted_anim) + + shifted_label = mcrfpy.Caption(text="Shifted", pos=(570, 220), + fill_color=mcrfpy.Color(180, 180, 200)) + ui.append(shifted_label) + + # HSL value displays + hue_label = mcrfpy.Caption(text="Hue: 0.0", pos=(20, 500), + fill_color=mcrfpy.Color(255, 180, 180)) + ui.append(hue_label) + sat_label = mcrfpy.Caption(text="Sat: 0.0", pos=(20, 530), + fill_color=mcrfpy.Color(180, 255, 180)) + ui.append(sat_label) + lit_label = mcrfpy.Caption(text="Lit: 0.0", pos=(20, 560), + fill_color=mcrfpy.Color(180, 180, 255)) + ui.append(lit_label) + + controls = mcrfpy.Caption( + text="[Left/Right] Hue +/-30 [Up/Down] Sat +/-0.1 [Z/X] Lit +/-0.1 [Q/E] Sheet [1] Viewer", + pos=(20, 740), + fill_color=mcrfpy.Color(120, 120, 140)) + ui.append(controls) + + def _rebuild_shifted(): + path = sheets[state["sheet_idx"]] + base = mcrfpy.Texture(path, fmt.tile_w, fmt.tile_h) + shifted = base.hsl_shift(state["hue"], state["sat"], state["lit"]) + shifted_sprite.texture = shifted + hue_label.text = f"Hue: {state['hue']:.0f}" + sat_label.text = f"Sat: {state['sat']:.1f}" + lit_label.text = f"Lit: {state['lit']:.1f}" + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + + changed = False + if key == mcrfpy.Key.LEFT: + state["hue"] = (state["hue"] - 30.0) % 360.0 + changed = True + elif key == mcrfpy.Key.RIGHT: + state["hue"] = (state["hue"] + 30.0) % 360.0 + changed = True + elif key == mcrfpy.Key.UP: + state["sat"] = min(1.0, state["sat"] + 0.1) + changed = True + elif key == mcrfpy.Key.DOWN: + state["sat"] = max(-1.0, state["sat"] - 0.1) + changed = True + elif key == mcrfpy.Key.Z: + state["lit"] = max(-1.0, state["lit"] - 0.1) + changed = True + elif key == mcrfpy.Key.X: + state["lit"] = min(1.0, state["lit"] + 0.1) + changed = True + elif key == mcrfpy.Key.Q: + state["sheet_idx"] = (state["sheet_idx"] - 1) % len(sheets) + _reload() + elif key == mcrfpy.Key.E: + state["sheet_idx"] = (state["sheet_idx"] + 1) % len(sheets) + _reload() + elif key == mcrfpy.Key.Num1: + mcrfpy.Scene("viewer").activate() + return + elif key == mcrfpy.Key.Num3: + mcrfpy.Scene("gallery").activate() + return + elif key == mcrfpy.Key.Num4: + mcrfpy.Scene("factions").activate() + return + + if changed: + _rebuild_shifted() + + def _reload(): + path = sheets[state["sheet_idx"]] + new_tex = mcrfpy.Texture(path, fmt.tile_w, fmt.tile_h) + orig_sprite.texture = new_tex + _rebuild_shifted() + + scene.on_key = on_key + _rebuild_shifted() + return scene + + +# --------------------------------------------------------------------------- +# Scene 3: Creature Gallery +# --------------------------------------------------------------------------- +def _build_scene_gallery(): + scene = mcrfpy.Scene("gallery") + ui = scene.children + + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), + fill_color=mcrfpy.Color(30, 30, 40)) + ui.append(bg) + + title = mcrfpy.Caption(text="shade_sprite - Character Gallery", + pos=(20, 10), + fill_color=mcrfpy.Color(220, 220, 255)) + ui.append(title) + + sheets = _available_sheets() + if not sheets: + msg = mcrfpy.Caption( + text="No sprite assets found.", + pos=(20, 60), + fill_color=mcrfpy.Color(255, 100, 100)) + ui.append(msg) + + def on_key(key, action): + if action == mcrfpy.InputState.PRESSED and key == mcrfpy.Key.Num1: + mcrfpy.Scene("viewer").activate() + scene.on_key = on_key + return scene + + fmt = PUNY_24 + directions = [Direction.S, Direction.SW, Direction.W, Direction.NW, + Direction.N, Direction.NE, Direction.E, Direction.SE] + + # Layout: grid of characters, 4 columns + cols = 4 + x_start, y_start = 40, 60 + x_spacing, y_spacing = 240, 130 + scale = 3.0 + + gallery_anims = [] + count = min(len(sheets), 16) # max 4x4 grid + + for i in range(count): + col = i % cols + row = i // cols + x = x_start + col * x_spacing + y = y_start + row * y_spacing + + tex = mcrfpy.Texture(sheets[i], fmt.tile_w, fmt.tile_h) + sprite = mcrfpy.Sprite(texture=tex, pos=(x + 20, y + 20), + scale=scale) + ui.append(sprite) + + a = AnimatedSprite(sprite, fmt, Direction.S) + a.play("walk") + _animated_sprites.append(a) + gallery_anims.append(a) + + name = os.path.basename(sheets[i]).replace(".png", "") + lbl = mcrfpy.Caption(text=name, pos=(x, y), + fill_color=mcrfpy.Color(150, 150, 170)) + ui.append(lbl) + + state = {"dir_idx": 0, "anim_idx": 1} # start with walk + anim_names = list(fmt.animations.keys()) + + controls = mcrfpy.Caption( + text="[W/S] Direction [A/D] Animation [1] Viewer [2] HSL [4] Factions", + pos=(20, 740), + fill_color=mcrfpy.Color(120, 120, 140)) + ui.append(controls) + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + if key == mcrfpy.Key.W: + state["dir_idx"] = (state["dir_idx"] - 1) % 8 + d = Direction(state["dir_idx"]) + for a in gallery_anims: + a.direction = d + elif key == mcrfpy.Key.S: + state["dir_idx"] = (state["dir_idx"] + 1) % 8 + d = Direction(state["dir_idx"]) + for a in gallery_anims: + a.direction = d + elif key == mcrfpy.Key.A: + state["anim_idx"] = (state["anim_idx"] - 1) % len(anim_names) + name = anim_names[state["anim_idx"]] + for a in gallery_anims: + a.play(name) + elif key == mcrfpy.Key.D: + state["anim_idx"] = (state["anim_idx"] + 1) % len(anim_names) + name = anim_names[state["anim_idx"]] + for a in gallery_anims: + a.play(name) + elif key == mcrfpy.Key.Num1: + mcrfpy.Scene("viewer").activate() + elif key == mcrfpy.Key.Num2: + mcrfpy.Scene("hsl").activate() + elif key == mcrfpy.Key.Num4: + mcrfpy.Scene("factions").activate() + + scene.on_key = on_key + return scene + + +# --------------------------------------------------------------------------- +# Scene 4: Faction Generator +# --------------------------------------------------------------------------- +def _build_scene_factions(): + scene = mcrfpy.Scene("factions") + ui = scene.children + + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), + fill_color=mcrfpy.Color(30, 30, 40)) + ui.append(bg) + + title = mcrfpy.Caption(text="shade_sprite - Faction Generator", + pos=(20, 10), + fill_color=mcrfpy.Color(220, 220, 255)) + ui.append(title) + + sheets = _available_sheets() + if not sheets: + msg = mcrfpy.Caption( + text="No sprite assets found.", + pos=(20, 60), + fill_color=mcrfpy.Color(255, 100, 100)) + ui.append(msg) + + def on_key(key, action): + if action == mcrfpy.InputState.PRESSED and key == mcrfpy.Key.Num1: + mcrfpy.Scene("viewer").activate() + scene.on_key = on_key + return scene + + fmt = PUNY_24 + scale = 3.0 + + # State for generated factions + faction_anims = [] # store references for animation ticking + faction_sprites = [] # sprites to update on re-roll + faction_labels = [] + + # Faction colors (HSL hue values) + faction_hues = [0, 60, 120, 180, 240, 300] + faction_names_pool = [ + "Iron Guard", "Shadow Pact", "Dawn Order", "Ember Clan", + "Frost Legion", "Vine Court", "Storm Band", "Ash Wardens", + "Gold Company", "Crimson Oath", "Azure Fleet", "Jade Circle", + ] + + def _generate_factions(): + # Clear old faction animations from global list + for a in faction_anims: + if a in _animated_sprites: + _animated_sprites.remove(a) + faction_anims.clear() + + # Pick 4 factions with random hues and characters + hues = random.sample(faction_hues, min(4, len(faction_hues))) + names = random.sample(faction_names_pool, 4) + + # We'll create sprites dynamically + # Clear old sprites (rebuild scene content below bg/title/controls) + while len(ui) > 3: # keep bg, title, controls + # Can't easily remove from UICollection, so we rebuild the scene + pass + # Actually, just position everything and update textures + return hues, names + + def _build_faction_display(): + for a in faction_anims: + if a in _animated_sprites: + _animated_sprites.remove(a) + faction_anims.clear() + faction_sprites.clear() + faction_labels.clear() + + hues = [random.uniform(0, 360) for _ in range(4)] + names = random.sample(faction_names_pool, 4) + + y_start = 80 + for fi in range(4): + y = y_start + fi * 160 + hue = hues[fi] + + # Faction name + lbl = mcrfpy.Caption( + text=f"{names[fi]} (hue {hue:.0f})", + pos=(20, y), + fill_color=mcrfpy.Color(200, 200, 220)) + ui.append(lbl) + faction_labels.append(lbl) + + # Pick 4 random character sheets for this faction + chosen = random.sample(sheets, min(4, len(sheets))) + for ci, path in enumerate(chosen): + x = 40 + ci * 200 + + # Apply faction hue shift + base_tex = mcrfpy.Texture(path, fmt.tile_w, fmt.tile_h) + shifted_tex = base_tex.hsl_shift(hue) + + s = mcrfpy.Sprite(texture=shifted_tex, pos=(x, y + 30), + scale=scale) + ui.append(s) + faction_sprites.append(s) + + a = AnimatedSprite(s, fmt, Direction.S) + a.play("walk") + _animated_sprites.append(a) + faction_anims.append(a) + + controls = mcrfpy.Caption( + text="[Space] Re-roll factions [1] Viewer [2] HSL [3] Gallery", + pos=(20, 740), + fill_color=mcrfpy.Color(120, 120, 140)) + ui.append(controls) + + _build_faction_display() + + def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + if key == mcrfpy.Key.SPACE: + # Rebuild scene + _rebuild_factions_scene() + elif key == mcrfpy.Key.Num1: + mcrfpy.Scene("viewer").activate() + elif key == mcrfpy.Key.Num2: + mcrfpy.Scene("hsl").activate() + elif key == mcrfpy.Key.Num3: + mcrfpy.Scene("gallery").activate() + + def _rebuild_factions_scene(): + # Easiest: rebuild the whole scene + new_scene = _build_scene_factions() + new_scene.activate() + + scene.on_key = on_key + return scene + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main(): + if not ASSET_DIR: + print("WARNING: No Puny-Characters asset directory found.") + print("Searched:", _SEARCH_PATHS) + print("The demo will show placeholder messages.") + print() + + # Build all scenes + _build_scene_viewer() + _build_scene_hsl() + _build_scene_gallery() + _build_scene_factions() + + # Start animation timer (20fps animation updates) + mcrfpy.Timer("shade_anim", _tick_all, 50) + + # Activate first scene + mcrfpy.Scene("viewer").activate() + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/shade_sprite/formats.py b/shade_sprite/formats.py new file mode 100644 index 0000000..2472bf6 --- /dev/null +++ b/shade_sprite/formats.py @@ -0,0 +1,261 @@ +"""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] diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 8446409..2b0abf2 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -581,6 +581,9 @@ PyObject* PyInit_mcrfpy() mcrfpydef::PyWangSetType.tp_methods = PyWangSet::methods; mcrfpydef::PyWangSetType.tp_getset = PyWangSet::getsetters; + // Texture methods (from_bytes, composite, hsl_shift) + mcrfpydef::PyTextureType.tp_methods = PyTexture::methods; + // LDtk types mcrfpydef::PyLdtkProjectType.tp_methods = PyLdtkProject::methods; mcrfpydef::PyLdtkProjectType.tp_getset = PyLdtkProject::getsetters; diff --git a/src/PyTexture.cpp b/src/PyTexture.cpp index ce4782e..29c1991 100644 --- a/src/PyTexture.cpp +++ b/src/PyTexture.cpp @@ -1,6 +1,9 @@ #include "PyTexture.h" #include "McRFPy_API.h" #include "McRFPy_Doc.h" +#include "PyTypeCache.h" +#include +#include PyTexture::PyTexture(std::string filename, int sprite_w, int sprite_h) : source(filename), sprite_width(sprite_w), sprite_height(sprite_h), sheet_width(0), sheet_height(0) @@ -90,13 +93,13 @@ sf::Sprite PyTexture::sprite(int index, sf::Vector2f pos, sf::Vector2f s) PyObject* PyTexture::pyObject() { - auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Texture"); + PyTypeObject* type = PyTypeCache::Texture(); if (!type) { - PyErr_SetString(PyExc_RuntimeError, "Failed to get Texture type from module"); + PyErr_SetString(PyExc_RuntimeError, "Failed to get Texture type from cache"); return NULL; } PyObject* obj = PyTexture::pynew(type, Py_None, Py_None); - Py_DECREF(type); // GetAttrString returns new reference + // PyTypeCache returns borrowed reference — no DECREF needed if (!obj) { return NULL; @@ -209,3 +212,298 @@ PyGetSetDef PyTexture::getsetters[] = { MCRF_PROPERTY(source, "Source filename path (str, read-only). The path used to load this texture."), NULL}, {NULL} // Sentinel }; + +// ============================================================================ +// Texture.from_bytes(data, width, height, sprite_w, sprite_h, name) classmethod +// ============================================================================ +PyObject* PyTexture::from_bytes(PyObject* cls, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"data", "width", "height", "sprite_width", "sprite_height", "name", nullptr}; + Py_buffer buf; + int width, height, sprite_w, sprite_h; + const char* name = ""; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "y*iiii|s", + const_cast(keywords), + &buf, &width, &height, &sprite_w, &sprite_h, &name)) + return NULL; + + Py_ssize_t expected = (Py_ssize_t)width * height * 4; + if (buf.len != expected) { + PyBuffer_Release(&buf); + PyErr_Format(PyExc_ValueError, + "Expected %zd bytes (width=%d * height=%d * 4), got %zd", + expected, width, height, buf.len); + return NULL; + } + + sf::Image img; + img.create(width, height, (const sf::Uint8*)buf.buf); + PyBuffer_Release(&buf); + + auto ptex = PyTexture::from_image(img, sprite_w, sprite_h, name); + return ptex->pyObject(); +} + +// ============================================================================ +// Texture.composite(layers, sprite_w, sprite_h, name) classmethod +// ============================================================================ +PyObject* PyTexture::composite(PyObject* cls, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"layers", "sprite_width", "sprite_height", "name", nullptr}; + PyObject* layers_list; + int sprite_w, sprite_h; + const char* name = ""; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "Oii|s", + const_cast(keywords), + &layers_list, &sprite_w, &sprite_h, &name)) + return NULL; + + if (!PyList_Check(layers_list)) { + PyErr_SetString(PyExc_TypeError, "layers must be a list of Texture objects"); + return NULL; + } + + Py_ssize_t count = PyList_Size(layers_list); + if (count == 0) { + PyErr_SetString(PyExc_ValueError, "layers list must not be empty"); + return NULL; + } + + // Validate all elements are Texture objects and collect images + std::vector images; + unsigned int tex_w = 0, tex_h = 0; + + // Use PyTypeCache for reliable, leak-free isinstance check + PyTypeObject* texture_type = PyTypeCache::Texture(); + if (!texture_type) { + PyErr_SetString(PyExc_RuntimeError, "Failed to get Texture type from cache"); + return NULL; + } + + for (Py_ssize_t i = 0; i < count; i++) { + PyObject* item = PyList_GetItem(layers_list, i); + if (!PyObject_IsInstance(item, (PyObject*)texture_type)) { + PyErr_Format(PyExc_TypeError, + "layers[%zd] is not a Texture object", i); + return NULL; + } + auto& ptex = ((PyTextureObject*)item)->data; + if (!ptex) { + PyErr_Format(PyExc_ValueError, + "layers[%zd] has invalid internal data", i); + return NULL; + } + + sf::Image img = ptex->texture.copyToImage(); + auto size = img.getSize(); + + if (i == 0) { + tex_w = size.x; + tex_h = size.y; + } else if (size.x != tex_w || size.y != tex_h) { + PyErr_Format(PyExc_ValueError, + "All layers must have same dimensions. " + "Layer 0 is %ux%u, layer %zd is %ux%u", + tex_w, tex_h, i, size.x, size.y); + return NULL; + } + images.push_back(std::move(img)); + } + // PyTypeCache returns borrowed reference — no DECREF needed + + // Alpha-composite all layers bottom-to-top + sf::Image result; + result.create(tex_w, tex_h, sf::Color::Transparent); + + for (unsigned int y = 0; y < tex_h; y++) { + for (unsigned int x = 0; x < tex_w; x++) { + // Start with first layer + sf::Color dst = images[0].getPixel(x, y); + + // Composite each subsequent layer on top + for (size_t i = 1; i < images.size(); i++) { + sf::Color src = images[i].getPixel(x, y); + if (src.a == 0) continue; + if (src.a == 255 || dst.a == 0) { + dst = src; + continue; + } + + // Standard alpha compositing (Porter-Duff "over") + float sa = src.a / 255.0f; + float da = dst.a / 255.0f; + float out_a = sa + da * (1.0f - sa); + + if (out_a > 0.0f) { + dst.r = (sf::Uint8)((src.r * sa + dst.r * da * (1.0f - sa)) / out_a); + dst.g = (sf::Uint8)((src.g * sa + dst.g * da * (1.0f - sa)) / out_a); + dst.b = (sf::Uint8)((src.b * sa + dst.b * da * (1.0f - sa)) / out_a); + dst.a = (sf::Uint8)(out_a * 255.0f); + } + } + result.setPixel(x, y, dst); + } + } + + auto ptex = PyTexture::from_image(result, sprite_w, sprite_h, name); + return ptex->pyObject(); +} + +// ============================================================================ +// HSL conversion helpers (internal) +// ============================================================================ +namespace { + +struct HSL { + float h, s, l; +}; + +HSL rgb_to_hsl(sf::Uint8 r, sf::Uint8 g, sf::Uint8 b) +{ + float rf = r / 255.0f, gf = g / 255.0f, bf = b / 255.0f; + float mx = std::max({rf, gf, bf}); + float mn = std::min({rf, gf, bf}); + float l = (mx + mn) / 2.0f; + + if (mx == mn) return {0.0f, 0.0f, l}; + + float d = mx - mn; + float s = (l > 0.5f) ? d / (2.0f - mx - mn) : d / (mx + mn); + float h; + if (mx == rf) { + h = (gf - bf) / d + (gf < bf ? 6.0f : 0.0f); + } else if (mx == gf) { + h = (bf - rf) / d + 2.0f; + } else { + h = (rf - gf) / d + 4.0f; + } + h *= 60.0f; + return {h, s, l}; +} + +static float hue_to_rgb(float p, float q, float t) +{ + if (t < 0.0f) t += 1.0f; + if (t > 1.0f) t -= 1.0f; + if (t < 1.0f/6.0f) return p + (q - p) * 6.0f * t; + if (t < 1.0f/2.0f) return q; + if (t < 2.0f/3.0f) return p + (q - p) * (2.0f/3.0f - t) * 6.0f; + return p; +} + +sf::Color hsl_to_rgb(float h, float s, float l, sf::Uint8 a) +{ + if (s <= 0.0f) { + sf::Uint8 v = (sf::Uint8)(l * 255.0f); + return sf::Color(v, v, v, a); + } + + float hn = h / 360.0f; + float q = (l < 0.5f) ? l * (1.0f + s) : l + s - l * s; + float p = 2.0f * l - q; + + float r = hue_to_rgb(p, q, hn + 1.0f/3.0f); + float g = hue_to_rgb(p, q, hn); + float b = hue_to_rgb(p, q, hn - 1.0f/3.0f); + + return sf::Color( + (sf::Uint8)(r * 255.0f), + (sf::Uint8)(g * 255.0f), + (sf::Uint8)(b * 255.0f), + a); +} + +} // anonymous namespace + +// ============================================================================ +// texture.hsl_shift(hue_shift, sat_shift, lit_shift) instance method +// ============================================================================ +PyObject* PyTexture::hsl_shift(PyTextureObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"hue_shift", "sat_shift", "lit_shift", nullptr}; + float hue_shift, sat_shift = 0.0f, lit_shift = 0.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "f|ff", + const_cast(keywords), + &hue_shift, &sat_shift, &lit_shift)) + return NULL; + + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Texture has invalid internal data"); + return NULL; + } + + sf::Image img = self->data->texture.copyToImage(); + auto size = img.getSize(); + + for (unsigned int y = 0; y < size.y; y++) { + for (unsigned int x = 0; x < size.x; x++) { + sf::Color px = img.getPixel(x, y); + if (px.a == 0) continue; // skip transparent + + HSL hsl = rgb_to_hsl(px.r, px.g, px.b); + + // Apply shifts + hsl.h = std::fmod(hsl.h + hue_shift, 360.0f); + if (hsl.h < 0.0f) hsl.h += 360.0f; + + hsl.s = std::clamp(hsl.s + sat_shift, 0.0f, 1.0f); + hsl.l = std::clamp(hsl.l + lit_shift, 0.0f, 1.0f); + + img.setPixel(x, y, hsl_to_rgb(hsl.h, hsl.s, hsl.l, px.a)); + } + } + + auto ptex = PyTexture::from_image(img, + self->data->sprite_width, self->data->sprite_height, + self->data->source + "+hsl"); + return ptex->pyObject(); +} + +// ============================================================================ +// Methods table +// ============================================================================ +PyMethodDef PyTexture::methods[] = { + {"from_bytes", (PyCFunction)PyTexture::from_bytes, METH_VARARGS | METH_KEYWORDS | METH_CLASS, + MCRF_METHOD(Texture, from_bytes, + MCRF_SIG("(data: bytes, width: int, height: int, sprite_width: int, sprite_height: int, name: str = '')", "Texture"), + MCRF_DESC("Create a Texture from raw RGBA pixel data."), + MCRF_ARGS_START + MCRF_ARG("data", "Raw RGBA bytes (length must equal width * height * 4)") + MCRF_ARG("width", "Image width in pixels") + MCRF_ARG("height", "Image height in pixels") + MCRF_ARG("sprite_width", "Width of each sprite cell") + MCRF_ARG("sprite_height", "Height of each sprite cell") + MCRF_ARG("name", "Optional name for the texture (default: '')") + MCRF_RETURNS("Texture: New texture containing the pixel data") + MCRF_RAISES("ValueError", "If data length does not match width * height * 4") + MCRF_NOTE("This is a class method. Useful for procedurally generated textures.") + )}, + {"composite", (PyCFunction)PyTexture::composite, METH_VARARGS | METH_KEYWORDS | METH_CLASS, + MCRF_METHOD(Texture, composite, + MCRF_SIG("(layers: list[Texture], sprite_width: int, sprite_height: int, name: str = '')", "Texture"), + MCRF_DESC("Alpha-composite multiple texture layers into a single texture."), + MCRF_ARGS_START + MCRF_ARG("layers", "List of Texture objects, composited bottom-to-top") + MCRF_ARG("sprite_width", "Width of each sprite cell in the result") + MCRF_ARG("sprite_height", "Height of each sprite cell in the result") + MCRF_ARG("name", "Optional name for the composite texture") + MCRF_RETURNS("Texture: New texture with all layers composited") + MCRF_RAISES("ValueError", "If layers have different dimensions or list is empty") + MCRF_NOTE("This is a class method. Uses Porter-Duff 'over' alpha compositing.") + )}, + {"hsl_shift", (PyCFunction)PyTexture::hsl_shift, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(Texture, hsl_shift, + MCRF_SIG("(hue_shift: float, sat_shift: float = 0.0, lit_shift: float = 0.0)", "Texture"), + MCRF_DESC("Create a new texture with HSL color adjustments applied."), + MCRF_ARGS_START + MCRF_ARG("hue_shift", "Hue rotation in degrees [0.0, 360.0)") + MCRF_ARG("sat_shift", "Saturation adjustment [-1.0, 1.0] (default 0.0)") + MCRF_ARG("lit_shift", "Lightness adjustment [-1.0, 1.0] (default 0.0)") + MCRF_RETURNS("Texture: New texture with color-shifted pixels") + MCRF_NOTE("Preserves alpha channel. Skips fully transparent pixels.") + )}, + {NULL} // Sentinel +}; diff --git a/src/PyTexture.h b/src/PyTexture.h index 3d79cc4..ff41373 100644 --- a/src/PyTexture.h +++ b/src/PyTexture.h @@ -51,6 +51,12 @@ public: static PyObject* get_source(PyTextureObject* self, void* closure); static PyGetSetDef getsetters[]; + + // Methods (classmethods and instance methods) + static PyObject* from_bytes(PyObject* cls, PyObject* args, PyObject* kwds); + static PyObject* composite(PyObject* cls, PyObject* args, PyObject* kwds); + static PyObject* hsl_shift(PyTextureObject* self, PyObject* args, PyObject* kwds); + static PyMethodDef methods[]; }; namespace mcrfpydef { diff --git a/tests/unit/shade_sprite_test.py b/tests/unit/shade_sprite_test.py new file mode 100644 index 0000000..98bec14 --- /dev/null +++ b/tests/unit/shade_sprite_test.py @@ -0,0 +1,190 @@ +"""Unit tests for shade_sprite module.""" +import mcrfpy +import sys +import os + +# Add project root to path so shade_sprite can be imported +script_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(os.path.dirname(script_dir)) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from shade_sprite import ( + AnimatedSprite, CharacterAssembler, Direction, + PUNY_29, PUNY_24, CREATURE_RPGMAKER, SLIME, + ALL_FORMATS, detect_format, AnimFrame, AnimDef, +) + +errors = [] + +def test(name, condition, msg=""): + if not condition: + errors.append(f"FAIL: {name} - {msg}") + print(f" FAIL: {name} {msg}") + else: + print(f" PASS: {name}") + + +# ---- Format definitions ---- +print("=== Format Definitions ===") + +test("PUNY_29 dimensions", PUNY_29.tile_w == 32 and PUNY_29.tile_h == 32) +test("PUNY_29 columns", PUNY_29.columns == 29) +test("PUNY_29 rows", PUNY_29.rows == 8) +test("PUNY_29 directions", PUNY_29.directions == 8) +test("PUNY_29 has 10 animations", len(PUNY_29.animations) == 10, + f"got {len(PUNY_29.animations)}: {list(PUNY_29.animations.keys())}") +test("PUNY_29 idle is looping", PUNY_29.animations["idle"].loop) +test("PUNY_29 death no chain", PUNY_29.animations["death"].chain_to is None) +test("PUNY_29 slash chains to idle", PUNY_29.animations["slash"].chain_to == "idle") + +test("PUNY_24 dimensions", PUNY_24.tile_w == 32 and PUNY_24.tile_h == 32) +test("PUNY_24 columns", PUNY_24.columns == 24) +test("PUNY_24 has 8 animations", len(PUNY_24.animations) == 8, + f"got {len(PUNY_24.animations)}: {list(PUNY_24.animations.keys())}") + +test("CREATURE_RPGMAKER tile", CREATURE_RPGMAKER.tile_w == 24 and CREATURE_RPGMAKER.tile_h == 24) +test("CREATURE_RPGMAKER 4-dir", CREATURE_RPGMAKER.directions == 4) + +test("SLIME columns", SLIME.columns == 15) +test("SLIME 1-dir", SLIME.directions == 1) + +test("ALL_FORMATS count", len(ALL_FORMATS) == 4) + + +# ---- Format detection ---- +print("\n=== Format Detection ===") + +test("detect 928x256 -> PUNY_29", detect_format(928, 256) is PUNY_29) +test("detect 768x256 -> PUNY_24", detect_format(768, 256) is PUNY_24) +test("detect 480x32 -> SLIME", detect_format(480, 32) is SLIME) +test("detect 72x96 -> CREATURE_RPGMAKER", detect_format(72, 96) is CREATURE_RPGMAKER) +test("detect unknown -> None", detect_format(100, 100) is None) + + +# ---- Direction ---- +print("\n=== Direction ===") + +test("Direction.S == 0", Direction.S == 0) +test("Direction.N == 4", Direction.N == 4) +test("Direction.E == 6", Direction.E == 6) + +# 8-dir row mapping +test("8-dir S -> row 0", PUNY_29.direction_row(Direction.S) == 0) +test("8-dir N -> row 4", PUNY_29.direction_row(Direction.N) == 4) +test("8-dir E -> row 6", PUNY_29.direction_row(Direction.E) == 6) + +# 4-dir mapping +test("4-dir S -> row 0", CREATURE_RPGMAKER.direction_row(Direction.S) == 0) +test("4-dir W -> row 1", CREATURE_RPGMAKER.direction_row(Direction.W) == 1) +test("4-dir E -> row 2", CREATURE_RPGMAKER.direction_row(Direction.E) == 2) +test("4-dir N -> row 3", CREATURE_RPGMAKER.direction_row(Direction.N) == 3) +test("4-dir SW -> row 0 (rounds to S)", CREATURE_RPGMAKER.direction_row(Direction.SW) == 0) + + +# ---- Sprite index calculation ---- +print("\n=== Sprite Index ===") + +# PUNY_29: row * 29 + col +test("PUNY_29 col=0 S -> 0", PUNY_29.sprite_index(0, Direction.S) == 0) +test("PUNY_29 col=5 S -> 5", PUNY_29.sprite_index(5, Direction.S) == 5) +test("PUNY_29 col=0 N -> 116", PUNY_29.sprite_index(0, Direction.N) == 4 * 29) +test("PUNY_29 col=5 E -> 6*29+5", PUNY_29.sprite_index(5, Direction.E) == 6 * 29 + 5) + +# SLIME: always row 0 (1-dir) +test("SLIME col=3 any dir -> 3", SLIME.sprite_index(3, Direction.E) == 3) + + +# ---- AnimatedSprite (with mock sprite) ---- +print("\n=== AnimatedSprite ===") + +# Create a real mcrfpy.Sprite with a from_bytes texture for testing +tex_data = bytes([128, 128, 128, 255] * (768 * 256)) +tex = mcrfpy.Texture.from_bytes(tex_data, 768, 256, 32, 32) +sprite = mcrfpy.Sprite(texture=tex, pos=(0, 0)) + +anim = AnimatedSprite(sprite, PUNY_24, Direction.S) +test("AnimatedSprite created", anim is not None) +test("AnimatedSprite starts with idle", anim.animation_name == "idle") +test("AnimatedSprite direction is S", anim.direction == Direction.S) + +# Play walk +anim.play("walk") +test("play walk", anim.animation_name == "walk") +test("walk frame 0", anim.frame_index == 0) + +# Tick through first frame (walk frame 0 is col 1, 200ms) +anim.tick(100) # half of 200ms +test("tick 100ms stays on frame 0", anim.frame_index == 0) + +anim.tick(100) # now 200ms total -> advance to frame 1 +test("tick 200ms advances to frame 1", anim.frame_index == 1) + +# Sprite index should reflect col 2 (walk frame 1), row 0 (S) +expected_idx = 0 * 24 + 2 # row=0, col=2 +test("sprite_index after advance", + sprite.sprite_index == expected_idx, + f"got {sprite.sprite_index}, expected {expected_idx}") + +# Direction change +anim.set_direction(Direction.E) +test("direction changed to E", anim.direction == Direction.E) +# Same column but different row +expected_idx = 6 * 24 + 2 # row=6 (E), col=2 +test("sprite_index after dir change", + sprite.sprite_index == expected_idx, + f"got {sprite.sprite_index}, expected {expected_idx}") + +# One-shot animation +anim.play("slash") +test("slash starts at frame 0", anim.frame_index == 0) +# Slash: 4 frames of 100ms each, then chains to idle +anim.tick(100) +anim.tick(100) +anim.tick(100) +test("slash frame 3 after 300ms", anim.frame_index == 3) +anim.tick(100) # finish last frame -> chain to idle +test("slash chains to idle", anim.animation_name == "idle") + +# Death animation (no chain) +anim.play("death") +anim.tick(100) # frame 0 +anim.tick(100) # frame 1 (last frame for PUNY_24 death) +# death has chain_to=None, so it stays finished +# Wait for last frame to expire +anim.tick(1000) # way past duration +test("death finished", anim.finished) +test("death stays on last frame", anim.frame_index == 1) + +# Invalid animation name +try: + anim.play("nonexistent") + test("invalid anim raises KeyError", False, "no exception") +except KeyError: + test("invalid anim raises KeyError", True) + + +# ---- CharacterAssembler (basic) ---- +print("\n=== CharacterAssembler ===") + +asm = CharacterAssembler(PUNY_24) +test("assembler created", asm is not None) + +# Empty build should fail +try: + asm.build() + test("empty build raises", False, "no exception") +except ValueError: + test("empty build raises ValueError", True) + + +# ---- Summary ---- +print() +if errors: + print(f"FAILED: {len(errors)} tests failed") + for e in errors: + print(f" {e}") + sys.exit(1) +else: + print("All tests passed!") + sys.exit(0) diff --git a/tests/unit/texture_methods_test.py b/tests/unit/texture_methods_test.py new file mode 100644 index 0000000..ce01a42 --- /dev/null +++ b/tests/unit/texture_methods_test.py @@ -0,0 +1,123 @@ +"""Tests for Texture.from_bytes(), Texture.composite(), texture.hsl_shift()""" +import mcrfpy +import sys + +errors = [] + +def test(name, condition, msg=""): + if not condition: + errors.append(f"FAIL: {name} - {msg}") + print(f" FAIL: {name} {msg}") + else: + print(f" PASS: {name}") + +# ---- Test from_bytes ---- +print("=== Texture.from_bytes ===") + +# Create a 4x4 red image (RGBA) +w, h = 4, 4 +red_bytes = bytes([255, 0, 0, 255] * (w * h)) +tex = mcrfpy.Texture.from_bytes(red_bytes, w, h, 2, 2) +test("from_bytes returns Texture", isinstance(tex, mcrfpy.Texture)) +test("from_bytes sprite_width", tex.sprite_width == 2, f"got {tex.sprite_width}") +test("from_bytes sprite_height", tex.sprite_height == 2, f"got {tex.sprite_height}") +test("from_bytes sheet_width", tex.sheet_width == 2, f"got {tex.sheet_width}") +test("from_bytes sheet_height", tex.sheet_height == 2, f"got {tex.sheet_height}") +test("from_bytes sprite_count", tex.sprite_count == 4, f"got {tex.sprite_count}") + +# Wrong size should raise ValueError +try: + mcrfpy.Texture.from_bytes(b"\x00\x00", 4, 4, 2, 2) + test("from_bytes wrong size raises", False, "no exception raised") +except ValueError as e: + test("from_bytes wrong size raises ValueError", True) + +# bytearray should work too +ba = bytearray([0, 255, 0, 128] * 16) +tex2 = mcrfpy.Texture.from_bytes(ba, 4, 4, 4, 4) +test("from_bytes accepts bytearray", tex2.sprite_count == 1) + +# With name parameter +tex3 = mcrfpy.Texture.from_bytes(red_bytes, 4, 4, 2, 2, name="test_red") +test("from_bytes with name", tex3.source == "test_red", f"got '{tex3.source}'") + +# ---- Test composite ---- +print("\n=== Texture.composite ===") + +# Two 4x4 layers: red bottom, semi-transparent blue top +red_data = bytes([255, 0, 0, 255] * 16) +blue_data = bytes([0, 0, 255, 128] * 16) # 50% alpha blue + +red_tex = mcrfpy.Texture.from_bytes(red_data, 4, 4, 4, 4) +blue_tex = mcrfpy.Texture.from_bytes(blue_data, 4, 4, 4, 4) + +comp = mcrfpy.Texture.composite([red_tex, blue_tex], 4, 4) +test("composite returns Texture", isinstance(comp, mcrfpy.Texture)) +test("composite sprite_count", comp.sprite_count == 1) + +# Fully transparent over opaque should equal opaque +transparent_data = bytes([0, 0, 0, 0] * 16) +trans_tex = mcrfpy.Texture.from_bytes(transparent_data, 4, 4, 4, 4) +comp2 = mcrfpy.Texture.composite([red_tex, trans_tex], 4, 4) +test("composite transparent over red", isinstance(comp2, mcrfpy.Texture)) + +# Opaque over opaque should equal top +green_data = bytes([0, 255, 0, 255] * 16) +green_tex = mcrfpy.Texture.from_bytes(green_data, 4, 4, 4, 4) +comp3 = mcrfpy.Texture.composite([red_tex, green_tex], 4, 4) +test("composite opaque over opaque", isinstance(comp3, mcrfpy.Texture)) + +# Empty list should raise +try: + mcrfpy.Texture.composite([], 4, 4) + test("composite empty raises", False, "no exception") +except ValueError: + test("composite empty raises ValueError", True) + +# Wrong type in list +try: + mcrfpy.Texture.composite([red_tex, "not a texture"], 4, 4) + test("composite wrong type raises", False, "no exception") +except TypeError: + test("composite wrong type raises TypeError", True) + +# With name +comp4 = mcrfpy.Texture.composite([red_tex], 2, 2, name="my_composite") +test("composite with name", comp4.source == "my_composite", f"got '{comp4.source}'") + +# ---- Test hsl_shift ---- +print("\n=== texture.hsl_shift ===") + +# Shift pure red by 120 degrees -> should become green-ish +shifted = red_tex.hsl_shift(120.0) +test("hsl_shift returns Texture", isinstance(shifted, mcrfpy.Texture)) +test("hsl_shift preserves sprite dims", + shifted.sprite_width == red_tex.sprite_width and + shifted.sprite_height == red_tex.sprite_height) + +# Zero shift should still work +same = red_tex.hsl_shift(0.0) +test("hsl_shift zero produces Texture", isinstance(same, mcrfpy.Texture)) + +# With sat and lit shifts +darkened = red_tex.hsl_shift(0.0, 0.0, -0.3) +test("hsl_shift with lit_shift", isinstance(darkened, mcrfpy.Texture)) + +# Transparent pixels should be unchanged +trans_shifted = trans_tex.hsl_shift(180.0) +test("hsl_shift on transparent", isinstance(trans_shifted, mcrfpy.Texture)) + +# Shift by 360 should be same as 0 +full_circle = red_tex.hsl_shift(360.0) +test("hsl_shift 360 degrees", isinstance(full_circle, mcrfpy.Texture)) + +# ---- Summary ---- +print() +if errors: + print(f"FAILED: {len(errors)} tests failed") + for e in errors: + print(f" {e}") + sys.exit(1) +else: + print("All tests passed!") + sys.exit(0)