One commited on
Commit
b3b54df
·
verified ·
1 Parent(s): 38b94fb

Upload index.html

Browse files
Files changed (1) hide show
  1. 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
- function drawGrain(){
461
- const cw=canvas.width,ch=canvas.height;
462
- const imgData=ctx.getImageData(0,0,cw,ch);
463
- const data=imgData.data;
464
- const grainSize=3*DPR; // scale grain size with DPR
465
- // precompute blob positions in canvas coordinates for wax detection
466
- const ox=CX-MAX_HW-pad,oy=LIQ_TOP-pad;
467
- const baseIntensity=2.25; // very subtle grain intensity (reduced 25% more)
468
- const waxMultiplier=2.25; // 125% stronger where wax is (another 50% on top)
469
- for(let y=0;y<ch;y+=grainSize){
470
- for(let x=0;x<cw;x+=grainSize){
471
- // use pre-computed wax field to determine grain intensity (fast lookup)
472
- const bufX=CX-MAX_HW-pad,bufY=LIQ_TOP-pad;
473
- const wx=Math.floor((x/DPR-bufX)),wy=Math.floor((y/DPR-bufY));
474
- let waxAmount=0;
475
- if(wx>=0&&wx<BW&&wy>=0&&wy<BH){
476
- waxAmount=field[wy*BW+wx]; // 0 = no wax, higher = more wax
477
- }
478
- const intensity=baseIntensity*(1+waxAmount*waxMultiplier);
479
- const n=(Math.random()-0.5)*intensity;
480
- for(let gy=0;gy<grainSize&&y+gy<ch;gy++){
481
- for(let gx=0;gx<grainSize&&x+gx<cw;gx++){
482
- const idx=((Math.floor(y+gy)*cw+Math.floor(x+gx))*4);
483
- data[idx]=Math.max(0,Math.min(255,data[idx]+n)); // R
484
- data[idx+1]=Math.max(0,Math.min(255,data[idx+1]+n)); // G
485
- data[idx+2]=Math.max(0,Math.min(255,data[idx+2]+n)); // B
486
- }
487
- }
488
- }
489
- }
490
- ctx.putImageData(imgData,0,0);
491
- }
492
- function drawLiquid(){
493
- ctx.save();ctx.clip(liquidClip);
494
- const g=ctx.createLinearGradient(0,LIQ_TOP,0,LIQ_BOT);
495
- // base liquid follows the lamp light's hue in RGB mode; stays purple by default (almost transparent)
496
- g.addColorStop(0,rgba(tint({r:35,g:9,b:74}),0.15));
497
- g.addColorStop(0.5,rgba(tint({r:27,g:6,b:64}),0.15));
498
- g.addColorStop(1,rgba(tint({r:18,g:3,b:44}),0.15));
499
- ctx.fillStyle=g;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16);
500
-
501
- // ---- bottom lamp glow: heat rising up the liquid column (scaled for subtler RGB) ----
502
- const glowScale=1-rgbMix*0.85; // reduce additive glows by 85% in RGB mode (halved effect)
503
- ctx.globalCompositeOperation='lighter';
504
- const hg=ctx.createLinearGradient(0,LIQ_BOT-190,0,LIQ_BOT+10);
505
- hg.addColorStop(0,rgba(LIGHT,0));
506
- hg.addColorStop(0.6,rgba(LIGHT,0.05*glowScale));
507
- hg.addColorStop(1,rgba(LIGHT,0.18*glowScale));
508
- ctx.fillStyle=hg;ctx.fillRect(CX-MAX_HW-8,LIQ_BOT-190,MAX_HW*2+16,200);
509
- const led=ctx.createRadialGradient(CX,LIQ_BOT+6,4,CX,LIQ_BOT+6,150);
510
- led.addColorStop(0,rgba(mixWhite(LIGHT,0.4),0.28*glowScale));
511
- led.addColorStop(0.35,rgba(LIGHT,0.12*glowScale));
512
- led.addColorStop(1,rgba(LIGHT,0));
513
- ctx.fillStyle=led;ctx.fillRect(CX-170,LIQ_BOT-150,340,170);
514
-
515
- // drifting shimmer
516
- const sx=CX+Math.sin(time*0.4)*60;
517
- const sh=ctx.createRadialGradient(sx,(LIQ_TOP+LIQ_BOT)/2,10,sx,(LIQ_TOP+LIQ_BOT)/2,220);
518
- const shim=tint({r:150,g:90,b:220});
519
- sh.addColorStop(0,rgba(shim,0.10));sh.addColorStop(1,rgba(shim,0));
520
- ctx.fillStyle=sh;ctx.fillRect(CX-MAX_HW-8,LIQ_TOP-8,MAX_HW*2+16,LIQ_BOT-LIQ_TOP+16);
521
-
522
- // bubbles
523
- ctx.globalCompositeOperation='source-over';
524
- ctx.fillStyle='rgba(255,240,255,0.14)';
525
- for(const b of bubbles){
526
- const y=b.y*(LIQ_BOT-LIQ_TOP)+LIQ_TOP,hw=halfWidth(y)-6;
527
- const x=CX+(b.x*2-1)*hw+Math.sin(time*2+b.ph)*3;
528
- ctx.beginPath();ctx.arc(x,y,b.r,0,Math.PI*2);ctx.fill();
529
- }
530
-
531
- // carbonation bubbles shed by the blobs (same look, fade so they "pop")
532
- for(const q of blobBubs){
533
- ctx.globalAlpha=Math.min(1,(q.y-LIQ_TOP)/30);
534
- ctx.beginPath();ctx.arc(q.x,q.y,q.r,0,Math.PI*2);ctx.fill();
535
- }
536
- ctx.globalAlpha=1;
537
-
538
- // ---- the lava: diffuse halo + glowing body ----
539
- renderBlobs();
540
- const bx=CX-MAX_HW-pad,by=LIQ_TOP-pad;
541
- ctx.globalCompositeOperation='lighter';
542
- ctx.globalAlpha=0.16;ctx.drawImage(buf,bx-24,by-24,BW+48,BH+48); // wide soft halo
543
- ctx.globalAlpha=0.30;ctx.drawImage(buf,bx-10,by-10,BW+20,BH+20); // tighter glow
544
- ctx.globalCompositeOperation='source-over';
545
- ctx.globalAlpha=1;ctx.drawImage(buf,bx,by); // wax body
546
- ctx.restore();
547
- }
548
- function drawGlass(){
549
- ctx.save();
550
- ctx.fillStyle='rgba(255,255,255,0.03)';ctx.fill(glass);
551
- const g=ctx.createLinearGradient(CX-MAX_HW-8,0,CX+MAX_HW+8,0);
552
- g.addColorStop(0,'rgba(255,255,255,0.02)');g.addColorStop(0.15,'rgba(255,255,255,0.22)');
553
- 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)');
554
- ctx.strokeStyle=g;ctx.lineWidth=2.4;ctx.stroke(glass);
555
- // subtle curved reflection to suggest glass curvature (skeuomorphic)
556
- const refl=ctx.createLinearGradient(CX-MAX_HW*0.7,LIQ_TOP,CX-MAX_HW*0.3,LIQ_BOT);
557
- refl.addColorStop(0,'rgba(255,255,255,0)');
558
- refl.addColorStop(0.4,'rgba(255,255,255,0.08)');
559
- refl.addColorStop(0.6,'rgba(255,255,255,0.12)');
560
- refl.addColorStop(1,'rgba(255,255,255,0)');
561
- ctx.fillStyle=refl;
562
- ctx.beginPath();
563
- // curved highlight on the left side (flipped: now faces left "(" with a touch more curve)
564
- ctx.moveTo(CX-MAX_HW*0.35,LIQ_TOP+20);
565
- ctx.quadraticCurveTo(CX-MAX_HW*0.60,(LIQ_TOP+LIQ_BOT)/2,CX-MAX_HW*0.42,LIQ_BOT-30);
566
- ctx.lineTo(CX-MAX_HW*0.65,LIQ_BOT-20);
567
- ctx.quadraticCurveTo(CX-MAX_HW*0.80,(LIQ_TOP+LIQ_BOT)/2,CX-MAX_HW*0.58,LIQ_TOP+30);
568
- ctx.closePath();
569
- ctx.fill();
570
- // subtle right-side reflection
571
- const refl2=ctx.createLinearGradient(CX+MAX_HW*0.4,LIQ_TOP,CX+MAX_HW*0.7,LIQ_BOT);
572
- refl2.addColorStop(0,'rgba(255,255,255,0)');
573
- refl2.addColorStop(0.5,'rgba(255,255,255,0.05)');
574
- refl2.addColorStop(1,'rgba(255,255,255,0)');
575
- ctx.fillStyle=refl2;
576
- ctx.beginPath();
577
- ctx.moveTo(CX+MAX_HW*0.45,LIQ_TOP+40);
578
- ctx.quadraticCurveTo(CX+MAX_HW*0.6,(LIQ_TOP+LIQ_BOT)/2,CX+MAX_HW*0.5,LIQ_BOT-40);
579
- ctx.lineTo(CX+MAX_HW*0.38,LIQ_BOT-35);
580
- ctx.quadraticCurveTo(CX+MAX_HW*0.48,(LIQ_TOP+LIQ_BOT)/2,CX+MAX_HW*0.36,LIQ_TOP+50);
581
- ctx.closePath();
582
- ctx.fill();
583
- ctx.restore();
584
- }
585
-
586
- // ---- main loop ----
587
- function frame(now){
588
- try{
589
- const dt=Math.max(0,Math.min(0.05,(now-last)/1000));last=now;time+=dt;
590
- step(dt*SIM_SPEED);
591
- // ---- lamp colour: default warm, or slow RGB cycle when the LED is clicked ----
592
- hue=(hue+dt*HUE_SPEED)%360;
593
- const tm=RGB_MODE?1:0;
594
- rgbMix+=(tm-rgbMix)*(1-Math.exp(-dt*2.8));
595
- if(!RGB_MODE&&rgbMix<0.004)rgbMix=0;
596
- if(RGB_MODE&&rgbMix>0.996)rgbMix=1;
597
- const hc=hueRGB(hue);
598
- 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};
599
- LUM_L=Math.max(1,0.299*LIGHT.r+0.587*LIGHT.g+0.114*LIGHT.b);
600
- drawBackground();
601
- drawLED();
602
- drawCap();
603
- drawLiquid();
604
- drawGlass();
605
- drawBase();
606
- drawSlider();
607
- drawGrain();
608
- canvas.style.cursor=(slDrag||slHover)?'ew-resize':(ledHover?'pointer':'default');
609
- }catch(err){
610
- const d=document.createElement('div');
611
- 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';
612
- d.textContent='Lava lamp error: '+err.message;
613
- document.body.appendChild(d);
614
- throw err;
615
- }
616
- requestAnimationFrame(frame);
617
- }
618
- requestAnimationFrame(frame);
619
- </script>
620
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>