body { width: 100vw; height: 100vh; overflow: hidden; background: linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e8ba3 100%); display: flex; justify-content: center; align-items: center; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
#canvas { cursor: pointer; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); border-radius: 8px; background: rgba(255, 255, 255, 0.05); }
.instructions { position: absolute; top: 30px; left: 50%; transform: translateX(-50%); color: white; font-size: clamp(16px, 2vw, 24px); text-align: center; text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.7); padding: 15px 30px; background: rgba(0, 0, 0, 0.3); border-radius: 12px; backdrop-filter: blur(10px); } </style>
</head> <body> <div class="instructions">Click to Flip the Hourglass</div> <canvas id="canvas"></canvas> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // Responsive canvas sizing function resizeCanvas() { const scale = Math.min(window.innerWidth / 600, window.innerHeight / 800, 1.5); canvas.width = 500 * scale; canvas.height = 700 * scale; } resizeCanvas(); window.addEventListener('resize', resizeCanvas); // Physics constants const GRAVITY = 0.3; const PARTICLE_RADIUS = 2; const FRICTION = 0.98; const RESTITUTION = 0.1; const MAX_PARTICLES = 3000; // Hourglass geometry let rotation = 0; let targetRotation = 0; let isRotating = false; class Particle { constructor(x, y) { this.x = x; this.y = y; this.vx = (Math.random() - 0.5) * 0.5; this.vy = 0; this.radius = PARTICLE_RADIUS; this.active = true; } update(gravityDir) { if (!this.active) return; this.vy += GRAVITY * gravityDir; this.vx *= FRICTION; this.vy *= FRICTION; this.x += this.vx; this.y += this.vy; } draw() { if (!this.active) return; ctx.fillStyle = '#f4a460'; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fill(); } } const particles = []; // Initialize particles in top chamber function initParticles() { particles.length = 0; const centerX = canvas.width / 2; const topY = canvas.height * 0.2; const chamberWidth = canvas.width * 0.35; for (let i = 0; i < MAX_PARTICLES; i++) { const x = centerX + (Math.random() - 0.5) * chamberWidth; const y = topY + Math.random() * (canvas.height * 0.2); particles.push(new Particle(x, y)); } } initParticles(); // Hourglass geometry functions function getHourglassPath() { const w = canvas.width; const h = canvas.height; const centerX = w / 2; const centerY = h / 2; const topWidth = w * 0.35; const neckWidth = w * 0.08; const height = h * 0.35; return { topLeft: { x: centerX - topWidth, y: centerY - height }, topRight: { x: centerX + topWidth, y: centerY - height }, neckTopLeft: { x: centerX - neckWidth, y: centerY - neckWidth * 0.5 }, neckTopRight: { x: centerX + neckWidth, y: centerY - neckWidth * 0.5 }, neckBottomLeft: { x: centerX - neckWidth, y: centerY + neckWidth * 0.5 }, neckBottomRight: { x: centerX + neckWidth, y: centerY + neckWidth * 0.5 }, bottomLeft: { x: centerX - topWidth, y: centerY + height }, bottomRight: { x: centerX + topWidth, y: centerY + height }, centerX, centerY, topWidth, neckWidth, height }; } function drawHourglass() { const path = getHourglassPath(); ctx.save(); ctx.translate(path.centerX, path.centerY); ctx.rotate(rotation); ctx.translate(-path.centerX, -path.centerY); // Glass frame ctx.strokeStyle = '#8b7355'; ctx.lineWidth = 8; ctx.fillStyle = 'rgba(240, 230, 220, 0.15)'; // Top chamber ctx.beginPath(); ctx.moveTo(path.topLeft.x, path.topLeft.y); ctx.lineTo(path.topRight.x, path.topRight.y); ctx.lineTo(path.neckTopRight.x, path.neckTopRight.y); ctx.lineTo(path.neckTopLeft.x, path.neckTopLeft.y); ctx.closePath(); ctx.fill(); ctx.stroke(); // Bottom chamber ctx.beginPath(); ctx.moveTo(path.neckBottomLeft.x, path.neckBottomLeft.y); ctx.lineTo(path.neckBottomRight.x, path.neckBottomRight.y); ctx.lineTo(path.bottomRight.x, path.bottomRight.y); ctx.lineTo(path.bottomLeft.x, path.bottomLeft.y); ctx.closePath(); ctx.fill(); ctx.stroke(); // Decorative wooden frame ctx.fillStyle = '#654321'; const frameThickness = canvas.height * 0.025; ctx.fillRect(path.topLeft.x - 20, path.topLeft.y - frameThickness, path.topWidth * 2 + 40, frameThickness); ctx.fillRect(path.bottomLeft.x - 20, path.bottomLeft.y, path.topWidth * 2 + 40, frameThickness); ctx.restore(); } function rotatePoint(x, y, centerX, centerY, angle) { const cos = Math.cos(angle); const sin = Math.sin(angle); const dx = x - centerX; const dy = y - centerY; return { x: centerX + dx * cos - dy * sin, y: centerY + dx * sin + dy * cos }; } function checkCollisions() { const path = getHourglassPath(); const centerX = path.centerX; const centerY = path.centerY; const gravityDir = Math.cos(rotation) >= 0 ? 1 : -1; particles.forEach((p, i) => { if (!p.active) return; // Rotate particle position to hourglass space const rotated = rotatePoint(p.x, p.y, centerX, centerY, -rotation); // Top chamber walls if (rotated.y < centerY - path.neckWidth * 0.5) { const leftWall = path.topLeft.x + (path.neckTopLeft.x - path.topLeft.x) * (centerY - path.neckWidth * 0.5 - rotated.y) / path.height; const rightWall = path.topRight.x - (path.topRight.x - path.neckTopRight.x) * (centerY - path.neckWidth * 0.5 - rotated.y) / path.height; if (rotated.x - p.radius < leftWall) { const newRotated = { x: leftWall + p.radius, y: rotated.y }; const newPos = rotatePoint(newRotated.x, newRotated.y, centerX, centerY, rotation); p.x = newPos.x; p.vx = Math.abs(p.vx) * RESTITUTION; } if (rotated.x + p.radius > rightWall) { const newRotated = { x: rightWall - p.radius, y: rotated.y }; const newPos = rotatePoint(newRotated.x, newRotated.y, centerX, centerY, rotation); p.x = newPos.x; p.vx = -Math.abs(p.vx) * RESTITUTION; } // Top boundary if (rotated.y - p.radius < path.topLeft.y) { const newRotated = { x: rotated.x, y: path.topLeft.y + p.radius }; const newPos = rotatePoint(newRotated.x, newRotated.y, centerX, centerY, rotation); p.y = newPos.y; p.vy *= -RESTITUTION; } } // Bottom chamber walls if (rotated.y > centerY + path.neckWidth * 0.5) { const leftWall = path.neckBottomLeft.x - (path.neckBottomLeft.x - path.bottomLeft.x) * (rotated.y - centerY - path.neckWidth * 0.5) / path.height; const rightWall = path.neckBottomRight.x + (path.bottomRight.x - path.neckBottomRight.x) * (rotated.y - centerY - path.neckWidth * 0.5) / path.height; if (rotated.x - p.radius < leftWall) { const newRotated = { x: leftWall + p.radius, y: rotated.y }; const newPos = rotatePoint(newRotated.x, newRotated.y, centerX, centerY, rotation); p.x = newPos.x; p.vx = Math.abs(p.vx) * RESTITUTION; } if (rotated.x + p.radius > rightWall) { const newRotated = { x: rightWall - p.radius, y: rotated.y }; const newPos = rotatePoint(newRotated.x, newRotated.y, centerX, centerY, rotation); p.x = newPos.x; p.vx = -Math.abs(p.vx) * RESTITUTION; } // Bottom boundary if (rotated.y + p.radius > path.bottomLeft.y) { const newRotated = { x: rotated.x, y: path.bottomLeft.y - p.radius }; const newPos = rotatePoint(newRotated.x, newRotated.y, centerX, centerY, rotation); p.y = newPos.y; p.vy *= -RESTITUTION; } } // Particle-to-particle collisions (simplified for performance) for (let j = i + 1; j < particles.length; j++) { const other = particles[j]; if (!other.active) continue; const dx = other.x - p.x; const dy = other.y - p.y; const dist = Math.sqrt(dx * dx + dy * dy); const minDist = p.radius + other.radius; if (dist < minDist && dist > 0) { const angle = Math.atan2(dy, dx); const overlap = minDist - dist; const moveX = Math.cos(angle) * overlap * 0.5; const moveY = Math.sin(angle) * overlap * 0.5; p.x -= moveX; p.y -= moveY; other.x += moveX; other.y += moveY; // Velocity exchange const dvx = (p.vx - other.vx) * 0.5; const dvy = (p.vy - other.vy) * 0.5; p.vx -= dvx * 0.5; p.vy -= dvy * 0.5; other.vx += dvx * 0.5; other.vy += dvy * 0.5; } } }); } function animate() { ctx.fillStyle = 'rgba(30, 60, 114, 0.1)'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Smooth rotation if (Math.abs(targetRotation - rotation) > 0.01) { rotation += (targetRotation - rotation) * 0.08; isRotating = true; } else { rotation = targetRotation; isRotating = false; } const gravityDir = Math.cos(rotation) >= 0 ? 1 : -1; // Update and draw particles particles.forEach(p => { p.update(gravityDir); }); checkCollisions(); // Draw hourglass frame drawHourglass(); // Draw particles on top particles.forEach(p => p.draw()); requestAnimationFrame(animate); } canvas.addEventListener('click', () => { targetRotation += Math.PI; }); animate(); </script> </body> </html>