Files
Active-Ragdoll/active_ragdoll-v3-weapons.html
2026-06-10 17:59:46 -04:00

1014 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Active Ragdoll — Weapons</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#0e1116;font-family:system-ui,'Segoe UI',Roboto,sans-serif}
canvas{display:block;cursor:crosshair}
#ui{position:fixed;top:14px;left:14px;z-index:10;background:rgba(15,18,24,.88);backdrop-filter:blur(8px);
border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:14px 16px;color:#e8ecf2;
box-shadow:0 8px 30px rgba(0,0,0,.5);max-width:296px;user-select:none}
#ui h1{font-size:15px;margin:0 0 2px;font-weight:650}
#ui .sub{font-size:11px;color:#9aa6b5;margin-bottom:8px}
#state{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.6px;padding:3px 11px;border-radius:99px;
background:#173527;color:#7ce0a3;border:1px solid rgba(124,224,163,.3);margin-bottom:8px;transition:all .25s}
.lbl{font-size:10px;color:#6b7a8d;text-transform:uppercase;letter-spacing:.8px;margin:8px 0 4px;font-weight:600}
.row{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:3px}
button{cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#232a35;color:#e8ecf2;border-radius:9px;
padding:6px 10px;font-size:12px;font-weight:550;transition:background .15s,transform .05s;white-space:nowrap}
button:hover{background:#2e3848}button:active{transform:scale(.95)}
button.tog.on{background:#3b82f6;border-color:#60a5fa}
button.danger{border-color:rgba(239,68,68,.35);color:#fca5a5}button.danger:hover{background:#3b1a1a}
button.he{border-color:rgba(251,146,60,.45);color:#fdba74}button.he:hover{background:#3b2010}
button.ap{border-color:rgba(167,243,208,.35);color:#86efac}button.ap:hover{background:#0d2b1a}
button.mol{border-color:rgba(251,191,36,.4);color:#fde68a}button.mol:hover{background:#2d1f06}
button.sel{outline:2px solid #60a5fa;outline-offset:1px}
#ammo-row{display:flex;align-items:center;gap:6px;margin-top:6px;flex-wrap:wrap}
.ammo-btn{font-size:11px;padding:4px 9px}
.ammo-btn.active-ammo{background:#1e3a22;border-color:#4ade80;color:#86efac}
#cam-badge{display:inline-block;font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;margin-left:5px;
background:#1a1f2b;border:1px solid rgba(255,255,255,.15);color:#94a3b8;vertical-align:middle}
.slider-row{display:flex;align-items:center;gap:8px;margin-top:5px}
.slider-row label{font-size:11px;color:#9aa6b5;white-space:nowrap}
.slider-row input[type=range]{flex:1;accent-color:#ef4444;height:4px}
.slider-row span{font-size:11px;color:#fca5a5;width:36px;text-align:right}
#hint{position:fixed;bottom:12px;left:50%;transform:translateX(-50%);z-index:10;color:#aab6c6;font-size:12px;
background:rgba(15,18,24,.65);border:1px solid rgba(255,255,255,.07);padding:6px 14px;
border-radius:99px;white-space:nowrap;pointer-events:none}
#hint b{color:#dfe7f1}
#flash{position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5;opacity:0;
background:radial-gradient(ellipse at center,rgba(255,160,60,.3) 0%,transparent 70%);transition:opacity .06s}
</style>
</head>
<body>
<div id="ui">
<h1>Active Ragdoll <span id="cam-badge">ORBIT</span></h1>
<div class="sub">Weapons Edition · Self-balancing muscles</div>
<div><span id="state">BALANCING</span></div>
<div class="lbl">Ragdoll</div>
<div class="row">
<button id="bShove">Shove</button>
<button id="bKO">Knockout</button>
<button id="bSlow" class="tog">Slow-mo</button>
<button id="bReset">Reset</button>
</div>
<div class="lbl">Camera <span style="color:#6b7a8d;font-weight:400">(V to cycle)</span></div>
<div class="row">
<button id="camOrbit" class="sel" onclick="setCamMode('orbit')">🌐 Orbit</button>
<button id="camFollow" onclick="setCamMode('follow')">🎯 Follow</button>
<button id="camFP" onclick="setCamMode('fp')">👁 First-Person</button>
</div>
<div class="lbl">Weapon</div>
<div class="row" id="weapon-row">
<button id="wBall" class="sel" onclick="selectWeapon('ball')">⚾ Ball</button>
<button id="wGun" onclick="selectWeapon('gun')">🔫 Pistol</button>
<button id="wCannon" onclick="selectWeapon('cannon')">💣 Cannon</button>
<button id="wC4" onclick="selectWeapon('c4')">🧱 C4</button>
<button id="wMol" class="mol" onclick="selectWeapon('molotov')">🍾 Molotov</button>
</div>
<div id="ammo-row" style="display:none">
<span style="font-size:11px;color:#9aa6b5">Ammo:</span>
<button class="ammo-btn active-ammo" id="amNormal" onclick="selectAmmo('normal')">Normal</button>
<button class="ammo-btn he" id="amHE" onclick="selectAmmo('HE')">HE</button>
<button class="ammo-btn ap" id="amAP" onclick="selectAmmo('AP')">AP</button>
</div>
<div id="c4-options" style="display:none">
<div class="slider-row">
<label>Blast power</label>
<input type="range" id="c4Power" min="5" max="100" value="28" step="1">
<span id="c4PowerVal">28</span>
</div>
</div>
<div class="lbl" style="margin-top:8px">C4</div>
<div class="row">
<button class="danger" id="bDetonate">💥 Detonate</button>
<button class="danger" id="bClearC4">Clear</button>
</div>
</div>
<div id="hint"><b>LMB</b> fire/place &nbsp;·&nbsp; <b>RMB drag</b> grab &nbsp;·&nbsp; <b>LMB drag</b> orbit &nbsp;·&nbsp; <b>V</b> cam &nbsp;·&nbsp; <b>scroll</b> zoom</div>
<div id="flash"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
'use strict';
/* ===== RENDERER ===== */
const renderer = new THREE.WebGLRenderer({antialias:true,powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setSize(innerWidth,innerHeight);
renderer.shadowMap.enabled=true; renderer.shadowMap.type=THREE.PCFSoftShadowMap;
renderer.outputEncoding=THREE.sRGBEncoding;
renderer.toneMapping=THREE.ACESFilmicToneMapping; renderer.toneMappingExposure=1.06;
document.body.appendChild(renderer.domElement);
/* ===== SCENE ===== */
const scene=new THREE.Scene();
scene.fog=new THREE.Fog(0x9fb2c2,18,72);
{
const c=document.createElement('canvas'); c.width=1; c.height=256;
const g=c.getContext('2d'), gr=g.createLinearGradient(0,0,0,256);
gr.addColorStop(0,'#3e6da8'); gr.addColorStop(.5,'#8aa6c0'); gr.addColorStop(1,'#c8cdce');
g.fillStyle=gr; g.fillRect(0,0,1,256);
scene.background=new THREE.CanvasTexture(c);
}
scene.add(new THREE.HemisphereLight(0xbdd3ea,0x4d463b,.9));
const sun=new THREE.DirectionalLight(0xfff0d8,1.5);
sun.position.set(6,9,4); sun.castShadow=true;
sun.shadow.mapSize.set(1024,1024);
sun.shadow.camera.near=1; sun.shadow.camera.far=30;
sun.shadow.camera.left=-8; sun.shadow.camera.right=8;
sun.shadow.camera.top=8; sun.shadow.camera.bottom=-8;
sun.shadow.bias=-0.001; sun.shadow.radius=3;
scene.add(sun);
const fill=new THREE.DirectionalLight(0xc9d8ff,.28); fill.position.set(-5,4,-6); scene.add(fill);
const flashPt=new THREE.PointLight(0xff8822,0,20,2); scene.add(flashPt);
function makeGroundTex(){
const c=document.createElement('canvas'); c.width=c.height=512;
const g=c.getContext('2d');
// base concrete colour
g.fillStyle='#7a7e83'; g.fillRect(0,0,512,512);
// subtle noise
for(let i=0;i<5000;i++){
const v=18+Math.random()*40|0;
g.fillStyle=`rgba(${v},${v},${v+3},${.04+Math.random()*.10})`;
g.fillRect(Math.random()*512,Math.random()*512,1.8,1.8);
}
// grid lines
g.strokeStyle='rgba(255,255,255,0.18)'; g.lineWidth=1.5;
const cell=64;
for(let i=0;i<=512;i+=cell){
g.beginPath(); g.moveTo(i,0); g.lineTo(i,512); g.stroke();
g.beginPath(); g.moveTo(0,i); g.lineTo(512,i); g.stroke();
}
// faint diagonal cross-hatch every other cell for depth
g.strokeStyle='rgba(0,0,0,0.07)'; g.lineWidth=.8;
for(let row=0;row<512/cell;row++) for(let col=0;col<512/cell;col++){
const x=col*cell, y=row*cell;
g.beginPath(); g.moveTo(x,y); g.lineTo(x+cell,y+cell); g.stroke();
}
const t=new THREE.CanvasTexture(c);
t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(18,18); t.anisotropy=8; return t;
}
const ground=new THREE.Mesh(
new THREE.PlaneGeometry(4000,4000),
new THREE.MeshStandardMaterial({map:makeGroundTex(),roughness:.95,metalness:0})
);
ground.rotation.x=-Math.PI/2; ground.receiveShadow=true; scene.add(ground);
/* ===== CAMERA ===== */
const camera=new THREE.PerspectiveCamera(60,innerWidth/innerHeight,.05,200);
let camMode='orbit';
const orbit={yaw:.65,pitch:.18,dist:4.6,target:new THREE.Vector3(0,1,0)};
const fp={yaw:.65,pitch:0};
function updateCamera(){
orbit.pitch=Math.max(-1.48,Math.min(1.48,orbit.pitch));
orbit.dist=Math.max(1,Math.min(20,orbit.dist));
if(camMode==='fp'){
const head=parts[P.head];
fp.pitch=Math.max(-1.4,Math.min(1.4,fp.pitch));
camera.position.copy(head.pos).setY(head.pos.y+.06);
camera.lookAt(
head.pos.x+Math.sin(fp.yaw)*Math.cos(fp.pitch)*10,
head.pos.y+.06+Math.sin(fp.pitch)*10,
head.pos.z+Math.cos(fp.yaw)*Math.cos(fp.pitch)*10
);
} else {
const cp=Math.cos(orbit.pitch);
camera.position.set(
orbit.target.x+Math.sin(orbit.yaw)*cp*orbit.dist,
orbit.target.y+Math.sin(orbit.pitch)*orbit.dist,
orbit.target.z+Math.cos(orbit.yaw)*cp*orbit.dist
);
camera.lookAt(orbit.target);
}
}
updateCamera();
addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);});
const camBadge=document.getElementById('cam-badge');
const CAM_LABELS={orbit:'ORBIT',follow:'FOLLOW',fp:'1ST PERSON'};
function setCamMode(m){
camMode=m; camBadge.textContent=CAM_LABELS[m];
['Orbit','Follow','FP'].forEach(id=>document.getElementById('cam'+id).classList.toggle('sel',id.toLowerCase()===m||(id==='FP'&&m==='fp')));
if(m==='follow'||m==='fp') orbit.target.copy(brain.com);
}
addEventListener('keydown',e=>{if(e.key==='v'||e.key==='V'){const m=['orbit','follow','fp'];setCamMode(m[(m.indexOf(camMode)+1)%3]);}});
/* ===== PHYSICS ===== */
const GRAVITY=-9.81,AIR=.998,PHYS_DT=1/60,SUBSTEPS=2,ITER=8;
const parts=[],P={};
function addPart(name,x,y,z,m,r){
P[name]=parts.length;
parts.push({name,pos:new THREE.Vector3(x,y,z),prev:new THREE.Vector3(x,y,z),force:new THREE.Vector3(),m,w:1/m,r});
}
const cons=[],bonded=new Set();
function link(a,b,stiff=1,type='eq',rest=null){
const i=P[a],j=P[b],d=rest!==null?rest:parts[i].pos.distanceTo(parts[j].pos);
cons.push({i,j,rest:d,stiff,type});
if(type==='eq')bonded.add(i<j?i+'_'+j:j+'_'+i);
}
const _d=new THREE.Vector3(),_v=new THREE.Vector3(),_t=new THREE.Vector3(),_t2=new THREE.Vector3(),_tmp=new THREE.Vector3();
function projectCon(c){
const a=parts[c.i],b=parts[c.j];
_d.subVectors(b.pos,a.pos); const L=_d.length(); if(L<1e-9)return;
if(c.type==='max'&&L<=c.rest)return;
if(c.type==='min'&&L>=c.rest)return;
const diff=(L-c.rest)/L,ws=a.w+b.w; if(!ws)return;
a.pos.addScaledVector(_d,diff*c.stiff*a.w/ws);
b.pos.addScaledVector(_d,-diff*c.stiff*b.w/ws);
}
// Max speed per substep (m/s) — prevents constraint explosions
const MAX_VEL=18;
const MAX_VEL2=MAX_VEL*MAX_VEL;
function integrate(h){
for(const p of parts){
_v.subVectors(p.pos,p.prev).multiplyScalar(AIR);
// Clamp velocity to prevent blow-ups
const v2=_v.lengthSq()/(h*h);
if(v2>MAX_VEL2) _v.multiplyScalar(MAX_VEL/Math.sqrt(v2)*h);
p.prev.copy(p.pos); p.pos.add(_v);
p.pos.y+=GRAVITY*h*h;
p.pos.addScaledVector(p.force,p.w*h*h);
}
}
function collideGround(){
for(const p of parts){
if(p.pos.y<p.r){
const vy=p.pos.y-p.prev.y;
p.pos.y=p.r; p.prev.y=p.r+vy*.25;
p.prev.x+=(p.pos.x-p.prev.x)*.6; p.prev.z+=(p.pos.z-p.prev.z)*.6;
}
}
}
function collideSelf(){
for(let i=0;i<parts.length;i++) for(let j=i+1;j<parts.length;j++){
const key=i+'_'+j; if(bonded.has(key))continue;
const a=parts[i],b=parts[j],rr=a.r+b.r;
_d.subVectors(b.pos,a.pos); const L2=_d.lengthSq();
if(L2>rr*rr||L2<1e-10)continue;
const L=Math.sqrt(L2),diff=(L-rr)/L,w=a.w+b.w;
a.pos.addScaledVector(_d,diff*.7*a.w/w); b.pos.addScaledVector(_d,-diff*.7*b.w/w);
}
}
function addImpulse(p,dv){p.prev.addScaledVector(dv,-PHYS_DT/SUBSTEPS);}
/* ===== PARTICLES ===== */
const sparks=[];
const _sparkGeo=new THREE.SphereGeometry(.024,4,3);
const _smokeGeo=new THREE.SphereGeometry(.1,5,4);
function spawnSparks(pos,count,color,speed,life){
for(let i=0;i<count;i++){
const dir=new THREE.Vector3(Math.random()-.5,Math.random()*.8+.2,Math.random()-.5).normalize();
const m=new THREE.Mesh(_sparkGeo,new THREE.MeshBasicMaterial({color}));
m.position.copy(pos); scene.add(m);
sparks.push({mesh:m,vel:dir.multiplyScalar(speed*(.4+Math.random()*.8)),life,maxLife:life,grav:true});
}
}
function spawnSmoke(pos,count){
for(let i=0;i<count;i++){
const off=new THREE.Vector3(Math.random()-.5,Math.random()*.3,Math.random()-.5).multiplyScalar(.3);
const m=new THREE.Mesh(_smokeGeo,new THREE.MeshBasicMaterial({color:0x555566,transparent:true,opacity:.4}));
m.position.copy(pos).add(off); scene.add(m);
sparks.push({mesh:m,vel:new THREE.Vector3((Math.random()-.5)*.5,.4+Math.random()*.5,(Math.random()-.5)*.5),
life:1.1+Math.random(),maxLife:1.5,grav:false,smoke:true});
}
}
// Fire particles for molotov low flat spread
function spawnFire(pos,count,life){
for(let i=0;i<count;i++){
const dir=new THREE.Vector3(Math.random()-.5,Math.random()*.4+.6,Math.random()-.5).normalize();
const m=new THREE.Mesh(_sparkGeo,new THREE.MeshBasicMaterial({color:0xff6600}));
m.position.copy(pos); scene.add(m);
sparks.push({mesh:m,vel:dir.multiplyScalar(.8+Math.random()*1.2),life,maxLife:life,grav:false,fire:true});
}
}
function stepSparks(dt){
for(let i=sparks.length-1;i>=0;i--){
const s=sparks[i]; s.life-=dt;
if(s.life<=0){scene.remove(s.mesh);sparks.splice(i,1);continue;}
s.mesh.position.addScaledVector(s.vel,dt);
if(s.grav)s.vel.y+=GRAVITY*dt*.3;
const f=s.life/s.maxLife;
if(s.smoke){s.mesh.material.opacity=.35*f;s.mesh.scale.setScalar(1+.5*(1-f));}
else if(s.fire){
const hue=.07*f*f; // orange→red→dark
s.mesh.material.color.setHSL(hue,.95,.5+.15*f);
s.mesh.scale.setScalar(f*.8+.2);
} else {
s.mesh.material.color.setHSL(.07*f,1,.5);
}
}
}
/* flash overlay */
const flashEl=document.getElementById('flash');
let flashT=0;
function triggerFlash(i){flashT=.18*i;flashEl.style.opacity=String(Math.min(.85,.45*i));setTimeout(()=>flashEl.style.opacity='0',80);}
function stepFlash(dt){flashT=Math.max(0,flashT-dt);flashPt.intensity=flashT*16;}
/* ===== FIRE STATE (molotov effect on ragdoll) ===== */
const fireState={active:false,t:0,dur:5};
// When on fire: ragdoll dances wildly, then collapses
function startFire(){
fireState.active=true; fireState.t=0;
setState('fire');
}
function stepFire(dt){
if(!fireState.active)return;
fireState.t+=dt;
// spawn fire particles around torso
if(Math.random()<dt*30){
const torsoPos=parts[P.chest].pos.clone().addScaledVector(
new THREE.Vector3(Math.random()-.5,Math.random()*.3,Math.random()-.5),.2);
spawnFire(torsoPos,2,.6+Math.random()*.4);
}
if(fireState.t>fireState.dur){
fireState.active=false;
setState('ko'); brain.koT=99; // stay down
}
}
// Dance targeting: sinusoidal crazy limb targets
function applyFireDance(dt){
if(!fireState.active)return;
const T=brain.targets, t=fireState.t;
// wild arm flailing
const flail=Math.min(1,fireState.t*.5);
T.handL.addScaledVector(_right,-(Math.sin(t*7)*.4+.1)*flail).y+=Math.abs(Math.sin(t*6.3))*.5*flail;
T.handR.addScaledVector(_right, (Math.sin(t*5.5+1)*.4+.1)*flail).y+=Math.abs(Math.sin(t*7.7+2))*.5*flail;
T.elbowL.addScaledVector(_right,-Math.sin(t*8)*.25*flail).y+=Math.abs(Math.sin(t*5))*.3*flail;
T.elbowR.addScaledVector(_right, Math.sin(t*6+1)*.25*flail).y+=Math.abs(Math.sin(t*6.5))*.3*flail;
// stomp legs
T.footL.y+=Math.abs(Math.sin(t*4))*.3*flail;
T.footR.y+=Math.abs(Math.sin(t*4.3+1.5))*.3*flail;
T.kneeL.y+=Math.abs(Math.sin(t*3.7))*.2*flail;
T.kneeR.y+=Math.abs(Math.sin(t*4.1+.8))*.2*flail;
// torso thrash
T.chest.addScaledVector(_right,Math.sin(t*4.5)*.1*flail);
T.head.addScaledVector(_right,Math.sin(t*5.2+.5)*.12*flail).y+=Math.sin(t*3.5)*.1*flail;
}
/* ===== EXPLOSIONS ===== */
// mode: 'normal'=radial launch | 'groundsmear'=horizontal push+pin y
function explodeAt(pos,radius,force,mode){
let hit=false;
for(const p of parts){
_tmp.subVectors(p.pos,pos);
const dist=_tmp.length();
if(dist<radius){
hit=true;
const fall=1-dist/radius;
_tmp.normalize();
if(mode==='groundsmear'){
const horiz=new THREE.Vector3(_tmp.x,0,_tmp.z).normalize();
horiz.multiplyScalar(force*fall*fall*1.2);
horiz.y=force*fall*.05;
addImpulse(p,horiz);
} else {
_tmp.multiplyScalar(force*fall*fall);
_tmp.y+=force*fall*.5;
addImpulse(p,_tmp);
}
}
}
// Only stagger the ragdoll if the blast actually reached it
if(hit){ setState('ko'); brain.koT=Math.max(brain.koT,4.5); }
}
// AP skewer: shoot each body part along the shot direction with diminishing velocity
function skewer(shotDir,force){
for(const p of parts){
// along-shot impulse only no lateral scatter
const imp=shotDir.clone().multiplyScalar(force*p.m*.12);
imp.y=0; // no vertical body should drag along ground
addImpulse(p,imp);
}
setState('ko'); brain.koT=Math.max(brain.koT,6);
brain.strength=0;
}
/* ===== C4 ===== */
const c4List=[];
const c4Geo=new THREE.BoxGeometry(.22,.08,.14);
const c4Mat=new THREE.MeshStandardMaterial({color:0xd4c98a,roughness:.85});
function placeC4(pos){
const mesh=new THREE.Mesh(c4Geo,c4Mat); mesh.position.copy(pos); mesh.position.y=.041;
mesh.rotation.y=Math.random()*Math.PI; mesh.castShadow=true; scene.add(mesh);
const led=new THREE.Mesh(new THREE.SphereGeometry(.018,6,6),new THREE.MeshBasicMaterial({color:0xff2222}));
led.position.set(.08,.055,0); mesh.add(led);
c4List.push({mesh,pos:mesh.position.clone(),ledMesh:led,blink:0,armed:true});
}
function detonateC4(){
const power=parseFloat(document.getElementById('c4Power').value);
// scale: slider 5100 → force 868, radius 1.24.2
const t=power/100, force=8+t*60, radius=1.2+t*3;
let any=false;
for(const c of c4List){
if(!c.armed)continue; any=true;
flashPt.position.copy(c.pos).setY(.5);
triggerFlash(1+t*1.8);
explodeAt(c.pos,radius,force,'groundsmear');
spawnSparks(c.pos.clone().setY(.1),20+Math.round(t*25),0xff6600,3+t*3,1);
spawnSmoke(c.pos.clone().setY(.1),6+Math.round(t*10));
scene.remove(c.mesh);
}
c4List.length=0;
if(any)snapCameraToBody();
}
function stepC4(dt){
for(const c of c4List){c.blink+=dt;c.ledMesh.material.color.setHex(c.blink%0.6<.1?0xff0000:0x440000);}
}
function snapCameraToBody(){orbit.target.copy(brain.com);orbit.dist=Math.max(orbit.dist,6);}
/* ===== PROJECTILES ===== */
const balls=[];
const ballGeo=new THREE.SphereGeometry(1,14,10);
const BMATS={
normal: new THREE.MeshStandardMaterial({color:0xd04020,roughness:.55,metalness:.1}),
gun: new THREE.MeshStandardMaterial({color:0xd4aa44,roughness:.15,metalness:.95}),
HE: new THREE.MeshStandardMaterial({color:0xcc3311,roughness:.4,metalness:.5,emissive:0x441100}),
AP: new THREE.MeshStandardMaterial({color:0x44aa66,roughness:.25,metalness:.9,emissive:0x001a0a}),
cannon: new THREE.MeshStandardMaterial({color:0x445566,roughness:.3,metalness:.8}),
molotov:new THREE.MeshStandardMaterial({color:0x88aa44,roughness:.4,metalness:.1}),
};
function spawnBall(pos,vel,r,m,type='normal'){
const mesh=new THREE.Mesh(ballGeo,BMATS[type]||BMATS.normal);
mesh.castShadow=true; mesh.scale.setScalar(r); scene.add(mesh);
balls.push({pos:pos.clone(),vel:vel.clone(),r,m,life:12,mesh,type,hitParts:new Set()});
}
function onBallHitPart(ball,part){
_d.subVectors(part.pos,ball.pos).normalize();
const vn=ball.vel.dot(_d);
if(vn<=0)return false;
if(ball.type==='gun'){
// Low knockback due to overpenetration fast but low momentum transfer
addImpulse(part,_tmp.copy(_d).multiplyScalar(vn*ball.m*.8));
ball.vel.multiplyScalar(.55); // still moves through
return false;
} else if(ball.type==='AP'){
// Skewer: punch along original travel dir, very little per-part lateral
// Each part gets pushed backward along shot dir only if not already hit
if(!ball.hitParts.has(part)){
ball.hitParts.add(part);
const shotDir=ball.vel.clone().normalize();
// Strong rearward impulse along shot direction
addImpulse(part,shotDir.clone().multiplyScalar(vn*ball.m*2.2));
// tiny downward pin so he doesn't fly up
addImpulse(part,new THREE.Vector3(0,-vn*ball.m*.4,0));
spawnSparks(ball.pos.clone(),5,0x88ffaa,2,.3);
}
ball.vel.multiplyScalar(.92); // barely slowed punches right through
return false; // never removes on hit dies by life or ground
} else if(ball.type==='HE'){
return true; // explode on touch
} else if(ball.type==='cannon'){
// Good knockback bounces and sends body flying
const share=3*ball.m/(ball.m+part.m);
addImpulse(part,_tmp.copy(_d).multiplyScalar(vn*share));
ball.vel.addScaledVector(_d,-vn*1.6);
ball.pos.copy(part.pos).addScaledVector(_d,-(part.r+ball.r));
return false;
} else if(ball.type==='normal'){
const share=2*ball.m/(ball.m+part.m);
addImpulse(part,_tmp.copy(_d).multiplyScalar(vn*share*.8));
ball.vel.addScaledVector(_d,-vn*1.4);
ball.pos.copy(part.pos).addScaledVector(_d,-(part.r+ball.r));
return false;
} else if(ball.type==='molotov'){
return true; // shatter on hit
}
return false;
}
function doExplodeBall(k){
const b=balls[k];
if(b.type==='HE'){
flashPt.position.copy(b.pos);
triggerFlash(1.8);
// HE: groundsmear pushes him along floor, NOT into sky
explodeAt(b.pos,2.6,48,'groundsmear');
spawnSparks(b.pos.clone(),22,0xff5500,5,.7);
spawnSmoke(b.pos.clone(),8);
snapCameraToBody();
} else if(b.type==='molotov'){
// shatter + ignite
spawnSparks(b.pos.clone(),20,0xff8800,4,.8);
spawnFire(b.pos.clone(),25,1.5);
spawnSmoke(b.pos.clone(),6);
flashPt.position.copy(b.pos); flashPt.intensity=4; setTimeout(()=>flashPt.intensity=0,100);
// Only ignite ragdoll if it is within fire splash radius (2.5 units)
const fireDist=b.pos.distanceTo(brain.com);
if(fireDist<2.5) startFire();
}
scene.remove(b.mesh); balls.splice(k,1);
}
// Swept-sphere: returns t in [0,1] of first entry of moving sphere into static sphere, or -1
function sweptSphere(px,py,pz, cx,cy,cz, dx,dy,dz, r){
const fx=px-cx, fy=py-cy, fz=pz-cz;
const a=dx*dx+dy*dy+dz*dz; if(a<1e-14)return -1;
const b=2*(fx*dx+fy*dy+fz*dz);
const c=fx*fx+fy*fy+fz*fz-r*r;
const disc=b*b-4*a*c; if(disc<0)return -1;
const t=(-b-Math.sqrt(disc))/(2*a);
return (t>=0&&t<=1)?t:-1;
}
function stepBalls(h){
for(let k=balls.length-1;k>=0;k--){
const b=balls[k]; b.life-=h;
b.vel.y+=GRAVITY*h;
const px=b.pos.x, py=b.pos.y, pz=b.pos.z;
b.pos.x+=b.vel.x*h; b.pos.y+=b.vel.y*h; b.pos.z+=b.vel.z*h;
const dx=b.pos.x-px, dy=b.pos.y-py, dz=b.pos.z-pz;
if(b.pos.y<b.r){
b.pos.y=b.r;
if(b.type==='HE'||b.type==='molotov'){doExplodeBall(k);continue;}
if(b.type==='AP'){b.vel.y*=-.1;b.vel.x*=.7;b.vel.z*=.7;}
else{b.vel.y*=-.4;b.vel.x*=.85;b.vel.z*=.85;}
}
// Find earliest-contact body part using swept sphere
let bestT=2, bestPart=null;
for(const p of parts){
const r=p.r+b.r;
let t=sweptSphere(px,py,pz, p.pos.x,p.pos.y,p.pos.z, dx,dy,dz, r);
// Also check static overlap (slow balls or already inside)
if(t<0){
const ox=b.pos.x-p.pos.x, oy=b.pos.y-p.pos.y, oz=b.pos.z-p.pos.z;
if(ox*ox+oy*oy+oz*oz<r*r) t=0;
}
if(t>=0&&t<bestT){bestT=t;bestPart=p;}
}
let explode=false;
if(bestPart){
// Rewind to contact point
if(bestT>0){b.pos.x=px+dx*bestT;b.pos.y=py+dy*bestT;b.pos.z=pz+dz*bestT;}
explode=onBallHitPart(b,bestPart);
}
if(explode){doExplodeBall(k);continue;}
b.mesh.position.copy(b.pos);
if(b.life<=0){scene.remove(b.mesh);balls.splice(k,1);}
}
}
function stepPhysics(){
const h=PHYS_DT/SUBSTEPS;
for(let s=0;s<SUBSTEPS;s++){
integrate(h);
// Apply grab ONCE per substep, before constraints, with a gentle step
if(grab.active){
const gp=parts[grab.pi];
_tmp.subVectors(grab.target,gp.pos);
const gDist=_tmp.length();
// Move at most 0.06 units per substep — smooth, can't blow up constraints
const step=Math.min(gDist, 0.06);
if(gDist>0.001){
gp.pos.addScaledVector(_tmp, step/gDist);
// Zero out stored velocity toward grab direction so it doesn't fight us
const vDot=_tmp.dot(_v.subVectors(gp.pos,gp.prev));
if(vDot<0) gp.prev.addScaledVector(_tmp,(vDot/gDist)*0.5/gDist);
}
}
for(let it=0;it<ITER;it++){
for(const c of cons)projectCon(c);
collideSelf(); collideGround();
}
stepBalls(h);
}
for(const p of parts)p.force.set(0,0,0);
}
/* ===== SKELETON ===== */
addPart('pelvis', 0, .95, 0, 12,.125);
addPart('chest', 0, 1.24, 0, 13, .14);
addPart('neck', 0, 1.44, 0, 2,.055);
addPart('head', 0, 1.61, 0, 5,.105);
addPart('shoulderL',-.21,1.38, 0, 2.5, .06);
addPart('shoulderR', .21,1.38, 0, 2.5, .06);
addPart('elbowL', -.25, 1.12,.015,1.6, .05);
addPart('elbowR', .25, 1.12,.015,1.6, .05);
addPart('handL', -.27, .875,.03, 1.2,.047);
addPart('handR', .27, .875,.03, 1.2,.047);
addPart('hipL', -.115, .91, 0, 5,.075);
addPart('hipR', .115, .91, 0, 5,.075);
addPart('kneeL', -.125, .49,.03, 3.5,.058);
addPart('kneeR', .125, .49,.03, 3.5,.058);
addPart('footL', -.13, .05,-.03,1.8, .05);
addPart('footR', .13, .05,-.03,1.8, .05);
addPart('toeL', -.13, .042,.135, .6,.042);
addPart('toeR', .13, .042,.135, .6,.042);
link('pelvis','chest');link('chest','neck');link('neck','head');link('pelvis','neck',.9);
link('shoulderL','shoulderR');link('chest','shoulderL');link('chest','shoulderR');
link('neck','shoulderL',.9);link('neck','shoulderR',.9);
link('pelvis','shoulderL',.55);link('pelvis','shoulderR',.55);
link('pelvis','hipL');link('pelvis','hipR');link('hipL','hipR');
link('chest','hipL',.8);link('chest','hipR',.8);
link('shoulderL','elbowL');link('elbowL','handL');
link('shoulderR','elbowR');link('elbowR','handR');
link('hipL','kneeL');link('kneeL','footL');link('footL','toeL');link('kneeL','toeL',.7);
link('hipR','kneeR');link('kneeR','footR');link('footR','toeR');link('kneeR','toeR',.7);
link('shoulderL','handL',.8,'min',.18);link('shoulderR','handR',.8,'min',.18);
link('hipL','footL',.8,'min',.32);link('hipR','footR',.8,'min',.32);
link('head','chest',.5,'max',.39);link('head','chest',.5,'min',.31);
link('kneeL','kneeR',.6,'min',.09);link('footL','footR',.6,'min',.08);
link('head','pelvis',.6,'min',.46);
/* ===== VISUALS ===== */
const matSuit =new THREE.MeshStandardMaterial({color:0x4b5563,roughness:.74,metalness:.05});
const matSuit2=new THREE.MeshStandardMaterial({color:0x39414d,roughness:.8, metalness:.05});
const matSkin =new THREE.MeshStandardMaterial({color:0xc99c7d,roughness:.55,metalness:0});
const matShoe =new THREE.MeshStandardMaterial({color:0x22262d,roughness:.42,metalness:.1});
const rig=new THREE.Group(); scene.add(rig);
const limbs=[],pickMeshes=[],UP=new THREE.Vector3(0,1,0);
function makeLimb(a,b,ra,rb,mat){
const grp=new THREE.Group();
const cyl=new THREE.Mesh(new THREE.CylinderGeometry(rb,ra,1,12,1,true),mat); cyl.castShadow=true; grp.add(cyl);
const capA=new THREE.Mesh(new THREE.SphereGeometry(ra,12,8),mat); capA.castShadow=true; grp.add(capA);
const capB=new THREE.Mesh(new THREE.SphereGeometry(rb,12,8),mat); capB.castShadow=true; grp.add(capB);
rig.add(grp);
const L={grp,cyl,capA,capB,ia:P[a],ib:P[b]};
limbs.push(L);
for(const m of[cyl,capA,capB]){m.userData.pick=[P[a],P[b]];pickMeshes.push(m);}
return L;
}
makeLimb('pelvis','chest',.135,.15,matSuit);
makeLimb('chest','neck',.1,.055,matSuit);
makeLimb('neck','head',.05,.05,matSkin);
makeLimb('shoulderL','shoulderR',.055,.055,matSuit);
makeLimb('hipL','hipR',.07,.07,matSuit2);
makeLimb('shoulderL','elbowL',.055,.045,matSuit);
makeLimb('shoulderR','elbowR',.055,.045,matSuit);
makeLimb('elbowL','handL',.042,.034,matSuit);
makeLimb('elbowR','handR',.042,.034,matSuit);
makeLimb('hipL','kneeL',.075,.055,matSuit2);
makeLimb('hipR','kneeR',.075,.055,matSuit2);
makeLimb('kneeL','footL',.052,.04,matSuit2);
makeLimb('kneeR','footR',.052,.04,matSuit2);
makeLimb('footL','toeL',.05,.044,matShoe);
makeLimb('footR','toeR',.05,.044,matShoe);
const headMesh=new THREE.Mesh(new THREE.SphereGeometry(.105,18,14),matSkin);
headMesh.castShadow=true; headMesh.scale.set(.92,1.12,.98);
headMesh.userData.pick=[P.head]; rig.add(headMesh); pickMeshes.push(headMesh);
const handMeshes=['handL','handR'].map(n=>{
const m=new THREE.Mesh(new THREE.SphereGeometry(.05,10,8),matSkin);
m.castShadow=true; m.scale.set(.8,1.15,1);
m.userData.pick=[P[n]]; rig.add(m); pickMeshes.push(m); return m;
});
const _q=new THREE.Quaternion();
function syncVisuals(){
for(const L of limbs){
const a=parts[L.ia].pos,b=parts[L.ib].pos,len=a.distanceTo(b);
L.grp.position.copy(a).add(b).multiplyScalar(.5);
if(len>1e-6){_d.subVectors(b,a).multiplyScalar(1/len);_q.setFromUnitVectors(UP,_d);L.grp.quaternion.copy(_q);}
L.cyl.scale.set(1,Math.max(len,1e-4),1);
L.capA.position.y=-len/2; L.capB.position.y=len/2;
}
headMesh.position.copy(parts[P.head].pos);
headMesh.quaternion.copy(limbs[2].grp.quaternion);
handMeshes[0].position.copy(parts[P.handL].pos);
handMeshes[1].position.copy(parts[P.handR].pos);
}
const POSE={};
for(const p of parts)POSE[p.name]=p.pos.clone();
/* ===== BRAIN ===== */
const GAIN={pelvis:[72,9],chest:[72,9],neck:[55,7],head:[48,7],shoulder:[48,7],
elbow:[30,5],hand:[24,4],hip:[72,9],knee:[62,8],foot:[85,10],toe:[65,8]};
const GCOMP={pelvis:.92,chest:.92,neck:.9,head:.9,shoulder:.85,
elbow:.7,hand:.6,hip:.9,knee:.55,foot:0,toe:0};
function groupOf(n){return n.replace(/[LR]$/,'');}
const brain={
state:'balance',t:0,koT:0,strength:1,
root:new THREE.Vector3(),
com:new THREE.Vector3(),comPrev:new THREE.Vector3(0,1,0),comVel:new THREE.Vector3(),
step:null,lastFoot:'R',cool:0,targets:{},totalM:0
};
for(const p of parts){brain.targets[p.name]=new THREE.Vector3();brain.totalM+=p.m;}
const _right=new THREE.Vector3(),_fwd=new THREE.Vector3(),
_off=new THREE.Vector3(),_feet=new THREE.Vector3(),_fall=new THREE.Vector3();
let simTime=0;
function setState(s){if(brain.state!==s){brain.state=s;brain.t=0;}}
function brainUpdate(dt){
simTime+=dt; brain.t+=dt; brain.cool=Math.max(0,brain.cool-dt);
const B=brain,T=B.targets;
_right.subVectors(parts[P.hipR].pos,parts[P.hipL].pos); _right.y=0;
if(_right.lengthSq()<1e-6)_right.set(1,0,0); else _right.normalize();
_fwd.set(-_right.z,0,_right.x);
B.com.set(0,0,0);
for(const p of parts)B.com.addScaledVector(p.pos,p.m/B.totalM);
B.comVel.subVectors(B.com,B.comPrev).multiplyScalar(1/dt); B.comPrev.copy(B.com);
_feet.addVectors(parts[P.footL].pos,parts[P.footR].pos).multiplyScalar(.5);
_off.subVectors(B.com,_feet); _off.y=0;
_off.addScaledVector(_t.copy(B.comVel).setY(0),.16);
const offLen=_off.length();
_t2.subVectors(parts[P.neck].pos,parts[P.pelvis].pos).normalize();
const upr=_t2.y,comSpeed=B.comVel.length();
if(B.state==='balance'){
B.strength+=(1-B.strength)*Math.min(1,dt*5);
if(upr<.55||offLen>.34)setState('falling');
} else if(B.state==='falling'){
B.strength=.3;
if(B.com.y<.55&&comSpeed<1.8)setState('down');
else if(upr>.78&&offLen<.12&&B.com.y>.85)setState('balance');
} else if(B.state==='down'){
B.strength=.06;
if(B.t>1.25)setState('getup');
if(upr>.78&&B.com.y>.85)setState('balance');
} else if(B.state==='getup'){
B.strength=1.25;
if(upr>.82&&B.com.y>.88&&offLen<.2)setState('balance');
if(B.t>3)setState('down');
} else if(B.state==='ko'){
B.strength=.015; B.koT-=dt;
if(B.koT<=0)setState('down');
} else if(B.state==='fire'){
// Medium strength so he can sort of stand but limbs flail
B.strength=.45;
}
const S=B.strength;
if(B.state==='balance'||B.state==='falling'){
_t.copy(_feet); _t.y=0; B.root.lerp(_t,1-Math.exp(-dt*6));
} else {B.root.set(B.com.x,0,B.com.z);}
for(const p of parts){
const o=POSE[p.name];
T[p.name].copy(B.root).addScaledVector(_right,o.x).addScaledVector(_fwd,o.z).setY(o.y);
}
const br=Math.sin(simTime*1.7)*.006,sw=Math.sin(simTime*.9)*.012;
T.chest.y+=br; T.head.y+=br*1.4;
T.head.addScaledVector(_fwd,sw); T.chest.addScaledVector(_fwd,sw*.5);
if(B.state==='balance'){
T.chest.addScaledVector(_off,-.55);T.head.addScaledVector(_off,-.8);T.pelvis.addScaledVector(_off,-.25);
const inst=Math.min(1,offLen*5);
if(inst>.15){
const wave=Math.sin(simTime*11)*.16*inst;
T.handL.addScaledVector(_right,-(0.12+.26*inst)).addScaledVector(_fwd,wave).y+=.30*inst;
T.handR.addScaledVector(_right,(0.12+.26*inst)).addScaledVector(_fwd,-wave).y+=.30*inst;
T.elbowL.addScaledVector(_right,-.16*inst).y+=.14*inst;
T.elbowR.addScaledVector(_right,.16*inst).y+=.14*inst;
}
if(!B.step&&B.cool<=0&&offLen>.12){
const dir=_t.copy(_off).normalize(),foot=(B.lastFoot==='R')?'L':'R';
const to=new THREE.Vector3(B.com.x,0,B.com.z).addScaledVector(dir,.24).addScaledVector(_right,foot==='L'?-.10:.10);
B.step={foot,from:parts[P['foot'+foot]].pos.clone(),to,t:0,dur:.22};
B.lastFoot=foot;
}
}
if(B.step){
const st=B.step; st.t+=dt;
const f=Math.min(1,st.t/st.dur),lift=Math.sin(f*Math.PI)*.14;
const ft=T['foot'+st.foot];
ft.lerpVectors(st.from,st.to,f); ft.y=.05+lift;
T['toe'+st.foot].copy(ft).addScaledVector(_fwd,.15);T['toe'+st.foot].y=ft.y-.005;
if(f>=1){B.step=null;B.cool=.07;}
}
if(B.state==='falling'){
_fall.copy(B.comVel); _fall.y=0;
if(_fall.lengthSq()<.01)_fall.copy(_off);
if(_fall.lengthSq()>1e-6)_fall.normalize();
T.handL.copy(parts[P.shoulderL].pos).addScaledVector(_fall,.42).y=Math.max(.15,parts[P.shoulderL].pos.y-.25);
T.handR.copy(parts[P.shoulderR].pos).addScaledVector(_fall,.42).y=Math.max(.15,parts[P.shoulderR].pos.y-.25);
T.elbowL.copy(parts[P.shoulderL].pos).addScaledVector(_fall,.2);
T.elbowR.copy(parts[P.shoulderR].pos).addScaledVector(_fall,.2);
T.head.copy(parts[P.neck].pos).addScaledVector(_fall,-.12).y+=.12;
T.kneeL.y+=.15; T.kneeR.y+=.15;
}
// Apply fire dance on top of base targets
if(B.state==='fire') applyFireDance(dt);
const getup=B.state==='getup',ramp=getup?Math.min(1,B.t/.45):1;
for(const p of parts){
const g=GAIN[groupOf(p.name)];
_t.subVectors(T[p.name],p.pos);
const errLen=_t.length();
if(errLen>.6)_t.multiplyScalar(.6/errLen);
_v.subVectors(p.pos,p.prev).multiplyScalar(1/dt);
const kp=g[0]*p.m*S*ramp,kd=g[1]*p.m*Math.min(1,S*1.5);
p.force.addScaledVector(_t,kp).addScaledVector(_v,-kd);
p.force.y+=-GRAVITY*p.m*GCOMP[groupOf(p.name)]*Math.min(1,S)*ramp;
if(getup&&(p.name==='pelvis'||p.name==='chest')){
p.force.y+=-GRAVITY*p.m*.5*ramp;
p.prev.x+=(p.pos.x-p.prev.x)*.04;p.prev.z+=(p.pos.z-p.prev.z)*.04;
}
const fmax=p.m*260;
if(p.force.lengthSq()>fmax*fmax)p.force.setLength(fmax);
}
}
/* ===== WEAPON STATE ===== */
let currentWeapon='ball',currentAmmo='normal',isFiring=false,gunFireCooldown=0;
function selectWeapon(w){
currentWeapon=w;
['Ball','Gun','Cannon','C4','Mol'].forEach(id=>{
const btn=document.getElementById('w'+id);
if(btn)btn.classList.toggle('sel',id.toLowerCase()===w||(id==='Mol'&&w==='molotov'));
});
document.getElementById('ammo-row').style.display=(w==='cannon')?'flex':'none';
document.getElementById('c4-options').style.display=(w==='c4')?'block':'none';
}
function selectAmmo(a){
currentAmmo=a;
['Normal','HE','AP'].forEach(id=>document.getElementById('am'+id).classList.toggle('active-ammo',id.toLowerCase()===a));
}
// C4 slider live update
document.getElementById('c4Power').addEventListener('input',function(){
document.getElementById('c4PowerVal').textContent=this.value;
});
const _fireRay=new THREE.Raycaster(),_fireNDC=new THREE.Vector2();
function fireWeapon(nx,ny){
_fireNDC.set(nx,ny);
_fireRay.setFromCamera(_fireNDC,camera);
const origin=_fireRay.ray.origin.clone().addScaledVector(_fireRay.ray.direction,.5);
const dir=_fireRay.ray.direction.clone();
if(currentWeapon==='ball'){
spawnBall(origin,dir.multiplyScalar(15),.09,3.5,'normal');
} else if(currentWeapon==='gun'){
spawnBall(origin,dir.multiplyScalar(55),.04,1.2,'gun'); // lower mass = low knockback
flashPt.position.copy(origin); flashPt.intensity=8; setTimeout(()=>flashPt.intensity=0,35);
spawnSparks(origin.clone(),5,0xffdd66,4,.2);
} else if(currentWeapon==='cannon'){
let speed,radius,mass,type,flashMult;
if(currentAmmo==='HE'){ speed=26;radius=.17;mass=12;type='HE';flashMult=1.2;}
else if(currentAmmo==='AP'){speed=40;radius=.10;mass=8;type='AP';flashMult=.8;}
else{ speed=22;radius=.15;mass=10;type='cannon';flashMult=1;}
const spawnPt=_fireRay.ray.origin.clone().addScaledVector(dir,.8);
spawnBall(spawnPt,dir.multiplyScalar(speed),radius,mass,type);
triggerFlash(flashMult);
spawnSparks(spawnPt.clone(),10,0xffaa22,3,.25);
} else if(currentWeapon==='c4'){
const dy=_fireRay.ray.direction.y;
if(dy<-0.01){
const t2=_fireRay.ray.origin.y/(-dy);
if(t2>0) placeC4(_fireRay.ray.origin.clone().addScaledVector(_fireRay.ray.direction,t2));
}
} else if(currentWeapon==='molotov'){
// Thrown as a lobbed projectile
const throwVel=dir.clone().multiplyScalar(12);
throwVel.y+=4; // arc upward
spawnBall(origin,throwVel,.075,1.5,'molotov');
}
}
/* ===== INPUT ===== */
const grab={active:false,pi:0,dist:0,target:new THREE.Vector3()};
const _grabRay=new THREE.Raycaster(),_grabNDC=new THREE.Vector2();
let dragMode=null,lastX=0,lastY=0,movedPx=0;
const _liveNDC={x:0,y:0};
renderer.domElement.addEventListener('pointerdown',e=>{
e.preventDefault(); renderer.domElement.setPointerCapture(e.pointerId);
lastX=e.clientX; lastY=e.clientY; movedPx=0;
_liveNDC.x=(e.clientX/innerWidth)*2-1;
_liveNDC.y=-(e.clientY/innerHeight)*2+1;
if(e.button===2){
_grabNDC.set(_liveNDC.x,_liveNDC.y); _grabRay.setFromCamera(_grabNDC,camera);
const hits=_grabRay.intersectObjects(pickMeshes,false);
if(hits.length){
const h=hits[0],pk2=h.object.userData.pick;
let best=pk2[0];
if(pk2.length>1&&h.point.distanceToSquared(parts[pk2[1]].pos)<h.point.distanceToSquared(parts[pk2[0]].pos))best=pk2[1];
grab.active=true; grab.pi=best; grab.dist=h.distance; grab.target.copy(h.point);
dragMode='grab';
} else dragMode='orbit';
} else if(e.button===0){
dragMode='mayfire';
if(currentWeapon==='gun'||currentWeapon==='cannon'){
isFiring=true; gunFireCooldown=0; fireWeapon(_liveNDC.x,_liveNDC.y);
}
}
});
renderer.domElement.addEventListener('pointermove',e=>{
const dx=e.clientX-lastX,dy=e.clientY-lastY;
lastX=e.clientX; lastY=e.clientY;
movedPx+=Math.abs(dx)+Math.abs(dy);
_liveNDC.x=(e.clientX/innerWidth)*2-1;
_liveNDC.y=-(e.clientY/innerHeight)*2+1;
if(dragMode==='orbit'||(dragMode==='mayfire'&&movedPx>5)){
dragMode='orbit'; isFiring=false;
if(camMode==='fp'){fp.yaw-=dx*.004;fp.pitch+=dy*.004;}
else{orbit.yaw-=dx*.005;orbit.pitch+=dy*.005;}
updateCamera();
} else if(dragMode==='grab'){
_grabNDC.set(_liveNDC.x,_liveNDC.y); _grabRay.setFromCamera(_grabNDC,camera);
grab.target.copy(_grabRay.ray.origin).addScaledVector(_grabRay.ray.direction,grab.dist);
grab.target.y=Math.max(.06,grab.target.y);
}
});
renderer.domElement.addEventListener('pointerup',e=>{
if(dragMode==='mayfire'&&movedPx<6&&e.button===0){
if(currentWeapon!=='gun'&&currentWeapon!=='cannon')fireWeapon(_liveNDC.x,_liveNDC.y);
}
grab.active=false; dragMode=null; isFiring=false;
});
renderer.domElement.addEventListener('wheel',e=>{
e.preventDefault(); orbit.dist*=(e.deltaY>0?1.1:.9); updateCamera();
},{passive:false});
addEventListener('contextmenu',e=>e.preventDefault());
/* ===== UI ===== */
const stateEl=document.getElementById('state');
const STATE_STYLE={
balance:['BALANCING','#173527','#7ce0a3'],
falling:['STUMBLING','#3a2c14','#f0b46a'],
down:['DOWN','#2b2f36','#aab6c6'],
getup:['GETTING UP','#15283f','#7db8f5'],
ko:['KNOCKED OUT','#3a1717','#f08a8a'],
fire:['ON FIRE','#3b1500','#fb923c'],
};
function syncBadge(){
const s=STATE_STYLE[brain.state]||STATE_STYLE.down;
if(stateEl.textContent!==s[0]){stateEl.textContent=s[0];stateEl.style.background=s[1];stateEl.style.color=s[2];stateEl.style.borderColor=s[2]+'44';}
}
document.getElementById('bShove').onclick=()=>{
const a=Math.random()*Math.PI*2;
_t.set(Math.cos(a)*4.2,1.2,Math.sin(a)*4.2);
addImpulse(parts[P.chest],_t); addImpulse(parts[P.pelvis],_t2.copy(_t).multiplyScalar(.55));
};
document.getElementById('bKO').onclick=()=>{brain.koT=3.5;setState('ko');};
let slow=false;
document.getElementById('bSlow').onclick=function(){slow=!slow;this.classList.toggle('on',slow);};
document.getElementById('bReset').onclick=()=>{
for(const p of parts){p.pos.copy(POSE[p.name]);p.prev.copy(POSE[p.name]);p.force.set(0,0,0);}
for(const b of balls)scene.remove(b.mesh); balls.length=0;
for(const c of c4List)scene.remove(c.mesh); c4List.length=0;
for(const s of sparks)scene.remove(s.mesh); sparks.length=0;
fireState.active=false;
brain.root.set(0,0,0); brain.comPrev.set(0,1,0);
brain.step=null; setState('balance'); brain.strength=1;
orbit.target.set(0,1,0); orbit.dist=4.6; updateCamera();
};
document.getElementById('bDetonate').onclick=detonateC4;
document.getElementById('bClearC4').onclick=()=>{for(const c of c4List)scene.remove(c.mesh);c4List.length=0;};
/* ===== MAIN LOOP ===== */
const clock=new THREE.Clock();
let acc=0;
function animate(){
requestAnimationFrame(animate);
let dt=Math.min(clock.getDelta(),.05);
if(slow)dt*=.28;
if(isFiring&&(currentWeapon==='gun'||currentWeapon==='cannon')){
gunFireCooldown-=dt;
if(gunFireCooldown<=0){gunFireCooldown=currentWeapon==='gun'?.08:.4;fireWeapon(_liveNDC.x,_liveNDC.y);}
}
acc+=dt; let n=0;
while(acc>=PHYS_DT&&n<4){brainUpdate(PHYS_DT);stepPhysics();acc-=PHYS_DT;n++;}
stepSparks(dt); stepC4(dt); stepFlash(dt); stepFire(dt);
if(camMode==='follow'){orbit.target.copy(brain.com);updateCamera();}
else if(camMode==='fp'){updateCamera();}
else{
_t.copy(brain.com); _t.y=Math.max(.4,Math.min(1.6,brain.com.y));
orbit.target.lerp(_t,1-Math.exp(-dt*4)); updateCamera();
}
syncVisuals(); syncBadge();
renderer.render(scene,camera);
}
animate();
</script>
</body>
</html>