Open Field

collaborative temporal space

A shared field extending in three dimensions. You arrive from a unique location in the world — the sky shifts with your local time, and you are placed in the field near your own coordinates. No account. No name. No trace of who you are, only your presence.

Location is used once to place you in the open field, and the site never stores, shares, or logs any personal identifying data. When you arrive, a small mark appears where you entered — colored by where you are in the world. When you leave, traces remain as tracks in the field, colored by choices you made while you were here. The marks you make, traces of your presence fade slowly over time, like everything here. If you listen closely upon your return you may hear echoes of what remains in the field.

The field remembers you were here, but doesn’t know your name.

How to move & make
drag · draw
W A S D · fly through space    X · hard brake    three-finger tap · brake (touch)
arrow keys · turn & tilt  (two-finger drag on touch)
color, brush size, nib shape, image & video upload · download · in the bar
Before you enter
This is an open, user-generated space. Uploads are not pre-screened. You are solely responsible for what you share. By entering, you agree:
— You are 13 or older.
— No illegal content of any kind. No sexual content involving minors. No harassment, hate, or graphic violence.
— Only upload images you have the right to share. Don't upload anything private, or anything you wouldn't want public.
— Marks are public and may be removed at any time.
— The space is provided as-is, with no warranty; you take part at your own risk.
— Report a problem: studio@aaronhiggins.com
Entering means you've read and agree to the above.
Open Field is an artwork by Aaron Higgins. © 2026 Aaron Higgins / Uncanny Studios LLC. All rights reserved. User-generated content remains the property of its creators. By contributing, you grant a non-exclusive license to display your marks within the field for the duration of their natural decay.
Open Field · draw · WASD navigate · arrows rotate
0 0 0
0 marks
W/S · forward/back
A/D · strafe
← → · turn
↑ ↓ · tilt
0){const n=R(); a=a*(1-rough*0.5*n); if(a((Math.floor(w/1000)%8)+8)%8; const off=o=>[OX+o[0],OY+o[1],OZ+o[2]]; const szf=sz=>Array.isArray(sz)?(t=>sz[0]*(1-t)+sz[1]):sz; (p.fragments||[]).forEach(f=>{ const col=f.color||DC, style=f.style||'ink', F=f.F||DF, rough=(f.rough==null?DR:f.rough); const wx=OX+f.dx, wy=OY+f.dy, wz=OZ+f.dz; const baked=_ncBake(f.text,col,style,F,rough); const im=new Image(); const s={type:'stamp',id:newId(),sw:baked.sw,sh:baked.sh,color:col,ts:Date.now()-_vgAgeMs,wx,wy,wz,dataURL:baked.dataURL,zone:zoneOf(wz),xcell:zoneOf(wx),_noPersist:true,_img:im}; im.onload=()=>{ s._loaded=true; }; im.src=baked.dataURL; strokes.push(s); (f.marks||[]).forEach(mk=>{ const mc=mk.color||col, nib=mk.nib||'triangle', ext=mk.extMode||'sensitive', sz=szf(mk.sz); if(mk.g==='streak'){ pushV(_ncStreak(off(mk.o),mk.dir,mk.len,mk.n),mc,sz,nib,ext); } else if(mk.g==='seedhead'){ _ncSeedhead(off(mk.o),mk.R,mk.N).forEach(pp=>pushV(pp,mc,sz,nib,ext)); } else if(mk.g==='seam'){ _ncSeam(off(mk.o),mk.dir,mk.len,mk.stitches).forEach(pp=>pushV(pp,mc,sz,nib,ext)); } else if(mk.g==='scatter'){ _ncScatter(off(mk.o),mk.dir,mk.spread,mk.n,mk.len).forEach(pp=>pushV(pp,mc,sz,nib,ext)); } else if(mk.g==='poly'){ pushV(mk.pts.map(q=>({wx:OX+q[0],wy:OY+q[1],wz:OZ+q[2]})),mc,sz,nib,ext); } }); }); } const _TOL = { built:false, base:null, canopyPts:[], fall:{kind:null, parts:[]}, phase:null }; function _tolRng(seed){ let a=seed>>>0; return ()=>{ a|=0; a=a+0x6D2B79F5|0; let t=Math.imul(a^a>>>15,1|a); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }; } function _tolLerpHex(a,b,t){ const pa=parseInt(a.slice(1),16), pb=parseInt(b.slice(1),16); const r=Math.round(((pa>>16)&255)*(1-t)+((pb>>16)&255)*t), g=Math.round(((pa>>8)&255)*(1-t)+((pb>>8)&255)*t), bl=Math.round((pa&255)*(1-t)+(pb&255)*t); return '#'+((1<<24)+(r<<16)+(g<<8)+bl).toString(16).slice(1); } function _tolPhase(now){ const d=new Date(now); const doy=Math.floor((d-new Date(d.getFullYear(),0,0))/86400000); const ramp=(a,b,x)=>Math.max(0,Math.min(1,(x-a)/(b-a))); return { doy, bloom: ramp(80,85,doy) *(1-ramp(100,105,doy)), petalFall: ramp(103,106,doy)*(1-ramp(118,121,doy)), leaf: ramp(118,150,doy)*(1-ramp(298,318,doy)), turn: ramp(272,296,doy), seedHold: ramp(210,228,doy)*(1-ramp(243,247,doy)), seedFall: ramp(243,247,doy)*(1-ramp(270,274,doy)), leafFall: ramp(296,300,doy)*(1-ramp(316,320,doy)), springT: ramp(118,170,doy), petalLitter: ramp(103,108,doy)*(1-ramp(128,140,doy)), seedLitter: ramp(244,250,doy)*(1-ramp(300,310,doy)), leafLitter: ramp(297,302,doy)*(1-ramp(345,360,doy)) }; } const _TP = { splay:0.46, depth:6, rimFlat:0.28, lowDroop:0.38, inwardBase:0.06, inwardDepth:0.14, outward:0.22, billows:10, billowSpread:70, billowRise:50, leaf:38, leafScatter:82, leafScatterY:60 }; function _tolBuild(p){ const OX=p.OX, OY=p.OY, OZ=p.OZ, H=p.H||800, seed=p.seed||20260612; const rng=_tolRng(seed); const ph=_tolPhase(p._now||Date.now()); const lerp=_tolLerpHex; _TOL.built=true; _TOL.base={OX,OY,OZ,H}; _TOL.seed=seed; _TOL.phase=ph; _TOL.canopyPts=[]; _TOL.fall={kind:null,parts:[]}; const _treeStart=strokes.length; const INK='#2c3438', SIENNA='#7a4a32', BONE='#d9d4c5', HEATHER='#8d83a0', GOLD='#d9b86b'; const FOREST='#3e5a44', MOSS='#6f7d54', OLIVE='#8a8a5c', OCHRE='#c2a25c', RUST='#9c5b3c', STRAW='#d8c690'; for(let i=0;i<8;i++){ const a=(i/8)*Math.PI*2+rng()*0.4; const len=110+rng()*80; const pts=[]; for(let t=0;t<=1.001;t+=0.25){ pts.push({wx:OX+Math.sin(a)*len*t+(rng()-0.5)*10, wy:OY-(1-t)*30+t*5, wz:OZ+Math.cos(a)*len*t+(rng()-0.5)*10}); } pushV(pts, i%3===0?SIENNA:INK, (t)=>19*(1-t)+3, 'round','sensitive'); } const leanX=(rng()-0.5)*40, leanZ=(rng()-0.5)*40; const forkT=0.36; for(let g=0; g<6; g++){ const off=(g-2.5)*18; const pts=[]; for(let s=0;s<=10;s++){ const t=s/10; const wob=Math.sin(t*5.5+g*1.7)*(6+(1-t)*9)+(rng()-0.5)*5; pts.push({wx:OX+leanX*t+off*(1-t*0.5)+wob, wy:OY-H*forkT*t, wz:OZ+leanZ*t+(rng()-0.5)*5}); } pushV(pts, g===2||g===4?SIENNA:INK, (t)=>(24-(g%2)*5)*(1-t)+6, 'bristle','sensitive'); } pushV([{wx:OX+30,wy:OY-H*0.13,wz:OZ-12},{wx:OX+58,wy:OY-H*0.125,wz:OZ-6}], BONE, (t)=>4*(1-t)+1.4, 'round','sensitive'); pushV([{wx:OX-19,wy:OY-H*0.07,wz:OZ+16},{wx:OX-16,wy:OY-H*0.04,wz:OZ+18}], INK, 8, 'round','sensitive'); const forkY=OY-H*forkT; const midX=OX+leanX; const RIM=H*0.22; const tips=[[],[]], nodes=[[],[]]; const branch=(x,y,z,dx,dy,dz,len,w,depth,hIdx,D0)=>{ const nx=x+dx*len, ny=y+dy*len, nz=z+dz*len; pushV([{wx:x,wy:y,wz:z},{wx:x+dx*len*0.5+(rng()-0.5)*len*0.14,wy:y+dy*len*0.55,wz:z+dz*len*0.5+(rng()-0.5)*len*0.14},{wx:nx,wy:ny,wz:nz}], INK, (t)=>w*(1-t)+Math.max(1,w*0.32), 'bristle','sensitive'); if(depth<=4) nodes[hIdx].push({x:nx,y:ny,z:nz}); if(depth<=0){ tips[hIdx].push({x:nx,y:ny,z:nz}); return; } const nb=depth>=5?2:(rng()<0.66?2:3); for(let b=0;b{ const dx=sgn*_TP.splay, dy=-0.86, dz=(rng()-0.5)*0.28; const mm=Math.hypot(dx,dy,dz); branch(midX+sgn*24, forkY, OZ+leanZ, dx/mm,dy/mm,dz/mm, H*0.19, 17, _TP.depth, hi, _TP.depth); }); const billows=[]; [0,1].forEach(hi=>{ const sgn=hi===0?-1:1; const N=nodes[hi], T=tips[hi]; if(!N.length&&!T.length) return; for(let b=0;b<_TP.billows;b++){ const src=(b<_TP.billows/2&&N.length)?N[Math.floor(rng()*N.length)]:T[Math.floor(rng()*T.length)]||N[0]; if(!src) continue; let bx=src.x+(rng()-0.5)*_TP.billowSpread, by=src.y-10-rng()*_TP.billowRise, bz=src.z+(rng()-0.5)*_TP.billowSpread; if(Math.abs(bx-midX)<18) bx=midX+sgn*(18+rng()*30); billows.push({x:bx,y:by,z:bz,stagger:rng(),baseCol:[FOREST,MOSS,OLIVE][Math.floor(rng()*3)],den:1}); } }); const allTips=tips[0].concat(tips[1]); const topY=allTips.length?Math.min(...allTips.map(t=>t.y)):forkY-H*0.5; for(let v=0;v<3;v++){ billows.push({x:midX+(rng()-0.5)*44, y:topY+H*0.07+v*H*0.07+(rng()-0.5)*30, z:OZ+leanZ+(rng()-0.5)*70, stagger:rng(), baseCol:[FOREST,MOSS][v%2], den:0.62}); } billows.push({x:midX-12, y:forkY-H*0.40, z:OZ+leanZ+10, stagger:rng(), baseCol:MOSS, den:0.58}); billows.push({x:midX+16, y:forkY-H*0.52, z:OZ+leanZ-14, stagger:rng(), baseCol:FOREST, den:0.58}); const leafN=Math.round(_TP.leaf*ph.leaf); billows.forEach((bl,bi)=>{ let col=lerp(STRAW, bl.baseCol, Math.min(1,ph.springT)); const tl=Math.max(0,Math.min(1,(ph.turn-bl.stagger*0.5)*1.6)); if(tl>0) col=lerp(col, [OCHRE,RUST,SIENNA][bi%3], tl); const n=Math.round(leafN*(bl.den||1)); for(let i=0;i4.8*(1-t)+1.8, 'round','sensitive'); } }); if(ph.seedHold>0.02){ const nS=Math.round(34*ph.seedHold); for(let i=0;i0.02){ const nB=Math.round(170*ph.bloom); for(let i=0;i{ for(let i=0;i2.8*(1-t)+1, 'round','sensitive'); } }; if(ph.petalLitter>0.02) litter(Math.round(60*ph.petalLitter), ()=>lerp(HEATHER,BONE,rng()), 200); if(ph.seedLitter >0.02) litter(Math.round(26*ph.seedLitter), ()=>GOLD, 180); if(ph.leafLitter >0.02) litter(Math.round(70*ph.leafLitter), ()=>[OCHRE,RUST,SIENNA][Math.floor(rng()*3)], 280); const fk = ph.petalFall>0.05?['petal',46] : ph.seedFall>0.05?['seed',16] : ph.leafFall>0.05?['leaf',56] : null; if(fk && _TOL.canopyPts.length){ _TOL.fall.kind=fk[0]; const rng2=_tolRng(seed+ph.doy); for(let i=0;i=0;i--) if(strokes[i]._isTree) strokes.splice(i,1); const B=_TOL.base; if(B) _tolBuild({OX:B.OX, OY:B.OY, OZ:B.OZ, H:B.H, seed:_TOL.seed||20260612, _now:Date.now()}); } try { window.reshapeTree = reshapeTree; window._TP = _TP; } catch(_e){} function tickTreeFall(){ if(!_TOL.built || !_TOL.fall.kind) return; const B=_TOL.base; const d2=(camX-B.OX)**2+(camY-(B.OY-B.H*0.5))**2+(camZ-B.OZ)**2; if(d2>3200*3200) return; for(const pt of _TOL.fall.parts){ if(pt.rest>0){ pt.rest--; if(pt.rest===0){ const cp=_TOL.canopyPts[Math.floor(Math.random()*_TOL.canopyPts.length)]; pt.x=cp.x; pt.y=cp.y; pt.z=cp.z; pt.ph=Math.random()*Math.PI*2; } continue; } pt.ph+=0.05; pt.y+=pt.vy; pt.x+=Math.sin(pt.ph)*pt.sway*0.4; pt.z+=Math.cos(pt.ph*0.8)*pt.sway*0.3; if(pt.y>=B.OY-2){ pt.y=B.OY-2; pt.rest=200+Math.random()*400; } const s=pt.st; s.pts[0].wx=pt.x; s.pts[0].wy=pt.y; s.pts[0].wz=pt.z; s.pts[1].wx=pt.x+Math.sin(pt.ph*1.3)*6; s.pts[1].wy=pt.y+2; s.pts[1].wz=pt.z+Math.cos(pt.ph*1.3)*4; } } const VIRGIL_GENERATORS = { treeOfLife: p => _tolBuild(p), noteCluster: p => _ncBuildCluster(p), lorenz: p => { const {sigma=10,rho=28,OX=4500,OY=3000,OZ=2000,scale=9,total=8000,seg=60} = p; const beta = Array.isArray(p.beta) ? p.beta[0]/p.beta[1] : 8/3; let lx=0.1,ly=0,lz=0; const dt=0.008; const all=[]; for(let i=0;ip.z)),maxZ=Math.max(...all.map(p=>p.z)); const SEG=seg; for(let s=0;s0.08; const col=hslToHexV(isFast?42+zNorm*20:245+frac*60, isFast?85:60+zNorm*30, isFast?65+zNorm*25:38+zNorm*28); const nib=isFast?'round':'bristle'; const ext=isFast?'consistent':'sensitive'; const sz=isFast?2.5+zNorm*2:1.2+speed*18; const pts=seg2.map(p=>({wx:OX+p.x*scale,wy:OY+p.z*scale*-0.9+p.y*scale*0.15,wz:OZ+p.y*scale})); pushV(pts,col,sz,nib,ext); } }, rossler: p => { const {a=0.2,b=0.2,c=5.7,OX=3500,OY=1800,OZ=7200,scale=12,total=12000} = p; let rx=0.1,ry=0,rz=0; const dt=0.006; const all=[]; for(let i=0;iq.z)),maxZ=Math.max(...all.map(q=>q.z)); const SEG=45; for(let s=0;s0.08; const col=hslToHexV(isFast?42+zNorm*20:245+frac*60, isFast?85:60+zNorm*30, isFast?65+zNorm*25:38+zNorm*28); const pts=seg2.map(q=>({wx:OX+q.x*scale,wy:OY+q.z*scale*-0.9+q.y*scale*0.15,wz:OZ+q.y*scale})); pushV(pts,col,isFast?2.5+zNorm*2:1.2+speed*18,isFast?'round':'bristle',isFast?'consistent':'sensitive'); } }, dandelion_clean: p => { const {N=180,R=280,seedLen=90,OX=6500,OY=1200,OZ=6500} = p; const ga=Math.PI*(3-Math.sqrt(5)); for(let i=0;i2.8-t*1.8,'triangle','consistent'); } for(let i=0;i<30;i++){const y=1-(i/29)*2,ring=Math.sqrt(1-y*y),theta=ga*i;const pts=[];for(let s=0;s<=5;s++){const t=s/5;const r=18+t*22;pts.push({wx:OX+ring*Math.cos(theta)*r,wy:OY+y*r,wz:OZ+ring*Math.sin(theta)*r});}pushV(pts,hslToHexV(35,55,52),t=>2.5-t*1.2,'round','consistent');} }, dandelion_expressive: p => { const {N=180,R=300,OX=2500,OY=1500,OZ=6000} = p; const ga=Math.PI*(3-Math.sqrt(5)); for(let i=0;ibaseSize*(1-t*0.7),nibChoice,'sensitive'); } }, murmuration: p => { const {birds=420,phases=[0,0.18],rx=280,ry=420,rz=260,OX=2200,OY=1400,OZ=5500} = p; const ga=Math.PI*(3-Math.sqrt(5)); function murShape(t,phase){ const y=1-(t*2),ring=Math.sqrt(Math.max(0,1-y*y)),theta=ga*(t*birds); const rxV=ring*rx,ryV=y*ry,rzV=ring*rz; const wave=Math.sin(theta*0.3+y*Math.PI*2.1+phase)*55; return{x:OX+Math.cos(theta)*rxV+wave*0.4,y:OY+ryV+Math.cos(y*Math.PI*3+phase*0.7)*30, z:OZ+Math.sin(theta)*rzV+wave*0.6, vx:-Math.sin(theta)*rxV*0.08+Math.cos(theta*0.3+phase)*8, vy:-2.8+Math.cos(y*Math.PI*2+phase)*3, vz:Math.cos(theta)*rzV*0.08+Math.sin(theta*0.3+phase)*8}; } phases.forEach((phase,pi)=>{ for(let i=0;i0.75) col=hslToHexV(195+(iridHue%60)*0.3,55+edge*25,28+edge*28); else if(edge>0.4) col=hslToHexV(210+iridHue*0.05,30,16+edge*18); else col=hslToHexV(225,18,10+edge*8); pushV(pts,col,(0.8+edge*1.8)*(pi===0?1:0.7),'bristle',pi===0?'sensitive':'consistent'); } }); }, bird_of_paradise: p => { const {OX=7200,groundY=3200,OZ=1500,stemH=600} = p; const topY=groundY-stemH; const C={od:hslToHexV(22,92,44),om:hslToHexV(28,88,55),ol:hslToHexV(38,82,66),ov:hslToHexV(15,85,38), cd:hslToHexV(232,70,32),cm:hslToHexV(222,65,48),cl:hslToHexV(210,55,62), gd:hslToHexV(125,52,22),gm:hslToHexV(115,44,34),gl:hslToHexV(105,38,48),gv:hslToHexV(130,55,18), bd:hslToHexV(88,48,28),bm:hslToHexV(80,42,40),bl2:hslToHexV(72,38,52)}; for(let layer=0;layer<2;layer++){const pts=[];for(let i=0;i<=28;i++){const t=i/28;pts.push({wx:OX+Math.sin(t*Math.PI*0.8)*18+layer*5,wy:groundY-t*stemH,wz:OZ+Math.sin(t*Math.PI)*12-layer*10});}pushV(pts,layer===0?C.gd:C.gl,t=>4.5-t*1.5,'round','consistent');} [[0.18,1],[0.32,-1]].forEach(([sf,side])=>{ const lb={x:OX+12*side,y:groundY-stemH*sf,z:OZ}; for(let layer=0;layer<5;layer++){const pts=[];for(let i=0;i<=28;i++){const t=i/28;const env=Math.sin(t*Math.PI);pts.push({wx:lb.x+side*(t*340*0.75)+env*85*side*(1-t)*0.7,wy:lb.y-t*340*0.08+t*t*60,wz:lb.z+t*50+layer*10});} pushV(pts,hslToHexV(125-layer*10,52-layer*8,22+layer*10),t=>5.5-t*3.5,'bristle','sensitive');} }); const brY=topY+40; for(let side of [-1,1]){const pts=[];for(let i=0;i<=18;i++){const t=i/18;const env=Math.sin(t*Math.PI);pts.push({wx:OX+side*env*48,wy:brY+t*80,wz:OZ+t*35+env*12});}pushV(pts,side<0?C.bd:C.bl2,t=>5-t*3,'bristle','sensitive');} const sb={x:OX+8,y:brY-10,z:OZ+20}; [-0.38,0,0.34].forEach((ang,si)=>{ const LEN=180+si*12; for(let layer=0;layer<4;layer++){const pts=[];for(let i=0;i<=22;i++){const t=i/22;const w=Math.sin(t*Math.PI)*(28-layer*4)*(1-t*0.3);pts.push({wx:sb.x+Math.sin(ang)*t*LEN+w+(layer-1.5)*9*0.3,wy:sb.y-Math.cos(ang)*t*LEN*0.95+Math.sin(t*Math.PI*0.9)*12,wz:sb.z+t*18+(layer-1.5)*9});} pushV(pts,[C.ov,C.od,C.om,C.ol][layer],t=>3.5-t*2.5,'bristle','sensitive');} }); const pb={x:sb.x+10,y:sb.y-30,z:sb.z+12}; [[-1,0.12],[1,-0.08]].forEach(([side,lean])=>{ for(let layer=0;layer<3;layer++){const pts=[];for(let i=0;i<=20;i++){const t=i/20;pts.push({wx:pb.x+side*Math.sin(lean)*t*155+Math.sin(t*Math.PI)*18*side*(1-layer*0.2)*(1-t),wy:pb.y-Math.cos(lean)*t*145,wz:pb.z+side*14+t*28+layer*8});} pushV(pts,[C.cd,C.cm,C.cl][layer],t=>4-t*3,'bristle','sensitive');} }); }, nepenthes: p => { const {OX=5800,groundY=3800,OZ=4200,PH=320} = p; const C={bs:hslToHexV(108,48,18),bd:hslToHexV(102,42,26),bm:hslToHexV(95,38,36),bl:hslToHexV(88,32,48),bh:hslToHexV(78,28,58), rd:hslToHexV(358,72,22),rm:hslToHexV(4,68,35),rl:hslToHexV(10,62,48),rv:hslToHexV(352,78,28), pd:hslToHexV(355,80,18),pm:hslToHexV(3,75,30),pl:hslToHexV(12,65,42), ic:hslToHexV(48,35,78),if2:hslToHexV(8,55,52),ip:hslToHexV(355,65,22), ld:hslToHexV(88,30,55),ls:hslToHexV(358,58,35),ll:hslToHexV(75,25,68),lv:hslToHexV(102,45,28), gd:hslToHexV(108,50,20),gm:hslToHexV(100,42,32)}; const PB={x:OX,y:groundY-80,z:OZ}; const mouthY=PB.y-PH; function pitcherProfile(t){if(t<0.08)return{rx:12+t*80,rz:10+t*60};if(t<0.45)return{rx:52+t*38,rz:42+t*30};if(t<0.72)return{rx:72-(t-0.45)*55,rz:58-(t-0.45)*42};return{rx:38+(t-0.72)*120,rz:30+(t-0.72)*90};} const SECTIONS=40; for(let strip=0;strip<24;strip++){const ang=(strip/24)*Math.PI*2;const isShadow=Math.cos(ang-0.4)<-0.2,isLit=Math.cos(ang-0.4)>0.5,isVein=strip%3===0; const pts=[];for(let si=0;si<=SECTIONS;si++){const t=si/SECTIONS;const {rx,rz}=pitcherProfile(t);const slant=t<0.5?-8*t:-4+8*(t-0.5);pts.push({wx:PB.x+Math.cos(ang)*rx+slant,wy:PB.y-t*PH,wz:PB.z+Math.sin(ang)*rz});} pushV(pts,isVein?C.rv:isShadow?C.bs:isLit?C.bl:C.bm,t=>isVein?1.2:isShadow?3.5:3,'bristle','sensitive');} const {rx:mRx,rz:mRz}=pitcherProfile(1.0); for(let r=0;r<3;r++){for(let strip=0;strip<16;strip++){const ang=(strip/16)*Math.PI*2;const ribOff=Math.cos(ang*8)*4;const pts=[];for(let i=0;i<=12;i++){const t=i/12;const flare=1+t*0.18;pts.push({wx:PB.x+Math.cos(ang)*(mRx*(1+r*0.04)+ribOff)*flare,wy:mouthY+t*28,wz:PB.z+Math.sin(ang)*(mRz*(1+r*0.04)+ribOff*0.8)*flare});} pushV(pts,[C.pd,C.pm,C.pl][r],t=>2.5+Math.abs(Math.cos(ang*8))*1.5,'round','consistent');}} const lh={x:PB.x-8,y:mouthY-8,z:PB.z-mRz}; for(let layer=0;layer<4;layer++){const pts=[];for(let i=0;i<=22;i++){const t=i/22;pts.push({wx:lh.x+Math.sin(t*Math.PI)*55*(1-layer*0.15),wy:lh.y-Math.sin(t*Math.PI)*20-t*35-layer*6,wz:lh.z-t*65});} pushV(pts,[C.lv,C.ld,C.bl,C.ll][layer],t=>3.5-t*2,'bristle','sensitive');} const pts=[];for(let i=0;i<=30;i++){const t=i/30;const coil=Math.sin(t*Math.PI*3)*18*(1-t*0.6);pts.push({wx:OX+(OX+40-OX)*t*t+coil,wy:groundY-500+(groundY-80-(groundY-500))*t,wz:OZ+(OZ+30-OZ)*t+coil*0.5});}pushV(pts,C.gd,t=>2.5-t*1.2,'round','consistent'); }, painting: p => { const {OX,OY,OZ,subject=''} = p; for(let i=0;i<12;i++){const y=OY+180+i*28;const pts=[];for(let s=0;s<=16;s++){const t=s/16;pts.push({wx:OX-180+t*420+(Math.random()-0.5)*12,wy:y+Math.sin(t*Math.PI+i*0.4)*22,wz:OZ+(Math.random()-0.5)*40});}pushV(pts,hslToHexV(240+i*4,28+i*2,18+i*2),t=>6-t*1.5,'bristle','sensitive');} for(let i=0;i<18;i++){const y=OY-80+i*22;const pts=[];for(let s=0;s<=14;s++){const t=s/14;pts.push({wx:OX-100+t*480+(Math.random()-0.5)*14,wy:y+Math.sin(t*Math.PI*1.8+i*0.6)*10,wz:OZ+(Math.random()-0.5)*40});}pushV(pts,hslToHexV(i<8?218+Math.random()*25:28+Math.random()*22,i<8?32:62,i<8?38+i*2:55),t=>1.8+Math.sin(t*Math.PI)*2.5,'bristle','sensitive');} for(let i=0;i<14;i++){const cx2=OX-40+(Math.random()-0.5)*50,cy=OY-60+(Math.random()-0.5)*80,ang=-0.3+(Math.random()-0.5)*0.4,len=18+Math.random()*45;const pts=[];for(let s=0;s<=7;s++){const t=s/7;pts.push({wx:cx2+Math.cos(ang)*t*len,wy:cy+Math.sin(ang)*t*len*0.5,wz:OZ-5});}pushV(pts,hslToHexV(42+Math.random()*12,55,65+Math.random()*18),t=>1+Math.sin(t*Math.PI)*3.5,'round','sensitive');} }, eiffel: p => { const {OX=5000,OY=3500,OZ=5000,H=900,BASE=220} = p; const eiffelR = t => Math.max(4, BASE*Math.pow(1-t,1.7)+4); const colAt = t => hslToHexV(30 - t*4, 32 - t*16, 30 + t*44); const corners=[[-1,-1],[1,-1],[1,1],[-1,1]]; const LS=64; corners.forEach(([sx,sz])=>{const pts=[];for(let i=0;i<=LS;i++){const t=i/LS;const r=eiffelR(t);pts.push({wx:OX+sx*r,wy:OY-t*H,wz:OZ+sz*r});}pushV(pts,colAt(0.4),t=>3.4-t*2.2,'triangle','consistent');}); const RL=9; const ringT=[]; for(let lv=0;lv{const bl={wx:OX+ca[0]*r0,wy:y0,wz:OZ+ca[1]*r0},br={wx:OX+cb[0]*r0,wy:y0,wz:OZ+cb[1]*r0},tl={wx:OX+ca[0]*r1,wy:y1,wz:OZ+ca[1]*r1},tr={wx:OX+cb[0]*r1,wy:y1,wz:OZ+cb[1]*r1};pushV([bl,tr],colAt((t0+t1)/2),1.4,'bristle','consistent');pushV([br,tl],colAt((t0+t1)/2),1.4,'bristle','consistent');});} const tTop=ringT[RL-1],yTop=OY-tTop*H; pushV([{wx:OX,wy:yTop,wz:OZ},{wx:OX,wy:OY-H-60,wz:OZ}],hslToHexV(36,40,80),t=>2.2-t*1.6,'round','consistent'); pushV([{wx:OX,wy:OY-H-60,wz:OZ},{wx:OX,wy:OY-H-95,wz:OZ}],hslToHexV(44,85,62),2.6,'round','consistent'); }, }; VIRGIL_GENERATORS.strokeSet = p => (p.strokes||[]).forEach(s => strokes.push(Object.assign({}, s, { id:newId(), _noPersist:true, ts:(p.mortalTs!=null ? p.mortalTs : Date.now()) }))); const VIRGIL_GEN_DAYS = 9; const VIRGIL_OVERLAP = 0.68; const VIRGIL_LIFECYCLE = new Set(['noteCluster','lorenz','rossler','dandelion_clean','dandelion_expressive','murmuration','bird_of_paradise','nepenthes','eiffel','painting']); function _vgCycle(key){ const phase = _ncRng('ph|'+key)(); const d = Date.now()/86400000 / VIRGIL_GEN_DAYS + phase; return { gen: Math.floor(d), frac: d - Math.floor(d) }; } function _vgVary(type, params, key, genIdx){ const R = _ncRng('vary|'+key+'|'+genIdx); const p = Object.assign({}, params); const j = (v, d, lo, hi) => (v != null ? v : d) * (lo + R()*(hi-lo)); const drift = a => (R()*2-1)*a; if (p.OX != null) p.OX = wrap(p.OX + drift(500), WX); if (p.OY != null) p.OY = wrap(p.OY + drift(260), WY); if (p.OZ != null) p.OZ = wrap(p.OZ + drift(500), WZ); if (p.groundY != null) p.groundY = wrap(p.groundY + drift(160), WY); if (type==='lorenz'){ p.sigma=j(p.sigma,10,0.88,1.14); p.rho=j(p.rho,28,0.9,1.12); p.scale=j(p.scale,9,0.85,1.18); } else if (type==='noteCluster'){ if (p.rough != null || true) p.rough = Math.max(0.05, Math.min(1, j(p.rough, 0.3, 0.75, 1.3))); p.fragments = (p.fragments||[]).map(f => { const g = Object.assign({}, f); g.dx = (g.dx||0) + drift(55); g.dy = (g.dy||0) + drift(35); g.dz = (g.dz||0) + drift(55); if (g.rough != null) g.rough = Math.max(0.05, Math.min(1, j(g.rough, 0.3, 0.75, 1.3))); return g; }); } else if (type==='rossler'){ p.a=j(p.a,0.2,0.85,1.2); p.c=j(p.c,5.7,0.9,1.12); p.scale=j(p.scale,12,0.85,1.18); } else if (type==='dandelion_clean'){ p.N=Math.round(j(p.N,180,0.8,1.2)); p.R=j(p.R,280,0.85,1.18); p.seedLen=j(p.seedLen,90,0.8,1.3); } else if (type==='dandelion_expressive'){ p.N=Math.round(j(p.N,180,0.8,1.2)); p.R=j(p.R,300,0.85,1.18); } else if (type==='murmuration'){ p.birds=Math.round(j(p.birds,420,0.8,1.15)); p.rx=j(p.rx,280,0.85,1.2); p.ry=j(p.ry,420,0.85,1.2); p.rz=j(p.rz,260,0.85,1.2); p.phases=(p.phases||[0,0.18]).map(ph=>ph+R()*0.8); } else if (type==='bird_of_paradise'){ p.stemH=j(p.stemH,600,0.85,1.15); } else if (type==='nepenthes'){ p.PH=j(p.PH,320,0.85,1.18); } else if (type==='eiffel'){ p.H=j(p.H,900,0.88,1.12); p.BASE=j(p.BASE,220,0.88,1.12); } return p; } function loadVirgilObjects(){ if (!db) return; db.ref('field/objects_v1').once('value').then(snap => { const data = snap.val(); if (!data) return; let count = 0; Object.entries(data).forEach(([key, obj]) => { if (key === '_info' || !obj.type || !obj.params) return; const gen = VIRGIL_GENERATORS[obj.type]; if (!gen) return; const before = strokes.length; if (VIRGIL_LIFECYCLE.has(obj.type)){ const cyc = _vgCycle(key); _vgAgeMs = cyc.frac * FADE_DAYS * 86400000; gen(_vgVary(obj.type, obj.params, key, cyc.gen)); if (cyc.frac > VIRGIL_OVERLAP){ _vgAgeMs = 0; gen(_vgVary(obj.type, obj.params, key, cyc.gen + 1)); } _vgAgeMs = 86400000; } else { gen(obj.params); } for (let _i=before; _i console.warn('loadVirgilObjects failed', e)); } const RESEED_THRESHOLD = 400; const RESEED_COUNT = 17; const RESEED_INTERVAL = 60000; let _lastReseed = 0; function checkReseed(){ if (!fbConnected || !db) return; const now = Date.now(); if (now - _lastReseed < RESEED_INTERVAL) return; const drawCount = strokes.filter(s => !s.type || s.type==='stroke').length; if (drawCount >= RESEED_THRESHOLD) return; _lastReseed = now; console.log(`Reseeding — drawCount ${drawCount} below threshold ${RESEED_THRESHOLD}`); try { const genOf = id => { const m = /^echo(\d)_/.exec(id||''); return m ? +m[1] : 0; }; const pool = strokes.filter(s => (!s.type || s.type === 'stroke') && s.pts && s.pts.length && !s._noPersist && s.id && s.id.indexOf('dot_') !== 0 && s.id.indexOf('trail_') !== 0 && effAge(s) < 0.85 && genOf(s.id) < 2 ); const srcs = []; for (let i = 0; i < 7 && pool.length; i++){ srcs.push(pool.splice(Math.floor(Math.random()*pool.length), 1)[0]); } srcs.forEach(src => { const g = genOf(src.id) + 1; const jitter = 300; const clone = JSON.parse(JSON.stringify(serializeStroke(src))); delete clone._decay; clone.id = 'echo' + g + '_' + newId(); clone.ts = Date.now() - (2 + Math.random()*5)*86400000; if (clone.pts) clone.pts = clone.pts.map(p=>({wx:wrap((p.wx||0)+(Math.random()-.5)*jitter,WX),wy:wrap((p.wy||0)+(Math.random()-.5)*jitter,WY),wz:wrap((p.wz||0)+(Math.random()-.5)*jitter,WZ)})); if (clone.pts && clone.pts.length){ const mid=clone.pts[Math.floor(clone.pts.length/2)]; clone.wx=mid.wx; clone.wy=mid.wy; clone.wz=mid.wz; } clone.zone = ((Math.floor((clone.wz||0)/1000)%8)+8)%8; clone.xcell = ((Math.floor((clone.wx||0)/1000)%8)+8)%8; const s2 = restoreOneStroke(JSON.parse(JSON.stringify(clone))); if (s2){ s2._fbKey = clone.id; fbKeys.add(clone.id); strokes.push(s2); if (fbConnected && db) db.ref('field/strokes_v3/' + clone.zone + '/' + clone.id).set(clone).catch(()=>{}); } }); if (srcs.length) console.log(`Reseeded ${srcs.length} user echoes`); } catch(e){} db.ref('field/seed_library').once('value').then(snap => { const all = []; snap.forEach(ch => { const v=ch.val(); if(v) all.push(v); }); if (!all.length) return; const picks = []; for(let i=0;i<7&&all.length;i++){ const idx = Math.floor(Math.random()*all.length); picks.push(all.splice(idx,1)[0]); } picks.forEach(s => { const jitter = 300; const clone = JSON.parse(JSON.stringify(s)); clone.id = newId(); clone.ts = Date.now() - Math.random()*86400000*5; if (clone.pts) clone.pts = clone.pts.map(p=>({wx:wrap((p.wx||0)+(Math.random()-.5)*jitter,WX),wy:wrap((p.wy||0)+(Math.random()-.5)*jitter,WY),wz:wrap((p.wz||0)+(Math.random()-.5)*jitter,WZ)})); if (clone.wx !== undefined) { clone.wx=wrap(clone.wx+(Math.random()-.5)*jitter,WX); clone.wy=wrap(clone.wy+(Math.random()-.5)*jitter,WY); clone.wz=wrap(clone.wz+(Math.random()-.5)*jitter,WZ); } const s2 = restoreOneStroke(clone); if (s2) { strokes.push(s2); if(fbConnected&&db){ const _ser=serializeStroke(clone); db.ref('field/strokes_v3/'+(_ser.zone||0)+'/'+clone.id).set(_ser).catch(()=>{}); } } }); console.log(`Reseeded ${picks.length} Aaron marks`); }).catch(()=>{}); } const PINNED_KEYS = ["mpvbhxiq_v_rqhpi370_hk2qj","mpvcqayg_v_3hfbxv6b_wezac","mpvcqwbs_v_3hfbxv6b_vzrww","mpvfz8vi_v_z9idddnu_dcu4o","mpvgiy4u_v_t7vpqrxl_3kgex","mpvgiy4u_v_t7vpqrxl_66mlk","mpvgiy4u_v_t7vpqrxl_7bzi4","mpvgiy4u_v_t7vpqrxl_cjovz","mpvgiy4u_v_t7vpqrxl_gx4hf","mpvgiy4u_v_t7vpqrxl_jnkhl","mpvgiy4u_v_t7vpqrxl_kwuc7","mpvgiy4u_v_t7vpqrxl_n273l","mpvgiy4u_v_t7vpqrxl_ng47a","mpvgiy4u_v_t7vpqrxl_o8ygm","mpvgiy4u_v_t7vpqrxl_pli45","mpvgiy4u_v_t7vpqrxl_vkd3i","mpvgiy4u_v_t7vpqrxl_wa7ee","mpvgiy4u_v_t7vpqrxl_yl14w","mpvgkiss_v_t7vpqrxl_0odap","mpvgkiss_v_t7vpqrxl_3gt56","mpvgkiss_v_t7vpqrxl_49231","mpvgkiss_v_t7vpqrxl_4um0c","mpvgkiss_v_t7vpqrxl_5fvt2","mpvgkiss_v_t7vpqrxl_6e9bo","mpvgkiss_v_t7vpqrxl_6v1in","mpvgkiss_v_t7vpqrxl_7c7z0","mpvgkiss_v_t7vpqrxl_7xvmw","mpvgkiss_v_t7vpqrxl_85rww","mpvgkiss_v_t7vpqrxl_8gv0a","mpvgkiss_v_t7vpqrxl_8h5ai","mpvgkiss_v_t7vpqrxl_9tz7k","mpvgkiss_v_t7vpqrxl_9yjev","mpvgkiss_v_t7vpqrxl_aq302","mpvgkiss_v_t7vpqrxl_at0nk","mpvgkiss_v_t7vpqrxl_c9svt","mpvgkiss_v_t7vpqrxl_cv7p6","mpvgkiss_v_t7vpqrxl_d2hxl","mpvgkiss_v_t7vpqrxl_dfmq7","mpvgkiss_v_t7vpqrxl_dj4fj","mpvgkiss_v_t7vpqrxl_dm7h8","mpvgkiss_v_t7vpqrxl_ds5ez","mpvgkiss_v_t7vpqrxl_e4e4v","mpvgkiss_v_t7vpqrxl_ekzoi","mpvgkiss_v_t7vpqrxl_f2fwi","mpvgkiss_v_t7vpqrxl_fkf41","mpvgkiss_v_t7vpqrxl_g9a5b","mpvgkiss_v_t7vpqrxl_gk4r2","mpvgkiss_v_t7vpqrxl_gqzb5","mpvgkiss_v_t7vpqrxl_gu9yl","mpvgkiss_v_t7vpqrxl_h22t6","mpvgkiss_v_t7vpqrxl_h7kfz","mpvgkiss_v_t7vpqrxl_hb9qm","mpvgkiss_v_t7vpqrxl_hcr0i","mpvgkiss_v_t7vpqrxl_hzx5n","mpvgkiss_v_t7vpqrxl_i4hzf","mpvgkiss_v_t7vpqrxl_ij2d3","mpvgkiss_v_t7vpqrxl_j12j4","mpvgkiss_v_t7vpqrxl_j9l9a","mpvgkiss_v_t7vpqrxl_jb14b","mpvgkiss_v_t7vpqrxl_jp1g0","mpvgkiss_v_t7vpqrxl_k5yh1","mpvgkiss_v_t7vpqrxl_knexs","mpvgkiss_v_t7vpqrxl_l9y0k","mpvgkiss_v_t7vpqrxl_lq2s6","mpvgkiss_v_t7vpqrxl_m3n4p","mpvgkiss_v_t7vpqrxl_n9c4t","mpvgkiss_v_t7vpqrxl_nk5p2","mpvgkiss_v_t7vpqrxl_o2x7q","mpvgkiss_v_t7vpqrxl_p1b8m","mpvgkiss_v_t7vpqrxl_q4h2r","mpvgkiss_v_t7vpqrxl_r6j1s","mpvgkiss_v_t7vpqrxl_s2m5n","mpvgkiss_v_t7vpqrxl_t3k8p","mpvgkiss_v_t7vpqrxl_u1n4q","mpvgkiss_v_t7vpqrxl_v5j2r","mpvgkiss_v_t7vpqrxl_w7k3s","mpvgkiss_v_t7vpqrxl_x2m6t","mpvgkiss_v_t7vpqrxl_y4n1p","mpvgkiss_v_t7vpqrxl_z6j3q","mpvglezf_v_t7vpqrxl_rdc53","mpvgmed7_v_t7vpqrxl_k7ill"]; function loadPinnedMedia(){ if (!db) return; PINNED_KEYS.forEach(key => { if (fbKeys.has(key)) return; for (let z = 0; z < 8; z++){ db.ref('field/strokes_v3/' + z + '/' + key).once('value', snap => { const data = snap.val(); if (!data || fbKeys.has(key)) return; fbKeys.add(key); const s = restoreOneStroke(data); if (s) { s._fbKey = key; strokes.push(s); } }).catch(()=>{}); } }); } let ZONE_RANGE = (matchMedia('(max-width:820px)').matches || matchMedia('(pointer:coarse)').matches) ? 1 : 4; let FOG_SCALE = 0.6; const ZONE_CAP = 2500; let zoneRefs = {}; let subscribedZones = new Set(); function currentZone(){ return ((Math.floor(camZ / 1000) % 8) + 8) % 8; } function attachZoneListener(z){ if (subscribedZones.has(z)) return; const ref = db.ref('field/strokes_v3/' + z).limitToLast(ZONE_CAP); ref.on('child_added', snap => { const key = snap.key, data = snap.val(); if (!data || fbKeys.has(key)) return; fbKeys.add(key); const s = restoreOneStroke(data); if (s) { s._fbKey = key; strokes.push(s); } }); ref.on('child_changed', snap => { const key = snap.key, data = snap.val(); if (!data) return; const existing = strokes.find(s => s._fbKey === key); if (existing){ if (data.approved !== undefined) existing.approved = data.approved; if (data.imgUrl && !existing.imgUrl && !(existing._loaded && existing._img && existing._img.naturalWidth)){ existing.imgUrl = data.imgUrl; delete existing.dataURL; const newImg = new Image(); newImg.crossOrigin='anonymous'; newImg.onload = () => { existing._img = newImg; existing._loaded = true; }; newImg.src = data.imgUrl; } } }); ref.on('child_removed', snap => { const key = snap.key; fbKeys.delete(key); strokes = strokes.filter(s => s._fbKey !== key); }); zoneRefs[z] = ref; subscribedZones.add(z); console.log('Subscribed zone', z); } function detachZoneListener(z){ if (!subscribedZones.has(z)) return; if (zoneRefs[z]) zoneRefs[z].off(); delete zoneRefs[z]; subscribedZones.delete(z); console.log('Detached zone', z); } let _lastZoneUpdate = -1; function updateZoneSubscriptions(){ const cz = currentZone(); if (cz === _lastZoneUpdate) return; _lastZoneUpdate = cz; const needed = new Set(); for (let dz = -ZONE_RANGE; dz <= ZONE_RANGE; dz++){ needed.add(((cz + dz) % 8 + 8) % 8); } for (const z of needed) attachZoneListener(z); for (const z of subscribedZones){ if (!needed.has(z)) detachZoneListener(z); } } window._setZoneRange = n => { ZONE_RANGE = Math.max(0, Math.min(4, n|0)); _lastZoneUpdate = -1; updateZoneSubscriptions(); console.log('ZONE_RANGE', ZONE_RANGE, '-> zones', [...subscribedZones]); }; window._setFog = v => { FOG_SCALE = Math.max(0, +v); console.log('FOG_SCALE', FOG_SCALE); }; function initFirebase(){ try { const app = firebase.initializeApp(firebaseConfig); db = firebase.database(app); fbConnected = true; fbRef = db.ref('field/strokes_v3'); updateZoneSubscriptions(); db.ref('.info/connected').on('value', s => { if (s.val()) flushPendingCommits(); }); db.ref('field/bookmarks').on('value', s => { const a=[]; s.forEach(ch=>{ const v=ch.val(); if(v && isFinite(v.x)&&isFinite(v.y)&&isFinite(v.z)) a.push({x:v.x,y:v.y,z:v.z}); }); _driftPins=a; }); console.log('Firebase connected (v3 zone loader)'); setTimeout(loadVirgilObjects, 2000); setTimeout(loadPinnedMedia, 3000); } catch(e){ console.warn('Firebase failed, using localStorage', e); fbConnected = false; } } function serializeStroke(s, keepDecay){ let out; if (s.type === 'gif-anim'){ out = { type:'gif-anim', id:s.id, sw:s.sw, sh:s.sh, color:s.color, ts:s.ts, wx:s.wx, wy:s.wy, wz:s.wz, frames: s.frames.map(f => f.imgUrl ? { imgUrl:f.imgUrl, delay:f.delay } : { dataURL:f.dataURL, delay:f.delay }) }; if (s.maskEdge !== undefined) out.maskEdge = s.maskEdge; if (s.maskSplatter !== undefined) out.maskSplatter = s.maskSplatter; if (s.maskSeed !== undefined) out.maskSeed = s.maskSeed; } else if (s.type === 'stamp'){ out = { type:'stamp', id:s.id, sw:s.sw, sh:s.sh, color:s.color, ts:s.ts, wx:s.wx, wy:s.wy, wz:s.wz }; if (s.maskEdge !== undefined) out.maskEdge = s.maskEdge; if (s.maskSplatter !== undefined) out.maskSplatter = s.maskSplatter; if (s.maskSeed !== undefined) out.maskSeed = s.maskSeed; if (s.imgUrl) out.imgUrl = s.imgUrl; else if (s.dataURL) out.dataURL = s.dataURL; } else { const { _img, frames, _lastT, _fbKey, _mid, _decay, _seed, _rgb, _bris, _noPersist, ...rest } = s; if (rest.pts && rest.pts.length){ const m = rest.pts[Math.floor(rest.pts.length/2)]; rest.wx = m.wx; rest.wy = m.wy; rest.wz = m.wz; } out = rest; } const _zw = (typeof out.wz === 'number') ? out.wz : 0; const _xw = (typeof out.wx === 'number') ? out.wx : 0; out.zone = ((Math.floor(_zw / 1000) % 8) + 8) % 8; out.xcell = ((Math.floor(_xw / 1000) % 8) + 8) % 8; if (keepDecay && s._decay) out._decay = s._decay; return out; } function restoreOneStroke(s){ if (!s) return null; if (s._loadT == null) s._loadT = performance.now(); if ((s.type === 'stamp' || s.type === 'gif-anim') && s.approved === false) return null; if (s.type === 'gif-anim' && s.frames){ s.frames = s.frames.map(f => { const im=new Image(); if (f.imgUrl){ im.crossOrigin='anonymous'; im.src=f.imgUrl; } else im.src=f.dataURL||''; return {img:im, imgUrl:f.imgUrl||null, dataURL:f.dataURL||null, delay:f.delay}; }); s.frameIdx=0; s.frameTimer=0; s._lastT=null; } if (s.type === 'stamp' && (s.imgUrl || s.dataURL)){ s._img = new Image(); if (s.imgUrl) s._img.crossOrigin = 'anonymous'; s._img.onload = () => { s._loaded = true; }; s._img.src = s.imgUrl || s.dataURL; } return s; } function save(){ if (fbConnected && fbRef) saveToFirebase(); saveLocal(); } let _trimT = null; function trimSeedLibrary(){ if (!db) return; clearTimeout(_trimT); _trimT = setTimeout(() => { db.ref('field/seed_library').once('value').then(snap => { const entries = []; snap.forEach(ch => { const v = ch.val(); entries.push({ key: ch.key, ts: (v && v.ts) || 0 }); }); if (entries.length <= SEED_LIBRARY_MAX) return; entries.sort((a,b) => a.ts - b.ts); const drop = entries.slice(0, entries.length - SEED_LIBRARY_MAX); const updates = {}; drop.forEach(e => { updates[e.key] = null; }); db.ref('field/seed_library').update(updates).catch(()=>{}); }).catch(()=>{}); }, 1500); } function saveToFirebase(){ try { strokes.forEach((s,i) => { if (s._fbKey || s._noPersist) return; try { if (!s.id) s.id = newId(); const ser = serializeStroke(s); const key = s.id; s._fbKey = key; fbKeys.add(key); const zone = ser.zone != null ? ser.zone : 0; db.ref('field/strokes_v3/' + zone + '/' + key).set(ser).catch(e => { console.warn('fb save failed', e.message); delete s._fbKey; }); if (isOwner && s.type !== 'dot'){ try { db.ref('field/seed_library/' + key).set(ser).then(trimSeedLibrary).catch(()=>{}); } catch(e){} } } catch(err){ console.warn('serialize failed', err); } }); } catch(e){ saveLocal(); } } function saveLocal(){ try { const ser = strokes.slice(-600).map(s => serializeStroke(s, true)); localStorage.setItem(SK, JSON.stringify(ser)); } catch(e){} } function loadLocal(){ try { const r = localStorage.getItem(SK); if (!r) return; const arr = JSON.parse(r).map(restoreOneStroke).filter(Boolean); arr.forEach(s => { if (s.id){ fbKeys.add(s.id); s._fbKey = s.id; } }); strokes = arr; } catch(e){ strokes = []; } } const pendingCommits = new Map(); function queueCommit(id, fn){ if (id && fn) pendingCommits.set(id, fn); } function flushPendingCommits(){ if (!(fbConnected && fbRef && db)) return; pendingCommits.forEach(fn => { try { fn(); } catch(e){} }); } window.addEventListener('keydown', e => { const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; keys[e.key] = true; markActivity(); if (e.key === 'x'){ velX=velY=velZ=velYaw=velPitch=0; flyingHome=false; _bmDest=null; stopDrift(); } if (e.key === 'f' && !driftMode){ startDrift(); } if (e.key === 'm'){ toggleAudio(); } if (e.key === 'p' || e.key === 'P'){ if(typeof exportFrame==='function') exportFrame(); } if (typeof flyingHome !== 'undefined' && flyingHome){ flyingHome = false; _bmDest = null; } if (driftMode && ['w','a','s','d'].includes(e.key.toLowerCase())){ stopDrift(); } if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' '].includes(e.key)) e.preventDefault(); if (e.key === 'Escape' && stampPending) cancelStamp(); }); window.addEventListener('keyup', e => { keys[e.key] = false; }); function tickKeys(){ const fwdX = Math.sin(camYaw)*Math.cos(camPitch); const fwdY = -Math.sin(camPitch); const fwdZ = Math.cos(camYaw)*Math.cos(camPitch); const rightX = Math.cos(camYaw); const rightZ = -Math.sin(camYaw); if (keys['w']||keys['W']){ velX+=fwdX*THRUST; velY+=fwdY*THRUST; velZ+=fwdZ*THRUST; } if (keys['s']||keys['S']){ velX-=fwdX*THRUST; velY-=fwdY*THRUST; velZ-=fwdZ*THRUST; } if (keys['a']||keys['A']){ velX-=rightX*THRUST; velZ-=rightZ*THRUST; } if (keys['d']||keys['D']){ velX+=rightX*THRUST; velZ+=rightZ*THRUST; } if (keys['ArrowLeft']) camYaw -= ROT_SPD; if (keys['ArrowRight']) camYaw += ROT_SPD; if (keys['ArrowUp']) camPitch += ROT_SPD*0.7; if (keys['ArrowDown']) camPitch -= ROT_SPD*0.7; } function tickMove(){ let spd = Math.sqrt(velX*velX+velY*velY+velZ*velZ); if (spd > MAX_WASD){ const k=MAX_WASD/spd; velX*=k; velY*=k; velZ*=k; spd=MAX_WASD; } camX = wrap(camX+velX, WX); camY = wrap(camY+velY, WY); camZ = wrap(camZ+velZ, WZ); const recentWheel = (Date.now() - lastWheel) < 160; const thrusting = recentWheel || keys['w']||keys['W']||keys['s']||keys['S']||keys['a']||keys['A']||keys['d']||keys['D']; if (!thrusting && spd < STOP_EPS){ velX=velY=velZ=0; spd=0; } camYaw += velYaw; camPitch += velPitch; velYaw *= 0.85; velPitch *= 0.85; document.getElementById('posout').textContent = Math.round(camX)+' '+Math.round(camY)+' '+Math.round(camZ); } CV.addEventListener('wheel', e => { markActivity(); lastWheel = Date.now(); flyingHome = false; _bmDest = null; let dy = e.deltaY; if (e.deltaMode === 1) dy *= 16; else if (e.deltaMode === 2) dy *= H; const fx=Math.sin(camYaw)*Math.cos(camPitch), fy=-Math.sin(camPitch), fz=Math.cos(camYaw)*Math.cos(camPitch); let imp = dy * -0.12; const MIN_IMP = 1.2; if (Math.abs(imp) < MIN_IMP) imp = MIN_IMP * Math.sign(imp || -1); velX+=fx*imp; velY+=fy*imp; velZ+=fz*imp; e.preventDefault(); }, {passive:false}); let hsl = { h:140, s:32, l:30, a:100 }; let hslMode = 'h'; function hslToHex(h,s,l){ s/=100; l/=100; const a = s*Math.min(l,1-l); const f = n => { const k=(n+h/30)%12; const c=l-a*Math.max(-1,Math.min(k-3,9-k,1)); return Math.round(255*c).toString(16).padStart(2,'0'); }; return '#'+f(0)+f(8)+f(4); } function updateColor(){ col = hslToHex(hsl.h,hsl.s,hsl.l); const _pv = document.getElementById('colprev'); const _al = (hsl.a == null) ? 1 : Math.max(0, Math.min(1, hsl.a/100)); const _pr = hexRGB(col); const _pf = 'rgba('+_pr[0]+','+_pr[1]+','+_pr[2]+','+_al.toFixed(3)+')'; _pv.style.background = 'linear-gradient('+_pf+', '+_pf+'), repeating-conic-gradient(rgba(255,255,255,0.18) 0% 25%, rgba(0,0,0,0.35) 0% 50%) 50% / 10px 10px'; if (isGif && _gifBuf) reprocessGif(); else if (isGif && gifFrames) recolorVideoFrames(); else if (stampGray) applyStill(); } function setSlider(mode){ const sl = document.getElementById('hslSlider'); if (mode==='h'){ sl.min=0; sl.max=360; sl.value=hsl.h; } if (mode==='s'){ sl.min=0; sl.max=100; sl.value=hsl.s; } if (mode==='l'){ sl.min=0; sl.max=100; sl.value=hsl.l; } if (mode==='a'){ sl.min=0; sl.max=100; sl.value=hsl.a!=null?hsl.a:100; } sliderGradient(mode, sl); } function sliderGradient(mode, sl){ let g; if (mode==='h') g='linear-gradient(to right,hsl(0,80%,55%),hsl(60,80%,55%),hsl(120,80%,55%),hsl(180,80%,55%),hsl(240,80%,55%),hsl(300,80%,55%),hsl(360,80%,55%))'; else if (mode==='s') g=`linear-gradient(to right,hsl(${hsl.h},0%,${hsl.l}%),hsl(${hsl.h},100%,${hsl.l}%))`; else if (mode==='a') g=`linear-gradient(to right,transparent,hsl(${hsl.h},${hsl.s}%,${hsl.l}%))`; else g=`linear-gradient(to right,hsl(${hsl.h},${hsl.s}%,0%),hsl(${hsl.h},${hsl.s}%,50%),hsl(${hsl.h},${hsl.s}%,100%))`; sl.style.backgroundImage = g; } function calcSizes(pts, base, mode){ if (mode === 'consistent' || pts.length < 2) return pts.map(() => base); const sizes = []; for (let i=0;i _sc.r + 0.5){ solarRise = _sc.r; solarSet = _sc.s; } } catch(e){} function solarWarp(h){ const rise = solarRise, set = solarSet; if (rise == null || set == null || !(set > rise + 0.5)) return h; const noon = (rise + set) / 2; if (h < rise) return (h / Math.max(0.5, rise)) * 6; if (h < noon) return 6 + (h - rise) / Math.max(0.5, noon - rise) * 6; if (h < set) return 12 + (h - noon) / Math.max(0.5, set - noon) * 6; return 18 + (h - set) / Math.max(0.5, 24 - set) * 6; } function geoToWorld(lat, lon){ const cLat = Math.round(lat * 2) / 2; const cLon = Math.round(lon * 2) / 2; const wx = wrap(((cLon + 180) / 360) * WX, WX); const wy = wrap(((90 - cLat) / 180) * WY, WY); const wz = wrap(((cLon + cLat + 270) / 360) * WZ, WZ); return { wx, wy, wz }; } function geoToHue(lat, lon){ return ((lon + 180) / 360 * 300 + 60) % 360; } function parseWeatherCode(code, cloudPct){ const rain = (code >= 51 && code <= 67) || (code >= 80 && code <= 82); const snow = (code >= 71 && code <= 77) || (code >= 85 && code <= 86); const fog = code >= 45 && code <= 48; const cloudCover = cloudPct / 100; return { code, cloudCover, rain, snow, fog }; } function fetchWeather(lat, lon){ const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=weather_code,cloud_cover&daily=sunrise,sunset&timezone=auto`; fetch(url).then(r => r.json()).then(d => { if (d.current) weather = parseWeatherCode(d.current.weather_code, d.current.cloud_cover || 0); if (d.daily && d.daily.sunrise && d.daily.sunset){ const hm = s => { const m = /T(\d\d):(\d\d)/.exec(s||''); return m ? (+m[1] + (+m[2])/60) : null; }; const r0 = hm(d.daily.sunrise[0]), s0 = hm(d.daily.sunset[0]); if (r0 != null && s0 != null){ solarRise = r0; solarSet = s0; try { localStorage.setItem('of_solar_v1', JSON.stringify({ r:r0, s:s0, d:new Date().toDateString() })); } catch(e){} } } }).catch(() => {}); } function applyGeo(lat, lon, spoof){ geoLat = lat; geoLon = lon; const world = geoToWorld(geoLat, geoLon); spawnX = world.wx; spawnY = world.wy; spawnZ = world.wz; const hasBookmark = new URLSearchParams(location.search).get('bm'); if (!hasBookmark){ setTimeout(() => { flyingHome = true; flyHomeStart = performance.now(); flyHomeFrom = { x:camX, y:camY, z:camZ, yaw:camYaw, pitch:camPitch }; _bmDest = { x:spawnX, y:spawnY, z:spawnZ, yaw:0, pitch:0 }; }, 400); } fetchWeather(geoLat, geoLon); setInterval(() => fetchWeather(geoLat, geoLon), 600000); registerStar(geoLat, geoLon); if (!spoof) registerPresence(geoLat, geoLon); } function initGeo(){ const _tp = new URLSearchParams(location.search).get('t'); if (_tp != null && isFinite(Number(_tp))) { devTimeOverride = ((Number(_tp) % 24) + 24) % 24; console.log('[devTime] time spoof ->', devTimeOverride); } const _gp = new URLSearchParams(location.search).get('geo'); if (_gp) { const c = _gp.split(',').map(Number); if (c.length === 2 && isFinite(c[0]) && isFinite(c[1])) { console.log('[devGeo] location spoof ->', c[0], c[1]); applyGeo(c[0], c[1], true); return; } } if (!navigator.geolocation) return; navigator.geolocation.getCurrentPosition(pos => { applyGeo(pos.coords.latitude, pos.coords.longitude, false); }, () => {}, { timeout: 8000, maximumAge: 300000 }); } function registerPresence(lat, lon){ const tryRegister = () => { if (!fbConnected || !db || !myUid) { setTimeout(tryRegister, 800); return; } const cLat = Math.round(lat * 2) / 2; const cLon = Math.round(lon * 2) / 2; const world = geoToWorld(cLat, cLon); const SCATTER = 140; world.wx = wrap(world.wx + (Math.random()-0.5)*2*SCATTER, WX); world.wy = wrap(world.wy + (Math.random()-0.5)*2*SCATTER, WY); world.wz = wrap(world.wz + (Math.random()-0.5)*2*SCATTER, WZ); const hue = geoToHue(cLat, cLon); const size = 8 + Math.random() * 12; const geoCol = hslToHex(hue, 55 + Math.random()*30, 50 + Math.random()*20); const xcell = ((Math.floor(world.wx / 1000) % 8) + 8) % 8; const id = 'dot_' + myUid; const zone = ((Math.floor(world.wz / 1000) % 8) + 8) % 8; const ref = db.ref('field/strokes_v3/' + zone + '/' + id); fbKeys.add(id); dotRef = ref; dotSize = size; dotWorld = { wx:world.wx, wy:world.wy, wz:world.wz, zone, xcell }; ref.onDisconnect().set({ id, type: 'dot', wx: world.wx, wy: world.wy, wz: world.wz, color: geoCol, size, zone, xcell, ts: Date.now() }); const trailId = 'trail_' + myUid; trailRef = db.ref('field/strokes_v3/' + zone + '/' + trailId); fbKeys.add(trailId); console.log('Presence dot + trail ref (v3 zone', zone, ') queued at disconnect:', id, geoCol, size.toFixed(1)+'px'); }; tryRegister(); } let audioCtx = null, audioMuted = true, reverbNode = null, masterGain = null, masterComp = null, _audioStartT = 0; const PLUCK_DIST = 1200; const PLUCK_COOLDOWN = 600; const MAX_VOICES = 12; const OPEN_BREEZE_RAMP = 3.0; const OPEN_CHORAL_DELAY = 1.8; const OPEN_CHORAL_FADE = 3.0; const OPEN_PIXIE_DELAY = 4.2; let activeVoices = 0; const plucked = new Map(); let activeTrailVoices = 0; const MAX_TRAIL_VOICES = 3; const TRAIL_COOLDOWN = 4000; let _nearTrails = []; let _beatPrevCam = null; let _lastBeatT = 0; const BEAT_FLOOR = 95; let _beatLoad = 0; let _beatLoadT = 0; const MAX_BOWL_VOICES = 12; let activeBowlVoices = 0; const HIT_R = 45; const BEAT_PENTA = [55,61.74,69.30,82.41,92.50,110,123.47,138.59,164.81,185,220,246.94,277.18,329.63,370]; function initAudio(){ if (audioCtx) return; try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); try { if (navigator.audioSession) navigator.audioSession.type = 'playback'; } catch(e){} masterGain = audioCtx.createGain(); masterGain.gain.value = 0.9; masterComp = audioCtx.createDynamicsCompressor(); masterComp.threshold.setValueAtTime(-14, audioCtx.currentTime); masterComp.knee.setValueAtTime(24, audioCtx.currentTime); masterComp.ratio.setValueAtTime(3, audioCtx.currentTime); masterComp.attack.setValueAtTime(0.012, audioCtx.currentTime); masterComp.release.setValueAtTime(0.3, audioCtx.currentTime); masterGain.connect(masterComp); masterComp.connect(audioCtx.destination); const convLen = Math.floor(audioCtx.sampleRate * 3.2); const buf = audioCtx.createBuffer(2, convLen, audioCtx.sampleRate); for (let ch=0;ch<2;ch++){ const d = buf.getChannelData(ch); for (let i=0;i { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.value = v.f; g.gain.value = v.g; const lfo = audioCtx.createOscillator(); const lfoG = audioCtx.createGain(); lfo.type = 'sine'; lfo.frequency.value = 0.03 + i*0.017; lfoG.gain.value = 0.4 + i*0.15; lfo.connect(lfoG); lfoG.connect(osc.frequency); osc.connect(g); g.connect(rumbleFilter); osc.start(); lfo.start(); return osc; }); window._rumble = { oscs:rumbleOscs, gain:rumbleGain, filter:rumbleFilter }; _audioStartT = audioCtx.currentTime; noiseGain.gain.cancelScheduledValues(_audioStartT); noiseGain.gain.setValueAtTime(0.0001, _audioStartT); noiseGain.gain.linearRampToValueAtTime(0.008, _audioStartT + OPEN_BREEZE_RAMP); if (!window._ofWakeHooked){ window._ofWakeHooked = true; window._ofNeedsRevive = false; const _isMob = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); const _kick = () => { try { const b = audioCtx.createBuffer(1, 1, 22050); const s = audioCtx.createBufferSource(); s.buffer = b; s.connect(audioCtx.destination); s.start(0); } catch(e){} }; const _revive = () => { if (audioMuted || !audioCtx) return; try { const p = (audioCtx.state !== 'running') ? audioCtx.resume() : audioCtx.suspend().then(() => audioCtx.resume()); p.then(_kick).catch(()=>{}); } catch(e){} window._ofNeedsRevive = false; }; document.addEventListener('visibilitychange', () => { if (document.hidden){ window._ofNeedsRevive = true; return; } _revive(); if (_isMob) window._ofNeedsRevive = true; }); window.addEventListener('pageshow', () => { _revive(); if (_isMob) window._ofNeedsRevive = true; }); window.addEventListener('focus', () => { if (window._ofNeedsRevive) _revive(); }); const _touchRevive = () => { if (window._ofNeedsRevive || (!audioMuted && audioCtx && audioCtx.state !== 'running')) { window._ofTouchReviveT = Date.now(); _revive(); } }; document.addEventListener('touchstart', _touchRevive, {passive:true}); document.addEventListener('pointerdown', _touchRevive); } } catch(e){} } function hueToFreq(hue){ const scale = [0,2,4,7,9,12,14,16,19,21]; const idx = Math.floor((hue/360) * scale.length) % scale.length; const baseFreq = 220; return baseFreq * Math.pow(2, scale[idx]/12); } function nibToType(n){ if (n==='square') return 'square'; if (n==='triangle') return 'triangle'; if (n==='bristle') return 'sawtooth'; return 'sine'; } function pluckMark(s, dist){ if (!audioCtx || audioMuted || activeVoices >= MAX_VOICES) return; if (audioCtx.currentTime - _audioStartT < OPEN_CHORAL_DELAY) return; const now = performance.now(); if (plucked.has(s.id) && now - plucked.get(s.id) < PLUCK_COOLDOWN) return; plucked.set(s.id, now); const rgb = s._rgb || hexRGB(s.color || '#888'); const r=rgb[0]/255, g=rgb[1]/255, b=rgb[2]/255; const mx=Math.max(r,g,b), mn=Math.min(r,g,b); let h=0; if(mx!==mn){ const d=mx-mn; if(mx===r) h=((g-b)/d+(gWX/2)dx-=WX;if(dx<-WX/2)dx+=WX; if(dz>WZ/2)dz-=WZ;if(dz<-WZ/2)dz+=WZ; const rightX = Math.cos(camYaw), rightZ = -Math.sin(camYaw); const lateral = dx*rightX + dz*rightZ; const pan = Math.max(-1, Math.min(1, lateral / PLUCK_DIST)); const bodyType = (type === 'sawtooth' || type === 'square') ? 'triangle' : type; const voices = [ { detune: 0, g: vol, type: bodyType }, { detune: 7, g: vol * 0.85, type: 'sine' }, { detune: -6, g: vol * 0.85, type: 'sine' }, { detune: 1200, g: vol * 0.16, type: 'sine' }, ]; const gain = audioCtx.createGain(); const panner = audioCtx.createStereoPanner(); const filter = audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.setValueAtTime(freq * 2 + 300, t); filter.frequency.linearRampToValueAtTime(freq * 4 + 1400, t + attack + 0.4); filter.frequency.linearRampToValueAtTime(freq * 2 + 200, t + dur); filter.Q.setValueAtTime(0.5, t); panner.pan.setValueAtTime(pan, t); gain.gain.setValueAtTime(0.0001, t); gain.gain.linearRampToValueAtTime(1, t + attack); gain.gain.setValueAtTime(1, t + Math.max(attack + 0.01, dur * 0.4)); gain.gain.exponentialRampToValueAtTime(0.0001, t + dur); const dryGain = audioCtx.createGain(); const wetGain = audioCtx.createGain(); dryGain.gain.setValueAtTime(0.3, t); wetGain.gain.setValueAtTime(0.85, t); filter.connect(gain); gain.connect(panner); panner.connect(dryGain); dryGain.connect(masterGain || audioCtx.destination); if (reverbNode){ panner.connect(wetGain); wetGain.connect(reverbNode); } const lfo = audioCtx.createOscillator(); const lfoGain = audioCtx.createGain(); lfo.frequency.setValueAtTime(4.5 + Math.random()*1.5, t); lfoGain.gain.setValueAtTime(5 + Math.random()*3, t); lfo.connect(lfoGain); lfo.start(t); lfo.stop(t + dur + 0.2); const oscs = voices.map(v => { const osc = audioCtx.createOscillator(); const hGain = audioCtx.createGain(); osc.type = v.type; osc.frequency.setValueAtTime(freq, t); osc.detune.setValueAtTime(v.detune + (Math.random()-0.5)*4, t); lfoGain.connect(osc.detune); hGain.gain.setValueAtTime(v.g, t); osc.connect(hGain); hGain.connect(filter); osc.start(t); osc.stop(t + dur + 0.1); return { osc, hGain }; }); activeVoices++; oscs[0].osc.onended = () => { activeVoices--; oscs.forEach(o => { o.osc.disconnect(); o.hGain.disconnect(); }); try { lfo.disconnect(); lfoGain.disconnect(); } catch(e){} filter.disconnect(); gain.disconnect(); panner.disconnect(); dryGain.disconnect(); wetGain.disconnect(); }; } function vibrateTrail(s, dist){ if (!audioCtx || audioMuted || activeTrailVoices >= MAX_TRAIL_VOICES) return; if (audioCtx.currentTime - _audioStartT < OPEN_CHORAL_DELAY) return; const now = performance.now(); if (plucked.has(s.id) && now - plucked.get(s.id) < TRAIL_COOLDOWN) return; plucked.set(s.id, now); const rgb = s._rgb || hexRGB(s.color || '#888'); const r=rgb[0]/255, g=rgb[1]/255, b=rgb[2]/255; const mx=Math.max(r,g,b), mn=Math.min(r,g,b), L=(mx+mn)/2; let h=0, sat=0; if (mx!==mn){ const dd=mx-mn; sat = L>0.5 ? dd/(2-mx-mn) : dd/(mx+mn); if(mx===r) h=((g-b)/dd+(gWX/2)dx-=WX;if(dx<-WX/2)dx+=WX; if(dz>WZ/2)dz-=WZ;if(dz<-WZ/2)dz+=WZ; const rightX=Math.cos(camYaw), rightZ=-Math.sin(camYaw); const pan = Math.max(-1, Math.min(1, (dx*rightX+dz*rightZ)/PLUCK_DIST)); const gain = audioCtx.createGain(); const panner = audioCtx.createStereoPanner(); const filter = audioCtx.createBiquadFilter(); filter.type='lowpass'; filter.frequency.setValueAtTime(cutoff, t); filter.Q.setValueAtTime(0.6, t); panner.pan.setValueAtTime(pan, t); gain.gain.setValueAtTime(0.0001, t); gain.gain.linearRampToValueAtTime(vol, t + 0.4); gain.gain.setValueAtTime(vol, t + Math.max(0.5, dur*0.5)); gain.gain.exponentialRampToValueAtTime(0.0001, t + dur); const trem = audioCtx.createOscillator(); const tremG = audioCtx.createGain(); trem.type='sine'; trem.frequency.setValueAtTime(waveRate, t); tremG.gain.setValueAtTime(vol*0.45, t); trem.connect(tremG); tremG.connect(gain.gain); trem.start(t); trem.stop(t + dur + 0.2); const dryGain=audioCtx.createGain(), wetGain=audioCtx.createGain(); dryGain.gain.setValueAtTime(0.3, t); wetGain.gain.setValueAtTime(0.9, t); filter.connect(gain); gain.connect(panner); panner.connect(dryGain); dryGain.connect(masterGain||audioCtx.destination); if (reverbNode){ panner.connect(wetGain); wetGain.connect(reverbNode); } const vib = audioCtx.createOscillator(); const vibG = audioCtx.createGain(); vib.type='sine'; vib.frequency.setValueAtTime(waveRate*0.5, t); vibG.gain.setValueAtTime(spread*0.4, t); vib.connect(vibG); vib.start(t); vib.stop(t + dur + 0.2); const oscs = [-spread, spread].map(dt => { const osc=audioCtx.createOscillator(); const og=audioCtx.createGain(); osc.type=type; osc.frequency.setValueAtTime(freq, t); osc.detune.setValueAtTime(dt, t); vibG.connect(osc.detune); og.gain.setValueAtTime(0.5, t); osc.connect(og); og.connect(filter); osc.start(t); osc.stop(t + dur + 0.2); return osc; }); activeTrailVoices++; oscs[0].onended = () => { activeTrailVoices--; oscs.forEach(o=>o.disconnect()); try { trem.disconnect(); tremG.disconnect(); vib.disconnect(); vibG.disconnect(); } catch(e){} filter.disconnect(); gain.disconnect(); panner.disconnect(); dryGain.disconnect(); wetGain.disconnect(); }; } function crossingVoice(s, dist){ if (s.id && s.id.indexOf('trail_') === 0) vibrateTrail(s, dist); else pluckMark(s, dist); } window._presenceVoice = 'chant'; window._chant = { overtone: 2.6 }; function strikePoint(freq, vel, decay, pan){ if (window._presenceVoice === 'chime') return _strikeChime(freq, vel, decay, pan); if (window._presenceVoice === 'bow') return _bowPoint(freq, vel, decay, pan); return _chantPoint(freq, vel, decay, pan); } function _chantPoint(freq, vel, decay, pan){ if (activeBowlVoices >= MAX_BOWL_VOICES) return; const t = audioCtx.currentTime; let f = freq; while (f > 96) f /= 2; f = Math.max(48, f); const atk = Math.max(0.55, 0.9 - vel*0.25); const dur = Math.max(3.4, decay * 1.55); const out = audioCtx.createGain(); out.gain.setValueAtTime(1, t); const panner = audioCtx.createStereoPanner(); panner.pan.setValueAtTime(pan*0.7, t); out.connect(panner); const dry = audioCtx.createGain(); dry.gain.setValueAtTime(0.3, t); panner.connect(dry); dry.connect(masterGain || audioCtx.destination); let wet = null; if (reverbNode){ wet = audioCtx.createGain(); wet.gain.setValueAtTime(1.5, t); panner.connect(wet); wet.connect(reverbNode); } const env = audioCtx.createGain(); const peak = 0.15 * Math.max(0.08, vel); env.gain.setValueAtTime(0.0001, t); env.gain.linearRampToValueAtTime(peak, t + atk); env.gain.setValueAtTime(peak, t + Math.max(atk + 0.05, dur*0.55)); env.gain.exponentialRampToValueAtTime(0.0001, t + dur); const sway = audioCtx.createOscillator(); const swayG = audioCtx.createGain(); sway.type='sine'; sway.frequency.setValueAtTime(0.22, t); swayG.gain.setValueAtTime(peak*0.18, t); sway.connect(swayG); swayG.connect(env.gain); sway.start(t); sway.stop(t + dur + 0.2); env.connect(out); const chest = audioCtx.createBiquadFilter(); chest.type='lowpass'; chest.frequency.setValueAtTime(f*4 + 90, t); chest.Q.setValueAtTime(0.7, t); const formant = audioCtx.createBiquadFilter(); formant.type='peaking'; formant.frequency.setValueAtTime(520, t); formant.Q.setValueAtTime(1.1, t); formant.gain.setValueAtTime(6.5, t); chest.connect(formant); formant.connect(env); const ot = audioCtx.createBiquadFilter(); ot.type='bandpass'; ot.Q.setValueAtTime(22, t); const harms = [6, 8, 9, 10, 12]; const h0 = harms[Math.floor((freq*7) % harms.length)], h1 = harms[Math.floor((freq*13) % harms.length)]; ot.frequency.setValueAtTime(f*h0, t); ot.frequency.linearRampToValueAtTime(f*h1, t + dur*0.6); ot.frequency.linearRampToValueAtTime(f*h0, t + dur); const OT = (window._chant && window._chant.overtone) || 2.6; const otG = audioCtx.createGain(); otG.gain.setValueAtTime(0.0001, t); otG.gain.linearRampToValueAtTime(OT, t + atk + 0.4); otG.gain.linearRampToValueAtTime(0.0001, t + dur); ot.connect(otG); otG.connect(env); const GR = (window._chant && window._chant.growl != null) ? window._chant.growl : 0.55; const FRY = (window._chant && window._chant.fry != null) ? window._chant.fry : 0.35; const grit = audioCtx.createWaveShaper(); (function(){ const n=257, c=new Float32Array(n), dv=2+GR*8; for(let k=0;k { const o = audioCtx.createOscillator(); o.type = 'sawtooth'; o.frequency.setValueAtTime(f, t); o.detune.setValueAtTime(det, t); const og = audioCtx.createGain(); og.gain.setValueAtTime(0.4, t); o.connect(og); og.connect(chest); og.connect(ot); og.connect(grit); o.start(t); o.stop(t + dur + 0.1); oscs.push({o, g:og}); }); const sub = audioCtx.createOscillator(); sub.type='sine'; sub.frequency.setValueAtTime(f*0.5, t); const subG = audioCtx.createGain(); subG.gain.setValueAtTime(0.5, t); sub.connect(subG); subG.connect(chest); sub.start(t); sub.stop(t + dur + 0.1); activeBowlVoices++; oscs[0].o.onended = () => { activeBowlVoices--; try { oscs.forEach(v=>{v.o.disconnect();v.g.disconnect();}); sub.disconnect(); subG.disconnect(); sway.disconnect(); swayG.disconnect(); chest.disconnect(); formant.disconnect(); ot.disconnect(); otG.disconnect(); env.disconnect(); out.disconnect(); panner.disconnect(); dry.disconnect(); if(wet)wet.disconnect(); } catch(e){} }; } function _bowPoint(freq, vel, decay, pan){ if (activeBowlVoices >= MAX_BOWL_VOICES) return; const t = audioCtx.currentTime; const f = Math.max(55, Math.min(220, freq)); const atk = Math.max(0.35, 0.6 - vel*0.2); const dur = Math.max(1.8, decay * 0.9); const out = audioCtx.createGain(); out.gain.setValueAtTime(1, t); const panner = audioCtx.createStereoPanner(); panner.pan.setValueAtTime(pan, t); out.connect(panner); const dry = audioCtx.createGain(); dry.gain.setValueAtTime(0.32, t); panner.connect(dry); dry.connect(masterGain || audioCtx.destination); let wet = null; if (reverbNode){ wet = audioCtx.createGain(); wet.gain.setValueAtTime(1.1, t); panner.connect(wet); wet.connect(reverbNode); } const lp = audioCtx.createBiquadFilter(); lp.type='lowpass'; lp.Q.setValueAtTime(0.8, t); lp.frequency.setValueAtTime(f*3 + 120, t); lp.frequency.linearRampToValueAtTime(f*6 + 350 + vel*500, t + atk + 0.25); lp.frequency.linearRampToValueAtTime(f*2.5 + 100, t + dur); const env = audioCtx.createGain(); const peak = 0.16 * Math.max(0.08, vel); env.gain.setValueAtTime(0.0001, t); env.gain.linearRampToValueAtTime(peak, t + atk); env.gain.setValueAtTime(peak, t + Math.max(atk + 0.05, dur * 0.45)); env.gain.exponentialRampToValueAtTime(0.0001, t + dur); lp.connect(env); env.connect(out); const vib = audioCtx.createOscillator(); const vibG = audioCtx.createGain(); vib.type='sine'; vib.frequency.setValueAtTime(4.6, t); vibG.gain.setValueAtTime(0, t); vibG.gain.linearRampToValueAtTime(5, t + atk + 0.5); vib.connect(vibG); vib.start(t); vib.stop(t + dur + 0.2); const oscs = []; [[-5,'sawtooth',0.5],[5,'sawtooth',0.5],[0,'triangle',0.55],[1200,'sine',0.06]].forEach(([det,ty,g]) => { const o = audioCtx.createOscillator(); o.type = ty; o.frequency.setValueAtTime(f, t); o.detune.setValueAtTime(det, t); vibG.connect(o.detune); const og = audioCtx.createGain(); og.gain.setValueAtTime(g, t); o.connect(og); og.connect(lp); o.start(t); o.stop(t + dur + 0.1); oscs.push({o, g:og}); }); let nb=null, nf=null, ng=null; if (window._noiseBuf){ nb = audioCtx.createBufferSource(); nb.buffer = window._noiseBuf; nf = audioCtx.createBiquadFilter(); nf.type='bandpass'; nf.frequency.setValueAtTime(f*8, t); nf.Q.setValueAtTime(1.2, t); ng = audioCtx.createGain(); ng.gain.setValueAtTime(0.0001, t); ng.gain.linearRampToValueAtTime(0.012*vel, t + atk*0.6); ng.gain.exponentialRampToValueAtTime(0.0001, t + atk + 0.6); nb.connect(nf); nf.connect(ng); ng.connect(out); nb.start(t); nb.stop(t + atk + 0.8); } activeBowlVoices++; oscs[0].o.onended = () => { activeBowlVoices--; try { oscs.forEach(v=>{v.o.disconnect();v.g.disconnect();}); vib.disconnect(); vibG.disconnect(); lp.disconnect(); env.disconnect(); out.disconnect(); panner.disconnect(); dry.disconnect(); if(wet)wet.disconnect(); if(nb){nb.disconnect();nf.disconnect();ng.disconnect();} } catch(e){} }; } function _strikeChime(freq, vel, decay, pan){ if (activeBowlVoices >= MAX_BOWL_VOICES) return; const t = audioCtx.currentTime; const ring = decay; const out = audioCtx.createGain(); out.gain.setValueAtTime(1, t); const panner = audioCtx.createStereoPanner(); panner.pan.setValueAtTime(pan, t); out.connect(panner); const dry = audioCtx.createGain(); dry.gain.setValueAtTime(0.3, t); panner.connect(dry); dry.connect(masterGain || audioCtx.destination); let wet = null; if (reverbNode){ wet = audioCtx.createGain(); wet.gain.setValueAtTime(1.4, t); panner.connect(wet); wet.connect(reverbNode); } const partials = [[1.0,1.0,1.0],[2.0,0.5,0.85],[3.0,0.3,0.7],[4.72,0.13,0.5]]; const oscs = []; [-4, 4].forEach(det => { partials.forEach(p => { const o = audioCtx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(freq*p[0], t); o.detune.setValueAtTime(det, t); const g = audioCtx.createGain(); const peak = 0.09*vel*p[1]; const pd = Math.max(0.3, ring*p[2]); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(peak, t + 0.028); g.gain.exponentialRampToValueAtTime(0.0001, t + pd); o.connect(g); g.connect(out); o.start(t); o.stop(t + pd + 0.1); oscs.push({o,g}); }); }); let nb=null, nf=null, ng=null; if (window._noiseBuf){ nb = audioCtx.createBufferSource(); nb.buffer = window._noiseBuf; nf = audioCtx.createBiquadFilter(); nf.type='bandpass'; nf.frequency.setValueAtTime(freq*4, t); nf.Q.setValueAtTime(2, t); ng = audioCtx.createGain(); ng.gain.setValueAtTime(0.02*vel, t); ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.04); nb.connect(nf); nf.connect(ng); ng.connect(out); nb.start(t); nb.stop(t + 0.06); } activeBowlVoices++; oscs[0].o.onended = () => { activeBowlVoices--; try { oscs.forEach(v=>{v.o.disconnect();v.g.disconnect();}); out.disconnect(); panner.disconnect(); dry.disconnect(); if(wet)wet.disconnect(); if(nb){nb.disconnect();nf.disconnect();ng.disconnect();} } catch(e){} }; } function tickBeats(){ if (audioMuted || !audioCtx) return; if (audioCtx.currentTime - _audioStartT < OPEN_CHORAL_DELAY){ _beatPrevCam = {x:camX,y:camY,z:camZ}; return; } const Cx=camX, Cy=camY, Cz=camZ; const _bnow = performance.now(); if (_beatLoadT) _beatLoad *= Math.exp(-(_bnow - _beatLoadT)/900); _beatLoadT = _bnow; const P = _beatPrevCam || {x:Cx,y:Cy,z:Cz}; const bx=P.x-Cx, by=P.y-Cy, bz=P.z-Cz; const bb = bx*bx+by*by+bz*bz; const rightX=Math.cos(camYaw), rightZ=-Math.sin(camYaw); for (const s of _nearTrails){ const pts = s.pts; if (!pts || pts.length<1) continue; if (!s._beatArm || s._beatArm.length !== pts.length) s._beatArm = new Array(pts.length).fill(true); if (s._beatShift == null){ const rgb = s._rgb || hexRGB(s.color || '#888'); const r=rgb[0]/255,gg=rgb[1]/255,b=rgb[2]/255, mx=Math.max(r,gg,b),mn=Math.min(r,gg,b),dd=mx-mn; let hue01=0; if(dd>0){ if(mx===r)hue01=((gg-b)/dd+(ggWX/2)qx-=WX; if(qx<-WX/2)qx+=WX; if(qy>WY/2)qy-=WY; if(qy<-WY/2)qy+=WY; if(qz>WZ/2)qz-=WZ; if(qz<-WZ/2)qz+=WZ; let tt = bb>1e-6 ? (qx*bx+qy*by+qz*bz)/bb : 0; tt = tt<0?0:(tt>1?1:tt); const ex=qx-tt*bx, ey=qy-tt*by, ez=qz-tt*bz; const segDist = Math.sqrt(ex*ex+ey*ey+ez*ez); if (segDist < HIT_R){ if (s._beatArm[i] !== false){ const now = performance.now(); const effFloor = (BEAT_FLOOR + _beatLoad*15) * (0.85 + Math.random()*0.3); if (now - _lastBeatT >= effFloor && activeBowlVoices < MAX_BOWL_VOICES){ _lastBeatT = now; s._beatArm[i] = false; _beatLoad = Math.min(7, _beatLoad + 1); const sz = (s.sizes && s.sizes[i]!=null) ? s.sizes[i] : TRAIL_BASE_SIZE; const szN = Math.max(0, Math.min(1, (sz-1)/8)); let idx = Math.round((1-szN)*(BEAT_PENTA.length-1)) + s._beatShift; idx = idx<0?0:(idx>BEAT_PENTA.length-1?BEAT_PENTA.length-1:idx); const freq = BEAT_PENTA[idx]; const vel = 0.5 + szN*0.5; const decay = 2.5 + szN*2.5 + Math.min(2.0, _beatLoad*0.35); const d0 = Math.sqrt(qx*qx+qy*qy+qz*qz); const distFade = Math.max(0.12, 1 - d0/PLUCK_DIST); const pan = Math.max(-1, Math.min(1, (qx*rightX+qz*rightZ)/PLUCK_DIST)); strikePoint(freq, vel*distFade, decay, pan); } } } else if (segDist > HIT_R*1.8){ s._beatArm[i] = true; } } } _beatPrevCam = {x:Cx,y:Cy,z:Cz}; } let _crossingFrame = 0; function checkCrossings(){ if (audioMuted || !audioCtx) return; _crossingFrame++; if (_crossingFrame % 5 !== 0) return; let nearCount = 0; _nearTrails.length = 0; for (const s of strokes){ if (s.type === 'dot') continue; const pts = s.pts; if (pts && pts.length > 1){ const step = Math.max(1, Math.floor(pts.length / 4)); for (let i=0; iWX/2)dx-=WX;if(dx<-WX/2)dx+=WX; if(dy>WY/2)dy-=WY;if(dy<-WY/2)dy+=WY; if(dz>WZ/2)dz-=WZ;if(dz<-WZ/2)dz+=WZ; const dist = Math.sqrt(dx*dx+dy*dy+dz*dz); if (dist < PLUCK_DIST){ nearCount++; crossingVoice(s, dist); if (s.id && s.id.indexOf('trail_')===0) _nearTrails.push(s); break; } } } else { const wx = s.wx || (pts && pts[0] ? pts[0].wx : null); const wy = s.wy || (pts && pts[0] ? pts[0].wy : null); const wz = s.wz || (pts && pts[0] ? pts[0].wz : null); if (wx==null) continue; let dx=wx-camX, dy=wy-camY, dz=wz-camZ; if(dx>WX/2)dx-=WX;if(dx<-WX/2)dx+=WX; if(dy>WY/2)dy-=WY;if(dy<-WY/2)dy+=WY; if(dz>WZ/2)dz-=WZ;if(dz<-WZ/2)dz+=WZ; const dist = Math.sqrt(dx*dx+dy*dy+dz*dz); if (dist < PLUCK_DIST){ nearCount++; crossingVoice(s, dist); } } } if (window._rumble){ const density = Math.min(1, nearCount / 15); const curve = density * density; const targetVol = curve * 0.04; window._rumble.gain.gain.setTargetAtTime(targetVol, audioCtx.currentTime, 0.6); } } setInterval(() => { const now=performance.now(); for (const [k,v] of plucked){ if(now-v>5000) plucked.delete(k); } }, 5000); const FIREFLY_COUNT = 40; let fireflies = []; function initFireflies(){ fireflies = []; for (let i=0;i 1000){ _ffDecayCacheT = now; _ffDecayCache = []; for (const s of strokes){ const age = effAge(s); if (age > DISS_START && age < 1){ const wx = s.wx || (s.pts && s.pts[0] ? s.pts[0].wx : null); const wy = s.wy || (s.pts && s.pts[0] ? s.pts[0].wy : null); const wz = s.wz || (s.pts && s.pts[0] ? s.pts[0].wz : null); if (wx != null) _ffDecayCache.push({wx, wy, wz}); } } } for (const f of fireflies){ if (now < f.retargetAt) continue; f.retargetAt = now + 2000 + Math.random()*3000; if (_ffDecayCache.length > 0){ const t = _ffDecayCache[Math.floor(Math.random()*_ffDecayCache.length)]; f.targetWx = t.wx + (Math.random()-0.5)*200; f.targetWy = t.wy + (Math.random()-0.5)*200; f.targetWz = t.wz + (Math.random()-0.5)*200; } else { f.targetWx = wrap(camX + (Math.random()-0.5)*1500, WX); f.targetWy = wrap(camY + (Math.random()-0.5)*1000, WY); f.targetWz = wrap(camZ + (Math.random()-0.5)*1500, WZ); } } } let _pixieVoices = 0; function pixieTone(f, pr){ if (!audioCtx || audioMuted || _pixieVoices >= 4) return; if (audioCtx.currentTime - _audioStartT < OPEN_PIXIE_DELAY) return; if (pr.depth > 800) return; if (Math.random() > 0.006) return; const t = audioCtx.currentTime; const baseFreq = hueToFreq(f.toneHue) * 2; const radialV = (f.dx * Math.sin(camYaw) + f.dz * Math.cos(camYaw)); const dop = Math.max(-0.16, Math.min(0.16, radialV * 0.9)); const fStart = baseFreq * (1 + dop); const fEnd = Math.max(1, baseFreq * (1 - dop)); const rightX = Math.cos(camYaw), rightZ = -Math.sin(camYaw); const lateralV = f.dx * rightX + f.dz * rightZ; const panNow = Math.max(-1, Math.min(1, (pr.sx/W - 0.5)*2)); const panEnd = Math.max(-1, Math.min(1, panNow + lateralV * 0.9)); const distVol = Math.max(0, 1 - pr.depth / 800); const PIXIE_VOL_MIN = 0.004, PIXIE_VOL_MAX = 0.010; const baseVol = PIXIE_VOL_MIN + Math.random() * (PIXIE_VOL_MAX - PIXIE_VOL_MIN); const vol = baseVol + distVol * baseVol * 2.2; const dur = 1.3 + Math.random() * 1.3; const gain = audioCtx.createGain(); const pan = audioCtx.createStereoPanner(); const res = audioCtx.createBiquadFilter(); res.type = 'bandpass'; res.frequency.setValueAtTime(baseFreq * 1.25, t); res.Q.setValueAtTime(4, t); gain.gain.setValueAtTime(0.0001, t); gain.gain.linearRampToValueAtTime(vol, t + 0.12); gain.gain.exponentialRampToValueAtTime(0.0001, t + dur); pan.pan.setValueAtTime(panNow, t); pan.pan.linearRampToValueAtTime(panEnd, t + dur); res.connect(gain); gain.connect(pan); pan.connect(masterGain || audioCtx.destination); if (reverbNode){ const wet=audioCtx.createGain(); wet.gain.value=1.0; pan.connect(wet); wet.connect(reverbNode); } const lfo = audioCtx.createOscillator(); const lfoG = audioCtx.createGain(); lfo.type = 'sine'; lfo.frequency.setValueAtTime(5 + Math.random()*2, t); lfoG.gain.setValueAtTime(6, t); lfo.connect(lfoG); lfo.start(t); lfo.stop(t + dur + 0.1); const oscs = [-6, 6].map(dt => { const osc = audioCtx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(fStart, t); osc.frequency.exponentialRampToValueAtTime(fEnd, t + dur); osc.detune.setValueAtTime(dt, t); lfoG.connect(osc.detune); osc.connect(res); osc.start(t); osc.stop(t + dur + 0.1); return osc; }); _pixieVoices++; oscs[0].onended = () => { _pixieVoices--; oscs.forEach(o=>o.disconnect()); try { lfo.disconnect(); lfoG.disconnect(); } catch(e){} res.disconnect(); gain.disconnect(); pan.disconnect(); }; } function drawFireflies(darkness){ fireflySeekTargets(); const now = performance.now() * 0.001; cx.save(); for (const f of fireflies){ if (f.targetWx != null){ let dx = f.targetWx - f.wx, dy = f.targetWy - f.wy, dz = f.targetWz - f.wz; if(dx>WX/2)dx-=WX;if(dx<-WX/2)dx+=WX; if(dy>WY/2)dy-=WY;if(dy<-WY/2)dy+=WY; if(dz>WZ/2)dz-=WZ;if(dz<-WZ/2)dz+=WZ; f.dx += dx * 0.001; f.dy += dy * 0.001; f.dz += dz * 0.001; } f.dx *= 0.96; f.dy *= 0.96; f.dz *= 0.96; f.dx += (Math.random()-0.5)*0.15; f.dy += (Math.random()-0.5)*0.1; f.dz += (Math.random()-0.5)*0.15; f.wx = wrap(f.wx + f.dx, WX); f.wy = wrap(f.wy + f.dy, WY); f.wz = wrap(f.wz + f.dz, WZ); const pr = project(f.wx, f.wy, f.wz); if (!pr || pr.depth < 10 || pr.depth > 3000) continue; const t2 = now * f.speed + f.phase; const jit = Math.sin(t2 * f.jitter * 3.7) * 0.3 + Math.sin(t2 * f.jitter * 7.1) * 0.2; const cycle = (t2 + jit) % (Math.PI*2); const flash = cycle < f.flashLen * Math.PI*2 ? Math.sin(cycle / (f.flashLen*Math.PI*2) * Math.PI) : 0; if (flash < 0.05) continue; const distFade = Math.max(0.1, 1 - pr.depth/2500); const vis = 0.32 + 0.68 * darkness; const alpha = flash * vis * distFade * 0.85; cx.globalAlpha = alpha; cx.fillStyle = `hsl(${f.hue},80%,${f.lum}%)`; const r = f.size * Math.max(0.3, pr.scale * 0.5); if (f.trail.length > 1){ cx.globalAlpha = alpha * 0.25; cx.strokeStyle = `hsl(${f.hue},60%,${f.lum}%)`; cx.lineWidth = r * 0.6; cx.lineCap = 'round'; cx.beginPath(); cx.moveTo(f.trail[0].x, f.trail[0].y); for (let ti=1;ti 8) f.trail.shift(); cx.globalAlpha = alpha; cx.beginPath(); cx.arc(pr.sx, pr.sy, r*0.3, 0, Math.PI*2); cx.fill(); if (pr.depth < 800) pixieTone(f, pr); } cx.restore(); } function drawRainbow(sunF, daylight){ if (!weather.rain || daylight < 0.25) return; const centerX = (1 - sunF) * W; const centerY = H * 0.95; const baseR = Math.min(W, H) * 0.7; const colors = [ [255,0,0], [255,127,0], [255,255,0], [0,200,0], [0,0,255], [75,0,130], [148,0,211] ]; cx.save(); cx.globalCompositeOperation = 'lighter'; for (let i=0; iWX/2)dx-=WX;if(dx<-WX/2)dx+=WX; if(dy>WY/2)dy-=WY;if(dy<-WY/2)dy+=WY; if(dz>WZ/2)dz-=WZ;if(dz<-WZ/2)dz+=WZ; const dist=Math.sqrt(dx*dx+dy*dy+dz*dz); if(dist>BOID_VIS)continue; n++; aliX+=o.vx; aliY+=o.vy; aliZ+=o.vz; cohX+=dx; cohY+=dy; cohZ+=dz; if(dist0){sepX-=dx/dist;sepY-=dy/dist;sepZ-=dz/dist;} } if(n>0){ b.vx+=(aliX/n-b.vx)*0.008; b.vy+=(aliY/n-b.vy)*0.008; b.vz+=(aliZ/n-b.vz)*0.008; b.vx+=(cohX/n)*0.001; b.vy+=(cohY/n)*0.001; b.vz+=(cohZ/n)*0.001; } b.vx+=sepX*0.03; b.vy+=sepY*0.03; b.vz+=sepZ*0.03; b.vx+=(Math.random()-0.5)*0.04; b.vy+=(Math.random()-0.5)*0.02; b.vz+=(Math.random()-0.5)*0.04; const spd=Math.sqrt(b.vx*b.vx+b.vy*b.vy+b.vz*b.vz); if(spd>BOID_MAX_V){const k=BOID_MAX_V/spd;b.vx*=k;b.vy*=k;b.vz*=k;} b.wx=wrap(b.wx+b.vx,WX); b.wy=wrap(b.wy+b.vy,WY); b.wz=wrap(b.wz+b.vz,WZ); } } function drawBoids(){ cx.save(); for (const b of boids){ const pr=project(b.wx,b.wy,b.wz); if(!pr||pr.depth<10||pr.depth>4000)continue; const fog=Math.exp(-0.00022*pr.depth*pr.depth*0.001)*Math.min(1,Math.max(0,(pr.depth-20)/80)); cx.globalAlpha=Math.max(0.05,fog*0.7); cx.fillStyle='#111'; cx.beginPath(); cx.arc(pr.sx,pr.sy,Math.max(0.8,b.size*pr.scale),0,Math.PI*2); cx.fill(); } cx.restore(); } const RAIN_COUNT = 180, SNOW_COUNT = 80; let rainDrops = [], snowFlakes = []; function initParticles(){ for (let i=0;i H+20){ d.y=-10; d.x=Math.random()*W; } if (d.x > W+10){ d.x=-5; } cx.globalAlpha = d.op * op; cx.beginPath(); cx.moveTo(d.x, d.y); cx.lineTo(d.x+1.5, d.y+d.len); cx.stroke(); } cx.restore(); } function drawSnow(cloudOpacity){ const op = cloudOpacity * 0.85; cx.save(); for (const f of snowFlakes){ f.y += f.speed; f.x += f.drift; if (f.y > H+10){ f.y=-5; f.x=Math.random()*W; } cx.globalAlpha = f.op * op; cx.fillStyle = 'rgba(220,230,255,1)'; cx.beginPath(); cx.arc(f.x, f.y, f.r, 0, Math.PI*2); cx.fill(); } cx.restore(); } let stars = {}; let myStarId = null; function geoToScreen(lat, lon){ const x = ((lon + 180) / 360) * W; const y = ((90 - lat) / 180) * H * 0.75 + H * 0.05; return { x, y }; } function registerStar(lat, lon){ if (!db || !fbConnected) { setTimeout(() => registerStar(lat, lon), 1000); return; } const cLat = Math.round(lat * 2) / 2; const cLon = Math.round(lon * 2) / 2; const hue = Math.round(hsl.h); const pos = geoToScreen(cLat, cLon); const fadeMs = (60 + Math.random() * 240) * 1000; const size = 0.5 + Math.random() * 2.0; const twinkleSpeed = 0.4 + Math.random() * 1.2; const twinklePhase = Math.random() * Math.PI * 2; const starData = { lat: cLat, lon: cLon, hue, x: pos.x, y: pos.y, fadeMs, size, twinkleSpeed, twinklePhase, ts: Date.now() }; const ref = db.ref('field/presence').push(starData); myStarId = ref.key; ref.onDisconnect().remove(); db.ref('field/presence').on('child_added', snap => { const d = snap.val(); if (!d) return; stars[snap.key] = { ...d, arrivalT: performance.now() }; }); db.ref('field/presence').on('child_removed', snap => { if (stars[snap.key]) stars[snap.key]._leaving = performance.now(); }); } function projDome(az, elev){ const dx=Math.cos(elev)*Math.sin(az), dy=-Math.sin(elev), dz=Math.cos(elev)*Math.cos(az); const ry=rotY(dx,dy,dz,-camYaw*SKY_BODY_SPREAD); const rp=rotX(ry.x,ry.y,ry.z,-camPitch*SKY_BODY_SPREAD); if(rp.z<=0.02) return null; return { x:W/2+(rp.x/rp.z)*FOV, y:H/2+(rp.y/rp.z)*FOV }; } function projDomeZ(az, elev){ const dx=Math.cos(elev)*Math.sin(az), dy=-Math.sin(elev), dz=Math.cos(elev)*Math.cos(az); const ry=rotY(dx,dy,dz,-camYaw*SKY_BODY_SPREAD); const rp=rotX(ry.x,ry.y,ry.z,-camPitch*SKY_BODY_SPREAD); if(rp.z<=0.02) return null; return { x:W/2+(rp.x/rp.z)*FOV, y:H/2+(rp.y/rp.z)*FOV, z:rp.z }; } function geoToDome(lat, lon){ return { az:((lon+180)/360)*6.2831853, elev:Math.max(-0.25, Math.min(1.4, (lat/90)*1.25)) }; } const CONSTELLATION = (function(){ function mk(s){ return function(){ s|=0; s=s+0x6D2B79F5|0; let t=Math.imul(s^s>>>15,1|s); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }; } const r=mk(20260608), N=170, stars=[]; for(let i=0;iMath.acos(Math.max(-1,Math.min(1,Math.cos(a.elev)*Math.cos(b.elev)*Math.cos(a.az-b.az)+Math.sin(a.elev)*Math.sin(b.elev)))); for(let i=0;ii && bd<0.22 && r()<0.7) links.push([i,best]); } return { stars, links }; })(); let _starOcc = { sun:null, moon:null }; function _clipSkyBodies(){ const o = _starOcc; if (!o.sun && !o.moon) return; cx.beginPath(); cx.rect(0, 0, W, H); if (o.sun){ cx.moveTo(o.sun.x + o.sun.r, o.sun.y); cx.arc(o.sun.x, o.sun.y, o.sun.r, 0, Math.PI*2); } if (o.moon){ cx.moveTo(o.moon.x + o.moon.r, o.moon.y); cx.arc(o.moon.x, o.moon.y, o.moon.r, 0, Math.PI*2); } cx.clip('evenodd'); } function drawConstellations(skyDarkness){ if(skyDarkness<0.02) return; const now=performance.now(), S=CONSTELLATION.stars, P=new Array(S.length); for(let i=0;iW+60&&b.x>W+60)||(a.y<-60&&b.y<-60)||(a.y>H+60&&b.y>H+60)) continue; const sa=S[pr[0]], sb=S[pr[1]], hue=(sa.hue+sb.hue)*0.5, sat=(sa.sat+sb.sat)*0.5, lit=(sa.lit+sb.lit)*0.5; const dx=b.x-a.x, dy=b.y-a.y, len=Math.hypot(dx,dy)||1, K=Math.max(5,(len/5)|0); cx.fillStyle=`hsl(${hue|0},${sat|0}%,${lit|0}%)`; for(let k=0;k<=K;k++){ const u=k/K, endw=Math.abs(u-0.5)*2; const rr=0.55+endw*endw*1.5, x=a.x+dx*u, y=a.y+dy*u; cx.globalAlpha=(0.05+0.16*endw)*skyDarkness; cx.beginPath(); cx.arc(x,y,rr,0,6.2832); cx.fill(); } } for(let i=0;iW+6||p.y<-6||p.y>H+6) continue; const s=S[i], tw=0.6+0.4*Math.sin(now*0.001*s.sp+s.ph); cx.fillStyle=`hsl(${s.hue|0},${s.sat|0}%,${Math.min(94,(s.lit+10))|0}%)`; cx.globalAlpha=Math.min(1, s.br*tw*skyDarkness*0.45); cx.beginPath(); cx.arc(p.x,p.y,s.sz*2.2,0,6.2832); cx.fill(); cx.globalAlpha=Math.min(1, s.br*tw*skyDarkness); cx.beginPath(); cx.arc(p.x,p.y,s.sz*1.1,0,6.2832); cx.fill(); } cx.globalAlpha=1; cx.restore(); } function drawStars(skyDarkness){ if (Object.keys(stars).length === 0) return; const now = performance.now(); const toRemove = []; cx.save(); _clipSkyBodies(); cx.globalCompositeOperation = 'lighter'; for (const [id, s] of Object.entries(stars)){ const age = now - s.arrivalT; let fadeIn = Math.min(1, age / s.fadeMs); let fadeOut = 1; if (s._leaving) { fadeOut = Math.max(0, 1 - (now - s._leaving) / 3000); if (fadeOut <= 0){ toRemove.push(id); continue; } } const twinkle = 0.55 + 0.45 * Math.sin(now * 0.001 * s.twinkleSpeed + s.twinklePhase); const alpha = fadeIn * fadeOut * twinkle * skyDarkness * 0.45; if (alpha < 0.01) continue; if (s.lat == null || s.lon == null) continue; const _d = geoToDome(s.lat, s.lon), _p = projDome(_d.az, _d.elev); if (!_p || _p.x < -4 || _p.x > W+4 || _p.y < -4 || _p.y > H+4) continue; cx.globalAlpha = alpha; cx.fillStyle = `hsl(${s.hue},65%,78%)`; cx.beginPath(); cx.arc(_p.x, _p.y, s.size, 0, Math.PI*2); cx.fill(); } cx.restore(); for (const id of toRemove) delete stars[id]; } const SKY_KEYS = [ [ 0, [10,8,28], [4,4,18] ], [ 4, [18,14,48], [8,6,30] ], [ 6, [72,42,90], [38,18,58] ], [ 7, [165,128,150],[150,72,78] ], [ 8, [120,150,190], [238,206,178]],// morning — blue top, cream-peach horizon (retuned 2026-06-13: amber was washing the whole sky-top till ~10a) [ 9, [100,130,180],[160,130,100]],// morning — soft blue, warm base [12, [60,110,180], [130,150,180]],// midday — clear blue [15, [50,100,165], [140,130,100]],// afternoon — deeper blue, golden [17, [80,120,170], [200,175,140]], [19, [200,80,70], [160,80,130]], [20, [100,60,120], [60,50,100]], [22, [20,16,48], [8,6,28] ], [24, [10,8,28], [4,4,18] ], ]; let _skyDrift = null; function initSkyDrift(){ let s = 20260608; const rnd = () => { s = (s*1664525 + 1013904223)|0; return (s>>>0)/4294967296; }; const NIBS = ['round','square','triangle','bristle']; const sigs = []; for (let i=0;i<3;i++) sigs.push({ seed:(1000+i*7919)>>>0, nib:NIBS[(rnd()*4)|0], w:3+rnd()*4, dur:300+rnd()*220, phase:rnd(), _ci:null, _path:null }); _skyDrift = { sigs, t0:performance.now() }; } function _sigPath(seed){ let s=seed>>>0; const r=()=>{ s=(s*1664525+1013904223)>>>0; return s/4294967296; }; const DA=0.011, n=440, pts=new Array(n); const ang=r()*Math.PI*2, curv=(r()-0.5)*0.010; let az=r()*Math.PI*2, elev=-0.12 + r()*0.78; let a=ang; for(let i=0;i { const m=/(\d+)\D+(\d+)\D+(\d+)/.exec(str||''); return m?[+m[1],+m[2],+m[3]]:[30,30,50]; }; return [p(sky&&sky.top), p(sky&&sky.bot)]; } function _silhouette(top,bot,yf,dL){ let r=(top[0]+(bot[0]-top[0])*yf)/255, g=(top[1]+(bot[1]-top[1])*yf)/255, b=(top[2]+(bot[2]-top[2])*yf)/255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b); let h=0,sat=0,l=(mx+mn)/2; if(mx!==mn){ const d=mx-mn; sat=l>0.5?d/(2-mx-mn):d/(mx+mn); if(mx===r)h=(g-b)/d+(g{ if(tt<0)tt+=1; if(tt>1)tt-=1; if(tt<1/6)return p+(q-p)*6*tt; if(tt<1/2)return q; if(tt<2/3)return p+(q-p)*(2/3-tt)*6; return p; }; let R,G,B; if(sat===0){R=G=B=l;} else { const q=l<0.5?l*(1+sat):l+sat-l*sat, p2=2*l-q; R=hue2(p2,q,h+1/3); G=hue2(p2,q,h); B=hue2(p2,q,h-1/3); } return `rgb(${R*255|0},${G*255|0},${B*255|0})`; } function _skMark(x,y,ang,len,w,nib,col,al){ cx.save(); cx.translate(x,y); cx.rotate(ang); cx.globalAlpha=al; cx.fillStyle=col; cx.strokeStyle=col; cx.lineCap='round'; cx.lineJoin='round'; const h=len/2; if(nib==='round'){ const K=Math.max(4,(len/4)|0); for(let i=0;i<=K;i++){ const u=i/K, rr=w*0.5*Math.pow(Math.sin(Math.PI*u),0.5); if(rr<0.4) continue; cx.beginPath(); cx.arc(-h+len*u,0,rr,0,6.283); cx.fill(); } } else if(nib==='square'){ cx.fillRect(-h,-w*0.5,len,w); } else if(nib==='triangle'){ cx.beginPath(); cx.moveTo(h,0); cx.lineTo(-h,-w*0.7); cx.lineTo(-h,w*0.7); cx.closePath(); cx.fill(); } else { for(let o=-1;o<=1;o++){ cx.lineWidth=Math.max(0.5,w*0.34); cx.beginPath(); cx.moveTo(-h,o*w*0.34); cx.lineTo(h,o*w*0.34); cx.stroke(); } } cx.restore(); cx.globalAlpha=1; } function drawSkyDrift(sky){ if (!_skyDrift) initSkyDrift(); const t = (performance.now() - _skyDrift.t0) * 0.001; const day = (sky && sky.daylight!=null) ? sky.daylight : 1; const tb = _skyTopBot(sky), top=tb[0], bot=tb[1]; const trailA = 0.24 * (0.35 + 0.65*day); const DL = 0.18; const TRAIL_PTS = 200; const step = 2; const MAXLEN = 46; for (const sg of _skyDrift.sigs){ const cyc = t/sg.dur + sg.phase, ci = Math.floor(cyc), frac = cyc - ci; if (sg._ci !== ci){ sg._ci = ci; sg._path = _sigPath((sg.seed ^ (ci*2654435761)) >>> 0); } const P = sg._path, n = P.pts.length; const headIdx = Math.min(n-1, Math.floor(frac*n)); const env = Math.min(1, frac/0.06) * Math.min(1, (1-frac)/0.12); if (env <= 0.001) continue; for (let i=headIdx; i>=0 && (headIdx-i) < TRAIL_PTS; i-=step){ const pa = P.pts[i]; const a = projDome(pa.az, pa.elev); if (!a) continue; if (a.x<-80||a.x>W+80||a.y<-80||a.y>H+80) continue; const pb = P.pts[Math.min(n-1,i+step)]; const b = projDome(pb.az, pb.elev); let ang=0, len=14; if (b){ const dx=b.x-a.x, dy=b.y-a.y; ang=Math.atan2(dy,dx); len=Math.min(MAXLEN, Math.max(4, Math.hypot(dx,dy)*1.5)); } const behindFrac = (headIdx-i)/TRAIL_PTS; _skMark(a.x, a.y, ang, len, sg.w, sg.nib, _silhouette(top,bot,Math.min(1,Math.max(0,a.y/H)),DL), trailA*(1-behindFrac)*env); } } } function _cloudRng(seed){ let s=seed>>>0; return ()=>{ s=(s*1664525+1013904223)>>>0; return s/4294967296; }; } let _cloudField=null; function buildCloudField(){ const r=_cloudRng(20260609>>>0), N=11, clouds=[]; for(let i=0;i>>0 }); const NH=7; for(let i=0;i>>0 }); return { clouds }; } function _cloudParticles(cloud, stratusBias, count){ const r=_cloudRng((cloud.seed^0x9e3779b9)>>>0), parts=[]; const wAz=cloud.baseSize*(1+stratusBias*2.4), wEl=cloud.baseSize*(1-stratusBias*0.6); for(let i=0;i>>0) }); } return parts; } function drawClouds(sky){ const cc=weather.cloudCover||0; if(cc<0.08) return; if(!_cloudField) _cloudField=buildCloudField(); const t=performance.now()*0.001, day=(sky&&sky.daylight!=null)?sky.daylight:1; const _phSyn=29.530588853,_phRef=Date.UTC(2000,0,6,18,14)/86400000,_phNow=Date.now()/86400000; let _mp=((_phNow-_phRef)%_phSyn)/_phSyn; if(_mp<0)_mp+=1; const _moonI=(window.__moonIllumOverride!=null?window.__moonIllumOverride:(1-Math.cos(2*Math.PI*_mp))/2); const _dayEff=Math.min(1,day+(1-day)*_moonI*(window.__moonLift!=null?window.__moonLift:0.35)); const _dtb=(typeof _skyTopBot==='function')?_skyTopBot(sky):[[140,165,200],[150,150,150]]; const dv=0.28+0.72*Math.max(0,Math.min(1,day)); const _cloudCol=(hue)=>{ const lo=[206+(150-206)*hue,150+(162-150)*hue,150+(200-150)*hue]; const hi=[246+(232-246)*hue,234+(238-234)*hue,238+(247-238)*hue]; return [Math.round(lo[0]+(hi[0]-lo[0])*dv),Math.round(lo[1]+(hi[1]-lo[1])*dv),Math.round(lo[2]+(hi[2]-lo[2])*dv)]; }; const _scrib=(cxC,cyC,R,d,r,g,b,a,lw,seed)=>{ const rnd=_cloudRng(seed); cx.strokeStyle='rgba('+r+','+g+','+b+','+a+')'; cx.lineWidth=lw; cx.lineJoin='round'; cx.lineCap='round'; cx.beginPath(); let x=cxC+(rnd()-0.5)*R*0.4, y=cyC+(rnd()-0.5)*R*0.4, h=rnd()*6.2831853; cx.moveTo(x,y); for(let i=0;i<300;i++){ const curvy=Math.sin(i*0.21+seed*0.5)*0.40+(rnd()-0.5)*0.20; const ang=(rnd()-0.5)*3.0+((rnd()<0.12)?(rnd()-0.5)*3.0:0); h+=curvy*(1-d)+ang*d; const dx=x-cxC, dy=y-cyC, dist=Math.sqrt(dx*dx+dy*dy); if(dist>R){ const toC=Math.atan2(-dy,-dx); const diff=((toC-h+Math.PI*3)%(2*Math.PI))-Math.PI; h+=diff*0.35; } const base=R*(0.07+0.09*d), sl=base*(((d>0.35)&&(rnd()<0.10))?(1.8+rnd()*3.0):1); x+=Math.cos(h)*sl; y+=Math.sin(h)*sl; cx.lineTo(x,y); } cx.stroke(); }; const nClouds=Math.max(1,Math.min(_cloudField.clouds.length,Math.round(_cloudField.clouds.length*Math.min(1,0.3+cc)))); cx.save(); cx.globalCompositeOperation='source-over'; for(let ci=0;ciW+280||aP.y<-280||aP.y>H+280) continue; const mag=Math.max(120,Math.min(2200,FOV/Math.max(0.05,aP.z))); const R=cl.sizeMul*(48+28*Math.min(1.4,mag/520)); const decay=0.5+0.5*Math.sin(cl.phase); const _ef=Math.max(0,Math.min(1,cl.elev/0.85)); const _hz=(1-_ef)*0.5, _bc=_cloudCol(cl.hue); const _wb=[_bc[0]+(198-_bc[0])*_hz+_ef*8, _bc[1]+(206-_bc[1])*_hz, _bc[2]+(216-_bc[2])*_hz-_ef*6]; const _MIX=Math.max(0,Math.min(1,(window.__domeMix!=null?window.__domeMix:0.55)+(1-_dayEff)*(window.__domeMixNight!=null?window.__domeMixNight:0.30))); const _skt=((cl.seed>>>3)%1000)/1000; let _sc=[_dtb[0][0]+(_dtb[1][0]-_dtb[0][0])*_skt,_dtb[0][1]+(_dtb[1][1]-_dtb[0][1])*_skt,_dtb[0][2]+(_dtb[1][2]-_dtb[0][2])*_skt]; const _sg=(_sc[0]+_sc[1]+_sc[2])/3,_sj=((cl.seed>>>11)%1000)/1000,_sm=1+(_sj-0.5)*2*(window.__skySatJitter!=null?window.__skySatJitter:0.08); _sc=[_sg+(_sc[0]-_sg)*_sm,_sg+(_sc[1]-_sg)*_sm,_sg+(_sc[2]-_sg)*_sm]; const col=[Math.round(_wb[0]+(_sc[0]-_wb[0])*_MIX), Math.round(_wb[1]+(_sc[1]-_wb[1])*_MIX), Math.round(_wb[2]+(_sc[2]-_wb[2])*_MIX)]; const aBase=(window.__domeAlpha!=null?window.__domeAlpha:0.9)*(0.82+0.18*_ef); const _cr=_cloudRng((cl.seed^0x1b873593)>>>0); const nB=1+Math.floor(_cr()*(1+cc*8)); const _spx=R*0.6, _spy=R*0.34; for(let b=0;b>>0; _scrib(aP.x+ox,aP.y+oy,bR,bd,col[0],col[1],col[2],aBase,1.7,bs); _scrib(aP.x+ox+bR*0.10,aP.y+oy-bR*0.06,bR*0.82,Math.min(1,bd+0.18),col[0],col[1],col[2],aBase*0.7,1.4,(bs^0x5bd1e995)>>>0); } } cx.globalAlpha=1; cx.restore(); } let _worldCloudField=null; function buildWorldClouds(){ const r=_cloudRng(0x5EED1234>>>0), N=40, clouds=[]; for(let i=0;i>>0 }); return { clouds }; } function drawWorldClouds(sky){ if(typeof project!=='function') return; if(!_worldCloudField) _worldCloudField=buildWorldClouds(); const _addL=(c,dL)=>{ let r=c[0]/255,g=c[1]/255,b=c[2]/255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b); let l=(mx+mn)/2,h=0,s=0; const d=mx-mn; if(d>0){ s=l>0.5?d/(2-mx-mn):d/(mx+mn); if(mx===r)h=((g-b)/d+(g{ if(t<0)t+=1; if(t>1)t-=1; if(t<1/6)return p+(q-p)*6*t; if(t<1/2)return q; if(t<2/3)return p+(q-p)*(2/3-t)*6; return p; }; let R,G,B; if(s===0){ R=G=B=l; } else { const q=l<0.5?l*(1+s):l+s-l*s,p=2*l-q; R=h2(p,q,h+1/3); G=h2(p,q,h); B=h2(p,q,h-1/3); } return [Math.round(R*255),Math.round(G*255),Math.round(B*255)]; }; const _tb=(sky&&typeof _skyTopBot==='function')?_skyTopBot(sky):[[120,140,180],[170,160,150]]; const _wcColor=_addL([(_tb[0][0]+_tb[1][0])/2,(_tb[0][1]+_tb[1][1])/2,(_tb[0][2]+_tb[1][2])/2],0.05); const _scrib=(cxC,cyC,R,d,r,g,b,a,lw,seed)=>{ const rnd=_cloudRng(seed); cx.strokeStyle='rgba('+r+','+g+','+b+','+a+')'; cx.lineWidth=lw; cx.lineJoin='round'; cx.lineCap='round'; cx.beginPath(); let x=cxC+(rnd()-0.5)*R*0.4, y=cyC+(rnd()-0.5)*R*0.4, h=rnd()*6.2831853; cx.moveTo(x,y); const _steps=Math.max(1,Math.floor(240*(typeof _wp==='number'?_wp:1))); for(let i=0;i<_steps;i++){ const curvy=Math.sin(i*0.21+seed*0.5)*0.40+(rnd()-0.5)*0.20; const ang=(rnd()-0.5)*3.0+((rnd()<0.12)?(rnd()-0.5)*3.0:0); h+=curvy*(1-d)+ang*d; const dx=x-cxC, dy=y-cyC, dist=Math.sqrt(dx*dx+dy*dy); if(dist>R){ const toC=Math.atan2(-dy,-dx); const diff=((toC-h+Math.PI*3)%(2*Math.PI))-Math.PI; h+=diff*0.35; } const base=R*(0.07+0.09*d), sl=base*(((d>0.35)&&(rnd()<0.10))?(1.8+rnd()*3.0):1); x+=Math.cos(h)*sl; y+=Math.sin(h)*sl; cx.lineTo(x,y); } cx.stroke(); }; const _N=_worldCloudField.clouds.length; const _nActive=Math.max(4, Math.min(_N, Math.round(_N*(0.2+(weather.cloudCover||0))))); const _nowT=performance.now()/1000, _DUR=0.75; var _wp=1; const _day=(sky&&sky.daylight!=null)?sky.daylight:1; const _phSyn=29.530588853,_phRef=Date.UTC(2000,0,6,18,14)/86400000,_phNow=Date.now()/86400000; let _mp=((_phNow-_phRef)%_phSyn)/_phSyn; if(_mp<0)_mp+=1; const _moonI=(window.__moonIllumOverride!=null?window.__moonIllumOverride:(1-Math.cos(2*Math.PI*_mp))/2); const _dayEff=Math.min(1,_day+(1-_day)*_moonI*(window.__moonLift!=null?window.__moonLift:0.35)); const _wDay=(window.__worldDayMix!=null?window.__worldDayMix:0.18),_wNight=(window.__worldNightMix!=null?window.__worldNightMix:0.78); const _nm=Math.max(0,Math.min(1,_wNight+(_wDay-_wNight)*_dayEff)); const _wa=(window.__worldAlpha!=null?window.__worldAlpha:0.35); const vis=[]; for(let _i=0;_i<_nActive;_i++){ const cl=_worldCloudField.clouds[_i]; const p=project(cl.wx,cl.wy,cl.wz); if(!p||p.sx<-400||p.sx>W+400||p.sy<-400||p.sy>H+400){ cl._vis=false; continue; } vis.push({p,cl}); } vis.sort((a,b)=>b.p.depth-a.p.depth); cx.save(); cx.globalCompositeOperation='source-over'; for(const it of vis){ const p=it.p, cl=it.cl; const R=cl.sizeMul*640*p.scale; if(R<6){ cl._vis=false; continue; } if(!cl._vis){ cl._vis=true; cl._t0=_nowT; } const _prog=Math.max(0,Math.min(1,(_nowT-(cl._t0||0))/_DUR)), _ein=Math.min(1,_prog/0.2); _wp=_prog; const fade=Math.max(0,Math.min(1,(8000-p.depth)/6000)); const _bh=(function(h){var w=[245,232,236],c=[206,222,248];return [w[0]+(c[0]-w[0])*h,w[1]+(c[1]-w[1])*h,w[2]+(c[2]-w[2])*h];})(cl.hue); const _skt=((cl.seed>>>3)%1000)/1000; let _sc=[_tb[0][0]+(_tb[1][0]-_tb[0][0])*_skt,_tb[0][1]+(_tb[1][1]-_tb[0][1])*_skt,_tb[0][2]+(_tb[1][2]-_tb[0][2])*_skt]; const _sg=(_sc[0]+_sc[1]+_sc[2])/3,_sj=((cl.seed>>>11)%1000)/1000,_sm=1+(_sj-0.5)*2*(window.__skySatJitter!=null?window.__skySatJitter:0.08); _sc=[_sg+(_sc[0]-_sg)*_sm,_sg+(_sc[1]-_sg)*_sm,_sg+(_sc[2]-_sg)*_sm]; const col=[Math.round(_bh[0]+(_sc[0]-_bh[0])*_nm),Math.round(_bh[1]+(_sc[1]-_bh[1])*_nm),Math.round(_bh[2]+(_sc[2]-_bh[2])*_nm)], aBase=_wa*fade*_ein; const _cr=_cloudRng((cl.seed^0x1b873593)>>>0); const nB=2+Math.floor(_cr()*4); const _spx=R*0.6, _spy=R*0.34; for(let b=0;b>>0; _scrib(p.sx+ox,p.sy+oy,bR,bd,col[0],col[1],col[2],aBase,1.7,bs); _scrib(p.sx+ox+bR*0.10,p.sy+oy-bR*0.06,bR*0.82,Math.min(1,bd+0.18),col[0],col[1],col[2],aBase*0.7,1.4,(bs^0x5bd1e995)>>>0); } } cx.globalAlpha=1; cx.restore(); } function skyGradient(){ const now = new Date(); const _realT = (devTimeOverride != null) ? devTimeOverride : (now.getHours() + now.getMinutes()/60 + now.getSeconds()/3600); const t = (devTimeOverride != null) ? _realT : solarWarp(_realT); let k0 = SKY_KEYS[SKY_KEYS.length-2], k1 = SKY_KEYS[SKY_KEYS.length-1]; for (let i=0; i= SKY_KEYS[i][0] && t < SKY_KEYS[i+1][0]){ k0 = SKY_KEYS[i]; k1 = SKY_KEYS[i+1]; break; } } const raw = (t - k0[0]) / (k1[0] - k0[0]); const f = raw * raw * (3 - 2 * raw); const lerp = (a,b) => Math.round(a + (b-a)*f); const top = k0[1].map((v,i) => lerp(v, k1[1][i])); const bot = k0[2].map((v,i) => lerp(v, k1[2][i])); const sunF = Math.max(0, Math.min(1, (t - 6) / 12)); const daylight = Math.max(0, Math.sin(Math.PI * sunF)); const warmStr = Math.max(0, 1 - Math.abs(sunF - 0.5)*2.5) * 0.18 + (t>5&&t<9 || t>17&&t<21 ? 0.22 : 0); const coolStr = warmStr * 0.6; return { top:`rgb(${top[0]},${top[1]},${top[2]})`, bot:`rgb(${bot[0]},${bot[1]},${bot[2]})`, sunF, warmStr, coolStr, daylight, darkness: Math.max(0, 1 - Math.max(0, Math.sin(Math.PI * (t - 6) / 12))) }; } function buildFrameStrokes_UNUSED(w, h){ const mTop=28, mSide=22, mBot=90; const strands=[]; function jit(i,j){const x=Math.sin((i+1)*127.1+(j+1)*311.7)*43758.5453;return (x-Math.floor(x))*2-1;} function lay(x0,y0,x1,y1,idx,op,lw,nib){ const len=Math.hypot(x1-x0,y1-y0)||1; const STEPS=Math.max(2,Math.ceil(len/12)); const dx=(x1-x0)/len,dy=(y1-y0)/len; const px=-dy,py=dx; const pts=[]; for(let s=0;s<=STEPS;s++){ const t=s/STEPS; const wave=jit(idx*200+s,1)*6; pts.push({x:x0+(x1-x0)*t+px*wave+jit(idx*100+s,2)*3, y:y0+(y1-y0)*t+py*wave+jit(idx*100+s,3)*3}); } strands.push({pts,op,lw,nib:nib||'round'}); } function slash(cx2,cy,ang,len,idx,op,lw,nib){ const dx=Math.cos(ang),dy=Math.sin(ang); const STEPS=Math.max(2,Math.ceil(len/10)); const pts=[]; for(let s=0;s<=STEPS;s++){ const t=(s/STEPS-0.5)*len; const waver=jit(idx*50+s,4)*5; pts.push({x:cx2+dx*t+(-dy)*waver, y:cy+dy*t+dx*waver}); } strands.push({pts,op,lw,nib:nib||'round'}); } const NIBS=['round','square','triangle','round','round','square']; for(let p=0;p<30;p++){ const y=-60+p*6+jit(p,0)*8; lay(-80+jit(p,3)*30,y,w+80+jit(p,4)*30,y,p,0.35+Math.abs(jit(p,2))*0.35,20+Math.abs(jit(p,1))*80,NIBS[p%6]); } for(let d=0;d<20;d++){ const cx2=-40+(w+80)*(d/19)+jit(d+100,0)*40; const cy=jit(d+100,1)*mTop*1.5; slash(cx2,cy,Math.PI/4+jit(d+100,2)*0.6,40+Math.abs(jit(d+100,3))*100,d+100,0.3+Math.abs(jit(d+100,5))*0.4,15+Math.abs(jit(d+100,4))*60,NIBS[d%6]); } for(let p=0;p<40;p++){ const y=h-mBot-40+p*6+jit(p+50,0)*10; lay(-100+jit(p+50,3)*40,y,w+100+jit(p+50,4)*40,y,p+50,0.38+Math.abs(jit(p+50,2))*0.38,25+Math.abs(jit(p+50,1))*90,NIBS[p%6]); } for(let d=0;d<28;d++){ const cx2=-50+(w+100)*(d/27)+jit(d+200,0)*45; const cy=h-mBot+jit(d+200,1)*mBot*1.2; slash(cx2,cy,Math.PI/4+jit(d+200,2)*0.65,50+Math.abs(jit(d+200,3))*120,d+200,0.35+Math.abs(jit(d+200,5))*0.4,18+Math.abs(jit(d+200,4))*75,NIBS[d%6]); } for(let p=0;p<22;p++){ const x=-60+p*6+jit(p+300,0)*8; lay(x,-80+jit(p+300,3)*30,x,h+80+jit(p+300,4)*30,p+300,0.32+Math.abs(jit(p+300,2))*0.34,18+Math.abs(jit(p+300,1))*70,NIBS[p%6]); } for(let d=0;d<16;d++){ const cx2=jit(d+400,0)*mSide*1.8; const cy=-40+(h+80)*(d/15)+jit(d+400,1)*40; slash(cx2,cy,Math.PI/4+jit(d+400,2)*0.6,40+Math.abs(jit(d+400,3))*90,d+400,0.3+Math.abs(jit(d+400,5))*0.36,14+Math.abs(jit(d+400,4))*55,NIBS[d%6]); } for(let p=0;p<22;p++){ const x=w+60-p*6+jit(p+500,0)*8; lay(x,-80+jit(p+500,3)*30,x,h+80+jit(p+500,4)*30,p+500,0.32+Math.abs(jit(p+500,2))*0.34,18+Math.abs(jit(p+500,1))*70,NIBS[p%6]); } for(let d=0;d<16;d++){ const cx2=w+jit(d+600,0)*mSide*1.8; const cy=-40+(h+80)*(d/15)+jit(d+600,1)*40; slash(cx2,cy,Math.PI/4+jit(d+600,2)*0.6,40+Math.abs(jit(d+600,3))*90,d+600,0.3+Math.abs(jit(d+600,5))*0.36,14+Math.abs(jit(d+600,4))*55,NIBS[d%6]); } return {strands,mTop,mSide,mBot}; } function renderFrameOffscreen(w,h){ const {strands,mTop,mSide,mBot}=_frameStrokes; const off=document.createElement('canvas'); off.width=w; off.height=h; const oc=off.getContext('2d'); const depthTop=mTop+80, depthBot=mBot+80, depthSide=mSide+60; oc.save(); oc.beginPath(); oc.rect(0, 0, w, depthTop); oc.rect(0, h-depthBot, w, depthBot); oc.rect(0, depthTop, depthSide, h-depthTop-depthBot); oc.rect(w-depthSide, depthTop, depthSide, h-depthTop-depthBot); oc.clip(); for(const {pts,op,lw,nib} of strands){ oc.globalAlpha=op; oc.strokeStyle='rgba(0,0,0,1)'; oc.fillStyle='rgba(0,0,0,1)'; oc.lineWidth=lw; oc.lineCap=nib==='square'?'square':'round'; oc.lineJoin=nib==='square'?'miter':'round'; if(nib==='triangle'){ for(let i=1;i= PP_MAX) return; const n = s.pts ? s.pts.length : 0; if (n < 2) return; pathPulses.push({ s, pos: fromStart ? 0 : n-1, dir: fromStart ? 1 : -1, gen: gen||0, born: performance.now() }); } function igniteNearby(wpt, gen, exclude){ if (!wpt) return; let lit = 0; for (const s of _visibleTrails){ if (s === exclude || !s.pts) continue; const step = Math.max(1, (s.pts.length/6)|0); for (let i=0;iWX/2)dx-=WX;if(dx<-WX/2)dx+=WX; if(dy>WY/2)dy-=WY;if(dy<-WY/2)dy+=WY; if(dz>WZ/2)dz-=WZ;if(dz<-WZ/2)dz+=WZ; if (dx*dx+dy*dy+dz*dz < PP_CASCADE_R*PP_CASCADE_R){ spawnPulse(s, i < s.pts.length/2, gen); if (++lit >= 2) return; break; } } } } function drawPathPulses(){ const now = performance.now(); const dt = _ppLastT ? Math.min(0.05, (now-_ppLastT)/1000) : 0.016; _ppLastT = now; const vis = _visibleTrails; if (vis.length && pathPulses.length < PP_MAX){ const rate = Math.min(3, PP_AMBIENT * Math.sqrt(vis.length)); if (Math.random() < rate*dt) spawnPulse(vis[(Math.random()*vis.length)|0], Math.random()<0.5, 0); } if (!pathPulses.length) return; cx.save(); cx.globalCompositeOperation = 'lighter'; for (let pi=pathPulses.length-1; pi>=0; pi--){ const p = pathPulses[pi], s = p.s, pts = s.pts, proj = s._lastProj; if (!pts || !proj || proj.length !== pts.length || pts.length<2){ pathPulses.splice(pi,1); continue; } p.pos += p.dir * PP_SPEED * dt; const ended = p.pos <= 0 || p.pos >= pts.length-1; const ip = Math.max(0, Math.min(pts.length-1, p.pos)); if (!s._rgb) s._rgb = hexRGB(s.color||'#9ec8e0'); const cr=s._rgb[0], cg=s._rgb[1], cb=s._rgb[2]; if (!s._phsl){ const r=cr/255,g=cg/255,b=cb/255, mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn,L=(mx+mn)/2; let h=0,sat=0; if(d>0){ sat=L>0.5?d/(2-mx-mn):d/(mx+mn); if(mx===r)h=((g-b)/d+(g hslToHex(PH.h, Math.min(100,(PH.s+0.5*w)*100), Math.min(85,(PH.l+0.32*w)*100)); const frac = ip/(pts.length-1); const birthFade = Math.min(1, (now - (p.born||now))/520); const endFade = Math.max(0, Math.min(1, frac/0.12, (1-frac)/0.12)) * birthFade; const SPAN=0.7; const prof=[[-2,0.18],[-1,0.55],[0,1.0],[1,0.55],[2,0.18]]; const nodes=[]; for (const seg of prof){ const sp = ip - p.dir*seg[0]*SPAN; if (sp<0 || sp>pts.length-1){ nodes.push(null); continue; } const i0=Math.floor(sp), i1=Math.min(pts.length-1,i0+1), fr=sp-i0; const a=proj[i0], b=proj[i1]; if(!a||!b){ nodes.push(null); continue; } nodes.push({ sx:a.sx+(b.sx-a.sx)*fr, sy:a.sy+(b.sy-a.sy)*fr, sc:(a.scale+b.scale)*0.5, w:seg[1] }); } cx.lineCap='round'; cx.lineJoin='round'; for (let n=1;n{PP_SPEED=v;}, ambient:v=>{PP_AMBIENT=v;}, fire:()=>{ if(_visibleTrails.length) spawnPulse(_visibleTrails[(Math.random()*_visibleTrails.length)|0], Math.random()<0.5, 0); }, list:()=>pathPulses }; function render(){ const sky = skyGradient(); const SKY_TINT_CEIL = 0.42; const SKY_TINT_HW = 0.6; const SKY_TINT_NIGHTFLOOR = 0.5; const SKY_TINT_NEARKEEP = 0.3; const _stT = sky.top.match(/\d+/g).map(Number), _stB = sky.bot.match(/\d+/g).map(Number); const skyAvg = [ _stT[0]*(1-SKY_TINT_HW)+_stB[0]*SKY_TINT_HW, _stT[1]*(1-SKY_TINT_HW)+_stB[1]*SKY_TINT_HW, _stT[2]*(1-SKY_TINT_HW)+_stB[2]*SKY_TINT_HW ]; const skyTintBase = SKY_TINT_CEIL * (1 - (sky.darkness||0)*(1-SKY_TINT_NIGHTFLOOR)); const SKY_SPAN = 900; const SKY_PITCH_PAR = 320; const SKY_YAW_PAR = 0.5; let _strip = window._skyStrip; if (!_strip){ _strip = window._skyStrip = document.createElement('canvas'); _strip.width = 2; _strip.height = SKY_SPAN*2; } { const _sc = _strip.getContext('2d'); const _sg = _sc.createLinearGradient(0,0,0,SKY_SPAN*2); _sg.addColorStop(0, sky.top); _sg.addColorStop(0.5, sky.bot); _sg.addColorStop(1, sky.top); _sc.fillStyle = _sg; _sc.fillRect(0,0,2,SKY_SPAN*2); } const _vOff = ((camPitch * SKY_PITCH_PAR) % (SKY_SPAN*2) + SKY_SPAN*2) % (SKY_SPAN*2); cx.save(); cx.translate(0, _vOff); cx.fillStyle = cx.createPattern(_strip, 'repeat'); cx.fillRect(0, -_vOff, W, H); cx.restore(); drawConstellations(sky.darkness); drawStars(sky.darkness); drawSkyDrift(sky); const SUN = { size:70, soft:3, wobble:0.10, glow:1.5, glowA:0.16, body:1 }; const _sunDay = (sky.daylight!=null) ? sky.daylight : 1; const _t = (devTimeOverride!=null) ? devTimeOverride : solarWarp((function(){ const n=new Date(); return n.getHours()+n.getMinutes()/60; })()); const sunBright = Math.max(0, Math.min(1, Math.min((_t-5.0)/1.2, (20.5-_t)/1.5))); const _sunA = (0.12 + sky.sunF*0.76)*6.2831853, _sunE = _sunDay*1.05; const _sunRy = rotY(Math.cos(_sunE)*Math.sin(_sunA), -Math.sin(_sunE), Math.cos(_sunE)*Math.cos(_sunA), -camYaw*SKY_BODY_SPREAD); const _sunRp = rotX(_sunRy.x, _sunRy.y, _sunRy.z, -camPitch*SKY_BODY_SPREAD); const sunFront = _sunRp.z > 0.02; const sunX = sunFront ? (W/2 + (_sunRp.x/_sunRp.z)*FOV) : -1e5; const sunY = sunFront ? (H/2 + (_sunRp.y/_sunRp.z)*FOV) : -1e5; const sunR = SUN.size; _starOcc.sun = (sunFront && sunBright > 0.01) ? { x:sunX, y:sunY, r:sunR } : null; const _sunGold = Math.pow(_sunDay, 0.7); const _sunCg = Math.round(110+_sunGold*120), _sunCb = Math.round(78+_sunGold*60); const drawSunSplat = (cxC, cyC) => { const blob = (rad) => { cx.beginPath(); const N=56; for (let i=0;i<=N;i++){ const a=i/N*6.2831853; const wob = 1 + SUN.wobble*(Math.sin(3*a+1.3)*0.5 + Math.sin(5*a-0.7)*0.3 + Math.sin(8*a+2.1)*0.22 + Math.sin(13*a-1.1)*0.12); const x=cxC+Math.cos(a)*rad*wob, y=cyC+Math.sin(a)*rad*wob; i?cx.lineTo(x,y):cx.moveTo(x,y); } cx.closePath(); }; cx.save(); cx.globalCompositeOperation='source-over'; cx.filter=`blur(0.8px)`; const _sdots = [ { dx: 0.00, dy: 0.00, r:1.00, c:[255, _sunCg, _sunCb], a:1.0 }, { dx:-0.10, dy: 0.06, r:0.74, c:[255, Math.min(255,_sunCg+Math.round(18+_sunDay*40)), Math.min(255,_sunCb+Math.round(16+_sunDay*56))], a:0.6 }, { dx: 0.08, dy:-0.06, r:0.41, c:[255, Math.min(255,_sunCg+Math.round(24+_sunDay*60)), Math.min(255,_sunCb+Math.round(18+_sunDay*92))], a:0.75 }, ]; for (const d of _sdots){ const X=cxC+d.dx*sunR, Y=cyC+d.dy*sunR, R=sunR*d.r; const g2=cx.createRadialGradient(X,Y,0,X,Y,R); g2.addColorStop(0,`rgba(${d.c[0]},${d.c[1]},${d.c[2]},${d.a*sunBright})`); g2.addColorStop(0.92,`rgba(${d.c[0]},${d.c[1]},${d.c[2]},${d.a*sunBright})`); g2.addColorStop(1,`rgba(${d.c[0]},${d.c[1]},${d.c[2]},0)`); cx.fillStyle=g2; cx.beginPath(); cx.arc(X,Y,R,0,6.2832); cx.fill(); } cx.restore(); }; if (sunBright > 0.01 && sunFront && sunX > -sunR*SUN.glow*2 && sunX < W+sunR*SUN.glow*2 && sunY > -sunR*SUN.glow*2 && sunY < H+sunR*SUN.glow*2){ drawSunSplat(sunX, sunY); } _starOcc.moon = null; const moonBright = Math.max(0, Math.min(1, (_t>=19 ? (_t-19)/1.5 : (_t<=7 ? (7-_t)/1.5 : 0)))); if (moonBright > 0.01){ const _nf = Math.max(0, Math.min(1, (((_t-19.5+24)%24)/11))); const _mhi = Math.sin(Math.PI*_nf); const _phSyn=29.530588853, _phRef=Date.UTC(2000,0,6,18,14)/86400000, _phNow=Date.now()/86400000; let _moonPh=((_phNow-_phRef)%_phSyn)/_phSyn; if(_moonPh<0)_moonPh+=1; const _moonIllum=(1-Math.cos(2*Math.PI*_moonPh))/2; const _moonA = (0.12 + _nf*0.76)*6.2831853, _moonE = _mhi*1.05; const _moonRy = rotY(Math.cos(_moonE)*Math.sin(_moonA), -Math.sin(_moonE), Math.cos(_moonE)*Math.cos(_moonA), -camYaw*SKY_BODY_SPREAD); const _moonRp = rotX(_moonRy.x, _moonRy.y, _moonRy.z, -camPitch*SKY_BODY_SPREAD); const moonFront = _moonRp.z > 0.02; const moonX = moonFront ? (W/2 + (_moonRp.x/_moonRp.z)*FOV) : -1e5; const moonY = moonFront ? (H/2 + (_moonRp.y/_moonRp.z)*FOV) : -1e5, moonR = 58; if (moonFront) _starOcc.moon = { x:moonX, y:moonY, r:moonR }; const _mr=Math.round(255-_mhi*4), _mg=Math.round(170+_mhi*82), _mb=Math.round(132+_mhi*84); const drawMoonSplat = (cxC,cyC) => { const blob = (rad)=>{ cx.beginPath(); const N=56; for(let i=0;i<=N;i++){ const a=i/N*6.2831853; const wob=1+0.16*(Math.sin(3*a+1.3)*0.5+Math.sin(5*a-0.7)*0.3+Math.sin(8*a+2.1)*0.22+Math.sin(13*a-1.1)*0.12); const x=cxC+Math.cos(a)*rad*wob,y=cyC+Math.sin(a)*rad*wob; i?cx.lineTo(x,y):cx.moveTo(x,y); } cx.closePath(); }; cx.save(); const _wob=(a)=>1+0.16*(Math.sin(3*a+1.3)*0.5+Math.sin(5*a-0.7)*0.3+Math.sin(8*a+2.1)*0.22+Math.sin(13*a-1.1)*0.12); const MOON_FEATHER=0.8, _dpr=window.devicePixelRatio||1, _pad=Math.ceil(moonR*1.7); const _buf=window._moonBuf||(window._moonBuf=document.createElement('canvas')); _buf.width=Math.ceil(_pad*2*_dpr); _buf.height=Math.ceil(_pad*2*_dpr); const _bx=_buf.getContext('2d'); _bx.setTransform(_dpr,0,0,_dpr,0,0); _bx.clearRect(0,0,_pad*2,_pad*2); const _ox=_pad, _oy=_pad; _bx.filter='blur(0.8px)'; const _mdots = [ { dx: 0.00, dy: 0.00, r:1.00, c:[_mr,_mg,_mb], a:1.0 }, { dx:-0.12, dy: 0.08, r:0.92, c:[Math.min(255,_mr+16),Math.min(255,_mg+18),Math.min(255,_mb+14)], a:0.9 }, { dx: 0.10, dy:-0.06, r:0.71, c:[Math.min(255,_mr+28),Math.min(255,_mg+30),Math.min(255,_mb+24)], a:0.85 }, ]; for (const d of _mdots){ const X=_ox+d.dx*moonR, Y=_oy+d.dy*moonR, R=moonR*d.r; const g=_bx.createRadialGradient(X,Y,0,X,Y,R); g.addColorStop(0,`rgba(${d.c[0]},${d.c[1]},${d.c[2]},${d.a*moonBright})`); g.addColorStop(0.92,`rgba(${d.c[0]},${d.c[1]},${d.c[2]},${d.a*moonBright})`); g.addColorStop(1,`rgba(${d.c[0]},${d.c[1]},${d.c[2]},0)`); _bx.fillStyle=g; _bx.beginPath(); _bx.arc(X,Y,R,0,6.2832); _bx.fill(); } _bx.filter='blur(1.4px)'; const _maria=[{dx:-0.364,dy:-0.468,r:0.390,a:0.36},{dx:0.078,dy:-0.520,r:0.286,a:0.32},{dx:0.416,dy:-0.182,r:0.312,a:0.34},{dx:-0.572,dy:0.078,r:0.286,a:0.30},{dx:-0.182,dy:0.208,r:0.338,a:0.34},{dx:0.260,dy:0.390,r:0.234,a:0.28},{dx:0.520,dy:0.208,r:0.156,a:0.26}]; const _mdc=[Math.round(_mr*0.70),Math.round(_mg*0.73),Math.round(_mb*0.82)]; for(const m of _maria){const X=_ox+m.dx*moonR,Y=_oy+m.dy*moonR,RR=moonR*m.r;const g=_bx.createRadialGradient(X,Y,0,X,Y,RR);g.addColorStop(0,`rgba(${_mdc[0]},${_mdc[1]},${_mdc[2]},${m.a*moonBright})`);g.addColorStop(0.6,`rgba(${_mdc[0]},${_mdc[1]},${_mdc[2]},${m.a*moonBright*0.85})`);g.addColorStop(1,`rgba(${_mdc[0]},${_mdc[1]},${_mdc[2]},0)`);_bx.fillStyle=g;_bx.beginPath();_bx.arc(X,Y,RR,0,6.2832);_bx.fill();} _bx.filter='blur(0.4px)'; const _crat=[{dx:0.02,dy:0.52,r:0.045,a:0.5},{dx:-0.26,dy:0.40,r:0.03,a:0.42},{dx:0.30,dy:0.40,r:0.028,a:0.4}]; for(const c of _crat){const X=_ox+c.dx*moonR,Y=_oy+c.dy*moonR,RR=moonR*c.r;const g=_bx.createRadialGradient(X,Y,0,X,Y,RR*2.2);g.addColorStop(0,`rgba(255,252,242,${c.a*moonBright})`);g.addColorStop(0.4,`rgba(255,250,238,${c.a*moonBright*0.5})`);g.addColorStop(1,'rgba(255,250,238,0)');_bx.fillStyle=g;_bx.beginPath();_bx.arc(X,Y,RR*2.2,0,6.2832);_bx.fill();} _bx.globalCompositeOperation='destination-in'; _bx.filter=`blur(${MOON_FEATHER}px)`; _bx.fillStyle='#fff'; { const _th=2*Math.PI*_moonPh, _c=Math.cos(_th), _wax=_moonPh<=0.5, _limb=_wax?1:-1, _termF=_wax?_c:-_c, _N=48, _wf=()=>1; _bx.beginPath(); for(let i=0;i<=_N;i++){ const yy=-moonR+(2*moonR)*(i/_N), ww=Math.sqrt(Math.max(0,moonR*moonR-yy*yy)), xx=_termF*ww, wv=_wf(xx,yy); i?_bx.lineTo(_ox+xx*wv,_oy+yy*wv):_bx.moveTo(_ox+xx*wv,_oy+yy*wv); } for(let i=0;i<=_N;i++){ const yy=moonR-(2*moonR)*(i/_N), ww=Math.sqrt(Math.max(0,moonR*moonR-yy*yy)), xx=_limb*ww, wv=_wf(xx,yy); _bx.lineTo(_ox+xx*wv,_oy+yy*wv); } _bx.closePath(); _bx.fill(); } _bx.filter='none'; _bx.globalCompositeOperation='source-over'; cx.drawImage(_buf, cxC-_pad, cyC-_pad, _pad*2, _pad*2); cx.restore(); }; if (moonFront && moonX > -moonR*1.4*2 && moonX < W+moonR*1.4*2 && moonY > -moonR*1.4*2 && moonY < H+moonR*1.4*2){ drawMoonSplat(moonX, moonY); } } drawClouds(sky); drawWorldClouds(sky); if (weather.rain) drawRain(Math.max(0.3, weather.cloudCover)); if (weather.snow) drawSnow(Math.max(0.3, weather.cloudCover)); drawFireflies(sky.darkness); drawRainbow(sky.sunF, sky.daylight); drawBoids(); if (weather.fog){ cx.fillStyle = 'rgba(140,145,150,0.35)'; cx.fillRect(0, 0, W, H); } if (eroding) applyEntropyBrush(brushX, brushY); strokes = strokes.filter(s => { const dead = ageOf(s.ts) >= 1; if (!dead) return true; if (fbConnected && db && s._fbKey){ const zone = s.zone != null ? s.zone : 0; db.ref('field/strokes_v3/' + zone + '/' + s._fbKey).remove().catch(()=>{}); } return false; }); const rendered = []; for (const s of strokes){ if (s.type === 'stamp' || s.type === 'gif-anim') continue; if (!s.pts || s.pts.length < 2) continue; const age = effAge(s); if (age >= 1) continue; if (!s._mid) s._mid = s.pts[Math.floor(s.pts.length/2)]; const midPr = project(s._mid.wx, s._mid.wy, s._mid.wz); if (!midPr || midPr.depth > FAR_CULL) continue; if (midPr.sx < -600 || midPr.sx > W+600 || midPr.sy < -600 || midPr.sy > H+600) continue; const diss = age > DISS_START ? (age-DISS_START)/(1-DISS_START) : 0; let wpts = s.pts, dead = null; if (diss > 0){ if (s._seed == null) s._seed = Math.floor(s.ts % 100000); const seed = s._seed, spreadAmt = SPREAD_MAX * diss * diss; wpts = new Array(s.pts.length); dead = new Array(s.pts.length); for (let i=0;i (DISS_START + hash2(i, seed)*(1-DISS_START)); const jx=hash2(i,seed+11)*2-1, jy=hash2(i,seed+22)*2-1, jz=hash2(i,seed+33)*2-1; wpts[i] = { wx:wrap(s.pts[i].wx+jx*spreadAmt,WX), wy:wrap(s.pts[i].wy+jy*spreadAmt,WY), wz:wrap(s.pts[i].wz+jz*spreadAmt,WZ) }; } } const proj = wpts.map(p => project(p.wx,p.wy,p.wz)); let valid=0, sumD=0, sumS=0; for (const p of proj){ if (p){ valid++; sumD+=p.depth; sumS+=p.scale; } } if (valid < 2) continue; const avgDepth = sumD/valid, avgScale = sumS/valid; const farFog = Math.exp(-0.00005 * FOG_SCALE * (weather.fog ? 4.5 : 1 + weather.cloudCover*0.6) * avgDepth * avgDepth * 0.001); const nearFog = Math.min(1, Math.max(0, (avgDepth-20)/80)); const op = Math.max(0.01, farFog * nearFog * (1 - age*0.94) * 0.88) * loadFade(s) * (s.opacity != null ? s.opacity : 1); if (op < 0.012) continue; const bl = Math.min(4, Math.max(0, (avgDepth-1500)/2500)); rendered.push({ s, wpts, proj, dead, diss, avgDepth, avgScale, op, bl }); } rendered.sort((a,b) => b.avgDepth - a.avgDepth); _visibleTrails.length = 0; for (const { s, wpts, proj, dead, diss, avgDepth, avgScale, op, bl } of rendered){ if (s.id && s.id.indexOf('trail_') === 0){ s._lastProj = proj; _visibleTrails.push(s); } const ptSizes = s.sizes || s.pts.map(()=>s.size); const closeEnough = avgScale > CLOSE_SCALE; const live = i => proj[i] && !(dead && dead[i]); if (!s._rgb) s._rgb = hexRGB(s.color); let r0 = s._rgb[0], g0 = s._rgb[1], b0 = s._rgb[2]; if (diss > 0){ const seed = s._seed||0, ww = diss*WANDER; r0 += (hash2(seed,7)*2-1)*ww; g0 += (hash2(seed,8)*2-1)*ww; b0 += (hash2(seed,9)*2-1)*ww; } const lum = 0.299*r0 + 0.587*g0 + 0.114*b0; const desat = Math.min(1, Math.max(0, (avgDepth-DESAT_NEAR)/(DESAT_FAR-DESAT_NEAR))) * DESAT_MAX; let dr = r0+(lum-r0)*desat, dg = g0+(lum-g0)*desat, db = b0+(lum-b0)*desat; if (skyTintBase > 0.003){ const dRamp = Math.min(1, Math.max(0, (avgDepth-DESAT_NEAR)/(DESAT_FAR-DESAT_NEAR))); const tk = skyTintBase * (SKY_TINT_NEARKEEP + (1-SKY_TINT_NEARKEEP)*dRamp); dr += (skyAvg[0]-dr)*tk; dg += (skyAvg[1]-dg)*tk; db += (skyAvg[2]-db)*tk; } if (sunBright > 0.01){ const _mp = proj[proj.length>>1] || proj.find(Boolean); if (_mp){ const _bl = Math.max(0, 1 - Math.hypot(_mp.sx - sunX, _mp.sy - sunY)/(sunR*2.6)); if (_bl > 0){ const _k = _bl*_bl*0.88*sunBright; dr += (10-dr)*_k; dg += (9-dg)*_k; db += (16-db)*_k; } } } const baseRGB = [dr,dg,db]; const baseCol = `rgb(${dr|0},${dg|0},${db|0})`; cx.save(); if (USE_DOF_BLUR && bl > 0.5) cx.filter = `blur(${bl.toFixed(1)}px)`; cx.globalAlpha = op; const variable = closeEnough && (s.extMode === 'sensitive'); const nibMode = s.nib || 'round'; if (nibMode === 'bristle'){ if (!s._bris) s._bris = makeBristles(s._seed!=null ? s._seed : Math.floor((s.ts||0)%100000)); cx.strokeStyle = baseCol; cx.lineCap='round'; cx.lineJoin='round'; for (const b of s._bris){ for (let i=1;i 0){ for (let i=1;i 1){ cx.lineWidth = Math.max(0.3, s.size * avgScale); cx.beginPath(); cx.moveTo(vp[0].sx, vp[0].sy); for (let i=1;i= 1) continue; if (!s._img && (s.imgUrl || s.dataURL)){ s._img=new Image(); if (s.imgUrl) s._img.crossOrigin="anonymous"; s._img.onload=()=>{s._loaded=true;}; s._img.onerror=()=>{ if(!s._retried){s._retried=true;setTimeout(()=>{s._img=null;},3000);} }; s._img.src=s.imgUrl||s.dataURL; } if (!s._img || (!s._loaded && !s._img.naturalWidth)) continue; const pr = project(s.wx,s.wy,s.wz); if (!pr || pr.depth < 10 || pr.depth > FAR_CULL) continue; const farFog=Math.exp(-0.00022*pr.depth*pr.depth*0.001), nearFog=Math.min(1,Math.max(0,(pr.depth-20)/80)); const op=Math.max(0.01, farFog*nearFog*(1-age*0.94)*0.88) * loadFade(s); if (op < 0.012) continue; const sw=s.sw*pr.scale, sh=s.sh*pr.scale, bl=Math.min(4,Math.max(0,(pr.depth-1500)/2500)); cx.save(); cx.globalAlpha=op; if (USE_DOF_BLUR && bl>0.5) cx.filter=`blur(${bl.toFixed(1)}px)`; const dissS = age > DISS_START ? (age-DISS_START)/(1-DISS_START) : 0; if (s._seed==null) s._seed = Math.floor(s.ts % 100000); const mcS = (s.maskEdge === true ? getMaskedCanvas(s._img, s, 's') : null) || s._img; if (dissS > 0){ drawFragmented(mcS, s._seed, pr.sx, pr.sy, sw, sh, age, dissS); } else { cx.drawImage(mcS, pr.sx-sw/2, pr.sy-sh/2, sw, sh); } cx.restore(); } for (const s of strokes){ if (s.type !== 'dot') continue; const age = effAge(s); if (age >= 1) continue; const pr = project(s.wx, s.wy, s.wz); if (!pr || pr.depth < 10 || pr.depth > FAR_CULL) continue; const farFog = Math.exp(-0.00022*FOG_SCALE*pr.depth*pr.depth*0.001), nearFog = Math.min(1, Math.max(0,(pr.depth-20)/80)); const op = Math.max(0.01, farFog*nearFog*(1-age*0.94)*0.9) * loadFade(s); if (op < 0.012) continue; const r = Math.max(0.5, (s.size||12) * pr.scale); if (s._twPh==null){ const _h0=Math.floor((s.ts||0)%100000); s._twPh=hash2(_h0,17)*6.2832; s._twSp=0.6+hash2(_h0,18)*1.8; } const _tn=performance.now()*0.001; const tw = 0.42 + 0.58*Math.max(0, Math.sin(_tn*s._twSp+s._twPh)*0.7 + Math.sin(_tn*s._twSp*1.73+s._twPh*2.1)*0.3); const opT = op*tw; if (!s._rgb) s._rgb = hexRGB(s.color || '#88aabb'); let [dr,dg,dbb] = s._rgb; if (skyTintBase > 0.003){ const dRamp = Math.min(1, Math.max(0, (pr.depth-DESAT_NEAR)/(DESAT_FAR-DESAT_NEAR))); const tk = skyTintBase * (SKY_TINT_NEARKEEP + (1-SKY_TINT_NEARKEEP)*dRamp); dr += (skyAvg[0]-dr)*tk; dg += (skyAvg[1]-dg)*tk; dbb += (skyAvg[2]-dbb)*tk; } if (sunBright > 0.01){ const _bl = Math.max(0, 1 - Math.hypot(pr.sx - sunX, pr.sy - sunY)/(sunR*2.6)); if (_bl > 0){ const _k=_bl*_bl*0.88*sunBright; dr += (10-dr)*_k; dg += (9-dg)*_k; dbb += (16-dbb)*_k; } } const dissD = age > DISS_START ? (age-DISS_START)/(1-DISS_START) : 0; let ox=0, oy=0; if (dissD > 0){ if (s._seed==null) s._seed = Math.floor(s.ts % 100000); ox = (hash2(s._seed,5)*2-1) * SPREAD_MAX*pr.scale*dissD*dissD; oy = (hash2(s._seed,9)*2-1) * SPREAD_MAX*pr.scale*dissD*dissD + dissD*dissD*r*2; } cx.save(); cx.globalAlpha = opT; cx.fillStyle = `rgb(${dr|0},${dg|0},${dbb|0})`; cx.beginPath(); cx.arc(pr.sx+ox, pr.sy+oy, r*(1-dissD*0.4)*(0.12+0.88*loadFade(s)), 0, Math.PI*2); cx.fill(); cx.globalAlpha = opT*0.25; cx.beginPath(); cx.arc(pr.sx+ox, pr.sy+oy, r*1.8*(0.12+0.88*loadFade(s)), 0, Math.PI*2); cx.fill(); cx.restore(); } drawPathPulses(); const nowT = performance.now(); for (const s of strokes){ if (s.type !== 'gif-anim') continue; const age = effAge(s); if (age >= 1) continue; if (!s.frames || !s.frames.length) continue; const pr = project(s.wx,s.wy,s.wz); if (!pr || pr.depth < 10 || pr.depth > FAR_CULL) continue; if (!s._lastT) s._lastT = nowT; s.frameTimer += nowT - s._lastT; s._lastT = nowT; const fr = s.frames[s.frameIdx]; if (s.frameTimer >= fr.delay){ s.frameTimer -= fr.delay; s.frameIdx=(s.frameIdx+1)%s.frames.length; } let img = s.frames[s.frameIdx].img; if (!img || !img.complete || !img.naturalWidth){ let alt = null; for (let k=0;k0.5) cx.filter=`blur(${bl.toFixed(1)}px)`; const dissG = age > DISS_START ? (age-DISS_START)/(1-DISS_START) : 0; if (s._seed==null) s._seed = Math.floor(s.ts % 100000); const mcG = (s.maskEdge === true ? getMaskedCanvas(img, s, s.frameIdx) : null) || img; if (dissG > 0){ drawFragmented(mcG, s._seed, pr.sx, pr.sy, sw, sh, age, dissG); } else { cx.drawImage(mcG, pr.sx-sw/2, pr.sy-sh/2, sw, sh); } cx.restore(); } if (cur && cur.screenPts && cur.screenPts.length > 1){ cx.save(); cx.globalAlpha=0.88; cx.strokeStyle=cur.color; const sp=cur.screenPts; const nm = cur.nib || 'round'; if (nm === 'bristle'){ if (!cur._bris) cur._bris = makeBristles(Math.floor((cur.ts||0)%100000)); cx.lineCap='round'; cx.lineJoin='round'; for (const b of cur._bris){ for (let i=1;is.type==='stamp').length; const gifCount = strokes.filter(s=>s.type==='gif-anim').length; const dotCount = strokes.filter(s=>s.type==='dot').length; const drawCount = strokes.length - stampCount - gifCount - dotCount; const hereCount = Object.keys(stars).filter(id => !stars[id]._leaving).length; document.getElementById('mout').textContent = drawCount + ' mark' + (drawCount!==1?'s':'') + (stampCount?' · '+stampCount+' stamp':'') + (gifCount?' · '+gifCount+' gif':'') + (hereCount?' · '+hereCount+' here':''); } let driftMode = false, driftT = 0, driftSeed = Math.random() * 1000; let driftEase = 0; let driftYawV = 0, driftPitchV = 0; let driftVX = 0, driftVY = 0, driftVZ = 0; let _driftPins = []; let _gazeYaw = NaN, _gazePitch = NaN; let _lastManualLook = 0; let _returnStart = -1, _returnFromPitch = 0; const IDLE_DRIFT_MS = 60000; function startDrift(){ driftMode = true; driftEase = 0; driftYawV = 0; driftPitchV = 0; velX = velY = velZ = 0; _gazeYaw = NaN; _gazePitch = NaN; _returnStart = -1; const a = Math.random() * Math.PI * 2, e = (Math.random() - 0.5) * 0.5; driftVX = Math.sin(a) * Math.cos(e) * 0.3; driftVY = Math.sin(e) * 0.18; driftVZ = Math.cos(a) * Math.cos(e) * 0.3; try { const b = document.getElementById('driftBtn'); if (b) b.classList.add('on'); } catch(e){} } function stopDrift(){ driftMode = false; driftEase = 0; driftYawV = 0; driftPitchV = 0; try { const b = document.getElementById('driftBtn'); if (b) b.classList.remove('on'); } catch(e){} } function tickDrift(){ if (!driftMode){ driftEase = 0; return; } if (typeof flyingHome !== 'undefined' && flyingHome){ driftEase = 0; return; } driftEase = Math.min(1, driftEase + 0.0018); const TURN = 0.0016; const CRUISE = 0.9; driftVX += (Math.random() - 0.5) * TURN; driftVY += (Math.random() - 0.5) * TURN * 0.6; driftVZ += (Math.random() - 0.5) * TURN; let sp = Math.hypot(driftVX, driftVY, driftVZ) || 1e-6; const f = (sp + (CRUISE - sp) * 0.02) / sp; driftVX *= f; driftVY *= f; driftVZ *= f; camX = wrap(camX + driftVX * driftEase, WX); camY = wrap(camY + driftVY * driftEase, WY); camZ = wrap(camZ + driftVZ * driftEase, WZ); let arrowLook = false; if (keys['ArrowLeft']) { camYaw -= ROT_SPD; arrowLook = true; } if (keys['ArrowRight']) { camYaw += ROT_SPD; arrowLook = true; } if (keys['ArrowUp']) { camPitch += ROT_SPD*0.7; arrowLook = true; } if (keys['ArrowDown']) { camPitch -= ROT_SPD*0.7; arrowLook = true; } const HOLD_MS = 2000; const RETURN_MS = 24000; const looking = arrowLook || (Date.now() - lastActivity) < HOLD_MS; if (!looking && Math.abs(camPitch) > 0.0005){ if (_returnStart < 0){ _returnStart = performance.now(); _returnFromPitch = camPitch; } const t = Math.min(1, (performance.now() - _returnStart) / RETURN_MS); camPitch = _returnFromPitch * (1 - easeInOut(t)); } else { _returnStart = -1; } const PMAX = 1.4; if (camPitch > PMAX) camPitch = PMAX; if (camPitch < -PMAX) camPitch = -PMAX; } function easeInOut(t){ return t<0.5 ? 2*t*t : -1+(4-2*t)*t; } function tickFlyHome(){ if (!flyingHome) return; const dest = _bmDest || { x:0, y:0, z:0, yaw:0, pitch:0 }; const t = Math.min(1, (performance.now() - flyHomeStart) / FLY_HOME_MS); const e = easeInOut(t); let dyaw = dest.yaw - flyHomeFrom.yaw, dpitch = dest.pitch - flyHomeFrom.pitch; while (dyaw > Math.PI) dyaw -= Math.PI*2; while (dyaw < -Math.PI) dyaw += Math.PI*2; let dx = dest.x-flyHomeFrom.x, dy = dest.y-flyHomeFrom.y, dz = dest.z-flyHomeFrom.z; if (dx > WX/2) dx -= WX; if (dx < -WX/2) dx += WX; if (dy > WY/2) dy -= WY; if (dy < -WY/2) dy += WY; if (dz > WZ/2) dz -= WZ; if (dz < -WZ/2) dz += WZ; camX = wrap(flyHomeFrom.x + dx*e, WX); camY = wrap(flyHomeFrom.y + dy*e, WY); camZ = wrap(flyHomeFrom.z + dz*e, WZ); camYaw = flyHomeFrom.yaw + dyaw*e; camPitch = flyHomeFrom.pitch + dpitch*e; if (t >= 1){ camX=dest.x; camY=dest.y; camZ=dest.z; camYaw=dest.yaw; camPitch=dest.pitch; flyingHome = false; _bmDest = null; } } function sampleTrail(){ if (!trailRef) return; const now = performance.now(); if (now - trailLastSampleT < TRAIL_SAMPLE_MS) return; trailLastSampleT = now; const transSpeed = Math.hypot(velX, velY, velZ); const rotSpeed = Math.hypot(velYaw, velPitch) * 250; const totalActivity = transSpeed + rotSpeed * 0.5; const decel = Math.max(0, trailPrevSpeed - transSpeed); trailPrevSpeed = transSpeed; if (totalActivity < TRAIL_MIN_SPEED) return; if (trailLastCam){ let dx=camX-trailLastCam.x, dz=camZ-trailLastCam.z; if (dx> WX/2) dx-=WX; if (dx<-WX/2) dx+=WX; if (dz> WZ/2) dz-=WZ; if (dz<-WZ/2) dz+=WZ; if (Math.hypot(dx,dz) < TRAIL_MIN_DIST) return; } trailLastCam = {x:camX, y:camY, z:camZ}; trailPts.push({wx:wrap(camX,WX), wy:wrap(camY,WY), wz:wrap(camZ,WZ)}); const actN = Math.min(1, totalActivity/MAX_DRIFT); const decelBonus = Math.min(0.6, decel/(MAX_DRIFT*0.5)); const sz = TRAIL_BASE_SIZE * (1 - actN*0.65 + decelBonus); trailSizes.push(Math.max(1, Math.min(TRAIL_BASE_SIZE*1.8, sz))); if (trailPts.length > TRAIL_MAX_PTS){ trailPts.shift(); trailSizes.shift(); } if (trailPts.length >= 2 && trailPts.length % TRAIL_SYNC_EVERY === 0){ const tz=((Math.floor(camZ/1000)%8)+8)%8, tx=((Math.floor(camX/1000)%8)+8)%8; trailRef.onDisconnect().set({ id: trailRef.key, pts: trailPts.slice(), sizes: trailSizes.slice(), color: col, size: TRAIL_BASE_SIZE, nib, extMode, ts: Date.now(), zone: tz, xcell: tx, }); if (dotRef && dotWorld){ dotRef.onDisconnect().set({ id: 'dot_'+myUid, type: 'dot', wx: dotWorld.wx, wy: dotWorld.wy, wz: dotWorld.wz, color: col, size: dotSize, zone: dotWorld.zone, xcell: dotWorld.xcell, ts: Date.now() }); } } } let _ambientReady = false; setTimeout(() => { _ambientReady = true; }, 4000); function loop(){ try { tickFlyHome(); tickDrift(); if (!driftMode && !flyingHome && Date.now()-lastActivity > IDLE_DRIFT_MS && document.getElementById('gate').classList.contains('hidden')) startDrift(); if(_ambientReady && _crossingFrame%2===0) tickBoids(); updateZoneSubscriptions(); if(_ambientReady) checkCrossings(); if(_ambientReady) tickBeats(); if(_ambientReady) tickTreeFall(); if (!flyingHome && !driftMode){ tickKeys(); tickMove(); } else if (!flyingHome){ tickMove(); } document.getElementById('posout').textContent = Math.round(camX)+' '+Math.round(camY)+' '+Math.round(camZ); sampleTrail(); render(); checkReseed(); } catch(e){ console.error('loop error (continuing):', e); } requestAnimationFrame(loop); } function startStroke(sx,sy){ isDrawing = true; markActivity(); scrollV = 0; cur = { color:col, size:sz, screenPts:[{x:sx,y:sy}], worldPts:[screenToWorld(sx,sy)], ts:Date.now(), nib }; } function moveStroke(sx,sy){ if (!cur) return; markActivity(); const last = cur.screenPts[cur.screenPts.length-1]; if (Math.hypot(sx-last.x, sy-last.y) < 2) return; cur.screenPts.push({x:sx,y:sy}); cur.worldPts.push(screenToWorld(sx,sy)); } function endStroke(){ isDrawing = false; if (cur && cur.worldPts.length > 1){ if (sessionStrokeCount >= SESSION_STROKE_LIMIT){ showLimit('stroke limit reached'); cur = null; return; } const s = { id: newId(), pts: cur.worldPts, sizes: calcSizes(cur.worldPts, cur.size, extMode), color: cur.color, size: cur.size, extMode, nib: cur.nib, ts: cur.ts, opacity: (hsl.a != null ? hsl.a : 100) / 100, }; strokes.push(s); sessionStrokeCount++; save(); } cur = null; } CV.addEventListener('mousedown', e => { markActivity(); if (entropyActive && e.button===0){ eroding=true; brushX=e.clientX; brushY=e.clientY; return; } if (stampPending){ placeStamp(e.clientX, e.clientY); return; } if (e.button===2 || e.button===1){ isPan=true; lastMX=e.clientX; lastMY=e.clientY; CV.style.cursor='grab'; return; } startStroke(e.clientX, e.clientY); }); CV.addEventListener('mousemove', e => { mouseX=(e.clientX/W-0.5); mouseY=(e.clientY/H-0.5); if (entropyActive){ brushX=e.clientX; brushY=e.clientY; } if (isPan){ markActivity(); const dx=e.clientX-lastMX, dy=e.clientY-lastMY; camYaw-=dx*0.003; camPitch+=dy*0.002; lastMX=e.clientX; lastMY=e.clientY; return; } if (!isDrawing) return; moveStroke(e.clientX, e.clientY); }); CV.addEventListener('mouseup', () => { if (eroding){ eroding=false; if (modMode && fbConnected && db){ strokes = strokes.filter(s => { if (effAge(s) < 1) return true; const zone = s.zone != null ? s.zone : 0; if (s._fbKey) db.ref('field/strokes_v3/' + zone + '/' + s._fbKey).remove().catch(()=>{}); return false; }); } saveLocal(); return; } if (isPan){ isPan=false; CV.style.cursor = entropyActive?'cell':'crosshair'; return; } endStroke(); }); CV.addEventListener('mouseleave', () => { if (eroding){ eroding=false; saveLocal(); } isPan=false; CV.style.cursor = entropyActive?'cell':'crosshair'; if (isDrawing) endStroke(); }); CV.addEventListener('contextmenu', e => e.preventDefault()); let lastPinch=null, lastMid=null, touchMode=null; CV.addEventListener('touchstart', e => { e.preventDefault(); markActivity(); flyingHome = false; _bmDest = null; if (entropyActive && e.touches.length===1){ eroding=true; touchMode='erode'; brushX=e.touches[0].clientX; brushY=e.touches[0].clientY; return; } if (stampPending && e.touches.length===1){ placeStamp(e.touches[0].clientX, e.touches[0].clientY); return; } if (e.touches.length===1){ touchMode='draw'; startStroke(e.touches[0].clientX, e.touches[0].clientY); } else if (e.touches.length===2){ if (isDrawing) endStroke(); touchMode='nav'; lastPinch = Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY); lastMid = { x:(e.touches[0].clientX+e.touches[1].clientX)/2, y:(e.touches[0].clientY+e.touches[1].clientY)/2 }; } else if (e.touches.length>=3){ if (isDrawing) endStroke(); touchMode=null; velX=velY=velZ=velYaw=velPitch=0; flyingHome=false; _bmDest=null; stopDrift(); } }, {passive:false}); CV.addEventListener('touchmove', e => { e.preventDefault(); markActivity(); if (touchMode==='erode' && e.touches.length===1){ brushX=e.touches[0].clientX; brushY=e.touches[0].clientY; return; } if (touchMode==='draw' && e.touches.length===1){ moveStroke(e.touches[0].clientX, e.touches[0].clientY); } else if (e.touches.length===2){ touchMode='nav'; const dist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY); if (lastPinch!==null){ const dd=dist-lastPinch; const fx=Math.sin(camYaw)*Math.cos(camPitch),fy=-Math.sin(camPitch),fz=Math.cos(camYaw)*Math.cos(camPitch); const mvX=fx*dd*3, mvY=fy*dd*3, mvZ=fz*dd*3; camX=wrap(camX+mvX,WX); camY=wrap(camY+mvY,WY); camZ=wrap(camZ+mvZ,WZ); gVelX=mvX; gVelY=mvY; gVelZ=mvZ; } lastPinch=dist; const mid={x:(e.touches[0].clientX+e.touches[1].clientX)/2, y:(e.touches[0].clientY+e.touches[1].clientY)/2}; if (lastMid){ const dYaw=-(mid.x-lastMid.x)*0.004, dPitch=(mid.y-lastMid.y)*0.003; camYaw+=dYaw; camPitch+=dPitch; gVelYaw=dYaw; gVelPitch=dPitch; } lastMid=mid; } }, {passive:false}); CV.addEventListener('touchend', e => { if (e.touches.length < 2){ velX += gVelX; velY += gVelY; velZ += gVelZ; velYaw = gVelYaw; velPitch = gVelPitch; gVelX=gVelY=gVelZ=gVelYaw=gVelPitch=0; lastPinch=null; lastMid=null; } if (e.touches.length===0){ if (touchMode==='draw') endStroke(); if (touchMode==='erode') saveLocal(); eroding=false; touchMode=null; } else if (e.touches.length===1){ touchMode = entropyActive ? 'erode' : 'draw'; } }); (function(){ let lastTapT = 0, startT = 0, startPts = null, moved = false, twoActive = false; const MOVE_TOL = 18, TAP_MAX = 320, DBL_GAP = 430; CV.addEventListener('touchstart', e => { if (e.touches.length === 2){ twoActive = true; moved = false; startT = Date.now(); startPts = [{x:e.touches[0].clientX,y:e.touches[0].clientY},{x:e.touches[1].clientX,y:e.touches[1].clientY}]; } else twoActive = false; }, {passive:false}); CV.addEventListener('touchmove', e => { if (twoActive && e.touches.length === 2 && startPts){ const d0 = Math.hypot(e.touches[0].clientX-startPts[0].x, e.touches[0].clientY-startPts[0].y); const d1 = Math.hypot(e.touches[1].clientX-startPts[1].x, e.touches[1].clientY-startPts[1].y); if (d0 > MOVE_TOL || d1 > MOVE_TOL) moved = true; } }, {passive:false}); CV.addEventListener('touchend', e => { if (twoActive && e.touches.length < 2){ twoActive = false; if ((Date.now()-startT) < TAP_MAX && !moved){ const now = Date.now(); if (now - lastTapT < DBL_GAP){ lastTapT = 0; _gestureSave(); } else lastTapT = now; } } }); })(); window.addEventListener('deviceorientation', e => { if (e.gamma!=null) mouseX=(e.gamma/45)*0.4; if (e.beta!=null) mouseY=((e.beta-45)/45)*0.4; }); let stampGray=null, stampW=0, stampH=0, stampPending=false, threshold=128, invertThresh=false; let maskEdgeOn = (localStorage.getItem('of_maskedge_v1') !== '0'); let maskSplatter = (()=>{ const v=parseFloat(localStorage.getItem('of_masksplat_v1')); return isNaN(v)?0.35:Math.max(0,Math.min(1,v)); })(); let _pendingMaskSeed = (Math.random()*100000)|0; const DEFAULT_SPLATTER = 0.35; let isGif=false, gifFrames=null, gifMode='anim'; let tritoneMode=false; let triColors=['#1a2a5e','#8b3a3a','#d4a650']; let triThresh=[85,170]; let triActive=0; let triAlpha=[255,255,0]; function _stampAlpha(){ return Math.round((hsl.a==null?100:hsl.a)/100*255); } function applyTritoneData(gray, w, h){ const out = new ImageData(w, h); const rgb = triColors.map(c => hexRGB(c)); const t0 = triThresh[0], t1 = triThresh[1]; for (let i=0; i b.addEventListener('click', () => { document.querySelectorAll('[data-imghsl]').forEach(x=>x.classList.remove('on')); b.classList.add('on'); imgHslMode = b.dataset.imghsl; syncImgHslControls(); })); document.getElementById('imgHslSlider').addEventListener('input', e => { hsl[imgHslMode] = parseFloat(e.target.value); updateColor(); if (tritoneMode){ if (imgHslMode==='a') triAlpha[triActive] = _stampAlpha(); else triColors[triActive] = col; updateTriChipUI(); } if (stampGray) applyStill(); else if (isGif && !_gifBuf && gifFrames) recolorVideoFrames(); syncImgHslControls(); paintModalPreview(); try { localStorage.setItem('of_hsl_v1', JSON.stringify(hsl)); } catch(_e){} }); function renderImgColPal(){ const row = document.getElementById('imgColPalRow'); if (!row) return; row.innerHTML = ''; readColPal().forEach(c => { const chip = document.createElement('button'); const _a8 = c.length === 9 ? parseInt(c.slice(7,9),16)/255 : 1; const _rgb0 = hexRGB(c); chip.style.cssText = 'width:22px;height:22px;border-radius:4px;border:1px solid rgba(255,255,255,0.18);cursor:pointer;padding:0;flex-shrink:0;position:relative;overflow:hidden;background:repeating-conic-gradient(rgba(255,255,255,0.18) 0% 25%, rgba(0,0,0,0.35) 0% 50%) 50% / 10px 10px;'; const _fill = document.createElement('span'); _fill.style.cssText = 'position:absolute;inset:0;pointer-events:none;background:rgba('+_rgb0[0]+','+_rgb0[1]+','+_rgb0[2]+','+_a8.toFixed(3)+');'; chip.appendChild(_fill); chip.title = c; chip.addEventListener('click', () => { const rgb = hexRGB(c); const r2=rgb[0]/255, g2=rgb[1]/255, b2=rgb[2]/255; const mx=Math.max(r2,g2,b2), mn=Math.min(r2,g2,b2); const l2=(mx+mn)/2, d=mx-mn, s2=mx===mn?0:l2>0.5?d/(2-mx-mn):d/(mx+mn); let h2=0; if(mx!==mn){ switch(mx){ case r2: h2=((g2-b2)/d+(g2 { e.preventDefault(); writeColPal(readColPal().filter(x=>x!==c)); renderImgColPal(); }); row.appendChild(chip); }); } let _modalAnimTimer = null, _modalAnimIdx = 0, _modalAnimLastT = 0; function startModalAnim(){ stopModalAnim(); if (!isGif || !gifFrames || gifFrames.length < 2) return; _modalAnimIdx = 0; _modalAnimLastT = performance.now(); function tick(){ if (!isGif || !gifFrames || gifFrames.length < 2){ stopModalAnim(); return; } const now = performance.now(); const fr = gifFrames[_modalAnimIdx]; if (now - _modalAnimLastT >= (fr.delay||80)){ _modalAnimLastT = now; _modalAnimIdx = (_modalAnimIdx + 1) % gifFrames.length; } const cur = gifFrames[_modalAnimIdx]; imgPrevCanvas.width = stampW; imgPrevCanvas.height = stampH; imgPrevCx.clearRect(0,0,stampW,stampH); if (cur._canvas) imgPrevCx.drawImage(cur._canvas, 0, 0); else if (cur.img && cur.img.complete && cur.img.naturalWidth) imgPrevCx.drawImage(cur.img, 0, 0, stampW, stampH); applyPreviewMask(); _modalAnimTimer = requestAnimationFrame(tick); } _modalAnimTimer = requestAnimationFrame(tick); } function stopModalAnim(){ if (_modalAnimTimer){ cancelAnimationFrame(_modalAnimTimer); _modalAnimTimer = null; } } let _prevMaskCv=null, _prevMaskKey=''; function applyPreviewMask(){ if (!maskEdgeOn || !stampW || !stampH) return; const key = _pendingMaskSeed+'|'+maskSplatter.toFixed(3)+'|'+stampW+'|'+stampH; if (!_prevMaskCv || _prevMaskKey!==key){ _prevMaskCv = proceduralMask(_pendingMaskSeed, stampW, stampH, maskSplatter); _prevMaskKey=key; } imgPrevCx.globalCompositeOperation='destination-in'; imgPrevCx.drawImage(_prevMaskCv, 0, 0, stampW, stampH); imgPrevCx.globalCompositeOperation='source-over'; } function paintModalPreview(){ if (!stampW || !stampH) return; if (isGif && gifFrames && gifFrames.length > 1){ startModalAnim(); } else { stopModalAnim(); imgPrevCanvas.width = stampW; imgPrevCanvas.height = stampH; imgPrevCx.clearRect(0,0,stampW,stampH); imgPrevCx.drawImage(stampPrev, 0, 0); applyPreviewMask(); } } invertBtn.addEventListener('click', () => { invertThresh = !invertThresh; invertBtn.classList.toggle('on', invertThresh); if (isGif && _gifBuf) reprocessGif(); else if (isGif && _videoGrays) reprocessVideoFrames(); else applyStill(); paintModalPreview(); }); function hexRGB(hex){ const h=hex.replace('#',''); return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)]; } gifModeBtn.addEventListener('click', () => { gifMode = gifMode==='anim' ? 'telescope' : gifMode==='telescope' ? 'scatter' : 'anim'; gifModeBtn.textContent = gifMode==='anim' ? 'animate' : gifMode==='telescope' ? 'telescope' : 'scatter'; gifModeBtn.classList.toggle('on', gifMode!=='anim'); }); const maskEdgeBtn = document.getElementById('maskEdgeBtn'); const maskSplatRow = document.getElementById('maskSplatRow'); const maskSplatSlider = document.getElementById('maskSplatSlider'); function syncMaskUI(){ if (maskSplatRow) maskSplatRow.style.display = maskEdgeOn ? 'flex' : 'none'; if (maskSplatSlider) maskSplatSlider.value = Math.round(maskSplatter*100); } if (maskEdgeBtn){ maskEdgeBtn.classList.toggle('on', maskEdgeOn); maskEdgeBtn.addEventListener('click', () => { maskEdgeOn = !maskEdgeOn; maskEdgeBtn.classList.toggle('on', maskEdgeOn); try { localStorage.setItem('of_maskedge_v1', maskEdgeOn ? '1' : '0'); } catch(e){} syncMaskUI(); paintModalPreview(); }); } if (maskSplatSlider){ maskSplatSlider.addEventListener('input', e => { maskSplatter = Math.max(0, Math.min(1, parseFloat(e.target.value)/100)); _prevMaskKey = ''; try { localStorage.setItem('of_masksplat_v1', maskSplatter.toFixed(3)); } catch(_e){} paintModalPreview(); }); } const tritoneBtn = document.getElementById('tritoneBtn'); const triChipsEl = document.getElementById('triChips'); function updateTriChipUI(){ document.querySelectorAll('.tri-chip').forEach(b => { const idx = parseInt(b.dataset.tri); const _a=(triAlpha[idx]||0)/255, _rb=hexRGB(triColors[idx]), _pf='rgba('+_rb[0]+','+_rb[1]+','+_rb[2]+','+_a.toFixed(3)+')'; b.style.background='linear-gradient('+_pf+','+_pf+'), repeating-conic-gradient(rgba(255,255,255,0.22) 0% 25%, rgba(0,0,0,0.4) 0% 50%) 50% / 7px 7px'; b.style.overflow='hidden'; b.style.borderColor = idx === triActive ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.15)'; }); } tritoneBtn.addEventListener('click', () => { tritoneMode = !tritoneMode; tritoneBtn.classList.toggle('on', tritoneMode); triChipsEl.style.display = tritoneMode ? 'flex' : 'none'; if (tritoneMode){ const rgb = hexRGB(col); const r=rgb[0]/255, g=rgb[1]/255, b=rgb[2]/255; const mx=Math.max(r,g,b), mn=Math.min(r,g,b), l2=(mx+mn)/2; triColors[0] = hslToHex(hsl.h, hsl.s, Math.max(10, hsl.l-30)); triColors[1] = col; triColors[2] = hslToHex(hsl.h, Math.max(10, hsl.s-20), Math.min(95, hsl.l+30)); updateTriChipUI(); } if (isGif && _videoGrays) reprocessVideoFrames(); else if (stampGray) applyStill(); paintModalPreview(); }); document.querySelectorAll('.tri-chip').forEach(b => { b.addEventListener('click', () => { triActive = parseInt(b.dataset.tri); const rgb = hexRGB(triColors[triActive]); const r2=rgb[0]/255, g2=rgb[1]/255, b2=rgb[2]/255; const mx=Math.max(r2,g2,b2), mn=Math.min(r2,g2,b2); const l2=(mx+mn)/2; const d=mx-mn, s2=mx===mn?0:l2>0.5?d/(2-mx-mn):d/(mx+mn); let h2=0; if(mx!==mn){ switch(mx){ case r2:h2=((g2-b2)/d+(g2 { e.preventDefault(); const idx = parseInt(b.dataset.tri); triAlpha[idx] = triAlpha[idx] > 0 ? 0 : 255; updateTriChipUI(); if (isGif && _videoGrays) reprocessVideoFrames(); else if (stampGray) applyStill(); paintModalPreview(); }); }); function processImage(img){ const maxSz=400, sc=Math.min(1,maxSz/Math.max(img.width,img.height)); stampW=Math.max(1,Math.round(img.width*sc)); stampH=Math.max(1,Math.round(img.height*sc)); const off=document.createElement('canvas'); off.width=stampW; off.height=stampH; const oc=off.getContext('2d'); oc.drawImage(img,0,0,stampW,stampH); const raw=oc.getImageData(0,0,stampW,stampH); stampGray=new Uint8Array(stampW*stampH); for (let i=0;i threshold; const show = invertThresh ? !above : above; if (show){ o.data[idx]=cr; o.data[idx+1]=cg; o.data[idx+2]=cb; o.data[idx+3]=_stampAlpha(); } else o.data[idx+3]=0; } return o; })(); stampPrev.width=stampW; stampPrev.height=stampH; stampPrevCx.putImageData(out,0,0); isGif=false; } let _gifBuf = null; let _videoGrays = null; function reprocessGif(){ if (_gifBuf) processGif(_gifBuf); } function reprocessVideoFrames(){ if (!_videoGrays || !_videoGrays.length) return; const [cr,cg,cb] = hexRGB(col); const delay = (gifFrames && gifFrames[0]) ? gifFrames[0].delay : 80; const newFrames = _videoGrays.map((gray, i) => { const fc = document.createElement('canvas'); fc.width=stampW; fc.height=stampH; const fctx = fc.getContext('2d'); const out = tritoneMode ? applyTritoneData(gray, stampW, stampH) : (function(){ const o = new ImageData(stampW, stampH); for (let p=0; p threshold; const show = invertThresh ? !above : above; if (show){ o.data[idx]=cr; o.data[idx+1]=cg; o.data[idx+2]=cb; o.data[idx+3]=_stampAlpha(); } else o.data[idx+3]=0; } return o; })(); fctx.putImageData(out, 0, 0); const dataURL = fc.toDataURL('image/png'); const im = new Image(); im.src = dataURL; if (i === 0){ stampPrev.width=stampW; stampPrev.height=stampH; stampPrevCx.clearRect(0,0,stampW,stampH); stampPrevCx.putImageData(out,0,0); } return { img:im, _canvas:fc, dataURL, delay }; }); gifFrames = newFrames; paintModalPreview(); } function recolorVideoFrames(){ if (_videoGrays && _videoGrays.length){ reprocessVideoFrames(); return; } if (!gifFrames || !gifFrames.length) return; const [cr,cg,cb] = hexRGB(col); const newFrames = gifFrames.map((fr, i) => { const fc = document.createElement('canvas'); fc.width=stampW; fc.height=stampH; const fctx = fc.getContext('2d'); if (fr._canvas) fctx.drawImage(fr._canvas, 0, 0); else if (fr.img && fr.img.complete && fr.img.naturalWidth) fctx.drawImage(fr.img, 0, 0, stampW, stampH); const id = fctx.getImageData(0,0,stampW,stampH); for (let p=0;p 0){ id.data[p]=cr; id.data[p+1]=cg; id.data[p+2]=cb; id.data[p+3]=Math.round(id.data[p+3]*((hsl.a==null?100:hsl.a)/100)); } } fctx.putImageData(id,0,0); const dataURL = fc.toDataURL('image/png'); const im = new Image(); im.src = dataURL; if (i === 0){ stampPrev.width=stampW; stampPrev.height=stampH; stampPrevCx.clearRect(0,0,stampW,stampH); stampPrevCx.putImageData(id,0,0); } return { img:im, _canvas:fc, dataURL, delay:fr.delay }; }); gifFrames = newFrames; paintModalPreview(); } function processGif(buf){ _gifBuf = buf; try { if (typeof GifReader==='undefined') throw new Error('omggif missing'); const reader=new GifReader(new Uint8Array(buf)); const n=reader.numFrames(); if (!n) throw new Error('0 frames'); const gw=reader.width, gh=reader.height; const maxSz=400, sc=Math.min(1,maxSz/Math.max(gw,gh)); stampW=Math.round(gw*sc); stampH=Math.round(gh*sc); const [cr,cg,cb]=hexRGB(col); const keep=[], urls=[], delays=[]; for (let fi=0;fi threshold; const show = invertThresh ? !above : above; if (show){ out.data[i]=cr; out.data[i+1]=cg; out.data[i+2]=cb; out.data[i+3]=255; } else out.data[i+3]=0; } const fc=document.createElement('canvas'); fc.width=stampW; fc.height=stampH; fc.getContext('2d').putImageData(out,0,0); keep.push(fc); urls.push(fc.toDataURL('image/png')); delays.push(Math.max(20,(info.delay||10)*10)); } gifFrames = urls.map((u,i)=>{ const im=new Image(); im.src=u; return {img:im, dataURL:u, delay:delays[i]}; }); stampPrev.width=stampW; stampPrev.height=stampH; gifFrames[0].img.onload=()=>{ stampPrevCx.drawImage(gifFrames[0].img,0,0); paintModalPreview(); }; isGif=true; gifMode='anim'; gifModeBtn.textContent='animate'; gifModeBtn.classList.remove('on'); gifModeBtn.style.display='inline-block'; openImgModal('Place animation'); paintModalPreview(); return true; } catch(e){ console.error('GIF parse failed', e); return false; } } function placeStamp(clientX, clientY){ if (!stampPending) return; const w = screenToWorld(clientX, clientY); if (sessionUploadCount >= SESSION_UPLOAD_LIMIT){ showLimit('upload limit reached'); cancelStamp(); return; } if (isGif){ if (gifMode==='anim'){ const sid = newId(); const gifStroke = { type:'gif-anim', id:sid, frames:gifFrames.slice(), sw:stampW, sh:stampH, color:col, ts:Date.now(), wx:w.wx, wy:w.wy, wz:w.wz, maskEdge:maskEdgeOn, maskSplatter:maskSplatter, maskSeed:_pendingMaskSeed, frameIdx:0, frameTimer:0, _lastT:null, approved:true }; strokes.push(gifStroke); if (fbRef){ const preKey = fbRef.push().key; if(preKey) gifStroke._fbKey = preKey; } if (storageReady){ (async () => { try { let _uploaded = 0; const _total = gifStroke.frames.length; showLimit('uploading 0/' + _total); const imgUrls = await Promise.all(gifStroke.frames.map((fr, i) => new Promise(res => { const fc=document.createElement('canvas'); fc.width=stampW; fc.height=stampH; fc.getContext('2d').drawImage(fr.img, 0, 0); fc.toBlob(async blob => { const url = await uploadImageBlob(blob, sid+'_f'+i); _uploaded++; showLimit('uploading ' + _uploaded + '/' + _total); res(url); }, 'image/png'); }) )); showLimit(''); gifStroke.frames = gifStroke.frames.map((fr, i) => { const oldImg = fr.img; const im=new Image(); im.crossOrigin='anonymous'; const frame = { img:oldImg, imgUrl:imgUrls[i], delay:fr.delay }; im.onload = () => { frame.img = im; }; im.src=imgUrls[i]; return frame; }); const commitAnim = () => { if (gifStroke._committed || !gifStroke._fbKey) return; if (!(fbConnected && fbRef && db)){ queueCommit(gifStroke._fbKey, commitAnim); return; } const _ser = serializeStroke(gifStroke); gifStroke._committed = true; pendingCommits.delete(gifStroke._fbKey); db.ref('field/strokes_v3/' + (_ser.zone != null ? _ser.zone : 0) + '/' + gifStroke._fbKey).set(_ser).catch(e => { console.warn('fb anim save failed', e.message); gifStroke._committed = false; queueCommit(gifStroke._fbKey, commitAnim); }); }; commitAnim(); saveLocal(); } catch(e){ console.warn('GIF frame Storage upload failed', e); } })(); } } else { const totalHueShift = 20; if (gifMode==='telescope'){ const zStep=300; gifFrames.forEach((fr,i) => { const wz=wrap(w.wz+i*zStep, WZ); const t = gifFrames.length > 1 ? i/(gifFrames.length-1) : 0; const shiftedHue = (hsl.h + t*totalHueShift) % 360; const shiftedCol = hslToHex(shiftedHue, hsl.s, hsl.l); const [cr2,cg2,cb2] = hexRGB(shiftedCol); const fc = document.createElement('canvas'); fc.width=stampW; fc.height=stampH; const fctx = fc.getContext('2d'); fctx.drawImage(fr.img, 0, 0); const id = fctx.getImageData(0,0,stampW,stampH); for (let p=0;p 0){ id.data[p]=cr2; id.data[p+1]=cg2; id.data[p+2]=cb2; } } fctx.putImageData(id,0,0); const dataURL = fc.toDataURL('image/png'); const im=new Image(); im.src=dataURL; strokes.push({ type:'stamp', id:newId(), _img:im, dataURL, sw:stampW, sh:stampH, color:shiftedCol, ts:Date.now()+i*10, wx:w.wx, wy:w.wy, wz, maskEdge:maskEdgeOn, maskSplatter:maskSplatter, maskSeed:_pendingMaskSeed+i }); }); } else { const SCATTER_R = 500; gifFrames.forEach((fr,i) => { const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); const r = SCATTER_R * Math.cbrt(Math.random()); const ox = r * Math.sin(phi) * Math.cos(theta); const oy = r * Math.sin(phi) * Math.sin(theta) * 0.5; const oz = r * Math.cos(phi); const scaleVar = 0.4 + Math.random() * 1.2; const sw2 = Math.round(stampW * scaleVar), sh2 = Math.round(stampH * scaleVar); const shiftedHue = ((hsl.h + (Math.random() - 0.5) * 50) % 360 + 360) % 360; const shiftedCol = hslToHex(shiftedHue, hsl.s, hsl.l); const [cr2,cg2,cb2] = hexRGB(shiftedCol); const fc = document.createElement('canvas'); fc.width=sw2; fc.height=sh2; const fctx = fc.getContext('2d'); fctx.drawImage(fr.img, 0, 0, sw2, sh2); const id = fctx.getImageData(0,0,sw2,sh2); for (let p=0;p 0){ id.data[p]=cr2; id.data[p+1]=cg2; id.data[p+2]=cb2; } } fctx.putImageData(id,0,0); const dataURL = fc.toDataURL('image/png'); const im=new Image(); im.src=dataURL; strokes.push({ type:'stamp', id:newId(), _img:im, dataURL, sw:sw2, sh:sh2, color:shiftedCol, ts:Date.now()+i*10, wx:wrap(w.wx+ox,WX), wy:wrap(w.wy+oy,WY), wz:wrap(w.wz+oz,WZ), maskEdge:maskEdgeOn, maskSplatter:maskSplatter, maskSeed:_pendingMaskSeed+i }); }); } } gifFrames=null; if (gifMode==='anim'){ saveLocal(); } else { setTimeout(save, 150); } sessionUploadCount++; } else { const [cr,cg,cb]=hexRGB(col); const tmp=document.createElement('canvas'); tmp.width=stampW; tmp.height=stampH; const out = tritoneMode ? applyTritoneData(stampGray, stampW, stampH) : (function(){ const o=new ImageData(stampW,stampH); for (let i=0;i threshold; const show = invertThresh ? !above : above; if (show){ o.data[idx]=cr; o.data[idx+1]=cg; o.data[idx+2]=cb; o.data[idx+3]=_stampAlpha(); } else o.data[idx+3]=0; } return o; })(); tmp.getContext('2d').putImageData(out,0,0); const url=tmp.toDataURL('image/png'); const im=new Image(); im.src=url; const sid=newId(); const s={ type:'stamp', id:sid, _img:im, dataURL:url, sw:stampW, sh:stampH, color:col, ts:Date.now(), wx:w.wx, wy:w.wy, wz:w.wz, maskEdge:maskEdgeOn, maskSplatter:maskSplatter, maskSeed:_pendingMaskSeed, approved:true }; strokes.push(s); sessionUploadCount++; s._fbKey = sid; fbKeys.add(sid); save(); const commitStamp = () => { if (s._committed) return; if (!(fbConnected && fbRef && db)){ queueCommit(sid, commitStamp); return; } const _ser = serializeStroke(s); const _zone = _ser.zone != null ? _ser.zone : 0; s._committed = true; pendingCommits.delete(sid); db.ref('field/strokes_v3/' + _zone + '/' + sid).set(_ser).catch(e => { console.warn('fb stamp save failed', e.message); s._committed = false; queueCommit(sid, commitStamp); }); if (isOwner){ try { db.ref('field/seed_library/' + sid).set(_ser).then(trimSeedLibrary).catch(()=>{}); } catch(e){} } }; if (storageReady){ tmp.toBlob(async blob => { try { const imgUrl = await uploadImageBlob(blob, sid); s.imgUrl = imgUrl; delete s.dataURL; const newImg = new Image(); newImg.crossOrigin = 'anonymous'; newImg.onload = () => { s._img = newImg; }; newImg.src = imgUrl; commitStamp(); saveLocal(); } catch(uploadErr){ console.warn('Storage upload failed, persisting dataURL fallback', uploadErr); commitStamp(); } }, 'image/png'); } else { commitStamp(); } setTimeout(() => { if (!s._committed) commitStamp(); }, 8000); } stampPending=false; isGif=false; stampGray=null; _videoGrays=null; isDrawing=false; cur=null; stampPrev.style.display='none'; imgBtn.classList.remove('on'); } function cancelStamp(){ stopModalAnim(); stampPending=false; isGif=false; stampGray=null; gifFrames=null; _gifBuf=null; _videoGrays=null; invertThresh=false; tritoneMode=false; tritoneBtn.classList.remove('on'); triChipsEl.style.display='none'; pendingSaveToLib=false; pendingLibThumb=null; stampPrev.style.display='none'; imgBtn.classList.remove('on'); invertBtn.classList.remove('on'); if (imgModal) imgModal.classList.add('hidden'); } imgBtn.addEventListener('click', () => { if (stampPending){ cancelStamp(); return; } openImageHub(); }); document.getElementById('imgUploadBtn').addEventListener('click', () => imgFile.click()); const STAMP_LIB_KEY = 'of_stamp_lib'; const STAMP_LIB_MAX = 14; let pendingSaveToLib = false; let pendingLibThumb = null; const IDB_NAME = 'of_stamps_db'; const IDB_VER = 1; const IDB_STORE = 'frames'; function _openIDB(){ return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VER); req.onerror = () => reject(req.error); req.onsuccess = () => resolve(req.result); req.onupgradeneeded = e => { e.target.result.createObjectStore(IDB_STORE, { keyPath: 'id' }); }; }); } async function idbSaveFrames(chipId, frames, grays, stillGray){ try { const d = await _openIDB(); const tx = d.transaction(IDB_STORE,'readwrite'); tx.objectStore(IDB_STORE).put({id:chipId, frames:frames||null, grays:grays||null, stillGray:stillGray||null}); await new Promise(r=>{tx.oncomplete=r;}); } catch(e){ console.warn('IDB save failed',e); } } async function idbLoadFrames(chipId){ try { const d = await _openIDB(); const tx = d.transaction(IDB_STORE,'readonly'); const req = tx.objectStore(IDB_STORE).get(chipId); return await new Promise(r=>{req.onsuccess=()=>r(req.result||null);}); } catch(e){ return null; } } async function idbDeleteFrames(chipId){ try { const d = await _openIDB(); const tx = d.transaction(IDB_STORE,'readwrite'); tx.objectStore(IDB_STORE).delete(chipId); } catch(e){} } function readStampLib(){ try { return JSON.parse(localStorage.getItem(STAMP_LIB_KEY)||'[]'); } catch(e){ return []; } } function writeStampLib(lib){ try { localStorage.setItem(STAMP_LIB_KEY, JSON.stringify(lib)); } catch(e){} } function captureLibThumb(){ const TH=44, tc=document.createElement('canvas'); tc.width=TH; tc.height=TH; const tctx=tc.getContext('2d'); const sc=Math.max(TH/stampW, TH/stampH); const tw=Math.round(stampW*sc), th=Math.round(stampH*sc); tctx.drawImage(stampPrev,(TH-tw)/2,(TH-th)/2,tw,th); return tc.toDataURL('image/png'); } function addToStampLib(url, sw, sh, thumb, frames, grays, stillGray){ const lib = readStampLib(); const filtered = lib.filter(x => x.url !== url); const entry = { id: Date.now().toString(36)+Math.random().toString(36).slice(2,5), url, sw, sh, thumb, tv: 2, ts: Date.now() }; if (frames && frames.length > 1){ entry.hasFrames = true; entry.frameCount = frames.length; idbSaveFrames(entry.id, frames, grays); } else if (stillGray){ entry.hasGray = true; idbSaveFrames(entry.id, null, null, stillGray); } const final = [entry, ...filtered].slice(0, STAMP_LIB_MAX); const evicted = filtered.filter(x => !final.find(f => f.id === x.id)); evicted.forEach(x => { if (x.hasFrames) idbDeleteFrames(x.id); }); writeStampLib(final); renderStampLibUI(); } function removeFromStampLib(id){ const lib = readStampLib(); const entry = lib.find(x => x.id === id); if (entry && (entry.hasFrames || entry.hasGray)) idbDeleteFrames(id); writeStampLib(lib.filter(x => x.id !== id)); renderStampLibUI(); } let _rethumbRan = false; function _rethumbLib(){ const lib = readStampLib(); const pending = lib.filter(x => x.tv !== 2 && x.url); if (!pending.length) return; let done = 0; const finish = () => { if (++done === pending.length){ writeStampLib(lib); renderStampLibUI(); } }; pending.forEach(entry => { const img = new Image(); img.onload = () => { try { const TH=44, tc=document.createElement('canvas'); tc.width=TH; tc.height=TH; const tctx=tc.getContext('2d'); const iw=img.naturalWidth||1, ih=img.naturalHeight||1; const sc=Math.max(TH/iw, TH/ih); const tw=Math.round(iw*sc), th2=Math.round(ih*sc); tctx.drawImage(img,(TH-tw)/2,(TH-th2)/2,tw,th2); entry.thumb = tc.toDataURL('image/png'); } catch(e){} entry.tv = 2; finish(); }; img.onerror = () => { entry.tv = 2; finish(); }; img.src = entry.url; }); } function renderStampLibUI(){ if (!_rethumbRan){ _rethumbRan = true; _rethumbLib(); } const lib = readStampLib(); const container = document.getElementById('stampLib'); const row = document.getElementById('stampLibRow'); if (!container || !row) return; container.style.display='block'; row.innerHTML = ''; if (!lib.length){ const empty = document.createElement('span'); empty.style.cssText = 'font-size:9px;letter-spacing:.12em;color:rgba(230,230,230,0.2);text-transform:uppercase;'; empty.textContent = 'placed images appear here'; row.appendChild(empty); return; } lib.forEach(s => { const wrap = document.createElement('div'); wrap.className = 'stamp-chip-wrap'; const btn = document.createElement('button'); btn.className = 'stamp-chip'; btn.title = 'reuse this stamp'; const img = document.createElement('img'); img.src = s.thumb; btn.appendChild(img); btn.addEventListener('click', () => openFromStampLib(s)); const del = document.createElement('button'); del.className = 'stamp-chip-del'; del.textContent = '\u00d7'; del.addEventListener('click', e => { e.stopPropagation(); removeFromStampLib(s.id); }); wrap.appendChild(btn); wrap.appendChild(del); row.appendChild(wrap); }); } async function openFromStampLib(s){ if (s.hasFrames || (s.frames && s.frames.length > 1)){ let frameData = s.frames; let graysData = null; if (s.hasFrames && !s.frames){ imgStatus.textContent = 'loading frames...'; const idbResult = await idbLoadFrames(s.id); if (!idbResult || !idbResult.frames){ showLimit('frames not found'); openImageHub(); return; } frameData = idbResult.frames; graysData = idbResult.grays; } stampW = s.sw; stampH = s.sh; _gifBuf = null; _videoGrays = null; const loadedFrames = frameData.map(f => { const im = new Image(); im.src = f.dataURL; return { img:im, dataURL:f.dataURL, delay:f.delay }; }); gifFrames = loadedFrames; stampPrev.width=stampW; stampPrev.height=stampH; loadedFrames[0].img.onload = () => { stampPrevCx.drawImage(loadedFrames[0].img,0,0); paintModalPreview(); }; stampPrevCx.drawImage(loadedFrames[0].img,0,0); isGif=true; gifMode='anim'; gifModeBtn.textContent='animate'; gifModeBtn.classList.remove('on'); gifModeBtn.style.display='inline-block'; if (graysData && graysData.length){ _videoGrays = graysData.map(g => new Uint8Array(g)); } else { Promise.all(loadedFrames.map(f => new Promise(res => { if (f.img.complete && f.img.naturalWidth) res(); else f.img.onload = res; }))).then(() => { _videoGrays = loadedFrames.map(f => { const tc = document.createElement('canvas'); tc.width=stampW; tc.height=stampH; tc.getContext('2d').drawImage(f.img, 0, 0, stampW, stampH); const id = tc.getContext('2d').getImageData(0,0,stampW,stampH); const gray = new Uint8Array(stampW*stampH); for (let p=0;p 0 ? 255 : 0; return gray; }); }); } openImgModal('Place animation'); paintModalPreview(); } else { if (s.hasGray){ const idbResult = await idbLoadFrames(s.id); if (idbResult && idbResult.stillGray){ stampW = s.sw; stampH = s.sh; stampGray = new Uint8Array(idbResult.stillGray); _gifBuf = null; _videoGrays = null; applyStill(); gifModeBtn.style.display='none'; openImgModal('Place image'); paintModalPreview(); return; } } const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => processImage(img); img.onerror = () => { showLimit('stamp failed to load'); openImageHub(); }; img.src = s.url; } } function processVideo(file){ const VIDEO_FRAMES = 48; const FRAME_DELAY = 80; const url = URL.createObjectURL(file); const vid = document.createElement('video'); vid.muted = true; vid.playsInline = true; vid.preload = 'metadata'; vid.src = url; imgBtn.classList.add('loading'); vid.addEventListener('loadedmetadata', () => { const dur = vid.duration; if (!dur || !isFinite(dur)){ URL.revokeObjectURL(url); imgBtn.classList.remove('loading'); return; } const maxSz = 400; let vw = vid.videoWidth || 320, vh = vid.videoHeight || 240; const sc = Math.min(1, maxSz / Math.max(vw, vh)); stampW = Math.round(vw*sc); stampH = Math.round(vh*sc); const [cr,cg,cb] = hexRGB(col); const frames = []; const grays = []; let fi = 0; function captureNext(){ if (fi >= VIDEO_FRAMES){ URL.revokeObjectURL(url); _videoGrays = grays; gifFrames = frames; stampPrev.width=stampW; stampPrev.height=stampH; frames[0].img.onload = () => { stampPrevCx.drawImage(frames[0].img, 0, 0); paintModalPreview(); }; stampPrevCx.drawImage(frames[0].img, 0, 0); isGif=true; gifMode='anim'; gifModeBtn.textContent='animate'; gifModeBtn.classList.remove('on'); gifModeBtn.style.display='inline-block'; imgBtn.classList.remove('loading'); openImgModal('Place video'); paintModalPreview(); return; } const seekT = (fi / (VIDEO_FRAMES-1)) * dur; vid.currentTime = seekT; } vid.addEventListener('seeked', () => { const fc = document.createElement('canvas'); fc.width=stampW; fc.height=stampH; const fctx = fc.getContext('2d'); fctx.drawImage(vid, 0, 0, stampW, stampH); const raw = fctx.getImageData(0,0,stampW,stampH); const gray = new Uint8Array(stampW*stampH); for (let i=0;i threshold; const show = invertThresh ? !above : above; if (show){ out.data[idx]=cr; out.data[idx+1]=cg; out.data[idx+2]=cb; out.data[idx+3]=255; } else out.data[idx+3]=0; } fctx.putImageData(out,0,0); const dataURL = fc.toDataURL('image/png'); const im = new Image(); im.src = dataURL; frames.push({ img:im, dataURL, delay:FRAME_DELAY }); fi++; imgStatus.textContent = 'capturing ' + fi + '/' + VIDEO_FRAMES; captureNext(); }); captureNext(); }); vid.addEventListener('error', () => { URL.revokeObjectURL(url); imgBtn.classList.remove('loading'); showLimit('video load failed'); }); } imgFile.addEventListener('change', e => { const f=e.target.files[0]; if (!f) return; const isG = f.type==='image/gif' || f.name.toLowerCase().endsWith('.gif'); const isV = f.type.startsWith('video/') || /\.(mp4|mov|webm|m4v)$/i.test(f.name); invertThresh=false; invertBtn.classList.remove('on'); if (isV){ processVideo(f); } else if (isG){ const r=new FileReader(); r.onload=ev=>{ if (!processGif(ev.target.result)) alert('Could not parse GIF'); }; r.readAsArrayBuffer(f); } else { const url=URL.createObjectURL(f); const img=new Image(); img.onload=()=>{ processImage(img); URL.revokeObjectURL(url); }; img.src=url; } imgFile.value=''; }); threshEl.addEventListener('input', e => { threshold=parseInt(e.target.value); if (tritoneMode){ triThresh[0]=Math.max(10,Math.round(threshold*0.66)); triThresh[1]=Math.min(245,Math.round(threshold*1.33)); } if (isGif && _gifBuf) reprocessGif(); else if (isGif && _videoGrays) reprocessVideoFrames(); else applyStill(); paintModalPreview(); }); document.getElementById('imgPlace').addEventListener('click', () => { if (stampW && stampH){ const thumb = captureLibThumb(); const chipUrl = stampPrev.toDataURL('image/png'); if (isGif && gifFrames && gifFrames.length > 1){ const chipFrames = gifFrames.map(f => ({dataURL:f.dataURL, delay:f.delay})); const chipGrays = _videoGrays ? _videoGrays.map(g => Array.from(g)) : null; addToStampLib(chipUrl, stampW, stampH, thumb, chipFrames, chipGrays); } else { addToStampLib(chipUrl, stampW, stampH, thumb, null, null, stampGray ? Array.from(stampGray) : null); } } closeImgModal(); stampPrev.style.left=(W/2-stampW/2)+'px'; stampPrev.style.top=(H/2-stampH/2)+'px'; stampPrev.style.display='block'; stampPending=true; imgBtn.classList.add('on'); showLimit('tap in the field to place'); }); document.getElementById('imgCancel').addEventListener('click', () => { closeImgModal(); cancelStamp(); }); document.getElementById('imgModalX').addEventListener('click', () => { closeImgModal(); cancelStamp(); }); imgModal.addEventListener('click', e => { if (e.target === imgModal){ closeImgModal(); cancelStamp(); } }); document.querySelectorAll('[data-hsl]').forEach(b => b.addEventListener('click', () => { document.querySelectorAll('[data-hsl]').forEach(x=>x.classList.remove('on')); b.classList.add('on'); hslMode=b.dataset.hsl; setSlider(hslMode); })); document.getElementById('hslSlider').addEventListener('input', e => { hsl[hslMode]=parseFloat(e.target.value); updateColor(); sliderGradient(hslMode, e.target); try { localStorage.setItem('of_hsl_v1', JSON.stringify(hsl)); } catch(_e){} }); const SIZE_STEPS = [{sz:2,r:2.5,name:'fine'},{sz:8,r:4.5,name:'medium'},{sz:24,r:7,name:'bold'},{sz:56,r:9.5,name:'huge'}]; let sizeIdx = 0; const sizeBtn = document.getElementById('sizeBtn'); function applySize(idx, persist){ sizeIdx = ((idx % SIZE_STEPS.length) + SIZE_STEPS.length) % SIZE_STEPS.length; const st = SIZE_STEPS[sizeIdx]; sz = st.sz; sizeBtn.innerHTML = ''; sizeBtn.title = st.name+' brush'; sizeBtn.classList.toggle('on', sizeIdx!==0); if(persist){ try { localStorage.setItem('of_tools_v1', JSON.stringify({nib, sz, extMode})); } catch(_e){} } } sizeBtn.addEventListener('click', () => applySize(sizeIdx+1, true)); const extBtn=document.getElementById('extBtn'); const ICON_BRUSH_SPLAYED = ''; const ICON_BRUSH_TIGHT = ''; extBtn.addEventListener('click', () => { extMode = extMode==='consistent' ? 'sensitive' : 'consistent'; const splayed = extMode==='sensitive'; extBtn.classList.add('on'); extBtn.innerHTML = splayed ? ICON_BRUSH_SPLAYED : ICON_BRUSH_TIGHT; extBtn.title = splayed ? 'splayed brush (expressive)' : 'tight brush (consistent)'; try { localStorage.setItem('of_tools_v1', JSON.stringify({nib, sz, extMode})); } catch(_e){} }); const nibBtn=document.getElementById('nibBtn'); const ICON_NIB_ROUND = ''; const ICON_NIB_SQUARE = ''; const ICON_NIB_TRIANGLE= ''; const ICON_NIB_BRISTLE = ''; nibBtn.addEventListener('click', () => { nib = nib==='round' ? 'square' : nib==='square' ? 'triangle' : nib==='triangle' ? 'bristle' : 'round'; if (nib==='round'){ nibBtn.innerHTML = ICON_NIB_ROUND; nibBtn.title = 'round nib'; } else if (nib==='square'){ nibBtn.innerHTML = ICON_NIB_SQUARE; nibBtn.title = 'square nib'; } else if (nib==='triangle'){ nibBtn.innerHTML = ICON_NIB_TRIANGLE; nibBtn.title = 'triangle nib'; } else { nibBtn.innerHTML = ICON_NIB_BRISTLE; nibBtn.title = 'bristle nib'; } nibBtn.classList.toggle('on', nib!=='round'); try { localStorage.setItem('of_tools_v1', JSON.stringify({nib, sz, extMode})); } catch(_e){} }); (function(){ const toggle = document.getElementById('colorToggle'); const pop = document.getElementById('colorPop'); toggle.addEventListener('click', e => { e.stopPropagation(); pop.classList.toggle('hidden'); if(!pop.classList.contains('hidden')) renderColPal(); }); pop.addEventListener('click', e => e.stopPropagation()); document.addEventListener('click', () => pop.classList.add('hidden')); const popX = document.getElementById('colorPopX'); if (popX) popX.addEventListener('click', e => { e.stopPropagation(); pop.classList.add('hidden'); }); document.addEventListener('touchstart', e => { if (pop.classList.contains('hidden')) return; if (pop.contains(e.target) || toggle.contains(e.target)) return; pop.classList.add('hidden'); }, {passive:true}); })(); const COL_PAL_KEY = 'of_col_pal_v1'; const COL_PAL_MAX = 24; function readColPal(){ try { return JSON.parse(localStorage.getItem(COL_PAL_KEY)||'[]'); } catch(e){ return []; } } function writeColPal(arr){ try { localStorage.setItem(COL_PAL_KEY, JSON.stringify(arr)); } catch(e){} } function renderColPal(){ const row = document.getElementById('colPalRow'); if (!row) return; const pal = readColPal(); row.innerHTML = ''; pal.forEach((c,i) => { const chip = document.createElement('button'); const _a8 = c.length === 9 ? parseInt(c.slice(7,9),16)/255 : 1; const _rgb0 = hexRGB(c); chip.style.cssText = 'width:100%;aspect-ratio:1;border-radius:4px;border:1px solid rgba(255,255,255,0.18);cursor:pointer;padding:0;position:relative;overflow:hidden;background:repeating-conic-gradient(rgba(255,255,255,0.18) 0% 25%, rgba(0,0,0,0.35) 0% 50%) 50% / 10px 10px;'; const _fill = document.createElement('span'); _fill.style.cssText = 'position:absolute;inset:0;pointer-events:none;background:rgba('+_rgb0[0]+','+_rgb0[1]+','+_rgb0[2]+','+_a8.toFixed(3)+');'; chip.appendChild(_fill); chip.title = c; chip.addEventListener('click', () => applyHexCol(c)); chip.addEventListener('contextmenu', e => { e.preventDefault(); writeColPal(readColPal().filter(x=>x!==c)); renderColPal(); }); row.appendChild(chip); }); } function applyHexCol(c){ const a8 = c.length === 9 ? parseInt(c.slice(7,9),16)/255 : 1; const rgb = hexRGB(c); const r2=rgb[0]/255, g2=rgb[1]/255, b2=rgb[2]/255; const mx=Math.max(r2,g2,b2), mn=Math.min(r2,g2,b2); const l2=(mx+mn)/2; const d=mx-mn, s2=mx===mn?0:l2>0.5?d/(2-mx-mn):d/(mx+mn); let h2=0; if(mx!==mn){ switch(mx){ case r2: h2=((g2-b2)/d+(g2 { const chip = document.createElement('button'); chip.style.cssText = 'width:100%;aspect-ratio:1;border-radius:4px;border:1px solid rgba(255,255,255,0.32);cursor:pointer;padding:0;background:'+hex+';'; chip.title = name + ' ' + hex; chip.addEventListener('click', () => applyHexCol(hex)); row.appendChild(chip); }); } renderColAnchors(); document.getElementById('colSaveBtn').addEventListener('click', () => { const _a = (hsl.a == null) ? 100 : Math.round(hsl.a); let c = hslToHex(hsl.h, hsl.s, hsl.l); if (_a < 100) c += Math.round(_a/100*255).toString(16).padStart(2,'0'); writeColPal([c, ...readColPal().filter(x=>x!==c)].slice(0, COL_PAL_MAX)); renderColPal(); }); const ICON_SPEAKER_OFF = ''; const ICON_SPEAKER_ON = ''; function toggleAudio(){ if (!audioMuted && window._ofTouchReviveT && Date.now() - window._ofTouchReviveT < 1200){ window._ofTouchReviveT = 0; const b = document.getElementById('audioBtn'); if (b){ b.innerHTML = ICON_SPEAKER_ON; b.classList.add('on'); b.title = 'audio on (M)'; } return; } audioMuted=!audioMuted; if(!audioMuted) initAudio(); try { if (typeof audioCtx !== 'undefined' && audioCtx) { audioMuted ? audioCtx.suspend() : audioCtx.resume(); } } catch(_e){} const btn = document.getElementById('audioBtn'); btn.innerHTML = audioMuted ? ICON_SPEAKER_OFF : ICON_SPEAKER_ON; btn.classList.toggle('on', !audioMuted); btn.title = audioMuted ? 'audio off (M)' : 'audio on (M)'; try { localStorage.setItem('of_audio_v1', audioMuted ? '0' : '1'); } catch(_e){} } document.getElementById('audioBtn').addEventListener('click', toggleAudio); document.getElementById('driftBtn').addEventListener('click', () => { if (driftMode) stopDrift(); else startDrift(); }); let _pinQueue = [], _pinQueueUsed = []; document.getElementById('randomPinBtn').addEventListener('click', () => { if (!db) return; db.ref('field/bookmarks').once('value').then(snap => { const all = []; snap.forEach(ch => { const v=ch.val(); if(v) all.push({id:ch.key,...v}); }); if (!all.length) return; if (_pinQueue.length === 0){ _pinQueue = all.filter(p => !_pinQueueUsed.includes(p.id)); if (_pinQueue.length === 0){ _pinQueueUsed = []; _pinQueue = [...all]; } for(let i=_pinQueue.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [_pinQueue[i],_pinQueue[j]]=[_pinQueue[j],_pinQueue[i]]; } } const pin = _pinQueue.shift(); _pinQueueUsed.push(pin.id); _bmDest = {x:pin.x,y:pin.y,z:pin.z,yaw:pin.yaw||0,pitch:pin.pitch||0}; flyingHome = true; flyHomeStart = performance.now(); flyHomeFrom = {x:camX,y:camY,z:camZ,yaw:camYaw,pitch:camPitch}; velX=velY=velZ=velYaw=velPitch=0; markActivity(); showLimit('→ ' + (pin.name||'unnamed pin')); }).catch(()=>{}); }); document.getElementById('homeBtn').addEventListener('click', () => { flyingHome = true; flyHomeStart = performance.now(); flyHomeFrom = { x:camX, y:camY, z:camZ, yaw:camYaw, pitch:camPitch }; _bmDest = (spawnX !== null) ? { x:spawnX, y:spawnY, z:spawnZ, yaw:0, pitch:0 } : null; velX=0; velY=0; velZ=0; velYaw=0; velPitch=0; markActivity(); }); const entropyBtn=document.getElementById('entropyBtn'); if (modMode) entropyBtn.classList.add('mod'); entropyBtn.addEventListener('click', () => { entropyActive = !entropyActive; entropyBtn.classList.toggle('on', entropyActive); CV.style.cursor = entropyActive ? 'cell' : 'crosshair'; if (entropyActive && stampPending) cancelStamp(); }); const saveBtn = document.getElementById('saveBtn'); const AR_PRESETS = { '4:5':[1080,1350], '1:1':[1080,1080], '2:3':[1080,1620], '9:16':[1080,1920], '3:2':[1620,1080], '16:9':[1920,1080], 'window':null }; let _exportAR = '4:5'; try { const _a=localStorage.getItem('of_export_ar'); if(_a && (_a in AR_PRESETS)) _exportAR=_a; } catch(e){} function _saveOverlay(blob, fname, reason){ const url = URL.createObjectURL(blob); const file = new File([blob], fname, { type:'image/png' }); const ov = document.createElement('div'); ov.style.cssText = 'position:fixed;inset:0;z-index:600;background:rgba(0,0,0,0.85);backdrop-filter:blur(6px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px;'; const im = document.createElement('img'); im.src = url; im.alt = fname; im.style.cssText = 'max-width:86vw;max-height:60vh;border:1px solid rgba(230,230,230,0.25);border-radius:8px;'; const btn = document.createElement('button'); btn.textContent = 'save to photos'; btn.style.cssText = "font-family:'Courier New',monospace;font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:#141414;background:rgba(235,235,235,0.96);border:none;border-radius:22px;padding:13px 28px;cursor:pointer;"; btn.addEventListener('click', () => { if (navigator.share && navigator.canShare && navigator.canShare({ files:[file] })){ navigator.share({ files:[file] }).then(() => { ov.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }).catch(()=>{}); } else { const a=document.createElement('a'); a.href=url; a.download=fname; document.body.appendChild(a); a.click(); a.remove(); } }); const note = document.createElement('div'); note.textContent = 'or press + hold the image'; note.style.cssText = "font-family:'Courier New',monospace;font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:rgba(230,230,230,0.5);text-align:center;max-width:86vw;"; ov.appendChild(im); ov.appendChild(btn); ov.appendChild(note); ov.addEventListener('click', e => { if (e.target === ov){ ov.remove(); setTimeout(()=>URL.revokeObjectURL(url), 500); } }); document.body.appendChild(ov); } function _downloadCanvas(canvas, arKey){ canvas.toBlob(blob => { if(!blob){ saveBtn.classList.add('loading'); setTimeout(()=>saveBtn.classList.remove('loading'),2500); return; } const fname = 'field_' + Date.now() + '_' + arKey.replace(':','x') + '.png'; const file = new File([blob], fname, { type:'image/png' }); const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); const canShareFile = navigator.canShare && navigator.canShare({ files:[file] }); const fallbackDownload = () => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href=url; a.download=fname; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; if (isMobile && navigator.share && canShareFile){ navigator.share({ files:[file] }).catch(() => _saveOverlay(blob, fname)); return; } fallbackDownload(); }, 'image/png'); } function _gestureSave(){ try { const preset = AR_PRESETS[_exportAR] || [1080,1350]; const rw = preset[0], rh = preset[1]; let cw = CV.width, ch = Math.round(CV.width * rh / rw); if (ch > CV.height){ ch = CV.height; cw = Math.round(CV.height * rw / rh); } const sx = Math.round((CV.width - cw)/2), sy = Math.round((CV.height - ch)/2); const out = document.createElement('canvas'); out.width = rw; out.height = rh; const o = out.getContext('2d'); o.fillStyle = '#000'; o.fillRect(0, 0, rw, rh); o.drawImage(CV, sx, sy, cw, ch, 0, 0, rw, rh); const fname = 'field_' + Date.now() + '_' + _exportAR.replace(':','x') + '.png'; const bin = atob(out.toDataURL('image/png').split(',')[1]); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); const blob = new Blob([bytes], { type:'image/png' }); const file = new File([blob], fname, { type:'image/png' }); if (navigator.share && navigator.canShare && navigator.canShare({ files:[file] })){ navigator.share({ files:[file] }).catch(err => { if (!err || err.name !== 'AbortError') _saveOverlay(blob, fname, (err && (err.name + (err.message ? ': ' + err.message : ''))) || 'share rejected'); }); } else { _saveOverlay(blob, fname, 'canShare(files)=false'); } } catch(e){ try { exportFrame(); } catch(_e){} } } function exportFrame(arKey){ arKey = (arKey && (arKey in AR_PRESETS)) ? arKey : _exportAR; _exportAR = arKey; try { localStorage.setItem('of_export_ar', arKey); } catch(e){} const preset = AR_PRESETS[arKey]; try { if (!preset){ const out=document.createElement('canvas'); out.width=CV.width; out.height=CV.height; const o=out.getContext('2d'); o.fillStyle='#000'; o.fillRect(0,0,out.width,out.height); o.drawImage(CV,0,0); _downloadCanvas(out, arKey); return; } const tw=preset[0], th=preset[1]; dpr=1; W=tw; H=th; CV.width=tw; CV.height=th; cx.setTransform(1,0,0,1,0,0); FOV = (FOV_OVERRIDE != null) ? FOV_OVERRIDE : Math.max(440, Math.min(980, Math.hypot(tw, th) * FOV_DIAG)); let f=0; const cap=()=>{ if(++f < 3){ requestAnimationFrame(cap); return; } const out=document.createElement('canvas'); out.width=tw; out.height=th; const o=out.getContext('2d'); o.fillStyle='#000'; o.fillRect(0,0,tw,th); o.drawImage(CV,0,0); resize(); _downloadCanvas(out, arKey); }; requestAnimationFrame(cap); } catch(e){ console.error('export failed', e); resize(); } } window.OFexport = exportFrame; const REC_PRESETS = { '16:9':[1280,720], '9:16':[720,1280] }; function OFrecord(seconds, ar){ if (window._ofRec){ console.warn('OFrecord: already rolling — OFrecord.stop() first'); return; } seconds = Math.max(2, Math.min(300, +seconds || 15)); ar = (ar && REC_PRESETS[ar]) ? ar : '16:9'; const tw = REC_PRESETS[ar][0], th = REC_PRESETS[ar][1]; dpr = 1; W = tw; H = th; CV.width = tw; CV.height = th; cx.setTransform(1,0,0,1,0,0); FOV = (FOV_OVERRIDE != null) ? FOV_OVERRIDE : Math.max(440, Math.min(980, Math.hypot(tw, th) * FOV_DIAG)); const stream = CV.captureStream(30); let audioDest = null; if (audioCtx && !audioMuted && masterComp){ try { audioDest = audioCtx.createMediaStreamDestination(); masterComp.connect(audioDest); stream.addTrack(audioDest.stream.getAudioTracks()[0]); } catch(e){} } const mimes = ['video/mp4;codecs=avc1.42E01E,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm']; let mime = ''; for (const m of mimes){ try { if (MediaRecorder.isTypeSupported(m)){ mime = m; break; } } catch(e){} } const rec = mime ? new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 8000000 }) : new MediaRecorder(stream, { videoBitsPerSecond: 8000000 }); const chunks = []; rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); }; rec.onstop = () => { try { if (audioDest) masterComp.disconnect(audioDest); } catch(e){} resize(); const kind = rec.mimeType || mime || 'video/webm'; const ext = kind.indexOf('mp4') >= 0 ? 'mp4' : 'webm'; const blob = new Blob(chunks, { type: kind }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'field_' + Date.now() + '_' + ar.replace(':','x') + '_720p.' + ext; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); window._ofRec = null; console.log('OFrecord: saved ' + a.download + ' (' + (blob.size/1048576).toFixed(1) + ' MB, ' + kind + ')'); }; rec.start(1000); const timer = setTimeout(() => { try { rec.stop(); } catch(e){} }, seconds*1000); window._ofRec = { rec, timer }; console.log('OFrecord: ' + ar + ' 720p · ' + seconds + 's — rolling' + (audioDest ? ' (with field audio)' : ' (silent: audio is off)') + '. OFrecord.stop() ends early.'); } OFrecord.stop = function(){ const r = window._ofRec; if (!r) return; clearTimeout(r.timer); try { r.rec.stop(); } catch(e){} }; window.OFrecord = OFrecord; window.OFfly = (function(){ const ease = u => u*u*(3-2*u); function cr(p0,p1,p2,p3,u){ const u2=u*u, u3=u2*u; return 0.5*((2*p1)+(-p0+p2)*u+(2*p0-5*p1+4*p2-p3)*u2+(-p0+3*p1-3*p2+p3)*u3); } let raf=null, t0=0, dur=0, pts=null, onDone=null; function sample(gt){ const n=pts.length; const f=gt*(n-1); let i=Math.floor(f); if(i>n-2)i=n-2; const u=f-i; const P=k=>pts[Math.max(0,Math.min(n-1,k))]; const a=P(i-1), b=P(i), c=P(i+1), d=P(i+2); return { x:cr(a.x,b.x,c.x,d.x,u), y:cr(a.y,b.y,c.y,d.y,u), z:cr(a.z,b.z,c.z,d.z,u), yaw:cr(a.yaw,b.yaw,c.yaw,d.yaw,u), pitch:cr(a.pitch,b.pitch,c.pitch,d.pitch,u) }; } function frame(now){ const gt=Math.min(1,(now-t0)/(dur*1000)); const s=sample(ease(gt)); camX=wrap(s.x,WX); camY=wrap(s.y,WY); camZ=wrap(s.z,WZ); camYaw=s.yaw; camPitch=s.pitch; velX=velY=velZ=velYaw=velPitch=0; markActivity(); if(gt<1){ raf=requestAnimationFrame(frame); } else { raf=null; console.log('OFfly: path complete'); if(onDone){ const f=onDone; onDone=null; f(); } } } function fly(points, seconds, done){ if(raf) cancelAnimationFrame(raf); pts=((points&&points.length)?points:fly.path).map(p=>({x:+p.x, y:+p.y, z:+p.z, yaw:+p.yaw||0, pitch:+p.pitch||0})); if(pts.length<2){ console.warn('OFfly: need at least 2 waypoints — OFfly.mark() to collect them'); return; } dur=Math.max(2, +seconds || Math.min(120, pts.length*5)); try{ stopDrift(); }catch(e){} flyingHome=false; _bmDest=null; onDone=done||null; t0=performance.now(); raf=requestAnimationFrame(frame); console.log('OFfly: '+pts.length+' waypoints over '+dur+'s'); } fly.path=[]; fly.mark=function(){ const p={x:Math.round(camX), y:Math.round(camY), z:Math.round(camZ), yaw:+camYaw.toFixed(3), pitch:+camPitch.toFixed(3)}; fly.path.push(p); console.log('OFfly.mark #'+fly.path.length+' '+JSON.stringify(p)); return p; }; fly.clear=function(){ fly.path=[]; console.log('OFfly: path cleared'); }; fly.stop=function(){ if(raf){ cancelAnimationFrame(raf); raf=null; console.log('OFfly: stopped'); } }; return fly; })(); function OFtake(points, seconds, ar){ seconds = Math.max(4, +seconds || 20); OFrecord(seconds + 3, ar); setTimeout(() => OFfly(points, seconds, () => setTimeout(OFrecord.stop, 600)), 350); } window.OFtake = OFtake; const savePop = document.getElementById('savePop'); function openSavePop(){ if(!savePop) return; savePop.querySelectorAll('.arbtn').forEach(b => b.classList.toggle('on', b.dataset.ar===_exportAR)); savePop.classList.remove('hidden'); } let _saveHold=null, _saveHeld=false; saveBtn.addEventListener('click', () => { if(_saveHeld){ _saveHeld=false; return; } exportFrame(); }); saveBtn.addEventListener('contextmenu', e => { e.preventDefault(); openSavePop(); }); saveBtn.addEventListener('touchstart', () => { _saveHeld=false; _saveHold=setTimeout(()=>{ _saveHeld=true; openSavePop(); }, 500); }, {passive:true}); saveBtn.addEventListener('touchend', () => clearTimeout(_saveHold)); saveBtn.addEventListener('touchmove', () => clearTimeout(_saveHold)); if(savePop){ savePop.querySelectorAll('.arbtn').forEach(b => b.addEventListener('click', () => { savePop.classList.add('hidden'); exportFrame(b.dataset.ar); })); document.addEventListener('click', e => { if(!savePop.classList.contains('hidden') && !savePop.contains(e.target) && e.target!==saveBtn && !saveBtn.contains(e.target)) savePop.classList.add('hidden'); }); document.addEventListener('touchstart', e => { if(!savePop.classList.contains('hidden') && !savePop.contains(e.target) && e.target!==saveBtn && !saveBtn.contains(e.target)) savePop.classList.add('hidden'); }, {passive:true}); } let auth = null, myUid = null, isOwner = false; const OWNER_UID = "5auFLVF6QwcUMZUSmgLEFXaBgAE3"; function initAuth(){ try { auth = firebase.auth(); auth.onAuthStateChanged(u => { if (!u) return; myUid = u.uid; window._fieldUid = u.uid; const prov = (u.providerData[0] && u.providerData[0].providerId) || 'anonymous'; isOwner = !!OWNER_UID && u.uid === OWNER_UID; console.log('AUTH uid =', u.uid, '| provider =', prov, '| isOwner =', isOwner); if (isOwner){ modMode = true; try { entropyBtn.classList.add('mod'); } catch(e){} try { const _l=document.getElementById('ofLogoMark'); if(_l){ _l.querySelectorAll('.lgdot').forEach(_d=>_d.setAttribute('fill','#9ec8e0')); _l.style.opacity='.5'; } } catch(e){} } else { try { const _l=document.getElementById('ofLogoMark'); if(_l){ _l.querySelectorAll('.lgdot').forEach(_d=>_d.setAttribute('fill','#e6e6e6')); _l.style.opacity='.26'; } } catch(e){} } if (fbConnected){ try { saveToFirebase(); } catch(e){} } }); if (modMode){ if (!auth.currentUser) auth.signInAnonymously().catch(()=>{}); } else { if (!auth.currentUser) auth.signInAnonymously().catch(e => console.warn('anon signin failed', e)); } } catch(e){ console.warn('auth init failed', e); } } function modSignIn(){ if (!auth){ console.warn('auth not ready'); return; } const cur = auth.currentUser; if (cur && cur.providerData.some(p => p.providerId === 'google.com')){ console.log('already signed in with Google, uid =', cur.uid); return; } auth.signInWithPopup(new firebase.auth.GoogleAuthProvider()) .then(res => { console.log('GOOGLE SIGN-IN OK uid =', res.user.uid); }) .catch(e => console.warn('google popup signin failed', e.code, e.message)); } let storage = null, storageReady = false; function initStorage(){ try { storage = firebase.storage(); storageReady = true; console.log('Storage ready'); } catch(e){ console.warn('storage init failed', e); } } async function uploadImageBlob(blob, id){ const ref = storage.ref('field/' + id + '.png'); await ref.put(blob, { contentType: 'image/png', cacheControl: 'public,max-age=31536000' }); return await ref.getDownloadURL(); } (function(){ const gate = document.getElementById('gate'); const enterBtn = document.getElementById('gateEnter'); const ACK = 'of_gate_ack'; try { if (localStorage.getItem(ACK) === '1'){ gate.classList.add('hidden'); setTimeout(startDrift, 900); } } catch(e){} enterBtn.addEventListener('click', () => { try { localStorage.setItem(ACK, '1'); } catch(e){} gate.classList.add('hidden'); markActivity(); setTimeout(startDrift, 700); }); const lg = document.createElement('div'); lg.id = 'ofLogoMark'; lg.title = 'Open Field'; lg.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:11;opacity:.26;line-height:0;cursor:pointer;transition:opacity .2s'; lg.innerHTML = ''; let pop=document.getElementById('ofpop'); if(!pop){ pop=document.createElement('div'); pop.id='ofpop'; pop.style.cssText='position:fixed;z-index:40;display:none;flex-direction:column;gap:8px;align-items:center;background:rgba(20,17,26,.96);border:1px solid #2c2733;color:#cfcbd6;padding:10px 15px;border-radius:8px;font:12px/1.4 \'Courier New\',monospace;letter-spacing:.06em;white-space:nowrap;box-shadow:0 8px 30px rgba(0,0,0,.5)'; document.body.appendChild(pop); } const placePop=()=>{ const r=lg.getBoundingClientRect(); pop.style.right=Math.max(8,Math.round(window.innerWidth-r.right))+'px'; pop.style.left='auto'; pop.style.bottom=Math.round(window.innerHeight-r.top+10)+'px'; pop.style.transform='none'; }; const hidePop=()=>{ pop.style.display='none'; }; const showPop=(html)=>{ pop.innerHTML=html; pop.style.display='flex'; placePop(); }; const btnCss='color:#a7a2b0;background:none;border:1px solid #3a3445;font:inherit;cursor:pointer;padding:5px 13px;border-radius:6px'; document.addEventListener('click', e=>{ if(pop.style.display!=='none' && !pop.contains(e.target) && !lg.contains(e.target)) hidePop(); }); lg.addEventListener('click', e=>{ e.preventDefault(); if(pop.style.display!=='none'){ hidePop(); return; } showPop('Aaron Higgins · © 2026'); }); lg.addEventListener('contextmenu', e=>{ e.preventDefault(); hidePop(); const owned=!!(auth && auth.currentUser && auth.currentUser.uid===OWNER_UID); if(owned){ showPop('owner ✓'); const b=document.getElementById('ofsignout'); if(b) b.onclick=()=>{ try{ if(auth) auth.signOut().then(()=>{ try{ auth.signInAnonymously().catch(()=>{}); }catch(e){} }); }catch(e){} isOwner=false; modMode=false; try{ entropyBtn.classList.remove('mod'); }catch(e){} try{ lg.querySelectorAll('.lgdot').forEach(d=>d.setAttribute('fill','#e6e6e6')); }catch(e){} lg.style.opacity='.26'; hidePop(); }; } else { showPop(''); const b=document.getElementById('ofsignin'); if(b) b.onclick=()=>{ modMode=true; if(typeof modSignIn==='function') modSignIn(); hidePop(); }; } }); lg.addEventListener('mouseenter', () => lg.style.opacity = (isOwner ? '.72' : '.5')); lg.addEventListener('mouseleave', () => lg.style.opacity = (isOwner ? '.5' : '.26')); addEventListener('resize', ()=>{ if(pop.style.display!=='none') placePop(); }); document.body.appendChild(lg); })(); (function(){ const modal = document.getElementById('txtModal'); const txtIn = document.getElementById('txtInput'); const sizeEl = document.getElementById('txtFontSize'); const placeBtn= document.getElementById('txtPlace'); const cancelB = document.getElementById('txtCancel'); const txtBtn = document.getElementById('txtBtn'); txtBtn.addEventListener('click', () => { if (stampPending){ cancelStamp(); txtBtn.classList.remove('on'); return; } txtIn.value = ''; modal.classList.remove('hidden'); setTimeout(() => txtIn.focus(), 80); }); cancelB.addEventListener('click', () => modal.classList.add('hidden')); modal.addEventListener('click', e => { if (e.target === modal) modal.classList.add('hidden'); }); function renderText(){ const text = txtIn.value.trim(); if (!text) return; const fontSize = parseInt(sizeEl.value); const font = `${fontSize}px 'Courier New', monospace`; const mc = document.createElement('canvas'); const mctx = mc.getContext('2d'); mctx.font = font; const lines = text.split('\n'); const lineH = fontSize * 1.25; const maxW = Math.max(...lines.map(l => mctx.measureText(l).width)); const pad = Math.ceil(fontSize * 0.2); const cw = Math.ceil(maxW) + pad*2; const ch = Math.ceil(lines.length * lineH) + pad*2; const off = document.createElement('canvas'); off.width = cw; off.height = ch; const octx = off.getContext('2d'); octx.fillStyle = '#fff'; octx.fillRect(0, 0, cw, ch); octx.fillStyle = '#000'; octx.font = font; octx.textBaseline = 'top'; lines.forEach((l, i) => octx.fillText(l, pad, pad + i*lineH)); const raw = octx.getImageData(0, 0, cw, ch); stampW = cw; stampH = ch; stampGray = new Uint8Array(cw*ch); for (let i=0;i { if (e.key==='Enter' && !e.shiftKey){ e.preventDefault(); renderText(); } }); sizeEl.addEventListener('input', () => { if (stampGray) renderText(); }); })(); let _bmDest = null; (function(){ const modal = document.getElementById('pinModal'); const nameInput = document.getElementById('pinNameInput'); const saveBtn = document.getElementById('pinSave'); const cancelBtn = document.getElementById('pinCancel'); const urlBox = document.getElementById('pinUrlBox'); const urlInput = document.getElementById('pinUrlInput'); const pinBtn = document.getElementById('pinBtn'); pinBtn.addEventListener('click', () => { nameInput.value = ''; urlBox.style.display = 'none'; saveBtn.textContent = 'save & copy link'; saveBtn.disabled = false; modal.classList.remove('hidden'); setTimeout(() => nameInput.focus(), 80); }); cancelBtn.addEventListener('click', () => modal.classList.add('hidden')); modal.addEventListener('click', e => { if (e.target === modal) modal.classList.add('hidden'); }); saveBtn.addEventListener('click', () => { const name = nameInput.value.trim(); if (!name) { nameInput.focus(); return; } if (!fbConnected || !db) { showLimit('not connected'); return; } saveBtn.disabled = true; saveBtn.textContent = 'saving…'; const id = newId(); const bm = { name, x: camX, y: camY, z: camZ, yaw: camYaw, pitch: camPitch, ts: Date.now() }; db.ref('field/bookmarks/' + id).set(bm).then(() => { const url = location.origin + location.pathname + '?bm=' + id; urlInput.value = url; urlBox.style.display = 'block'; saveBtn.textContent = 'link ready'; navigator.clipboard.writeText(url).then(() => { saveBtn.textContent = '\u2713 copied'; setTimeout(() => modal.classList.add('hidden'), 800); }).catch(() => { }); setTimeout(() => { urlInput.select(); urlInput.setSelectionRange(0, 9999); }, 100); }).catch(() => { saveBtn.disabled = false; saveBtn.textContent = 'save & copy link'; showLimit('save failed'); }); }); nameInput.addEventListener('keydown', e => { if (e.key === 'Enter') saveBtn.click(); }); })(); function loadBookmarkFromUrl(){ try { const bmId = new URLSearchParams(location.search).get('bm'); if (!bmId || !db) return; db.ref('field/bookmarks/' + bmId).once('value').then(snap => { const bm = snap.val(); if (!bm) return; _bmDest = bm; flyingHome = true; flyHomeStart = performance.now(); flyHomeFrom = { x:camX, y:camY, z:camZ, yaw:camYaw, pitch:camPitch }; }).catch(()=>{}); } catch(e){} } (function(){ const exp = document.getElementById('uiExpand'); const more = document.getElementById('uiMore'); const ICON_DOTS = ''; const ICON_X = ''; exp.addEventListener('click', () => { const open = more.classList.toggle('open'); exp.classList.toggle('open', open); exp.innerHTML = open ? ICON_X : ICON_DOTS; }); })(); (function(){ const ui=document.getElementById('ui'), row1=document.getElementById('uiRow1'), more=document.getElementById('uiMore'), exp=document.getElementById('uiExpand'), hide=document.getElementById('uiHide'), txt=document.getElementById('txtBtn'), img=document.getElementById('imgBtn'); if(!ui||!row1||!more||!exp||!hide||!txt||!img) return; const wide=window.matchMedia('(min-width:1200px)'); function place(){ if(txt.parentNode !== more) more.insertBefore(txt, more.firstChild); if(wide.matches){ ui.appendChild(exp); ui.appendChild(hide); } else { row1.appendChild(exp); row1.appendChild(hide); } } place(); if(wide.addEventListener) wide.addEventListener('change', place); else wide.addListener(place); })(); (function(){ const hideBtn = document.getElementById('uiHide'); const revealBtn = document.getElementById('uiReveal'); function hideUI(){ document.body.classList.add('ui-hidden'); } function showUI(){ document.body.classList.remove('ui-hidden'); } if (hideBtn) hideBtn.addEventListener('click', hideUI); if (revealBtn) revealBtn.addEventListener('click', showUI); let _lastTap = 0; CV.addEventListener('touchend', e => { if (e.touches.length > 0) return; const now = Date.now(); if (now - _lastTap < 280 && document.body.classList.contains('ui-hidden')) showUI(); _lastTap = now; }); })(); try { initFirebase(); initAuth(); initStorage(); initGeo(); loadLocal(); try { const _hc=JSON.parse(localStorage.getItem('of_hsl_v1')||'null'); if(_hc&&_hc.h!=null){hsl.h=_hc.h;hsl.s=_hc.s;hsl.l=_hc.l;if(_hc.a!=null)hsl.a=_hc.a;} } catch(_e){} try { const _t=JSON.parse(localStorage.getItem('of_tools_v1')||'null'); if(_t){ if(_t.nib && ['round','square','triangle','bristle'].includes(_t.nib)){ nib=_t.nib; const nibBtn=document.getElementById('nibBtn'); if(nib==='square'){nibBtn.innerHTML=ICON_NIB_SQUARE;nibBtn.title='square nib';} else if(nib==='triangle'){nibBtn.innerHTML=ICON_NIB_TRIANGLE;nibBtn.title='triangle nib';} else if(nib==='bristle'){nibBtn.innerHTML=ICON_NIB_BRISTLE;nibBtn.title='bristle nib';} nibBtn.classList.toggle('on',nib!=='round'); } if(_t.sz){ let _i=SIZE_STEPS.findIndex(s=>s.sz===_t.sz); if(_i<0){ _i=SIZE_STEPS.reduce((best,s,bi)=>Math.abs(s.sz-_t.sz) { setTimeout(() => { initAudio(); if(audioCtx && audioCtx.state!=='running') audioCtx.resume(); }, 3000); document.removeEventListener('pointerdown',_resumeAudio); document.removeEventListener('touchstart',_resumeAudio); document.removeEventListener('click',_resumeAudio); document.removeEventListener('keydown',_resumeAudio); }; document.addEventListener('pointerdown', _resumeAudio); document.addEventListener('touchstart', _resumeAudio); document.addEventListener('click', _resumeAudio); document.addEventListener('keydown', _resumeAudio); } } catch(_e){} loop(); setTimeout(loadBookmarkFromUrl, 1200); } catch(e){ document.body.innerHTML = '
init error: '+e.message+'

'+e.stack+'
'; } (function(){ let ecoGain=null, ecoStarted=false, ecoClock=0, ecoVoiceCount=0; const ECO_MASTER=0.55, ECO_TICK=0.12, ECO_LOOKAHEAD=0.30, ECO_MAX=10; const A=()=>BEAT_PENTA; function rnd(a,b){ return a+Math.random()*(b-a); } function note(lo,hi){ const p=A(); const i=lo+Math.floor(Math.random()*(hi-lo+1)); return p[Math.max(0,Math.min(p.length-1,i))]; } function hash(n){ const x=Math.sin(n*127.1+31.7)*43758.5453; return x-Math.floor(x); } function bump(h,c,w){ let d=Math.abs(h-c); d=Math.min(d,24-d); return Math.exp(-(d*d)/(2*w*w)); } function noiseSrc(){ if(!window._noiseBuf) return null; const s=audioCtx.createBufferSource(); s.buffer=window._noiseBuf; s.loop=true; return s; } function pan(){ const p=audioCtx.createStereoPanner?audioCtx.createStereoPanner():null; if(p) p.pan.value=rnd(-0.6,0.6); return p; } function route(node,wet){ node.connect(ecoGain); if(reverbNode&&wet>0){ const w=audioCtx.createGain(); w.gain.value=wet; node.connect(w); w.connect(reverbNode);} } function chain(src,p,env){ if(p){ src.connect(p); p.connect(env);} else src.connect(env); } function vPad(t,f){ const dur=rnd(5,9), atk=rnd(1.6,3.4); const p=A(); let i=p.indexOf(f); if(i<0)i=5; const partner=p[Math.min(p.length-1,i+2)]; const env=audioCtx.createGain(); env.gain.setValueAtTime(0.0001,t); env.gain.linearRampToValueAtTime(0.05,t+atk); env.gain.setValueAtTime(0.05,t+Math.max(atk+0.1,dur*0.5)); env.gain.exponentialRampToValueAtTime(0.0001,t+dur); const lp=audioCtx.createBiquadFilter(); lp.type='lowpass'; lp.Q.value=0.4; lp.frequency.setValueAtTime(f*3+300,t); lp.frequency.linearRampToValueAtTime(f*5+700,t+atk); lp.frequency.linearRampToValueAtTime(f*2+200,t+dur); [[f,0,'sine',0.6],[f,7,'triangle',0.4],[partner,-5,'sine',0.35],[f*2,4,'sine',0.12]].forEach(([fr,dt,ty,g])=>{ const o=audioCtx.createOscillator(); o.type=ty; o.frequency.value=fr; o.detune.value=dt+rnd(-3,3); const og=audioCtx.createGain(); og.gain.value=g; o.connect(og); og.connect(lp); o.start(t); o.stop(t+dur+0.1); }); lp.connect(env); const pn=pan(); if(pn){ env.connect(pn); route(pn,0.7); } else route(env,0.7); return dur; } function vBowl(t,f){ const dur=rnd(4,7), atk=rnd(0.3,0.8); const env=audioCtx.createGain(); env.gain.setValueAtTime(0.0001,t); env.gain.linearRampToValueAtTime(0.045,t+atk); env.gain.exponentialRampToValueAtTime(0.0001,t+dur); const partials=[[1,0.6,1],[2.0,0.3,0.85],[3.0,0.16,0.7],[4.2,0.07,0.5]]; [-4,4].forEach(det=>{ partials.forEach(([rt,g,df])=>{ const o=audioCtx.createOscillator(); o.type='sine'; o.frequency.value=f*rt; o.detune.value=det; const og=audioCtx.createGain(); og.gain.value=g; o.connect(og); og.connect(env); o.start(t); o.stop(t+dur*df+0.1); }); }); const pn=pan(); if(pn){ env.connect(pn); route(pn,0.85); } else route(env,0.85); return dur; } function vBreath(t,f){ const dur=rnd(3,6); const src=noiseSrc(); if(!src) return 0.3; const bp=audioCtx.createBiquadFilter(); bp.type='bandpass'; bp.Q.value=0.7; bp.frequency.setValueAtTime(300,t); bp.frequency.linearRampToValueAtTime(900,t+dur*0.55); bp.frequency.linearRampToValueAtTime(400,t+dur); const env=audioCtx.createGain(); env.gain.setValueAtTime(0.0001,t); env.gain.linearRampToValueAtTime(0.024,t+dur*0.45); env.gain.linearRampToValueAtTime(0.0001,t+dur); src.connect(bp); bp.connect(env); const pn=pan(); if(pn){ env.connect(pn); route(pn,0.3);} else route(env,0.3); src.start(t); src.stop(t+dur+0.05); return dur; } function vShimmer(t,f){ const dur=rnd(2.5,4.5), atk=rnd(0.15,0.4); const hf=f*2; const env=audioCtx.createGain(); env.gain.setValueAtTime(0.0001,t); env.gain.linearRampToValueAtTime(0.022,t+atk); env.gain.exponentialRampToValueAtTime(0.0001,t+dur); [-6,6].forEach(det=>{ const o=audioCtx.createOscillator(); o.type='sine'; o.frequency.value=hf; o.detune.value=det; const og=audioCtx.createGain(); og.gain.value=0.5; o.connect(og); og.connect(env); o.start(t); o.stop(t+dur+0.1); }); const pn=pan(); if(pn){ env.connect(pn); route(pn,0.9);} else route(env,0.9); return dur; } const SPECIES=[ {key:'pad', v:vPad, lo:3, hi:9, base:0.5, minGap:4.0, day:h=>0.55+bump(h,2,5)*0.35+bump(h,19,4)*0.30}, {key:'bowl', v:vBowl, lo:6, hi:12, base:0.45, minGap:2.5, day:h=>0.45+bump(h,12,5)*0.35+bump(h,18,3)*0.40}, {key:'breath', v:vBreath, lo:0, hi:0, base:0.40, minGap:3.5, day:h=>0.45+bump(h,7,3)*0.30+bump(h,18,3)*0.30}, {key:'shimmer',v:vShimmer,lo:9, hi:14, base:0.20, minGap:5.0, day:h=>0.20+bump(h,1,4)*0.70+bump(h,6,2)*0.40}, ]; SPECIES.forEach((s,i)=>{ s.idx=i; s.phase=Math.random()*6.28; s.drift=rnd(0.012,0.04); s.next=0; s.ab=0; }); function zoneMult(z,idx){ let v=hash(z*13.1+idx*7.7); v=v*v*1.7; return 0.06+Math.min(1.34,v); } function schedule(sp,t){ const f=note(sp.lo,sp.hi); let dur=0.3; try{ dur=sp.v(t,f)||0.3; }catch(e){ return; } ecoVoiceCount++; setTimeout(()=>{ ecoVoiceCount=Math.max(0,ecoVoiceCount-1); }, Math.max(0,(t-audioCtx.currentTime+dur)*1000)+60); } function tick(){ if(typeof audioMuted!=='undefined'&&audioMuted) return; if(!audioCtx||audioCtx.state!=='running') return; const now=audioCtx.currentTime; if(now-_audioStartT < OPEN_CHORAL_DELAY+4) return; ecoClock+=ECO_TICK; const d=new Date(); const h=(devTimeOverride!=null)?devTimeOverride:(d.getHours()+d.getMinutes()/60); const fz=camZ/1000; const z0=((Math.floor(fz)%8)+8)%8, z1=(z0+1)%8, fr=fz-Math.floor(fz); for(const sp of SPECIES){ const raw=(Math.sin(ecoClock*sp.drift+sp.phase)*0.5+0.5)*(0.55+0.45*Math.sin(ecoClock*sp.drift*0.41+sp.phase*1.7)); sp.ab=Math.max(0,(raw-0.28))/0.72; const zm=zoneMult(z0,sp.idx)*(1-fr)+zoneMult(z1,sp.idx)*fr; const rate=sp.base*sp.ab*sp.day(h)*zm; if(rate<0.012){ if(sp.next{ s.next=base+Math.random()*8; }); setInterval(tick,ECO_TICK*1000); ecoStarted=true; window._eco={ setLevel:v=>{ if(ecoGain) ecoGain.gain.value=v; }, SPECIES }; } if(typeof initAudio==='function'){ const _orig=initAudio; initAudio=function(){ const r=_orig.apply(this,arguments); try{ startEcosystem(); }catch(e){} return r; }; } if(typeof audioMuted!=='undefined'&&!audioMuted&&audioCtx){ try{ startEcosystem(); }catch(e){} } })();