${this._esc(d.dot)}`+
`
${this._esc(d.text)} · ${this._esc(d.mood||'')}
`
).join('');
},
renderItems(){
const wrap=this.q('#items'); if(!wrap) return;
const playId=this.action&&this.action.id;
// only (re)build DOM when item set changes; positions are updated live in positionActionItem / drag
if(this._itemsBuilt) { this._applyItemLayout(); return; }
wrap.innerHTML='';
this.itemEls={};
for(const it of this.items){
const div=document.createElement('div');
div.className='item';
div.dataset.id=it.id;
div.setAttribute('data-tip', it.tip);
div.style.width=it.size+'%';
div.style.zIndex='5';
const inner=document.createElement('div');
inner.className='item-inner';
inner.innerHTML=this.ICONS(it.kind);
div.appendChild(inner);
this.itemEls[it.id]=inner;
div.addEventListener('pointerdown',(e)=>this.startDrag(e,it.id));
wrap.appendChild(div);
}
this._itemsBuilt=true;
this._applyItemLayout();
},
_applyItemLayout(){
const playId=this.action&&this.action.id;
for(const it of this.items){
const inner=this.itemEls[it.id]; if(!inner) continue;
const div=inner.closest('[data-id]'); if(!div) continue;
if(it.id===playId) continue; // positionActionItem owns it while playing
div.style.left=it.x+'%'; div.style.top=it.y+'%';
div.style.transform='translate(-50%,-100%)';
div.style.transition=(it.resting)?'left .5s cubic-bezier(.34,1.4,.5,1), top .5s cubic-bezier(.34,1.4,.5,1)':'none';
div.style.zIndex='5';
}
},
// ====================================================================
// MOOD / NEEDS APPLICATION (backend-fed)
// ====================================================================
// map backend appearance.mood[7] into our mood model (lerp like the redesign)
applyAppearance(ap){
if(!ap) return;
const m=ap.mood;
if(Array.isArray(m) && m.length>=7){
const C={v:m[0],a:m[1],d:m[2],u:m[3],g:m[4],w:m[5],i:m[6]};
const before={...this.mood};
['v','a','d','g','w','i'].forEach(k=>{ this.mood[k]=this.mood[k]*0.42 + C[k]*0.58; });
this.mood.u=C.u;
this.lastDelta={};
['v','a','d','u','g','w','i'].forEach(k=>{ this.lastDelta[k]=Math.round(this.mood[k])-Math.round(before[k]); });
}
},
// prefer backend deltas[] for the meter arrows when present
applyDeltas(deltas){
if(Array.isArray(deltas) && deltas.length>=7){
const keys=['v','a','d','u','g','w','i'];
this.lastDelta={}; keys.forEach((k,i)=>this.lastDelta[k]=deltas[i]);
}
},
applyNeeds(needs){
if(!needs) return;
['hunger','energy','attention','thirst'].forEach(k=>{
if(typeof needs[k]==='number') this.needsObj[k]=Math.max(0,Math.min(100,needs[k]));
});
},
applyCrisis(crisis){
if(crisis && typeof crisis.value==='number'){ this.haveCrisis=true; this.crisis=Math.max(0,Math.min(1,crisis.value)); }
},
applySoul(state){
if(Array.isArray(state.soul) && state.soul.length>=7){
const s=state.soul; this.haveSoul=true;
this.soul={v:s[0],a:s[1],d:s[2],u:s[3],g:s[4],w:s[5],i:s[6]};
}
if(typeof state.distance==='number') this.backendDistance=Math.round(state.distance);
},
// single entry point for any backend response (say / care / toy / snapshot)
applyState(state, opts){
opts=opts||{};
this.applyAppearance(state.appearance);
if(opts.useDeltas) this.applyDeltas(state.deltas);
this.applyNeeds(state.needs);
this.applyCrisis(state.crisis); // defensive: absent -> haveCrisis stays false
this.applySoul(state); // defensive: absent -> distance from compute or dash
if(state.mood_word) this.moodWord=state.mood_word;
if(state.read && state.read.length>=7){
this.readPhrase=this.feltPhrase({v:state.read[0],a:state.read[1],d:state.read[2],u:state.read[3],g:state.read[4],w:state.read[5],i:state.read[6]});
}
if(state.trace) this.renderTrace(state.trace);
if(state.why!=null) this.renderWhy(state.why);
// panel repaint
this.renderNeeds(); this.renderCrisis(); this.renderDistance();
this.renderMoodWord(); this.renderReadPhrase();
},
// ====================================================================
// ANIMATION LOOP (ported verbatim from the redesign)
// ====================================================================
step(now){
try{ this._step(now); }catch(err){ if(!this._warned){console.warn('clanker step',err);this._warned=true;} }
this._raf=requestAnimationFrame(this.step.bind(this));
},
_step(now){
const dt=Math.min(0.05,(now-this.lastTs)/1000); this.lastTs=now;
const t=(now-this.t0)/1000;
// disp lerp toward mood (server-authoritative mood already set on each response/poll)
['v','a','d','u','g','w','i'].forEach(k=>{ this.disp[k]+=(this.mood[k]-this.disp[k])*Math.min(1,4*dt); });
this.autoConsume(dt);
this.behavior = this.action ? 'idle' : this.pickBehavior();
if(this.behavior==='tantrum' && performance.now()-this.lastSpeak>1700){ this.lastSpeak=performance.now(); this.speak(); }
this.drawCreature(t);
this.positionActionItem();
this.uiAcc+=dt;
if(this.uiAcc>0.22){ this.uiAcc=0; this.renderMeters(); }
},
drawCreature(t){
const r=this.root; if(!r) return;
const g=(id)=>r.querySelector('#'+id);
const d=this.disp; const aN=(d.a-128)/127, vN=(d.v-128)/127, iN=(d.i-128)/127;
const energy=this.needsObj.energy;
const act=this.action&&this.action.type; const beh=this.behavior;
const sleepy=(energy<24&&aN<0&&beh==='idle'&&!act);
// ---- body transform (bob / slump / shake / breathe) ----
let tx=0, ty=0, sc=1, scY=1, rotB=0;
const breathe=1+Math.sin(t*1.8)*0.012;
if(beh==='tantrum'){
tx=Math.sin(t*42)*5; ty=Math.abs(Math.sin(t*9))*-7; rotB=Math.sin(t*40)*3; sc=1.02;
this.shake=2.6;
} else if(beh==='sad'){
ty=10; scY=0.93; tx=Math.sin(t*0.7)*2; this.shake=0;
} else if(act==='ball'){
ty=Math.sin(t*4.4)*4; tx=Math.sin(t*2.2)*4; this.shake=0;
} else if(act==='teddy'){
ty=Math.sin(t*3)*2; sc=1+Math.max(0,Math.sin(t*5.5))*0.02; this.shake=0;
} else if(act==='balloon'){
ty=Math.sin(t*3.1)*5-2; tx=Math.sin(t*1.7)*6; this.shake=0;
} else if(act==='music'){
tx=Math.sin(t*2.2)*7; rotB=Math.sin(t*2.2)*3; this.shake=0;
} else {
let bobAmp=1.4+Math.max(0,aN)*4.0; if(energy<30)bobAmp*=0.4;
ty=Math.sin(t*(1.5+Math.max(0,aN)*3.2))*bobAmp;
tx=Math.sin(t*0.85)*(1.4+Math.max(0,aN)*1.6);
if(d.g<120)ty+=(120-d.g)/4.5;
if(d.v>166&&d.a>150)rotB=Math.sin(t*6)*3;
this.shake=0;
}
const blob=g('blob'); if(blob) blob.setAttribute('transform',`translate(${tx.toFixed(2)},${ty.toFixed(2)}) rotate(${rotB.toFixed(2)} 100 120) scale(${(sc*breathe).toFixed(3)},${(scY*breathe).toFixed(3)})`);
if(this.stageEl){ const sh=this.shake; this.stageEl.style.transform = sh>0?`translate(${(Math.random()*2-1)*sh}px,${(Math.random()*2-1)*sh}px)`:''; }
// ---- eyes: blink + idle dart + state ----
if(t>this.nextBlink){ this.blinkUntil=t+0.12; this.nextBlink=t+2.4+Math.random()*3; }
const blinking=t