Giant refactor. added layers. ui overhaul. added save/load and we now got presets
This commit is contained in:
Sam
2025-12-28 03:21:25 +13:00
parent f01076df57
commit 14ec23237f
90 changed files with 4971 additions and 22901 deletions

View File

@@ -0,0 +1,163 @@
/**
* AnimationEngine - Manages the canvas, render loop, and timing
*/
class AnimationEngine {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
// Sizing
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
// Timing
this.targetFps = 60;
this.frameDuration = 1000 / this.targetFps;
this.lastTimestamp = 0;
this.elapsedTime = 0;
this.rotation = 0;
this.degPerSec = 10;
// State
this.paused = false;
this.isRunning = false;
// Scene reference (set via setScene)
this.scene = null;
}
/**
* Set the scene to render
* @param {Scene} scene - The scene instance
*/
setScene(scene) {
this.scene = scene;
}
/**
* Resize canvas to fill window
*/
resizeCanvas() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
this.centerX = this.canvas.width / 2;
this.centerY = this.canvas.height / 2;
// Update global references for backward compatibility
if (typeof centerX !== 'undefined') {
centerX = this.centerX;
centerY = this.centerY;
}
if (typeof ctx !== 'undefined') {
ctx = this.ctx;
}
}
/**
* Clear the canvas
*/
clear() {
this.ctx.fillStyle = 'black';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
/**
* Main render loop
*/
render(timestamp) {
if (!this.isRunning) return;
if (!this.lastTimestamp) {
this.lastTimestamp = timestamp;
}
const deltaTime = timestamp - this.lastTimestamp;
this.lastTimestamp = timestamp;
let adjustedDeltaTime = 0;
if (!this.paused) {
this.rotation += this.degPerSec / this.targetFps;
this.elapsedTime += deltaTime;
adjustedDeltaTime = deltaTime / 100;
}
const adjustedElapsed = this.elapsedTime / 1000;
// Clear and draw
this.clear();
if (this.scene) {
this.scene.draw(adjustedElapsed, adjustedDeltaTime);
}
requestAnimationFrame((ts) => this.render(ts));
}
/**
* Start the animation loop
*/
start() {
if (this.isRunning) return;
this.isRunning = true;
this.lastTimestamp = 0;
requestAnimationFrame((ts) => this.render(ts));
}
/**
* Stop the animation loop
*/
stop() {
this.isRunning = false;
}
/**
* Toggle pause state
*/
togglePause() {
this.paused = !this.paused;
return this.paused;
}
/**
* Reset rotation/timing
*/
reset() {
this.rotation = 0;
this.elapsedTime = 0;
}
/**
* Step forward one frame
*/
stepForward() {
this.rotation += this.degPerSec / this.targetFps;
}
/**
* Step backward one frame
*/
stepBackward() {
this.rotation -= this.degPerSec / this.targetFps;
}
/**
* Set degrees per second
*/
setSpeed(degPerSec) {
this.degPerSec = parseFloat(degPerSec);
}
/**
* Get canvas context (for shapes that need direct access)
*/
getContext() {
return this.ctx;
}
/**
* Get center coordinates
*/
getCenter() {
return { x: this.centerX, y: this.centerY };
}
}

124
docs/js/core/BaseShape.js Normal file
View File

@@ -0,0 +1,124 @@
/**
* BaseShape - Base class for all animated shapes
*
* To create a new shape:
* 1. Create a new file in /js/shapes/
* 2. Extend BaseShape
* 3. Define static `config` array with control definitions
* 4. Implement constructor with matching parameters
* 5. Implement `draw(elapsed, deltaTime)` method
* 6. Register with shapeRegistry at the end of the file
*
* Example:
* ```
* class MyShape extends BaseShape {
* static config = [
* { type: 'range', property: 'size', min: 10, max: 500, defaultValue: 100 },
* { type: 'color', property: 'color', defaultValue: '#ff0000' }
* ];
*
* constructor(size, color) {
* super();
* this.size = size;
* this.color = color;
* }
*
* draw(elapsed, deltaTime) {
* this.updateFilters(elapsed);
* // Drawing code using this.size and this.color
* }
* }
* shapeRegistry.register('MyShape', MyShape);
* ```
*
* Control types:
* - range: { type, property, min, max, defaultValue, callback? }
* - color: { type, property, defaultValue }
* - checkbox: { type, property, defaultValue }
* - dropdown: { type, property, defaultValue, options: [{value, label}] }
* - button: { type, label, method }
* - header: { type, text }
*/
class BaseShape {
constructor() {
this.controls = [];
this.controlManager = null;
this.speedMultiplier = 500;
}
/**
* Initialize controls using the ControlManager
* Called automatically when a shape is created
* @param {ControlManager} controlManager - The control manager instance
*/
initializeControls(controlManager) {
this.controlManager = controlManager;
// Get config from static property
const config = this.constructor.config || [];
for (const item of config) {
const controlData = controlManager.addControl(item, this);
if (controlData) {
this.controls.push(controlData);
}
}
// Add speed multiplier control (common to all shapes)
const speedControl = controlManager.addControl({
type: 'range',
min: 1,
max: 1000,
defaultValue: this.speedMultiplier,
property: 'speedMultiplier'
}, this);
if (speedControl) {
this.controls.push(speedControl);
}
}
/**
* Clean up controls when shape is removed
*/
remove() {
if (this.controlManager) {
for (const control of this.controls) {
this.controlManager.removeControl(control);
}
}
this.controls = [];
}
/**
* Update any active filters on controls
* Call this at the start of your draw() method
* @param {number} elapsed - Total elapsed time in seconds
*/
updateFilters(elapsed) {
if (!this.controlManager) return;
for (const control of this.controls) {
if (control.filters && control.filters.length > 0) {
const newValue = this.controlManager.filterManager.computeFilteredValue(
control.filters,
elapsed
);
if (newValue !== null && control.element) {
control.element.value = newValue;
const event = new Event('input', { bubbles: true });
control.element.dispatchEvent(event);
}
}
}
}
/**
* Main draw method - override in subclasses
* @param {number} elapsed - Total elapsed time in seconds
* @param {number} deltaTime - Time since last frame
*/
draw(elapsed, deltaTime) {
throw new Error('draw() method not implemented');
}
}

267
docs/js/core/Scene.js Normal file
View File

@@ -0,0 +1,267 @@
/**
* Scene - Manages multiple shapes on a single canvas
*
* Features:
* - Multiple shapes rendered in layer order
* - Per-shape visibility and pause controls
* - Collapsible control panels per shape
* - Add/remove shapes dynamically
*/
class Scene {
constructor() {
this.layers = []; // Array of { id, shape, visible, paused, collapsed }
this.nextLayerId = 1;
}
/**
* Add a shape to the scene
* @param {string} shapeName - Name of the shape class
* @param {object} initialValues - Initial property values
* @returns {number} Layer ID
*/
addShape(shapeName, initialValues = {}) {
const shape = shapeRegistry.createInstance(shapeName, initialValues);
if (!shape) {
throw new Error(`Failed to create shape: ${shapeName}`);
}
const layer = {
id: this.nextLayerId++,
name: shapeName,
shape: shape,
visible: true,
paused: false,
collapsed: false
};
this.layers.push(layer);
return layer.id;
}
/**
* Remove a shape from the scene
* @param {number} layerId - Layer ID to remove
*/
removeShape(layerId) {
const index = this.layers.findIndex(l => l.id === layerId);
if (index !== -1) {
const layer = this.layers[index];
// Clean up controls
if (layer.shape.remove) {
layer.shape.remove();
}
this.layers.splice(index, 1);
}
}
/**
* Get a layer by ID
* @param {number} layerId
* @returns {object|null}
*/
getLayer(layerId) {
return this.layers.find(l => l.id === layerId) || null;
}
/**
* Toggle layer visibility
* @param {number} layerId
*/
toggleVisibility(layerId) {
const layer = this.getLayer(layerId);
if (layer) {
layer.visible = !layer.visible;
}
}
/**
* Toggle layer pause state
* @param {number} layerId
*/
togglePause(layerId) {
const layer = this.getLayer(layerId);
if (layer) {
layer.paused = !layer.paused;
}
}
/**
* Toggle layer control panel collapsed state
* @param {number} layerId
*/
toggleCollapsed(layerId) {
const layer = this.getLayer(layerId);
if (layer) {
layer.collapsed = !layer.collapsed;
}
}
/**
* Move a layer up in the render order
* @param {number} layerId
*/
moveUp(layerId) {
const index = this.layers.findIndex(l => l.id === layerId);
if (index > 0) {
[this.layers[index - 1], this.layers[index]] = [this.layers[index], this.layers[index - 1]];
}
}
/**
* Move a layer down in the render order
* @param {number} layerId
*/
moveDown(layerId) {
const index = this.layers.findIndex(l => l.id === layerId);
if (index < this.layers.length - 1) {
[this.layers[index], this.layers[index + 1]] = [this.layers[index + 1], this.layers[index]];
}
}
/**
* Draw all visible shapes
* @param {number} elapsed - Total elapsed time
* @param {number} deltaTime - Time since last frame
*/
draw(elapsed, deltaTime) {
for (const layer of this.layers) {
if (layer.visible && !layer.paused) {
layer.shape.draw(elapsed, deltaTime);
}
}
}
/**
* Get all layers
* @returns {array}
*/
getLayers() {
return this.layers;
}
/**
* Clear all layers
*/
clear() {
for (const layer of this.layers) {
if (layer.shape.remove) {
layer.shape.remove();
}
}
this.layers = [];
}
/**
* Export scene to JSON
* @returns {object} Scene data
*/
exportToJSON() {
return {
version: 1,
layers: this.layers.map(layer => {
const shape = layer.shape;
const ShapeClass = shape.constructor;
const config = ShapeClass.config || [];
// Extract current values from shape
const values = {};
config.forEach(item => {
if (item.property && shape.hasOwnProperty(item.property)) {
values[item.property] = shape[item.property];
}
});
// Also save speedMultiplier
values.speedMultiplier = shape.speedMultiplier;
// Extract filter settings and control bounds from shape.controls
const filters = {};
const controlBounds = {};
if (shape.controls) {
for (const control of shape.controls) {
const property = control.config?.property;
if (!property) continue;
// Save filters
if (control.filters && control.filters.length > 0) {
filters[property] = control.filters.map(f => ({
type: f.type,
params: { ...f.params }
}));
}
// Save custom min/max bounds if different from original
if (control.configState) {
const { min, max, originalMin, originalMax } = control.configState;
if (min !== originalMin || max !== originalMax) {
controlBounds[property] = { min, max };
}
}
}
}
return {
name: layer.name,
visible: layer.visible,
paused: layer.paused,
collapsed: layer.collapsed,
values: values,
filters: filters,
controlBounds: controlBounds
};
})
};
}
/**
* Import scene from JSON
* @param {object} data - Scene data
* @param {SceneUI} sceneUI - UI instance to rebuild panels
*/
importFromJSON(data, sceneUI) {
// Clear existing
this.clear();
if (sceneUI) {
sceneUI.clearAllPanels();
}
// Validate version
if (!data.version || !data.layers) {
throw new Error('Invalid scene data');
}
// Recreate layers
for (const layerData of data.layers) {
try {
// Check if shape type exists
if (!shapeRegistry.get(layerData.name)) {
console.warn(`Shape "${layerData.name}" not found, skipping`);
continue;
}
const layerId = this.addShape(layerData.name, layerData.values);
const layer = this.getLayer(layerId);
if (!layer || !layer.shape) {
console.warn(`Failed to create layer for "${layerData.name}"`);
continue;
}
layer.visible = layerData.visible !== false;
layer.paused = layerData.paused || false;
layer.collapsed = layerData.collapsed || false;
// Create UI panel (this also creates controls, applies filters and bounds)
if (sceneUI) {
sceneUI.createLayerPanel(layer, layerData.filters, layerData.controlBounds);
}
} catch (e) {
console.warn(`Failed to load shape "${layerData.name}":`, e.message);
}
}
}
}
// Global scene instance
const scene = new Scene();

View File

@@ -0,0 +1,89 @@
/**
* ShapeRegistry - Automatically registers and manages available shape classes
* Shapes register themselves when loaded, no manual map needed
*/
class ShapeRegistry {
constructor() {
this.shapes = new Map();
}
/**
* Register a shape class
* @param {string} name - The name of the shape
* @param {class} shapeClass - The shape class constructor
*/
register(name, shapeClass) {
if (this.shapes.has(name)) {
console.warn(`Shape "${name}" is already registered. Overwriting.`);
}
this.shapes.set(name, shapeClass);
}
/**
* Get a shape class by name
* @param {string} name - The name of the shape
* @returns {class|null} The shape class or null if not found
*/
get(name) {
return this.shapes.get(name) || null;
}
/**
* Create an instance of a shape
* @param {string} name - The name of the shape
* @param {object} initialValues - Initial property values (overrides defaults)
* @returns {BaseShape} The shape instance
*/
createInstance(name, initialValues = {}) {
const ShapeClass = this.get(name);
if (!ShapeClass) {
throw new Error(`Unknown shape: "${name}". Available shapes: ${this.getAvailableNames().join(', ')}`);
}
// Get the static config to determine constructor argument order
const config = ShapeClass.config || [];
// Build positional arguments array from config defaults + initialValues overrides
const args = config
.filter(item => item.property) // Only items with properties
.map(item => {
// Use provided value or fall back to default
return initialValues.hasOwnProperty(item.property)
? initialValues[item.property]
: item.defaultValue;
});
return new ShapeClass(...args);
}
/**
* Get all registered shape names (alphabetically sorted)
* @returns {string[]} Array of shape names
*/
getAvailableNames() {
return Array.from(this.shapes.keys()).sort((a, b) => a.localeCompare(b));
}
/**
* Get the config definition for a shape
* @param {string} name - The name of the shape
* @returns {array|null} The config array or null
*/
getConfig(name) {
const ShapeClass = this.get(name);
if (!ShapeClass) return null;
return ShapeClass.config || [];
}
/**
* Check if a shape is registered
* @param {string} name - The name of the shape
* @returns {boolean}
*/
has(name) {
return this.shapes.has(name);
}
}
// Global singleton instance
const shapeRegistry = new ShapeRegistry();