mirror of
https://github.com/SamEyeBam/animate.git
synced 2026-10-01 01:56:52 +00:00
BallOfLife
This commit is contained in:
@@ -87,7 +87,7 @@ class ControlFactory {
|
||||
// Label showing property name
|
||||
const label = document.createElement('span');
|
||||
label.className = 'control-label-inline';
|
||||
label.textContent = config.property;
|
||||
label.textContent = config.label || config.property;
|
||||
|
||||
// Value display (editable)
|
||||
const valueDisplay = document.createElement('span');
|
||||
@@ -102,6 +102,7 @@ class ControlFactory {
|
||||
input.id = `el${config.property}`;
|
||||
input.min = config.min;
|
||||
input.max = config.max;
|
||||
input.step = config.step ?? 1;
|
||||
input.value = instance[config.property] ?? config.defaultValue;
|
||||
|
||||
// Store original config bounds for reference
|
||||
@@ -296,7 +297,7 @@ class ControlFactory {
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'control-label-inline';
|
||||
label.textContent = config.property;
|
||||
label.textContent = config.label || config.property;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
@@ -351,7 +352,7 @@ class ControlFactory {
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'control-label-inline';
|
||||
label.textContent = config.property;
|
||||
label.textContent = config.label || config.property;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
@@ -393,7 +394,7 @@ class ControlFactory {
|
||||
const label = document.createElement('p');
|
||||
label.className = 'control-label';
|
||||
label.id = `elText${config.property}`;
|
||||
label.textContent = `${config.property}: ${config.defaultValue}`;
|
||||
label.textContent = config.label || `${config.property}: ${instance[config.property] ?? config.defaultValue}`;
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.className = 'control control-dropdown';
|
||||
@@ -410,7 +411,7 @@ class ControlFactory {
|
||||
const listener = (event) => {
|
||||
const newValue = event.target.value;
|
||||
instance[config.property] = newValue;
|
||||
label.textContent = `${config.property}: ${newValue}`;
|
||||
label.textContent = config.label || `${config.property}: ${newValue}`;
|
||||
if (config.callback) {
|
||||
config.callback(instance, newValue);
|
||||
}
|
||||
|
||||
@@ -213,6 +213,39 @@ class SceneUI {
|
||||
if (index >= 0 && index < presets.length) {
|
||||
this.scene.importFromJSON(presets[index].data, this);
|
||||
this.hidePresetsModal();
|
||||
const name = this.presetSlug(presets[index].name);
|
||||
if (name && this.presetBasePath !== undefined) {
|
||||
history.pushState(null, '', this.presetBasePath + encodeURIComponent(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
presetSlug(name) {
|
||||
return name.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
loadPresetFromPath() {
|
||||
const path = window.location.pathname;
|
||||
if (!path.startsWith(this.presetBasePath)) return;
|
||||
let name;
|
||||
try {
|
||||
name = decodeURIComponent(path.slice(this.presetBasePath.length).replace(/\/$/, ''));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!name || name === 'index.html') {
|
||||
this.scene.clear();
|
||||
this.clearAllPanels();
|
||||
return;
|
||||
}
|
||||
const presets = [...this.getBuiltInPresets(), ...this.getPresets()];
|
||||
const preset = presets.find(item => this.presetSlug(item.name) === this.presetSlug(name));
|
||||
if (preset) {
|
||||
this.scene.importFromJSON(preset.data, this);
|
||||
} else {
|
||||
this.scene.clear();
|
||||
this.clearAllPanels();
|
||||
alert(`Preset "${name}" was not found. Choose a preset from the Presets menu.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ engine.setScene(scene);
|
||||
|
||||
// Initialize Scene UI
|
||||
const sceneUI = new SceneUI(scene, 'layers-container');
|
||||
sceneUI.presetBasePath = new URL('../', document.currentScript.src).pathname;
|
||||
sceneUI.loadPresetFromPath();
|
||||
window.addEventListener('popstate', () => sceneUI.loadPresetFromPath());
|
||||
|
||||
// ============================================
|
||||
// Event Listeners
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* BallOfLife - A cylinder supporting a sphere, projected onto the 2D canvas.
|
||||
*/
|
||||
class BallOfLife extends BaseShape {
|
||||
static config = [
|
||||
{ type: 'header', text: 'Point spacing & dimensions (cm)' },
|
||||
{ type: 'range', min: 1, max: 100, defaultValue: 6, property: 'pointsPerRow', label: 'Columns around cylinder (total)' },
|
||||
{ type: 'range', min: 1, max: 1000, step: 0.1, defaultValue: 30.4, property: 'columnSpacingCm', label: 'Column: point every (cm)' },
|
||||
{ type: 'range', min: 1, max: 12, defaultValue: 3, property: 'pointSize' },
|
||||
{ type: 'color', defaultValue: [255, 53, 53], property: 'pointColor' },
|
||||
{ type: 'header', text: 'Cylinder' },
|
||||
{ type: 'range', min: 1, max: 3000, step: 0.1, defaultValue: 103.9, property: 'cylinderRadiusCm', label: 'Cylinder radius (cm)' },
|
||||
{ type: 'range', min: 1, max: 10000, step: 0.1, defaultValue: 1000, property: 'columnHeightCm', label: 'Column height (cm)' },
|
||||
{ type: 'range', min: -360, max: 360, defaultValue: 0, property: 'twist' },
|
||||
{ type: 'header', text: 'Sphere' },
|
||||
{ type: 'range', min: 1, max: 3000, step: 0.1, defaultValue: 300, property: 'sphereRadiusCm', label: 'Ball radius (cm)' },
|
||||
{ type: 'range', min: 1, max: 1000, step: 0.1, defaultValue: 30.4, property: 'longitudeSpacingCm', label: 'Longitude: point every (cm)' },
|
||||
{ type: 'range', min: 3, max: 100, defaultValue: 17, property: 'longitudeCount', label: 'Longitude lines (total)' },
|
||||
{ type: 'header', text: 'View' },
|
||||
{ type: 'range', min: -80, max: 80, defaultValue: 7, property: 'tilt' },
|
||||
{ type: 'range', min: 10, max: 200, defaultValue: 124, property: 'zoomPercent' },
|
||||
{ type: 'range', min: 0, max: 100, defaultValue: 50, property: 'perspectiveStrength' },
|
||||
{ type: 'header', text: 'Rings' },
|
||||
{ type: 'checkbox', defaultValue: true, property: 'showRings' },
|
||||
{ type: 'color', defaultValue: [191, 199, 213], property: 'ringColor' },
|
||||
{ type: 'range', min: 1, max: 8, defaultValue: 1.5, property: 'ringWidth' },
|
||||
{ type: 'header', text: 'Ground Points' },
|
||||
{ type: 'checkbox', defaultValue: true, property: 'showGroundSpray' },
|
||||
{ type: 'range', min: 1, max: 1000, step: 0.1, defaultValue: 30.4, property: 'groundSpacingCm', label: 'Ground: point every (cm)' },
|
||||
{ type: 'range', min: 0, max: 10000, step: 0.1, defaultValue: 900, property: 'groundLengthCm', label: 'Ground line length (cm)' },
|
||||
{ type: 'color', defaultValue: [255, 53, 53], property: 'sprayColor' },
|
||||
{ type: 'range', min: 1, max: 12, defaultValue: 3, property: 'groundPointSize' },
|
||||
{ type: 'header', text: 'Animation' },
|
||||
{ type: 'dropdown', defaultValue: 'steady', property: 'lightingEffect', label: 'Lighting effect', options: [
|
||||
{ value: 'steady', label: 'Steady (no animation)' },
|
||||
{ value: 'pulse', label: 'Pulse' },
|
||||
{ value: 'wave', label: 'Rising wave' },
|
||||
{ value: 'chase', label: 'Rotating chase' },
|
||||
{ value: 'sparkle', label: 'Sparkle' },
|
||||
{ value: 'rainbow', label: 'Rainbow flow' },
|
||||
] },
|
||||
{ type: 'range', min: 0, max: 300, defaultValue: 100, property: 'lightingSpeed', label: 'Lighting speed (%)' },
|
||||
{ type: 'checkbox', defaultValue: false, property: 'autoRotate' },
|
||||
{ type: 'range', min: 0, max: 200, defaultValue: 100, property: 'rotateSpeed' },
|
||||
{ type: 'checkbox', defaultValue: false, property: 'reverseRotation' },
|
||||
{ type: 'header', text: 'Stickman (scale reference)' },
|
||||
{ type: 'checkbox', defaultValue: true, property: 'showStickman', label: 'Show stickman' },
|
||||
{ type: 'range', min: 1, max: 300, step: 0.1, defaultValue: 175, property: 'stickmanHeightCm', label: 'Stickman height (cm)' },
|
||||
];
|
||||
|
||||
constructor(...values) {
|
||||
super();
|
||||
// Match the registry's positional config arguments.
|
||||
BallOfLife.config.filter(item => item.property).forEach((item, index) => {
|
||||
const value = values[index] ?? item.defaultValue;
|
||||
this[item.property] = Array.isArray(value) ? [...value] : value;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply a 3x3 rotation matrix, then perspective divide. Strength 0 is orthographic.
|
||||
project(x, y, z, matrix, scale) {
|
||||
const rx = matrix[0] * x + matrix[1] * y + matrix[2] * z;
|
||||
const ry = matrix[3] * x + matrix[4] * y + matrix[5] * z;
|
||||
const rz = matrix[6] * x + matrix[7] * y + matrix[8] * z;
|
||||
const halfHeight = this.columnHeightCm / 600;
|
||||
const bound = Math.max(
|
||||
Math.hypot(this.cylinderRadiusCm / 300 +
|
||||
(this.showGroundSpray ? Math.max(0, this.groundLengthCm) / 300 : 0), halfHeight),
|
||||
halfHeight + 2 * this.sphereRadiusCm / 300,
|
||||
this.showStickman ? Math.hypot(
|
||||
this.cylinderRadiusCm / 300 + this.stickmanHeightCm / 300 * 0.6,
|
||||
halfHeight + this.stickmanHeightCm / 300
|
||||
) : 0
|
||||
);
|
||||
// Keep the shapes and ground spray in front of the camera at maximum perspective.
|
||||
const distance = Math.max(6, bound * 2.5);
|
||||
const strength = Math.max(0, Math.min(100, this.perspectiveStrength)) / 50;
|
||||
const perspective = distance / (distance + rz * strength);
|
||||
return {
|
||||
x: centerX + rx * scale * perspective,
|
||||
y: centerY + ry * scale * perspective,
|
||||
depth: rz,
|
||||
perspective,
|
||||
worldX: x,
|
||||
worldY: y,
|
||||
worldZ: z,
|
||||
};
|
||||
}
|
||||
|
||||
draw(elapsed) {
|
||||
this.updateFilters(elapsed);
|
||||
const columns = Math.max(1, Math.min(100, Math.round(this.pointsPerRow)));
|
||||
const rows = Math.floor(this.columnHeightCm / this.columnSpacingCm + 1e-9) + 1;
|
||||
const direction = this.reverseRotation ? -1 : 1;
|
||||
const angle = this.autoRotate ? elapsed * this.speedMultiplier / 200 * (this.rotateSpeed / 100) * direction : 0;
|
||||
const tilt = this.tilt * Math.PI / 180;
|
||||
const ca = Math.cos(angle), sa = Math.sin(angle);
|
||||
const ct = Math.cos(tilt), st = Math.sin(tilt);
|
||||
// R_x(tilt) * R_y(angle): spin around the cylinder axis, then tilt the view.
|
||||
const matrix = [ca, 0, sa, st * sa, ct, -st * ca, -ct * sa, st, ct * ca];
|
||||
const scale = Math.min(ctx.canvas.width / 6, ctx.canvas.height / 10) * this.zoomPercent / 100;
|
||||
const radius = this.cylinderRadiusCm / 300;
|
||||
const halfHeight = this.columnHeightCm / 600;
|
||||
const points = [];
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
// Start at the base and keep the requested spacing exactly.
|
||||
const fraction = 1 - row * this.columnSpacingCm / this.columnHeightCm;
|
||||
const y = (2 * fraction - 1) * halfHeight;
|
||||
for (let column = 0; column < columns; column++) {
|
||||
const theta = 2 * Math.PI * column / columns + fraction * this.twist * Math.PI / 180;
|
||||
points.push(this.project(radius * Math.cos(theta), y, radius * Math.sin(theta), matrix, scale));
|
||||
}
|
||||
}
|
||||
|
||||
const sphereRadius = this.sphereRadiusCm / 300;
|
||||
// Seat the sphere in the hollow rim: radius² + centerOffset² = sphereRadius².
|
||||
// A sphere narrower than the opening has no rim contact; place its equator at the rim.
|
||||
const centerOffset = Math.sqrt(Math.max(0, sphereRadius * sphereRadius - radius * radius));
|
||||
const sphereCenterY = -halfHeight - centerOffset;
|
||||
const arcLength = Math.PI * this.sphereRadiusCm;
|
||||
const latitudes = Math.ceil(arcLength / this.longitudeSpacingCm) + 1;
|
||||
const longitudes = Math.max(3, Math.min(100, Math.round(this.longitudeCount)));
|
||||
for (let latitude = 0; latitude < latitudes; latitude++) {
|
||||
// Latitude runs from -90 to +90 degrees, including one point at each pole.
|
||||
const phi = -Math.PI / 2 + Math.min(latitude * this.longitudeSpacingCm / this.sphereRadiusCm, Math.PI);
|
||||
const pole = latitude === 0 || latitude === latitudes - 1;
|
||||
const ringRadius = pole ? 0 : sphereRadius * Math.cos(phi);
|
||||
const y = sphereCenterY + sphereRadius * Math.sin(phi);
|
||||
const count = pole ? 1 : longitudes;
|
||||
for (let longitude = 0; longitude < count; longitude++) {
|
||||
const theta = 2 * Math.PI * longitude / longitudes;
|
||||
points.push(this.project(ringRadius * Math.cos(theta), y,
|
||||
ringRadius * Math.sin(theta), matrix, scale));
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
if (this.showGroundSpray) {
|
||||
const count = Math.floor(this.groundLengthCm / this.groundSpacingCm + 1e-9);
|
||||
const length = Math.max(0, this.groundLengthCm) / 300;
|
||||
for (let column = 0; column < columns; column++) {
|
||||
// Match the twist at the cylinder base.
|
||||
const theta = 2 * Math.PI * column / columns +
|
||||
this.twist * Math.PI / 180;
|
||||
for (let point = 1; point <= count; point++) {
|
||||
// Continue outward from the base point without duplicating it.
|
||||
const distance = radius + point * this.groundSpacingCm / 300;
|
||||
if (length === 0) break;
|
||||
const p = this.project(distance * Math.cos(theta), halfHeight,
|
||||
distance * Math.sin(theta), matrix, scale);
|
||||
points.push({ ...p, color: this.sprayColor, size: this.groundPointSize });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Project circular rims using the same transform as the points.
|
||||
ctx.strokeStyle = Array.isArray(this.ringColor) ? `rgb(${this.ringColor.join(',')})` : this.ringColor;
|
||||
ctx.lineWidth = this.ringWidth;
|
||||
for (const y of this.showRings ? [-halfHeight, halfHeight] : []) {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 128; i++) {
|
||||
const theta = 2 * Math.PI * i / 128;
|
||||
const p = this.project(radius * Math.cos(theta), y, radius * Math.sin(theta), matrix, scale);
|
||||
if (i === 0) ctx.moveTo(p.x, p.y);
|
||||
else ctx.lineTo(p.x, p.y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Paint far points first; perspective makes nearer points appear larger.
|
||||
const lightingTime = elapsed * (this.speedMultiplier / 100) * (this.lightingSpeed / 100);
|
||||
const baseAlpha = ctx.globalAlpha;
|
||||
points.sort((a, b) => b.depth - a.depth);
|
||||
for (const p of points) {
|
||||
const lighting = this.pointLighting(p, lightingTime);
|
||||
const color = lighting.color ?? p.color ?? this.pointColor;
|
||||
ctx.globalAlpha = baseAlpha * lighting.brightness;
|
||||
ctx.fillStyle = Array.isArray(color) ? `rgb(${color.join(',')})` : color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, Math.max(0.5, p.size ?? this.pointSize) * p.perspective, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = baseAlpha;
|
||||
if (this.showStickman) {
|
||||
this.drawStickman(halfHeight, radius, [1, 0, 0, 0, ct, -st, 0, st, ct], scale);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
pointLighting(point, time) {
|
||||
const turn = 2 * Math.PI;
|
||||
const height = this.columnHeightCm / 600 - point.worldY;
|
||||
const radialDistance = Math.hypot(point.worldX, point.worldZ);
|
||||
const theta = Math.atan2(point.worldZ, point.worldX);
|
||||
// Use physical coordinates so patterns remain attached to points while rotating.
|
||||
const travel = height - radialDistance;
|
||||
switch (this.lightingEffect) {
|
||||
case 'pulse':
|
||||
return { brightness: 0.15 + 0.85 * (0.5 + 0.5 * Math.sin(time * turn)) };
|
||||
case 'wave':
|
||||
return { brightness: 0.12 + 0.88 * Math.pow(0.5 + 0.5 * Math.cos(travel * 3 - time * turn), 4) };
|
||||
case 'chase':
|
||||
return { brightness: 0.12 + 0.88 * Math.pow(0.5 + 0.5 * Math.cos(theta - time * turn), 8) };
|
||||
case 'sparkle': {
|
||||
const seed = Math.sin(point.worldX * 127.1 + point.worldY * 311.7 + point.worldZ * 74.7) * 43758.5453;
|
||||
const phase = seed - Math.floor(seed);
|
||||
return { brightness: 0.1 + 0.9 * Math.pow(0.5 + 0.5 * Math.sin(time * turn * (0.7 + phase) + phase * turn), 16) };
|
||||
}
|
||||
case 'rainbow': {
|
||||
const hue = ((travel * 65 + theta * 180 / Math.PI - time * 90) % 360 + 360) % 360;
|
||||
return { brightness: 1, color: `hsl(${hue}, 100%, 60%)` };
|
||||
}
|
||||
default:
|
||||
return { brightness: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
drawStickman(groundY, radius, matrix, scale) {
|
||||
const height = this.stickmanHeightCm / 300;
|
||||
const center = radius + height * 0.4;
|
||||
// Keep the figure beside the cylinder as it spins, on the same ground plane.
|
||||
const project = (x, y) => this.project(center + x * height, groundY - y * height, 0, matrix, scale);
|
||||
ctx.strokeStyle = '#f0f0f0';
|
||||
ctx.lineWidth = Math.max(1, height * scale * 0.025);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= 48; i++) {
|
||||
const angle = i / 48 * Math.PI * 2;
|
||||
const p = project(0.09 * Math.cos(angle), 0.91 + 0.09 * Math.sin(angle));
|
||||
if (i === 0) ctx.moveTo(p.x, p.y);
|
||||
else ctx.lineTo(p.x, p.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
for (const limb of [
|
||||
[[0, 0.82], [0, 0.43]],
|
||||
[[-0.2, 0.48], [0, 0.72], [0.2, 0.48]],
|
||||
[[-0.17, 0], [0, 0.43], [0.17, 0]],
|
||||
]) {
|
||||
ctx.beginPath();
|
||||
limb.forEach(([x, y], index) => {
|
||||
const p = project(x, y);
|
||||
if (index === 0) ctx.moveTo(p.x, p.y);
|
||||
else ctx.lineTo(p.x, p.y);
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shapeRegistry.register('ball of life', BallOfLife);
|
||||
Reference in New Issue
Block a user