gravity
This commit is contained in:
223
lib/gravity-engine.ts
Normal file
223
lib/gravity-engine.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user