The Sims Online 2D — full game: CAS, build mode, needs, AI Mode (whim-driven autonomy), careers+chance cards, neighborhood AI sims, death/ghosts, meal tiers+sickness, paintings/novels, house parties, memories, weather; 38 headless tests green

This commit is contained in:
2026-08-23 07:02:13 +00:00
commit 90bcecbf4b
14 changed files with 6626 additions and 0 deletions
+886
View File
@@ -0,0 +1,886 @@
/* ============================================================
* 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 = `<span class="clabel">${label}</span>`;
d.appendChild(inner);
R_.appendChild(d);
return d;
};
// name
const nameWrap = document.createElement('div');
nameWrap.innerHTML = `<input type="text" id="casName" maxlength="26" value="${t.name}">`;
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 = `<input type="range" min="0" max="10" value="${t.traits[tr]}" style="flex:1">` +
`<span class="pval">${t.traits[tr]}</span>`;
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 = '<span style="font-size:22px"></span><span>Add</span>';
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();
}