/* ============================================================================ TERRAIN AS REAL GEOMETRY ---------------------------------------------------------------------------- The old renderer painted a heightfield onto a flat quad and faked relief with shading. Here the heightfield IS the mesh: a displaced grid with true vertex normals and per-vertex material colour. Everything the player complained about follows from this change — hills occlude, craters have walls you can look into, slopes catch the sun, and a shell crater is a hole in the ground rather than a dark circle painted on it. One mesh, one draw call, 32-bit indices. Deformation rewrites the affected vertices in place and re-uploads only that window, so blowing a crater costs a small bufferSubData rather than a rebuild. ============================================================================ */ /* 256 keeps the terrain a single draw call while reducing each ground cell from 16.7 m to 12.5 m on the 3.2 km theatre. That extra silhouette density matters most at city aprons: graded pads, gutters and road crowns can now meet building footprints without the coarse mesh cutting diagonally through them. Raising the 2048 source/height canvas instead would duplicate tens of megabytes across terrainCanvas, terrainBase and heightF on Android; geometry density is the safer resolution increase. */ /* 320 raises silhouette density to 10 m cells. The real close-range detail now comes from per-pixel heightfield normals (below), so geometry only has to carry the outline — 103k verts still one draw call. */ const TGRID=320; const TVERT=TGRID+1; /* Vertical exaggeration. The heightfield was authored as a shading source, so its raw noise is far too spiky to use as literal geometry — at full strength the map becomes razor ridges. Scaled down and smoothed it reads as rolling terrain a tank could plausibly drive over, which is what it has to be now that it IS the ground. */ const HSCALE=118; const SEABED=-26; // floor the ocean bottom so water has depth let terrMesh=null, terrVerts=null, terrVAO=null, terrVBO=null, terrIBO=null, terrIdxCount=0; let waterMesh=null, waterVAO=null, waterVBO=null, waterIBO=null, waterIdxCount=0, waterVerts=null; /* WATER HAS TO FOLLOW THE GROUND IT SITS IN. The water sheet is built once from authored hydrology (oceans, rivers, lakes). deformTerrain rewrites the heightfield all match long — craters, superweapon pits, singularity collapses — but punching a bowl below WATER_H must not grow that sheet into an inland pond. Coverage is gated on WATER_AUTH (frozen at gen). Shoreline bowls are the exception: a crater that actually touches the waterline enqueues a flood job. The front walks from the breach through below-table cells inside that bowl (~54 wu/s). Those cells become real water again — WATER_AUTH, PASS, naval mask, albedo — the way a beach crater used to fill before inland punches were locked dry. Isolated inland bowls still stay dirt. */ let waterNeed=null, waterTH=null, waterDirty=false, waterRebuildT=0, waterBaseCol=null, waterBowlSynced=0; let WATER_LIP=null; const WATER_FLOOD_CAP=6; const waterFloods=[]; let waterTickAt=-1; /* The playable heightfield still ends at MAP, but the camera is deliberately allowed to overhang it so a corner base can remain centred while the player rotates. A cheap low-density skirt gives that overhang real depth instead of exposing the framebuffer clear as a flat blue/black wedge. The terrain shader turns the skirt into atmospheric fake-land; it never participates in pathfinding, fog sensors, placement or deformation. */ const TERR_EDGE_EXT=960; let terrEdgeVAO=null, terrEdgeVBO=null, terrEdgeIBO=null, terrEdgeIdxCount=0; /* CONTEXT-LOSS RESET. Every builder above caches its VAO/VBO in a module variable and takes an if(!vao) create-else-update branch. After a context loss those handles still LOOK truthy but point at dead GL objects, so the rebuild path poured fresh vertices into dead buffers and the ground simply never drew again - the exact match-killing symptom on device: units, structures and boundary intact, terrain a flat fog-coloured void. Recovery must forget the handles so the builders genuinely re-create. */ /* --------------------------------------------------------------------------- HEIGHT TEXTURE — the terrain's own normal map, at heightfield resolution. Vertex normals live at mesh density (10 m). The heightfield knows the ground at 1.56 m. Uploading it as ONE global R16F sheet lets the fragment stage derive per-pixel normals: an 8x lighting-resolution jump with no new geometry, no chunks, and — because every deformation already funnels through terrainDirty() — crater edits re-upload just their window into the same sheet. A single texture cannot have chunk seams. --------------------------------------------------------------------------- */ let heightTex=null; function waterLipAt(wx,wy){ if(!WATER_LIP||!heightF) return false; const x=clamp(wx/MAP*TS|0,0,TS-1), y=clamp(wy/MAP*TS|0,0,TS-1); return !!WATER_LIP[y*TS+x]; } function waterVisualWetTexel(ix,iy){ return !WATER_AUTH||!!WATER_AUTH[iy*TS+ix]||!!(WATER_LIP&&WATER_LIP[iy*TS+ix]); } function waterLipReset(){ WATER_LIP=null; waterBowlSynced=0; waterFloods.length=0; waterTickAt=-1; } function terrainWorldH(ix,iy){ const h=heightF[iy*TS+ix]; const wet=waterVisualWetTexel(ix,iy); return (wet&&h<=WATER_H) ? Math.max(SEABED,(h-WATER_H)*HSCALE*1.4) : (h-WATER_H)*HSCALE; } function uploadHeightTex(x0,y0,x1,y1){ if(!heightF||typeof gl==='undefined'||!gl) return; const full=x0==null; if(full){ x0=0; y0=0; x1=TS; y1=TS; } x0=clamp(x0|0,0,TS); y0=clamp(y0|0,0,TS); x1=clamp(Math.ceil(x1),0,TS); y1=clamp(Math.ceil(y1),0,TS); const w=x1-x0, h=y1-y0; if(w<=0||h<=0) return; const buf=new Float32Array(w*h); for(let y=0;y[a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t]; let c; const wet=(typeof authoredWaterAt==='function'?authoredWaterAt(wx,wy):h{ const w1=Math.sin(x*0.0016+z*0.0009)+0.6*Math.sin(z*0.0027-x*0.0013); let r=1-Math.abs(Math.sin(x*0.00115+z*0.00165+w1*0.9)); r*=r; const r2=1-Math.abs(Math.sin((x+z)*0.00205+w1*0.5)); return r*0.72+r2*r2*0.28; }; const axis=(a,b,n)=>Array.from({length:n+1},(_,i)=>a+(b-a)*i/n); const along=axis(-TERR_EDGE_EXT,MAP+TERR_EDGE_EXT,72); const middle=axis(0,MAP,48); /* THE SMEAR AXIS. Six rows carried the whole 960-unit skirt, so every distance-driven band in the terrain shader (outer haze, exclusion zone, foam/storm patterns) was reconstructed from 5 linear spans across a strongly curved superellipse field — which is exactly what read as long stretched stripes in the outer ranges. Eleven rows costs +2.9k triangles against a 205k-triangle terrain (+1.4%, build-time only, zero fill cost) and makes those contours curve properly. */ const bands=[0,60,130,230,330,470,610,700,800,880,TERR_EDGE_EXT]; const smooth=q=>q*q*(3-2*q); /* One height function, used for the vertex AND for its normal. Every skirt vertex previously shipped (0,1,0): a 255-unit ridged mountain range lit as a flat plane, with no relief anywhere in the outer ranges. The per-pixel normal path cannot rescue it either — outside 0..1 the height texture clamps, so its central difference is identically zero along the outward axis, which generates precisely the long streaks reported. */ const edgeH=(x,z)=>{ const outside=Math.max(0,-x,x-MAP,-z,z-MAP), q=clamp(outside/TERR_EDGE_EXT,0,1); const bx=clamp(x,0,MAP), bz=clamp(z,0,MAP), join=clamp(outside/150,0,1); const relief=(Math.sin(x*.0067+z*.0031)+Math.sin(z*.0083-x*.0027))*(1-q)*reliefAmp*join; const build=smooth(clamp((outside-120)/(TERR_EDGE_EXT*0.82-120),0,1)); return terrainH(bx,bz)-smooth(q)*sink*(1-build)+relief+ridged(x,z)*peakAmp*build; }; const addRect=(xs,zs)=>{ const base=verts.length/12, nx=xs.length; for(const z of zs) for(const x of xs){ const outside=Math.max(0,-x,x-MAP,-z,z-MAP), q=clamp(outside/TERR_EDGE_EXT,0,1); const bx=clamp(x,0,MAP), bz=clamp(z,0,MAP), join=clamp(outside/150,0,1); /* Continue the edge silhouette, then let it settle slightly into the weather. Relief starts at zero on the seam, so even a grazing camera cannot reveal a vertical crack between authored and fake terrain. */ const relief=(Math.sin(x*.0067+z*.0031)+Math.sin(z*.0083-x*.0027))*(1-q)*reliefAmp*join; const build=smooth(clamp((outside-120)/(TERR_EDGE_EXT*0.82-120),0,1)); const mtn=ridged(x,z)*peakAmp*build; /* Isles fall away fastest into their drowned perimeter; dry maps keep harder broken shelves; storm maps flatten into a low obscured waste. */ const h=terrainH(bx,bz)-smooth(q)*sink*(1-build)+relief+mtn; const cc=TH.cliff||[112,116,122]; /* Colour climbs from biome cliff rock through range rock to the cap. */ const pk=peakAmp>0?clamp((mtn-peakAmp*0.30)/(peakAmp*0.55),0,1):0; const cap=peakAmp>0?clamp((mtn-peakAmp*0.72)/(peakAmp*0.26),0,1)*(edgeKind===2?0.55:1):0; const cr=cc[0]+(peakCol[0]-cc[0])*pk+(capCol[0]-peakCol[0])*cap*pk; const cg=cc[1]+(peakCol[1]-cc[1])*pk+(capCol[1]-peakCol[1])*cap*pk; const cb=cc[2]+(peakCol[2]-cc[2])*pk+(capCol[2]-peakCol[2])*cap*pk; const eps=32; let nvx=edgeH(x-eps,z)-edgeH(x+eps,z), nvy=2*eps, nvz=edgeH(x,z-eps)-edgeH(x,z+eps); const nl=Math.hypot(nvx,nvy,nvz)||1; verts.push(x,h,z, nvx/nl,nvy/nl,nvz/nl, cr/255,cg/255,cb/255, x*.035,z*.035,MAT.EARTH); } for(let z=0;z-d)); addRect(along,bands.map(d=>MAP+d)); addRect(bands.slice().reverse().map(d=>-d),middle); addRect(bands.map(d=>MAP+d),middle); terrEdgeIdxCount=idx.length; const data=new Float32Array(verts),indices=new Uint32Array(idx); if(!terrEdgeVAO){ terrEdgeVAO=gl.createVertexArray(); gl.bindVertexArray(terrEdgeVAO); terrEdgeVBO=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,terrEdgeVBO); gl.bufferData(gl.ARRAY_BUFFER,data,gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,3,gl.FLOAT,false,VSTRIDE,0); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,3,gl.FLOAT,false,VSTRIDE,12); gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2,3,gl.FLOAT,false,VSTRIDE,24); gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3,2,gl.FLOAT,false,VSTRIDE,36); gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4,1,gl.FLOAT,false,VSTRIDE,44); terrEdgeIBO=gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,terrEdgeIBO); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,indices,gl.STATIC_DRAW); gl.disableVertexAttribArray(5); gl.vertexAttrib4f(5,0,0,0,1); gl.disableVertexAttribArray(6); gl.vertexAttrib1f(6,0); gl.disableVertexAttribArray(7); gl.vertexAttrib4f(7,1,1,1,1); gl.disableVertexAttribArray(8); gl.vertexAttrib1f(8,1); gl.bindVertexArray(null); }else{ gl.bindVertexArray(terrEdgeVAO); gl.bindBuffer(gl.ARRAY_BUFFER,terrEdgeVBO); gl.bufferData(gl.ARRAY_BUFFER,data,gl.STATIC_DRAW); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,terrEdgeIBO); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,indices,gl.STATIC_DRAW); gl.bindVertexArray(null); } } /* Rewrite a rectangular window of terrain vertices — positions, normals and colours — from the current heightfield. Normals are central differences of the heightfield rather than face normals, which keeps the surface smooth across cell boundaries and makes lighting read as terrain, not as a grid. */ function getRoadTile(gx,gy){ /* Connectivity used to select painted lanes, corners and crosswalks. At the coarse fallback-mesh UV scale those tiles turned every junction into a toy road mat. The causeway silhouette already lives in ROADG; choose only rugged materials here and use a stable hash to scatter damaged plates. */ const scar=((gx*73856093)^(gy*19349663))>>>0; if(curTheme==='ashland') return (scar&7)===0?MAT.BLAST_SLAG:MAT.BASALT_CRUST; if(curTheme==='arctic') return (scar&7)===0?MAT.ICE_PACK:MAT.SHATTER_CONC; if(curTheme==='vespera') return (scar&7)===0?MAT.CANYON_ROCK:MAT.BEDROCK; return (scar&7)===0?MAT.CRATER_DEBRIS:MAT.SHATTER_CONC; } const _tc=[0,0,0]; function refreshTerrainVerts(gx0,gz0,gx1,gz1,TH){ TH=TH||(typeof themePaint==='function'?themePaint(THEMES[curTheme]||THEMES.verdant):THEMES[curTheme]||THEMES.verdant); const cell=MAP/TGRID; gx0=clamp(gx0,0,TVERT-1); gz0=clamp(gz0,0,TVERT-1); gx1=clamp(gx1,0,TVERT-1); gz1=clamp(gz1,0,TVERT-1); const eps=cell*0.75; for(let z=gz0;z<=gz1;z++) for(let x=gx0;x<=gx1;x++){ const wx=x*cell, wy=z*cell; const h=terrainH(wx,wy); const hl=terrainH(wx-eps,wy), hr=terrainH(wx+eps,wy); const hd=terrainH(wx,wy-eps), hu=terrainH(wx,wy+eps); let nx=hl-hr, ny=2*eps, nz=hd-hu; const l=Math.hypot(nx,ny,nz)||1; nx/=l; ny/=l; nz/=l; const slope=Math.hypot(hr-hl,hu-hd)/(2*eps); const raw=heightF?heightF[clamp(Math.round(wy/MAP*(TS-1)),0,TS-1)*TS+clamp(Math.round(wx/MAP*(TS-1)),0,TS-1)]:0.5; terrColor(raw,slope,TH,wx,wy,_tc); const o=(z*TVERT+x)*12; terrVerts[o]=wx; terrVerts[o+1]=h; terrVerts[o+2]=wy; terrVerts[o+3]=nx; terrVerts[o+4]=ny; terrVerts[o+5]=nz; terrVerts[o+6]=_tc[0]; terrVerts[o+7]=_tc[1]; terrVerts[o+8]=_tc[2]; terrVerts[o+9]=wx*0.035; terrVerts[o+10]=wy*0.035; let mid=MAT.EARTH; const rx=clamp(wy/MAP*PGS|0,0,PGS-1), ry=clamp(wx/MAP*PGS|0,0,PGS-1); const authoredWet=typeof authoredWaterAt==='function'&&authoredWaterAt(wx,wy); const lipWet=waterLipAt(wx,wy); if(typeof ROADG!=='undefined'&&ROADG&&ROADG[rx*PGS+ry]){ mid=getRoadTile(ry,rx); } else if(raw0.72) { mid=(curTheme==='arctic') ? MAT.STONE : (curTheme==='ashland' ? MAT.BASALT_CRUST : MAT.STONE); } else if(raw regenerate and upload. Called by every crater, collapse and excavation, which is how deformation becomes geometry. */ function terrainDirty(wx,wy,rad,depth){ /* Lip + splash first so the height sheet and vertex colours see the wet bowl on this same upload, not a frame later. */ waterReactDeform(wx,wy,rad,depth); if(!terrVerts) return; const cell=MAP/TGRID, pad=2; const gx0=Math.floor((wx-rad)/cell)-pad, gx1=Math.ceil((wx+rad)/cell)+pad; const gz0=Math.floor((wy-rad)/cell)-pad, gz1=Math.ceil((wy+rad)/cell)+pad; refreshTerrainVerts(gx0,gz0,gx1,gz1); uploadTerrainRegion(gx0,gz0,gx1,gz1); /* Same window, same choke point: the per-pixel normal sheet follows every crater and foundation cut with a small sub-upload, never a full rebuild. */ const k=TS/MAP, hp=6; uploadHeightTex((wx-rad)*k-hp,(wy-rad)*k-hp,(wx+rad)*k+hp,(wy+rad)*k+hp); /* Did this deform change visual water coverage? Only the touched window is scanned. A dry inland crater punching below WATER_H must not grow the sheet — crater-below-water-table used to spawn fake lakes. Authored oceans/rivers/lakes and WATER_LIP shoreline bowls still rebuild. */ if(waterNeed&&!waterDirty){ const cx0=clamp(gx0,0,TGRID),cx1=clamp(gx1,0,TGRID),cz0=clamp(gz0,0,TGRID),cz1=clamp(gz1,0,TGRID); for(let z=cz0;z<=cz1&&!waterDirty;z++) for(let x=cx0;x<=cx1;x++){ const wxv=x*cell, wyv=z*cell; const hx=clamp(Math.round(wxv/MAP*(TS-1)),0,TS-1), hy=clamp(Math.round(wyv/MAP*(TS-1)),0,TS-1); const authored=WATER_AUTH?WATER_AUTH[hy*TS+hx]:1; const lip=WATER_LIP&&WATER_LIP[hy*TS+hx]; const wet=(authored||lip)&&heightF[hy*TS+hx]0) waterRebuildT-=dt; if(waterRebuildT<=0) waterBowlSynced=0; if(!waterDirty||waterRebuildT>0||!waterTH||typeof gl==='undefined'||!gl) return; waterRebuildT=waterFloods.length?0.14:0.45; waterBowlSynced=0; buildWaterMesh(waterTH); } function waterFloodEnqueue(wx,wy,rad,hx,hy){ const r=rad||40; for(let i=0;i=WATER_FLOOD_CAP) waterFloods.shift(); const F={x:wx,y:wy,r:r,hx:hx,hy:hy,front:0,maxFront:r*1.18+10,speed:54,age:0,fxT:0}; waterFloods.push(F); return F; } /* Grow WATER_LIP from authored water through below-table cells inside this bowl. Path distance, not a disc — water comes through the breach. */ function waterFloodMark(F,frontWu){ if(!waterLipEnsure()) return false; const k=TS/MAP; const cx=F.x*k, cy=F.y*k; const cr=Math.max(6,F.r*k); const frontT=Math.max(1,frontWu*k); const reach=cr+frontT+3; const x0=clamp(Math.floor(cx-reach),0,TS-1), x1=clamp(Math.ceil(cx+reach),0,TS-1); const y0=clamp(Math.floor(cy-reach),0,TS-1), y1=clamp(Math.ceil(cy+reach),0,TS-1); const W=x1-x0+1, Hh=y1-y0+1; const seen=new Uint8Array(W*Hh); const at=(x,y)=>(y-y0)*W+(x-x0); const q=[]; for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++){ if(!WATER_AUTH[y*TS+x]) continue; if(heightF[y*TS+x]>=WATER_H+0.012) continue; q.push(x,y,0); seen[at(x,y)]=1; } if(!q.length) return false; const DX=[1,-1,0,0], DY=[0,0,1,-1]; const bowlR=cr*1.22; let marked=false, qh=0; while(qhfrontT) continue; for(let i=0;i<4;i++){ const nx=x+DX[i], ny=y+DY[i]; if(nxx1||nyy1) continue; const si=at(nx,ny); if(seen[si]) continue; seen[si]=1; if(heightF[ny*TS+nx]>=WATER_H+0.012) continue; const nd=dist+1; const dx=nx-cx, dy=ny-cy; if(dx*dx+dy*dy>bowlR*bowlR) continue; if(!WATER_AUTH[ny*TS+nx]&&nd<=frontT){ if(waterFloodCommit(nx,ny)) marked=true; } if(nd<=frontT) q.push(nx,ny,nd); } } return marked; } function waterFloodTick(dt){ if(dt>0&&waterFloods.length){ let marked=false; for(let i=waterFloods.length-1;i>=0;i--){ const F=waterFloods[i]; F.age+=dt; F.front=Math.min(F.maxFront, F.front+F.speed*dt); if(waterFloodMark(F,F.front)){ marked=true; if(typeof waterFxImpact==='function'){ const ang=Math.atan2(F.y-F.hy,F.x-F.hx); waterFxImpact(F.hx+Math.cos(ang)*F.front*0.82, F.hy+Math.sin(ang)*F.front*0.82, 9+F.r*0.07, 0.62, true); } } F.fxT+=dt; if(F.fxT>=0.48&&typeof waterFxCrater==='function'){ F.fxT=0; waterFxCrater(F.x,F.y,Math.max(22,F.r*0.62),0.035,F.hx,F.hy); } if(F.front>=F.maxFront-0.05||F.age>3.6){ if(waterFloodMark(F,F.maxFront)) marked=true; waterFloods.splice(i,1); } } if(marked){ waterDirty=true; if(waterRebuildT>0.12) waterRebuildT=0.12; const k=TS/MAP; for(let i=0;i=WATER_H+0.012) continue; if(waterVisualWetTexel(hx,hy)) return true; } return false; } /* First splash owns the wet-bowl mesh. Later marks in the 0.45s window stay on waterMaintain so a shoreline volley is one rebuild, not sixteen. */ function waterSyncBowl(wx,wy,rad){ if(!waterTH||typeof gl==='undefined'||!gl) return; if(!waterBowlMeshMisses(wx,wy,rad)) return; /* A prior lake maintain can leave waterRebuildT hot. That must not delay the first shoreline hole — only a bowl we already synced. */ if(waterBowlSynced&&waterRebuildT>0){ waterDirty=true; if(waterRebuildT>0.08) waterRebuildT=0.08; return; } buildWaterMesh(waterTH); waterRebuildT=0.45; waterBowlSynced=1; } /* Nearest authored-water sample inside rad. Stepped — this is a deform hook, not a per-frame scan. Null on dry maps and inland bowls. */ function waterNearAuthored(wx,wy,rad){ if(!WATER_AUTH||!heightF) return null; if(typeof battlefieldWaterMode==='function'&&battlefieldWaterMode()==='none') return null; const k=TS/MAP; const cx=wx*k, cy=wy*k, reach=Math.max(8,(rad||24)*k); const x0=clamp(Math.floor(cx-reach),0,TS-1), x1=clamp(Math.ceil(cx+reach),0,TS-1); const y0=clamp(Math.floor(cy-reach),0,TS-1), y1=clamp(Math.ceil(cy+reach),0,TS-1); const step=Math.max(1,(reach/18)|0); let best=null, bestD=1e9; for(let y=y0;y<=y1;y+=step) for(let x=x0;x<=x1;x+=step){ if(!WATER_AUTH[y*TS+x]) continue; const ddx=x-cx, ddy=y-cy, d=ddx*ddx+ddy*ddy; if(d=1) return false; WATER_AUTH[i]=1; if(WATER_LIP) WATER_LIP[i]=1; if(PASS){ const px=clamp(wx/MAP*PGS|0,0,PGS-1), py=clamp(wy/MAP*PGS|0,0,PGS-1); const pi=py*PGS+px; PASS[pi]=0; if(typeof NAVW!=='undefined'&&NAVW){ NAVW[pi]=1; if(typeof NAVCOMP!=='undefined'&&NAVCOMP&&!NAVCOMP[pi]){ const DX=[1,-1,0,0], DY=[0,0,1,-1]; for(let k=0;k<4;k++){ const qx=px+DX[k], qy=py+DY[k]; if(qx<0||qy<0||qx>=PGS||qy>=PGS) continue; const c=NAVCOMP[qy*PGS+qx]; if(c){ NAVCOMP[pi]=c; if(typeof NAV_SIZE!=='undefined'&&NAV_SIZE) NAV_SIZE[c]=(NAV_SIZE[c]||0)+1; break; } } } } } return true; } function waterFloodRelight(x0,y0,x1,y1){ if(typeof shadeRegion!=='function'||!heightF) return; const sx=clamp(x0-2,0,TS-1), sy=clamp(y0-2,0,TS-1); const ex=clamp(x1+2,0,TS-1), ey=clamp(y1+2,0,TS-1); const w=ex-sx+1, h=ey-sy+1; if(w<=0||h<=0) return; shadeRegion(sx,sy,w,h,null,true); if(typeof gl==='undefined'||!gl||!terrainCanvas||typeof terrainTex==='undefined'||!terrainTex) return; const tmp=document.createElement('canvas'); tmp.width=w; tmp.height=h; tmp.getContext('2d').drawImage(terrainCanvas,sx,sy,w,h,0,0,w,h); const was=gl.getParameter(gl.ACTIVE_TEXTURE); gl.activeTexture(gl.TEXTURE10); const prev=gl.getParameter(gl.TEXTURE_BINDING_2D); gl.bindTexture(gl.TEXTURE_2D,terrainTex); gl.texSubImage2D(gl.TEXTURE_2D,0,sx,sy,gl.RGBA,gl.UNSIGNED_BYTE,tmp); gl.bindTexture(gl.TEXTURE_2D,prev); gl.activeTexture(was); } function waterReactDeform(wx,wy,rad,depth){ if(typeof battlefieldWaterMode==='function'&&battlefieldWaterMode()==='none') return false; const r=rad||40; const onWet=typeof authoredWaterAt==='function'&&authoredWaterAt(wx,wy); const hit=onWet? [wx,wy] : waterNearAuthored(wx,wy,r+40); if(!hit) return false; const F=waterFloodEnqueue(wx,wy,r,hit[0],hit[1]); /* Seed a thin contact so the splash lands on wet sheet, then the tick walks the front through the bowl. Instant full-lip was a painted stain. */ F.front=Math.max(F.front, 8); if(waterFloodMark(F,F.front)){ waterDirty=true; waterSyncBowl(wx,wy,r); const k=TS/MAP, cr=Math.max(6,r*k), reach=cr+F.front*k+4; waterFloodRelight( clamp(Math.floor(wx*k-reach),0,TS-1), clamp(Math.floor(wy*k-reach),0,TS-1), clamp(Math.ceil(wx*k+reach),0,TS-1), clamp(Math.ceil(wy*k+reach),0,TS-1)); } if(typeof waterFxCrater==='function') waterFxCrater(wx,wy,Math.max(22,r*0.62),depth,hit[0],hit[1]); return true; } /* Hydrology class stamped into the water sheet: 0 ocean — readable swell + glitter 1 river — directional flow along the authored carve axis 2 lake — enclosed / secondary bodies, calmer Not biome (lava/ice) — that stays uKind. Small NAV components on an ocean map are lakes so a lagoon does not heave like the open sea. */ function waterHydroAt(wx,wy){ /* Flooded crater bowls are enclosed ponds — ocean swell in a 60 wu hole reads as the sheet tearing. */ if(typeof waterLipAt==='function'&&waterLipAt(wx,wy)) return 2; const mode=typeof battlefieldWaterMode==='function'?battlefieldWaterMode():'none'; if(mode==='river') return 1; if(mode==='ocean'){ /* Only a smaller SECONDARY naval component is a lake. Shallows of the main sea used to return 2 because waterComponentAt is 0 there, and the whole ocean drew as a millpond. */ if(typeof waterComponentAt==='function'&&typeof NAV_MAIN!=='undefined'&&NAV_MAIN>0&&typeof NAV_SIZE!=='undefined'){ const c=waterComponentAt(wx,wy); if(c&&c!==NAV_MAIN&&(NAV_SIZE[c]||0)>8&&(NAV_SIZE[c]||0)<(NAV_SIZE[NAV_MAIN]||0)*0.45) return 2; } return 0; } return 2; } function waterAmpNow(){ const G=typeof GFX!=='undefined'?GFX:{}; const a=G.waterAmp; return a==null?1:+a; } function waterFxTier(){ /* Same cuts as uDetail: LOW cheapest, MEDIUM readable, HIGH richer. Reads GFX.waterAmp only — no new META.settings key. */ const a=waterAmpNow(); return a>=0.85?2:a>=0.55?1:0; } function waterSurfaceY(wx,wy){ /* MUST match VSW. Hulls that bob on a different swell sit in a hole or fly. Visual only — sim pathing stays on the flat naval mask; do not put this in sim.js (contended, and a bouncing flowfield is a bug). */ const t=(typeof performance!=='undefined'?performance.now():0)*0.001; const hydro=waterHydroAt(wx,wy); const h=typeof terrainH==='function'?terrainH(wx,wy):0; const deep=clamp((-h-0.8)/11.0,0,1); let A=hydro>1.5?0.90:hydro>0.5?0.62:2.85; A*=waterAmpNow()*deep; const TH=typeof THEMES!=='undefined'&&THEMES[curTheme]; if(TH&&TH.water==='ice') A*=0.28; else if(TH&&TH.water==='lava') A*=0.55; let swell; if(hydro>1.5){ swell=(Math.sin(wx*0.011+t*0.28)+Math.sin(wy*0.009-t*0.22))*0.5; }else if(hydro>0.5){ const fl=typeof battlefieldWaterFlow==='function'?battlefieldWaterFlow():[1,0]; swell=Math.sin(wx*fl[0]*0.048+wy*fl[1]*0.048-t*1.55); }else{ const w1=Math.sin(wx*0.016+t*0.62), w2=Math.sin(wy*0.013-t*0.48); const w3=Math.sin((wx+wy)*0.022+t*0.91); swell=w1*0.50+w2*0.32+w3*0.18; } /* Same suction the water VS applies — hulls that skip it sit on a hump next to a pulled sheet. Visual only; sim pathing stays flat. */ let y=WATER_Y+0.55+swell*A-waterCraterPull(wx,wy)*deep; if(waterFxTier()>=2) y+=waterCraterChop(wx,wy)*deep; return y; } /* Cheap GPU water FX — not a solver. Wakes and impact rings are: 1. uniforms sampled by the water fragment (reads at command zoom, no z-fight) 2. a few additive InstMesh quads at tactical zoom (silhouette) Sim pathing stays on the flat naval mask. ONE declaration. This file is a classic