Spaces:
Running
Running
fix: creature no longer screen-shakes at neutral mood (tantrum FSM tuned for the real engine)
58182e8 verified | ; | |
| /* 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,'&').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(); | |