/* ============================================================ * main.js β€” global state G, game loop, input, camera, * Create-A-Sim, title flow, save/load, bills, aging * ============================================================ */ 'use strict'; const SPEED_MUL = [0, 1, 3, 8]; // game-minutes per real second multiplier const START_FUNDS = 20000; window.G = { mode: 'title', // title | cas | live | buy | build cam: { x: 0, y: 0, zoom: 1 }, world: null, sims: [], time: { absMin: 7 * 60, get day() { return Math.floor(this.absMin / 1440) + 1; }, get hourFloat() { return (this.absMin % 1440) / 60; }, get hour() { return Math.floor(this.hourFloat); }, get min() { return Math.floor(this.absMin % 60); }, }, funds: START_FUNDS, speed: 1, prevSpeed: 1, freeWill: true, aspirationPoints: 0, selectedSim: null, buySel: null, buyRot: 0, buildTool: 'wall', floorSel: 1, wallColor: '#efe6d4', mouseTile: null, hoverEdge: null, dtReal: 0, pendingPizza: 0, mailBillsDue: false, billsAmount: 0, billsPaid: true, nextBillDay: 4, _jobMenuSim: null, dirtPuddleTick: 0, dishPiles: [], weather: { type: 'sunny', flash: 0, boltIn: 0 }, fires: [], neighborhood: null, hoodSel: null, hoodRects: [], hoodBtns: [], hoodHover: null, neighborhoodFriendBase: 15, visitsToday: [], ghosts: [], graves: [], party: null, pendingGroceries: 0, pendingChance: null, simById(id) { return this.sims.find(s => s.id === id) || null; }, addSim(s) { this.sims.push(s); Bus.emit('simsChanged'); rebuildPortraits(); }, removeSim(s) { this.sims = this.sims.filter(x => x !== s); // release every object reservation the departing sim held if (G.world) for (const o of G.world.objects) if (o.usedBy === s) o.usedBy = null; if (s.isVisitor && s.hoodMeta) syncVisitorMemory(s); // neighbors remember! if (this.selectedSim === s) selectSim(this.sims.find(x => !x.isVisitor) || null); Bus.emit('simsChanged'); if (G.mode !== 'cas') rebuildPortraits(); }, }; /* ============================================================ * BOOT * ============================================================ */ initRender(document.getElementById('game')); centerCamera(); requestAnimationFrame(frame); let lastTs = performance.now(); let uiAccum = 0; function frame(ts) { const dt = Math.min(0.1, (ts - lastTs) / 1000); lastTs = ts; G.dtReal = dt; R.time += dt; handlePanKeys(dt); if (G.mode === 'cas') drawCasPreview(); else if (G.world && G.mode === 'hood') { drawHood(); tickFx(dt); } else if (G.world && G.mode !== 'title') { const mps = SPEED_MUL[G.speed]; // game minutes per real second let gmin = mps * dt; if (gmin > 0) advanceTime(gmin); draw(); tickFx(dt); } else if (G.world) draw(); uiAccum += dt; if (uiAccum > 0.15 && G.mode !== 'title' && G.mode !== 'cas') { uiAccum = 0; updateHud(); refreshPortraitsThrottled(); } requestAnimationFrame(frame); } /* ---------------- time & world upkeep ---------------- */ let roomTimer = 0; let lastDay = 1; let autosaveMark = -1; function advanceTime(gmin) { G.time.absMin += gmin; // sims for (const s of [...G.sims]) { s.tick(gmin); // career/school mood sampling while away if (s.atWork) { s.workMoodSum = (s.workMoodSum || 0) + s.moodScore() * gmin; s.workMoodN = (s.workMoodN || 0) + gmin; } if (s.atSchool) { s.schoolMoodSum = (s.schoolMoodSum || 0) + s.moodScore() * gmin; s.schoolMoodN = (s.schoolMoodN || 0) + gmin; } } CareerSys.tick(gmin); SchoolSys.tick(); processVisits(); PartySys.tick(); ghostTick(gmin); fireTick(gmin); worldUpkeep(gmin); // puddle drying for (const arr of [G.world.dirtPuddle]) { if (!arr) break; for (let i = arr.length - 1; i >= 0; i--) { arr[i].t -= gmin * 2; if (arr[i].t <= 0) arr.splice(i, 1); } } // environment recompute roomTimer += gmin; if (roomTimer > 20 || Bus._dirty) { roomTimer = 0; G.world.recomputeRoom(); } // day rollover const day = G.time.day; if (day !== lastDay) { lastDay = day; onNewDay(day); } // bills if (!G.mailBillsDue && day >= G.nextBillDay && G.time.hour >= 9) sendBills(); if (G.mailBillsDue && !G.billsPaid && G.time.absMin > G.billDeadline) repossess(); // daily autosave at 01:00 if (G.time.hour === 1 && autosaveMark !== day) { autosaveMark = day; saveGame(true); } // weather ambience: lightning strikes while raining if (G.weather.type === 'rain') { if (G.weather.boltIn <= 0) { G.weather.boltIn = rand(25, 90); // real seconds G.weather.flash = .8; AudioSys.sfx('thud'); } else { G.weather.boltIn -= G.dtReal; } } if (G.weather.flash > 0) G.weather.flash -= G.dtReal * 2.2; // queue processing for (const s of G.sims) { if (!s.action && s.queue.length && !s.path.length) { const nxt = s.queue.shift(); s.action = nxt; nxt.begin(); if (nxt.done) s.action = null; } // release stuck walkers if (s.anim === 'walk' && !s.path.length && !s.action) s.anim = 'idle'; } } function onNewDay(day) { // roll today's weather const r = Math.random(); G.weather.type = r < .5 ? 'sunny' : r < .8 ? 'cloudy' : 'rain'; if (G.weather.type !== 'sunny') toast(G.weather.type === 'rain' ? '🌧️ Rain moving in today…' : '☁️ A cloudy day.'); document.getElementById('weatherIcon').textContent = G.weather.type === 'rain' ? '🌧️' : G.weather.type === 'cloudy' ? '☁️' : 'β˜€οΈ'; scheduleVisitors(); // neighbors plan their strolls-by today for (const s of G.sims) if (!s.isVisitor) { s.daysAlive++; // relationships drift toward long-term baseline for (const [, r] of s.rels) r.str = lerp(r.str, r.ltr, 0.35); // birthdays if (s.daysAlive === 20 && s.ageStage === 'adult') { s.ageStage = 'elder'; toast(`πŸŽ‚ Happy Birthday, ${s.name}! They are now an elder.`, 'good'); s.say('πŸŽ‚'); } if (s.ageStage === 'elder' && s.daysAlive >= 34 && chance(.5)) { dieOfOldAge(s); continue; } } G.billsPaid = false; } function dieOfOldAge(s) { dieOf(s, 'oldage'); } function sendBills() { let value = 0; for (const o of G.world.objects) value += OBJECTS[o.defId].price || 0; value += G.world.walls.size * 70; G.billsAmount = Math.max(60, Math.round(value * 0.006)); G.mailBillsDue = true; G.billsPaid = false; G.billDeadline = G.time.absMin + 24 * 60; G.nextBillDay = G.time.day + 3; toastBill(G.billsAmount); } function repossess() { const sellable = G.world.objects.filter(o => (OBJECTS[o.defId].price || 0) >= 200); G.mailBillsDue = false; if (sellable.length) { const victim = choice(sellable); G.world.removeObject(victim); toast(`🚚 The bill collector repossessed the ${OBJECTS[victim.defId].name}!`, 'bad'); } else { toast(`😀 Collection agency fines you Β§200!`, 'bad'); G.funds -= 200; } } /* ============================================================ * MODE SWITCHING * ============================================================ */ function setMode(m) { if (G.mode === m) return; const leavingBuildish = G.mode === 'buy' || G.mode === 'build' || G.mode === 'hood'; G.mode = m; hidePie(); document.getElementById('modeLive').classList.toggle('active', m === 'live'); document.getElementById('modeBuy').classList.toggle('active', m === 'buy'); document.getElementById('modeBuild').classList.toggle('active', m === 'build'); if (m === 'buy') { openBuyDrawer(); closeBuildBar(); G.buySel = G.buySel; } else closeBuyDrawer(); if (m === 'build') { openBuildBar(); closeBuyDrawer(); } else closeBuildBar(); if ((m === 'hood') && !leavingBuildish) { G.prevSpeed = G.speed || 1; setSpeed(0); G.hoodSel = null; } if (m === 'live' && G.speed === 0 && G.prevSpeed) setSpeed(G.prevSpeed); if (m !== 'buy') { /* keep buySel for return */ } updateSimPanel(); R.canvas.style.cursor = m === 'live' ? 'default' : 'crosshair'; } document.getElementById('modeLive').onclick = () => setMode('live'); document.getElementById('modeBuy').onclick = () => setMode('buy'); document.getElementById('modeBuild').onclick = () => setMode('build'); /* ---------------- options ---------------- */ document.getElementById('btnFreeWill').onclick = function () { G.freeWill = !G.freeWill; this.classList.toggle('active', G.freeWill); if (G.freeWill) { const n = G.sims.filter(s => !s.isVisitor && s.ageStage !== 'baby').length; toast(`πŸ€– AI Mode ON β€” ${n} sim(s) now follow their own needs & goals.`, 'good'); AudioSys.sfx('chime'); } else { toast('🧍 AI Mode OFF β€” you control everyone directly.', ''); AudioSys.sfx('click'); } }; document.getElementById('btnSave').onclick = () => { saveGame(false); }; document.getElementById('btnQuit').onclick = () => { saveGame(true); location.reload(); }; /* ============================================================ * INPUT β€” camera pan/zoom, picking, build/buy tools * ============================================================ */ const keys = new Map(); let mouseDown = null; // {button, sx, sy, moved, lastTile} function canvasPos(e) { const r = R.canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; } R.canvas.addEventListener('mousemove', (e) => { const [px, py] = canvasPos(e); G.mousePx = [px, py]; const [wx, wy] = pxToWorld(px, py); G.mouseTile = [Math.floor(wx), Math.floor(wy)]; G.hoverEdge = pickEdge(px, py); if (G.mode === 'hood') hoodHover(px, py); if (mouseDown && mouseDown.button === 2) { G.cam.x += e.movementX; G.cam.y += e.movementY; if (Math.abs(e.movementX) + Math.abs(e.movementY) > 0) mouseDown.moved = true; return; } if (mouseDown && mouseDown.button === 0) { if (dist2(px, py, mouseDown.sx, mouseDown.sy) > 36) mouseDown.moved = true; if (G.mode === 'build') dragBuildTo(G.mouseTile); } }); R.canvas.addEventListener('mousedown', (e) => { if (G.mode === 'title' || G.mode === 'cas') return; const [px, py] = canvasPos(e); mouseDown = { button: e.button, sx: px, sy: py, moved: false, lastTile: [...G.mouseTile] }; if (e.button === 0) onClickLeft(px, py); }); window.addEventListener('mouseup', (e) => { if (mouseDown && mouseDown.button === 2 && !mouseDown.moved) onClickRight(e); mouseDown = null; }); R.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); R.canvas.addEventListener('wheel', (e) => { e.preventDefault(); const [px, py] = canvasPos(e); const oldZ = G.cam.zoom; const nz = clamp(oldZ * Math.exp(-e.deltaY * 0.0012), 0.45, 2.4); // zoom toward cursor G.cam.x = px - (px - G.cam.x) * (nz / oldZ); G.cam.y = py - (py - G.cam.y) * (nz / oldZ); G.cam.zoom = nz; }, { passive: false }); function handlePanKeys(dt) { const v = 650 * dt / G.cam.zoom; if (keys.has('arrowleft') || keys.has('a')) G.cam.x += v; if (keys.has('arrowright') || keys.has('d')) G.cam.x -= v; if (keys.has('arrowup') || keys.has('w')) G.cam.y += v; if (keys.has('arrowdown') || keys.has('s')) G.cam.y -= v; } window.addEventListener('keydown', (e) => { const k = e.key.toLowerCase(); keys.set(k, true); if (G.mode === 'title' || G.mode === 'cas') return; if (k === ' ') { e.preventDefault(); setSpeed(G.speed === 0 ? (G.prevSpeed || 1) : (G.prevSpeed = G.speed, 0)); } if (k === '1') setSpeed(1); if (k === '2') setSpeed(2); if (k === '3') setSpeed(3); if (k === 'f') document.getElementById('btnFreeWill').click(); if (k === 'p') setMode(G.mode === 'live' ? 'buy' : G.mode === 'buy' ? 'build' : 'live'); if (k === 'n') { G.mode === 'hood' ? exitHood() : enterHood(); } if (k === 'r' && G.mode === 'buy' && G.buySel) G.buyRot = (G.buyRot + 1) % 4; if (k === 'escape') { if (!document.getElementById('pieMenu').classList.contains('hidden')) hidePie(); else if (G.mode !== 'live') setMode('live'); else selectSim(null); } }); window.addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase())); /* ---------- picking helpers ---------- */ function simAtScreen(px, py) { let best = null, bd = 22 * 22 * G.cam.zoom * G.cam.zoom; for (const s of G.sims) { if (!s.atHome) continue; const [ax, ay] = isoToScreen(s.x, s.y); const sx = ax * G.cam.zoom + G.cam.x, sy = ay * G.cam.zoom + G.cam.y - 20 * G.cam.zoom; const d = dist2(sx, sy, px, py); if (d < bd) { bd = d; best = s; } } return best; } function pickEdge(px, py) { if (!G.mouseTile || !G.world.inside(...G.mouseTile)) return null; const [x, y] = G.mouseTile; const cands = [ { x, y, e: 'n' }, { x, y, e: 'w' }, { x, y: y + 1, e: 'n' }, { x: x + 1, y, e: 'w' }, ]; let best = null, bd = 18 * 18; const z = WALL_H * G.cam.zoom * .55; for (const c of cands) { if (c.y > G.world.h || c.x > G.world.w) continue; const A = c.e === 'n' ? tileCornerPx(c.x, c.y) : tileCornerPx(c.x, c.y); const B = c.e === 'n' ? tileCornerPx(c.x + 1, c.y) : tileCornerPx(c.x, c.y + 1); const mx = (A[0] + B[0]) / 2, my = (A[1] + B[1]) / 2 - z; const d = ptSegDist2(px, py, A[0], A[1] - z, B[0], B[1] - z); void mx; void my; if (d < bd) { bd = d; best = c; } } return best; } function ptSegDist2(p, q, ax, ay, bx, by) { const dx = bx - ax, dy = by - ay; const L2 = dx * dx + dy * dy; let t = L2 ? ((p - ax) * dx + (q - ay) * dy) / L2 : 0; t = clamp(t, 0, 1); return dist2(p, q, ax + t * dx, ay + t * dy); } /* ---------- clicks ---------- */ function onClickLeft(px, py) { if (G.mode === 'hood') { hoodClick(px, py); return; } if (G.mode === 'buy') { if (G.buySel) tryPlaceBuy(); else pickAndSelectSimOrNothing(); return; } if (G.mode === 'build') { buildClick(); return; } // LIVE MODE const sim = simAtScreen(px, py); if (sim) { const a = G.selectedSim; // babies get a care menu instead of socials if (sim.ageStage === 'baby') { const actor = (a && !a.isVisitor && a.ageStage !== 'baby') ? a : firstFamilySim(); const pseudo = { defId:'baby', x: sim.x, y: sim.y, w:1, h:1, usedBy:null, simRef: sim }; const entries = []; if (actor && actor.ageStage === 'adult') { entries.push({ label:'Feed Baby', icon:'🍼', fn: () => commandUse(actor, { ...pseudo }, { id:'feedBaby', label:'Feed Baby', icon:'🍼', special:'feedBaby', pose:'stand', dur:20 }) }); entries.push({ label:'Cuddle Baby', icon:'🀱', fn: () => commandUse(actor, { ...pseudo }, { id:'cuddleBaby', label:'Cuddle Baby', icon:'🀱', special:'cuddleBaby', pose:'stand', dur:16 }) }); } else { entries.push({ label:'(Need an adult to care for the baby)', icon:'🚼', disabled:true, fn:()=>{} }); } entries.push('-'); entries.push({ label:'Switch to ' + sim.name.split(' ')[0], icon:'πŸ‘†', fn: () => selectSim(sim) }); showPie(px, py, entries, 'πŸ‘Ά ' + sim.name); return; } const a2 = a; if (!a || a === sim || a.isVisitor) { selectSim(sim); } else if (!a.atHome) { selectSim(sim); } else { // social pie toward clicked sim const rel = a.getRel(sim); const entries = []; for (const s of SOCIALS) { if (s.minRel != null && rel.ltr < s.minRel) continue; if (s.minRelMax != null && rel.str > s.minRelMax) continue; entries.push({ label: s.label, icon: s.icon, fn: () => AI.startSocial(a, sim, s) }); } entries.push('-'); // love & household growth const neitherMarried = !a2.marriedTo && !sim.marriedTo; if (neitherMarried && rel.ltr >= 85) { entries.push({ label: 'Propose Marriage', icon: 'πŸ’', fn: () => proposeMarriage(a2, sim) }); } if (sim.isVisitor && rel.ltr >= 65) { entries.push({ label: 'Ask to Move In', icon: '🏑', fn: () => askToMoveIn(a2, sim) }); } if (entries.length > 1) entries.push('-'); entries.push({ label: 'Switch to ' + sim.name.split(' ')[0], icon: 'πŸ‘†', fn: () => selectSim(sim) }); showPie(px, py, entries, 'πŸ’¬ ' + sim.name + (rel.ltr >= 50 ? ' 🀝' : rel.ltr <= -30 ? ' βš”οΈ' : '')); } return; } const t = G.mouseTile; const obj = t && G.world.objAt(t[0], t[1]); if (obj) { selectSim(G.selectedSim || firstFamilySim()); showPie(px, py, objectInteractions(obj), `${OBJECTS[obj.defId].emoji} ${OBJECTS[obj.defId].name}`); return; } // walk command const s = G.selectedSim || firstFamilySim(); if (s && t && G.world.inside(t[0], t[1])) commandGoHere(s, t[0], t[1]); } function onClickRight(e) { const [px, py] = canvasPos(e); if (G.mode === 'buy') { const t = G.mouseTile; const obj = t && G.world.objAt(t[0], t[1]); if (obj) { const def = OBJECTS[obj.defId]; if (obj.usedBy) { toast("Can't sell an object in use!", 'bad'); return; } const refund = Math.round(def.price * 0.7); G.funds += refund; G.world.removeObject(obj); toast(`πŸ’° Sold ${def.name} back for ${fmtMoney(refund)}.`); Bus.emit('fundsChanged'); return; } if (G.buySel) { G.buySel = null; openBuyDrawer(); } return; } if (G.mode === 'live') { const t = G.mouseTile; const obj = t && G.world.objAt(t[0], t[1]); if (obj) showPie(px, py, objectInteractions(obj), `${OBJECTS[obj.defId].emoji} ${OBJECTS[obj.defId].name}`); } } function firstFamilySim() { return G.sims.find(s => !s.isVisitor) || null; } function pickAndSelectSimOrNothing() { /* click-through in buy mode when nothing selected */ } /* ---------- buy placement ---------- */ function tryPlaceBuy() { const def = OBJECTS[G.buySel]; if (!def || !G.mouseTile) return; const rot = G.buyRot; const w = rot % 2 ? def.h : def.w, h = rot % 2 ? def.w : def.h; if (G.funds < def.price) { toast('❌ Not enough simoleons!', 'bad'); return; } if (!G.world.canPlace(def, G.mouseTile[0], G.mouseTile[1], rot % 2)) { toast("🚫 Can't place it there.", 'bad'); return; } G.funds -= def.price; G.world.placeObject(G.buySel, G.mouseTile[0], G.mouseTile[1], rot % 2); Bus.emit('fundsChanged'); } /* ---------- build tools ---------- */ function buildClick() { const tool = G.buildTool; const ed = G.hoverEdge; if (tool === 'wall' ) { /* handled by drag */ mouseDown.lastTile = [...(G.mouseTile||[])]; return; } if (tool === 'floor') { paintFloorTile(G.mouseTile); return; } if (!ed) return; if (tool === 'door' || tool === 'window') { const w = G.world.wallAt(ed.x, ed.y, ed.e); if (!w || w.kind !== 'wall') { toast('Doors & windows go into existing walls.', 'bad'); return; } const cost = tool === 'door' ? 250 : 180; if (G.funds < cost) { toast('❌ Not enough simoleons!', 'bad'); return; } G.funds -= cost; w.kind = tool; Bus.emit('worldChanged'); return; } if (tool === 'delWall') { const w = G.world.wallAt(ed.x, ed.y, ed.e); if (w) { G.world.removeWall(ed.x, ed.y, ed.e); G.funds += 35; Bus.emit('fundsChanged'); } return; } } function dragBuildTo(tile) { if (!tile || !mouseDown?.lastTile) return; const [lx, ly] = mouseDown.lastTile; let [cx, cy] = tile; const tool = G.buildTool; // step line toward cursor one tile at a time let guard = 40; while ((lx !== cx || ly !== cy) && guard-- > 0) { let nx = lx, ny = ly; if (Math.abs(cx - lx) >= Math.abs(cy - ly)) nx += Math.sign(cx - lx); else ny += Math.sign(cy - ly); const ed = G.world.sharedEdge(lx, ly, nx, ny); if (ed) { if (tool === 'wall') { if (G.funds >= 70) { if (G.world.placeWall(ed.x, ed.y, ed.e, 'wall')) { G.funds -= 70; Bus.emit('fundsChanged'); } } else { toastOnce('❌ Out of money for walls!', 'bad'); break; } } else if (tool === 'delWall') { if (G.world.removeWall(ed.x, ed.y, ed.e)) { G.funds += 35; Bus.emit('fundsChanged'); } } else if (tool === 'floor') { paintFloorTile([nx, ny]); } } mouseDown.lastTile = [nx, ny]; mouseDown.lastTile[0] = nx; mouseDown.lastTile[1] = ny; if (tool === 'floor') break; // floor paints per-tile via paintFloorTile below too } if (tool === 'floor') paintFloorTile(tile); } let lastToastKey = '', lastToastT = 0; function toastOnce(msg, cls) { if (performance.now() - lastToastT < 2500 && msg === lastToastKey) return; lastToastKey = msg; lastToastT = performance.now(); toast(msg, cls); } function paintFloorTile(tile) { if (!tile || !G.world.inside(tile[0], tile[1])) return; const idx = tile[1] * G.world.w + tile[0]; if (G.world.floor[idx] === G.floorSel) return; if (G.funds < 12) { toastOnce('❌ Out of money for flooring!', 'bad'); return; } G.funds -= 12; G.world.setFloor(tile[0], tile[1], G.floorSel); Bus.emit('fundsChanged'); } Bus.on('worldChanged', () => { Bus._dirty = true; }); Bus.on('objectsChanged', () => { Bus._dirty = true; }); /* ============================================================ * CAMERA init * ============================================================ */ function centerCamera() { const [sx, sy] = isoToScreen(LOT_W / 2, LOT_H / 2); G.cam.x = window.innerWidth / 2 - sx; G.cam.y = window.innerHeight / 2 - sy; G.cam.zoom = clamp(window.innerWidth / 1500, .8, 1.3); } /* ============================================================ * CREATE-A-SIM * ============================================================ */ const CAS = { fam: [], cur: 0, animT: 0, }; function openCas(fresh = true) { if (fresh) { CAS.fam = []; const a = randomSimData('f'); a.name = 'Bella Goth'; a.nameCustom = true; a.gender = 'f'; a.skin = 0; a.hairStyle = 1; a.hairColor = 0; a.shirt = 4; a.aspiration='fortune'; const b = randomSimData('m'); b.name = 'Mortimer Goth'; b.nameCustom = true; b.gender = 'm'; b.skin = 0; b.hairStyle = 0; b.hairColor = 6; b.shirt = 8; b.aspiration='knowledge'; CAS.fam.push(a, b); CAS.cur = 0; } G.mode = 'cas'; document.getElementById('titleScreen').classList.add('hidden'); document.getElementById('casScreen').classList.remove('hidden'); hideHud(); buildCasControls(); rebuildCasFamilyRow(); } function hideHud() { document.getElementById('topbar').classList.add('hidden'); document.getElementById('bottombar').classList.add('hidden'); document.getElementById('simPanel').classList.add('hidden'); } function showHud() { document.getElementById('topbar').classList.remove('hidden'); document.getElementById('bottombar').classList.remove('hidden'); } function curCas() { return CAS.fam[CAS.cur]; } function buildCasControls() { const t = curCas(); const R_ = document.getElementById('casRight'); const keepScroll = R_.scrollTop; // rebuilding shouldn't yank the panel around R_.innerHTML = ''; const row = (label, inner) => { const d = document.createElement('div'); d.className = 'cas-row'; d.innerHTML = `${label}`; d.appendChild(inner); R_.appendChild(d); return d; }; // name const nameWrap = document.createElement('div'); nameWrap.innerHTML = ``; row('Name', nameWrap); R_.querySelector('#casName').oninput = (e) => { t.name = e.target.value; t.nameCustom = true; rebuildCasFamilyRow(); }; // gender β€” keeps every appearance choice; only suggests a fitting first name // when the player hasn't typed their own name yet. const gen = chipGroup([['m', 'πŸ‘¨ Male'], ['f', 'πŸ‘© Female']], t.gender, v => { if (t.gender === v) return; t.gender = v; if (!t.nameCustom) { const surname = t.name.split(' ').slice(1).join(' ') || choice(LAST_NAMES); const pool = v === 'f' ? FIRST_NAMES_F : FIRST_NAMES_M; t.name = choice(pool) + (surname ? ' ' + surname : ''); } buildCasControls(); rebuildCasFamilyRow(); }); row('Gender', gen); // skin row('Skin tone', swatchGroup(SKINS, t.skin, v => { t.skin = v; buildCasControls(); })); // hair style row('Hair style', chipGroup([['0', 'Short'], ['1', 'Long'], ['2', 'Ponytail'], ['3', 'Spiky']], String(t.hairStyle), v => { t.hairStyle = +v; buildCasControls(); })); row('Hair color', swatchGroup(HAIRS, t.hairColor, v => { t.hairColor = v; buildCasControls(); })); row('Shirt', swatchGroup(SHIRTS, t.shirt, v => { t.shirt = v; buildCasControls(); })); row('Pants', swatchGroup(PANTS, t.pants, v => { t.pants = v; buildCasControls(); })); // traits sliders const labels = { neat:'Neat ✨', outgoing:'Outgoing πŸŽ‰', active:'Active πŸƒ', playful:'Playful πŸ€ͺ', nice:'Nice 😊' }; for (const tr of TRAITS) { const wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;flex:1;align-items:center;gap:8px;'; wrap.innerHTML = `` + `${t.traits[tr]}`; wrap.querySelector('input').oninput = (e) => { t.traits[tr] = +e.target.value; wrap.querySelector('.pval').textContent = e.target.value; }; row(labels[tr], wrap); } // aspiration row('Aspiration', chipGroup(Object.entries(ASPIRATIONS).map(([k, v]) => [k, v.icon + ' ' + v.name]), t.aspiration, v => { t.aspiration = v; buildCasControls(); })); R_.scrollTop = keepScroll; } function chipGroup(options, sel, cb) { const d = document.createElement('div'); d.style.cssText = 'display:flex;gap:5px;flex-wrap:wrap;'; for (const [v, label] of options) { const c = document.createElement('button'); c.className = 'chip' + (String(v) === String(sel) ? ' sel' : ''); c.textContent = label; c.onclick = () => cb(v); d.appendChild(c); } return d; } function swatchGroup(colors, sel, cb) { const d = document.createElement('div'); d.style.cssText = 'display:flex;gap:5px;flex-wrap:wrap;'; colors.forEach((c, i) => { const s = document.createElement('div'); s.className = 'swatchBig' + (i === sel ? ' sel' : ''); s.style.background = c; s.onclick = () => cb(i); d.appendChild(s); }); return d; } function rebuildCasFamilyRow() { const row = document.getElementById('casFamilyRow'); row.innerHTML = ''; CAS.fam.forEach((t, i) => { const d = document.createElement('div'); d.className = 'famSlot' + (i === CAS.cur ? ' sel' : ''); const cv = document.createElement('canvas'); cv.width = 58; cv.height = 48; d.appendChild(cv); const nm = document.createElement('div'); nm.textContent = (t.name || 'Sim').split(' ')[0]; d.appendChild(nm); if (CAS.fam.length > 1) { const del = document.createElement('button'); del.className = 'del'; del.textContent = 'βœ•'; del.onclick = (e) => { e.stopPropagation(); CAS.fam.splice(i, 1); CAS.cur = 0; buildCasControls(); rebuildCasFamilyRow(); }; d.appendChild(del); } d.onclick = () => { CAS.cur = i; buildCasControls(); rebuildCasFamilyRow(); }; drawMiniPortrait(cv, t); row.appendChild(d); }); const add = document.createElement('div'); add.className = 'famSlot'; add.innerHTML = 'βž•Add'; add.onclick = () => { if (CAS.fam.length >= 8) { toast('Maximum household size is 8!', 'bad'); return; } CAS.fam.push(randomSimData()); CAS.cur = CAS.fam.length - 1; buildCasControls(); rebuildCasFamilyRow(); }; row.appendChild(add); } function drawMiniPortrait(cv, t) { const c = cv.getContext('2d'); c.clearRect(0, 0, cv.width, cv.height); const fake = Object.assign(new Sim({}), t, { selected:false }); drawSimSprite(c, cv.width / 2, cv.height - 4, fake, { zoom: 0.62, facing: 0, anim: 'idle', animT: 0, heightOffset: 74 }); } let casAnimT = 0; function drawCasPreview() { casAnimT += G.dtReal; const cv = document.getElementById('casPreview'); const c = cv.getContext('2d'); c.clearRect(0, 0, cv.width, cv.height); const t = curCas(); if (!t) return; const fake = Object.assign(new Sim({}), t, { selected:false }); const walking = Math.sin(casAnimT * .8) > 0; drawSimSprite(c, cv.width / 2, cv.height - 30, fake, { zoom: 2.6, facing: Math.sin(casAnimT * .4) > .6 ? 3 : 0, anim: walking ? 'walk' : 'idle', animT: casAnimT, heightOffset: 78, }); } document.getElementById('casRandomize').onclick = () => { CAS.fam[CAS.cur] = randomSimData(curCas()?.gender); buildCasControls(); rebuildCasFamilyRow(); }; document.getElementById('casAdd').onclick = () => { if (CAS.fam.length >= 8) { toast('Maximum household size is 8!', 'bad'); return; } CAS.fam.push(randomSimData()); CAS.cur = CAS.fam.length - 1; buildCasControls(); rebuildCasFamilyRow(); }; document.getElementById('casBack').onclick = () => { document.getElementById('casScreen').classList.add('hidden'); document.getElementById('titleScreen').classList.remove('hidden'); G.mode = 'title'; }; document.getElementById('casMoveIn').onclick = () => { startNewGame(CAS.fam.map(t => ({ ...t, traits: { ...t.traits } }))); }; /* ============================================================ * GAME START / SAVE / LOAD * ============================================================ */ function startNewGame(templates) { G.world = new World(); buildStarterHouse(G.world); G.sims = []; G.funds = START_FUNDS; G.time.absMin = 7 * 60; // Monday 7:00 AM G.aspirationPoints = 0; G.freeWill = true; G.nextBillDay = 4; G.mailBillsDue = false; G.billsPaid = true; G.pendingPizza = 0; G.dishPiles = []; G.fires = []; G.ghosts = []; G.graves = []; G.party = null; G.pendingGroceries = 0; G.pendingChance = null; G.neighborhood = genNeighborhood(); scheduleVisitors(); G.weather = { type: 'sunny', flash: 0, boltIn: 0 }; document.getElementById('weatherIcon').textContent = 'β˜€οΈ'; lastDay = 1; autosaveMark = -1; roomTimer = 999; const doorX = 11 + 5; templates.forEach((t, i) => { const s = simFromTemplate(t); const spot = G.world.findFreeSpotNear(doorX, 19 + (i % 3), 6) || [doorX + i, 20]; s.x = spot[0]; s.y = spot[1]; G.sims.push(s); }); enterLiveMode(true); } function enterLiveMode(isNew) { G.mode = 'live'; document.getElementById('titleScreen').classList.add('hidden'); document.getElementById('casScreen').classList.add('hidden'); showHud(); document.getElementById('btnContinue').classList.remove('hidden'); centerCameraOnHouse(); rebuildPortraits(); selectSim(firstFamilySim()); G.world.recomputeRoom(); for (const s of G.sims) if (!s.isVisitor) WantSys.roll(s); if (isNew) { setTimeout(() => toast(`🏑 Welcome home! Click the ground to walk, objects to interact. Press ❓ anytime for help.`), 400); setTimeout(() => toast(`πŸ’‘ Tip: Buy a computer β†’ "Find a Job" to start earning.`), 6000); } } function centerCameraOnHouse() { const [sx, sy] = isoToScreen(16, 14); G.cam.x = window.innerWidth / 2 - sx * G.cam.zoom; G.cam.y = window.innerHeight / 2 - sy * G.cam.zoom; } const SAVE_KEY = 'tso2d_save_v2'; function saveGame(auto) { if (!G.world) return; const famIds = new Set(G.sims.filter(s => !s.isVisitor).map(s => s.id)); const data = { v: 2, funds: G.funds, absMin: G.time.absMin, freeWill: G.freeWill, aspirationPoints: G.aspirationPoints, nextBillDay: G.nextBillDay, billsPaid: G.billsPaid, world: G.world.serialize(), sims: G.sims.filter(s => famIds.has(s.id)).map(s => s.serialize()), neighborhood: G.neighborhood, graves: G.graves || [], }; try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); toast(auto ? 'πŸ’Ύ Autosaved.' : 'πŸ’Ύ Game saved!'); } catch (e) { toast('⚠️ Save failed: ' + e.message, 'bad'); } } function loadGame() { const raw = localStorage.getItem(SAVE_KEY); if (!raw) return false; try { const d = JSON.parse(raw); G.world = World.deserialize(d.world); G.sims = []; for (const sd of d.sims) { const s = new Sim(sd); s.atHome = true; s.action = null; s.queue = []; s.path = []; G.sims.push(s); } G.funds = d.funds ?? START_FUNDS; G.time.absMin = d.absMin ?? 420; G.freeWill = d.freeWill !== false; G.aspirationPoints = d.aspirationPoints || 0; G.nextBillDay = d.nextBillDay || 4; G.billsPaid = d.billsPaid !== false; G.mailBillsDue = false; G.pendingPizza = 0; G.dishPiles = []; G.fires = []; G.ghosts = []; G.party = null; G.pendingGroceries = 0; G.pendingChance = null; G.graves = d.graves || []; G.neighborhood = d.neighborhood || genNeighborhood(); scheduleVisitors(); G.weather = { type: 'sunny', flash: 0, boltIn: 0 }; document.getElementById('weatherIcon').textContent = 'β˜€οΈ'; document.getElementById('btnFreeWill').classList.toggle('active', G.freeWill); lastDay = G.time.day; roomTimer = 999; enterLiveMode(false); for (const s of G.sims) if (!s.isVisitor && (!s.wants || !s.wants.length)) WantSys.roll(s); toast('πŸ“‚ Welcome back to the neighborhood!'); return true; } catch (e) { console.error('load failed', e); toast('⚠️ Could not load that save.', 'bad'); return false; } } /* title buttons */ document.getElementById('btnNewGame').onclick = () => openCas(true); document.getElementById('btnContinue').onclick = () => { if (!loadGame()) toast('No save found β€” start a New Family!', 'bad'); }; if (localStorage.getItem(SAVE_KEY)) document.getElementById('btnContinue').classList.remove('hidden'); /* portrait refresh throttle helpers */ let portAcc = 0, panelAcc = 0; function refreshPortraitsThrottled() { refreshPortraits(); panelAcc += 1; if (panelAcc % 3 === 0) updateSimPanel(); }