/* ============================================================ MASSFRONT v2 — engine: WebGL2 instanced renderer + AAA-ish assets ============================================================ */ // ---------- utils ---------- let _seed = 1234567; function srand(s){ _seed = s|0 || 1; } function rnd(){ _seed = (_seed * 1664525 + 1013904223) | 0; return ((_seed >>> 9) & 0x7fffff) / 0x800000; } function rr(a,b){ return a + rnd()*(b-a); } const clamp=(v,a,b)=>vb?b:v); const dist2=(x1,y1,x2,y2)=>{const dx=x2-x1,dy=y2-y1;return dx*dx+dy*dy;}; const TAU = Math.PI*2; /* OTA payload publishes window.__MF_OTA_ASSETS (data URIs) in the prelude. Loaders that build URLs by concatenation never match a string-replace, so they ask here first. A packaged APK has no map — the real files are on disk. data: URIs pass through so an already-inlined atlas path cannot be prefixed. */ function mf2AssetURL(path){ const p=String(path||''); if(p.indexOf('data:')===0) return p; const rel=p.replace(/^\.\//,''); const o=typeof window!=='undefined'&&window.__MF_OTA_ASSETS; if(o&&o[rel]) return o[rel]; return './'+rel; } // ---------- world constants ---------- /* Large is now a real 3.2 km theatre, not the old 2.6 km allocation with the camera pulled back. The terrain texture stays 2048 for a bounded mobile GPU cost; passability rises below so path cells retain roughly the old physical size and ships do not cut across wider shorelines. */ const MAP = 3200; /* Commander start positions, as a fraction of the map. Inset from the corners because the camera clamps its VIEW to the battlefield — a start at 0.16 sat in a corner the camera could never centre, so the dropship was unviewable and unflyable before you had even deployed. Every system that cares about where the bases are (terrain bumps, deposit layout, road trunks, district placement, wildlife exclusion) reads these, so they can never drift apart. */ const SP_LO=0.26, SP_HI=0.74; const MAXU = 34000; // hiveworld scale: armies + up to ~28k wildlife const MAXP = 6000; const MAXPART = 9000; // ---------- canvas / GL ---------- const cv = document.getElementById('gl'); /* Production is WebGL2 only. WebGL1 (getContext('webgl'), #version 100, OES_standard_derivatives / ANGLE_instanced_arrays / OES_vertex_array_object / WEBGL_draw_buffers) is not a fallback — those are core in ES 3.00. failIfMajorPerformanceCaveat prefers the discrete GPU and refuses SwiftShader; if that returns null we retry WebGL2 without the caveat (integrated hardware) and still reject software renderer strings. Same canvas cannot then be given a WebGL1 context. */ function mfGL2IsSoftware(g){ try{ const d=g.getExtension('WEBGL_debug_renderer_info'); const r=(d?String(g.getParameter(d.UNMASKED_RENDERER_WEBGL)):'')+' '+String(g.getParameter(g.RENDERER)); return /swiftshader|llvmpipe|lavapipe|software|microsoft basic render/i.test(r); }catch(e){ return false; } } function mfCreateWebGL2(canvas, extra){ if(!canvas||!canvas.getContext) return null; const strict=Object.assign({powerPreference:'high-performance',failIfMajorPerformanceCaveat:true}, extra||{}); let g=null; try{ g=canvas.getContext('webgl2', strict); }catch(e){ g=null; } if(!g){ const hw=Object.assign({}, strict, {failIfMajorPerformanceCaveat:false}); try{ g=canvas.getContext('webgl2', hw); }catch(e){ g=null; } } if(!g) return null; if(typeof WebGL2RenderingContext!=='undefined' && !(g instanceof WebGL2RenderingContext)) return null; if(mfGL2IsSoftware(g)) return null; return g; } function mfWebGL2Required(){ if(!document.getElementById('glLostOverlay')){ const o=document.createElement('div'); o.id='glLostOverlay'; o.style.cssText='position:fixed;inset:0;z-index:2147483000;display:flex;flex-direction:column;' +'align-items:center;justify-content:center;gap:15px;background:#02060c;color:#cfe6ff;text-align:center;padding:26px;' +'font-family:var(--fT,system-ui,sans-serif)'; o.innerHTML='
WEBGL2 REQUIRED
' +'
MASSFRONT needs a hardware WebGL2 GPU. WebGL1 and software renderers are not used.
'; (document.body||document.documentElement).appendChild(o); } throw new Error('MASSFRONT requires WebGL2 on a hardware GPU (no WebGL1 / SwiftShader fallback)'); } const gl = mfCreateWebGL2(cv, {antialias:false, alpha:false}) || mfWebGL2Required(); try{ const d=gl.getExtension('WEBGL_debug_renderer_info'); window.__MF_GL_INFO={ webgl2: gl instanceof WebGL2RenderingContext, version: String(gl.getParameter(gl.VERSION)), shading: String(gl.getParameter(gl.SHADING_LANGUAGE_VERSION)), renderer: d?String(gl.getParameter(d.UNMASKED_RENDERER_WEBGL)):String(gl.getParameter(gl.RENDERER)) }; }catch(_){} /* ---------- WebGL context-loss recovery --------------------------------------- Android WebView drops the GL context under memory pressure and on backgrounding. Every program, buffer and texture in this renderer is const-bound at load and cannot be reallocated in place, so the 3D view goes permanently black while the HTML UI stays alive (the reported "map vanished, only UI shows" bug — it also blanks the menu's LIVE 3D feed and attract diorama). The only correct rebuild is a document reload, which re-applies the installed patch and comes back to a live scene. preventDefault() is MANDATORY — without it the browser never fires 'restored' and the blank screen is permanent. A short guard avoids a reload loop on a chronically low-memory device (show the panel, let the player choose). */ (function installGLContextGuard(){ if(!cv||!cv.addEventListener) return; var glDead=false, justReset=false; try{ var pr=new URL(location.href).searchParams.get('mf_glreset'); if(pr){ var tms=parseInt(pr,36); justReset=isFinite(tms)&&(Date.now()-tms)<20000; } }catch(e){} function glReload(){ try{ var u=new URL(location.href); u.searchParams.set('mf_glreset',Date.now().toString(36)); location.replace(u.href); }catch(e){ location.reload(); } } function glOverlay(){ if(document.getElementById('glLostOverlay')) return; var o=document.createElement('div'); o.id='glLostOverlay'; o.style.cssText='position:fixed;inset:0;z-index:2147483000;display:flex;flex-direction:column;' +'align-items:center;justify-content:center;gap:15px;background:#02060c;color:#cfe6ff;text-align:center;padding:26px;' +'font-family:var(--fT,system-ui,sans-serif)'; o.innerHTML='
GRAPHICS INTERRUPTED
' +'
The device reclaimed graphics memory. Your match has been saved — reload and choose RESUME DROPPED SESSION to pick it up where it stopped.
' +''; (document.body||document.documentElement).appendChild(o); var b=document.getElementById('glLostReload'); if(b){ if(typeof mfBindTap==='function') mfBindTap(b,glReload); else b.addEventListener('click',glReload); } } /* RECOVERY LIVES IN src/glrecover.js NOW. This handler used to reload the page 1.7 seconds after a context loss — and, worse, `webglcontextrestored` ALSO reloaded, so the one event meaning "you can carry on" was treated exactly like the failure and the match was lost either way. A lost context is very often given back within a few seconds on mobile; 1.7 s guaranteed we never waited long enough to find out. glrecover.js pauses, waits properly, rebuilds the GL objects in place and steps the quality preset down. What is left here is the FALLBACK, for the case where glrecover.js is absent (an older OTA shell) or never got the chance to arm itself. It only acts if nothing else has claimed the loss. */ cv.addEventListener('webglcontextlost',function(e){ e.preventDefault(); glDead=true; if(typeof glrLost!=='undefined') return; // recovery owns this try{ if(typeof sessSnapshot==='function') sessSnapshot('contextlost'); }catch(_){} try{ if(typeof paused!=='undefined') paused=true; }catch(_){} glOverlay(); if(!justReset) setTimeout(function(){ if(glDead) glReload(); }, 12000); },false); cv.addEventListener('webglcontextrestored',function(){ if(typeof glrLost!=='undefined') return; // recovery rebuilds in place glReload(); },false); })(); let VW=0, VH=0, DPR=1; /* iOS caps its GPU budget hard, and a Pro Max at DPR 3 asks for 2.4x the pixels of the same scene on a typical Android panel for no visible gain at command zoom. 2.0 is the ceiling everywhere; iPhones with a very tall panel come down again so fill rate stays inside what the tile memory likes. */ const IOS = /iPad|iPhone|iPod/.test(navigator.platform||'') || (navigator.userAgent.includes('Mac') && 'ontouchend' in document); /* Mobile WebViews share a comparatively small graphics budget with the OS and the rest of the app. A 412x900 phone at DPR 3 otherwise asks the renderer for a 3.3-million-pixel colour buffer, then AO duplicates it twice and adds a depth target. That memory spike was enough to make Android reclaim the context mid-match even while the scene was reporting 100+ FPS. Resolution is capped by presentation preset; geometry and texture detail stay intact. */ const MF_MOBILE_GPU = IOS || /Android|Mobile/i.test(navigator.userAgent||''); function mf2d(cv){ /* Chrome warns on getImageData unless this hint is on the FIRST getContext. A later call cannot add it. Throughput trade-off is correct here: the height/mask canvases exist to be read back. */ try{ return cv.getContext('2d',{willReadFrequently:true})||cv.getContext('2d'); } catch(e){ return cv.getContext('2d'); } } function resize(){ const raw = window.devicePixelRatio||1; const q=(typeof qualityKey==='function')?qualityKey():'high'; const G=typeof GFX!=='undefined'?GFX:{}; const tall=Math.max(window.innerWidth,window.innerHeight)>860; /* Fillrate split (412×915 phone @ native 3): MEDIUM dprCap 1.25 → ~0.59 Mpx HIGH phone 1.52–1.65 → ~0.87–1.03 Mpx (never native 2/3 — that is the context-loss spike: AO duplicates colour and adds depth) HIGH desktop min(raw,2) MEDIUM must stay cheaper on the SAME GPU. Do not let HIGH phone climb to 2; do not let MEDIUM share HIGH's cap. */ let cap=G.dprCap>0?G.dprCap :(q==='low'?1.15:q==='medium'?(tall?1.18:1.25):q==='cinematic'?(tall?1.70:1.80):(tall?1.52:1.65)); if(!MF_MOBILE_GPU && (q==='high'||q==='cinematic')) cap=2; else if(!MF_MOBILE_GPU && q==='medium') cap=Math.min(cap,1.35); DPR = Math.min(raw, cap); /* Measure the CSS box, not the last bitmap. A leftover canvas.width/height from a portrait boot used to win clientWidth on some desktop resizes, so the 100%×100% rule stretched a tall buffer across a wide window. Hidden menu canvases report 0 — fall back to the window so the first match frame is already landscape-correct. */ const box=cv.getBoundingClientRect(); const cssW=box.width>2?box.width:(cv.clientWidth||window.innerWidth); const cssH=box.height>2?box.height:(cv.clientHeight||window.innerHeight); VW = cssW; VH = cssH; cv.width = Math.round(VW*DPR); cv.height = Math.round(VH*DPR); gl.viewport(0,0,cv.width,cv.height); if(typeof camUpdateMatrices==='function') camUpdateMatrices(); } /* Sun-depth CSM atlas binds on TEXTURE4 during csmApply only. Terrain already owns 0/1/2/3/7–15; ads stay on 7; post 4/5/6 never move to 0. MEDIUM/LOW never allocate or sample this unit. */ const MF_CSM_UNIT=4; function mfGfxKey(){ /* One name for every quality gate that cannot write meta.js. Prefer the live merge (qualityKey) so Advanced overrides still change the title. */ if(typeof qualityKey==='function') return qualityKey(); const q=typeof META!=='undefined'&&META.settings&&META.settings.quality; return q==='low'||q==='medium'||q==='cinematic'?q:'high'; } function mfHazeQ(){ /* Distance-haze scale. Border haze stays full strength in the shaders — that term hides the map edge and is not a quality extra. */ const q=mfGfxKey(); return q==='low'?0.70:q==='medium'?1.0:q==='cinematic'?1.36:1.16; } function mfGfxScissor(on){ /* MEDIUM/LOW skip fragment work in the HUD chrome and off-map void. HIGH/CINEMATIC keep the full target so overhang haze and tall rims are not clipped. Disable before any pass that changes viewport size (bloom is quarter-res). */ if(!on){ gl.disable(gl.SCISSOR_TEST); return; } const q=mfGfxKey(); if(q==='high'||q==='cinematic'){ gl.disable(gl.SCISSOR_TEST); return; } if(typeof camBounds!=='function'||typeof w2s!=='function'){ gl.disable(gl.SCISSOR_TEST); return; } const B=camBounds(); const pad=q==='low'?36:72; const c=[w2s(B.x0-pad,B.y0-pad),w2s(B.x1+pad,B.y0-pad),w2s(B.x0-pad,B.y1+pad),w2s(B.x1+pad,B.y1+pad)]; let sx0=1e9,sy0=1e9,sx1=-1e9,sy1=-1e9; for(let i=0;i<4;i++){ const p=c[i]; sx0=Math.min(sx0,p[0]); sy0=Math.min(sy0,p[1]); sx1=Math.max(sx1,p[0]); sy1=Math.max(sy1,p[1]); } const s=DPR||1, W=cv.width, H=cv.height; let x=Math.floor(sx0*s), y=Math.floor((VH-sy1)*s); let w=Math.ceil((sx1-sx0)*s), h=Math.ceil((sy1-sy0)*s); x=Math.max(0,x); y=Math.max(0,y); w=Math.min(W-x,Math.max(1,w)); h=Math.min(H-y,Math.max(1,h)); if(w*h<(W*H)*0.28){ gl.disable(gl.SCISSOR_TEST); return; } gl.enable(gl.SCISSOR_TEST); gl.scissor(x,y,w,h); } function mfApplyAnisoBudget(){ /* Live Advanced Aniso row. Upload-time mfAniso(8) is the HIGH default; this walks already-resident atlases so a MEDIUM/OFF tap is not a relaunch. Same pname as mfAniso — MAX_ANISOTROPY_EXT is not valid and leaves INVALID_ENUM (1280) in the error flag. Height is a data texture and is left alone. */ if(typeof gl==='undefined'||!gl||(gl.isContextLost&&gl.isContextLost())) return; const ani=gl.getExtension('EXT_texture_filter_anisotropic') ||gl.getExtension('WEBKIT_EXT_texture_filter_anisotropic'); if(!ani) return; const max=gl.getParameter(ani.MAX_TEXTURE_MAX_ANISOTROPY_EXT); const want=(typeof mfAnisoCap==='function')?mfAnisoCap():8; const n=Math.max(1, Math.min(want, max>0?max:1)); const wasUnit=gl.getParameter(gl.ACTIVE_TEXTURE); gl.activeTexture(gl.TEXTURE7); const was=gl.getParameter(gl.TEXTURE_BINDING_2D); const list=[]; if(typeof matTex!=='undefined'&&matTex) list.push(matTex); if(typeof matNrmTex!=='undefined'&&matNrmTex) list.push(matNrmTex); if(typeof matOrmTex!=='undefined'&&matOrmTex) list.push(matOrmTex); if(typeof matDamageTex!=='undefined'&&matDamageTex) list.push(matDamageTex); if(typeof matDetailTex!=='undefined'&&matDetailTex) list.push(matDetailTex); if(typeof terrainTex!=='undefined'&&terrainTex) list.push(terrainTex); if(typeof mfWorld2BaseAO!=='undefined'&&mfWorld2BaseAO) list.push(mfWorld2BaseAO); if(typeof mfWorld2NRE!=='undefined'&&mfWorld2NRE) list.push(mfWorld2NRE); if(typeof mfWorld2Masks!=='undefined'&&mfWorld2Masks) list.push(mfWorld2Masks); if(typeof mfWorld2DamageTex!=='undefined'&&mfWorld2DamageTex) list.push(mfWorld2DamageTex); if(typeof mfWorld2Detail!=='undefined'&&mfWorld2Detail) list.push(mfWorld2Detail); for(let i=0;isetTimeout(resize,50)); /* Safari fires orientationchange BEFORE the layout settles, and WKWebView resizes again when the URL bar or the home indicator area animates. */ window.addEventListener('orientationchange', ()=>setTimeout(resize,220)); if(window.visualViewport) window.visualViewport.addEventListener('resize',()=>setTimeout(resize,60)); // ---------- shaders ---------- const VS = `#version 300 es layout(location=0) in vec2 aC; layout(location=1) in vec4 aPS; layout(location=2) in vec4 aUV; layout(location=3) in float aR; layout(location=4) in vec4 aCol; layout(location=5) in float aLift; uniform vec2 uCam; uniform vec2 uSc; uniform vec2 uYaw; uniform float uPitch; out vec2 vUV; out vec4 vCol; void main(){ float c=cos(aR), s=sin(aR); vec2 p=vec2(aC.x*aPS.z, aC.y*aPS.w); p=vec2(p.x*c-p.y*s, p.x*s+p.y*c); vec2 rel=(aPS.xy+p)-uCam; // orbit the world under the camera, then tilt it away from us vec2 r2=vec2(rel.x*uYaw.x-rel.y*uYaw.y, rel.x*uYaw.y+rel.y*uYaw.x); r2.y=r2.y*uPitch - aLift*(1.0-uPitch); // tall things rise as the camera tilts vec2 n=r2*uSc; gl_Position=vec4(n.x,-n.y,0.,1.); vUV=mix(aUV.xy,aUV.zw,aC+0.5); vCol=aCol; }`; const FS = `#version 300 es precision highp float; in vec2 vUV; in vec4 vCol; uniform sampler2D uT; out vec4 o; void main(){ vec4 t=texture(uT,vUV); o=t*vCol; if(o.a<0.004) discard; }`; function mkShader(type,src){ const s=gl.createShader(type); gl.shaderSource(s,src); gl.compileShader(s); if(!gl.getShaderParameter(s,gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; } const prog = gl.createProgram(); gl.attachShader(prog, mkShader(gl.VERTEX_SHADER,VS)); gl.attachShader(prog, mkShader(gl.FRAGMENT_SHADER,FS)); gl.linkProgram(prog); if(!gl.getProgramParameter(prog,gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog)); gl.useProgram(prog); const uCam=gl.getUniformLocation(prog,'uCam'), uSc=gl.getUniformLocation(prog,'uSc'), uT=gl.getUniformLocation(prog,'uT'); const uYawL=gl.getUniformLocation(prog,'uYaw'), uPitchL=gl.getUniformLocation(prog,'uPitch'); gl.uniform2f(uYawL,1,0); gl.uniform1f(uPitchL,1); gl.uniform1i(uT,0); const quadB=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,quadB); /* Two triangles, not an instanced TRIANGLE_STRIP. ANGLE/D3D11 and several mobile drivers carry strip connectivity across instances, joining distant quads with full-screen cyan scanlines. Same layout as BBBatch. */ gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([ -.5,-.5, .5,-.5, .5,.5, -.5,-.5, .5,.5, -.5,.5 ]),gl.STATIC_DRAW); // ---------- instanced batch (LEGACY sprite path — retained only so the // atlas/minimap helpers keep compiling; nothing draws through it any more) let BILLBOARD=0; const STRIDE=44; class Batch{ constructor(cap){ this.cap=cap; this.n=0; this.buf=new ArrayBuffer(cap*STRIDE); this.f=new Float32Array(this.buf); this.b=new Uint8Array(this.buf); this.glb=gl.createBuffer(); this.vao=gl.createVertexArray(); gl.bindVertexArray(this.vao); gl.bindBuffer(gl.ARRAY_BUFFER,quadB); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0,2,gl.FLOAT,false,0,0); gl.bindBuffer(gl.ARRAY_BUFFER,this.glb); gl.bufferData(gl.ARRAY_BUFFER,this.buf.byteLength,gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,4,gl.FLOAT,false,STRIDE,0); gl.vertexAttribDivisor(1,1); gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2,4,gl.FLOAT,false,STRIDE,16); gl.vertexAttribDivisor(2,1); gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3,1,gl.FLOAT,false,STRIDE,32); gl.vertexAttribDivisor(3,1); gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4,4,gl.UNSIGNED_BYTE,true,STRIDE,36); gl.vertexAttribDivisor(4,1); gl.enableVertexAttribArray(5); gl.vertexAttribPointer(5,1,gl.FLOAT,false,STRIDE,40); gl.vertexAttribDivisor(5,1); gl.bindVertexArray(null); } add(uv,x,y,w,h,rot,r,g,b,a,lift){ if(this.n>=this.cap) return; const o=this.n*11, ob=this.n*44+36; const f=this.f; // BILLBOARD sprites stay screen-upright and screen-sized: undo the camera // tilt/orbit on the quad itself so only their POSITION moves in the world. if(BILLBOARD){ rot-=camYaw; h/=camPitch; } f[o]=x; f[o+1]=y; f[o+2]=w; f[o+3]=h; f[o+4]=uv[0]; f[o+5]=uv[1]; f[o+6]=uv[2]; f[o+7]=uv[3]; f[o+8]=rot; this.b[ob]=r; this.b[ob+1]=g; this.b[ob+2]=b; this.b[ob+3]=a; f[o+10]=lift||0; this.n++; } flush(){ if(!this.n) return; gl.bindVertexArray(this.vao); gl.bindBuffer(gl.ARRAY_BUFFER,this.glb); gl.bufferSubData(gl.ARRAY_BUFFER,0,this.b.subarray(0,this.n*STRIDE)); gl.drawArraysInstanced(gl.TRIANGLES,0,6,this.n); gl.bindVertexArray(null); this.n=0; } } const batN=new Batch(30000); const batA=new Batch(26000); const batT=new Batch(4); /* ============================================================ PROCEDURAL ART — 2048px atlas, 256px cells, pseudo-3D style ============================================================ */ const ATLAS=2048, CELL=256, PAD=20; const sprites={}; let atlasCanvas=document.createElement('canvas'); atlasCanvas.width=ATLAS; atlasCanvas.height=ATLAS; const ac=atlasCanvas.getContext('2d'); let cellIdx=0; // sprites that get the "material pass": directional key light + AO for a rendered-3D feel const SHADE=new Set(['bot','tankH','tankT','heavyH','heavyT','artyH','artyT','cdr','gun','longbow', 'hornet','titan','fac','pgen','mex','turB','turT','sgen','tgate','pyro','vulture','bulwark','rav','nest', 'corv','dread','harbor', // every hand-authored structure gets the full material treatment too 'hellB','arcB','novaB','fabB','silo','wall','carrier','relicT','relicD','relicI','relicK','tankF','crate', 'dep','depR','geyser','rock','crystal']); // procedural micro-surface used by the material pass — gives flat vector art real texture let MATNOISE=null; function matNoise(){ if(MATNOISE) return MATNOISE; const S=CELL; MATNOISE=document.createElement('canvas'); MATNOISE.width=S; MATNOISE.height=S; const c=MATNOISE.getContext('2d'), img=c.createImageData(S,S), d=img.data; for(let y=0;y0?255:0; d[o+3]=Math.min(255,Math.abs(v)*0.62); } c.putImageData(img,0,0); return MATNOISE; } function defSprite(name, fn){ const cx=(cellIdx%8)*CELL, cy=Math.floor(cellIdx/8)*CELL; cellIdx++; ac.save(); ac.translate(cx+CELL/2, cy+CELL/2); ac.beginPath(); ac.rect(-CELL/2+1,-CELL/2+1,CELL-2,CELL-2); ac.clip(); fn(ac, CELL/2-PAD); if(SHADE.has(name)){ // ---- MATERIAL PASS (clipped to sprite pixels) ---- // Turns flat vector shapes into lit, textured surfaces: surface grain, // warm key light, deep occlusion, cool bounce and a hard rim highlight. ac.globalCompositeOperation='source-atop'; // 1. micro surface texture so nothing reads as a flat fill ac.globalAlpha=0.20; ac.drawImage(matNoise(),-CELL/2,-CELL/2,CELL,CELL); ac.globalAlpha=1; // 2. warm key light from the top-left let g=ac.createLinearGradient(-CELL/2,-CELL/2,CELL/2,CELL/2); g.addColorStop(0,'rgba(255,246,222,.40)'); g.addColorStop(.30,'rgba(255,246,222,.12)'); g.addColorStop(.48,'rgba(255,246,222,0)'); ac.fillStyle=g; ac.fillRect(-CELL/2,-CELL/2,CELL,CELL); // 3. occlusion falling away to the bottom-right g=ac.createLinearGradient(CELL/2,CELL/2,-CELL/5,-CELL/5); g.addColorStop(0,'rgba(6,10,20,.52)'); g.addColorStop(.30,'rgba(6,10,20,.22)'); g.addColorStop(.62,'rgba(6,10,20,0)'); ac.fillStyle=g; ac.fillRect(-CELL/2,-CELL/2,CELL,CELL); // 4. cool sky bounce from below g=ac.createLinearGradient(0,CELL/2,0,-CELL/8); g.addColorStop(0,'rgba(96,150,210,.22)'); g.addColorStop(1,'rgba(96,150,210,0)'); ac.fillStyle=g; ac.fillRect(-CELL/2,-CELL/2,CELL,CELL); // 5. tight specular rim along the upper-left silhouette g=ac.createRadialGradient(-CELL*0.22,-CELL*0.26,CELL*0.05,-CELL*0.22,-CELL*0.26,CELL*0.5); g.addColorStop(0,'rgba(255,255,255,.30)'); g.addColorStop(.55,'rgba(255,255,255,.05)'); g.addColorStop(1,'rgba(255,255,255,0)'); ac.fillStyle=g; ac.fillRect(-CELL/2,-CELL/2,CELL,CELL); ac.globalCompositeOperation='source-over'; // 6. contact shadow beneath, drawn behind the sprite ac.globalCompositeOperation='destination-over'; ac.fillStyle='rgba(0,0,0,.34)'; ac.beginPath(); ac.ellipse(CELL*0.045,CELL*0.10,CELL*0.40,CELL*0.27,0,0,TAU); ac.fill(); ac.globalCompositeOperation='source-over'; } ac.restore(); const inset=6; sprites[name]=[(cx+inset)/ATLAS,(cy+inset)/ATLAS,(cx+CELL-inset)/ATLAS,(cy+CELL-inset)/ATLAS]; } function rg(c,x,y,r,stops){ const g=c.createRadialGradient(x,y,0,x,y,r); for(const s of stops) g.addColorStop(s[0],s[1]); return g; } function lg(c,x0,y0,x1,y1,stops){ const g=c.createLinearGradient(x0,y0,x1,y1); for(const s of stops) g.addColorStop(s[0],s[1]); return g; } // steel gradient helper function steel(c,x0,y0,x1,y1,lo,hi){ return lg(c,x0,y0,x1,y1,[[0,hi],[0.5,'#9aa7b1'],[1,lo]]); } function panelLines(c,x,y,w,h,n,alpha){ c.strokeStyle='rgba(20,26,32,'+(alpha||0.5)+')'; c.lineWidth=2; for(let i=1;i{ c.fillStyle='#fff'; c.fillRect(-80,-80,160,160); }); // ================= GROUND UNITS (nose = -y) ================= // --- STRIKER bot --- defSprite('bot', c=>{ c.fillStyle='rgba(0,0,0,.4)'; c.beginPath(); c.ellipse(6,22,62,44,0,0,TAU); c.fill(); // legs for(const s of [-1,1]){ c.fillStyle='#4b545c'; c.beginPath(); c.moveTo(s*46,-30); c.lineTo(s*78,10); c.lineTo(s*66,26); c.lineTo(s*40,-8); c.closePath(); c.fill(); c.strokeStyle='#20262c'; c.lineWidth=5; c.stroke(); c.fillStyle='#333b42'; c.beginPath(); c.ellipse(s*70,22,16,10,0,0,TAU); c.fill(); } // torso c.fillStyle=steel(c,0,-90,0,70,'#525c66','#cfdae2'); c.beginPath(); c.moveTo(0,-92); c.lineTo(50,-16); c.lineTo(38,62); c.lineTo(-38,62); c.lineTo(-50,-16); c.closePath(); c.fill(); c.strokeStyle='#171d23'; c.lineWidth=6; c.stroke(); panelLines(c,-38,-40,76,90,4,.35); // gun c.fillStyle='#272e35'; c.fillRect(-9,-118,18,54); c.fillStyle='#3c454d'; c.fillRect(-13,-122,26,14); // team core + visor c.fillStyle='#fff'; c.beginPath(); c.arc(0,6,18,0,TAU); c.fill(); c.fillStyle='rgba(255,255,255,.9)'; c.fillRect(-26,-32,52,9); rivets(c,[[-30,40],[30,40],[-40,-8],[40,-8]]); }); // --- RHINO hull --- defSprite('tankH', c=>{ c.fillStyle='rgba(0,0,0,.42)'; c.beginPath(); c.ellipse(6,18,86,64,0,0,TAU); c.fill(); // treads for(const s of [-1,1]){ c.fillStyle='#2c3238'; c.beginPath(); c.roundRect(s*58-24,-72,48,146,14); c.fill(); c.strokeStyle='#14181c'; c.lineWidth=5; c.stroke(); c.fillStyle='#191e22'; for(let i=0;i<9;i++) c.fillRect(s*58-20,-66+i*16,40,7); } // hull c.fillStyle=steel(c,-60,0,60,0,'#59636c','#c4cfd7'); c.beginPath(); c.moveTo(-52,-80); c.lineTo(52,-80); c.lineTo(64,54); c.lineTo(-64,54); c.closePath(); c.fill(); c.strokeStyle='#151a1f'; c.lineWidth=6; c.stroke(); // front plow c.fillStyle='#6b7680'; c.beginPath(); c.moveTo(-52,-80); c.lineTo(0,-96); c.lineTo(52,-80); c.closePath(); c.fill(); c.strokeStyle='#151a1f'; c.stroke(); panelLines(c,-52,-60,104,100,4,.35); // team stripes c.fillStyle='rgba(255,255,255,.92)'; c.fillRect(-46,34,92,10); c.fillRect(-46,-70,10,90); c.fillRect(36,-70,10,90); rivets(c,[[-44,-70],[44,-70],[-54,44],[54,44]]); }); // --- RHINO turret --- defSprite('tankT', c=>{ c.fillStyle='rgba(0,0,0,.3)'; c.beginPath(); c.arc(4,8,44,0,TAU); c.fill(); c.fillStyle='#2f363d'; c.fillRect(-10,-118,20,80); // barrel c.fillStyle='#454f57'; c.fillRect(-14,-124,28,18); // muzzle brake c.fillStyle=steel(c,-34,0,34,0,'#4b555e','#aeb9c2'); c.beginPath(); c.arc(0,0,38,0,TAU); c.fill(); c.strokeStyle='#151a1f'; c.lineWidth=6; c.stroke(); c.fillStyle='#5d676f'; c.beginPath(); c.arc(0,0,24,0,TAU); c.fill(); c.fillStyle='#fff'; c.beginPath(); c.arc(0,0,13,0,TAU); c.fill(); }); // --- GOLIATH hull --- defSprite('heavyH', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(8,18,100,74,0,0,TAU); c.fill(); for(const s of [-1,1]){ c.fillStyle='#262c31'; c.beginPath(); c.roundRect(s*66-28,-84,56,170,16); c.fill(); c.strokeStyle='#101418'; c.lineWidth=6; c.stroke(); c.fillStyle='#15191d'; for(let i=0;i<10;i++) c.fillRect(s*66-23,-78+i*17,46,7); } c.fillStyle=steel(c,-70,0,70,0,'#4a545d','#a8b4bd'); c.beginPath(); c.moveTo(-60,-92); c.lineTo(60,-92); c.lineTo(76,66); c.lineTo(-76,66); c.closePath(); c.fill(); c.strokeStyle='#101519'; c.lineWidth=7; c.stroke(); panelLines(c,-60,-70,120,120,5,.4); // armor wedges c.fillStyle='#6b7680'; c.beginPath(); c.moveTo(-60,-92); c.lineTo(0,-110); c.lineTo(60,-92); c.closePath(); c.fill(); c.strokeStyle='#101519'; c.stroke(); c.fillStyle='rgba(255,255,255,.92)'; c.beginPath(); c.moveTo(-26,44); c.lineTo(0,22); c.lineTo(26,44); c.lineTo(0,58); c.closePath(); c.fill(); rivets(c,[[-52,-80],[52,-80],[-64,52],[64,52],[0,-96]]); }); // --- GOLIATH turret --- defSprite('heavyT', c=>{ c.fillStyle='rgba(0,0,0,.3)'; c.beginPath(); c.arc(4,8,52,0,TAU); c.fill(); c.fillStyle='#272e34'; c.fillRect(-26,-128,18,88); c.fillRect(8,-128,18,88); // twin barrels c.fillStyle='#454f57'; c.fillRect(-30,-134,26,18); c.fillRect(4,-134,26,18); c.fillStyle=steel(c,-40,0,40,0,'#434d55','#9fabb4'); c.beginPath(); c.roundRect(-42,-40,84,80,16); c.fill(); c.strokeStyle='#12171b'; c.lineWidth=7; c.stroke(); c.fillStyle='#fff'; c.beginPath(); c.arc(0,4,15,0,TAU); c.fill(); rivets(c,[[-32,-30],[32,-30],[-32,30],[32,30]]); }); // --- THUMPER hull (artillery platform) --- defSprite('artyH', c=>{ c.fillStyle='rgba(0,0,0,.42)'; c.beginPath(); c.ellipse(6,20,90,66,0,0,TAU); c.fill(); // stabilizer legs for(const s of [-1,1]){ c.fillStyle='#3c454d'; c.beginPath(); c.moveTo(s*30,20); c.lineTo(s*86,52); c.lineTo(s*78,70); c.lineTo(s*26,42); c.closePath(); c.fill(); c.strokeStyle='#14181c'; c.lineWidth=5; c.stroke(); c.fillStyle='#20262b'; c.beginPath(); c.ellipse(s*82,62,16,10,0,0,TAU); c.fill(); // front wheels c.fillStyle='#2c3238'; c.beginPath(); c.roundRect(s*54-18,-70,36,70,12); c.fill(); c.strokeStyle='#14181c'; c.stroke(); } c.fillStyle=steel(c,-50,0,50,0,'#4d575f','#aab6bf'); c.beginPath(); c.moveTo(-44,-60); c.lineTo(44,-60); c.lineTo(58,52); c.lineTo(-58,52); c.closePath(); c.fill(); c.strokeStyle='#12171b'; c.lineWidth=6; c.stroke(); panelLines(c,-44,-40,88,80,3,.4); c.fillStyle='rgba(255,255,255,.9)'; c.fillRect(-40,36,80,9); }); // --- THUMPER barrel --- defSprite('artyT', c=>{ c.fillStyle='#20262b'; c.fillRect(-13,-130,26,120); c.fillStyle='#39424a'; c.fillRect(-17,-136,34,22); c.fillStyle='#4a545c'; c.fillRect(-10,-60,20,50); // recoil sleeve c.fillStyle=steel(c,-28,0,28,0,'#49535b','#a3afb8'); c.beginPath(); c.arc(0,10,30,0,TAU); c.fill(); c.strokeStyle='#12171b'; c.lineWidth=6; c.stroke(); c.fillStyle='#fff'; c.beginPath(); c.arc(0,10,12,0,TAU); c.fill(); }); // --- COMMANDER --- defSprite('cdr', c=>{ c.fillStyle='rgba(0,0,0,.5)'; c.beginPath(); c.ellipse(6,26,86,60,0,0,TAU); c.fill(); // legs for(const s of [-1,1]){ c.fillStyle='#4d5760'; c.beginPath(); c.moveTo(s*34,-8); c.lineTo(s*74,26); c.lineTo(s*62,66); c.lineTo(s*34,40); c.closePath(); c.fill(); c.strokeStyle='#171d22'; c.lineWidth=6; c.stroke(); c.fillStyle='#2b3238'; c.beginPath(); c.roundRect(s*68-20,54,40,24,8); c.fill(); } // shoulder pods for(const s of [-1,1]){ c.fillStyle=steel(c,s*90,-60,s*40,-20,'#4e5860','#b9c5cd'); c.beginPath(); c.roundRect(s*44+(s<0?-52:0),-72,52,54,12); c.fill(); c.strokeStyle='#12171c'; c.lineWidth=6; c.stroke(); // cannon c.fillStyle='#1b2126'; c.fillRect(s*58-9,-122,18,58); c.fillStyle='#39424a'; c.fillRect(s*58-13,-128,26,14); } // torso c.fillStyle=steel(c,0,-100,0,80,'#5a646d','#e2ecf3'); c.beginPath(); c.moveTo(0,-104); c.lineTo(46,-40); c.lineTo(36,72); c.lineTo(-36,72); c.lineTo(-46,-40); c.closePath(); c.fill(); c.strokeStyle='#10161b'; c.lineWidth=7; c.stroke(); panelLines(c,-36,-30,72,90,4,.3); // core glow c.fillStyle=rg(c,0,-4,28,[[0,'#ffffff'],[.45,'#ffffff'],[1,'rgba(255,255,255,0)']]); c.beginPath(); c.arc(0,-4,28,0,TAU); c.fill(); // head + gold visor c.fillStyle='#6a747d'; c.beginPath(); c.roundRect(-16,-84,32,26,8); c.fill(); c.strokeStyle='#10161b'; c.lineWidth=4; c.stroke(); c.fillStyle='#ffd257'; c.fillRect(-11,-77,22,8); }); // --- WASP gunship --- defSprite('gun', c=>{ c.fillStyle='rgba(0,0,0,.28)'; c.beginPath(); c.ellipse(10,34,70,36,0,0,TAU); c.fill(); // wings c.fillStyle=steel(c,0,-10,0,14,'#39424a','#78838d'); c.beginPath(); c.moveTo(-104,-2); c.lineTo(-30,-26); c.lineTo(30,-26); c.lineTo(104,-2); c.lineTo(104,14); c.lineTo(30,2); c.lineTo(-30,2); c.lineTo(-104,14); c.closePath(); c.fill(); c.strokeStyle='#13181d'; c.lineWidth=5; c.stroke(); // engines for(const s of [-1,1]){ c.fillStyle='#20262c'; c.beginPath(); c.roundRect(s*78-14,-24,28,52,10); c.fill(); c.strokeStyle='#0f1418'; c.lineWidth=4; c.stroke(); c.fillStyle=rg(c,s*78,30,14,[[0,'#bfe9ff'],[1,'rgba(90,180,255,0)']]); c.beginPath(); c.arc(s*78,30,14,0,TAU); c.fill(); } // fuselage c.fillStyle=steel(c,0,-90,0,70,'#525c66','#d3dee6'); c.beginPath(); c.moveTo(0,-92); c.lineTo(30,-16); c.lineTo(22,66); c.lineTo(-22,66); c.lineTo(-30,-16); c.closePath(); c.fill(); c.strokeStyle='#11171c'; c.lineWidth=6; c.stroke(); // cockpit c.fillStyle=lg(c,0,-70,0,-30,[[0,'#cfefff'],[1,'#3a7ca8']]); c.beginPath(); c.moveTo(0,-72); c.lineTo(18,-34); c.lineTo(-18,-34); c.closePath(); c.fill(); c.fillStyle='#fff'; c.beginPath(); c.arc(0,20,13,0,TAU); c.fill(); }); // --- LONGBOW sniper walker --- defSprite('longbow', c=>{ c.fillStyle='rgba(0,0,0,.4)'; c.beginPath(); c.ellipse(6,22,74,50,0,0,TAU); c.fill(); for(const s of [-1,1]){ c.fillStyle='#454f57'; c.beginPath(); c.moveTo(s*30,-16); c.lineTo(s*80,18); c.lineTo(s*68,40); c.lineTo(s*28,10); c.closePath(); c.fill(); c.strokeStyle='#171d22'; c.lineWidth=5; c.stroke(); } // rail gun — very long c.fillStyle='#1d2328'; c.fillRect(-7,-146,14,120); c.fillStyle='#41c8ff'; c.fillRect(-2,-140,4,108); // rail glow slit c.fillStyle='#39424a'; c.fillRect(-12,-152,24,14); // body c.fillStyle=steel(c,-40,0,40,0,'#4c565e','#b4c0c9'); c.beginPath(); c.roundRect(-40,-30,80,78,18); c.fill(); c.strokeStyle='#12171b'; c.lineWidth=6; c.stroke(); // scope dish c.fillStyle='#2b3238'; c.beginPath(); c.arc(30,-18,17,0,TAU); c.fill(); c.fillStyle='#9fe8ff'; c.beginPath(); c.arc(30,-18,8,0,TAU); c.fill(); c.fillStyle='#fff'; c.beginPath(); c.arc(-16,16,12,0,TAU); c.fill(); }); // --- HORNET rocket buggy --- defSprite('hornet', c=>{ c.fillStyle='rgba(0,0,0,.4)'; c.beginPath(); c.ellipse(6,20,80,56,0,0,TAU); c.fill(); for(const s of [-1,1]){ c.fillStyle='#20262b'; c.beginPath(); c.ellipse(s*58,-38,16,22,0,0,TAU); c.fill(); c.beginPath(); c.ellipse(s*62,42,18,24,0,0,TAU); c.fill(); c.strokeStyle='#0f1317'; c.lineWidth=5; c.beginPath(); c.ellipse(s*58,-38,16,22,0,0,TAU); c.stroke(); c.beginPath(); c.ellipse(s*62,42,18,24,0,0,TAU); c.stroke(); } c.fillStyle=steel(c,-46,0,46,0,'#535d66','#c0ccd4'); c.beginPath(); c.moveTo(-38,-72); c.lineTo(38,-72); c.lineTo(52,58); c.lineTo(-52,58); c.closePath(); c.fill(); c.strokeStyle='#12171b'; c.lineWidth=6; c.stroke(); // rocket pod (2x3 tubes) c.fillStyle='#2b3238'; c.beginPath(); c.roundRect(-34,-46,68,64,10); c.fill(); c.strokeStyle='#0f1418'; c.lineWidth=4; c.stroke(); c.fillStyle='#0e1418'; for(let i=0;i<3;i++) for(let j=0;j<2;j++){ c.beginPath(); c.arc(-16+j*32,-32+i*22,9,0,TAU); c.fill(); } c.fillStyle='#ff8b5e'; for(let i=0;i<3;i++) for(let j=0;j<2;j++){ c.beginPath(); c.arc(-16+j*32,-32+i*22,4,0,TAU); c.fill(); } c.fillStyle='rgba(255,255,255,.9)'; c.fillRect(-44,44,88,9); }); // --- TITAN experimental --- defSprite('titan', c=>{ c.fillStyle='rgba(0,0,0,.5)'; c.beginPath(); c.ellipse(6,20,104,84,0,0,TAU); c.fill(); // 4 legs for(const q of [[-1,-1],[1,-1],[-1,1],[1,1]]){ const sx=q[0], sy=q[1]; c.fillStyle='#3c454d'; c.beginPath(); c.moveTo(sx*30,sy*20); c.lineTo(sx*96,sy*64); c.lineTo(sx*80,sy*88); c.lineTo(sx*24,sy*44); c.closePath(); c.fill(); c.strokeStyle='#12171b'; c.lineWidth=7; c.stroke(); c.fillStyle='#20262b'; c.beginPath(); c.ellipse(sx*90,sy*76,18,13,0,0,TAU); c.fill(); // knee armor c.fillStyle='#59636c'; c.beginPath(); c.arc(sx*62,sy*48,15,0,TAU); c.fill(); c.strokeStyle='#12171b'; c.lineWidth=4; c.stroke(); } // saucer body c.fillStyle=steel(c,0,-80,0,80,'#4a545d','#c9d5dd'); c.beginPath(); c.ellipse(0,0,74,64,0,0,TAU); c.fill(); c.strokeStyle='#0e1317'; c.lineWidth=8; c.stroke(); c.fillStyle='#39424a'; c.beginPath(); c.ellipse(0,0,52,44,0,0,TAU); c.fill(); c.strokeStyle='#171d22'; c.lineWidth=4; c.stroke(); // twin cannon nubs front c.fillStyle='#1b2126'; c.fillRect(-30,-92,20,44); c.fillRect(10,-92,20,44); // big core c.fillStyle=rg(c,0,0,34,[[0,'#ffffff'],[.5,'#ffffff'],[1,'rgba(255,255,255,0)']]); c.beginPath(); c.arc(0,0,34,0,TAU); c.fill(); rivets(c,[[-58,-30],[58,-30],[-58,30],[58,30],[0,54]]); }); // ================= BUILDINGS (pseudo-3D: roof + front wall) ================= // --- FACTORY --- defSprite('fac', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(8,64,110,44,0,0,TAU); c.fill(); // front wall c.fillStyle=lg(c,0,40,0,96,[[0,'#5a646d'],[1,'#31383f']]); c.beginPath(); c.roundRect(-96,36,192,60,8); c.fill(); c.strokeStyle='#12171b'; c.lineWidth=6; c.stroke(); // bay door on wall c.fillStyle='#181d22'; c.fillRect(-56,44,112,44); c.fillStyle='#252c33'; for(let i=0;i<4;i++) c.fillRect(-56,44+i*11,112,5); // hazard strip c.save(); c.beginPath(); c.rect(-96,88,192,10); c.clip(); for(let i=-6;i<7;i++){ c.fillStyle=i%2?'#e6c145':'#1c2126'; c.beginPath(); c.moveTo(i*24,88); c.lineTo(i*24+16,88); c.lineTo(i*24+4,98); c.lineTo(i*24-12,98); c.fill(); } c.restore(); // roof c.fillStyle=lg(c,0,-96,0,44,[[0,'#96a2ac'],[1,'#565f68']]); c.beginPath(); c.roundRect(-100,-96,200,136,14); c.fill(); c.strokeStyle='#10151a'; c.lineWidth=7; c.stroke(); // roof bay c.fillStyle='#1c2227'; c.beginPath(); c.roundRect(-64,-64,128,88,8); c.fill(); c.fillStyle='#2e363d'; for(let i=0;i<4;i++) c.fillRect(-64,-64+i*22,128,9); // roof vents + lights c.fillStyle='#434d55'; c.beginPath(); c.roundRect(-92,-88,44,26,6); c.fill(); c.beginPath(); c.roundRect(48,-88,44,26,6); c.fill(); c.fillStyle='#9fe8ff'; c.fillRect(-70,-32,8,52); c.fillRect(62,-32,8,52); // team strip c.fillStyle='#fff'; c.fillRect(-100,-96,200,12); }); // --- REACTOR --- defSprite('pgen', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(6,58,92,38,0,0,TAU); c.fill(); // base skirt c.fillStyle=lg(c,0,30,0,86,[[0,'#525c66'],[1,'#2c3339']]); c.beginPath(); c.roundRect(-80,28,160,58,12); c.fill(); c.strokeStyle='#11161b'; c.lineWidth=6; c.stroke(); c.fillStyle='#e6c145'; c.fillRect(-80,74,160,8); // dome c.fillStyle=rg(c,-20,-30,110,[[0,'#aeb9c2'],[.7,'#5c666f'],[1,'#3a4249']]); c.beginPath(); c.arc(0,-6,84,0,TAU); c.fill(); c.strokeStyle='#10151a'; c.lineWidth=7; c.stroke(); // coil ring c.strokeStyle='#c8d2da'; c.lineWidth=9; for(let i=0;i<4;i++){ const a=i*Math.PI/2+Math.PI/4; c.beginPath(); c.arc(0,-6,66,a-.4,a+.4); c.stroke(); } // core c.fillStyle=rg(c,0,-6,52,[[0,'#fff9df'],[.4,'#ffd257'],[1,'rgba(255,150,40,0)']]); c.beginPath(); c.arc(0,-6,52,0,TAU); c.fill(); c.fillStyle='#23282d'; c.beginPath(); c.arc(0,-6,20,0,TAU); c.fill(); c.fillStyle='#ffe9a8'; c.beginPath(); c.arc(0,-6,10,0,TAU); c.fill(); c.fillStyle='#fff'; c.beginPath(); c.arc(0,-78,10,0,TAU); c.fill(); }); // --- EXTRACTOR --- defSprite('mex', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(6,56,90,36,0,0,TAU); c.fill(); // base hex platform + wall c.fillStyle=lg(c,0,20,0,80,[[0,'#4c565e'],[1,'#292f35']]); c.beginPath(); for(let i=0;i<6;i++){ const a=i*TAU/6-Math.PI/2; const x=Math.cos(a)*84,y=Math.sin(a)*66+14; i?c.lineTo(x,y):c.moveTo(x,y); } c.closePath(); c.fill(); c.strokeStyle='#10151a'; c.lineWidth=6; c.stroke(); // top plate c.fillStyle=lg(c,0,-80,0,40,[[0,'#8b98a2'],[1,'#4e585f']]); c.beginPath(); for(let i=0;i<6;i++){ const a=i*TAU/6-Math.PI/2; const x=Math.cos(a)*78,y=Math.sin(a)*60-10; i?c.lineTo(x,y):c.moveTo(x,y); } c.closePath(); c.fill(); c.strokeStyle='#10151a'; c.lineWidth=6; c.stroke(); // pipes c.strokeStyle='#39424a'; c.lineWidth=12; c.beginPath(); c.moveTo(-58,-24); c.lineTo(-84,4); c.stroke(); c.beginPath(); c.moveTo(58,-24); c.lineTo(84,4); c.stroke(); // drill glow c.fillStyle=rg(c,0,-12,44,[[0,'#e9ffe9'],[.4,'#5dff9a'],[1,'rgba(40,180,90,0)']]); c.beginPath(); c.arc(0,-12,44,0,TAU); c.fill(); c.fillStyle='#20262b'; c.beginPath(); c.arc(0,-12,18,0,TAU); c.fill(); c.fillStyle='#8dffba'; c.beginPath(); c.arc(0,-12,8,0,TAU); c.fill(); c.fillStyle='#fff'; c.fillRect(-7,-92,14,18); }); // --- SENTINEL base --- defSprite('turB', c=>{ c.fillStyle='rgba(0,0,0,.42)'; c.beginPath(); c.ellipse(4,46,78,32,0,0,TAU); c.fill(); c.fillStyle=lg(c,0,16,0,68,[[0,'#4c565e'],[1,'#282e34']]); c.beginPath(); for(let i=0;i<8;i++){ const a=i*TAU/8-Math.PI/8; const x=Math.cos(a)*74,y=Math.sin(a)*58+12; i?c.lineTo(x,y):c.moveTo(x,y); } c.closePath(); c.fill(); c.strokeStyle='#0f1418'; c.lineWidth=6; c.stroke(); c.fillStyle=lg(c,0,-64,0,30,[[0,'#8b98a2'],[1,'#525c64']]); c.beginPath(); for(let i=0;i<8;i++){ const a=i*TAU/8-Math.PI/8; const x=Math.cos(a)*68,y=Math.sin(a)*52-8; i?c.lineTo(x,y):c.moveTo(x,y); } c.closePath(); c.fill(); c.strokeStyle='#0f1418'; c.lineWidth=6; c.stroke(); c.fillStyle='#252c32'; c.beginPath(); c.arc(0,-8,34,0,TAU); c.fill(); rivets(c,[[-52,-32],[52,-32],[-52,20],[52,20]]); }); // --- SENTINEL gun --- defSprite('turT', c=>{ c.fillStyle='#1d2328'; c.fillRect(-16,-120,13,84); c.fillRect(3,-120,13,84); c.fillStyle='#5dff9a'; c.fillRect(-12,-114,5,72); c.fillRect(7,-114,5,72); // green energy rails c.fillStyle='#39424a'; c.fillRect(-20,-126,40,16); c.fillStyle=steel(c,-30,0,30,0,'#49535b','#a8b4bd'); c.beginPath(); c.roundRect(-32,-38,64,72,16); c.fill(); c.strokeStyle='#10151a'; c.lineWidth=6; c.stroke(); c.fillStyle='#5dff9a'; c.beginPath(); c.arc(0,0,12,0,TAU); c.fill(); }); // --- AEGIS repair station --- defSprite('sgen', c=>{ c.fillStyle='rgba(0,0,0,.42)'; c.beginPath(); c.ellipse(4,50,80,34,0,0,TAU); c.fill(); c.fillStyle=lg(c,0,20,0,72,[[0,'#4c565e'],[1,'#292f35']]); c.beginPath(); c.roundRect(-72,24,144,50,10); c.fill(); c.strokeStyle='#10151a'; c.lineWidth=6; c.stroke(); c.fillStyle=lg(c,0,-70,0,30,[[0,'#8b98a2'],[1,'#4e585f']]); c.beginPath(); c.roundRect(-76,-70,152,96,14); c.fill(); c.strokeStyle='#10151a'; c.lineWidth=6; c.stroke(); // emitter dish c.fillStyle=rg(c,0,-22,54,[[0,'#e6fff1'],[.4,'#54e090'],[1,'rgba(40,180,110,0)']]); c.beginPath(); c.arc(0,-22,54,0,TAU); c.fill(); c.strokeStyle='#c8d2da'; c.lineWidth=7; c.beginPath(); c.arc(0,-22,40,0,TAU); c.stroke(); c.beginPath(); c.arc(0,-22,26,0,TAU); c.stroke(); c.fillStyle='#fff'; c.beginPath(); c.arc(0,-22,10,0,TAU); c.fill(); }); // --- TITAN GATE --- defSprite('tgate', c=>{ c.fillStyle='rgba(0,0,0,.5)'; c.beginPath(); c.ellipse(8,70,116,42,0,0,TAU); c.fill(); // side towers for(const s of [-1,1]){ c.fillStyle=steel(c,s*104,0,s*60,0,'#39424a','#7d8892'); c.beginPath(); c.roundRect(s*66+(s<0?-40:0),-96,40,190,10); c.fill(); c.strokeStyle='#0e1317'; c.lineWidth=7; c.stroke(); c.fillStyle='#ff8b5e'; c.beginPath(); c.arc(s*86,-84,8,0,TAU); c.fill(); } // crossbar roof c.fillStyle=lg(c,0,-100,0,-30,[[0,'#96a2ac'],[1,'#565f68']]); c.beginPath(); c.roundRect(-102,-100,204,52,10); c.fill(); c.strokeStyle='#0e1317'; c.lineWidth=7; c.stroke(); c.fillStyle='#fff'; c.fillRect(-102,-100,204,10); // inner energy arch c.fillStyle=rg(c,0,20,80,[[0,'rgba(180,240,255,.95)'],[.5,'rgba(90,190,255,.5)'],[1,'rgba(60,140,255,0)']]); c.beginPath(); c.rect(-62,-48,124,140); c.fill(); // hazard base c.save(); c.beginPath(); c.rect(-102,84,204,12); c.clip(); for(let i=-6;i<7;i++){ c.fillStyle=i%2?'#e6c145':'#1c2126'; c.beginPath(); c.moveTo(i*26,84); c.lineTo(i*26+18,84); c.lineTo(i*26+6,96); c.lineTo(i*26-12,96); c.fill(); } c.restore(); }); // ================= FX / DOODADS ================= defSprite('ring', c=>{ c.strokeStyle='#fff'; c.lineWidth=12; c.beginPath(); c.arc(0,0,96,0,TAU); c.stroke(); c.globalAlpha=.4; c.lineWidth=26; c.beginPath(); c.arc(0,0,96,0,TAU); c.stroke(); }); defSprite('glow', c=>{ c.fillStyle=rg(c,0,0,110,[[0,'rgba(255,255,255,1)'],[.25,'rgba(255,255,255,.6)'],[1,'rgba(255,255,255,0)']]); c.beginPath(); c.arc(0,0,110,0,TAU); c.fill(); }); defSprite('bolt', c=>{ const g=lg(c,0,-100,0,100,[[0,'rgba(255,255,255,0)'],[.3,'rgba(255,255,255,1)'],[.7,'rgba(255,255,255,1)'],[1,'rgba(255,255,255,0)']]); c.fillStyle=g; c.beginPath(); c.ellipse(0,0,20,104,0,0,TAU); c.fill(); c.fillStyle='rgba(255,255,255,.95)'; c.beginPath(); c.ellipse(0,0,8,84,0,0,TAU); c.fill(); }); defSprite('beam', c=>{ // uniform-width beam segment (stretchable) const g=lg(c,-20,0,20,0,[[0,'rgba(255,255,255,0)'],[.5,'rgba(255,255,255,1)'],[1,'rgba(255,255,255,0)']]); c.fillStyle=g; c.fillRect(-20,-108,40,216); c.fillStyle='rgba(255,255,255,.95)'; c.fillRect(-6,-108,12,216); }); defSprite('smoke', c=>{ c.fillStyle=rg(c,0,0,100,[[0,'rgba(200,205,210,.8)'],[.6,'rgba(130,135,140,.4)'],[1,'rgba(110,115,120,0)']]); c.beginPath(); c.arc(0,0,100,0,TAU); c.fill(); for(let i=0;i<6;i++){ const a=i*TAU/6; c.fillStyle='rgba(170,175,180,.3)'; c.beginPath(); c.arc(Math.cos(a)*48,Math.sin(a)*48,44,0,TAU); c.fill(); } }); defSprite('fireball', c=>{ c.fillStyle=rg(c,0,0,104,[[0,'#fffbe8'],[.25,'#ffd257'],[.55,'#ff8b3a'],[.8,'rgba(220,70,30,.6)'],[1,'rgba(140,40,20,0)']]); c.beginPath(); c.arc(0,0,104,0,TAU); c.fill(); for(let i=0;i<7;i++){ const a=i*TAU/7+0.4, d=52+((i*37)%30); c.fillStyle='rgba(255,150,60,.55)'; c.beginPath(); c.arc(Math.cos(a)*d,Math.sin(a)*d,30,0,TAU); c.fill(); } }); defSprite('debris', c=>{ const g=lg(c,0,-60,0,60,[[0,'rgba(255,240,200,0)'],[.4,'#ffcf7a'],[1,'#ff7030']]); c.fillStyle=g; c.beginPath(); c.moveTo(0,-64); c.lineTo(14,10); c.lineTo(0,64); c.lineTo(-14,10); c.closePath(); c.fill(); }); defSprite('crater', c=>{ c.fillStyle=rg(c,0,0,104,[[0,'rgba(8,8,10,.85)'],[.55,'rgba(18,16,14,.55)'],[1,'rgba(18,16,14,0)']]); c.beginPath(); c.arc(0,0,104,0,TAU); c.fill(); c.fillStyle='rgba(55,45,36,.5)'; for(let i=0;i<9;i++){ const a=rnd()*TAU, d=36+rnd()*54; c.beginPath(); c.arc(Math.cos(a)*d,Math.sin(a)*d,7+rnd()*11,0,TAU); c.fill(); } }); defSprite('rock', c=>{ c.fillStyle='rgba(0,0,0,.35)'; c.beginPath(); c.ellipse(8,22,76,50,0,0,TAU); c.fill(); const g=lg(c,-60,-60,60,60,[[0,'#8a9098'],[1,'#3f444b']]); c.fillStyle=g; c.beginPath(); c.moveTo(-72,20); c.lineTo(-44,-52); c.lineTo(16,-68); c.lineTo(72,-12); c.lineTo(56,44); c.lineTo(-24,56); c.closePath(); c.fill(); c.strokeStyle='#20242a'; c.lineWidth=6; c.stroke(); c.fillStyle='rgba(255,255,255,.18)'; c.beginPath(); c.moveTo(-44,-52); c.lineTo(16,-68); c.lineTo(28,-28); c.lineTo(-28,-16); c.closePath(); c.fill(); c.fillStyle='rgba(90,120,70,.4)'; c.beginPath(); c.ellipse(-30,36,26,12,0,0,TAU); c.fill(); }); defSprite('tree', c=>{ c.fillStyle='rgba(0,0,0,.4)'; c.beginPath(); c.ellipse(14,26,64,36,0,0,TAU); c.fill(); const blob=(x,y,r,c1,c2)=>{ ac.fillStyle=rg(ac,x-r*0.3,y-r*0.3,r*1.4,[[0,c1],[1,c2]]); ac.beginPath(); ac.arc(x,y,r,0,TAU); ac.fill(); }; blob(-26,10,42,'#4e7a34','#2c4a20'); blob(30,16,38,'#517e36','#2c4a20'); blob(2,-26,46,'#5f9040','#35571f'); blob(-8,-2,30,'#6ba24b','#3a6124'); c.fillStyle='rgba(190,230,140,.45)'; c.beginPath(); c.arc(-14,-34,12,0,TAU); c.fill(); c.beginPath(); c.arc(16,-18,9,0,TAU); c.fill(); }); defSprite('crystal', c=>{ c.fillStyle='rgba(0,0,0,.3)'; c.beginPath(); c.ellipse(6,34,60,26,0,0,TAU); c.fill(); const shard=(x,y,w,h,rot)=>{ ac.save(); ac.translate(x,y); ac.rotate(rot); ac.fillStyle=lg(ac,-w,0,w,0,[[0,'#2f8f55'],[.5,'#a6ffcd'],[1,'#2f8f55']]); ac.beginPath(); ac.moveTo(0,-h); ac.lineTo(w,0); ac.lineTo(0,h*0.5); ac.lineTo(-w,0); ac.closePath(); ac.fill(); ac.strokeStyle='#83ffb5'; ac.lineWidth=4; ac.stroke(); ac.restore(); }; shard(-28,14,20,52,-0.3); shard(30,18,17,44,0.35); shard(0,-6,24,66,0.05); c.fillStyle=rg(c,0,0,90,[[0,'rgba(120,255,180,.35)'],[1,'rgba(120,255,180,0)']]); c.beginPath(); c.arc(0,0,90,0,TAU); c.fill(); }); defSprite('cloud', c=>{ for(let i=0;i<9;i++){ const a=i*TAU/9, d=(i%3)*26; c.fillStyle='rgba(255,255,255,.16)'; c.beginPath(); c.arc(Math.cos(a)*d*1.6,Math.sin(a)*d,66-((i*17)%28),0,TAU); c.fill(); } c.fillStyle=rg(c,0,0,104,[[0,'rgba(255,255,255,.5)'],[1,'rgba(255,255,255,0)']]); c.beginPath(); c.arc(0,0,104,0,TAU); c.fill(); }); defSprite('wreck', c=>{ c.fillStyle='#262b30'; c.beginPath(); c.moveTo(-68,-20); c.lineTo(-8,-60); c.lineTo(60,-28); c.lineTo(68,24); c.lineTo(4,60); c.lineTo(-56,36); c.closePath(); c.fill(); c.strokeStyle='#101418'; c.lineWidth=6; c.stroke(); c.fillStyle='#39424a'; c.fillRect(-36,-24,44,32); c.fillStyle='rgba(255,120,40,.7)'; c.beginPath(); c.arc(16,8,9,0,TAU); c.fill(); c.fillStyle='rgba(255,180,80,.4)'; c.beginPath(); c.arc(-20,-30,6,0,TAU); c.fill(); }); defSprite('dep', c=>{ c.fillStyle='rgba(0,0,0,.32)'; c.beginPath(); c.ellipse(0,12,82,58,0,0,TAU); c.fill(); c.fillStyle=rg(c,0,0,88,[[0,'rgba(93,255,154,.42)'],[1,'rgba(93,255,154,0)']]); c.beginPath(); c.arc(0,0,88,0,TAU); c.fill(); // rocky collar the crystals erupt from c.fillStyle='#3c4a42'; c.beginPath(); for(let a=0;a<9;a++){ const th=a*TAU/9, r=52+((a*37)%13)*2.2; c[a?'lineTo':'moveTo'](Math.cos(th)*r,Math.sin(th)*r*0.78+10); } c.closePath(); c.fill(); c.strokeStyle='#232c27'; c.lineWidth=6; c.stroke(); // crystal cluster: several faceted shards at different heights const shard=(x,y,w,h,lit,dark)=>{ c.fillStyle=dark; c.beginPath(); c.moveTo(x,y-h); c.lineTo(x+w,y); c.lineTo(x,y+h*0.42); c.lineTo(x-w,y); c.closePath(); c.fill(); c.fillStyle=lit; c.beginPath(); c.moveTo(x,y-h); c.lineTo(x+w*0.42,y-h*0.1); c.lineTo(x,y+h*0.42); c.closePath(); c.fill(); c.strokeStyle='rgba(230,255,240,.55)'; c.lineWidth=3; c.beginPath(); c.moveTo(x,y-h); c.lineTo(x,y+h*0.42); c.stroke(); }; shard(-30,14,20,44,'#8dffbe','#2c8a52'); shard(30,18,17,36,'#7bf5b0','#25794a'); shard(2,4,28,66,'#c9ffe0','#37a463'); c.fillStyle='rgba(190,255,215,.55)'; // motes for(const p of [[-46,-12],[44,-6],[12,-52],[-14,30]]){ c.beginPath(); c.arc(p[0],p[1],4.2,0,TAU); c.fill(); } }); // --- RICH vein (gold, 2x yield) --- defSprite('depR', c=>{ c.fillStyle='rgba(0,0,0,.34)'; c.beginPath(); c.ellipse(0,12,86,60,0,0,TAU); c.fill(); c.fillStyle=rg(c,0,0,94,[[0,'rgba(255,210,87,.48)'],[1,'rgba(255,210,87,0)']]); c.beginPath(); c.arc(0,0,94,0,TAU); c.fill(); c.fillStyle='#4a4132'; // ore-veined bedrock c.beginPath(); for(let a=0;a<10;a++){ const th=a*TAU/10, r=56+((a*29)%15)*2.4; c[a?'lineTo':'moveTo'](Math.cos(th)*r,Math.sin(th)*r*0.78+10); } c.closePath(); c.fill(); c.strokeStyle='#2a2419'; c.lineWidth=6; c.stroke(); c.strokeStyle='rgba(255,210,87,.5)'; c.lineWidth=4; // glittering veins for(const v of [[-44,26,-8,-12],[40,20,6,-18],[-16,36,20,4]]){ c.beginPath(); c.moveTo(v[0],v[1]); c.quadraticCurveTo(v[0]/2,v[1]/2,v[2],v[3]); c.stroke(); } const shard=(x,y,w,h,lit,dark)=>{ c.fillStyle=dark; c.beginPath(); c.moveTo(x,y-h); c.lineTo(x+w,y); c.lineTo(x,y+h*0.42); c.lineTo(x-w,y); c.closePath(); c.fill(); c.fillStyle=lit; c.beginPath(); c.moveTo(x,y-h); c.lineTo(x+w*0.42,y-h*0.1); c.lineTo(x,y+h*0.42); c.closePath(); c.fill(); c.strokeStyle='rgba(255,246,214,.6)'; c.lineWidth=3; c.beginPath(); c.moveTo(x,y-h); c.lineTo(x,y+h*0.42); c.stroke(); }; shard(-32,12,22,50,'#ffe9a3','#a87e2c'); shard(33,18,19,40,'#ffdd82','#96701f'); shard(1,0,30,74,'#fff6d8','#c89a34'); c.fillStyle='rgba(255,236,170,.6)'; for(const p of [[-50,-14],[48,-8],[14,-58],[-16,32]]){ c.beginPath(); c.arc(p[0],p[1],4.6,0,TAU); c.fill(); } }); // --- ENERGY GEYSER (rock collar + steam). The old concentric ellipses + // 5 rim arcs WAS the metal iris on the phone APK. --- defSprite('geyser', c=>{ c.fillStyle='rgba(0,0,0,.32)'; c.beginPath(); c.ellipse(0,10,80,58,0,0,TAU); c.fill(); c.fillStyle=rg(c,0,-8,70,[[0,'rgba(165,225,250,.40)'],[1,'rgba(165,225,250,0)']]); c.beginPath(); c.arc(0,-6,78,0,TAU); c.fill(); c.fillStyle='#4a453c'; c.beginPath(); for(let a=0;a<9;a++){ const th=a*TAU/9, r=54+((a*41)%11)*2.0; c[a?'lineTo':'moveTo'](Math.cos(th)*r,Math.sin(th)*r*0.76+12); } c.closePath(); c.fill(); c.strokeStyle='#2c2822'; c.lineWidth=6; c.stroke(); c.fillStyle='#6a6356'; c.beginPath(); for(let a=0;a<7;a++){ const th=a*TAU/7+0.3, r=28+((a*23)%9); c[a?'lineTo':'moveTo'](Math.cos(th)*r,Math.sin(th)*r*0.72+6); } c.closePath(); c.fill(); c.fillStyle=rg(c,0,-18,28,[[0,'#e8f7ff'],[0.45,'#9ad8e8'],[1,'rgba(80,140,150,.15)']]); c.beginPath(); c.ellipse(0,-22,18,28,0,0,TAU); c.fill(); c.fillStyle='rgba(200,236,246,.55)'; for(const p of [[-10,-48],[12,-56],[2,-68]]){ c.beginPath(); c.arc(p[0],p[1],7+p[1]*-0.04,0,TAU); c.fill(); } }); // --- WALL segment (drawn near-white → team tint) --- defSprite('wall', c=>{ c.fillStyle='rgba(0,0,0,.4)'; c.fillRect(-86,-74,180,168); c.fillStyle='#5a646e'; c.fillRect(-92,-92,184,184); c.fillStyle=lg(c,0,-92,0,92,[[0,'#e8eef4'],[0.5,'#b8c2cc'],[1,'#77828c']]); c.fillRect(-80,-80,160,160); c.strokeStyle='#20262c'; c.lineWidth=8; c.strokeRect(-80,-80,160,160); c.fillStyle='#f4f8fb'; c.fillRect(-64,-64,128,26); // top bevel highlight c.fillStyle='rgba(30,38,46,.5)'; c.fillRect(-64,42,128,22); c.fillStyle='#9aa6b0'; // studs for(const p of [[-52,-8],[0,-8],[52,-8]]){ c.beginPath(); c.arc(p[0],p[1],13,0,TAU); c.fill(); c.strokeStyle='#2a323a'; c.lineWidth=5; c.stroke(); } c.fillStyle='rgba(255,255,255,.9)'; c.fillRect(-80,-6,12,12); c.fillRect(68,-6,12,12); // link tabs }); // --- SILO storage tanks (near-white → team tint) --- defSprite('silo', c=>{ c.fillStyle='rgba(0,0,0,.4)'; c.beginPath(); c.ellipse(8,14,84,66,0,0,TAU); c.fill(); c.fillStyle='#4a545e'; c.beginPath(); c.roundRect(-88,-80,176,164,18); c.fill(); c.strokeStyle='#1a2129'; c.lineWidth=7; c.stroke(); for(const p of [[-44,-36],[44,-36],[-44,44],[44,44]]){ c.fillStyle=rg(c,p[0]-10,p[1]-12,44,[[0,'#e8eef4'],[0.55,'#a8b4be'],[1,'#5a646e']]); c.beginPath(); c.arc(p[0],p[1],36,0,TAU); c.fill(); c.strokeStyle='#20262c'; c.lineWidth=5; c.stroke(); c.fillStyle='rgba(255,255,255,.85)'; c.beginPath(); c.arc(p[0],p[1],10,0,TAU); c.fill(); } c.strokeStyle='#2a323a'; c.lineWidth=8; c.beginPath(); c.moveTo(-44,-36); c.lineTo(44,44); c.moveTo(44,-36); c.lineTo(-44,44); c.stroke(); }); // --- HELLSTORM rotary flak tower (near-white → team tint) --- defSprite('hellB', c=>{ c.fillStyle='rgba(0,0,0,.42)'; c.beginPath(); c.ellipse(8,14,86,68,0,0,TAU); c.fill(); c.fillStyle='#4a545e'; c.beginPath(); for(let a=0;a<6;a++){ const th=a*TAU/6+0.26; c[a?'lineTo':'moveTo'](Math.cos(th)*88,Math.sin(th)*88); } c.closePath(); c.fill(); c.strokeStyle='#1a2129'; c.lineWidth=7; c.stroke(); c.fillStyle=lg(c,-60,-60,60,60,[[0,'#dde6ee'],[0.5,'#a8b4be'],[1,'#68727c']]); c.beginPath(); c.arc(0,0,58,0,TAU); c.fill(); c.strokeStyle='#20262c'; c.lineWidth=6; c.stroke(); for(const a of [0,TAU/4,TAU/2,TAU*3/4]){ // quad rotary barrels const bx3=Math.cos(a+0.4)*26, by3=Math.sin(a+0.4)*26; c.save(); c.translate(bx3,by3); c.rotate(a+0.4); c.fillStyle='#2a323a'; c.fillRect(-7,-58,14,52); c.fillStyle='#8a96a0'; c.fillRect(-4,-58,8,52); c.restore(); } c.fillStyle='#fff'; c.beginPath(); c.arc(0,0,16,0,TAU); c.fill(); c.fillStyle='#20262c'; c.beginPath(); c.arc(0,0,8,0,TAU); c.fill(); }); // --- ARC PYLON tesla coil --- defSprite('arcB', c=>{ c.fillStyle='rgba(0,0,0,.4)'; c.beginPath(); c.ellipse(6,16,74,58,0,0,TAU); c.fill(); c.fillStyle='#3c4650'; c.beginPath(); c.arc(0,10,64,0,TAU); c.fill(); c.strokeStyle='#171d23'; c.lineWidth=7; c.stroke(); for(let k=0;k<4;k++){ // coil stack const w=52-k*9, y=6-k*20; c.fillStyle=k%2?'#8a96a0':'#c8d2da'; c.beginPath(); c.ellipse(0,y,w,w*0.42,0,0,TAU); c.fill(); c.strokeStyle='#20262c'; c.lineWidth=4; c.stroke(); } c.fillStyle=rg(c,0,-66,26,[[0,'#eaffff'],[0.4,'#41c8ff'],[1,'rgba(20,90,140,.8)']]); c.beginPath(); c.arc(0,-62,22,0,TAU); c.fill(); c.strokeStyle='rgba(200,240,255,.9)'; c.lineWidth=3; for(const a of [0.5,2.2,4.1]){ c.beginPath(); c.moveTo(Math.cos(a)*20,-62+Math.sin(a)*20); c.lineTo(Math.cos(a)*40,-62+Math.sin(a)*34); c.stroke(); } }); // --- NOVA CANNON superweapon --- defSprite('novaB', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(8,16,92,74,0,0,TAU); c.fill(); c.fillStyle='#414b55'; c.beginPath(); for(let a=0;a<8;a++){ const th=a*TAU/8+0.4; c[a?'lineTo':'moveTo'](Math.cos(th)*92,Math.sin(th)*92); } c.closePath(); c.fill(); c.strokeStyle='#161c22'; c.lineWidth=8; c.stroke(); c.fillStyle=lg(c,-70,-70,70,70,[[0,'#d4dde6'],[0.5,'#98a4ae'],[1,'#5a646e']]); c.beginPath(); for(let a=0;a<8;a++){ const th=a*TAU/8+0.4; c[a?'lineTo':'moveTo'](Math.cos(th)*66,Math.sin(th)*66); } c.closePath(); c.fill(); c.strokeStyle='#20262c'; c.lineWidth=5; c.stroke(); for(const s of [-1,1]){ // twin rail cannons c.fillStyle='#2a323a'; c.fillRect(s*16-8,-98,16,88); c.fillStyle='#9aa6b0'; c.fillRect(s*16-4,-98,8,88); c.fillStyle='#fff'; c.fillRect(s*16-6,-102,12,10); } c.fillStyle=rg(c,0,8,30,[[0,'#fff6d8'],[0.4,'#ffd257'],[1,'rgba(160,100,10,.85)']]); c.beginPath(); c.arc(0,8,26,0,TAU); c.fill(); rivets(c,[[-52,-52],[52,-52],[-52,60],[52,60]]); }); // --- MASS FABRICATOR: energy crucible forging mass --- defSprite('fabB', c=>{ c.fillStyle='rgba(0,0,0,.42)'; c.beginPath(); c.ellipse(8,14,84,66,0,0,TAU); c.fill(); c.fillStyle='#464f59'; c.beginPath(); c.roundRect(-84,-70,168,152,16); c.fill(); c.strokeStyle='#191f26'; c.lineWidth=7; c.stroke(); for(const s of [-1,1]){ // heat-sink fins c.fillStyle='#5e6872'; for(let k=0;k<4;k++) c.fillRect(s*54-9,-56+k*30,18,20); } c.fillStyle=lg(c,0,-56,0,60,[[0,'#c8d2da'],[0.55,'#8c98a2'],[1,'#59636d']]); c.beginPath(); c.roundRect(-48,-52,96,110,12); c.fill(); c.strokeStyle='#20262c'; c.lineWidth=5; c.stroke(); c.fillStyle=rg(c,0,-6,44,[[0,'#fff2c8'],[0.35,'#ffb43c'],[0.75,'#c8500a'],[1,'rgba(90,20,0,.9)']]); c.beginPath(); c.arc(0,-4,38,0,TAU); c.fill(); // molten crucible c.strokeStyle='#2a323a'; c.lineWidth=6; c.stroke(); c.fillStyle='rgba(255,240,200,.95)'; c.beginPath(); c.arc(-11,-16,10,0,TAU); c.fill(); c.fillStyle='#41c8ff'; // energy feed conduits c.fillRect(-6,-84,12,26); c.fillRect(-30,54,60,10); rivets(c,[[-60,-60],[60,-60],[-60,64],[60,64]]); }); // --- SUPPLY CRATE: airdropped boost pod --- defSprite('crate', c=>{ c.fillStyle='rgba(0,0,0,.42)'; c.beginPath(); c.ellipse(8,20,66,42,0,0,TAU); c.fill(); c.fillStyle=lg(c,-60,-60,60,60,[[0,'#ffe9a3'],[0.45,'#d8a94e'],[0.62,'#9c7526'],[1,'#f2cf78']]); c.beginPath(); c.roundRect(-58,-52,116,104,12); c.fill(); c.strokeStyle='#3a2c0c'; c.lineWidth=8; c.stroke(); c.fillStyle='rgba(60,44,10,.6)'; c.fillRect(-58,-10,116,20); c.fillStyle='rgba(255,255,255,.9)'; c.fillRect(-34,-40,68,12); c.fillRect(-34,20,68,12); c.fillStyle='#2a1f06'; c.beginPath(); c.arc(0,0,15,0,TAU); c.fill(); c.fillStyle='#ffe9a3'; c.beginPath(); c.arc(0,0,8,0,TAU); c.fill(); }); // --- RELIC TOWER: ruined arcology spire --- defSprite('relicT', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(14,26,74,46,0,0,TAU); c.fill(); c.fillStyle=lg(c,-40,-100,40,80,[[0,'#9aa3ab'],[0.5,'#6b747d'],[1,'#3d444b']]); c.beginPath(); c.moveTo(-38,80); c.lineTo(-26,-96); c.lineTo(22,-104); c.lineTo(40,80); c.closePath(); c.fill(); c.strokeStyle='#22282e'; c.lineWidth=6; c.stroke(); c.fillStyle='rgba(20,26,32,.75)'; // broken windows for(let r=0;r<7;r++) for(let k=0;k<3;k++) if((r+k)%3) c.fillRect(-24+k*17,-84+r*22,11,13); c.fillStyle='#525b64'; // sheared top c.beginPath(); c.moveTo(-26,-96); c.lineTo(22,-104); c.lineTo(6,-118); c.lineTo(-16,-108); c.closePath(); c.fill(); }); // --- RELIC DOME: collapsed civic hall --- defSprite('relicD', c=>{ c.fillStyle='rgba(0,0,0,.42)'; c.beginPath(); c.ellipse(12,22,90,58,0,0,TAU); c.fill(); c.fillStyle=lg(c,0,-70,0,60,[[0,'#a8b1b9'],[0.55,'#79828b'],[1,'#454d55']]); c.beginPath(); c.arc(0,10,76,Math.PI,TAU); c.closePath(); c.fill(); c.strokeStyle='#242a30'; c.lineWidth=7; c.stroke(); c.fillStyle='rgba(18,24,30,.8)'; // caved-in section c.beginPath(); c.moveTo(16,-56); c.lineTo(58,-6); c.lineTo(10,4); c.lineTo(-6,-34); c.closePath(); c.fill(); c.fillStyle='#5d666f'; c.fillRect(-80,4,160,26); c.strokeStyle='#242a30'; c.lineWidth=6; c.strokeRect(-80,4,160,26); c.fillStyle='#8d959d'; for(const px2 of [-58,-20,20,58]){ c.fillRect(px2-7,-24,14,32); } }); // --- INDUSTRIAL HALL: gutted foundry, the richest salvage on the map --- defSprite('relicI', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(14,30,104,54,0,0,TAU); c.fill(); // long saw-tooth roofed shed c.fillStyle=lg(c,-90,-60,90,70,[[0,'#8b939b'],[0.5,'#636b73'],[1,'#373e45']]); c.beginPath(); c.rect(-96,-34,192,96); c.fill(); c.strokeStyle='#1e242a'; c.lineWidth=6; c.stroke(); // saw-tooth clerestory roof — the tell of a factory for(let k=0;k<5;k++){ const bx=-96+k*39; c.fillStyle=k%2?'#7d858d':'#69717a'; c.beginPath(); c.moveTo(bx,-34); c.lineTo(bx+20,-72); c.lineTo(bx+39,-34); c.closePath(); c.fill(); c.strokeStyle='#1e242a'; c.lineWidth=4; c.stroke(); c.fillStyle='rgba(24,32,40,.72)'; // shattered glazing if(k!==2) c.fillRect(bx+8,-62,16,22); } // collapsed bay: a hole punched through the middle c.fillStyle='rgba(16,20,26,.82)'; c.beginPath(); c.moveTo(-14,-30); c.lineTo(34,-16); c.lineTo(20,44); c.lineTo(-26,26); c.closePath(); c.fill(); // roller doors along the front c.fillStyle='#535b64'; for(const bx of [-72,-24,24,66]){ c.fillRect(bx-14,22,28,40); } c.strokeStyle='#232a30'; c.lineWidth=3; for(const bx of [-72,-24,24,66]){ c.strokeRect(bx-14,22,28,40); } // stack c.fillStyle=lg(c,-84,0,-58,0,[[0,'#98a0a8'],[1,'#4d555d']]); c.fillRect(-84,-104,26,74); c.strokeStyle='#1e242a'; c.lineWidth=5; c.strokeRect(-84,-104,26,74); c.fillStyle='rgba(30,26,22,.6)'; c.fillRect(-84,-104,26,12); }); // --- TANK FARM: clustered storage drums, goes up like a bomb --- defSprite('relicK', c=>{ c.fillStyle='rgba(0,0,0,.42)'; c.beginPath(); c.ellipse(10,26,86,50,0,0,TAU); c.fill(); // containment bund c.strokeStyle='#6b6257'; c.lineWidth=9; c.beginPath(); c.roundRect(-84,-58,168,124,14); c.stroke(); c.fillStyle='rgba(84,78,68,.45)'; c.beginPath(); c.roundRect(-84,-58,168,124,14); c.fill(); for(const [dx,dy,r] of [[-40,-14,40],[36,-22,34],[8,38,30]]){ c.fillStyle=lg(c,dx-r,dy,dx+r,dy,[[0,'#c3ccd4'],[0.42,'#8a929a'],[1,'#4a525a']]); c.beginPath(); c.arc(dx,dy,r,0,TAU); c.fill(); c.strokeStyle='#1f252b'; c.lineWidth=6; c.stroke(); c.strokeStyle='rgba(30,36,42,.5)'; c.lineWidth=3; // banding c.beginPath(); c.arc(dx,dy,r*0.66,0,TAU); c.stroke(); c.fillStyle='#ffb347'; c.beginPath(); c.arc(dx,dy,r*0.2,0,TAU); c.fill(); } // pipework tying the drums together c.strokeStyle='#7a828a'; c.lineWidth=8; c.lineCap='round'; c.beginPath(); c.moveTo(-40,-14); c.lineTo(36,-22); c.lineTo(8,38); c.stroke(); c.strokeStyle='#3a4248'; c.lineWidth=3; c.beginPath(); c.moveTo(-40,-14); c.lineTo(36,-22); c.lineTo(8,38); c.stroke(); }); // --- FUEL TANK: volatile environmental destructible --- defSprite('tankF', c=>{ c.fillStyle='rgba(0,0,0,.42)'; c.beginPath(); c.ellipse(8,18,62,44,0,0,TAU); c.fill(); c.fillStyle=lg(c,-50,0,50,0,[[0,'#c9d2da'],[0.45,'#8d959d'],[1,'#4e565e']]); c.beginPath(); c.arc(0,0,54,0,TAU); c.fill(); c.strokeStyle='#20262c'; c.lineWidth=7; c.stroke(); c.fillStyle='#ff8d3a'; c.beginPath(); c.arc(0,0,26,0,TAU); c.fill(); c.fillStyle='#2a1c08'; c.beginPath(); c.moveTo(0,-17); c.lineTo(15,10); c.lineTo(-15,10); c.closePath(); c.fill(); c.fillStyle='#ffd257'; c.fillRect(-3,-9,6,11); c.beginPath(); c.arc(0,6,3.4,0,TAU); c.fill(); c.fillStyle='#6d757d'; c.fillRect(-58,-8,10,16); c.fillRect(48,-8,10,16); }); // --- SUPER CARRIER: the mobile HQ that drops from orbit --- defSprite('carrier', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(10,18,104,74,0,0,TAU); c.fill(); // hull: broad arrowhead c.fillStyle=lg(c,-90,-90,90,90,[[0,'#dbe4ec'],[0.42,'#93a0ab'],[0.62,'#6d7883'],[1,'#39424c']]); c.beginPath(); c.moveTo(0,-108); c.lineTo(62,-34); c.lineTo(86,52); c.lineTo(40,86); c.lineTo(-40,86); c.lineTo(-86,52); c.lineTo(-62,-34); c.closePath(); c.fill(); c.strokeStyle='#141a20'; c.lineWidth=7; c.stroke(); // spine + deck plating c.fillStyle='rgba(20,26,32,.55)'; c.fillRect(-10,-92,20,168); panelLines(c,-58,-24,116,96,4,.4); // side engine nacelles for(const s of [-1,1]){ c.fillStyle='#4c5660'; c.beginPath(); c.roundRect(s*66-16,4,32,72,10); c.fill(); c.strokeStyle='#171d23'; c.lineWidth=5; c.stroke(); c.fillStyle=rg(c,s*66,66,26,[[0,'#dffaff'],[0.45,'#41c8ff'],[1,'rgba(20,90,140,0)']]); c.beginPath(); c.arc(s*66,66,24,0,TAU); c.fill(); } // bridge tower c.fillStyle='#c2ccd5'; c.beginPath(); c.moveTo(-26,-46); c.lineTo(26,-46); c.lineTo(18,4); c.lineTo(-18,4); c.closePath(); c.fill(); c.strokeStyle='#1a2129'; c.lineWidth=5; c.stroke(); c.fillStyle='rgba(255,255,255,.92)'; c.fillRect(-20,-38,40,12); // team beacon + nose lamp c.fillStyle='#fff'; c.beginPath(); c.arc(0,-74,15,0,TAU); c.fill(); c.fillStyle='rgba(255,255,255,.85)'; c.beginPath(); c.moveTo(0,-104); c.lineTo(9,-86); c.lineTo(-9,-86); c.closePath(); c.fill(); rivets(c,[[-52,-30],[52,-30],[-64,50],[64,50],[0,72]]); }); // --- BIRD silhouette (white → tinted) --- defSprite('bird', c=>{ c.strokeStyle='#fff'; c.lineWidth=16; c.lineCap='round'; c.beginPath(); c.moveTo(-70,-10); c.quadraticCurveTo(-24,-52,0,-6); c.quadraticCurveTo(24,-52,70,-10); c.stroke(); c.fillStyle='#fff'; c.beginPath(); c.ellipse(0,-2,14,20,0,0,TAU); c.fill(); }); // ================= NEW UNITS ================= // --- PYRO flame tank --- defSprite('pyro', c=>{ c.fillStyle='rgba(0,0,0,.4)'; c.beginPath(); c.ellipse(6,18,80,58,0,0,TAU); c.fill(); for(const s of [-1,1]){ c.fillStyle='#2c3238'; c.beginPath(); c.roundRect(s*52-22,-64,44,132,13); c.fill(); c.strokeStyle='#14181c'; c.lineWidth=5; c.stroke(); c.fillStyle='#191e22'; for(let i=0;i<8;i++) c.fillRect(s*52-18,-58+i*16,36,7); } c.fillStyle=steel(c,-54,0,54,0,'#5c5046','#c2ab92'); // warm scorched hull c.beginPath(); c.moveTo(-46,-70); c.lineTo(46,-70); c.lineTo(56,50); c.lineTo(-56,50); c.closePath(); c.fill(); c.strokeStyle='#171310'; c.lineWidth=6; c.stroke(); // fuel tanks c.fillStyle='#8a4a26'; c.beginPath(); c.roundRect(-40,6,32,42,10); c.fill(); c.beginPath(); c.roundRect(8,6,32,42,10); c.fill(); c.strokeStyle='#241209'; c.lineWidth=4; c.strokeRect(-40,6,32,42); c.strokeRect(8,6,32,42); // nozzle c.fillStyle='#20262b'; c.fillRect(-12,-104,24,44); c.fillStyle='#ff8b3a'; c.beginPath(); c.arc(0,-100,10,0,TAU); c.fill(); c.fillStyle=rg(c,0,-24,20,[[0,'#ffd257'],[1,'rgba(255,120,40,0)']]); c.beginPath(); c.arc(0,-24,20,0,TAU); c.fill(); c.fillStyle='rgba(255,255,255,.9)'; c.fillRect(-42,-60,84,8); // hazard chevrons c.fillStyle='#e6c145'; c.beginPath(); c.moveTo(-20,38); c.lineTo(0,26); c.lineTo(20,38); c.lineTo(0,50); c.closePath(); c.fill(); }); // --- VULTURE AA missile carrier --- defSprite('vulture', c=>{ c.fillStyle='rgba(0,0,0,.4)'; c.beginPath(); c.ellipse(6,18,80,58,0,0,TAU); c.fill(); for(const s of [-1,1]){ c.fillStyle='#20262b'; c.beginPath(); c.ellipse(s*56,-30,15,20,0,0,TAU); c.fill(); c.beginPath(); c.ellipse(s*58,36,16,22,0,0,TAU); c.fill(); } c.fillStyle=steel(c,-48,0,48,0,'#4c565e','#b4c0c9'); c.beginPath(); c.moveTo(-40,-64); c.lineTo(40,-64); c.lineTo(50,52); c.lineTo(-50,52); c.closePath(); c.fill(); c.strokeStyle='#12171b'; c.lineWidth=6; c.stroke(); // twin angled missile racks for(const s of [-1,1]){ c.save(); c.translate(s*22,-10); c.rotate(s*0.35); c.fillStyle='#2b3238'; c.beginPath(); c.roundRect(-14,-44,28,72,8); c.fill(); c.strokeStyle='#0f1418'; c.lineWidth=4; c.stroke(); c.fillStyle='#e8f4fb'; for(let i=0;i<3;i++){ c.beginPath(); c.arc(0,-30+i*24,7,0,TAU); c.fill(); } c.fillStyle='#ff5d43'; for(let i=0;i<3;i++){ c.beginPath(); c.arc(0,-30+i*24,3,0,TAU); c.fill(); } c.restore(); } // radar c.fillStyle='#39424a'; c.beginPath(); c.arc(0,28,14,0,TAU); c.fill(); c.fillStyle='#9fe8ff'; c.beginPath(); c.arc(0,28,7,0,TAU); c.fill(); c.fillStyle='rgba(255,255,255,.9)'; c.fillRect(-36,44,72,8); }); // --- BULWARK shield walker --- defSprite('bulwark', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(6,24,84,58,0,0,TAU); c.fill(); for(const s of [-1,1]){ c.fillStyle='#4b545c'; c.beginPath(); c.moveTo(s*34,-16); c.lineTo(s*84,20); c.lineTo(s*70,46); c.lineTo(s*30,12); c.closePath(); c.fill(); c.strokeStyle='#171d22'; c.lineWidth=6; c.stroke(); c.fillStyle='#2b3238'; c.beginPath(); c.ellipse(s*76,34,17,11,0,0,TAU); c.fill(); } c.fillStyle=steel(c,0,-80,0,60,'#4e5860','#c4d0d8'); c.beginPath(); c.moveTo(0,-84); c.lineTo(48,-30); c.lineTo(40,58); c.lineTo(-40,58); c.lineTo(-48,-30); c.closePath(); c.fill(); c.strokeStyle='#10161b'; c.lineWidth=7; c.stroke(); // emitter pylons for(const s of [-1,1]){ c.fillStyle='#39424a'; c.fillRect(s*30-7,-96,14,42); c.fillStyle='#41c8ff'; c.beginPath(); c.arc(s*30,-96,9,0,TAU); c.fill(); } // shield core c.fillStyle=rg(c,0,-8,34,[[0,'#e6f8ff'],[.4,'#41c8ff'],[1,'rgba(40,140,220,0)']]); c.beginPath(); c.arc(0,-8,34,0,TAU); c.fill(); c.fillStyle='#12303f'; c.beginPath(); c.arc(0,-8,14,0,TAU); c.fill(); c.fillStyle='#bfeeff'; c.beginPath(); c.arc(0,-8,7,0,TAU); c.fill(); }); // --- RAVAGER creature (neutral) --- defSprite('rav', c=>{ c.fillStyle='rgba(0,0,0,.4)'; c.beginPath(); c.ellipse(4,20,78,52,0,0,TAU); c.fill(); // six legs for(const s of [-1,1]) for(let l=0;l<3;l++){ const ly=-40+l*38; c.strokeStyle='#3d3326'; c.lineWidth=13; c.lineCap='round'; c.beginPath(); c.moveTo(s*24,ly); c.quadraticCurveTo(s*66,ly-16,s*84,ly+18); c.stroke(); c.strokeStyle='#241d14'; c.lineWidth=5; c.beginPath(); c.moveTo(s*24,ly); c.quadraticCurveTo(s*66,ly-16,s*84,ly+18); c.stroke(); } // carapace const g=lg(c,0,-80,0,70,[[0,'#8a7a52'],[.5,'#655338'],[1,'#3d3326']]); c.fillStyle=g; c.beginPath(); c.moveTo(0,-84); c.quadraticCurveTo(52,-56,44,10); c.quadraticCurveTo(38,58,0,66); c.quadraticCurveTo(-38,58,-44,10); c.quadraticCurveTo(-52,-56,0,-84); c.closePath(); c.fill(); c.strokeStyle='#1c1610'; c.lineWidth=6; c.stroke(); // plate ridges c.strokeStyle='rgba(28,22,16,.7)'; c.lineWidth=4; for(let i=0;i<3;i++){ c.beginPath(); c.moveTo(-38+i*4,-40+i*30); c.quadraticCurveTo(0,-52+i*30,38-i*4,-40+i*30); c.stroke(); } // mandibles for(const s of [-1,1]){ c.fillStyle='#241d14'; c.beginPath(); c.moveTo(s*12,-78); c.quadraticCurveTo(s*38,-96,s*20,-110); c.quadraticCurveTo(s*24,-88,s*4,-80); c.closePath(); c.fill(); } // eyes glow c.fillStyle='#ffb13a'; c.beginPath(); c.arc(-10,-64,6,0,TAU); c.fill(); c.beginPath(); c.arc(10,-64,6,0,TAU); c.fill(); c.fillStyle=rg(c,0,-64,20,[[0,'rgba(255,170,60,.5)'],[1,'rgba(255,170,60,0)']]); c.beginPath(); c.arc(0,-64,20,0,TAU); c.fill(); }); // --- NEST (neutral structure) --- defSprite('nest', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(4,30,96,54,0,0,TAU); c.fill(); // mound const g=rg(c,-20,-30,120,[[0,'#7a6a48'],[.6,'#57472f'],[1,'#332a1c']]); c.fillStyle=g; c.beginPath(); c.moveTo(-88,34); c.quadraticCurveTo(-70,-52,0,-64); c.quadraticCurveTo(72,-50,88,36); c.quadraticCurveTo(40,62,0,60); c.quadraticCurveTo(-42,62,-88,34); c.closePath(); c.fill(); c.strokeStyle='#191309'; c.lineWidth=6; c.stroke(); // spines for(let i=0;i<6;i++){ const a=-Math.PI*0.85+i*0.34, d=66; c.fillStyle='#3d3326'; c.beginPath(); c.moveTo(Math.cos(a)*d,Math.sin(a)*d*0.6-6); c.lineTo(Math.cos(a)*(d+26),Math.sin(a)*(d+26)*0.6-22); c.lineTo(Math.cos(a)*d+9,Math.sin(a)*d*0.6+2); c.closePath(); c.fill(); } // orifice glow c.fillStyle=rg(c,0,4,40,[[0,'#ffd28a'],[.4,'#ff9b3a'],[1,'rgba(160,60,20,0)']]); c.beginPath(); c.ellipse(0,4,40,30,0,0,TAU); c.fill(); c.fillStyle='#2a1408'; c.beginPath(); c.ellipse(0,6,20,14,0,0,TAU); c.fill(); // eggs for(const e of [[-52,26],[48,20],[-30,44],[36,42]]){ c.fillStyle='#c9b98a'; c.beginPath(); c.ellipse(e[0],e[1],11,14,0.3,0,TAU); c.fill(); c.strokeStyle='#191309'; c.lineWidth=3; c.stroke(); } }); // ================= NAVY ================= // --- CORVETTE gunboat --- defSprite('corv', c=>{ c.fillStyle='rgba(0,10,20,.45)'; c.beginPath(); c.ellipse(6,10,52,92,0,0,TAU); c.fill(); // hull const g=lg(c,-40,0,40,0,[[0,'#4c565e'],[.5,'#b9c5cd'],[1,'#4c565e']]); c.fillStyle=g; c.beginPath(); c.moveTo(0,-102); c.quadraticCurveTo(40,-58,38,20); c.quadraticCurveTo(36,78,20,92); c.lineTo(-20,92); c.quadraticCurveTo(-36,78,-38,20); c.quadraticCurveTo(-40,-58,0,-102); c.closePath(); c.fill(); c.strokeStyle='#10161b'; c.lineWidth=6; c.stroke(); // deck line c.strokeStyle='rgba(20,28,34,.6)'; c.lineWidth=3; c.beginPath(); c.moveTo(0,-88); c.quadraticCurveTo(30,-50,28,20); c.quadraticCurveTo(27,66,16,80); c.stroke(); // superstructure c.fillStyle='#39424a'; c.beginPath(); c.roundRect(-18,-6,36,52,8); c.fill(); c.strokeStyle='#10161b'; c.lineWidth=4; c.stroke(); c.fillStyle='#9fe8ff'; c.fillRect(-12,2,24,8); // bow gun c.fillStyle='#2b3238'; c.beginPath(); c.arc(0,-46,15,0,TAU); c.fill(); c.fillStyle='#20262b'; c.fillRect(-5,-92,10,44); c.fillStyle='#fff'; c.beginPath(); c.arc(0,-46,7,0,TAU); c.fill(); c.fillStyle='rgba(255,255,255,.9)'; c.fillRect(-16,68,32,8); }); // --- DREADNOUGHT artillery ship --- defSprite('dread', c=>{ c.fillStyle='rgba(0,10,20,.5)'; c.beginPath(); c.ellipse(6,10,62,102,0,0,TAU); c.fill(); const g=lg(c,-50,0,50,0,[[0,'#414b53'],[.5,'#a8b4bd'],[1,'#414b53']]); c.fillStyle=g; c.beginPath(); c.moveTo(0,-108); c.quadraticCurveTo(52,-62,50,10); c.quadraticCurveTo(48,84,26,100); c.lineTo(-26,100); c.quadraticCurveTo(-48,84,-50,10); c.quadraticCurveTo(-52,-62,0,-108); c.closePath(); c.fill(); c.strokeStyle='#0e1418'; c.lineWidth=7; c.stroke(); // armor deck plates c.strokeStyle='rgba(18,24,30,.55)'; c.lineWidth=3; for(let i=0;i<3;i++){ c.beginPath(); c.moveTo(-40+i*4,-60+i*46); c.lineTo(40-i*4,-60+i*46); c.stroke(); } // twin turrets w/ long guns for(const ty of [-34,26]){ c.fillStyle='#20262b'; c.fillRect(-14,ty-72,10,60); c.fillRect(4,ty-72,10,60); c.fillStyle=lg(c,-24,ty,24,ty,[[0,'#4b555e'],[.5,'#98a4ad'],[1,'#4b555e']]); c.beginPath(); c.roundRect(-26,ty-16,52,34,10); c.fill(); c.strokeStyle='#0e1418'; c.lineWidth=5; c.stroke(); } // bridge tower c.fillStyle='#333c44'; c.beginPath(); c.roundRect(-16,58,32,30,6); c.fill(); c.fillStyle='#ffd257'; c.beginPath(); c.arc(0,64,5,0,TAU); c.fill(); c.fillStyle='rgba(255,255,255,.9)'; c.fillRect(-20,88,40,8); }); // --- HARBOR --- defSprite('harbor', c=>{ c.fillStyle='rgba(0,0,0,.45)'; c.beginPath(); c.ellipse(8,58,108,44,0,0,TAU); c.fill(); // dock platform c.fillStyle=lg(c,0,-40,0,90,[[0,'#8b98a2'],[1,'#4a545c']]); c.beginPath(); c.roundRect(-100,-64,200,132,12); c.fill(); c.strokeStyle='#10151a'; c.lineWidth=7; c.stroke(); // wet dock cutout c.fillStyle='#123246'; c.beginPath(); c.roundRect(-58,-36,116,84,10); c.fill(); c.strokeStyle='#0a1c28'; c.lineWidth=5; c.stroke(); c.fillStyle='rgba(120,220,255,.25)'; for(let i=0;i<4;i++) c.fillRect(-52,-24+i*20,104,4); // crane c.fillStyle='#39424a'; c.fillRect(66,-58,14,96); c.fillStyle='#e6c145'; c.fillRect(28,-64,58,10); c.fillStyle='#20262b'; c.fillRect(30,-54,6,26); // bollards rivets(c,[[-88,-52],[-88,56],[88,56],[-70,-52]]); c.fillStyle='#fff'; c.fillRect(-100,-64,200,10); // hazard edge c.save(); c.beginPath(); c.rect(-100,58,200,10); c.clip(); for(let i=-6;i<7;i++){ c.fillStyle=i%2?'#e6c145':'#1c2126'; c.beginPath(); c.moveTo(i*26,58); c.lineTo(i*26+18,58); c.lineTo(i*26+6,68); c.lineTo(i*26-12,68); c.fill(); } c.restore(); }); // ================= ANIMATED PARTS & NEW FX ================= defSprite('rotor', c=>{ // extractor drill spinner c.strokeStyle='#d9ffe9'; c.lineWidth=10; c.lineCap='round'; for(let i=0;i<3;i++){ const a=i*TAU/3; c.beginPath(); c.moveTo(Math.cos(a)*14,Math.sin(a)*14); c.lineTo(Math.cos(a)*58,Math.sin(a)*58); c.stroke(); } c.fillStyle='#5dff9a'; c.beginPath(); c.arc(0,0,14,0,TAU); c.fill(); }); defSprite('dish', c=>{ // rotating radar dish c.fillStyle='#39424a'; c.fillRect(-6,-6,12,52); c.fillStyle=lg(c,0,-60,0,0,[[0,'#cfdae2'],[1,'#5c666f']]); c.beginPath(); c.ellipse(0,-34,44,26,0,0,TAU); c.fill(); c.strokeStyle='#12171b'; c.lineWidth=5; c.stroke(); c.fillStyle='#20262b'; c.beginPath(); c.ellipse(0,-34,26,14,0,0,TAU); c.fill(); c.fillStyle='#9fe8ff'; c.beginPath(); c.arc(0,-34,7,0,TAU); c.fill(); }); defSprite('bubble', c=>{ // shield dome c.strokeStyle='rgba(120,220,255,.9)'; c.lineWidth=5; c.beginPath(); c.arc(0,0,100,0,TAU); c.stroke(); c.fillStyle=rg(c,0,0,100,[[0,'rgba(90,190,255,0)'],[.8,'rgba(90,190,255,.08)'],[.95,'rgba(140,225,255,.35)'],[1,'rgba(140,225,255,0)']]); c.beginPath(); c.arc(0,0,100,0,TAU); c.fill(); // hex sparkles c.strokeStyle='rgba(160,230,255,.35)'; c.lineWidth=2; for(let i=0;i<8;i++){ const a=i*TAU/8+0.3, d=78; c.beginPath(); c.arc(Math.cos(a)*d,Math.sin(a)*d,10,0,TAU); c.stroke(); } }); defSprite('flame', c=>{ // coherent depth-billboard fire, not a radial explosion puff const outer=lg(c,0,92,0,-108,[[0,'rgba(255,244,118,.98)'],[.28,'rgba(255,177,28,.98)'], [.66,'rgba(255,74,12,.88)'],[1,'rgba(155,20,8,0)']]); c.fillStyle=outer;c.beginPath();c.moveTo(0,-108); c.bezierCurveTo(18,-75,58,-45,50,2);c.bezierCurveTo(70,36,42,86,8,94); c.bezierCurveTo(-22,98,-62,72,-53,24);c.bezierCurveTo(-65,-18,-30,-64,0,-108);c.closePath();c.fill(); const inner=lg(c,0,76,0,-58,[[0,'rgba(255,255,164,.98)'],[.38,'rgba(255,218,64,.96)'], [.78,'rgba(255,116,18,.72)'],[1,'rgba(255,70,8,0)']]); c.fillStyle=inner;c.beginPath();c.moveTo(-4,-64);c.bezierCurveTo(12,-34,31,-7,24,25); c.bezierCurveTo(18,55,4,78,-10,77);c.bezierCurveTo(-29,72,-35,44,-25,20); c.bezierCurveTo(-17,-2,-20,-30,-4,-64);c.closePath();c.fill(); }); defSprite('warn', c=>{ // meteor warning marker c.strokeStyle='#ff5d43'; c.lineWidth=9; c.setLineDash([26,18]); c.beginPath(); c.arc(0,0,88,0,TAU); c.stroke(); c.setLineDash([]); c.beginPath(); c.moveTo(-30,0); c.lineTo(30,0); c.moveTo(0,-30); c.lineTo(0,30); c.stroke(); }); /* Commander locator is a transparent VFX glyph, not a piece of equipment. White authored pixels are tinted per instance by the billboard shader. */ defSprite('commanderFx',c=>{ c.save();c.lineCap='round';c.lineJoin='round'; c.shadowColor='rgba(255,230,120,.9)';c.shadowBlur=18; c.strokeStyle='rgba(255,255,255,.96)';c.lineWidth=12; c.beginPath();c.moveTo(-58,-34);c.lineTo(0,18);c.lineTo(58,-34);c.stroke(); c.lineWidth=8;c.beginPath();c.arc(0,-42,27,0,TAU);c.stroke(); c.fillStyle='rgba(255,255,255,.92)'; c.beginPath();c.moveTo(0,-61);c.lineTo(12,-37);c.lineTo(0,-20);c.lineTo(-12,-37);c.closePath();c.fill(); c.restore(); }); const tex=gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D,tex); gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA8,gl.RGBA,gl.UNSIGNED_BYTE,atlasCanvas); gl.generateMipmap(gl.TEXTURE_2D); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); /* Replace only the flame cell with the authored volumetric fire sprite once its compact runtime PNG arrives. It remains in the shared atlas/batch, so battle fire keeps one draw call and the black source background vanishes naturally under additive blending. The procedural cell above is a safe boot/offline fallback rather than a second effect layered on top. */ const fireImg=new Image();fireImg.onload=()=>{ const uv=sprites.flame,cx=Math.floor(uv[0]*ATLAS/CELL)*CELL,cy=Math.floor(uv[1]*ATLAS/CELL)*CELL; const tile=document.createElement('canvas');tile.width=tile.height=CELL; const tc=tile.getContext('2d');tc.fillStyle='#000';tc.fillRect(0,0,CELL,CELL);tc.drawImage(fireImg,0,0,CELL,CELL); ac.clearRect(cx,cy,CELL,CELL);ac.drawImage(tile,cx,cy); const bound=gl.getParameter(gl.TEXTURE_BINDING_2D),flip=gl.getParameter(gl.UNPACK_FLIP_Y_WEBGL); gl.bindTexture(gl.TEXTURE_2D,tex);gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false); gl.texSubImage2D(gl.TEXTURE_2D,0,cx,cy,gl.RGBA,gl.UNSIGNED_BYTE,tile);gl.generateMipmap(gl.TEXTURE_2D); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,flip);gl.bindTexture(gl.TEXTURE_2D,bound); window.__mfAuthoredFireReady=true; };fireImg.onerror=()=>console.warn('[VFX] Authored fire sprite unavailable; using procedural fallback'); fireImg.src='assets/textures/vfx/mf-fire-plume-v1.png'; return tex; } /* ============================================================ TERRAIN — heightfield: water, beaches, grass, cliffs, plateaus ============================================================ */ let terrainCanvas; const TS=2048; // stay 2048: 4096 duplicates heightF+terrainCanvas+terrainBase on Android // ---------- biome themes ---------- const THEMES={ verdant:{ name:'Verdant', wDeep:[12,44,74], wShal:[52,116,154], foam:[150,205,205], b0:[168,152,110], b1:[138,118,68], g0:[72,112,50], g1:[54,88,42], mossAmt:[14,18,5], h0:[70,84,48], h1:[96,92,66], cliff:[96,90,84], plat:[74,104,52], trees:260, treeTint:[255,255,255], water:'sea' }, ashland:{ name:'Ashlands', wDeep:[130,22,4], wShal:[255,124,22], foam:[255,214,120], b0:[70,58,50], b1:[52,44,40], g0:[64,58,55], g1:[44,40,38], mossAmt:[22,6,2], h0:[74,62,55], h1:[88,72,60], cliff:[58,50,46], plat:[82,72,64], trees:0, treeTint:[255,255,255], water:'lava' }, arctic:{ name:'Arctic', wDeep:[10,38,74], wShal:[86,168,206], foam:[240,251,255], b0:[158,170,186], b1:[122,138,158], g0:[240,246,252], g1:[152,176,208], mossAmt:[-26,-12,16], h0:[128,152,188], h1:[236,243,250], cliff:[62,66,76], plat:[250,252,255], trees:170, treeTint:[74,104,88], water:'ice' }, /* VESPERA read as a second PYRAETH: its sand was warm ochre and the macro tint is the loudest voice on the ground, so the violet albedo variant was being overpowered by its own palette. Dusk is a MAGENTA-LILAC world lit low and cold — the sand keeps warmth only where the sun still touches it (b0/plat), while soil, moss and cliffs go violet. This is what makes "alien and foreign" legible at strategic zoom, not just up close. */ vespera:{ name:'Vespera Dusk', wDeep:[24,18,52], wShal:[110,75,150], foam:[230,190,250], b0:[142,104,116], b1:[96,68,92], g0:[150,104,128], g1:[104,72,104], mossAmt:[14,-4,26], h0:[122,86,116], h1:[178,132,150], cliff:[74,52,74], plat:[196,152,150], trees:110, treeTint:[210,140,230], water:'dusk' } }; let curTheme='verdant'; /* ---------- planetary battlefield catalogue --------------------------------- A site is a map, not a skin placed over a generic size toggle. Every one of the sixteen regions owns an authored Small, Medium and Large battlefield. The fixed 3.2 km terrain allocation is retained for mobile memory stability; `size` selects the playable theatre within it (see BATTLEFIELD_PRESETS). The eight legacy definitions remain addressable for campaign/save compatibility, but no War Room region advertises them. */ const MAPDEFS=(()=>{ const out={ vanguard:{nm:'Vanguard Valley',ds:'Legacy highway country',seed:20260729,hBias:0,relief:1,crater:0,outpost:2,relic:1,city:1,indus:1,roads:1,size:'standard',hazard:'dust'}, highland:{nm:'Highland Scar',ds:'Legacy cliff arcology',seed:77113,hBias:.055,relief:1.4,crater:0,outpost:1,relic:2,city:2,indus:1,roads:1,size:'standard',hazard:'collapse'}, isles:{nm:'Shattered Isles',ds:'Legacy drowned archipelago',seed:41522,hBias:-.062,relief:.95,crater:0,city:1,indus:0,roads:0,seabed:1,bridge:1,size:'standard',hazard:'squall'}, crater:{nm:'Relic Basin',ds:'Legacy impact metropolis',seed:90210,hBias:.02,relief:1.15,crater:1,outpost:2,relic:2,towns:1,city:3,indus:2,roads:1,size:'standard',hazard:'pulse'}, oasis:{nm:'Spire Oasis',ds:'Legacy solar-well valley',seed:50291,hBias:-.01,relief:1.05,crater:0,outpost:1,relic:1,towns:1,city:1,indus:1,roads:1,size:'standard',hazard:'heat'}, ruins_reach:{nm:'Ruins Reach',ds:'Legacy foundry grid',seed:88301,hBias:.03,relief:1.2,crater:0,outpost:3,relic:1,city:2,indus:3,roads:1,size:'standard',hazard:'pulse'}, frost_reach:{nm:'Frost Reach',ds:'Legacy frozen canyon',seed:61094,hBias:.04,relief:1.3,crater:0,city:1,indus:1,roads:1,size:'standard',hazard:'whiteout'}, ash_ridge:{nm:'Ash Ridge',ds:'Legacy slag caldera',seed:39102,hBias:.06,relief:1.35,crater:1,city:2,indus:2,roads:1,size:'standard',hazard:'eruption'} }; /* Trailing object is optional site extras. Map IDs stay stable for saves; theme/poi/civic counts are the faction-homeworld contract. */ const add=(region,theme,base,sites)=>{ for(let i=0;i-0.025)D.hBias=-0.025; else if(wet[id]&&D.hBias>-0.035)D.hBias=-0.035; } } return out; })(); /* Default must be a four-homeworld site. `vanguard` remains in MAPDEFS for training/save compatibility, but it is not a drop world and must not be the career starting selection. */ let curMap='aelos_north_medium'; function theatreMapId(maps, size){ /* Standard mode contract is the medium / 12-site theatre. Compact (_small) and Large stay on the region row; do not treat maps[0] as the drop. */ const want=(typeof battlefieldPresetKey==='function'?battlefieldPresetKey(size||'standard'):(size||'standard')); if(!maps||!maps.length) return ''; for(let i=0;i=0) return k; } return planetForTheme(curTheme); } /* The 48 authored homeworld sites. Legacy MAPDEFS (vanguard, highland, …) stay loadable for training/saves but are never drop worlds. */ function homeworldMapIds(){ const ids=[]; for(const k in PLANETS) for(const m of getPlanetMaps(PLANETS[k])) ids.push(m); return ids; } function isHomeworldMap(m){ if(!m) return false; for(const k in PLANETS) if(getPlanetMaps(PLANETS[k]).indexOf(m)>=0) return true; return false; } /* Runtime kit for the selected site's homeworld. nova is the player kit and is not a FACTIONS{} key; legion/syndicate/horde are. */ function mapHomeFac(map){ const m=map||(typeof curMap!=='undefined'?curMap:''); const k=planetForMap(m),P=PLANETS[k]; return (P&&P.fac)||'nova'; } /* Four solar systems. Galaxy picks a SYSTEM; the system view shows that system's planets. There are no moons — a planet has four regions, and each region has three maps. Other faction homeworlds must NEVER share this orbit. */ const SYSTEMS={ sombrero:{id:'sombrero', nm:'SOMBRERO-I', star:'FRONTLINE PRIME', fac:'nova', home:'aelos', color:'#5ad4ff', ds:'Nova beginner system. One homeworld, four regions, three battlefields each.', x:-.72,y:-.20,z:.18}, andromeda:{id:'andromeda', nm:'ANDROMEDA-IV', star:'DOMINION FURNACE', fac:'legion', home:'pyraeth', color:'#ff714c', ds:'Crimson Dominion system. One homeworld, four regions, three battlefields each.', x:.66,y:-.46,z:-.08}, orion:{id:'orion', nm:'ORION ARC', star:'GRID SUN', fac:'syndicate', home:'nordhall', color:'#7dff9a', ds:'Syndicate system. One homeworld, four regions, three battlefields each.', x:.48,y:.55,z:.30}, helios:{id:'helios', nm:'HELIOS CORE', star:'HIVE STAR', fac:'horde', home:'vespera', color:'#c46bff', ds:'Brood system. One homeworld, four regions, three battlefields each.', x:-.35,y:.62,z:-.30} }; function systemForPlanet(k){ const P=PLANETS[k];if(P&&P.system&&SYSTEMS[P.system])return P.system; for(const id in SYSTEMS) if(SYSTEMS[id].home===k) return id; return 'sombrero'; } function systemPlanets(sys){ const id=typeof sys==='string'?sys:(sys&&sys.id)||'sombrero'; const keys=[]; for(const k in PLANETS) if(PLANETS[k].system===id) keys.push(k); if(!keys.length){const h=SYSTEMS[id]&&SYSTEMS[id].home;if(h)keys.push(h);} return keys; } function systemBodies(sys){ /* Catalog helper only. The War Table never draws extra moons or sibling drop worlds from this list — each system still has one homeworld. */ return systemPlanets(sys).map(id=>{const P=PLANETS[id];return {id,kind:'planet',playable:true,nm:P?P.nm:id,theme:P&&P.theme};}); } var lastPlanetPreviewTargets=[]; var lastPlanetGlobe={cx:0,cy:0,R:0,W:0,H:0}; /* =========================================================== METEORA-QUALITY 3D PLANET RENDERER - 3D Simplex noise (Stefan Gustavson inline) - fBm domain warping for organic coastlines - Smooth biome gradient blending (no hard if/else) - Per-pixel cloud integration - Starfield background - Meteora orbital ring aesthetic =========================================================== */ /* --- Inline 3D Simplex Noise (Gustavson) ------------------- */ function _snoise3(px,py,pz){ const F3=1/3,G3=1/6; let s=(px+py+pz)*F3; let i=Math.floor(px+s),j=Math.floor(py+s),k=Math.floor(pz+s); let t=(i+j+k)*G3; let x0=px-(i-t),y0=py-(j-t),z0=pz-(k-t); let i1,j1,k1,i2,j2,k2; if(x0>=y0){if(y0>=z0){i1=1;j1=0;k1=0;i2=1;j2=1;k2=0}else if(x0>=z0){i1=1;j1=0;k1=0;i2=1;j2=0;k2=1}else{i1=0;j1=0;k1=1;i2=1;j2=0;k2=1}}else{if(y0(n^(n>>4))&0xF; const g=(h0,x,y,z)=>{const g=gi[h0%12];return g[0]*x+g[1]*y+g[2]*z}; const ii=i&255,jj=j&255,kk=k&255; let n0=0,n1=0,n2=0,n3=0; let t0=0.6-x0*x0-y0*y0-z0*z0; if(t0>=0){t0*=t0;n0=t0*t0*g(h(ii+h(jj+h(kk))),x0,y0,z0)} let t1=0.6-x1*x1-y1*y1-z1*z1; if(t1>=0){t1*=t1;n1=t1*t1*g(h(ii+i1+h(jj+j1+h(kk+k1))),x1,y1,z1)} let t2=0.6-x2*x2-y2*y2-z2*z2; if(t2>=0){t2*=t2;n2=t2*t2*g(h(ii+i2+h(jj+j2+h(kk+k2))),x2,y2,z2)} let t3=0.6-x3*x3-y3*y3-z3*z3; if(t3>=0){t3*=t3;n3=t3*t3*g(h(ii+1+h(jj+1+h(kk+1))),x3,y3,z3)} return 32*(n0+n1+n2+n3); } /* --- fBm with domain warping (4 octaves) ------------------- */ function _fbm3(x,y,z,octaves){ let v=0,amp=0.5,freq=1; for(let o=0;o{rng^=rng<<13;rng^=rng>>17;rng^=rng<<5;return((rng>>>0)/4294967296)}; for(let i=0;i<120;i++){ stars.push({x:rand()*W, y:rand()*H, r:0.5+rand()*1.5, a:0.3+rand()*0.7}); } return stars; } let _starCache={}; function _getStars(W,H,key){ const k=key+'_'+W+'_'+H; if(!_starCache[k]) _starCache[k]=_buildStarfield(W,H,key.charCodeAt(0)*7919+key.charCodeAt(1)*1277); return _starCache[k]; } /* War Table holograms ship a fixed bitmap (560×360 planet, 600×520 galaxy) then CSS `width:100%;height:100%` stretches that into a different-aspect box. On a short landscape desktop that is 680×220 — circles become ovals and Aelos flattens. Sync the backing store to the laid-out CSS box so draw code sees the real aspect. Detached offscreen canvases (bare globe blit) report clientWidth 0; leave those alone. Cap the short side: the planet ray-sphere is per-pixel. */ function mfFitCanvas2D(cv, maxShort){ if(!cv) return; const cssW=cv.clientWidth|0, cssH=cv.clientHeight|0; if(cssW<2||cssH<2) return; const dpr=Math.min(typeof devicePixelRatio==='number'?devicePixelRatio:1, 1.25); let w=Math.max(2, Math.round(cssW*dpr)); let h=Math.max(2, Math.round(cssH*dpr)); const cap=maxShort||640; const short=Math.min(w,h); if(short>cap){ const k=cap/short; w=Math.max(2, Math.round(w*k)); h=Math.max(2, Math.round(h*k)); } if(cv.width!==w||cv.height!==h){ cv.width=w; cv.height=h; } } /* METEORA-STYLE ORBITAL CAROUSEL & 3D PLANET SPHERE RENDERER */ function draw3DPlanetSphere(cv, planetKey, yaw, pitch, selRegionId, bare){ if(!cv) return; if(!bare) mfFitCanvas2D(cv, 640); const ctx=cv.getContext('2d'); const W=cv.width, H=cv.height; /* Bare fill uses more of the offscreen so the system blit is not a 102px disc stretched out of a 320 canvas — that read as a dark tiled ball. */ const R=Math.min(W,H)*(bare?0.46:0.32); const cx=Math.floor(W*0.5), cy=Math.floor(H*(bare?0.5:0.52)); lastPlanetGlobe={cx,cy,R,W,H}; ctx.clearRect(0,0,W,H); const P=PLANETS[planetKey]||PLANETS.aelos; const TH=THEMES[P.theme]||THEMES.verdant; lastPlanetPreviewTargets = []; /* bare: globe+atmosphere only, for compositing onto the system orbit. The War Table must not open a second WebGL context; this 2D ray-sphere is the same PLANET-stage painter, minus the stage chrome that would stamp a black rectangle over the starfield. */ if(!bare){ /* ------------------------------------------------------- 1. Deep space background + starfield ------------------------------------------------------- */ const bgGrad=ctx.createLinearGradient(0,0,0,H); bgGrad.addColorStop(0,'rgb(4,8,20)'); bgGrad.addColorStop(1,'rgb(2,6,16)'); ctx.fillStyle=bgGrad; ctx.fillRect(0,0,W,H); const stars=_getStars(W,H,planetKey); for(const s of stars){ ctx.globalAlpha=s.a; ctx.fillStyle='#ffffff'; ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,Math.PI*2); ctx.fill(); } ctx.globalAlpha=1; /* ------------------------------------------------------- 2. Concentric Meteora orbital ring tracks (ellipses) ------------------------------------------------------- */ for(let ring=0;ring<4;ring++){ const rRx=R*(1.6+ring*0.5); const rRy=rRx*0.28; ctx.strokeStyle=`rgba(120,210,255,${0.12-ring*0.02})`; ctx.lineWidth=1; ctx.setLineDash(ring%2===0?[]:[3,8]); ctx.beginPath(); ctx.ellipse(cx,cy+R*0.15,rRx,rRy,-0.04,0,Math.PI*2); ctx.stroke(); } ctx.setLineDash([]); } /* Planet globe is regions only. Sibling homeworlds and invented moons stay out of this orbit — they made each world look like its own solar system. */ /* ------------------------------------------------------- 4. Main planet: Rayleigh atmospheric corona ------------------------------------------------------- */ const atmosR=R*1.12; const atmosG=ctx.createRadialGradient(cx-R*0.15,cy-R*0.15,R*0.88,cx,cy,atmosR*1.45); atmosG.addColorStop(0.0,`rgba(${TH.wShal.join(',')},0.80)`); atmosG.addColorStop(0.35,`rgba(${TH.wShal.join(',')},0.30)`); atmosG.addColorStop(0.7,`rgba(${TH.wDeep.join(',')},0.10)`); atmosG.addColorStop(1.0,'rgba(0,0,0,0)'); ctx.fillStyle=atmosG; ctx.beginPath(); ctx.arc(cx,cy,atmosR*1.45,0,Math.PI*2); ctx.fill(); /* ------------------------------------------------------- 5. PIXEL-PERFECT RAY-SPHERE: domain-warped fBm biomes ------------------------------------------------------- */ /* Start from the already-painted orbital scene. createImageData() is fully transparent and putImageData() replaces pixels instead of alpha-blending; using it here erased the orbit tracks after they had been drawn. The terrain sphere only rewrites its own opaque disc. */ const imgData=ctx.getImageData(0,0,W,H); const data=imgData.data; /* Sun vector (top-left): Rayleigh rim lighting */ const lx=-0.38, ly=-0.42, lz=0.82; const r2=R*R; const x0=Math.max(0,Math.floor(cx-R)), x1=Math.min(W,Math.ceil(cx+R)); const y0m=Math.max(0,Math.floor(cy-R)), y1m=Math.min(H,Math.ceil(cy+R)); const cosP=Math.cos(pitch), sinP=Math.sin(pitch); /* Biome palette: 5 smooth gradient stops */ const bPal=[ {h:-1.0, rgb:TH.wDeep}, // deep ocean {h:-0.05,rgb:TH.wShal}, // shallow/coastal {h: 0.08, rgb:TH.b0}, // beach/sand {h: 0.22, rgb:TH.g0}, // lowland / jungle {h: 0.52, rgb:TH.h0}, // highland {h: 0.78, rgb:TH.plat}, // mountain peak ]; function sampleBiome(h){ /* Find the surrounding gradient stops and lerp */ for(let bi=0;bir2) continue; const idx=(y*W+x)*4; /* Sphere normal */ const nx=dx/R, ny=dy/R; const nz=Math.sqrt(Math.max(0,1-nx*nx-ny*ny)); /* 3D pitch rotation → spherical coords */ const rotY=ny*cosP-nz*sinP; const rotZ=ny*sinP+nz*cosP; const lat=Math.asin(Math.max(-1,Math.min(1,rotY))); const lon=Math.atan2(nx,rotZ)+yaw; /* Convert to 3D unit sphere point for triplanar noise */ const spx=Math.cos(lat)*Math.sin(lon); const spy=Math.sin(lat); const spz=Math.cos(lat)*Math.cos(lon); /* Domain-warped fBm terrain height (3 → 4 octave warp) */ const h=_domainWarp(spx*1.4,spy*1.4,spz*1.4,1.8,0.45); /* Polar ice blend */ const poleFade=Math.max(0,(Math.abs(lat)-1.05)*4); /* Cloud noise (2 octaves fast, phase-shifted) */ const cSx=spx*2.2+yaw*0.08, cSy=spy*2.2, cSz=spz*2.2; const cloud=_fbm3(cSx+3.1,cSy+1.7,cSz+5.3,2)*0.5+0.5; const cloudAlpha=Math.max(0,Math.min(1,(cloud-0.58)*3.5)); /* Biome color from smooth gradient */ let [r,g,b]=sampleBiome(h); /* Polar ice override */ if(poleFade>0){ r=r+(245-r)*poleFade; g=g+(250-g)*poleFade; b=b+(255-b)*poleFade; } /* Cloud layer blend */ r=r+(248-r)*cloudAlpha; g=g+(252-g)*cloudAlpha; b=b+(255-b)*cloudAlpha; /* Diffuse 3D lighting */ const dotL=Math.max(0.05,nx*lx+ny*ly+nz*lz); /* Rayleigh atmosphere rim (Fresnel) */ const rim=Math.pow(1-nz,3.8); const rimR=TH.wShal[0]*rim*0.9; const rimG=TH.wShal[1]*rim*0.9; const rimB=TH.wShal[2]*rim*0.9; /* Night-side falloff */ const nightBlend=Math.max(0,Math.min(1,-dotL*3)); data[idx ]=Math.min(255,Math.max(0,Math.round((r*dotL+rimR)*(1-nightBlend*0.85)))); data[idx+1]=Math.min(255,Math.max(0,Math.round((g*dotL+rimG)*(1-nightBlend*0.85)))); data[idx+2]=Math.min(255,Math.max(0,Math.round((b*dotL+rimB)*(1-nightBlend*0.85)))); data[idx+3]=255; } } ctx.putImageData(imgData,0,0); /* ------------------------------------------------------- 6. Specular sun glint (ocean surface) + Rim edge ------------------------------------------------------- */ const specG=ctx.createRadialGradient(cx-R*0.4,cy-R*0.42,0,cx-R*0.4,cy-R*0.42,R*0.55); specG.addColorStop(0,'rgba(255,255,255,0.38)'); specG.addColorStop(0.5,'rgba(255,255,255,0.08)'); specG.addColorStop(1,'rgba(255,255,255,0)'); ctx.save(); ctx.beginPath(); ctx.arc(cx,cy,R,0,Math.PI*2); ctx.clip(); ctx.fillStyle=specG; ctx.fillRect(cx-R,cy-R,R*2,R*2); ctx.restore(); ctx.strokeStyle=`rgba(${TH.wShal.join(',')},0.75)`; ctx.lineWidth=bare?1.5:2; ctx.beginPath(); ctx.arc(cx,cy,R,0,Math.PI*2); ctx.stroke(); /* ------------------------------------------------------- 7. 3D SURFACE REGION SECTOR POLYGON CAPS ------------------------------------------------------- */ for(const reg of P.regions){ const rLat=reg.lat, rLon=reg.lon+yaw; const cosLat=Math.cos(rLat), sinLat=Math.sin(rLat); const cosLon=Math.cos(rLon), sinLon=Math.sin(rLon); const px=cx+R*cosLat*sinLon; const py=cy-R*(sinLat*cosP-cosLat*cosLon*sinP); const pz=cosLat*cosLon*cosP+sinLat*sinP; if(pz>-0.15){ const isSel=(reg.id===selRegionId); const regColor=reg.color||'#38ef7d'; const radCap=reg.rad||0.38; const pts=[]; const numPts=20; for(let k=0;k-0.2) pts.push({x:ptX,y:ptY}); } if(pts.length>=3){ ctx.beginPath(); ctx.moveTo(pts[0].x,pts[0].y); for(let k=1;k0.02){ const isSel=(reg.id===selRegionId); const regColor=reg.color||'#38ef7d'; const lineH=isSel?20:12; ctx.strokeStyle=isSel?regColor:'rgba(255,255,255,0.4)'; ctx.lineWidth=isSel?2:1; ctx.beginPath(); ctx.moveTo(px,py); ctx.lineTo(px,py-lineH); ctx.stroke(); const topY=py-lineH; if(isSel){ ctx.strokeStyle=regColor; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(px,topY,10,0,Math.PI*2); ctx.stroke(); ctx.fillStyle=regColor; ctx.beginPath(); ctx.arc(px,topY,3.5,0,Math.PI*2); ctx.fill(); } else { ctx.fillStyle=regColor; ctx.beginPath(); ctx.arc(px,topY,3,0,Math.PI*2); ctx.fill(); } ctx.font='700 9px var(--fT,monospace)'; ctx.textAlign='center'; const lw=ctx.measureText(reg.nm).width+16; const ly=topY-(isSel?18:13); const lbg=ctx.createLinearGradient(px-lw*0.5,ly-12,px+lw*0.5,ly+4); lbg.addColorStop(0,isSel?'rgba(10,30,50,0.95)':'rgba(5,15,28,0.82)'); lbg.addColorStop(1,isSel?'rgba(5,20,38,0.95)':'rgba(3,10,20,0.82)'); ctx.fillStyle=lbg; ctx.strokeStyle=isSel?regColor:'rgba(120,200,255,0.35)'; ctx.lineWidth=isSel?1.5:0.8; const lx2=px-lw*0.5, lBr=4; ctx.beginPath(); ctx.moveTo(lx2+lBr,ly-12); ctx.lineTo(lx2+lw-lBr,ly-12); ctx.arcTo(lx2+lw,ly-12,lx2+lw,ly-12+lBr,lBr); ctx.lineTo(lx2+lw,ly+4-lBr); ctx.arcTo(lx2+lw,ly+4,lx2+lw-lBr,ly+4,lBr); ctx.lineTo(lx2+lBr,ly+4); ctx.arcTo(lx2,ly+4,lx2,ly+4-lBr,lBr); ctx.lineTo(lx2,ly-12+lBr); ctx.arcTo(lx2,ly-12,lx2+lBr,ly-12,lBr); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.fillStyle=isSel?'#ffffff':'#c8e8ff'; ctx.fillText(reg.nm,px,ly); } } } function segDist(wx,wy,x0,y0,x1,y1){ const dx=x1-x0, dy=y1-y0; const t=clamp(((wx-x0)*dx+(wy-y0)*dy)/(dx*dx+dy*dy),0,1); return Math.sqrt(dist2(wx,wy,x0+dx*t,y0+dy*t)); } /* Wet battlefields need authored hydrography, not a lucky low patch in the same noise used by dry maps. A continuous, gently bending channel reaches opposite theatre edges and stays wide enough for fleets to turn. The soft outer shelf creates a readable beach/bank instead of the black polygon cut exposed by the first naval prototype. Seed-driven orientation keeps every region distinct while remaining deterministic for replays and previews. */ function mapWaterCarve(def,wx,wy,h){ const mode=def.waterMode||'none';if(mode==='none')return h; const u=wx/MAP-.5,v=wy/MAP-.5,ang=((def.seed%13)-6)*.055+(mode==='river'?.36:.08); const ca=Math.cos(ang),sa=Math.sin(ang),along=u*ca+v*sa,across=-u*sa+v*ca; const bend=Math.sin(along*7.2+(def.seed%97)*.071)*(mode==='river'?.055:.075) +Math.sin(along*15.5+(def.seed%41)*.13)*(mode==='river'?.014:.022); const half=mode==='river'?.082:.205,bank=mode==='river'?.050:.072,d=Math.abs(across-bend); if(d