0
GAMES#5cdfb4f0
LOADOUT // MECH
@Owner·deposited 1w ago·updated 1w ago·27 views
GAMES#5cdfb4f0
LOADOUT // MECH
OW
@Owner
27Views
0Comments
0Forks
0Saves
SHARE · REMIX
LOADOUT // MECH — a HTML Games widget by @Owner.
CONTROLS
#rogue#game#mech
No comments yet. Be the first!
✦ Remix with AI
SDK in this widgetNo Vibes SDK features detected yet
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "LOADOUT // MECH" by @Owner]
Source: https://itjustvibes.com/Owner/loadout-mech
Type: HTML
--- SOURCE CODE ---
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>LOADOUT // MECH</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;600;700&family=Press+Start+2P&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
:root{--cy:#6cf2ff;--amber:#ff9a3d;--red:#ff4d6d;--gold:#ffd23f;--ink:#05080d;--steel:#9fb3c8;}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;user-select:none;}
html,body{height:100%;overflow:hidden;background:#05070c;font-family:'Chakra Petch',sans-serif;color:#dfeefc;}
#app{position:fixed;inset:0;}
canvas{display:block;}
#ui{position:absolute;inset:0;pointer-events:none;z-index:5;}
/* top hud */
#top{position:absolute;top:14px;left:16px;right:16px;display:flex;justify-content:space-between;align-items:flex-start;}
.panel{background:linear-gradient(180deg,rgba(10,18,28,.82),rgba(6,10,16,.7));
border:1px solid rgba(108,242,255,.25);border-radius:3px;padding:8px 11px;
box-shadow:0 0 22px -8px rgba(108,242,255,.4),inset 0 0 0 1px rgba(0,0,0,.5);
backdrop-filter:blur(3px);clip-path:polygon(0 0,calc(100% - 9px) 0,100% 9px,100% 100%,9px 100%,0 calc(100% - 9px));}
.lbl{font-size:10px;letter-spacing:2px;color:var(--steel);text-transform:uppercase;opacity:.8;}
#hpbar{width:168px;height:13px;background:#0a1118;border:1px solid #1d2c3a;margin-top:5px;position:relative;overflow:hidden;}
#hpfill{height:100%;width:100%;background:linear-gradient(90deg,#1de08a,#7dffc4);transition:width .15s;
box-shadow:0 0 14px rgba(40,230,150,.6);}
#hptxt{font-family:'Press Start 2P';font-size:8px;margin-top:6px;color:#7dffc4;}
#right{text-align:right;}
#wave{font-family:'Press Start 2P';font-size:13px;color:var(--gold);text-shadow:0 0 12px rgba(255,210,63,.5);}
#foes{font-size:13px;letter-spacing:1px;color:var(--steel);margin-top:4px;}
/* loadout dock */
#dock{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);display:flex;gap:8px;}
.slot{min-width:86px;text-align:center;background:linear-gradient(180deg,rgba(10,18,28,.85),rgba(6,10,16,.75));
border:1px solid rgba(159,179,200,.22);border-radius:3px;padding:7px 9px;
clip-path:polygon(7px 0,100% 0,100% calc(100% - 7px),calc(100% - 7px) 100%,0 100%,0 7px);}
.slot .s-lbl{font-size:9px;letter-spacing:2px;color:var(--steel);opacity:.7;}
.slot .s-val{font-family:'Press Start 2P';font-size:9px;margin-top:6px;line-height:1.4;}
.slot.flash{animation:slotflash .5s;}
@keyframes slotflash{0%{box-shadow:0 0 0 2px #fff, 0 0 30px #fff;}100%{box-shadow:none;}}
/* damage numbers */
#dmgLayer{position:absolute;inset:0;overflow:hidden;}
.dnum{position:absolute;font-family:'Press Start 2P';font-size:11px;transform:translate(-50%,-50%);
text-shadow:1px 1px 0 #000,0 0 8px currentColor;will-change:transform,opacity;}
/* toast */
#toast{position:absolute;left:50%;top:64px;transform:translateX(-50%) translateY(-12px);
font-family:'Press Start 2P';font-size:11px;color:#05080d;background:var(--gold);
padding:9px 16px;border-radius:2px;opacity:0;transition:opacity .25s,transform .25s;letter-spacing:1px;
clip-path:polygon(6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%,0 6px);
box-shadow:0 6px 24px -6px rgba(0,0,0,.8);}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
/* overlays */
#ov{position:absolute;inset:0;z-index:20;display:flex;flex-direction:column;align-items:center;justify-content:center;
text-align:center;padding:24px;
background:radial-gradient(circle at 50% 35%,rgba(8,16,26,.78),rgba(3,5,10,.96));}
#ov.hidden{display:none;}
.game-title{font-family:'Press Start 2P';font-size:clamp(20px,6vw,36px);letter-spacing:3px;
color:#eaf6ff;text-shadow:0 0 24px rgba(108,242,255,.55),3px 3px 0 #07304a;line-height:1.5;}
.tag{color:var(--cy);letter-spacing:5px;font-size:14px;margin:10px 0 20px;text-transform:uppercase;}
.desc{max-width:440px;font-size:18px;line-height:1.4;color:#aebfd0;margin-bottom:24px;}
.desc b{color:var(--gold);font-weight:700;}
.cta{pointer-events:auto;font-family:'Press Start 2P';font-size:13px;color:#05080d;cursor:pointer;
background:linear-gradient(180deg,#7dffc4,#1de08a);border:none;padding:16px 30px;letter-spacing:2px;
clip-path:polygon(8px 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%,0 8px);
box-shadow:0 6px 0 #0f8a55,0 12px 30px -8px rgba(40,230,150,.5);transition:transform .08s,box-shadow .08s;}
.cta:active{transform:translateY(4px);box-shadow:0 2px 0 #0f8a55;}
.controls{margin-top:22px;font-size:15px;color:#6f8197;line-height:1.7;letter-spacing:.5px;}
.controls b{color:var(--steel);}
/* touch */
#touch{position:absolute;inset:0;z-index:8;pointer-events:none;display:none;}
.stickbase{position:absolute;bottom:22px;width:120px;height:120px;border-radius:50%;pointer-events:auto;
background:radial-gradient(circle,rgba(255,255,255,.05),rgba(255,255,255,.015));
border:1.5px solid rgba(255,255,255,.12);}
#moveBase{left:20px;} #aimBase{right:20px;border-color:rgba(255,77,109,.3);}
.knob{position:absolute;left:38px;top:38px;width:44px;height:44px;border-radius:50%;border:2px solid #000;}
#moveKnob{background:radial-gradient(circle at 38% 32%,#bfe9ff,#3a7fa0);}
#aimKnob{background:radial-gradient(circle at 38% 32%,#ffb3c1,#c0203f);}
.tlabel{position:absolute;bottom:146px;font-size:10px;letter-spacing:2px;color:#6f8197;width:120px;text-align:center;}
#moveLabel{left:20px;} #aimLabel{right:20px;}
</style>
</head>
<body>
<div id="app"></div>
<div id="ui">
<div id="top">
<div class="panel">
<div class="lbl">CHASSIS INTEGRITY</div>
<div id="hpbar"><div id="hpfill"></div></div>
<div id="hptxt">100 / 100</div>
</div>
<div class="panel" id="right">
<div id="wave">WAVE 1</div>
<div id="foes">HOSTILES: 0</div>
</div>
</div>
<div id="dock">
<div class="slot" id="slotL"><div class="s-lbl">L-ARM</div><div class="s-val" style="color:var(--cy)">BLASTER</div></div>
<div class="slot" id="slotLeg"><div class="s-lbl">CHASSIS</div><div class="s-val" style="color:var(--steel)">STANDARD</div></div>
<div class="slot" id="slotR"><div class="s-lbl">R-ARM</div><div class="s-val" style="color:var(--cy)">BLASTER</div></div>
</div>
<div id="dmgLayer"></div>
<div id="toast"></div>
<div id="touch">
<div class="tlabel" id="moveLabel">MOVE</div>
<div class="stickbase" id="moveBase"><div class="knob" id="moveKnob"></div></div>
<div class="tlabel" id="aimLabel">AIM / FIRE</div>
<div class="stickbase" id="aimBase"><div class="knob" id="aimKnob"></div></div>
</div>
<div id="ov">
<div class="game-title">LOADOUT<br>MECH</div>
<div class="tag">-- combat slice --</div>
<div class="desc">Every mech you wreck drops a <b>module</b>. Drive over it and it bolts onto your frame -- new gun, new legs, new feel. Survive the waves and rebuild yourself from your kills.</div>
<button class="cta" id="deploy">DEPLOY</button>
<div class="controls" id="ctrlText"><b>WASD</b> move · <b>mouse</b> aim · <b>click / hold</b> fire</div>
</div>
</div>
<script>
window.addEventListener('load',()=>{
"use strict";
const T=THREE;
if(!T){document.getElementById('ctrlText').textContent='Three.js failed to load -- check connection.';return;}
// ---------------- renderer / scene ----------------
const app=document.getElementById('app');
const renderer=new T.WebGLRenderer({antialias:true,powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(2,window.devicePixelRatio||1));
renderer.outputEncoding=T.sRGBEncoding;
renderer.toneMapping=T.ACESFilmicToneMapping;
renderer.toneMappingExposure=1.12;
app.appendChild(renderer.domElement);
const scene=new T.Scene();
const BG=new T.Color(0x070d16);
scene.background=BG;
scene.fog=new T.FogExp2(0x070d16,0.018);
const camera=new T.PerspectiveCamera(54,1,0.1,400);
const CAM_OFF=new T.Vector3(0,17,15);
function resize(){
const w=app.clientWidth,h=app.clientHeight;
renderer.setSize(w,h,false);
camera.aspect=w/h;camera.updateProjectionMatrix();
}
window.addEventListener('resize',resize);
// ---------------- lights ----------------
scene.add(new T.AmbientLight(0x4a6680,0.55));
const key=new T.DirectionalLight(0xbfe0ff,1.15); key.position.set(-18,30,12); scene.add(key);
const rim=new T.DirectionalLight(0xff7a4d,0.5); rim.position.set(20,8,-18); scene.add(rim);
const playerGlow=new T.PointLight(0x6cf2ff,0.0,26,2); scene.add(playerGlow);
const fxLight=new T.PointLight(0xffaa55,0.0,40,2); scene.add(fxLight);
// ---------------- arena ----------------
const ARENA=42;
const floorMat=new T.MeshStandardMaterial({color:0x0d161f,metalness:0.6,roughness:0.55});
const floor=new T.Mesh(new T.PlaneGeometry(ARENA*2,ARENA*2),floorMat);
floor.rotation.x=-Math.PI/2; scene.add(floor);
// emissive grid
const grid=new T.GridHelper(ARENA*2,40,0x18506a,0x102233);
grid.material.opacity=0.5; grid.material.transparent=true; grid.position.y=0.02; scene.add(grid);
// glowing arena ring
const ring=new T.Mesh(new T.RingGeometry(ARENA-0.6,ARENA,80),
new T.MeshBasicMaterial({color:0x6cf2ff,side:T.DoubleSide,transparent:true,opacity:0.5}));
ring.rotation.x=-Math.PI/2; ring.position.y=0.03; scene.add(ring);
// cover pillars
const pillarMat=new T.MeshStandardMaterial({color:0x16222e,metalness:0.7,roughness:0.4,emissive:0x06222e,emissiveIntensity:0.4});
const pillars=[];
[[14,0,-10],[-16,0,8],[6,0,16],[-10,0,-16],[20,0,14],[-22,0,-6]].forEach(p=>{
const h=4+Math.random()*5;
const m=new T.Mesh(new T.BoxGeometry(3,h,3),pillarMat);
m.position.set(p[0],h/2,p[2]); scene.add(m); pillars.push({mesh:m,r:2.2,x:p[0],z:p[2],h});
const cap=new T.Mesh(new T.BoxGeometry(3.3,0.4,3.3),new T.MeshBasicMaterial({color:0x1d6e8c}));
cap.position.set(p[0],h+0.1,p[2]); scene.add(cap);
});
// ---------------- audio ----------------
let AC=null,mg=null;
function initAudio(){ if(AC)return; AC=new (window.AudioContext||window.webkitAudioContext)();
mg=AC.createGain(); mg.gain.value=0.5; mg.connect(AC.destination);
const o=AC.createOscillator();o.type='sine';o.frequency.value=42;
const g=AC.createGain();g.gain.value=0.05;const lp=AC.createBiquadFilter();lp.type='lowpass';lp.frequency.value=240;
o.connect(g);g.connect(lp);lp.connect(mg);o.start(); }
function tone(type,opt={}){ if(!AC)return; const t=AC.currentTime,g=AC.createGain();g.connect(mg);
if(type==='blaster'){const o=AC.createOscillator();o.type='square';o.frequency.setValueAtTime(720,t);o.frequency.exponentialRampToValueAtTime(180,t+.09);g.gain.setValueAtTime(.1,t);g.gain.exponentialRampToValueAtTime(.001,t+.1);o.connect(g);o.start(t);o.stop(t+.1);}
else if(type==='gat'){const o=AC.createOscillator();o.type='square';o.frequency.setValueAtTime(420,t);o.frequency.exponentialRampToValueAtTime(160,t+.05);g.gain.setValueAtTime(.07,t);g.gain.exponentialRampToValueAtTime(.001,t+.06);o.connect(g);o.start(t);o.stop(t+.06);}
else if(type==='cannon'){const o=AC.createOscillator();o.type='sawtooth';o.frequency.setValueAtTime(160,t);o.frequency.exponentialRampToValueAtTime(50,t+.22);g.gain.setValueAtTime(.28,t);g.gain.exponentialRampToValueAtTime(.001,t+.24);o.connect(g);o.start(t);o.stop(t+.25);noise(t,.12,.18);}
else if(type==='rocket'){const o=AC.createOscillator();o.type='sawtooth';o.frequency.setValueAtTime(300,t);o.frequency.exponentialRampToValueAtTime(120,t+.3);g.gain.setValueAtTime(.12,t);g.gain.exponentialRampToValueAtTime(.001,t+.32);o.connect(g);o.start(t);o.stop(t+.33);}
else if(type==='hit'){const o=AC.createOscillator();o.type='square';o.frequency.setValueAtTime(220,t);o.frequency.exponentialRampToValueAtTime(70,t+.08);g.gain.setValueAtTime(.16,t);g.gain.exponentialRampToValueAtTime(.001,t+.1);o.connect(g);o.start(t);o.stop(t+.1);}
else if(type==='boom'){const o=AC.createOscillator();o.type='sawtooth';o.frequency.setValueAtTime(180,t);o.frequency.exponentialRampToValueAtTime(34,t+.5);g.gain.setValueAtTime(.34,t);g.gain.exponentialRampToValueAtTime(.001,t+.55);o.connect(g);o.start(t);o.stop(t+.56);noise(t,.3,.3);}
else if(type==='hurt'){const o=AC.createOscillator();o.type='triangle';o.frequency.setValueAtTime(260,t);o.frequency.exponentialRampToValueAtTime(80,t+.2);g.gain.setValueAtTime(.3,t);g.gain.exponentialRampToValueAtTime(.001,t+.22);o.connect(g);o.start(t);o.stop(t+.22);}
else if(type==='equip'){[523,784,1046].forEach((f,i)=>{const o=AC.createOscillator();o.type='square';o.frequency.value=f;const gg=AC.createGain();gg.connect(mg);gg.gain.setValueAtTime(.0001,t+i*.06);gg.gain.linearRampToValueAtTime(.12,t+i*.06+.01);gg.gain.exponentialRampToValueAtTime(.001,t+i*.06+.13);o.connect(gg);o.start(t+i*.06);o.stop(t+i*.06+.14);});}
}
function noise(t,dur,vol){const n=AC.createBufferSource();const bf=AC.createBuffer(1,AC.sampleRate*dur,AC.sampleRate);const d=bf.getChannelData(0);for(let i=0;i<d.length;i++)d[i]=(Math.random()*2-1)*Math.pow(1-i/d.length,2);n.buffer=bf;const g=AC.createGain();g.gain.value=vol;const hp=AC.createBiquadFilter();hp.type='highpass';hp.frequency.value=600;n.connect(hp);hp.connect(g);g.connect(mg);n.start(t);}
// ---------------- registries ----------------
const WEAPONS={
blaster:{name:'BLASTER',hex:0x6cf2ff,css:'#6cf2ff',cd:0.20,dmg:7,speed:62,kind:'bolt',sz:0.32,len:1.1},
gatling:{name:'GATLING',hex:0xffd23f,css:'#ffd23f',cd:0.065,dmg:3,speed:78,kind:'bolt',sz:0.22,len:1.4,spread:0.12},
cannon:{name:'CANNON',hex:0xff7b3d,css:'#ff7b3d',cd:0.80,dmg:26,speed:50,kind:'shell',sz:0.55,len:1.8,splash:5,knock:9},
rocket:{name:'ROCKET',hex:0xff4d6d,css:'#ff4d6d',cd:0.95,dmg:18,speed:42,kind:'rocket',sz:0.45,len:1.6,splash:7,homing:0.05},
};
const LEGS={
standard:{name:'STANDARD',css:'#9fb3c8',speed:11,hp:0,scale:1.0,hex:0x9fb3c8},
strider:{name:'STRIDER',css:'#7dffc4',speed:16,hp:-15,scale:0.9,hex:0x7dffc4},
juggernaut:{name:'JUGGER',css:'#ff9a3d',speed:7.5,hp:50,scale:1.18,hex:0xff9a3d},
};
// ---------------- mech factory ----------------
function buildArm(weaponKey,side,bodyHex){
const W=WEAPONS[weaponKey];
const g=new T.Group();
const shoulder=new T.Mesh(new T.BoxGeometry(0.7,0.8,0.8),new T.MeshStandardMaterial({color:bodyHex,metalness:0.7,roughness:0.4}));
g.add(shoulder);
// weapon barrel(s) point +Z
const barMat=new T.MeshStandardMaterial({color:0x1a242e,metalness:0.85,roughness:0.3,emissive:W.hex,emissiveIntensity:0.35});
if(weaponKey==='gatling'){
const cluster=new T.Group();
for(let i=0;i<4;i++){const br=new T.Mesh(new T.CylinderGeometry(0.09,0.09,W.len,8),barMat);
br.rotation.x=Math.PI/2;const a=i*Math.PI/2;br.position.set(Math.cos(a)*0.16,Math.sin(a)*0.16,W.len/2+0.3);cluster.add(br);}
g.add(cluster); g.userData.spin=cluster;
} else if(weaponKey==='cannon'){
const br=new T.Mesh(new T.CylinderGeometry(0.3,0.34,W.len,12),barMat);br.rotation.x=Math.PI/2;br.position.z=W.len/2+0.3;g.add(br);
const mz=new T.Mesh(new T.CylinderGeometry(0.4,0.38,0.3,12),new T.MeshBasicMaterial({color:W.hex}));mz.rotation.x=Math.PI/2;mz.position.z=W.len+0.35;g.add(mz);
} else if(weaponKey==='rocket'){
const box=new T.Mesh(new T.BoxGeometry(0.6,0.6,W.len),barMat);box.position.z=W.len/2+0.3;g.add(box);
for(let i=0;i<4;i++){const tube=new T.Mesh(new T.CylinderGeometry(0.1,0.1,0.2,8),new T.MeshBasicMaterial({color:W.hex}));
tube.rotation.x=Math.PI/2;tube.position.set((i%2?0.16:-0.16),(i<2?0.16:-0.16),W.len+0.3);g.add(tube);}
} else {
const br=new T.Mesh(new T.CylinderGeometry(0.16,0.18,W.len,10),barMat);br.rotation.x=Math.PI/2;br.position.z=W.len/2+0.3;g.add(br);
const tip=new T.Mesh(new T.SphereGeometry(0.16,10,10),new T.MeshBasicMaterial({color:W.hex}));tip.position.z=W.len+0.3;g.add(tip);
}
g.userData.muzzleZ=W.len+0.4;
g.position.set(side*0.95,0.1,0.1);
return g;
}
function buildLegs(legKey,bodyHex){
const L=LEGS[legKey];
const g=new T.Group();
const mat=new T.MeshStandardMaterial({color:bodyHex,metalness:0.7,roughness:0.45});
const accent=new T.MeshStandardMaterial({color:0x101820,metalness:0.85,roughness:0.3,emissive:L.hex,emissiveIntensity:0.25});
for(const sx of [-0.55,0.55]){
const thigh=new T.Mesh(new T.BoxGeometry(0.55,1.0,0.6),mat);thigh.position.set(sx,-1.0,0);
const shin=new T.Mesh(new T.BoxGeometry(0.45,1.0,0.55),accent);shin.position.set(sx,-1.95,0.12);
const foot=new T.Mesh(new T.BoxGeometry(0.6,0.3,1.0),mat);foot.position.set(sx,-2.45,0.25);
g.add(thigh,shin,foot); g.userData[sx<0?'legL':'legR']=[thigh,shin,foot];
}
return g;
}
function buildMech(cfg){ // cfg: {bodyHex, accentHex, left, right, leg}
const root=new T.Group();
const legs=buildLegs(cfg.leg,cfg.bodyHex); root.add(legs);
const core=new T.Mesh(new T.BoxGeometry(1.7,1.5,1.2),new T.MeshStandardMaterial({color:cfg.bodyHex,metalness:0.72,roughness:0.4}));
core.position.y=0.35; root.add(core);
const chest=new T.Mesh(new T.BoxGeometry(1.2,0.8,0.4),new T.MeshStandardMaterial({color:0x0e1620,metalness:0.9,roughness:0.25,emissive:cfg.accentHex,emissiveIntensity:0.6}));
chest.position.set(0,0.5,0.62); root.add(chest);
const head=new T.Mesh(new T.BoxGeometry(0.7,0.5,0.7),new T.MeshStandardMaterial({color:cfg.bodyHex,metalness:0.7,roughness:0.4}));
head.position.y=1.35; root.add(head);
const eye=new T.Mesh(new T.BoxGeometry(0.5,0.14,0.08),new T.MeshBasicMaterial({color:cfg.accentHex}));
eye.position.set(0,1.38,0.36); root.add(eye);
const armMount=new T.Group(); armMount.position.y=0.6; root.add(armMount);
let la=buildArm(cfg.left,-1,cfg.bodyHex), ra=buildArm(cfg.right,1,cfg.bodyHex);
armMount.add(la,ra);
// blob shadow
const sh=new T.Mesh(new T.CircleGeometry(2,24),new T.MeshBasicMaterial({color:0x000000,transparent:true,opacity:0.42}));
sh.rotation.x=-Math.PI/2; sh.position.y=0.04; root.add(sh);
root.userData={legs,armMount,la,ra,core,eye,shadow:sh,cfg};
const s=LEGS[cfg.leg].scale; root.scale.setScalar(s);
return root;
}
function swapArm(mech,side,weaponKey){
const u=mech.userData; const old=side==='L'?u.la:u.ra;
u.armMount.remove(old);
const na=buildArm(weaponKey,side==='L'?-1:1,u.cfg.bodyHex);
u.armMount.add(na);
if(side==='L'){u.la=na;u.cfg.left=weaponKey;}else{u.ra=na;u.cfg.right=weaponKey;}
}
function swapLegs(mech,legKey){
const u=mech.userData; mech.remove(u.legs);
u.legs=buildLegs(legKey,u.cfg.bodyHex); mech.add(u.legs);
u.cfg.leg=legKey; mech.scale.setScalar(LEGS[legKey].scale);
}
// ---------------- game state ----------------
let player,enemies=[],bullets=[],parts=[],sparks=[],puffs=[],rings=[],dnums=[];
let state='menu',wave=0,shake=0,hitstop=0,flash=0,tt=0;
let pHP=100,pMaxHP=100,laCd=0,raCd=0,lastEquipSide='R';
const keys={};let mouse={x:0,y:0,down:false};
const aim=new T.Vector3(0,0,1); // ground aim direction
const aimPoint=new T.Vector3(); // ground aim world point
const ray=new T.Raycaster(); const groundPlane=new T.Plane(new T.Vector3(0,1,0),0);
const tmp=new T.Vector3(),tmp2=new T.Vector3(),fwd=new T.Vector3(),rgt=new T.Vector3();
let touchMode=('ontouchstart'in window)||navigator.maxTouchPoints>0;
let moveVec={x:0,y:0},aimVec={x:0,y:0},aimActive=false;
function recalcStats(){
const base=100+LEGS[player.userData.cfg.leg].hp;
pMaxHP=base; if(pHP>pMaxHP)pHP=pMaxHP;
}
function startGame(){
// reset
enemies.forEach(e=>scene.remove(e.mech)); bullets.forEach(b=>scene.remove(b.mesh));
parts.forEach(p=>scene.remove(p.grp)); enemies=[];bullets=[];parts=[];sparks=[];puffs=[];rings=[];
if(player)scene.remove(player);
player=buildMech({bodyHex:0x2f6f8f,accentHex:0x6cf2ff,left:'blaster',right:'blaster',leg:'standard'});
player.position.set(0,0,0); scene.add(player);
pHP=100;pMaxHP=100;wave=0;laCd=raCd=0;
state='play'; spawnWave(); updateHUD(); updateDock();
}
function spawnWave(){
wave++;
const n=2+wave;
const choices=Object.keys(WEAPONS);
for(let i=0;i<n;i++){
const a=Math.random()*Math.PI*2,r=ARENA*0.7;
const wk=choices[(Math.random()*choices.length)|0];
const m=buildMech({bodyHex:0x6a2230,accentHex:0xff4d6d,left:wk,right:Math.random()<0.4?wk:'blaster',leg:Math.random()<0.3?'juggernaut':'standard'});
m.position.set(Math.cos(a)*r,0,Math.sin(a)*r);
scene.add(m);
enemies.push({mech:m,hp:24+wave*8,maxhp:24+wave*8,cd:Math.random()*1.2,wk,kb:new T.Vector3(),flash:0,bob:Math.random()*7});
}
toast('WAVE '+wave+' // '+n+' HOSTILES');
updateHUD();
}
// ---------------- combat ----------------
function muzzleWorld(mech,armKey,out){
const arm=mech.userData[armKey];
out.set(0,0,arm.userData.muzzleZ); arm.localToWorld(out); return out;
}
function fireWeapon(mech,armKey,weaponKey,dir,fromPlayer){
const W=WEAPONS[weaponKey];
const pos=muzzleWorld(mech,armKey,new T.Vector3());
let d=dir.clone();
if(W.spread){const a=(Math.random()*2-1)*W.spread; d.applyAxisAngle(new T.Vector3(0,1,0),a);}
const geo=W.kind==='shell'?new T.SphereGeometry(W.sz,12,12):
W.kind==='rocket'?new T.BoxGeometry(W.sz*0.8,W.sz*0.8,W.sz*2.2):
new T.BoxGeometry(W.sz*0.5,W.sz*0.5,W.sz*2.6);
const mesh=new T.Mesh(geo,new T.MeshBasicMaterial({color:W.hex}));
mesh.position.copy(pos); mesh.position.y=1.0;
mesh.lookAt(pos.clone().add(d));
scene.add(mesh);
bullets.push({mesh,dir:d,spd:W.speed,dmg:W.dmg,life:2.2,from:fromPlayer?'p':'e',W,col:W.hex});
// muzzle flash
spawnFlash(pos.x,1.0,pos.z,W.hex);
// light pulse + shake + sound
fxLight.position.copy(pos);fxLight.position.y=1.2;fxLight.color.setHex(W.hex);fxLight.intensity=1.6;
if(fromPlayer){shake=Math.min(shake+(W.kind==='cannon'?2.6:W.kind==='rocket'?1.6:0.5),7);}
tone(weaponKey==='blaster'?'blaster':weaponKey==='gatling'?'gat':weaponKey==='cannon'?'cannon':'rocket');
// recoil kick on arm
mech.userData[armKey].position.z=-0.25;
}
function playerFire(){
const L=player.userData.cfg.left,R=player.userData.cfg.right;
if(laCd<=0){fireWeapon(player,'la',L,aim,true);laCd=WEAPONS[L].cd;}
if(raCd<=0){fireWeapon(player,'ra',R,aim,true);raCd=WEAPONS[R].cd;}
}
function dealEnemyDamage(e,dmg,dir,W){
e.hp-=dmg; e.flash=0.12;
if(dir){e.kb.add(dir.clone().multiplyScalar((W&&W.knock?W.knock:2.5)));}
dmgNum(e.mech.position,dmg,'#ffffff');
spawnSparks(e.mech.position.x,1.2,e.mech.position.z,0xff7a8c,6);
tone('hit'); hitstop=Math.max(hitstop,0.035); shake=Math.min(shake+1.1,7);
if(e.hp<=0)killEnemy(e);
}
function killEnemy(e){
const i=enemies.indexOf(e); if(i<0)return; enemies.splice(i,1);
explode(e.mech.position.x,e.mech.position.z,0xff7a4d);
scene.remove(e.mech);
dropPart(e.mech.position.x,e.mech.position.z,e.wk);
updateHUD();
if(enemies.length===0){ state='cleared'; setTimeout(()=>{ if(state==='cleared'){toast('SECTOR CLEAR');setTimeout(()=>{if(state==='cleared'){state='play';spawnWave();}},900);} },300); }
}
function hurtPlayer(dmg,dir){
pHP-=dmg; flash=Math.max(flash,0.5); shake=Math.min(shake+5,9); tone('hurt'); hitstop=Math.max(hitstop,0.05);
player.userData.core.material.emissive.setHex(0xff3344); player.userData.core.material.emissiveIntensity=1;
if(dir)player.position.add(dir.clone().setY(0).multiplyScalar(0.5));
spawnSparks(player.position.x,1.2,player.position.z,0xff4455,8);
updateHUD();
if(pHP<=0)gameOver();
}
function gameOver(){
state='dead'; explode(player.position.x,player.position.z,0x6cf2ff,true);
scene.remove(player); shake=10;flash=0.7;tone('boom');
setTimeout(showDeath,800);
}
// ---------------- parts / pickups ----------------
function dropPart(x,z,kind){
// 60% the weapon it used, else random weapon, occasionally legs
let type='weapon',key=kind;
const r=Math.random();
if(r<0.18){type='leg';key=Math.random()<0.5?'strider':'juggernaut';}
else if(r<0.5){const ks=Object.keys(WEAPONS);key=ks[(Math.random()*ks.length)|0];}
const hex=type==='leg'?LEGS[key].hex:WEAPONS[key].hex;
const grp=new T.Group();
const core=new T.Mesh(new T.IcosahedronGeometry(0.5,0),new T.MeshStandardMaterial({color:hex,emissive:hex,emissiveIntensity:0.8,metalness:0.6,roughness:0.3}));
grp.add(core);
const halo=new T.Mesh(new T.RingGeometry(0.8,0.95,24),new T.MeshBasicMaterial({color:hex,transparent:true,opacity:0.7,side:T.DoubleSide}));
halo.rotation.x=-Math.PI/2; halo.position.y=-0.4; grp.add(halo);
const beam=new T.Mesh(new T.CylinderGeometry(0.12,0.12,3,8),new T.MeshBasicMaterial({color:hex,transparent:true,opacity:0.25}));
beam.position.y=1.0; grp.add(beam);
grp.position.set(x,1.0,z); scene.add(grp);
parts.push({grp,core,halo,type,key,hex,t:0});
}
function equipPart(p){
const css=p.type==='leg'?LEGS[p.key].css:WEAPONS[p.key].css;
const nm=p.type==='leg'?LEGS[p.key].name:WEAPONS[p.key].name;
if(p.type==='leg'){ swapLegs(player,p.key); recalcStats(); toast('CHASSIS // '+nm); flashSlot('slotLeg'); }
else { const side=lastEquipSide==='R'?'L':'R'; lastEquipSide=side; swapArm(player,side,p.key);
toast((side==='L'?'L-ARM':'R-ARM')+' // '+nm); flashSlot(side==='L'?'slotL':'slotR'); }
tone('equip'); flash=Math.max(flash,0.25);
spawnSparks(player.position.x,1.4,player.position.z,p.hex,16);
scene.remove(p.grp); parts.splice(parts.indexOf(p),1);
updateDock(); updateHUD();
}
// ---------------- fx ----------------
function spawnFlash(x,y,z,hex){
const m=new T.Mesh(new T.SphereGeometry(0.5,8,8),new T.MeshBasicMaterial({color:hex,transparent:true,opacity:0.9}));
m.position.set(x,y,z); scene.add(m); puffs.push({mesh:m,life:0.1,max:0.1,grow:6,fade:true});
}
function spawnSparks(x,y,z,hex,n){
for(let i=0;i<n;i++){const m=new T.Mesh(new T.BoxGeometry(0.12,0.12,0.12),new T.MeshBasicMaterial({color:hex}));
m.position.set(x,y,z);
const a=Math.random()*Math.PI*2,el=Math.random()*1.2;
const sp=4+Math.random()*8;
scene.add(m); sparks.push({mesh:m,v:new T.Vector3(Math.cos(a)*sp,el*sp*0.6,Math.sin(a)*sp),life:0.4+Math.random()*0.3,max:0.7});}
}
function explode(x,z,hex,big){
tone('boom'); shake=Math.min(shake+(big?9:5),12);
fxLight.position.set(x,2,z);fxLight.color.setHex(hex);fxLight.intensity=big?5:3;
spawnSparks(x,1.4,z,hex,big?34:20);
const m=new T.Mesh(new T.SphereGeometry(1,12,12),new T.MeshBasicMaterial({color:hex,transparent:true,opacity:0.85}));
m.position.set(x,1.4,z);scene.add(m);puffs.push({mesh:m,life:0.3,max:0.3,grow:14,fade:true});
const rg=new T.Mesh(new T.RingGeometry(0.6,1.1,32),new T.MeshBasicMaterial({color:hex,transparent:true,opacity:0.8,side:T.DoubleSide}));
rg.rotation.x=-Math.PI/2;rg.position.set(x,0.2,z);scene.add(rg);rings.push({mesh:rg,life:0.5,max:0.5,grow:big?34:22});
// smoke
for(let i=0;i<(big?6:4);i++){const s=new T.Mesh(new T.SphereGeometry(0.7,8,8),new T.MeshBasicMaterial({color:0x222a33,transparent:true,opacity:0.5}));
s.position.set(x+(Math.random()-.5)*2,1+Math.random()*1.5,z+(Math.random()-.5)*2);scene.add(s);
puffs.push({mesh:s,life:0.6+Math.random()*0.3,max:0.9,grow:2,fade:true,rise:1.5});}
}
function dmgNum(pos,n,css){ dnums.push({pos:pos.clone().add(new T.Vector3(0,2,0)),n:Math.round(n),css,life:0.7,el:null}); }
// ---------------- HUD ----------------
const $=id=>document.getElementById(id);
function updateHUD(){
$('hpfill').style.width=Math.max(0,pHP/pMaxHP*100)+'%';
const c=pHP/pMaxHP; $('hpfill').style.background=c>0.5?'linear-gradient(90deg,#1de08a,#7dffc4)':c>0.25?'linear-gradient(90deg,#e0b81d,#ffe27d)':'linear-gradient(90deg,#e0341d,#ff7d7d)';
$('hptxt').textContent=Math.max(0,Math.ceil(pHP))+' / '+pMaxHP;
$('wave').textContent='WAVE '+wave;
$('foes').textContent='HOSTILES: '+enemies.length;
}
function updateDock(){
const c=player.userData.cfg;
$('slotL').querySelector('.s-val').textContent=WEAPONS[c.left].name;
$('slotL').querySelector('.s-val').style.color=WEAPONS[c.left].css;
$('slotR').querySelector('.s-val').textContent=WEAPONS[c.right].name;
$('slotR').querySelector('.s-val').style.color=WEAPONS[c.right].css;
$('slotLeg').querySelector('.s-val').textContent=LEGS[c.leg].name;
$('slotLeg').querySelector('.s-val').style.color=LEGS[c.leg].css;
}
function flashSlot(id){const el=$(id);el.classList.remove('flash');void el.offsetWidth;el.classList.add('flash');}
let toastTimer=null;
function toast(txt){const t=$('toast');t.textContent=txt;t.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),1500);}
const ov=$('ov');
function showDeath(){
const c=player?player.userData.cfg:{left:'blaster',right:'blaster',leg:'standard'};
ov.classList.remove('hidden');
ov.innerHTML=`<div class="game-title" style="color:#ff6b81;font-size:clamp(22px,7vw,40px)">DESTROYED</div>
<div class="tag" style="color:#ff9aa8">reached wave ${wave}</div>
<div class="desc">Final loadout: <b style="color:${WEAPONS[c.left].css}">${WEAPONS[c.left].name}</b> / <b style="color:${WEAPONS[c.right].css}">${WEAPONS[c.right].name}</b> on <b style="color:${LEGS[c.leg].css}">${LEGS[c.leg].name}</b> legs.</div>
<button class="cta" id="again">REDEPLOY</button>`;
$('again').onclick=()=>{ov.classList.add('hidden');startGame();};
}
// ---------------- input ----------------
addEventListener('keydown',e=>{keys[e.key.toLowerCase()]=true;if(['arrowup','arrowdown','arrowleft','arrowright',' '].includes(e.key.toLowerCase()))e.preventDefault();});
addEventListener('keyup',e=>{keys[e.key.toLowerCase()]=false;});
renderer.domElement.addEventListener('mousemove',e=>{const r=renderer.domElement.getBoundingClientRect();
mouse.x=((e.clientX-r.left)/r.width)*2-1; mouse.y=-((e.clientY-r.top)/r.height)*2+1;});
renderer.domElement.addEventListener('mousedown',()=>{initAudio();mouse.down=true;});
addEventListener('mouseup',()=>mouse.down=false);
if(touchMode){
$('ctrlText').innerHTML='<b>LEFT STICK</b> move · <b>RIGHT STICK</b> aim + fire';
$('touch').style.display='block';
setupStick('moveBase','moveKnob',(x,y)=>{moveVec.x=x;moveVec.y=y;},()=>{moveVec.x=0;moveVec.y=0;});
setupStick('aimBase','aimKnob',(x,y)=>{aimVec.x=x;aimVec.y=y;aimActive=(x*x+y*y)>0.05;},()=>{aimVec.x=0;aimVec.y=0;aimActive=false;});
}
function setupStick(baseId,knobId,onMove,onEnd){
const base=$(baseId),knob=$(knobId);let id=null,cx=0,cy=0;
base.addEventListener('touchstart',e=>{e.preventDefault();initAudio();const t=e.changedTouches[0];id=t.identifier;
const r=base.getBoundingClientRect();cx=r.left+r.width/2;cy=r.top+r.height/2;},{passive:false});
addEventListener('touchmove',e=>{for(const t of e.changedTouches)if(t.identifier===id){
let dx=t.clientX-cx,dy=t.clientY-cy;const m=Math.hypot(dx,dy),max=42;if(m>max){dx=dx/m*max;dy=dy/m*max;}
knob.style.left=(38+dx)+'px';knob.style.top=(38+dy)+'px';onMove(dx/max,dy/max);}},{passive:false});
addEventListener('touchend',e=>{for(const t of e.changedTouches)if(t.identifier===id){id=null;knob.style.left='38px';knob.style.top='38px';onEnd();}});
}
$('deploy').onclick=()=>{initAudio();ov.classList.add('hidden');startGame();};
// ---------------- update ----------------
function update(dt){
tt+=dt;
// arena ring pulse
ring.material.opacity=0.35+0.2*Math.sin(tt*2);
if(state!=='play'&&state!=='cleared')return;
// camera basis (ground)
camera.getWorldDirection(fwd); fwd.y=0; fwd.normalize();
rgt.set(-fwd.z,0,fwd.x);
// movement
let ix=0,iz=0;
if(keys['w']||keys['arrowup'])iz+=1;
if(keys['s']||keys['arrowdown'])iz-=1;
if(keys['d']||keys['arrowright'])ix+=1;
if(keys['a']||keys['arrowleft'])ix-=1;
if(touchMode){ix=moveVec.x;iz=-moveVec.y;}
tmp.set(0,0,0).addScaledVector(fwd,iz).addScaledVector(rgt,ix);
if(tmp.lengthSq()>0.02){tmp.normalize();
const spd=LEGS[player.userData.cfg.leg].speed;
player.position.addScaledVector(tmp,spd*dt);
// walk bob
const u=player.userData; const ph=Math.sin(tt*12)*0.12;
if(u.legs.userData.legL){u.legs.userData.legL.forEach((p,i)=>p.position.z=Math.sin(tt*12)*0.3);
u.legs.userData.legR.forEach((p,i)=>p.position.z=-Math.sin(tt*12)*0.3);}
player.position.y=Math.abs(Math.sin(tt*12))*0.1;
if(Math.random()<0.25)spawnSparks(player.position.x,0.1,player.position.z,0x2a3642,1);
} else { player.position.y*=0.8; }
// clamp to arena & pillars
const pr=ARENA-2;const pd=Math.hypot(player.position.x,player.position.z);
if(pd>pr){player.position.x*=pr/pd;player.position.z*=pr/pd;}
pillars.forEach(p=>{const dx=player.position.x-p.x,dz=player.position.z-p.z,d=Math.hypot(dx,dz),min=p.r+1.4;
if(d<min){player.position.x=p.x+dx/d*min;player.position.z=p.z+dz/d*min;}});
// aim
if(touchMode){
if(aimActive){aim.set(aimVec.x,0,aimVec.y).applyAxisAngle(new T.Vector3(0,1,0),0);
// map screen-stick to camera-relative ground dir
aim.set(0,0,0).addScaledVector(fwd,-aimVec.y).addScaledVector(rgt,aimVec.x);
if(aim.lengthSq()>0.01)aim.normalize();}
} else {
ray.setFromCamera(new T.Vector2(mouse.x,mouse.y),camera);
if(ray.ray.intersectPlane(groundPlane,aimPoint)){
aim.copy(aimPoint).sub(player.position).setY(0);
if(aim.lengthSq()>0.01)aim.normalize();
}
}
player.rotation.y=Math.atan2(aim.x,aim.z);
// firing
laCd-=dt;raCd-=dt;
const firing = touchMode? aimActive : mouse.down;
if(firing) playerFire();
// arm recoil recover + gatling spin
['la','ra'].forEach(k=>{const a=player.userData[k];a.position.z+=(0.1-a.position.z)*Math.min(1,dt*12);
if(a.userData.spin)a.userData.spin.rotation.z+=dt*22;});
// core emissive recover
const core=player.userData.core; core.material.emissiveIntensity*=Math.pow(0.01,dt);
// player glow
playerGlow.position.copy(player.position);playerGlow.position.y=1.5;playerGlow.intensity=0.7;
playerGlow.color.setHex(WEAPONS[player.userData.cfg.right].hex);
// enemies
enemies.forEach(e=>{
const m=e.mech; e.flash-=dt; e.bob+=dt;
m.position.add(e.kb.clone().multiplyScalar(dt)); e.kb.multiplyScalar(Math.pow(0.0005,dt));
const to=tmp.copy(player.position).sub(m.position).setY(0); const d=to.length(); to.normalize();
const want=12;
if(Math.abs(d-want)>1.5){m.position.addScaledVector(to,(d>want?1:-1)*6*dt);}
else {m.position.addScaledVector(tmp2.set(-to.z,0,to.x),3*dt);}
// pillar avoid + arena clamp
const ed=Math.hypot(m.position.x,m.position.z);if(ed>ARENA-2){m.position.x*=(ARENA-2)/ed;m.position.z*=(ARENA-2)/ed;}
m.rotation.y=Math.atan2(to.x,to.z);
m.position.y=Math.abs(Math.sin(e.bob*9))*0.08;
// flash material
e.mech.userData.core.material.emissive.setHex(e.flash>0?0xffffff:0x6a2230);
e.mech.userData.core.material.emissiveIntensity=e.flash>0?1.2:0.0;
e.cd-=dt;
if(e.cd<=0&&d<26){e.cd=WEAPONS[e.wk].cd*2.4+0.3; fireWeapon(m,'ra',e.wk,to.clone(),false);}
});
// bullets
for(let i=bullets.length-1;i>=0;i--){const b=bullets[i];
b.mesh.position.addScaledVector(b.dir,b.spd*dt); b.life-=dt;
let dead=b.life<=0;
const bp=b.mesh.position;
if(Math.hypot(bp.x,bp.z)>ARENA){dead=true;}
pillars.forEach(p=>{if(!dead&&Math.hypot(bp.x-p.x,bp.z-p.z)<p.r&&bp.y<p.h){dead=true;spawnSparks(bp.x,bp.y,bp.z,b.col,5);}});
if(b.from==='p'){
for(const e of enemies){if(dead)break;
if(e.mech.position.distanceTo(bp)<1.7){dealEnemyDamage(e,b.dmg,b.dir,b.W);
if(b.W.splash){enemies.forEach(o=>{if(o!==e&&o.mech.position.distanceTo(bp)<b.W.splash)dealEnemyDamage(o,b.dmg*0.5,b.dir,null);});explode(bp.x,bp.z,b.col);}
dead=true;}}
} else {
if(player&&player.position.distanceTo(bp)<1.6){hurtPlayer(b.dmg,b.dir);dead=true;}
}
if(dead){spawnSparks(bp.x,bp.y,bp.z,b.col,4);scene.remove(b.mesh);bullets.splice(i,1);}
}
// pickups
for(let i=parts.length-1;i>=0;i--){const p=parts[i];p.t+=dt;
p.core.rotation.y+=dt*2;p.core.rotation.x+=dt*1.3;p.grp.position.y=1.0+Math.sin(p.t*3)*0.2;
p.halo.rotation.z+=dt*1.5;p.halo.material.opacity=0.5+0.3*Math.sin(p.t*5);
if(player&&Math.hypot(p.grp.position.x-player.position.x,p.grp.position.z-player.position.z)<1.6)equipPart(p);
}
// fxlight decay
fxLight.intensity*=Math.pow(0.0002,dt);
}
function stepFx(dt){
for(let i=sparks.length-1;i>=0;i--){const s=sparks[i];s.mesh.position.addScaledVector(s.v,dt);s.v.y-=22*dt;s.v.multiplyScalar(Math.pow(0.1,dt));s.life-=dt;
s.mesh.material.opacity=Math.max(0,s.life/s.max);s.mesh.material.transparent=true;
if(s.life<=0){scene.remove(s.mesh);sparks.splice(i,1);}}
for(let i=puffs.length-1;i>=0;i--){const p=puffs[i];p.life-=dt;const k=p.life/p.max;
p.mesh.scale.addScalar(p.grow*dt);if(p.rise)p.mesh.position.y+=p.rise*dt;
p.mesh.material.opacity=Math.max(0,k*(p.mesh.material.opacity>0?1:1)*(p.fade?k:1));
if(p.life<=0){scene.remove(p.mesh);puffs.splice(i,1);}}
for(let i=rings.length-1;i>=0;i--){const r=rings[i];r.life-=dt;const k=r.life/r.max;
r.mesh.scale.addScalar(r.grow*dt);r.mesh.material.opacity=Math.max(0,k*0.8);
if(r.life<=0){scene.remove(r.mesh);rings.splice(i,1);}}
}
// damage numbers (screen space)
const dmgLayer=$('dmgLayer');
function stepDnums(dt){
const r=renderer.domElement.getBoundingClientRect();
for(let i=dnums.length-1;i>=0;i--){const d=dnums[i];d.life-=dt;d.pos.y+=dt*1.4;
if(!d.el){d.el=document.createElement('div');d.el.className='dnum';d.el.textContent=d.n;d.el.style.color=d.css;dmgLayer.appendChild(d.el);}
const v=d.pos.clone().project(camera);
d.el.style.left=((v.x*0.5+0.5)*r.width)+'px';
d.el.style.top=((-v.y*0.5+0.5)*r.height)+'px';
d.el.style.opacity=Math.max(0,d.life/0.7);
if(d.life<=0){dmgLayer.removeChild(d.el);dnums.splice(i,1);}
}
}
// ---------------- camera + loop ----------------
function updateCamera(dt){
const tgt=player&&(state==='play'||state==='cleared')?player.position:new T.Vector3(0,0,0);
const want=tmp.copy(tgt).add(CAM_OFF);
camera.position.lerp(want,Math.min(1,dt*4));
const sx=(Math.random()*2-1)*shake*0.12,sy=(Math.random()*2-1)*shake*0.12;
camera.position.x+=sx;camera.position.y+=sy;
camera.lookAt(tgt.x,tgt.y+1.5,tgt.z);
}
let last=performance.now();
function loop(now){
let dt=(now-last)/1000;last=now;if(dt>0.05)dt=0.05;
shake*=Math.pow(0.0009,dt);if(shake<0.04)shake=0;
flash*=Math.pow(0.004,dt);if(flash<0.01)flash=0;
if(hitstop>0)hitstop-=dt; else update(dt);
stepFx(dt);stepDnums(dt);updateCamera(dt);
renderer.setClearColor(BG);
renderer.render(scene,camera);
// hit flash overlay via fog tint
if(flash>0){scene.background.setRGB(0.07+flash*0.5,0.05,0.08);}else{scene.background.copy(BG);}
requestAnimationFrame(loop);
}
resize();requestAnimationFrame(loop);
});
</script>
</body>
</html>
```
[REQUESTED CHANGES]
(no specific request — apply your best judgment)
--- HOW TO RESPOND (READ FIRST) ---
Before writing any code, follow this exact process:
1. **ANALYZE** the widget source code provided above and identify:
a. Which Vibes SDK features it already uses (vibes.save, vibes.load, vibes.shared.join, etc.)
b. Which SDK features would genuinely benefit THIS specific widget — tailored to what it does, not a dump of everything available.
2. **PRESENT A NUMBERED LIST** covering:
- SDK features currently active in this widget
- New SDK features that would concretely improve this widget (be specific: why this widget, what it enables)
3. **WAIT** — do not write any code yet. Reply with your analysis and numbered list, then stop and ask the user which numbered items they want.
4. **IMPLEMENT ONLY** the items the user confirms, plus any explicit change they requested. Do not add unrequested features.
**IMPORTANT — Shared state room names:**
If you add vibes.shared.join(), do NOT use a hardcoded string literal as the room name (e.g. vibes.shared.join("lobby")) unless the user explicitly wants ALL viewers to share one single global state. A hardcoded room name means every person who visits this widget reads and writes the same shared state — it is a global room. For per-user or per-session isolation, derive the room name from a variable (e.g. a user ID, session token, or random value). When in doubt, use vibes.save/vibes.load for per-user persistence instead.
--- VIBES SDK CONTEXT ---
## Vibes SDK Reference
You are building an HTML widget for It Just Vibes (itjustvibes.com). The Vibes SDK is auto-injected — do NOT add a script tag. Just use `window.vibes` (or just `vibes`).
### Setup
Wrap your startup code in `vibes.onReady`:
```js
vibes.onReady(async () => {
const saved = await vibes.load("myKey");
// your widget logic here
});
```
### State (Per-User Persistence)
Every user gets their own isolated state per widget. All methods return Promises.
| Method | Description |
|--------|-------------|
| `await vibes.save(key, value)` | Save JSON-serializable data |
| `await vibes.load(key)` | Load saved data (returns `null` if not found) |
| `await vibes.delete(key)` | Delete a saved key |
| `await vibes.listKeys()` | Get array of all saved key names |
**Key rules:**
- Keys are strings, max 64 characters, alphanumeric + dashes/underscores
- Values must be JSON-serializable (objects, arrays, strings, numbers, booleans)
- Max 100KB per value, 500KB per widget per user, 5MB per user total
- Max 100 keys per widget per user
### Fetch Proxy
Use direct `fetch()` for APIs with permissive CORS headers (`Access-Control-Allow-Origin: *`). Use `vibes.fetch` for APIs without CORS headers (the proxy handles cross-origin requests):
```js
const resp = await vibes.fetch("https://api.example.com/data", {
method: "GET", // GET, POST, PUT, DELETE
headers: {}, // optional headers
body: null, // optional body (string)
timeout: null // optional timeout in ms
});
const data = await resp.json(); // or resp.text()
console.log(resp.status, resp.ok);
```
### Multiplayer (Shared State)
Real-time shared state across all users viewing the same widget.
```js
// Join a room (call once at startup)
await vibes.shared.join("lobby", { persistent: true });
// Set shared state (broadcasts to all users)
await vibes.shared.set("score", { player1: 10, player2: 7 });
// Read shared state (synchronous, returns last known value)
const score = vibes.shared.get("score");
// Listen for changes to a specific key
vibes.shared.onChange("score", (newValue) => {
console.log("Score updated:", newValue);
});
// Listen for any shared state change
vibes.shared.onAny((key, value) => {
console.log(key, "changed to", value);
});
// Get number of connected users
const count = await vibes.shared.getUserCount();
// Leave room
vibes.shared.leave();
// Clear all shared state for this room
await vibes.shared.clear();
```
**Shared state options:**
- `{ persistent: true }` — state survives page reloads (stored server-side)
- Default room name is "__default__" if omitted
### Rules
1. **Do NOT use localStorage, sessionStorage, or window.storage** — they are blocked or undefined in the sandbox. Use `vibes.save`/`vibes.load` instead.
2. **Do NOT add a script tag to import the SDK** — it is auto-injected.
3. **Wrap startup code in `vibes.onReady()`** — the SDK may not be ready immediately.
4. **Await all SDK calls** — every method (except `vibes.shared.get`) returns a Promise.
5. **Do NOT import external JS libraries via script tags** — they will be blocked. Include library code inline or use a CDN link in a `<link>` tag for CSS only.
6. **Keep total code under 80KB** — that is the default widget size limit (your account limit may be higher).
### Rate Limits
| Operation | Limit |
|-----------|-------|
| Writes (save + delete combined) | 30/min |
| Reads (load) | 60/min |
| List keys | 30/min |
| Fetch proxy | Rate limited per widget |
### Error Handling
All SDK methods can reject. Wrap in try/catch:
```js
try {
await vibes.save("key", value);
} catch (err) {
console.error("Save failed:", err.message);
}
```
Fetch proxy errors include `err.code`: `"RATE_LIMITED"`, `"BLOCKED"`, `"FETCH_ERROR"`.
### Data Export
Export rows of data as CSV or Excel directly from your widget.
```js
// Register dataset for the platform's "Export data" button
// AND optionally trigger an immediate download
vibes.exportData(rows, { filename: 'results.csv' })
// rows: Array of arrays (each inner array is one row; first row = headers)
// options.filename: sets the download filename and format (csv or xlsx)
// options.directDownload: false to only register without downloading (default: true)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `filename` | string | `'data.csv'` | Download filename; extension determines format (`.csv` or `.xlsx`) |
| `directDownload` | boolean | `true` | Trigger immediate browser download in addition to registering the dataset |
**Notes:**
- First call registers the dataset so the widget chrome shows an "Export data" button
- Use `.csv` extension for CSV, `.xlsx` for Excel
- CSV injection safety is automatic (formula-starting cells prefixed with `'`)
- Data stays in the browser — no server round-trip
--- FORK & RESUBMIT INSTRUCTION ---
Return the complete, self-contained HTML file with all changes applied.
It should be ready to paste into itjustvibes.com/submit to create a new fork.
Include this comment at the top of the output:
/* Forked from: https://itjustvibes.com/Owner/loadout-mech */