Files
the-sims-online-2d/js/main.js
T

887 lines
33 KiB
JavaScript
Raw 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.
/* ============================================================
* 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();
}