HEXAPOD_WALKER

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/d5efadba0eca72df/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HEXAPOD</title>
<style>
  :root{--ink:#111;--mut:#8c8c8c;--line:#e7e7e7}
  html,body{margin:0;height:100%;background:#f4f4f4;overflow:hidden;color:var(--ink);
    font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif}
  canvas{display:block;touch-action:none}
  #title{position:fixed;top:18px;left:20px;font-size:12px;letter-spacing:.28em;font-weight:600}
  #title span{display:block;margin-top:4px;font-weight:400;letter-spacing:.08em;color:var(--mut);font-size:10px}
  #keys{position:fixed;left:20px;bottom:16px;font-size:10px;letter-spacing:.06em;color:var(--mut);line-height:1.8}
  #keys b{color:var(--ink);font-weight:600;margin-right:2px}
  #panel{position:fixed;top:16px;right:16px;width:272px;max-height:calc(100% - 32px);overflow:auto;
    background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px;
    box-shadow:0 6px 28px rgba(0,0,0,.05);font-size:10px;letter-spacing:.08em;text-transform:uppercase}
  .lab{display:flex;justify-content:space-between;margin-bottom:8px;color:var(--mut)}
  .lab b{color:var(--ink);font-weight:600}
  .ends{display:flex;justify-content:space-between;margin-top:6px;color:var(--ink);font-weight:600}
  input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:16px;background:transparent;margin:0;cursor:pointer}
  input[type=range]::-webkit-slider-runnable-track{height:2px;background:#dcdcdc;border-radius:2px}
  input[type=range]::-moz-range-track{height:2px;background:#dcdcdc;border-radius:2px}
  input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:#111;margin-top:-5px;border:0}
  input[type=range]::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#111;border:0}
  .btns{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:18px 0 14px}
  button{font:inherit;font-size:9px;letter-spacing:.06em;text-transform:uppercase;background:#fff;color:var(--ink);
    border:1px solid #d6d6d6;border-radius:6px;padding:8px 0;cursor:pointer}
  button:hover{border-color:#111}
  button.on{background:#111;color:#fff;border-color:#111}
  .sw{display:flex;justify-content:space-between;align-items:center;cursor:pointer;margin:0 0 12px;
    padding:10px 0 0;border-top:1px solid var(--line)}
  .sw span{color:var(--ink)}
  .sw small{display:block;margin-top:3px;font-size:9px;color:var(--mut);letter-spacing:.06em}
  .sw input{position:absolute;opacity:0;pointer-events:none}
  .sw i{position:relative;flex:none;width:30px;height:16px;border-radius:9px;border:1px solid #cfcfcf;background:#fff;transition:background .2s,border-color .2s}
  .sw i::after{content:"";position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:#111;transition:transform .2s,background .2s}
  .sw input:checked + i{background:#111;border-color:#111}
  .sw input:checked + i::after{transform:translateX(14px);background:#fff}
  .modes{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:0 0 16px;padding-bottom:16px;border-bottom:1px solid var(--line);transition:opacity .2s}
  .modes.off{opacity:.4}
  .modes button{padding:7px 0;font-size:8.5px;letter-spacing:.04em}
  details{padding-top:2px}
  summary{cursor:pointer;color:var(--mut);margin-bottom:10px;list-style:none}
  summary::-webkit-details-marker{display:none}
  .row{margin-bottom:10px}
  @media (max-width:640px){#panel{width:220px}#keys{display:none}}
</style>
</head>
<body>
<div id="title">HEXAPOD<span>Gang-Prototyp</span></div>
<div id="keys">
  <b>W A S D</b> laufen (relativ zur Kamera) &nbsp; <b>Shift</b> rennen &nbsp; <b>Leertaste</b> seitwärts (Blickrichtung halten)<br>
  <b>C</b> ducken &nbsp; <b>1</b> protect &nbsp; <b>2</b> aufstellen · shiver &nbsp; <b>G</b> Begleiter an/aus &nbsp; <b>Maus ziehen</b> Kamera drehen &nbsp; <b>Rad</b> Zoom
</div>
<div id="panel">
  <div class="lab"><span>Charakter</span><b id="cv"></b></div>
  <input id="ch" type="range" min="0" max="1" step="0.01" value="0.5">
  <div class="ends"><span>Niedlich</span><span>Spinne</span></div>
  <div class="btns">
    <button id="bDuck">Ducken · C</button>
    <button id="bSide">Seitwärts · ␣</button>
    <button id="bProt">Protect · 1</button>
    <button id="bRise">Aufstellen · 2</button>
  </div>
  <label class="sw"><div><span>Begleiter</span><small>3 kleine Hexapods · G</small></div><input type="checkbox" id="swF"><i></i></label>
  <div class="modes off" id="modes">
    <button data-m="shy">Schüchtern</button>
    <button data-m="bold">Forsch</button>
    <button data-m="wild">Übermütig</button>
    <button data-m="ign">Ignorant</button>
    <button data-m="mix" class="on">Gemischt</button>
  </div>
  <details open><summary>Feintuning</summary><div id="rows"></div></details>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
const clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
const lerp=(a,b,t)=>a+(b-a)*t;
const rr=(a,b)=>a+Math.random()*(b-a);
const sm=t=>t*t*(3-2*t);
const wrapA=a=>Math.atan2(Math.sin(a),Math.cos(a));
const V3=THREE.Vector3;

/* ---------- Parameter: niedlich (a) → spinne (b) ---------- */
const DEFS=[
  {k:'speed',  l:'Tempo',                    min:.4, max:5.5, st:.05, a:.8,  b:3.8},
  {k:'stride', l:'Schrittlänge',             min:.1, max:.6,  st:.01, a:.17, b:.42},
  {k:'lift',   l:'Schritthöhe',              min:.01,max:.2,  st:.01, a:.11, b:.045},
  {k:'height', l:'Körperhöhe',               min:.14,max:.55, st:.01, a:.31, b:.42},
  {k:'spread', l:'Beinspreizung',            min:.8, max:1.5, st:.01, a:.9,  b:1.25},
  {k:'gait',   l:'Gangart · Tripod → Welle', min:0,  max:1,   st:.01, a:0,   b:.35},
  {k:'clumsy', l:'Tapsigkeit',               min:0,  max:1,   st:.01, a:.85, b:.05},
  {k:'agility',l:'Reaktion · träge → direkt',min:2,  max:30,  st:.5,  a:3,   b:22},
  {k:'turn',   l:'Wendigkeit',               min:.8, max:8,   st:.1,  a:1.5, b:6},
  {k:'burst',  l:'Stop-and-Go',              min:0,  max:1,   st:.01, a:0,   b:.55},
  {k:'side',   l:'Seitwärts-Schübe',         min:0,  max:1,   st:.01, a:.05, b:.55},
  {k:'bob',    l:'Wippen',                   min:0,  max:.05, st:.001,a:.008,b:.004},
  {k:'lean',   l:'Körperneigung',            min:0,  max:1,   st:.01, a:.8,  b:.2},
  {k:'head',   l:'Vorderkörper-Zucken',      min:0,  max:1,   st:.01, a:.2,  b:.9},
  {k:'twitch', l:'Bein-Zucken im Stand',     min:0,  max:1,   st:.01, a:.15, b:.8}
];
const P={};
const sliders={};
const rowsEl=document.getElementById('rows');
DEFS.forEach(d=>{
  const row=document.createElement('div');row.className='row';
  row.innerHTML='<div class="lab"><span>'+d.l+'</span><b></b></div><input type="range" min="'+d.min+'" max="'+d.max+'" step="'+d.st+'">';
  rowsEl.appendChild(row);
  const inp=row.querySelector('input'), out=row.querySelector('b');
  const show=()=>{out.textContent=(+P[d.k]).toFixed(d.st<.01?3:2)};
  inp.addEventListener('input',()=>{P[d.k]=+inp.value;show()});
  inp.addEventListener('pointerup',()=>inp.blur());
  sliders[d.k]={inp,show};
});
const chEl=document.getElementById('ch'), cvEl=document.getElementById('cv');
function applyCharacter(c){
  DEFS.forEach(d=>{P[d.k]=lerp(d.a,d.b,c);sliders[d.k].inp.value=P[d.k];sliders[d.k].show()});
  cvEl.textContent=Math.round(c*100)+' % Spinne';
}
chEl.addEventListener('input',()=>applyCharacter(+chEl.value));
chEl.addEventListener('pointerup',()=>chEl.blur());
applyCharacter(.5);

/* ---------- Szene ---------- */
const BG=0xf4f4f4;
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;
document.body.appendChild(renderer.domElement);
const scene=new THREE.Scene();
scene.background=new THREE.Color(BG);
scene.fog=new THREE.Fog(BG,10,34);
const camera=new THREE.PerspectiveCamera(40,innerWidth/innerHeight,.05,100);

scene.add(new THREE.HemisphereLight(0xffffff,0xbdbdbd,.58));
const sun=new THREE.DirectionalLight(0xffffff,.6);
sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
const sc=sun.shadow.camera; sc.left=-5;sc.right=5;sc.top=5;sc.bottom=-5;sc.near=1;sc.far=18;
sun.shadow.bias=-.0005;
scene.add(sun);scene.add(sun.target);
const rim=new THREE.DirectionalLight(0xffffff,.3);
rim.position.set(-3,2,-4);scene.add(rim);

const floor=new THREE.Mesh(new THREE.PlaneGeometry(200,200),new THREE.MeshStandardMaterial({color:0xe4e4e4,roughness:1,metalness:0}));
floor.rotation.x=-Math.PI/2;floor.receiveShadow=true;scene.add(floor);
const grid=new THREE.GridHelper(40,80,0xd2d2d2,0xd8d8d8);
grid.position.y=.002;scene.add(grid);

/* ---------- Normal Map aus feinem, kachelbarem Rauschen ---------- */
function makeNormalTexture(size){
  const h=new Float32Array(size*size);
  [[24,.3],[48,.3],[96,.24],[192,.16]].forEach(o=>{
    const n=o[0],w=o[1],g=new Float32Array(n*n);
    for(let i=0;i<g.length;i++)g[i]=Math.random();
    for(let y=0;y<size;y++){
      const v=y/size*n,j=Math.floor(v),fy=sm(v-j),j0=j%n,j1=(j+1)%n;
      for(let x=0;x<size;x++){
        const u=x/size*n,i=Math.floor(u),fx=sm(u-i),i0=i%n,i1=(i+1)%n;
        const a=g[j0*n+i0],b=g[j0*n+i1],c=g[j1*n+i0],d=g[j1*n+i1];
        h[y*size+x]+=w*lerp(lerp(a,b,fx),lerp(c,d,fx),fy);
      }
    }
  });
  const cv=document.createElement('canvas');cv.width=cv.height=size;
  const ctx=cv.getContext('2d'),img=ctx.createImageData(size,size),S=10;
  for(let y=0;y<size;y++)for(let x=0;x<size;x++){
    const xp=(x+1)%size,xm=(x-1+size)%size,yp=(y+1)%size,ym=(y-1+size)%size;
    let nx=-(h[y*size+xp]-h[y*size+xm])*S,ny=-(h[yp*size+x]-h[ym*size+x])*S,nz=1;
    const l=Math.hypot(nx,ny,nz);nx/=l;ny/=l;nz/=l;
    const p=(y*size+x)*4;
    img.data[p]=(nx*.5+.5)*255;img.data[p+1]=(ny*.5+.5)*255;img.data[p+2]=(nz*.5+.5)*255;img.data[p+3]=255;
  }
  ctx.putImageData(img,0,0);
  const t=new THREE.CanvasTexture(cv);
  t.wrapS=t.wrapT=THREE.RepeatWrapping;
  t.repeat.set(10,10);
  t.anisotropy=8;
  return t;
}
const nTex=makeNormalTexture(512);

/* ---------- Materialien (von allen Hexapods geteilt) ---------- */
const matB=new THREE.MeshStandardMaterial({color:0x1b1b1b,roughness:.78,metalness:.15,normalMap:nTex,normalScale:new THREE.Vector2(.4,.4)});
const matW=new THREE.MeshStandardMaterial({color:0xe9e9e9,roughness:.55,metalness:.05,normalMap:nTex,normalScale:new THREE.Vector2(.25,.25)});
function mesh(g,m){const x=new THREE.Mesh(g,m);x.castShadow=true;x.receiveShadow=true;return x}
function ico(r,det,sx,sy,sz,m){const q=mesh(new THREE.IcosahedronGeometry(r,det),m);q.scale.set(sx,sy,sz);return q}

function lathePts(n,len,rf,closeStart,closeEnd){
  const p=[];
  if(closeStart)p.push(new THREE.Vector2(.0008,0));
  for(let i=0;i<=n;i++){const t=i/n;p.push(new THREE.Vector2(Math.max(rf(t),.0008),t*len))}
  if(closeEnd)p.push(new THREE.Vector2(.0008,len));
  return p;
}
function segGeo(L,rf,n,closeEnd){
  const g=new THREE.LatheGeometry(lathePts(n,L,rf,true,closeEnd),12);
  g.rotateZ(-Math.PI/2);return g;
}
function bodyGeo(L,rf,n,seg){
  const g=new THREE.LatheGeometry(lathePts(n,L,rf,false,false),seg);
  g.rotateX(Math.PI/2);return g;
}

/* =====================================================================
   HEXAPOD-FABRIK  (K = Größenfaktor, isMain = Hauptfigur)
   ===================================================================== */
const FOOT_Y0=.02, RN=.45;
const tmp=new V3(), RT=new V3();

/* Protect-Pose pro Bein (Reihe 0 vorn, 1 Mitte, 2 hinten):
   Beine liegen gestapelt an den Flanken, Schienbein zurückgeklappt */
const TUCK_ROW=[
  {fb: 1, s:.40, f:1.10, bR:2.95, bL:3.28},
  {fb: 0, s:.00, f:1.20, bR:2.95, bL:3.28},
  {fb:-1, s:.40, f:1.10, bR:2.95, bL:3.28}
];

function makeHexapod(K,isMain){
  const robot=new THREE.Group();
  robot.rotation.order='YXZ';
  robot.scale.setScalar(K);
  scene.add(robot);

  // Vorderkörper
  const THL=.25;
  const front=new THREE.Group();front.position.set(0,0,-.05);robot.add(front);
  {
    const g=bodyGeo(THL,t=>.125*Math.pow(Math.sin(Math.PI*Math.pow(t,.8)),.5)*(1+.015*Math.sin(t*Math.PI*9)),48,24);
    g.translate(0,0,-THL/2);
    const th=mesh(g,matB);th.scale.set(.95,.82,1);front.add(th);
  }
  const saddle=ico(.09,2,.75,.2,1.3,matW);saddle.position.set(0,.092,.01);front.add(saddle);
  [[.04,.03,-.105,.014],[-.04,.03,-.105,.014],[.018,.052,-.095,.009],[-.018,.052,-.095,.009]].forEach(e=>{
    const eye=ico(e[3],2,1,1,1,matW);eye.position.set(e[0],e[1],e[2]);front.add(eye);
  });

  // Hinterleib
  const ABL=.27;
  const abdG=new THREE.Group();abdG.position.set(0,.03,-.005);robot.add(abdG);
  {
    const g=bodyGeo(ABL,t=>.17*Math.pow(Math.sin(Math.PI*Math.pow(t,.8)),.6)*(1+.022*Math.cos(t*Math.PI*2*4.5)),72,28);
    const ab=mesh(g,matB);ab.scale.set(1,.92,1);ab.rotation.x=-.1;ab.position.y=.01;abdG.add(ab);
  }

  // Beine
  const C=.07,L1=.3,L2=.5;
  const femR=t=>(.05-.012*t+.012*Math.sin(Math.PI*t))*(1+.045*Math.sin(t*Math.PI*8));
  const tibR=t=>(.04-.026*Math.pow(t,.8))*(1+.05*Math.sin(t*Math.PI*10));
  function buildLeg(){
    const hipG=new THREE.Group();
    hipG.add(ico(.052,2,1,1,1,matW));
    hipG.add(mesh(segGeo(C+.02,t=>.042*(1+.06*Math.sin(t*Math.PI*3)),10,false),matB));
    const fem=new THREE.Group();fem.position.x=C;hipG.add(fem);
    fem.add(mesh(segGeo(L1,femR,24,false),matB));
    const plate=ico(.06,2,2.3,.2,.7,matW);plate.position.set(L1*.5,.052,0);fem.add(plate);
    const tib=new THREE.Group();tib.position.x=L1;fem.add(tib);
    tib.add(ico(.046,2,1,1,1,matB));
    [-1,1].forEach(sd=>{
      const disc=mesh(new THREE.CylinderGeometry(.04,.04,.008,16),matB);
      disc.rotation.x=Math.PI/2;disc.position.z=sd*.037;tib.add(disc);
    });
    tib.add(mesh(segGeo(L2,tibR,28,true),matB));
    const foot=ico(.019,2,1,1,1,matW);foot.position.x=L2;tib.add(foot);
    return {hipG,fem,tib};
  }
  const rowZ=[-.14,-.05,.04], alpha=[.75,.15,-.55];
  const offTri=[0,.5,.5,0,0,.5], offWave=[5/6,2/6,4/6,1/6,3/6,0];
  const legs=[];
  for(let i=0;i<6;i++){
    const sx=i%2===0?-1:1,row=i>>1;
    const hp=new V3(sx*.105,-.02,rowZ[row]);
    const dir=new V3(sx*Math.cos(alpha[row]),0,-Math.sin(alpha[row]));
    const L=buildLeg();L.hipG.position.copy(hp);robot.add(L.hipG);
    const sh=ico(.042,1,1.2,.5,1.4,matW);sh.position.set(hp.x,hp.y+.05,hp.z);sh.rotation.z=sx*.25;robot.add(sh);
    // Protect-Zielwinkel
    const T=TUCK_ROW[row];
    const tuck={
      yaw:Math.atan2(T.fb*Math.sin(T.s),sx*Math.cos(T.s)),
      f:T.f,
      t:(sx>0?T.bR:T.bL)-T.f
    };
    Object.assign(L,{hp,dir,sx,tuck,phase:0,swinging:false,foot:new V3(),P:new V3(),Q:new V3(),T:new V3(),
      noise:new THREE.Vector2(),liftMul:1,tw:0,twT:rr(0,2)});
    legs.push(L);
  }

  /* ----- Zustand ----- */
  const pos=new V3(0,0,0);
  const vel={x:0,z:0};
  let yaw=0,om=0,phiC=0,gate=1,gateRun=true,gateT=.3,duckA=0,protectA=0,riseA=0,by=.3*K,pitch=0,roll=0,wasMoving=false;
  let headT=1,headTarget=0;
  let strafe=false,strafeManual=false,strafeT=2,lockYaw=0;
  const FOOT_Y=FOOT_Y0*K, fMax=isMain?9:12;

  function resetFeet(){
    const Rn=RN*P.spread,c=Math.cos(yaw),s=Math.sin(yaw);
    legs.forEach(L=>{
      const nx=(L.hp.x+L.dir.x*Rn)*K,nz=(L.hp.z+L.dir.z*Rn)*K;
      L.P.set(pos.x+nx*c+nz*s,FOOT_Y,pos.z-nx*s+nz*c);
      L.foot.copy(L.P);L.phase=0;L.swinging=false;
    });
  }
  function place(x,z,yw){
    pos.set(x,0,z);yaw=yw;vel.x=vel.z=0;om=0;phiC=0;wasMoving=false;
    gate=1;gateRun=true;gateT=.3;strafe=false;strafeManual=false;pitch=roll=0;
    protectA=riseA=0;
    by=P.height*K;
    robot.position.set(pos.x,by,pos.z);robot.rotation.set(0,yaw,0);robot.updateMatrixWorld(true);
    resetFeet();
  }
  resetFeet();

  /* ----- Schritt ----- */
  function step(dt,time,inp){
    const protectIn=!!inp.protect, riseIn=!!inp.rise&&!protectIn;
    const lock=protectIn||riseIn;
    duckA+=((inp.duck?1:0)-duckA)*(1-Math.exp(-7*dt));
    protectA+=((protectIn?1:0)-protectA)*(1-Math.exp(-9*dt));
    riseA+=((riseIn?1:0)-riseA)*(1-Math.exp(-5*dt));
    const pa=sm(clamp(protectA,0,1)), ra=sm(clamp(riseA,0,1));

    const has=lock?false:inp.has,ix=inp.ix,iz=inp.iz;
    const slow=1-.55*duckA;
    const spd=inp.spd*slow;

    // Seitwärtslaufen (nur Hauptfigur)
    if(!inp.allowStrafe){strafe=false;strafeManual=false}
    else if(!has){strafe=false;strafeManual=false;strafeT=rr(1,3)}
    else{
      if(inp.side&&!strafe){strafe=true;strafeManual=true;lockYaw=yaw}
      if(!inp.side&&strafeManual){strafe=false;strafeManual=false;strafeT=rr(1,3)}
      if(!strafeManual){
        strafeT-=dt;
        if(strafeT<=0){
          if(strafe){strafe=false;strafeT=rr(1.5,4)/(.3+P.side)}
          else if(Math.random()<P.side){strafe=true;lockYaw=yaw;strafeT=rr(.6,1.5)}
          else strafeT=rr(1,3)/(.3+P.side);
        }
      }
    }

    // Ausrichtung
    let dyaw=0,mult=1,omT=0;
    if(has){
      if(strafe){dyaw=wrapA(lockYaw-yaw);mult=.85}
      else{
        dyaw=wrapA(Math.atan2(-ix,-iz)-yaw);
        const align=.3+.7*Math.max(0,Math.cos(dyaw));
        mult=lerp(1,align,clamp(1.2-P.turn/6,0,1));
      }
      omT=clamp(dyaw*4,-P.turn,P.turn);
    }else if(inp.face&&!lock){
      dyaw=wrapA(Math.atan2(-inp.fx,-inp.fz)-yaw);
      omT=clamp(dyaw*1.5,-.8,.8);
    }
    const rate=1-Math.exp(-P.agility*dt);
    vel.x+=((has?ix*spd*mult:0)-vel.x)*rate;
    vel.z+=((has?iz*spd*mult:0)-vel.z)*rate;
    om+=(omT-om)*(1-Math.exp(-Math.min(P.agility*1.5,20)*dt));

    // Stop-and-Go
    if(has&&P.burst>.02){
      gateT-=dt;
      if(gateT<=0){
        gateRun=!gateRun;
        gateT=gateRun?lerp(3.5,.35,P.burst)*rr(.6,1.4):lerp(0,.4,P.burst)*rr(.6,1.4);
      }
    }else{gateRun=true;gateT=.25}
    gate+=((gateRun?1:0)-gate)*(1-Math.exp(-25*dt));

    const gvx=vel.x*gate,gvz=vel.z*gate,gom=om*gate;
    const speedEff=Math.hypot(vel.x,vel.z)+Math.abs(om)*.12;
    const moving=has||speedEff>.12;
    const duty=lerp(.55,.8,P.gait);
    const fMove=clamp(speedEff*duty/(P.stride*K),.6,fMax);
    const fIdle=(1-duty)/.3;
    const fu=moving?fMove:fIdle;

    pos.x+=gvx*dt;pos.z+=gvz*dt;yaw+=gom*dt;
    const cy=Math.cos(yaw),sy=Math.sin(yaw);

    if(moving){if(!wasMoving)phiC=0;phiC+=fMove*dt*gate}
    wasMoving=moving;

    // Höhe (Ducken → Protect → Aufstellen)
    let hT=lerp(P.height,.1,duckA);
    hT=lerp(hT,.105,pa);
    hT=lerp(hT,.36,ra);
    hT*=K;
    by+=(hT-by)*(1-Math.exp(-8*dt));
    const mv=clamp(speedEff/(.4*K),0,1);
    const calm=(1-pa)*(1-ra);
    const breath=Math.sin(time*1.7)*(.003+P.bob*.15)*(1-.7*mv)*K*(1-pa);
    const bobY=Math.cos(phiC*Math.PI*4)*P.bob*mv*(1-duckA)*K*calm;

    // Neigung
    const vF=-gvx*sy-gvz*cy;
    const pLoco=clamp(-vF*.05*P.lean/K+Math.sin(phiC*Math.PI*4+1)*P.clumsy*.035*mv,-.3,.3);
    const pT=pLoco*(1-ra)+ra*.78;
    const rT=(gom*.05*P.lean+Math.sin(phiC*Math.PI*2)*P.clumsy*.15*mv+Math.sin(time*1.1)*.035*P.clumsy*(1-mv))*calm;
    pitch+=(pT-pitch)*(1-Math.exp(-(ra>.01?5:8)*dt));roll+=(rT-roll)*(1-Math.exp(-8*dt));
    const waddle=Math.sin(phiC*Math.PI*2+.6)*P.clumsy*.1*mv*calm;
    const sh=ra*ra;
    const trP=Math.sin(time*37)*.012*sh, trR=Math.sin(time*43+2)*.016*sh;

    robot.position.set(pos.x,by+bobY+breath,pos.z);
    robot.rotation.set(pitch+trP,yaw+waddle,roll+trR);
    robot.updateMatrixWorld(true);

    // Vorderkörper-Zucken, Hinterleib-Schwingen
    headT-=dt;
    if(headT<=0){headT=rr(.5,1.6)*lerp(2.5,.6,P.head);headTarget=(Math.random()-.5)*2*.4*(.4+.6*P.head)}
    front.rotation.y+=(headTarget*(1-pa)+gom*.1-front.rotation.y)*(1-Math.exp(-lerp(2.5,18,P.head)*dt));
    abdG.rotation.y=-waddle*1.4+Math.sin(time*1.3)*.03*(1-pa);
    abdG.rotation.x=Math.sin(time*1.7+1)*.02+pitch*-.5;

    // Beine
    const Rn=RN*P.spread;
    let swingCount=0;legs.forEach(L=>{if(L.swinging)swingCount++});
    legs.forEach((L,i)=>{
      const nx=(L.hp.x+L.dir.x*Rn)*K,nz=(L.hp.z+L.dir.z*Rn)*K;
      const Nwx=pos.x+nx*cy+nz*sy,Nwz=pos.z-nx*sy+nz*cy;

      if(moving){
        const off=lerp(offTri[i],offWave[i],P.gait);
        L.phase=(((phiC+off)%1)+1)%1;
      }else if(L.swinging){
        L.phase+=fIdle*dt;if(L.phase>=1)L.phase=0;
      }else{
        const d=Math.hypot(L.P.x-Nwx,L.P.z-Nwz);
        if(d>.04*K&&swingCount<2&&!lock){L.phase=duty;swingCount++}
      }

      const inSwing=L.phase>=duty;
      if(inSwing&&!L.swinging){
        L.swinging=true;L.Q.copy(L.foot);
        L.noise.set((Math.random()-.5)*2*P.clumsy*.08*K,(Math.random()-.5)*2*P.clumsy*.08*K);
        L.liftMul=1+(Math.random()-.5)*P.clumsy;
      }else if(!inSwing&&L.swinging){
        L.swinging=false;
        L.P.set(L.T.x,FOOT_Y,L.T.z);
      }

      if(inSwing){
        const t=(L.phase-duty)/(1-duty);
        const rem=((1-t)*(1-duty)+duty*.5)/fu;
        const lx=pos.x+gvx*rem,lz=pos.z+gvz*rem,lyw=yaw+gom*rem;
        const c=Math.cos(lyw),s=Math.sin(lyw);
        L.T.set(lx+nx*c+nz*s+L.noise.x,FOOT_Y,lz-nx*s+nz*c+L.noise.y);
        const e=sm(t);
        L.foot.set(lerp(L.Q.x,L.T.x,e),FOOT_Y+P.lift*K*L.liftMul*Math.sin(Math.PI*t),lerp(L.Q.z,L.T.z,e));
      }else{
        L.foot.set(L.P.x,FOOT_Y,L.P.z);
      }

      // Zucken
      L.twT-=dt;
      if(L.twT<=0){L.twT=rr(.6,3)/(.2+P.twitch);if(Math.random()<P.twitch)L.tw=1}
      L.tw*=Math.exp(-9*dt);
      if(L.tw>.01){
        const a=L.tw*P.twitch*(moving?.25:1)*K*(1-ra);
        L.foot.y+=a*.05;L.foot.x+=a*.02*L.dir.x;L.foot.z+=a*.02*L.dir.z;
      }

      // IK im (unskalierten) Körper-Koordinatensystem
      tmp.copy(L.foot);robot.worldToLocal(tmp);
      // Aufstellen: beide Vorderbeine zitternd nach oben/vorn
      if(ra>.001&&i<2){
        RT.set(L.hp.x+L.sx*.20+Math.sin(time*41+i*1.9)*.014*sh,
               L.hp.y+.30+Math.sin(time*47+i*2.7)*.016*sh,
               L.hp.z-.40+Math.sin(time*37+i*.9)*.014*sh);
        tmp.lerp(RT,ra);
      }
      const dx=tmp.x-L.hp.x,dz=tmp.z-L.hp.z,dy=tmp.y-L.hp.y;
      let hy=Math.atan2(-dz,dx);
      const X=Math.hypot(dx,dz)-C,Y=dy;
      const D=clamp(Math.hypot(X,Y),Math.abs(L1-L2)+.01,L1+L2-.005);
      const cA=clamp((L1*L1+D*D-L2*L2)/(2*L1*D),-1,1);
      const cK=clamp((L1*L1+L2*L2-D*D)/(2*L1*L2),-1,1);
      let fz=Math.atan2(Y,X)+Math.acos(cA);
      let tz=-(Math.PI-Math.acos(cK));
      // Protect: Beine anziehen
      if(pa>.001){
        const T=L.tuck;
        hy+=wrapA(T.yaw-hy)*pa;
        fz=lerp(fz,T.f,pa);
        tz=lerp(tz,T.t,pa);
      }
      L.hipG.rotation.y=hy;
      L.fem.rotation.z=fz;
      L.tib.rotation.z=tz;
    });
  }

  return {K,robot,pos,vel,step,place,
    get yaw(){return yaw},get protectA(){return protectA},get riseA(){return riseA}};
}

/* =====================================================================
   BEGLEITER-VERHALTEN
   ===================================================================== */
const main=makeHexapod(1,true);

const FCFG=[
  {fac:1.00, ang:-.75, d:1.6},
  {fac:.88,  ang:.25,  d:2.6},
  {fac:.76,  ang:.95,  d:3.6}
];
const followers=FCFG.map(c=>{
  const h=makeHexapod(.5,false);
  h.robot.visible=false;
  return {h,fac:c.fac,ang:c.ang,d:c.d,go:false,mode:null,state:'orbit',t:0,gT:0,cool:0,
    phi:0,r:2,rT:2,rB:2,dir:1,a:0,ro:2,wx:0,wz:0,dphi:0,dr:2,willRise:false,rose:false,sit:false};
});

const MODES={
  shy: {sp:.95,face:true},
  bold:{sp:1.25,face:true},
  wild:{sp:1.45,face:true},
  ign: {sp:.7,face:false}
};
const MIX=['bold','shy','wild'];
let modeSel='mix';
const modeOf=(F,i)=>modeSel==='mix'?MIX[i]:modeSel;

function startOrbit(F,m){
  const mp=main.pos;
  F.state='orbit';
  const rx=F.h.pos.x-mp.x,rz=F.h.pos.z-mp.z;
  F.phi=Math.atan2(rx,rz);
  F.dir=Math.random()<.5?-1:1;
  F.r=Math.hypot(rx,rz);
  if(m==='bold'){F.rT=rr(1.1,1.8);F.t=rr(4,8)}
  else if(m==='shy'){F.rT=rr(3.0,4.2);F.t=rr(5,9)}
  else{F.rB=rr(1.2,3.0);F.rT=F.rB;F.t=rr(1.5,3.2)}
}
function startHold(F,m){
  F.state='hold';F.gT=0;F.rose=false;
  if(m==='bold'){F.a=rr(-1.2,1.2);F.ro=rr(1.3,2.0);F.t=rr(2.5,5);F.willRise=Math.random()<.55}
  else if(m==='shy'){F.a=(Math.random()<.5?-1:1)*rr(1.6,3.1);F.ro=rr(2.8,4.2);F.t=rr(3,7)}
  else{F.a=rr(-Math.PI,Math.PI);F.ro=rr(.9,3.2);F.t=rr(.7,1.6)}
}
function startDash(F){F.state='dash';F.dphi=rr(0,Math.PI*2);F.dr=rr(.9,3.8);F.t=rr(1,2.2)}
function startPlay(F){F.state='play';F.t=rr(.9,1.8)}
function startWander(F){
  const mp=main.pos,h=F.h;
  F.state='wander';F.t=14;
  const dM=Math.hypot(h.pos.x-mp.x,h.pos.z-mp.z),a=rr(0,Math.PI*2);
  if(dM>7){const l=rr(0,4);F.wx=mp.x+Math.cos(a)*l;F.wz=mp.z+Math.sin(a)*l}
  else{const l=rr(2.5,6);F.wx=h.pos.x+Math.cos(a)*l;F.wz=h.pos.z+Math.sin(a)*l}
}
function startIdle(F){F.state='idle';F.t=rr(1.5,4.5);F.sit=Math.random()<.22}
function wildNext(F,dM,msp){
  const pPlay=(F.cool<=0)?((msp<.2&&dM<4.5)?.3:.1):0;
  const r=Math.random();
  if(r<pPlay)startPlay(F);
  else if(r<pPlay+.35)startDash(F);
  else if(r<pPlay+.6){startOrbit(F,'wild')}
  else startHold(F,'wild');
}

function plan(F,i,dt,time){
  const m=modeOf(F,i),h=F.h,mp=main.pos,M=MODES[m];
  if(F.mode!==m){F.mode=m;F.go=false;F.gT=0;F.cool=0;if(m==='ign')startWander(F);else startOrbit(F,m)}
  const rx=h.pos.x-mp.x,rz=h.pos.z-mp.z,dM=Math.hypot(rx,rz)||1e-6;
  const msp=Math.hypot(main.vel.x,main.vel.z);
  const base=P.speed*.62*F.fac*M.sp;
  const o={tx:h.pos.x,tz:h.pos.z,spd:base,tol:.15,protect:false,rise:false,duck:false,face:M.face};
  F.t-=dt;if(F.gT>0)F.gT-=dt;if(F.cool>0)F.cool-=dt;

  const orbit=k=>{
    F.r+=(F.rT-F.r)*(1-Math.exp(-1.2*dt));
    F.phi+=F.dir*(base*k)/Math.max(F.r,.6)*dt;
    o.tx=mp.x+Math.sin(F.phi)*F.r;o.tz=mp.z+Math.cos(F.phi)*F.r;
  };
  const hold=()=>{
    const ang=main.yaw+F.a;
    o.tx=mp.x-Math.sin(ang)*F.ro;o.tz=mp.z-Math.cos(ang)*F.ro;o.tol=.12;
  };

  if(m==='bold'){
    if(F.state==='orbit'){
      orbit(.9);
      if(F.t<=0){if(Math.random()<.6)startHold(F,m);else{F.dir*=-1;F.rT=rr(1.1,1.8);F.t=rr(4,8)}}
    }else{
      hold();
      // Imponieren: aufstellen, sobald angekommen und der Main ruhig ist
      if(F.willRise&&!F.rose&&msp<.5&&Math.hypot(o.tx-h.pos.x,o.tz-h.pos.z)<.35){F.gT=rr(1.4,2.4);F.rose=true}
      if(F.gT>0)o.rise=true;
      else if(F.t<=0)startOrbit(F,m);
    }
    if(main.riseA>.5&&dM<4)o.rise=true;
  }
  else if(m==='shy'){
    if(F.state==='orbit'||F.state==='hold'){
      const approaching=msp>.8&&((main.vel.x*rx+main.vel.z*rz)/(msp*dM))>.5;
      const mr=main.riseA>.5;
      if(dM<1.0||(mr&&dM<2.4)){F.state='curl';F.t=rr(1.8,3.4)}
      else if(dM<1.6||(approaching&&dM<3.4)||(mr&&dM<4.5)){F.state='flee';F.t=rr(1.2,2.2)}
    }
    if(F.state==='orbit'){
      F.rT=Math.max(2.0,F.rT-.07*dt);orbit(.45);
      if(F.t<=0){if(Math.random()<.55)startHold(F,m);else{F.dir*=-1;F.t=rr(5,9)}}
    }else if(F.state==='hold'){
      hold();
      if(msp<.2)F.ro=Math.max(1.7,F.ro-.12*dt);   // wagt sich langsam näher
      if(F.t<=0)startOrbit(F,m);
    }else if(F.state==='flee'){
      o.tx=mp.x+rx/dM*5.5;o.tz=mp.z+rz/dM*5.5;o.spd=base*1.9;o.face=false;o.tol=.3;
      if(F.t<=0){startOrbit(F,m);F.rT=rr(3.2,4.4)}
    }else{ // curl
      o.protect=true;
      if(F.t<=0){F.state='flee';F.t=1.0}
    }
  }
  else if(m==='wild'){
    if((main.riseA>.5||main.protectA>.5)&&dM<5&&F.state!=='play'&&F.cool<=0)startPlay(F);
    switch(F.state){
      case 'dash':
        o.tx=mp.x+Math.sin(F.dphi)*F.dr;o.tz=mp.z+Math.cos(F.dphi)*F.dr;o.spd=base*1.6;o.tol=.3;
        if(F.t<=0||Math.hypot(o.tx-h.pos.x,o.tz-h.pos.z)<.4)wildNext(F,dM,msp);
        break;
      case 'orbit':
        F.rT=F.rB+Math.sin(time*2.3+i)*.7;orbit(1.3);
        if(F.t<=0)wildNext(F,dM,msp);
        break;
      case 'hold':
        hold();o.spd=base*1.4;
        if(F.t<=0)wildNext(F,dM,msp);
        break;
      default: // play
        o.rise=true;
        if(F.t<=0){F.cool=4;wildNext(F,dM,msp)}
    }
  }
  else{ // ignorant
    if(F.state==='wander'){
      o.tx=F.wx;o.tz=F.wz;o.tol=.25;
      if(Math.hypot(F.wx-h.pos.x,F.wz-h.pos.z)<.4||F.t<=0)startIdle(F);
    }else{
      o.duck=F.sit;
      if(F.t<=0)startWander(F);
    }
    o.face=false;
  }
  return o;
}

const all=[];
function drive(F,dt,time,o){
  const h=F.h,mp=main.pos;
  const locked=o.protect||o.rise;
  const dxT=o.tx-h.pos.x,dzT=o.tz-h.pos.z,dT=Math.hypot(dxT,dzT)||1e-6;
  if(dT>o.tol+.3)F.go=true;else if(dT<o.tol)F.go=false;
  let vx=F.go?dxT/dT:0,vz=F.go?dzT/dT:0;

  // Abstand zu allen anderen halten
  all.forEach(q=>{
    if(q===h)return;
    const ox=h.pos.x-q.pos.x,oz=h.pos.z-q.pos.z,dd=Math.hypot(ox,oz)||1e-6;
    const minD=(h.K+q.K)*.45;
    if(dd<minD){const w=(minD-dd)/minD*1.8;vx+=ox/dd*w;vz+=oz/dd*w}
  });
  const len=Math.hypot(vx,vz),has=!locked&&len>.2;
  if(len>0){vx/=len;vz/=len}

  const catchUp=1+clamp((dT-1.2)/2.5,0,1.1);
  const near=F.go?clamp((dT-o.tol)/.7,.3,1):.5;
  const fxm=mp.x-h.pos.x,fzm=mp.z-h.pos.z,fl=Math.hypot(fxm,fzm)||1e-6;
  h.step(dt,time,{has,ix:vx,iz:vz,spd:o.spd*catchUp*near,duck:o.duck,protect:o.protect,rise:o.rise,
    side:false,allowStrafe:false,face:o.face,fx:fxm/fl,fz:fzm/fl});
}

let followersOn=false;
const swF=document.getElementById('swF'),modesEl=document.getElementById('modes');
function setFollowers(on){
  followersOn=on;swF.checked=on;modesEl.classList.toggle('off',!on);
  followers.forEach(F=>{
    F.h.robot.visible=on;F.go=false;F.mode=null;
    if(on){
      const a=main.yaw+Math.PI+F.ang;
      const x=main.pos.x-Math.sin(a)*F.d,z=main.pos.z-Math.cos(a)*F.d;
      F.h.place(x,z,Math.atan2(-(main.pos.x-x),-(main.pos.z-z)));
    }
  });
  all.length=0;all.push(main);followers.forEach(F=>all.push(F.h));
}
swF.addEventListener('change',()=>{setFollowers(swF.checked);swF.blur()});
modesEl.querySelectorAll('button').forEach(b=>{
  b.onclick=()=>{
    modeSel=b.dataset.m;
    modesEl.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b));
    b.blur();
  };
});

/* ---------- Eingabe ---------- */
const keys={},ui={duck:false,side:false,prot:false,rise:false};
const KM={KeyW:'w',KeyS:'s',KeyA:'a',KeyD:'d',KeyC:'c',Space:'x',
  Digit1:'p',Numpad1:'p',Digit2:'r',Numpad2:'r',
  ShiftLeft:'shift',ShiftRight:'shift',ArrowUp:'w',ArrowDown:'s',ArrowLeft:'a',ArrowRight:'d'};
addEventListener('keydown',e=>{
  if(e.code==='KeyG'){if(!e.repeat)setFollowers(!followersOn);e.preventDefault();return}
  const k=KM[e.code];if(k){keys[k]=true;e.preventDefault()}
});
addEventListener('keyup',e=>{const k=KM[e.code];if(k){keys[k]=false;e.preventDefault()}});
addEventListener('blur',()=>{for(const k in keys)keys[k]=false});
[['bDuck','duck'],['bSide','side'],['bProt','prot'],['bRise','rise']].forEach(([id,key])=>{
  const b=document.getElementById(id);
  b.onclick=()=>{ui[key]=!ui[key];b.classList.toggle('on',ui[key]);b.blur()};
});

/* ---------- Kamera (folgt der Hauptfigur, dreht nicht mit) ---------- */
const drag={yaw:.5,pitch:.5};let dist=5.6,dragging=false,lx=0,ly=0;
const camT=new V3(0,.12,0),camA=new V3(0,.12,0),camG=new V3();
const cvs=renderer.domElement;
cvs.addEventListener('pointerdown',e=>{dragging=true;lx=e.clientX;ly=e.clientY;cvs.setPointerCapture(e.pointerId)});
cvs.addEventListener('pointerup',()=>{dragging=false});
cvs.addEventListener('pointermove',e=>{
  if(!dragging)return;
  drag.yaw-=(e.clientX-lx)*.006;drag.pitch=clamp(drag.pitch+(e.clientY-ly)*.005,.08,1.3);
  lx=e.clientX;ly=e.clientY;
});
cvs.addEventListener('wheel',e=>{dist=clamp(dist*(1+e.deltaY*.001),1.8,16);e.preventDefault()},{passive:false});
addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight)});

/* ---------- Update ---------- */
all.push(main);followers.forEach(F=>all.push(F.h));

function update(dt,time){
  dt=Math.min(dt,.05);
  const k=keys;

  // Hauptfigur: Eingabe relativ zur Kamera
  const ca=drag.yaw;
  const fw=(k.w?1:0)-(k.s?1:0),rt=(k.d?1:0)-(k.a?1:0);
  let ix=-Math.sin(ca)*fw+Math.cos(ca)*rt,iz=-Math.cos(ca)*fw-Math.sin(ca)*rt;
  const il=Math.hypot(ix,iz),has=il>.01;
  if(has){ix/=il;iz/=il}
  main.step(dt,time,{has,ix,iz,spd:P.speed*(k.shift?1.55:1),
    duck:!!(k.c||ui.duck),protect:!!(k.p||ui.prot),rise:!!(k.r||ui.rise),
    side:!!(k.x||ui.side),allowStrafe:true,face:false});

  // Begleiter
  if(followersOn){
    followers.forEach((F,i)=>{
      const h=F.h;
      const rx=h.pos.x-main.pos.x,rz=h.pos.z-main.pos.z,dM=Math.hypot(rx,rz)||1e-6;
      if(dM>13){
        h.place(main.pos.x+rx/dM*4.5,main.pos.z+rz/dM*4.5,Math.atan2(rx,rz));
        F.go=false;F.mode=null;return;
      }
      drive(F,dt,time,plan(F,i,dt,time));
    });
  }

  // Licht, Boden folgen der Hauptfigur
  const mp=main.pos;
  sun.position.set(mp.x+2.5,5,mp.z+1.8);sun.target.position.set(mp.x,0,mp.z);
  floor.position.set(mp.x,0,mp.z);
  grid.position.x=Math.round(mp.x*2)/2;grid.position.z=Math.round(mp.z*2)/2;

  // Kamera: zweistufig geglättet, keine Mitdrehung
  camG.set(mp.x+main.vel.x*1.15,.12,mp.z+main.vel.z*1.15);
  camA.lerp(camG,1-Math.exp(-1.0*dt));
  camT.lerp(camA,1-Math.exp(-2.2*dt));
  const a=drag.yaw,p=drag.pitch;
  camera.position.set(camT.x+Math.sin(a)*Math.cos(p)*dist,camT.y+Math.sin(p)*dist,camT.z+Math.cos(a)*Math.cos(p)*dist);
  camera.lookAt(camT);
}

let last=performance.now();
function loop(now){
  update((now-last)/1000,now/1000);last=now;
  renderer.render(scene,camera);
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();
</script>
</body>
</html>
script.js
console.log('läuft');
style.css
body {
  font-family: system-ui, sans-serif;
  margin: 2rem;
}