Add cookbook examples with updated callback signatures for #229, #230

Cookbook structure:
- lib/: Reusable component library (Button, StatBar, AnimationChain, etc.)
- primitives/: Demo apps for individual components
- features/: Demo apps for complex features (animation chaining, shaders)
- apps/: Complete mini-applications (calculator, dialogue system)
- automation/: Screenshot capture utilities

API signature updates applied:
- on_enter/on_exit/on_move callbacks now only receive (pos) per #230
- on_cell_enter/on_cell_exit callbacks only receive (cell_pos) per #230
- Animation chain library uses Timer-based sequencing (unaffected by #229)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-01-28 18:58:25 -05:00
commit 55f6ea9502
41 changed files with 8493 additions and 0 deletions

View file

@ -0,0 +1,387 @@
# McRogueFace Cookbook - Animation Utilities
"""
Utilities for complex animation orchestration.
Example:
from lib.anim_utils import AnimationChain, AnimationGroup, delay
# Sequential animations
chain = AnimationChain(
(frame, "x", 200, 0.5),
delay(0.2),
(frame, "y", 300, 0.5),
callback=on_complete
)
chain.start()
# Parallel animations
group = AnimationGroup(
(frame, "x", 200, 0.5),
(frame, "opacity", 0.5, 0.5),
callback=on_complete
)
group.start()
"""
import mcrfpy
class AnimationStep:
"""Base class for animation steps."""
def start(self, callback):
"""Start the step, call callback when done."""
raise NotImplementedError
class PropertyAnimation(AnimationStep):
"""Single property animation step.
Args:
target: The UI element to animate
property: Property name (e.g., "x", "y", "opacity")
value: Target value
duration: Duration in seconds
easing: Easing function (default: EASE_OUT)
"""
def __init__(self, target, property_name, value, duration, easing=None):
self.target = target
self.property_name = property_name
self.value = value
self.duration = duration
self.easing = easing or mcrfpy.Easing.EASE_OUT
self._timer_name = None
def start(self, callback=None):
"""Start the animation."""
# Start the animation on the target
self.target.animate(self.property_name, self.value, self.duration, self.easing)
# Schedule callback after duration
if callback:
self._timer_name = f"anim_step_{id(self)}"
mcrfpy.Timer(self._timer_name, lambda rt: callback(), int(self.duration * 1000))
class DelayStep(AnimationStep):
"""Delay step - just waits for a duration."""
def __init__(self, duration):
self.duration = duration
self._timer_name = None
def start(self, callback=None):
"""Wait for duration then call callback."""
if callback:
self._timer_name = f"delay_step_{id(self)}"
mcrfpy.Timer(self._timer_name, lambda rt: callback(), int(self.duration * 1000))
# If no callback, this is a no-op (shouldn't happen in a chain)
class CallbackStep(AnimationStep):
"""Step that calls a function."""
def __init__(self, func):
self.func = func
def start(self, callback=None):
"""Call the function immediately, then callback."""
self.func()
if callback:
callback()
def delay(duration):
"""Create a delay step.
Args:
duration: Delay in seconds
Returns:
DelayStep object
"""
return DelayStep(duration)
def callback(func):
"""Create a callback step.
Args:
func: Function to call (no arguments)
Returns:
CallbackStep object
"""
return CallbackStep(func)
def _parse_animation_spec(spec):
"""Parse an animation specification.
Args:
spec: Either an AnimationStep, or a tuple of (target, property, value, duration[, easing])
Returns:
AnimationStep object
"""
if isinstance(spec, AnimationStep):
return spec
if isinstance(spec, tuple):
if len(spec) == 4:
target, prop, value, duration = spec
return PropertyAnimation(target, prop, value, duration)
elif len(spec) == 5:
target, prop, value, duration, easing = spec
return PropertyAnimation(target, prop, value, duration, easing)
raise ValueError(f"Invalid animation spec: {spec}")
class AnimationChain:
"""Sequential animation execution.
Runs animations one after another. Each step must complete before
the next one starts.
Args:
*steps: Animation steps - either AnimationStep objects or tuples of
(target, property, value, duration[, easing])
callback: Optional function to call when chain completes
loop: If True, restart chain when it completes
Example:
chain = AnimationChain(
(frame, "x", 200, 0.5),
delay(0.2),
(frame, "y", 300, 0.5),
callback=lambda: print("Done!")
)
chain.start()
"""
def __init__(self, *steps, callback=None, loop=False):
self.steps = [_parse_animation_spec(s) for s in steps]
self.callback = callback
self.loop = loop
self._current_index = 0
self._running = False
def start(self):
"""Start the animation chain."""
self._current_index = 0
self._running = True
self._run_next()
def stop(self):
"""Stop the animation chain."""
self._running = False
def _run_next(self):
"""Run the next step in the chain."""
if not self._running:
return
if self._current_index >= len(self.steps):
# Chain complete
if self.callback:
self.callback()
if self.loop:
self._current_index = 0
self._run_next()
else:
self._running = False
return
# Run current step
step = self.steps[self._current_index]
self._current_index += 1
step.start(callback=self._run_next)
class AnimationGroup:
"""Parallel animation execution.
Runs all animations simultaneously. The group completes when
all animations have finished.
Args:
*steps: Animation steps - either AnimationStep objects or tuples of
(target, property, value, duration[, easing])
callback: Optional function to call when all animations complete
Example:
group = AnimationGroup(
(frame, "x", 200, 0.5),
(frame, "opacity", 0.5, 0.3),
callback=lambda: print("All done!")
)
group.start()
"""
def __init__(self, *steps, callback=None):
self.steps = [_parse_animation_spec(s) for s in steps]
self.callback = callback
self._pending = 0
self._running = False
def start(self):
"""Start all animations in parallel."""
if not self.steps:
if self.callback:
self.callback()
return
self._running = True
self._pending = len(self.steps)
for step in self.steps:
step.start(callback=self._on_step_complete)
def stop(self):
"""Stop tracking the group (note: individual animations continue)."""
self._running = False
def _on_step_complete(self):
"""Called when each step completes."""
if not self._running:
return
self._pending -= 1
if self._pending <= 0:
self._running = False
if self.callback:
self.callback()
class AnimationSequence:
"""More complex animation sequencing with named steps and branching.
Args:
steps: Dict mapping step names to animation specs or step lists
start_step: Name of the first step to run
callback: Optional function to call when sequence ends
"""
def __init__(self, steps=None, start_step="start", callback=None):
self.steps = steps or {}
self.start_step = start_step
self.callback = callback
self._current_step = None
self._running = False
def add_step(self, name, *animations, next_step=None):
"""Add a named step.
Args:
name: Step name
*animations: Animation specs for this step
next_step: Name of next step (or None to end)
"""
self.steps[name] = {
"animations": [_parse_animation_spec(a) for a in animations],
"next": next_step
}
def start(self, step_name=None):
"""Start the sequence from a given step."""
self._running = True
self._run_step(step_name or self.start_step)
def stop(self):
"""Stop the sequence."""
self._running = False
def _run_step(self, name):
"""Run a named step."""
if not self._running or name not in self.steps:
if name is None and self.callback:
self.callback()
return
self._current_step = name
step_data = self.steps[name]
animations = step_data.get("animations", [])
next_step = step_data.get("next")
if not animations:
# No animations, go directly to next
self._run_step(next_step)
return
# Run animations in parallel, then proceed to next step
group = AnimationGroup(
*animations,
callback=lambda: self._run_step(next_step)
)
group.start()
# Convenience functions for common patterns
def fade_in(target, duration=0.3, easing=None):
"""Create a fade-in animation (opacity 0 to 1)."""
target.opacity = 0
return PropertyAnimation(target, "opacity", 1.0, duration, easing)
def fade_out(target, duration=0.3, easing=None):
"""Create a fade-out animation (current opacity to 0)."""
return PropertyAnimation(target, "opacity", 0.0, duration, easing)
def slide_in_from_left(target, distance=100, duration=0.3, easing=None):
"""Create a slide-in from left animation."""
original_x = target.x
target.x = original_x - distance
return PropertyAnimation(target, "x", original_x, duration, easing or mcrfpy.Easing.EASE_OUT)
def slide_in_from_right(target, distance=100, duration=0.3, easing=None):
"""Create a slide-in from right animation."""
original_x = target.x
target.x = original_x + distance
return PropertyAnimation(target, "x", original_x, duration, easing or mcrfpy.Easing.EASE_OUT)
def slide_in_from_top(target, distance=100, duration=0.3, easing=None):
"""Create a slide-in from top animation."""
original_y = target.y
target.y = original_y - distance
return PropertyAnimation(target, "y", original_y, duration, easing or mcrfpy.Easing.EASE_OUT)
def slide_in_from_bottom(target, distance=100, duration=0.3, easing=None):
"""Create a slide-in from bottom animation."""
original_y = target.y
target.y = original_y + distance
return PropertyAnimation(target, "y", original_y, duration, easing or mcrfpy.Easing.EASE_OUT)
def pulse(target, scale_factor=1.1, duration=0.2):
"""Create a pulse animation (scale up then back)."""
# Note: This requires scale animation support
# If not available, we approximate with position
original_x = target.x
original_y = target.y
offset = (scale_factor - 1) * 10 # Approximate offset
return AnimationChain(
(target, "x", original_x - offset, duration / 2),
(target, "x", original_x, duration / 2),
)
def shake(target, intensity=5, duration=0.3):
"""Create a shake animation."""
original_x = target.x
step_duration = duration / 6
return AnimationChain(
(target, "x", original_x - intensity, step_duration),
(target, "x", original_x + intensity, step_duration),
(target, "x", original_x - intensity * 0.5, step_duration),
(target, "x", original_x + intensity * 0.5, step_duration),
(target, "x", original_x - intensity * 0.25, step_duration),
(target, "x", original_x, step_duration),
)