Skip to content

Instantly share code, notes, and snippets.

@secdev02
Created March 10, 2026 22:18
Show Gist options
  • Select an option

  • Save secdev02/871ed38583e1d2018454f34e2cd1d3f7 to your computer and use it in GitHub Desktop.

Select an option

Save secdev02/871ed38583e1d2018454f34e2cd1d3f7 to your computer and use it in GitHub Desktop.
Free Your Mind - Single Page App - Game and JS Physics
<!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