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) <b>Shift</b> rennen <b>Leertaste</b> seitwärts (Blickrichtung halten)<br>
<b>C</b> ducken <b>1</b> protect <b>2</b> aufstellen · shiver <b>G</b> Begleiter an/aus <b>Maus ziehen</b> Kamera drehen <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>