Files

474 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* 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: <b>${fmtMoney(amount)}</b> <span class="envelope" id="payBillsBtn">PAY</span>`;
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 += `<div class="needRow"><div class="nlabel"><span>${meta.icon} ${meta.label}</span><span>${Math.round(v)}</span></div>
<div class="nbar"><div style="width:${v}%;background:${col}"></div></div></div>`;
}
} else if (tab === 'wants') {
const asp = ASPIRATIONS[s.aspiration];
const lvl = Math.floor(G.aspirationPoints / 500);
const prog = G.aspirationPoints % 500;
html += `<div class="relCard"><div class="rname"><span>${asp.icon} ${asp.name}</span><span>Lvl ${lvl}</span></div>
<div class="nbar"><div style="width:${prog / 5}%;background:#c95ad9"></div></div>
<div style="font-size:11px;color:#9fb4ea;margin-top:4px">${prog}/500 to next level</div></div>`;
if (!s.wants || !s.wants.length) html += '<i>No whims right now…</i>';
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 += `<div class="relCard"><div class="rname"><span>${t.icon} ${t.label}</span><span>+${t.reward}</span></div>
${t.count || t.amount ? `<div class="relBar"><div style="width:${pct}%;background:#43c15a"></div></div>` : ''}
</div>`;
}
html += `<div style="font-size:11px;color:#9fb4ea">Whims are guided by the ${asp.name} aspiration. Fulfil them for aspiration points!</div>`;
} 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 += `<div class="skillRow"><span>${sk.icon} ${sk.name}</span><span class="pips" style="color:#ffd23e">${pips}</span><b>${lvl}</b></div>`;
}
} else if (tab === 'rels') {
const others = G.sims.filter(o => o !== s);
if (!others.length) html += '<i>No other sims around yet.<br>Try the phone → Invite Neighbor!</i>';
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 += `<div class="relCard"><div class="rname"><span>${o.name} ${badge}</span><span>${Math.round(r.ltr)}</span></div>
<div class="relBar"><div style="width:${(r.ltr + 100) / 2}%;background:${ltrCol}"></div></div>
<div class="relBar"><div style="width:${(r.str + 100) / 2}%;background:${strCol}"></div></div>
</div>`;
}
} else if (tab === 'career') {
if (s.atWork) html += `<div class="careerLine">🚗 Currently <b>at work</b>.</div>`;
if (!s.job) {
html += `<div class="careerLine">❌ Unemployed.<br>Use a <b>computer</b> → Find a Job.</div>`;
} 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 += `<div class="careerLine">${c.icon} <b>${rank.title}</b><br>
${c.trackName} · Level ${s.job.rank + 1}/10<br>
💰 ${fmtMoney(rank.salary)}/day · 🕘 ${rank.hours[0]}:00${rank.hours[1]}:00<br>
Off: ${rank.offDays.map(d => DAY_NAMES[d]).join(', ')}</div>
<div class="needRow"><div class="nlabel"><span>📈 Performance</span><span>${Math.round(perf)}</span></div>
<div class="nbar"><div style="width:${perf}%;background:${perf > 66 ? '#43c15a' : perf > 33 ? '#e8a33d' : '#e05252'}"></div></div></div>
<div class="careerLine"><b>Next level needs:</b><br>${
Object.entries(rank.req).map(([k, v]) => `${SKILLS.find(x => x.id === k)?.icon || ''} ${SKILLS.find(x => x.id === k)?.name}: ${v}`).join('<br>') || '— just keep performance up!'
}</div>`;
}
html += `<hr><div class="bioLine">⭐ Aspiration points: ${Math.round(G.aspirationPoints)}</div>`;
} else if (tab === 'bio') {
const zod = ['Capricorn','Aquarius','Pisces','Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius'][s.id % 12];
html += `<div class="bioLine">🧑 Name: <b>${s.name}</b></div>
<div class="bioLine">${s.gender === 'm' ? '👨 Male' : '👩 Female'} · ${s.ageStage === 'elder' ? '🧓 Elder' : s.ageStage === 'child' ? '🧒 Child' : s.ageStage === 'baby' ? '👶 Baby' : '🧍 Adult'}</div>
<div class="bioLine">♒ Zodiac sign: ${zod}</div>
<div class="bioLine">✨ Aspiration: <b>${ASPIRATIONS[s.aspiration].icon} ${ASPIRATIONS[s.aspiration].name}</b><br>
<small style="color:#9fb4ea">${ASPIRATIONS[s.aspiration].desc}</small></div>
<div class="bioLine" style="margin-top:8px"><b>Personality</b></div>` +
TRAITS.map(tr => {
const labels = { neat:'Neat', outgoing:'Outgoing', active:'Active', playful:'Playful', nice:'Nice' };
return `<div class="skillRow"><span>${labels[tr]}</span><span class="pips" style="color:#ffd23e">${
'●'.repeat(s.traits[tr]) + '·'.repeat(10 - s.traits[tr])}</span></div>`;
}).join('');
// life milestones diary
const mem = (s.memories || []).slice(0, 8);
html += `<div class="bioLine" style="margin-top:8px"><b>📜 Memories</b></div>` +
(mem.length
? `<div style="max-height:130px;overflow:auto">` + mem.map(m =>
`<div class="bioLine">Day ${m.day} ${m.icon} ${m.text}</div>`).join('') + `</div>`
: `<div class="bioLine" style="color:#9aa">No memories yet — go live a little!</div>`);
if (s.novelChapters > 0) html += `<div class="bioLine">✍️ Writing a novel — chapter ${s.novelChapters}/10</div>`;
if ((s.paintings || []).length) html += `<div class="bioLine">🖼️ ${s.paintings.length} painting(s) ready to sell</div>`;
}
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 = `<div class="cc-box">
<div class="cc-head">💼 Career Opportunity — ${cname}</div>
<div class="cc-q">${card.q}</div>
<div class="cc-btns">${card.a.map((a, i) =>
`<button data-i="${i}">${a.icon || ''} ${a.label}</button>`).join('')}</div>
<div class="cc-sub">Time is paused while ${sim.name.split(' ')[0]} decides…</div>
</div>`;
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% — <b>${{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]||''}</b>`;
}
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 = `<span>${en.icon || ''}</span><span>${en.label}</span>` +
(en.price != null ? `<span class="price">${en.price < 0 ? '+' : ''}${fmtMoney(Math.abs(en.price)).slice(0)}</span>` : '');
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));
}