224 lines
6.2 KiB
TypeScript
224 lines
6.2 KiB
TypeScript
interface PhysicsBody {
|
|
el: HTMLElement;
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
isDragging: boolean;
|
|
startX: number;
|
|
startY: number;
|
|
}
|
|
|
|
export class GravityEngine {
|
|
private bodies: PhysicsBody[] = [];
|
|
private animationFrameId: number | null = null;
|
|
private isRunning = false;
|
|
|
|
INITIAL_FORCE = 10;
|
|
|
|
start() {
|
|
if (this.isRunning) return;
|
|
this.isRunning = true;
|
|
|
|
const containers = document.querySelectorAll(".gravity-body");
|
|
const targetElements: HTMLElement[] = [];
|
|
|
|
// Helper to recursively drill down to leaf nodes
|
|
const extractLeaves = (el: HTMLElement) => {
|
|
// 1. Define "solid" elements that should fall as one single piece
|
|
const isSolid = ["SVG", "IMG", "BUTTON", "IFRAME", "A"].includes(
|
|
el.tagName.toUpperCase(),
|
|
);
|
|
|
|
// 2. Base case: If it's solid, or has no children, it's a target
|
|
if (isSolid || el.children.length === 0) {
|
|
// Only extract elements that actually take up visual space
|
|
if (el.offsetWidth > 0 && el.offsetHeight > 0) {
|
|
targetElements.push(el);
|
|
}
|
|
return;
|
|
}
|
|
|
|
el.style.width = `${el.offsetWidth}px`;
|
|
el.style.height = `${el.offsetHeight}px`;
|
|
|
|
Array.from(el.children).forEach((child) => {
|
|
extractLeaves(child as HTMLElement);
|
|
});
|
|
};
|
|
|
|
containers.forEach((container) => {
|
|
const htmlContainer = container as HTMLElement;
|
|
|
|
// Lock the main container's dimensions
|
|
htmlContainer.style.width = `${htmlContainer.offsetWidth}px`;
|
|
htmlContainer.style.height = `${htmlContainer.offsetHeight}px`;
|
|
|
|
// Start the recursive extraction on its direct children
|
|
Array.from(htmlContainer.children).forEach((child) => {
|
|
extractLeaves(child as HTMLElement);
|
|
});
|
|
});
|
|
|
|
const initialStates = targetElements.map((el) => {
|
|
const rect = el.getBoundingClientRect();
|
|
return { el, rect };
|
|
});
|
|
|
|
this.bodies = initialStates.map(({ el, rect }) => {
|
|
// Lock dimensions so it doesn't warp when pulled out of flex/grid
|
|
el.style.width = `${rect.width}px`;
|
|
el.style.height = `${rect.height}px`;
|
|
el.style.margin = "0px";
|
|
|
|
// Snap to fixed positioning at its exact current visual location
|
|
el.style.position = "fixed";
|
|
el.style.left = "0px";
|
|
el.style.top = "0px";
|
|
el.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
|
|
|
|
const body: PhysicsBody = {
|
|
el: el,
|
|
x: rect.left,
|
|
y: rect.top,
|
|
vx: (Math.random() - 0.5) * 8, // slight explosion outward
|
|
vy: (Math.random() - 0.5) * 5, // slight pop upward
|
|
isDragging: false,
|
|
startX: 0,
|
|
startY: 0,
|
|
};
|
|
|
|
this.attachMouseEvents(body);
|
|
return body;
|
|
});
|
|
|
|
this.tick();
|
|
}
|
|
|
|
stop() {
|
|
this.isRunning = false;
|
|
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
|
|
|
|
// Reset all children back to their normal document flow
|
|
this.bodies.forEach((body) => {
|
|
body.el.style.transform = "";
|
|
body.el.style.position = "";
|
|
body.el.style.left = "";
|
|
body.el.style.top = "";
|
|
body.el.style.width = "";
|
|
body.el.style.height = "";
|
|
body.el.style.margin = "";
|
|
});
|
|
|
|
// Reset parent containers
|
|
const containers = document.querySelectorAll(".gravity-body");
|
|
containers.forEach((container) => {
|
|
const htmlContainer = container as HTMLElement;
|
|
htmlContainer.style.width = "";
|
|
htmlContainer.style.height = "";
|
|
});
|
|
|
|
this.bodies = [];
|
|
}
|
|
|
|
private attachMouseEvents(body: PhysicsBody) {
|
|
// Prevent default drag behaviors that interfere with physics
|
|
body.el.ondragstart = () => false;
|
|
|
|
body.el.addEventListener("pointerdown", (e) => {
|
|
body.isDragging = true;
|
|
body.startX = e.clientX;
|
|
body.startY = e.clientY;
|
|
body.vx = 0;
|
|
body.vy = 0;
|
|
body.el.setPointerCapture(e.pointerId);
|
|
});
|
|
|
|
body.el.addEventListener("pointermove", (e) => {
|
|
if (!body.isDragging) return;
|
|
|
|
// Calculate velocity based on mouse movement for the "throw"
|
|
body.vx = e.movementX * 0.5;
|
|
body.vy = e.movementY * 0.5;
|
|
|
|
body.x += e.movementX;
|
|
body.y += e.movementY;
|
|
});
|
|
|
|
body.el.addEventListener("pointerup", (e) => {
|
|
body.isDragging = false;
|
|
body.el.releasePointerCapture(e.pointerId);
|
|
|
|
// The Drag vs. Click Resolver
|
|
const deltaX = Math.abs(e.clientX - body.startX);
|
|
const deltaY = Math.abs(e.clientY - body.startY);
|
|
|
|
// If the user moved the mouse less than 5px, treat it as a click
|
|
if (deltaX > 5 || deltaY > 5) {
|
|
// It was a drag. Prevent links from firing.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
|
|
// Catch clicks at the capture phase to stop them if we were dragging
|
|
body.el.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
const deltaX = Math.abs(e.clientX - body.startX);
|
|
const deltaY = Math.abs(e.clientY - body.startY);
|
|
if (deltaX > 5 || deltaY > 5) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
},
|
|
true,
|
|
);
|
|
}
|
|
|
|
private tick = () => {
|
|
if (!this.isRunning) return;
|
|
|
|
const gravity = 0.5;
|
|
const bounce = -0.7;
|
|
const floorY = window.innerHeight;
|
|
|
|
this.bodies.forEach((body) => {
|
|
if (body.isDragging) {
|
|
// Only update visually while dragging, physics are paused
|
|
body.el.style.position = "fixed";
|
|
body.el.style.left = "0px";
|
|
body.el.style.top = "0px";
|
|
body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
|
|
return;
|
|
}
|
|
|
|
// Apply Gravity
|
|
body.vy += gravity;
|
|
body.x += body.vx;
|
|
body.y += body.vy;
|
|
|
|
// Apply Air Friction
|
|
body.vx *= 0.99;
|
|
body.vy *= 0.99;
|
|
|
|
const rect = body.el.getBoundingClientRect();
|
|
|
|
// Floor Collision
|
|
if (body.y + rect.height > floorY) {
|
|
body.y = floorY - rect.height;
|
|
body.vy *= bounce;
|
|
body.vx *= 0.9; // Ground friction
|
|
}
|
|
|
|
// Render Step
|
|
body.el.style.position = "fixed";
|
|
body.el.style.left = "0px";
|
|
body.el.style.top = "0px";
|
|
body.el.style.transform = `translate(${body.x}px, ${body.y}px)`;
|
|
});
|
|
|
|
this.animationFrameId = requestAnimationFrame(this.tick);
|
|
};
|
|
}
|