CUTE_SORRY_WALKER v0.1

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/98e65e32e6cc6d9b/" 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">
<title>Sorry Walker</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;height:100%;background:#dce1e7;overflow:hidden;font-family:-apple-system,"Helvetica Neue",Helvetica,Arial,sans-serif}
canvas{display:block}
#say{position:fixed;left:0;top:0;transform:translate(-50%,-100%);font-size:14px;letter-spacing:.06em;color:#7c8794;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap}
#hint{position:fixed;left:16px;bottom:14px;font-size:11px;letter-spacing:.08em;color:#8f99a5;user-select:none;pointer-events:none}
#panel{position:fixed;right:0;top:0;bottom:0;width:236px;background:#fff;border-left:1px solid #eef0f3;padding:44px 16px 18px;box-sizing:border-box;overflow:auto;font-size:11px;letter-spacing:.06em;color:#8b95a1;transition:transform .25s}
#panel.hide{transform:translateX(100%)}
#panel h4{font-weight:400;margin:20px 0 8px;color:#b4bcc6;text-transform:uppercase;font-size:10px;letter-spacing:.14em}
#panel h4:first-child{margin-top:0}
.row{margin:0 0 10px}
.row label{display:flex;justify-content:space-between}
.row input{width:100%;margin:4px 0 0;accent-color:#9aa5b1}
#panel button,#tog{font:inherit;font-size:11px;letter-spacing:.06em;color:#8b95a1;background:#fff;border:1px solid #e3e7ec;border-radius:3px;padding:6px 10px;margin:0 6px 6px 0;cursor:pointer}
#panel button:hover,#tog:hover{border-color:#b4bcc6}
#tog{position:fixed;right:12px;top:10px;z-index:5;margin:0;border:none;padding:4px 6px}
#hud{position:fixed;left:0;top:0;transform:translate(-50%,-100%);pointer-events:none;font-size:8px;letter-spacing:.1em;color:#8f99a5;opacity:.9}
#hud .hr{display:flex;align-items:center;gap:6px;height:7px;margin:2px 0}
#hud .hr span{width:50px;text-align:right}
#hud .hr i{display:block;width:44px;height:2px;background:rgba(255,255,255,.6)}
#hud .hr b{display:block;height:100%;width:0;background:#8f9aa6}
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}
</script>
</head>
<body>
<div id="say">I'm Sorry.</div>
<div id="hint">WASD / Pfeile · Shift rennen · Maus ziehen: Kamera · R: neue Form · T: Missgeschick · P: Panel · H: Balken</div>
<button id="tog">Panel</button>
<div id="panel"></div>
<script type="module">
import * as THREE from 'three';
import {mergeVertices} from 'three/addons/utils/BufferGeometryUtils.js';
import {RoomEnvironment} from 'three/addons/environments/RoomEnvironment.js';
import {MeshSurfaceSampler} from 'three/addons/math/MeshSurfaceSampler.js';

/* ---------- helpers ---------- */
const clamp=(x,a,b)=>Math.min(b,Math.max(a,x));
const lerp=(a,b,t)=>a+(b-a)*t;
const sstep=(a,b,x)=>{const t=clamp((x-a)/(b-a),0,1);return t*t*(3-2*t)};
const easeOutBack=x=>{const c1=1.4,c3=c1+1;return 1+c3*Math.pow(x-1,3)+c1*Math.pow(x-1,2)};
function angDiff(a,b){let d=(b-a)%(Math.PI*2);if(d>Math.PI)d-=Math.PI*2;if(d<-Math.PI)d+=Math.PI*2;return d}
function rng(seed){return()=>{seed|=0;seed=seed+0x6D2B79F5|0;let t=Math.imul(seed^seed>>>15,1|seed);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
function hash(x,y,k){let h=(Math.imul(x,374761393)+Math.imul(y,668265263)+Math.imul(k+1,2147483647))|0;h=Math.imul(h^(h>>>13),1274126177);h^=h>>>16;return(h>>>0)/4294967296}

/* ---------- generative normal perturbation: dense 3D Perlin noise in the shader ---------- */
const NOISE_GLSL=`
vec3 hash33n(vec3 p){p=vec3(dot(p,vec3(127.1,311.7,74.7)),dot(p,vec3(269.5,183.3,246.1)),dot(p,vec3(113.5,271.9,124.6)));return -1.0+2.0*fract(sin(p)*43758.5453123);}
float pnoise3(vec3 p){
  vec3 i=floor(p),f=fract(p),u=f*f*f*(f*(f*6.0-15.0)+10.0);
  return mix(
    mix(mix(dot(hash33n(i),f),dot(hash33n(i+vec3(1,0,0)),f-vec3(1,0,0)),u.x),
        mix(dot(hash33n(i+vec3(0,1,0)),f-vec3(0,1,0)),dot(hash33n(i+vec3(1,1,0)),f-vec3(1,1,0)),u.x),u.y),
    mix(mix(dot(hash33n(i+vec3(0,0,1)),f-vec3(0,0,1)),dot(hash33n(i+vec3(1,0,1)),f-vec3(1,0,1)),u.x),
        mix(dot(hash33n(i+vec3(0,1,1)),f-vec3(0,1,1)),dot(hash33n(i+vec3(1,1,1)),f-vec3(1,1,1)),u.x),u.y),u.z);
}
float fbmN(vec3 p){return pnoise3(p)*0.62+pnoise3(p*2.3+7.1)*0.38;}
vec3 perturbN(vec3 surf_pos,vec3 surf_norm,vec2 dHdxy,float faceDirection){
  vec3 vSigmaX=dFdx(surf_pos);vec3 vSigmaY=dFdy(surf_pos);vec3 vN=surf_norm;
  vec3 R1=cross(vSigmaY,vN);vec3 R2=cross(vN,vSigmaX);
  float fDet=dot(vSigmaX,R1)*faceDirection;
  vec3 vGrad=sign(fDet)*(dHdxy.x*R1+dHdxy.y*R2);
  return normalize(abs(fDet)*surf_norm-vGrad);
}`;
/* ---------- wind-direction horizon glow (fog + sky dome share one formula) ---------- */
const GLOW={value:new THREE.Vector3(1,0,0)};
THREE.Material.prototype.onBeforeCompile=function(sh){sh.uniforms.uGlow=GLOW};
THREE.ShaderChunk.fog_pars_vertex=THREE.ShaderChunk.fog_pars_vertex.replace('varying float vFogDepth;','varying float vFogDepth;\nvarying vec3 vFogDir;');
THREE.ShaderChunk.fog_vertex=THREE.ShaderChunk.fog_vertex.replace('vFogDepth = - mvPosition.z;','vFogDepth = - mvPosition.z;\n\tvFogDir = (vec4(mvPosition.xyz,0.0)*viewMatrix).xyz;');
THREE.ShaderChunk.fog_pars_fragment=THREE.ShaderChunk.fog_pars_fragment.replace('varying float vFogDepth;','varying float vFogDepth;\n\tvarying vec3 vFogDir;\n\tuniform vec3 uGlow;');
THREE.ShaderChunk.fog_fragment=THREE.ShaderChunk.fog_fragment.replace('gl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );',`float fgg=clamp(dot(normalize(vFogDir.xz+vec2(1e-5)),uGlow.xy)*0.5+0.5,0.0,1.0);fgg=fgg*fgg*(3.0-2.0*fgg);
	vec3 fogC=mix(fogColor*0.7,min(fogColor*1.12,vec3(1.0)),fgg);
	gl_FragColor.rgb = mix( gl_FragColor.rgb, fogC, fogFactor );`);
function addNoiseBump(mat,{freq,amp,world,near,far}){
  const key='nb'+freq+'_'+amp+'_'+world+'_'+near+'_'+far;
  mat.onBeforeCompile=sh=>{
    sh.uniforms.uGlow=GLOW;
    sh.vertexShader=sh.vertexShader
      .replace('#include <common>','#include <common>\nvarying vec3 vNP;')
      .replace('#include <begin_vertex>','#include <begin_vertex>\n'+(world
        ?`vec4 nwp=vec4(transformed,1.0);
#ifdef USE_INSTANCING
nwp=instanceMatrix*nwp;
#endif
nwp=modelMatrix*nwp;vNP=nwp.xyz;`
        :'vNP=transformed;'));
    sh.fragmentShader=sh.fragmentShader
      .replace('#include <common>','#include <common>\nvarying vec3 vNP;\n'+NOISE_GLSL)
      .replace('#include <normal_fragment_maps>',`#include <normal_fragment_maps>
{
  float nfade=1.0-smoothstep(${near.toFixed(1)},${far.toFixed(1)},length(vViewPosition));
  float nh=0.0;
  if(nfade>0.001){nh=fbmN(vNP*${freq.toFixed(2)})*${amp.toFixed(5)}*nfade;}
  vec2 dH=vec2(dFdx(nh),dFdy(nh));
  normal=perturbN(-vViewPosition,normal,dH,faceDirection);
}`);
  };
  mat.customProgramCacheKey=()=>key;
}

/* ---------- behaviour settings (side panel) ---------- */
const S={dream:60,wobbleRate:40,guilt:70,wind:60,rollers:10,furLen:10,furDens:100,shed:60,interval:8,slipInterval:12,skidShare:40,fallPct:35,fall:1.4,getup:2,say:85,ups:40,dither:70,ditherLen:1,walk:1.4,run:2.6};
const S0={...S};
const DEFS=[
  ['Gemüt'],
  ['dream','Verträumtheit (%)',0,100,5],
  ['wobbleRate','Schwanken (%)',0,100,5],
  ['guilt','Schlechtes Gewissen (%)',0,100,5],
  ['Wind & Rollsteine'],
  ['wind','Wind (%)',0,100,5],
  ['rollers','Rollsteine (Anzahl)',0,24,1],
  ['Fell'],
  ['furLen','Fell-Länge (cm)',2,18,0.5],
  ['furDens','Fell-Dichte (%)',20,150,5],
  ['shed','Fellbüschel im Wind (%)',0,100,5],
  ['Missgeschicke'],
  ['interval','Stolper-Intervall (s)',2,30,0.5],
  ['slipInterval','Ausrutsch-Intervall (s)',2,30,0.5],
  ['skidShare','Wegschlittern statt Ausrutschen (%)',0,100,5],
  ['fallPct','davon echtes Hinfallen (%)',0,100,5],
  ['fall','Fall-Tempo ×',0.6,2.5,0.05],
  ['getup','Aufsteh-Tempo ×',0.6,3,0.05],
  ['Sprüche'],
  ['say','Spruch danach (%)',0,100,5],
  ['ups','„Ups.“ statt „I\'m Sorry.“ (%)',0,100,5],
  ['Unentschlossenheit'],
  ['dither','Zögern vor Steinen (%)',0,100,5],
  ['ditherLen','Zögern-Dauer ×',0.5,2.5,0.1],
  ['Bewegung'],
  ['walk','Gehtempo',0.8,2.6,0.1],
  ['run','Renntempo',1.5,4.5,0.1]
];
const panel=document.getElementById('panel'),inputs={};
for(const d of DEFS){
  if(d.length===1){const h=document.createElement('h4');h.textContent=d[0];panel.appendChild(h);continue}
  const [k,name,min,max,step]=d;
  const row=document.createElement('div');row.className='row';
  const lab=document.createElement('label'),nm=document.createElement('span'),val=document.createElement('span');
  nm.textContent=name;lab.append(nm,val);
  const inp=document.createElement('input');inp.type='range';inp.min=min;inp.max=max;inp.step=step;inp.value=S[k];
  const upd=()=>{val.textContent=String(+inp.value)};
  inp.addEventListener('input',()=>{S[k]=+inp.value;upd()});
  upd();row.append(lab,inp);panel.appendChild(row);inputs[k]={inp,upd};
}
{
  for(const k of['furLen','furDens'])inputs[k].inp.addEventListener('change',()=>setFigure());
  const h=document.createElement('h4');h.textContent='Aktionen';panel.appendChild(h);
  const mk=(txt,fn)=>{const b=document.createElement('button');b.textContent=txt;b.addEventListener('click',fn);panel.appendChild(b)};
  mk('Neue Form (R)',()=>{P=randomParams();setFigure()});
  mk('Missgeschick (T)',()=>{if(F.state==='walk')startTrip()});
  mk('Balken (H)',()=>{hud.style.display=hud.style.display==='none'?'':'none'});
  mk('Zurücksetzen',()=>{Object.assign(S,S0);for(const k in inputs){inputs[k].inp.value=S[k];inputs[k].upd()}setFigure()});
}
panel.addEventListener('change',()=>{if(document.activeElement)document.activeElement.blur()});
const tog=document.getElementById('tog');
const togglePanel=()=>panel.classList.toggle('hide');
tog.addEventListener('click',togglePanel);

/* ---------- Perlin noise (CPU: terrain, body lumps) ---------- */
function makeNoise(seed){
  const r=rng(seed),p=new Uint8Array(512),a=[...Array(256).keys()];
  for(let i=255;i>0;i--){const j=Math.floor(r()*(i+1));[a[i],a[j]]=[a[j],a[i]]}
  for(let i=0;i<512;i++)p[i]=a[i&255];
  const G=[[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]];
  const fade=t=>t*t*t*(t*(t*6-15)+10);
  const grad=(h,x,y)=>{const g=G[h&7];return g[0]*x+g[1]*y};
  return(x,y)=>{
    const X=Math.floor(x),Y=Math.floor(y),xf=x-X,yf=y-Y,xi=X&255,yi=Y&255;
    const aa=p[p[xi]+yi],ab=p[p[xi]+yi+1],ba=p[p[xi+1]+yi],bb=p[p[xi+1]+yi+1];
    const u=fade(xf),v=fade(yf);
    return lerp(lerp(grad(aa,xf,yf),grad(ba,xf-1,yf),u),lerp(grad(ab,xf,yf-1),grad(bb,xf-1,yf-1),u),v)*1.2;
  };
}
const perlin=makeNoise(7);
function height(x,z){
  let h=3.0*perlin(x*0.022,z*0.022);
  h+=1.2*perlin(x*0.05+31.4,z*0.05-17.2);
  const r=1-Math.abs(perlin(x*0.035-9,z*0.035+4));
  h+=1.4*r*r;
  h+=0.3*perlin(x*0.11,z*0.11);
  return h;
}
function normalAt(x,z){
  const e=0.3;
  return new THREE.Vector3(height(x-e,z)-height(x+e,z),2*e,height(x,z-e)-height(x,z+e)).normalize();
}

/* ---------- renderer / scene ---------- */
const FOG=0xdce1e7;
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(FOG);
scene.fog=new THREE.Fog(FOG,6,44);
const sky=new THREE.Mesh(new THREE.SphereGeometry(120,32,16),new THREE.ShaderMaterial({
  side:THREE.BackSide,depthWrite:false,depthTest:false,fog:false,
  uniforms:{uGlow:{value:null},uFog:{value:new THREE.Color(FOG)}},
  vertexShader:'varying vec3 vD;void main(){vD=position;gl_Position=projectionMatrix*viewMatrix*modelMatrix*vec4(position,1.0);}',
  fragmentShader:`varying vec3 vD;uniform vec3 uGlow;uniform vec3 uFog;
void main(){vec3 d=normalize(vD);float g=clamp(dot(normalize(d.xz+vec2(1e-5)),uGlow.xy)*0.5+0.5,0.0,1.0);g=g*g*(3.0-2.0*g);
vec3 fc=mix(uFog*0.7,min(uFog*1.12,vec3(1.0)),g);
gl_FragColor=vec4(mix(fc,fc*mix(0.8,0.9,g),smoothstep(0.0,0.6,d.y)),1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`}));
sky.material.uniforms.uGlow=GLOW;sky.renderOrder=-10;sky.frustumCulled=false;scene.add(sky);
const camera=new THREE.PerspectiveCamera(50,innerWidth/innerHeight,0.1,200);

scene.add(new THREE.HemisphereLight(0xffffff,0xbcc6d2,1.55));
const sun=new THREE.DirectionalLight(0xfffcf6,1.45);
sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
const sc=sun.shadow.camera;sc.left=sc.bottom=-14;sc.right=sc.top=14;sc.near=1;sc.far=80;
sun.shadow.bias=-0.0006;sun.shadow.normalBias=0.04;
scene.add(sun,sun.target);
const sunDir=new THREE.Vector3(-0.5,0.75,0.45).normalize();

/* ---------- terrain (follows the player, vertices displaced by Perlin) ---------- */
const TSIZE=130,TSEG=130,STEP=TSIZE/TSEG;
const tgeo=new THREE.PlaneGeometry(TSIZE,TSIZE,TSEG,TSEG);
tgeo.rotateX(-Math.PI/2);
const tpos=tgeo.attributes.position;
const terrainMat=new THREE.MeshLambertMaterial({color:0xffffff});
addNoiseBump(terrainMat,{freq:14,amp:0.006,world:true,near:12,far:28});
const terrain=new THREE.Mesh(tgeo,terrainMat);
terrain.receiveShadow=true;terrain.frustumCulled=false;
scene.add(terrain);
let tcx=1e9,tcz=1e9;
function updateTerrain(px,pz){
  const cx=Math.round(px/STEP)*STEP,cz=Math.round(pz/STEP)*STEP;
  if(cx===tcx&&cz===tcz)return;
  tcx=cx;tcz=cz;terrain.position.set(cx,0,cz);
  for(let i=0;i<tpos.count;i++)tpos.setY(i,height(cx+tpos.getX(i),cz+tpos.getZ(i)));
  tpos.needsUpdate=true;tgeo.computeVertexNormals();
}

/* ---------- rocks ---------- */
const rockGeo=new THREE.IcosahedronGeometry(1,1);
{
  const p=rockGeo.attributes.position,v=new THREE.Vector3();
  for(let i=0;i<p.count;i++){
    v.fromBufferAttribute(p,i);
    v.multiplyScalar(1+0.28*perlin(v.x*1.7+3,v.y*1.7+v.z*1.3));
    v.y*=0.8;p.setXYZ(i,v.x,v.y,v.z);
  }
  rockGeo.computeVertexNormals();
}
const rockMat=new THREE.MeshLambertMaterial({color:0xffffff,flatShading:true});
addNoiseBump(rockMat,{freq:12,amp:0.008,world:true,near:10,far:26});
const rocks=new THREE.InstancedMesh(rockGeo,rockMat,400);
rocks.castShadow=rocks.receiveShadow=true;rocks.frustumCulled=false;
scene.add(rocks);
let rockList=[],rcx=1e9,rcz=1e9;const CELL=14;
function updateRocks(px,pz){
  const cx=Math.floor(px/CELL),cz=Math.floor(pz/CELL);
  if(cx===rcx&&cz===rcz)return;
  rcx=cx;rcz=cz;rockList=[];let n=0;
  const d=new THREE.Object3D(),col=new THREE.Color();
  for(let i=-5;i<=5;i++)for(let j=-5;j<=5;j++){
    const gx=cx+i,gz=cz+j;
    if(hash(gx,gz,0)>0.55)continue;
    const cnt=1+Math.floor(hash(gx,gz,1)*3);
    const bx=(gx+hash(gx,gz,2))*CELL,bz=(gz+hash(gx,gz,3))*CELL;
    for(let k=0;k<cnt&&n<400;k++){
      const x=bx+(k?(hash(gx,gz,10+k)-.5)*6:0),z=bz+(k?(hash(gx,gz,20+k)-.5)*6:0);
      if(x*x+z*z<16)continue;
      const s=(k===0?0.9:0.45)*(0.6+hash(gx,gz,30+k)*1.4);
      const sy=s*(0.55+hash(gx,gz,40+k)*0.5);
      d.position.set(x,height(x,z)+sy*0.1,z);
      d.rotation.set(0,hash(gx,gz,50+k)*6.28,0);
      d.scale.set(s,sy,s*(0.8+hash(gx,gz,55+k)*0.4));
      d.updateMatrix();
      rocks.setMatrixAt(n,d.matrix);
      const g=0.5+hash(gx,gz,60+k)*0.15;col.setRGB(g,g+0.01,g+0.03);rocks.setColorAt(n,col);
      rockList.push({x,z,r:s*0.85});n++;
    }
  }
  rocks.count=n;rocks.instanceMatrix.needsUpdate=true;
  if(rocks.instanceColor)rocks.instanceColor.needsUpdate=true;
}

/* ---------- footprints ---------- */
const FPN=320;
const fpGeo=new THREE.CircleGeometry(1,18);fpGeo.rotateX(-Math.PI/2);
const fp=new THREE.InstancedMesh(fpGeo,new THREE.MeshLambertMaterial({color:0xffffff,polygonOffset:true,polygonOffsetFactor:-2,polygonOffsetUnits:-2}),FPN);
fp.count=0;fp.frustumCulled=false;fp.receiveShadow=true;
fp.setColorAt(0,new THREE.Color(1,1,1));
scene.add(fp);
const fpBorn=new Float32Array(FPN);let fpI=0,fpCount=0;
const UP=new THREE.Vector3(0,1,0),_v=new THREE.Vector3(),_s=new THREE.Vector3(),_qa=new THREE.Quaternion(),_qb=new THREE.Quaternion(),_m=new THREE.Matrix4(),_c=new THREE.Color();

/* ---------- the figure: generative inflated blob, basic rig ---------- */
// body kinds: round ball, capsule (pill), squircle (rounded box)
function defaultParams(){return{kind:'capsule',rx:.29,ry:.27,rz:.26,cap:.07,pear:.03,puff:.92,bumps:.012,seed:3.3,legReach:.2,legR:.082,hip:.58,armLen:.05,armR:.07,armY:.34,eyeSep:.55,eyeY:.15,eyeR:.03}}
function randomParams(){
  const r=(a,b)=>a+Math.random()*(b-a),kind=['round','capsule','squircle'][Math.floor(Math.random()*3)],b=r(.25,.33);
  const base={kind,seed:r(0,50),pear:r(-.03,.06),bumps:r(0,.02),legReach:r(.17,.23),legR:r(.07,.092),hip:r(.52,.64),armLen:r(.03,.08),armR:r(.058,.08),armY:r(.28,.42),eyeSep:r(.46,.62),eyeY:r(.05,.24),eyeR:r(.024,.038)};
  if(kind==='round')return{...base,rx:b,ry:b*r(.92,1.08),rz:b*r(.88,.98),cap:0,puff:r(.93,1)};
  if(kind==='capsule')return{...base,rx:b*r(.8,.95),ry:b*r(.8,.95),rz:b*r(.78,.92),cap:b*r(.25,.65),puff:r(.9,.98)};
  return{...base,rx:b,ry:b*r(.9,1.05),rz:b*r(.88,1),cap:0,puff:r(.58,.7)};
}
let P=defaultParams();
const envTex=new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(renderer),0.04).texture;
let fig=null;

/* ---------- fur: strand-based ribbons, wind-animated in the vertex shader, tufts shed in gusts ---------- */
const GN=32; // tuft groups 0..30 can fall out, 31 = never
function makeFur(mesh,count,len,baseCol,opt){
  const sampler=new MeshSurfaceSampler(mesh).build();
  const roots=[],p=new THREE.Vector3(),n=new THREE.Vector3();
  for(let tries=0;roots.length<count&&tries<count*4;tries++){
    sampler.sample(p,n);
    if(opt.skip&&opt.skip(p))continue;
    roots.push({p:p.clone(),n:n.clone()});
  }
  const cnt=roots.length,K=4,VPS=K*2;
  const grp=new Float32Array(cnt).fill(GN-1),seeds=[];
  if(opt.groups&&cnt>0){
    for(let g=0;g<GN-1;g++)seeds.push(roots[Math.floor(Math.random()*cnt)].p.clone());
    for(let i=0;i<cnt;i++){let b=0,bd=1e9;for(let g=0;g<GN-1;g++){const d=roots[i].p.distanceToSquared(seeds[g]);if(d<bd){bd=d;b=g}}grp[i]=b}
  }
  const position=new Float32Array(cnt*VPS*3),aRoot=new Float32Array(cnt*VPS*3),normal=new Float32Array(cnt*VPS*3),color=new Float32Array(cnt*VPS*3),aTan=new Float32Array(cnt*VPS*3),aTS=new Float32Array(cnt*VPS*2),aSLG=new Float32Array(cnt*VPS*3);
  const index=new Uint32Array(cnt*(K-1)*12);
  const pts=[0,1,2,3].map(()=>new THREE.Vector3()),d=new THREE.Vector3(),tj=new THREE.Vector3(),tg=new THREE.Vector3();
  let ii=0;
  for(let i=0;i<cnt;i++){
    const P0=roots[i].p,N0=roots[i].n,lf=opt.lenAt?opt.lenAt(P0):1,L=len*(0.65+Math.random()*0.7)*lf,seed=Math.random(),br=0.92+Math.random()*0.16;
    tj.set(Math.random()-.5,Math.random()-.5,Math.random()-.5);tj.addScaledVector(N0,-tj.dot(N0)).normalize();
    d.copy(N0).addScaledVector(tj,0.45);d.y-=0.35;d.normalize();
    for(let k=0;k<K;k++){const t=k/(K-1);pts[k].copy(P0).addScaledVector(d,L*t);pts[k].y-=0.25*L*t*t}
    for(let k=0;k<K;k++){
      const t=k/(K-1);
      if(k<K-1)tg.copy(pts[k+1]).sub(pts[k]);else tg.copy(pts[k]).sub(pts[k-1]);
      tg.normalize();
      const cr=lerp(0.78,1.12,t)*br;
      for(let s=0;s<2;s++){
        const v=i*VPS+k*2+s,v3=v*3;
        position[v3]=pts[k].x;position[v3+1]=pts[k].y;position[v3+2]=pts[k].z;
        aRoot[v3]=P0.x;aRoot[v3+1]=P0.y;aRoot[v3+2]=P0.z;
        normal[v3]=N0.x;normal[v3+1]=N0.y;normal[v3+2]=N0.z;
        color[v3]=baseCol.r*cr;color[v3+1]=baseCol.g*cr;color[v3+2]=baseCol.b*cr;
        aTan[v3]=tg.x;aTan[v3+1]=tg.y;aTan[v3+2]=tg.z;
        aTS[v*2]=t;aTS[v*2+1]=s?1:-1;
        aSLG[v3]=seed;aSLG[v3+1]=L;aSLG[v3+2]=grp[i];
      }
    }
    for(let k=0;k<K-1;k++){
      const a=i*VPS+k*2,b=a+1,c=a+2,e=a+3;
      index[ii++]=a;index[ii++]=b;index[ii++]=c;index[ii++]=b;index[ii++]=e;index[ii++]=c;
      index[ii++]=a;index[ii++]=c;index[ii++]=b;index[ii++]=b;index[ii++]=c;index[ii++]=e;
    }
  }
  const geo=new THREE.BufferGeometry();
  geo.setAttribute('position',new THREE.BufferAttribute(position,3));
  geo.setAttribute('aRoot',new THREE.BufferAttribute(aRoot,3));
  geo.setAttribute('normal',new THREE.BufferAttribute(normal,3));
  geo.setAttribute('color',new THREE.BufferAttribute(color,3));
  geo.setAttribute('aTan',new THREE.BufferAttribute(aTan,3));
  geo.setAttribute('aTS',new THREE.BufferAttribute(aTS,2));
  geo.setAttribute('aSLG',new THREE.BufferAttribute(aSLG,3));
  geo.setIndex(new THREE.BufferAttribute(index,1));
  return{geo,seeds};
}
function furMaterial(U){
  const m=new THREE.MeshLambertMaterial({vertexColors:true});
  m.onBeforeCompile=sh=>{
    sh.uniforms.uGlow=GLOW;sh.uniforms.uTime=U.uTime;sh.uniforms.uWindL=U.uWindL;sh.uniforms.uGravL=U.uGravL;sh.uniforms.uG=U.uG;
    sh.vertexShader=sh.vertexShader
      .replace('#include <common>',`#include <common>
attribute vec3 aRoot;attribute vec3 aTan;attribute vec2 aTS;attribute vec3 aSLG;
uniform float uTime;uniform vec3 uWindL;uniform vec3 uGravL;uniform float uG[${GN}];`)
      .replace('#include <begin_vertex>',`#include <begin_vertex>
float fT=aTS.x;float fSeed=aSLG.x;float fLen=aSLG.y;
float fGone=uG[int(aSLG.z+0.5)];
float wt=fT*fT;
float wave=0.75+0.25*sin(uTime*(6.0+fSeed*5.0)+fSeed*40.0+aRoot.y*25.0);
vec3 fside=normalize(cross(normal,uWindL+vec3(0.0001,0.0002,0.0003)));
float wl=length(uWindL);
vec3 sway=uWindL*fLen*1.15*wt*wave+uGravL*fLen*0.45*wt;
sway+=fside*sin(uTime*(11.0+fSeed*9.0)+fSeed*30.0)*fLen*(0.22*wl+0.04)*wt;
transformed=aRoot+(position-aRoot+sway)*fGone;`)
      .replace('#include <project_vertex>',`#include <project_vertex>
{
  vec3 tanV=normalize(mat3(modelViewMatrix)*aTan);
  vec3 rgt=normalize(cross(tanV,mvPosition.xyz));
  float fw=mix(0.0078,0.0024,fT)*(0.7+0.6*fSeed)*fGone;
  mvPosition.xyz+=rgt*aTS.y*fw;
  gl_Position=projectionMatrix*mvPosition;
}`);
  };
  m.customProgramCacheKey=()=>'fur2';
  return m;
}
// tufts blown out of the fur
const softTex=(()=>{const c=document.createElement('canvas');c.width=c.height=64;const g=c.getContext('2d'),gr=g.createRadialGradient(32,32,0,32,32,32);gr.addColorStop(0,'rgba(255,255,255,1)');gr.addColorStop(1,'rgba(255,255,255,0)');g.fillStyle=gr;g.fillRect(0,0,64,64);return new THREE.CanvasTexture(c)})();
const TN=30,TP=10,tuftGeo=new THREE.BufferGeometry(),tPos=new Float32Array(TN*TP*3),tCol=new Float32Array(TN*TP*4);
tuftGeo.setAttribute('position',new THREE.BufferAttribute(tPos,3));tuftGeo.setAttribute('color',new THREE.BufferAttribute(tCol,4));
const tuftPts=new THREE.Points(tuftGeo,new THREE.PointsMaterial({size:0.13,map:softTex,transparent:true,depthWrite:false,vertexColors:true,sizeAttenuation:true}));
tuftPts.frustumCulled=false;scene.add(tuftPts);
const tufts=[];for(let i=0;i<TN;i++)tufts.push({life:0,x:0,y:0,z:0,vx:0,vy:0,vz:0,off:Array.from({length:TP},()=>[(Math.random()-.5)*0.08,(Math.random()-.5)*0.06,(Math.random()-.5)*0.08])});
let tuftI=0;
function spawnTuft(x,y,z){
  const t=tufts[tuftI];tuftI=(tuftI+1)%TN;
  t.life=4+Math.random()*3;t.x=x;t.y=y;t.z=z;t.vx=W.dx*(1.5+W.s*3)+(Math.random()-.5)*.6;t.vy=0.3+Math.random()*0.5;t.vz=W.dz*(1.5+W.s*3)+(Math.random()-.5)*.6;
}
function updateTufts(dt,now){
  for(let i=0;i<TN;i++){
    const t=tufts[i];
    if(t.life>0){
      t.life-=dt;
      const tx=W.dx*(2+W.s*7),tz=W.dz*(2+W.s*7);
      t.vx+=(tx-t.vx)*1.4*dt+(Math.random()-.5)*2*dt;t.vz+=(tz-t.vz)*1.4*dt+(Math.random()-.5)*2*dt;
      t.vy+=(-0.5-t.vy*0.6)*dt+Math.sin(now*3+i)*0.6*dt;
      t.x+=t.vx*dt;t.y+=t.vy*dt;t.z+=t.vz*dt;
      const gy=height(t.x,t.z)+0.04;if(t.y<gy){t.y=gy;t.vy=Math.abs(t.vy)*0.4}
    }
    const a=t.life>0?Math.min(1,t.life/1.2)*0.85:0;
    for(let j=0;j<TP;j++){
      const k=i*TP+j,o=t.off[j],wob=1+0.4*Math.sin(now*5+j+i);
      tPos[k*3]=t.x+o[0]*wob;tPos[k*3+1]=t.y+o[1]*wob;tPos[k*3+2]=t.z+o[2]*wob;
      tCol[k*4]=0.6;tCol[k*4+1]=0.88;tCol[k*4+2]=0.9;tCol[k*4+3]=a;
    }
  }
  tuftGeo.attributes.position.needsUpdate=true;tuftGeo.attributes.color.needsUpdate=true;
}
const _inv=new THREE.Matrix4(),_m3=new THREE.Matrix3();
function shedTuft(){
  const cand=[];let gone=0;
  for(let g=0;g<GN-1;g++){if(fig.gArr[g]>0.99)cand.push(g);else gone++}
  if(!cand.length||gone>=8||!fig.seeds.length)return;
  const g=cand[Math.floor(Math.random()*cand.length)];
  fig.gArr[g]=0;fig.regrow[g]=0;
  const v=fig.seeds[g].clone();fig.body.localToWorld(v);
  spawnTuft(v.x,v.y,v.z);
}
function updateFur(dt,now){
  for(const f of fig.furList){
    const U=f.U;U.uTime.value=now;
    _inv.copy(f.mesh.matrixWorld).invert();_m3.setFromMatrix4(_inv);
    U.uWindL.value.set(W.x,0,W.z).applyMatrix3(_m3);
    U.uGravL.value.set(0,-1,0).applyMatrix3(_m3).normalize();
  }
  if(Math.random()<dt*2.6*W.s*W.s*S.shed/100)shedTuft();
  for(let g=0;g<GN-1;g++){
    if(fig.gArr[g]<1){fig.regrow[g]+=dt;if(fig.regrow[g]>4)fig.gArr[g]=Math.min(1,fig.gArr[g]+dt/18)}
  }
}

function buildFigure(P){
  P.ryE=P.ry+P.cap;
  const root=new THREE.Group(),slope=new THREE.Group(),rig=new THREE.Group();
  root.add(slope);slope.add(rig);
  const mat=new THREE.MeshPhysicalMaterial({color:0x16161a,roughness:0.5,clearcoat:0.2,clearcoatRoughness:0.5,envMap:envTex,envMapIntensity:0.3});
  addNoiseBump(mat,{freq:180,amp:0.0004,world:false,near:3,far:7});
  const black=new THREE.MeshStandardMaterial({color:0xf6f6f6,roughness:0.25,envMap:envTex,envMapIntensity:0.6});

  // body: sphere -> pillowy form (puff, pear taper, waist, bend, low-freq lumps)
  let bg=new THREE.SphereGeometry(1,64,48);
  bg.deleteAttribute('normal');bg.deleteAttribute('uv');bg=mergeVertices(bg);
  const bp=bg.attributes.position,pw=v=>Math.sign(v)*Math.pow(Math.abs(v),P.puff);
  for(let i=0;i<bp.count;i++){
    const x=pw(bp.getX(i)),y=pw(bp.getY(i)),z=pw(bp.getZ(i));
    const sx=1-P.pear*y;
    const n=1+P.bumps*perlin(x*1.6+P.seed,y*1.6+z*1.3);
    bp.setXYZ(i,x*sx*P.rx*n,(y*P.ry+P.cap*clamp(y/0.18,-1,1))*n,z*sx*P.rz*n);
  }
  bg.computeVertexNormals();
  const body=new THREE.Mesh(bg,mat);body.castShadow=true;rig.add(body);
  root.updateMatrixWorld(true);

  const ray=new THREE.Raycaster();
  const surf=(x,y)=>{
    ray.set(new THREE.Vector3(x,y,3),new THREE.Vector3(0,0,-1));
    const h=ray.intersectObject(body,false)[0];
    return h?{p:h.point.clone(),n:h.face.normal.clone()}:{p:new THREE.Vector3(x,y,P.rz),n:new THREE.Vector3(0,0,1)};
  };
  // button eyes, placed on the body surface by raycast
  const ex=P.rx*P.eyeSep,ey=P.ryE*P.eyeY,eyes=[];
  for(const sd of[-1,1]){
    const s=surf(sd*ex,ey);
    const e=new THREE.Mesh(new THREE.SphereGeometry(P.eyeR,20,14),black);
    e.scale.set(1,1,0.55);e.position.copy(s.p).addScaledVector(s.n,0.002);
    e.quaternion.setFromUnitVectors(new THREE.Vector3(0,0,1),s.n);
    body.add(e);eyes.push(e);
  }

  // limbs: capsule hanging from a ball-joint pivot
  const limb=(r,length,foot)=>{
    const g=new THREE.Group(),cg=new THREE.CapsuleGeometry(r,length,6,16);
    cg.translate(0,-length/2,0);
    const m=new THREE.Mesh(cg,mat);m.castShadow=true;g.add(m);
    if(foot){
      const f=new THREE.Mesh(new THREE.SphereGeometry(1,20,14),mat);
      f.scale.set(r*1.15,r*0.8,r*1.55);f.position.set(0,-length-r*0.35,r*0.6);f.castShadow=true;
      g.add(f);g.userData.foot=f;
    }
    return g;
  };
  const limbs=[];
  const legLength=P.legReach-1.15*P.legR,hipY=-P.ryE*P.hip;
  for(const sd of[1,-1]){const g=limb(P.legR,legLength,true);g.position.set(sd*P.rx*0.42,hipY,0.03);rig.add(g);limbs.push({g,side:sd,leg:true,s:{x:0,v:0},o:{x:0,v:0},ts:0,to:0,k:400,c:26});}
  for(const sd of[1,-1]){const g=limb(P.armR,P.armLen,false);g.position.set(sd*P.rx*0.84,P.ryE*P.armY,0);rig.add(g);limbs.push({g,side:sd,leg:false,s:{x:0,v:0},o:{x:0,v:0},ts:0,to:0,k:100,c:6.5});}
  // strand fur on body, arms and legs (short around the eyes)
  const gArr=new Float32Array(GN).fill(1),regrow=new Float32Array(GN),baseCol=new THREE.Color().setHSL(0.5,0.32,0.87);
  const fLen=S.furLen/100,dens=S.furDens/100,furList=[];
  const eyeP=eyes.map(e=>e.position.clone()),eR=P.eyeR;
  const eyeDist=p=>Math.min(p.distanceTo(eyeP[0]),p.distanceTo(eyeP[1]));
  const mkFur=(mesh,count,opt)=>{
    const U={uTime:{value:0},uWindL:{value:new THREE.Vector3()},uGravL:{value:new THREE.Vector3(0,-1,0)},uG:{value:gArr}};
    const f=makeFur(mesh,Math.round(count*dens),fLen,baseCol,opt);
    const fm=new THREE.Mesh(f.geo,furMaterial(U));fm.frustumCulled=false;mesh.add(fm);
    furList.push({mesh:fm,U});return f;
  };
  const bodyFur=mkFur(body,11000,{groups:true,skip:p=>eyeDist(p)<eR*2.4,lenAt:p=>0.1+0.9*sstep(eR*2.4,eR*5,eyeDist(p))});
  for(const l of limbs){
    const cm=l.g.children[0];
    if(l.leg)mkFur(cm,1300,{skip:p=>p.y>-P.legR*0.6});
    else mkFur(cm,600,{skip:p=>p.y>-P.armR*0.3});
  }
  return{root,slope,rig,body,eyes,limbs,hipY,furList,gArr,regrow,seeds:bodyFur.seeds};
}
function setFigure(){
  if(fig){scene.remove(fig.root);fig.root.traverse(o=>{if(o.geometry)o.geometry.dispose()});}
  fig=buildFigure(P);scene.add(fig.root);
}
setFigure();

/* ---------- state ---------- */
const F={x:0,z:0,yaw:0,vx:0,vz:0,pvx:0,pvz:0,state:'walk',t:0,phase:0,pitch:0,roll:0,rollV:0,pitch0:0,roll0:0,
  fa:0,fv:0,dir:1,axis:'pitch',maxq:1.4,failType:'trip',fallSide:1,recover:false,
  hop:0,hopV:0,tripIn:S.interval,tripTot:S.interval,slipIn:S.slipInterval,slipTot:S.slipInterval,guilt:0,D:0,dT:0,dSw:2,B:0.15,bT:0.2,bSw:3,settled:false,lying:0,cool:0,dcool:0,rcool:0,h0:0,dseq:[],
  sayOn:false,sayT:0,sayBase:0,sayKind:'sorry',knockCool:0,
  sq:{x:0,v:0},sa:0,sb:0,blink:2,bt:1,stepN:[NaN,NaN],tripSide:1};
const cam={yaw:0,pitch:0.38,dist:4.2,tx:0,ty:0,tz:0};
const keys={};
addEventListener('keydown',e=>{
  const k=e.key.toLowerCase();keys[k]=true;
  if(k.startsWith('arrow')||k===' ')e.preventDefault();
  if(e.repeat)return;
  if(k==='r'){P=randomParams();setFigure();}
  if(k==='t'&&F.state==='walk')startTrip();
  if(k==='p')togglePanel();
  if(k==='h')hud.style.display=hud.style.display==='none'?'':'none';
});
addEventListener('keyup',e=>{keys[e.key.toLowerCase()]=false});
const key=k=>!!keys[k];
let drag=false,lx=0,ly=0;
const cv=renderer.domElement;
cv.addEventListener('pointerdown',e=>{drag=true;lx=e.clientX;ly=e.clientY;cv.setPointerCapture(e.pointerId)});
cv.addEventListener('pointerup',()=>drag=false);
cv.addEventListener('pointermove',e=>{if(!drag)return;cam.yaw-=(e.clientX-lx)*0.006;cam.pitch=clamp(cam.pitch+(e.clientY-ly)*0.004,0.08,1.2);lx=e.clientX;ly=e.clientY});
cv.addEventListener('wheel',e=>{cam.dist=clamp(cam.dist+e.deltaY*0.004,2.2,8)},{passive:true});
addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight)});

const sayEl=document.getElementById('say');
const hud=document.createElement('div');hud.id='hud';document.body.appendChild(hud);
const bars={};
for(const[k,label]of[['dream','verträumt'],['wob','schwankend'],['trip','stolpern'],['slip','rutschen'],['guilt','gewissen'],['wind','wind']]){
  const r=document.createElement('div');r.className='hr';
  r.innerHTML='<span>'+label+'</span><i><b></b></i>';hud.appendChild(r);bars[k]=r.querySelector('b');
}
const rndTrip=()=>S.interval*(0.6+Math.random()*0.8);
const rndSlip=()=>S.slipInterval*(0.6+Math.random()*0.8);
function startTrip(type){
  type=type||['trip','slip','skid','wobble'][Math.floor(Math.random()*4)];
  F.state='trip';F.t=0;F.failType=type;F.tripSide=Math.random()<.5?1:-1;F.fallSide=Math.random()<.5?1:-1;F.roll=0;F.pitch=0;
  F.recover=Math.random()*100>=S.fallPct;F.sayOn=false;
  if(type==='skid'&&Math.hypot(F.vx,F.vz)>0.3){F.vx*=1.35;F.vz*=1.35}
}
function toWalk(){F.state='walk';F.t=0;F.cool=1.2;F.tripIn=Math.max(F.tripIn,2);F.slipIn=Math.max(F.slipIn,2)}
function startSay(chance){
  const g=F.guilt;
  if(Math.random()*100>=Math.max(S.say*chance,g*100*chance))return;
  F.sayOn=true;F.sayT=0;F.sayBase=F.yaw;
  F.sayKind=Math.random()<lerp(1-S.ups/100,1,g)?'sorry':'ups';
  sayEl.textContent=F.sayKind==='ups'?'Ups.':"I'm Sorry.";
  F.guilt*=0.75;
}
function beginFall(speed){
  const ty=F.failType,sv=Math.min(speed,3)*0.5;
  F.guilt=Math.min(1,F.guilt+0.45*S.guilt/100);
  F.state='fall';F.t=0;F.settled=false;F.lying=0;F.hop=0;F.hopV=0;F.rollV=(Math.random()-.5)*1.6;
  if(ty==='trip'){F.axis='pitch';F.dir=1;F.maxq=1.4;F.fa=0.55;F.fv=1.6+sv}
  else if(ty==='slip'||ty==='skid'){F.axis='pitch';F.dir=-1;F.maxq=0.62;F.fa=-(ty==='slip'?0.45:0.3);F.fv=-(1.6+sv)}
  else if(Math.random()<0.55){F.axis='roll';F.dir=F.fallSide;F.maxq=1.3;F.fa=F.dir*0.3;F.fv=F.dir*1.5}
  else{F.axis='pitch';F.dir=-1;F.maxq=0.62;F.fa=-0.3;F.fv=-1.6}
}
function startDither(h0){
  F.state='dither';F.t=0;F.h0=h0;F.dseq=[];F.sayOn=false;
  let s=Math.random()<.5?-1:1;const n=2+Math.floor(Math.random()*3);
  for(let k=0;k<n;k++){F.dseq.push(s*(0.55+Math.random()*0.35));s=-s}
  F.dseq.push(s*0.95);
}
function stepSpring(sp,target,k,c,dt){sp.v+=(-k*(sp.x-target)-c*sp.v)*dt;sp.x+=sp.v*dt}
function stamp(i,now){
  const f=fig.limbs[i].g.userData.foot;f.getWorldPosition(_v);
  const x=_v.x,z=_v.z,n=normalAt(x,z);
  _qa.setFromUnitVectors(UP,n);_qb.setFromAxisAngle(UP,F.yaw);_qa.multiply(_qb);
  _v.set(x,height(x,z)+0.035,z);_s.set(P.legR*0.95,1,P.legR*1.5);
  _m.compose(_v,_qa,_s);fp.setMatrixAt(fpI,_m);fpBorn[fpI]=now;
  fpI=(fpI+1)%FPN;fpCount=Math.min(fpCount+1,FPN);fp.count=fpCount;fp.instanceMatrix.needsUpdate=true;
}

/* ---------- wind: coarse streak particles + gusts ---------- */
const W={dx:1,dz:0,s:0,x:0,z:0};
function updateWindState(now){
  const a=0.6+0.7*perlin(now*0.035,21.7);W.dx=Math.cos(a);W.dz=Math.sin(a);
  W.s=(S.wind/100)*clamp(0.55+0.75*perlin(now*0.22,3.3)+0.25*perlin(now*0.8,8.1),0,1.3);
  W.x=W.dx*W.s;W.z=W.dz*W.s;
  GLOW.value.set(-W.dx,-W.dz,0);
}
const WN=150,wPos=new Float32Array(WN*6),wGeo=new THREE.BufferGeometry(),wP=[];
for(let i=0;i<WN;i++)wP.push({x:(Math.random()-.5)*28,z:(Math.random()-.5)*28,y:0.1+Math.random()*3,sp:0.7+Math.random()*0.6,ph:Math.random()*6.28});
wGeo.setAttribute('position',new THREE.BufferAttribute(wPos,3));
const wLines=new THREE.LineSegments(wGeo,new THREE.LineBasicMaterial({color:0xffffff,transparent:true,opacity:0.7}));
wLines.frustumCulled=false;scene.add(wLines);
function updateWind(dt,now){
  for(let i=0;i<WN;i++){
    const p=wP[i],v=(1.5+W.s*9)*p.sp;
    p.x+=(W.dx*v+Math.sin(now*1.3+p.ph)*0.6)*dt;p.z+=(W.dz*v+Math.cos(now*1.1+p.ph)*0.6)*dt;
    const rx=p.x-F.x,rz=p.z-F.z;
    if(rx>14)p.x-=28;else if(rx<-14)p.x+=28;
    if(rz>14)p.z-=28;else if(rz<-14)p.z+=28;
    const y=height(p.x,p.z)+p.y+0.15*Math.sin(now*2+p.ph),len=(0.12+W.s*0.9)*p.sp,k=i*6;
    wPos[k]=p.x;wPos[k+1]=y;wPos[k+2]=p.z;
    wPos[k+3]=p.x-W.dx*len;wPos[k+4]=y;wPos[k+5]=p.z-W.dz*len;
  }
  wGeo.attributes.position.needsUpdate=true;
}

/* ---------- rolling stones (blown by wind, knock the figure over) ---------- */
const RN=24;
const rollGeo=new THREE.IcosahedronGeometry(1,1);
{
  const p=rollGeo.attributes.position,v=new THREE.Vector3();
  for(let i=0;i<p.count;i++){v.fromBufferAttribute(p,i);v.multiplyScalar(1+0.14*perlin(v.x*2.1+9,v.y*2.1+v.z*1.7));p.setXYZ(i,v.x,v.y,v.z)}
  rollGeo.computeVertexNormals();
}
const rollMesh=new THREE.InstancedMesh(rollGeo,rockMat,RN);
rollMesh.castShadow=rollMesh.receiveShadow=true;rollMesh.frustumCulled=false;scene.add(rollMesh);
const rollers=[];
const _fr=new THREE.Frustum(),_pm=new THREE.Matrix4(),_sph=new THREE.Sphere(),_v2=new THREE.Vector3();
function respawnRoller(R){
  R.r=0.08+Math.random()*0.1;R.slow=0;R.cool=0;R.active=true;
  camera.updateMatrixWorld();
  _pm.multiplyMatrices(camera.projectionMatrix,camera.matrixWorldInverse);_fr.setFromProjectionMatrix(_pm);
  let x=0,z=0,ok=false;
  for(let t=0;t<12&&!ok;t++){
    const back=20+Math.random()*(14+t*2),lat=Math.random()<0.4?(Math.random()-.5)*4:(Math.random()-.5)*34;
    x=F.x-W.dx*back-W.dz*lat;z=F.z-W.dz*back+W.dx*lat;
    _sph.set(_v2.set(x,height(x,z),z),2.5);ok=!_fr.intersectsSphere(_sph);
  }
  if(!ok){const back=46,lat=(Math.random()-.5)*34;x=F.x-W.dx*back-W.dz*lat;z=F.z-W.dz*back+W.dx*lat}
  R.x=x;R.z=z;const sp=1.8+Math.random()*1.6;
  R.vx=W.dx*sp+(Math.random()-.5)*0.6;R.vz=W.dz*sp+(Math.random()-.5)*0.6;
}
for(let i=0;i<RN;i++){
  const R={x:0,z:0,vx:0,vz:0,r:.1,slow:0,cool:0,active:false,q:new THREE.Quaternion().random()};
  rollers.push(R);
  const g=0.55+Math.random()*0.1;rollMesh.setColorAt(i,new THREE.Color().setRGB(g,g+0.01,g+0.03));
}
const _qr=new THREE.Quaternion(),_ax=new THREE.Vector3(),_sc=new THREE.Vector3(),_pp=new THREE.Vector3(),_mm=new THREE.Matrix4();
function startKnock(px,pz,power){
  const c=Math.cos(F.yaw),s=Math.sin(F.yaw),plx=c*px-s*pz,plz=s*px+c*pz,sv=Math.min(power,3);
  F.state='fall';F.t=0;F.settled=false;F.lying=0;F.hop=0;F.hopV=0.9;F.sayOn=false;F.failType='knock';F.pitch=0;F.roll=0;F.rollV=(Math.random()-.5)*1.2;
  if(Math.abs(plz)>=Math.abs(plx)){F.axis='pitch';F.dir=plz>0?1:-1;F.maxq=F.dir>0?1.4:0.62}
  else{F.axis='roll';F.dir=plx>0?-1:1;F.maxq=1.3}
  F.fa=F.dir*0.15;F.fv=F.dir*(2.2+sv*0.6);
  F.vx+=px*0.5;F.vz+=pz*0.5;F.sq.v+=3;
  for(const l of fig.limbs)l.s.v+=(Math.random()-.5)*8;
  F.guilt=Math.min(1,F.guilt+0.15*S.guilt/100);F.knockCool=2.5;
}
function updateRollers(dt){
  const n=Math.min(RN,Math.round(S.rollers));
  for(let i=n;i<RN;i++)rollers[i].active=false;
  for(let i=0;i<n;i++){
    const R=rollers[i];
    if(!R.active)respawnRoller(R);
    R.cool-=dt;
    const nr=normalAt(R.x,R.z);
    R.vx+=(7*nr.x/nr.y+W.dx*1.8*W.s)*dt;R.vz+=(7*nr.z/nr.y+W.dz*1.8*W.s)*dt;
    const dr=Math.exp(-0.55*dt);R.vx*=dr;R.vz*=dr;
    R.x+=R.vx*dt;R.z+=R.vz*dt;
    for(const rk of rockList){
      const ex=R.x-rk.x,ez=R.z-rk.z,rr=R.r+rk.r*0.9,d2=ex*ex+ez*ez;
      if(d2<rr*rr&&d2>1e-6){
        const d=Math.sqrt(d2),nx=ex/d,nz=ez/d,vn=R.vx*nx+R.vz*nz;
        R.x=rk.x+nx*rr;R.z=rk.z+nz*rr;
        if(vn<0){R.vx-=1.6*vn*nx;R.vz-=1.6*vn*nz}
      }
    }
    // against the figure
    const fx=F.x-R.x,fz=F.z-R.z,d=Math.hypot(fx,fz),rr=R.r+0.27;
    if(d<rr&&d>1e-6){
      const ux=fx/d,uz=fz/d,cs=(R.vx-F.vx)*ux+(R.vz-F.vz)*uz;
      if(F.state==='fall'||F.state==='getup'){
        R.x=F.x-ux*rr;R.z=F.z-uz*rr;if(cs>0){R.vx*=0.6;R.vz*=0.6}
      }else if(R.cool<=0&&F.knockCool<=0&&cs>0.35){
        const power=cs*Math.pow(R.r/0.13,1.2);
        R.cool=1.2;
        if(power>1.4)startKnock(R.vx,R.vz,power);
        else if(power>0.6){F.vx+=R.vx*0.4;F.vz+=R.vz*0.4;startTrip('wobble');F.recover=true;F.knockCool=1.5}
        else{F.vx+=R.vx*0.3;F.vz+=R.vz*0.3}
        R.vx=R.vx*0.55+(Math.random()-.5)*0.8;R.vz=R.vz*0.55+(Math.random()-.5)*0.8;
        R.x=F.x-ux*rr;R.z=F.z-uz*rr;
      }
    }
    const sp=Math.hypot(R.vx,R.vz);
    if(sp>0.01){_ax.set(R.vz,0,-R.vx).normalize();_qr.setFromAxisAngle(_ax,sp/R.r*dt);R.q.premultiply(_qr)}
    R.slow=sp<0.2?R.slow+dt:0;
    if(Math.hypot(R.x-F.x,R.z-F.z)>58||R.slow>4)respawnRoller(R);
    _pp.set(R.x,height(R.x,R.z)+R.r*0.9,R.z);_sc.set(R.r,R.r,R.r);_mm.compose(_pp,R.q,_sc);rollMesh.setMatrixAt(i,_mm);
  }
  rollMesh.count=n;rollMesh.instanceMatrix.needsUpdate=true;
  if(rollMesh.instanceColor)rollMesh.instanceColor.needsUpdate=true;
}

/* ---------- update ---------- */
function update(dt,now){
  F.t+=dt*({getup:S.getup,trip:S.fall,fall:S.fall}[F.state]||1);
  F.cool-=dt;F.dcool-=dt;F.rcool-=dt;F.knockCool-=dt;
  updateWindState(now);
  const legs=[fig.limbs[0],fig.limbs[1]],arms=[fig.limbs[2],fig.limbs[3]];
  const ctrl=F.state==='walk';

  // camera-relative input
  const kx=(key('d')||key('arrowright')?1:0)-(key('a')||key('arrowleft')?1:0);
  const kz=(key('w')||key('arrowup')?1:0)-(key('s')||key('arrowdown')?1:0);
  if(key('q'))cam.yaw+=1.8*dt;if(key('e'))cam.yaw-=1.8*dt;
  let dx=0,dz=0,has=false;
  if(ctrl&&(kx||kz)){
    const sy=Math.sin(cam.yaw),cy=Math.cos(cam.yaw);
    dx=-sy*kz+cy*kx;dz=-cy*kz-sy*kx;const l=Math.hypot(dx,dz);dx/=l;dz/=l;has=true;
  }

  // velocity per state
  F.pvx=F.vx;F.pvz=F.vz;
  F.guilt=Math.max(0,F.guilt-dt*0.025);
  const spd0=Math.hypot(F.vx,F.vz);
  if(F.state==='walk'){
    // dreaminess (wandering off course) and sway (balance) drift on their own
    F.dSw-=dt;if(F.dSw<=0){F.dSw=4+Math.random()*5;F.dT=(Math.random()<0.55?Math.random():0)*S.dream/100*(1-0.6*F.guilt)}
    F.D+=clamp(F.dT-F.D,-0.2*dt,0.2*dt);
    F.bSw-=dt;if(F.bSw<=0){F.bSw=3+Math.random()*4;F.bT=Math.random()<S.wobbleRate/100*0.5?0.97+Math.random()*0.03:Math.random()*0.45}
    F.B+=clamp((spd0>0.4?F.bT:0.1)-F.B,-0.25*dt,0.25*dt);
    F.B=Math.min(1,F.B+W.s*0.04*dt);
    if(has){const hh=Math.atan2(dx,dz)+perlin(now*0.35,11.3)*1.2*F.D;dx=Math.sin(hh);dz=Math.cos(hh)}
  }
  const sp=(key('shift')?S.run:S.walk)*(1-0.25*F.guilt)*(1-0.2*F.D);
  if(F.state==='walk'){
    const k=1-Math.exp(-7*dt);
    F.vx+=(dx*sp-F.vx)*k;F.vz+=(dz*sp-F.vz)*k;
    if(F.sayOn){
      // saying it halfway through the motion: only turn halfway towards the camera
      const camA=Math.atan2(camera.position.x-F.x,camera.position.z-F.z);
      const base=has?Math.atan2(dx,dz):F.sayBase;
      const w=sstep(0,0.15,F.sayT)*(1-sstep(0.8,1.1,F.sayT));
      F.yaw+=angDiff(F.yaw,base+0.5*angDiff(base,camA)*w)*(1-Math.exp(-10*dt));
    }else if(has)F.yaw+=angDiff(F.yaw,Math.atan2(dx,dz))*(1-Math.exp(-12*dt));
    else if(spd0>0.7)F.yaw+=angDiff(F.yaw,Math.atan2(F.vx,F.vz))*(1-Math.exp(-2.5*dt));
  }else{
    let damp=4;
    if(F.state==='trip')damp=(F.failType==='slip'||F.failType==='skid')?0.3:0.7;
    else if(F.state==='fall')damp=(F.axis==='pitch'&&F.dir>0)?1.8:1.0;
    else damp={getup:6,dither:6,recover:2.5}[F.state]||4;
    F.vx*=Math.exp(-damp*dt);F.vz*=Math.exp(-damp*dt);
  }
  // wind pushes the figure around, walking or idle
  {const wk={walk:1,dither:0.5,trip:0.5,recover:0.5,fall:0.15,getup:0.15}[F.state]||1;F.vx+=W.x*11*wk*dt;F.vz+=W.z*11*wk*dt}
  F.x+=F.vx*dt;F.z+=F.vz*dt;
  let hit=false;
  for(const r of rockList){
    const ddx=F.x-r.x,ddz=F.z-r.z,rr=r.r+0.25,d2=ddx*ddx+ddz*ddz;
    if(d2<rr*rr&&d2>1e-6){const d=Math.sqrt(d2);F.x=r.x+ddx/d*rr;F.z=r.z+ddz/d*rr;hit=true}
  }
  updateRollers(dt);
  const speed=Math.hypot(F.vx,F.vz);
  if(F.state==='walk'){
    if(speed>0.9){F.tripIn-=dt;F.slipIn-=dt}
    if(F.cool<=0){
      if(hit&&speed>1.0)startTrip('trip');
      else if(F.tripIn<=0){F.tripTot=F.tripIn=rndTrip();startTrip('trip')}
      else if(F.slipIn<=0){F.slipTot=F.slipIn=rndSlip();startTrip(Math.random()*100<S.skidShare?'skid':'slip')}
      else if(F.B>=0.96&&speed>0.4){F.B=0.15;F.bT=0.2;F.bSw=3+Math.random()*3;startTrip('wobble')}
    }
    if(F.state==='walk'&&!F.sayOn&&F.guilt>0.55&&Math.random()<dt*0.16*(F.guilt-0.5))startSay(1);
    // indecision: a rock straight ahead
    if(F.state==='walk'&&speed>0.7&&F.dcool<=0&&F.rcool<=0){
      const ux=F.vx/speed,uz=F.vz/speed;
      for(const r of rockList){
        const tx=r.x-F.x,tz=r.z-F.z,dist=Math.hypot(tx,tz),gap=dist-r.r-0.25;
        if(gap<2.0&&gap>0.15&&(tx*ux+tz*uz)/dist>0.8){
          F.rcool=1.2;
          if(Math.random()*100<S.dither){F.dcool=6;startDither(Math.atan2(ux,uz))}
          break;
        }
      }
    }
  }

  // pose targets
  let wa=0,A=0,lean=0,twist=0,wad=0,bow=0,lk=420,lc=28,ak=100,ac=6.5;
  const lift=[0,0];
  const cycle=(A_,wa_,liftH)=>{
    for(let i=0;i<2;i++){
      const p=F.phase+i*Math.PI;
      legs[i].ts=A_*Math.sin(p);legs[i].to=0.06;
      lift[i]=liftH*Math.max(0,Math.cos(p))*wa_;
      arms[i].ts=-0.85*A_*Math.sin(p);arms[i].to=0.35+0.1*wa_;
    }
  };
  switch(F.state){
    case'walk':{
      wa=sstep(0.08,0.5,speed);A=clamp(0.22+0.22*speed,0.22,0.85)*wa;
      const Ls=2*P.legReach*Math.sin(Math.max(A,0.2));
      F.phase+=dt*0.85*Math.PI*speed/Ls;
      cycle(A,wa,Math.min(0.075,0.03+0.02*speed));
      lean=0.05*Math.min(speed/1.4,1.6);wad=0.11*wa*Math.sin(F.phase);twist=0.1*wa*Math.sin(F.phase);
      wad+=0.13*F.B*Math.sin(now*4.3)*(speed>0.3?1:0.4);twist+=0.08*F.D*Math.sin(now*1.3);ak=100-55*F.D;
      {const wyc=Math.cos(F.yaw),wys=Math.sin(F.yaw),wlx=wyc*W.x-wys*W.z,wlz=wys*W.x+wyc*W.z;
       lean+=0.2*wlz;wad-=0.2*wlx;twist+=0.05*W.s*Math.sin(now*9);
       for(let i=0;i<2;i++)arms[i].ts+=0.5*W.s*Math.sin(now*11+i*2)}
      if(F.sayOn){
        F.sayT+=dt;
        const b=Math.sin(Math.PI*clamp((F.sayT-0.05)/0.75,0,1));
        bow=0.18*b*(1+F.guilt);ak=150;ac=11;
        if(F.sayKind==='sorry'){for(let i=0;i<2;i++){arms[i].ts=0.3+1.3*b;arms[i].to=0.3-0.12*b}}
        else{arms[1].ts=0.3+1.5*b;arms[1].to=0.5-0.3*b;twist+=0.18*b}
        if(F.sayT>1.15)F.sayOn=false;
      }
      break;
    }
    case'dither':{
      const dT=0.42*S.ditherLen,idx=Math.floor(F.t/dT);
      if(idx>=F.dseq.length){
        const hh=F.h0+F.dseq[F.dseq.length-1];
        F.vx=Math.sin(hh)*S.walk*0.6;F.vz=Math.cos(hh)*S.walk*0.6;
        toWalk();break;
      }
      const dd=angDiff(F.yaw,F.h0+F.dseq[idx]);
      F.yaw+=dd*(1-Math.exp(-9*dt));
      wa=clamp(Math.abs(dd)*2,0,1);A=0.3*wa;F.phase+=dt*12*wa;
      cycle(A,wa,0.025);
      wad=-0.16*clamp(dd*2,-1,1);twist=0.1*Math.sin(F.t*7);
      for(let i=0;i<2;i++){arms[i].to=0.75;arms[i].ts=0.3+0.2*Math.sin(F.t*6+i*2)}
      break;
    }
    case'trip':{
      const ty=F.failType,T={trip:0.6,slip:0.3,wobble:0.7,skid:0.45}[ty],rk=F.recover?0.6:1;
      const u=Math.min(F.t/T,1),f=2*Math.PI*3.2*F.t;
      lk=240;lc=18;ak=60;ac=4;
      if(ty==='trip'){
        legs[0].ts=0.5+0.5*Math.sin(f);legs[1].ts=0.5+0.5*Math.sin(f+Math.PI*0.8);
        legs[0].to=legs[1].to=-(0.15+0.55*u);
        if(F.tripSide<0){const t=legs[0].ts;legs[0].ts=legs[1].ts;legs[1].ts=t}
        for(let i=0;i<2;i++){arms[i].ts=0.8+1.6*Math.sin(f*0.8+i*1.8);arms[i].to=0.9}
        F.pitch=rk*(0.55*u*u+0.05*Math.sin(f));wad=0.12*Math.sin(f*0.5);
      }else if(ty==='slip'){
        for(let i=0;i<2;i++){legs[i].ts=1.3;legs[i].to=0.3;arms[i].ts=2.2+0.3*Math.sin(f+i);arms[i].to=0.9}
        F.pitch=-0.45*u*rk;
      }else if(ty==='wobble'){
        F.phase+=dt*14;cycle(0.35,1,0.025);
        for(let i=0;i<2;i++){arms[i].ts=0.6+1.8*Math.sin(f*0.9+i*Math.PI);arms[i].to=1.0}
        F.roll=0.4*Math.sin(f*0.8)*Math.min(1,u*3);F.pitch=0.1*Math.sin(f*0.6);
      }else{
        for(let i=0;i<2;i++){legs[i].ts=0.9;legs[i].to=0.45;arms[i].ts=0.4+0.5*Math.sin(f+i*2);arms[i].to=1.0}
        F.pitch=-0.3*u*rk;
      }
      if(F.t>=T){
        if(F.recover){F.state='recover';F.t=0;F.pitch0=F.pitch;F.roll0=F.roll}
        else beginFall(speed);
      }
      break;
    }
    case'recover':{
      const T=0.4,x=Math.min(F.t/T,1),e=1-easeOutBack(Math.min(F.t/0.3,1));
      lk=300;lc=22;ak=80;ac=5;
      F.pitch=F.pitch0*e;F.roll=F.roll0*e;
      F.hop=0.06*Math.sin(Math.PI*clamp((F.t-0.05)/0.3,0,1));
      F.phase+=dt*16;cycle(0.45*(1-x),1-x,0.03);
      for(let i=0;i<2;i++){arms[i].ts=0.3+1.2*Math.sin(F.t*18+i*2)*(1-x);arms[i].to=0.35+0.45*(1-x)}
      if(F.t>=T){F.pitch=0;F.roll=0;F.hop=0;F.sq.v+=1.5;F.guilt=Math.min(1,F.guilt+0.2*S.guilt/100);toWalk();startSay(0.6)}
      break;
    }
    case'fall':{
      lk=70;lc=5;ak=50;ac=3.5;
      const g=20*S.fall*S.fall;
      if(!F.settled){
        let q=F.fa*F.dir,qv=F.fv*F.dir;
        qv+=g*Math.sin(Math.min(q,1.5)+0.1)*dt;qv*=Math.exp(-0.4*dt);q+=qv*dt;
        if(q>=F.maxq){
          q=F.maxq;
          if(qv>0.8){
            F.sq.v+=Math.min(6,qv*2.2);F.hopV=Math.min(1.6,0.4*qv);
            for(const l of fig.limbs)l.s.v+=(Math.random()-.5)*7;
          }
          qv=-qv*0.4;
          if(Math.abs(qv)<0.5){qv=0;F.settled=true}
        }
        F.fa=q*F.dir;F.fv=qv*F.dir;
      }else{
        F.lying+=dt*S.getup;
        if(F.lying>0.7){F.state='getup';F.t=0;F.pitch0=F.pitch;F.roll0=F.roll;F.hop=0;F.hopV=0}
      }
      if(F.axis==='pitch'){F.pitch=F.fa;F.rollV+=(-30*F.roll-4*F.rollV)*dt;F.roll+=F.rollV*dt}
      else{F.roll=F.fa;F.pitch=0.1}
      F.hopV-=14*dt;F.hop+=F.hopV*dt;if(F.hop<0){F.hop=0;F.hopV=F.hopV<-1?-F.hopV*0.25:0}
      const tt=F.t,ly=F.lying,st=F.settled;
      if(F.axis==='roll'){
        for(let i=0;i<2;i++){
          arms[i].ts=st?0.5:1.0;arms[i].to=0.9+0.2*Math.sin(tt*12+i);
          legs[i].ts=(st?0.6:0.5)+0.15*Math.sin((st?ly*8:tt*16)+i*Math.PI);legs[i].to=0.3;
        }
      }else if(F.dir<0){
        for(let i=0;i<2;i++){
          arms[i].ts=st?0.2:-0.5+0.4*Math.sin(tt*14+i);arms[i].to=1.05;
          legs[i].ts=1.05+(st?0.1*Math.sin(ly*8+i*Math.PI):0.3*Math.sin(tt*16+i*2));legs[i].to=0.28;
        }
      }else{
        for(let i=0;i<2;i++){
          if(!st){arms[i].ts=1.7+0.3*Math.sin(tt*14+i);arms[i].to=0.5;legs[i].ts=-0.5+0.4*Math.sin(tt*16+i*2);legs[i].to=0.35}
          else{arms[i].ts=1.2+0.2*Math.sin(ly*9+i);arms[i].to=1.0;legs[i].ts=-0.3+0.4*Math.sin(ly*8+i*Math.PI);legs[i].to=0.3}
        }
      }
      break;
    }
    case'getup':{
      const T=0.95,t=F.t,x=Math.min(t/0.8,1),e=1-easeOutBack(x);
      lk=200;lc=16;ak=120;ac=9;
      F.pitch=F.pitch0*e;F.roll=F.roll0*e;
      F.hop=0.14*Math.sin(Math.PI*clamp((t-0.35)/0.5,0,1));
      const ls=t<0.5?-0.4+(t/0.5)*1.1:0.7*(1-clamp((t-0.5)/0.45,0,1));
      const as=t<0.4?1.2-(t/0.4)*2.0:-0.8*(1-clamp((t-0.4)/0.55,0,1))+0.3*clamp((t-0.4)/0.55,0,1);
      for(let i=0;i<2;i++){legs[i].ts=ls;legs[i].to=0.2;arms[i].ts=as;arms[i].to=0.6}
      if(t>=T){F.pitch=0;F.roll=0;F.hop=0;F.sq.v+=2.5;toWalk();startSay(1)}
      break;
    }
  }

  // springs (limbs jiggle with their own inertia)
  const fx=Math.sin(F.yaw),fz=Math.cos(F.yaw);
  const acc=((F.vx-F.pvx)*fx+(F.vz-F.pvz)*fz)/Math.max(dt,1e-4);
  for(const l of legs){l.k=lk;l.c=lc}
  for(const l of arms){l.k=ak;l.c=ac;l.s.v+=clamp(-acc*1.5,-25,25)*dt}
  for(const l of fig.limbs){
    stepSpring(l.s,l.ts,l.k,l.c,dt);stepSpring(l.o,l.to,l.k,l.c,dt);
    l.g.rotation.set(-l.s.x,0,l.side*l.o.x);
    l.g.position.y=fig.hipY+(l.leg?lift[fig.limbs.indexOf(l)]:0);
  }
  stepSpring(F.sq,0,250,14,dt);F.sq.x=clamp(F.sq.x,-0.35,0.45);

  // body height: legs carry it when upright, ellipsoid support when down
  const base=-fig.hipY+P.legReach;
  let d=Infinity;
  for(let i=0;i<2;i++)d=Math.min(d,P.legReach*(1-Math.cos(legs[i].s.x))+lift[i]);
  const u=clamp(1-Math.max(Math.abs(F.pitch),Math.abs(F.roll))/0.7,0,1);
  const cth=Math.cos(F.pitch),sth=Math.sin(F.pitch),cps=Math.cos(F.roll),sps=Math.sin(F.roll);
  const supp=Math.hypot(P.rx*cth*sps,P.ryE*cth*cps,P.rz*sth);
  fig.rig.position.y=lerp(supp,base-d,u)+F.hop;
  fig.rig.rotation.set(F.pitch+lean+bow,twist,F.roll+wad);

  // squash & stretch + breathing
  const br=0.012*Math.sin(now*2.2),sy=1-0.7*F.sq.x+br,sxz=1+0.5*F.sq.x-br*0.5;
  fig.body.scale.set(sxz,sy,sxz);fig.body.position.y=-P.ryE*(1-sy);

  // blink
  F.blink-=dt;if(F.blink<0){F.bt=0;F.blink=2.5+Math.random()*3}
  if(F.bt<1){F.bt=Math.min(1,F.bt+dt/0.16)}
  for(const e of fig.eyes)e.scale.y=1-0.9*Math.sin(Math.PI*F.bt);

  // terrain tilt + placement
  const gh=height(F.x,F.z),nrm=normalAt(F.x,F.z),c=Math.cos(F.yaw),s=Math.sin(F.yaw);
  const nlx=c*nrm.x-s*nrm.z,nlz=s*nrm.x+c*nrm.z;
  F.sa+=(Math.atan2(nlz,nrm.y)-F.sa)*(1-Math.exp(-8*dt));
  F.sb+=(-Math.atan2(nlx,nrm.y)-F.sb)*(1-Math.exp(-8*dt));
  fig.slope.rotation.set(F.sa*0.8,0,F.sb*0.8);
  fig.root.position.set(F.x,gh,F.z);fig.root.rotation.y=F.yaw;
  fig.root.updateMatrixWorld(true);
  updateFur(dt,now);updateTufts(dt,now);

  // footprints on foot contact (leg phase crosses pi/2)
  if(F.state==='walk'||F.state==='dither'){
    for(let i=0;i<2;i++){
      const n=Math.floor((F.phase+i*Math.PI-Math.PI/2)/(Math.PI*2));
      if(F.stepN[i]!==n){
        F.stepN[i]=n;
        if(wa>0.4){stamp(i,now);F.sq.v+=0.9*wa}
      }
    }
  }
  // fade old prints
  for(let i=0;i<fpCount;i++){
    const k=sstep(0,110,now-fpBorn[i]);
    _c.setRGB(lerp(0.62,1,k),lerp(0.68,1,k),lerp(0.78,1,k));fp.setColorAt(i,_c);
  }
  if(fp.instanceColor)fp.instanceColor.needsUpdate=true;

  // world follows player
  updateTerrain(F.x,F.z);updateRocks(F.x,F.z);
  sun.position.set(F.x+sunDir.x*30,gh+sunDir.y*30,F.z+sunDir.z*30);
  sun.target.position.set(F.x,gh,F.z);

  // camera
  const kk=1-Math.exp(-6*dt);
  cam.tx+=(F.x-cam.tx)*kk;cam.ty+=(gh+0.6-cam.ty)*kk;cam.tz+=(F.z-cam.tz)*kk;
  const cp=Math.cos(cam.pitch);
  camera.position.set(cam.tx+Math.sin(cam.yaw)*cp*cam.dist,cam.ty+Math.sin(cam.pitch)*cam.dist,cam.tz+Math.cos(cam.yaw)*cp*cam.dist);
  const minY=height(camera.position.x,camera.position.z)+0.5;
  if(camera.position.y<minY)camera.position.y=minY;
  camera.lookAt(cam.tx,cam.ty,cam.tz);sky.position.copy(camera.position);

  // speech sits right on top of the head (follows the bow)
  _v.set(0,P.ryE*1.0+0.04,0);fig.body.localToWorld(_v);_v.project(camera);
  const hx=(_v.x*0.5+0.5)*innerWidth,hy=(-_v.y*0.5+0.5)*innerHeight;
  sayEl.style.left=hx+'px';sayEl.style.top=hy+'px';
  hud.style.left=hx+'px';hud.style.top=(hy-30)+'px';
  bars.dream.style.width=(F.D*100)+'%';bars.wob.style.width=(F.B*100)+'%';
  bars.trip.style.width=(clamp(1-F.tripIn/F.tripTot,0,1)*100)+'%';
  bars.slip.style.width=(clamp(1-F.slipIn/F.slipTot,0,1)*100)+'%';
  bars.guilt.style.width=(F.guilt*100)+'%';
  bars.wind.style.width=(clamp(W.s/1.3,0,1)*100)+'%';
  updateWind(dt,now);
  sayEl.style.opacity=(F.sayOn&&F.state==='walk'&&F.sayT>0.05&&F.sayT<1.0)?1:0;
}

/* ---------- loop ---------- */
const clock=new THREE.Clock();let tNow=0;
camera.position.set(0,3,5);
function frame(){
  const dt=Math.min(clock.getDelta(),1/30);tNow+=dt;
  update(dt,tNow);
  renderer.render(scene,camera);
  requestAnimationFrame(frame);
}
frame();
</script>
</body>
</html>