Creating Animation Plugins for Tell Me A Tale AI

This document outlines the process for developing new animation plugins for the Tell Me A Tale AI interactive storytelling application. By following this guide, you can create custom visual effects that enhance the storytelling experience.

Plugin System Overview

The animation system uses a simple plugin registry. Each animation is a self-contained JavaScript file that registers itself with a global object, window.TellMeATaleAnimationPlugins. The main application script (main_script.js) then calls upon these registered plugins to start and stop animations.

Core Concepts:

Plugin Structure

Each animation plugin must be an object with the following properties:

Property Type Description Required
id String A unique identifier for the animation (e.g., "myCoolEffect", "gentleRain"). This ID is used in theme_settings.animation_name to trigger the animation. Must be URL-safe if used in parameters. Yes
name String A human-readable name for the animation (e.g., "My Cool Effect", "Gentle Rain"). Used for logging and potentially UI. Yes
type String Indicates the primary mechanism of the animation. Can be:
  • 'canvas': For animations drawn on an HTML5 canvas.
  • 'css': For animations primarily driven by adding/removing CSS classes and CSS animations/transitions.
  • 'dom': For animations that directly manipulate other DOM elements (use with caution).
Yes
start Function function(targetElement, animationOverlayElement)
This function is called by the main script to begin the animation.
  • targetElement: The primary DOM element associated with the current story page content (usually document.getElementById('story-content')). Useful for CSS animations that target the content area or its children. For 'canvas' type, this might be ignored if the animation only uses the overlay. For the 'textFocusFadeIn' example, this was the story-screen element.
  • animationOverlayElement: The DOM element (usually document.getElementById('animation-overlay')) where canvas-based animations should draw. CSS animations might ignore this.
This function MUST return another function: the stop function for this specific animation instance.
Yes
Important: The start method must return a function. This returned function will be called by the main script to stop and clean up the *specific instance* of the animation that was started. This allows each animation instance to manage its own resources (like animationFrameId, event listeners, or dynamically created DOM elements).

Creating a New Animation Plugin

1. Create the JavaScript File

Create a new .js file in the /animations/ directory (e.g., /animations/myNewAnimation.js).

2. Basic Plugin Template (IIFE)

Wrap your plugin code in an Immediately Invoked Function Expression (IIFE) to avoid polluting the global scope and to ensure it runs upon loading.


// animations/myNewAnimation.js
(function() {
    // Ensure the global registry exists
    if (typeof window.TellMeATaleAnimationPlugins === 'undefined') {
        console.error("TellMeATaleAnimationPlugins registry is not defined. MyNewAnimation cannot be registered.");
        // Optionally, create a placeholder registry if this script might load first,
        // though ideally the main script (or a dedicated manager script) defines it first.
        // window.TellMeATaleAnimationPlugins = { _plugins: {}, register: function(p){this._plugins[p.id]=p;}, get: function(id){ return this._plugins[id];}};
        return;
    }

    // --- Your Animation-Specific Variables and Functions Here ---
    // Example for a canvas animation:
    let animationFrameId;
    let canvas, ctx;
    let particles = []; // Example state
    let resizeObserverInstance; // Store the observer instance
    let lastTimestamp = 0;

    function initMyAnimation() {
        // Initialize particles, positions, etc.
        // Ensure canvas and ctx are valid.
        particles = []; // Clear previous
        if (!canvas || canvas.width === 0 || canvas.height === 0) return;
        // ... setup logic ...
        lastTimestamp = performance.now();
    }

    function drawMyAnimation(timestamp) {
        if (!canvas || !ctx) { // Check if canvas/context still exist (might have been stopped)
            if (animationFrameId) cancelAnimationFrame(animationFrameId);
            return;
        }

        const deltaTime = timestamp - lastTimestamp; // Time since last frame in ms
        lastTimestamp = timestamp;

        // Your drawing logic using deltaTime for smooth animation
        // ctx.clearRect(0, 0, canvas.width, canvas.height);
        // ... draw particles/elements ...

        animationFrameId = requestAnimationFrame(drawMyAnimation);
    }

    // --- Register the Plugin ---
    window.TellMeATaleAnimationPlugins.register({
        id: 'myNewAnimationId', // Unique ID
        name: 'My New Awesome Animation', // Human-readable name
        type: 'canvas', // or 'css', 'dom'

        start: function(targetElement, animationOverlayElement) {
            // This function is called when the animation should begin.

            // For 'canvas' type:
            if (this.type === 'canvas') {
                if (!animationOverlayElement) {
                    console.error(this.name + ": animationOverlayElement is required for canvas animations.");
                    return () => {}; // Return a no-op stop function
                }
                // Clear any previous content from the overlay
                animationOverlayElement.innerHTML = '';
                canvas = document.createElement('canvas');
                animationOverlayElement.appendChild(canvas);

                canvas.width = animationOverlayElement.offsetWidth;
                canvas.height = animationOverlayElement.offsetHeight;
                ctx = canvas.getContext('2d');

                // Handle resize - IMPORTANT for canvas animations
                if (resizeObserverInstance) resizeObserverInstance.disconnect(); // Disconnect old one if any
                resizeObserverInstance = new ResizeObserver(entries => {
                    if (!canvas) return; // If canvas was removed, do nothing
                    for (let entry of entries) {
                        if (canvas.width !== entry.contentRect.width || canvas.height !== entry.contentRect.height) {
                            canvas.width = entry.contentRect.width;
                            canvas.height = entry.contentRect.height;
                            initMyAnimation(); // Re-initialize elements for new size
                        }
                    }
                });
                resizeObserverInstance.observe(animationOverlayElement);
            }
            // For 'css' type:
            else if (this.type === 'css') {
                if (!targetElement) {
                     console.error(this.name + ": targetElement is required for this CSS animation.");
                     return () => {};
                }
                // Example: targetElement.classList.add('my-css-animation-active');
            }

            // Initialize and start the animation loop (for canvas)
            initMyAnimation();
            if (this.type === 'canvas') {
                 lastTimestamp = performance.now(); // Reset timestamp before starting loop
                 animationFrameId = requestAnimationFrame(drawMyAnimation);
            }


            // MUST RETURN THE STOP FUNCTION FOR THIS INSTANCE
            return function stopMyAnimationInstance() {
                console.log('Stopping animation instance:', this.id); // 'this' here refers to the plugin object if not careful with context

                if (animationFrameId) {
                    cancelAnimationFrame(animationFrameId);
                    animationFrameId = null;
                }
                if (resizeObserverInstance) {
                    resizeObserverInstance.disconnect();
                    resizeObserverInstance = null;
                }

                // Cleanup for 'canvas' type
                if (canvas && canvas.parentElement) {
                    canvas.parentElement.removeChild(canvas);
                }
                if (animationOverlayElement && this.type === 'canvas') { // Ensure check matches type
                    animationOverlayElement.innerHTML = '';
                }
                canvas = null;
                ctx = null;
                particles = []; // Clear internal state

                // Cleanup for 'css' type (example)
                // if (targetElement && this.type === 'css') {
                //    targetElement.classList.remove('my-css-animation-active');
                // }
            }.bind(this); // Bind 'this' if the stop function needs to refer to plugin properties like 'this.type'
        }
    });
})();
        

3. Implement Animation Logic

For Canvas Animations:

For CSS Animations:

4. Include the Plugin Script

Add a script tag for your new animation file in your main HTML file (e.g., index.html), before main_script.js is loaded, but ideally after any script that defines window.TellMeATaleAnimationPlugins.


<!-- Animation Plugins -->
<script src="animations/snowfall.js"></script>
<script src="animations/rain.js"></script>
<!-- ... other existing animation plugins ... -->
<script src="animations/myNewAnimation.js"></script> <!-- Your new plugin -->

<!-- Your main application script -->
<script src="main_script.js">

5. Triggering the Animation

To use your new animation, set the animation_name property within a page's theme_settings in your story data to the id of your new plugin.


{
  "page_content": "A chilling wind...",
  "choices": ["Investigate", "Ignore"],
  // ... other page properties ...
  "theme_settings": {
    // ... other theme settings ...
    "animation_name": "myNewAnimationId" // Use the ID you defined
  }
}
        

When this page is rendered, and if dynamic theming is enabled, main_script.js will call applyPageAnimation('myNewAnimationId').

Best Practices & Tips

Context of this in Stop Function: If your returned stop function needs to access properties of the plugin object itself (e.g., this.type), you might need to bind the context:

// In start method, when returning the stop function:
return function stopInstance() {
    // 'this' here will refer to the plugin object
    if (this.type === 'canvas') { /* ... */ }
}.bind(this); // Binds the plugin object as 'this' for the stop function
            
Alternatively, capture necessary plugin properties in closure variables within the start function that the returned stop function can access.

Example: Simple CSS Pulse Plugin

animations/simplePulse.js


(function() {
    if (typeof window.TellMeATaleAnimationPlugins === 'undefined') { return; }

    const PULSE_CLASS = 'simple-pulse-active';

    window.TellMeATaleAnimationPlugins.register({
        id: 'simplePulse',
        name: 'Simple CSS Pulse',
        type: 'css',
        start: function(targetElement, animationOverlayElement) {
            if (!targetElement) {
                console.error("SimplePulse: targetElement is required.");
                return () => {};
            }
            targetElement.classList.add(PULSE_CLASS);
            const originalTarget = targetElement; // Capture in closure

            return function stopSimplePulse() {
                if (originalTarget) {
                    originalTarget.classList.remove(PULSE_CLASS);
                }
            };
        }
    });
})();
        

And in style.css:


.simple-pulse-active {
    animation: simplePulseKeyframes 1.5s infinite ease-in-out;
}

@keyframes simplePulseKeyframes {
    0%, 100% { transform: scale(1); opacity: 1; }
    50% { transform: scale(1.05); opacity: 0.7; }
}
        

By following these guidelines, you can effectively extend the Tell Me A Tale AI application with new and exciting visual animations!