Upload index.html
Browse files- index.html +659 -620
index.html
CHANGED
|
@@ -1,621 +1,660 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8"/>
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
-
<title>Lava Lamp</title>
|
| 7 |
-
<style>
|
| 8 |
-
html,body{margin:0;height:100%;background:#05030a;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
| 9 |
-
canvas{width:min(480px,92vw);height:auto;aspect-ratio:480/760;border-radius:14px;background:#05030a;touch-action:none}
|
| 10 |
-
</style>
|
| 11 |
-
</head>
|
| 12 |
-
<body>
|
| 13 |
-
<canvas id="c" tabindex="0" aria-label="Animated lava lamp. Press space to toggle colour mode; use left and right arrow keys to adjust the wax amount."></canvas>
|
| 14 |
-
<script>
|
| 15 |
-
"use strict";
|
| 16 |
-
// polyfill for older browsers (ctx.roundRect) so the lamp never crashes
|
| 17 |
-
if(!CanvasRenderingContext2D.prototype.roundRect){
|
| 18 |
-
CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){
|
| 19 |
-
if(typeof r==='number')r={x:r,y:r};
|
| 20 |
-
else if(!r)r={x:0,y:0};
|
| 21 |
-
const k=0.5523,rx=r.x,ry=r.y;
|
| 22 |
-
this.moveTo(x+rx,y);
|
| 23 |
-
this.lineTo(x+w-rx,y);
|
| 24 |
-
this.bezierCurveTo(x+w-rx+rx*k,y,x+w,y+ry-ry*k,x+w,y+ry);
|
| 25 |
-
this.lineTo(x+w,y+h-ry);
|
| 26 |
-
this.bezierCurveTo(x+w,y+h-ry+ry*k,x+w-rx+rx*k,y+h,x+w-rx,y+h);
|
| 27 |
-
this.lineTo(x+rx,y+h);
|
| 28 |
-
this.bezierCurveTo(x+rx-rx*k,y+h,x,y+h-ry+ry*k,x,y+h-ry);
|
| 29 |
-
this.lineTo(x,y+ry);
|
| 30 |
-
this.bezierCurveTo(x,y+ry-ry*k,x+rx-rx*k,y,x+rx,y);
|
| 31 |
-
this.closePath();
|
| 32 |
-
return this;
|
| 33 |
-
};
|
| 34 |
-
}
|
| 35 |
-
const W=480,H=760,CX=W/2;
|
| 36 |
-
const canvas=document.getElementById('c');
|
| 37 |
-
const ctx=canvas.getContext('2d');
|
| 38 |
-
let DPR=1;
|
| 39 |
-
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);}
|
| 40 |
-
fit();addEventListener('resize',fit);
|
| 41 |
-
|
| 42 |
-
// ---- lamp geometry (px) ----
|
| 43 |
-
const LIQ_TOP=175,LIQ_BOT=632;
|
| 44 |
-
const MIN_HW=64,MAX_HW=122; // tube half-widths: neck vs belly
|
| 45 |
-
const NECK_HW=MIN_HW-10;
|
| 46 |
-
function halfWidth(y){ // interior half-width of the liquid column
|
| 47 |
-
const t=Math.max(0,Math.min(1,(y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP)));
|
| 48 |
-
return MIN_HW+(MAX_HW-MIN_HW)*Math.pow(Math.sin(Math.PI*t),0.9);
|
| 49 |
-
}
|
| 50 |
-
function glassHalf(y){
|
| 51 |
-
if(y<LIQ_TOP) return NECK_HW+4;
|
| 52 |
-
if(y>LIQ_BOT) return halfWidth(LIQ_BOT)+4;
|
| 53 |
-
return halfWidth(y)+5;
|
| 54 |
-
}
|
| 55 |
-
const liquidClip=(()=>{ // clip path for everything inside the tube
|
| 56 |
-
const p=new Path2D(),N=60;
|
| 57 |
-
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);}
|
| 58 |
-
for(let i=N;i>=0;i--){const y=LIQ_TOP+(LIQ_BOT-LIQ_TOP)*i/N;p.lineTo(CX+halfWidth(y)+3,y);}
|
| 59 |
-
p.closePath();return p;
|
| 60 |
-
})();
|
| 61 |
-
function glassPath(){
|
| 62 |
-
const p=new Path2D(),y0=126,y1=650,pts=[];
|
| 63 |
-
for(let y=y0;y<=y1;y+=4)pts.push(y);
|
| 64 |
-
p.moveTo(CX-glassHalf(pts[0]),pts[0]);
|
| 65 |
-
for(const y of pts)p.lineTo(CX-glassHalf(y),y);
|
| 66 |
-
for(let i=pts.length-1;i>=0;i--)p.lineTo(CX+glassHalf(pts[i]),pts[i]);
|
| 67 |
-
p.closePath();return p;
|
| 68 |
-
}
|
| 69 |
-
const glass=glassPath();
|
| 70 |
-
|
| 71 |
-
// ---- blobs ----
|
| 72 |
-
const blobs=[];
|
| 73 |
-
function spawn(x,y,r,temp){blobs.push({x,y,vx:0,vy:0,r,temp,seed:Math.random()*1000});}
|
| 74 |
-
const startT=[0.92,0.85,0.35,0.22,0.62,0.75];
|
| 75 |
-
for(let i=0;i<6;i++){
|
| 76 |
-
const y=LIQ_TOP+60+Math.random()*(LIQ_BOT-LIQ_TOP-130);
|
| 77 |
-
let r=34+Math.random()*26;
|
| 78 |
-
r=Math.min(r,Math.max(30,halfWidth(y)-14));
|
| 79 |
-
spawn(CX+(Math.random()*2-1)*(halfWidth(y)-r-8),y,r,startT[i]);
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
// ---- bubbles (tiny, for life) ----
|
| 83 |
-
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});
|
| 84 |
-
// carbonation: air bubbles shed from the blobs' surfaces (purely cosmetic)
|
| 85 |
-
const blobBubs=[];
|
| 86 |
-
|
| 87 |
-
// ---- wax amount (unlabeled slider on the base, just under the LED) ----
|
| 88 |
-
// volume multiplier 0.25x..4x; middle = as-is. Radius scales as cbrt(volume).
|
| 89 |
-
let WAXR=1;
|
| 90 |
-
const SL={x:CX,y:728,w:156}; // slider track geometry (canvas px)
|
| 91 |
-
let slF=0.5,slHover=false,slDrag=false;
|
| 92 |
-
function setWaxFromX(px){
|
| 93 |
-
slF=Math.max(0,Math.min(1,(px-SL.x+SL.w/2)/SL.w));
|
| 94 |
-
WAXR=Math.cbrt(Math.pow(2,4*slF-2)); // f: 0→0.25x, 0.5→1x, 1→4x volume
|
| 95 |
-
}
|
| 96 |
-
function overSlider(p){return Math.abs(p.x-SL.x)<SL.w/2+16&&Math.abs(p.y-SL.y)<18;}
|
| 97 |
-
function canvasXY(e){const r=canvas.getBoundingClientRect();return{x:(e.clientX-r.left)*W/r.width,y:(e.clientY-r.top)*H/r.height};}
|
| 98 |
-
canvas.addEventListener('pointerdown',e=>{const p=canvasXY(e);if(overLED(p)){RGB_MODE=!RGB_MODE;e.preventDefault();return;}if(overSlider(p)){slDrag=true;try{canvas.setPointerCapture(e.pointerId);}catch(_){}setWaxFromX(p.x);e.preventDefault();}});
|
| 99 |
-
canvas.addEventListener('pointermove',e=>{const p=canvasXY(e);ledHover=overLED(p);slHover=overSlider(p)&&!ledHover;if(slDrag)setWaxFromX(p.x);});
|
| 100 |
-
const endSlDrag=e=>{if(slDrag){slDrag=false;try{canvas.releasePointerCapture(e.pointerId);}catch(_){}}};
|
| 101 |
-
canvas.addEventListener('pointerup',endSlDrag);
|
| 102 |
-
canvas.addEventListener('pointercancel',endSlDrag);
|
| 103 |
-
// keyboard: space/enter toggles the light, arrows adjust wax (same maths as dragging)
|
| 104 |
-
canvas.addEventListener('keydown',e=>{
|
| 105 |
-
if(e.code==='Space'||e.code==='Enter'){RGB_MODE=!RGB_MODE;e.preventDefault();}
|
| 106 |
-
else if(e.code==='ArrowRight'||e.code==='ArrowUp'){setWaxFromX(SL.x-SL.w/2+Math.min(1,slF+0.06)*SL.w);e.preventDefault();}
|
| 107 |
-
else if(e.code==='ArrowLeft'||e.code==='ArrowDown'){setWaxFromX(SL.x-SL.w/2+Math.max(0,slF-0.06)*SL.w);e.preventDefault();}
|
| 108 |
-
});
|
| 109 |
-
|
| 110 |
-
// ---- clickable LED: toggles a slow RGB colour cycle on the whole lamp ----
|
| 111 |
-
let RGB_MODE=false,rgbMix=0,hue=140; // mix eases 0(default) <-> 1(full RGB)
|
| 112 |
-
const RM=matchMedia('(prefers-reduced-motion: reduce)').matches; // respect OS motion setting
|
| 113 |
-
const HUE_SPEED=(360/16)*(RM?0.5:1); // one full colour cycle every ~16 s (slower for reduced motion)
|
| 114 |
-
const DEF_L={r:255,g:90,b:45}; // the lamp's original warm colour
|
| 115 |
-
const LED_POS={x:CX,y:H-52};
|
| 116 |
-
let ledHover=false;
|
| 117 |
-
function overLED(p){return Math.hypot(p.x-LED_POS.x,p.y-LED_POS.y)<20;}
|
| 118 |
-
function hueRGB(h){ // pleasant saturated RGB from a hue (deg)
|
| 119 |
-
h=((h%360)+360)%360;const s=0.84,v=1,c=v*s,x=c*(1-Math.abs((h/60)%2-1)),m=v-c;
|
| 120 |
-
let r,g,b;
|
| 121 |
-
if(h<60){r=c;g=x;b=0;}else if(h<120){r=x;g=c;b=0;}
|
| 122 |
-
else if(h<180){r=0;g=c;b=x;}else if(h<240){r=0;g=x;b=c;}
|
| 123 |
-
else if(h<300){r=x;g=0;b=c;}else{r=c;g=0;b=x;}
|
| 124 |
-
return{r:Math.round((r+m)*255),g:Math.round((g+m)*255),b:Math.round((b+m)*255)};
|
| 125 |
-
}
|
| 126 |
-
const rgba=(c,a)=>`rgba(${c.r|0},${c.g|0},${c.b|0},${a})`;
|
| 127 |
-
const mixWhite=(c,t)=>({r:c.r+(255-c.r)*t,g:c.g+(255-c.g)*t,b:c.b+(255-c.b)*t});
|
| 128 |
-
// Blend a fixed colour toward the lamp light's hue as RGB mode fades in (rgbMix 0->1)
|
| 129 |
-
const tint=(orig)=>{const t=0.25*rgbMix;return{r:orig.r*(1-t)+LIGHT.r*t,g:orig.g*(1-t)+LIGHT.g*t,b:orig.b*(1-t)+LIGHT.b*t};};
|
| 130 |
-
let LIGHT={...DEF_L},LUM_L=1; // effective lamp colour, updated each frame
|
| 131 |
-
|
| 132 |
-
// ---- physics (px units) ----
|
| 133 |
-
let time=0,last=performance.now();
|
| 134 |
-
const SIM_SPEED=0.55*(RM?0.5:1); // slow, heavy wax (scales all motion + transitions)
|
| 135 |
-
const easeIO=t=>t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;
|
| 136 |
-
const lerp=(a,b,t)=>a+(b-a)*t;
|
| 137 |
-
const ABS_DUR=1.6,BUD_DUR=2.6; // merge/split transition durations (s)
|
| 138 |
-
|
| 139 |
-
function step(dt){
|
| 140 |
-
const midY=(LIQ_TOP+LIQ_BOT)/2;
|
| 141 |
-
for(const b of blobs){
|
| 142 |
-
if(b.splitAge!==undefined)b.splitAge+=dt;
|
| 143 |
-
if(b.childOf)continue; // absorbed child is driven below
|
| 144 |
-
const t=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP);
|
| 145 |
-
const er=b.r*WAXR; // world radius at current wax amount
|
| 146 |
-
const botY=LIQ_BOT-er*0.9-6; // this blob's floor (size-dependent)
|
| 147 |
-
// heater at the bottom: strongest right on the base, so even big blobs
|
| 148 |
-
// resting on their own floor get fully heated; cooler air up top
|
| 149 |
-
if(b.y>botY-38) b.temp+=dt*0.75*(b.y-(botY-38))/38;
|
| 150 |
-
else b.temp-=dt*(0.04+(t<0.3?0.15:0));
|
| 151 |
-
b.temp=Math.max(0,Math.min(1,b.temp));
|
| 152 |
-
// buoyancy: screen y grows downward, so negative ay = rising.
|
| 153 |
-
// hot = light (rises), cold = heavy (sinks); bigger blobs weigh more
|
| 154 |
-
const ay=(0.5-b.temp)*150-(b.r-42)*1.1;
|
| 155 |
-
// melting: cold wax is thick/solid-ish, hot wax melts and flows freely
|
| 156 |
-
const melt=b.temp; // 0=cold(solid) → 1=hot(liquid)
|
| 157 |
-
const drag=(1.35+(b.r-42)*0.012)*(1.35-0.7*melt); // hot = less drag = more fluid
|
| 158 |
-
b.vy+=ay*dt;b.vy*=Math.exp(-dt*drag);
|
| 159 |
-
b.vx*=Math.exp(-dt*drag*0.55); // wax drifts sideways more easily than it falls
|
| 160 |
-
// lateral turbulence: the curved walls shear the column, so blobs at
|
| 161 |
-
// different heights get pushed left/right out of phase with each other
|
| 162 |
-
const tn=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP);
|
| 163 |
-
const flow=Math.sin(time*0.21+tn*4.6+b.seed*0.15)*0.55
|
| 164 |
-
+Math.sin(time*0.13-tn*8.1+b.seed*0.40)*0.30
|
| 165 |
-
+Math.sin(time*0.34+tn*2.3)*0.15;
|
| 166 |
-
b.vx+=(flow*9+Math.sin(time*1.3+b.seed)*4)*dt*(b.r/45)*(0.7+0.6*melt); // liquid wax rides the currents more
|
| 167 |
-
const sp=Math.hypot(b.vx,b.vy);if(sp>140){b.vx*=140/sp;b.vy*=140/sp;}
|
| 168 |
-
b.x+=b.vx*dt;b.y+=b.vy*dt;
|
| 169 |
-
// walls follow the tube profile
|
| 170 |
-
let hw=halfWidth(b.y)-er*0.92-4;
|
| 171 |
-
if(hw<6){hw=6;b.vy+=(midY-b.y)*0.8*dt;} // too fat for the neck: drift to belly
|
| 172 |
-
const lim=Math.abs(b.x-CX);
|
| 173 |
-
if(lim>hw){b.x=CX+Math.sign(b.x-CX)*hw;b.vx*=-0.3;}
|
| 174 |
-
const topY=LIQ_TOP+er*0.9+8;
|
| 175 |
-
if(b.y<topY){b.y=topY;b.vy=Math.max(0,b.vy)*0.4;}
|
| 176 |
-
if(b.y>botY){b.y=botY;b.vy=Math.min(0,b.vy)*0.4;}
|
| 177 |
-
}
|
| 178 |
-
|
| 179 |
-
// --- smooth transitions: blobs never appear, vanish or split instantly ---
|
| 180 |
-
for(let i=blobs.length-1;i>=0;i--){
|
| 181 |
-
const b=blobs[i];
|
| 182 |
-
if(!b.abs)continue;
|
| 183 |
-
b.abs.t+=dt/ABS_DUR;
|
| 184 |
-
const k=easeIO(Math.min(1,b.abs.t));
|
| 185 |
-
b.r=lerp(b.abs.rFrom,b.abs.rTo,k);
|
| 186 |
-
b.x+=(b.abs.cx-b.x)*Math.min(1,dt*2.4);
|
| 187 |
-
b.y+=(b.abs.cy-b.y)*Math.min(1,dt*2.4);
|
| 188 |
-
const ch=b.abs.child;
|
| 189 |
-
ch.x+=(b.x-ch.x)*Math.min(1,dt*4.5);
|
| 190 |
-
ch.y+=(b.y-ch.y)*Math.min(1,dt*4.5);
|
| 191 |
-
ch.r=ch.r0*(1-k); // shrink to nothing (hidden)
|
| 192 |
-
if(b.abs.t>=1){ // fully melted: drop it
|
| 193 |
-
const idx=blobs.indexOf(ch);if(idx>-1)blobs.splice(idx,1);
|
| 194 |
-
b.abs=null;
|
| 195 |
-
}
|
| 196 |
-
}
|
| 197 |
-
for(const b of blobs){
|
| 198 |
-
if(!b.bud)continue;
|
| 199 |
-
const u=b.bud;
|
| 200 |
-
if(!blobs.includes(u.child)){b.bud=null;continue;}
|
| 201 |
-
u.t+=dt/(u.dur||BUD_DUR);
|
| 202 |
-
const k=easeIO(Math.min(1,u.t));
|
| 203 |
-
b.r=lerp(u.rFrom,u.rTo,k);
|
| 204 |
-
u.child.r=u.r2*lerp(0.15,1,k);
|
| 205 |
-
if(u.t>=1){ // free now - keep them from re-fusing mid-drift
|
| 206 |
-
u.child.budding=false;u.child.partner=b;b.partner=u.child;b.bud=null;
|
| 207 |
-
u.child.splitAge=0; // start the grace period timer
|
| 208 |
-
}
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
// coalesce: deeply overlapping blobs start a slow absorption (no instant splice)
|
| 212 |
-
for(let i=0;i<blobs.length;i++)for(let j=i+1;j<blobs.length;j++){
|
| 213 |
-
const a=blobs[i],c=blobs[j];
|
| 214 |
-
if(a.abs||a.childOf||c.abs||c.childOf||a.bud||c.bud||a.budding||c.budding)continue;
|
| 215 |
-
// protect newly split-off blobs from immediate absorption (3s grace period)
|
| 216 |
-
if((a.splitAge!==undefined&&a.splitAge<3)||(c.splitAge!==undefined&&c.splitAge<3))continue;
|
| 217 |
-
// don't absorb very small blobs (they're still establishing themselves)
|
| 218 |
-
if(a.r<12||c.r<12)continue;
|
| 219 |
-
if((a.partner&&a.partner===c)||(c.partner&&c.partner===a))continue;
|
| 220 |
-
const dx=c.x-a.x,dy=c.y-a.y,d=Math.hypot(dx,dy)||0.001,rs=a.r+c.r;
|
| 221 |
-
if(d<rs*0.32){
|
| 222 |
-
const va=a.r**3,vc=c.r**3,v=va+vc;
|
| 223 |
-
let P=vc>va?c:a,S=P===a?c:a;
|
| 224 |
-
const wp=P===a?va:vc,ws=S===a?va:vc;
|
| 225 |
-
P.vx=(P.vx*wp+S.vx*ws)/v;P.vy=(P.vy*wp+S.vy*ws)/v;
|
| 226 |
-
P.temp=(P.temp*wp+S.temp*ws)/v;
|
| 227 |
-
S.childOf=P;S.r0=S.r;
|
| 228 |
-
P.abs={child:S,t:0,rFrom:P.r,rTo:Math.min(70,Math.cbrt(v)),
|
| 229 |
-
cx:(a.x*va+c.x*vc)/v,cy:(a.y*va+c.y*vc)/v};
|
| 230 |
-
}else if(d<rs*1.04){ // gentle cohesion pull (only when nearly touching)
|
| 231 |
-
const f=(1-d/(rs*1.04))*2,ux=dx/d,uy=dy/d;
|
| 232 |
-
a.vx+=ux*f*dt*a.r/45;a.vy+=uy*f*dt*a.r/45;
|
| 233 |
-
c.vx-=ux*f*dt*c.r/45;c.vy-=uy*f*dt*c.r/45;
|
| 234 |
-
}
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
// heavy, cold mega-blobs shed a droplet that SWELLS out of them (no instant split)
|
| 238 |
-
for(const b of blobs){
|
| 239 |
-
if(b.abs||b.childOf||b.bud)continue;
|
| 240 |
-
if(b.r>58&&b.temp<0.5&&Math.random()<dt*0.7){
|
| 241 |
-
const r2=b.r*0.48,ang=Math.random()*Math.PI*2;
|
| 242 |
-
const nx=b.x+Math.cos(ang)*b.r*0.72,ny=b.y+Math.sin(ang)*b.r*0.72;
|
| 243 |
-
if(halfWidth(ny)-r2>10&&ny>LIQ_TOP+r2+6&&ny<LIQ_BOT-r2-6){
|
| 244 |
-
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};
|
| 245 |
-
blobs.push(child);
|
| 246 |
-
b.bud={child,t:0,rFrom:b.r,rTo:Math.max(34,Math.cbrt(b.r**3-r2**3)),r2};
|
| 247 |
-
}
|
| 248 |
-
}
|
| 249 |
-
}
|
| 250 |
-
|
| 251 |
-
// boiling: a blob sitting right on the heater, hot enough, fizzes off tiny
|
| 252 |
-
// droplets from its lower edge like it's hitting its bubbling point
|
| 253 |
-
for(const b of blobs){
|
| 254 |
-
if(b.abs||b.childOf||b.bud)continue;
|
| 255 |
-
const er=b.r*WAXR;
|
| 256 |
-
const botY=LIQ_BOT-er*0.9-6; // this blob's floor (size-dependent)
|
| 257 |
-
if(b.y>botY-16&&b.temp>0.45&&b.r>20&&Math.random()<dt*(b.temp-0.3)*5){
|
| 258 |
-
const r2=b.r*(0.13+Math.random()*0.09); // small fizz droplet
|
| 259 |
-
const ang=Math.PI/2+(Math.random()-0.5)*1.6; // lower arc of the blob
|
| 260 |
-
const nx=b.x+Math.cos(ang)*b.r*0.86,ny=b.y+Math.sin(ang)*b.r*0.86;
|
| 261 |
-
if(halfWidth(ny)-r2>4&&ny<LIQ_BOT-r2-3){
|
| 262 |
-
const child={x:nx,y:ny,vx:b.vx+Math.cos(ang)*(10+Math.random()*8),vy:-5-Math.random()*6,r:r2*0.15,temp:Math.min(1,b.temp+0.05),seed:Math.random()*1000,budding:true};
|
| 263 |
-
blobs.push(child);
|
| 264 |
-
b.bud={child,t:0,dur:0.9,rFrom:b.r,rTo:Math.max(18,Math.cbrt(b.r**3-r2**3)),r2};
|
| 265 |
-
// burst of carbonated bubbles at the boil location
|
| 266 |
-
const count=4+Math.floor(Math.random()*5); // 4-8 bubbles for a bigger burst
|
| 267 |
-
for(let i=0;i<count;i++){
|
| 268 |
-
blobBubs.push({
|
| 269 |
-
x:nx+(Math.random()-0.5)*6,
|
| 270 |
-
y:ny+(Math.random()-0.5)*6,
|
| 271 |
-
vy:-(20+Math.random()*18),
|
| 272 |
-
r:0.7+Math.random()*1.8,
|
| 273 |
-
ph:Math.random()*7,
|
| 274 |
-
wob:2+Math.random()*2
|
| 275 |
-
});
|
| 276 |
-
}
|
| 277 |
-
}
|
| 278 |
-
}
|
| 279 |
-
}
|
| 280 |
-
|
| 281 |
-
for(const b of blobs){
|
| 282 |
-
if(!b.partner)continue;
|
| 283 |
-
const p=b.partner;
|
| 284 |
-
if(Math.hypot(p.x-b.x,p.y-b.y)>(b.r+p.r)*1.25){b.partner=null;p.partner=null;}
|
| 285 |
-
}
|
| 286 |
-
|
| 287 |
-
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();}}
|
| 288 |
-
|
| 289 |
-
// blobs fizz: hotter wax sheds tiny air bubbles off its surface, like
|
| 290 |
-
// carbonation coming out of solution (no interaction with the blobs)
|
| 291 |
-
for(const b of blobs){
|
| 292 |
-
if(Math.random()<dt*(0.08+b.temp*0.3)){
|
| 293 |
-
const rr=b.r*WAXR;
|
| 294 |
-
const ang=Math.random()*Math.PI*2;
|
| 295 |
-
let x=b.x+Math.cos(ang)*rr*(1.02+Math.random()*0.1);
|
| 296 |
-
const y=b.y+Math.sin(ang)*rr*(1.02+Math.random()*0.1);
|
| 297 |
-
const hw=halfWidth(y)-4; // keep inside the glass
|
| 298 |
-
if(Math.abs(x-CX)>hw)x=CX+(x>CX?hw:-hw);
|
| 299 |
-
// spawn a small group of bubbles (like an air pocket bursting)
|
| 300 |
-
const count=2+Math.floor(Math.random()*4); // 2-5 bubbles
|
| 301 |
-
for(let i=0;i<count;i++){
|
| 302 |
-
blobBubs.push({
|
| 303 |
-
x:x+(Math.random()-0.5)*3,
|
| 304 |
-
y:y+(Math.random()-0.5)*3,
|
| 305 |
-
vy:-(16+Math.random()*14),
|
| 306 |
-
r:0.7+Math.random()*1.5,
|
| 307 |
-
ph:Math.random()*7,
|
| 308 |
-
wob:2+Math.random()*2
|
| 309 |
-
});
|
| 310 |
-
}
|
| 311 |
-
}
|
| 312 |
-
}
|
| 313 |
-
for(let i=blobBubs.length-1;i>=0;i--){
|
| 314 |
-
const q=blobBubs[i];
|
| 315 |
-
q.vy-=dt*6; // accelerate upward as it rises
|
| 316 |
-
q.y+=q.vy*dt;
|
| 317 |
-
q.x+=Math.sin(time*3+q.ph)*q.wob*dt; // gentle sideways wiggle
|
| 318 |
-
if(q.y<LIQ_TOP+4||blobBubs.length>120)blobBubs.splice(i,1);
|
| 319 |
-
}
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
-
// ---- metaball rendering (gooey, temperature-tinted glow) ----
|
| 323 |
-
const pad=18,BW=Math.ceil(MAX_HW*2+pad*2),BH=Math.ceil(LIQ_BOT-LIQ_TOP+pad*2);
|
| 324 |
-
const buf=document.createElement('canvas');buf.width=BW;buf.height=BH;
|
| 325 |
-
const bctx=buf.getContext('2d');
|
| 326 |
-
const field=new Float32Array(BW*BH);
|
| 327 |
-
const fieldT=new Float32Array(BW*BH); // strength weighted by wax temperature
|
| 328 |
-
function renderBlobs(){
|
| 329 |
-
field.fill(0);fieldT.fill(0);
|
| 330 |
-
const ox=CX-MAX_HW-pad,oy=LIQ_TOP-pad;
|
| 331 |
-
for(const b of blobs){
|
| 332 |
-
const bx=b.x-ox,by=b.y-oy,rr=b.r*WAXR*1.12;
|
| 333 |
-
// organic undulation: low-frequency noise on the surface so blobs look
|
| 334 |
-
// molten and lumpy instead of perfect circles
|
| 335 |
-
const s1=b.seed,s2=b.seed*1.7+3.1,t1=time*1.6+s1,t2=-time*1.2+s2;
|
| 336 |
-
const R=rr*1.13,R2=R*R;
|
| 337 |
-
const x0=Math.max(0,Math.floor(bx-R)),x1=Math.min(BW-1,Math.ceil(bx+R));
|
| 338 |
-
const y0=Math.max(0,Math.floor(by-R)),y1=Math.min(BH-1,Math.ceil(by+R));
|
| 339 |
-
for(let y=y0;y<=y1;y++){
|
| 340 |
-
const wy1=0.07*y+t1,wy2=-0.09*y+t2;
|
| 341 |
-
for(let x=x0;x<=x1;x++){
|
| 342 |
-
const dx=x-bx,dy=y-by,q=dx*dx+dy*dy;
|
| 343 |
-
if(q>=R2)continue;
|
| 344 |
-
const w=1+0.06*Math.sin(0.11*x+wy1)+0.05*Math.sin(0.17*x+wy2);
|
| 345 |
-
const re=rr*w,re2=re*re;
|
| 346 |
-
if(q<re2){const f=1-q/re2;const idx=y*BW+x;field[idx]+=f;fieldT[idx]+=f*b.temp;}
|
| 347 |
-
}
|
| 348 |
-
}
|
| 349 |
-
}
|
| 350 |
-
const img=bctx.createImageData(BW,BH),d=img.data;
|
| 351 |
-
for(let i=0;i<BW*BH;i++){
|
| 352 |
-
const f=field[i];let r=0,g=0,bl=0,a=0;
|
| 353 |
-
if(f>0.52){ // molten core
|
| 354 |
-
const k=Math.min(1,(f-0.52)/1.15);
|
| 355 |
-
const t=f>0?fieldT[i]/f:0; // avg wax temperature here
|
| 356 |
-
r=(90+165*k)*(0.8+t*0.35);
|
| 357 |
-
g=(8+Math.pow(k,1.8)*150)*(0.55+t*0.6);
|
| 358 |
-
bl=(34-Math.pow(k,2)*14+k*k*70)*(0.6+t*0.5);
|
| 359 |
-
// white-hot core for very hot, deep wax (glowing from within)
|
| 360 |
-
// gradual transition to avoid hard-edged blipping points
|
| 361 |
-
if(f>1.0&&t>0.25){
|
| 362 |
-
const w=Math.min(1,(f-1.0)/1.6)*Math.min(1,(t-0.25)/0.8)*0.45;
|
| 363 |
-
r+=(255-r)*w;g+=(235-g)*w;bl+=(210-bl)*w;
|
| 364 |
-
}
|
| 365 |
-
a=255*Math.min(1,(f-0.52)/0.35);
|
| 366 |
-
}else if(f>0.3){ // soft red halo
|
| 367 |
-
const k=(f-0.3)/0.22;
|
| 368 |
-
r=255;g=60*k;bl=40*k;a=110*k*k;
|
| 369 |
-
}
|
| 370 |
-
if(rgbMix>0&&a>0){ // re-light the wax by the current lamp colour (50% blend)
|
| 371 |
-
const lum=0.299*r+0.587*g+0.114*bl;
|
| 372 |
-
if(lum>1){const s=Math.min(1.45,lum/LUM_L);
|
| 373 |
-
const waxBlend=0.25*rgbMix; // only blend 25% toward RGB (halved)
|
| 374 |
-
r+=(LIGHT.r*s-r)*waxBlend;g+=(LIGHT.g*s-g)*waxBlend;bl+=(LIGHT.b*s-bl)*waxBlend;}
|
| 375 |
-
}
|
| 376 |
-
// soft tone map to prevent overexposure blips (like a camera sensor flattening)
|
| 377 |
-
const tone=v=>v<=230?v:230+25*(1-Math.exp(-(v-230)/8));
|
| 378 |
-
d[i*4]=tone(r);d[i*4+1]=tone(g);d[i*4+2]=tone(bl);d[i*4+3]=a;
|
| 379 |
-
}
|
| 380 |
-
bctx.putImageData(img,0,0);
|
| 381 |
-
}
|
| 382 |
-
|
| 383 |
-
// ---- drawing helpers ----
|
| 384 |
-
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;}
|
| 385 |
-
function drawBackground(){
|
| 386 |
-
const g=ctx.createRadialGradient(CX,H*0.42,60,CX,H*0.42,H*0.75);
|
| 387 |
-
g.addColorStop(0,'#181026');g.addColorStop(1,'#05030a');
|
| 388 |
-
ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
|
| 389 |
-
// warm light pool from the lamp onto the room (scaled down in RGB mode)
|
| 390 |
-
const glowScale=1-rgbMix*0.85; // reduce additive glows by 85% in RGB mode (halved effect)
|
| 391 |
-
ctx.save();ctx.globalCompositeOperation='lighter';
|
| 392 |
-
const lg=ctx.createRadialGradient(CX,H-40,20,CX,H-40,H*0.62);
|
| 393 |
-
lg.addColorStop(0,rgba(LIGHT,0.16*glowScale));lg.addColorStop(0.5,rgba(LIGHT,0.05*glowScale));lg.addColorStop(1,rgba(LIGHT,0));
|
| 394 |
-
ctx.fillStyle=lg;ctx.fillRect(0,0,W,H);ctx.restore();
|
| 395 |
-
ctx.save();ctx.globalAlpha=0.55;ctx.fillStyle='#000';
|
| 396 |
-
ctx.beginPath();ctx.ellipse(CX,H-24,190,26,0,0,Math.PI*2);ctx.fill();ctx.restore();
|
| 397 |
-
}
|
| 398 |
-
function drawLED(){
|
| 399 |
-
const glowScale=1-rgbMix*0.85; // reduce additive glows by 85% in RGB mode (halved effect)
|
| 400 |
-
ctx.save();ctx.globalCompositeOperation='lighter';
|
| 401 |
-
const g=ctx.createRadialGradient(CX,H-58,4,CX,H-58,150);
|
| 402 |
-
g.addColorStop(0,rgba(LIGHT,0.38*glowScale));g.addColorStop(1,rgba(LIGHT,0));
|
| 403 |
-
ctx.fillStyle=g;ctx.fillRect(CX-170,H-210,340,210);ctx.restore();
|
| 404 |
-
}
|
| 405 |
-
function drawCap(){
|
| 406 |
-
const g=metalGrad(0,84,0,132,'#151022','#4d3c6b');
|
| 407 |
-
ctx.fillStyle=g;
|
| 408 |
-
ctx.beginPath();ctx.moveTo(CX-NECK_HW-6,127);ctx.quadraticCurveTo(CX,76,CX+NECK_HW+6,127);ctx.closePath();ctx.fill();
|
| 409 |
-
const rim=ctx.createLinearGradient(0,118,0,132);
|
| 410 |
-
rim.addColorStop(0,'#5a4880');rim.addColorStop(1,'#191226');
|
| 411 |
-
ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-NECK_HW-8,118,(NECK_HW+8)*2,13,6);ctx.fill();
|
| 412 |
-
}
|
| 413 |
-
function drawBase(){
|
| 414 |
-
const body=new Path2D();
|
| 415 |
-
body.moveTo(CX-MIN_HW-14,659);
|
| 416 |
-
body.quadraticCurveTo(CX-MAX_HW-40,H-70,CX-MAX_HW-52,H-38);
|
| 417 |
-
body.quadraticCurveTo(CX-MAX_HW-52,H-20,CX-MAX_HW-30,H-18);
|
| 418 |
-
body.lineTo(CX+MAX_HW+30,H-18);
|
| 419 |
-
body.quadraticCurveTo(CX+MAX_HW+52,H-20,CX+MAX_HW+52,H-38);
|
| 420 |
-
body.quadraticCurveTo(CX+MAX_HW+40,H-70,CX+MIN_HW+14,659);
|
| 421 |
-
body.closePath();
|
| 422 |
-
ctx.save();ctx.clip(body);
|
| 423 |
-
const g=ctx.createLinearGradient(0,659,0,H);g.addColorStop(0,'#3c2d58');g.addColorStop(0.35,'#241736');g.addColorStop(1,'#0c0716');
|
| 424 |
-
ctx.fillStyle=g;ctx.fillRect(0,650,W,H-650);
|
| 425 |
-
const sheen=ctx.createLinearGradient(CX-MAX_HW-52,0,CX+MAX_HW+52,0);
|
| 426 |
-
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)');
|
| 427 |
-
ctx.fillStyle=sheen;ctx.fillRect(0,650,W,H-650);
|
| 428 |
-
ctx.restore();
|
| 429 |
-
const rim=ctx.createLinearGradient(0,645,0,661);
|
| 430 |
-
rim.addColorStop(0,'#6a5590');rim.addColorStop(1,'#171026');
|
| 431 |
-
ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-MIN_HW-16,645,(MIN_HW+16)*2,16,8);ctx.fill();
|
| 432 |
-
const pulse=0.75+0.25*Math.sin(time*2);
|
| 433 |
-
const ledR=ledHover?4.3:3.4;
|
| 434 |
-
ctx.save();ctx.shadowColor=rgba(LIGHT,0.9);ctx.shadowBlur=12*pulse*(ledHover?1.6:1);
|
| 435 |
-
ctx.fillStyle=rgba(LIGHT,Math.min(1,0.85*pulse*(ledHover?1.3:1)));ctx.beginPath();ctx.arc(CX,H-52,ledR,0,Math.PI*2);ctx.fill();
|
| 436 |
-
ctx.fillStyle=rgba(mixWhite(LIGHT,0.6),0.9*pulse*(ledHover?1.15:1));ctx.beginPath();ctx.arc(CX,H-52,ledR*0.45,0,Math.PI*2);ctx.fill();
|
| 437 |
-
ctx.restore();
|
| 438 |
-
}
|
| 439 |
-
function drawSlider(){
|
| 440 |
-
const kx=SL.x-SL.w/2+slF*SL.w;
|
| 441 |
-
ctx.save();
|
| 442 |
-
// groove
|
| 443 |
-
ctx.fillStyle='rgba(0,0,0,0.55)';
|
| 444 |
-
ctx.beginPath();ctx.roundRect(SL.x-SL.w/2,SL.y-1.75,SL.w,3.5,1.75);ctx.fill();
|
| 445 |
-
ctx.strokeStyle='rgba(255,255,255,0.07)';ctx.lineWidth=1;
|
| 446 |
-
ctx.beginPath();ctx.roundRect(SL.x-SL.w/2+0.5,SL.y-1.3,SL.w-1,2.6,1.3);ctx.stroke();
|
| 447 |
-
// knob, echoing the LED above it
|
| 448 |
-
const r=(slDrag||slHover)?5:4;
|
| 449 |
-
ctx.shadowColor=rgba(LIGHT,0.9);
|
| 450 |
-
ctx.shadowBlur=slDrag?15:(slHover?11:7);
|
| 451 |
-
const g=ctx.createRadialGradient(kx,SL.y,0.5,kx,SL.y,r+1.5);
|
| 452 |
-
g.addColorStop(0,'rgba(255,240,225,0.95)');
|
| 453 |
-
g.addColorStop(0.45,rgba(LIGHT,0.85));
|
| 454 |
-
g.addColorStop(1,rgba(LIGHT,0));
|
| 455 |
-
ctx.fillStyle=g;ctx.beginPath();ctx.arc(kx,SL.y,r+1.5,0,Math.PI*2);ctx.fill();
|
| 456 |
-
ctx.restore();
|
| 457 |
-
}
|
| 458 |
-
|
| 459 |
-
// film grain overlay - subtle animated noise for that analog feel (larger grains)
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
ctx.
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
const
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
}
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
ctx.
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
const
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
ctx.
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
//
|
| 571 |
-
const
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
ctx.
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
ctx.
|
| 581 |
-
ctx.
|
| 582 |
-
ctx.
|
| 583 |
-
ctx.
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
function
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>Lava Lamp</title>
|
| 7 |
+
<style>
|
| 8 |
+
html,body{margin:0;height:100%;background:#05030a;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
| 9 |
+
canvas{width:min(480px,92vw);height:auto;aspect-ratio:480/760;border-radius:14px;background:#05030a;touch-action:none}
|
| 10 |
+
</style>
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<canvas id="c" tabindex="0" aria-label="Animated lava lamp. Press space to toggle colour mode; use left and right arrow keys to adjust the wax amount."></canvas>
|
| 14 |
+
<script>
|
| 15 |
+
"use strict";
|
| 16 |
+
// polyfill for older browsers (ctx.roundRect) so the lamp never crashes
|
| 17 |
+
if(!CanvasRenderingContext2D.prototype.roundRect){
|
| 18 |
+
CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){
|
| 19 |
+
if(typeof r==='number')r={x:r,y:r};
|
| 20 |
+
else if(!r)r={x:0,y:0};
|
| 21 |
+
const k=0.5523,rx=r.x,ry=r.y;
|
| 22 |
+
this.moveTo(x+rx,y);
|
| 23 |
+
this.lineTo(x+w-rx,y);
|
| 24 |
+
this.bezierCurveTo(x+w-rx+rx*k,y,x+w,y+ry-ry*k,x+w,y+ry);
|
| 25 |
+
this.lineTo(x+w,y+h-ry);
|
| 26 |
+
this.bezierCurveTo(x+w,y+h-ry+ry*k,x+w-rx+rx*k,y+h,x+w-rx,y+h);
|
| 27 |
+
this.lineTo(x+rx,y+h);
|
| 28 |
+
this.bezierCurveTo(x+rx-rx*k,y+h,x,y+h-ry+ry*k,x,y+h-ry);
|
| 29 |
+
this.lineTo(x,y+ry);
|
| 30 |
+
this.bezierCurveTo(x,y+ry-ry*k,x+rx-rx*k,y,x+rx,y);
|
| 31 |
+
this.closePath();
|
| 32 |
+
return this;
|
| 33 |
+
};
|
| 34 |
+
}
|
| 35 |
+
const W=480,H=760,CX=W/2;
|
| 36 |
+
const canvas=document.getElementById('c');
|
| 37 |
+
const ctx=canvas.getContext('2d');
|
| 38 |
+
let DPR=1;
|
| 39 |
+
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);}
|
| 40 |
+
fit();addEventListener('resize',fit);
|
| 41 |
+
|
| 42 |
+
// ---- lamp geometry (px) ----
|
| 43 |
+
const LIQ_TOP=175,LIQ_BOT=632;
|
| 44 |
+
const MIN_HW=64,MAX_HW=122; // tube half-widths: neck vs belly
|
| 45 |
+
const NECK_HW=MIN_HW-10;
|
| 46 |
+
function halfWidth(y){ // interior half-width of the liquid column
|
| 47 |
+
const t=Math.max(0,Math.min(1,(y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP)));
|
| 48 |
+
return MIN_HW+(MAX_HW-MIN_HW)*Math.pow(Math.sin(Math.PI*t),0.9);
|
| 49 |
+
}
|
| 50 |
+
function glassHalf(y){
|
| 51 |
+
if(y<LIQ_TOP) return NECK_HW+4;
|
| 52 |
+
if(y>LIQ_BOT) return halfWidth(LIQ_BOT)+4;
|
| 53 |
+
return halfWidth(y)+5;
|
| 54 |
+
}
|
| 55 |
+
const liquidClip=(()=>{ // clip path for everything inside the tube
|
| 56 |
+
const p=new Path2D(),N=60;
|
| 57 |
+
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);}
|
| 58 |
+
for(let i=N;i>=0;i--){const y=LIQ_TOP+(LIQ_BOT-LIQ_TOP)*i/N;p.lineTo(CX+halfWidth(y)+3,y);}
|
| 59 |
+
p.closePath();return p;
|
| 60 |
+
})();
|
| 61 |
+
function glassPath(){
|
| 62 |
+
const p=new Path2D(),y0=126,y1=650,pts=[];
|
| 63 |
+
for(let y=y0;y<=y1;y+=4)pts.push(y);
|
| 64 |
+
p.moveTo(CX-glassHalf(pts[0]),pts[0]);
|
| 65 |
+
for(const y of pts)p.lineTo(CX-glassHalf(y),y);
|
| 66 |
+
for(let i=pts.length-1;i>=0;i--)p.lineTo(CX+glassHalf(pts[i]),pts[i]);
|
| 67 |
+
p.closePath();return p;
|
| 68 |
+
}
|
| 69 |
+
const glass=glassPath();
|
| 70 |
+
|
| 71 |
+
// ---- blobs ----
|
| 72 |
+
const blobs=[];
|
| 73 |
+
function spawn(x,y,r,temp){blobs.push({x,y,vx:0,vy:0,r,temp,seed:Math.random()*1000});}
|
| 74 |
+
const startT=[0.92,0.85,0.35,0.22,0.62,0.75];
|
| 75 |
+
for(let i=0;i<6;i++){
|
| 76 |
+
const y=LIQ_TOP+60+Math.random()*(LIQ_BOT-LIQ_TOP-130);
|
| 77 |
+
let r=34+Math.random()*26;
|
| 78 |
+
r=Math.min(r,Math.max(30,halfWidth(y)-14));
|
| 79 |
+
spawn(CX+(Math.random()*2-1)*(halfWidth(y)-r-8),y,r,startT[i]);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// ---- bubbles (tiny, for life) ----
|
| 83 |
+
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});
|
| 84 |
+
// carbonation: air bubbles shed from the blobs' surfaces (purely cosmetic)
|
| 85 |
+
const blobBubs=[];
|
| 86 |
+
|
| 87 |
+
// ---- wax amount (unlabeled slider on the base, just under the LED) ----
|
| 88 |
+
// volume multiplier 0.25x..4x; middle = as-is. Radius scales as cbrt(volume).
|
| 89 |
+
let WAXR=1;
|
| 90 |
+
const SL={x:CX,y:728,w:156}; // slider track geometry (canvas px)
|
| 91 |
+
let slF=0.5,slHover=false,slDrag=false;
|
| 92 |
+
function setWaxFromX(px){
|
| 93 |
+
slF=Math.max(0,Math.min(1,(px-SL.x+SL.w/2)/SL.w));
|
| 94 |
+
WAXR=Math.cbrt(Math.pow(2,4*slF-2)); // f: 0→0.25x, 0.5→1x, 1→4x volume
|
| 95 |
+
}
|
| 96 |
+
function overSlider(p){return Math.abs(p.x-SL.x)<SL.w/2+16&&Math.abs(p.y-SL.y)<18;}
|
| 97 |
+
function canvasXY(e){const r=canvas.getBoundingClientRect();return{x:(e.clientX-r.left)*W/r.width,y:(e.clientY-r.top)*H/r.height};}
|
| 98 |
+
canvas.addEventListener('pointerdown',e=>{const p=canvasXY(e);if(overLED(p)){RGB_MODE=!RGB_MODE;e.preventDefault();return;}if(overSlider(p)){slDrag=true;try{canvas.setPointerCapture(e.pointerId);}catch(_){}setWaxFromX(p.x);e.preventDefault();}});
|
| 99 |
+
canvas.addEventListener('pointermove',e=>{const p=canvasXY(e);ledHover=overLED(p);slHover=overSlider(p)&&!ledHover;if(slDrag)setWaxFromX(p.x);});
|
| 100 |
+
const endSlDrag=e=>{if(slDrag){slDrag=false;try{canvas.releasePointerCapture(e.pointerId);}catch(_){}}};
|
| 101 |
+
canvas.addEventListener('pointerup',endSlDrag);
|
| 102 |
+
canvas.addEventListener('pointercancel',endSlDrag);
|
| 103 |
+
// keyboard: space/enter toggles the light, arrows adjust wax (same maths as dragging)
|
| 104 |
+
canvas.addEventListener('keydown',e=>{
|
| 105 |
+
if(e.code==='Space'||e.code==='Enter'){RGB_MODE=!RGB_MODE;e.preventDefault();}
|
| 106 |
+
else if(e.code==='ArrowRight'||e.code==='ArrowUp'){setWaxFromX(SL.x-SL.w/2+Math.min(1,slF+0.06)*SL.w);e.preventDefault();}
|
| 107 |
+
else if(e.code==='ArrowLeft'||e.code==='ArrowDown'){setWaxFromX(SL.x-SL.w/2+Math.max(0,slF-0.06)*SL.w);e.preventDefault();}
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
// ---- clickable LED: toggles a slow RGB colour cycle on the whole lamp ----
|
| 111 |
+
let RGB_MODE=false,rgbMix=0,hue=140; // mix eases 0(default) <-> 1(full RGB)
|
| 112 |
+
const RM=matchMedia('(prefers-reduced-motion: reduce)').matches; // respect OS motion setting
|
| 113 |
+
const HUE_SPEED=(360/16)*(RM?0.5:1); // one full colour cycle every ~16 s (slower for reduced motion)
|
| 114 |
+
const DEF_L={r:255,g:90,b:45}; // the lamp's original warm colour
|
| 115 |
+
const LED_POS={x:CX,y:H-52};
|
| 116 |
+
let ledHover=false;
|
| 117 |
+
function overLED(p){return Math.hypot(p.x-LED_POS.x,p.y-LED_POS.y)<20;}
|
| 118 |
+
function hueRGB(h){ // pleasant saturated RGB from a hue (deg)
|
| 119 |
+
h=((h%360)+360)%360;const s=0.84,v=1,c=v*s,x=c*(1-Math.abs((h/60)%2-1)),m=v-c;
|
| 120 |
+
let r,g,b;
|
| 121 |
+
if(h<60){r=c;g=x;b=0;}else if(h<120){r=x;g=c;b=0;}
|
| 122 |
+
else if(h<180){r=0;g=c;b=x;}else if(h<240){r=0;g=x;b=c;}
|
| 123 |
+
else if(h<300){r=x;g=0;b=c;}else{r=c;g=0;b=x;}
|
| 124 |
+
return{r:Math.round((r+m)*255),g:Math.round((g+m)*255),b:Math.round((b+m)*255)};
|
| 125 |
+
}
|
| 126 |
+
const rgba=(c,a)=>`rgba(${c.r|0},${c.g|0},${c.b|0},${a})`;
|
| 127 |
+
const mixWhite=(c,t)=>({r:c.r+(255-c.r)*t,g:c.g+(255-c.g)*t,b:c.b+(255-c.b)*t});
|
| 128 |
+
// Blend a fixed colour toward the lamp light's hue as RGB mode fades in (rgbMix 0->1)
|
| 129 |
+
const tint=(orig)=>{const t=0.25*rgbMix;return{r:orig.r*(1-t)+LIGHT.r*t,g:orig.g*(1-t)+LIGHT.g*t,b:orig.b*(1-t)+LIGHT.b*t};};
|
| 130 |
+
let LIGHT={...DEF_L},LUM_L=1; // effective lamp colour, updated each frame
|
| 131 |
+
|
| 132 |
+
// ---- physics (px units) ----
|
| 133 |
+
let time=0,last=performance.now();
|
| 134 |
+
const SIM_SPEED=0.55*(RM?0.5:1); // slow, heavy wax (scales all motion + transitions)
|
| 135 |
+
const easeIO=t=>t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;
|
| 136 |
+
const lerp=(a,b,t)=>a+(b-a)*t;
|
| 137 |
+
const ABS_DUR=1.6,BUD_DUR=2.6; // merge/split transition durations (s)
|
| 138 |
+
|
| 139 |
+
function step(dt){
|
| 140 |
+
const midY=(LIQ_TOP+LIQ_BOT)/2;
|
| 141 |
+
for(const b of blobs){
|
| 142 |
+
if(b.splitAge!==undefined)b.splitAge+=dt;
|
| 143 |
+
if(b.childOf)continue; // absorbed child is driven below
|
| 144 |
+
const t=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP);
|
| 145 |
+
const er=b.r*WAXR; // world radius at current wax amount
|
| 146 |
+
const botY=LIQ_BOT-er*0.9-6; // this blob's floor (size-dependent)
|
| 147 |
+
// heater at the bottom: strongest right on the base, so even big blobs
|
| 148 |
+
// resting on their own floor get fully heated; cooler air up top
|
| 149 |
+
if(b.y>botY-38) b.temp+=dt*0.75*(b.y-(botY-38))/38;
|
| 150 |
+
else b.temp-=dt*(0.04+(t<0.3?0.15:0));
|
| 151 |
+
b.temp=Math.max(0,Math.min(1,b.temp));
|
| 152 |
+
// buoyancy: screen y grows downward, so negative ay = rising.
|
| 153 |
+
// hot = light (rises), cold = heavy (sinks); bigger blobs weigh more
|
| 154 |
+
const ay=(0.5-b.temp)*150-(b.r-42)*1.1;
|
| 155 |
+
// melting: cold wax is thick/solid-ish, hot wax melts and flows freely
|
| 156 |
+
const melt=b.temp; // 0=cold(solid) → 1=hot(liquid)
|
| 157 |
+
const drag=(1.35+(b.r-42)*0.012)*(1.35-0.7*melt); // hot = less drag = more fluid
|
| 158 |
+
b.vy+=ay*dt;b.vy*=Math.exp(-dt*drag);
|
| 159 |
+
b.vx*=Math.exp(-dt*drag*0.55); // wax drifts sideways more easily than it falls
|
| 160 |
+
// lateral turbulence: the curved walls shear the column, so blobs at
|
| 161 |
+
// different heights get pushed left/right out of phase with each other
|
| 162 |
+
const tn=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP);
|
| 163 |
+
const flow=Math.sin(time*0.21+tn*4.6+b.seed*0.15)*0.55
|
| 164 |
+
+Math.sin(time*0.13-tn*8.1+b.seed*0.40)*0.30
|
| 165 |
+
+Math.sin(time*0.34+tn*2.3)*0.15;
|
| 166 |
+
b.vx+=(flow*9+Math.sin(time*1.3+b.seed)*4)*dt*(b.r/45)*(0.7+0.6*melt); // liquid wax rides the currents more
|
| 167 |
+
const sp=Math.hypot(b.vx,b.vy);if(sp>140){b.vx*=140/sp;b.vy*=140/sp;}
|
| 168 |
+
b.x+=b.vx*dt;b.y+=b.vy*dt;
|
| 169 |
+
// walls follow the tube profile
|
| 170 |
+
let hw=halfWidth(b.y)-er*0.92-4;
|
| 171 |
+
if(hw<6){hw=6;b.vy+=(midY-b.y)*0.8*dt;} // too fat for the neck: drift to belly
|
| 172 |
+
const lim=Math.abs(b.x-CX);
|
| 173 |
+
if(lim>hw){b.x=CX+Math.sign(b.x-CX)*hw;b.vx*=-0.3;}
|
| 174 |
+
const topY=LIQ_TOP+er*0.9+8;
|
| 175 |
+
if(b.y<topY){b.y=topY;b.vy=Math.max(0,b.vy)*0.4;}
|
| 176 |
+
if(b.y>botY){b.y=botY;b.vy=Math.min(0,b.vy)*0.4;}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// --- smooth transitions: blobs never appear, vanish or split instantly ---
|
| 180 |
+
for(let i=blobs.length-1;i>=0;i--){
|
| 181 |
+
const b=blobs[i];
|
| 182 |
+
if(!b.abs)continue;
|
| 183 |
+
b.abs.t+=dt/ABS_DUR;
|
| 184 |
+
const k=easeIO(Math.min(1,b.abs.t));
|
| 185 |
+
b.r=lerp(b.abs.rFrom,b.abs.rTo,k);
|
| 186 |
+
b.x+=(b.abs.cx-b.x)*Math.min(1,dt*2.4);
|
| 187 |
+
b.y+=(b.abs.cy-b.y)*Math.min(1,dt*2.4);
|
| 188 |
+
const ch=b.abs.child;
|
| 189 |
+
ch.x+=(b.x-ch.x)*Math.min(1,dt*4.5);
|
| 190 |
+
ch.y+=(b.y-ch.y)*Math.min(1,dt*4.5);
|
| 191 |
+
ch.r=ch.r0*(1-k); // shrink to nothing (hidden)
|
| 192 |
+
if(b.abs.t>=1){ // fully melted: drop it
|
| 193 |
+
const idx=blobs.indexOf(ch);if(idx>-1)blobs.splice(idx,1);
|
| 194 |
+
b.abs=null;
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
for(const b of blobs){
|
| 198 |
+
if(!b.bud)continue;
|
| 199 |
+
const u=b.bud;
|
| 200 |
+
if(!blobs.includes(u.child)){b.bud=null;continue;}
|
| 201 |
+
u.t+=dt/(u.dur||BUD_DUR);
|
| 202 |
+
const k=easeIO(Math.min(1,u.t));
|
| 203 |
+
b.r=lerp(u.rFrom,u.rTo,k);
|
| 204 |
+
u.child.r=u.r2*lerp(0.15,1,k);
|
| 205 |
+
if(u.t>=1){ // free now - keep them from re-fusing mid-drift
|
| 206 |
+
u.child.budding=false;u.child.partner=b;b.partner=u.child;b.bud=null;
|
| 207 |
+
u.child.splitAge=0; // start the grace period timer
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// coalesce: deeply overlapping blobs start a slow absorption (no instant splice)
|
| 212 |
+
for(let i=0;i<blobs.length;i++)for(let j=i+1;j<blobs.length;j++){
|
| 213 |
+
const a=blobs[i],c=blobs[j];
|
| 214 |
+
if(a.abs||a.childOf||c.abs||c.childOf||a.bud||c.bud||a.budding||c.budding)continue;
|
| 215 |
+
// protect newly split-off blobs from immediate absorption (3s grace period)
|
| 216 |
+
if((a.splitAge!==undefined&&a.splitAge<3)||(c.splitAge!==undefined&&c.splitAge<3))continue;
|
| 217 |
+
// don't absorb very small blobs (they're still establishing themselves)
|
| 218 |
+
if(a.r<12||c.r<12)continue;
|
| 219 |
+
if((a.partner&&a.partner===c)||(c.partner&&c.partner===a))continue;
|
| 220 |
+
const dx=c.x-a.x,dy=c.y-a.y,d=Math.hypot(dx,dy)||0.001,rs=a.r+c.r;
|
| 221 |
+
if(d<rs*0.32){
|
| 222 |
+
const va=a.r**3,vc=c.r**3,v=va+vc;
|
| 223 |
+
let P=vc>va?c:a,S=P===a?c:a;
|
| 224 |
+
const wp=P===a?va:vc,ws=S===a?va:vc;
|
| 225 |
+
P.vx=(P.vx*wp+S.vx*ws)/v;P.vy=(P.vy*wp+S.vy*ws)/v;
|
| 226 |
+
P.temp=(P.temp*wp+S.temp*ws)/v;
|
| 227 |
+
S.childOf=P;S.r0=S.r;
|
| 228 |
+
P.abs={child:S,t:0,rFrom:P.r,rTo:Math.min(70,Math.cbrt(v)),
|
| 229 |
+
cx:(a.x*va+c.x*vc)/v,cy:(a.y*va+c.y*vc)/v};
|
| 230 |
+
}else if(d<rs*1.04){ // gentle cohesion pull (only when nearly touching)
|
| 231 |
+
const f=(1-d/(rs*1.04))*2,ux=dx/d,uy=dy/d;
|
| 232 |
+
a.vx+=ux*f*dt*a.r/45;a.vy+=uy*f*dt*a.r/45;
|
| 233 |
+
c.vx-=ux*f*dt*c.r/45;c.vy-=uy*f*dt*c.r/45;
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// heavy, cold mega-blobs shed a droplet that SWELLS out of them (no instant split)
|
| 238 |
+
for(const b of blobs){
|
| 239 |
+
if(b.abs||b.childOf||b.bud)continue;
|
| 240 |
+
if(b.r>58&&b.temp<0.5&&Math.random()<dt*0.7){
|
| 241 |
+
const r2=b.r*0.48,ang=Math.random()*Math.PI*2;
|
| 242 |
+
const nx=b.x+Math.cos(ang)*b.r*0.72,ny=b.y+Math.sin(ang)*b.r*0.72;
|
| 243 |
+
if(halfWidth(ny)-r2>10&&ny>LIQ_TOP+r2+6&&ny<LIQ_BOT-r2-6){
|
| 244 |
+
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};
|
| 245 |
+
blobs.push(child);
|
| 246 |
+
b.bud={child,t:0,rFrom:b.r,rTo:Math.max(34,Math.cbrt(b.r**3-r2**3)),r2};
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// boiling: a blob sitting right on the heater, hot enough, fizzes off tiny
|
| 252 |
+
// droplets from its lower edge like it's hitting its bubbling point
|
| 253 |
+
for(const b of blobs){
|
| 254 |
+
if(b.abs||b.childOf||b.bud)continue;
|
| 255 |
+
const er=b.r*WAXR;
|
| 256 |
+
const botY=LIQ_BOT-er*0.9-6; // this blob's floor (size-dependent)
|
| 257 |
+
if(b.y>botY-16&&b.temp>0.45&&b.r>20&&Math.random()<dt*(b.temp-0.3)*5){
|
| 258 |
+
const r2=b.r*(0.13+Math.random()*0.09); // small fizz droplet
|
| 259 |
+
const ang=Math.PI/2+(Math.random()-0.5)*1.6; // lower arc of the blob
|
| 260 |
+
const nx=b.x+Math.cos(ang)*b.r*0.86,ny=b.y+Math.sin(ang)*b.r*0.86;
|
| 261 |
+
if(halfWidth(ny)-r2>4&&ny<LIQ_BOT-r2-3){
|
| 262 |
+
const child={x:nx,y:ny,vx:b.vx+Math.cos(ang)*(10+Math.random()*8),vy:-5-Math.random()*6,r:r2*0.15,temp:Math.min(1,b.temp+0.05),seed:Math.random()*1000,budding:true};
|
| 263 |
+
blobs.push(child);
|
| 264 |
+
b.bud={child,t:0,dur:0.9,rFrom:b.r,rTo:Math.max(18,Math.cbrt(b.r**3-r2**3)),r2};
|
| 265 |
+
// burst of carbonated bubbles at the boil location
|
| 266 |
+
const count=4+Math.floor(Math.random()*5); // 4-8 bubbles for a bigger burst
|
| 267 |
+
for(let i=0;i<count;i++){
|
| 268 |
+
blobBubs.push({
|
| 269 |
+
x:nx+(Math.random()-0.5)*6,
|
| 270 |
+
y:ny+(Math.random()-0.5)*6,
|
| 271 |
+
vy:-(20+Math.random()*18),
|
| 272 |
+
r:0.7+Math.random()*1.8,
|
| 273 |
+
ph:Math.random()*7,
|
| 274 |
+
wob:2+Math.random()*2
|
| 275 |
+
});
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
for(const b of blobs){
|
| 282 |
+
if(!b.partner)continue;
|
| 283 |
+
const p=b.partner;
|
| 284 |
+
if(Math.hypot(p.x-b.x,p.y-b.y)>(b.r+p.r)*1.25){b.partner=null;p.partner=null;}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
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();}}
|
| 288 |
+
|
| 289 |
+
// blobs fizz: hotter wax sheds tiny air bubbles off its surface, like
|
| 290 |
+
// carbonation coming out of solution (no interaction with the blobs)
|
| 291 |
+
for(const b of blobs){
|
| 292 |
+
if(Math.random()<dt*(0.08+b.temp*0.3)){
|
| 293 |
+
const rr=b.r*WAXR;
|
| 294 |
+
const ang=Math.random()*Math.PI*2;
|
| 295 |
+
let x=b.x+Math.cos(ang)*rr*(1.02+Math.random()*0.1);
|
| 296 |
+
const y=b.y+Math.sin(ang)*rr*(1.02+Math.random()*0.1);
|
| 297 |
+
const hw=halfWidth(y)-4; // keep inside the glass
|
| 298 |
+
if(Math.abs(x-CX)>hw)x=CX+(x>CX?hw:-hw);
|
| 299 |
+
// spawn a small group of bubbles (like an air pocket bursting)
|
| 300 |
+
const count=2+Math.floor(Math.random()*4); // 2-5 bubbles
|
| 301 |
+
for(let i=0;i<count;i++){
|
| 302 |
+
blobBubs.push({
|
| 303 |
+
x:x+(Math.random()-0.5)*3,
|
| 304 |
+
y:y+(Math.random()-0.5)*3,
|
| 305 |
+
vy:-(16+Math.random()*14),
|
| 306 |
+
r:0.7+Math.random()*1.5,
|
| 307 |
+
ph:Math.random()*7,
|
| 308 |
+
wob:2+Math.random()*2
|
| 309 |
+
});
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
for(let i=blobBubs.length-1;i>=0;i--){
|
| 314 |
+
const q=blobBubs[i];
|
| 315 |
+
q.vy-=dt*6; // accelerate upward as it rises
|
| 316 |
+
q.y+=q.vy*dt;
|
| 317 |
+
q.x+=Math.sin(time*3+q.ph)*q.wob*dt; // gentle sideways wiggle
|
| 318 |
+
if(q.y<LIQ_TOP+4||blobBubs.length>120)blobBubs.splice(i,1);
|
| 319 |
+
}
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// ---- metaball rendering (gooey, temperature-tinted glow) ----
|
| 323 |
+
const pad=18,BW=Math.ceil(MAX_HW*2+pad*2),BH=Math.ceil(LIQ_BOT-LIQ_TOP+pad*2);
|
| 324 |
+
const buf=document.createElement('canvas');buf.width=BW;buf.height=BH;
|
| 325 |
+
const bctx=buf.getContext('2d');
|
| 326 |
+
const field=new Float32Array(BW*BH);
|
| 327 |
+
const fieldT=new Float32Array(BW*BH); // strength weighted by wax temperature
|
| 328 |
+
function renderBlobs(){
|
| 329 |
+
field.fill(0);fieldT.fill(0);
|
| 330 |
+
const ox=CX-MAX_HW-pad,oy=LIQ_TOP-pad;
|
| 331 |
+
for(const b of blobs){
|
| 332 |
+
const bx=b.x-ox,by=b.y-oy,rr=b.r*WAXR*1.12;
|
| 333 |
+
// organic undulation: low-frequency noise on the surface so blobs look
|
| 334 |
+
// molten and lumpy instead of perfect circles
|
| 335 |
+
const s1=b.seed,s2=b.seed*1.7+3.1,t1=time*1.6+s1,t2=-time*1.2+s2;
|
| 336 |
+
const R=rr*1.13,R2=R*R;
|
| 337 |
+
const x0=Math.max(0,Math.floor(bx-R)),x1=Math.min(BW-1,Math.ceil(bx+R));
|
| 338 |
+
const y0=Math.max(0,Math.floor(by-R)),y1=Math.min(BH-1,Math.ceil(by+R));
|
| 339 |
+
for(let y=y0;y<=y1;y++){
|
| 340 |
+
const wy1=0.07*y+t1,wy2=-0.09*y+t2;
|
| 341 |
+
for(let x=x0;x<=x1;x++){
|
| 342 |
+
const dx=x-bx,dy=y-by,q=dx*dx+dy*dy;
|
| 343 |
+
if(q>=R2)continue;
|
| 344 |
+
const w=1+0.06*Math.sin(0.11*x+wy1)+0.05*Math.sin(0.17*x+wy2);
|
| 345 |
+
const re=rr*w,re2=re*re;
|
| 346 |
+
if(q<re2){const f=1-q/re2;const idx=y*BW+x;field[idx]+=f;fieldT[idx]+=f*b.temp;}
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
const img=bctx.createImageData(BW,BH),d=img.data;
|
| 351 |
+
for(let i=0;i<BW*BH;i++){
|
| 352 |
+
const f=field[i];let r=0,g=0,bl=0,a=0;
|
| 353 |
+
if(f>0.52){ // molten core
|
| 354 |
+
const k=Math.min(1,(f-0.52)/1.15);
|
| 355 |
+
const t=f>0?fieldT[i]/f:0; // avg wax temperature here
|
| 356 |
+
r=(90+165*k)*(0.8+t*0.35);
|
| 357 |
+
g=(8+Math.pow(k,1.8)*150)*(0.55+t*0.6);
|
| 358 |
+
bl=(34-Math.pow(k,2)*14+k*k*70)*(0.6+t*0.5);
|
| 359 |
+
// white-hot core for very hot, deep wax (glowing from within)
|
| 360 |
+
// gradual transition to avoid hard-edged blipping points
|
| 361 |
+
if(f>1.0&&t>0.25){
|
| 362 |
+
const w=Math.min(1,(f-1.0)/1.6)*Math.min(1,(t-0.25)/0.8)*0.45;
|
| 363 |
+
r+=(255-r)*w;g+=(235-g)*w;bl+=(210-bl)*w;
|
| 364 |
+
}
|
| 365 |
+
a=255*Math.min(1,(f-0.52)/0.35);
|
| 366 |
+
}else if(f>0.3){ // soft red halo
|
| 367 |
+
const k=(f-0.3)/0.22;
|
| 368 |
+
r=255;g=60*k;bl=40*k;a=110*k*k;
|
| 369 |
+
}
|
| 370 |
+
if(rgbMix>0&&a>0){ // re-light the wax by the current lamp colour (50% blend)
|
| 371 |
+
const lum=0.299*r+0.587*g+0.114*bl;
|
| 372 |
+
if(lum>1){const s=Math.min(1.45,lum/LUM_L);
|
| 373 |
+
const waxBlend=0.25*rgbMix; // only blend 25% toward RGB (halved)
|
| 374 |
+
r+=(LIGHT.r*s-r)*waxBlend;g+=(LIGHT.g*s-g)*waxBlend;bl+=(LIGHT.b*s-bl)*waxBlend;}
|
| 375 |
+
}
|
| 376 |
+
// soft tone map to prevent overexposure blips (like a camera sensor flattening)
|
| 377 |
+
const tone=v=>v<=230?v:230+25*(1-Math.exp(-(v-230)/8));
|
| 378 |
+
d[i*4]=tone(r);d[i*4+1]=tone(g);d[i*4+2]=tone(bl);d[i*4+3]=a;
|
| 379 |
+
}
|
| 380 |
+
bctx.putImageData(img,0,0);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
// ---- drawing helpers ----
|
| 384 |
+
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;}
|
| 385 |
+
function drawBackground(){
|
| 386 |
+
const g=ctx.createRadialGradient(CX,H*0.42,60,CX,H*0.42,H*0.75);
|
| 387 |
+
g.addColorStop(0,'#181026');g.addColorStop(1,'#05030a');
|
| 388 |
+
ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
|
| 389 |
+
// warm light pool from the lamp onto the room (scaled down in RGB mode)
|
| 390 |
+
const glowScale=1-rgbMix*0.85; // reduce additive glows by 85% in RGB mode (halved effect)
|
| 391 |
+
ctx.save();ctx.globalCompositeOperation='lighter';
|
| 392 |
+
const lg=ctx.createRadialGradient(CX,H-40,20,CX,H-40,H*0.62);
|
| 393 |
+
lg.addColorStop(0,rgba(LIGHT,0.16*glowScale));lg.addColorStop(0.5,rgba(LIGHT,0.05*glowScale));lg.addColorStop(1,rgba(LIGHT,0));
|
| 394 |
+
ctx.fillStyle=lg;ctx.fillRect(0,0,W,H);ctx.restore();
|
| 395 |
+
ctx.save();ctx.globalAlpha=0.55;ctx.fillStyle='#000';
|
| 396 |
+
ctx.beginPath();ctx.ellipse(CX,H-24,190,26,0,0,Math.PI*2);ctx.fill();ctx.restore();
|
| 397 |
+
}
|
| 398 |
+
function drawLED(){
|
| 399 |
+
const glowScale=1-rgbMix*0.85; // reduce additive glows by 85% in RGB mode (halved effect)
|
| 400 |
+
ctx.save();ctx.globalCompositeOperation='lighter';
|
| 401 |
+
const g=ctx.createRadialGradient(CX,H-58,4,CX,H-58,150);
|
| 402 |
+
g.addColorStop(0,rgba(LIGHT,0.38*glowScale));g.addColorStop(1,rgba(LIGHT,0));
|
| 403 |
+
ctx.fillStyle=g;ctx.fillRect(CX-170,H-210,340,210);ctx.restore();
|
| 404 |
+
}
|
| 405 |
+
function drawCap(){
|
| 406 |
+
const g=metalGrad(0,84,0,132,'#151022','#4d3c6b');
|
| 407 |
+
ctx.fillStyle=g;
|
| 408 |
+
ctx.beginPath();ctx.moveTo(CX-NECK_HW-6,127);ctx.quadraticCurveTo(CX,76,CX+NECK_HW+6,127);ctx.closePath();ctx.fill();
|
| 409 |
+
const rim=ctx.createLinearGradient(0,118,0,132);
|
| 410 |
+
rim.addColorStop(0,'#5a4880');rim.addColorStop(1,'#191226');
|
| 411 |
+
ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-NECK_HW-8,118,(NECK_HW+8)*2,13,6);ctx.fill();
|
| 412 |
+
}
|
| 413 |
+
function drawBase(){
|
| 414 |
+
const body=new Path2D();
|
| 415 |
+
body.moveTo(CX-MIN_HW-14,659);
|
| 416 |
+
body.quadraticCurveTo(CX-MAX_HW-40,H-70,CX-MAX_HW-52,H-38);
|
| 417 |
+
body.quadraticCurveTo(CX-MAX_HW-52,H-20,CX-MAX_HW-30,H-18);
|
| 418 |
+
body.lineTo(CX+MAX_HW+30,H-18);
|
| 419 |
+
body.quadraticCurveTo(CX+MAX_HW+52,H-20,CX+MAX_HW+52,H-38);
|
| 420 |
+
body.quadraticCurveTo(CX+MAX_HW+40,H-70,CX+MIN_HW+14,659);
|
| 421 |
+
body.closePath();
|
| 422 |
+
ctx.save();ctx.clip(body);
|
| 423 |
+
const g=ctx.createLinearGradient(0,659,0,H);g.addColorStop(0,'#3c2d58');g.addColorStop(0.35,'#241736');g.addColorStop(1,'#0c0716');
|
| 424 |
+
ctx.fillStyle=g;ctx.fillRect(0,650,W,H-650);
|
| 425 |
+
const sheen=ctx.createLinearGradient(CX-MAX_HW-52,0,CX+MAX_HW+52,0);
|
| 426 |
+
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)');
|
| 427 |
+
ctx.fillStyle=sheen;ctx.fillRect(0,650,W,H-650);
|
| 428 |
+
ctx.restore();
|
| 429 |
+
const rim=ctx.createLinearGradient(0,645,0,661);
|
| 430 |
+
rim.addColorStop(0,'#6a5590');rim.addColorStop(1,'#171026');
|
| 431 |
+
ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-MIN_HW-16,645,(MIN_HW+16)*2,16,8);ctx.fill();
|
| 432 |
+
const pulse=0.75+0.25*Math.sin(time*2);
|
| 433 |
+
const ledR=ledHover?4.3:3.4;
|
| 434 |
+
ctx.save();ctx.shadowColor=rgba(LIGHT,0.9);ctx.shadowBlur=12*pulse*(ledHover?1.6:1);
|
| 435 |
+
ctx.fillStyle=rgba(LIGHT,Math.min(1,0.85*pulse*(ledHover?1.3:1)));ctx.beginPath();ctx.arc(CX,H-52,ledR,0,Math.PI*2);ctx.fill();
|
| 436 |
+
ctx.fillStyle=rgba(mixWhite(LIGHT,0.6),0.9*pulse*(ledHover?1.15:1));ctx.beginPath();ctx.arc(CX,H-52,ledR*0.45,0,Math.PI*2);ctx.fill();
|
| 437 |
+
ctx.restore();
|
| 438 |
+
}
|
| 439 |
+
function drawSlider(){
|
| 440 |
+
const kx=SL.x-SL.w/2+slF*SL.w;
|
| 441 |
+
ctx.save();
|
| 442 |
+
// groove
|
| 443 |
+
ctx.fillStyle='rgba(0,0,0,0.55)';
|
| 444 |
+
ctx.beginPath();ctx.roundRect(SL.x-SL.w/2,SL.y-1.75,SL.w,3.5,1.75);ctx.fill();
|
| 445 |
+
ctx.strokeStyle='rgba(255,255,255,0.07)';ctx.lineWidth=1;
|
| 446 |
+
ctx.beginPath();ctx.roundRect(SL.x-SL.w/2+0.5,SL.y-1.3,SL.w-1,2.6,1.3);ctx.stroke();
|
| 447 |
+
// knob, echoing the LED above it
|
| 448 |
+
const r=(slDrag||slHover)?5:4;
|
| 449 |
+
ctx.shadowColor=rgba(LIGHT,0.9);
|
| 450 |
+
ctx.shadowBlur=slDrag?15:(slHover?11:7);
|
| 451 |
+
const g=ctx.createRadialGradient(kx,SL.y,0.5,kx,SL.y,r+1.5);
|
| 452 |
+
g.addColorStop(0,'rgba(255,240,225,0.95)');
|
| 453 |
+
g.addColorStop(0.45,rgba(LIGHT,0.85));
|
| 454 |
+
g.addColorStop(1,rgba(LIGHT,0));
|
| 455 |
+
ctx.fillStyle=g;ctx.beginPath();ctx.arc(kx,SL.y,r+1.5,0,Math.PI*2);ctx.fill();
|
| 456 |
+
ctx.restore();
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// film grain overlay - subtle animated noise for that analog feel (larger grains)
|
| 460 |
+
// Film grain: GPU-optimized with full-resolution noise for v14-matching visuals
|
| 461 |
+
// Two separate noise canvases: one small tile for base grain pattern, one full-res for wax grain
|
| 462 |
+
const NOISE_TILE=96;
|
| 463 |
+
const baseNoiseCanvas=document.createElement('canvas');
|
| 464 |
+
baseNoiseCanvas.width=NOISE_TILE;baseNoiseCanvas.height=NOISE_TILE;
|
| 465 |
+
const baseNoiseCtx=baseNoiseCanvas.getContext('2d');
|
| 466 |
+
|
| 467 |
+
const waxNoiseCanvas=document.createElement('canvas');
|
| 468 |
+
const waxNoiseCtx=waxNoiseCanvas.getContext('2d');
|
| 469 |
+
|
| 470 |
+
function regenerateBaseNoise(){
|
| 471 |
+
const nd=baseNoiseCtx.createImageData(NOISE_TILE,NOISE_TILE);
|
| 472 |
+
for(let i=0;i<nd.data.length;i+=4){
|
| 473 |
+
const v=Math.random()*255|0;
|
| 474 |
+
nd.data[i]=v;nd.data[i+1]=v;nd.data[i+2]=v;nd.data[i+3]=255;
|
| 475 |
+
}
|
| 476 |
+
baseNoiseCtx.putImageData(nd,0,0);
|
| 477 |
+
}
|
| 478 |
+
regenerateBaseNoise();
|
| 479 |
+
|
| 480 |
+
// Pre-create grain overlay canvas for base grain (reuse every frame)
|
| 481 |
+
const grainOverlayCanvas=document.createElement('canvas');
|
| 482 |
+
const grainOCtx=grainOverlayCanvas.getContext('2d');
|
| 483 |
+
let baseNoisePattern=null;
|
| 484 |
+
|
| 485 |
+
function drawGrain(){
|
| 486 |
+
const cw=canvas.width,ch=canvas.height;
|
| 487 |
+
|
| 488 |
+
// Regenerate base noise tile for flicker (fast: only 96x96 pixels)
|
| 489 |
+
regenerateBaseNoise();
|
| 490 |
+
baseNoisePattern=baseNoiseCtx.createPattern(baseNoiseCanvas,'repeat');
|
| 491 |
+
|
| 492 |
+
// Size grain overlay canvas if needed
|
| 493 |
+
if(grainOverlayCanvas.width!==cw||grainOverlayCanvas.height!==ch){
|
| 494 |
+
grainOverlayCanvas.width=cw;grainOverlayCanvas.height=ch;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// Clear and draw base grain using pattern fill (GPU-accelerated)
|
| 498 |
+
grainOCtx.clearRect(0,0,cw,ch);
|
| 499 |
+
grainOCtx.globalAlpha=0.06; // Base grain intensity (v15 x 0.25 - reduced by 75%)
|
| 500 |
+
grainOCtx.globalCompositeOperation='source-over';
|
| 501 |
+
grainOCtx.save();
|
| 502 |
+
grainOCtx.translate(Math.random()*NOISE_TILE,Math.random()*NOISE_TILE);
|
| 503 |
+
grainOCtx.fillStyle=baseNoisePattern;
|
| 504 |
+
grainOCtx.fillRect(-NOISE_TILE,-NOISE_TILE,cw+2*NOISE_TILE,ch+2*NOISE_TILE);
|
| 505 |
+
grainOCtx.restore();
|
| 506 |
+
|
| 507 |
+
// Draw base grain overlay to main canvas
|
| 508 |
+
ctx.drawImage(grainOverlayCanvas,0,0);
|
| 509 |
+
|
| 510 |
+
// Wax-masked grain: stronger where wax is (full-res noise for v14 match)
|
| 511 |
+
const bctx=buf.getContext('2d');
|
| 512 |
+
const bw=buf.width,bh=buf.height;
|
| 513 |
+
|
| 514 |
+
// Size and regenerate wax noise canvas at full resolution
|
| 515 |
+
if(waxNoiseCanvas.width!==bw||waxNoiseCanvas.height!==bh){
|
| 516 |
+
waxNoiseCanvas.width=bw;waxNoiseCanvas.height=bh;
|
| 517 |
+
}
|
| 518 |
+
const nd2=waxNoiseCtx.createImageData(bw,bh);
|
| 519 |
+
for(let i=0;i<nd2.data.length;i+=4){
|
| 520 |
+
const v=Math.random()*255|0;
|
| 521 |
+
nd2.data[i]=v;nd2.data[i+1]=v;nd2.data[i+2]=v;nd2.data[i+3]=255;
|
| 522 |
+
}
|
| 523 |
+
waxNoiseCtx.putImageData(nd2,0,0);
|
| 524 |
+
|
| 525 |
+
bctx.save();
|
| 526 |
+
bctx.globalAlpha=0.14; // Wax grain intensity (v15 x 0.25 - reduced by 75%)
|
| 527 |
+
bctx.globalCompositeOperation='source-atop'; // Only affects opaque wax pixels
|
| 528 |
+
bctx.drawImage(waxNoiseCanvas,0,0);
|
| 529 |
+
bctx.restore();
|
| 530 |
+
}
|
| 531 |
+
function drawLiquid(){
|
| 532 |
+
ctx.save();ctx.clip(liquidClip);
|
| 533 |
+
const g=ctx.createLinearGradient(0,LIQ_TOP,0,LIQ_BOT);
|
| 534 |
+
// base liquid follows the lamp light's hue in RGB mode; stays purple by default (almost transparent)
|
| 535 |
+
g.addColorStop(0,rgba(tint({r:35,g:9,b:74}),0.15));
|
| 536 |
+
g.addColorStop(0.5,rgba(tint({r:27,g:6,b:64}),0.15));
|
| 537 |
+
g.addColorStop(1,rgba(tint({r:18,g:3,b:44}),0.15));
|
| 538 |
+
ctx.fillStyle=g;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16);
|
| 539 |
+
|
| 540 |
+
// ---- bottom lamp glow: heat rising up the liquid column (scaled for subtler RGB) ----
|
| 541 |
+
const glowScale=1-rgbMix*0.85; // reduce additive glows by 85% in RGB mode (halved effect)
|
| 542 |
+
ctx.globalCompositeOperation='lighter';
|
| 543 |
+
const hg=ctx.createLinearGradient(0,LIQ_BOT-190,0,LIQ_BOT+10);
|
| 544 |
+
hg.addColorStop(0,rgba(LIGHT,0));
|
| 545 |
+
hg.addColorStop(0.6,rgba(LIGHT,0.05*glowScale));
|
| 546 |
+
hg.addColorStop(1,rgba(LIGHT,0.18*glowScale));
|
| 547 |
+
ctx.fillStyle=hg;ctx.fillRect(CX-MAX_HW-8,LIQ_BOT-190,MAX_HW*2+16,200);
|
| 548 |
+
const led=ctx.createRadialGradient(CX,LIQ_BOT+6,4,CX,LIQ_BOT+6,150);
|
| 549 |
+
led.addColorStop(0,rgba(mixWhite(LIGHT,0.4),0.28*glowScale));
|
| 550 |
+
led.addColorStop(0.35,rgba(LIGHT,0.12*glowScale));
|
| 551 |
+
led.addColorStop(1,rgba(LIGHT,0));
|
| 552 |
+
ctx.fillStyle=led;ctx.fillRect(CX-170,LIQ_BOT-150,340,170);
|
| 553 |
+
|
| 554 |
+
// drifting shimmer
|
| 555 |
+
const sx=CX+Math.sin(time*0.4)*60;
|
| 556 |
+
const sh=ctx.createRadialGradient(sx,(LIQ_TOP+LIQ_BOT)/2,10,sx,(LIQ_TOP+LIQ_BOT)/2,220);
|
| 557 |
+
const shim=tint({r:150,g:90,b:220});
|
| 558 |
+
sh.addColorStop(0,rgba(shim,0.10));sh.addColorStop(1,rgba(shim,0));
|
| 559 |
+
ctx.fillStyle=sh;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16);
|
| 560 |
+
|
| 561 |
+
// bubbles
|
| 562 |
+
ctx.globalCompositeOperation='source-over';
|
| 563 |
+
ctx.fillStyle='rgba(255,240,255,0.14)';
|
| 564 |
+
for(const b of bubbles){
|
| 565 |
+
const y=b.y*(LIQ_BOT-LIQ_TOP)+LIQ_TOP,hw=halfWidth(y)-6;
|
| 566 |
+
const x=CX+(b.x*2-1)*hw+Math.sin(time*2+b.ph)*3;
|
| 567 |
+
ctx.beginPath();ctx.arc(x,y,b.r,0,Math.PI*2);ctx.fill();
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
// carbonation bubbles shed by the blobs (same look, fade so they "pop")
|
| 571 |
+
for(const q of blobBubs){
|
| 572 |
+
ctx.globalAlpha=Math.min(1,(q.y-LIQ_TOP)/30);
|
| 573 |
+
ctx.beginPath();ctx.arc(q.x,q.y,q.r,0,Math.PI*2);ctx.fill();
|
| 574 |
+
}
|
| 575 |
+
ctx.globalAlpha=1;
|
| 576 |
+
|
| 577 |
+
// ---- the lava: diffuse halo + glowing body ----
|
| 578 |
+
renderBlobs();
|
| 579 |
+
const bx=CX-MAX_HW-pad,by=LIQ_TOP-pad;
|
| 580 |
+
ctx.globalCompositeOperation='lighter';
|
| 581 |
+
ctx.globalAlpha=0.16;ctx.drawImage(buf,bx-24,by-24,BW+48,BH+48); // wide soft halo
|
| 582 |
+
ctx.globalAlpha=0.30;ctx.drawImage(buf,bx-10,by-10,BW+20,BH+20); // tighter glow
|
| 583 |
+
ctx.globalCompositeOperation='source-over';
|
| 584 |
+
ctx.globalAlpha=1;ctx.drawImage(buf,bx,by); // wax body
|
| 585 |
+
ctx.restore();
|
| 586 |
+
}
|
| 587 |
+
function drawGlass(){
|
| 588 |
+
ctx.save();
|
| 589 |
+
ctx.fillStyle='rgba(255,255,255,0.03)';ctx.fill(glass);
|
| 590 |
+
const g=ctx.createLinearGradient(CX-MAX_HW-8,0,CX+MAX_HW+8,0);
|
| 591 |
+
g.addColorStop(0,'rgba(255,255,255,0.02)');g.addColorStop(0.15,'rgba(255,255,255,0.22)');
|
| 592 |
+
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)');
|
| 593 |
+
ctx.strokeStyle=g;ctx.lineWidth=2.4;ctx.stroke(glass);
|
| 594 |
+
// subtle curved reflection to suggest glass curvature (skeuomorphic)
|
| 595 |
+
const refl=ctx.createLinearGradient(CX-MAX_HW*0.7,LIQ_TOP,CX-MAX_HW*0.3,LIQ_BOT);
|
| 596 |
+
refl.addColorStop(0,'rgba(255,255,255,0)');
|
| 597 |
+
refl.addColorStop(0.4,'rgba(255,255,255,0.08)');
|
| 598 |
+
refl.addColorStop(0.6,'rgba(255,255,255,0.12)');
|
| 599 |
+
refl.addColorStop(1,'rgba(255,255,255,0)');
|
| 600 |
+
ctx.fillStyle=refl;
|
| 601 |
+
ctx.beginPath();
|
| 602 |
+
// curved highlight on the left side (flipped: now faces left "(" with a touch more curve)
|
| 603 |
+
ctx.moveTo(CX-MAX_HW*0.35,LIQ_TOP+20);
|
| 604 |
+
ctx.quadraticCurveTo(CX-MAX_HW*0.60,(LIQ_TOP+LIQ_BOT)/2,CX-MAX_HW*0.42,LIQ_BOT-30);
|
| 605 |
+
ctx.lineTo(CX-MAX_HW*0.65,LIQ_BOT-20);
|
| 606 |
+
ctx.quadraticCurveTo(CX-MAX_HW*0.80,(LIQ_TOP+LIQ_BOT)/2,CX-MAX_HW*0.58,LIQ_TOP+30);
|
| 607 |
+
ctx.closePath();
|
| 608 |
+
ctx.fill();
|
| 609 |
+
// subtle right-side reflection
|
| 610 |
+
const refl2=ctx.createLinearGradient(CX+MAX_HW*0.4,LIQ_TOP,CX+MAX_HW*0.7,LIQ_BOT);
|
| 611 |
+
refl2.addColorStop(0,'rgba(255,255,255,0)');
|
| 612 |
+
refl2.addColorStop(0.5,'rgba(255,255,255,0.05)');
|
| 613 |
+
refl2.addColorStop(1,'rgba(255,255,255,0)');
|
| 614 |
+
ctx.fillStyle=refl2;
|
| 615 |
+
ctx.beginPath();
|
| 616 |
+
ctx.moveTo(CX+MAX_HW*0.45,LIQ_TOP+40);
|
| 617 |
+
ctx.quadraticCurveTo(CX+MAX_HW*0.6,(LIQ_TOP+LIQ_BOT)/2,CX+MAX_HW*0.5,LIQ_BOT-40);
|
| 618 |
+
ctx.lineTo(CX+MAX_HW*0.38,LIQ_BOT-35);
|
| 619 |
+
ctx.quadraticCurveTo(CX+MAX_HW*0.48,(LIQ_TOP+LIQ_BOT)/2,CX+MAX_HW*0.36,LIQ_TOP+50);
|
| 620 |
+
ctx.closePath();
|
| 621 |
+
ctx.fill();
|
| 622 |
+
ctx.restore();
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
// ---- main loop ----
|
| 626 |
+
function frame(now){
|
| 627 |
+
try{
|
| 628 |
+
const dt=Math.max(0,Math.min(0.05,(now-last)/1000));last=now;time+=dt;
|
| 629 |
+
step(dt*SIM_SPEED);
|
| 630 |
+
// ---- lamp colour: default warm, or slow RGB cycle when the LED is clicked ----
|
| 631 |
+
hue=(hue+dt*HUE_SPEED)%360;
|
| 632 |
+
const tm=RGB_MODE?1:0;
|
| 633 |
+
rgbMix+=(tm-rgbMix)*(1-Math.exp(-dt*2.8));
|
| 634 |
+
if(!RGB_MODE&&rgbMix<0.004)rgbMix=0;
|
| 635 |
+
if(RGB_MODE&&rgbMix>0.996)rgbMix=1;
|
| 636 |
+
const hc=hueRGB(hue);
|
| 637 |
+
LIGHT={r:DEF_L.r+(hc.r-DEF_L.r)*rgbMix,g:DEF_L.g+(hc.g-DEF_L.g)*rgbMix,b:DEF_L.b+(hc.b-DEF_L.b)*rgbMix};
|
| 638 |
+
LUM_L=Math.max(1,0.299*LIGHT.r+0.587*LIGHT.g+0.114*LIGHT.b);
|
| 639 |
+
drawBackground();
|
| 640 |
+
drawLED();
|
| 641 |
+
drawCap();
|
| 642 |
+
drawLiquid();
|
| 643 |
+
drawGlass();
|
| 644 |
+
drawBase();
|
| 645 |
+
drawSlider();
|
| 646 |
+
drawGrain();
|
| 647 |
+
canvas.style.cursor=(slDrag||slHover)?'ew-resize':(ledHover?'pointer':'default');
|
| 648 |
+
}catch(err){
|
| 649 |
+
const d=document.createElement('div');
|
| 650 |
+
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';
|
| 651 |
+
d.textContent='Lava lamp error: '+err.message;
|
| 652 |
+
document.body.appendChild(d);
|
| 653 |
+
throw err;
|
| 654 |
+
}
|
| 655 |
+
requestAnimationFrame(frame);
|
| 656 |
+
}
|
| 657 |
+
requestAnimationFrame(frame);
|
| 658 |
+
</script>
|
| 659 |
+
</body>
|
| 660 |
</html>
|