Created
March 10, 2026 22:18
-
-
Save secdev02/871ed38583e1d2018454f34e2cd1d3f7 to your computer and use it in GitHub Desktop.
Free Your Mind - Single Page App - Game and JS Physics
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>Physics Construct</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&display=swap'); | |
| :root { | |
| --bg: #070a0f; | |
| --panel: #0d1117; | |
| --border: #1a2535; | |
| --accent: #00ffc3; | |
| --accent2: #ff6b35; | |
| --accent3: #7c3aed; | |
| --text: #c9d1d9; | |
| --dim: #4a5568; | |
| --glow: 0 0 20px rgba(0,255,195,0.3); | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: 'Share Tech Mono', monospace; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 10px 20px; | |
| border-bottom: 1px solid var(--border); | |
| background: var(--panel); | |
| flex-shrink: 0; | |
| } | |
| header h1 { | |
| font-family: 'Orbitron', monospace; | |
| font-size: 1.1rem; | |
| font-weight: 900; | |
| letter-spacing: 0.2em; | |
| color: var(--accent); | |
| text-shadow: var(--glow); | |
| } | |
| .mode-tabs { | |
| display: flex; | |
| gap: 4px; | |
| } | |
| .tab { | |
| padding: 6px 16px; | |
| border: 1px solid var(--border); | |
| background: transparent; | |
| color: var(--dim); | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.75rem; | |
| letter-spacing: 0.1em; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| text-transform: uppercase; | |
| } | |
| .tab:hover { border-color: var(--accent); color: var(--accent); } | |
| .tab.active { | |
| background: var(--accent); | |
| color: var(--bg); | |
| border-color: var(--accent); | |
| box-shadow: var(--glow); | |
| } | |
| .workspace { | |
| display: flex; | |
| flex: 1; | |
| overflow: hidden; | |
| } | |
| .sidebar { | |
| width: 220px; | |
| flex-shrink: 0; | |
| background: var(--panel); | |
| border-right: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| } | |
| .sidebar-section { | |
| padding: 12px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .sidebar-section label { | |
| display: block; | |
| font-size: 0.65rem; | |
| color: var(--dim); | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| margin-bottom: 8px; | |
| } | |
| .control-row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 8px; | |
| gap: 8px; | |
| } | |
| .control-row span { | |
| font-size: 0.7rem; | |
| color: var(--text); | |
| min-width: 80px; | |
| } | |
| .control-row .val { | |
| font-size: 0.7rem; | |
| color: var(--accent); | |
| min-width: 36px; | |
| text-align: right; | |
| } | |
| input[type=range] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 3px; | |
| background: var(--border); | |
| outline: none; | |
| border-radius: 2px; | |
| } | |
| input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| background: var(--accent); | |
| cursor: pointer; | |
| box-shadow: 0 0 6px rgba(0,255,195,0.6); | |
| } | |
| .btn { | |
| width: 100%; | |
| padding: 8px; | |
| border: 1px solid var(--border); | |
| background: transparent; | |
| color: var(--text); | |
| font-family: 'Share Tech Mono', monospace; | |
| font-size: 0.7rem; | |
| letter-spacing: 0.1em; | |
| cursor: pointer; | |
| text-transform: uppercase; | |
| transition: all 0.15s; | |
| margin-bottom: 4px; | |
| } | |
| .btn:hover { border-color: var(--accent); color: var(--accent); } | |
| .btn.primary { | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| background: rgba(0,255,195,0.05); | |
| } | |
| .btn.primary:hover { | |
| background: var(--accent); | |
| color: var(--bg); | |
| box-shadow: var(--glow); | |
| } | |
| .btn.danger { border-color: var(--accent2); color: var(--accent2); } | |
| .btn.danger:hover { background: var(--accent2); color: var(--bg); } | |
| .canvas-area { | |
| flex: 1; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .telemetry { | |
| position: absolute; | |
| top: 12px; | |
| right: 12px; | |
| background: rgba(13,17,23,0.85); | |
| border: 1px solid var(--border); | |
| padding: 10px 14px; | |
| font-size: 0.68rem; | |
| line-height: 1.8; | |
| pointer-events: none; | |
| backdrop-filter: blur(4px); | |
| } | |
| .telemetry-row { display: flex; gap: 12px; } | |
| .telemetry-label { color: var(--dim); } | |
| .telemetry-val { color: var(--accent); min-width: 60px; } | |
| .overlay-msg { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(13,17,23,0.9); | |
| border: 1px solid var(--border); | |
| padding: 8px 20px; | |
| font-size: 0.7rem; | |
| color: var(--dim); | |
| pointer-events: none; | |
| letter-spacing: 0.08em; | |
| } | |
| .scene-hidden { display: none; } | |
| .checkbox-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 6px; | |
| } | |
| input[type=checkbox] { | |
| accent-color: var(--accent); | |
| width: 13px; | |
| height: 13px; | |
| } | |
| .checkbox-row label { | |
| font-size: 0.7rem; | |
| color: var(--text); | |
| margin: 0; | |
| letter-spacing: 0; | |
| text-transform: none; | |
| cursor: pointer; | |
| } | |
| .stat-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 4px; | |
| } | |
| .stat-box { | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| padding: 6px; | |
| text-align: center; | |
| } | |
| .stat-box .s-label { font-size: 0.6rem; color: var(--dim); } | |
| .stat-box .s-val { font-size: 0.8rem; color: var(--accent); display: block; margin-top: 2px; } | |
| .color-row { | |
| display: flex; | |
| gap: 6px; | |
| flex-wrap: wrap; | |
| } | |
| .color-swatch { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| border: 2px solid transparent; | |
| transition: border 0.1s; | |
| } | |
| .color-swatch.selected { border-color: white; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>⬡ PHYSICS CONSTRUCT</h1> | |
| <div class="mode-tabs"> | |
| <button class="tab active" onclick="switchScene('balls')">BALL DROP</button> | |
| <button class="tab" onclick="switchScene('flight')">FLIGHT SIM</button> | |
| <button class="tab" onclick="switchScene('cloth')">CLOTH</button> | |
| <button class="tab" onclick="switchScene('waves')">WAVE TANK</button> | |
| <button class="tab" onclick="switchScene('orbital')">ORBITAL</button> | |
| </div> | |
| </header> | |
| <div class="workspace"> | |
| <div class="sidebar" id="sidebar"></div> | |
| <div class="canvas-area"> | |
| <canvas id="canvas"></canvas> | |
| <div class="telemetry" id="telemetry"></div> | |
| <div class="overlay-msg" id="overlay"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const sidebar = document.getElementById('sidebar'); | |
| const telemetryEl = document.getElementById('telemetry'); | |
| const overlayEl = document.getElementById('overlay'); | |
| let W, H, raf, currentScene = 'balls'; | |
| function resize() { | |
| const area = canvas.parentElement; | |
| W = canvas.width = area.clientWidth; | |
| H = canvas.height = area.clientHeight; | |
| if (scenes[currentScene] && scenes[currentScene].onResize) scenes[currentScene].onResize(); | |
| } | |
| window.addEventListener('resize', resize); | |
| function switchScene(name) { | |
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | |
| event.target.classList.add('active'); | |
| if (scenes[currentScene] && scenes[currentScene].destroy) scenes[currentScene].destroy(); | |
| cancelAnimationFrame(raf); | |
| currentScene = name; | |
| scenes[name].init(); | |
| loop(); | |
| } | |
| function loop() { | |
| ctx.clearRect(0, 0, W, H); | |
| scenes[currentScene].update(); | |
| scenes[currentScene].draw(); | |
| raf = requestAnimationFrame(loop); | |
| } | |
| // ─── UTILS ──────────────────────────────────────────────────────────────────── | |
| function lerp(a, b, t) { return a + (b - a) * t; } | |
| function clamp(v, mn, mx) { return Math.max(mn, Math.min(mx, v)); } | |
| function rnd(a, b) { return a + Math.random() * (b - a); } | |
| function dist(x1,y1,x2,y2) { return Math.sqrt((x2-x1)**2+(y2-y1)**2); } | |
| function drawGrid() { | |
| ctx.strokeStyle = 'rgba(26,37,53,0.6)'; | |
| ctx.lineWidth = 1; | |
| const step = 40; | |
| for (let x = 0; x < W; x += step) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); } | |
| for (let y = 0; y < H; y += step) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); } | |
| } | |
| function makeTelemetry(rows) { | |
| telemetryEl.innerHTML = rows.map(r => | |
| '<div class="telemetry-row"><span class="telemetry-label">' + r[0] + '</span><span class="telemetry-val">' + r[1] + '</span></div>' | |
| ).join(''); | |
| } | |
| function buildSidebar(html) { sidebar.innerHTML = html; } | |
| function makeSlider(id, label, min, max, val, step, unit, onChange) { | |
| return '<div class="control-row"><span>' + label + '</span><span class="val" id="' + id + 'Val">' + val + (unit||'') + '</span></div>' + | |
| '<input type="range" id="' + id + '" min="' + min + '" max="' + max + '" value="' + val + '" step="' + step + '" oninput="document.getElementById(\'' + id + 'Val\').textContent=this.value+\'' + (unit||'') + '\';(' + onChange.toString() + ')(+this.value)">'; | |
| } | |
| // ─── SCENE: BALL DROP ───────────────────────────────────────────────────────── | |
| const ballScene = (() => { | |
| let balls = [], params = { gravity: 0.4, bounce: 0.72, friction: 0.995, radius: 18, trail: true, wind: 0 }; | |
| let mouseDown = false, mx = 0, my = 0; | |
| let spawnColor = '#00ffc3'; | |
| const colors = ['#00ffc3','#ff6b35','#7c3aed','#f59e0b','#ec4899','#3b82f6']; | |
| function addBall(x, y, vx, vy) { | |
| balls.push({ x, y, vx: vx||rnd(-3,3), vy: vy||rnd(-2,1), r: params.radius, trail: [], color: spawnColor, mass: params.radius*0.5 }); | |
| } | |
| function onMouseDown(e) { mouseDown = true; updateMouse(e); } | |
| function onMouseMove(e) { updateMouse(e); if (mouseDown && balls.length < 120) addBall(mx, my, rnd(-2,2), rnd(-2,1)); } | |
| function onMouseUp() { mouseDown = false; } | |
| function updateMouse(e) { const r = canvas.getBoundingClientRect(); mx = e.clientX - r.left; my = e.clientY - r.top; } | |
| function onClick(e) { updateMouse(e); addBall(mx, my); } | |
| return { | |
| init() { | |
| balls = []; | |
| for (let i = 0; i < 12; i++) addBall(rnd(80, W-80), rnd(80, 200)); | |
| canvas.addEventListener('mousedown', onMouseDown); | |
| canvas.addEventListener('mousemove', onMouseMove); | |
| canvas.addEventListener('mouseup', onMouseUp); | |
| canvas.addEventListener('click', onClick); | |
| buildSidebar(` | |
| <div class="sidebar-section"><label>Spawn</label> | |
| <div class="btn primary" onclick="ballScene_spawn()">Drop Ball</div> | |
| <div class="btn" onclick="ballScene_burst()">Burst (10)</div> | |
| <div class="btn danger" onclick="ballScene_clear()">Clear All</div> | |
| </div> | |
| <div class="sidebar-section"><label>Ball Color</label> | |
| <div class="color-row"> | |
| ${colors.map((c,i) => '<div class="color-swatch' + (i===0?' selected':'') + '" style="background:' + c + '" onclick="ballScene_color(\'' + c + '\',this)"></div>').join('')} | |
| </div> | |
| </div> | |
| <div class="sidebar-section"><label>Physics</label> | |
| ${makeSlider('bs_grav','Gravity',0,1.5,0.4,0.01,'', v=>{ params.gravity=v; })} | |
| ${makeSlider('bs_bounce','Bounce',0,1,0.72,0.01,'', v=>{ params.bounce=v; })} | |
| ${makeSlider('bs_friction','Air Drag',0.97,1,0.995,0.001,'', v=>{ params.friction=v; })} | |
| ${makeSlider('bs_radius','Radius',5,40,18,1,'px', v=>{ params.radius=v; })} | |
| ${makeSlider('bs_wind','Wind',-3,3,0,0.1,'', v=>{ params.wind=v; })} | |
| </div> | |
| <div class="sidebar-section"><label>Options</label> | |
| <div class="checkbox-row"><input type="checkbox" id="bs_trail" checked onchange="params_trail(this.checked)"><label for="bs_trail">Trail FX</label></div> | |
| <div class="checkbox-row"><input type="checkbox" id="bs_collide" checked><label for="bs_collide">Ball Collisions</label></div> | |
| </div> | |
| <div class="sidebar-section"><label>Stats</label> | |
| <div class="stat-grid"> | |
| <div class="stat-box"><span class="s-label">BALLS</span><span class="s-val" id="bs_count">0</span></div> | |
| <div class="stat-box"><span class="s-label">MAX VEL</span><span class="s-val" id="bs_vel">0</span></div> | |
| </div> | |
| </div> | |
| `); | |
| window.ballScene_spawn = () => addBall(W/2, 60); | |
| window.ballScene_burst = () => { for(let i=0;i<10;i++) addBall(rnd(W*0.2,W*0.8), rnd(50,150)); }; | |
| window.ballScene_clear = () => { balls = []; }; | |
| window.ballScene_color = (c, el) => { spawnColor = c; document.querySelectorAll('.color-swatch').forEach(s=>s.classList.remove('selected')); el.classList.add('selected'); }; | |
| window.params_trail = (v) => { params.trail = v; }; | |
| overlayEl.textContent = 'CLICK OR DRAG TO SPAWN BALLS'; | |
| }, | |
| destroy() { | |
| canvas.removeEventListener('mousedown', onMouseDown); | |
| canvas.removeEventListener('mousemove', onMouseMove); | |
| canvas.removeEventListener('mouseup', onMouseUp); | |
| canvas.removeEventListener('click', onClick); | |
| }, | |
| onResize() {}, | |
| update() { | |
| const doCollide = document.getElementById('bs_collide') && document.getElementById('bs_collide').checked; | |
| balls.forEach(b => { | |
| b.vx += params.wind * 0.05; | |
| b.vy += params.gravity; | |
| b.vx *= params.friction; | |
| b.vy *= params.friction; | |
| b.x += b.vx; | |
| b.y += b.vy; | |
| if (params.trail) { b.trail.push({x:b.x,y:b.y}); if(b.trail.length>18) b.trail.shift(); } | |
| // walls | |
| if (b.x - b.r < 0) { b.x = b.r; b.vx *= -params.bounce; } | |
| if (b.x + b.r > W) { b.x = W - b.r; b.vx *= -params.bounce; } | |
| if (b.y - b.r < 0) { b.y = b.r; b.vy *= -params.bounce; } | |
| if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy) * params.bounce; if(Math.abs(b.vy)<0.5) b.vy=0; } | |
| }); | |
| if (doCollide) { | |
| for (let i = 0; i < balls.length; i++) { | |
| for (let j = i+1; j < balls.length; j++) { | |
| const a = balls[i], b = balls[j]; | |
| const dx = b.x - a.x, dy = b.y - a.y; | |
| const d = Math.sqrt(dx*dx+dy*dy); | |
| const minD = a.r + b.r; | |
| if (d < minD && d > 0) { | |
| const nx = dx/d, ny = dy/d; | |
| const overlap = (minD - d) / 2; | |
| a.x -= nx * overlap; a.y -= ny * overlap; | |
| b.x += nx * overlap; b.y += ny * overlap; | |
| const dvx = a.vx - b.vx, dvy = a.vy - b.vy; | |
| const dot = dvx*nx + dvy*ny; | |
| const imp = dot * params.bounce; | |
| a.vx -= imp*nx; a.vy -= imp*ny; | |
| b.vx += imp*nx; b.vy += imp*ny; | |
| } | |
| } | |
| } | |
| } | |
| // telemetry | |
| const maxV = balls.reduce((m,b)=>Math.max(m,Math.hypot(b.vx,b.vy)),0); | |
| document.getElementById('bs_count') && (document.getElementById('bs_count').textContent = balls.length); | |
| document.getElementById('bs_vel') && (document.getElementById('bs_vel').textContent = maxV.toFixed(1)); | |
| makeTelemetry([ | |
| ['BALLS', balls.length], | |
| ['GRAVITY', params.gravity.toFixed(2)], | |
| ['BOUNCE', params.bounce.toFixed(2)], | |
| ['WIND', params.wind.toFixed(1)], | |
| ['MAX VEL', maxV.toFixed(2)] | |
| ]); | |
| }, | |
| draw() { | |
| drawGrid(); | |
| // floor glow | |
| const fg = ctx.createLinearGradient(0,H-4,0,H); | |
| fg.addColorStop(0,'rgba(0,255,195,0.15)'); | |
| fg.addColorStop(1,'rgba(0,255,195,0)'); | |
| ctx.fillStyle = fg; | |
| ctx.fillRect(0,H-4,W,4); | |
| balls.forEach(b => { | |
| // trail | |
| if (params.trail && b.trail.length > 1) { | |
| for (let i = 1; i < b.trail.length; i++) { | |
| const alpha = i / b.trail.length * 0.5; | |
| ctx.strokeStyle = b.color.replace(')', ',' + alpha + ')').replace('rgb','rgba').replace('#',''); | |
| ctx.lineWidth = b.r * 0.5 * (i/b.trail.length); | |
| ctx.beginPath(); | |
| ctx.moveTo(b.trail[i-1].x, b.trail[i-1].y); | |
| ctx.lineTo(b.trail[i].x, b.trail[i].y); | |
| ctx.strokeStyle = b.color + Math.round(alpha*255).toString(16).padStart(2,'0'); | |
| ctx.stroke(); | |
| } | |
| } | |
| // glow | |
| const g = ctx.createRadialGradient(b.x-b.r*0.3,b.y-b.r*0.3,b.r*0.1,b.x,b.y,b.r*1.4); | |
| g.addColorStop(0, b.color + 'cc'); | |
| g.addColorStop(0.5, b.color + '66'); | |
| g.addColorStop(1, 'transparent'); | |
| ctx.fillStyle = g; | |
| ctx.beginPath(); ctx.arc(b.x,b.y,b.r*1.4,0,Math.PI*2); ctx.fill(); | |
| // ball | |
| const grad = ctx.createRadialGradient(b.x-b.r*0.3,b.y-b.r*0.3,1,b.x,b.y,b.r); | |
| grad.addColorStop(0,'rgba(255,255,255,0.4)'); | |
| grad.addColorStop(0.5, b.color + 'cc'); | |
| grad.addColorStop(1, b.color + '88'); | |
| ctx.fillStyle = grad; | |
| ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill(); | |
| }); | |
| } | |
| }; | |
| })(); | |
| // ─── SCENE: FLIGHT SIM ──────────────────────────────────────────────────────── | |
| const flightScene = (() => { | |
| let plane, keys = {}, particles = [], clouds = [], t = 0; | |
| let params = { thrust: 0.15, lift: 0.004, drag: 0.018, gravity: 0.06, turbulence: 0 }; | |
| let terrain = [], crashed = false; | |
| function genTerrain() { | |
| terrain = []; | |
| let y = H * 0.78; | |
| for (let x = 0; x <= W; x += 8) { | |
| y += rnd(-4, 4); | |
| y = clamp(y, H*0.55, H*0.9); | |
| terrain.push({x, y}); | |
| } | |
| } | |
| function genClouds() { | |
| clouds = []; | |
| for (let i = 0; i < 8; i++) clouds.push({ x: rnd(0,W), y: rnd(60,H*0.4), w: rnd(80,160), h: rnd(30,55), speed: rnd(0.2,0.6) }); | |
| } | |
| function resetPlane() { | |
| plane = { x: 120, y: H*0.35, vx: 2, vy: 0, angle: 0, thrust: 0, throttle: 0.5, flap: 0 }; | |
| particles = []; | |
| crashed = false; | |
| } | |
| function onKey(e, down) { | |
| keys[e.key] = down; | |
| if (e.key === 'r' || e.key === 'R') resetPlane(); | |
| } | |
| return { | |
| init() { | |
| genTerrain(); | |
| genClouds(); | |
| resetPlane(); | |
| window.addEventListener('keydown', e => onKey(e, true)); | |
| window.addEventListener('keyup', e => onKey(e, false)); | |
| buildSidebar(` | |
| <div class="sidebar-section"><label>Controls</label> | |
| <div style="font-size:0.68rem;color:var(--dim);line-height:2"> | |
| <div>▲ / W — Pitch Up</div> | |
| <div>▼ / S — Pitch Down</div> | |
| <div>► / D — Throttle Up</div> | |
| <div>◄ / A — Throttle Down</div> | |
| <div>R — Reset</div> | |
| </div> | |
| </div> | |
| <div class="sidebar-section"><label>Environment</label> | |
| ${makeSlider('fl_grav','Gravity',0,0.2,0.06,0.01,'', v=>{ params.gravity=v; })} | |
| ${makeSlider('fl_turb','Turbulence',0,2,0,0.1,'', v=>{ params.turbulence=v; })} | |
| ${makeSlider('fl_drag','Air Drag',0.005,0.05,0.018,0.001,'', v=>{ params.drag=v; })} | |
| </div> | |
| <div class="sidebar-section"><label>Plane</label> | |
| ${makeSlider('fl_thrust','Max Thrust',0.05,0.4,0.15,0.01,'', v=>{ params.thrust=v; })} | |
| ${makeSlider('fl_lift','Lift Factor',0.001,0.01,0.004,0.0005,'', v=>{ params.lift=v; })} | |
| </div> | |
| <div class="sidebar-section"><label>Actions</label> | |
| <div class="btn primary" onclick="resetPlane()">Reset Plane (R)</div> | |
| <div class="btn" onclick="flightScene_newTerrain()">New Terrain</div> | |
| </div> | |
| `); | |
| window.flightScene_newTerrain = () => genTerrain(); | |
| overlayEl.textContent = 'ARROW KEYS TO FLY · R TO RESET'; | |
| }, | |
| destroy() { | |
| window.removeEventListener('keydown', e => onKey(e, true)); | |
| window.removeEventListener('keyup', e => onKey(e, false)); | |
| keys = {}; | |
| }, | |
| onResize() { genTerrain(); }, | |
| update() { | |
| if (crashed) return; | |
| t++; | |
| const p = plane; | |
| // throttle | |
| if (keys['ArrowRight'] || keys['d'] || keys['D']) p.throttle = clamp(p.throttle + 0.01, 0, 1); | |
| if (keys['ArrowLeft'] || keys['a'] || keys['A']) p.throttle = clamp(p.throttle - 0.01, 0, 1); | |
| // pitch | |
| if (keys['ArrowUp'] || keys['w'] || keys['W']) p.angle -= 0.035; | |
| if (keys['ArrowDown'] || keys['s'] || keys['S']) p.angle += 0.035; | |
| p.angle = clamp(p.angle, -1.1, 1.1); | |
| p.angle *= 0.96; | |
| // physics | |
| const thrust = p.throttle * params.thrust; | |
| p.vx += Math.cos(p.angle) * thrust; | |
| p.vy += Math.sin(p.angle) * thrust; | |
| p.vy += params.gravity; | |
| // lift (based on speed and angle of attack) | |
| const speed = Math.hypot(p.vx, p.vy); | |
| const lift = speed * params.lift * (1 - Math.abs(p.angle) * 0.5); | |
| p.vy -= lift; | |
| // drag | |
| p.vx -= p.vx * params.drag * speed * 0.3; | |
| p.vy -= p.vy * params.drag * speed * 0.3; | |
| // turbulence | |
| if (params.turbulence > 0) { p.vx += rnd(-1,1)*params.turbulence*0.03; p.vy += rnd(-1,1)*params.turbulence*0.03; } | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| // boundary | |
| if (p.x < 0) p.x = 0; | |
| if (p.x > W) p.x = W; | |
| if (p.y < 20) { p.y = 20; p.vy = Math.abs(p.vy) * 0.3; } | |
| // exhaust particles | |
| if (p.throttle > 0.1) { | |
| for (let i = 0; i < Math.ceil(p.throttle*3); i++) { | |
| particles.push({ | |
| x: p.x - Math.cos(p.angle)*30, | |
| y: p.y - Math.sin(p.angle)*30, | |
| vx: -Math.cos(p.angle)*rnd(2,5) + rnd(-1,1), | |
| vy: -Math.sin(p.angle)*rnd(2,5) + rnd(-1,1), | |
| life: 1, maxLife: rnd(20,40), | |
| r: rnd(2,5), | |
| color: p.throttle > 0.7 ? '#ff6b35' : '#f59e0b' | |
| }); | |
| } | |
| } | |
| particles.forEach(p => { p.x+=p.vx; p.y+=p.vy; p.life-=1/p.maxLife; p.vx*=0.92; p.vy*=0.92; }); | |
| particles = particles.filter(p => p.life > 0); | |
| // clouds drift | |
| clouds.forEach(c => { c.x += c.speed; if (c.x > W+200) c.x = -200; }); | |
| // terrain collision | |
| for (let i = 0; i < terrain.length-1; i++) { | |
| const tx = terrain[i].x, ty = terrain[i].y; | |
| if (p.x >= tx && p.x < terrain[i+1].x) { | |
| if (p.y >= ty - 10) { crashed = true; break; } | |
| } | |
| } | |
| const speed2 = Math.hypot(p.vx, p.vy); | |
| makeTelemetry([ | |
| ['THROTTLE', Math.round(p.throttle*100) + '%'], | |
| ['SPEED', speed2.toFixed(2) + ' kts'], | |
| ['ALTITUDE', Math.round(H - p.y) + ' ft'], | |
| ['PITCH', (p.angle * 57.3).toFixed(1) + '°'], | |
| ['VX', p.vx.toFixed(2)], | |
| ['VY', p.vy.toFixed(2)], | |
| ['LIFT', (speed2 * params.lift).toFixed(4)] | |
| ]); | |
| }, | |
| draw() { | |
| // sky gradient | |
| const sky = ctx.createLinearGradient(0,0,0,H); | |
| sky.addColorStop(0,'#050b18'); | |
| sky.addColorStop(0.6,'#0d1f3c'); | |
| sky.addColorStop(1,'#1a2a1a'); | |
| ctx.fillStyle = sky; | |
| ctx.fillRect(0,0,W,H); | |
| // stars | |
| ctx.fillStyle = 'rgba(255,255,255,0.4)'; | |
| for (let i = 0; i < 80; i++) { | |
| const sx = ((i*137+50) % W), sy = ((i*97+30) % (H*0.5)); | |
| const blink = 0.3 + 0.3*Math.sin(t*0.03+i); | |
| ctx.globalAlpha = blink; | |
| ctx.fillRect(sx,sy,1,1); | |
| } | |
| ctx.globalAlpha = 1; | |
| // clouds | |
| clouds.forEach(c => { | |
| ctx.fillStyle = 'rgba(200,220,255,0.1)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(c.x, c.y, c.w, c.h*0.5, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.fillStyle = 'rgba(200,220,255,0.06)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(c.x+c.w*0.3, c.y-c.h*0.2, c.w*0.6, c.h*0.4, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| }); | |
| // terrain | |
| if (terrain.length) { | |
| ctx.beginPath(); | |
| ctx.moveTo(terrain[0].x, H); | |
| terrain.forEach(p => ctx.lineTo(p.x, p.y)); | |
| ctx.lineTo(terrain[terrain.length-1].x, H); | |
| ctx.closePath(); | |
| const tg = ctx.createLinearGradient(0,H*0.5,0,H); | |
| tg.addColorStop(0,'#1a3a1a'); | |
| tg.addColorStop(1,'#0a1a0a'); | |
| ctx.fillStyle = tg; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#2a5a2a'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| terrain.forEach((p,i) => i===0 ? ctx.moveTo(p.x,p.y) : ctx.lineTo(p.x,p.y)); | |
| ctx.stroke(); | |
| } | |
| // exhaust particles | |
| particles.forEach(p => { | |
| ctx.globalAlpha = p.life * 0.7; | |
| ctx.fillStyle = p.color; | |
| ctx.beginPath(); ctx.arc(p.x, p.y, p.r*p.life, 0, Math.PI*2); ctx.fill(); | |
| }); | |
| ctx.globalAlpha = 1; | |
| // plane | |
| if (!crashed) { | |
| ctx.save(); | |
| ctx.translate(plane.x, plane.y); | |
| ctx.rotate(plane.angle); | |
| // engine glow | |
| const throttleGlow = plane.throttle; | |
| if (throttleGlow > 0.05) { | |
| const eg = ctx.createRadialGradient(-28,0,1,-28,0,20*throttleGlow); | |
| eg.addColorStop(0,'rgba(255,150,50,0.8)'); | |
| eg.addColorStop(1,'transparent'); | |
| ctx.fillStyle = eg; | |
| ctx.beginPath(); ctx.arc(-28,0,20*throttleGlow,0,Math.PI*2); ctx.fill(); | |
| } | |
| // body | |
| ctx.fillStyle = '#e0e8f0'; | |
| ctx.beginPath(); | |
| ctx.ellipse(0, 0, 28, 6, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| // cockpit | |
| ctx.fillStyle = '#00bfff'; | |
| ctx.beginPath(); | |
| ctx.ellipse(12, -2, 9, 5, 0.2, 0, Math.PI*2); | |
| ctx.fill(); | |
| // wings | |
| ctx.fillStyle = '#c0ccd8'; | |
| ctx.beginPath(); | |
| ctx.moveTo(0,-6); ctx.lineTo(-10,-24); ctx.lineTo(-18,-24); ctx.lineTo(-12,-6); | |
| ctx.closePath(); ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.moveTo(0,6); ctx.lineTo(-10,24); ctx.lineTo(-18,24); ctx.lineTo(-12,6); | |
| ctx.closePath(); ctx.fill(); | |
| // tail | |
| ctx.fillStyle = '#a0b0c0'; | |
| ctx.beginPath(); | |
| ctx.moveTo(-22,-5); ctx.lineTo(-28,-14); ctx.lineTo(-24,-14); ctx.lineTo(-20,-5); | |
| ctx.closePath(); ctx.fill(); | |
| ctx.restore(); | |
| // speed vector | |
| ctx.strokeStyle = 'rgba(0,255,195,0.3)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(plane.x, plane.y); | |
| ctx.lineTo(plane.x + plane.vx*8, plane.y + plane.vy*8); | |
| ctx.stroke(); | |
| } else { | |
| // crash effect | |
| ctx.fillStyle = 'rgba(255,80,0,0.15)'; | |
| ctx.beginPath(); ctx.arc(plane.x, plane.y, 60, 0, Math.PI*2); ctx.fill(); | |
| ctx.fillStyle = '#ff4444'; | |
| ctx.font = 'bold 32px Orbitron, monospace'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('CRASHED', W/2, H/2 - 20); | |
| ctx.fillStyle = 'var(--dim)'; | |
| ctx.font = '14px Share Tech Mono, monospace'; | |
| ctx.fillText('Press R to reset', W/2, H/2 + 20); | |
| ctx.textAlign = 'left'; | |
| } | |
| // throttle bar | |
| ctx.fillStyle = 'rgba(13,17,23,0.7)'; | |
| ctx.fillRect(14, H-60, 14, 45); | |
| ctx.fillStyle = '#00ffc3'; | |
| ctx.fillRect(14, H-60 + (1-plane.throttle)*45, 14, plane.throttle*45); | |
| ctx.fillStyle = 'rgba(255,255,255,0.3)'; | |
| ctx.font = '9px Share Tech Mono'; ctx.textAlign='center'; | |
| ctx.fillText('THR', 21, H-65); | |
| ctx.textAlign='left'; | |
| } | |
| }; | |
| })(); | |
| // ─── SCENE: CLOTH ───────────────────────────────────────────────────────────── | |
| const clothScene = (() => { | |
| let points = [], constraints = [], params = { gravity: 0.5, stiffness: 3, wind: 0, tear: 30 }; | |
| const cols = 22, rows = 16; | |
| let dragging = null, mx=0, my=0; | |
| function build() { | |
| points = []; constraints = []; | |
| const sx = (W - 300) / 2, ex = sx + 300; | |
| const sy = 50; | |
| const gapX = (ex-sx)/(cols-1), gapY = 22; | |
| for (let r = 0; r < rows; r++) { | |
| for (let c = 0; c < cols; c++) { | |
| points.push({ x: sx+c*gapX, y: sy+r*gapY, px: sx+c*gapX, py: sy+r*gapY, pinned: r===0 && c%3===0, ox: sx+c*gapX, oy: sy+r*gapY }); | |
| } | |
| } | |
| for (let r = 0; r < rows; r++) { | |
| for (let c = 0; c < cols; c++) { | |
| if (c < cols-1) constraints.push({ a: r*cols+c, b: r*cols+c+1, rest: gapX }); | |
| if (r < rows-1) constraints.push({ a: r*cols+c, b: (r+1)*cols+c, rest: gapY }); | |
| } | |
| } | |
| } | |
| function onMouseMove(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| mx = e.clientX - rect.left; my = e.clientY - rect.top; | |
| if (dragging) { dragging.x = mx; dragging.y = my; dragging.px = mx; dragging.py = my; } | |
| } | |
| function onMouseDown(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| mx = e.clientX - rect.left; my = e.clientY - rect.top; | |
| let best = null, bd = 999; | |
| points.forEach(p => { const d = dist(mx,my,p.x,p.y); if(d<bd){ bd=d; best=p; } }); | |
| if (bd < 20) dragging = best; | |
| } | |
| function onMouseUp() { dragging = null; } | |
| return { | |
| init() { | |
| build(); | |
| canvas.addEventListener('mousemove', onMouseMove); | |
| canvas.addEventListener('mousedown', onMouseDown); | |
| canvas.addEventListener('mouseup', onMouseUp); | |
| buildSidebar(` | |
| <div class="sidebar-section"><label>Cloth</label> | |
| ${makeSlider('cl_grav','Gravity',0,2,0.5,0.05,'', v=>{ params.gravity=v; })} | |
| ${makeSlider('cl_stiff','Stiffness',1,8,3,0.5,'', v=>{ params.stiffness=v; })} | |
| ${makeSlider('cl_wind','Wind',-5,5,0,0.1,'', v=>{ params.wind=v; })} | |
| ${makeSlider('cl_tear','Tear Dist',10,80,30,1,'', v=>{ params.tear=v; })} | |
| </div> | |
| <div class="sidebar-section"><label>Actions</label> | |
| <div class="btn primary" onclick="clothScene_rebuild()">Rebuild Cloth</div> | |
| <div class="btn danger" onclick="clothScene_cut()">Cut All Pins</div> | |
| </div> | |
| <div class="sidebar-section"><label>Info</label> | |
| <div style="font-size:0.68rem;color:var(--dim);line-height:1.8"> | |
| Drag cloth to move it.<br> | |
| Increase Tear Dist to rip. | |
| </div> | |
| </div> | |
| `); | |
| window.clothScene_rebuild = build; | |
| window.clothScene_cut = () => points.forEach(p => p.pinned = false); | |
| overlayEl.textContent = 'CLICK AND DRAG THE CLOTH'; | |
| }, | |
| destroy() { | |
| canvas.removeEventListener('mousemove', onMouseMove); | |
| canvas.removeEventListener('mousedown', onMouseDown); | |
| canvas.removeEventListener('mouseup', onMouseUp); | |
| }, | |
| onResize() { build(); }, | |
| update() { | |
| points.forEach(p => { | |
| if (p.pinned) return; | |
| const vx = (p.x - p.px) * 0.98 + params.wind * 0.04; | |
| const vy = (p.y - p.py) * 0.98; | |
| p.px = p.x; p.py = p.y; | |
| p.x += vx; | |
| p.y += vy + params.gravity; | |
| if (p.y > H - 5) { p.y = H-5; p.py = p.y + vy*0.3; } | |
| if (p.x < 0) p.x = 0; | |
| if (p.x > W) p.x = W; | |
| }); | |
| for (let iter = 0; iter < params.stiffness; iter++) { | |
| constraints = constraints.filter(c => { | |
| const a = points[c.a], b = points[c.b]; | |
| const dx = b.x-a.x, dy = b.y-a.y; | |
| const d = Math.sqrt(dx*dx+dy*dy); | |
| if (d > params.tear * 1.5) return false; | |
| const diff = (d - c.rest) / d * 0.5; | |
| if (!a.pinned) { a.x += dx*diff; a.y += dy*diff; } | |
| if (!b.pinned) { b.x -= dx*diff; b.y -= dy*diff; } | |
| return true; | |
| }); | |
| } | |
| makeTelemetry([ | |
| ['POINTS', points.length], | |
| ['CONSTRAINTS', constraints.length], | |
| ['WIND', params.wind.toFixed(1)], | |
| ['GRAVITY', params.gravity.toFixed(2)] | |
| ]); | |
| }, | |
| draw() { | |
| drawGrid(); | |
| // cloth | |
| constraints.forEach(c => { | |
| const a = points[c.a], b = points[c.b]; | |
| const d = dist(a.x,a.y,b.x,b.y); | |
| const stress = clamp(d / c.rest - 1, 0, 1); | |
| const r = Math.round(lerp(0, 255, stress)); | |
| const g = Math.round(lerp(255, 100, stress)); | |
| ctx.strokeStyle = 'rgb(' + r + ',' + g + ',180)'; | |
| ctx.lineWidth = 1.5; | |
| ctx.globalAlpha = 0.85; | |
| ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); | |
| }); | |
| ctx.globalAlpha = 1; | |
| points.forEach(p => { | |
| if (p.pinned) { | |
| ctx.fillStyle = '#00ffc3'; | |
| ctx.beginPath(); ctx.arc(p.x,p.y,4,0,Math.PI*2); ctx.fill(); | |
| } | |
| }); | |
| } | |
| }; | |
| })(); | |
| // ─── SCENE: WAVE TANK ───────────────────────────────────────────────────────── | |
| const waveScene = (() => { | |
| let sources = [], t = 0, params = { amplitude: 40, freq: 0.04, speed: 1, damping: 0.002, interference: true }; | |
| return { | |
| init() { | |
| sources = [{ x: W*0.3, y: H*0.5, phase: 0, freq: 0.04, amp: 40 }, { x: W*0.7, y: H*0.5, phase: 0, freq: 0.04, amp: 40 }]; | |
| buildSidebar(` | |
| <div class="sidebar-section"><label>Wave</label> | |
| ${makeSlider('wv_amp','Amplitude',5,80,40,1,'', v=>{ params.amplitude=v; sources.forEach(s=>s.amp=v); })} | |
| ${makeSlider('wv_freq','Frequency',0.01,0.12,0.04,0.005,'', v=>{ params.freq=v; sources.forEach(s=>s.freq=v); })} | |
| ${makeSlider('wv_speed','Speed',0.3,3,1,0.1,'', v=>{ params.speed=v; })} | |
| ${makeSlider('wv_damp','Damping',0,0.01,0.002,0.0005,'', v=>{ params.damping=v; })} | |
| </div> | |
| <div class="sidebar-section"><label>Actions</label> | |
| <div class="btn primary" onclick="waveScene_add()">Add Source</div> | |
| <div class="btn danger" onclick="waveScene_clear()">Clear Sources</div> | |
| </div> | |
| <div class="sidebar-section"><label>Info</label> | |
| <div style="font-size:0.68rem;color:var(--dim);line-height:1.8"> | |
| Click canvas to add sources.<br>Observe interference patterns. | |
| </div> | |
| </div> | |
| `); | |
| window.waveScene_add = () => sources.push({ x: rnd(W*0.1,W*0.9), y: rnd(H*0.2,H*0.8), phase: rnd(0,Math.PI*2), freq: params.freq, amp: params.amplitude }); | |
| window.waveScene_clear = () => { sources = []; }; | |
| canvas.addEventListener('click', waveScene_click); | |
| function waveScene_click(e) { | |
| const r = canvas.getBoundingClientRect(); | |
| sources.push({ x: e.clientX-r.left, y: e.clientY-r.top, phase: rnd(0,Math.PI*2), freq: params.freq, amp: params.amplitude }); | |
| } | |
| overlayEl.textContent = 'CLICK TO ADD WAVE SOURCES'; | |
| }, | |
| destroy() { canvas.removeEventListener('click', ()=>{}); }, | |
| onResize() {}, | |
| update() { | |
| t += params.speed * 0.5; | |
| makeTelemetry([ | |
| ['SOURCES', sources.length], | |
| ['AMPLITUDE', params.amplitude], | |
| ['FREQUENCY', params.freq.toFixed(3)], | |
| ['SPEED', params.speed.toFixed(1)] | |
| ]); | |
| }, | |
| draw() { | |
| ctx.fillStyle = '#030810'; | |
| ctx.fillRect(0,0,W,H); | |
| const step = 5; | |
| const img = ctx.createImageData(W, H); | |
| const data = img.data; | |
| for (let y = 0; y < H; y += step) { | |
| for (let x = 0; x < W; x += step) { | |
| let val = 0; | |
| sources.forEach(s => { | |
| const d = dist(x,y,s.x,s.y); | |
| const decay = Math.exp(-d * params.damping); | |
| val += Math.sin(d * s.freq - t * 0.08 + s.phase) * s.amp * decay; | |
| }); | |
| val = clamp(val / Math.max(sources.length, 1), -params.amplitude, params.amplitude); | |
| const norm = (val + params.amplitude) / (params.amplitude * 2); | |
| const ri = Math.round(lerp(5, 0, norm)), gi = Math.round(lerp(30, 255, norm)), bi = Math.round(lerp(80, 195, norm)); | |
| for (let dy = 0; dy < step && y+dy < H; dy++) { | |
| for (let dx = 0; dx < step && x+dx < W; dx++) { | |
| const idx = ((y+dy)*W+(x+dx))*4; | |
| data[idx] = ri; data[idx+1] = gi; data[idx+2] = bi; data[idx+3] = 255; | |
| } | |
| } | |
| } | |
| } | |
| ctx.putImageData(img, 0, 0); | |
| // draw sources | |
| sources.forEach((s,i) => { | |
| ctx.strokeStyle = '#fff'; | |
| ctx.lineWidth = 1.5; | |
| for (let r = 10; r < 60; r += 15) { | |
| ctx.globalAlpha = 0.3 * (1 - r/60); | |
| ctx.beginPath(); ctx.arc(s.x,s.y,r,0,Math.PI*2); ctx.stroke(); | |
| } | |
| ctx.globalAlpha = 1; | |
| ctx.fillStyle = '#00ffc3'; | |
| ctx.beginPath(); ctx.arc(s.x,s.y,5,0,Math.PI*2); ctx.fill(); | |
| }); | |
| } | |
| }; | |
| })(); | |
| // ─── SCENE: ORBITAL ─────────────────────────────────────────────────────────── | |
| const orbitalScene = (() => { | |
| let bodies = [], t = 0, params = { G: 0.5, trails: true, trailLen: 200 }; | |
| const COLORS = ['#f59e0b','#3b82f6','#ec4899','#10b981','#ef4444','#a855f7']; | |
| function addBody(x, y, vx, vy, mass, color) { | |
| bodies.push({ x, y, vx, vy, mass, color: color||COLORS[bodies.length%COLORS.length], trail: [], r: Math.pow(mass, 0.35)*2.5 }); | |
| } | |
| function preset(name) { | |
| bodies = []; | |
| if (name === 'solar') { | |
| addBody(W/2, H/2, 0, 0, 200, '#f59e0b'); | |
| addBody(W/2+120, H/2, 0, 2.0, 5, '#3b82f6'); | |
| addBody(W/2+200, H/2, 0, 1.5, 8, '#ec4899'); | |
| addBody(W/2-280, H/2, 0, -1.3, 12, '#10b981'); | |
| } else if (name === 'binary') { | |
| addBody(W/2-80, H/2, 0, 1.4, 80, '#f59e0b'); | |
| addBody(W/2+80, H/2, 0, -1.4, 80, '#3b82f6'); | |
| addBody(W/2, H/2-180, 1.8, 0, 2, '#ec4899'); | |
| } else if (name === 'chaos') { | |
| for (let i = 0; i < 5; i++) addBody(rnd(W*0.2,W*0.8), rnd(H*0.2,H*0.8), rnd(-1.5,1.5), rnd(-1.5,1.5), rnd(20,100)); | |
| } | |
| } | |
| return { | |
| init() { | |
| preset('solar'); | |
| buildSidebar(` | |
| <div class="sidebar-section"><label>Presets</label> | |
| <div class="btn primary" onclick="preset_solar()">Solar System</div> | |
| <div class="btn" onclick="preset_binary()">Binary Star</div> | |
| <div class="btn" onclick="preset_chaos()">Chaos (5 Bodies)</div> | |
| </div> | |
| <div class="sidebar-section"><label>Physics</label> | |
| ${makeSlider('orb_G','Gravity (G)',0.1,2,0.5,0.05,'', v=>{ params.G=v; })} | |
| ${makeSlider('orb_trail','Trail Length',0,400,200,10,'', v=>{ params.trailLen=v; })} | |
| </div> | |
| <div class="sidebar-section"><label>Options</label> | |
| <div class="checkbox-row"><input type="checkbox" id="orb_trails" checked onchange="params.trails=this.checked"><label for="orb_trails">Trails</label></div> | |
| </div> | |
| <div class="sidebar-section"><label>Actions</label> | |
| <div class="btn" onclick="orb_addRandom()">Add Random Body</div> | |
| <div class="btn danger" onclick="bodies=[]">Clear All</div> | |
| </div> | |
| `); | |
| window.preset_solar = () => preset('solar'); | |
| window.preset_binary = () => preset('binary'); | |
| window.preset_chaos = () => preset('chaos'); | |
| window.orb_addRandom = () => addBody(rnd(W*0.1,W*0.9), rnd(H*0.1,H*0.9), rnd(-1,1), rnd(-1,1), rnd(5,60)); | |
| overlayEl.textContent = 'N-BODY GRAVITATIONAL SIMULATION'; | |
| }, | |
| destroy() {}, | |
| onResize() {}, | |
| update() { | |
| t++; | |
| // gravity | |
| for (let i = 0; i < bodies.length; i++) { | |
| for (let j = i+1; j < bodies.length; j++) { | |
| const a = bodies[i], b = bodies[j]; | |
| const dx = b.x - a.x, dy = b.y - a.y; | |
| const d = Math.sqrt(dx*dx+dy*dy); | |
| if (d < 5) continue; | |
| const f = params.G * a.mass * b.mass / (d*d); | |
| const fx = f*dx/d, fy = f*dy/d; | |
| a.vx += fx/a.mass; a.vy += fy/a.mass; | |
| b.vx -= fx/b.mass; b.vy -= fy/b.mass; | |
| } | |
| } | |
| bodies.forEach(b => { | |
| b.x += b.vx; b.y += b.vy; | |
| if (params.trails) { b.trail.push({x:b.x,y:b.y}); if(b.trail.length>params.trailLen) b.trail.shift(); } | |
| // wraparound | |
| if (b.x < -50) b.x = W+50; | |
| if (b.x > W+50) b.x = -50; | |
| if (b.y < -50) b.y = H+50; | |
| if (b.y > H+50) b.y = -50; | |
| }); | |
| const ke = bodies.reduce((s,b)=>s+0.5*b.mass*(b.vx*b.vx+b.vy*b.vy),0); | |
| makeTelemetry([ | |
| ['BODIES', bodies.length], | |
| ['G', params.G.toFixed(2)], | |
| ['TOT KE', ke.toFixed(1)], | |
| ['TIME', t] | |
| ]); | |
| }, | |
| draw() { | |
| ctx.fillStyle = 'rgba(7,10,15,0.25)'; | |
| ctx.fillRect(0,0,W,H); | |
| bodies.forEach(b => { | |
| if (params.trails && b.trail.length > 1) { | |
| for (let i = 1; i < b.trail.length; i++) { | |
| ctx.strokeStyle = b.color + Math.round((i/b.trail.length)*99).toString(16).padStart(2,'0'); | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(b.trail[i-1].x,b.trail[i-1].y); ctx.lineTo(b.trail[i].x,b.trail[i].y); ctx.stroke(); | |
| } | |
| } | |
| const g = ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,b.r*2.5); | |
| g.addColorStop(0,'white'); | |
| g.addColorStop(0.3,b.color); | |
| g.addColorStop(1,'transparent'); | |
| ctx.fillStyle = g; | |
| ctx.beginPath(); ctx.arc(b.x,b.y,b.r*2.5,0,Math.PI*2); ctx.fill(); | |
| }); | |
| } | |
| }; | |
| })(); | |
| // ─── REGISTER ───────────────────────────────────────────────────────────────── | |
| const scenes = { balls: ballScene, flight: flightScene, cloth: clothScene, waves: waveScene, orbital: orbitalScene }; | |
| resize(); | |
| scenes['balls'].init(); | |
| loop(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment