Tasmota/lib/libesp32/berry_animation/docs/ANIMATION_DEVELOPMENT.md

586 lines
16 KiB
Markdown

# Animation Development Guide
Guide for developers creating custom animation classes in the Berry Animation Framework.
## Overview
**Note**: This guide is for developers who want to extend the framework by creating new animation classes. For using existing animations, see the [DSL Reference](DSL_REFERENCE.md) which provides a declarative way to create animations without programming.
The Berry Animation Framework uses a unified architecture where all visual elements inherit from the base `Animation` class. This guide explains how to create custom animation classes that integrate seamlessly with the framework's parameter system, value providers, and rendering pipeline.
## Animation Class Structure
### Basic Class Template
```berry
#@ solidify:MyAnimation,weak
class MyAnimation : animation.animation
# NO instance variables for parameters - they are handled by the virtual parameter system
# Parameter definitions following the new specification
static var PARAMS = {
"my_param1": {"default": "default_value", "type": "string"},
"my_param2": {"min": 0, "max": 255, "default": 100, "type": "int"}
# Do NOT include inherited Animation parameters here
}
def init(engine)
# Engine parameter is MANDATORY and cannot be nil
super(self).init(engine)
# Only initialize non-parameter instance variables (none in this example)
# Parameters are handled by the virtual parameter system
end
# Handle parameter changes (optional)
def on_param_changed(name, value)
# Add custom logic for parameter changes if needed
# Parameter validation is handled automatically by the framework
end
def render(frame, time_ms)
if !self.is_running || frame == nil
return false
end
# Use engine time if not provided
if time_ms == nil
time_ms = self.engine.time_ms
end
# Use virtual parameter access - automatically resolves ValueProviders
var param1 = self.my_param1
var param2 = self.my_param2
# Your rendering logic here
# ...
return true
end
# NO setter methods needed - use direct virtual parameter assignment:
# obj.my_param1 = value
# obj.my_param2 = value
def tostring()
return f"MyAnimation(param1={self.my_param1}, param2={self.my_param2}, running={self.is_running})"
end
end
```
## PARAMS System
### Static Parameter Definition
The `PARAMS` static variable defines all parameters specific to your animation class. This system provides:
- **Parameter validation** with min/max constraints and type checking
- **Default value handling** for initialization
- **Virtual parameter access** through getmember/setmember
- **Automatic ValueProvider resolution**
#### Parameter Definition Format
```berry
static var PARAMS = {
"parameter_name": {
"default": default_value, # Default value (optional)
"min": minimum_value, # Minimum value for integers (optional)
"max": maximum_value, # Maximum value for integers (optional)
"enum": [val1, val2, val3], # Valid enum values (optional)
"type": "parameter_type", # Expected type (optional)
"nillable": true # Whether nil values are allowed (optional)
}
}
```
#### Supported Types
- **`"int"`** - Integer values (default if not specified)
- **`"string"`** - String values
- **`"bool"`** - Boolean values (true/false)
- **`"instance"`** - Object instances
- **`"any"`** - Any type (no type validation)
#### Important Rules
- **Do NOT include inherited parameters** - Animation base class parameters are handled automatically
- **Only define class-specific parameters** in your PARAMS
- **No constructor parameter mapping** - the new system uses engine-only constructors
- **Parameters are accessed via virtual members**: `obj.param_name`
## Constructor Implementation
### Engine-Only Constructor Pattern
```berry
def init(engine)
# 1. ALWAYS call super with engine (engine is the ONLY parameter)
super(self).init(engine)
# 2. Initialize non-parameter instance variables only
self.internal_state = initial_value
self.buffer = nil
# Do NOT initialize parameters here - they are handled by the virtual system
end
```
### Parameter Change Handling
```berry
def on_param_changed(name, value)
# Optional method to handle parameter changes
if name == "scale"
# Recalculate internal state when scale changes
self._update_internal_buffers()
elif name == "color"
# Handle color changes
self._invalidate_color_cache()
end
end
```
### Key Changes from Old System
- **Engine-only constructor**: Constructor takes ONLY the engine parameter
- **No parameter initialization**: Parameters are set by caller using virtual member assignment
- **No instance variables for parameters**: Parameters are handled by the virtual system
- **Automatic validation**: Parameter validation happens automatically based on PARAMS constraints
## Value Provider Integration
### Automatic ValueProvider Resolution
The virtual parameter system automatically resolves ValueProviders when you access parameters:
```berry
def render(frame, time_ms)
# Use engine time if not provided
if time_ms == nil
time_ms = self.engine.time_ms
end
# Virtual parameter access automatically resolves ValueProviders
var color = self.color # Returns current color value, not the provider
var position = self.pos # Returns current position value
var size = self.size # Returns current size value
# Use resolved values in rendering logic
for i: position..(position + size - 1)
if i >= 0 && i < frame.width
frame.set_pixel_color(i, color)
end
end
return true
end
```
### Setting Dynamic Parameters
Users can set both static values and ValueProviders using the same syntax:
```berry
# Create animation
var anim = animation.my_animation(engine)
# Static values
anim.color = 0xFFFF0000
anim.pos = 5
anim.size = 3
# Dynamic values
anim.color = animation.smooth(0xFF000000, 0xFFFFFFFF, 2000)
anim.pos = animation.triangle(0, 29, 3000)
```
### Performance Optimization
For performance-critical code, cache parameter values:
```berry
def render(frame, time_ms)
# Cache parameter values to avoid multiple virtual member access
var current_color = self.color
var current_pos = self.pos
var current_size = self.size
# Use cached values in loops
for i: current_pos..(current_pos + current_size - 1)
if i >= 0 && i < frame.width
frame.set_pixel_color(i, current_color)
end
end
return true
end
```
## Parameter Access
### Direct Virtual Member Assignment
The new system uses direct parameter assignment instead of setter methods:
```berry
# Create animation
var anim = animation.my_animation(engine)
# Direct parameter assignment (recommended)
anim.color = 0xFF00FF00
anim.pos = 10
anim.size = 5
# Method chaining is not needed - just set parameters directly
```
### Parameter Validation
The parameter system handles validation automatically based on PARAMS constraints:
```berry
# This will raise an exception due to min: 0 constraint
anim.size = -1 # Raises value_error
# This will be accepted
anim.size = 5 # Parameter updated successfully
# Method-based setting returns true/false for validation
var success = anim.set_param("size", -1) # Returns false, no exception
```
### Accessing Raw Parameters
```berry
# Get current parameter value (resolved if ValueProvider)
var current_color = anim.color
# Get raw parameter (returns ValueProvider if set)
var raw_color = anim.get_param("color")
# Check if parameter is a ValueProvider
if animation.is_value_provider(raw_color)
print("Color is dynamic")
else
print("Color is static")
end
```
## Rendering Implementation
### Frame Buffer Operations
```berry
def render(frame, time_ms)
if !self.is_running || frame == nil
return false
end
# Get frame dimensions
var width = frame.width
var height = frame.height # Usually 1 for LED strips
# Resolve dynamic parameters
var color = self.resolve_value(self.color, "color", time_ms)
var opacity = self.resolve_value(self.opacity, "opacity", time_ms)
# Render your effect
for i: 0..(width-1)
var pixel_color = calculate_pixel_color(i, time_ms)
frame.set_pixel_color(i, pixel_color)
end
# Apply opacity if not full
if opacity < 255
frame.apply_opacity(opacity)
end
return true # Frame was modified
end
```
### Common Rendering Patterns
#### Fill Pattern
```berry
# Fill entire frame with color
frame.fill_pixels(color)
```
#### Position-Based Effects
```berry
# Render at specific positions
var start_pos = self.resolve_value(self.pos, "pos", time_ms)
var size = self.resolve_value(self.size, "size", time_ms)
for i: 0..(size-1)
var pixel_pos = start_pos + i
if pixel_pos >= 0 && pixel_pos < frame.width
frame.set_pixel_color(pixel_pos, color)
end
end
```
#### Gradient Effects
```berry
# Create gradient across frame
for i: 0..(frame.width-1)
var progress = i / (frame.width - 1.0) # 0.0 to 1.0
var interpolated_color = interpolate_color(start_color, end_color, progress)
frame.set_pixel_color(i, interpolated_color)
end
```
## Complete Example: PulsePositionAnimation
Here's a complete example showing all concepts:
```berry
#@ solidify:PulsePositionAnimation,weak
class BeaconAnimation : animation.animation
# NO instance variables for parameters - they are handled by the virtual parameter system
# Parameter definitions following the new specification
static var PARAMS = {
"color": {"default": 0xFFFFFFFF},
"back_color": {"default": 0xFF000000},
"pos": {"default": 0},
"beacon_size": {"min": 0, "default": 1},
"slew_size": {"min": 0, "default": 0}
}
# Initialize a new Pulse Position animation
# Engine parameter is MANDATORY and cannot be nil
def init(engine)
# Call parent constructor with engine (engine is the ONLY parameter)
super(self).init(engine)
# Only initialize non-parameter instance variables (none in this case)
# Parameters are handled by the virtual parameter system
end
# Handle parameter changes (optional - can be removed if no special handling needed)
def on_param_changed(name, value)
# No special handling needed for this animation
# Parameter validation is handled automatically by the framework
end
# Render the pulse to the provided frame buffer
def render(frame, time_ms)
if frame == nil
return false
end
# Use engine time if not provided
if time_ms == nil
time_ms = self.engine.time_ms
end
var pixel_size = frame.width
# Use virtual parameter access - automatically resolves ValueProviders
var back_color = self.back_color
var pos = self.pos
var slew_size = self.slew_size
var beacon_size = self.beacon_size
var color = self.color
# Fill background if not transparent
if back_color != 0xFF000000
frame.fill_pixels(back_color)
end
# Calculate pulse boundaries
var pulse_min = pos
var pulse_max = pos + beacon_size
# Clamp to frame boundaries
if pulse_min < 0
pulse_min = 0
end
if pulse_max >= pixel_size
pulse_max = pixel_size
end
# Draw the main pulse
var i = pulse_min
while i < pulse_max
frame.set_pixel_color(i, color)
i += 1
end
# Draw slew regions if slew_size > 0
if slew_size > 0
# Left slew (fade from background to pulse color)
var left_slew_min = pos - slew_size
var left_slew_max = pos
if left_slew_min < 0
left_slew_min = 0
end
if left_slew_max >= pixel_size
left_slew_max = pixel_size
end
i = left_slew_min
while i < left_slew_max
# Calculate blend factor
var blend_factor = tasmota.scale_uint(i, pos - slew_size, pos - 1, 255, 0)
var alpha = 255 - blend_factor
var blend_color = (alpha << 24) | (color & 0x00FFFFFF)
var blended_color = frame.blend(back_color, blend_color)
frame.set_pixel_color(i, blended_color)
i += 1
end
# Right slew (fade from pulse color to background)
var right_slew_min = pos + beacon_size
var right_slew_max = pos + beacon_size + slew_size
if right_slew_min < 0
right_slew_min = 0
end
if right_slew_max >= pixel_size
right_slew_max = pixel_size
end
i = right_slew_min
while i < right_slew_max
# Calculate blend factor
var blend_factor = tasmota.scale_uint(i, pos + beacon_size, pos + beacon_size + slew_size - 1, 0, 255)
var alpha = 255 - blend_factor
var blend_color = (alpha << 24) | (color & 0x00FFFFFF)
var blended_color = frame.blend(back_color, blend_color)
frame.set_pixel_color(i, blended_color)
i += 1
end
end
return true
end
# NO setter methods - use direct virtual parameter assignment instead:
# obj.color = value
# obj.pos = value
# obj.beacon_size = value
# obj.slew_size = value
# String representation of the animation
def tostring()
return f"PulsePositionAnimation(color=0x{self.color :08x}, pos={self.pos}, beacon_size={self.beacon_size}, slew_size={self.slew_size})"
end
end
# Export class directly - no redundant factory function needed
return {'beacon_animation': PulsePositionAnimation}
```
## Testing Your Animation
### Unit Tests
Create comprehensive tests for your animation:
```berry
import animation
def test_my_animation()
# Create LED strip and engine for testing
var strip = global.Leds(10) # Use built-in LED strip for testing
var engine = animation.animation_engine(strip)
# Test basic construction
var anim = animation.my_animation(engine)
assert(anim != nil, "Animation should be created")
# Test parameter setting
anim.color = 0xFFFF0000
assert(anim.color == 0xFFFF0000, "Color should be set")
# Test parameter updates
anim.color = 0xFF00FF00
assert(anim.color == 0xFF00FF00, "Color should be updated")
# Test value providers
var dynamic_color = animation.smooth(engine)
dynamic_color.min_value = 0xFF000000
dynamic_color.max_value = 0xFFFFFFFF
dynamic_color.duration = 2000
anim.color = dynamic_color
var raw_color = anim.get_param("color")
assert(animation.is_value_provider(raw_color), "Should accept value provider")
# Test rendering
var frame = animation.frame_buffer(10)
anim.start()
var result = anim.render(frame, 1000)
assert(result == true, "Should render successfully")
print("✓ All tests passed")
end
test_my_animation()
```
### Integration Testing
Test with the animation engine:
```berry
var strip = global.Leds(30) # Use built-in LED strip
var engine = animation.animation_engine(strip)
var anim = animation.my_animation(engine)
# Set parameters
anim.color = 0xFFFF0000
anim.pos = 5
anim.beacon_size = 3
engine.add_animation(anim)
engine.start()
# Let it run for a few seconds
tasmota.delay(3000)
engine.stop()
print("Integration test completed")
```
## Best Practices
### Performance
- **Minimize calculations** in render() method
- **Cache resolved values** when possible
- **Use integer math** instead of floating point
- **Avoid memory allocation** in render loops
### Memory Management
- **Reuse objects** when possible
- **Clear references** to large objects when done
- **Use static variables** for constants
### Code Organization
- **Group related parameters** together
- **Use descriptive variable names**
- **Comment complex algorithms**
- **Follow Berry naming conventions**
### Error Handling
- **Validate parameters** in constructor
- **Handle edge cases** gracefully
- **Return false** from render() on errors
- **Use meaningful error messages**
## Publishing Your Animation Class
Once you've created a new animation class:
1. **Add it to the animation module** by importing it in `animation.be`
2. **Create a factory function** following the engine-first pattern
3. **Add DSL support** by ensuring the transpiler recognizes your factory function
4. **Document parameters** in the class hierarchy documentation
5. **Test with DSL** to ensure users can access your animation declaratively
**Remember**: Users should primarily interact with animations through the DSL. The programmatic API is mainly for framework development and advanced integrations.
This guide provides everything needed to create professional-quality animation classes that integrate seamlessly with the Berry Animation Framework's parameter system and rendering pipeline.