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.
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.
window.TellMeATaleAnimationPlugins
provides methods to register(plugin)
and get(id)
animation plugins..js
file, typically placed in an /animations
subfolder.start
method. The start
method must return a specific stop
function for that animation instance.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:
|
Yes |
start |
Function |
function(targetElement, animationOverlayElement) This function is called by the main script to begin the animation.
stop function for this specific animation instance.
|
Yes |
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).
Create a new .js
file in the /animations/ directory (e.g., /animations/myNewAnimation.js).
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'
}
});
})();
animationOverlayElement
to create and append your canvas
element.ctx
).init
function to set up initial states (e.g., particle positions, colors).draw
function that:
ctx.clearRect(0, 0, canvas.width, canvas.height);
).requestAnimationFrame(drawFunction)
for smooth looping. Store the ID returned by requestAnimationFrame
.
// Inside your draw function:
const deltaTime = (timestamp - lastTimestamp) / 16.66; // Normalize to 60FPS base (1000ms / 60fps ≈ 16.66ms)
lastTimestamp = timestamp;
// ...
// particle.x += particle.speedX * deltaTimeFactor;
ResizeObserver
on the animationOverlayElement
to detect size changes. When the overlay resizes, update your canvas dimensions (canvas.width
, canvas.height
) and typically re-initialize or adjust your animation elements.stop
function must:
cancelAnimationFrame(animationFrameId)
.ResizeObserver
.particles = []
).canvas
and ctx
variables to null
.style.css
file.start
function will typically add a specific CSS class to the targetElement
(or another relevant element) to trigger the CSS animation/transition.stop
function must remove the CSS class that was added.
// In plugin's start method:
// targetElement.classList.add('my-animation-active-class');
// return function() { targetElement.classList.remove('my-animation-active-class'); };
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">
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')
.
id
is unique to avoid conflicts.requestAnimationFrame
correctly. Optimize drawing operations.stop
function (animation frame IDs, event listeners, DOM elements, observers, large arrays). Leaks can degrade performance over time.animationOverlayElement
) and log errors gracefully.
console.log
during development but consider removing or reducing them for production to keep the console clean.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.
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!