storytellurx-v1/app/components/utils/gravity.tsx
Elias Rhouzlane 09ca3c176e fix: matter
2025-07-15 15:36:06 +02:00

513 lines
13 KiB
TypeScript

"use client";
import {
createContext,
forwardRef,
ReactNode,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { calculatePosition } from "@/app/utils/calculate-position.tsx";
import { parsePathToVertices } from "@/app/components/utils/svg-path-to-vertices.tsx";
import { debounce } from "@std/async/debounce";
import * as Matter from "matter-js";
import cn from "@bureaudouble/outils/cn.ts";
const {
Bodies,
Common,
Engine,
Events,
Mouse,
MouseConstraint,
Query,
Render,
Runner,
World,
} = Matter.default;
type GravityProps = {
children: ReactNode;
debug?: boolean;
gravity?: { x: number; y: number };
resetOnResize?: boolean;
grabCursor?: boolean;
addTopWall?: boolean;
autoStart?: boolean;
className?: string;
};
type PhysicsBody = {
element: HTMLElement;
body: Matter.Body;
props: MatterBodyProps;
};
type MatterBodyProps = {
children: ReactNode;
matterBodyOptions?: Matter.IBodyDefinition;
isDraggable?: boolean;
bodyType?: "rectangle" | "circle" | "svg";
sampleLength?: number;
x?: number | string;
y?: number | string;
angle?: number;
className?: string;
};
export type GravityRef = {
start: () => void;
stop: () => void;
reset: () => void;
};
const GravityContext = createContext<
{
registerElement: (
id: string,
element: HTMLElement,
props: MatterBodyProps,
) => void;
unregisterElement: (id: string) => void;
} | null
>(null);
export const MatterBody = ({
children,
className,
matterBodyOptions = {
friction: 0.1,
restitution: 0.1,
density: 0.001,
isStatic: false,
},
bodyType = "rectangle",
isDraggable = true,
sampleLength = 15,
x = 0,
y = 0,
angle = 0,
...props
}: MatterBodyProps) => {
const elementRef = useRef<HTMLDivElement>(null);
const idRef = useRef(Math.random().toString(36).substring(7));
const context = useContext(GravityContext);
useEffect(() => {
if (!elementRef.current || !context) return;
context.registerElement(idRef.current, elementRef.current, {
children,
matterBodyOptions,
bodyType,
sampleLength,
isDraggable,
x,
y,
angle,
...props,
});
return () => context.unregisterElement(idRef.current);
}, [props, children, matterBodyOptions, isDraggable]);
return (
<div
ref={elementRef}
className={cn(
"absolute",
className,
isDraggable && "pointer-events-none",
)}
>
{children}
</div>
);
};
const Gravity = forwardRef<GravityRef, GravityProps>(
(
{
children,
debug = false,
gravity = { x: 0, y: 1 },
grabCursor = true,
resetOnResize = true,
addTopWall = true,
autoStart = true,
className,
...props
},
ref,
) => {
const canvas = useRef<HTMLDivElement>(null);
const engine = useRef(Engine.create());
const render = useRef<Render>();
const runner = useRef<Runner>();
const bodiesMap = useRef(new Map<string, PhysicsBody>());
const frameId = useRef<number>();
const mouseConstraint = useRef<Matter.MouseConstraint>();
const mouseDown = useRef(false);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const isRunning = useRef(false);
// Register Matter.js body in the physics world
const registerElement = useCallback(
(id: string, element: HTMLElement, props: MatterBodyProps) => {
if (!canvas.current) return;
const width = element.offsetWidth;
const height = element.offsetHeight;
const canvasRect = canvas.current!.getBoundingClientRect();
const angle = (props.angle || 0) * (Math.PI / 180);
const x = calculatePosition(props.x, canvasRect.width, width);
const y = calculatePosition(props.y, canvasRect.height, height);
let body;
if (props.bodyType === "circle") {
const radius = Math.max(width, height) / 2;
body = Bodies.circle(x, y, radius, {
...props.matterBodyOptions,
angle: angle,
render: {
fillStyle: debug ? "#888888" : "#00000000",
strokeStyle: debug ? "#333333" : "#00000000",
lineWidth: debug ? 3 : 0,
},
});
} else if (props.bodyType === "svg") {
const paths = element.querySelectorAll("path");
const vertexSets: Matter.Vector[][] = [];
paths.forEach((path) => {
const d = path.getAttribute("d");
const p = parsePathToVertices(d!, props.sampleLength);
vertexSets.push(p);
});
body = Bodies.fromVertices(x, y, vertexSets, {
...props.matterBodyOptions,
angle: angle,
render: {
fillStyle: debug ? "#888888" : "#00000000",
strokeStyle: debug ? "#333333" : "#00000000",
lineWidth: debug ? 3 : 0,
},
});
} else {
body = Bodies.rectangle(x, y, width, height, {
...props.matterBodyOptions,
angle: angle,
render: {
fillStyle: debug ? "#888888" : "#00000000",
strokeStyle: debug ? "#333333" : "#00000000",
lineWidth: debug ? 3 : 0,
},
});
}
if (body) {
World.add(engine.current.world, [body]);
bodiesMap.current.set(id, { element, body, props });
}
},
[debug],
);
// Unregister Matter.js body from the physics world
const unregisterElement = useCallback((id: string) => {
const body = bodiesMap.current.get(id);
if (body) {
World.remove(engine.current.world, body.body);
bodiesMap.current.delete(id);
}
}, []);
// Keep react elements in sync with the physics world
const updateElements = useCallback(() => {
bodiesMap.current.forEach(({ element, body }) => {
const { x, y } = body.position;
const rotation = body.angle * (180 / Math.PI);
element.style.transform = `translate(${
x - element.offsetWidth / 2
}px, ${y - element.offsetHeight / 2}px) rotate(${rotation}deg)`;
});
frameId.current = requestAnimationFrame(updateElements);
}, []);
const initializeRenderer = useCallback(() => {
if (!canvas.current) return;
const height = canvas.current.offsetHeight;
const width = canvas.current.offsetWidth;
Common.setDecomp(require("poly-decomp"));
engine.current.gravity.x = gravity.x;
engine.current.gravity.y = gravity.y;
render.current = Render.create({
element: canvas.current,
engine: engine.current,
options: {
width,
height,
wireframes: false,
background: "#00000000",
},
});
const mouse = Mouse.create(render.current.canvas);
mouseConstraint.current = MouseConstraint.create(engine.current, {
mouse: mouse,
constraint: {
stiffness: 0.2,
render: {
visible: debug,
},
},
});
// Add walls
const walls = [
// Floor
Bodies.rectangle(width / 2, height + 10, width, 20, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
}),
// Right wall
Bodies.rectangle(width + 10, height / 2, 20, height, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
}),
// Left wall
Bodies.rectangle(-10, height / 2, 20, height, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
}),
];
const topWall = addTopWall
? Bodies.rectangle(width / 2, -10, width, 20, {
isStatic: true,
friction: 1,
render: {
visible: debug,
},
})
: null;
if (topWall) {
walls.push(topWall);
}
const touchingMouse = () =>
Query.point(
engine.current.world.bodies,
mouseConstraint.current?.mouse.position || { x: 0, y: 0 },
).length > 0;
if (grabCursor) {
Events.on(engine.current, "beforeUpdate", (event) => {
if (canvas.current) {
if (!mouseDown.current && !touchingMouse()) {
canvas.current.style.cursor = "default";
} else if (touchingMouse()) {
canvas.current.style.cursor = mouseDown.current
? "grabbing"
: "grab";
}
}
});
canvas.current.addEventListener("mousedown", (event) => {
mouseDown.current = true;
if (canvas.current) {
if (touchingMouse()) {
canvas.current.style.cursor = "grabbing";
} else {
canvas.current.style.cursor = "default";
}
}
});
canvas.current.addEventListener("mouseup", (event) => {
mouseDown.current = false;
if (canvas.current) {
if (touchingMouse()) {
canvas.current.style.cursor = "grab";
} else {
canvas.current.style.cursor = "default";
}
}
});
}
World.add(engine.current.world, [mouseConstraint.current, ...walls]);
render.current.mouse = mouse;
runner.current = Runner.create();
Render.run(render.current);
updateElements();
runner.current.enabled = false;
if (autoStart) {
runner.current.enabled = true;
startEngine();
}
}, [updateElements, debug, autoStart]);
// Clear the Matter.js world
const clearRenderer = useCallback(() => {
if (frameId.current) {
cancelAnimationFrame(frameId.current);
}
if (mouseConstraint.current) {
World.remove(engine.current.world, mouseConstraint.current);
}
if (render.current) {
Mouse.clearSourceEvents(render.current.mouse);
Render.stop(render.current);
render.current.canvas.remove();
}
if (runner.current) {
Runner.stop(runner.current);
}
if (engine.current) {
World.clear(engine.current.world, false);
Engine.clear(engine.current);
}
bodiesMap.current.clear();
}, []);
const handleResize = useCallback(() => {
if (!canvas.current || !resetOnResize) return;
const newWidth = canvas.current.offsetWidth;
const newHeight = canvas.current.offsetHeight;
setCanvasSize({ width: newWidth, height: newHeight });
// Clear and reinitialize
clearRenderer();
initializeRenderer();
}, [clearRenderer, initializeRenderer, resetOnResize]);
const startEngine = useCallback(() => {
if (runner.current) {
runner.current.enabled = true;
Runner.run(runner.current, engine.current);
}
if (render.current) {
Render.run(render.current);
}
frameId.current = requestAnimationFrame(updateElements);
isRunning.current = true;
}, [updateElements, canvasSize]);
const stopEngine = useCallback(() => {
if (!isRunning.current) return;
if (runner.current) {
Runner.stop(runner.current);
}
if (render.current) {
Render.stop(render.current);
}
if (frameId.current) {
cancelAnimationFrame(frameId.current);
}
isRunning.current = false;
}, []);
const reset = useCallback(() => {
stopEngine();
bodiesMap.current.forEach(({ element, body, props }) => {
body.angle = props.angle || 0;
const x = calculatePosition(
props.x,
canvasSize.width,
element.offsetWidth,
);
const y = calculatePosition(
props.y,
canvasSize.height,
element.offsetHeight,
);
body.position.x = x;
body.position.y = y;
});
updateElements();
handleResize();
}, []);
useImperativeHandle(
ref,
() => ({
start: startEngine,
stop: stopEngine,
reset,
}),
[startEngine, stopEngine],
);
useEffect(() => {
if (!resetOnResize) return;
const debouncedResize = debounce(handleResize, 500);
window.addEventListener("resize", debouncedResize);
return () => {
window.removeEventListener("resize", debouncedResize);
// debouncedResize.cancel();
};
}, [handleResize, resetOnResize]);
useEffect(() => {
initializeRenderer();
return clearRenderer;
}, [initializeRenderer, clearRenderer]);
return (
<GravityContext.Provider value={{ registerElement, unregisterElement }}>
<div
ref={canvas}
className={cn(className, "absolute top-0 left-0 w-full h-full")}
{...props}
>
{children}
</div>
</GravityContext.Provider>
);
},
);
Gravity.displayName = "Gravity";
export default Gravity;