Download index.html from One/Lamp: direct link, hf CLI and curl.
- Browser
- Download file 35.6 kB
-
https://huggingface.co/spaces/One/Lamp/resolve/main/index.html
- Command line
-
hf download hf://spaces/One/Lamp/index.html
-
curl -L -o index.html https://huggingface.co/spaces/One/Lamp/resolve/main/index.html
35.6 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| <title>Lava Lamp</title> | |
| <style> | |
| html,body{margin:0;height:100%;background:#05030a;display:flex;align-items:center;justify-content:center;overflow:hidden} | |
| canvas{width:min(480px,92vw);height:auto;aspect-ratio:480/760;border-radius:14px;background:#05030a;touch-action:none} | |
| </style> | |
| </head> | |
| <body> | |
| <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> | |
| <script> | |
| ; | |
| // polyfill for older browsers (ctx.roundRect) so the lamp never crashes | |
| if(!CanvasRenderingContext2D.prototype.roundRect){ | |
| CanvasRenderingContext2D.prototype.roundRect=function(x,y,w,h,r){ | |
| if(typeof r==='number')r={x:r,y:r}; | |
| else if(!r)r={x:0,y:0}; | |
| const k=0.5523,rx=r.x,ry=r.y; | |
| this.moveTo(x+rx,y); | |
| this.lineTo(x+w-rx,y); | |
| this.bezierCurveTo(x+w-rx+rx*k,y,x+w,y+ry-ry*k,x+w,y+ry); | |
| this.lineTo(x+w,y+h-ry); | |
| this.bezierCurveTo(x+w,y+h-ry+ry*k,x+w-rx+rx*k,y+h,x+w-rx,y+h); | |
| this.lineTo(x+rx,y+h); | |
| this.bezierCurveTo(x+rx-rx*k,y+h,x,y+h-ry+ry*k,x,y+h-ry); | |
| this.lineTo(x,y+ry); | |
| this.bezierCurveTo(x,y+ry-ry*k,x+rx-rx*k,y,x+rx,y); | |
| this.closePath(); | |
| return this; | |
| }; | |
| } | |
| const W=480,H=760,CX=W/2; | |
| const canvas=document.getElementById('c'); | |
| const ctx=canvas.getContext('2d'); | |
| let DPR=1; | |
| 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);} | |
| fit();addEventListener('resize',fit); | |
| // ---- lamp geometry (px) ---- | |
| const LIQ_TOP=175,LIQ_BOT=632; | |
| const MIN_HW=64,MAX_HW=122; // tube half-widths: neck vs belly | |
| const NECK_HW=MIN_HW-10; | |
| function halfWidth(y){ // interior half-width of the liquid column | |
| const t=Math.max(0,Math.min(1,(y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP))); | |
| return MIN_HW+(MAX_HW-MIN_HW)*Math.pow(Math.sin(Math.PI*t),0.9); | |
| } | |
| function glassHalf(y){ | |
| if(y<LIQ_TOP) return NECK_HW+4; | |
| if(y>LIQ_BOT) return halfWidth(LIQ_BOT)+4; | |
| return halfWidth(y)+5; | |
| } | |
| const liquidClip=(()=>{ // clip path for everything inside the tube | |
| const p=new Path2D(),N=60; | |
| 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);} | |
| for(let i=N;i>=0;i--){const y=LIQ_TOP+(LIQ_BOT-LIQ_TOP)*i/N;p.lineTo(CX+halfWidth(y)+3,y);} | |
| p.closePath();return p; | |
| })(); | |
| function glassPath(){ | |
| const p=new Path2D(),y0=126,y1=650,pts=[]; | |
| for(let y=y0;y<=y1;y+=4)pts.push(y); | |
| p.moveTo(CX-glassHalf(pts[0]),pts[0]); | |
| for(const y of pts)p.lineTo(CX-glassHalf(y),y); | |
| for(let i=pts.length-1;i>=0;i--)p.lineTo(CX+glassHalf(pts[i]),pts[i]); | |
| p.closePath();return p; | |
| } | |
| const glass=glassPath(); | |
| // ---- blobs ---- | |
| const blobs=[]; | |
| function spawn(x,y,r,temp){blobs.push({x,y,vx:0,vy:0,r,temp,seed:Math.random()*1000});} | |
| const startT=[0.92,0.85,0.35,0.22,0.62,0.75]; | |
| for(let i=0;i<6;i++){ | |
| const y=LIQ_TOP+60+Math.random()*(LIQ_BOT-LIQ_TOP-130); | |
| let r=34+Math.random()*26; | |
| r=Math.min(r,Math.max(30,halfWidth(y)-14)); | |
| spawn(CX+(Math.random()*2-1)*(halfWidth(y)-r-8),y,r,startT[i]); | |
| } | |
| // ---- bubbles (tiny, for life) ---- | |
| 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}); | |
| // carbonation: air bubbles shed from the blobs' surfaces (purely cosmetic) | |
| const blobBubs=[]; | |
| // ---- wax amount (unlabeled slider on the base, just under the LED) ---- | |
| // volume multiplier 0.25x..4x; middle = as-is. Radius scales as cbrt(volume). | |
| let WAXR=1; | |
| const SL={x:CX,y:728,w:156}; // slider track geometry (canvas px) | |
| let slF=0.5,slHover=false,slDrag=false; | |
| let slMode='amount'; // 'amount' or 'color' | |
| let slF_amount=0.5; // remembered position for amount mode | |
| let waxHue=0; // 0-360, current wax hue in color mode | |
| // Toggle switch geometry (to the right of the slider) | |
| const TG={x:SL.x+SL.w/2+24,y:SL.y-8,w:32,h:16}; | |
| let tgHover=false; | |
| function setWaxFromX(px){ | |
| slF=Math.max(0,Math.min(1,(px-SL.x+SL.w/2)/SL.w)); | |
| if(slMode==='amount'){ | |
| slF_amount=slF; | |
| WAXR=Math.cbrt(Math.pow(2,4*slF-2)); // f: 0→0.25x, 0.5→1x, 1→4x volume | |
| }else{ | |
| waxHue=slF*360; | |
| } | |
| } | |
| // Compute slider position from current color (for switching back to color mode) | |
| function sliderPosFromColor(){return waxHue/360;} | |
| function overSlider(p){return Math.abs(p.x-SL.x)<SL.w/2+16&&Math.abs(p.y-SL.y)<18;} | |
| function overToggle(p){return p.x>TG.x&&p.x<TG.x+TG.w&&p.y>TG.y&&p.y<TG.y+TG.h;} | |
| function toggleSliderMode(){ | |
| slMode=slMode==='amount'?'color':'amount'; | |
| // Restore remembered position for the target mode | |
| if(slMode==='amount'){ | |
| slF=slF_amount; | |
| WAXR=Math.cbrt(Math.pow(2,4*slF-2)); | |
| }else{ | |
| // Slider reflects current color, not remembered position | |
| slF=sliderPosFromColor(); | |
| } | |
| } | |
| function canvasXY(e){const r=canvas.getBoundingClientRect();return{x:(e.clientX-r.left)*W/r.width,y:(e.clientY-r.top)*H/r.height};} | |
| canvas.addEventListener('pointerdown',e=>{const p=canvasXY(e);if(overLED(p)){RGB_MODE=!RGB_MODE;e.preventDefault();return;}if(overToggle(p)){toggleSliderMode();e.preventDefault();return;}if(overSlider(p)){slDrag=true;try{canvas.setPointerCapture(e.pointerId);}catch(_){}setWaxFromX(p.x);e.preventDefault();}}); | |
| canvas.addEventListener('pointermove',e=>{const p=canvasXY(e);ledHover=overLED(p);slHover=overSlider(p)&&!ledHover;tgHover=overToggle(p)&&!ledHover&&!slHover;if(slDrag)setWaxFromX(p.x);}); | |
| const endSlDrag=e=>{if(slDrag){slDrag=false;try{canvas.releasePointerCapture(e.pointerId);}catch(_){}}}; | |
| canvas.addEventListener('pointerup',endSlDrag); | |
| canvas.addEventListener('pointercancel',endSlDrag); | |
| // keyboard: space/enter toggles the light, arrows adjust wax (same maths as dragging) | |
| canvas.addEventListener('keydown',e=>{ | |
| if(e.code==='Space'||e.code==='Enter'){RGB_MODE=!RGB_MODE;e.preventDefault();} | |
| else if(e.code==='ArrowRight'||e.code==='ArrowUp'){setWaxFromX(SL.x-SL.w/2+Math.min(1,slF+0.06)*SL.w);e.preventDefault();} | |
| else if(e.code==='ArrowLeft'||e.code==='ArrowDown'){setWaxFromX(SL.x-SL.w/2+Math.max(0,slF-0.06)*SL.w);e.preventDefault();} | |
| }); | |
| // ---- clickable LED: toggles a slow RGB colour cycle on the whole lamp ---- | |
| let RGB_MODE=false,rgbMix=0,hue=140; // mix eases 0(default) <-> 1(full RGB) | |
| const RM=matchMedia('(prefers-reduced-motion: reduce)').matches; // respect OS motion setting | |
| const HUE_SPEED=(360/16)*(RM?0.5:1); // one full colour cycle every ~16 s (slower for reduced motion) | |
| const DEF_L={r:255,g:90,b:45}; // the lamp's original warm colour | |
| const LED_POS={x:CX,y:H-52}; | |
| let ledHover=false; | |
| function overLED(p){return Math.hypot(p.x-LED_POS.x,p.y-LED_POS.y)<20;} | |
| function hueRGB(h){ // pleasant saturated RGB from a hue (deg) | |
| 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; | |
| let r,g,b; | |
| if(h<60){r=c;g=x;b=0;}else if(h<120){r=x;g=c;b=0;} | |
| else if(h<180){r=0;g=c;b=x;}else if(h<240){r=0;g=x;b=c;} | |
| else if(h<300){r=x;g=0;b=c;}else{r=c;g=0;b=x;} | |
| return{r:Math.round((r+m)*255),g:Math.round((g+m)*255),b:Math.round((b+m)*255)}; | |
| } | |
| const rgba=(c,a)=>`rgba(${c.r|0},${c.g|0},${c.b|0},${a})`; | |
| 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}); | |
| // Blend a fixed colour toward the lamp light's hue as RGB mode fades in (rgbMix 0->1) | |
| 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};}; | |
| let LIGHT={...DEF_L},LUM_L=1; // effective lamp colour, updated each frame | |
| // ---- physics (px units) ---- | |
| let time=0,last=performance.now(); | |
| const SIM_SPEED=0.55*(RM?0.5:1); // slow, heavy wax (scales all motion + transitions) | |
| const easeIO=t=>t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2; | |
| const lerp=(a,b,t)=>a+(b-a)*t; | |
| const ABS_DUR=1.6,BUD_DUR=2.6; // merge/split transition durations (s) | |
| let bonds=[]; // spring bonds between stuck-together blobs | |
| function step(dt){ | |
| const midY=(LIQ_TOP+LIQ_BOT)/2; | |
| for(const b of blobs){ | |
| if(b.splitAge!==undefined)b.splitAge+=dt; | |
| if(b.childOf)continue; // absorbed child is driven below | |
| const t=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP); | |
| const er=b.r*WAXR; // world radius at current wax amount | |
| const botY=LIQ_BOT-er*0.9-6; // this blob's floor (size-dependent) | |
| // heater at the bottom: strongest right on the base, so even big blobs | |
| // resting on their own floor get fully heated; cooler air up top | |
| if(b.y>botY-38) b.temp+=dt*0.75*(b.y-(botY-38))/38; | |
| else b.temp-=dt*(0.04+(t<0.3?0.15:0)); | |
| b.temp=Math.max(0,Math.min(1,b.temp)); | |
| // buoyancy: screen y grows downward, so negative ay = rising. | |
| // hot = light (rises), cold = heavy (sinks); bigger blobs weigh more | |
| const ay=(0.5-b.temp)*165-(b.r-42)*1.1; | |
| // melting: cold wax is thick/solid-ish, hot wax melts and flows freely | |
| const melt=b.temp; // 0=cold(solid) → 1=hot(liquid) | |
| const drag=(1.35+(b.r-42)*0.012)*(1.35-0.7*melt); // hot = less drag = more fluid | |
| b.vy+=ay*dt;b.vy*=Math.exp(-dt*drag); | |
| b.vx*=Math.exp(-dt*drag*0.55); // wax drifts sideways more easily than it falls | |
| // lateral turbulence: the curved walls shear the column, so blobs at | |
| // different heights get pushed left/right out of phase with each other | |
| const tn=(b.y-LIQ_TOP)/(LIQ_BOT-LIQ_TOP); | |
| const flow=Math.sin(time*0.21+tn*4.6+b.seed*0.15)*0.55 | |
| +Math.sin(time*0.13-tn*8.1+b.seed*0.40)*0.30 | |
| +Math.sin(time*0.34+tn*2.3)*0.15; | |
| 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 | |
| const sp=Math.hypot(b.vx,b.vy);if(sp>140){b.vx*=140/sp;b.vy*=140/sp;} | |
| b.x+=b.vx*dt;b.y+=b.vy*dt; | |
| // walls follow the tube profile | |
| let hw=halfWidth(b.y)-er*0.92-4; | |
| if(hw<6){hw=6;b.vy+=(midY-b.y)*0.8*dt;} // too fat for the neck: drift to belly | |
| const lim=Math.abs(b.x-CX); | |
| if(lim>hw){b.x=CX+Math.sign(b.x-CX)*hw;b.vx*=-0.3;} | |
| const topY=LIQ_TOP+er*0.9+8; | |
| if(b.y<topY){b.y=topY;b.vy=Math.max(0,b.vy)*0.4;} | |
| if(b.y>botY){b.y=botY;b.vy=Math.min(0,b.vy)*0.4;} | |
| } | |
| // --- spring bonds: stuck-together blobs pull each other to stay close --- | |
| for(let i=bonds.length-1;i>=0;i--){ | |
| const bn=bonds[i]; | |
| if(!blobs.includes(bn.a)||!blobs.includes(bn.b)){bonds.splice(i,1);continue;} | |
| const dx=bn.b.x-bn.a.x,dy=bn.b.y-bn.a.y,d=Math.hypot(dx,dy)||0.001; | |
| // spring force: pull back toward ideal distance (they're stuck together) | |
| const f=(d-bn.ideal)*5.25,ux=dx/d,uy=dy/d; | |
| bn.a.vx+=ux*f*dt;bn.a.vy+=uy*f*dt; | |
| bn.b.vx-=ux*f*dt;bn.b.vy-=uy*f*dt; | |
| // break bond if they drift too far apart | |
| if(d>bn.ideal*4.5)bonds.splice(i,1); | |
| } | |
| // --- smooth transitions: blobs never appear, vanish or split instantly --- | |
| for(let i=blobs.length-1;i>=0;i--){ | |
| const b=blobs[i]; | |
| if(!b.abs)continue; | |
| b.abs.t+=dt/ABS_DUR; | |
| const k=easeIO(Math.min(1,b.abs.t)); | |
| b.r=lerp(b.abs.rFrom,b.abs.rTo,k); | |
| b.x+=(b.abs.cx-b.x)*Math.min(1,dt*2.4); | |
| b.y+=(b.abs.cy-b.y)*Math.min(1,dt*2.4); | |
| const ch=b.abs.child; | |
| ch.x+=(b.x-ch.x)*Math.min(1,dt*4.5); | |
| ch.y+=(b.y-ch.y)*Math.min(1,dt*4.5); | |
| ch.r=ch.r0*(1-k); // shrink to nothing (hidden) | |
| if(b.abs.t>=1){ // fully melted: drop it | |
| const idx=blobs.indexOf(ch);if(idx>-1)blobs.splice(idx,1); | |
| for(let bi=bonds.length-1;bi>=0;bi--)if(bonds[bi].a===ch||bonds[bi].b===ch)bonds.splice(bi,1); | |
| b.abs=null; | |
| } | |
| } | |
| for(const b of blobs){ | |
| if(!b.bud)continue; | |
| const u=b.bud; | |
| if(!blobs.includes(u.child)){b.bud=null;continue;} | |
| u.t+=dt/(u.dur||BUD_DUR); | |
| const k=easeIO(Math.min(1,u.t)); | |
| b.r=lerp(u.rFrom,u.rTo,k); | |
| u.child.r=u.r2*lerp(0.15,1,k); | |
| if(u.t>=1){ // free now - keep them from re-fusing mid-drift | |
| u.child.budding=false;u.child.partner=b;b.partner=u.child;b.bud=null; | |
| u.child.splitAge=0; // start the grace period timer | |
| } | |
| } | |
| // coalesce: deeply overlapping blobs start a slow absorption (no instant splice) | |
| for(let i=0;i<blobs.length;i++)for(let j=i+1;j<blobs.length;j++){ | |
| const a=blobs[i],c=blobs[j]; | |
| if(a.abs||a.childOf||c.abs||c.childOf||a.bud||c.bud||a.budding||c.budding)continue; | |
| // protect newly split-off blobs from immediate absorption (3s grace period) | |
| if((a.splitAge!==undefined&&a.splitAge<3)||(c.splitAge!==undefined&&c.splitAge<3))continue; | |
| // allow even tiny droplets to be absorbed so they don't float forever | |
| if(a.r<5||c.r<5)continue; | |
| if((a.partner&&a.partner===c)||(c.partner&&c.partner===a))continue; | |
| const dx=c.x-a.x,dy=c.y-a.y,d=Math.hypot(dx,dy)||0.001,rs=a.r+c.r; | |
| // small blobs that collide get stuck together (bond) instead of absorbing | |
| // but only if BOTH are small - large blobs are greedy and absorb small ones | |
| const bothSmall=a.r<35&&c.r<35; | |
| if(bothSmall&&!bonds.some(b=>(b.a===a&&b.b===c)||(b.a===c&&b.b===a))){ | |
| // very small beads (r<15) get an even wider bond threshold to catch fast-moving passes | |
| const thresh=a.r<15||c.r<15?rs*1.3:rs*1.05; | |
| if(d<thresh)bonds.push({a,b:c,ideal:rs*0.6}); | |
| } | |
| // skip absorption for bonded pairs (they stay as lumpy clusters) | |
| if(bonds.some(b=>(b.a===a&&b.b===c)||(b.a===c&&b.b===a)))continue; | |
| if(d<rs*0.40){ | |
| const va=a.r**3,vc=c.r**3,v=va+vc; | |
| let P=vc>va?c:a,S=P===a?c:a; | |
| const wp=P===a?va:vc,ws=S===a?va:vc; | |
| P.vx=(P.vx*wp+S.vx*ws)/v;P.vy=(P.vy*wp+S.vy*ws)/v; | |
| P.temp=(P.temp*wp+S.temp*ws)/v; | |
| S.childOf=P;S.r0=S.r; | |
| P.abs={child:S,t:0,rFrom:P.r,rTo:Math.min(70,Math.cbrt(v)), | |
| cx:(a.x*va+c.x*vc)/v,cy:(a.y*va+c.y*vc)/v}; | |
| }else if(d<rs*1.08){ // gentle cohesion pull (only when nearly touching) | |
| const f=(1-d/(rs*1.10))*7,ux=dx/d,uy=dy/d; | |
| a.vx+=ux*f*dt*a.r/45;a.vy+=uy*f*dt*a.r/45; | |
| c.vx-=ux*f*dt*c.r/45;c.vy-=uy*f*dt*c.r/45; | |
| } | |
| } | |
| // large blobs split into two big pieces (not strictly equal sized) | |
| // hotter wax is more liquid and splits more readily | |
| for(const b of blobs){ | |
| if(b.abs||b.childOf||b.bud)continue; | |
| if(b.r>45&&Math.random()<dt*(0.8+b.temp*1.5)){ | |
| const r2=b.r*(0.35+Math.random()*0.2),ang=Math.random()*Math.PI*2; | |
| const nx=b.x+Math.cos(ang)*b.r*0.72,ny=b.y+Math.sin(ang)*b.r*0.72; | |
| if(halfWidth(ny)-r2>10&&ny>LIQ_TOP+r2+6&&ny<LIQ_BOT-r2-6){ | |
| 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}; | |
| blobs.push(child); | |
| b.bud={child,t:0,rFrom:b.r,rTo:Math.max(34,Math.cbrt(b.r**3-r2**3)),r2}; | |
| } | |
| } | |
| } | |
| // boiling: a blob sitting right on the heater, hot enough, fizzes off tiny | |
| // droplets from its lower edge like it's hitting its bubbling point | |
| for(const b of blobs){ | |
| if(b.abs||b.childOf||b.bud)continue; | |
| const er=b.r*WAXR; | |
| const botY=LIQ_BOT-er*0.9-6; // this blob's floor (size-dependent) | |
| if(b.y>botY-16&&b.temp>0.45&&b.r>20&&Math.random()<dt*(b.temp-0.3)*0.3125){ // 75% fewer small beads | |
| const r2=b.r*(0.13+Math.random()*0.09); // small fizz droplet | |
| const ang=Math.PI/2+(Math.random()-0.5)*1.6; // lower arc of the blob | |
| const nx=b.x+Math.cos(ang)*b.r*0.86,ny=b.y+Math.sin(ang)*b.r*0.86; | |
| if(halfWidth(ny)-r2>4&&ny<LIQ_BOT-r2-3){ | |
| 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}; | |
| blobs.push(child); | |
| b.bud={child,t:0,dur:0.9,rFrom:b.r,rTo:Math.max(18,Math.cbrt(b.r**3-r2**3)),r2}; | |
| // burst of carbonated bubbles at the boil location | |
| const count=4+Math.floor(Math.random()*5); // 4-8 bubbles for a bigger burst | |
| for(let i=0;i<count;i++){ | |
| blobBubs.push({ | |
| x:nx+(Math.random()-0.5)*6, | |
| y:ny+(Math.random()-0.5)*6, | |
| vy:-(20+Math.random()*18), | |
| r:0.7+Math.random()*1.8, | |
| ph:Math.random()*7, | |
| wob:2+Math.random()*2 | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| for(const b of blobs){ | |
| if(!b.partner)continue; | |
| const p=b.partner; | |
| if(Math.hypot(p.x-b.x,p.y-b.y)>(b.r+p.r)*1.25){b.partner=null;p.partner=null;} | |
| } | |
| 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();}} | |
| // blobs fizz: hotter wax sheds tiny air bubbles off its surface, like | |
| // carbonation coming out of solution (no interaction with the blobs) | |
| // bigger blobs fizz more, tiny bits barely bubble | |
| for(const b of blobs){ | |
| const sizeFactor=0.25+(b.r/50)*0.75; // r=10→0.4, r=30→0.7, r=50→1.0, r=80→1.25 | |
| if(Math.random()<dt*(0.16+b.temp*0.55)*sizeFactor){ // ~1.9x more air bubbles | |
| const rr=b.r*WAXR; | |
| const ang=Math.random()*Math.PI*2; | |
| let x=b.x+Math.cos(ang)*rr*(1.02+Math.random()*0.1); | |
| const y=b.y+Math.sin(ang)*rr*(1.02+Math.random()*0.1); | |
| const hw=halfWidth(y)-4; // keep inside the glass | |
| if(Math.abs(x-CX)>hw)x=CX+(x>CX?hw:-hw); | |
| // spawn a small group of bubbles (like an air pocket bursting) | |
| const count=2+Math.floor(Math.random()*4); // 2-5 bubbles | |
| for(let i=0;i<count;i++){ | |
| blobBubs.push({ | |
| x:x+(Math.random()-0.5)*3, | |
| y:y+(Math.random()-0.5)*3, | |
| vy:-(16+Math.random()*14), | |
| r:0.7+Math.random()*1.5, | |
| ph:Math.random()*7, | |
| wob:2+Math.random()*2 | |
| }); | |
| } | |
| } | |
| } | |
| for(let i=blobBubs.length-1;i>=0;i--){ | |
| const q=blobBubs[i]; | |
| q.vy-=dt*6; // accelerate upward as it rises | |
| q.y+=q.vy*dt; | |
| q.x+=Math.sin(time*3+q.ph)*q.wob*dt; // gentle sideways wiggle | |
| if(q.y<LIQ_TOP+4||blobBubs.length>180)blobBubs.splice(i,1); | |
| } | |
| } | |
| // ---- metaball rendering (gooey, temperature-tinted glow) ---- | |
| const pad=18,BW=Math.ceil(MAX_HW*2+pad*2),BH=Math.ceil(LIQ_BOT-LIQ_TOP+pad*2); | |
| const buf=document.createElement('canvas');buf.width=BW;buf.height=BH; | |
| const bctx=buf.getContext('2d'); | |
| const field=new Float32Array(BW*BH); | |
| const fieldT=new Float32Array(BW*BH); // strength weighted by wax temperature | |
| function renderBlobs(){ | |
| field.fill(0);fieldT.fill(0); | |
| const ox=CX-MAX_HW-pad,oy=LIQ_TOP-pad; | |
| for(const b of blobs){ | |
| const bx=b.x-ox,by=b.y-oy,rr=b.r*WAXR*1.12; | |
| // organic undulation: low-frequency noise on the surface so blobs look | |
| // molten and lumpy instead of perfect circles | |
| const s1=b.seed,s2=b.seed*1.7+3.1,t1=time*1.6+s1,t2=-time*1.2+s2; | |
| const R=rr*1.13,R2=R*R; | |
| const x0=Math.max(0,Math.floor(bx-R)),x1=Math.min(BW-1,Math.ceil(bx+R)); | |
| const y0=Math.max(0,Math.floor(by-R)),y1=Math.min(BH-1,Math.ceil(by+R)); | |
| for(let y=y0;y<=y1;y++){ | |
| const wy1=0.07*y+t1,wy2=-0.09*y+t2; | |
| for(let x=x0;x<=x1;x++){ | |
| const dx=x-bx,dy=y-by,q=dx*dx+dy*dy; | |
| if(q>=R2)continue; | |
| const w=1+0.06*Math.sin(0.11*x+wy1)+0.05*Math.sin(0.17*x+wy2); | |
| const re=rr*w,re2=re*re; | |
| if(q<re2){const f=1-q/re2;const idx=y*BW+x;field[idx]+=f;fieldT[idx]+=f*b.temp;} | |
| } | |
| } | |
| } | |
| const img=bctx.createImageData(BW,BH),d=img.data; | |
| for(let i=0;i<BW*BH;i++){ | |
| const f=field[i];let r=0,g=0,bl=0,a=0; | |
| if(f>0.52){ // molten core | |
| const k=Math.min(1,(f-0.52)/1.15); | |
| const t=f>0?fieldT[i]/f:0; // avg wax temperature here | |
| let baseR=(90+165*k)*(0.8+t*0.35); | |
| let baseG=(8+Math.pow(k,1.8)*150)*(0.55+t*0.6); | |
| let baseB=(34-Math.pow(k,2)*14+k*k*70)*(0.6+t*0.5); | |
| // Always apply selected color (persists across mode switches) | |
| const h=waxHue/60%6,s=0.85,l=0.55; | |
| const c=(1-Math.abs(2*l-1))*s,x=c*(1-Math.abs(h%2-1)),m=l-c/2; | |
| let hr=0,hg=0,hb=0; | |
| if(h<1){hr=c;hg=x;}else if(h<2){hr=x;hg=c;}else if(h<3){hg=c;hb=x;} | |
| else if(h<4){hg=x;hb=c;}else if(h<5){hr=x;hb=c;}else{hr=c;hb=x;} | |
| hr=(hr+m)*255;hg=(hg+m)*255;hb=(hb+m)*255; | |
| // Blend: 70% selected color, 30% temperature glow | |
| r=baseR*0.3+hr*0.7;g=baseG*0.3+hg*0.7;bl=baseB*0.3+hb*0.7; | |
| // white-hot core for very hot, deep wax (glowing from within) | |
| // gradual transition to avoid hard-edged blipping points | |
| if(f>1.0&&t>0.25){ | |
| const w=Math.min(1,(f-1.0)/1.6)*Math.min(1,(t-0.25)/0.8)*0.45; | |
| r+=(255-r)*w;g+=(235-g)*w;bl+=(210-bl)*w; | |
| } | |
| a=255*Math.min(1,(f-0.52)/0.35); | |
| }else if(f>0.3){ // soft halo matching lamp light | |
| const k=(f-0.3)/0.22; | |
| r=LIGHT.r;g=LIGHT.g*k;bl=LIGHT.b*k;a=110*k*k; | |
| } | |
| if(rgbMix>0&&a>0){ // re-light the wax by the current lamp colour (50% blend) | |
| const lum=0.299*r+0.587*g+0.114*bl; | |
| if(lum>1){const s=Math.min(1.45,lum/LUM_L); | |
| const waxBlend=0.25*rgbMix; // only blend 25% toward RGB (halved) | |
| r+=(LIGHT.r*s-r)*waxBlend;g+=(LIGHT.g*s-g)*waxBlend;bl+=(LIGHT.b*s-bl)*waxBlend;} | |
| } | |
| // soft tone map to prevent overexposure blips (like a camera sensor flattening) | |
| const tone=v=>v<=230?v:230+25*(1-Math.exp(-(v-230)/8)); | |
| d[i*4]=tone(r);d[i*4+1]=tone(g);d[i*4+2]=tone(bl);d[i*4+3]=a; | |
| } | |
| bctx.putImageData(img,0,0); | |
| } | |
| // ---- drawing helpers ---- | |
| 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;} | |
| function drawBackground(){ | |
| const g=ctx.createRadialGradient(CX,H*0.42,60,CX,H*0.42,H*0.75); | |
| g.addColorStop(0,'#181026');g.addColorStop(1,'#05030a'); | |
| ctx.fillStyle=g;ctx.fillRect(0,0,W,H); | |
| // warm light pool from the lamp onto the room (scaled down in RGB mode) | |
| const glowScale=1-rgbMix*0.5; // reduce additive glows by 50% in RGB mode | |
| ctx.save();ctx.globalCompositeOperation='lighter'; | |
| const lg=ctx.createRadialGradient(CX,H-40,20,CX,H-40,H*0.62); | |
| lg.addColorStop(0,rgba(LIGHT,0.16*glowScale));lg.addColorStop(0.5,rgba(LIGHT,0.05*glowScale));lg.addColorStop(1,rgba(LIGHT,0)); | |
| ctx.fillStyle=lg;ctx.fillRect(0,0,W,H);ctx.restore(); | |
| ctx.save();ctx.globalAlpha=0.55;ctx.fillStyle='#000'; | |
| ctx.beginPath();ctx.ellipse(CX,H-24,190,26,0,0,Math.PI*2);ctx.fill();ctx.restore(); | |
| } | |
| function drawLED(){ | |
| const glowScale=1-rgbMix*0.5; // reduce additive glows by 50% in RGB mode | |
| ctx.save();ctx.globalCompositeOperation='lighter'; | |
| const g=ctx.createRadialGradient(CX,H-58,4,CX,H-58,150); | |
| g.addColorStop(0,rgba(LIGHT,0.38*glowScale));g.addColorStop(1,rgba(LIGHT,0)); | |
| ctx.fillStyle=g;ctx.fillRect(CX-170,H-210,340,210);ctx.restore(); | |
| } | |
| function drawCap(){ | |
| const g=metalGrad(0,84,0,132,'#151022','#4d3c6b'); | |
| ctx.fillStyle=g; | |
| ctx.beginPath();ctx.moveTo(CX-NECK_HW-6,127);ctx.quadraticCurveTo(CX,76,CX+NECK_HW+6,127);ctx.closePath();ctx.fill(); | |
| const rim=ctx.createLinearGradient(0,118,0,132); | |
| rim.addColorStop(0,'#5a4880');rim.addColorStop(1,'#191226'); | |
| ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-NECK_HW-8,118,(NECK_HW+8)*2,13,6);ctx.fill(); | |
| } | |
| function drawBase(){ | |
| const body=new Path2D(); | |
| body.moveTo(CX-MIN_HW-14,659); | |
| body.quadraticCurveTo(CX-MAX_HW-40,H-70,CX-MAX_HW-52,H-38); | |
| body.quadraticCurveTo(CX-MAX_HW-52,H-20,CX-MAX_HW-30,H-18); | |
| body.lineTo(CX+MAX_HW+30,H-18); | |
| body.quadraticCurveTo(CX+MAX_HW+52,H-20,CX+MAX_HW+52,H-38); | |
| body.quadraticCurveTo(CX+MAX_HW+40,H-70,CX+MIN_HW+14,659); | |
| body.closePath(); | |
| ctx.save();ctx.clip(body); | |
| const g=ctx.createLinearGradient(0,659,0,H);g.addColorStop(0,'#3c2d58');g.addColorStop(0.35,'#241736');g.addColorStop(1,'#0c0716'); | |
| ctx.fillStyle=g;ctx.fillRect(0,650,W,H-650); | |
| const sheen=ctx.createLinearGradient(CX-MAX_HW-52,0,CX+MAX_HW+52,0); | |
| 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)'); | |
| ctx.fillStyle=sheen;ctx.fillRect(0,650,W,H-650); | |
| ctx.restore(); | |
| const rim=ctx.createLinearGradient(0,645,0,661); | |
| rim.addColorStop(0,'#6a5590');rim.addColorStop(1,'#171026'); | |
| ctx.fillStyle=rim;ctx.beginPath();ctx.roundRect(CX-MIN_HW-16,645,(MIN_HW+16)*2,16,8);ctx.fill(); | |
| const pulse=0.75+0.25*Math.sin(time*2); | |
| const ledR=ledHover?4.3:3.4; | |
| ctx.save();ctx.shadowColor=rgba(LIGHT,0.9);ctx.shadowBlur=12*pulse*(ledHover?1.6:1); | |
| 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(); | |
| 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(); | |
| ctx.restore(); | |
| } | |
| function drawSlider(){ | |
| const kx=SL.x-SL.w/2+slF*SL.w; | |
| ctx.save(); | |
| // groove | |
| ctx.fillStyle='rgba(0,0,0,0.55)'; | |
| ctx.beginPath();ctx.roundRect(SL.x-SL.w/2,SL.y-1.75,SL.w,3.5,1.75);ctx.fill(); | |
| ctx.strokeStyle='rgba(255,255,255,0.07)';ctx.lineWidth=1; | |
| ctx.beginPath();ctx.roundRect(SL.x-SL.w/2+0.5,SL.y-1.3,SL.w-1,2.6,1.3);ctx.stroke(); | |
| // knob, echoing the LED above it | |
| const r=(slDrag||slHover)?5:4; | |
| ctx.shadowColor=rgba(LIGHT,0.9); | |
| ctx.shadowBlur=slDrag?15:(slHover?11:7); | |
| const g=ctx.createRadialGradient(kx,SL.y,0.5,kx,SL.y,r+1.5); | |
| g.addColorStop(0,'rgba(255,240,225,0.95)'); | |
| g.addColorStop(0.45,rgba(LIGHT,0.85)); | |
| g.addColorStop(1,rgba(LIGHT,0)); | |
| ctx.fillStyle=g;ctx.beginPath();ctx.arc(kx,SL.y,r+1.5,0,Math.PI*2);ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function drawToggle(){ | |
| const on=slMode==='color'; | |
| ctx.save(); | |
| // Switch track | |
| ctx.fillStyle=on?'rgba(80,180,255,0.85)':'rgba(120,60,40,0.85)'; | |
| ctx.beginPath();ctx.roundRect(TG.x,TG.y,TG.w,TG.h,TG.h/2);ctx.fill(); | |
| // Switch knob | |
| const kx=on?TG.x+TG.w-TG.h/2:TG.x+TG.h/2; | |
| const ky=TG.y+TG.h/2; | |
| ctx.fillStyle='rgba(255,255,255,0.95)'; | |
| ctx.beginPath();ctx.arc(kx,ky,TG.h/2-2,0,Math.PI*2);ctx.fill(); | |
| ctx.restore(); | |
| } | |
| // film grain overlay - subtle animated noise for that analog feel (larger grains) | |
| // Film grain: GPU-optimized with full-resolution noise for v14-matching visuals | |
| // Two separate noise canvases: one small tile for base grain pattern, one full-res for wax grain | |
| const NOISE_TILE=96; | |
| const baseNoiseCanvas=document.createElement('canvas'); | |
| baseNoiseCanvas.width=NOISE_TILE;baseNoiseCanvas.height=NOISE_TILE; | |
| const baseNoiseCtx=baseNoiseCanvas.getContext('2d'); | |
| const waxNoiseCanvas=document.createElement('canvas'); | |
| const waxNoiseCtx=waxNoiseCanvas.getContext('2d'); | |
| function regenerateBaseNoise(){ | |
| const nd=baseNoiseCtx.createImageData(NOISE_TILE,NOISE_TILE); | |
| for(let i=0;i<nd.data.length;i+=4){ | |
| const v=Math.random()*255|0; | |
| nd.data[i]=v;nd.data[i+1]=v;nd.data[i+2]=v;nd.data[i+3]=255; | |
| } | |
| baseNoiseCtx.putImageData(nd,0,0); | |
| } | |
| regenerateBaseNoise(); | |
| // Pre-create grain overlay canvas for base grain (reuse every frame) | |
| const grainOverlayCanvas=document.createElement('canvas'); | |
| const grainOCtx=grainOverlayCanvas.getContext('2d'); | |
| let baseNoisePattern=null; | |
| function drawGrain(){ | |
| const cw=canvas.width,ch=canvas.height; | |
| // Regenerate base noise tile for flicker (fast: only 96x96 pixels) | |
| regenerateBaseNoise(); | |
| baseNoisePattern=baseNoiseCtx.createPattern(baseNoiseCanvas,'repeat'); | |
| // Size grain overlay canvas if needed | |
| if(grainOverlayCanvas.width!==cw||grainOverlayCanvas.height!==ch){ | |
| grainOverlayCanvas.width=cw;grainOverlayCanvas.height=ch; | |
| } | |
| // Clear and draw base grain using pattern fill (GPU-accelerated) | |
| grainOCtx.clearRect(0,0,cw,ch); | |
| grainOCtx.globalAlpha=0.06; // Base grain intensity (v15 x 0.25 - reduced by 75%) | |
| grainOCtx.globalCompositeOperation='source-over'; | |
| grainOCtx.save(); | |
| grainOCtx.translate(Math.random()*NOISE_TILE,Math.random()*NOISE_TILE); | |
| grainOCtx.fillStyle=baseNoisePattern; | |
| grainOCtx.fillRect(-NOISE_TILE,-NOISE_TILE,cw+2*NOISE_TILE,ch+2*NOISE_TILE); | |
| grainOCtx.restore(); | |
| // Draw base grain overlay to main canvas | |
| ctx.drawImage(grainOverlayCanvas,0,0); | |
| // Wax-masked grain: stronger where wax is (full-res noise for v14 match) | |
| const bctx=buf.getContext('2d'); | |
| const bw=buf.width,bh=buf.height; | |
| // Size and regenerate wax noise canvas at full resolution | |
| if(waxNoiseCanvas.width!==bw||waxNoiseCanvas.height!==bh){ | |
| waxNoiseCanvas.width=bw;waxNoiseCanvas.height=bh; | |
| } | |
| const nd2=waxNoiseCtx.createImageData(bw,bh); | |
| for(let i=0;i<nd2.data.length;i+=4){ | |
| const v=Math.random()*255|0; | |
| nd2.data[i]=v;nd2.data[i+1]=v;nd2.data[i+2]=v;nd2.data[i+3]=255; | |
| } | |
| waxNoiseCtx.putImageData(nd2,0,0); | |
| bctx.save(); | |
| bctx.globalAlpha=0.14; // Wax grain intensity (v15 x 0.25 - reduced by 75%) | |
| bctx.globalCompositeOperation='source-atop'; // Only affects opaque wax pixels | |
| bctx.drawImage(waxNoiseCanvas,0,0); | |
| bctx.restore(); | |
| } | |
| function drawLiquid(){ | |
| ctx.save();ctx.clip(liquidClip); | |
| const g=ctx.createLinearGradient(0,LIQ_TOP,0,LIQ_BOT); | |
| // base liquid follows the lamp light's hue in RGB mode; stays purple by default (almost transparent) | |
| g.addColorStop(0,rgba(tint({r:35,g:9,b:74}),0.15)); | |
| g.addColorStop(0.5,rgba(tint({r:27,g:6,b:64}),0.15)); | |
| g.addColorStop(1,rgba(tint({r:18,g:3,b:44}),0.15)); | |
| ctx.fillStyle=g;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16); | |
| // ---- bottom lamp glow: heat rising up the liquid column (scaled for subtler RGB) ---- | |
| const glowScale=1-rgbMix*0.5; // reduce additive glows by 50% in RGB mode | |
| ctx.globalCompositeOperation='lighter'; | |
| const hg=ctx.createLinearGradient(0,LIQ_BOT-190,0,LIQ_BOT+10); | |
| hg.addColorStop(0,rgba(LIGHT,0)); | |
| hg.addColorStop(0.6,rgba(LIGHT,0.05*glowScale)); | |
| hg.addColorStop(1,rgba(LIGHT,0.18*glowScale)); | |
| ctx.fillStyle=hg;ctx.fillRect(CX-MAX_HW-8,LIQ_BOT-190,MAX_HW*2+16,200); | |
| const led=ctx.createRadialGradient(CX,LIQ_BOT+6,4,CX,LIQ_BOT+6,150); | |
| led.addColorStop(0,rgba(mixWhite(LIGHT,0.4),0.28*glowScale)); | |
| led.addColorStop(0.35,rgba(LIGHT,0.12*glowScale)); | |
| led.addColorStop(1,rgba(LIGHT,0)); | |
| ctx.fillStyle=led;ctx.fillRect(CX-170,LIQ_BOT-150,340,170); | |
| // drifting shimmer | |
| const sx=CX+Math.sin(time*0.4)*60; | |
| const sh=ctx.createRadialGradient(sx,(LIQ_TOP+LIQ_BOT)/2,10,sx,(LIQ_TOP+LIQ_BOT)/2,220); | |
| const shim=tint({r:150,g:90,b:220}); | |
| sh.addColorStop(0,rgba(shim,0.10));sh.addColorStop(1,rgba(shim,0)); | |
| ctx.fillStyle=sh;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16); | |
| // bubbles | |
| ctx.globalCompositeOperation='source-over'; | |
| ctx.fillStyle='rgba(255,240,255,0.14)'; | |
| for(const b of bubbles){ | |
| const y=b.y*(LIQ_BOT-LIQ_TOP)+LIQ_TOP,hw=halfWidth(y)-6; | |
| const x=CX+(b.x*2-1)*hw+Math.sin(time*2+b.ph)*3; | |
| ctx.beginPath();ctx.arc(x,y,b.r,0,Math.PI*2);ctx.fill(); | |
| } | |
| // carbonation bubbles shed by the blobs (same look, fade so they "pop") | |
| for(const q of blobBubs){ | |
| ctx.globalAlpha=Math.min(1,(q.y-LIQ_TOP)/30); | |
| ctx.beginPath();ctx.arc(q.x,q.y,q.r,0,Math.PI*2);ctx.fill(); | |
| } | |
| ctx.globalAlpha=1; | |
| // ---- the lava: diffuse halo + glowing body ---- | |
| renderBlobs(); | |
| const bx=CX-MAX_HW-pad,by=LIQ_TOP-pad; | |
| ctx.globalCompositeOperation='lighter'; | |
| ctx.globalAlpha=0.16;ctx.drawImage(buf,bx-24,by-24,BW+48,BH+48); // wide soft halo | |
| ctx.globalAlpha=0.30;ctx.drawImage(buf,bx-10,by-10,BW+20,BH+20); // tighter glow | |
| ctx.globalCompositeOperation='source-over'; | |
| ctx.globalAlpha=1;ctx.drawImage(buf,bx,by); // wax body | |
| ctx.restore(); | |
| } | |
| function drawGlass(){ | |
| ctx.save(); | |
| ctx.fillStyle='rgba(255,255,255,0.03)';ctx.fill(glass); | |
| const g=ctx.createLinearGradient(CX-MAX_HW-8,0,CX+MAX_HW+8,0); | |
| g.addColorStop(0,'rgba(255,255,255,0.02)');g.addColorStop(0.15,'rgba(255,255,255,0.22)'); | |
| 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)'); | |
| ctx.strokeStyle=g;ctx.lineWidth=2.4;ctx.stroke(glass); | |
| // subtle curved reflection to suggest glass curvature (skeuomorphic) | |
| const refl=ctx.createLinearGradient(CX-MAX_HW*0.7,LIQ_TOP,CX-MAX_HW*0.3,LIQ_BOT); | |
| refl.addColorStop(0,'rgba(255,255,255,0)'); | |
| refl.addColorStop(0.4,'rgba(255,255,255,0.08)'); | |
| refl.addColorStop(0.6,'rgba(255,255,255,0.12)'); | |
| refl.addColorStop(1,'rgba(255,255,255,0)'); | |
| ctx.fillStyle=refl; | |
| ctx.beginPath(); | |
| // curved highlight on the left side (flipped: now faces left "(" with a touch more curve) | |
| ctx.moveTo(CX-MAX_HW*0.35,LIQ_TOP+20); | |
| ctx.quadraticCurveTo(CX-MAX_HW*0.60,(LIQ_TOP+LIQ_BOT)/2,CX-MAX_HW*0.42,LIQ_BOT-30); | |
| ctx.lineTo(CX-MAX_HW*0.65,LIQ_BOT-20); | |
| ctx.quadraticCurveTo(CX-MAX_HW*0.80,(LIQ_TOP+LIQ_BOT)/2,CX-MAX_HW*0.58,LIQ_TOP+30); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // subtle right-side reflection | |
| const refl2=ctx.createLinearGradient(CX+MAX_HW*0.4,LIQ_TOP,CX+MAX_HW*0.7,LIQ_BOT); | |
| refl2.addColorStop(0,'rgba(255,255,255,0)'); | |
| refl2.addColorStop(0.5,'rgba(255,255,255,0.05)'); | |
| refl2.addColorStop(1,'rgba(255,255,255,0)'); | |
| ctx.fillStyle=refl2; | |
| ctx.beginPath(); | |
| ctx.moveTo(CX+MAX_HW*0.45,LIQ_TOP+40); | |
| ctx.quadraticCurveTo(CX+MAX_HW*0.6,(LIQ_TOP+LIQ_BOT)/2,CX+MAX_HW*0.5,LIQ_BOT-40); | |
| ctx.lineTo(CX+MAX_HW*0.38,LIQ_BOT-35); | |
| ctx.quadraticCurveTo(CX+MAX_HW*0.48,(LIQ_TOP+LIQ_BOT)/2,CX+MAX_HW*0.36,LIQ_TOP+50); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.restore(); | |
| } | |
| // ---- main loop ---- | |
| function frame(now){ | |
| try{ | |
| const dt=Math.max(0,Math.min(0.05,(now-last)/1000));last=now;time+=dt; | |
| step(dt*SIM_SPEED); | |
| // ---- lamp colour: default warm, or slow RGB cycle when the LED is clicked ---- | |
| hue=(hue+dt*HUE_SPEED)%360; | |
| const tm=RGB_MODE?1:0; | |
| rgbMix+=(tm-rgbMix)*(1-Math.exp(-dt*2.8)); | |
| if(!RGB_MODE&&rgbMix<0.004)rgbMix=0; | |
| if(RGB_MODE&&rgbMix>0.996)rgbMix=1; | |
| const hc=hueRGB(hue); | |
| 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}; | |
| LUM_L=Math.max(1,0.299*LIGHT.r+0.587*LIGHT.g+0.114*LIGHT.b); | |
| drawBackground(); | |
| drawLED(); | |
| drawCap(); | |
| drawLiquid(); | |
| drawGlass(); | |
| drawBase(); | |
| drawSlider(); | |
| drawToggle(); | |
| drawGrain(); | |
| canvas.style.cursor=(slDrag||slHover)?'ew-resize':(ledHover?'pointer':'default'); | |
| }catch(err){ | |
| const d=document.createElement('div'); | |
| 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'; | |
| d.textContent='Lava lamp error: '+err.message; | |
| document.body.appendChild(d); | |
| throw err; | |
| } | |
| requestAnimationFrame(frame); | |
| } | |
| requestAnimationFrame(frame); | |
| </script> | |
| </body> | |
| </html> |