Upload files to "/"

This commit is contained in:
2026-06-10 17:59:46 -04:00
parent a9d35e0d01
commit 7c2e37b29b
5 changed files with 4080 additions and 0 deletions

580
active_ragdoll-v1.html Normal file
View File

@@ -0,0 +1,580 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Active Ragdoll — Euphoria-style</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#0e1116;font-family:system-ui,'Segoe UI',Roboto,sans-serif}
canvas{display:block}
#ui{position:fixed;top:14px;left:14px;z-index:10;background:rgba(15,18,24,.74);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,.45);max-width:300px;user-select:none}
#ui h1{font-size:15px;margin:0 0 2px;font-weight:650;letter-spacing:.2px}
#ui .sub{font-size:11px;color:#9aa6b5;margin-bottom:10px}
#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:10px;transition:all .25s}
.row{display:flex;flex-wrap:wrap;gap:6px}
button{cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#232a35;color:#e8ecf2;border-radius:9px;
padding:7px 11px;font-size:12px;font-weight:550;transition:background .15s,transform .05s}
button:hover{background:#2e3848}button:active{transform:scale(.95)}
button.tog.on{background:#3b82f6;border-color:#60a5fa}
#hint{position:fixed;bottom:12px;left:50%;transform:translateX(-50%);z-index:10;color:#aab6c6;font-size:12px;
background:rgba(15,18,24,.6);border:1px solid rgba(255,255,255,.07);padding:7px 14px;border-radius:99px;white-space:nowrap}
#hint b{color:#dfe7f1}
</style>
</head>
<body>
<div id="ui">
<h1>Active Ragdoll</h1>
<div class="sub">Self-balancing muscles · Euphoria-style behaviors</div>
<div><span id="state">BALANCING</span></div>
<div class="row">
<button id="bShove">Shove</button>
<button id="bBall">Cannonball</button>
<button id="bKO">Knockout</button>
<button id="bSlow" class="tog">Slow-mo</button>
<button id="bReset">Reset</button>
</div>
</div>
<div id="hint"><b>Drag body</b> to grab &nbsp;·&nbsp; <b>drag space</b> to orbit &nbsp;·&nbsp; <b>scroll</b> zoom &nbsp;·&nbsp; <b>click</b> throws a ball</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
'use strict';
/* ================= Renderer / scene ================= */
const renderer=new THREE.WebGLRenderer({antialias:true});
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);
const scene=new THREE.Scene();
scene.fog=new THREE.Fog(0x9fb2c2,16,70);
{ // vertical sky gradient
const c=document.createElement('canvas');c.width=1;c.height=256;const g=c.getContext('2d');
const 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);
const t=new THREE.CanvasTexture(c);t.encoding=THREE.sRGBEncoding;scene.background=t;
}
const hemi=new THREE.HemisphereLight(0xbdd3ea,0x4d463b,0.9);scene.add(hemi);
const sun=new THREE.DirectionalLight(0xfff0d8,1.5);
sun.position.set(6,9,4);sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
sun.shadow.camera.near=1;sun.shadow.camera.far=30;
sun.shadow.camera.left=-7;sun.shadow.camera.right=7;sun.shadow.camera.top=7;sun.shadow.camera.bottom=-7;
sun.shadow.bias=-0.0006;sun.shadow.radius=4;
scene.add(sun);
const fill=new THREE.DirectionalLight(0xc9d8ff,0.28);fill.position.set(-5,4,-6);scene.add(fill);
function groundTexture(){
const c=document.createElement('canvas');c.width=c.height=512;const g=c.getContext('2d');
g.fillStyle='#7d8186';g.fillRect(0,0,512,512);
for(let i=0;i<9000;i++){
const v=20+Math.random()*45|0;
g.fillStyle=`rgba(${v},${v},${v+4},${0.05+Math.random()*0.12})`;
g.fillRect(Math.random()*512,Math.random()*512,1.6,1.6);
}
g.strokeStyle='rgba(255,255,255,0.055)';g.lineWidth=2;g.strokeRect(1,1,510,510);
const t=new THREE.CanvasTexture(c);t.wrapS=t.wrapT=THREE.RepeatWrapping;
t.repeat.set(28,28);t.anisotropy=8;t.encoding=THREE.sRGBEncoding;return t;
}
const ground=new THREE.Mesh(
new THREE.PlaneGeometry(220,220),
new THREE.MeshStandardMaterial({map:groundTexture(),roughness:0.95,metalness:0})
);
ground.rotation.x=-Math.PI/2;ground.receiveShadow=true;scene.add(ground);
/* ================= Camera (custom orbit) ================= */
const camera=new THREE.PerspectiveCamera(50,innerWidth/innerHeight,0.1,200);
const orbit={yaw:0.65,pitch:0.18,dist:4.6,target:new THREE.Vector3(0,1.0,0)};
function updateCamera(){
orbit.pitch=Math.max(-0.08,Math.min(1.32,orbit.pitch));
orbit.dist=Math.max(2,Math.min(13,orbit.dist));
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);
});
/* ================= Physics: verlet particles + PBD constraints ================= */
const GRAVITY=-9.81, AIR=0.998, PHYS_DT=1/60, SUBSTEPS=2, ITER=9;
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 pk(i,j){return i<j? i+'_'+j : j+'_'+i;}
// type: 'eq' exact distance, 'max' rope, 'min' spacer
function link(a,b,stiff=1,type='eq',rest=null){
const i=P[a],j=P[b];
const d=(rest!==null)?rest:parts[i].pos.distanceTo(parts[j].pos);
cons.push({i,j,rest:d,stiff,type});
if(type==='eq')bonded.add(pk(i,j));
}
const _d=new THREE.Vector3(), _v=new THREE.Vector3(), _t=new THREE.Vector3(), _t2=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, wsum=a.w+b.w;if(!wsum)return;
const s=c.stiff;
a.pos.addScaledVector(_d, diff*s*a.w/wsum);
b.pos.addScaledVector(_d,-diff*s*b.w/wsum);
}
function integrate(h){
for(const p of parts){
_v.subVectors(p.pos,p.prev).multiplyScalar(AIR);
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*0.25; // restitution
p.prev.x+=(p.pos.x-p.prev.x)*0.6; // friction
p.prev.z+=(p.pos.z-p.prev.z)*0.6;
}
}
}
function collideSelf(){
for(let i=0;i<parts.length;i++)for(let j=i+1;j<parts.length;j++){
if(bonded.has(i+'_'+j))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*0.7*a.w/w);
b.pos.addScaledVector(_d,-diff*0.7*b.w/w);
}
}
// instantaneous velocity change (m/s) on a particle
function addImpulse(p,dv){p.prev.addScaledVector(dv,-PHYS_DT/SUBSTEPS);}
/* ---- projectiles ---- */
const balls=[];
const ballGeo=new THREE.SphereGeometry(1,20,14);
const ballMat=new THREE.MeshStandardMaterial({color:0x49525f,roughness:0.3,metalness:0.8});
function spawnBall(pos,vel,r,m){
const mesh=new THREE.Mesh(ballGeo,ballMat);
mesh.castShadow=true;mesh.scale.setScalar(r);scene.add(mesh);
balls.push({pos:pos.clone(),vel:vel.clone(),r,m,life:7,mesh});
}
function stepBalls(h){
for(let k=balls.length-1;k>=0;k--){
const b=balls[k];b.life-=h;
b.vel.y+=GRAVITY*h;
b.pos.addScaledVector(b.vel,h);
if(b.pos.y<b.r){b.pos.y=b.r;b.vel.y*=-0.45;b.vel.x*=0.86;b.vel.z*=0.86;}
for(const p of parts){
_d.subVectors(p.pos,b.pos);const rr=p.r+b.r;
if(_d.lengthSq()<rr*rr){
const n=_d.normalize();
const vn=b.vel.dot(n);
if(vn>0){
const share=2*b.m/(b.m+p.m);
_t.copy(n).multiplyScalar(vn*share*0.9);
addImpulse(p,_t);
b.vel.addScaledVector(n,-vn*1.4);
}
b.pos.copy(p.pos).addScaledVector(n,-rr);
}
}
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);
for(let it=0;it<ITER;it++){
for(const c of cons)projectCon(c);
collideSelf();
collideGround();
if(grab.active){const p=parts[grab.pi];p.pos.lerp(grab.target,0.35);}
}
stepBalls(h);
}
for(const p of parts)p.force.set(0,0,0);
}
/* ================= Skeleton ================= */
// name x y z mass radius
addPart('pelvis', 0.000, 0.950, 0.000, 12.0, 0.125);
addPart('chest', 0.000, 1.240, 0.000, 13.0, 0.140);
addPart('neck', 0.000, 1.440, 0.000, 2.0, 0.055);
addPart('head', 0.000, 1.610, 0.000, 5.0, 0.105);
addPart('shoulderL', -0.210, 1.380, 0.000, 2.5, 0.060);
addPart('shoulderR', 0.210, 1.380, 0.000, 2.5, 0.060);
addPart('elbowL', -0.250, 1.120, 0.015, 1.6, 0.050);
addPart('elbowR', 0.250, 1.120, 0.015, 1.6, 0.050);
addPart('handL', -0.270, 0.875, 0.030, 1.2, 0.047);
addPart('handR', 0.270, 0.875, 0.030, 1.2, 0.047);
addPart('hipL', -0.115, 0.910, 0.000, 5.0, 0.075);
addPart('hipR', 0.115, 0.910, 0.000, 5.0, 0.075);
addPart('kneeL', -0.125, 0.490, 0.030, 3.5, 0.058);
addPart('kneeR', 0.125, 0.490, 0.030, 3.5, 0.058);
addPart('footL', -0.130, 0.050,-0.030, 1.8, 0.050);
addPart('footR', 0.130, 0.050,-0.030, 1.8, 0.050);
addPart('toeL', -0.130, 0.042, 0.135, 0.6, 0.042);
addPart('toeR', 0.130, 0.042, 0.135, 0.6, 0.042);
// spine
link('pelvis','chest');link('chest','neck');link('neck','head');
link('pelvis','neck',0.9);
// shoulder girdle
link('shoulderL','shoulderR');link('chest','shoulderL');link('chest','shoulderR');
link('neck','shoulderL',0.9);link('neck','shoulderR',0.9);
link('pelvis','shoulderL',0.55);link('pelvis','shoulderR',0.55);
// pelvis girdle
link('pelvis','hipL');link('pelvis','hipR');link('hipL','hipR');
link('chest','hipL',0.8);link('chest','hipR',0.8);
// arms
link('shoulderL','elbowL');link('elbowL','handL');
link('shoulderR','elbowR');link('elbowR','handR');
// legs
link('hipL','kneeL');link('kneeL','footL');link('footL','toeL');link('kneeL','toeL',0.7);
link('hipR','kneeR');link('kneeR','footR');link('footR','toeR');link('kneeR','toeR',0.7);
// joint limits (soft)
link('shoulderL','handL',0.8,'min',0.18);link('shoulderR','handR',0.8,'min',0.18);
link('hipL','footL',0.8,'min',0.32);link('hipR','footR',0.8,'min',0.32);
link('head','chest',0.5,'max',0.39);link('head','chest',0.5,'min',0.31);
link('kneeL','kneeR',0.6,'min',0.09);link('footL','footR',0.6,'min',0.08);
link('head','pelvis',0.6,'min',0.46);
/* ================= Visual body ================= */
const matSuit =new THREE.MeshStandardMaterial({color:0x4b5563,roughness:0.74,metalness:0.05});
const matSuit2=new THREE.MeshStandardMaterial({color:0x39414d,roughness:0.8 ,metalness:0.05});
const matSkin =new THREE.MeshStandardMaterial({color:0xc99c7d,roughness:0.55,metalness:0});
const matShoe =new THREE.MeshStandardMaterial({color:0x22262d,roughness:0.42,metalness:0.1});
const rig=new THREE.Group();scene.add(rig);
const limbs=[], pickMeshes=[];
const 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,16,1,true),mat);
cyl.castShadow=true;grp.add(cyl);
const capA=new THREE.Mesh(new THREE.SphereGeometry(ra,16,12),mat);capA.castShadow=true;grp.add(capA);
const capB=new THREE.Mesh(new THREE.SphereGeometry(rb,16,12),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',0.135,0.150,matSuit); // torso
makeLimb('chest','neck',0.10,0.055,matSuit); // upper chest taper
makeLimb('neck','head',0.05,0.05,matSkin); // neck
makeLimb('shoulderL','shoulderR',0.055,0.055,matSuit);
makeLimb('hipL','hipR',0.07,0.07,matSuit2);
makeLimb('shoulderL','elbowL',0.055,0.045,matSuit);
makeLimb('shoulderR','elbowR',0.055,0.045,matSuit);
makeLimb('elbowL','handL',0.042,0.034,matSuit);
makeLimb('elbowR','handR',0.042,0.034,matSuit);
makeLimb('hipL','kneeL',0.075,0.055,matSuit2);
makeLimb('hipR','kneeR',0.075,0.055,matSuit2);
makeLimb('kneeL','footL',0.052,0.04,matSuit2);
makeLimb('kneeR','footR',0.052,0.04,matSuit2);
makeLimb('footL','toeL',0.05,0.044,matShoe);
makeLimb('footR','toeR',0.05,0.044,matShoe);
// head (slightly egg-shaped)
const headMesh=new THREE.Mesh(new THREE.SphereGeometry(0.105,24,18),matSkin);
headMesh.castShadow=true;headMesh.scale.set(0.92,1.12,0.98);
headMesh.userData.pick=[P.head];rig.add(headMesh);pickMeshes.push(headMesh);
// hands
const handMeshes=['handL','handR'].map(n=>{
const m=new THREE.Mesh(new THREE.SphereGeometry(0.05,14,10),matSkin);
m.castShadow=true;m.scale.set(0.8,1.15,1.0);
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;
const len=a.distanceTo(b);
L.grp.position.copy(a).add(b).multiplyScalar(0.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); // follow neck
handMeshes[0].position.copy(parts[P.handL].pos);
handMeshes[1].position.copy(parts[P.handR].pos);
}
// remember rest pose (offsets are relative to pelvis at origin, y absolute)
const POSE={};for(const p of parts)POSE[p.name]=p.pos.clone();
/* ================= Brain: euphoria-style active controller ================= */
// PD gains per body group: [kp, kd] (scaled by mass and muscle strength)
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]};
// gravity compensation per group (1 = fully supported by muscles)
const GCOMP={pelvis:0.92,chest:0.92,neck:0.9,head:0.9,shoulder:0.85,
elbow:0.7,hand:0.6,hip:0.9,knee:0.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(0,0,0),
com:new THREE.Vector3(), comPrev:new THREE.Vector3(), 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;
// --- body frame ---
_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);
// --- center of mass & support ---
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(0.5);
_off.subVectors(B.com,_feet);_off.y=0;
_off.addScaledVector(_t.copy(B.comVel).setY(0),0.16); // velocity lead
const offLen=_off.length();
_t2.subVectors(parts[P.neck].pos,parts[P.pelvis].pos).normalize();
const upr=_t2.y;
const comSpeed=B.comVel.length();
// --- state transitions ---
if(B.state==='balance'){
B.strength+= (1-B.strength)*Math.min(1,dt*5);
if(upr<0.55||offLen>0.34)setState('falling');
}else if(B.state==='falling'){
B.strength=0.3;
if(B.com.y<0.55&&comSpeed<1.8)setState('down');
else if(upr>0.78&&offLen<0.12&&B.com.y>0.85)setState('balance');
}else if(B.state==='down'){
B.strength=0.06;
if(B.t>1.25)setState('getup');
if(upr>0.78&&B.com.y>0.85)setState('balance');
}else if(B.state==='getup'){
B.strength=1.25;
if(upr>0.82&&B.com.y>0.88&&offLen<0.2)setState('balance');
if(B.t>3)setState('down');
}else if(B.state==='ko'){
B.strength=0.015;B.koT-=dt;
if(B.koT<=0)setState('down');
}
const S=B.strength;
// --- root target (where the body wants its pelvis, in XZ) ---
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);
}
// --- default targets: rest pose mapped into current body frame ---
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);
}
// subtle idle life
const br=Math.sin(simTime*1.7)*0.006, sw=Math.sin(simTime*0.9)*0.012;
T.chest.y+=br;T.head.y+=br*1.4;
T.head.addScaledVector(_fwd,sw);T.chest.addScaledVector(_fwd,sw*0.5);
// --- balance behaviors ---
if(B.state==='balance'){
// counter-lean torso against the offset
T.chest.addScaledVector(_off,-0.55);T.head.addScaledVector(_off,-0.8);
T.pelvis.addScaledVector(_off,-0.25);
// arms swing out when unstable
const inst=Math.min(1,offLen*5);
if(inst>0.15){
const wave=Math.sin(simTime*11)*0.16*inst;
T.handL.addScaledVector(_right,-(0.12+0.26*inst)).addScaledVector(_fwd,wave).y+=0.30*inst;
T.handR.addScaledVector(_right, (0.12+0.26*inst)).addScaledVector(_fwd,-wave).y+=0.30*inst;
T.elbowL.addScaledVector(_right,-0.16*inst).y+=0.14*inst;
T.elbowR.addScaledVector(_right, 0.16*inst).y+=0.14*inst;
}
// protective stepping
if(!B.step&&B.cool<=0&&offLen>0.12){
const dir=_t.copy(_off).normalize();
const foot=(B.lastFoot==='R')?'L':'R';
const to=new THREE.Vector3(B.com.x,0,B.com.z).addScaledVector(dir,0.24)
.addScaledVector(_right,foot==='L'?-0.10:0.10);
B.step={foot,from:parts[P['foot'+foot]].pos.clone(),to,t:0,dur:0.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)*0.14;
const ft=T['foot'+st.foot];
ft.lerpVectors(st.from,st.to,f);ft.y=0.05+lift;
T['toe'+st.foot].copy(ft).addScaledVector(_fwd,0.15);T['toe'+st.foot].y=ft.y-0.005;
if(f>=1){B.step=null;B.cool=0.07;}
}
// --- falling: brace with arms, tuck head ---
if(B.state==='falling'){
_fall.copy(B.comVel);_fall.y=0;
if(_fall.lengthSq()<0.01)_fall.copy(_off);
if(_fall.lengthSq()>1e-6)_fall.normalize();
T.handL.copy(parts[P.shoulderL].pos).addScaledVector(_fall,0.42).y=Math.max(0.15,parts[P.shoulderL].pos.y-0.25);
T.handR.copy(parts[P.shoulderR].pos).addScaledVector(_fall,0.42).y=Math.max(0.15,parts[P.shoulderR].pos.y-0.25);
T.elbowL.copy(parts[P.shoulderL].pos).addScaledVector(_fall,0.2);
T.elbowR.copy(parts[P.shoulderR].pos).addScaledVector(_fall,0.2);
T.head.copy(parts[P.neck].pos).addScaledVector(_fall,-0.12).y+=0.12; // chin tuck
T.kneeL.y+=0.15;T.kneeR.y+=0.15; // soften legs
}
// --- muscles: PD force toward targets ---
const getup=B.state==='getup';
const ramp=getup?Math.min(1,B.t/0.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>0.6)_t.multiplyScalar(0.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);
// gravity compensation so the pose doesn't sag
p.force.y+=-GRAVITY*p.m*GCOMP[groupOf(p.name)]*Math.min(1,S)*ramp;
// get-up assist: extra lift + horizontal damping
if(getup&&(p.name==='pelvis'||p.name==='chest')){
p.force.y+=-GRAVITY*p.m*0.5*ramp;
p.prev.x+=(p.pos.x-p.prev.x)*0.04;p.prev.z+=(p.pos.z-p.prev.z)*0.04;
}
const fmax=p.m*260;
if(p.force.lengthSq()>fmax*fmax)p.force.setLength(fmax);
}
}
/* ================= Input: grab / orbit / throw ================= */
const grab={active:false,pi:0,dist:0,target:new THREE.Vector3()};
const ray=new THREE.Raycaster(), ndc=new THREE.Vector2();
let dragMode=null, lastX=0, lastY=0, movedPx=0;
function setNDC(e){ndc.set((e.clientX/innerWidth)*2-1,-(e.clientY/innerHeight)*2+1);}
renderer.domElement.addEventListener('pointerdown',e=>{
e.preventDefault();renderer.domElement.setPointerCapture(e.pointerId);
lastX=e.clientX;lastY=e.clientY;movedPx=0;
setNDC(e);ray.setFromCamera(ndc,camera);
const hits=ray.intersectObjects(pickMeshes,false);
if(hits.length&&e.button===0){
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';
});
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);
if(dragMode==='orbit'){orbit.yaw-=dx*0.005;orbit.pitch+=dy*0.005;updateCamera();}
else if(dragMode==='grab'){
setNDC(e);ray.setFromCamera(ndc,camera);
grab.target.copy(ray.ray.origin).addScaledVector(ray.ray.direction,grab.dist);
grab.target.y=Math.max(0.06,grab.target.y);
}
});
renderer.domElement.addEventListener('pointerup',e=>{
if(dragMode==='orbit'&&movedPx<6&&e.button===0){ // click on empty space → throw ball
setNDC(e);ray.setFromCamera(ndc,camera);
spawnBall(_t.copy(ray.ray.origin).addScaledVector(ray.ray.direction,0.4),
_t2.copy(ray.ray.direction).multiplyScalar(15),0.09,3.5);
}
grab.active=false;dragMode=null;
});
renderer.domElement.addEventListener('wheel',e=>{
e.preventDefault();orbit.dist*=(e.deltaY>0?1.1:0.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']
};
function syncBadge(){
const s=STATE_STYLE[brain.state];
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(0.55));
};
document.getElementById('bBall').onclick=()=>{
const a=Math.random()*Math.PI*2;
const from=_t.set(Math.cos(a)*5,1.5+Math.random(),Math.sin(a)*5);
const vel=_t2.copy(parts[P.chest].pos).sub(from).normalize().multiplyScalar(16);
spawnBall(from,vel,0.14,6);
};
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;brain.root.set(0,0,0);brain.comPrev.set(0,1,0);
brain.step=null;setState('balance');brain.strength=1;
};
/* ================= Main loop ================= */
const clock=new THREE.Clock();
let acc=0;
function animate(){
requestAnimationFrame(animate);
let dt=Math.min(clock.getDelta(),0.05);
if(slow)dt*=0.28;
acc+=dt;let n=0;
while(acc>=PHYS_DT&&n<4){
brainUpdate(PHYS_DT);
stepPhysics();
acc-=PHYS_DT;n++;
}
// camera gently follows the character
_t.copy(brain.com);_t.y=Math.max(0.5,Math.min(1.1,brain.com.y));
orbit.target.lerp(_t,1-Math.exp(-dt*3));updateCamera();
syncVisuals();syncBadge();
renderer.render(scene,camera);
}
brain.comPrev.set(0,1,0);
animate();
</script>
</body>
</html>

View File

@@ -0,0 +1,911 @@
<!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,.85);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,.45);max-width:296px;user-select:none}
#ui h1{font-size:15px;margin:0 0 2px;font-weight:650;letter-spacing:.2px}
#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.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 9px;border-radius:99px;margin-left:6px;
background:#1a1f2b;border:1px solid rgba(255,255,255,.15);color:#94a3b8;vertical-align:middle}
#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">(or press V)</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">
<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>
</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 class="lbl" style="margin-top:8px">C4</div>
<div class="row">
<button class="danger" id="bDetonate">💥 Detonate</button>
<button class="danger" id="bClearC4">Clear C4</button>
</div>
</div>
<div id="hint"><b>LMB</b> fire/place &nbsp;·&nbsp; <b>RMB drag</b> grab ragdoll &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');
const 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);
const t=new THREE.CanvasTexture(c); t.encoding=THREE.sRGBEncoding; scene.background=t;
}
scene.add(new THREE.HemisphereLight(0xbdd3ea,0x4d463b,0.9));
const sun = new THREE.DirectionalLight(0xfff0d8,1.5);
sun.position.set(6,9,4); sun.castShadow=true;
sun.shadow.mapSize.set(1024,1024); // reduced for perf
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,0.28);
fill.position.set(-5,4,-6); scene.add(fill);
const flashPt = new THREE.PointLight(0xff8822,0,20,2); scene.add(flashPt);
/* ground */
function makeGroundTex(){
const c=document.createElement('canvas'); c.width=c.height=512;
const g=c.getContext('2d');
g.fillStyle='#7d8186'; g.fillRect(0,0,512,512);
for(let i=0;i<6000;i++){
const v=20+Math.random()*45|0;
g.fillStyle=`rgba(${v},${v},${v+4},${.05+Math.random()*.12})`;
g.fillRect(Math.random()*512,Math.random()*512,1.6,1.6);
}
const t=new THREE.CanvasTexture(c);
t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(24,24);
t.anisotropy=4; t.encoding=THREE.sRGBEncoding; return t;
}
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(220,220),
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,0.05,200);
// Camera modes: 'orbit' | 'follow' | 'fp'
// orbit = standard orbit around body, lerped follow
// follow = hard-locks orbit target to COM, player can still orbit around it
// fp = first-person from head position, looking forward
let camMode='orbit';
const orbit = {yaw:.65, pitch:.18, dist:4.6, target:new THREE.Vector3(0,1,0)};
// fp look angles (separate from orbit)
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.0, Math.min(20, orbit.dist));
if(camMode==='fp'){
// position at head, look in fp direction
const head=parts[P.head];
fp.pitch = Math.max(-1.4, Math.min(1.4, fp.pitch));
const cp=Math.cos(fp.pitch);
camera.position.copy(head.pos);
camera.position.y += 0.06;
const lookAt=new THREE.Vector3(
head.pos.x + Math.sin(fp.yaw)*cp*10,
head.pos.y + 0.06 + Math.sin(fp.pitch)*10,
head.pos.z + Math.cos(fp.yaw)*cp*10
);
camera.lookAt(lookAt);
} 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'));
});
// when switching to follow, snap immediately
if(m==='follow'||m==='fp'){
orbit.target.copy(brain.com);
}
}
// Cycle cam with V key
addEventListener('keydown',e=>{
if(e.key==='v'||e.key==='V'){
const modes=['orbit','follow','fp'];
setCamMode(modes[(modes.indexOf(camMode)+1)%3]);
}
});
/* ===================== PHYSICS ===================== */
const GRAVITY=-9.81, AIR=0.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 pk(i,j){return i<j?i+'_'+j:j+'_'+i;}
function link(a,b,stiff=1,type='eq',rest=null){
const i=P[a],j=P[b];
const d=rest!==null?rest:parts[i].pos.distanceTo(parts[j].pos);
cons.push({i,j,rest:d,stiff,type});
if(type==='eq') bonded.add(pk(i,j));
}
// Reusable scratch vectors — never allocate in hot loops
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;
const s=c.stiff;
a.pos.addScaledVector(_d, diff*s*a.w/ws);
b.pos.addScaledVector(_d,-diff*s*b.w/ws);
}
function integrate(h){
for(const p of parts){
_v.subVectors(p.pos,p.prev).multiplyScalar(AIR);
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*0.25;
p.prev.x+=(p.pos.x-p.prev.x)*0.6;
p.prev.z+=(p.pos.z-p.prev.z)*0.6;
}
}
}
function collideSelf(){
for(let i=0;i<parts.length;i++) for(let j=i+1;j<parts.length;j++){
if(bonded.has(i+'_'+j)) 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*0.7*a.w/w);
b.pos.addScaledVector(_d,-diff*0.7*b.w/w);
}
}
function addImpulse(p,dv){ p.prev.addScaledVector(dv,-PHYS_DT/SUBSTEPS); }
/* ===================== PARTICLES ===================== */
const sparks=[];
// Shared geometry/material pool for performance
const _sparkGeo=new THREE.SphereGeometry(.025,4,3);
const _smokeGeo=new THREE.SphereGeometry(.1,5,4);
const _smokeMat=new THREE.MeshBasicMaterial({color:0x555566,transparent:true,opacity:.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,_smokeMat.clone());
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.2+Math.random(),maxLife:1.6,grav:false,smoke: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 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; }
/* ===================== EXPLOSIONS ===================== */
function explodeAt(pos, radius, force){
for(const p of parts){
_tmp.subVectors(p.pos,pos);
const dist=_tmp.length();
if(dist<radius){
const fall=1-dist/radius;
_tmp.normalize().multiplyScalar(force*fall*fall);
_tmp.y+=force*fall*.5;
addImpulse(p,_tmp);
}
}
setState('ko'); brain.koT=Math.max(brain.koT,4.5);
}
/* ===================== 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(){
let any=false;
for(const c of c4List){
if(!c.armed)continue; any=true;
flashPt.position.copy(c.pos).setY(.5);
triggerFlash(2.8); explodeAt(c.pos,4.2,68);
spawnSparks(c.pos.clone().setY(.1),35,0xff6600,5,1);
spawnSmoke(c.pos.clone().setY(.1),14);
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<0.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}),
};
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,hit:false});
}
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'){
addImpulse(part, _tmp.copy(_d).multiplyScalar(vn*ball.m*3));
ball.vel.multiplyScalar(.3);
return false;
} else if(ball.type==='HE'){
return true;
} else if(ball.type==='AP'){
addImpulse(part, _tmp.copy(_d).multiplyScalar(vn*ball.m*4));
spawnSparks(ball.pos.clone(),8,0x88ffaa,3,.35);
ball.vel.multiplyScalar(.45);
return false;
} else {
const share=2*ball.m/(ball.m+part.m);
addImpulse(part, _tmp.copy(_d).multiplyScalar(vn*share*.9));
ball.vel.addScaledVector(_d,-vn*1.4);
ball.pos.copy(part.pos).addScaledVector(_d,-(part.r+ball.r));
return false;
}
}
function stepBalls(h){
for(let k=balls.length-1;k>=0;k--){
const b=balls[k]; b.life-=h;
b.vel.y+=GRAVITY*h;
b.pos.addScaledVector(b.vel,h);
if(b.pos.y<b.r){
b.pos.y=b.r;
if(b.type==='HE'){ doExplodeBall(k); continue; }
b.vel.y*=-.45; b.vel.x*=.86; b.vel.z*=.86;
}
let remove=false;
for(const p of parts){
_d.subVectors(p.pos,b.pos);
if(_d.lengthSq()<(p.r+b.r)*(p.r+b.r)){
remove=onBallHitPart(b,p);
if(remove) break;
}
}
if(remove){ doExplodeBall(k); continue; }
b.mesh.position.copy(b.pos);
if(b.life<=0){ scene.remove(b.mesh); balls.splice(k,1); }
}
}
function doExplodeBall(k){
const b=balls[k];
flashPt.position.copy(b.pos);
triggerFlash(1.8); explodeAt(b.pos,2.6,52);
spawnSparks(b.pos.clone(),22,0xff5500,5,.7);
spawnSmoke(b.pos.clone(),8);
snapCameraToBody();
scene.remove(b.mesh); balls.splice(k,1);
}
function stepPhysics(){
const h=PHYS_DT/SUBSTEPS;
for(let s=0;s<SUBSTEPS;s++){
integrate(h);
for(let it=0;it<ITER;it++){
for(const c of cons) projectCon(c);
collideSelf(); collideGround();
if(grab.active){ parts[grab.pi].pos.lerp(grab.target,.35); }
}
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=[];
const 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;
const 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');
}
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();
const 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;
}
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';
let currentAmmo='normal';
let isFiring=false;
let gunFireCooldown=0;
function selectWeapon(w){
currentWeapon=w;
['Ball','Gun','Cannon','C4'].forEach(id=>{
const btn=document.getElementById('w'+id);
if(btn) btn.classList.toggle('sel', id.toLowerCase()===w);
});
document.getElementById('ammo-row').style.display=(w==='cannon')?'flex':'none';
}
function selectAmmo(a){
currentAmmo=a;
['Normal','HE','AP'].forEach(id=>{
document.getElementById('am'+id).classList.toggle('active-ammo',id.toLowerCase()===a);
});
}
// Pre-allocated ray/ndc to avoid per-click allocation
const _fireRay=new THREE.Raycaster();
const _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, 2, 'gun');
flashPt.position.copy(origin); flashPt.intensity=8;
setTimeout(()=>flashPt.intensity=0,35);
spawnSparks(origin.clone(),6,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=9; 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'){
// ground intersection
const dy=_fireRay.ray.direction.y;
if(dy<-0.01){
const t2=_fireRay.ray.origin.y/(-dy);
if(t2>0){
const gp=_fireRay.ray.origin.clone().addScaledVector(_fireRay.ray.direction,t2);
placeC4(gp);
}
}
}
}
/* ===================== INPUT ===================== */
const grab={active:false,pi:0,dist:0,target:new THREE.Vector3()};
const _grabRay=new THREE.Raycaster();
const _grabNDC=new THREE.Vector2();
let dragMode=null, lastX=0, lastY=0, movedPx=0;
function setGrabNDC(e){ _grabNDC.set((e.clientX/innerWidth)*2-1,-(e.clientY/innerHeight)*2+1); }
// Live ndc for held-fire (updated every mousemove)
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){
// RIGHT CLICK — grab ragdoll only
setGrabNDC(e); _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){
// LEFT CLICK — ONLY fire or orbit. Never grabs body.
dragMode='mayfire'; // will become 'orbit' if dragged, or fire on up
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'){
setGrabNDC(e); _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){
// clean click — fire once (gun/cannon already fired on down)
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']
};
function syncBadge(){
const s=STATE_STYLE[brain.state];
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;
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;
// held-fire
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);
// Camera update
if(camMode==='follow'){
// hard-follow: target snaps directly to COM every frame, no lerp
orbit.target.copy(brain.com);
updateCamera();
} else if(camMode==='fp'){
updateCamera(); // fp reads head pos directly
} else {
// orbit: smooth follow
_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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,993 @@
<!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);
}
function integrate(h){
for(const p of parts){
_v.subVectors(p.pos,p.prev).multiplyScalar(AIR);
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);
for(let it=0;it<ITER;it++){
for(const c of cons)projectCon(c);
collideSelf(); collideGround();
if(grab.active)parts[grab.pi].pos.lerp(grab.target,.35);
}
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>

View File

@@ -0,0 +1,583 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Active Ragdoll — 67 Pose & Hustle · Stable Grab · Follow Cam</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:350px;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; background:#2e4a7a}
button.hustle-btn{background:#42213d;border-color:#c084fc;color:#e9d5ff}
button.hustle-btn:hover{background:#5e2a57}
#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">FOLLOW</span></h1>
<div class="sub">67 Pose (palms-up seesaw) · Hustle (march+swing) · Sticky C4 (smooth grab)</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>
<button id="b67" style="background:#4a2a1a;border-color:#f59e0b;">6⃣7⃣ Pose</button>
<button id="bHustle" class="hustle-btn">💃 Hustle</button>
</div>
<div class="lbl">Camera <span style="color:#6b7a8d;font-weight:400">(V to cycle)</span></div>
<div class="row">
<button id="camOrbit" onclick="setCamMode('orbit')">🌐 Orbit</button>
<button id="camFollow" class="sel" onclick="setCamMode('follow')">🎯 Follow</button>
<button id="camFP" onclick="setCamMode('fp')">👁 First-Person</button>
<button id="camIndie" onclick="setCamMode('independent')">🎥 Independent</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>C4 Power</label><input type="range" id="c4Power" min="5" max="100" value="45" step="1"><span id="c4PowerVal">45</span></div></div>
<div id="he-options" style="display:none"><div class="slider-row"><label>HE Power</label><input type="range" id="hePower" min="15" max="100" value="48" step="1"><span id="hePowerVal">48</span></div></div>
<div class="lbl" style="margin-top:8px">C4 / Sticky C4</div>
<div class="row">
<button class="danger" id="bDetonate">💥 Detonate C4</button>
<button class="danger" id="bClearC4">Clear C4</button>
<button id="bAddSticky" style="background:#2a3a5a;border-color:#60a5fa;">📍 Add Sticky C4</button>
<button class="danger" id="bDetonateSticky">💣 Detonate Sticky</button>
</div>
</div>
<div id="hint"><b>LMB</b> fire/place &nbsp;·&nbsp; <b>RMB drag</b> grab (stable) &nbsp;·&nbsp; <b>↑↓←→</b> camera &nbsp;·&nbsp; <b>V</b> cam</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');
g.fillStyle='#7a7e83'; g.fillRect(0,0,512,512);
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); }
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(); }
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 (default Follow) ========== */
const camera=new THREE.PerspectiveCamera(60,innerWidth/innerHeight,.05,200);
let camMode='follow';
const orbit={yaw:.65,pitch:.18,dist:4.6,target:new THREE.Vector3(0,1,0)};
const fp={yaw:.65,pitch:0};
const indie={pos:new THREE.Vector3(2.2, 1.8, 3.5), yaw:0.65, pitch:-0.12};
const keyStateArrow={up:false,down:false,left:false,right:false,space:false,shift:false};
function getCameraDirectionVectors(){
const forward=new THREE.Vector3(0,0,-1).applyQuaternion(camera.quaternion);
forward.y=0; forward.normalize();
const right=new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);
right.y=0; right.normalize();
return {forward,right};
}
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 if(camMode==='orbit'){
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);
} else if(camMode==='follow'){
const cp=Math.cos(orbit.pitch);
const followTarget=brain.com;
camera.position.set(followTarget.x+Math.sin(orbit.yaw)*cp*orbit.dist, followTarget.y+Math.sin(orbit.pitch)*orbit.dist, followTarget.z+Math.cos(orbit.yaw)*cp*orbit.dist);
camera.lookAt(followTarget);
} else {
const {forward,right}=getCameraDirectionVectors();
let move=new THREE.Vector3(0,0,0);
if(keyStateArrow.up) move.z+=1;
if(keyStateArrow.down) move.z-=1;
if(keyStateArrow.left) move.x-=1;
if(keyStateArrow.right) move.x+=1;
if(move.length()>0) move.normalize();
const speed=12;
indie.pos.addScaledVector(forward, move.z*speed*0.016);
indie.pos.addScaledVector(right, move.x*speed*0.016);
if(keyStateArrow.space) indie.pos.y+=5*0.016;
if(keyStateArrow.shift) indie.pos.y-=5*0.016;
indie.pos.y=Math.max(0.5,Math.min(20,indie.pos.y));
const lookDir=new THREE.Vector3(Math.sin(indie.yaw), Math.sin(indie.pitch), Math.cos(indie.yaw)).normalize();
camera.position.copy(indie.pos);
camera.lookAt(indie.pos.clone().add(lookDir));
}
}
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',independent:'INDEPENDENT'};
function setCamMode(m){ camMode=m; camBadge.textContent=CAM_LABELS[m];
['Orbit','Follow','FP','Indie'].forEach(id=>document.getElementById('cam'+id).classList.toggle('sel',id.toLowerCase()===m||(id==='Indie'&&m==='independent')));
if(m==='follow'||m==='fp') orbit.target.copy(brain.com);
}
addEventListener('keydown',e=>{
const key=e.key; const keyL=key.toLowerCase();
if(keyL==='v'){ const m=['orbit','follow','fp','independent']; setCamMode(m[(m.indexOf(camMode)+1)%4]); e.preventDefault();}
if(key === 'ArrowUp') { keyStateArrow.up=true; e.preventDefault(); }
if(key === 'ArrowDown') { keyStateArrow.down=true; e.preventDefault(); }
if(key === 'ArrowLeft') { keyStateArrow.left=true; e.preventDefault(); }
if(key === 'ArrowRight') { keyStateArrow.right=true; e.preventDefault(); }
if(key === ' ') { keyStateArrow.space=true; e.preventDefault(); }
if(key === 'Shift') { keyStateArrow.shift=true; e.preventDefault(); }
});
addEventListener('keyup',e=>{
const key=e.key;
if(key === 'ArrowUp') keyStateArrow.up=false;
if(key === 'ArrowDown') keyStateArrow.down=false;
if(key === 'ArrowLeft') keyStateArrow.left=false;
if(key === 'ArrowRight') keyStateArrow.right=false;
if(key === ' ') keyStateArrow.space=false;
if(key === 'Shift') keyStateArrow.shift=false;
});
/* ========== 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);
}
function integrate(h){ for(const p of parts){
_v.subVectors(p.pos,p.prev).multiplyScalar(AIR);
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 & FIRE ========== */
const sparks=[]; const _sparkGeo=new THREE.SphereGeometry(.024,4,3); const _smokeGeo=new THREE.SphereGeometry(.1,5,4); const _fireGlowGeo=new THREE.SphereGeometry(.045,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}); } }
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}); } }
let bodyFireEmitterTimer=0;
function emitBodyFlames(dt){
if(!fireState.active) return;
bodyFireEmitterTimer+=dt;
const fireParts=['chest','pelvis','head','shoulderL','shoulderR','hipL','hipR','kneeL','kneeR'];
const intensity=Math.min(1.2, fireState.danceTimer*0.5);
const countPerPart=Math.floor(3+intensity*8);
for(let i=0;i<fireParts.length;i++){
const partIdx=P[fireParts[i]];
if(partIdx!==undefined){
const partPos=parts[partIdx].pos.clone();
partPos.x+=(Math.random()-.5)*0.2;
partPos.z+=(Math.random()-.5)*0.2;
partPos.y+=(Math.random()-.2)*0.15;
for(let f=0;f<countPerPart;f++){
const dir=new THREE.Vector3(Math.random()-.5, Math.random()*.8+.3, Math.random()-.5).normalize();
const m=new THREE.Mesh(_sparkGeo,new THREE.MeshBasicMaterial({color:0xff4400}));
m.position.copy(partPos);
scene.add(m);
sparks.push({mesh:m,vel:dir.multiplyScalar(1.2+Math.random()*1.5),life:0.25+Math.random()*0.3,maxLife:0.5,grav:false,fire:true});
}
if(Math.random()<0.3){
const glow=new THREE.Mesh(_fireGlowGeo,new THREE.MeshBasicMaterial({color:0xff8822,transparent:true,opacity:0.7}));
glow.position.copy(partPos);
scene.add(glow);
sparks.push({mesh:glow,vel:new THREE.Vector3((Math.random()-.5)*0.8,Math.random()*1.2,(Math.random()-.5)*0.8), life:0.2,maxLife:0.3,grav:false,fire:true});
}
}
}
if(Math.random()<0.2) spawnSmoke(parts[P.chest].pos.clone(), 2);
}
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; 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); } } }
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;}
const fireZones=[];
function addFireZone(pos, radius, duration){ fireZones.push({ pos:pos.clone(), radius, duration, life:0 }); }
function stepFireZones(dt){
for(let i=0;i<fireZones.length;i++){ const z=fireZones[i]; z.life+=dt;
for(let p=0;p<4+Math.random()*5;p++) spawnFire(z.pos.clone().addScaledVector(new THREE.Vector3(Math.random()-.5,0,Math.random()-.5),z.radius*.9), 1, 0.4);
if(!fireState.active && brain.com.distanceTo(z.pos)<z.radius){ startFire(); applyKO(5.0); }
if(z.life>=z.duration){ fireZones.splice(i,1); i--; } }
}
/* ========== UNIFIED KNOCKOUT & FIRE STATE ========== */
let forcedGetupTimer=0;
const fireState={active:false, danceTimer:0, duration:5};
function startFire(){ if(fireState.active)return; fireState.active=true; fireState.danceTimer=0; setState('fire'); brain.strength=1.4; }
function applyKO(duration){ if(fireState.active) fireState.active=false; setState('ko'); brain.koT=duration; forcedGetupTimer=duration; brain.strength=0.02; }
function updateKOGetup(dt){ if(brain.state==='ko'){ forcedGetupTimer-=dt; if(forcedGetupTimer<=0 && !isAirborne()){ setState('getup'); brain.strength=1.2; forcedGetupTimer=0; } } }
function isAirborne(){ return (parts[P.footL].pos.y>0.12 || parts[P.footR].pos.y>0.12) && brain.comVel.y>0.5; }
function applyFireDance(dt){ if(!fireState.active)return;
fireState.danceTimer+=dt;
const t=fireState.danceTimer;
const intensity=Math.min(1.6, t*0.6);
const T=brain.targets;
T.handL.addScaledVector(_right, -(Math.sin(t*12)*0.9+0.3)*intensity).y+=Math.abs(Math.sin(t*11))*1.2*intensity;
T.handR.addScaledVector(_right, (Math.sin(t*11+1.2)*0.9+0.3)*intensity).y+=Math.abs(Math.sin(t*10.5))*1.2*intensity;
T.elbowL.addScaledVector(_right, -Math.sin(t*14)*0.65*intensity).y+=Math.abs(Math.sin(t*8.5))*0.8*intensity;
T.elbowR.addScaledVector(_right, Math.sin(t*13+1.5)*0.65*intensity).y+=Math.abs(Math.sin(t*9))*0.8*intensity;
T.footL.y+=Math.abs(Math.sin(t*10))*0.8*intensity; T.footR.y+=Math.abs(Math.sin(t*11.5))*0.8*intensity;
T.kneeL.y+=Math.abs(Math.sin(t*8))*0.6*intensity; T.kneeR.y+=Math.abs(Math.sin(t*9.2))*0.6*intensity;
T.chest.addScaledVector(_right, Math.sin(t*10)*0.45*intensity).z+=Math.sin(t*7)*0.35*intensity;
T.head.addScaledVector(_right, Math.sin(t*14)*0.55*intensity).y+=Math.sin(t*10)*0.45*intensity;
T.pelvis.y+=Math.abs(Math.sin(t*18))*0.28*intensity;
if(Math.random()<dt*0.7){ const runDir=new THREE.Vector3(Math.sin(t*5),0,Math.cos(t*4)).normalize(); addImpulse(parts[P.chest], runDir.multiplyScalar(6.5)); addImpulse(parts[P.pelvis], runDir.multiplyScalar(4)); }
if(fireState.danceTimer>=fireState.duration){ fireState.active=false; applyKO(0); setState('ko'); brain.koT=3; forcedGetupTimer=3; }
}
/* ========== C4 & STICKY C4 ========== */
const c4List=[]; const stickyC4List=[];
const c4Geo=new THREE.BoxGeometry(.22,.08,.14); const c4Mat=new THREE.MeshStandardMaterial({color:0xd4c98a,roughness:.85});
const miniC4Geo=new THREE.BoxGeometry(.14,.04,.1); const miniMat=new THREE.MeshStandardMaterial({color:0xccaa66,roughness:.7,emissive:0x331100});
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 addStickyC4(){
const bodyParts=['chest','pelvis','head','shoulderL','shoulderR','hipL','hipR','kneeL','kneeR','elbowL','elbowR','handL','handR','footL','footR','toeL','toeR'];
const randomPart=bodyParts[Math.floor(Math.random()*bodyParts.length)];
const partIdx=P[randomPart];
if(partIdx===undefined) return;
const part=parts[partIdx];
let localOffset;
if(randomPart.includes('head')) localOffset=new THREE.Vector3((Math.random()-.5)*0.16, (Math.random()-.5)*0.12+0.08, (Math.random()-.5)*0.16);
else if(randomPart.includes('hand')||randomPart.includes('foot')||randomPart.includes('toe')) localOffset=new THREE.Vector3((Math.random()-.5)*0.12, (Math.random()-.5)*0.08+0.04, (Math.random()-.5)*0.1);
else if(randomPart.includes('knee')||randomPart.includes('elbow')) localOffset=new THREE.Vector3((Math.random()-.5)*0.14, (Math.random()-.5)*0.1+0.05, (Math.random()-.5)*0.12);
else localOffset=new THREE.Vector3((Math.random()-.5)*0.18, (Math.random()-.5)*0.12+0.08, (Math.random()-.5)*0.12+0.05);
const mesh=new THREE.Mesh(miniC4Geo,miniMat); mesh.castShadow=true;
mesh.position.copy(part.pos.clone().add(localOffset));
scene.add(mesh);
const led=new THREE.Mesh(new THREE.SphereGeometry(.01,4,4),new THREE.MeshBasicMaterial({color:0xff3333})); led.position.set(0.06,0.01,0.045); mesh.add(led);
stickyC4List.push({mesh, partIdx, localOffset, led, blink:0, armed:true});
}
function detonateStickyC4(){ const power=28; const radius=2.4;
for(let i=0;i<stickyC4List.length;i++){ const s=stickyC4List[i];
if(!s.armed)continue;
const part=parts[s.partIdx];
const worldPos=part.pos.clone().add(s.localOffset);
flashPt.position.copy(worldPos); triggerFlash(1.2);
explodeAt(worldPos, radius, power);
spawnSparks(worldPos.clone(),20,0xff8844,3.8,0.8);
scene.remove(s.mesh);
stickyC4List.splice(i,1); i--;
} }
function detonateC4(){ const powerVal=parseFloat(document.getElementById('c4Power').value); const t=powerVal/100; const force=8+t*72; const radius=1.2+t*3.2;
for(const c of c4List){ if(!c.armed)continue; flashPt.position.copy(c.pos).setY(.5); triggerFlash(1+t*1.8); explodeAt(c.pos,radius,force); spawnSparks(c.pos.clone().setY(.1),20+Math.round(t*28),0xff6600,4.5+t*2.5,1); spawnSmoke(c.pos.clone().setY(.1),8+Math.round(t*12)); scene.remove(c.mesh); }
c4List.length=0; snapCameraToBody(); }
function stepStickyC4(dt){ for(const s of stickyC4List){
s.blink+=dt; s.led.material.color.setHex(s.blink%0.5<0.1?0xff3333:0x660000);
const part=parts[s.partIdx]; s.mesh.position.copy(part.pos.clone().add(s.localOffset));
} }
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(){ if(camMode!=='independent') orbit.target.copy(brain.com); }
function explodeAt(pos, radius, force){ 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().multiplyScalar(force*fall*fall); _tmp.y+=force*fall*.5; addImpulse(p,_tmp); } }
if(hit) applyKO(3.0); }
/* ========== PROJECTILES (unchanged) ========== */
const balls=[]; const originalBallMat=new THREE.MeshStandardMaterial({color:0xd04020,roughness:.55,metalness:.1});
const heShellGroup=()=>{ const g=new THREE.Group(); const body=new THREE.Mesh(new THREE.CylinderGeometry(0.09,0.07,0.48,12),new THREE.MeshStandardMaterial({color:0xcc8844,metalness:.7})); body.castShadow=true; g.add(body); const nose=new THREE.Mesh(new THREE.ConeGeometry(0.07,0.16,8),new THREE.MeshStandardMaterial({color:0xeeaa66})); nose.position.y=0.27; g.add(nose); return g; };
const apShellGroup=()=>{ const g=new THREE.Group(); const body=new THREE.Mesh(new THREE.CylinderGeometry(0.085,0.075,0.52,16),new THREE.MeshStandardMaterial({color:0x99bbdd,metalness:.9})); body.castShadow=true; g.add(body); const tip=new THREE.Mesh(new THREE.ConeGeometry(0.055,0.22,12),new THREE.MeshStandardMaterial({color:0xcceeff,metalness:.95})); tip.position.y=0.3; g.add(tip); return g; };
const cannonGroup=()=>{ const g=new THREE.Group(); const body=new THREE.Mesh(new THREE.CylinderGeometry(0.1,0.09,0.5,10),new THREE.MeshStandardMaterial({color:0xaaaaaa,metalness:.85,roughness:.3})); g.add(body); return g; };
const bottleGroup=()=>{ const g=new THREE.Group(); const body=new THREE.Mesh(new THREE.CylinderGeometry(0.065,0.048,0.21,8),new THREE.MeshStandardMaterial({color:0xaa7755,roughness:.3})); g.add(body); const cap=new THREE.Mesh(new THREE.CylinderGeometry(0.048,0.052,0.04,6),new THREE.MeshStandardMaterial({color:0xcc8844})); cap.position.y=0.12; g.add(cap); return g; };
function spawnBall(pos,vel,r,m,type='normal'){ let mesh; const scale= (type==='cannon'||type==='HE'||type==='AP')?0.7:1;
if(type==='HE') mesh=heShellGroup();
else if(type==='AP') mesh=apShellGroup();
else if(type==='cannon') mesh=cannonGroup();
else if(type==='molotov') mesh=bottleGroup();
else if(type==='normal'){ mesh=new THREE.Mesh(new THREE.SphereGeometry(r,24,24),originalBallMat); mesh.castShadow=true; }
else { mesh=new THREE.Mesh(new THREE.SphereGeometry(r,16,16),new THREE.MeshStandardMaterial({color:0xd4aa44})); mesh.castShadow=true; }
if(mesh){ mesh.scale.setScalar(mesh.scale.x*scale); mesh.position.copy(pos); scene.add(mesh); }
balls.push({pos:pos.clone(),vel:vel.clone(),r:r*(type==='molotov'?0.12:r),m,life:12,mesh,type,hitParts:new Set(), customMesh:!!mesh}); }
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'){ addImpulse(part,_tmp.copy(_d).multiplyScalar(vn*ball.m*0.4)); ball.vel.multiplyScalar(.55); if(vn>4) applyKO(3.0); return false; }
else if(ball.type==='AP'){ if(!ball.hitParts.has(part)){ ball.hitParts.add(part); const shotDir=ball.vel.clone().normalize(); addImpulse(part, shotDir.clone().multiplyScalar(vn*ball.m*0.9)); addImpulse(part, new THREE.Vector3(0, -vn*ball.m*0.2, 0)); spawnSparks(ball.pos.clone(),5,0x88ffaa,2,.3); } ball.vel.multiplyScalar(.98); if(ball.hitParts.size>4) applyKO(3.0); return false; }
else if(ball.type==='HE'){ return true; }
else if(ball.type==='cannon'){ const share=2.2*ball.m/(ball.m+part.m); addImpulse(part,_tmp.copy(_d).multiplyScalar(vn*share*0.9)); ball.vel.addScaledVector(_d,-vn*1.2); ball.pos.copy(part.pos).addScaledVector(_d,-(part.r+ball.r)); if(vn>7) applyKO(3.0); return false; }
else if(ball.type==='normal'){ addImpulse(part,_tmp.copy(_d).multiplyScalar(vn*0.35*ball.m)); ball.vel.multiplyScalar(0.82); return false; }
else if(ball.type==='molotov'){ return true; } return false; }
function doExplodeBall(k){ const b=balls[k];
if(b.type==='HE'){ flashPt.position.copy(b.pos); const hePower=parseFloat(document.getElementById('hePower').value); const t=hePower/100; const force=12+t*58; const radius=1.6+t*3; triggerFlash(1.2+t*1.4); explodeAt(b.pos,radius,force); spawnSparks(b.pos.clone(),26+Math.round(t*18),0xff5500,5.5,.85); spawnSmoke(b.pos.clone(),9+Math.round(t*8)); snapCameraToBody(); }
else if(b.type==='molotov'){ spawnSparks(b.pos.clone(),28,0xff8800,4.5,1.0); for(let i=0;i<55;i++) spawnFire(b.pos.clone(),1,0.9); spawnSmoke(b.pos.clone(),12); flashPt.position.copy(b.pos); flashPt.intensity=6; setTimeout(()=>flashPt.intensity=0,150); addFireZone(b.pos.clone(), 2.6, 10.0); if(b.pos.distanceTo(brain.com)<3.5) startFire(); }
scene.remove(b.mesh); balls.splice(k,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;} }
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); 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){ 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;}
if(b.customMesh && b.mesh){ b.mesh.position.copy(b.pos); if(b.type==='AP'||b.type==='HE'||b.type==='cannon'){ const direction=b.vel.clone().normalize(); if(direction.length()>0.01){ const quat=new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0,1,0),direction); b.mesh.quaternion.copy(quat); } } else if(b.type==='molotov') b.mesh.rotation.z+=0.08; }
else if(b.mesh) 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); for(let it=0;it<ITER;it++){ for(const c of cons)projectCon(c); collideSelf(); collideGround(); if(grab.active){
const part=parts[grab.pi];
// soft grab: move part towards target with limited delta and kill velocity to avoid spasming
let delta=_t.copy(grab.target).sub(part.pos);
const maxDelta=0.042;
if(delta.length()>maxDelta) delta.multiplyScalar(maxDelta/delta.length());
part.pos.add(delta.multiplyScalar(0.22));
// damp velocity heavily to prevent oscillation
part.prev.lerp(part.pos, 0.65);
} } stepBalls(h); } for(const p of parts)p.force.set(0,0,0); }
/* ========== SKELETON setup ========== */
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);
const matSuit=new THREE.MeshStandardMaterial({color:0x4b5563,roughness:.74}); const matSuit2=new THREE.MeshStandardMaterial({color:0x39414d,roughness:.8});
const matSkin=new THREE.MeshStandardMaterial({color:0xc99c7d,roughness:.55}); const matShoe=new THREE.MeshStandardMaterial({color:0x22262d});
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.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.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 (full) ========== */
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]$/,'');}
let sixtySevenActive=false, sixtySevenTimer=0;
let hustleActive=false, hustleTimer=0;
function applySixtySeven(dt){ if(!sixtySevenActive)return;
sixtySevenTimer+=dt;
const T=brain.targets, t=sixtySevenTimer;
const seesaw=Math.sin(t*Math.PI*2/1.1);
const sl=parts[P.shoulderL].pos, sr=parts[P.shoulderR].pos;
const chestY=parts[P.chest].pos.y;
const elbowFwd=0.20, handFwd=0.38;
const elbowY=chestY-0.10, handMidY=chestY+0.05, handTravel=0.18;
T.elbowR.copy(sr).addScaledVector(_right,.04).addScaledVector(_fwd,elbowFwd).setY(elbowY+seesaw*.06);
T.handR.copy(sr).addScaledVector(_right,.06).addScaledVector(_fwd,handFwd).setY(handMidY+seesaw*handTravel);
T.elbowL.copy(sl).addScaledVector(_right,-.04).addScaledVector(_fwd,elbowFwd).setY(elbowY-seesaw*.06);
T.handL.copy(sl).addScaledVector(_right,-.06).addScaledVector(_fwd,handFwd).setY(handMidY-seesaw*handTravel);
T.head.addScaledVector(_fwd,seesaw*.02);
}
function applyHustle(dt){ if(!hustleActive)return;
hustleTimer+=dt;
const T=brain.targets, t=hustleTimer, beat=130/60;
const armSwing=Math.sin(t*beat*Math.PI);
const sl=parts[P.shoulderL].pos, sr=parts[P.shoulderR].pos;
const hl=parts[P.hipL].pos, hr=parts[P.hipR].pos;
const armUpR=armSwing*.55, armFwdR=armSwing*.18;
T.elbowR.copy(sr).addScaledVector(_right,.22).addScaledVector(_fwd,armFwdR).setY(sr.y+armUpR*.5);
T.handR.copy(sr).addScaledVector(_right,.38).addScaledVector(_fwd,armFwdR*1.4).setY(sr.y+armUpR);
const armUpL=-armSwing*.55, armFwdL=-armSwing*.18;
T.elbowL.copy(sl).addScaledVector(_right,-.22).addScaledVector(_fwd,armFwdL).setY(sl.y+armUpL*.5);
T.handL.copy(sl).addScaledVector(_right,-.38).addScaledVector(_fwd,armFwdL*1.4).setY(sl.y+armUpL);
const kneeHeightR=Math.max(0,armSwing)*.38, kneeHeightL=Math.max(0,-armSwing)*.38;
T.kneeR.copy(hr).addScaledVector(_right,.13).setY(hr.y-.28+kneeHeightR);
T.footR.copy(hr).addScaledVector(_right,.13).setY(Math.max(.05,kneeHeightR*.4));
T.toeR.copy(T.footR).addScaledVector(_fwd,.13).setY(T.footR.y);
T.kneeL.copy(hl).addScaledVector(_right,-.13).setY(hl.y-.28+kneeHeightL);
T.footL.copy(hl).addScaledVector(_right,-.13).setY(Math.max(.05,kneeHeightL*.4));
T.toeL.copy(T.footL).addScaledVector(_fwd,.13).setY(T.footL.y);
const sway=Math.sin(t*beat*Math.PI*2)*.06, bob=Math.abs(Math.sin(t*beat*Math.PI))*.05;
T.pelvis.addScaledVector(_right,sway).y+=bob;
T.chest.addScaledVector(_right,sway*.6).y+=bob;
T.head.addScaledVector(_right,sway*.4).y+=bob*.5;
}
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&&!isAirborne())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&&!isAirborne())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&&!isAirborne())setState('down'); }
else if(B.state==='fire'){ B.strength=1.4; }
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; }
if(fireState.active) applyFireDance(dt);
if(sixtySevenActive && (B.state==='balance'||B.state==='fire')) applySixtySeven(dt);
else if(hustleActive && (B.state==='balance'||B.state==='fire')) applyHustle(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 & UI ========== */
let currentWeapon='ball',currentAmmo='normal',isFiring=false,gunFireCooldown=0,ballFiredThisClick=false;
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'; document.getElementById('he-options').style.display=(w==='cannon' && currentAmmo==='HE')?'block':'none'; }
function selectAmmo(a){ currentAmmo=a; ['Normal','HE','AP'].forEach(id=>document.getElementById('am'+id).classList.toggle('active-ammo',id.toLowerCase()===a)); document.getElementById('he-options').style.display=(currentWeapon==='cannon' && a==='HE')?'block':'none'; }
document.getElementById('c4Power').addEventListener('input',function(){ document.getElementById('c4PowerVal').textContent=this.value; });
document.getElementById('hePower').addEventListener('input',function(){ document.getElementById('hePowerVal').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(70),.035,0.7,'gun'); flashPt.position.copy(origin); flashPt.intensity=7; setTimeout(()=>flashPt.intensity=0,35); spawnSparks(origin.clone(),4,0xffdd66,3.5,.2); }
else if(currentWeapon==='cannon'){ let speed,radius,mass,type; if(currentAmmo==='HE'){ speed=28;radius=.17;mass=11;type='HE';} else if(currentAmmo==='AP'){ speed=40;radius=.11;mass=7;type='AP';} else{ speed=24;radius=.14;mass=9;type='cannon';} const spawnPt=_fireRay.ray.origin.clone().addScaledVector(dir,.85); spawnBall(spawnPt,dir.multiplyScalar(speed),radius,mass,type); triggerFlash(1); spawnSparks(spawnPt.clone(),8,0xffaa22,2.8,.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'){ const throwVel=dir.clone().multiplyScalar(10.5); throwVel.y+=5.2; spawnBall(origin,throwVel,.12,2.2,'molotov'); } }
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};
let indieMouseDrag=false, lastMouseX=0, lastMouseY=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(camMode==='independent'){ indieMouseDrag=true; lastMouseX=e.clientX; lastMouseY=e.clientY; dragMode='orbit'; }
if(currentWeapon==='ball' && !ballFiredThisClick){ ballFiredThisClick=true; fireWeapon(_liveNDC.x,_liveNDC.y); }
else if(currentWeapon==='gun'){ fireWeapon(_liveNDC.x,_liveNDC.y); isFiring=false; }
else if(currentWeapon==='cannon'){ isFiring=true; gunFireCooldown=0; fireWeapon(_liveNDC.x,_liveNDC.y); }
else if(currentWeapon!=='ball' && currentWeapon!=='gun'){ 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)) && !grab.active){ dragMode='orbit'; isFiring=false;
if(camMode==='fp'){fp.yaw-=dx*.004;fp.pitch+=dy*.004;}
else if(camMode==='independent'){ indie.yaw-=dx*.008; indie.pitch=Math.max(-1.2,Math.min(1.2,indie.pitch+dy*.008)); }
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!=='ball' && currentWeapon!=='gun' && currentWeapon!=='cannon') fireWeapon(_liveNDC.x,_liveNDC.y); } grab.active=false; dragMode=null; isFiring=false; ballFiredThisClick=false; });
renderer.domElement.addEventListener('wheel',e=>{ e.preventDefault(); if(camMode==='independent'){ const zoomDir=new THREE.Vector3(0,0,e.deltaY>0?0.6:-0.6).applyQuaternion(camera.quaternion); indie.pos.add(zoomDir); } else orbit.dist*=(e.deltaY>0?1.1:.9); updateCamera();},{passive:false}); addEventListener('contextmenu',e=>e.preventDefault());
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)*7.5,2.2,Math.sin(a)*7.5); addImpulse(parts[P.chest],_t); addImpulse(parts[P.pelvis],_t2.copy(_t).multiplyScalar(.7)); };
document.getElementById('bKO').onclick=()=>applyKO(3.0);
let slow=false; document.getElementById('bSlow').onclick=function(){slow=!slow;this.classList.toggle('on',slow);};
document.getElementById('b67').onclick=function(){ sixtySevenActive=!sixtySevenActive; sixtySevenTimer=0;
if(sixtySevenActive){ hustleActive=false; hustleTimer=0; document.getElementById('bHustle').classList.remove('sel'); }
this.classList.toggle('sel',sixtySevenActive); };
document.getElementById('bHustle').onclick=function(){ hustleActive=!hustleActive; hustleTimer=0;
if(hustleActive){ sixtySevenActive=false; sixtySevenTimer=0; document.getElementById('b67').classList.remove('sel'); }
this.classList.toggle('sel',hustleActive); };
document.getElementById('bAddSticky').onclick=()=>addStickyC4();
document.getElementById('bDetonateSticky').onclick=()=>detonateStickyC4();
function doReset(){ 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 stickyC4List)scene.remove(s.mesh); stickyC4List.length=0; for(const s of sparks)scene.remove(s.mesh); sparks.length=0; fireZones.length=0; fireState.active=false; fireState.danceTimer=0; sixtySevenActive=false; hustleActive=false; sixtySevenTimer=0; hustleTimer=0; document.getElementById('b67').classList.remove('sel'); document.getElementById('bHustle').classList.remove('sel'); brain.root.set(0,0,0); brain.comPrev.set(0,1,0); brain.step=null; setState('balance'); brain.strength=1; forcedGetupTimer=0; orbit.target.set(0,1,0); indie.pos.set(2.2, 1.8, 3.5); indie.yaw=0.65; indie.pitch=-0.12; updateCamera(); }
document.getElementById('bReset').onclick=doReset;
document.getElementById('bDetonate').onclick=detonateC4;
document.getElementById('bClearC4').onclick=()=>{for(const c of c4List)scene.remove(c.mesh);c4List.length=0;};
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==='cannon'){ gunFireCooldown-=dt; if(gunFireCooldown<=0){ gunFireCooldown=0.5; 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); stepStickyC4(dt); stepFlash(dt); stepFireZones(dt); updateKOGetup(dt);
emitBodyFlames(dt);
if(camMode==='follow'){ const cp=Math.cos(orbit.pitch); const ft=brain.com; camera.position.set(ft.x+Math.sin(orbit.yaw)*cp*orbit.dist, ft.y+Math.sin(orbit.pitch)*orbit.dist, ft.z+Math.cos(orbit.yaw)*cp*orbit.dist); camera.lookAt(ft); }
else if(camMode==='orbit'){ const cp=Math.cos(orbit.pitch); orbit.target.lerp(brain.com,0.08); 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); }
else updateCamera();
syncVisuals(); syncBadge(); renderer.render(scene,camera); }
orbit.target.copy(brain.com);
animate();
</script>
</body>
</html>