clanker / static /app.js
deucebucket's picture
fix: creature no longer screen-shakes at neutral mood (tantrum FSM tuned for the real engine)
58182e8 verified
Raw
History Blame Contribute Delete
47.7 kB
'use strict';
/* Clanker pet — vanilla controller (no React, no dc-runtime).
Ports the redesign's DCLogic class to a plain object. The on-device fake VADUGWI
engine is GONE; data comes from the real FastAPI backend (/snapshot /say /care /toy
/moments). Animation loop, simlish, WebAudio blips, bowls, toy-play, behavior FSM
and tooltips are ported from the redesign verbatim. self_soothe + recognition
"welcome back" are preserved from the prior production app. */
const App = {
// ---------- state ----------
root: null, stageEl: null, inputEl: null, tipEl: null,
name: 'Huggy',
soundOn: true,
// mood model: backend appearance.mood -> mood; disp lerps toward mood (redesign)
// neutral baseline until the real /snapshot hydrates mood/soul (no mock bias)
soul: {v:128,a:128,d:128,u:0,g:128,w:128,i:128},
mood: {v:128,a:128,d:128,u:0,g:128,w:128,i:128},
disp: {v:128,a:128,d:128,u:0,g:128,w:128,i:128},
haveSoul: false, // backend soul[] present?
backendDistance: null, // backend distance (NEW field; may be absent)
needsObj: {hunger:74, energy:80, attention:66, thirst:78},
lastDelta: {},
crisis: 0,
haveCrisis: false,
diary: [],
t0: performance.now(), lastTs: 0,
nextBlink: 1.2, blinkUntil: 0,
nextDart: 3, dart: 0, _dartEnd: 0,
itemEls: {},
dragId: null, dragWrap: null,
food: 0, water: 0,
action: null, actPos:{x:50,y:52,rot:0,sc:1}, _actEnd: null,
eating: 0, drinking: 0,
behavior: 'idle', shake: 0,
lastSpeak: 0,
uiAcc: 0,
ac: null, _raf: null, _warned: false,
items: [],
readPhrase: 'waiting…',
moodWord: 'calm',
// ====================================================================
// ITEM DEFS (icons + defaults) — backend toy/care names mapped per HOOKUP_SPEC
// ====================================================================
ICONS(kind){
const I = {
balloon:'<svg viewBox="0 0 40 56" width="100%"><path d="M20 2 C30 2 34 14 32 24 C30 34 24 40 20 40 C16 40 10 34 8 24 C6 14 10 2 20 2Z" fill="#ff7aa8"/><path d="M16 10 q4 -4 9 0" stroke="#fff" stroke-width="2" fill="none" opacity=".6"/><path d="M20 40 l-2 6 4 0 z" fill="#ff7aa8"/><path d="M20 46 q-5 6 0 10" stroke="#caa" stroke-width="1.4" fill="none"/></svg>',
ball:'<svg viewBox="0 0 40 40" width="100%"><circle cx="20" cy="20" r="17" fill="#4db5e8"/><path d="M3 20 h34 M20 3 v34" stroke="#fff" stroke-width="2.4" opacity=".85"/><path d="M7 9 q13 11 26 0 M7 31 q13 -11 26 0" stroke="#fff" stroke-width="2" fill="none" opacity=".6"/></svg>',
teddy:'<svg viewBox="0 0 44 44" width="100%"><circle cx="12" cy="9" r="6" fill="#b07a44"/><circle cx="32" cy="9" r="6" fill="#b07a44"/><circle cx="22" cy="24" r="17" fill="#c98c50"/><circle cx="16" cy="21" r="2.3" fill="#3a2410"/><circle cx="28" cy="21" r="2.3" fill="#3a2410"/><ellipse cx="22" cy="28" rx="6" ry="5" fill="#e3bf90"/><circle cx="22" cy="26" r="1.8" fill="#3a2410"/></svg>',
music:'<svg viewBox="0 0 40 38" width="100%"><rect x="4" y="14" width="32" height="22" rx="3" fill="#caa24a"/><rect x="4" y="14" width="32" height="6" rx="3" fill="#a8823a"/><path d="M18 6 v18" stroke="#3a2410" stroke-width="2.4"/><circle cx="16" cy="25" r="3.4" fill="#3a2410"/><path d="M18 6 q8 1 8 7" stroke="#3a2410" stroke-width="2.4" fill="none"/></svg>',
rattle:'<svg viewBox="0 0 36 40" width="100%"><circle cx="18" cy="13" r="11" fill="#7ad0a0"/><circle cx="13" cy="11" r="2" fill="#fff" opacity=".8"/><rect x="15" y="22" width="6" height="16" rx="3" fill="#e0b25a"/></svg>',
mirror:'<svg viewBox="0 0 34 40" width="100%"><ellipse cx="17" cy="14" rx="13" ry="13" fill="#d8c089"/><ellipse cx="17" cy="14" rx="9.5" ry="9.5" fill="#cdeaf2"/><path d="M11 10 q5 -4 11 0" stroke="#fff" stroke-width="2" fill="none" opacity=".7"/><rect x="14" y="26" width="6" height="13" rx="3" fill="#b58f44"/></svg>'
};
return I[kind] || I.ball;
},
// care = backend route+name: ball->/care{play}; others->/toy{<name>}
defaultItems(){
const base = [
{id:'ball', kind:'ball', ep:'care', name:'play', x:30, y:88, size:7.5, tip:'Drag onto Huggy — he bounces it. Spikes arousal & joy.'},
{id:'balloon', kind:'balloon', ep:'toy', name:'balloon', x:14, y:76, size:8.5, tip:'Drag onto Huggy — he bats it around. Raises valence & dominance.'},
{id:'rattle', kind:'rattle', ep:'toy', name:'rattle', x:64, y:90, size:7, tip:'Drag onto Huggy — he shakes it. Bumps arousal & urgency.'},
{id:'music', kind:'music', ep:'toy', name:'musicbox', x:74, y:92, size:8, tip:'Drag onto Huggy — he sways to it. Soothes: arousal down, valence up.'},
{id:'mirror', kind:'mirror', ep:'toy', name:'mirror', x:84, y:88, size:7, tip:'Drag onto Huggy — he admires himself. Nudges self-worth.'},
{id:'teddy', kind:'teddy', ep:'toy', name:'teddy', x:93, y:92, size:10, tip:'Drag onto Huggy — he squeezes it. Comfort: steadies valence & self-worth.'}
];
return base.map(b=>({...b, resting:true, held:false}));
},
// ====================================================================
// PRESENTATIONAL HELPERS (client-side only; no engine math)
// ====================================================================
feltPhrase(C){
const vw = C.v<80?'miserable':C.v<118?'uneasy':C.v<=138?'level':C.v<=180?'warm':'glowing';
const aw = C.a<80?'flat':C.a<118?'settled':C.a<=138?'steady':C.a<=180?'keyed-up':'electric';
const ww = C.w<90?', a bit small':C.w>175?', sure of itself':'';
return `${vw}, ${aw}${ww}`;
},
emoteFrom(d){
if(this.haveCrisis && this.crisis>0.6) return ';_;';
if(d.v<100 && d.a>150) return '>:(';
if(d.v<104 && d.a<110) return 'T_T';
if(d.v<108) return '·︵·';
if(this.needsObj.energy<28) return '-_-';
if(d.v>168 && d.a>150) return '\\^o^/';
if(d.v>156) return '^‿^';
return '·‿·';
},
moodHue(d){
let hue, valN=(d.v-128)/127, aN=(d.a-128)/127;
if(d.v<116){ hue = d.a>138 ? 4 : 212; }
else if(d.v>152){ hue = 128; }
else hue = 46;
const sat = Math.round(54 + Math.abs(valN)*30 + Math.max(0,aN)*14);
return {hue, sat, light:58};
},
// backend role -> [bg, fg, border] + tooltip
roleColor(r){
const c={
EMOTIONAL:['rgba(95,170,255,.16)','#8fc0ff','rgba(95,170,255,.3)'],
SELF_REF :['rgba(255,210,30,.16)','#ffd86a','rgba(255,210,30,.3)'],
NEGATOR :['rgba(255,90,90,.16)','#ff9b9b','rgba(255,90,90,.3)'],
AMPLIFIER:['rgba(95,210,140,.16)','#8fe6b0','rgba(95,210,140,.3)'],
CONNECTOR:['rgba(150,150,150,.12)','#bbb','rgba(150,150,150,.26)'],
CHOPPER :['rgba(190,160,110,.16)','#dcc08a','rgba(190,160,110,.3)'],
SOLVENT :['rgba(170,120,255,.16)','#c6a6ff','rgba(170,120,255,.3)'],
GAS :['rgba(160,160,160,.12)','#9c968c','rgba(160,160,160,.24)']
};
return c[r]||c.GAS;
},
roleTip(r){
const t={
EMOTIONAL:'EMOTIONAL — carries a force vector from the curated vocabulary.',
SELF_REF :'SELF_REF — anchors the read to the speaker; routes valence into self-worth.',
NEGATOR :'NEGATOR — flips the sign of nearby emotional words.',
AMPLIFIER:'AMPLIFIER — scales nearby force up.',
CONNECTOR:'CONNECTOR — discourse glue between clauses.',
CHOPPER :'CHOPPER — context word whose meaning depends on structure.',
SOLVENT :'SOLVENT — dissolves LIQUID atoms in a casual register.',
GAS :'GAS — neutral / inert token.'
};
return t[r]||'structural role';
},
// ====================================================================
// RENDER FUNCTIONS (replace setState + dc-runtime sc-for/{{}})
// ====================================================================
renderMeters(){
const d=this.disp;
const dims=[
['V','Valence','#55aaff','negative bias','positive bias',"Valence — emotional direction (0–255). 128 is neutral. \"I'm fine\" reads ~83 (uneasy), not positive."],
['A','Arousal','#ff8855','calm / flat','reactive / volatile','Arousal — energy level. Low = flat or numb, high = reactive/volatile.'],
['D','Dominance','#aa55ff','feels powerless','feels in control','Dominance — agency & power. Force Flow (who-does-what-to-whom) feeds this.'],
['U','Urgency','#ff5555','no time pressure','everything urgent','Urgency — time pressure. Starts at 0 and only rises with urgent cues.'],
['G','Gravity','#ffaa55','everything heavy','light & floating','Gravity — emotional weight. Low = crushing, high = light/floating.'],
['W','Self-Worth','#55ffaa','self-critical','self-assured','Self-Worth — self-evaluation. Low W amplifies negatives (W→V coupling).'],
['I','Intent','#ffff55','withdrawing','connecting','Intent — communicative direction, from withdraw to connect/control.']
];
const read=(val,lo,hi)=> val<80?lo: val<118?'slightly '+lo: val>180?hi: val>138?'slightly '+hi:'neutral';
let html='';
for(const [key,name,color,lo,hi,tip] of dims){
const val=Math.round(d[key.toLowerCase()]);
const frac=val/255*100, center=50;
const barLeft=Math.min(center,frac), barW=Math.max(Math.abs(frac-center),1.2);
const del=this.lastDelta[key.toLowerCase()]||0;
const deltaText= del===0?'':(del>0?'▲'+del:'▼'+Math.abs(del));
const deltaColor= del>0?'#5fd08a':del<0?'#ff8a8a':'#a9967c';
html+=`<div class="meter" data-tip="${this._esc(tip)}">
<div class="meter-top">
<span class="meter-key" style="background:${color}">${key}</span>
<span class="meter-label">${name}</span>
<span class="meter-delta" style="color:${deltaColor}">${deltaText}</span>
<span class="meter-num">${val}</span>
</div>
<div class="meter-track"><div class="meter-center"></div>
<div class="meter-fill" style="left:${barLeft}%;width:${barW}%;background:${color}"></div>
</div>
<div class="meter-reading">— ${read(val,lo,hi)}</div>
</div>`;
}
const el=this.q('#meters'); if(el) el.innerHTML=html;
},
renderNeeds(){
const nc=(v)=> v<25?'#ff6b6b': v<50?'#ffb24d':'#5fd08a';
let html='';
for(const k of ['hunger','energy','attention','thirst']){
const v=Math.round(Math.max(0,Math.min(100,this.needsObj[k])));
html+=`<div class="need-row">
<span class="need-label">${k}</span>
<div class="need-track"><div class="need-fill" style="width:${v}%;background:${nc(v)}"></div></div>
<span class="need-val">${v}</span>
</div>`;
}
const el=this.q('#needs'); if(el) el.innerHTML=html;
},
renderDistance(){
let dist;
if(this.backendDistance!=null) dist=this.backendDistance; // backend (NEW)
else if(this.haveSoul){ // compute from soul[]
dist=0; ['v','a','d','u','g','w','i'].forEach(k=>dist+=Math.abs(this.mood[k]-this.soul[k])); dist=Math.round(dist/7);
} else dist=null; // unknown -> dash
const el=this.q('#soulDistance');
if(el) el.textContent = dist==null? 'Δ —' : ('Δ '+dist);
},
renderTrace(trace){
if(!trace) return;
const words=this.q('#traceWords'), structs=this.q('#traceStructs'),
contribs=this.q('#traceContribs'), countEl=this.q('#structCount');
// physical (toy/care/pet): no language to parse
if(trace.kind==='physical'){
if(words) words.innerHTML=`<span class="muted">${this._esc(trace.label||'physical interaction')} — physical interaction, no language to parse.</span>`;
if(structs) structs.innerHTML='<span class="muted">— none in this message</span>';
if(contribs) contribs.innerHTML='';
if(countEl) countEl.textContent='0';
return;
}
// WORDS chips
const ws=(trace.words||[]);
if(words){
if(ws.length===0) words.innerHTML='<span class="muted">talk to it and the structural read shows up here.</span>';
else words.innerHTML=ws.slice(0,14).map(w=>{
const [bg,fg,bd]=this.roleColor(w.role);
return `<span class="w-chip" style="background:${bg};color:${fg};border:1px solid ${bd}" data-tip="${this._esc(this.roleTip(w.role))}">${this._esc(w.word||w.token||'')}</span>`;
}).join('');
}
// STRUCTURES
const sts=(trace.structures||[]);
if(countEl) countEl.textContent=String(sts.length);
if(structs){
if(sts.length===0) structs.innerHTML='<span class="muted">— none in this message</span>';
else structs.innerHTML=sts.map(s=>{
const nm=typeof s==='string'?s:(s.name||String(s));
return `<span class="s-chip">${this._esc(nm)}</span>`;
}).join('');
}
// CONTRIBUTORS — word + signed per-dim string from dv/da/dd/du/dg
const cs=(trace.contributors||[]);
if(contribs){
contribs.innerHTML=cs.slice(0,5).map(c=>{
const parts=[];
[['dv','V'],['da','A'],['dd','D'],['du','U'],['dg','G']].forEach(([k,dim])=>{
const val=c[k];
if(val!=null && val!==0) parts.push(dim+(val>0?'+':'')+val);
});
const dims=parts.join(' ')||'·';
return `<div class="contrib-row"><span class="contrib-word">${this._esc(c.word||c.token||'?')}</span><span class="contrib-dims">${dims}</span></div>`;
}).join('');
}
},
renderWhy(why){
const el=this.q('#whyText'); if(!el) return;
if(!why){ el.textContent='—'; return; }
el.innerHTML=this._esc(why).replace(/\*\*(.+?)\*\*/g,'<b>$1</b>');
},
renderCrisis(){
const wrap=this.q('#crisisWrap'), bar=this.q('#crisisBar'), label=this.q('#crisisLabel');
if(!this.haveCrisis){
// DEFENSIVE: backend has no crisis -> zero it out, keep calm label
if(bar){ bar.style.width='0%'; bar.style.background='#5fd08a'; }
if(label){ label.textContent='—'; label.style.color='#5fd08a'; }
return;
}
const cr=this.crisis;
const lbl=cr<0.15?'calm':cr<0.4?'watch':cr<0.7?'concern':'high concern';
const col=cr<0.15?'#5fd08a':cr<0.4?'#ffd24d':cr<0.7?'#ff9d4d':'#ff5a5a';
if(bar){ bar.style.width=Math.round(cr*100)+'%'; bar.style.background=col; }
if(label){ label.textContent=lbl; label.style.color=col; }
},
renderMoodWord(){
const el=this.q('#moodWord'); if(el) el.textContent=this.moodWord||'…';
},
renderReadPhrase(){
const el=this.q('#readPhrase'); if(el) el.textContent=this.readPhrase;
},
renderDiary(){
const el=this.q('#diary'); if(!el) return;
if(!this.diary.length){ el.innerHTML='<span class="muted">moments you share will be remembered here.</span>'; return; }
el.innerHTML=this.diary.slice(0,9).map(d=>
`<div class="diary-row"><span class="diary-dot" style="color:${d.color}">${this._esc(d.dot)}</span>`+
`<div class="diary-text"><span class="body">${this._esc(d.text)}</span> <span class="mood">· ${this._esc(d.mood||'')}</span></div></div>`
).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<this.blinkUntil;
if(t>this.nextDart){ this.dart=(Math.random()<0.5?-1:1)*3; this.nextDart=t+1.6+Math.random()*3.2; this._dartEnd=t+0.9; }
if(t>this._dartEnd)this.dart=0;
let eyeRy=11, eyeDx=this.dart;
if(sleepy)eyeRy=4; else if(act==='teddy'||(beh==='idle'&&d.v>168&&d.a<120))eyeRy=5;
else if(beh==='tantrum')eyeRy=8; else if(beh==='sad')eyeRy=7; else if(aN>0.5)eyeRy=13;
if(act==='mirror')eyeRy=12;
if(blinking)eyeRy=2;
const eL=g('eyeL'),eR=g('eyeR');
if(eL){eL.setAttribute('ry',eyeRy); eL.setAttribute('cx',74+eyeDx);}
if(eR){eR.setAttribute('ry',eyeRy); eR.setAttribute('cx',126+eyeDx);}
const glL=g('glintL'),glR=g('glintR'); const glo=(blinking||eyeRy<5)?0:1;
if(glL){glL.setAttribute('opacity',glo);glL.setAttribute('cx',77+eyeDx);} if(glR){glR.setAttribute('opacity',glo);glR.setAttribute('cx',129+eyeDx);}
// ---- mouth ----
const mouth=g('mouth'); const chomp=this.eating>0||this.drinking>0;
if(mouth){
if(beh==='tantrum'){ const o=8+Math.abs(Math.sin(t*16))*8; mouth.setAttribute('d',`M84 150 Q100 ${150+o} 116 150 Q100 ${150+o*0.5} 84 150 Z`); mouth.setAttribute('fill','#5a1818'); }
else if(chomp){ const o=4+Math.abs(Math.sin(t*16))*9; mouth.setAttribute('d',`M86 150 Q100 ${150+o} 114 150 Q100 ${150+o*0.4} 86 150 Z`); mouth.setAttribute('fill','#5a2418'); }
else if(beh==='sad'){ mouth.setAttribute('d','M82 158 Q100 146 118 158'); mouth.setAttribute('fill','none'); }
else if((act==='ball'||act==='balloon'||act==='rattle')||(d.v>156&&d.a>150)){ mouth.setAttribute('d','M80 148 Q100 174 120 148 Q100 156 80 148 Z'); mouth.setAttribute('fill','#5a2418'); }
else { const depth=Math.max(-13,Math.min(20,16*vN)); mouth.setAttribute('d',`M82 152 Q100 ${(152+depth).toFixed(0)} 118 152`); mouth.setAttribute('fill','none'); }
}
// ---- brows ----
const bL=g('browL'),bR=g('browR');
let brow=0; if(beh==='tantrum')brow=24; else if(d.v<112&&d.d>132)brow=18; else if(beh==='sad'||(d.v<112&&d.d<122))brow=-16;
if(bL)bL.setAttribute('transform',`rotate(${brow} 86 96)`);
if(bR)bR.setAttribute('transform',`rotate(${-brow} 114 96)`);
// ---- cheeks ----
const cL=g('cheekL'),cR=g('cheekR'); let cf='#ff9bb0',co=0.32;
if(beh==='tantrum'){cf='#ff5a4d';co=0.6;} else if(d.v>152){co=0.5;} else if(d.v<112){co=0.12;}
[cL,cR].forEach(c=>{if(c){c.setAttribute('fill',cf);c.setAttribute('opacity',co);}});
// ---- tears (sad) ----
const tearO=(beh==='sad')?(0.5+0.5*Math.sin(t*2)):0;
const tearY=(beh==='sad')?(124+((t*40)%34)):124;
const tL=g('tearL'),tR=g('tearR');
if(tL){tL.setAttribute('opacity',tearO);tL.setAttribute('cy',tearY);}
if(tR){tR.setAttribute('opacity',tearO*0.8);tR.setAttribute('cy',124+((t*40+17)%34));}
// ---- steam (tantrum) ----
const stO=(beh==='tantrum')?(0.4+0.5*Math.abs(Math.sin(t*6))):0;
const sL=g('steamL'),sR=g('steamR');
if(sL){sL.setAttribute('opacity',stO);sL.setAttribute('cy',64-((t*30)%18));}
if(sR){sR.setAttribute('opacity',stO);sR.setAttribute('cy',64-((t*30+9)%18));}
// ---- emote glow ----
const mh=this.moodHue(d); const eg=g('emoteGlow');
if(eg){ eg.setAttribute('fill',`hsl(${mh.hue} ${mh.sat}% ${mh.light}%)`); eg.setAttribute('opacity',Math.min(0.42,Math.abs(vN)*0.46).toFixed(2)); eg.style.mixBlendMode = vN<0?'multiply':'screen'; }
// ---- arms ----
const aL=g('armL'),aR=g('armR'); let lr=0,rr=0;
if(beh==='tantrum'){ lr=-70+Math.sin(t*22)*40; rr=70-Math.sin(t*22)*40; }
else if(beh==='sad'){ lr=20; rr=-20; }
else if(act==='teddy'){ lr=-64; rr=64; }
else if(act==='ball'){ const sw=Math.sin(t*4.4)*22; lr=-30+sw; rr=30-sw; }
else if(act==='balloon'){ const up=Math.abs(Math.sin(t*3.1))*40; lr=-20-up*0.4; rr=-60-up; }
else if(act==='rattle'){ rr=-50+Math.sin(t*30)*18; lr=8; }
else if(act==='music'){ lr=-14; rr=-30; }
else if(act==='mirror'){ rr=-46; lr=10; }
else if(d.v>166&&d.a>150){ lr=-52; rr=52; }
else if(iN>0.18){ lr=-18*iN-6; rr=18*iN+6; }
else if(iN<-0.15){ lr=12; rr=-12; }
if(aL)aL.setAttribute('transform',`rotate(${lr.toFixed(1)} 52 150)`);
if(aR)aR.setAttribute('transform',`rotate(${rr.toFixed(1)} 148 150)`);
// ---- hearts (teddy) ----
const hearts=g('hearts'); if(hearts){ const show=(act==='teddy'); hearts.setAttribute('opacity',show?(0.6+0.4*Math.sin(t*4)):0); if(show)hearts.setAttribute('transform',`translate(0 ${-(t*18%30)})`); }
// ---- aura + mood dot ----
const aura=g('aura');
let auraHue=mh.hue, auraSat=mh.sat, auraL=mh.light;
if(beh==='tantrum'){auraHue=4;auraSat=85;auraL=55;} else if(beh==='sad'){auraHue=214;auraSat=50;auraL=52;}
if(aura){ aura.style.background=`radial-gradient(circle, hsl(${auraHue} ${auraSat}% ${auraL}% / ${(0.42+Math.abs(vN)*0.28+(beh!=='idle'?0.12:0)).toFixed(2)}), transparent 66%)`; }
const md=g('moodDot'); if(md){md.style.background=`hsl(${auraHue} ${auraSat}% ${auraL}%)`; md.style.boxShadow=`0 0 9px hsl(${auraHue} ${auraSat}% ${auraL}%)`;}
// ---- zzz ----
const zz=g('zzz'); if(zz){ zz.setAttribute('opacity', sleepy? (0.4+0.5*Math.sin(t*2)).toFixed(2):0); zz.setAttribute('y', sleepy? (60+Math.sin(t*1.4)*6).toFixed(0):60); }
},
// ====================================================================
// BOWLS (cosmetic fill; server owns the actual need value)
// ====================================================================
bowlSVG(level){
const f=Math.max(0,Math.min(1,level/100)); const fy=18-f*9; const op=f>0.04?1:0;
return `<svg viewBox="0 0 46 34" width="100%"><ellipse cx="23" cy="29" rx="17" ry="4" fill="rgba(0,0,0,.18)"/><path d="M4 16 h38 a19 14 0 0 1 -38 0Z" fill="#c97a4a"/><ellipse cx="23" cy="16" rx="19" ry="5.5" fill="#7a3f22"/><g opacity="${op}"><path d="M${10} ${fy+4} q13 -7 26 0 l0 6 q-13 6 -26 0Z" fill="#d6a04a"/><circle cx="16" cy="${fy+4}" r="2.4" fill="#a85f2a"/><circle cx="28" cy="${fy+3}" r="2.2" fill="#a85f2a"/><circle cx="23" cy="${fy+6}" r="2" fill="#b8722f"/></g></svg>`;
},
waterSVG(level){
const f=Math.max(0,Math.min(1,level/100)); const fy=18-f*9; const op=f>0.04?1:0;
return `<svg viewBox="0 0 46 34" width="100%"><ellipse cx="23" cy="29" rx="17" ry="4" fill="rgba(0,0,0,.18)"/><path d="M4 16 h38 a19 14 0 0 1 -38 0Z" fill="#9fb8cc"/><ellipse cx="23" cy="16" rx="19" ry="5.5" fill="#3f6f8f"/><g opacity="${op}"><path d="M${9} ${fy+5} q14 -6 28 0 l0 5 q-14 6 -28 0Z" fill="#6fb6e6"/><ellipse cx="23" cy="${fy+5}" rx="13" ry="2.6" fill="#9bd6f5"/><circle cx="18" cy="${fy+4}" r="1.4" fill="#fff" opacity=".7"/></g></svg>`;
},
refreshBowls(){
const r=this.root; if(!r)return;
const fb=r.querySelector('#foodFill'); if(fb)fb.innerHTML=this.bowlSVG(this.food);
const wb=r.querySelector('#waterFill'); if(wb)wb.innerHTML=this.waterSVG(this.water);
},
fillFood(){ this.ensureAudio(); this.food=100; this.refreshBowls(); this.popBubble('🍪'); this.logDiary('bowl filled with food','#ffd24d','🍪'); this.chirp(360,0.12); this.care('feed'); },
fillWater(){ this.ensureAudio(); this.water=100; this.refreshBowls(); this.popBubble('💧'); this.logDiary('bowl filled with water','#6fb6e6','💧'); this.chirp(520,0.12); this.care('water'); },
chirp(f,d){ this.ensureAudio(); if(this.ac&&this.soundOn)this.blip(f,this.ac.currentTime,d); },
popBubble(txt){ const eb=this.root&&this.root.querySelector('#bubble'); if(eb){eb.textContent=txt;eb.style.opacity='1';eb.style.animation='popIn .3s ease';clearTimeout(this._eb);this._eb=setTimeout(()=>{eb.style.opacity='0';eb.style.animation='';},1300);} },
// ====================================================================
// TOY PLAY (animation client-side; fires backend per item)
// ====================================================================
startPlay(it){
this.ensureAudio();
const dur={ball:3.0,teddy:2.8,balloon:3.0,rattle:2.4,music:3.4,mirror:2.6}[it.kind]||2.6;
this.action={type:it.kind,id:it.id,t0s:performance.now()/1000,dur};
it.held=true; it.resting=false;
this.speak(); this.popBubble(this.emoteFrom(this.disp));
// fire the real backend interaction
if(it.ep==='care') this.care(it.name);
else this.toy(it.name);
clearTimeout(this._actEnd); this._actEnd=setTimeout(()=>this.endPlay(),dur*1000);
},
endPlay(){
const a=this.action; this.action=null; if(!a)return;
const fx=43+Math.random()*14, fy=85+Math.random()*5;
const inner=this.itemEls[a.id], wrap=inner&&inner.closest('[data-id]');
if(wrap){ wrap.style.transition=''; wrap.style.transform=''; wrap.style.zIndex=''; }
const it=this.items.find(i=>i.id===a.id);
if(it){ it.held=false; it.x=fx; it.y=fy; it.resting=true; }
this._applyItemLayout(); this.saveItems();
},
positionActionItem(){
if(!this.action) return; const a=this.action; const inner=this.itemEls[a.id]; if(!inner)return;
const wrap=inner.closest('[data-id]'); if(!wrap)return;
const e=(performance.now()/1000)-a.t0s;
let x=50,y=50,rot=0,sc=1,anchor='-50%,-50%';
if(a.type==='ball'){ const ph=Math.abs(Math.sin(e*4.4)); y=48+ph*32; x=50+Math.sin(e*2.2)*5; rot=(e*240)%360; }
else if(a.type==='teddy'){ x=50; y=53; sc=1+Math.max(0,Math.sin(e*5.5))*0.12; rot=Math.sin(e*5.5)*4; }
else if(a.type==='balloon'){ x=50+Math.sin(e*1.7)*10; y=26+Math.sin(e*3.1)*4; anchor='-50%,-50%'; }
else if(a.type==='rattle'){ x=60; y=46; rot=Math.sin(e*30)*26; }
else if(a.type==='music'){ x=41; y=54; rot=Math.sin(e*2.2)*6; }
else if(a.type==='mirror'){ x=58; y=45; rot=Math.sin(e*2)*4; }
this.actPos={x,y,rot,sc};
wrap.style.transition='none';
wrap.style.left=x+'%'; wrap.style.top=y+'%';
wrap.style.transform=`translate(${anchor}) rotate(${rot}deg) scale(${sc})`;
wrap.style.zIndex=a.type==='balloon'?3:6;
},
// ====================================================================
// AUTO EAT / DRINK (cosmetic; server owns need value via /care)
// ====================================================================
autoConsume(dt){
if(this.action) return;
const n=this.needsObj;
if(this.food>0 && n.hunger<99){
const rr=22*dt; this.food=Math.max(0,this.food-rr);
this.eating=0.5; this.refreshBowls();
if(performance.now()-this.lastSpeak>1400){ this.lastSpeak=performance.now(); this.chirp(300+Math.random()*120,0.06); }
if(this.food<=0){ this.logDiary('finished eating','#5fd08a','♥'); this.popBubble('^‿^'); this.renderDiary(); }
} else this.eating=Math.max(0,this.eating-dt);
if(this.water>0 && n.thirst<99){
const rr=24*dt; this.water=Math.max(0,this.water-rr);
this.drinking=0.5; this.refreshBowls();
} else this.drinking=Math.max(0,this.drinking-dt);
},
// ====================================================================
// BEHAVIOR FSM (idle | sad | tantrum)
// ====================================================================
pickBehavior(){
const d=this.disp; const n=this.needsObj;
// real-engine neutral arousal is ~128, so a tantrum must require GENUINE anger
// (clearly negative valence + clearly high arousal) — never neutral-mood + low needs,
// which used to fire here and shook the screen while the mood read "neutral".
const starving=(n.attention<10 && n.hunger<10); // both truly empty
if((d.v<104 && d.a>150) || (starving && d.v<116 && d.a>150)) return 'tantrum';
// crisis / low-and-flat / genuinely sad -> droop (no screen shake)
if((this.haveCrisis&&this.crisis>0.55) || (d.v<110 && d.a<132)) return 'sad';
return 'idle';
},
// ====================================================================
// AUDIO (WebAudio oscillator blips — the redesign's signature; kept)
// ====================================================================
ensureAudio(){ if(!this.soundOn) return; try{ if(!this.ac) this.ac=new (window.AudioContext||window.webkitAudioContext)(); if(this.ac.state==='suspended')this.ac.resume(); }catch(e){} },
blip(freq,t0,dur){ if(!this.ac)return; const o=this.ac.createOscillator(),gn=this.ac.createGain(); o.type='triangle'; o.frequency.setValueAtTime(freq,t0); o.frequency.linearRampToValueAtTime(freq*1.04,t0+dur); gn.gain.setValueAtTime(0.0001,t0); gn.gain.exponentialRampToValueAtTime(0.16,t0+0.012); gn.gain.exponentialRampToValueAtTime(0.0001,t0+dur); o.connect(gn).connect(this.ac.destination); o.start(t0); o.stop(t0+dur+0.02); },
speak(serverText){
const d=this.disp;
let text;
if(serverText){ text=serverText; }
else {
const syl=['ba','da','la','na','mi','mu','wa','wo','dee','do','boo','ga','gee','sho','rup','meep','noo','wug','blee','va','du','gwi'];
const words=1+Math.round(Math.max(0,(d.a-110)/60))+(Math.random()<0.4?1:0);
text='';
for(let w=0;w<Math.min(4,words);w++){ const len=1+Math.floor(Math.random()*2); let word=''; for(let s=0;s<len;s++)word+=syl[Math.floor(Math.random()*syl.length)]; text+=(w?' ':'')+word; }
if(d.v>150)text+=' ♪'; if(d.v<104)text+='…';
}
const bub=this.root&&this.root.querySelector('#simlish');
if(bub){ bub.textContent=text; bub.style.opacity='1'; clearTimeout(this._st); this._st=setTimeout(()=>{bub.style.opacity='0';},1700); }
const eb=this.root&&this.root.querySelector('#bubble');
if(eb){ eb.textContent=this.emoteFrom(d); eb.style.opacity='1'; eb.style.animation='popIn .3s ease'; clearTimeout(this._eb); this._eb=setTimeout(()=>{eb.style.opacity='0';eb.style.animation='';},1700); }
this.ensureAudio(); if(!this.ac||!this.soundOn)return;
const base=190+((d.v)/255)*300; const now=this.ac.currentTime;
const count=Math.max(2,Math.min(8,Math.round(String(text).replace(/[^a-z♪…]/g,'').length/2)));
const tempo=0.10-Math.max(0,(d.a-128)/127)*0.04;
for(let i=0;i<count;i++){ const f=base*(0.9+Math.random()*0.4)*(d.v>150&&i===count-1?1.3:1); this.blip(f, now+i*tempo, 0.09); }
},
// ====================================================================
// DIARY (local log + server moments)
// ====================================================================
logDiary(text,color,dot){
this.diary=[{text, color:color||'#ffd21e', dot:dot||'•', mood:this.moodWord}, ...this.diary].slice(0,9);
this.renderDiary();
},
_relTime(ts){
let ms; if(typeof ts==='number'){ ms=ts<1e10?ts*1000:ts; } else { ms=Date.parse(ts); }
if(!ms||isNaN(ms)) return '';
const diff=Math.floor((Date.now()-ms)/1000);
if(diff<60) return 'just now';
if(diff<3600) return Math.floor(diff/60)+'m ago';
if(diff<86400) return Math.floor(diff/3600)+'h ago';
return Math.floor(diff/86400)+'d ago';
},
async loadMoments(){
try{
const r=await fetch('/moments'); if(!r.ok) return;
const data=await r.json(); const events=data.events||[];
// hydrate notable server moments into the diary (newest first), keeping local entries too
const server=events.slice().reverse().slice(0,6).map(ev=>({
text:(ev.label||(ev.type||'moment').replace(/_/g,' ')),
color:'#a9967c', dot:'◆',
mood:ev.mood_word||this._relTime(ev.ts)
}));
// local entries first, then server moments that aren't dupes
const seen=new Set(this.diary.map(d=>d.text));
this.diary=[...this.diary, ...server.filter(s=>!seen.has(s.text))].slice(0,9);
this.renderDiary();
}catch(_){}
},
// ====================================================================
// BACKEND CALLS
// ====================================================================
async _post(url, body){
try{
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
if(!r.ok){ this.popBubble('…?'); return null; }
return await r.json();
}catch(_){ this.popBubble('…?'); return null; }
},
async send(){
const el=this.inputEl; if(!el)return; const text=el.value.trim(); if(!text)return;
this.ensureAudio(); el.value='';
this.logDiary('“'+(text.length>30?text.slice(0,30)+'…':text)+'”','#8fc0ff','✎');
const state=await this._post('/say',{text});
if(!state) return;
this.applyState(state,{useDeltas:true});
if(state.recognition && state.recognition.returning){ this.popBubble('welcome back!'); this.showRemembers(true); }
if(state.relation && state.relation.known) this.showRemembers(true);
this.speak(state.simlish);
this.loadMoments();
},
async care(action){
const state=await this._post('/care',{action});
if(!state) return;
this.applyState(state,{useDeltas:true});
this.speak(state.simlish);
this.loadMoments();
},
async toy(name){
const state=await this._post('/toy',{toy:name});
if(!state) return;
this.applyState(state,{useDeltas:true});
this.speak(state.simlish);
this.loadMoments();
},
petCreature(e){ if(e)e.preventDefault(); this.ensureAudio(); this.speak(); this.popBubble('♥'); this.logDiary('got petted','#5fd08a','♥'); this.care('pet'); },
showRemembers(on){
const el=this.q('#remembersHint'); if(el) el.style.display=on?'block':'none';
},
// ====================================================================
// SELF-SOOTHE (preserved from prior production app): walk to & carry a comfort item
// ====================================================================
triggerSoothe(item){
if(!item) return;
// map backend item name to our item id (musicbox->music; play->ball)
const map={musicbox:'music', play:'ball', ball:'ball', balloon:'balloon', rattle:'rattle', mirror:'mirror', teddy:'teddy'};
const id=map[item]||item;
const it=this.items.find(i=>i.id===id);
if(!it || this.action) return;
// cosmetic comfort: he picks it up and plays (no extra backend call; server already lifted mood)
this.action={type:it.kind,id:it.id,t0s:performance.now()/1000,dur:3.0};
it.held=true; it.resting=false;
this.logDiary('soothed himself with the '+id,'#c6a6ff','✷');
clearTimeout(this._actEnd); this._actEnd=setTimeout(()=>this.endPlay(),3000);
},
// ====================================================================
// DRAG (toy -> creature)
// ====================================================================
startDrag(e,id){
e.preventDefault(); this.ensureAudio();
const it=this.items.find(x=>x.id===id); if(!it)return;
this.dragId=id;
const wrap=e.currentTarget; this.dragWrap=wrap;
wrap.style.cursor='grabbing'; wrap.style.transition='none'; wrap.style.zIndex=20;
this.moveItemTo(e.clientX,e.clientY);
},
moveItemTo(cx,cy){
if(!this.stageEl||!this.dragId)return;
const r=this.stageEl.getBoundingClientRect();
let x=(cx-r.left)/r.width*100, y=(cy-r.top)/r.height*100;
x=Math.max(3,Math.min(97,x)); y=Math.max(8,Math.min(99,y));
const it=this.items.find(i=>i.id===this.dragId);
if(it){ it.x=x; it.y=y; it.resting=false; it.held=false; }
this._applyItemLayout();
},
onPointerMove(e){ if(this.dragId)this.moveItemTo(e.clientX,e.clientY); },
onPointerUp(e){
if(!this.dragId)return;
const id=this.dragId; this.dragId=null;
if(this.dragWrap){this.dragWrap.style.cursor='grab';this.dragWrap.style.zIndex='';}
const r=this.stageEl.getBoundingClientRect();
const x=(e.clientX-r.left)/r.width*100, y=(e.clientY-r.top)/r.height*100;
const dx=x-50, dy=y-52; const onCreature=Math.sqrt(dx*dx+dy*dy)<19;
const it=this.items.find(i=>i.id===id);
if(onCreature && it){
this.startPlay(it);
} else if(it){
const fy=Math.max(y, 72+Math.random()*16);
it.x=Math.max(4,Math.min(96,x)); it.y=Math.min(96,fy); it.resting=true; it.held=false;
this._applyItemLayout(); this.saveItems();
}
},
saveItems(){ try{ const pos={}; this.items.forEach(i=>pos[i.id]={x:i.x,y:i.y}); localStorage.setItem('clanker_items_v3',JSON.stringify(pos)); }catch(e){} },
restoreItems(){ try{ const raw=localStorage.getItem('clanker_items_v3'); if(!raw)return; const pos=JSON.parse(raw); this.items.forEach(i=>{ if(pos[i.id]){ i.x=pos[i.id].x; i.y=pos[i.id].y; } }); }catch(e){} },
// ====================================================================
// THEME (default = pixel, the authored aesthetic; cozy/amber available via setThemeTo)
// ====================================================================
setThemeTo(theme){
const r=this.root; if(!r) return;
if(theme==='pixel'){
const pixel={'--bg':'#0e1410','--panel':'#16201a','--panel2':'#0a0f0c','--ink':'#cdeec0','--dim':'#6f9a73','--accent':'#ffd21e','--line':'rgba(120,210,120,.22)','--rad':'4px','--fhead':"'Press Start 2P',monospace",'--fbody':"'VT323',monospace",'--wall1':'#26402c','--wall2':'#16271a','--floor':'#1f3322','--sky1':'#7fe6b0','--sky2':'#bdf48a','--cstroke':'5','--cstroke-col':'#0c1a10'};
Object.keys(pixel).forEach(k=>r.style.setProperty(k,pixel[k]));
r.style.fontFamily="'VT323',monospace"; r.style.fontSize='17px'; r.style.letterSpacing='.3px';
} else {
// cozy/amber: clear overrides so the inline var(--x, fallback) warm defaults apply
['--bg','--panel','--panel2','--ink','--dim','--accent','--line','--rad','--fhead','--fbody','--wall1','--wall2','--floor','--sky1','--sky2','--cstroke','--cstroke-col'].forEach(k=>r.style.removeProperty(k));
r.style.fontFamily=''; r.style.fontSize=''; r.style.letterSpacing='';
}
},
// ====================================================================
// TOOLTIPS (client-only, delegated)
// ====================================================================
bindTooltips(){
const tip=this.tipEl;
const show=(e)=>{
const host=e.target.closest('[data-tip]'); if(!host){ return; }
const txt=host.getAttribute('data-tip'); if(!txt) return;
tip.innerHTML=txt;
tip.style.left=(e.clientX+16)+'px'; tip.style.top=e.clientY+'px'; tip.style.opacity='1';
};
this.root.addEventListener('mouseover',(e)=>{ if(e.target.closest('[data-tip]')) show(e); });
this.root.addEventListener('mousemove',(e)=>{ const h=e.target.closest('[data-tip]'); if(h && tip.style.opacity==='1'){ tip.style.left=(e.clientX+16)+'px'; tip.style.top=e.clientY+'px'; } });
this.root.addEventListener('mouseout',(e)=>{ const from=e.target.closest('[data-tip]'); const to=e.relatedTarget&&e.relatedTarget.closest&&e.relatedTarget.closest('[data-tip]'); if(from && from!==to) tip.style.opacity='0'; });
},
// ---------- utils ----------
q(sel){ return this.root?this.root.querySelector(sel):document.querySelector(sel); },
_esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); },
// ====================================================================
// BOOT
// ====================================================================
async init(){
this.root=document.getElementById('root');
this.stageEl=document.getElementById('stage');
this.inputEl=document.getElementById('msg');
this.tipEl=document.getElementById('tooltip');
this.items=this.defaultItems();
this.restoreItems();
// DEFAULT to the pixel aesthetic — this is the authored design
this.setThemeTo('pixel');
// events
const sendBtn=document.getElementById('send'); if(sendBtn) sendBtn.addEventListener('click',()=>this.send());
if(this.inputEl) this.inputEl.addEventListener('keydown',(e)=>{ if(e.key==='Enter') this.send(); });
const creature=document.getElementById('creature'); if(creature) creature.addEventListener('pointerdown',(e)=>this.petCreature(e));
const fb=document.getElementById('foodBowl'); if(fb) fb.addEventListener('click',()=>this.fillFood());
const wb=document.getElementById('waterBowl'); if(wb) wb.addEventListener('click',()=>this.fillWater());
const soundBtn=document.getElementById('soundBtn');
if(soundBtn) soundBtn.addEventListener('click',()=>{ this.soundOn=!this.soundOn; soundBtn.textContent=this.soundOn?'🔊':'🔇'; if(this.soundOn)this.ensureAudio(); });
window.addEventListener('pointermove',(e)=>this.onPointerMove(e));
window.addEventListener('pointerup',(e)=>this.onPointerUp(e));
this.bindTooltips();
this.renderItems();
this.refreshBowls();
this.renderMeters(); this.renderNeeds(); this.renderDistance();
this.renderCrisis(); this.renderMoodWord(); this.renderReadPhrase();
this.renderDiary();
// hydrate from snapshot
try{
const r=await fetch('/snapshot'); if(r.ok){
const state=await r.json();
// snapshot has no deltas; set mood directly (no arrow flicker)
this.applyState(state,{useDeltas:false});
if(state.self_soothe && state.self_soothe.item) this.triggerSoothe(state.self_soothe.item);
if(state.relation && state.relation.known) this.showRemembers(true);
}
}catch(_){}
this.loadMoments();
// animation loop
this.lastTs=performance.now();
this._raf=requestAnimationFrame(this.step.bind(this));
setTimeout(()=>{ this.speak(); },500);
// poll snapshot every 45s for needs + self_soothe
setInterval(()=>{
fetch('/snapshot').then(r=>r.json()).then(s=>{
if(s.needs){ this.applyNeeds(s.needs); this.renderNeeds(); }
if(s.crisis){ this.applyCrisis(s.crisis); this.renderCrisis(); }
if(s.self_soothe && s.self_soothe.item) this.triggerSoothe(s.self_soothe.item);
}).catch(()=>{});
},45000);
}
};
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',()=>App.init());
else App.init();