'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:'', ball:'', teddy:'', music:'', rattle:'', mirror:'' }; return I[kind] || I.ball; }, // care = backend route+name: ball->/care{play}; others->/toy{} 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+=`
${key} ${name} ${deltaText} ${val}
— ${read(val,lo,hi)}
`; } 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+=`
${k}
${v}
`; } 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=`${this._esc(trace.label||'physical interaction')} — physical interaction, no language to parse.`; if(structs) structs.innerHTML='— none in this message'; if(contribs) contribs.innerHTML=''; if(countEl) countEl.textContent='0'; return; } // WORDS chips const ws=(trace.words||[]); if(words){ if(ws.length===0) words.innerHTML='talk to it and the structural read shows up here.'; else words.innerHTML=ws.slice(0,14).map(w=>{ const [bg,fg,bd]=this.roleColor(w.role); return `${this._esc(w.word||w.token||'')}`; }).join(''); } // STRUCTURES const sts=(trace.structures||[]); if(countEl) countEl.textContent=String(sts.length); if(structs){ if(sts.length===0) structs.innerHTML='— none in this message'; else structs.innerHTML=sts.map(s=>{ const nm=typeof s==='string'?s:(s.name||String(s)); return `${this._esc(nm)}`; }).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 `
${this._esc(c.word||c.token||'?')}${dims}
`; }).join(''); } }, renderWhy(why){ const el=this.q('#whyText'); if(!el) return; if(!why){ el.textContent='—'; return; } el.innerHTML=this._esc(why).replace(/\*\*(.+?)\*\*/g,'$1'); }, 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='moments you share will be remembered here.'; return; } el.innerHTML=this.diary.slice(0,9).map(d=> `
${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=tthis.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 ``; }, 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 ``; }, 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;w150)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;i150&&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,'&').replace(//g,'>').replace(/"/g,'"'); }, // ==================================================================== // 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();