/* ============================================================ * ui.js β€” HUD, portraits, sim panel, buy catalog, build bar, * pie menus, toasts, career picker * ============================================================ */ 'use strict'; /* ---------------- toasts ---------------- */ function toast(msg, cls = '') { AudioSys.sfx(cls === 'bad' ? 'error' : 'toast'); const box = document.getElementById('toasts'); const el = document.createElement('div'); el.className = 'toast ' + cls; el.innerHTML = msg; box.appendChild(el); while (box.children.length > 4) box.removeChild(box.firstChild); setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .5s'; }, 4200); setTimeout(() => el.remove(), 4800); } function toastBill(amount) { AudioSys.sfx('bill'); const box = document.getElementById('toasts'); const el = document.createElement('div'); el.className = 'toast bad'; el.innerHTML = `πŸ“¬ Bills due: ${fmtMoney(amount)} PAY`; box.appendChild(el); document.getElementById('payBillsBtn').onclick = () => { if (G.funds >= amount) { G.funds -= amount; G.billsPaid = true; G.mailBillsDue = false; toast(`βœ… Bills paid: ${fmtMoney(amount)}`); el.remove(); Bus.emit('fundsChanged'); } else toast('❌ Not enough money for the bills!', 'bad'); }; } /* ---------------- HUD ---------------- */ function updateHud() { document.getElementById('fundsVal').textContent = Math.floor(G.funds).toLocaleString('en-US'); const t = G.time; document.getElementById('clockTime').textContent = `${Math.floor(t.hour)}:${String(Math.floor((t.hourFloat % 1) * 60)).padStart(2, '0')}` + ` ${t.hour >= 12 ? 'PM' : 'AM'}`; document.getElementById('clockDay').textContent = `${DAY_NAMES[(t.day - 1) % 7]}, Day ${t.day}`; } /* ---------------- portraits ---------------- */ const portraitEls = new Map(); function rebuildPortraits() { const row = document.getElementById('portraitRow'); row.innerHTML = ''; portraitEls.clear(); for (const s of G.sims.filter(s => !s.isVisitor)) { const d = document.createElement('div'); d.className = 'portrait' + (s.selected ? ' selected' : ''); const cv = document.createElement('canvas'); cv.width = 70; cv.height = 62; d.appendChild(cv); const nm = document.createElement('div'); nm.className = 'pname'; nm.textContent = s.name.split(' ')[0]; d.appendChild(nm); const pb = document.createElement('div'); pb.className = 'plumbob'; pb.textContent = 'πŸ”·'; d.appendChild(pb); const mb = document.createElement('div'); mb.className = 'moodbar'; const mfill = document.createElement('div'); mb.appendChild(mfill); d.appendChild(mb); d.onclick = () => { selectSim(s); }; row.appendChild(d); portraitEls.set(s.id, { root:d, cv, plumbob:pb, mfill }); } refreshPortraits(); } function refreshPortraits() { for (const s of G.sims.filter(s => !s.isVisitor)) { const pe = portraitEls.get(s.id); if (!pe) continue; pe.root.classList.toggle('selected', !!s.selected); const nmEl = pe.root.querySelector('.pname'); if (nmEl) nmEl.textContent = s.name.split(' ')[0] + (s.sickUntil && G.time.absMin < s.sickUntil ? ' 🀒' : ''); pe.plumbob.textContent = s.moodScore() > 60 ? '🟒' : s.moodScore() > 32 ? '🟑' : 'πŸ”΄'; const m = s.moodScore(); pe.mfill.style.width = m + '%'; pe.mfill.style.background = m > 60 ? '#3ddc55' : m > 32 ? '#ffd23e' : '#ff4040'; drawSimToCanvas(pe.cv, s, { facing:0, scale: Math.min(70/90, 62/130) + .18, groundPad: 4 }); } } /* ---------------- sim selection ---------------- */ function selectSim(s) { for (const o of G.sims) o.selected = false; if (s) s.selected = true; G.selectedSim = s; refreshPortraits(); updateSimPanel(); } /* ---------------- sim side panel ---------------- */ function updateSimPanel() { const panel = document.getElementById('simPanel'); const s = G.selectedSim; if (!s || G.mode === 'cas') { panel.classList.add('hidden'); return; } panel.classList.remove('hidden'); document.getElementById('simPanelName').textContent = s.name + (s.isVisitor ? ' (visiting)' : ''); const m = s.moodScore(); document.getElementById('simPanelMood').textContent = 'Mood: ' + (m > 75 ? 'Elated πŸ˜„' : m > 55 ? 'Happy πŸ™‚' : m > 35 ? 'Uneasy πŸ˜•' : m > 18 ? 'Miserable 😣' : 'Desperate 😫'); drawSimToCanvas(document.getElementById('simPortrait'), s, { facing:0, scale:1, groundPad:6 }); const body = document.getElementById('simPanelBody'); const tab = panel.dataset.tab || 'needs'; let html = ''; if (tab === 'needs') { for (const k in NEEDS) { const meta = NEEDS[k]; const v = clamp(s.needs[k], 0, 100); const col = v > 55 ? '#43c15a' : v > 28 ? '#e8a33d' : '#e05252'; html += `
${meta.icon} ${meta.label}${Math.round(v)}
`; } } else if (tab === 'wants') { const asp = ASPIRATIONS[s.aspiration]; const lvl = Math.floor(G.aspirationPoints / 500); const prog = G.aspirationPoints % 500; html += `
${asp.icon} ${asp.name}Lvl ${lvl}
${prog}/500 to next level
`; if (!s.wants || !s.wants.length) html += 'No whims right now…'; for (const w of (s.wants || [])) { const t = w.tpl; const pct = t.count ? Math.round(w.progress / t.count * 100) : (t.amount ? Math.min(100, Math.round(w.bank / t.amount * 100)) : 0); html += `
${t.icon} ${t.label}+${t.reward}
${t.count || t.amount ? `
` : ''}
`; } html += `
Whims are guided by the ${asp.name} aspiration. Fulfil them for aspiration points!
`; } else if (tab === 'skills') { for (const sk of SKILLS) { const lvl = Math.floor(s.skills[sk.id]); let pips = ''; for (let i = 0; i < 10; i++) pips += i < lvl ? '●' : 'Β·'; html += `
${sk.icon} ${sk.name}${pips}${lvl}
`; } } else if (tab === 'rels') { const others = G.sims.filter(o => o !== s); if (!others.length) html += 'No other sims around yet.
Try the phone β†’ Invite Neighbor!
'; for (const o of others) { const r = s.getRel(o); const ltrCol = r.ltr >= 50 ? '#43c15a' : r.ltr <= -25 ? '#e05252' : '#7f9fd9'; const strCol = r.str >= 40 ? '#43c15a' : r.str <= -20 ? '#e05252' : '#c9a24a'; const badge = r.ltr >= 75 ? 'πŸ’ž' : r.ltr >= 50 ? '🀝' : r.ltr <= -40 ? 'βš”οΈ' : ''; html += `
${o.name} ${badge}${Math.round(r.ltr)}
`; } } else if (tab === 'career') { if (s.atWork) html += `
πŸš— Currently at work.
`; if (!s.job) { html += `
❌ Unemployed.
Use a computer β†’ Find a Job.
`; } else { const c = CAREERS.find(c => c.id === s.job.track); const rank = c.ranks[s.job.rank]; const perf = s.job.perf ?? 50; html += `
${c.icon} ${rank.title}
${c.trackName} Β· Level ${s.job.rank + 1}/10
πŸ’° ${fmtMoney(rank.salary)}/day Β· πŸ•˜ ${rank.hours[0]}:00–${rank.hours[1]}:00
Off: ${rank.offDays.map(d => DAY_NAMES[d]).join(', ')}
πŸ“ˆ Performance${Math.round(perf)}
Next level needs:
${ Object.entries(rank.req).map(([k, v]) => `${SKILLS.find(x => x.id === k)?.icon || ''} ${SKILLS.find(x => x.id === k)?.name}: ${v}`).join('
') || 'β€” just keep performance up!' }
`; } html += `
⭐ Aspiration points: ${Math.round(G.aspirationPoints)}
`; } else if (tab === 'bio') { const zod = ['Capricorn','Aquarius','Pisces','Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius'][s.id % 12]; html += `
πŸ§‘ Name: ${s.name}
${s.gender === 'm' ? 'πŸ‘¨ Male' : 'πŸ‘© Female'} Β· ${s.ageStage === 'elder' ? 'πŸ§“ Elder' : s.ageStage === 'child' ? 'πŸ§’ Child' : s.ageStage === 'baby' ? 'πŸ‘Ά Baby' : '🧍 Adult'}
β™’ Zodiac sign: ${zod}
✨ Aspiration: ${ASPIRATIONS[s.aspiration].icon} ${ASPIRATIONS[s.aspiration].name}
${ASPIRATIONS[s.aspiration].desc}
Personality
` + TRAITS.map(tr => { const labels = { neat:'Neat', outgoing:'Outgoing', active:'Active', playful:'Playful', nice:'Nice' }; return `
${labels[tr]}${ '●'.repeat(s.traits[tr]) + 'Β·'.repeat(10 - s.traits[tr])}
`; }).join(''); // life milestones diary const mem = (s.memories || []).slice(0, 8); html += `
πŸ“œ Memories
` + (mem.length ? `
` + mem.map(m => `
Day ${m.day} ${m.icon} ${m.text}
`).join('') + `
` : `
No memories yet β€” go live a little!
`); if (s.novelChapters > 0) html += `
✍️ Writing a novel β€” chapter ${s.novelChapters}/10
`; if ((s.paintings || []).length) html += `
πŸ–ΌοΈ ${s.paintings.length} painting(s) ready to sell
`; } body.innerHTML = html; } /* ---------------- career chance cards ---------------- */ Bus.on('chanceCard', () => { const p = G.pendingChance; if (!p) return; const sim = G.simById(p.simId); if (!sim || !sim.job) { G.pendingChance = null; setSpeed(1); return; } const card = p.card; let el = document.getElementById('chanceCard'); if (!el) { el = document.createElement('div'); el.id = 'chanceCard'; document.body.appendChild(el); } const cname = (CAREERS.find(c => c.id === sim.job.track) || {}).name || 'Work'; el.innerHTML = `
πŸ’Ό Career Opportunity β€” ${cname}
${card.q}
${card.a.map((a, i) => ``).join('')}
Time is paused while ${sim.name.split(' ')[0]} decides…
`; el.classList.remove('hidden'); el.querySelectorAll('button').forEach(b => b.onclick = () => { const a = card.a[+b.dataset.i]; if (a.fx && a.fx.dice != null) { const win = chance(a.fx.dice); applyChanceFx(sim, win ? a.fx.win : a.fx.lose); toast(win ? `🎯 Bold move! It paid off for ${sim.name}.` : `😬 That backfired on ${sim.name}…`, win ? 'good' : 'bad'); } else { applyChanceFx(sim, a.fx || {}); toast(`${a.icon || ''} ${sim.name.split(' ')[0]} chose: ${a.label}`, ''); } AudioSys.sfx('click'); el.classList.add('hidden'); G.pendingChance = null; setSpeed(G.prevSpeedBeforeCard || 1); }); }); /* tab clicks */ document.querySelectorAll('#simPanelTabs button').forEach(b => { b.onclick = () => { document.querySelectorAll('#simPanelTabs button').forEach(x => x.classList.remove('active')); b.classList.add('active'); document.getElementById('simPanel').dataset.tab = b.dataset.tab; updateSimPanel(); }; }); document.getElementById('simPanelClose').onclick = () => document.getElementById('simPanel').classList.add('hidden'); /* ---------------- BUY drawer ---------------- */ let buyThumbCache = new Map(); function thumbFor(defId) { if (buyThumbCache.has(defId)) return buyThumbCache.get(defId).cloneNode ? (() => { const c = document.createElement('canvas'); c.width = 84; c.height = 64; c.getContext('2d').drawImage(buyThumbCache.get(defId), 0, 0); return c; })() : null; const src = document.createElement('canvas'); src.width = 168; src.height = 128; const c = src.getContext('2d'); const def = OBJECTS[defId]; c.save(); c.translate(84, 96); c.scale(.95, .95); const fake = { id: 3, defId, x:0, y:0, rot:0, w:def.w, h:def.h, dirty:.2, usedBy:false }; const painter = PAINTERS[def.shape]; // emulate drawObject's local space if (painter) painter(c, fake, 1.0); else { c.fillStyle = '#caa'; c.fillRect(-14, -30, 28, 30); } c.restore(); buyThumbCache.set(defId, src); const out = document.createElement('canvas'); out.width = 84; out.height = 64; out.getContext('2d').drawImage(src, 0, 0, 168, 128, 0, 0, 84, 64); return out; } function openBuyDrawer() { const drawer = document.getElementById('buyDrawer'); drawer.classList.remove('hidden'); const tabs = document.getElementById('buyTabs'); tabs.innerHTML = ''; let curCat = drawer.dataset.cat || 'seating'; for (const cat of BUY_CATS) { const b = document.createElement('button'); b.textContent = cat.icon + ' ' + cat.label; b.className = cat.id === curCat ? 'active' : ''; b.onclick = () => { drawer.dataset.cat = cat.id; openBuyDrawer(); }; tabs.appendChild(b); } const grid = document.getElementById('buyGrid'); grid.innerHTML = ''; for (const id in OBJECTS) { const def = OBJECTS[id]; if (def.cat !== curCat) continue; const card = document.createElement('div'); card.className = 'buyItem' + (G.buySel === id ? ' sel' : ''); card.appendChild(thumbFor(id)); const bn = document.createElement('div'); bn.className = 'bn'; bn.textContent = def.name; const bp = document.createElement('div'); bp.className = 'bp'; bp.textContent = fmtMoney(def.price); card.appendChild(bn); card.appendChild(bp); card.onclick = () => { G.buySel = id; G.buyRot = 0; openBuyDrawer(); setHint(`Placing ${def.name} (${fmtMoney(def.price)}) β€” click a tile Β· R rotate Β· Esc cancel`); }; grid.appendChild(card); } } function closeBuyDrawer() { document.getElementById('buyDrawer').classList.add('hidden'); } function setHint(txt) { document.getElementById('buyHint').innerHTML = txt; } /* ---------------- BUILD bar ---------------- */ function openBuildBar() { document.getElementById('buildBar').classList.remove('hidden'); const sw = document.getElementById('floorSwatches'); if (!sw.children.length) { FLOORS.forEach((f, i) => { if (f.outdoor) return; const s = document.createElement('div'); s.className = 'swatch' + (i === G.floorSel ? ' sel' : ''); s.style.background = f.c1; s.title = f.id; s.onclick = () => { G.floorSel = i; openBuildBar(); }; sw.appendChild(s); }); } else { [...sw.children].forEach((el, i) => el.classList.toggle('sel', i === G.floorSel)); } // wall color swatches const ws = document.getElementById('wallSwatches'); if (!ws.children.length) { const lbl = document.createElement('span'); lbl.style.cssText = 'font-size:11px;color:#9fb4ea;margin:0 2px;'; lbl.textContent = '🧱'; ws.appendChild(lbl); WALL_COLORS.forEach((c, i) => { const s = document.createElement('div'); s.className = 'swatch' + (c === G.wallColor ? ' sel' : ''); s.style.background = c; s.onclick = () => { G.wallColor = c; openBuildBar(); }; ws.appendChild(s); }); } else { let ci = 0; [...ws.children].forEach(el => { if (!el.style.background) return; // label el.classList.toggle('sel', WALL_COLORS[ci] === G.wallColor); ci++; }); } document.querySelectorAll('#buildBar [data-tool]').forEach(b => b.classList.toggle('active', b.dataset.tool === G.buildTool)); document.getElementById('buildHint').innerHTML = `Wall Β§70/segment Β· Door Β§250 Β· Window Β§180 Β· Floor Β§12/tile Β· Removing refunds 50% β€” ${{wall:'Drag across edges to build',door:'Click a wall segment',window:'Click a wall segment',floor:'Drag to paint floor',delWall:'Click walls/doors/windows to remove'}[G.buildTool]||''}`; } function closeBuildBar() { document.getElementById('buildBar').classList.add('hidden'); } /* build tool buttons */ document.querySelectorAll('#buildBar [data-tool]').forEach(b => { b.onclick = () => { G.buildTool = b.dataset.tool; openBuildBar(); }; }); /* ---------------- PIE MENU ---------------- */ function showPie(px, py, entries, title = '') { const pie = document.getElementById('pieMenu'); pie.innerHTML = ''; if (title) { const t = document.createElement('div'); t.style.cssText = 'padding:4px 12px;font-weight:800;color:#ffd23e;font-size:13px;'; t.textContent = title; pie.appendChild(t); pie.appendChild(document.createElement('hr')); } for (const en of entries) { if (en === '-') { pie.appendChild(document.createElement('hr')); continue; } const d = document.createElement('div'); d.className = 'pi' + (en.disabled ? ' dis' : ''); d.innerHTML = `${en.icon || ''}${en.label}` + (en.price != null ? `${en.price < 0 ? '+' : ''}${fmtMoney(Math.abs(en.price)).slice(0)}` : ''); if (!en.disabled) d.onclick = () => { hidePie(); AudioSys.sfx('click'); en.fn(); }; pie.appendChild(d); } pie.classList.remove('hidden'); // keep on-screen const r = pie.getBoundingClientRect(); pie.style.left = clamp(px, 6, window.innerWidth - r.width - 8) + 'px'; pie.style.top = clamp(py, 6, window.innerHeight - r.height - 8) + 'px'; } function hidePie() { document.getElementById('pieMenu').classList.add('hidden'); } window.addEventListener('mousedown', (e) => { const pie = document.getElementById('pieMenu'); if (!pie.classList.contains('hidden') && !pie.contains(e.target)) hidePie(); }); /* ---------------- interactions pie for an object ---------------- */ function objectInteractions(obj) { const def = OBJECTS[obj.defId]; const entries = []; if (obj.broken) { entries.push({ label: 'Repair', icon: 'πŸ”§', disabled: (G.selectedSim?.skills.mechanical || 0) < 1, fn: () => { const s = G.selectedSim; if (!s || !s.atHome) { toast('Pick a sim at home first!', 'bad'); return; } const mech = s.skills.mechanical || 0; commandUse(s, obj, { id:'repair', label:'Repair', icon:'πŸ”§', special:'repair', pose:'stand', dur: Math.max(14, 50 - mech * 4) }); }, }); return entries; } for (const inter of def.interactions || []) { if (inter.requiresDirty && obj.dirty <= .2) continue; if (inter.requiresFull && obj.dirty < .5) continue; if (inter.special === 'findJob' && G.selectedSim?.job) continue; if (inter.special === 'tryBaby' && !canTryForBaby(G.selectedSim)) continue; if (inter.babyOnly && G.selectedSim?.ageStage !== 'baby') continue; if (inter.childOnly && G.selectedSim?.ageStage !== 'child') continue; entries.push({ label: inter.label, icon: inter.icon, disabled: !!(inter.cost && G.funds < inter.cost), fn: () => { const s = G.selectedSim; if (!s || !s.atHome) { toast('Pick a sim at home first!', 'bad'); return; } commandUse(s, obj, inter); }, }); } // sinks grow a Wash Dishes action when there are dirty dishes around if (obj.defId === 'sink' && dishTotal() > 0) { entries.push({ label: 'Wash Dishes', icon: '🧼', fn: () => { const s = G.selectedSim; if (!s || !s.atHome) { toast('Pick a sim at home first!', 'bad'); return; } commandUse(s, obj, { id:'wash', label:'Wash Dishes', icon:'🧼', special:'wash', pose:'stand', dur:40 }); }, }); } return entries; } let keyIsShift = false; /* ---------------- career picker (computer) ---------------- */ function jobMenuOpenFor(sim) { return G._jobMenuSim === sim && !document.getElementById('pieMenu').classList.contains('hidden'); } function openJobPicker(sim, px, py) { G._jobMenuSim = sim; const entries = []; for (const c of CAREERS) { const r0 = c.ranks[0]; entries.push({ label: `${c.trackName} β€” ${r0.title}`, icon: c.icon, fn: () => { G._jobMenuSim = null; CareerSys.hire(sim, c.id); if (sim.action?.special === 'findJob') sim.action.finish(); updateSimPanel(); }, }); } entries.push('-', { label: 'Never mind', icon: '↩️', fn: () => { G._jobMenuSim = null; if (sim.action?.special === 'findJob') sim.action.finish(); }}); showPie(px, py, entries, 'πŸ“‹ Choose a career track'); } /* ---------------- help / mute ---------------- */ document.getElementById('btnHelp').onclick = () => document.getElementById('helpOverlay').classList.remove('hidden'); document.getElementById('helpClose').onclick = () => document.getElementById('helpOverlay').classList.add('hidden'); document.getElementById('btnHood').onclick = function () { if (typeof enterHood === 'function') G.mode === 'hood' ? exitHood() : enterHood(); }; document.getElementById('btnMute').onclick = function () { AudioSys.muted = !AudioSys.muted; this.classList.toggle('active', !AudioSys.muted); this.textContent = AudioSys.muted ? 'πŸ”‡' : 'πŸ”Š'; }; /* ---------------- speed buttons ---------------- */ document.querySelectorAll('.speed-btn').forEach(b => { b.onclick = () => setSpeed(+b.dataset.speed); }); function setSpeed(v) { G.speed = v; document.querySelectorAll('.speed-btn').forEach(b => b.classList.toggle('active', +b.dataset.speed === v)); }