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