581 lines
25 KiB
HTML
581 lines
25 KiB
HTML
<!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 · <b>drag space</b> to orbit · <b>scroll</b> zoom · <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>
|