Refactor pendulum simulation: implement lazy loading for Pendulum component, add drawing utilities, and enhance world generation logic
167 lines
No EOL
6.2 KiB
TypeScript
167 lines
No EOL
6.2 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { updateWorld } from "./physics";
|
|
import { drawWorld } from "./drawing";
|
|
import { getDefaultMyWorld, type WorldState } from "./worlds";
|
|
|
|
export default function Pendulum() {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const [subStep, setSubStep] = useState(10); // number of sub-steps per frame
|
|
const [constraintIterations, setConstraintIterations] = useState(1); // number of constraint iterations per sub-step
|
|
|
|
// refs to hold pendulum instances and animation state so we can reset from UI
|
|
const worldRef = useRef<WorldState | null>(null);
|
|
const rafRef = useRef<number | null>(null);
|
|
const prevTimeRef = useRef<number>(0);
|
|
|
|
// Drag state
|
|
const draggingRef = useRef<{ ballIdx: number | null, offset: [number, number] | null }>({ ballIdx: null, offset: null });
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const resize = () => {
|
|
const w = canvas.clientWidth || 600;
|
|
const h = canvas.clientHeight || 400;
|
|
canvas.width = Math.max(1, Math.round(w * dpr));
|
|
canvas.height = Math.max(1, Math.round(h * dpr));
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
};
|
|
resize();
|
|
window.addEventListener("resize", resize);
|
|
if (!worldRef.current) {
|
|
worldRef.current = getDefaultMyWorld();
|
|
}
|
|
prevTimeRef.current = performance.now();
|
|
const animate = (time: number) => {
|
|
const deltaTime = time - prevTimeRef.current;
|
|
prevTimeRef.current = time;
|
|
const dt = Math.min(0.1, deltaTime / 1000) / subStep;
|
|
for (let i = 0; i < subStep; i++) {
|
|
if (worldRef.current) {
|
|
updateWorld(worldRef.current, dt, constraintIterations);
|
|
}
|
|
}
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
if (worldRef.current) {
|
|
drawWorld(ctx, worldRef.current, worldRef.current.ballDrawers);
|
|
}
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
};
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
// --- Drag interaction handlers ---
|
|
function getPointerPos(evt: PointerEvent) {
|
|
if (!canvas) return [0, 0];
|
|
const rect = canvas.getBoundingClientRect();
|
|
return [evt.clientX - rect.left, evt.clientY - rect.top] as [number, number];
|
|
}
|
|
|
|
function onPointerDown(e: PointerEvent) {
|
|
if (!worldRef.current) return;
|
|
const pos = getPointerPos(e);
|
|
let minDist = Infinity;
|
|
let closestIdx: number | null = null;
|
|
worldRef.current.balls.forEach((ball, idx) => {
|
|
const dx = ball.pos[0] - pos[0];
|
|
const dy = ball.pos[1] - pos[1];
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < 25 && dist < minDist && !ball.isFixed) { // Only allow dragging non-fixed balls
|
|
minDist = dist;
|
|
closestIdx = idx;
|
|
}
|
|
});
|
|
if (closestIdx !== null) {
|
|
draggingRef.current.ballIdx = closestIdx;
|
|
draggingRef.current.offset = [
|
|
worldRef.current.balls[closestIdx].pos[0] - pos[0],
|
|
worldRef.current.balls[closestIdx].pos[1] - pos[1],
|
|
];
|
|
// Pause animation while dragging
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
}
|
|
}
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
if (!worldRef.current) return;
|
|
if (draggingRef.current.ballIdx === null) return;
|
|
const pos = getPointerPos(e);
|
|
const idx = draggingRef.current.ballIdx;
|
|
const offset = draggingRef.current.offset || [0, 0];
|
|
// Update ball position
|
|
worldRef.current.balls[idx].pos = [pos[0] + offset[0], pos[1] + offset[1]];
|
|
// Optionally update prevPos for stability
|
|
worldRef.current.balls[idx].prevPos = [pos[0] + offset[0], pos[1] + offset[1]];
|
|
// Redraw
|
|
if (!ctx) return;
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
drawWorld(ctx, worldRef.current, worldRef.current.ballDrawers);
|
|
}
|
|
|
|
function onPointerUp() {
|
|
if (draggingRef.current.ballIdx !== null) {
|
|
draggingRef.current.ballIdx = null;
|
|
draggingRef.current.offset = null;
|
|
// Resume animation
|
|
prevTimeRef.current = performance.now();
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
}
|
|
}
|
|
|
|
canvas.addEventListener("pointerdown", onPointerDown);
|
|
window.addEventListener("pointermove", onPointerMove);
|
|
window.addEventListener("pointerup", onPointerUp);
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", resize);
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
canvas.removeEventListener("pointerdown", onPointerDown);
|
|
window.removeEventListener("pointermove", onPointerMove);
|
|
window.removeEventListener("pointerup", onPointerUp);
|
|
};
|
|
}, [constraintIterations, subStep]);
|
|
|
|
// reset handler to re-create pendulums and avoid large dt on next frame
|
|
const reset = () => {
|
|
worldRef.current = getDefaultMyWorld();
|
|
prevTimeRef.current = performance.now();
|
|
};
|
|
|
|
return (
|
|
<div className="w-full h-full flex flex-col">
|
|
<div className="p-2">
|
|
<button
|
|
onClick={reset}
|
|
className="px-3 py-1.5 rounded border border-gray-300 bg-white text-sm hover:bg-gray-50 focus:outline-none"
|
|
>
|
|
리셋
|
|
</button>
|
|
<label className="ml-4 text-sm">
|
|
서브스텝:
|
|
<input
|
|
type="number"
|
|
value={subStep}
|
|
onChange={(e) => setSubStep(Math.max(1, Math.min(100, Number(e.target.value))))}
|
|
className="ml-2 w-16 px-2 py-1 border border-gray-300 rounded focus:outline-none"
|
|
/>
|
|
</label>
|
|
<label className="ml-4 text-sm">
|
|
제약 반복 횟수:
|
|
<input
|
|
type="number"
|
|
value={constraintIterations}
|
|
onChange={(e) => setConstraintIterations(Math.max(1, Math.min(200, Number(e.target.value))))}
|
|
className="ml-2 w-16 px-2 py-1 border border-gray-300 rounded focus:outline-none"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="flex-1 min-h-0">
|
|
<canvas
|
|
style={{ touchAction: "none" }}
|
|
ref={canvasRef}
|
|
className="w-full h-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |