Shade (merchant-shade.itch.io) entity animation tests

This commit is contained in:
John McCardle 2026-02-16 20:19:39 -05:00
commit 6fdf7279ce
10 changed files with 1813 additions and 3 deletions

66
shade_sprite/__init__.py Normal file
View file

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

127
shade_sprite/animation.py Normal file
View file

@ -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

75
shade_sprite/assembler.py Normal file
View file

@ -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="<composed>"):
"""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
)

661
shade_sprite/demo.py Normal file
View file

@ -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)

261
shade_sprite/formats.py Normal file
View file

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