Created
March 27, 2026 17:41
-
-
Save jpatel3/c51adcdead70ee89b6964faa3a4b7548 to your computer and use it in GitHub Desktop.
Astro-Drop Physics Lab Simulation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Astro-Drop Physics Lab</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| overflow: hidden; | |
| width: 100vw; | |
| height: 100vh; | |
| background: #000; | |
| } | |
| canvas { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* --- Overlay UI --- */ | |
| .overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| z-index: 10; | |
| } | |
| .header { | |
| position: absolute; | |
| top: 24px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| text-align: center; | |
| color: #fff; | |
| text-shadow: 0 2px 12px rgba(0,0,0,0.6); | |
| } | |
| .header h1 { | |
| font-size: 1.6em; | |
| font-weight: 700; | |
| letter-spacing: -0.02em; | |
| } | |
| .header p { | |
| font-size: 0.85em; | |
| opacity: 0.8; | |
| margin-top: 4px; | |
| font-weight: 400; | |
| } | |
| /* --- Control Panel (left side) --- */ | |
| .controls-panel { | |
| position: absolute; | |
| top: 50%; | |
| left: 28px; | |
| transform: translateY(-50%); | |
| pointer-events: all; | |
| background: rgba(15, 15, 25, 0.75); | |
| backdrop-filter: blur(16px); | |
| -webkit-backdrop-filter: blur(16px); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| border-radius: 16px; | |
| padding: 24px; | |
| width: 260px; | |
| color: #fff; | |
| } | |
| .control-section { | |
| margin-bottom: 20px; | |
| } | |
| .control-section:last-child { | |
| margin-bottom: 0; | |
| } | |
| .section-label { | |
| font-size: 0.7em; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: rgba(255,255,255,0.5); | |
| margin-bottom: 10px; | |
| } | |
| /* --- Radio Buttons --- */ | |
| .radio-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .radio-option { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 8px 12px; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| transition: background 0.15s; | |
| font-size: 0.85em; | |
| } | |
| .radio-option:hover { | |
| background: rgba(255,255,255,0.08); | |
| } | |
| .radio-option input[type="radio"] { | |
| appearance: none; | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border: 2px solid rgba(255,255,255,0.3); | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| cursor: pointer; | |
| position: relative; | |
| transition: border-color 0.15s; | |
| } | |
| .radio-option input[type="radio"]:checked { | |
| border-color: #60a5fa; | |
| } | |
| .radio-option input[type="radio"]:checked::after { | |
| content: ''; | |
| position: absolute; | |
| top: 2px; | |
| left: 2px; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: #60a5fa; | |
| } | |
| .radio-label { | |
| display: flex; | |
| flex-direction: column; | |
| line-height: 1.3; | |
| } | |
| .radio-label .name { | |
| font-weight: 500; | |
| } | |
| .radio-label .detail { | |
| font-size: 0.75em; | |
| color: rgba(255,255,255,0.4); | |
| } | |
| /* --- Checkboxes --- */ | |
| .checkbox-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .checkbox-option { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 8px 12px; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| transition: background 0.15s; | |
| font-size: 0.85em; | |
| } | |
| .checkbox-option:hover { | |
| background: rgba(255,255,255,0.08); | |
| } | |
| .checkbox-option input[type="checkbox"] { | |
| appearance: none; | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border: 2px solid rgba(255,255,255,0.3); | |
| border-radius: 4px; | |
| flex-shrink: 0; | |
| cursor: pointer; | |
| position: relative; | |
| transition: all 0.15s; | |
| } | |
| .checkbox-option input[type="checkbox"]:checked { | |
| background: #60a5fa; | |
| border-color: #60a5fa; | |
| } | |
| .checkbox-option input[type="checkbox"]:checked::after { | |
| content: ''; | |
| position: absolute; | |
| top: 1px; | |
| left: 4px; | |
| width: 4px; | |
| height: 8px; | |
| border: solid #fff; | |
| border-width: 0 2px 2px 0; | |
| transform: rotate(45deg); | |
| } | |
| .checkbox-option input[type="checkbox"]:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| .checkbox-option.disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| .checkbox-option.disabled .force-tag { | |
| background: rgba(255,255,255,0.1); | |
| color: rgba(255,255,255,0.4); | |
| } | |
| .force-tag { | |
| font-size: 0.65em; | |
| font-weight: 600; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| background: rgba(96,165,250,0.2); | |
| color: #60a5fa; | |
| margin-left: auto; | |
| flex-shrink: 0; | |
| } | |
| .force-tag.always { | |
| background: rgba(52,211,153,0.2); | |
| color: #34d399; | |
| } | |
| /* --- Drop Button --- */ | |
| .drop-btn { | |
| width: 100%; | |
| padding: 14px; | |
| border: none; | |
| border-radius: 12px; | |
| background: linear-gradient(135deg, #ef4444, #dc2626); | |
| color: #fff; | |
| font-family: 'Inter', sans-serif; | |
| font-size: 1em; | |
| font-weight: 700; | |
| letter-spacing: 0.02em; | |
| cursor: pointer; | |
| pointer-events: all; | |
| transition: all 0.15s; | |
| box-shadow: 0 4px 12px rgba(239,68,68,0.4); | |
| } | |
| .drop-btn:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 6px 20px rgba(239,68,68,0.5); | |
| } | |
| .drop-btn:active { | |
| transform: translateY(1px); | |
| box-shadow: 0 2px 8px rgba(239,68,68,0.3); | |
| } | |
| /* --- Stats HUD (bottom center) --- */ | |
| .stats-hud { | |
| position: absolute; | |
| bottom: 28px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(15, 15, 25, 0.75); | |
| backdrop-filter: blur(16px); | |
| -webkit-backdrop-filter: blur(16px); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| border-radius: 12px; | |
| padding: 12px 28px; | |
| color: #fff; | |
| text-align: center; | |
| font-size: 0.9em; | |
| font-weight: 500; | |
| white-space: nowrap; | |
| } | |
| .stats-hud .sub { | |
| font-size: 0.8em; | |
| font-weight: 400; | |
| color: rgba(255,255,255,0.5); | |
| margin-top: 2px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="simCanvas"></canvas> | |
| <div class="overlay"> | |
| <div class="header"> | |
| <h1>Astro-Drop Physics Lab</h1> | |
| <p>Drop objects across the solar system and observe the forces at play</p> | |
| </div> | |
| <div class="controls-panel"> | |
| <!-- Location --> | |
| <div class="control-section"> | |
| <div class="section-label">Location</div> | |
| <div class="radio-group" id="locationGroup"> | |
| <label class="radio-option"> | |
| <input type="radio" name="location" value="earth"> | |
| <span class="radio-label"><span class="name">Earth</span></span> | |
| </label> | |
| <label class="radio-option"> | |
| <input type="radio" name="location" value="moon" checked> | |
| <span class="radio-label"><span class="name">Moon</span></span> | |
| </label> | |
| <label class="radio-option"> | |
| <input type="radio" name="location" value="mars"> | |
| <span class="radio-label"><span class="name">Mars</span></span> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Object --> | |
| <div class="control-section"> | |
| <div class="section-label">Object</div> | |
| <div class="radio-group" id="objectGroup"> | |
| <label class="radio-option"> | |
| <input type="radio" name="object" value="ball" checked> | |
| <span class="radio-label"><span class="name">Soccer Ball</span></span> | |
| </label> | |
| <label class="radio-option"> | |
| <input type="radio" name="object" value="wood"> | |
| <span class="radio-label"><span class="name">Heavy Log</span></span> | |
| </label> | |
| <label class="radio-option"> | |
| <input type="radio" name="object" value="feather"> | |
| <span class="radio-label"><span class="name">Bird Feather</span></span> | |
| </label> | |
| <label class="radio-option"> | |
| <input type="radio" name="object" value="balloon"> | |
| <span class="radio-label"><span class="name">Helium Balloon</span></span> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Forces --> | |
| <div class="control-section"> | |
| <div class="section-label">Forces</div> | |
| <div class="checkbox-group"> | |
| <label class="checkbox-option disabled"> | |
| <input type="checkbox" id="forceGravity" checked disabled> | |
| <span>Gravity</span> | |
| <span class="force-tag always">always on</span> | |
| </label> | |
| <label class="checkbox-option"> | |
| <input type="checkbox" id="forceDrag" checked> | |
| <span>Air Drag</span> | |
| <span class="force-tag">optional</span> | |
| </label> | |
| <label class="checkbox-option"> | |
| <input type="checkbox" id="forceBuoyancy" checked> | |
| <span>Buoyancy</span> | |
| <span class="force-tag">optional</span> | |
| </label> | |
| </div> | |
| </div> | |
| <button class="drop-btn" onclick="startDrop()">DROP IT</button> | |
| </div> | |
| <div class="stats-hud" id="timerDisplay"> | |
| Ready to Drop | |
| <div class="sub">Select your experiment and hit drop</div> | |
| </div> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('simCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const timerDisplay = document.getElementById('timerDisplay'); | |
| const G_SCALE = 40; | |
| // Physics & Animation State | |
| let lastTime = 0; | |
| let elapsedSimTime = 0; | |
| let isRunning = false; | |
| let hasLanded = false; // true when object hit the ground or floated away | |
| // Object coordinates (will be set relative to canvas size) | |
| let x = 0, y = 0, vx = 0, vy = 0, angle = 0; | |
| // Astronaut Animation State | |
| let armAngle = Math.PI; | |
| // Canvas sizing | |
| let W = 0, H = 0; | |
| let groundY = 0; | |
| let scaleF = 1; // scale factor relative to original 500px design | |
| function resizeCanvas() { | |
| W = window.innerWidth; | |
| H = window.innerHeight; | |
| canvas.width = W; | |
| canvas.height = H; | |
| groundY = H - H * 0.1; // ground at bottom 10% | |
| scaleF = Math.min(W, H) / 500; | |
| if (!isRunning && !hasLanded) resetSim(); | |
| } | |
| window.addEventListener('resize', resizeCanvas); | |
| const data = { | |
| locations: { | |
| earth: { g: 9.81, density: 1.225, name: "Earth" }, | |
| moon: { g: 1.62, density: 0, name: "The Moon" }, | |
| mars: { g: 3.72, density: 0.020, name: "Mars" }, | |
| }, | |
| objects: { | |
| ball: { mass: 0.43, volume: 0.0055, area: 0.038, dragCoeff: 0.47, shape: 'circle', radius: 15, color: '#fff' }, | |
| wood: { mass: 5.00, volume: 0.0080, area: 0.060, dragCoeff: 0.80, shape: 'rect', w: 30, h: 40, color: '#8e6e46' }, | |
| feather: { mass: 0.0005, volume: 0.00001, area: 0.005, dragCoeff: 1.50, shape: 'feather', color: '#ecf0f1' }, | |
| balloon: { mass: 0.0055, volume: 0.0140, area: 0.060, dragCoeff: 0.47, shape: 'balloon', color: '#e74c3c', groundOffset: 22 } | |
| } | |
| }; | |
| // --- Helpers to get selected values --- | |
| function getLocation() { | |
| return document.querySelector('input[name="location"]:checked').value; | |
| } | |
| function getObject() { | |
| return document.querySelector('input[name="object"]:checked').value; | |
| } | |
| function isDragEnabled() { | |
| return document.getElementById('forceDrag').checked; | |
| } | |
| function isBuoyancyEnabled() { | |
| return document.getElementById('forceBuoyancy').checked; | |
| } | |
| // Listen for radio/checkbox changes — always reset (even if landed) | |
| document.querySelectorAll('input[name="location"], input[name="object"]').forEach(el => { | |
| el.addEventListener('change', () => { if (!isRunning) resetSim(); }); | |
| }); | |
| document.getElementById('forceDrag').addEventListener('change', () => { if (!isRunning) resetSim(); }); | |
| document.getElementById('forceBuoyancy').addEventListener('change', () => { if (!isRunning) resetSim(); }); | |
| // --- ASTRONAUT DRAWING (scaled to canvas) --- | |
| function drawAstronaut() { | |
| ctx.save(); | |
| const s = scaleF; | |
| { | |
| const cx = W * 0.6; | |
| const cy = groundY; | |
| ctx.translate(cx, cy); | |
| ctx.scale(s, s); | |
| ctx.fillStyle = '#bdc3c7'; ctx.fillRect(10, -180, 50, 100); | |
| ctx.fillStyle = '#ecf0f1'; | |
| ctx.beginPath(); ctx.roundRect(-20, -80, 25, 80, 10); ctx.fill(); | |
| ctx.beginPath(); ctx.roundRect(15, -80, 25, 80, 10); ctx.fill(); | |
| ctx.fillStyle = '#95a5a6'; ctx.fillRect(-20, -45, 25, 10); ctx.fillRect(15, -45, 25, 10); | |
| ctx.fillStyle = '#fff'; | |
| ctx.beginPath(); ctx.roundRect(-25, -180, 65, 110, 20); ctx.fill(); | |
| ctx.fillStyle = '#bdc3c7'; ctx.fillRect(-15, -150, 40, 30); | |
| ctx.fillStyle = '#e74c3c'; ctx.fillRect(-10, -145, 10, 10); | |
| ctx.fillStyle = '#3498db'; ctx.fillRect(5, -145, 10, 10); | |
| ctx.fillStyle = '#ecf0f1'; ctx.beginPath(); ctx.roundRect(25, -170, 20, 80, 10); ctx.fill(); | |
| ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(5, -195, 35, 0, Math.PI*2); ctx.fill(); | |
| let visorGrad = ctx.createLinearGradient(-15, -210, 15, -180); | |
| visorGrad.addColorStop(0, '#f1c40f'); visorGrad.addColorStop(1, '#d35400'); | |
| ctx.fillStyle = visorGrad; ctx.beginPath(); ctx.arc(-5, -195, 25, 0, Math.PI*2); ctx.fill(); | |
| ctx.save(); | |
| ctx.translate(-15, -160); | |
| ctx.rotate(armAngle); | |
| ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.roundRect(-12, -12, 90, 24, 12); ctx.fill(); | |
| ctx.fillStyle = '#95a5a6'; ctx.fillRect(35, -12, 10, 24); | |
| ctx.fillStyle = '#7f8c8d'; ctx.beginPath(); ctx.arc(80, 0, 12, 0, Math.PI*2); ctx.fill(); | |
| ctx.restore(); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawScene() { | |
| const locKey = getLocation(); | |
| ctx.clearRect(0, 0, W, H); | |
| if (locKey === 'earth') { | |
| let skyGrad = ctx.createLinearGradient(0, 0, 0, groundY); | |
| skyGrad.addColorStop(0, '#1a5276'); skyGrad.addColorStop(1, '#a9cce3'); | |
| ctx.fillStyle = skyGrad; ctx.fillRect(0, 0, W, H); | |
| let groundGrad = ctx.createLinearGradient(0, groundY, 0, H); | |
| groundGrad.addColorStop(0, '#2ecc71'); groundGrad.addColorStop(0.7, '#27ae60'); groundGrad.addColorStop(1, '#5c4033'); | |
| ctx.fillStyle = groundGrad; ctx.fillRect(0, groundY, W, H - groundY); | |
| } else if (locKey === 'moon') { | |
| let spaceGrad = ctx.createRadialGradient(W/2, H/2, 50, W/2, H/2, Math.max(W,H)*0.8); | |
| spaceGrad.addColorStop(0, '#0a0a1a'); spaceGrad.addColorStop(1, '#000000'); | |
| ctx.fillStyle = spaceGrad; ctx.fillRect(0, 0, W, H); | |
| ctx.fillStyle = 'white'; | |
| for(let i = 0; i < 80; i++) { | |
| let sx = (i * 137.5 + 50) % W; | |
| let sy = (i * 73.3 + 20) % (groundY - 20); | |
| ctx.globalAlpha = 0.3 + (i % 3) * 0.25; | |
| ctx.fillRect(sx, sy, 1 + (i % 2), 1 + (i % 2)); | |
| } | |
| ctx.globalAlpha = 1.0; | |
| ctx.fillStyle = '#2980b9'; ctx.beginPath(); ctx.arc(W*0.8, H*0.15, 40*scaleF, 0, Math.PI*2); ctx.fill(); | |
| ctx.fillStyle = '#27ae60'; ctx.beginPath(); ctx.arc(W*0.8 - 10*scaleF, H*0.15 - 10*scaleF, 15*scaleF, 0, Math.PI*2); ctx.fill(); | |
| ctx.fillStyle = '#ecf0f1'; ctx.beginPath(); ctx.arc(W*0.8 + 10*scaleF, H*0.15 + 10*scaleF, 10*scaleF, 0, Math.PI*2); ctx.fill(); | |
| let groundGrad = ctx.createLinearGradient(0, groundY, 0, H); | |
| groundGrad.addColorStop(0, '#bdc3c7'); groundGrad.addColorStop(1, '#7f8c8d'); | |
| ctx.fillStyle = groundGrad; ctx.fillRect(0, groundY, W, H - groundY); | |
| ctx.fillStyle = '#95a5a6'; ctx.beginPath(); ctx.ellipse(W*0.2, groundY + (H-groundY)*0.4, 40*scaleF, 10*scaleF, 0, 0, Math.PI*2); ctx.fill(); | |
| } else if (locKey === 'mars') { | |
| let skyGrad = ctx.createLinearGradient(0, 0, 0, groundY); | |
| skyGrad.addColorStop(0, '#c0392b'); skyGrad.addColorStop(1, '#e67e22'); | |
| ctx.fillStyle = skyGrad; ctx.fillRect(0, 0, W, H); | |
| let groundGrad = ctx.createLinearGradient(0, groundY, 0, H); | |
| groundGrad.addColorStop(0, '#d35400'); groundGrad.addColorStop(1, '#873600'); | |
| ctx.fillStyle = groundGrad; ctx.fillRect(0, groundY, W, H - groundY); | |
| ctx.fillStyle = '#a04000'; | |
| ctx.beginPath(); ctx.moveTo(W*0.05, groundY+10); ctx.lineTo(W*0.1, groundY-25*scaleF); ctx.lineTo(W*0.18, groundY+10); ctx.fill(); | |
| ctx.fillStyle = '#ba4a00'; | |
| ctx.beginPath(); ctx.moveTo(W*0.8, groundY+15); ctx.lineTo(W*0.85, groundY-20*scaleF); ctx.lineTo(W*0.92, groundY+15); ctx.fill(); | |
| } | |
| } | |
| function drawObject(drawX, drawY, drawAngle, objKey) { | |
| const obj = data.objects[objKey]; | |
| const s = scaleF; | |
| ctx.save(); | |
| ctx.translate(drawX, drawY); | |
| ctx.rotate(drawAngle); | |
| ctx.scale(s, s); | |
| ctx.fillStyle = obj.color; | |
| ctx.strokeStyle = '#333'; ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| if (obj.shape === 'circle') { | |
| // Soccer ball base | |
| const r = obj.radius; | |
| ctx.arc(0, 0, r, 0, Math.PI * 2); | |
| ctx.fill(); ctx.stroke(); | |
| // Pentagon pattern | |
| ctx.fillStyle = '#333'; | |
| // Center pentagon | |
| ctx.beginPath(); | |
| for (let i = 0; i < 5; i++) { | |
| const a = (i * 2 * Math.PI / 5) - Math.PI / 2; | |
| const px = Math.cos(a) * 5; | |
| const py = Math.sin(a) * 5; | |
| if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); | |
| } | |
| ctx.closePath(); ctx.fill(); | |
| // Outer pentagons (partial, visible as dark patches) | |
| for (let i = 0; i < 5; i++) { | |
| const a = (i * 2 * Math.PI / 5) - Math.PI / 2; | |
| const cx2 = Math.cos(a) * (r * 0.7); | |
| const cy2 = Math.sin(a) * (r * 0.7); | |
| ctx.beginPath(); | |
| for (let j = 0; j < 5; j++) { | |
| const a2 = a + (j * 2 * Math.PI / 5) - Math.PI / 2; | |
| const px = cx2 + Math.cos(a2) * 4; | |
| const py = cy2 + Math.sin(a2) * 4; | |
| if (j === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); | |
| } | |
| ctx.closePath(); ctx.fill(); | |
| } | |
| // Seam lines from center to outer pentagons | |
| ctx.strokeStyle = '#ccc'; ctx.lineWidth = 0.8; | |
| for (let i = 0; i < 5; i++) { | |
| const a = (i * 2 * Math.PI / 5) - Math.PI / 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(Math.cos(a) * 5, Math.sin(a) * 5); | |
| ctx.lineTo(Math.cos(a) * (r * 0.7), Math.sin(a) * (r * 0.7)); | |
| ctx.stroke(); | |
| } | |
| // Outer connecting seams | |
| for (let i = 0; i < 5; i++) { | |
| const a1 = (i * 2 * Math.PI / 5) - Math.PI / 2; | |
| const a2 = ((i + 1) % 5 * 2 * Math.PI / 5) - Math.PI / 2; | |
| const mid = (a1 + a2) / 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(Math.cos(a1) * (r * 0.7), Math.sin(a1) * (r * 0.7)); | |
| ctx.quadraticCurveTo(Math.cos(mid) * (r * 0.95), Math.sin(mid) * (r * 0.95), | |
| Math.cos(a2) * (r * 0.7), Math.sin(a2) * (r * 0.7)); | |
| ctx.stroke(); | |
| } | |
| } else if (obj.shape === 'rect') { | |
| ctx.fillRect(-obj.w/2, -obj.h/2, obj.w, obj.h); ctx.strokeRect(-obj.w/2, -obj.h/2, obj.w, obj.h); | |
| } else if (obj.shape === 'feather') { | |
| ctx.moveTo(0, -25); ctx.bezierCurveTo(20, -10, 15, 20, 0, 35); ctx.bezierCurveTo(-15, 20, -20, -10, 0, -25); ctx.fill(); | |
| ctx.beginPath(); ctx.moveTo(0, -35); ctx.lineTo(0, 30); ctx.strokeStyle='#bdc3c7'; ctx.stroke(); | |
| } else if (obj.shape === 'balloon') { | |
| // Teardrop balloon shape (no string) | |
| ctx.moveTo(0, -22); | |
| ctx.bezierCurveTo(22, -22, 24, 5, 0, 18); | |
| ctx.bezierCurveTo(-24, 5, -22, -22, 0, -22); | |
| ctx.fill(); | |
| ctx.strokeStyle = '#c0392b'; ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| // Knot at bottom | |
| ctx.beginPath(); | |
| ctx.moveTo(-3, 18); | |
| ctx.lineTo(0, 22); | |
| ctx.lineTo(3, 18); | |
| ctx.fillStyle = '#c0392b'; | |
| ctx.fill(); | |
| // Shine highlight | |
| ctx.beginPath(); | |
| ctx.ellipse(-6, -8, 4, 7, -0.3, 0, Math.PI * 2); | |
| ctx.fillStyle = 'rgba(255,255,255,0.35)'; | |
| ctx.fill(); | |
| } | |
| ctx.restore(); | |
| } | |
| // Compute the world position of the astronaut's palm/glove | |
| function getHandPosition() { | |
| // Astronaut: origin at (W*0.6, groundY), arm pivot at (-15, -160), hand at 80px along armAngle | |
| const cx = W * 0.6; | |
| const cy = groundY; | |
| const pivotX = -15, pivotY = -160; | |
| const handX = pivotX + Math.cos(armAngle) * 80; | |
| const handY = pivotY + Math.sin(armAngle) * 80; | |
| return { x: cx + handX * scaleF, y: cy + handY * scaleF }; | |
| } | |
| function resetSim() { | |
| isRunning = false; | |
| hasLanded = false; | |
| elapsedSimTime = 0; | |
| vx = 0; vy = 0; angle = 0; | |
| armAngle = Math.PI; | |
| const locKey = getLocation(); | |
| const objKey = getObject(); | |
| // Position object at the astronaut's palm | |
| const hand = getHandPosition(); | |
| x = hand.x; | |
| y = hand.y; | |
| drawScene(); | |
| drawAstronaut(); | |
| let hoverY = y; | |
| drawObject(x, hoverY, 0, objKey); | |
| timerDisplay.innerHTML = 'Ready to Drop<div class="sub">Select your experiment and hit drop</div>'; | |
| // Reset button text | |
| const btn = document.querySelector('.drop-btn'); | |
| btn.textContent = 'DROP IT'; | |
| btn.onclick = startDrop; | |
| } | |
| function startDrop() { | |
| resetSim(); | |
| isRunning = true; | |
| lastTime = performance.now(); | |
| timerDisplay.innerHTML = 'Dropping...<div class="sub">Observing forces in action</div>'; | |
| requestAnimationFrame(animate); | |
| } | |
| function onLanded() { | |
| hasLanded = true; | |
| const btn = document.querySelector('.drop-btn'); | |
| btn.textContent = 'RESET'; | |
| btn.onclick = resetSim; | |
| } | |
| function animate(currentTime) { | |
| if (!isRunning) return; | |
| let dt = (currentTime - lastTime) / 1000; | |
| if (dt > 0.1) dt = 0.016; | |
| lastTime = currentTime; | |
| elapsedSimTime += dt; | |
| const locKey = getLocation(); | |
| const objKey = getObject(); | |
| const loc = data.locations[locKey]; | |
| const obj = data.objects[objKey]; | |
| const dragOn = isDragEnabled(); | |
| const buoyancyOn = isBuoyancyEnabled(); | |
| // Arm Animation | |
| if (armAngle > Math.PI / 2) { | |
| armAngle -= 10 * dt; | |
| if (armAngle < Math.PI / 2) armAngle = Math.PI / 2; | |
| } | |
| { | |
| // Gravity (always on) | |
| let netAccel = loc.g; | |
| // Buoyancy (optional) | |
| if (buoyancyOn) { | |
| let buoyantAccel = (loc.density * obj.volume * loc.g) / obj.mass; | |
| netAccel -= buoyantAccel; | |
| } | |
| // Drag (optional) | |
| if (dragOn) { | |
| let velocity_ms = vy / G_SCALE; | |
| let dragForce = 0.5 * loc.density * (velocity_ms * velocity_ms) * obj.dragCoeff * obj.area; | |
| let dragAccel = dragForce / obj.mass; | |
| if (vy > 0) netAccel -= dragAccel; | |
| else if (vy < 0) netAccel += dragAccel; | |
| } | |
| vy += (netAccel * G_SCALE) * dt; | |
| // Feather aerodynamic behavior | |
| if (objKey === 'feather' && loc.density > 0 && dragOn) { | |
| let t = elapsedSimTime * 4; | |
| angle = Math.cos(t) * 0.8 * (loc.density / 1.225); | |
| vx = Math.sin(t) * 80 * (loc.density / 1.225); | |
| let stallEffect = Math.abs(Math.sin(t)) * 1.5 * (loc.density / 1.225); | |
| vy -= stallEffect * G_SCALE * dt; | |
| } else { vx = 0; angle = 0; } | |
| x += vx * dt; | |
| y += vy * dt; | |
| timerDisplay.innerHTML = `<span style="font-size:0.85em">⏱</span> ${elapsedSimTime.toFixed(2)}s`; | |
| let objHalfH = (obj.groundOffset || obj.h / 2 || obj.radius || 30) * scaleF; | |
| if (y >= groundY - objHalfH && vy > 0) { | |
| isRunning = false; | |
| y = groundY - objHalfH; | |
| timerDisplay.innerHTML = `${elapsedSimTime.toFixed(2)}s<div class="sub">It hit the ground!</div>`; | |
| onLanded(); | |
| } else if (y < -50) { | |
| isRunning = false; | |
| timerDisplay.innerHTML = `Floated away!`; | |
| onLanded(); | |
| } | |
| } | |
| drawScene(); | |
| drawAstronaut(); | |
| drawObject(x, y, angle, objKey); | |
| if (isRunning) requestAnimationFrame(animate); | |
| } | |
| // Keyboard shortcut: spacebar to drop or reset | |
| document.addEventListener('keydown', (e) => { | |
| if (e.code === 'Space' && !e.repeat) { | |
| e.preventDefault(); | |
| if (hasLanded) { | |
| resetSim(); | |
| } else { | |
| startDrop(); | |
| } | |
| } | |
| }); | |
| // Idle animation loop | |
| let idleRAF; | |
| function idleLoop() { | |
| if (!isRunning && !hasLanded) { | |
| elapsedSimTime += 0.016; | |
| const locKey = getLocation(); | |
| const objKey = getObject(); | |
| // Recompute hand position for idle bob | |
| const hand = getHandPosition(); | |
| x = hand.x; | |
| y = hand.y; | |
| drawScene(); | |
| drawAstronaut(); | |
| let hoverY = y; | |
| drawObject(x, hoverY, 0, objKey); | |
| } else if (hasLanded) { | |
| // Keep drawing the scene with object on the ground | |
| const locKey = getLocation(); | |
| const objKey = getObject(); | |
| drawScene(); | |
| drawAstronaut(); | |
| drawObject(x, y, angle, objKey); | |
| } | |
| idleRAF = requestAnimationFrame(idleLoop); | |
| } | |
| // Init | |
| resizeCanvas(); | |
| idleLoop(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment