<html>
<head>
<meta charset="UTF-8">
<title>Physics Hourglass Simulation</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at top, #151b26 0%, #05070b 55%, #010103 100%);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f4f7ff;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
.container {
width: min(90vw, 1200px);
height: min(90vh, 800px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
h1 {
position: absolute;
top: 4vh;
left: 50%;
transform: translateX(-50%);
font-size: clamp(2rem, 3vw, 3rem);
letter-spacing: 0.15em;
text-transform: uppercase;
color: #e9edf9;
text-shadow: 0 0 20px rgba(0,0,0,0.6);
pointer-events: none;
}
.subtitle {
position: absolute;
top: 10vh;
left: 50%;
transform: translateX(-50%);
font-size: clamp(0.8rem, 1vw, 1rem);
opacity: 0.7;
letter-spacing: 0.2em;
text-transform: uppercase;
pointer-events: none;
}
.hourglass-wrapper {
width: 100%;
height: 100%;
max-width: 900px;
max-height: 640px;
display: flex;
align-items: center;
justify-content: center;
transform-style: preserve-3d;
perspective: 2000px;
}
canvas {
width: 100%;
height: 100%;
max-width: 700px;
max-height: 700px;
background: transparent;
cursor: pointer;
border-radius: 24px;
box-shadow:
0 40px 120px rgba(0,0,0,0.7),
0 0 0 1px rgba(255,255,255,0.04),
inset 0 0 80px rgba(255,255,255,0.03);
transition: transform 0.8s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s ease;
}
canvas:hover {
transform: translateY(-6px) scale(1.01);
box-shadow:
0 50px 140px rgba(0,0,0,0.8),
0 0 0 1px rgba(255,255,255,0.06),
inset 0 0 90px rgba(255,255,255,0.04);
}
.hint {
position: absolute;
bottom: 5vh;
left: 50%;
transform: translateX(-50%);
font-size: clamp(0.7rem, 0.9vw, 0.9rem);
letter-spacing: 0.18em;
text-transform: uppercase;
opacity: 0.7;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.hint-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffd166;
box-shadow: 0 0 12px rgba(255,209,102,0.9);
}
@media (max-width: 768px) {
h1 {
top: 2vh;
}
.subtitle {
top: 7vh;
}
.hint {
bottom: 3vh;
}
}
body {
display: flex;
align-items: center;
justify-content: center;
}
.container {
width: min(90vw, 1200px);
height: min(90vh, 800px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
h1 {
position: absolute;
top: 4vh;
left: 50%;
transform: translateX(-50%);
font-size: clamp(2rem, 3vw, 3rem);
letter-spacing: 0.15em;
text-transform: uppercase;
color: #e9edf9;
text-shadow: 0 0 20px rgba(0,0,0,0.6);
pointer-events: none;
}
.subtitle {
position: absolute;
top: 10vh;
left: 50%;
transform: translateX(-50%);
font-size: clamp(0.8rem, 1vw, 1rem);
opacity: 0.7;
letter-spacing: 0.2em;
text-transform: uppercase;
pointer-events: none;
}
.hourglass-wrapper {
width: 100%;
height: 100%;
max-width: 900px;
max-height: 640px;
display: flex;
align-items: center;
justify-content: center;
transform-style: preserve-3d;
perspective: 2000px;
}
canvas {
width: 100%;
height: 100%;
max-width: 700px;
max-height: 700px;
background: transparent;
cursor: pointer;
border-radius: 24px;
box-shadow:
0 40px 120px rgba(0,0,0,0.7),
0 0 0 1px rgba(255,255,255,0.04),
inset 0 0 80px rgba(255,255,255,0.03);
transition: transform 0.8s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s ease;
}
canvas:hover {
transform: translateY(-6px) scale(1.01);
box-shadow:
0 50px 140px rgba(0,0,0,0.8),
0 0 0 1px rgba(255,255,255,0.06),
inset 0 0 90px rgba(255,255,255,0.04);
}
.hint {
position: absolute;
bottom: 5vh;
left: 50%;
transform: translateX(-50%);
font-size: clamp(0.7rem, 0.9vw, 0.9rem);
letter-spacing: 0.18em;
text-transform: uppercase;
opacity: 0.7;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
.hint-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffd166;
box-shadow: 0 0 12px rgba(255,209,102,0.9);
}
@media (max-width: 768px) {
h1 {
top: 2vh;
}
.subtitle {
top: 7vh;
}
.hint {
bottom: 3vh;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Granular Hourglass</h1>
<div class="subtitle">Click to flip • Physics-inspired sand flow</div>
<div class="hourglass-wrapper">
<canvas id="hourglass"></canvas>
</div>
<div class="hint">
<div class="hint-dot"></div>
Click anywhere on the hourglass to rotate 180°
</div>
</div>
<script>
(function() {
const canvas = document.getElementById('hourglass');
const ctx = canvas.getContext('2d');
// Handle retina and responsive sizing
function resize() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
initGeometry();
}
let w, h;
let particles = [];
let lastTime = 0;
let isFlipping = false;
let flipStart = 0;
const flipDuration = 800; // ms
let angle = 0; // 0 or PI; during animation: 0..PI
let worldUp = { x: 0, y: 1 }; // gravity direction in world space (downwards when angle=0)
let neckX, neckY;
let upperChamber, lowerChamber;
let neckRadius, outerPadding;
let maxParticles = 900; // adaptive later
let baseFriction = 0.94;
let wallBounce = -0.3;
function initGeometry() {
w = canvas.width / (window.devicePixelRatio || 1);
h = canvas.height / (window.devicePixelRatio || 1);
outerPadding = Math.min(w, h) * 0.08;
const bodyTop = outerPadding * 1.8;
const bodyBottom = h - outerPadding * 1.4;
const bodyHeight = bodyBottom - bodyTop;
const neckYLocal = bodyTop + bodyHeight * 0.5;
neckY = neckYLocal;
neckX = w * 0.5;
neckRadius = Math.min(w, h) * 0.012;
upperChamber = {
top: bodyTop,
bottom: neckYLocal - neckRadius * 1.4,
minWidth: w * 0.08,
maxWidth: w * 0.36
};
lowerChamber = {
top: neckYLocal + neckRadius * 1.4,
bottom: bodyBottom,
minWidth: w * 0.08,
maxWidth: w * 0.36
};
// Reinitialize particles proportionally when resized
const target = Math.round(maxParticles);
if (particles.length === 0) {
initParticles(target);
} else {
if (particles.length > target) {
particles.length = target;
} else {
while (particles.length < target) {
particles.push(makeParticle(true));
}
}
}
}
function hourglassWidthAtY(y) {
// Symmetric logistic-like bulge: narrowest at neck, wider toward ends
const cy = neckY;
const dy = Math.abs(y - cy);
const maxDy = Math.max(neckY - upperChamber.top, lowerChamber.bottom - neckY);
const t = dy / maxDy; // 0..1
const baseWidth = w * 0.11; // near neck
const extra = w * 0.23 * Math.pow(t, 0.8);
return baseWidth + extra;
}
function hourglassBoundsAtY(y) {
const hw = hourglassWidthAtY(y) * 0.5;
return { left: neckX - hw, right: neckX + hw };
}
function inUpperChamber(y, gDirY) {
// chamber whose "down" direction is worldUp rotated by angle
// use local sense: upper chamber is where sand moves toward neck from "top"
// Simplify: if angle ~0, upper is geometric top; if angle ~PI, inverted.
const localAngle = angle % (2 * Math.PI);
const inverted = localAngle > Math.PI * 0.5 && localAngle < Math.PI * 1.5;
if (!inverted) {
return y < neckY;
} else {
return y > neckY;
}
}
function makeParticle(inUpper) {
// Start inside one chamber, distributed by height
const margin = neckRadius * 1.6;
let yRangeTop, yRangeBottom;
if (inUpper) {
yRangeTop = upperChamber.top + margin;
yRangeBottom = neckY - margin * 1.5;
} else {
yRangeTop = neckY + margin * 1.5;
yRangeBottom = lowerChamber.bottom - margin;
}
const y = yRangeTop + Math.random() * (yRangeBottom - yRangeTop);
const bounds = hourglassBoundsAtY(y);
const x = bounds.left + 0.1 * (bounds.right - bounds.left) + Math.random() * 0.8 * (bounds.right - bounds.left);
return {
x,
y,
vx: 0,
vy: 0,
radius: Math.min(w, h) * (0.0045 + Math.random() * 0.0015),
settled: false,
upper: inUpper
};
}
function initParticles(n) {
particles = [];
for (let i = 0; i < n; i++) {
particles.push(makeParticle(true)); // start mostly in "upper" chamber
}
}
function rotateVector(vx, vy, ang) {
const ca = Math.cos(ang), sa = Math.sin(ang);
return { x: vx * ca - vy * sa, y: vx * sa + vy * ca };
}
function clamp(v, min, max) {
return v < min ? min : v > max ? max : v;
}
function update(t) {
if (!lastTime) lastTime = t;
const dt = Math.min(0.035, (t - lastTime) / 1000);
lastTime = t;
// Flip animation
if (isFlipping) {
const elapsed = t - flipStart;
let k = clamp(elapsed / flipDuration, 0, 1);
// smooth: ease in-out
k = k * k * (3 - 2 * k);
angle = Math.PI * k;
if (elapsed >= flipDuration) {
isFlipping = false;
angle = Math.PI;
// finalize new orientation by swapping "upper" labels
particles.forEach(p => {
p.upper = !p.upper;
p.settled = false;
p.vx *= 0.1;
p.vy *= 0.1;
});
worldUp = { x: 0, y: 1 };
angle = 0; // keep scene visually same, but logical "upper" toggled
}
}
// Gravity always downward in screen space; "upper" vs "lower" handled logically
const g = 800; // px/s^2
const gx = 0;
const gy = g;
// Some simple capacity logic: slow when near-empty
const upperCount = particles.filter(p => p.upper).length;
const lowerCount = particles.length - upperCount;
const flowFactor = clamp(upperCount / (particles.length * 0.9), 0.3, 1);
// Physics step: integrate, collisions, boundaries
granularStep(dt, gx, gy, flowFactor, upperCount, lowerCount);
draw();
requestAnimationFrame(update);
}
function granularStep(dt, gx, gy, flowFactor, upperCount, lowerCount) {
// Integration
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
// Apply gravity only if not completely jammed in neck.
p.vx += gx * dt;
p.vy += gy * dt;
// Add slight random horizontal jitter when near neck to mimic granular flow
const distToNeckY = Math.abs(p.y - neckY);
if (distToNeckY < 40) {
p.vx += (Math.random() - 0.5) * 20 * dt;
}
// Apply damping
p.vx *= baseFriction;
p.vy *= baseFriction;
p.x += p.vx * dt;
p.y += p.vy * dt;
}
// Boundary collisions and neck logic
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
// Hourglass body bounds at this y
const b = hourglassBoundsAtY(p.y);
const left = b.left + p.radius * 0.7;
const right = b.right - p.radius * 0.7;
// Left/right wall
if (p.x < left) {
p.x = left;
if (p.vx < 0) p.vx *= wallBounce;
} else if (p.x > right) {
p.x = right;
if (p.vx > 0) p.vx *= wallBounce;
}
// Top and bottom
if (p.y - p.radius < upperChamber.top) {
p.y = upperChamber.top + p.radius;
if (p.vy < 0) p.vy *= wallBounce;
}
if (p.y + p.radius > lowerChamber.bottom) {
p.y = lowerChamber.bottom - p.radius;
if (p.vy > 0) p.vy *= wallBounce * 0.3;
}
// Neck passage: allow only a limited number and require alignment
const neckHalf = neckRadius * 0.9;
const nearNeck = Math.abs(p.y - neckY) < neckRadius * 4;
if (nearNeck) {
const inVertical = p.x > neckX - neckHalf && p.x < neckX + neckHalf;
const goingDown = (p.upper && p.y < neckY && p.vy > 0) ||
(!p.upper && p.y > neckY && p.vy < 0);
if (!inVertical && Math.abs(p.y - neckY) < neckRadius * 1.4) {
// Push back to side to simulate jamming around neck
if (p.x < neckX) {
p.x = neckX - neckHalf - p.radius * 0.4;
p.vx += -30 * dt;
} else {
p.x = neckX + neckHalf + p.radius * 0.4;
p.vx += 30 * dt;
}
p.vy *= 0.4;
}
if (inVertical && goingDown) {
// Only some particles can pass at a time
const speedThresh = 20 * flowFactor;
if (Math.abs(p.vy) < speedThresh) {
// nudge
p.vy += (p.upper ? 1 : -1) * 200 * dt * flowFactor;
}
} else if (Math.abs(p.y - neckY) < neckRadius) {
// Bounce off the neck plane if trying to cross the wrong way
if (p.upper && p.y > neckY - p.radius && p.vy > 0) {
p.y = neckY - p.radius;
p.vy *= -0.2;
} else if (!p.upper && p.y < neckY + p.radius && p.vy < 0) {
p.y = neckY + p.radius;
p.vy *= -0.2;
}
}
// Transfer between chambers when crossing neck
if (p.upper && p.y > neckY + p.radius * 1.2 && inVertical) {
p.upper = false;
p.settled = false;
} else if (!p.upper && p.y < neckY - p.radius * 1.2 && inVertical) {
p.upper = true;
p.settled = false;
}
}
}
// Simple particle–particle collision and cone-like settling heuristic
const restitution = 0.05;
const staticFriction = 0.6;
for (let i = 0; i < particles.length; i++) {
const p1 = particles[i];
for (let j = i + 1; j < particles.length; j++) {
const p2 = particles[j];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
const minDist = p1.radius + p2.radius;
if (dist < minDist * 0.98) {
const overlap = (minDist - dist) * 0.5;
const nx = dx / dist;
const ny = dy / dist;
p1.x -= nx * overlap;
p1.y -= ny * overlap;
p2.x += nx * overlap;
p2.y += ny * overlap;
const rvx = p2.vx - p1.vx;
const rvy = p2.vy - p1.vy;
const vn = rvx * nx + rvy * ny;
if (vn < 0) {
const impulse = -(1 + restitution) * vn * 0.5;
const ix = impulse * nx;
const iy = impulse * ny;
p1.vx -= ix;
p1.vy -= iy;
p2.vx += ix;
p2.vy += iy;
}
// Tangential friction to promote conical pile
const vtX = rvx - vn * nx;
const vtY = rvy - vn * ny;
const vt = Math.sqrt(vtX * vtX + vtY * vtY);
if (vt > 0.0001) {
const fx = (vtX / vt) * staticFriction * 4;
const fy = (vtY / vt) * staticFriction * 4;
p1.vx += fx * dt;
p2.vx -= fx * dt;
p1.vy += fy * dt;
p2.vy -= fy * dt;
}
// Mark as settled if movement is very small and near bottom pile
const bottom = p1.upper ? upperChamber.top : lowerChamber.bottom;
if (Math.abs(p1.vx) + Math.abs(p1.vy) < 10 && Math.abs(p1.y - bottom) < (lowerChamber.bottom - neckY) * 0.8) {
p1.settled = true;
}
if (Math.abs(p2.vx) + Math.abs(p2.vy) < 10 && Math.abs(p2.y - bottom) < (lowerChamber.bottom - neckY) * 0.8) {
p2.settled = true;
}
}
}
}
// Artificial settling slope at bottom of each chamber to form cone
applyConeConstraint(true);
applyConeConstraint(false);
}
function applyConeConstraint(upperFlag) {
const bottomY = upperFlag ? upperChamber.top : lowerChamber.bottom;
const neckToBottom = Math.abs(bottomY - neckY);
const coneAngle = 0.9; // radians ~ 50°
const maxSlope = Math.tan(coneAngle);
const sign = upperFlag ? -1 : 1; // cone apex at neck, open towards bottom
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
if (p.upper !== upperFlag) continue;
const dy = (bottomY - p.y) * sign;
if (dy < 0) continue;
const idealHalfWidth = dy * maxSlope;
const allowed = hourglassWidthAtY(p.y) * 0.5;
const effective = Math.min(idealHalfWidth, allowed * 0.96);
const dx = p.x - neckX;
if (Math.abs(dx) > effective && dy > neckToBottom * 0.05) {
const targetX = neckX + Math.sign(dx) * effective;
p.x += (targetX - p.x) * 0.25;
p.vx *= 0.2;
p.vy *= 0.6;
p.settled = true;
}
}
}
function drawGlass() {
const bodyTop = upperChamber.top;
const bodyBottom = lowerChamber.bottom;
// Back glow
const gradient = ctx.createRadialGradient(
w * 0.5, h * 0.2, Math.min(w, h) * 0.05,
w * 0.5, h * 0.5, Math.max(w, h) * 0.6
);
gradient.addColorStop(0, 'rgba(255,255,255,0.07)');
gradient.addColorStop(0.45, 'rgba(81,117,197,0.06)');
gradient.addColorStop(1, 'rgba(0,0,0,0.95)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
// Wooden/metal base and top
const baseHeight = outerPadding * 0.7;
const baseGradient = ctx.createLinearGradient(0, h - baseHeight, 0, h);
baseGradient.addColorStop(0, '#15141d');
baseGradient.addColorStop(0.5, '#0d0c12');
baseGradient.addColorStop(1, '#05040a');
ctx.fillStyle = baseGradient;
ctx.fillRect(w * 0.18, h - baseHeight, w * 0.64, baseHeight);
const topGradient = ctx.createLinearGradient(0, 0, 0, bodyTop);
topGradient.addColorStop(0, '#0c0c15');
topGradient.addColorStop(1, '#171623');
ctx.fillStyle = topGradient;
ctx.fillRect(w * 0.2, 0, w * 0.6, bodyTop * 0.8);
// Glass shape path
ctx.save();
ctx.lineWidth = Math.min(w, h) * 0.015;
ctx.strokeStyle = 'rgba(210,220,255,0.9)';
ctx.fillStyle = 'rgba(12,18,34,0.85)';
ctx.beginPath();
const steps = 80;
// Left side top -> neck
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const y = bodyTop + (neckY - bodyTop) * t;
const b = hourglassBoundsAtY(y);
const x = b.left;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
// Left side neck -> bottom
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const y = neckY + (bodyBottom - neckY) * t;
const b = hourglassBoundsAtY(y);
const x = b.left;
ctx.lineTo(x, y);
}
// Right side bottom -> neck
for (let i = steps; i >= 0; i--) {
const t = i / steps;
const y = neckY + (bodyBottom - neckY) * t;
const b = hourglassBoundsAtY(y);
const x = b.right;
ctx.lineTo(x, y);
}
// Right side neck -> top
for (let i = steps; i >= 0; i--) {
const t = i / steps;
const y = bodyTop + (neckY - bodyTop) * t;
const b = hourglassBoundsAtY(y);
const x = b.right;
ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
// Glass edge highlight
ctx.stroke();
// Neck ring
ctx.beginPath();
ctx.strokeStyle = 'rgba(244,249,255,0.6)';
ctx.lineWidth = Math.min(w, h) * 0.006;
ctx.ellipse(neckX, neckY, neckRadius * 2.4, neckRadius * 0.8, 0, 0, Math.PI * 2);
ctx.stroke();
// Inner line at neck
ctx.beginPath();
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = Math.min(w, h) * 0.0025;
ctx.moveTo(neckX - neckRadius * 2.1, neckY);
ctx.lineTo(neckX + neckRadius * 2.1, neckY);
ctx.stroke();
// Inner glass reflections
ctx.globalAlpha = 0.18;
ctx.beginPath();
ctx.moveTo(neckX - hourglassWidthAtY(bodyTop) * 0.36, bodyTop + (bodyBottom - bodyTop) * 0.1);
ctx.quadraticCurveTo(
neckX - hourglassWidthAtY(neckY) * 0.8, (bodyTop + bodyBottom) * 0.5,
neckX - hourglassWidthAtY(bodyBottom) * 0.18, bodyBottom - (bodyBottom - bodyTop) * 0.25
);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = Math.min(w, h) * 0.007;
ctx.stroke();
ctx.globalAlpha = 0.08;
ctx.beginPath();
ctx.moveTo(neckX + hourglassWidthAtY(bodyTop) * 0.22, bodyTop + (bodyBottom - bodyTop) * 0.15);
ctx.quadraticCurveTo(
neckX + hourglassWidthAtY(neckY) * 0.4, (bodyTop + bodyBottom) * 0.55,
neckX + hourglassWidthAtY(bodyBottom) * 0.1, bodyBottom - (bodyBottom - bodyTop) * 0.28
);
ctx.lineWidth = Math.min(w, h) * 0.01;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
}
function drawSand() {
// Draw bottom and top piles with mild depth effect
const gradYellow = ctx.createLinearGradient(0, upperChamber.top, 0, lowerChamber.bottom);
gradYellow.addColorStop(0, '#ffe9a4');
gradYellow.addColorStop(0.45, '#ffd166');
gradYellow.addColorStop(1, '#e6a548');
const baseColorTop = '#ffecb8';
const baseColorBottom = '#f1b455';
for (let pass = 0; pass < 2; pass++) {
const depthAlpha = pass === 0 ? 0.4 : 0.9;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const localAlpha = depthAlpha * (0.4 + 0.6 * (p.settled ? 1 : 0.5));
ctx.beginPath();
ctx.fillStyle = pass === 0
? `rgba(255,220,150,${localAlpha * 0.35})`
: (p.upper ? baseColorTop : baseColorBottom);
ctx.globalAlpha = localAlpha;
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// Falling stream emphasis near neck
const streamRadius = neckRadius * 0.6;
ctx.save();
const gradStream = ctx.createRadialGradient(neckX, neckY + neckRadius * 2.2, 1,
neckX, neckY + neckRadius * 2.2, neckRadius * 4);
gradStream.addColorStop(0, 'rgba(255,235,180,0.9)');
gradStream.addColorStop(0.3, 'rgba(255,219,135,0.7)');
gradStream.addColorStop(1, 'rgba(255,219,135,0)');
ctx.fillStyle = gradStream;
ctx.beginPath();
ctx.ellipse(neckX, neckY + neckRadius * 2.4, streamRadius, neckRadius * 4, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function draw() {
ctx.clearRect(0, 0, w, h);
drawGlass();
drawSand();
// Small glint at neck
ctx.save();
ctx.globalAlpha = 0.7;
const glintGrad = ctx.createRadialGradient(
neckX - neckRadius * 1.2, neckY - neckRadius * 0.4, 1,
neckX - neckRadius * 1.2, neckY - neckRadius * 0.4, neckRadius * 4
);
glintGrad.addColorStop(0, 'rgba(255,255,255,0.85)');
glintGrad.addColorStop(0.25, 'rgba(255,255,255,0.3)');
glintGrad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = glintGrad;
ctx.beginPath();
ctx.ellipse(
neckX - neckRadius * 1.3,
neckY - neckRadius * 0.4,
neckRadius * 2.2,
neckRadius * 1.8,
-0.3,
0,
Math.PI * 2
);
ctx.fill();
ctx.restore();
}
function startFlip() {
if (isFlipping) return;
isFlipping = true;
flipStart = performance.now();
}
canvas.addEventListener('click', startFlip);
window.addEventListener('resize', resize);
resize();
requestAnimationFrame(update);
})();
</script>
</body>
</html>