Upload index.html
Browse files- index.html +424 -19
index.html
CHANGED
|
@@ -1,19 +1,424 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<title>Lava Lamp</title>
|
| 6 |
+
<style>
|
| 7 |
+
html,body{margin:0;height:100%;background:#05030a;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
| 8 |
+
canvas{width:min(480px,92vw);height:auto;aspect-ratio:480/760;border-radius:14px;background:#05030a;touch-action:none}
|
| 9 |
+
</style>
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<canvas id="c"></canvas>
|
| 13 |
+
<script>
|
| 14 |
+
"use strict";
|
| 15 |
+
// polyfill for older browsers (ctx.roundRect) so the lamp never crashes
|
| 16 |
+
if(!CanvasRenderingContext2D.prototype.roundRect){
|
| 17 |
+
CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){
|
| 18 |
+
if(typeof r==='number')r={x:r,y:r};
|
| 19 |
+
else if(!r)r={x:0,y:0};
|
| 20 |
+
const k=0.5523,rx=r.x,ry=r.y;
|
| 21 |
+
this.moveTo(x+rx,y);
|
| 22 |
+
this.lineTo(x+w-rx,y);
|
| 23 |
+
this.bezierCurveTo(x+w-rx+rx*k,y,x+w,y+ry-ry*k,x+w,y+ry);
|
| 24 |
+
this.lineTo(x+w,y+h-ry);
|
| 25 |
+
this.bezierCurveTo(x+w,y+h-ry+ry*k,x+w-rx+rx*k,y+h,x+w-rx,y+h);
|
| 26 |
+
this.lineTo(x+rx,y+h);
|
| 27 |
+
this.bezierCurveTo(x+rx-rx*k,y+h,x,y+h-ry+ry*k,x,y+h-ry);
|
| 28 |
+
this.lineTo(x,y+ry);
|
| 29 |
+
this.bezierCurveTo(x,y+ry-ry*k,x+rx-rx*k,y,x+rx,y);
|
| 30 |
+
this.closePath();
|
| 31 |
+
return this;
|
| 32 |
+
};
|
| 33 |
+
}
|
| 34 |
+
const W=480,H=760,CX=W/2;
|
| 35 |
+
const canvas=document.getElementById('c');
|
| 36 |
+
const ctx=canvas.getContext('2d');
|
| 37 |
+
let DPR=1;
|
| 38 |
+
function fit(){DPR=Math.min(2,window.devicePixelRatio||1);canvas.width=W*DPR;canvas.height=H*DPR;ctx.setTransform(DPR,0,0,DPR,0,0);}
|
| 39 |
+
fit();addEventListener('resize',fit);
|
| 40 |
+
|
| 41 |
+
// ---- lamp geometry (px) ----
|
| 42 |
+
const LIQ_TOP=175,LIQ_BOT=632;
|
| 43 |
+
const MIN_HW=64,MAX_HW=122; // tube half-widths: neck vs belly
|
| 44 |
+
const NECK_HW=MIN_HW-10;
|
| 45 |
+
function halfWidth(y){ // interior half-width of the liquid column
|
| 46 |
+
const t=Math.max(0,Math.min(1,(y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP)));
|
| 47 |
+
return MIN_HW+(MAX_HW-MIN_HW)*Math.pow(Math.sin(Math.PI*t),0.9);
|
| 48 |
+
}
|
| 49 |
+
function glassHalf(y){
|
| 50 |
+
if(y<LIQ_TOP) return NECK_HW+4;
|
| 51 |
+
if(y>LIQ_BOT) return halfWidth(LIQ_BOT)+4;
|
| 52 |
+
return halfWidth(y)+5;
|
| 53 |
+
}
|
| 54 |
+
const liquidClip=(()=>{ // clip path for everything inside the tube
|
| 55 |
+
const p=new Path2D(),N=60;
|
| 56 |
+
for(let i=0;i<=N;i++){const y=LIQ_TOP+(LIQ_BOT-LIQ_TOP)*i/N;p[i?'lineTo':'moveTo'](CX-halfWidth(y)-3,y);}
|
| 57 |
+
for(let i=N;i>=0;i--){const y=LIQ_TOP+(LIQ_BOT-LIQ_TOP)*i/N;p.lineTo(CX+halfWidth(y)+3,y);}
|
| 58 |
+
p.closePath();return p;
|
| 59 |
+
})();
|
| 60 |
+
function glassPath(){
|
| 61 |
+
const p=new Path2D(),y0=126,y1=650,pts=[];
|
| 62 |
+
for(let y=y0;y<=y1;y+=4)pts.push(y);
|
| 63 |
+
p.moveTo(CX-glassHalf(pts[0]),pts[0]);
|
| 64 |
+
for(const y of pts)p.lineTo(CX-glassHalf(y),y);
|
| 65 |
+
for(let i=pts.length-1;i>=0;i--)p.lineTo(CX+glassHalf(pts[i]),pts[i]);
|
| 66 |
+
p.closePath();return p;
|
| 67 |
+
}
|
| 68 |
+
const glass=glassPath();
|
| 69 |
+
|
| 70 |
+
// ---- blobs ----
|
| 71 |
+
const blobs=[];
|
| 72 |
+
function spawn(x,y,r,temp){blobs.push({x,y,vx:0,vy:0,r,temp,seed:Math.random()*1000});}
|
| 73 |
+
const startT=[0.92,0.85,0.35,0.22,0.62,0.75];
|
| 74 |
+
for(let i=0;i<6;i++){
|
| 75 |
+
const y=LIQ_TOP+60+Math.random()*(LIQ_BOT-LIQ_TOP-130);
|
| 76 |
+
let r=34+Math.random()*26;
|
| 77 |
+
r=Math.min(r,Math.max(30,halfWidth(y)-14));
|
| 78 |
+
spawn(CX+(Math.random()*2-1)*(halfWidth(y)-r-8),y,r,startT[i]);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// ---- bubbles (tiny, for life) ----
|
| 82 |
+
const bubbles=[];for(let i=0;i<14;i++)bubbles.push({x:Math.random(),y:Math.random(),s:0.5+Math.random(),r:0.8+Math.random()*1.6,ph:Math.random()*7});
|
| 83 |
+
|
| 84 |
+
// ---- wax amount (unlabeled slider on the base, just under the LED) ----
|
| 85 |
+
// volume multiplier 0.25x..4x; middle = as-is. Radius scales as cbrt(volume).
|
| 86 |
+
let WAXR=1;
|
| 87 |
+
const SL={x:CX,y:728,w:156}; // slider track geometry (canvas px)
|
| 88 |
+
let slF=0.5,slHover=false,slDrag=false;
|
| 89 |
+
function setWaxFromX(px){
|
| 90 |
+
slF=Math.max(0,Math.min(1,(px-SL.x+SL.w/2)/SL.w));
|
| 91 |
+
WAXR=Math.cbrt(Math.pow(2,4*slF-2)); // f: 0→0.25x, 0.5→1x, 1→4x volume
|
| 92 |
+
}
|
| 93 |
+
function overSlider(p){return Math.abs(p.x-SL.x)<SL.w/2+16&&Math.abs(p.y-SL.y)<18;}
|
| 94 |
+
function canvasXY(e){const r=canvas.getBoundingClientRect();return{x:(e.clientX-r.left)*W/r.width,y:(e.clientY-r.top)*H/r.height};}
|
| 95 |
+
canvas.addEventListener('pointerdown',e=>{const p=canvasXY(e);if(overSlider(p)){slDrag=true;try{canvas.setPointerCapture(e.pointerId);}catch(_){}setWaxFromX(p.x);e.preventDefault();}});
|
| 96 |
+
canvas.addEventListener('pointermove',e=>{const p=canvasXY(e);slHover=overSlider(p);if(slDrag)setWaxFromX(p.x);});
|
| 97 |
+
const endSlDrag=e=>{if(slDrag){slDrag=false;try{canvas.releasePointerCapture(e.pointerId);}catch(_){}}};
|
| 98 |
+
canvas.addEventListener('pointerup',endSlDrag);
|
| 99 |
+
canvas.addEventListener('pointercancel',endSlDrag);
|
| 100 |
+
|
| 101 |
+
// ---- physics (px units) ----
|
| 102 |
+
let time=0,last=performance.now();
|
| 103 |
+
const SIM_SPEED=0.55; // slow, heavy wax (scales all motion + transitions)
|
| 104 |
+
const easeIO=t=>t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;
|
| 105 |
+
const lerp=(a,b,t)=>a+(b-a)*t;
|
| 106 |
+
const ABS_DUR=1.6,BUD_DUR=2.6; // merge/split transition durations (s)
|
| 107 |
+
|
| 108 |
+
function step(dt){
|
| 109 |
+
const midY=(LIQ_TOP+LIQ_BOT)/2;
|
| 110 |
+
for(const b of blobs){
|
| 111 |
+
if(b.childOf)continue; // absorbed child is driven below
|
| 112 |
+
const t=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP);
|
| 113 |
+
const er=b.r*WAXR; // world radius at current wax amount
|
| 114 |
+
const botY=LIQ_BOT-er*0.9-6; // this blob's floor (size-dependent)
|
| 115 |
+
// heater at the bottom: strongest right on the base, so even big blobs
|
| 116 |
+
// resting on their own floor get fully heated; cooler air up top
|
| 117 |
+
if(b.y>botY-38) b.temp+=dt*0.5*(b.y-(botY-38))/38;
|
| 118 |
+
else b.temp-=dt*(0.045+(t<0.3?0.10:0));
|
| 119 |
+
b.temp=Math.max(0,Math.min(1,b.temp));
|
| 120 |
+
// buoyancy: screen y grows downward, so negative ay = rising.
|
| 121 |
+
// hot = light (rises), cold = heavy (sinks); bigger blobs weigh more
|
| 122 |
+
const ay=(0.5-b.temp)*150-(b.r-42)*1.1;
|
| 123 |
+
const drag=1.35+(b.r-42)*0.012;
|
| 124 |
+
b.vy+=ay*dt;b.vy*=Math.exp(-dt*drag);
|
| 125 |
+
b.vx*=Math.exp(-dt*drag*0.55); // wax drifts sideways more easily than it falls
|
| 126 |
+
// lateral turbulence: the curved walls shear the column, so blobs at
|
| 127 |
+
// different heights get pushed left/right out of phase with each other
|
| 128 |
+
const tn=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP);
|
| 129 |
+
const flow=Math.sin(time*0.21+tn*4.6+b.seed*0.15)*0.55
|
| 130 |
+
+Math.sin(time*0.13-tn*8.1+b.seed*0.40)*0.30
|
| 131 |
+
+Math.sin(time*0.34+tn*2.3)*0.15;
|
| 132 |
+
b.vx+=(flow*9+Math.sin(time*1.3+b.seed)*4)*dt*(b.r/45); // turbulence + fast wobble
|
| 133 |
+
const sp=Math.hypot(b.vx,b.vy);if(sp>140){b.vx*=140/sp;b.vy*=140/sp;}
|
| 134 |
+
b.x+=b.vx*dt;b.y+=b.vy*dt;
|
| 135 |
+
// walls follow the tube profile
|
| 136 |
+
let hw=halfWidth(b.y)-er*0.92-4;
|
| 137 |
+
if(hw<6){hw=6;b.vy+=(midY-b.y)*0.8*dt;} // too fat for the neck: drift to belly
|
| 138 |
+
const lim=Math.abs(b.x-CX);
|
| 139 |
+
if(lim>hw){b.x=CX+Math.sign(b.x-CX)*hw;b.vx*=-0.3;}
|
| 140 |
+
const topY=LIQ_TOP+er*0.9+8;
|
| 141 |
+
if(b.y<topY){b.y=topY;b.vy=Math.max(0,b.vy)*0.4;}
|
| 142 |
+
if(b.y>botY){b.y=botY;b.vy=Math.min(0,b.vy)*0.4;}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// --- smooth transitions: blobs never appear, vanish or split instantly ---
|
| 146 |
+
for(let i=blobs.length-1;i>=0;i--){
|
| 147 |
+
const b=blobs[i];
|
| 148 |
+
if(!b.abs)continue;
|
| 149 |
+
b.abs.t+=dt/ABS_DUR;
|
| 150 |
+
const k=easeIO(Math.min(1,b.abs.t));
|
| 151 |
+
b.r=lerp(b.abs.rFrom,b.abs.rTo,k);
|
| 152 |
+
b.x+=(b.abs.cx-b.x)*Math.min(1,dt*2.4);
|
| 153 |
+
b.y+=(b.abs.cy-b.y)*Math.min(1,dt*2.4);
|
| 154 |
+
const ch=b.abs.child;
|
| 155 |
+
ch.x+=(b.x-ch.x)*Math.min(1,dt*4.5);
|
| 156 |
+
ch.y+=(b.y-ch.y)*Math.min(1,dt*4.5);
|
| 157 |
+
ch.r=ch.r0*(1-k); // shrink to nothing (hidden)
|
| 158 |
+
if(b.abs.t>=1){ // fully melted: drop it
|
| 159 |
+
const idx=blobs.indexOf(ch);if(idx>-1)blobs.splice(idx,1);
|
| 160 |
+
b.abs=null;
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
for(const b of blobs){
|
| 164 |
+
if(!b.bud)continue;
|
| 165 |
+
const u=b.bud;
|
| 166 |
+
if(!blobs.includes(u.child)){b.bud=null;continue;}
|
| 167 |
+
u.t+=dt/BUD_DUR;
|
| 168 |
+
const k=easeIO(Math.min(1,u.t));
|
| 169 |
+
b.r=lerp(u.rFrom,u.rTo,k);
|
| 170 |
+
u.child.r=u.r2*lerp(0.15,1,k);
|
| 171 |
+
if(u.t>=1){ // free now - keep them from re-fusing mid-drift
|
| 172 |
+
u.child.budding=false;u.child.partner=b;b.partner=u.child;b.bud=null;
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// coalesce: deeply overlapping blobs start a slow absorption (no instant splice)
|
| 177 |
+
for(let i=0;i<blobs.length;i++)for(let j=i+1;j<blobs.length;j++){
|
| 178 |
+
const a=blobs[i],c=blobs[j];
|
| 179 |
+
if(a.abs||a.childOf||c.abs||c.childOf||a.bud||c.bud||a.budding||c.budding)continue;
|
| 180 |
+
if((a.partner&&a.partner===c)||(c.partner&&c.partner===a))continue;
|
| 181 |
+
const dx=c.x-a.x,dy=c.y-a.y,d=Math.hypot(dx,dy)||0.001,rs=a.r+c.r;
|
| 182 |
+
if(d<rs*0.46){
|
| 183 |
+
const va=a.r**3,vc=c.r**3,v=va+vc;
|
| 184 |
+
let P=vc>va?c:a,S=P===a?c:a;
|
| 185 |
+
const wp=P===a?va:vc,ws=S===a?va:vc;
|
| 186 |
+
P.vx=(P.vx*wp+S.vx*ws)/v;P.vy=(P.vy*wp+S.vy*ws)/v;
|
| 187 |
+
P.temp=(P.temp*wp+S.temp*ws)/v;
|
| 188 |
+
S.childOf=P;S.r0=S.r;
|
| 189 |
+
P.abs={child:S,t:0,rFrom:P.r,rTo:Math.min(70,Math.cbrt(v)),
|
| 190 |
+
cx:(a.x*va+c.x*vc)/v,cy:(a.y*va+c.y*vc)/v};
|
| 191 |
+
}else if(d<rs*1.08){ // gentle cohesion pull (only when nearly touching)
|
| 192 |
+
const f=(1-d/(rs*1.08))*9,ux=dx/d,uy=dy/d;
|
| 193 |
+
a.vx+=ux*f*dt*a.r/45;a.vy+=uy*f*dt*a.r/45;
|
| 194 |
+
c.vx-=ux*f*dt*c.r/45;c.vy-=uy*f*dt*c.r/45;
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// heavy, cold mega-blobs shed a droplet that SWELLS out of them (no instant split)
|
| 199 |
+
for(const b of blobs){
|
| 200 |
+
if(b.abs||b.childOf||b.bud)continue;
|
| 201 |
+
if(b.r>62&&b.temp<0.5&&Math.random()<dt*0.35){
|
| 202 |
+
const r2=b.r*0.48,ang=Math.random()*Math.PI*2;
|
| 203 |
+
const nx=b.x+Math.cos(ang)*b.r*0.72,ny=b.y+Math.sin(ang)*b.r*0.72;
|
| 204 |
+
if(halfWidth(ny)-r2>10&&ny>LIQ_TOP+r2+6&&ny<LIQ_BOT-r2-6){
|
| 205 |
+
const child={x:nx,y:ny,vx:b.vx+Math.cos(ang)*(20+Math.random()*14),vy:b.vy+Math.sin(ang)*(20+Math.random()*14)-10,r:r2*0.15,temp:b.temp,seed:Math.random()*1000,budding:true};
|
| 206 |
+
blobs.push(child);
|
| 207 |
+
b.bud={child,t:0,rFrom:b.r,rTo:Math.max(34,Math.cbrt(b.r**3-r2**3)),r2};
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
for(const b of blobs){
|
| 213 |
+
if(!b.partner)continue;
|
| 214 |
+
const p=b.partner;
|
| 215 |
+
if(Math.hypot(p.x-b.x,p.y-b.y)>(b.r+p.r)*1.25){b.partner=null;p.partner=null;}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
for(const b of bubbles){b.y-=dt*(0.03+0.02*b.s);if(b.y<0){b.y=1;b.x=Math.random();}}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// ---- metaball rendering (gooey, temperature-tinted glow) ----
|
| 222 |
+
const pad=18,BW=Math.ceil(MAX_HW*2+pad*2),BH=Math.ceil(LIQ_BOT-LIQ_TOP+pad*2);
|
| 223 |
+
const buf=document.createElement('canvas');buf.width=BW;buf.height=BH;
|
| 224 |
+
const bctx=buf.getContext('2d');
|
| 225 |
+
const field=new Float32Array(BW*BH);
|
| 226 |
+
const fieldT=new Float32Array(BW*BH); // strength weighted by wax temperature
|
| 227 |
+
function renderBlobs(){
|
| 228 |
+
field.fill(0);fieldT.fill(0);
|
| 229 |
+
const ox=CX-MAX_HW-pad,oy=LIQ_TOP-pad;
|
| 230 |
+
for(const b of blobs){
|
| 231 |
+
const bx=b.x-ox,by=b.y-oy,rr=b.r*WAXR*1.12;
|
| 232 |
+
// organic undulation: low-frequency noise on the surface so blobs look
|
| 233 |
+
// molten and lumpy instead of perfect circles
|
| 234 |
+
const s1=b.seed,s2=b.seed*1.7+3.1,t1=time*1.6+s1,t2=-time*1.2+s2;
|
| 235 |
+
const R=rr*1.13,R2=R*R;
|
| 236 |
+
const x0=Math.max(0,Math.floor(bx-R)),x1=Math.min(BW-1,Math.ceil(bx+R));
|
| 237 |
+
const y0=Math.max(0,Math.floor(by-R)),y1=Math.min(BH-1,Math.ceil(by+R));
|
| 238 |
+
for(let y=y0;y<=y1;y++){
|
| 239 |
+
const wy1=0.07*y+t1,wy2=-0.09*y+t2;
|
| 240 |
+
for(let x=x0;x<=x1;x++){
|
| 241 |
+
const dx=x-bx,dy=y-by,q=dx*dx+dy*dy;
|
| 242 |
+
if(q>=R2)continue;
|
| 243 |
+
const w=1+0.06*Math.sin(0.11*x+wy1)+0.05*Math.sin(0.17*x+wy2);
|
| 244 |
+
const re=rr*w,re2=re*re;
|
| 245 |
+
if(q<re2){const f=1-q/re2;const idx=y*BW+x;field[idx]+=f;fieldT[idx]+=f*b.temp;}
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
const img=bctx.createImageData(BW,BH),d=img.data;
|
| 250 |
+
for(let i=0;i<BW*BH;i++){
|
| 251 |
+
const f=field[i];let r=0,g=0,bl=0,a=0;
|
| 252 |
+
if(f>0.52){ // molten core
|
| 253 |
+
const k=Math.min(1,(f-0.52)/1.15);
|
| 254 |
+
const t=f>0?fieldT[i]/f:0; // avg wax temperature here
|
| 255 |
+
r=(90+165*k)*(0.8+t*0.35);
|
| 256 |
+
g=(8+Math.pow(k,1.8)*150)*(0.55+t*0.6);
|
| 257 |
+
bl=(34-Math.pow(k,2)*14+k*k*70)*(0.6+t*0.5);
|
| 258 |
+
// white-hot core for very hot, deep wax (glowing from within)
|
| 259 |
+
if(f>1.4&&t>0.6){
|
| 260 |
+
const w=Math.min(1,(f-1.4)/0.8)*Math.min(1,(t-0.6)/0.35)*0.7;
|
| 261 |
+
r+=(255-r)*w;g+=(225-g)*w;bl+=(190-bl)*w;
|
| 262 |
+
}
|
| 263 |
+
a=255*Math.min(1,(f-0.52)/0.35);
|
| 264 |
+
}else if(f>0.3){ // soft red halo
|
| 265 |
+
const k=(f-0.3)/0.22;
|
| 266 |
+
r=255;g=60*k;bl=40*k;a=110*k*k;
|
| 267 |
+
}
|
| 268 |
+
d[i*4]=Math.min(255,r);d[i*4+1]=Math.min(255,g);d[i*4+2]=Math.min(255,bl);d[i*4+3]=a;
|
| 269 |
+
}
|
| 270 |
+
bctx.putImageData(img,0,0);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
// ---- drawing helpers ----
|
| 274 |
+
function metalGrad(x0,y0,x1,y1,base,mid){const g=ctx.createLinearGradient(x0,y0,x1,y1);g.addColorStop(0,base);g.addColorStop(0.5,mid);g.addColorStop(1,base);return g;}
|
| 275 |
+
function drawBackground(){
|
| 276 |
+
const g=ctx.createRadialGradient(CX,H*0.42,60,CX,H*0.42,H*0.75);
|
| 277 |
+
g.addColorStop(0,'#181026');g.addColorStop(1,'#05030a');
|
| 278 |
+
ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
|
| 279 |
+
// warm light pool from the lamp onto the room
|
| 280 |
+
ctx.save();ctx.globalCompositeOperation='lighter';
|
| 281 |
+
const lg=ctx.createRadialGradient(CX,H-40,20,CX,H-40,H*0.62);
|
| 282 |
+
lg.addColorStop(0,'rgba(255,70,35,0.16)');lg.addColorStop(0.5,'rgba(255,60,30,0.05)');lg.addColorStop(1,'rgba(255,60,30,0)');
|
| 283 |
+
ctx.fillStyle=lg;ctx.fillRect(0,0,W,H);ctx.restore();
|
| 284 |
+
ctx.save();ctx.globalAlpha=0.55;ctx.fillStyle='#000';
|
| 285 |
+
ctx.beginPath();ctx.ellipse(CX,H-24,190,26,0,0,Math.PI*2);ctx.fill();ctx.restore();
|
| 286 |
+
}
|
| 287 |
+
function drawLED(){
|
| 288 |
+
ctx.save();ctx.globalCompositeOperation='lighter';
|
| 289 |
+
const g=ctx.createRadialGradient(CX,H-58,4,CX,H-58,150);
|
| 290 |
+
g.addColorStop(0,'rgba(255,60,35,0.38)');g.addColorStop(1,'rgba(255,60,35,0)');
|
| 291 |
+
ctx.fillStyle=g;ctx.fillRect(CX-170,H-210,340,210);ctx.restore();
|
| 292 |
+
}
|
| 293 |
+
function drawCap(){
|
| 294 |
+
const g=metalGrad(0,84,0,132,'#151022','#4d3c6b');
|
| 295 |
+
ctx.fillStyle=g;
|
| 296 |
+
ctx.beginPath();ctx.moveTo(CX-NECK_HW-6,127);ctx.quadraticCurveTo(CX,76,CX+NECK_HW+6,127);ctx.closePath();ctx.fill();
|
| 297 |
+
const rim=ctx.createLinearGradient(0,118,0,132);
|
| 298 |
+
rim.addColorStop(0,'#5a4880');rim.addColorStop(1,'#191226');
|
| 299 |
+
ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-NECK_HW-8,118,(NECK_HW+8)*2,13,6);ctx.fill();
|
| 300 |
+
}
|
| 301 |
+
function drawBase(){
|
| 302 |
+
const body=new Path2D();
|
| 303 |
+
body.moveTo(CX-MIN_HW-14,659);
|
| 304 |
+
body.quadraticCurveTo(CX-MAX_HW-40,H-70,CX-MAX_HW-52,H-38);
|
| 305 |
+
body.quadraticCurveTo(CX-MAX_HW-52,H-20,CX-MAX_HW-30,H-18);
|
| 306 |
+
body.lineTo(CX+MAX_HW+30,H-18);
|
| 307 |
+
body.quadraticCurveTo(CX+MAX_HW+52,H-20,CX+MAX_HW+52,H-38);
|
| 308 |
+
body.quadraticCurveTo(CX+MAX_HW+40,H-70,CX+MIN_HW+14,659);
|
| 309 |
+
body.closePath();
|
| 310 |
+
ctx.save();ctx.clip(body);
|
| 311 |
+
const g=ctx.createLinearGradient(0,659,0,H);g.addColorStop(0,'#3c2d58');g.addColorStop(0.35,'#241736');g.addColorStop(1,'#0c0716');
|
| 312 |
+
ctx.fillStyle=g;ctx.fillRect(0,650,W,H-650);
|
| 313 |
+
const sheen=ctx.createLinearGradient(CX-MAX_HW-52,0,CX+MAX_HW+52,0);
|
| 314 |
+
sheen.addColorStop(0,'rgba(255,255,255,0)');sheen.addColorStop(0.5,'rgba(255,255,255,0.13)');sheen.addColorStop(1,'rgba(255,255,255,0)');
|
| 315 |
+
ctx.fillStyle=sheen;ctx.fillRect(0,650,W,H-650);
|
| 316 |
+
ctx.restore();
|
| 317 |
+
const rim=ctx.createLinearGradient(0,645,0,661);
|
| 318 |
+
rim.addColorStop(0,'#6a5590');rim.addColorStop(1,'#171026');
|
| 319 |
+
ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-MIN_HW-16,645,(MIN_HW+16)*2,16,8);ctx.fill();
|
| 320 |
+
const pulse=0.75+0.25*Math.sin(time*2);
|
| 321 |
+
ctx.save();ctx.shadowColor='rgba(255,70,40,0.9)';ctx.shadowBlur=12*pulse;
|
| 322 |
+
ctx.fillStyle=`rgba(255,80,45,${0.85*pulse})`;ctx.beginPath();ctx.arc(CX,H-52,3.4,0,Math.PI*2);ctx.fill();ctx.restore();
|
| 323 |
+
}
|
| 324 |
+
function drawSlider(){
|
| 325 |
+
const kx=SL.x-SL.w/2+slF*SL.w;
|
| 326 |
+
ctx.save();
|
| 327 |
+
// groove
|
| 328 |
+
ctx.fillStyle='rgba(0,0,0,0.55)';
|
| 329 |
+
ctx.beginPath();ctx.roundRect(SL.x-SL.w/2,SL.y-1.75,SL.w,3.5,1.75);ctx.fill();
|
| 330 |
+
ctx.strokeStyle='rgba(255,255,255,0.07)';ctx.lineWidth=1;
|
| 331 |
+
ctx.beginPath();ctx.roundRect(SL.x-SL.w/2+0.5,SL.y-1.3,SL.w-1,2.6,1.3);ctx.stroke();
|
| 332 |
+
// knob, echoing the LED above it
|
| 333 |
+
const r=(slDrag||slHover)?5:4;
|
| 334 |
+
ctx.shadowColor='rgba(255,80,40,0.9)';
|
| 335 |
+
ctx.shadowBlur=slDrag?15:(slHover?11:7);
|
| 336 |
+
const g=ctx.createRadialGradient(kx,SL.y,0.5,kx,SL.y,r+1.5);
|
| 337 |
+
g.addColorStop(0,'rgba(255,238,220,0.95)');
|
| 338 |
+
g.addColorStop(0.45,'rgba(255,130,70,0.85)');
|
| 339 |
+
g.addColorStop(1,'rgba(255,70,35,0)');
|
| 340 |
+
ctx.fillStyle=g;ctx.beginPath();ctx.arc(kx,SL.y,r+1.5,0,Math.PI*2);ctx.fill();
|
| 341 |
+
ctx.restore();
|
| 342 |
+
}
|
| 343 |
+
function drawLiquid(){
|
| 344 |
+
ctx.save();ctx.clip(liquidClip);
|
| 345 |
+
const g=ctx.createLinearGradient(0,LIQ_TOP,0,LIQ_BOT);
|
| 346 |
+
g.addColorStop(0,'#23094a');g.addColorStop(0.5,'#1b0640');g.addColorStop(1,'#12032c');
|
| 347 |
+
ctx.fillStyle=g;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16);
|
| 348 |
+
|
| 349 |
+
// ---- bottom lamp glow: heat rising up the liquid column ----
|
| 350 |
+
ctx.globalCompositeOperation='lighter';
|
| 351 |
+
const hg=ctx.createLinearGradient(0,LIQ_BOT-190,0,LIQ_BOT+10);
|
| 352 |
+
hg.addColorStop(0,'rgba(255,80,45,0)');
|
| 353 |
+
hg.addColorStop(0.6,'rgba(255,70,40,0.10)');
|
| 354 |
+
hg.addColorStop(1,'rgba(255,110,60,0.38)');
|
| 355 |
+
ctx.fillStyle=hg;ctx.fillRect(CX-MAX_HW-8,LIQ_BOT-190,MAX_HW*2+16,200);
|
| 356 |
+
const led=ctx.createRadialGradient(CX,LIQ_BOT+6,4,CX,LIQ_BOT+6,150);
|
| 357 |
+
led.addColorStop(0,'rgba(255,150,95,0.55)');
|
| 358 |
+
led.addColorStop(0.35,'rgba(255,85,50,0.24)');
|
| 359 |
+
led.addColorStop(1,'rgba(255,60,40,0)');
|
| 360 |
+
ctx.fillStyle=led;ctx.fillRect(CX-170,LIQ_BOT-150,340,170);
|
| 361 |
+
|
| 362 |
+
// drifting shimmer
|
| 363 |
+
const sx=CX+Math.sin(time*0.4)*60;
|
| 364 |
+
const sh=ctx.createRadialGradient(sx,(LIQ_TOP+LIQ_BOT)/2,10,sx,(LIQ_TOP+LIQ_BOT)/2,220);
|
| 365 |
+
sh.addColorStop(0,'rgba(150,90,220,0.10)');sh.addColorStop(1,'rgba(150,90,220,0)');
|
| 366 |
+
ctx.fillStyle=sh;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16);
|
| 367 |
+
|
| 368 |
+
// bubbles
|
| 369 |
+
ctx.globalCompositeOperation='source-over';
|
| 370 |
+
ctx.fillStyle='rgba(255,240,255,0.14)';
|
| 371 |
+
for(const b of bubbles){
|
| 372 |
+
const y=b.y*(LIQ_BOT-LIQ_TOP)+LIQ_TOP,hw=halfWidth(y)-6;
|
| 373 |
+
const x=CX+(b.x*2-1)*hw+Math.sin(time*2+b.ph)*3;
|
| 374 |
+
ctx.beginPath();ctx.arc(x,y,b.r,0,Math.PI*2);ctx.fill();
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
// ---- the lava: diffuse halo + glowing body ----
|
| 378 |
+
renderBlobs();
|
| 379 |
+
const bx=CX-MAX_HW-pad,by=LIQ_TOP-pad;
|
| 380 |
+
ctx.globalCompositeOperation='lighter';
|
| 381 |
+
ctx.globalAlpha=0.16;ctx.drawImage(buf,bx-24,by-24,BW+48,BH+48); // wide soft halo
|
| 382 |
+
ctx.globalAlpha=0.30;ctx.drawImage(buf,bx-10,by-10,BW+20,BH+20); // tighter glow
|
| 383 |
+
ctx.globalCompositeOperation='source-over';
|
| 384 |
+
ctx.globalAlpha=1;ctx.drawImage(buf,bx,by); // wax body
|
| 385 |
+
ctx.restore();
|
| 386 |
+
}
|
| 387 |
+
function drawGlass(){
|
| 388 |
+
ctx.save();
|
| 389 |
+
ctx.fillStyle='rgba(255,255,255,0.03)';ctx.fill(glass);
|
| 390 |
+
const g=ctx.createLinearGradient(CX-MAX_HW-8,0,CX+MAX_HW+8,0);
|
| 391 |
+
g.addColorStop(0,'rgba(255,255,255,0.02)');g.addColorStop(0.15,'rgba(255,255,255,0.22)');
|
| 392 |
+
g.addColorStop(0.5,'rgba(255,255,255,0.04)');g.addColorStop(0.85,'rgba(255,255,255,0.16)');g.addColorStop(1,'rgba(255,255,255,0.02)');
|
| 393 |
+
ctx.strokeStyle=g;ctx.lineWidth=2.4;ctx.stroke(glass);
|
| 394 |
+
const hl=(x,w,a)=>{const gr=ctx.createLinearGradient(x,LIQ_TOP,x,LIQ_BOT);gr.addColorStop(0,'rgba(255,255,255,0)');gr.addColorStop(0.5,`rgba(255,255,255,${a})`);gr.addColorStop(1,'rgba(255,255,255,0)');ctx.fillStyle=gr;ctx.beginPath();ctx.roundRect(x-w/2,LIQ_TOP+14,w,LIQ_BOT-LIQ_TOP-30,w/2);ctx.fill();};
|
| 395 |
+
hl(CX-MAX_HW*0.62,7,0.10);hl(CX+MAX_HW*0.55,4,0.05);
|
| 396 |
+
ctx.restore();
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
// ---- main loop ----
|
| 400 |
+
function frame(now){
|
| 401 |
+
try{
|
| 402 |
+
const dt=Math.max(0,Math.min(0.05,(now-last)/1000));last=now;time+=dt;
|
| 403 |
+
step(dt*SIM_SPEED);
|
| 404 |
+
drawBackground();
|
| 405 |
+
drawLED();
|
| 406 |
+
drawCap();
|
| 407 |
+
drawLiquid();
|
| 408 |
+
drawGlass();
|
| 409 |
+
drawBase();
|
| 410 |
+
drawSlider();
|
| 411 |
+
canvas.style.cursor=slDrag||slHover?'ew-resize':'default';
|
| 412 |
+
}catch(err){
|
| 413 |
+
const d=document.createElement('div');
|
| 414 |
+
d.style.cssText='position:fixed;left:50%;top:8px;transform:translateX(-50%);color:#ffb3a7;background:rgba(20,5,10,.9);padding:8px 14px;border-radius:8px;font:13px monospace;z-index:9';
|
| 415 |
+
d.textContent='Lava lamp error: '+err.message;
|
| 416 |
+
document.body.appendChild(d);
|
| 417 |
+
throw err;
|
| 418 |
+
}
|
| 419 |
+
requestAnimationFrame(frame);
|
| 420 |
+
}
|
| 421 |
+
requestAnimationFrame(frame);
|
| 422 |
+
</script>
|
| 423 |
+
</body>
|
| 424 |
+
</html>
|