| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"> |
| <title>LeafGuard Field Simulation</title> |
| <style> |
| *{margin:0;padding:0;box-sizing:border-box} |
| html,body{width:100%;height:100%;background:#020a02;overflow:hidden} |
| #simulation-mount{position:relative;width:100%;height:100vh;overflow:hidden;background:#020a02;font-family:'Courier New',monospace} |
| #agro-canvas{position:absolute;top:0;left:0;width:100%;height:100%;display:block} |
| .hud{position:absolute;z-index:10;pointer-events:none} |
| .hud-interactive{pointer-events:all} |
| |
| |
| #hud-top{top:0;left:0;right:0;height:48px;background:rgba(0,0,0,0.72);border-bottom:1px solid rgba(80,160,80,0.25);display:flex;align-items:center;padding:0 14px;gap:18px} |
| #hud-brand{color:#4a9;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;white-space:nowrap} |
| #hud-simtime{color:#8ec;font-size:12px;white-space:nowrap} |
| #hud-weather-badge{background:rgba(60,120,60,0.3);border:1px solid rgba(80,180,80,0.3);border-radius:4px;padding:3px 8px;color:#aed;font-size:11px;white-space:nowrap} |
| #hud-stats-right{margin-left:auto;display:flex;gap:12px;align-items:center;font-size:11px} |
| .stat{color:#6a9;white-space:nowrap} |
| .stat span{color:#cec} |
| .stat.warn span{color:#fa8} |
| .stat.crit span{color:#f55} |
| |
| |
| #hud-legend{top:56px;left:10px;width:200px;background:rgba(0,10,0,0.8);border:1px solid rgba(60,140,60,0.3);border-radius:6px;padding:10px;font-size:11px;color:#8ac} |
| #hud-legend .leg-title{color:#4c9;font-size:10px;letter-spacing:1px;text-transform:uppercase;margin-bottom:6px} |
| .leg-row{display:flex;align-items:center;gap:6px;margin:3px 0} |
| .leg-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} |
| .leg-cond{margin-top:8px;padding-top:6px;border-top:1px solid rgba(60,120,60,0.2);font-size:12px;color:#ce9} |
| #hud-gps{margin-top:6px;font-size:10px;color:#678;line-height:1.6} |
| #hud-updated{font-size:9px;color:#456;margin-top:3px} |
| |
| |
| #hud-right{top:56px;right:10px;width:248px;display:flex;flex-direction:column;gap:8px} |
| .hud-card{background:rgba(0,10,0,0.82);border:1px solid rgba(60,140,60,0.28);border-radius:6px;padding:10px;font-size:11px;color:#8ac} |
| .hud-card .card-title{color:#4c9;font-size:10px;letter-spacing:1px;text-transform:uppercase;margin-bottom:7px} |
| .plant-row{display:flex;align-items:center;gap:5px;padding:2px 0;border-bottom:1px solid rgba(60,100,60,0.15)} |
| .plant-row:last-child{border-bottom:none} |
| .p-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0} |
| .p-name{color:#ace;flex:1;font-size:10px} |
| .p-bars{font-size:10px;color:#6a9} |
| .p-badge{font-size:9px;padding:1px 5px;border-radius:3px;font-weight:700} |
| .badge-ok{background:rgba(40,180,80,0.25);color:#4e9} |
| .badge-stress{background:rgba(200,160,40,0.25);color:#da6} |
| .badge-disease{background:rgba(200,40,40,0.25);color:#f55} |
| .badge-treated{background:rgba(40,120,200,0.25);color:#5ae} |
| .env-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px} |
| .env-item{background:rgba(0,20,0,0.5);border-radius:3px;padding:4px 6px} |
| .env-label{font-size:9px;color:#567;text-transform:uppercase;letter-spacing:0.5px} |
| .env-val{font-size:12px;color:#cec;font-weight:600;margin-top:1px} |
| .field-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px} |
| .field-item{background:rgba(0,20,0,0.5);border-radius:3px;padding:4px 6px;text-align:center} |
| .field-label{font-size:9px;color:#567;text-transform:uppercase} |
| .field-val{font-size:13px;color:#aec;font-weight:700} |
| .product-row{font-size:10px;color:#789;padding:2px 0;display:flex;justify-content:space-between} |
| |
| |
| #hud-bottom{bottom:36px;left:0;right:0;padding:0 12px;display:flex;align-items:center;gap:10px;height:44px;background:rgba(0,0,0,0.7);border-top:1px solid rgba(60,140,60,0.2)} |
| .btn-group{display:flex;gap:4px} |
| .btn{background:rgba(30,80,30,0.6);border:1px solid rgba(80,160,80,0.3);color:#8c9;font-family:inherit;font-size:11px;padding:5px 10px;border-radius:4px;cursor:pointer;transition:all 0.15s} |
| .btn:hover{background:rgba(50,130,50,0.7);color:#cec;border-color:rgba(80,200,80,0.5)} |
| .btn.active{background:rgba(40,180,80,0.35);color:#4e9;border-color:#4a7} |
| .btn-plant{font-size:10px;padding:4px 8px} |
| .btn-action{font-size:10px;padding:4px 8px;background:rgba(0,40,60,0.6);border-color:rgba(40,120,160,0.3);color:#6ae} |
| .btn-action:hover{background:rgba(0,60,100,0.7);color:#aef} |
| .btn-action.water-active{background:rgba(0,60,180,0.4);color:#4af;border-color:#48f} |
| .btn-action.spray-active{background:rgba(60,180,80,0.35);color:#4e9;border-color:#4a7} |
| .btn-speed{font-size:10px;padding:4px 6px} |
| .sep{color:rgba(80,160,80,0.3);font-size:18px;user-select:none} |
| #hud-bottom-hint{margin-left:auto;font-size:10px;color:#456;white-space:nowrap} |
| |
| |
| #hud-status{bottom:0;left:0;right:0;height:32px;background:rgba(0,0,0,0.8);border-top:1px solid rgba(40,100,40,0.2);display:flex;align-items:center;padding:0 14px;justify-content:space-between} |
| #status-hints{font-size:10px;color:#456} |
| #status-right{font-size:10px;color:#567} |
| |
| |
| #mode-indicator{top:56px;left:50%;transform:translateX(-50%);background:rgba(0,20,40,0.9);border:1px solid #4af;border-radius:20px;padding:6px 18px;color:#8cf;font-size:12px;display:none;letter-spacing:1px} |
| |
| |
| #plant-tooltip{background:rgba(0,15,0,0.95);border:1px solid rgba(80,200,80,0.4);border-radius:8px;padding:12px;font-size:11px;color:#aec;width:200px;display:none;pointer-events:none} |
| #plant-tooltip .tt-title{color:#4e9;font-size:13px;font-weight:700;margin-bottom:6px} |
| #plant-tooltip .tt-row{display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px solid rgba(40,80,40,0.2)} |
| #plant-tooltip .tt-row:last-child{border-bottom:none} |
| #plant-tooltip .tt-label{color:#678} |
| #plant-tooltip .tt-val{color:#cec} |
| |
| |
| #storm-warning{top:55px;left:50%;transform:translateX(-50%);background:rgba(180,60,0,0.9);border:1px solid #f80;border-radius:6px;padding:6px 16px;color:#fca;font-size:12px;font-weight:700;display:none;animation:pulse 1s infinite} |
| @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}} |
| |
| |
| ::-webkit-scrollbar{display:none} |
| </style> |
| </head> |
| <body> |
| <div id="simulation-mount"> |
| <canvas id="agro-canvas"></canvas> |
|
|
| |
| <div class="hud" id="hud-top"> |
| <span id="hud-brand">LeafGuard</span> |
| <span id="hud-simtime">Day 1 Β· Week 1 Β· Spring Β· 06:00</span> |
| <span id="hud-weather-badge">π Loadingβ¦</span> |
| <div id="hud-stats-right"> |
| <span class="stat">PLANTS <span id="s-plants">9</span></span> |
| <span class="stat">FPS <span id="s-fps">--</span></span> |
| <span class="stat" id="s-temp-stat">TEMP <span id="s-temp">--Β°C</span></span> |
| <span class="stat">WIND <span id="s-wind">--</span></span> |
| <span class="stat">RAIN <span id="s-rain">OFF</span></span> |
| <span class="stat">HUM <span id="s-hum">--%</span></span> |
| <span class="stat">SOIL <span id="s-soil">--%</span></span> |
| </div> |
| </div> |
|
|
| |
| <div class="hud" id="hud-legend"> |
| <div class="leg-title">Field Legend</div> |
| <div class="leg-row"><div class="leg-dot" style="background:#4e9"></div><span>Healthy</span></div> |
| <div class="leg-row"><div class="leg-dot" style="background:#da6"></div><span>Stressed</span></div> |
| <div class="leg-row"><div class="leg-dot" style="background:#f55"></div><span>Diseased</span></div> |
| <div class="leg-row"><div class="leg-dot" style="background:#5ae"></div><span>Treated</span></div> |
| <div class="leg-cond" id="leg-cond">CLEAR</div> |
| <div id="hud-gps">GPS: 24.7136Β°N 46.6753Β°E<br>Zone: Arid/Semi-arid</div> |
| <div id="hud-updated">Last update: --</div> |
| </div> |
|
|
| |
| <div class="hud" id="storm-warning">β STORM WARNING β Wind >18 m/s</div> |
|
|
| |
| <div class="hud" id="mode-indicator">WATER MODE β click a plant to irrigate</div> |
|
|
| |
| <div class="hud" id="hud-right"> |
| <div class="hud-card"> |
| <div class="card-title">Plant Status</div> |
| <div id="plant-list"></div> |
| </div> |
| <div class="hud-card"> |
| <div class="card-title">Environment</div> |
| <div class="env-grid"> |
| <div class="env-item"><div class="env-label">Sun Elevation</div><div class="env-val" id="e-sun">--Β°</div></div> |
| <div class="env-item"><div class="env-label">Cloud Cover</div><div class="env-val" id="e-cloud">--%</div></div> |
| <div class="env-item"><div class="env-label">Photosynthesis</div><div class="env-val" id="e-photo">--%</div></div> |
| <div class="env-item"><div class="env-label">Evaporation</div><div class="env-val" id="e-evap">--/hr</div></div> |
| </div> |
| </div> |
| <div class="hud-card"> |
| <div class="card-title">Field Metrics</div> |
| <div class="field-grid"> |
| <div class="field-item"><div class="field-label">Soil Moisture</div><div class="field-val" id="f-moist">--%</div></div> |
| <div class="field-item"><div class="field-label">Soil pH</div><div class="field-val" id="f-ph">6.8</div></div> |
| <div class="field-item"><div class="field-label">Avg Temp</div><div class="field-val" id="f-temp">--Β°C</div></div> |
| <div class="field-item"><div class="field-label">N-P-K Est</div><div class="field-val" id="f-npk">--</div></div> |
| </div> |
| </div> |
| <div class="hud-card"> |
| <div class="card-title">Applied Products</div> |
| <div id="product-list"><div style="color:#456;font-size:10px">None applied yet</div></div> |
| </div> |
| </div> |
|
|
| |
| <div class="hud hud-interactive" id="hud-bottom"> |
| <div class="btn-group"> |
| <button class="btn btn-plant active" id="btn-tomato" onclick="window.simSetPlant('tomato')">Tomato</button> |
| <button class="btn btn-plant" id="btn-wheat" onclick="window.simSetPlant('wheat')">Wheat</button> |
| <button class="btn btn-plant" id="btn-pepper" onclick="window.simSetPlant('pepper')">Pepper</button> |
| <button class="btn btn-plant" id="btn-cucumber" onclick="window.simSetPlant('cucumber')">Cucumber</button> |
| </div> |
| <span class="sep">|</span> |
| <div class="btn-group"> |
| <button class="btn btn-action" id="btn-water" onclick="window.simToggleWater()">π§ Water</button> |
| <button class="btn btn-action" id="btn-spray" onclick="window.simToggleSpray()">πΏ Spray</button> |
| <button class="btn btn-action" onclick="window.simAddDisease()">π¦ Disease</button> |
| <button class="btn btn-action" onclick="window.simAdvanceDay()">+1 Day</button> |
| <button class="btn btn-action" onclick="window.simAdvanceWeek()">+1 Week</button> |
| </div> |
| <span class="sep">|</span> |
| <div class="btn-group"> |
| <button class="btn btn-speed active" id="spd-1" onclick="window.simSetSpeed(1)">x1</button> |
| <button class="btn btn-speed" id="spd-6" onclick="window.simSetSpeed(6)">x6</button> |
| <button class="btn btn-speed" id="spd-24" onclick="window.simSetSpeed(24)">x24</button> |
| <button class="btn btn-speed" id="spd-168" onclick="window.simSetSpeed(168)">x168</button> |
| </div> |
| <span id="hud-bottom-hint">Drag=rotate | Right drag=pan | Scroll=zoom | Click=interact</span> |
| </div> |
|
|
| |
| <div class="hud" id="hud-status"> |
| <span id="status-hints">Drag=rotate Β· Right drag=pan Β· Scroll=zoom Β· Click plant=inspect</span> |
| <span id="status-right" id="status-r">GPS -- Β· -- Β· v1.0</span> |
| </div> |
|
|
| |
| <div class="hud" id="plant-tooltip"> |
| <div class="tt-title" id="tt-title">Plant 1</div> |
| <div class="tt-row"><span class="tt-label">Age</span><span class="tt-val" id="tt-age">--</span></div> |
| <div class="tt-row"><span class="tt-label">Health</span><span class="tt-val" id="tt-health">--</span></div> |
| <div class="tt-row"><span class="tt-label">Water</span><span class="tt-val" id="tt-water">--</span></div> |
| <div class="tt-row"><span class="tt-label">Growth</span><span class="tt-val" id="tt-growth">--</span></div> |
| <div class="tt-row"><span class="tt-label">Fruits</span><span class="tt-val" id="tt-fruits">--</span></div> |
| <div class="tt-row"><span class="tt-label">Status</span><span class="tt-val" id="tt-status">--</span></div> |
| <div class="tt-row"><span class="tt-label">Stressors</span><span class="tt-val" id="tt-stress">--</span></div> |
| <div class="tt-row"><span class="tt-label">Est. Harvest</span><span class="tt-val" id="tt-harvest">--</span></div> |
| </div> |
| </div> |
| <script> |
| |
| |
| |
| (function(){ |
| 'use strict'; |
| |
| |
| function m4id(){const m=new Float32Array(16);m[0]=m[5]=m[10]=m[15]=1;return m;} |
| function m4mul(a,b){ |
| const c=new Float32Array(16); |
| for(let r=0;r<4;r++)for(let col=0;col<4;col++){ |
| let s=0;for(let k=0;k<4;k++)s+=a[k*4+r]*b[col*4+k]; |
| c[col*4+r]=s; |
| } |
| return c; |
| } |
| function m4persp(fov,asp,n,f){ |
| const t=1/Math.tan(fov/2),nf=1/(n-f),m=new Float32Array(16); |
| m[0]=t/asp;m[5]=t;m[10]=(f+n)*nf;m[11]=-1;m[14]=2*f*n*nf;return m; |
| } |
| function m4look(ex,ey,ez,cx,cy,cz){ |
| let fx=cx-ex,fy=cy-ey,fz=cz-ez; |
| const fl=Math.sqrt(fx*fx+fy*fy+fz*fz);fx/=fl;fy/=fl;fz/=fl; |
| let rx=-fz,ry=0,rz=fx; |
| const rl=Math.sqrt(rx*rx+rz*rz);rx/=rl;rz/=rl; |
| const ux=ry*fz-rz*fy,uy=rz*fx-rx*fz,uz=rx*fy-ry*fx; |
| const m=new Float32Array(16); |
| m[0]=rx;m[4]=ry;m[8]=rz; m[12]=-(rx*ex+ry*ey+rz*ez); |
| m[1]=ux;m[5]=uy;m[9]=uz; m[13]=-(ux*ex+uy*ey+uz*ez); |
| m[2]=-fx;m[6]=-fy;m[10]=-fz;m[14]=fx*ex+fy*ey+fz*ez; |
| m[15]=1;return m; |
| } |
| function m4tr(tx,ty,tz){const m=m4id();m[12]=tx;m[13]=ty;m[14]=tz;return m;} |
| function m4sc(sx,sy,sz){const m=new Float32Array(16);m[0]=sx;m[5]=sy;m[10]=sz;m[15]=1;return m;} |
| function m4ry(a){const m=m4id(),c=Math.cos(a),s=Math.sin(a);m[0]=c;m[2]=-s;m[8]=s;m[10]=c;return m;} |
| function m4rz(a){const m=m4id(),c=Math.cos(a),s=Math.sin(a);m[0]=c;m[1]=s;m[4]=-s;m[5]=c;return m;} |
| function m4rx(a){const m=m4id(),c=Math.cos(a),s=Math.sin(a);m[5]=c;m[6]=s;m[9]=-s;m[10]=c;return m;} |
| |
| |
| |
| function mkBox(w,h,d,r,g,b){ |
| const hw=w/2,hh=h/2,hd=d/2; |
| const F=[ |
| {vs:[[-hw,-hh,hd],[hw,-hh,hd],[hw,hh,hd],[-hw,hh,hd]],n:[0,0,1]}, |
| {vs:[[hw,-hh,-hd],[-hw,-hh,-hd],[-hw,hh,-hd],[hw,hh,-hd]],n:[0,0,-1]}, |
| {vs:[[-hw,-hh,-hd],[-hw,-hh,hd],[-hw,hh,hd],[-hw,hh,-hd]],n:[-1,0,0]}, |
| {vs:[[hw,-hh,hd],[hw,-hh,-hd],[hw,hh,-hd],[hw,hh,hd]],n:[1,0,0]}, |
| {vs:[[-hw,hh,hd],[hw,hh,hd],[hw,hh,-hd],[-hw,hh,-hd]],n:[0,1,0]}, |
| {vs:[[-hw,-hh,-hd],[hw,-hh,-hd],[hw,-hh,hd],[-hw,-hh,hd]],n:[0,-1,0]}, |
| ]; |
| const V=[],I=[];let vi=0; |
| for(const f of F){for(const p of f.vs)V.push(p[0],p[1],p[2],f.n[0],f.n[1],f.n[2],r,g,b);I.push(vi,vi+1,vi+2,vi,vi+2,vi+3);vi+=4;} |
| return{verts:new Float32Array(V),idx:new Uint16Array(I)}; |
| } |
| function mkCyl(radius,height,segs,r,g,b){ |
| const V=[],I=[];let vi=0;const hh=height*.5,PI2=Math.PI*2; |
| for(let i=0;i<segs;i++){ |
| const a0=(i/segs)*PI2,a1=((i+1)/segs)*PI2; |
| const x0=Math.cos(a0)*radius,z0=Math.sin(a0)*radius,x1=Math.cos(a1)*radius,z1=Math.sin(a1)*radius; |
| V.push(x0,-hh,z0,Math.cos(a0),0,Math.sin(a0),r,g,b); |
| V.push(x1,-hh,z1,Math.cos(a1),0,Math.sin(a1),r,g,b); |
| V.push(x1, hh,z1,Math.cos(a1),0,Math.sin(a1),r,g,b); |
| V.push(x0, hh,z0,Math.cos(a0),0,Math.sin(a0),r,g,b); |
| I.push(vi,vi+1,vi+2,vi,vi+2,vi+3);vi+=4; |
| } |
| const tc=vi++;V.push(0,hh,0,0,1,0,r,g,b);const ts=vi; |
| for(let i=0;i<=segs;i++){const a=(i/segs)*PI2;V.push(Math.cos(a)*radius,hh,Math.sin(a)*radius,0,1,0,r,g,b);if(i>0)I.push(tc,ts+i-1,ts+i);vi++;} |
| const bc=vi++;V.push(0,-hh,0,0,-1,0,r,g,b);const bs=vi; |
| for(let i=0;i<=segs;i++){const a=(i/segs)*PI2;V.push(Math.cos(a)*radius,-hh,Math.sin(a)*radius,0,-1,0,r,g,b);if(i>0)I.push(bc,bs+i,bs+i-1);vi++;} |
| return{verts:new Float32Array(V),idx:new Uint16Array(I)}; |
| } |
| function mkSph(radius,segs,r,g,b){ |
| const V=[],I=[],lons=segs*2; |
| for(let lat=0;lat<=segs;lat++){ |
| const th=(lat/segs)*Math.PI,st=Math.sin(th),ct=Math.cos(th); |
| for(let lon=0;lon<=lons;lon++){ |
| const ph=(lon/lons)*Math.PI*2,nx=Math.cos(ph)*st,ny=ct,nz=Math.sin(ph)*st; |
| V.push(nx*radius,ny*radius,nz*radius,nx,ny,nz,r,g,b); |
| } |
| } |
| const cols=lons+1; |
| for(let lat=0;lat<segs;lat++)for(let lon=0;lon<lons;lon++){ |
| const a=lat*cols+lon,bv=a+cols; |
| I.push(a,bv,a+1,bv,bv+1,a+1); |
| } |
| return{verts:new Float32Array(V),idx:new Uint16Array(I)}; |
| } |
| function mkCone(radius,height,segs,r,g,b){ |
| const V=[],I=[];let vi=0;const hh=height*.5,PI2=Math.PI*2; |
| const sL=Math.sqrt(radius*radius+height*height),ny=radius/sL,ns=height/sL; |
| for(let i=0;i<segs;i++){ |
| const a0=(i/segs)*PI2,a1=((i+1)/segs)*PI2; |
| const x0=Math.cos(a0)*radius,z0=Math.sin(a0)*radius,x1=Math.cos(a1)*radius,z1=Math.sin(a1)*radius; |
| const am=(a0+a1)/2; |
| V.push(0,hh,0,Math.cos(am)*ns,ny,Math.sin(am)*ns,r,g,b); |
| V.push(x0,-hh,z0,Math.cos(a0)*ns,ny,Math.sin(a0)*ns,r,g,b); |
| V.push(x1,-hh,z1,Math.cos(a1)*ns,ny,Math.sin(a1)*ns,r,g,b); |
| I.push(vi,vi+1,vi+2);vi+=3; |
| } |
| const bc=vi++;V.push(0,-hh,0,0,-1,0,r,g,b);const bs=vi; |
| for(let i=0;i<=segs;i++){const a=(i/segs)*PI2;V.push(Math.cos(a)*radius,-hh,Math.sin(a)*radius,0,-1,0,r,g,b);if(i>0)I.push(bc,bs+i,bs+i-1);vi++;} |
| return{verts:new Float32Array(V),idx:new Uint16Array(I)}; |
| } |
| |
| |
| function combineMeshes(parts){ |
| let totalV=0,totalI=0; |
| for(const p of parts){totalV+=p.mesh.verts.length;totalI+=p.mesh.idx.length;} |
| const V=new Float32Array(totalV),I=new Uint16Array(totalI); |
| let vo=0,io=0,vBase=0; |
| for(const p of parts){ |
| const mv=p.mesh.verts,mi=p.mesh.idx; |
| const tx=p.tx||0,ty=p.ty||0,tz=p.tz||0; |
| const sx=p.sx||1,sy=p.sy||1,sz=p.sz||1; |
| for(let i=0;i<mv.length;i+=9){ |
| V[vo+0]=mv[i+0]*sx+tx; V[vo+1]=mv[i+1]*sy+ty; V[vo+2]=mv[i+2]*sz+tz; |
| V[vo+3]=mv[i+3]; V[vo+4]=mv[i+4]; V[vo+5]=mv[i+5]; |
| V[vo+6]=mv[i+6]; V[vo+7]=mv[i+7]; V[vo+8]=mv[i+8]; |
| vo+=9; |
| } |
| const vCount=mv.length/9; |
| for(let i=0;i<mi.length;i++)I[io++]=mi[i]+vBase; |
| vBase+=vCount; |
| } |
| return{verts:V,idx:I}; |
| } |
| |
| |
| const mount=document.getElementById('simulation-mount'); |
| const canvas=document.getElementById('agro-canvas'); |
| const gl=canvas.getContext('webgl',{antialias:true,alpha:false})||canvas.getContext('experimental-webgl'); |
| if(!gl){mount.innerHTML='<div style="color:#f55;padding:40px;text-align:center">WebGL not supported</div>';return;} |
| |
| gl.enable(gl.DEPTH_TEST);gl.enable(gl.BLEND); |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); |
| gl.enable(gl.CULL_FACE);gl.cullFace(gl.BACK); |
| |
| function resizeCanvas(){ |
| const pr=window.devicePixelRatio||1; |
| canvas.width=mount.clientWidth*pr;canvas.height=mount.clientHeight*pr; |
| gl.viewport(0,0,canvas.width,canvas.height); |
| } |
| resizeCanvas(); |
| window.addEventListener('resize',resizeCanvas); |
| |
| |
| const SOLID_VS=` |
| attribute vec3 a_pos; |
| attribute vec3 a_norm; |
| attribute vec3 a_col; |
| uniform mat4 u_mvp; |
| uniform mat4 u_model; |
| uniform vec3 u_lightDir; |
| uniform vec3 u_lightCol; |
| uniform float u_ambient; |
| uniform vec3 u_eyePos; |
| uniform float u_fogNear; |
| uniform float u_fogFar; |
| varying vec3 v_litCol; |
| varying float v_fog; |
| void main(){ |
| vec4 wPos=u_model*vec4(a_pos,1.0); |
| gl_Position=u_mvp*vec4(a_pos,1.0); |
| vec3 wNorm=normalize(mat3(u_model)*a_norm); |
| float diff=max(dot(wNorm,normalize(u_lightDir)),0.0); |
| vec3 lit=a_col*(u_ambient+diff*(1.0-u_ambient))*u_lightCol; |
| v_litCol=clamp(lit,0.0,1.0); |
| float dist=length(wPos.xyz-u_eyePos); |
| v_fog=clamp((u_fogFar-dist)/(u_fogFar-u_fogNear),0.0,1.0); |
| }`; |
| const SOLID_FS=` |
| precision mediump float; |
| varying vec3 v_litCol; |
| varying float v_fog; |
| uniform vec3 u_fogCol; |
| uniform vec3 u_tint; |
| uniform float u_alpha; |
| void main(){ |
| vec3 c=v_litCol*u_tint; |
| gl_FragColor=vec4(mix(u_fogCol,c,v_fog),u_alpha); |
| }`; |
| const LINE_VS=` |
| attribute vec3 a_pos; |
| attribute vec4 a_col; |
| uniform mat4 u_mvp; |
| varying vec4 v_col; |
| void main(){gl_Position=u_mvp*vec4(a_pos,1.0);v_col=a_col;}`; |
| const LINE_FS=` |
| precision mediump float; |
| varying vec4 v_col; |
| void main(){gl_FragColor=v_col;}`; |
| |
| function compileShader(src,type){ |
| const sh=gl.createShader(type);gl.shaderSource(sh,src);gl.compileShader(sh); |
| if(!gl.getShaderParameter(sh,gl.COMPILE_STATUS)){console.error(gl.getShaderInfoLog(sh));return null;} |
| return sh; |
| } |
| function makeProgram(vs,fs){ |
| const p=gl.createProgram(); |
| gl.attachShader(p,compileShader(vs,gl.VERTEX_SHADER)); |
| gl.attachShader(p,compileShader(fs,gl.FRAGMENT_SHADER)); |
| gl.linkProgram(p); |
| if(!gl.getProgramParameter(p,gl.LINK_STATUS)){console.error(gl.getProgramInfoLog(p));return null;} |
| return p; |
| } |
| const progSolid=makeProgram(SOLID_VS,SOLID_FS); |
| const progLine=makeProgram(LINE_VS,LINE_FS); |
| |
| |
| const S={ |
| aPos:gl.getAttribLocation(progSolid,'a_pos'), |
| aNorm:gl.getAttribLocation(progSolid,'a_norm'), |
| aCol:gl.getAttribLocation(progSolid,'a_col'), |
| uMvp:gl.getUniformLocation(progSolid,'u_mvp'), |
| uModel:gl.getUniformLocation(progSolid,'u_model'), |
| uLightDir:gl.getUniformLocation(progSolid,'u_lightDir'), |
| uLightCol:gl.getUniformLocation(progSolid,'u_lightCol'), |
| uAmbient:gl.getUniformLocation(progSolid,'u_ambient'), |
| uEyePos:gl.getUniformLocation(progSolid,'u_eyePos'), |
| uFogNear:gl.getUniformLocation(progSolid,'u_fogNear'), |
| uFogFar:gl.getUniformLocation(progSolid,'u_fogFar'), |
| uFogCol:gl.getUniformLocation(progSolid,'u_fogCol'), |
| uTint:gl.getUniformLocation(progSolid,'u_tint'), |
| uAlpha:gl.getUniformLocation(progSolid,'u_alpha'), |
| }; |
| const L={ |
| aPos:gl.getAttribLocation(progLine,'a_pos'), |
| aCol:gl.getAttribLocation(progLine,'a_col'), |
| uMvp:gl.getUniformLocation(progLine,'u_mvp'), |
| }; |
| |
| |
| function uploadMesh(mesh){ |
| const vbo=gl.createBuffer(),ibo=gl.createBuffer(); |
| gl.bindBuffer(gl.ARRAY_BUFFER,vbo);gl.bufferData(gl.ARRAY_BUFFER,mesh.verts,gl.STATIC_DRAW); |
| gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ibo);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,mesh.idx,gl.STATIC_DRAW); |
| return{vbo,ibo,count:mesh.idx.length}; |
| } |
| function drawMesh(buf,mvp,model,tint,alpha){ |
| gl.useProgram(progSolid); |
| gl.bindBuffer(gl.ARRAY_BUFFER,buf.vbo); |
| gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,buf.ibo); |
| gl.vertexAttribPointer(S.aPos,3,gl.FLOAT,false,36,0);gl.enableVertexAttribArray(S.aPos); |
| gl.vertexAttribPointer(S.aNorm,3,gl.FLOAT,false,36,12);gl.enableVertexAttribArray(S.aNorm); |
| gl.vertexAttribPointer(S.aCol,3,gl.FLOAT,false,36,24);gl.enableVertexAttribArray(S.aCol); |
| gl.uniformMatrix4fv(S.uMvp,false,mvp); |
| gl.uniformMatrix4fv(S.uModel,false,model); |
| gl.uniform3fv(S.uTint,tint||[1,1,1]); |
| gl.uniform1f(S.uAlpha,alpha!=null?alpha:1); |
| gl.drawElements(gl.TRIANGLES,buf.count,gl.UNSIGNED_SHORT,0); |
| } |
| |
| |
| |
| |
| function buildPlantMeshes(){ |
| const types={}; |
| |
| |
| types.tomato={ |
| parts:[ |
| {buf:uploadMesh(mkCyl(0.06,0.6,8,0.55,0.35,0.2)),ty:0.3,tx:0,tz:0}, |
| {buf:uploadMesh(mkSph(0.44,10,0.2,0.65,0.15)),ty:0.78,tx:0,tz:0}, |
| {buf:uploadMesh(mkSph(0.28,8,0.15,0.7,0.12)),ty:1.0,tx:0.1,tz:0.05}, |
| ], |
| fruitRipe:uploadMesh(mkSph(0.08,6,0.85,0.15,0.1)), |
| fruitUnripe:uploadMesh(mkSph(0.08,6,0.2,0.6,0.1)), |
| fruitPos:[[0.32,0.7,-0.18],[-0.28,0.65,0.12],[0.1,0.62,0.36],[-0.12,0.8,0.28],[0.38,0.73,0.14]], |
| swayFreq:0.8,maxFruits:5,fruitWeek:6, |
| }; |
| |
| |
| const wStalks=[]; |
| const wOff=[[-0.15,0,-0.15],[0.15,0,-0.15],[-0.15,0,0.15],[0.15,0,0.15]]; |
| for(const [ox,oy,oz] of wOff){ |
| wStalks.push({buf:uploadMesh(mkCyl(0.025,0.9,5,0.7,0.65,0.2)),ty:0.45+oy,tx:ox,tz:oz}); |
| wStalks.push({buf:uploadMesh(mkCone(0.04,0.18,5,0.8,0.7,0.15)),ty:0.99+oy,tx:ox,tz:oz}); |
| } |
| types.wheat={parts:wStalks,swayFreq:1.4,maxFruits:0,fruitWeek:999}; |
| |
| |
| types.pepper={ |
| parts:[ |
| {buf:uploadMesh(mkCyl(0.04,0.5,7,0.4,0.25,0.1)),ty:0.25,tx:0,tz:0}, |
| {buf:uploadMesh(mkSph(0.35,9,0.15,0.55,0.1)),ty:0.62,tx:0,tz:0}, |
| ], |
| fruitRipe:uploadMesh(mkCyl(0.035,0.12,5,0.85,0.1,0.1)), |
| fruitUnripe:uploadMesh(mkCyl(0.035,0.12,5,0.2,0.65,0.1)), |
| fruitPos:[[0.2,0.55,-0.1],[-0.22,0.52,0.14],[0.1,0.6,0.22],[-0.1,0.58,-0.2],[0.26,0.58,0.18]], |
| fruitRot:true, |
| swayFreq:0.8,maxFruits:5,fruitWeek:5, |
| }; |
| |
| |
| types.cucumber={ |
| parts:[ |
| {buf:uploadMesh(mkCyl(0.035,0.8,7,0.35,0.25,0.12)),ty:0.4,tx:0,tz:0}, |
| {buf:uploadMesh(mkSph(0.22,7,0.15,0.55,0.1)),ty:0.3,tx:0.16,tz:0.05,sy:0.25}, |
| {buf:uploadMesh(mkSph(0.24,7,0.15,0.55,0.1)),ty:0.55,tx:-0.14,tz:0.1,sy:0.25}, |
| {buf:uploadMesh(mkSph(0.2,7,0.15,0.55,0.1)),ty:0.77,tx:0.12,tz:-0.1,sy:0.25}, |
| ], |
| fruitRipe:uploadMesh(mkCyl(0.04,0.24,5,0.2,0.65,0.15)), |
| fruitPos:[[0.25,0.42,0.06],[-0.24,0.62,-0.05],[0.2,0.63,0.2]], |
| fruitRot:'x', |
| swayFreq:1.0,maxFruits:3,fruitWeek:5, |
| }; |
| |
| return types; |
| } |
| let PLANTS_MESH=buildPlantMeshes(); |
| |
| |
| |
| const GROUND_LAYER=uploadMesh(mkBox(10,0.08,10,0.23,0.12,0.06)); |
| const GROUND_SUB=uploadMesh(mkBox(10,0.3,10,0.18,0.09,0.04)); |
| const GROUND_BED=uploadMesh(mkBox(10,0.25,10,0.06,0.03,0.08)); |
| const SUN_MESH=uploadMesh(mkSph(0.6,8,1,0.98,0.88,0.4)); |
| const MOON_MESH=uploadMesh(mkSph(0.35,6,0.9,0.9,0.88)); |
| const CLOUD_MESH=uploadMesh(combineMeshes([ |
| {mesh:mkSph(1,7,0.9,0.9,0.92),tx:0,ty:0,tz:0}, |
| {mesh:mkSph(0.7,6,0.9,0.9,0.92),tx:0.9,ty:-0.1,tz:0}, |
| {mesh:mkSph(0.8,6,0.9,0.9,0.92),tx:-0.8,ty:-0.15,tz:0}, |
| ])); |
| const STAR_MESH=uploadMesh(mkSph(0.04,4,0.95,0.92,0.88)); |
| const PUDDLE_MESH=uploadMesh(mkBox(1.2,0.01,0.9,0.1,0.2,0.45)); |
| |
| |
| function makeDynBuf(size){ |
| const vbo=gl.createBuffer(); |
| gl.bindBuffer(gl.ARRAY_BUFFER,vbo); |
| gl.bufferData(gl.ARRAY_BUFFER,size*4,gl.DYNAMIC_DRAW); |
| return{vbo,size,data:new Float32Array(size)}; |
| } |
| const DYN_LINE=makeDynBuf(24000); |
| const DYN_PART=makeDynBuf(6000); |
| let dynLineCount=0,dynPartCount=0; |
| |
| function updateDynBuf(db,data,count){ |
| gl.bindBuffer(gl.ARRAY_BUFFER,db.vbo); |
| gl.bufferSubData(gl.ARRAY_BUFFER,0,data.subarray(0,count*7)); |
| } |
| function drawLines(buf,count,mvp){ |
| if(count===0)return; |
| gl.useProgram(progLine); |
| gl.bindBuffer(gl.ARRAY_BUFFER,buf.vbo); |
| gl.vertexAttribPointer(L.aPos,3,gl.FLOAT,false,28,0);gl.enableVertexAttribArray(L.aPos); |
| gl.vertexAttribPointer(L.aCol,4,gl.FLOAT,false,28,12);gl.enableVertexAttribArray(L.aCol); |
| gl.uniformMatrix4fv(L.uMvp,false,mvp); |
| gl.drawArrays(gl.LINES,0,count); |
| } |
| function drawPoints(buf,count,mvp){ |
| if(count===0)return; |
| gl.useProgram(progLine); |
| gl.bindBuffer(gl.ARRAY_BUFFER,buf.vbo); |
| gl.vertexAttribPointer(L.aPos,3,gl.FLOAT,false,28,0);gl.enableVertexAttribArray(L.aPos); |
| gl.vertexAttribPointer(L.aCol,4,gl.FLOAT,false,28,12);gl.enableVertexAttribArray(L.aCol); |
| gl.uniformMatrix4fv(L.uMvp,false,mvp); |
| gl.drawArrays(gl.POINTS,0,count); |
| } |
| |
| |
| function pushLine(d,off,x0,y0,z0,x1,y1,z1,r,g,b,a){ |
| d[off+0]=x0;d[off+1]=y0;d[off+2]=z0;d[off+3]=r;d[off+4]=g;d[off+5]=b;d[off+6]=a; |
| d[off+7]=x1;d[off+8]=y1;d[off+9]=z1;d[off+10]=r;d[off+11]=g;d[off+12]=b;d[off+13]=a; |
| return off+14; |
| } |
| function pushVert(d,off,x,y,z,r,g,b,a){ |
| d[off+0]=x;d[off+1]=y;d[off+2]=z;d[off+3]=r;d[off+4]=g;d[off+5]=b;d[off+6]=a; |
| return off+7; |
| } |
| |
| |
| const cam={ |
| theta:0.5,phi:0.55,dist:14, |
| tx:0,ty:0.5,tz:0, |
| tTheta:0.5,tPhi:0.55,tDist:14, |
| tTx:0,tTy:0.5,tTz:0, |
| mouse:{down:false,rdown:false,lx:0,ly:0}, |
| }; |
| function camEye(){ |
| const cp=Math.cos(cam.phi),sp=Math.sin(cam.phi); |
| const ct=Math.cos(cam.theta),st=Math.sin(cam.theta); |
| return[cam.tx+cam.dist*cp*st, cam.ty+cam.dist*sp, cam.tz+cam.dist*cp*ct]; |
| } |
| function lerpCam(){ |
| const L=0.08; |
| cam.theta+=(cam.tTheta-cam.theta)*L; cam.phi+=(cam.tPhi-cam.phi)*L; |
| cam.dist+=(cam.tDist-cam.dist)*L; |
| cam.tx+=(cam.tTx-cam.tx)*L; cam.ty+=(cam.tTy-cam.ty)*L; cam.tz+=(cam.tTz-cam.tz)*L; |
| cam.tPhi=Math.max(0.05,Math.min(Math.PI/2-0.05,cam.tPhi)); |
| cam.tDist=Math.max(3,Math.min(40,cam.tDist)); |
| } |
| canvas.addEventListener('mousedown',e=>{ |
| if(e.button===0)cam.mouse.down=true; |
| else if(e.button===2)cam.mouse.rdown=true; |
| cam.mouse.lx=e.clientX;cam.mouse.ly=e.clientY; |
| }); |
| canvas.addEventListener('contextmenu',e=>e.preventDefault()); |
| window.addEventListener('mouseup',()=>{cam.mouse.down=false;cam.mouse.rdown=false;}); |
| window.addEventListener('mousemove',e=>{ |
| const dx=e.clientX-cam.mouse.lx,dy=e.clientY-cam.mouse.ly; |
| cam.mouse.lx=e.clientX;cam.mouse.ly=e.clientY; |
| if(cam.mouse.down){cam.tTheta-=dx*0.006;cam.tPhi-=dy*0.006;} |
| else if(cam.mouse.rdown){ |
| const cp=Math.cos(cam.theta),sp_=Math.sin(cam.theta); |
| const f=cam.dist*0.003; |
| cam.tTx+=(-cp*dx+0)*f; cam.tTy+=dy*f*0.5; cam.tTz+=(sp_*dx)*f; |
| } |
| }); |
| canvas.addEventListener('wheel',e=>{cam.tDist+=e.deltaY*0.02;e.preventDefault();},{passive:false}); |
| |
| |
| let lastTouch=null,lastTouchDist=0; |
| canvas.addEventListener('touchstart',e=>{ |
| e.preventDefault(); |
| if(e.touches.length===1){cam.mouse.down=true;cam.mouse.lx=e.touches[0].clientX;cam.mouse.ly=e.touches[0].clientY;} |
| if(e.touches.length===2){lastTouchDist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);} |
| },{passive:false}); |
| canvas.addEventListener('touchend',()=>{cam.mouse.down=false;}); |
| canvas.addEventListener('touchmove',e=>{ |
| e.preventDefault(); |
| if(e.touches.length===1&&cam.mouse.down){ |
| const dx=e.touches[0].clientX-cam.mouse.lx,dy=e.touches[0].clientY-cam.mouse.ly; |
| cam.mouse.lx=e.touches[0].clientX;cam.mouse.ly=e.touches[0].clientY; |
| cam.tTheta-=dx*0.006;cam.tPhi-=dy*0.006; |
| } |
| if(e.touches.length===2){ |
| const d=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY); |
| cam.tDist-=(d-lastTouchDist)*0.05;lastTouchDist=d; |
| } |
| },{passive:false}); |
| |
| |
| const SS={ |
| simDay:1,simHour:6,simSeason:'Spring',simSpeed:1, |
| latitude:24.7136,longitude:46.6753,locationName:'Riyadh, SA',climateZone:'Arid', |
| temperature:34,humidity:18,windSpeed:3.2,windDirection:45, |
| windDirectionCardinal:'NE',precipitation:0,isRaining:false,rainIntensity:0, |
| cloudCover:0.1,weatherCondition:'CLEAR',lastWeatherUpdate:null, |
| sunElevation:45,sunAzimuth:180,isDaytime:true,photosynthesisRate:0.8, |
| soilMoisture:60,soilPH:6.8,evaporationRate:0.12, |
| plants:[],avgHealth:0,avgWater:0,yieldEst:0, |
| appliedProducts:[],plantType:'tomato', |
| waterModeActive:false,sprayModeActive:false, |
| }; |
| window.simState=SS; |
| |
| |
| for(let i=0;i<9;i++){ |
| SS.plants.push({ |
| id:i,health:80+Math.random()*20,water:65+Math.random()*20, |
| growth:0.15+Math.random()*0.3,status:'healthy',diseased:false,treated:false, |
| treatStartDay:0,treatProgress:0, |
| fruits:0,age:Math.floor(Math.random()*14)+1, |
| x:(i%3-1)*3,y:0,z:(Math.floor(i/3)-1)*3, |
| sprayParticles:[],waterParticles:[],photoGlow:0, |
| }); |
| } |
| |
| |
| const STARS=[]; |
| for(let i=0;i<200;i++){ |
| const th=Math.random()*Math.PI*2,ph=Math.random()*Math.PI*0.5+0.1; |
| const d=28; |
| STARS.push([Math.cos(ph)*Math.sin(th)*d, Math.sin(ph)*d, Math.cos(ph)*Math.cos(th)*d]); |
| } |
| |
| |
| const CLOUDS=[]; |
| for(let i=0;i<10;i++){ |
| CLOUDS.push({ |
| x:(Math.random()-0.5)*20,y:9+Math.random()*3,z:(Math.random()-0.5)*20, |
| scale:0.5+Math.random()*0.7,speed:0.3+Math.random()*0.4, |
| }); |
| } |
| |
| |
| const RAIN=[]; |
| for(let i=0;i<200;i++){ |
| RAIN.push({x:(Math.random()-0.5)*18,y:Math.random()*10,z:(Math.random()-0.5)*18,vy:0}); |
| } |
| |
| const WIND_STREAKS=[]; |
| for(let i=0;i<50;i++){ |
| WIND_STREAKS.push({x:(Math.random()-0.5)*18,y:0.5+Math.random()*4,z:(Math.random()-0.5)*18,t:Math.random()*10,alpha:0}); |
| } |
| |
| const EVAP=[]; |
| for(let p of SS.plants){ |
| for(let j=0;j<4;j++)EVAP.push({px:p.x,pz:p.z,x:p.x+(Math.random()-.5)*2,y:0,z:p.z+(Math.random()-.5)*2,vy:0.3+Math.random()*0.3,life:Math.random()}); |
| } |
| |
| |
| let lastPhysicsHour=-1; |
| function updatePhysics(dt,simTimeHours){ |
| const T=SS.temperature,M=SS.soilMoisture,W=SS.windSpeed; |
| |
| |
| const evaRate=0.01*T*(1-SS.humidity/100); |
| SS.evaporationRate=evaRate; |
| SS.soilMoisture=Math.max(0,SS.soilMoisture-evaRate*dt*0.5); |
| |
| |
| if(SS.isRaining){ |
| SS.soilMoisture=Math.min(100,SS.soilMoisture+SS.rainIntensity*0.8*dt); |
| } |
| |
| |
| const sunEl=SS.sunElevation*Math.PI/180; |
| SS.photosynthesisRate=Math.max(0,Math.sin(sunEl)); |
| SS.isDaytime=SS.sunElevation>-5; |
| |
| |
| for(const p of SS.plants){ |
| |
| p.water=Math.max(0,p.water-(0.5+T*0.02+W*0.01)*dt/3600*SS.simSpeed); |
| if(SS.isRaining)p.water=Math.min(100,p.water+SS.rainIntensity*1.2*dt/3600*SS.simSpeed); |
| |
| |
| let dh=0; |
| if(M>30&&M<80)dh+=0.5; if(T>18&&T<30)dh+=0.3; if(SS.photosynthesisRate>0.5)dh+=0.2; |
| if(M<20)dh-=1.0; if(M<10)dh-=3.0; |
| if(T>42)dh-=3.0; if(T<5)dh-=2.0; |
| if(T>38&&T<=42)dh-=1.0; else if(T>30&&T<=38)dh-=0.05; |
| |
| if(p.diseased){ |
| if(!p.treated){dh-=0.8;} |
| else{ |
| const daysTreating=SS.simDay-p.treatStartDay+(SS.simHour/24); |
| const suppress=Math.min(1,daysTreating/2); |
| dh-=0.8*(1-suppress); |
| } |
| } |
| |
| if(p.treated){ |
| const daysTreating=SS.simDay-p.treatStartDay+(SS.simHour/24); |
| dh+=0.4*Math.min(1,daysTreating); |
| } |
| if(W>18)dh-=0.5; |
| if(p.water<25||M<20)dh-=1.0; |
| p.health=Math.max(0,Math.min(100,p.health+dh*dt/3600*SS.simSpeed)); |
| |
| |
| if(SS.sunElevation>0&&p.health>40){ |
| let gr=0.002*(p.health/100)*SS.photosynthesisRate; |
| if(T>18&&T<30)gr*=1.2; |
| p.growth=Math.min(1,p.growth+gr*dt/3600*SS.simSpeed); |
| } |
| |
| |
| const MESH=PLANTS_MESH[SS.plantType]; |
| const maxF=MESH?MESH.maxFruits:0; |
| if(maxF>0&&p.growth>0.5&&p.health>60){ |
| const fc=0.001*dt/3600*SS.simSpeed; |
| if(Math.random()<fc)p.fruits=Math.min(maxF,p.fruits+1); |
| } |
| |
| |
| p.age+=dt/3600/24*SS.simSpeed; |
| |
| |
| if(p.treated){ |
| const daysTreating=SS.simDay-p.treatStartDay+(SS.simHour/24); |
| p.treatProgress=Math.min(1,daysTreating/4); |
| if(daysTreating>=4){p.treated=false;p.diseased=false;p.treatProgress=0;} |
| } |
| |
| if(p.treated)p.status='treated'; |
| else if(p.diseased)p.status='diseased'; |
| else if(p.water<25||M<20)p.status='stressed'; |
| else if(p.health>70)p.status='healthy'; |
| else p.status='poor'; |
| |
| |
| p.photoGlow=SS.photosynthesisRate*(0.5+0.5*Math.sin(Date.now()*0.002+p.id)); |
| } |
| |
| |
| SS.avgHealth=SS.plants.reduce((a,p)=>a+p.health,0)/9; |
| SS.avgWater=SS.plants.reduce((a,p)=>a+p.water,0)/9; |
| |
| |
| SS.simHour+=SS.simSpeed*dt/3600; |
| if(SS.simHour>=24){SS.simHour-=24;SS.simDay++;} |
| if(SS.simDay>90){SS.simDay=1;SS.simSeason=(['Spring','Summer','Autumn','Winter'][((['Spring','Summer','Autumn','Winter'].indexOf(SS.simSeason)+1)%4)]);} |
| } |
| |
| |
| function calcSun(lat,lng,dayOfYear,hour){ |
| const latR=lat*Math.PI/180; |
| const dec=23.45*Math.sin((284+dayOfYear)/365*2*Math.PI)*Math.PI/180; |
| const ha=(hour-12)*15*Math.PI/180; |
| const sinEl=Math.sin(latR)*Math.sin(dec)+Math.cos(latR)*Math.cos(dec)*Math.cos(ha); |
| const el=Math.asin(Math.max(-1,Math.min(1,sinEl))); |
| const cosAz=(Math.sin(dec)*Math.cos(latR)-Math.cos(dec)*Math.cos(ha)*Math.sin(latR))/Math.cos(el+0.001); |
| let az=Math.acos(Math.max(-1,Math.min(1,cosAz))); |
| if(Math.sin(ha)>0)az=Math.PI*2-az; |
| return{el:el*180/Math.PI,az:az*180/Math.PI,elR:el,azR:az}; |
| } |
| function sunWorldPos(el,az){ |
| const dist=22,elR=el*Math.PI/180,azR=az*Math.PI/180; |
| return[dist*Math.cos(elR)*Math.sin(azR),dist*Math.sin(elR),dist*Math.cos(elR)*Math.cos(azR)]; |
| } |
| |
| |
| function fetchWeather(){ |
| const{latitude:lat,longitude:lng}=SS; |
| const url=`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(4)}&longitude=${lng.toFixed(4)}¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,wind_direction_10m,precipitation,weather_code&timezone=auto`; |
| fetch(url).then(r=>r.json()).then(data=>{ |
| const c=data.current; |
| SS.temperature=c.temperature_2m; |
| SS.humidity=c.relative_humidity_2m; |
| SS.windSpeed=c.wind_speed_10m/3.6; |
| SS.windDirection=c.wind_direction_10m; |
| SS.precipitation=c.precipitation; |
| const wc=c.weather_code; |
| if(wc<=1){SS.cloudCover=0.05;SS.isRaining=false;SS.weatherCondition='CLEAR';} |
| else if(wc<=3){SS.cloudCover=0.4;SS.isRaining=false;SS.weatherCondition='CLOUDY';} |
| else if(wc<=48){SS.cloudCover=0.8;SS.isRaining=false;SS.weatherCondition='FOG';} |
| else if(wc<=67){SS.isRaining=true;SS.rainIntensity=wc<60?0.2:0.6;SS.cloudCover=0.7;SS.weatherCondition='RAIN';} |
| else if(wc<=82){SS.isRaining=true;SS.rainIntensity=1.0;SS.cloudCover=0.9;SS.weatherCondition='RAIN';} |
| else if(wc>=95){SS.isRaining=true;SS.rainIntensity=1.0;SS.windSpeed+=8;SS.cloudCover=0.95;SS.weatherCondition='STORM';} |
| else{SS.isRaining=false;SS.weatherCondition='CLEAR';} |
| if(c.precipitation>0&&!SS.isRaining){SS.isRaining=true;SS.rainIntensity=Math.min(1,c.precipitation/10);} |
| SS.lastWeatherUpdate=new Date().toLocaleTimeString(); |
| updateWindCardinal(); |
| }).catch(()=>{SS.lastWeatherUpdate='API unavailable';}); |
| } |
| function updateWindCardinal(){ |
| const dirs=['N','NE','E','SE','S','SW','W','NW']; |
| SS.windDirectionCardinal=dirs[Math.round(SS.windDirection/45)%8]; |
| } |
| function deriveClimate(){ |
| const la=Math.abs(SS.latitude); |
| if(la<15){SS.climateZone='Tropical';SS.humidity=75;SS.windDirection=120;} |
| else if(la<30){SS.climateZone='Arid';SS.humidity=18;SS.windDirection=45;} |
| else if(la<45){SS.climateZone='Mediterranean';SS.humidity=55;SS.windDirection=270;} |
| else if(la<65){SS.climateZone='Temperate';SS.humidity=65;SS.windDirection=225;} |
| else{SS.climateZone='Subarctic';SS.humidity=45;SS.windDirection=270;} |
| updateWindCardinal(); |
| } |
| |
| |
| function initLocation(){ |
| if(navigator.geolocation){ |
| navigator.geolocation.getCurrentPosition(pos=>{ |
| SS.latitude=pos.coords.latitude;SS.longitude=pos.coords.longitude; |
| SS.locationName='Your Location'; |
| deriveClimate();fetchWeather();updateGPSDisplay(); |
| },()=>{deriveClimate();fetchWeather();updateGPSDisplay();}); |
| }else{deriveClimate();fetchWeather();updateGPSDisplay();} |
| } |
| function updateGPSDisplay(){ |
| document.getElementById('hud-gps').innerHTML= |
| `GPS: ${SS.latitude.toFixed(4)}Β°N ${SS.longitude.toFixed(4)}Β°E<br>Zone: ${SS.climateZone}`; |
| document.getElementById('status-right').textContent= |
| `${SS.latitude.toFixed(2)}Β°N ${SS.longitude.toFixed(2)}Β°E Β· ${SS.climateZone} Β· v1.0`; |
| } |
| setInterval(fetchWeather,600000); |
| |
| |
| function skyColor(sunElev){ |
| const e=sunElev; |
| if(e<-10)return[0.01,0.04,0.01]; |
| if(e<0){const t=(e+10)/10;return lerp3([0.01,0.04,0.01],[0.06,0.1,0.04],t);} |
| if(e<15){const t=e/15;return lerp3([0.06,0.1,0.04],[0.04,0.12,0.04],t);} |
| return[0.04,0.12,0.04]; |
| } |
| function lerp3(a,b,t){return[a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];} |
| |
| |
| function updateRain(dt){ |
| const wdR=SS.windDirection*Math.PI/180; |
| const wdx=Math.sin(wdR)*SS.windSpeed*0.3,wdz=Math.cos(wdR)*SS.windSpeed*0.3; |
| const spd=4+SS.windSpeed*0.3; |
| for(const r of RAIN){ |
| r.y-=spd*dt;r.x+=wdx*dt;r.z+=wdz*dt; |
| if(r.y<-0.2){r.y=8+Math.random()*2;r.x=(Math.random()-.5)*18+wdx*2;r.z=(Math.random()-.5)*18+wdz*2;} |
| } |
| } |
| function updateWindStreaks(dt){ |
| const wdR=SS.windDirection*Math.PI/180; |
| const speed=SS.windSpeed*0.5; |
| const dx=Math.sin(wdR)*speed,dz=Math.cos(wdR)*speed; |
| for(const s of WIND_STREAKS){ |
| s.t+=dt; |
| s.x+=dx*dt;s.z+=dz*dt; |
| if(s.x>10||s.x<-10||s.z>10||s.z<-10){s.x=(Math.random()-.5)*20;s.z=(Math.random()-.5)*20;s.t=Math.random()*10;} |
| s.alpha=SS.windSpeed>3?Math.min(0.12,SS.windSpeed*0.005):0; |
| } |
| } |
| function updateEvap(dt){ |
| const hot=SS.temperature>38&&SS.soilMoisture<50; |
| for(const e of EVAP){ |
| e.y+=e.vy*dt*(hot?1:0.3);e.life-=dt*0.2; |
| if(e.life<=0){e.y=0;e.life=0.5+Math.random()*0.5;e.x=e.px+(Math.random()-.5)*2;e.z=e.pz+(Math.random()-.5)*2;} |
| } |
| } |
| |
| |
| function spawnSprayParticles(plant){ |
| plant.sprayParticles=[]; |
| for(let i=0;i<12;i++){ |
| const a=Math.random()*Math.PI*2,el=Math.random()*Math.PI/2; |
| plant.sprayParticles.push({ |
| x:plant.x,y:plant.growth*0.8,z:plant.z, |
| vx:Math.cos(a)*Math.cos(el)*2,vy:Math.sin(el)*2+1,vz:Math.sin(a)*Math.cos(el)*2, |
| life:1.0, |
| }); |
| } |
| } |
| function spawnWaterParticles(plant){ |
| plant.waterParticles=[]; |
| for(let i=0;i<8;i++){ |
| const a=Math.random()*Math.PI*2,r=Math.random()*0.5; |
| plant.waterParticles.push({ |
| x:plant.x+Math.cos(a)*r,y:3+Math.random(),z:plant.z+Math.sin(a)*r, |
| vx:(Math.random()-.5)*0.5,vy:-2-Math.random()*2,vz:(Math.random()-.5)*0.5, |
| life:1.0,bounced:false, |
| }); |
| } |
| } |
| function updatePlantParticles(dt){ |
| for(const p of SS.plants){ |
| for(const sp of p.sprayParticles){sp.x+=sp.vx*dt;sp.y+=sp.vy*dt;sp.z+=sp.vz*dt;sp.vy-=4*dt;sp.life-=dt;} |
| p.sprayParticles=p.sprayParticles.filter(s=>s.life>0); |
| for(const wp of p.waterParticles){ |
| wp.x+=wp.vx*dt;wp.y+=wp.vy*dt;wp.z+=wp.vz*dt; |
| if(!wp.bounced&&wp.y<0.05){wp.vy*=-0.2;wp.vx*=0.3;wp.vz*=0.3;wp.bounced=true;} |
| wp.life-=dt; |
| } |
| p.waterParticles=p.waterParticles.filter(w=>w.life>0); |
| } |
| } |
| |
| |
| let frameCount=0,fpsTime=0,fpsVal=0; |
| function updateFPS(dt){frameCount++;fpsTime+=dt;if(fpsTime>=0.5){fpsVal=Math.round(frameCount/fpsTime);frameCount=0;fpsTime=0;}} |
| |
| const statusColors={healthy:'#4e9',stressed:'#da6',diseased:'#f55',treated:'#5ae',poor:'#a66'}; |
| function updateHUD(){ |
| const S2=SS; |
| |
| const h=Math.floor(S2.simHour),m=Math.floor((S2.simHour%1)*60); |
| const wk=Math.ceil(S2.simDay/7); |
| document.getElementById('hud-simtime').textContent= |
| `Day ${S2.simDay} Β· Week ${wk} Β· ${S2.simSeason} Β· ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; |
| |
| |
| document.getElementById('hud-weather-badge').textContent=`π ${S2.locationName} Β· ${S2.weatherCondition}`; |
| |
| |
| document.getElementById('s-fps').textContent=fpsVal; |
| const tempEl=document.getElementById('s-temp'); |
| tempEl.textContent=`${S2.temperature.toFixed(1)}Β°C`; |
| const tStat=document.getElementById('s-temp-stat'); |
| tStat.className='stat'+(S2.temperature>42?' crit':S2.temperature>35?' warn':''); |
| document.getElementById('s-wind').textContent=`${S2.windSpeed.toFixed(1)}m/s @${Math.round(S2.windDirection)}Β° ${S2.windDirectionCardinal}`; |
| const rainTxt=['OFF','LIGHT','MOD','HEAVY']; |
| document.getElementById('s-rain').textContent=S2.isRaining?rainTxt[Math.ceil(S2.rainIntensity*2+0.1)]:'OFF'; |
| document.getElementById('s-hum').textContent=`${Math.round(S2.humidity)}%`; |
| document.getElementById('s-soil').textContent=`${Math.round(S2.soilMoisture)}%`; |
| |
| |
| const conds={CLEAR:'β CLEAR',CLOUDY:'β
CLOUDY',RAIN:'π§ RAIN',STORM:'β STORM',FOG:'π« FOG'}; |
| document.getElementById('leg-cond').textContent=conds[S2.weatherCondition]||S2.weatherCondition; |
| document.getElementById('hud-updated').textContent=`Updated: ${S2.lastWeatherUpdate||'--'}`; |
| |
| |
| document.getElementById('storm-warning').style.display=S2.windSpeed>18?'block':'none'; |
| |
| |
| const pl=document.getElementById('plant-list'); |
| pl.innerHTML=SS.plants.map(p=>` |
| <div class="plant-row"> |
| <div class="p-dot" style="background:${statusColors[p.status]||'#666'}"></div> |
| <span class="p-name">P${p.id+1}</span> |
| <span class="p-bars">H:${Math.round(p.health)}% W:${Math.round(p.water)}%</span> |
| <span class="p-badge badge-${p.status==='healthy'?'ok':p.status==='stressed'?'stress':p.status==='diseased'?'disease':p.status==='treated'?'treated':'stress'}">${p.status==='treated'?`D${Math.floor(p.treatProgress*4)+1}/4`:p.status.toUpperCase().slice(0,4)}</span> |
| </div>`).join(''); |
| |
| |
| const sunPhase=S2.sunElevation>15?'DAY':S2.sunElevation>0?'SUNRISE':S2.sunElevation>-5?'GOLDEN':'NIGHT'; |
| document.getElementById('e-sun').textContent=`${S2.sunElevation.toFixed(1)}Β° ${sunPhase}`; |
| document.getElementById('e-cloud').textContent=`${Math.round(S2.cloudCover*100)}%`; |
| document.getElementById('e-photo').textContent=`${Math.round(S2.photosynthesisRate*100)}%`; |
| document.getElementById('e-evap').textContent=`${S2.evaporationRate.toFixed(2)}/hr`; |
| |
| |
| document.getElementById('f-moist').textContent=`${Math.round(S2.soilMoisture)}%`; |
| document.getElementById('f-temp').textContent=`${S2.temperature.toFixed(1)}Β°C`; |
| const npk=`${Math.round(40+S2.avgHealth*.4)}-${Math.round(20+S2.avgHealth*.2)}-${Math.round(30+S2.avgHealth*.3)}`; |
| document.getElementById('f-npk').textContent=npk; |
| |
| |
| const prodEl=document.getElementById('product-list'); |
| if(SS.appliedProducts.length>0){ |
| prodEl.innerHTML=SS.appliedProducts.map(p=>`<div class="product-row"><span>${p.name}</span><span>Day ${p.day}</span></div>`).join(''); |
| } |
| |
| |
| const mi=document.getElementById('mode-indicator'); |
| if(SS.waterModeActive){mi.style.display='block';mi.textContent='π§ WATER MODE β click a plant to irrigate';} |
| else if(SS.sprayModeActive){mi.style.display='block';mi.textContent='πΏ SPRAY MODE β click a plant to treat';} |
| else mi.style.display='none'; |
| |
| |
| ['tomato','wheat','pepper','cucumber'].forEach(t=>{ |
| document.getElementById('btn-'+t).className='btn btn-plant'+(SS.plantType===t?' active':''); |
| }); |
| document.getElementById('btn-water').className='btn btn-action'+(SS.waterModeActive?' water-active':''); |
| document.getElementById('btn-spray').className='btn btn-action'+(SS.sprayModeActive?' spray-active':''); |
| [1,6,24,168].forEach(s=>{ |
| const el=document.getElementById('spd-'+s); |
| if(el)el.className='btn btn-speed'+(SS.simSpeed===s?' active':''); |
| }); |
| } |
| |
| |
| let tooltipTarget=null,tooltipTimer=0; |
| function showTooltip(plant,screenX,screenY){ |
| const el=document.getElementById('plant-tooltip'); |
| document.getElementById('tt-title').textContent=`Plant ${plant.id+1} (${SS.plantType})`; |
| document.getElementById('tt-age').textContent=`${Math.floor(plant.age)} days`; |
| document.getElementById('tt-health').textContent=`${Math.round(plant.health)}%`; |
| document.getElementById('tt-water').textContent=`${Math.round(plant.water)}%`; |
| document.getElementById('tt-growth').textContent=`${Math.round(plant.growth*100)}%`; |
| document.getElementById('tt-fruits').textContent=plant.fruits; |
| document.getElementById('tt-status').textContent=plant.status; |
| const stressors=[]; |
| if(SS.soilMoisture<20)stressors.push('Low moisture'); |
| if(plant.water<25)stressors.push('Thirsty'); |
| if(SS.temperature>42)stressors.push('Heat stress'); |
| if(SS.temperature<5)stressors.push('Cold stress'); |
| if(plant.diseased)stressors.push('Diseased'); |
| document.getElementById('tt-stress').textContent=stressors.join(', ')||'None'; |
| const daysLeft=Math.max(0,Math.round((90-plant.age)*(2-plant.health/100))); |
| document.getElementById('tt-harvest').textContent=`~Day ${SS.simDay+daysLeft}`; |
| const mw=mount.clientWidth,mh=mount.clientHeight; |
| el.style.left=Math.min(screenX+10,mw-220)+'px'; |
| el.style.top=Math.min(screenY-10,mh-300)+'px'; |
| el.style.display='block'; |
| tooltipTimer=2; |
| } |
| function hideTooltip(){document.getElementById('plant-tooltip').style.display='none';} |
| |
| |
| canvas.addEventListener('click',e=>{ |
| const rect=canvas.getBoundingClientRect(); |
| const mx=(e.clientX-rect.left)/rect.width*2-1; |
| const my=-((e.clientY-rect.top)/rect.height*2-1); |
| const eye=camEye(); |
| const aspect=canvas.width/canvas.height; |
| const proj=m4persp(0.7854,aspect,0.1,80); |
| const view=m4look(eye[0],eye[1],eye[2],cam.tx,cam.ty,cam.tz); |
| const vp=m4mul(proj,view); |
| |
| let best=null,bestDist=0.2; |
| for(const p of SS.plants){ |
| |
| const wx=p.x,wy=p.y+p.growth*0.6,wz=p.z; |
| const clip=[ |
| vp[0]*wx+vp[4]*wy+vp[8]*wz+vp[12], |
| vp[1]*wx+vp[5]*wy+vp[9]*wz+vp[13], |
| vp[2]*wx+vp[6]*wy+vp[10]*wz+vp[14], |
| vp[3]*wx+vp[7]*wy+vp[11]*wz+vp[15], |
| ]; |
| if(clip[3]<0.1)continue; |
| const ndcX=clip[0]/clip[3],ndcY=clip[1]/clip[3]; |
| const d=Math.sqrt((ndcX-mx)**2+(ndcY-my)**2); |
| if(d<bestDist){bestDist=d;best=p;} |
| } |
| |
| if(best){ |
| if(SS.waterModeActive){ |
| best.water=Math.min(100,best.water+25); |
| best.health=Math.min(100,best.health+4); |
| SS.soilMoisture=Math.min(100,SS.soilMoisture+15); |
| spawnWaterParticles(best); |
| }else if(SS.sprayModeActive){ |
| if(!best.treated){ |
| best.treated=true; |
| best.treatStartDay=SS.simDay+(SS.simHour/24); |
| best.treatProgress=0; |
| spawnSprayParticles(best); |
| SS.appliedProducts.push({name:`Spray P${best.id+1}`,day:SS.simDay}); |
| } |
| }else{ |
| showTooltip(best,e.clientX,e.clientY); |
| } |
| }else{ |
| hideTooltip(); |
| } |
| }); |
| |
| |
| function setLighting(lightDir,lightCol,ambient,eyePos,fogCol,fogNear,fogFar){ |
| gl.useProgram(progSolid); |
| gl.uniform3fv(S.uLightDir,lightDir);gl.uniform3fv(S.uLightCol,lightCol); |
| gl.uniform1f(S.uAmbient,ambient);gl.uniform3fv(S.uEyePos,eyePos); |
| gl.uniform3fv(S.uFogCol,fogCol);gl.uniform1f(S.uFogNear,fogNear);gl.uniform1f(S.uFogFar,fogFar); |
| } |
| |
| function drawMeshAt(buf,vp,px,py,pz,sx,sy,sz,rx,ry,rz,tint,alpha){ |
| let model=m4tr(px,py,pz); |
| if(ry!==0)model=m4mul(model,m4ry(ry)); |
| if(rx!==0)model=m4mul(model,m4rx(rx)); |
| if(rz!==0)model=m4mul(model,m4rz(rz)); |
| if(sx!==1||sy!==1||sz!==1)model=m4mul(model,m4sc(sx,sy,sz)); |
| const mvp=m4mul(vp,model); |
| drawMesh(buf,mvp,model,tint,alpha); |
| } |
| |
| |
| const healthTints={ |
| healthy:[1,1,1],stressed:[1.1,0.75,0.3],diseased:[1.2,0.4,0.4], |
| treated:[0.5,0.85,1.2],poor:[0.8,0.8,0.5] |
| }; |
| function renderPlants(vp,t){ |
| const PM=PLANTS_MESH[SS.plantType]; |
| if(!PM)return; |
| for(const p of SS.plants){ |
| const tint=healthTints[p.status]||[1,1,1]; |
| const g=Math.max(0.05,p.growth); |
| const freq=PM.swayFreq||1; |
| const swayZ=Math.sin(t*freq+p.id*1.3)*SS.windSpeed*0.012; |
| const swayX=Math.cos(t*freq*0.7+p.id*0.9)*SS.windSpeed*0.008; |
| const baseModel=m4mul(m4tr(p.x,p.y,p.z),m4mul(m4sc(g,g,g),m4rz(swayZ))); |
| |
| for(const part of PM.parts){ |
| const partModel=m4mul(baseModel,m4tr(part.tx||0,part.ty||0,part.tz||0)); |
| const scalePart=m4mul(partModel,(part.sx||part.sy||part.sz)?m4sc(part.sx||1,part.sy||1,part.sz||1):m4id()); |
| drawMesh(part.buf,m4mul(vp,scalePart),scalePart,tint,1); |
| } |
| |
| |
| const maxF=PM.maxFruits||0; |
| const ripeWeek=PM.fruitWeek||999; |
| if(maxF>0&&PM.fruitPos&&p.fruits>0){ |
| const numFruits=Math.min(p.fruits,PM.fruitPos.length); |
| for(let fi=0;fi<numFruits;fi++){ |
| const fp=PM.fruitPos[fi]; |
| const ripe=p.age>ripeWeek*7; |
| const fBuf=ripe?(PM.fruitRipe||PM.fruitUnripe):PM.fruitUnripe; |
| if(!fBuf)continue; |
| let fm=m4mul(baseModel,m4tr(fp[0],fp[1],fp[2])); |
| if(PM.fruitRot==='x')fm=m4mul(fm,m4rx(Math.PI/2)); |
| else if(PM.fruitRot===true)fm=m4mul(fm,m4rx(-0.5)); |
| drawMesh(fBuf,m4mul(vp,fm),fm,tint,1); |
| } |
| } |
| } |
| } |
| |
| |
| function renderGround(vp){ |
| |
| const wet=[0.23,0.12,0.04],dry=[0.77,0.67,0.52]; |
| const t=SS.soilMoisture/100; |
| const tc=[wet[0]+(dry[0]-wet[0])*(1-t),wet[1]+(dry[1]-wet[1])*(1-t),wet[2]+(dry[2]-wet[2])*(1-t)]; |
| |
| |
| drawMeshAt(GROUND_LAYER,vp,0,-0.04,0,1,1,1,0,0,0,tc,1); |
| drawMeshAt(GROUND_SUB,vp,0,-0.23,0,1,1,1,0,0,0,[0.18,0.09,0.04],1); |
| drawMeshAt(GROUND_BED,vp,0,-0.505,0,1,1,1,0,0,0,[0.06,0.03,0.08],1); |
| |
| |
| if(SS.soilMoisture>82){ |
| const puddles=[[-2,0,-2],[1,0,2],[-1.5,0,1.5]]; |
| const pa=Math.min(0.6,(SS.soilMoisture-82)/18*0.6); |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE); |
| for(const [px,py,pz] of puddles){ |
| drawMeshAt(PUDDLE_MESH,vp,px,0.01,pz,1,1,1,0,0,0,[0.15,0.35,0.6],pa); |
| } |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); |
| } |
| } |
| |
| |
| function buildLineData(vp,t){ |
| const LD=DYN_LINE.data; |
| let off=0; |
| |
| |
| for(const p of SS.plants){ |
| if(p.growth<0.05)continue; |
| const h=p.growth;const col=statusColors[p.status]||'#4e9'; |
| const rc=p.status==='healthy'?[0.27,0.87,0.5]:p.status==='stressed'?[0.87,0.67,0.2]:p.status==='diseased'?[0.87,0.2,0.2]:[0.3,0.6,1]; |
| const alpha=0.3*SS.photosynthesisRate*(0.5+0.5*Math.sin(t*1.5+p.id)); |
| for(let seg=0;seg<12&&off+14<LD.length;seg++){ |
| const y0=(seg/12)*h,y1=((seg+1)/12)*h; |
| const a0=seg*0.5+t*0.3,a1=(seg+1)*0.5+t*0.3; |
| const r=0.15+seg*0.02; |
| off=pushLine(LD,off,p.x+Math.cos(a0)*r,y0,p.z+Math.sin(a0)*r,p.x+Math.cos(a1)*r,y1,p.z+Math.sin(a1)*r,rc[0],rc[1],rc[2],alpha); |
| } |
| } |
| |
| for(const p of SS.plants){ |
| const rootLen=Math.min(0.8,0.2+p.age*0.008+SS.soilMoisture*0.003); |
| const moist=SS.soilMoisture>40; |
| const rc=moist?[0.3,0.6,0.9]:[0.8,0.5,0.15]; |
| for(let ri=0;ri<4&&off+14<LD.length;ri++){ |
| const a=(ri/4)*Math.PI*2,dx=Math.cos(a)*0.4,dz=Math.sin(a)*0.4; |
| off=pushLine(LD,off,p.x,0,p.z,p.x+dx,-rootLen,p.z+dz,rc[0],rc[1],rc[2],0.4); |
| } |
| } |
| |
| for(const p of SS.plants){ |
| if(SS.soilMoisture<30)continue; |
| const alpha=SS.soilMoisture/400;const rot=t*0.3; |
| const rc=[0.23,0.55,0.85]; |
| for(let ri=0;ri<8&&off+14<LD.length;ri++){ |
| const a0=(ri/8)*Math.PI*2+rot,a1=((ri+1)/8)*Math.PI*2+rot; |
| off=pushLine(LD,off,p.x+Math.cos(a0)*0.8,0.01,p.z+Math.sin(a0)*0.8,p.x+Math.cos(a1)*0.8,0.01,p.z+Math.sin(a1)*0.8,rc[0],rc[1],rc[2],alpha); |
| } |
| } |
| |
| const diseased=SS.plants.filter(p=>p.diseased); |
| for(let i=0;i<diseased.length;i++)for(let j=i+1;j<diseased.length&&off+14<LD.length;j++){ |
| const a=diseased[i],b2=diseased[j]; |
| const d=Math.sqrt((a.x-b2.x)**2+(a.z-b2.z)**2); |
| if(d<4){ |
| const pulse=0.25*(0.5+0.5*Math.sin(t*3)); |
| off=pushLine(LD,off,a.x,0.1,a.z,b2.x,0.1,b2.z,0.87,0.15,0.15,pulse); |
| } |
| } |
| |
| const treated=SS.plants.filter(p=>p.treated); |
| for(let i=0;i<treated.length;i++)for(let j=i+1;j<treated.length&&off+14<LD.length;j++){ |
| const a=treated[i],b2=treated[j]; |
| const d=Math.sqrt((a.x-b2.x)**2+(a.z-b2.z)**2); |
| if(d<6){ |
| const pulse=0.28*(0.5+0.5*Math.sin(t*4+i)); |
| off=pushLine(LD,off,a.x,0.15,a.z,b2.x,0.15,b2.z,0.87,0.63,0.16,pulse); |
| } |
| } |
| |
| if(SS.windSpeed>1&&off+42<LD.length){ |
| const wdR=SS.windDirection*Math.PI/180; |
| const len=SS.windSpeed*0.15,wx=Math.sin(wdR)*len,wz=Math.cos(wdR)*len; |
| off=pushLine(LD,off,0,0.2,0,wx,0.2,wz,0.9,0.9,0.9,0.5); |
| const ah=0.15,aR1=wdR+2.7,aR2=wdR-2.7; |
| off=pushLine(LD,off,wx,0.2,wz,wx+Math.sin(aR1)*ah,0.2,wz+Math.cos(aR1)*ah,0.9,0.9,0.9,0.5); |
| off=pushLine(LD,off,wx,0.2,wz,wx+Math.sin(aR2)*ah,0.2,wz+Math.cos(aR2)*ah,0.9,0.9,0.9,0.5); |
| } |
| |
| |
| off=appendRainAndWind(LD,off); |
| const lineVerts=off/7; |
| updateDynBuf(DYN_LINE,LD,lineVerts); |
| drawLines(DYN_LINE,lineVerts,vp); |
| return lineVerts; |
| } |
| |
| |
| |
| |
| function appendRainAndWind(LD, startOff){ |
| let off=startOff; |
| if(SS.isRaining){ |
| const wdR=SS.windDirection*Math.PI/180; |
| const ldx=Math.sin(wdR)*0.3,ldz=Math.cos(wdR)*0.3; |
| for(const r of RAIN){ |
| if(off+14>=LD.length)break; |
| const a=SS.rainIntensity*0.6; |
| off=pushLine(LD,off,r.x,r.y,r.z,r.x+ldx,r.y+0.3,r.z+ldz,0.7,0.83,1,a); |
| } |
| } |
| for(const s of WIND_STREAKS){ |
| if(s.alpha<=0||off+14>=LD.length)continue; |
| const wdR=SS.windDirection*Math.PI/180; |
| const len=0.8,dx=Math.sin(wdR)*len,dz=Math.cos(wdR)*len; |
| off=pushLine(LD,off,s.x,s.y,s.z,s.x-dx,s.y,s.z-dz,1,1,1,s.alpha); |
| } |
| return off; |
| } |
| |
| function renderParticles(vp,t,lineVertCount){ |
| |
| const PD=DYN_PART.data; |
| let off=0; |
| |
| for(const p of SS.plants){ |
| for(const sp of p.sprayParticles){ |
| if(off+7>=PD.length)break; |
| off=pushVert(PD,off,sp.x,sp.y,sp.z,0.31,0.86,0.7,sp.life); |
| } |
| for(const wp of p.waterParticles){ |
| if(off+7>=PD.length)break; |
| off=pushVert(PD,off,wp.x,wp.y,wp.z,0.39,0.63,1,wp.life); |
| } |
| } |
| |
| const hot=SS.temperature>38&&SS.soilMoisture<50; |
| for(const e of EVAP){ |
| if(off+7>=PD.length||!hot)break; |
| off=pushVert(PD,off,e.x,e.y,e.z,0.78,0.7,0.55,e.life*0.3); |
| } |
| |
| for(const p of SS.plants){ |
| if(p.photoGlow<0.05||off+7>=PD.length)continue; |
| off=pushVert(PD,off,p.x,p.growth*0.7,p.z,0.3,0.9,0.4,p.photoGlow*0.2); |
| } |
| const ptCount=off/7; |
| if(ptCount>0){ |
| updateDynBuf(DYN_PART,PD,ptCount); |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE); |
| drawPoints(DYN_PART,ptCount,vp); |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); |
| } |
| } |
| |
| |
| function renderSkyObjects(vp,sunPos,sunEl){ |
| gl.disable(gl.CULL_FACE); |
| |
| if(sunEl>-5){ |
| const brightness=Math.max(0.2,Math.min(1,(sunEl+5)/20)); |
| drawMeshAt(SUN_MESH,vp,sunPos[0],sunPos[1],sunPos[2],1,1,1,0,0,0,[brightness,brightness*0.9,brightness*0.4],1); |
| } |
| |
| const moonEl=-sunEl,moonAz=SS.sunAzimuth+180; |
| const mPos=sunWorldPos(moonEl,moonAz); |
| if(moonEl>-10){ |
| drawMeshAt(MOON_MESH,vp,mPos[0],mPos[1],mPos[2],1,1,1,0,0,0,[0.85,0.83,0.78],1); |
| } |
| |
| if(sunEl<5){ |
| const starAlpha=Math.max(0,Math.min(1,(-sunEl)/10)); |
| for(const s of STARS){ |
| drawMeshAt(STAR_MESH,vp,s[0],s[1],s[2],1,1,1,0,0,0,[0.92,0.9,0.85],starAlpha); |
| } |
| } |
| |
| const cScale=Math.max(1,SS.cloudCover*2); |
| for(const c of CLOUDS){ |
| const cloudAlpha=0.65*SS.cloudCover; |
| drawMeshAt(CLOUD_MESH,vp,c.x,c.y,c.z,c.scale*cScale,c.scale*0.25,c.scale*cScale,0,0,0,[0.88,0.88,0.9],cloudAlpha); |
| } |
| gl.enable(gl.CULL_FACE); |
| } |
| |
| |
| let lastT=0,simTime=0; |
| function render(now){ |
| now*=0.001; |
| const dt=Math.min((now-lastT),0.1);lastT=now; |
| simTime+=dt; |
| |
| updateFPS(dt); |
| lerpCam(); |
| |
| |
| const doy=Math.floor(SS.simDay%365)+1; |
| const sun=calcSun(SS.latitude,SS.longitude,doy,SS.simHour); |
| SS.sunElevation=sun.el;SS.sunAzimuth=sun.az; |
| const sunPos=sunWorldPos(sun.el,sun.az); |
| const sunDir=[sunPos[0],sunPos[1],sunPos[2]]; |
| const sdLen=Math.sqrt(sunDir[0]**2+sunDir[1]**2+sunDir[2]**2)||1; |
| const snorm=[sunDir[0]/sdLen,sunDir[1]/sdLen,sunDir[2]/sdLen]; |
| |
| |
| const isDay=sun.el>0; |
| const isGolden=sun.el>0&&sun.el<15; |
| let lightCol=[1,1,1],ambient=isDay?0.18:0.06; |
| if(isGolden){lightCol=[1,0.8,0.45];ambient=0.25;} |
| if(!isDay){lightCol=[0.2,0.25,0.4];ambient=0.06;} |
| const lightDir=isDay?snorm:[0,1,0]; |
| |
| |
| const skyCol=skyColor(sun.el); |
| gl.clearColor(skyCol[0],skyCol[1],skyCol[2],1); |
| gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT); |
| |
| |
| const eye=camEye(); |
| const aspect=canvas.width/canvas.height; |
| const proj=m4persp(0.7854,aspect,0.1,80); |
| const view=m4look(eye[0],eye[1],eye[2],cam.tx,cam.ty,cam.tz); |
| const vp=m4mul(proj,view); |
| |
| const fogNear=10,fogFar=35; |
| setLighting(lightDir,lightCol,ambient,eye,skyCol,fogNear,fogFar); |
| |
| |
| const wdR=SS.windDirection*Math.PI/180; |
| for(const c of CLOUDS){ |
| c.x+=Math.sin(wdR)*c.speed*dt*0.1;c.z+=Math.cos(wdR)*c.speed*dt*0.1; |
| if(c.x>14)c.x=-14;if(c.x<-14)c.x=14;if(c.z>14)c.z=-14;if(c.z<-14)c.z=14; |
| } |
| |
| |
| renderSkyObjects(vp,sunPos,sun.el); |
| renderGround(vp); |
| renderPlants(vp,simTime); |
| |
| |
| updateRain(dt);updateWindStreaks(dt);updateEvap(dt);updatePlantParticles(dt); |
| |
| |
| const lineVerts=buildLineData(vp,simTime); |
| renderParticles(vp,simTime,lineVerts); |
| |
| |
| updatePhysics(dt,SS.simHour); |
| |
| |
| if(tooltipTimer>0){tooltipTimer-=dt;if(tooltipTimer<=0)hideTooltip();} |
| |
| |
| if(Math.floor(simTime*30)%10===0)updateHUD(); |
| |
| requestAnimationFrame(render); |
| } |
| |
| |
| window.simSetPlant=function(type){ |
| SS.plantType=type; |
| |
| for(const p of SS.plants){p.fruits=0;} |
| }; |
| window.simToggleWater=function(){ |
| SS.waterModeActive=!SS.waterModeActive;if(SS.waterModeActive)SS.sprayModeActive=false; |
| }; |
| window.simToggleSpray=function(){ |
| SS.sprayModeActive=!SS.sprayModeActive;if(SS.sprayModeActive)SS.waterModeActive=false; |
| }; |
| window.simAddDisease=function(){ |
| const healthy=SS.plants.filter(p=>!p.diseased); |
| if(healthy.length>0){const p=healthy[Math.floor(Math.random()*healthy.length)];p.diseased=true;p.status='diseased';} |
| }; |
| window.simAdvanceDay=function(){ |
| SS.simHour+=24;if(SS.simHour>=24){SS.simHour%=24;SS.simDay++;} |
| for(const p of SS.plants){p.age+=1;} |
| }; |
| window.simAdvanceWeek=function(){ |
| SS.simDay+=7;SS.simHour=SS.simHour%24; |
| for(const p of SS.plants){p.age+=7;p.growth=Math.min(1,p.growth+0.05);} |
| }; |
| window.simSetSpeed=function(n){SS.simSpeed=n;}; |
| |
| |
| initLocation(); |
| requestAnimationFrame(render); |
| |
| |
| const _intv=setInterval(fetchWeather,600000); |
| const obs=new MutationObserver(()=>{ |
| if(!document.contains(mount)){clearInterval(_intv);obs.disconnect();} |
| }); |
| obs.observe(document.body,{childList:true,subtree:true}); |
| |
| })(); |
| </script> |
| </body> |
| </html> |
|
|