Tasmota/lib/libesp32/berry_animation/src/animations/fire.be

260 lines
8.6 KiB
Plaintext

# Fire animation effect for Berry Animation Framework
#
# This animation creates a realistic fire effect with flickering flames.
# The fire uses random intensity variations and warm colors to simulate flames.
import "./core/param_encoder" as encode_constraints
#@ solidify:FireAnimation,weak
class FireAnimation : animation.animation
# Non-parameter instance variables only
var heat_map # bytes() buffer storing heat values for each pixel (0-255)
var current_colors # bytes() buffer storing ARGB colors (4 bytes per pixel)
var last_update # Last update time for flicker timing
var random_seed # Seed for random number generation
# Parameter definitions following parameterized class specification
static var PARAMS = animation.enc_params({
# 'color' for the comet head (32-bit ARGB value), inherited from animation class
"intensity": {"min": 0, "max": 255, "default": 180},
"flicker_speed": {"min": 1, "max": 20, "default": 8},
"flicker_amount": {"min": 0, "max": 255, "default": 100},
"cooling_rate": {"min": 0, "max": 255, "default": 55},
"sparking_rate": {"min": 0, "max": 255, "default": 120}
})
# Initialize a new Fire animation
#
# @param engine: AnimationEngine - The animation engine (required)
def init(engine)
# Call parent constructor with engine
super(self).init(engine)
# Initialize non-parameter instance variables only
self.heat_map = bytes() # Use bytes() buffer for efficient 0-255 value storage
self.current_colors = bytes() # Use bytes() buffer for ARGB colors (4 bytes per pixel)
self.last_update = 0
# Initialize random seed using engine time
self.random_seed = self.engine.time_ms % 65536
end
# Initialize buffers based on current strip length
def _initialize_buffers()
var strip_length = self.engine.strip_length
# Create new bytes() buffer for heat values (1 byte per pixel)
self.heat_map.clear()
self.heat_map.resize(strip_length)
# Create new bytes() buffer for colors (4 bytes per pixel: ARGB)
self.current_colors.clear()
self.current_colors.resize(strip_length * 4)
# Initialize all pixels to zero heat and black color (0xFF000000)
var i = 0
while i < strip_length
self.current_colors.set(i * 4, 0xFF000000, -4) # Black with full alpha
i += 1
end
end
# Simple pseudo-random number generator
# Uses a linear congruential generator for consistent results
def _random()
self.random_seed = (self.random_seed * 1103515245 + 12345) & 0x7FFFFFFF
return self.random_seed
end
# Get random number in range [0, max)
def _random_range(max)
if max <= 0
return 0
end
return self._random() % max
end
# Update animation state based on current time
#
# @param time_ms: int - Current time in milliseconds
def update(time_ms)
# Check if it's time to update the fire simulation
# Update frequency is based on flicker_speed (Hz)
var flicker_speed = self.flicker_speed # Cache parameter value
var update_interval = 1000 / flicker_speed # milliseconds between updates
if time_ms - self.last_update >= update_interval
self.last_update = time_ms
self._update_fire_simulation(time_ms)
end
end
# Update the fire simulation
def _update_fire_simulation(time_ms)
# Cache parameter values for performance
var cooling_rate = self.cooling_rate
var sparking_rate = self.sparking_rate
var intensity = self.intensity
var flicker_amount = self.flicker_amount
var color_param = self.color
var strip_length = self.engine.strip_length
# Ensure buffers are correct size (bytes() uses .size() method)
if self.heat_map.size() != strip_length || self.current_colors.size() != strip_length * 4
self._initialize_buffers()
end
# Step 1: Cool down every pixel a little
var i = 0
while i < strip_length
var cooldown = self._random_range(tasmota.scale_uint(cooling_rate, 0, 255, 0, 10) + 2)
if cooldown >= self.heat_map[i]
self.heat_map[i] = 0
else
self.heat_map[i] -= cooldown
end
i += 1
end
# Step 2: Heat from each pixel drifts 'up' and diffuses a little
# Only do this if we have at least 3 pixels
if strip_length >= 3
var k = strip_length - 1
while k >= 2
var heat_avg = (self.heat_map[k-1] + self.heat_map[k-2] + self.heat_map[k-2]) / 3
# Ensure the result is an integer in valid range (0-255)
if heat_avg < 0
heat_avg = 0
elif heat_avg > 255
heat_avg = 255
end
self.heat_map[k] = int(heat_avg)
k -= 1
end
end
# Step 3: Randomly ignite new 'sparks' of heat near the bottom
if self._random_range(255) < sparking_rate
var spark_pos = self._random_range(7) # Sparks only in bottom 7 pixels
var spark_heat = self._random_range(95) + 160 # Heat between 160-254
# Ensure spark heat is in valid range (should already be, but be explicit)
if spark_heat > 255
spark_heat = 255
end
if spark_pos < strip_length
self.heat_map[spark_pos] = spark_heat
end
end
# Step 4: Convert heat to colors
i = 0
while i < strip_length
var heat = self.heat_map[i]
# Apply base intensity scaling
heat = tasmota.scale_uint(heat, 0, 255, 0, intensity)
# Add flicker effect
if flicker_amount > 0
var flicker = self._random_range(flicker_amount)
# Randomly add or subtract flicker
if self._random_range(2) == 0
heat = heat + flicker
else
if heat > flicker
heat = heat - flicker
else
heat = 0
end
end
# Clamp to valid range
if heat > 255
heat = 255
end
end
# Get color from provider based on heat value
var color = 0xFF000000 # Default to black
if heat > 0
# Get the color parameter (may be nil for default)
var resolved_color = color_param
# If color is nil, create default fire palette
if resolved_color == nil
# Create default fire palette on demand
var fire_provider = animation.rich_palette(self.engine)
fire_provider.colors = animation.PALETTE_FIRE
fire_provider.cycle_period = 0 # Use value-based color mapping, not time-based
fire_provider.transition_type = 1 # Use sine transition (smooth)
fire_provider.brightness = 255
resolved_color = fire_provider
end
# If the color is a provider that supports get_color_for_value, use it
if animation.is_color_provider(resolved_color) && resolved_color.get_color_for_value != nil
# Use value-based color mapping for heat
color = resolved_color.get_color_for_value(heat, 0)
else
# Use the resolved color and apply heat as brightness scaling
color = resolved_color
# Apply heat as brightness scaling
var a = (color >> 24) & 0xFF
var r = (color >> 16) & 0xFF
var g = (color >> 8) & 0xFF
var b = color & 0xFF
r = tasmota.scale_uint(heat, 0, 255, 0, r)
g = tasmota.scale_uint(heat, 0, 255, 0, g)
b = tasmota.scale_uint(heat, 0, 255, 0, b)
color = (a << 24) | (r << 16) | (g << 8) | b
end
end
self.current_colors.set(i * 4, color, -4)
i += 1
end
end
# Render the fire to the provided frame buffer
#
# @param frame: FrameBuffer - The frame buffer to render to
# @param time_ms: int - Current time in milliseconds
# @param strip_length: int - Length of the LED strip in pixels
# @return bool - True if frame was modified, false otherwise
def render(frame, time_ms, strip_length)
# Render each pixel with its current color
var i = 0
while i < strip_length
if i < frame.width
frame.set_pixel_color(i, self.current_colors.get(i * 4, -4))
end
i += 1
end
return true
end
# Override start method for timing control
def start(time_ms)
# Call parent start first
super(self).start(time_ms)
# Reset timing and reinitialize buffers
self.last_update = 0
self._initialize_buffers()
# Reset random seed
self.random_seed = self.engine.time_ms % 65536
return self
end
# String representation of the animation
def tostring()
return f"FireAnimation(intensity={self.intensity}, flicker_speed={self.flicker_speed}, priority={self.priority}, running={self.is_running})"
end
end
return {'fire_animation': FireAnimation}